merge: C6反馈驱动器族错配直指(回退selectionBasis+H零增量→先修targets/spec别改计分;80007盲修¥14.9对症解)+a2前提证伪实录(shop模板targets一直在,回退发生在生成物丢键) (cutover S2 工单a)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-03 19:24:08 -07:00
commit f121bb01ce
2 changed files with 238 additions and 0 deletions

View File

@ -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
# 数值 after<before(回落)→ 未增 → 零增量。
assert _h_zero_increment({"checks": [
{"before": 5, "after": 3, "pass": False}]}) is True
# 数值 after>before → 有增 → 非零增量。
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

View File

@ -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"))}