轨迹完整性校验补丁 · Phantom (techccy/phantom) Mozilla Public License 2.0 —— 见 NOTICE.md 新增两道一票否决:终点命中、时长下限。 应用:git apply trajectory-completeness.patch --- a/backend/app/config.py +++ b/backend/app/config.py @@ -158,6 +158,14 @@ # 低于此下限直接判为自动化脚本。 MIN_HUMAN_DURATION_MS: float = _env_float("PHANTOM_MIN_HUMAN_DURATION_MS", 250.0) +# ---- 轨迹完整性校验 ---- +# a) 终点命中 +ENDPOINT_VETO_ENABLED: bool = _env_bool("PHANTOM_ENDPOINT_VETO", True) +ENDPOINT_MAX_RATIO: float = _env_float("PHANTOM_ENDPOINT_MAX_RATIO", 2.0) +# b) 时长下限 +DURATION_VETO_ENABLED: bool = _env_bool("PHANTOM_DURATION_VETO", True) +DURATION_MIN_RATIO: float = _env_float("PHANTOM_DURATION_MIN_RATIO", 0.80) + # ---- _energy_score 边界(残差能量 px;手册 §三.3)---- # ENERGY_MAX≈粗糙白噪→0 # 注:移动端浏览器 pointermove 被合并降采样,真人触屏残差天然偏小(实测 ~0.15px), --- a/backend/app/scoring/engine.py +++ b/backend/app/scoring/engine.py @@ -57,6 +57,11 @@ # - 最小人类响应时长下限否决:离线高速合成 min_duration_veto: bool = False duration_ms: float = 0.0 + # 轨迹完整性否决 + endpoint_veto: bool = False + endpoint_dist_px: float = 0.0 + duration_short_veto: bool = False + expected_duration_ms: float = 0.0 extras: dict = field(default_factory=dict) @@ -126,8 +131,13 @@ bezier_control_points: list[tuple[float, float]], canvas_w: float, canvas_h: float, + target_half: float | None = None, + expected_duration_ms: float | None = None, ) -> ScoreBreakdown: - """完整评分入口。返回带明细的 ScoreBreakdown。""" + """完整评分入口。返回带明细的 ScoreBreakdown。 + + 完整性否决参数为 None 时跳过。 + """ xs = np.asarray(xs, dtype=np.float64) ys = np.asarray(ys, dtype=np.float64) ts_ms = np.asarray(ts_ms, dtype=np.float64) @@ -144,6 +154,8 @@ axial_tremor_amp_x=0.0, axial_tremor_amp_y=0.0, subpixel_veto=False, subpixel_entropy_x=0.0, subpixel_entropy_y=0.0, min_duration_veto=False, duration_ms=0.0, + endpoint_veto=False, endpoint_dist_px=0.0, + duration_short_veto=False, expected_duration_ms=0.0, ) if xs.size < config.MIN_POINTS: @@ -224,6 +236,19 @@ duration_ms = float(ts_ms[-1] - ts_ms[0]) if ts_ms.size >= 2 else 0.0 min_duration_veto = duration_ms < config.MIN_HUMAN_DURATION_MS + # 6) 轨迹完整性否决 + # a) 终点命中 + endpoint_dist = 0.0 + endpoint_veto = False + if config.ENDPOINT_VETO_ENABLED and target_half: + end_x, end_y = bezier_pts[-1] + endpoint_dist = float(math.hypot(xs[-1] - end_x, ys[-1] - end_y)) + endpoint_veto = endpoint_dist > target_half * config.ENDPOINT_MAX_RATIO + # b) 时长下限 + duration_short_veto = False + if config.DURATION_VETO_ENABLED and expected_duration_ms: + duration_short_veto = duration_ms < expected_duration_ms * config.DURATION_MIN_RATIO + composite = config.W_DTW * s_dtw + config.W_BIO * s_bio # 任意一道一票否决触发 → composite 封顶到 SMOOTHNESS_CAP,阻断脚本。 if ( @@ -233,6 +258,8 @@ or axial_asymmetry_veto or subpixel_veto or min_duration_veto + or endpoint_veto + or duration_short_veto ): composite = min(composite, config.SMOOTHNESS_CAP) @@ -264,6 +291,10 @@ subpixel_entropy_y=round(float(sub_entropy_y), 4), min_duration_veto=min_duration_veto, duration_ms=round(duration_ms, 3), + endpoint_veto=endpoint_veto, + endpoint_dist_px=round(endpoint_dist, 3), + duration_short_veto=duration_short_veto, + expected_duration_ms=round(float(expected_duration_ms or 0.0), 3), extras={}, ) --- a/backend/app/challenge.py +++ b/backend/app/challenge.py @@ -85,6 +85,8 @@ "canvas_w": canvas_w, "canvas_h": canvas_h, "duration": duration, + # 终点命中否决用 + "target_half": target_half, "created_at_ms": _now_ms(), } --- a/backend/app/main.py +++ b/backend/app/main.py @@ -105,12 +105,17 @@ xs = [p[0] for p in points] ys = [p[1] for p in points] ts = [p[2] for p in points] + _duration = record.get("duration") breakdown = scoring.engine.score_trajectory( xs, ys, ts, record["control_points"], record["canvas_w"], record["canvas_h"], + target_half=record.get("target_half"), + expected_duration_ms=(float(_duration) * 1000.0) if _duration else None, ) detail = scoring.engine.breakdown_to_dict(breakdown)