From 8d94128d7e7ef83f727c8c0f1b07481aeefddbb0 Mon Sep 17 00:00:00 2001 From: lili Date: Fri, 3 Jul 2026 19:21:55 -0700 Subject: [PATCH] =?UTF-8?q?fix(cheap):=20C6=E5=8F=8D=E9=A6=88=E9=A9=B1?= =?UTF-8?q?=E5=8A=A8=E5=99=A8=E6=97=8F=E9=94=99=E9=85=8D=E7=9B=B4=E6=8C=87?= =?UTF-8?q?(selectionBasis=E5=9B=9E=E9=80=80+H=E9=9B=B6=E5=A2=9E=E9=87=8F?= =?UTF-8?q?=E2=86=92=E5=85=88=E4=BF=AEtargets/spec=E5=88=AB=E6=94=B9?= =?UTF-8?q?=E8=AE=A1=E5=88=86)=20(cutover=20S2=20=E5=B7=A5=E5=8D=95a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工单a1(真修):build_cheap_feedback_obj 的 H_progress 分支——当判卷驱动器族是「回退」兜出来的 (staged play-spec.json 的 driver.selectionBasis 含「回退」二字)且 H 失败为零增量类(同源读 g.checks 的 before/after 判 after<=before,不 re-parse 字符串)时,把该门 fixDirection 从原「核对得分随真玩上升」 掉转为直指:先让运行态暴露 targets / 修 play-spec,而不是改计分逻辑;原 H 详情保留在 detail。 三条不命中支(driver 正常选中 / score 有真增即终局不可达 / 读不到 selectionBasis)一律保原门提示一字不变。 新增 _cheap_selection_basis(镜像 _cheap_driver_type 容错)、_h_zero_increment 两个 helper。 80007 盲修¥14.9(key-cycle 驱不动购销循环→真玩零得分→原文案指向改计分→M3 盲修烧到硬地板)实证。 单测 test_gate_judge_mismatch.py 7 用例全绿;既有 gate_judge/feedback 32 用例零回归;tier2 路未碰。 工单a2(实证无需改):前提「_template-shop 漏 targets 契约」经端到端真跑证伪。 _template-shop 自建档提交 9ddd173c(2026-06-30)起,_forensicsView().state() 就暴露 targets 键,与参考 模板 _template-feiyi 结构一致;A2 批(34923812)只加「起局按钮解耦 render + targets 键名约定进 prompt」、 未动 targets 暴露本身。全管线 scaffold→build→stage→smoke→ensure_play_spec 真跑,shop 得 driver.type=tap-targets(selectionBasis=可点目标类,非 key-cycle 回退),与 feiyi 正对照行为一致。 模板已满足契约,按最小改动不造多余改动。 Co-Authored-By: Claude Fable 5 --- .../tests/test_gate_judge_mismatch.py | 178 ++++++++++++++++++ tier2/gen-worker/worker/gate_judge.py | 60 ++++++ 2 files changed, 238 insertions(+) create mode 100644 tier2/gen-worker/tests/test_gate_judge_mismatch.py diff --git a/tier2/gen-worker/tests/test_gate_judge_mismatch.py b/tier2/gen-worker/tests/test_gate_judge_mismatch.py new file mode 100644 index 00000000..7edd4fda --- /dev/null +++ b/tier2/gen-worker/tests/test_gate_judge_mismatch.py @@ -0,0 +1,178 @@ +"""test_gate_judge_mismatch.py — C6 反馈「驱动器族错配直指」单测(cutover S2 工单a·80007 实证)。 + +守的不变量: + · 判卷驱动器族是「回退」兜出来的(play-spec.json 的 driver.selectionBasis 含「回退」) + **且** H_progress 失败是零增量类(真玩后关键指标 before==after / 未增)→ H 那条 fixDirection + 从原「核对得分/进度」门提示掉转为直指:先让运行态暴露 targets / 修 play-spec,而不是改计分。 + · 三条不命中支(driver 正常选中 / score 有真增 / 无 play-spec)一律保原门提示文案,一字不变、不炸。 + · 直指文案改的只有 fixDirection;原 H 详情(_cheap_gate_detail 抽的 checks/latch)仍留在 item.detail。 + · 改后对象仍过 C6 契约校验(fixDirection 只是变长的字符串)。 + +背景(80007 生产事故):经营 brief → _template-shop 生成 → state 未暴露 targets → ensure_play_spec 回退 +key-cycle → 按键循环驱不动购销循环 → 真玩零得分 → H 挂;原文案「核对得分随真玩上升」把修复方向指向改计分 +→ M3 盲修烧 ¥14.9 到硬地板。本直指把方向从「改计分」掉转为「先让运行态可被驱动」。 + +跑:PYTHONPATH=tier2/gen-worker cheap-worker/.venv/bin/python -m pytest tier2/gen-worker/tests/test_gate_judge_mismatch.py -v +""" +import importlib.util +import json +from pathlib import Path + +from worker.gate_judge import ( + _CHEAP_GATE_HINTS, + _cheap_selection_basis, + _h_zero_increment, + _render_cheap_feedback, + build_cheap_feedback_obj, +) + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_PLAYLOOP = _REPO_ROOT / "contracts" / "play-loop" + + +def _load_validator(): + spec = importlib.util.spec_from_file_location("_playloop_validate_mm", _PLAYLOOP / "validate.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +_V = _load_validator() +_C6 = json.loads((_PLAYLOOP / "verdict-feedback.schema.json").read_text(encoding="utf-8")) + + +def _assert_c6(obj): + errors = _V.validate(_C6, obj, _C6) + assert errors == [], f"反馈对象未过 C6 契约校验:{errors}" + + +# selectionBasis 两态(与 cheap_run._build_play_spec 逐字对齐)。 +_SB_FALLBACK = "state 无 targets/target 键 → 按键类回退 → key-cycle 族" +_SB_NORMAL = "state 暴露 targets/target 键 → 可点目标类 → tap-targets occupied 族" + + +def _v_h_zero(): + """H_progress 失败 + score 零增量(before==after=0):驱不动 → 分没动。""" + return {"pass": False, "guards": { + "A_boot": {"pass": True}, + "H_progress": {"pass": False, "checks": [ + {"path": "score", "op": "increased", "before": 0, "after": 0, "pass": False, + "why": "真玩应加分"}], "latch": {"pass": True}}, + }} + + +def _write_spec(tmp_path, selection_basis, driver_type="key-cycle"): + (tmp_path / "play-spec.json").write_text( + json.dumps({"driver": {"type": driver_type, "selectionBasis": selection_basis}}), + encoding="utf-8") + + +def _h_item(obj): + return next(it for it in obj["items"] if it["gate"] == "H_progress") + + +# ───────────────────────── 命中:回退 + 零增量 → 直指 ───────────────────────── + +def test_hit_rewrites_fix_direction(tmp_path): + """selectionBasis 含「回退」+ score before==after → fixDirection 掉转为直指(引 selectionBasis 原文)。""" + _write_spec(tmp_path, _SB_FALLBACK) + obj = build_cheap_feedback_obj(_v_h_zero(), game_id="m1", staged_dir=tmp_path) + h = _h_item(obj) + fix = h["fixDirection"] + assert "回退" in fix, "直指须点明驱动器族是回退选出" + assert "不是改计分" in fix, "直指须把方向从『改计分』掉转开" + assert "targets" in fix, "直指须指向让运行态暴露 targets / 修 play-spec" + assert _SB_FALLBACK in fix, "直指须引 selectionBasis 原文" + assert fix != _CHEAP_GATE_HINTS["H_progress"], "命中时不能还是原门提示" + # 原 H 详情(checks 抽出的 score 段)仍保留在 detail。 + assert "detail" in h and "score" in h["detail"], "原 H 详情须保留在 detail" + # 渲染文本(喂 agent 的全文)带上直指。 + assert "不是改计分" in _render_cheap_feedback(obj) + # 改后对象仍过 C6 契约(fixDirection 只是变长字符串)。 + _assert_c6(obj) + + +# ───────────────────────── 不命中①:driver 正常选中 → 原文案 ───────────────────────── + +def test_no_hit_normal_selection_keeps_original(tmp_path): + """selectionBasis 无「回退」(正常选中 tap-targets)→ H 门提示原文案一字不变。""" + _write_spec(tmp_path, _SB_NORMAL, driver_type="tap-targets") + obj = build_cheap_feedback_obj(_v_h_zero(), game_id="m2", staged_dir=tmp_path) + assert _h_item(obj)["fixDirection"] == _CHEAP_GATE_HINTS["H_progress"] + + +# ───────────────────────── 不命中②:含回退但 score 有增 → 原文案 ───────────────────────── + +def test_no_hit_score_increased_keeps_original(tmp_path): + """含「回退」但 score 有真增(0→40,H 挂在终局不可达而非驱不动)→ 零增量不满足 → 原文案。""" + _write_spec(tmp_path, _SB_FALLBACK) + v = {"pass": False, "guards": { + "H_progress": {"pass": False, "checks": [ + {"path": "score", "op": ">=100", "before": 0, "after": 40, "pass": False, + "why": "分数没到目标"}], "latch": {"pass": True}}, + }} + obj = build_cheap_feedback_obj(v, game_id="m3", staged_dir=tmp_path) + assert _h_item(obj)["fixDirection"] == _CHEAP_GATE_HINTS["H_progress"] + + +# ───────────────────────── 不命中③:无 play-spec 文件 → 原文案不炸 ───────────────────────── + +def test_no_hit_missing_play_spec_no_crash(tmp_path): + """staged 目录无 play-spec.json → selectionBasis None → 不触发、不抛,走原门提示。""" + obj = build_cheap_feedback_obj(_v_h_zero(), game_id="m4", staged_dir=tmp_path) + assert _h_item(obj)["fixDirection"] == _CHEAP_GATE_HINTS["H_progress"] + + +def test_no_hit_no_staged_dir_keeps_original(): + """降级口径(不给 staged_dir)→ selectionBasis None → 原文案。""" + obj = build_cheap_feedback_obj(_v_h_zero(), game_id="m5") + assert _h_item(obj)["fixDirection"] == _CHEAP_GATE_HINTS["H_progress"] + + +# ───────────────────────── helper 单元:_h_zero_increment / _cheap_selection_basis ───────────────────────── + +def test_h_zero_increment_cases(): + # 数值 before==after → 零增量。 + assert _h_zero_increment({"checks": [ + {"before": 0, "after": 0, "pass": False}]}) is True + # 数值 afterbefore → 有增 → 非零增量。 + assert _h_zero_increment({"checks": [ + {"before": 0, "after": 40, "pass": False}]}) is False + # 非数值相等(phase 没动)→ 零增量。 + assert _h_zero_increment({"checks": [ + {"before": "menu", "after": "menu", "pass": False}]}) is True + # 只有通过的 check(pass 非 False)→ 不计入 → 非零增量。 + assert _h_zero_increment({"checks": [ + {"before": 0, "after": 0, "pass": True}]}) is False + # 无 checks(latch-only 失败)→ 非零增量(H 挂在 latch 不是驱不动)。 + assert _h_zero_increment({"checks": [], "latch": {"pass": False}}) is False + # before/after 缺 → 跳过 → 非零增量。 + assert _h_zero_increment({"checks": [{"pass": False}]}) is False + # 坏输入不抛。 + assert _h_zero_increment({}) is False + assert _h_zero_increment({"checks": "oops"}) is False + + +def test_cheap_selection_basis_reads_and_tolerates(tmp_path): + # 正常读。 + _write_spec(tmp_path, _SB_FALLBACK) + assert _cheap_selection_basis(tmp_path) == _SB_FALLBACK + # 无 staged_dir → None。 + assert _cheap_selection_basis(None) is None + # 无 play-spec 文件 → None。 + empty = tmp_path / "empty" + empty.mkdir() + assert _cheap_selection_basis(empty) is None + # 坏 JSON → None(不抛)。 + bad = tmp_path / "bad" + bad.mkdir() + (bad / "play-spec.json").write_text("{not json", encoding="utf-8") + assert _cheap_selection_basis(bad) is None + # driver 无 selectionBasis 键 → None。 + nos = tmp_path / "nosb" + nos.mkdir() + (nos / "play-spec.json").write_text(json.dumps({"driver": {"type": "key-cycle"}}), encoding="utf-8") + assert _cheap_selection_basis(nos) is None diff --git a/tier2/gen-worker/worker/gate_judge.py b/tier2/gen-worker/worker/gate_judge.py index f7a9ddf1..dbde2703 100644 --- a/tier2/gen-worker/worker/gate_judge.py +++ b/tier2/gen-worker/worker/gate_judge.py @@ -189,6 +189,53 @@ def _game_log_brief(staged_dir: Optional[Path], *, max_entries: int = 8, max_cha return "" +def _cheap_selection_basis(staged_dir: Optional[Path]) -> Optional[str]: + """读判卷驱动器族的选取依据(staged play-spec.json 的 driver.selectionBasis;C6 错配直指的触发信号)。 + + 照 _cheap_driver_type 的容错风格:staged 缺 / 无 play-spec / 坏 JSON / 无该键 → None—— + 上层据 None 不触发「驱动器族错配直指」、走原门提示文案。 + """ + try: + if staged_dir is None: + return None + p = Path(staged_dir) / "play-spec.json" + if not p.exists(): + return None + spec = json.loads(p.read_text(encoding="utf-8")) or {} + sb = (spec.get("driver") or {}).get("selectionBasis") + return str(sb) if sb else None + except Exception: # noqa: BLE001 —— 读不出选取依据只降级(不触发直指),不连累反馈主体 + return None + + +def _h_zero_increment(g: dict) -> bool: + """H_progress 失败详情是否为「零增量类」——真玩后关键指标没往上走(before==after 或 score 未增)。 + + 同源 _cheap_gate_detail 的 H 分支取数(g['checks'] 里 pass is False 的项;绝不 re-parse 已渲染的字符串)。 + 判据:存在一条失败 check,其 before/after 都在且 after 未超过 before——数值按 after<=before(未增)、 + 非数值按 after==before(没动)。这正是「驱动器驱不动 → 状态没动」的信号。 + 若失败 check 里 score 有真增(after>before,H 挂在终局不可达而非驱不动)→ 不算零增量、不触发直指。 + 读不出(无 checks / 字段缺 / 异常)→ False,走原文案。 + """ + try: + for c in (g.get("checks") or []): + if not isinstance(c, dict) or c.get("pass") is not False: + continue + before, after = c.get("before"), c.get("after") + if before is None or after is None: + continue + numeric = (isinstance(before, (int, float)) and not isinstance(before, bool) + and isinstance(after, (int, float)) and not isinstance(after, bool)) + if numeric: + if after <= before: # 数值未增(含相等 = 零增量) + return True + elif after == before: # 非数值:值没动 + return True + return False + except Exception: # noqa: BLE001 —— 判不出零增量就当不满足(走原文案),绝不连累反馈 + return False + + def build_cheap_feedback_obj(v: dict, *, game_id=None, staged_dir=None, driver_type=None) -> Optional[dict]: """把便宜档九门 verdict 构造成 C6 VerdictFeedback 结构化对象(contracts/play-loop/verdict-feedback.schema.json)。 @@ -211,6 +258,8 @@ def build_cheap_feedback_obj(v: dict, *, game_id=None, staged_dir=None, if not failed: return None dt = driver_type or _cheap_driver_type(staged_dir) or "none" + # C6 错配直指(cutover S2 工单a)的触发信号之一:判卷驱动器族是不是「回退」兜出来的(staged 读一次)。 + selection_basis = _cheap_selection_basis(staged_dir) items = [] for name, g in failed: item = {"gate": name, "driverType": dt, @@ -219,6 +268,17 @@ def build_cheap_feedback_obj(v: dict, *, game_id=None, staged_dir=None, if detail: item["detail"] = detail if name == "H_progress": + # 驱动器族错配直指(80007 实证):判卷驱动器是「回退」选出的(state 没暴露 targets/target 键 → + # 按键类回退)且 H 失败是零增量类(真玩后关键指标没动)→ 把修复方向从「改计分」掉转为 + # 「先让运行态暴露 targets / 修 play-spec」。原 H 详情保留在 detail(上面已写)。 + # 任一条件不满足(driver 正常选中 / score 有真增 / 读不到 selectionBasis)→ 原门提示一字不变。 + if selection_basis and "回退" in selection_basis and _h_zero_increment(g): + item["fixDirection"] = ( + f"这局判卷驱动器族是「回退」兜出来的(selectionBasis:{selection_basis})," + "这份考卷很可能根本驱不动本游戏——真玩后关键指标零增量(见 detail)," + "通常是判卷驱动器没接上、而不是计分逻辑写错。第一优先:让游戏运行态暴露可点/可驱的 targets" + "(或修正 play-spec 让它选对驱动器族),而不是改计分/进度逻辑;" + "确认游戏能被真玩驱动后,再回头核对得分门。") latch = g.get("latch") if isinstance(latch, dict): lat = {"pass": bool(latch.get("pass"))}