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:
commit
f121bb01ce
178
tier2/gen-worker/tests/test_gate_judge_mismatch.py
Normal file
178
tier2/gen-worker/tests/test_gate_judge_mismatch.py
Normal 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
|
||||
@ -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"))}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user