feat(cheap): rubric v2 评分尺 8→11 条 + L2/L3/L4 三分组小计落库(质量模型 §3.3 计分制·前置小工单)

评分尺升级:通用底座 v2 = 现行 8 条(判据文本/顺序锁定、仅补层标)+ 新增 3 条(首3分钟脚本 L3 / 炫耀时刻 L4 / 同款钩子 L4)。逐条 0/1 标层,一次 LLM 评分产 L2×6/L3×3/L4×2 三分组小计;不设单一总分(§3.3 防 Goodhart)。

落库:summary.richness 从单一总分改为三分组小计——新增 groups 字段 + hits 每条带 layer;score 保留为向后兼容字段(供 bake_off.richness_dist 读、非设计总分)。两落库点(cheap_studio/cheap_service_driver)整体赋值自动继承新形状,仅日志 additive 补 groups。

红线:rubric 仍纯 LLM 非阻塞,不进 verdict/九门/达标判定;bake_off.py richness 正交红线一行未动。

验收(本机真跑 M3):金标复验 base1/base4/cheap-run-sc 原 8 条命中数漂移 = -1/0/0(≤±1,base1 唯一 -1 来自「30秒爽点」单条 LLM 边界抖动、非评分尺扰动);落库佐证 run-summary 真出现 richness.groups + hits.layer、既有键不受影响;test_cheap_verify 14 绿(+3 新测)、bake_off/service_driver 27 绿;全套另 14 failed 经 stash baseline 对比证实为 worktree 缺产物/node_modules 的 pre-existing 环境失败、零回归。

follow-up:bake_off.richness_dist 的 max=8 报告口径随 W-GENRE 品类批产接入 richnessByGenre 时对齐分组小计;sim-business skill §10 改口归质量设计档收口。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-02 19:58:20 -07:00
parent 4d220e77da
commit ba82e63c82
4 changed files with 130 additions and 47 deletions

View File

@ -314,7 +314,7 @@ async def drive_cheap_generation(job: dict, *, base_url: str | None = None, user
summary["richness"] = await cheap_verify.verify_richness(game_id, brief=brief)
rv = summary["richness"]
print(f"[cheap-driver] game={game_id} 丰富度 score={rv.get('score')}/{rv.get('max')} "
f"degraded={rv.get('degraded')}", flush=True)
f"groups={rv.get('groups')} degraded={rv.get('degraded')}", flush=True)
except Exception as e: # noqa: BLE001 —— richness 非阻塞铁律:绝不影响回调
summary["richness"] = {"score": None, "degraded": True, "reason": f"richness 接线异常:{type(e).__name__}: {e}"}

View File

@ -287,7 +287,7 @@ async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens=
try:
summary["richness"] = await cheap_verify.verify_richness(game_id, brief=brief)
rv = summary["richness"]
_rec(f"丰富度 LLM 评分 score={rv.get('score')}/{rv.get('max')} degraded={rv.get('degraded')}")
_rec(f"丰富度 LLM 评分 score={rv.get('score')}/{rv.get('max')} groups={rv.get('groups')} degraded={rv.get('degraded')}")
except Exception as e: # noqa: BLE001 非阻塞铁律richness 绝不影响生成主链
summary["richness"] = {"score": None, "degraded": True,
"reason": f"richness 接线异常:{type(e).__name__}: {e}"}

View File

@ -6,7 +6,8 @@ cheap_verify.py — 便宜档「丰富度」LLM 验证 agent非阻塞·只报
故本模块只做两件 100% 机械确定的事丰富不丰富的判断本身完全交 LLM judge
读产物 L3 源码确定性文件 I/O+ judge prompt确定性字符串
解析 LLM 返回的 JSON 成结构化评分确定性解析 + degraded 兜底
本模块**不含任何丰富度的代码断言**8 条好玩清单只是喂给 LLM 的评分尺命中与否全由 judge 读码后裁
本模块**不含任何丰富度的代码断言**11 条丰富度清单只是喂给 LLM 的评分尺命中与否全由 judge 读码后裁
评分按层标 L2/L3/L4 聚合成三分组小计L2 内容丰富 / L3 留存结构 / L4 传播钩子不设单一总分规范见质量模型 SoT §3.3 计分制
sim-business-game-design.md §10 写了4 条可机检 code-presence子集那与本红线冲突本模块刻意不照它写代码校验
非阻塞铁律LLM 调失败/超时/解析失败 返回 {score:None, degraded:True, reason:...}
@ -19,41 +20,78 @@ import asyncio
import cheap_run # 仅 stdlib 依赖(读产物 src 文件);不 import agentscope/_bootstrap故纯函数可在无 agentscope 环境单测。
# ── sim-business §10 反「无趣」8 条好玩清单judge 的评分 rubric──
# ── 通用底座 v2 = 11 条丰富度清单judge 的评分 rubric──
# 这是【喂给 LLM judge 的打分尺】,不是代码校验:丰富不丰富全由 judge 读源码后逐条裁。
# 与 .agents/skills/sim-business-game-design.md §10 同源(那边是 design 自检,这里是产物 judge 评分)。
# 每项 = (标准名, 含义)judge 按标准名逐条回命中与否 + 理由。
# 规范来源 = 质量模型 SoT《游戏质量与爆火能力·设计》§3.3 计分制(每条 0/1 + 层标 L2/L3/L4产三分组小计、不设单一总分
# v2 = 现行 8 条(与 .agents/skills/sim-business-game-design.md §10 同源)+ 新增 3 条(首 3 分钟脚本 / 炫耀时刻 / 同款钩子)。
# 每项 = (标准名, 层标, 含义)judge 按标准名逐条回命中与否 + 理由,命中数按层聚合成三分组小计。
# ★锚定纪律§3.3 第 3 条):原 8 条判据文本与顺序一字不改(评分尺变更须金标复验、原 8 条判定漂移 ≤±1
# 新增 3 条追加于末尾;层标只是聚合归属,不改原条目的判据本身。
# 分层含义L2 内容丰富(有料耐玩)/ L3 留存结构(想再玩的结构前提)/ L4 传播钩子(想传出去的结构前提)。
RICHNESS_CHECKLIST = [
("即时反馈", "每个主要操作有「飘字 +N + 音效 + 粒子」即时回报"),
("可见成长", "有数字/规模肉眼可见地变大(分数飙升/店铺扩张/等级上涨)"),
("下一个解锁", "任意时刻玩家眼前都有「再攒一点就解锁 X」的钩子"),
("30秒爽点", "开局 30 秒内有第一次升级/解锁"),
("数值滚雪球", "成长有「越来越快」的暴富段,而非平淡线性"),
("情感锚", "有萌角色/拥有物让玩家「想养大它」"),
("放置回归", "离线回来有惊喜(离线收益弹窗等)"),
("音反馈", "收益/升级/解锁有声音(哪怕程序化)"),
# 原 8 条(文本/顺序锁定,仅补层标)
("即时反馈", "L2", "每个主要操作有「飘字 +N + 音效 + 粒子」即时回报"),
("可见成长", "L2", "有数字/规模肉眼可见地变大(分数飙升/店铺扩张/等级上涨)"),
("下一个解锁", "L3", "任意时刻玩家眼前都有「再攒一点就解锁 X」的钩子"),
("30秒爽点", "L2", "开局 30 秒内有第一次升级/解锁"),
("数值滚雪球", "L2", "成长有「越来越快」的暴富段,而非平淡线性"),
("情感锚", "L2", "有萌角色/拥有物让玩家「想养大它」"),
("放置回归", "L3", "离线回来有惊喜(离线收益弹窗等)"),
("音反馈", "L2", "收益/升级/解锁有声音(哪怕程序化)"),
# 新增 3 条v2 草案,随首个品类件落地验证;判据文本源自 §3.3 第 1 条)
("首3分钟脚本", "L3", "首 3 分钟脚本成立010s 零阅读上手 / 1060s 首次升级 / 13min 露出 23 个后续锁"),
("炫耀时刻", "L4", "有可截图分享的结算/成就画面(打完有结算展示场景,不是直接黑屏)"),
("同款钩子", "L4", "有清晰的品类原型 + 主题标签元数据,可供 remix 预填做同款"),
]
_MAX = len(RICHNESS_CHECKLIST) # = 8
_MAX = len(RICHNESS_CHECKLIST) # = 11
_LAYERS = ("L2", "L3", "L4") # 三分组固定顺序(消费面按分组小计各自取阈,见 §3.6 档位表)
def _layer_maxes(checklist=RICHNESS_CHECKLIST) -> dict:
"""各层条目数(分母)。通用底座 v2 = L2:6 / L3:3 / L4:2。"""
m = {ly: 0 for ly in _LAYERS}
for _name, layer, _meaning in checklist:
m[layer] = m.get(layer, 0) + 1
return m
def _group_subtotals(hits: list, checklist=RICHNESS_CHECKLIST) -> dict:
"""把逐条命中按层标聚合成三分组小计 {L2:{score,max}, L3:..., L4:...}。
这是 §3.3 计分制的落库主形状三层各自对得上自己的消费点品类件验收/批次观测/回流分析
**不做单一总分平均**单一总分诱导 Goodhart §3.3 1
"""
maxes = _layer_maxes(checklist)
scores = {ly: 0 for ly in _LAYERS}
for h in hits:
ly = h.get("layer")
if h.get("hit") and ly in scores:
scores[ly] += 1
return {ly: {"score": scores[ly], "max": maxes.get(ly, 0)} for ly in _LAYERS}
# 只读 L3 游戏本体(玩法/画面/数值/资产);跳过 L1 plumbinghost-config/game.js/index.html——
# 固定脚手架、与「游戏丰富不丰富」无关,喂给 judge 只会稀释信号)。
_L3_SOURCE_FILES = ("game-logic.js", "core.js", "render.js", "balance.js", "assets.js")
_MAX_SRC_BYTES = 24000 # 喂 judge 的源码总量上限(控 token / 成本;超限按字节截断)。
_JUDGE_MAX_TOKENS = 3000 # judge 输出小8 条短理由 + 一句点评3000 足够、M3 无 thinking。
_JUDGE_MAX_TOKENS = 4000 # judge 输出小11 条短理由 + 一句点评4000 足够、M3 无 thinking。
# judge 的 system / user 框定——只评分、不改码、要看源码真实现、输出严格 JSON。
_JUDGE_SYSTEM = (
"你是轻量小游戏的【丰富度评审 agent】。任务 = 读一款便宜档 LittleJS 小游戏的源码,"
"只评判它「作为一款游戏够不够丰富、好玩」,逐条给出 8 条好玩清单的命中与否 + 一句中文理由,"
"只评判它「作为一款游戏够不够丰富、好玩、耐玩、想传出去」,逐条给出 11 条丰富度清单的命中与否 + 一句中文理由,"
"最后输出严格 JSON。你只评分、不修改代码、不阻断发布——这是非阻塞的质量信号。"
"评判要看源码里**真实实现**的玩法、数值成长、解锁阶梯、即时反馈与音效,别被空壳或注释骗。"
"这 11 条分三层L2 内容丰富有料耐玩、L3 留存结构想再玩的结构前提、L4 传播钩子(想让别人看/做同款的前提);"
"评判要看源码里**真实实现**的玩法、数值成长、解锁阶梯、即时反馈、音效、结算画面与品类元数据,别被空壳或注释骗。"
)
def _degraded(reason: str) -> dict:
"""降级结果非阻塞铁律score=None + degraded=Truescore 为 None 表示「这次没评出来」,绝不参与达标。"""
return {"score": None, "max": _MAX, "hits": [], "notes": None, "degraded": True, "reason": reason}
"""降级结果非阻塞铁律score=None + degraded=Truescore 为 None 表示「这次没评出来」,绝不参与达标。
groups 同置 None三分组小计的消费面品类件验收/回流分析 None 本次未评出 score=None 同义
"""
return {"score": None, "max": _MAX, "hits": [], "groups": None, "notes": None, "degraded": True, "reason": reason}
def _coerce_bool(v) -> bool:
@ -70,11 +108,17 @@ def _coerce_bool(v) -> bool:
def parse_judge_output(text, *, checklist=RICHNESS_CHECKLIST) -> dict:
"""纯函数:把 judge 的 LLM 文本输出解析成结构化丰富度评分(含 degraded 兜底)。可单测、零网络、零 agentscope。
成功 {score:int(命中数 0..8), max:8, hits:[{name,hit,why}*8], notes:str, degraded:False}
解析失败/ JSON 对象/ checks 数组 _degraded(...)score=None, degraded=True
成功 {
score:int(11 条命中总数 0..11), # ★向后兼容字段:仅供 bake_off.richness_dist 读;非「质量总分」。
max:11, # 设计不设单一总分(诱导 Goodhart主消费形状 = groups 三分组小计。
hits:[{name,layer,hit,why}*11], # 逐条:加 layer 层标L2/L3/L4
groups:{L2:{score,max}, L3:..., L4:...}, # ★三分组小计§3.3 计分制主形状)
notes:str, degraded:False}
解析失败/ JSON 对象/ checks 数组 _degraded(...)score=None, groups=None, degraded=True
对齐策略优先按 name 对齐 judge checks 8 条标准项name 对不上的按位置兜底容忍模型改名/英文名
本函数**不含任何丰富度的代码判断**只搬运 judge 的逐条裁决并计命中数
对齐策略优先按 name 对齐 judge checks 11 条标准项name 对不上的按位置兜底容忍模型改名/英文名
layer 层标取自 checklist不依赖 LLM 回传故分组小计对模型改名鲁棒
本函数**不含任何丰富度的代码判断**只搬运 judge 的逐条裁决并按层聚合命中数
"""
if not isinstance(text, str) or not text.strip():
return _degraded("judge 输出为空")
@ -99,17 +143,18 @@ def parse_judge_output(text, *, checklist=RICHNESS_CHECKLIST) -> dict:
hits = []
score = 0
for i, (name, _meaning) in enumerate(checklist):
for i, (name, layer, _meaning) in enumerate(checklist):
c = by_name.get(name)
if c is None and i < len(checks_raw) and isinstance(checks_raw[i], dict):
c = checks_raw[i] # name 对不上 → 位置兜底
hit = _coerce_bool(c.get("hit")) if isinstance(c, dict) else False
why = (str(c.get("why", "")).strip()[:200]) if isinstance(c, dict) else ""
hits.append({"name": name, "hit": hit, "why": why})
hits.append({"name": name, "layer": layer, "hit": hit, "why": why}) # layer 取自 checklist权威
if hit:
score += 1
notes = str(data.get("notes", "")).strip()[:500]
return {"score": score, "max": len(checklist), "hits": hits, "notes": notes, "degraded": False}
return {"score": score, "max": len(checklist), "hits": hits,
"groups": _group_subtotals(hits, checklist), "notes": notes, "degraded": False}
def _collect_sources(game_id: str, *, max_bytes: int = _MAX_SRC_BYTES) -> str:
@ -141,23 +186,24 @@ def _collect_sources(game_id: str, *, max_bytes: int = _MAX_SRC_BYTES) -> str:
def _build_judge_user(src_text: str, brief: str = "") -> str:
"""拼 judge 的 user 消息brief可选+ 8 条清单 + 产物源码 + 严格 JSON 输出契约(纯确定性字符串拼接)。"""
"""拼 judge 的 user 消息brief可选+ 11 条清单(带层标)+ 产物源码 + 严格 JSON 输出契约(纯确定性字符串拼接)。"""
checklist_lines = "\n".join(
f"{i + 1}. {name}{meaning}" for i, (name, meaning) in enumerate(RICHNESS_CHECKLIST)
f"{i + 1}. [{layer}] {name}{meaning}" for i, (name, layer, meaning) in enumerate(RICHNESS_CHECKLIST)
)
brief_block = f"这款游戏的 brief玩家想要的{brief}\n\n" if brief else ""
return (
f"{brief_block}"
"下面是这款便宜档小游戏的 L3 源码。请逐条评判它是否命中这 8 条好玩清单,"
"据**源码里真实实现了的玩法/数值/音效/解锁**判断(别被注释或空壳骗:比如只 import 了 audioMusic "
"下面是这款便宜档小游戏的 L3 源码。请逐条评判它是否命中这 11 条丰富度清单(每条前的 [L2]/[L3]/[L4] 是分层标注——"
"L2 内容丰富、L3 留存结构、L4 传播钩子——仅用于分组,不改变你对每条的独立判命中),"
"据**源码里真实实现了的玩法/数值/音效/解锁/结算画面/品类元数据**判断(别被注释或空壳骗:比如只 import 了 audioMusic "
"但收益处没真调 playSfx则「音反馈」不算命中\n\n"
f"{checklist_lines}\n\n"
"=== 源码开始 ===\n"
f"{src_text}\n"
"=== 源码结束 ===\n\n"
"严格只输出以下 JSON不要任何额外文字、不要 markdown 围栏):\n"
'{"checks":[{"name":"即时反馈","hit":true,"why":"一句中文理由"}, … 共 8 条,'
'name 用上面 8 条的中文名、hit 为 true/false], "notes":"整体一句话点评"}'
'{"checks":[{"name":"即时反馈","hit":true,"why":"一句中文理由"}, … 共 11 条,'
'name 用上面 11 条的中文名、hit 为 true/false], "notes":"整体一句话点评"}'
)

View File

@ -1,12 +1,15 @@
"""
test_cheap_verify.py U-A2 便宜档丰富度LLM 验证 agent 单测
test_cheap_verify.py U-A2 便宜档丰富度LLM 验证 agent 单测rubric v211 + 三分组小计
两层
parse_judge_output 纯函数解析 LLM 输出 结构化评分 + degraded 兜底 agentscope零网络
parse_judge_output 纯函数解析 LLM 输出 结构化评分 + 三分组小计 + degraded 兜底 agentscope零网络
plain python 即可跑cheap-worker/.venv/bin/python cheap-worker/tests/test_cheap_verify.py
verify_richness 非阻塞契约fake model 注入零真网络验证LLM 失败/超时/解析失败/ src degraded
绝不抛绝不阻断 agentscope.messagevenv 缺则自动跳过
rubric v2质量模型 SoT §3.3通用底座 11 分层 L2×6 / L3×3 / L4×2
逐条 0/1 标层 + L2/L3/L4 三分组小计score 保留为向后兼容字段bake_off 非单一总分
cheap-worker/.venv/bin/python -m pytest cheap-worker/tests/test_cheap_verify.py -q
"""
@ -26,35 +29,66 @@ except Exception: # noqa: BLE001 无 agentscope 时 ② 类测试跳过(纯
def _full_checks(hits):
"""据布尔列表造 8 条 checksname 用 cheap_verify 的标准名)。"""
return [{"name": n, "hit": h, "why": "理由"} for (n, _m), h in zip(cheap_verify.RICHNESS_CHECKLIST, hits)]
"""据布尔列表造 checksname 用 cheap_verify 的标准名hits 短于 11 条则 zip 取最短(余条判 False"""
return [{"name": n, "hit": h, "why": "理由"} for (n, _l, _m), h in zip(cheap_verify.RICHNESS_CHECKLIST, hits)]
# 通用底座 v2 层索引(据 RICHNESS_CHECKLIST 顺序L2={0,1,3,4,5,7} L3={2,6,8} L4={9,10}
def test_checklist_shape_v2():
"""底座 = 11 条,分层 L2×6 / L3×3 / L4×2分组归属对齐 §3.3 第 1 条)。"""
cl = cheap_verify.RICHNESS_CHECKLIST
assert len(cl) == 11 and cheap_verify._MAX == 11
layers = [layer for _n, layer, _m in cl]
assert layers.count("L2") == 6 and layers.count("L3") == 3 and layers.count("L4") == 2
# 原 8 条 name/顺序锁定(锚定纪律:评分尺变更不得动原条目)
assert [n for n, _l, _m in cl][:8] == [
"即时反馈", "可见成长", "下一个解锁", "30秒爽点", "数值滚雪球", "情感锚", "放置回归", "音反馈"]
# ───────────────────────── ① parse_judge_output 纯函数(无 agentscope/无网络)─────────────────────────
def test_parse_valid_full():
"""合法 8 条 JSON → 命中数正确、非 degraded、hits/notes 落位。"""
"""合法 JSON喂前 8 条命中态)→ 命中数正确、非 degraded、hits 带 layer、groups 三分组小计落位。"""
text = json.dumps({"checks": _full_checks([True, True, False, True, False, False, False, True]),
"notes": "还行"}, ensure_ascii=False)
r = cheap_verify.parse_judge_output(text)
assert r["degraded"] is False
assert r["score"] == 4 and r["max"] == 8
assert len(r["hits"]) == 8 and r["hits"][0]["name"] == "即时反馈"
assert r["score"] == 4 and r["max"] == 11
assert len(r["hits"]) == 11 and r["hits"][0]["name"] == "即时反馈" and r["hits"][0]["layer"] == "L2"
# 前 8 条命中态 [T,T,F,T,F,F,F,T]L2(0,1,3,4,5,7)=4L3(2,6,8=F,F,未喂F)=0L4(9,10 未喂)=0
assert r["groups"] == {"L2": {"score": 4, "max": 6}, "L3": {"score": 0, "max": 3}, "L4": {"score": 0, "max": 2}}
assert r["notes"] == "还行"
def test_parse_11_full_and_groups():
"""11 条全命中 → score=11、三分组小计满分L2 6/6 · L3 3/3 · L4 2/2"""
r = cheap_verify.parse_judge_output(json.dumps({"checks": _full_checks([True] * 11), "notes": ""}))
assert r["degraded"] is False and r["score"] == 11 and r["max"] == 11
assert r["groups"] == {"L2": {"score": 6, "max": 6}, "L3": {"score": 3, "max": 3}, "L4": {"score": 2, "max": 2}}
def test_parse_groups_by_layer():
"""按层聚合正确:命中态 [T,T,F,T,F,F,F,T,F,T,T] → L2=4 L3=0 L4=2、score=6。"""
pat = [True, True, False, True, False, False, False, True, False, True, True]
r = cheap_verify.parse_judge_output(json.dumps({"checks": _full_checks(pat)}))
assert r["score"] == 6
assert r["groups"]["L2"] == {"score": 4, "max": 6}
assert r["groups"]["L3"] == {"score": 0, "max": 3}
assert r["groups"]["L4"] == {"score": 2, "max": 2}
def test_parse_fenced_json():
"""markdown 围栏 + 前后赘语 → json_repair 兜得住、正确解析。"""
body = json.dumps({"checks": _full_checks([True] * 8), "notes": ""}, ensure_ascii=False)
body = json.dumps({"checks": _full_checks([True] * 11), "notes": ""}, ensure_ascii=False)
text = "这是我的评分:\n```json\n" + body + "\n```\n以上。"
r = cheap_verify.parse_judge_output(text)
assert r["degraded"] is False and r["score"] == 8
assert r["degraded"] is False and r["score"] == 11
def test_parse_garbage_degraded():
"""完全非 JSON → degradedscore=None不抛。"""
"""完全非 JSON → degradedscore=None, groups=None),不抛。"""
r = cheap_verify.parse_judge_output("完全不是 JSON 的一段话")
assert r["degraded"] is True and r["score"] is None and r["max"] == 8
assert r["degraded"] is True and r["score"] is None and r["max"] == 11 and r["groups"] is None
def test_parse_none_and_empty_degraded():
@ -81,6 +115,7 @@ def test_parse_coerce_and_positional():
r = cheap_verify.parse_judge_output(json.dumps({"checks": checks}))
assert r["degraded"] is False
assert r["score"] == 2 # 前两条 "true"/1 命中,第三条「否」不命中,其余 False
assert r["max"] == 11 and r["hits"][0]["layer"] == "L2" # 位置兜底也带 checklist 权威 layer
# ───────────────────────── ② verify_richness 非阻塞契约fake model零真网络─────────────────────────
@ -107,7 +142,7 @@ class _FakeModel:
return (11, 22)
_SRC = "// game-logic.js\nfunction createGame(){ /* 进货 库存 解锁 playSfx 飘字 */ }"
_SRC = "// game-logic.js\nfunction createGame(){ /* 进货 库存 解锁 playSfx 飘字 结算 remix */ }"
def _skip_no_as(name):
@ -115,12 +150,14 @@ def _skip_no_as(name):
def test_verify_happy_path():
"""fake model 返回合法 JSON → 结构化评分 + judgeTokens非 degraded。"""
"""fake model 返回合法 JSON → 结构化评分 + 三分组小计 + judgeTokens非 degraded。"""
if not _HAS_AS:
return _skip_no_as("test_verify_happy_path")
body = json.dumps({"checks": _full_checks([True] * 5 + [False] * 3), "notes": "不错"}, ensure_ascii=False)
body = json.dumps({"checks": _full_checks([True] * 5 + [False] * 6), "notes": "不错"}, ensure_ascii=False)
r = asyncio.run(cheap_verify.verify_richness("x", sources=_SRC, model=_FakeModel(text=body)))
assert r["degraded"] is False and r["score"] == 5
# 前 5 条命中 [T,T,T,T,T] 均属 L2 索引{0,1,3,4}? idx0,1L2 idx2L3 idx3,4L2 → L2=4 L3=1
assert r["groups"]["L2"]["score"] == 4 and r["groups"]["L3"]["score"] == 1 and r["groups"]["L4"]["score"] == 0
assert r["judgeTokens"]["total"] == 33