lili 688f83fdc5 feat(tier2): 收敛环台账 infra 归因穿线(observe-only 第一迭代)——治 F-2 R1 设计团队超时降级被误判"分散"的生产盲区
把「设计团队超时降级 / writer 撞轮数墙 / step_cap 熔断 / ¥ 软预算软停 / 墙钟超时」这类
编排层事件从 studio 运行态穿到批跑台账,让退路树能一眼归因。此前两败局其实 100% 集中在
「设计团队超时降级路」,但台账 fail_system 空 → decide_n5 按 fail_system 看"分散"出
R3_conditional,主持人只能人工翻日志归因。

做法(不塞 fail_system,游戏系统桶语义不污染;decide_n5 的 R1/R2 分流逻辑一字不动):
- RunRecord 新增 infra_flags: list[str](field(default_factory=list);受控值集 INFRA_FLAGS
  五项常量 design_team_degraded/writer_iter_wall/step_cap_tripped/soft_budget_tripped/
  wall_timeout,注释写明可扩)+ 纯映射 infra_flags_from_runtime(信号→值集,去重稳定序);
- studio 运行态采集:设计团队降级点(捕获异常,.kind=='timeout' 归 wall_timeout)、外层
  resume 轮数墙、熔断返回 kind、¥ 软预算软停标记,经 _infra_flags_from_runtime 归一进 result;
  design_team.DesignTeamError 加 kind 区分墙钟超时;
- batch_run.result_to_record 从 result 抽 infra_flags 落台账(防御式复制);
- decide_n5 只把各轮 infra_flags 分布打进 reasons(如「r1 infra 分布={design_team_degraded:2,
  wall_timeout:2}」),不分支、不改任何出口。

向后兼容:旧 jsonl 行无 infra_flags → from_jsonl_line 读空列表不炸;r{N} 轮分组正则不动;
middleware.py 不动;退路树出口枚举与判定不动;decide_n5 签名不破坏。

验收:新增单测 5(纯映射/studio fake 运行态采集/batch 落账/旧行兼容/decide_n5 分布行+出口不变
含 --no-strong-baseline 语义原样);全套 102 passed(97 基线 + 5);run_record 与 fallback_tree
的 __main__ 自检、decide_n5_from_jsonl 新旧混合行端到端均通过。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 00:37:16 -07:00

620 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""fallback_tree.py —— tier2 0号 spike 退路树五出口判定器(纯函数,零副作用)。
职责(对 docs/architecture/架构/生成引擎/tier2细节图说-G-spike-runbook.md 图 G5「退路树」
与 tier2实现详设.md §退路树):
spike 在 mini-desktop 跑完一整张模型矩阵后,把 run 级采集字段汇成「矩阵级聚合」(过门率 /
¥/成功款 / 收敛中位数 / fail_system 分布);本模块吃这份聚合,按 Q1Q4 三条数字触发线把它
纵向分流成五个确定出口,让 go/no-go 不靠人工拍脑袋,也让 no-go 之后有确定去处、绝不滑成无限调参。
设计依据(权威 = 图说,本模块 deepen 不偏离):
- 退路树拓扑(START→Q1→{GO | Q2→{R1 | Q3→{R2 | Q4→{R3 | KEEP}}}})照搬详设 §退路树的 mermaid,
每条触发线在 decide() 里写明对应图说哪一条 Q + 阈值常量。
- 五出口语义(GO / R1 退模板化 / R2 补骨架 / R3 天花板 / KEEP 留观)见下文每个出口的 docstring。
- 阈值标 ★ 的是 directional v1 建议值,**需创始人和实测校准**(详设 §过门判据 / §退路树都标了 ★);
全部集中在下方常量区、注明来源,真跑校准时只改这一处。
输入接口(stats)说明:
本判定器吃的是「矩阵级聚合」,不是单条 run-record。A3 的 run_record.py(单条 run 形状)在
Phase1 并行下可能还没落地;故这里按 G 族图说图 G4「汇成矩阵级三张图」+ 详设 §采集指标字段表 的
字段名先行定义 stats 的形状(见 decide() docstring 的 stats schema)。主会话集成 A3 时,
以「单条 run-record 列表 → 矩阵级聚合」这一步对齐字段名:聚合产物喂本判定器即可。
为什么是纯函数:退路树判定是 go/no-go 的可复核裁决,必须可单测、可对拍、零副作用——
不读文件、不发网络、不打印副作用日志(reasons 全部以返回值携带,供调用方落档/展示)。
"""
from __future__ import annotations
from typing import Any
# ──────────────────────────────────────────────────────────────────────────
# 阈值常量区(全部集中可调;★ = directional v1,需创始人和实测校准)
# 来源:tier2细节图说-G-spike-runbook.md 图 G5 / tier2实现详设.md §退路树 + §过门判据
# ──────────────────────────────────────────────────────────────────────────
# ★ Q1 触发线:最强便宜档(v4-pro)过门率「达阈值」的下限。
# 图说图 G5 / 详设 §退路树第一句:Q1「最强便宜档 v4-pro 过门率是否仍 < 40%?」
# 否(≥ 40%、达阈值)→ GO 转第三步铺引擎;是(< 40%)→ 继续往下分流。
# 注:这是退路树自己的「达阈值」线(40%),与 §过门判据里 spike 整体过门率
# ★≥50% 起评 / ≥70% 强信号 是两层口径——退路树用 40% 作 GO/不GO 的分界,
# ≥50%/≥70% 是过门率本身的强弱评级。两者都标 ★、都待校准。
QPASS_GO_MIN = 0.40 # ★ v4-pro 过门率 ≥ 此值 → 达阈值,Q1 走 GO 分支
# ★ Q4 触发线:便宜档「全线崩」的过门率上限。
# 图说图 G5 / 详设 §退路树第三句:Q4「是否便宜档全线 < 20%、而强模型基线 Opus/Fable 能过?」
# 是 → R3 判便宜模型天花板;否 → KEEP 留观。
QPASS_FLOOR_MAX = 0.20 # ★ 所有便宜档过门率 < 此值 → 视为「便宜档全线崩」
# 强基线(Opus/Fable)「能过」的过门率下限。
# 图说:强基线只作「路通不通」的上限基线,n=2~3、不进成本评估。Q4 的「强基线能过」
# = 强基线至少能跑出一款过门的(>0)。不设 ★:这是「上限基线能不能跑通」的存在性判断,
# 不是要给强基线定过门率门(强基线本就不进成本/过门率门评估)。
QPASS_STRONG_BASELINE_MIN = 0.0 # 强基线过门率 > 此值(即 >0,至少 1 款过)→ 视为「强基线能过」
# fail_system 分布里「表现层」桶名 + 「集中」判定阈值。
# 图说图 G2 / 详设 §采集字段:fail_system 区分 表现层 与 资源/合成/订单 三系统(经营品类特有)。
# 桶键名以 A3 的数据结构属主为准——run_record.py 的 `FailSystem` Literal 定义为
# {"resource", "merge", "order", "presentation"}(表现层 = presentation,英文)。本判定器是
# run-record 的消费方,故桶名对齐 A3 的英文字面值,不用图说散文里的中文「表现层」,否则集成时
# Q2 永远命中不到表现层桶(集成接缝:A3 产单条 run-record → 分析侧聚合成矩阵级 fail_system 分布 → 喂本判定器)。
# Q2「失败是否集中在表现层(render / 事件接线)?」、Q3「是否失败集中在某个系统装不出、其余能过?」
# 都读这份分布。「集中」= 该桶占失败总数的比例 ≥ 下面这条线。
PRESENTATION_BUCKET = "presentation" # 表现层桶键名(对齐 A3 run_record.FailSystem 的 Literal 值)
SYSTEM_BUCKETS = ("resource", "merge", "order") # 三系统桶名(资源/合成/订单,同 A3 FailSystem)
# ★ 「集中」阈值:某一类失败占失败总数 ≥ 此比例,视为「集中在该处」。
# 图说没钉死这个比例数(只定性说「集中在表现层」/「集中在某个系统」),这是把定性判据
# 落成可计算门时引入的实现阈值,标 ★ 待校准:太低会把零散失败误判成「集中」,太高会
# 放过真正的集中失败。directional v1 取 0.5(过半失败落在同一处即算集中)。
CONCENTRATION_RATIO = 0.50 # ★ 某桶失败占比 ≥ 此值 → 判「集中」
# ──────────────────────────────────────────────────────────────────────────
# 五出口码(返回值 exit 字段的取值;与图说图 G5 的五个出口框一一对应)
# ──────────────────────────────────────────────────────────────────────────
# go:最强便宜档过门率达阈值 → 转第三步以 Phaser 铺引擎。
# 注:人锚软门(创始人试玩判「有没有肥鹅味」)不可被本判定器替代——四维数字门给 GO,
# 人锚仍须另行过(详设 §过门判据「人锚软门 / 数字硬门双重结构」)。本判定器只裁数字面,
# 故 GO 的 reasons 里会显式提示「人锚仍须过」。
EXIT_GO = "go"
# R1 退向更模板化:判 56% 自治承重太重,改成更多预制表现模板、少 LLM 写。
EXIT_R1_TEMPLATE = "R1_退模板化"
# R2 补骨架再试:判某系统的骨架/driver 缺口,不是范式问题,补该系统骨架后再试。
EXIT_R2_SCAFFOLD = "R2_补骨架"
# R3 便宜模型天花板:换更贵模型重估经济性(撞 ¥3/款 上限就缓行),或整轨缓行。
EXIT_R3_CEILING = "R3_天花板"
# KEEP 留观:既非全线崩、也非天花板 → 据具体数微调前置物再跑(只许微调一轮,不滑成无限调参)。
EXIT_KEEP = "KEEP_留观"
# ── n=5 收敛环(切片二 U3)专属出口码(M3-only;decide_n5 用)──────────────────
# R3_conditional:网关无强档对照(M3-only)时,「便宜/中等档不收敛」无法判是否模型天花板。
# 与 EXIT_R3_CEILING(有强档对照下的真换档)语义不同,故另立:记 conditional no-go、待强档上线复测。
EXIT_R3_CONDITIONAL = "R3_conditional"
# INCONCLUSIVE:仅冷首轮(未经修复/确认)即 ≤1,不足以判 go(防 n=5 首轮裸运气)→ 需再跑一确认轮。
EXIT_INCONCLUSIVE = "INCONCLUSIVE_需确认轮"
# OVER_CAP:已跑轮数超 n≤20 上限(N5_MAX_ROUNDS 轮)→ 拒裁,应早已停环转退路树(跨轮机器闸兜底)。
EXIT_OVER_CAP = "OVER_CAP_超上限"
# n=5 收敛环累计轮数硬上限:4 轮 × 5 款 = n≤20(plan① 切片二「最多追加 3 轮」= Round1 + 3 = 4 轮)。
N5_MAX_ROUNDS = 4
# ──────────────────────────────────────────────────────────────────────────
# 内部小工具(纯函数;只做读取/计算,不抛出业务异常——脏输入按保守缺省处理)
# ──────────────────────────────────────────────────────────────────────────
def _pass_rate(model_stat: dict[str, Any] | None) -> float | None:
"""从单个模型档的聚合里取过门率(pass_rate)。
取值优先级:① 直接给的 pass_rate(0~1);② 由 pass_count / n 现算。
缺失/不可计算 → None(让上层显式区分「没这档数据」与「这档过门率为 0」)。
"""
if not isinstance(model_stat, dict):
return None
pr = model_stat.get("pass_rate")
if isinstance(pr, (int, float)):
return float(pr)
# 退一步:由 pass_count / n 现算(n>0 时)。
n = model_stat.get("n")
pc = model_stat.get("pass_count")
if isinstance(n, (int, float)) and n and isinstance(pc, (int, float)):
return float(pc) / float(n)
return None
def _bucket_share(fail_dist: dict[str, Any] | None, bucket: str) -> float:
"""某个 fail_system 桶的失败占比 = 该桶失败数 / 失败总数;失败总数为 0 → 0.0。"""
if not isinstance(fail_dist, dict) or not fail_dist:
return 0.0
total = 0.0
for v in fail_dist.values():
if isinstance(v, (int, float)) and v > 0:
total += float(v)
if total <= 0:
return 0.0
hit = fail_dist.get(bucket)
hit = float(hit) if isinstance(hit, (int, float)) and hit > 0 else 0.0
return hit / total
def _system_concentrated(fail_dist: dict[str, Any] | None) -> str | None:
"""是否「失败集中在某个系统装不出」(Q3)。是 → 返回该系统桶名;否 → None。
判据:三系统桶(resource/merge/order)里有任意一个的失败占比 ≥ CONCENTRATION_RATIO。
取占比最高且达阈值的那个系统(便于 reasons 指明补哪个系统的骨架)。
"""
best_bucket: str | None = None
best_share = 0.0
for b in SYSTEM_BUCKETS:
s = _bucket_share(fail_dist, b)
if s >= CONCENTRATION_RATIO and s > best_share:
best_bucket, best_share = b, s
return best_bucket
# ──────────────────────────────────────────────────────────────────────────
# 主判定器:decide(stats) -> {exit, reasons[]}
# ──────────────────────────────────────────────────────────────────────────
def decide(stats: dict[str, Any]) -> dict[str, Any]:
"""据矩阵级聚合 stats,按退路树 Q1Q4 三条数字触发线分流到五个出口。
stats schema(矩阵级聚合;字段名对齐详设 §采集指标字段表汇成的矩阵级三张图):
{
# 各模型档的聚合,键 = 模型名(对齐 G3 模型矩阵的角色档名)。
"by_model": {
"deepseek-v4-pro": {"pass_rate": 0.45, "n": 30, "pass_count": 14,
# fail_system 分布:{resource/merge/order/presentation: 失败款数}
"fail_system": {"presentation": 3, "merge": 1},
"cost_rmb_per_pass": 2.1, "repairs_median": 12},
"deepseek-v4-flash": {"pass_rate": 0.30, ...},
"MiniMax-M3": {"pass_rate": 0.55, ...},
"Opus": {"pass_rate": 0.50, ...}, # 强基线,n 小、不进成本
"Fable": {"pass_rate": 0.50, ...},
},
# 可选:跨「便宜档」聚合的 fail_system 分布(Q2 用,判失败是否集中表现层)。
# 缺则由 by_model 里各便宜档的 fail_system 现合(见下)。
"fail_system_overall": {"presentation": 8, "merge": 2, "order": 1},
}
角色档名约定(对齐 G3 图 / 详设 §模型矩阵):
- 最强便宜档(Q1 读它)= STRONG_CHEAP_KEYS 里第一个命中的;默认 'deepseek-v4-pro'
- 便宜档集合(Q4「便宜档全线 <20%」读它)= CHEAP_KEYS 命中的全部档。
- 强模型基线(Q4「强基线能过」读它)= STRONG_BASELINE_KEYS 命中的任意一个。
Returns:
{"exit": <五出口码之一>, "reasons": [<每步触发线的可读裁决依据>, ...]}
reasons 逐条记录走过的 Q 与读到的数字,供调用方落档/展示(纯函数不打印)。
脏/缺输入处理(绝不抛业务异常,保守裁决):
- by_model 缺失或非 dict → 直接 KEEP 留观,reasons 说明「数据不足无法裁,先补齐再跑」。
- 最强便宜档过门率取不到 → 同样 KEEP(数据缺口不当作 GO,也不当作天花板)。
"""
reasons: list[str] = []
by_model = stats.get("by_model") if isinstance(stats, dict) else None
if not isinstance(by_model, dict) or not by_model:
# 数据不足:不能裁 GO(过乐观)也不能裁 no-go(过悲观)→ 留观,让调用方先补齐数据。
reasons.append("stats.by_model 缺失或为空:矩阵级聚合数据不足,无法据数字裁定,先补齐采集再跑。")
return {"exit": EXIT_KEEP, "reasons": reasons}
# ── 解析三类档名(按约定优先级命中;命中即取,便于真跑时改档名映射只动这几处常量)──
strong_cheap = _first_present(by_model, STRONG_CHEAP_KEYS) # 最强便宜档(Q1)
cheap_models = _present_subset(by_model, CHEAP_KEYS) # 便宜档集合(Q4)
strong_baseline = _first_present(by_model, STRONG_BASELINE_KEYS) # 强基线(Q4)
# ── Q1:最强便宜档 v4-pro 过门率是否仍 < QPASS_GO_MIN(★40%)?──
# 图说图 G5 / 详设 §退路树:否(≥40%、达阈值)→ GO;是(<40%)→ 进 Q2。
pr_strong_cheap = _pass_rate(strong_cheap[1]) if strong_cheap else None
if pr_strong_cheap is None:
# 最强便宜档过门率取不到:这是退路树的入口判据,缺则无法分流 → 留观补数据。
reasons.append(
f"Q1 无法判定:最强便宜档({STRONG_CHEAP_KEYS[0]} 等)过门率缺失,"
"无 by_model 该档的 pass_rate/pass_count——先补齐该档采集再跑。")
return {"exit": EXIT_KEEP, "reasons": reasons}
reasons.append(
f"Q1 读最强便宜档 {strong_cheap[0]} 过门率={pr_strong_cheap:.0%}"
f"(GO 阈值 ★≥{QPASS_GO_MIN:.0%},来源图 G5/详设 §退路树第一条触发线)。")
if pr_strong_cheap >= QPASS_GO_MIN:
# GO:达阈值 → 转第三步铺引擎;但人锚软门仍须另行过(本判定器只裁数字面)。
reasons.append(
f"→ GO:最强便宜档过门率 {pr_strong_cheap:.0%} ≥ ★{QPASS_GO_MIN:.0%},达阈值,"
"转第三步以 Phaser 铺引擎。注意:人锚软门(创始人试玩判肥鹅味)不可被数字门替代,仍须过。")
return {"exit": EXIT_GO, "reasons": reasons}
# ── Q2:失败是否集中在表现层(render / 事件接线)?──
# 图说图 G5 / 详设 §退路树第一句:是(集中表现层)→ R1 退模板化(判 56% 自治承重太重)。
fail_overall = stats.get("fail_system_overall")
if not isinstance(fail_overall, dict) or not fail_overall:
# 没给跨便宜档的整体分布 → 由各便宜档的 fail_system 现合(退而求其次)。
fail_overall = _merge_fail_systems(cheap_models)
pres_share = _bucket_share(fail_overall, PRESENTATION_BUCKET)
reasons.append(
f"Q2 读便宜档 fail_system 分布,表现层占比={pres_share:.0%}"
f"(集中阈值 ★≥{CONCENTRATION_RATIO:.0%})。")
if pres_share >= CONCENTRATION_RATIO:
reasons.append(
f"→ R1 退模板化:失败集中在表现层(占比 {pres_share:.0%} ≥ ★{CONCENTRATION_RATIO:.0%}),"
"判 56% 自治承重太重 → 改用更多预制表现模板、少 LLM 写。")
return {"exit": EXIT_R1_TEMPLATE, "reasons": reasons}
# ── Q3:是否失败集中在某个系统装不出、其余系统能过?──
# 图说图 G5 / 详设 §退路树第二句:是 → R2 补骨架再试(判骨架/driver 缺口,不是范式问题)。
sys_bucket = _system_concentrated(fail_overall)
if sys_bucket is not None:
share = _bucket_share(fail_overall, sys_bucket)
reasons.append(
f"Q3 命中:失败集中在系统 {sys_bucket}(占比 {share:.0%} ≥ ★{CONCENTRATION_RATIO:.0%}),"
"其余系统能过。")
reasons.append(
f"→ R2 补骨架:判 {sys_bucket} 系统的骨架/driver 缺口(非范式问题),"
"补该系统骨架后再试。")
return {"exit": EXIT_R2_SCAFFOLD, "reasons": reasons}
reasons.append(
f"Q3 不命中:失败未集中在单一系统(三系统桶 {SYSTEM_BUCKETS} 占比均 < ★{CONCENTRATION_RATIO:.0%})。")
# ── Q4:是否便宜档全线 < QPASS_FLOOR_MAX(★20%)、而强模型基线 Opus/Fable 能过?──
# 图说图 G5 / 详设 §退路树第三句:是 → R3 便宜模型天花板;否 → KEEP 留观。
cheap_rates = [(name, _pass_rate(s)) for name, s in cheap_models]
cheap_rates_known = [(n, r) for n, r in cheap_rates if r is not None]
all_cheap_floor = bool(cheap_rates_known) and all(
r < QPASS_FLOOR_MAX for _, r in cheap_rates_known)
pr_baseline = _pass_rate(strong_baseline[1]) if strong_baseline else None
baseline_passes = pr_baseline is not None and pr_baseline > QPASS_STRONG_BASELINE_MIN
# 拼 Q4 的可读裁决依据:便宜档各自过门率 + 强基线过门率(缺则显式标「缺」)。
cheap_desc = ", ".join(f"{n}={r:.0%}" for n, r in cheap_rates_known) or "(便宜档过门率缺失)"
baseline_name = strong_baseline[0] if strong_baseline else "(缺)"
baseline_desc = (
f"{pr_baseline:.0%}(能过 = >{QPASS_STRONG_BASELINE_MIN:.0%})"
if pr_baseline is not None else "缺失"
)
reasons.append(
f"Q4 读便宜档全线={cheap_desc}(全线崩阈值 ★<{QPASS_FLOOR_MAX:.0%});"
f"强基线 {baseline_name} 过门率={baseline_desc}")
if all_cheap_floor and baseline_passes:
reasons.append(
f"→ R3 天花板:便宜档全线 < ★{QPASS_FLOOR_MAX:.0%} 而强基线能过 → 判便宜模型天花板,"
"换更贵模型重估经济性(撞 ¥3/款 上限就缓行),或整轨缓行。")
return {"exit": EXIT_R3_CEILING, "reasons": reasons}
# ── KEEP:既非全线崩、也非天花板 → 据具体数微调前置物再跑(只许微调一轮)──
reasons.append(
"→ KEEP 留观:既非全线崩(便宜档非全线 < ★20% 或强基线也没过)、也非单系统/表现层集中失败,"
"据具体数微调前置物后再跑一轮(铁律:只许微调一轮,绝不滑成无限调参)。")
return {"exit": EXIT_KEEP, "reasons": reasons}
# ──────────────────────────────────────────────────────────────────────────
# decide_n5 —— n=5 收敛环判定(切片二 U3;M3-only,覆写 decide() 的 Q1 入口与 R3 语义)
#
# 为什么不复用 decide():decide() 是「便宜档对照导向」——其 Q1 入口先找 STRONG_CHEAP_KEYS
# (deepseek-v4-pro),M3-only 的数据里没有该档 → 第 195 行 return EXIT_KEEP(数据不足),
# 根本走不到 Q2/Q3 的集中度分流。故 decide_n5 自走一套:GO 判据改成 KTD1「末轮≤1 且≥2轮」
# (压 n=5 首轮裸运气),R1/R2 分流复用本模块纯 helper(_bucket_share/_system_concentrated +
# CONCENTRATION_RATIO),R3 据有无强档对照分流(无 → conditional 待复测;有 → 天花板真换档)。
# 读 RunRecord 用 getattr 鸭子类型,不 import run_record(decide_n5 保持纯函数、零依赖)。
# ──────────────────────────────────────────────────────────────────────────
def _is_fail(rec: Any) -> bool:
"""一条 run 是否「失败」:decision≠accept 未 finished 未过门(任一即失败)。"""
return (getattr(rec, "decision", None) != "accept"
or not getattr(rec, "finished", False)
or not getattr(rec, "pass_gate", False))
def _round_fail_count(round_records: list[Any]) -> int:
"""一轮 5 款里的失败款数。"""
return sum(1 for r in round_records if _is_fail(r))
def _fail_system_dist(rounds: list[list[Any]]) -> dict[str, float]:
"""全部轮里所有失败款的 fail_system 分布(桶名→失败数),喂 _bucket_share/_system_concentrated。"""
dist: dict[str, float] = {}
for rd in rounds:
for r in rd:
if _is_fail(r):
fs = getattr(r, "fail_system", None)
if fs:
dist[fs] = dist.get(fs, 0.0) + 1.0
return dist
def _infra_flag_dist(round_records: list[Any]) -> dict[str, int]:
"""一轮内各 infra_flag 出现次数(编排层归因;鸭子类型读 rec.infra_flags,decide_n5 保持零依赖)。
observe-only:只喂 decide_n5 的 reasons 打印(治 F-2 R1 生产盲区),**绝不参与分流/改出口**。
统计的是全款(不限失败款):软预算软停等可能发生在最终过门的款上,也是有价值的编排层归因信号。
"""
dist: dict[str, int] = {}
for r in round_records:
for f in (getattr(r, "infra_flags", None) or []):
dist[f] = dist.get(f, 0) + 1
return dist
def decide_n5(rounds: list[list[Any]], *, has_strong_baseline: bool) -> dict[str, Any]:
"""n=5 收敛环判定。rounds = 各轮的 RunRecord 列表(有序,Round1 在前)。
出口(沿用本模块既有常量 + R3_conditional):
go / KEEP_留观 —— 收敛(都判 tier2 go;KEEP = go·留观该款微调)
R1_退模板化 / R2_补骨架 / R3_conditional —— 未收敛退路
INCONCLUSIVE_需确认轮 —— 仅冷首轮 ≤1,需再跑确认轮(非终判)
OVER_CAP_超上限 —— 轮数超 n≤20 上限,拒裁(跨轮机器闸兜底)
"""
reasons: list[str] = []
if not rounds:
return {"exit": EXIT_INCONCLUSIVE, "reasons": ["无任何轮次数据,需先跑 Round1。"]}
if len(rounds) > N5_MAX_ROUNDS:
return {"exit": EXIT_OVER_CAP, "reasons": [
f"已跑 {len(rounds)} 轮 > 上限 {N5_MAX_ROUNDS} 轮(n≤20),应早已停环转退路树;拒裁(跨轮机器闸)。"]}
fails = [_round_fail_count(rd) for rd in rounds]
last = fails[-1]
reasons.append(f"各轮失败数={fails};收敛判据 KTD1 = 末轮≤1 且 ≥2 轮(压 n=5 首轮裸运气)。")
# ── 编排层 infra 归因(observe-only;治 F-2 R1 生产盲区)────────────────────────────────
# 把各轮的 infra_flags 分布打进 reasons(如「r1 infra 分布={design_team_degraded:2}」),让主持人/
# 机器一眼看出「两败局是否集中在设计团队超时降级路」这类编排层原因——过去 fail_system 空时退路树
# 误判「分散」出 R3_conditional,归因只能人工翻日志。**只打印、不分支、不改任何出口**(纯观测穿线)。
# 放在收敛/未收敛分流之前 → 无论走哪个出口,reasons 都带上分布行(有 flag 的轮才打,无则不加噪)。
for _idx, _rd in enumerate(rounds, start=1):
_idist = _infra_flag_dist(_rd)
if _idist:
_shown = ", ".join(f"{k}:{v}" for k, v in _idist.items())
reasons.append(f"r{_idx} infra 分布={{{_shown}}}(编排层归因,observe-only,不参与分流)。")
# ── 收敛分支:末轮 ≤1 ──
if last <= 1:
if len(rounds) < 2:
reasons.append("仅 Round1 即 ≤1(冷首轮、未经修复/确认)→ 不判 go,需再跑一确认轮(同变体不改码)。")
return {"exit": EXIT_INCONCLUSIVE, "reasons": reasons}
if last == 0:
reasons.append("末轮 0 错且已 ≥2 轮 → 收敛,判 go(数字面;人锚软门「肥鹅味」仍须创始人另过)。")
return {"exit": EXIT_GO, "reasons": reasons}
reasons.append("末轮 1 错且已 ≥2 轮 → 收敛,判 KEEP_留观(go·留观该 1 款微调,对应 plan① 收敛需微调)。")
return {"exit": EXIT_KEEP, "reasons": reasons}
# ── 未收敛分支:末轮 >1 → 据 fail_system 集中度分流(集中优先于换档)──
dist = _fail_system_dist(rounds)
pres = _bucket_share(dist, PRESENTATION_BUCKET)
reasons.append(f"末轮失败 {last}>1、不收敛;全轮失败 fail_system 分布={dist},表现层占比={pres:.0%}(集中线 ★≥{CONCENTRATION_RATIO:.0%})。")
if pres >= CONCENTRATION_RATIO:
reasons.append("→ R1_退模板化:失败集中表现层 → 判自治承重太重,改更多预制模板、少 LLM 写"
"(spike 已证 M3 有表现层能力,故先收窄模板、非断言模型不行而换档)。")
return {"exit": EXIT_R1_TEMPLATE, "reasons": reasons}
sysb = _system_concentrated(dist)
if sysb is not None:
reasons.append(f"→ R2_补骨架:失败集中系统 {sysb}({_bucket_share(dist, sysb):.0%}≥★{CONCENTRATION_RATIO:.0%})、其余能过 → 补该系统平台侧脚手架后再试。")
return {"exit": EXIT_R2_SCAFFOLD, "reasons": reasons}
# 无任何集中:据有无强档对照分流
if has_strong_baseline:
reasons.append("→ R3_天花板:失败分散无集中、且有强档对照(强档能过)→ 判便宜/中等档天花板,换更贵档。")
return {"exit": EXIT_R3_CEILING, "reasons": reasons}
reasons.append("→ R3_conditional:失败分散无集中、且网关无强档对照(Opus/Fable/GLM 缺)→ 当前不可判换档,"
"记 conditional no-go,待强档上线跑一次对照再复判。")
return {"exit": EXIT_R3_CONDITIONAL, "reasons": reasons}
def decide_n5_from_jsonl(path: str, *, has_strong_baseline: bool) -> dict[str, Any]:
"""读 JSONL 台账(batch_run 落的 RunRecord 行),按 run_id 里的 r{N} 轮号分组成 rounds,调 decide_n5。
供 U5 S6 的 CLI 入口(`python -m worker.fallback_tree --n5 --in ... [--no-strong-baseline]`),
取本环 go/no-go 唯一权威——不走 aggregate.py 的旧 decide()(它对 M3-only Q1 早退、给假 KEEP)。
"""
import re as _re
try: # script 上下文(cwd=worker/)
from run_record import RunRecord # type: ignore
except ImportError: # package 上下文(-m worker.fallback_tree)
from worker.run_record import RunRecord # type: ignore
by_round: dict[str, list[Any]] = {}
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
rec = RunRecord.from_jsonl_line(line)
m = _re.search(r"(?:^|-)(r\d+)(?:-|$)", rec.run_id or "")
key = m.group(1) if m else "r1" # 无 r{N} 前缀 → 全归一轮(兜底)
by_round.setdefault(key, []).append(rec)
if not by_round:
return {"exit": EXIT_INCONCLUSIVE, "reasons": [f"台账 {path} 无记录。"]}
ordered = [by_round[k] for k in sorted(by_round, key=lambda x: int(x[1:]))]
out = decide_n5(ordered, has_strong_baseline=has_strong_baseline)
out["reasons"].insert(0, f"读台账 {path}:{len(ordered)} 轮、共 {sum(len(r) for r in ordered)} 款。")
return out
# ──────────────────────────────────────────────────────────────────────────
# 角色档名映射常量(对齐 G3 模型矩阵的四角色档;真跑改档名只动这里)
# ──────────────────────────────────────────────────────────────────────────
# 最强便宜档(Q1 读它):图 G3「强便宜档 deepseek-v4-pro」。按序命中第一个存在的档。
STRONG_CHEAP_KEYS = ("deepseek-v4-pro", "v4-pro")
# 便宜档集合(Q4「便宜档全线」读它):强便宜档 + 主力便宜档(图 G3 v4-pro / v4-flash)。
CHEAP_KEYS = ("deepseek-v4-pro", "v4-pro", "deepseek-v4-flash", "v4-flash")
# 强模型基线(Q4「强基线能过」读它):图 G3「上限基线 Opus/Fable」(仅验上限,不进成本)。
STRONG_BASELINE_KEYS = ("Opus", "Fable", "opus", "fable")
def _first_present(by_model: dict[str, Any], keys: tuple[str, ...]) -> tuple[str, dict] | None:
"""按 keys 顺序在 by_model 里找第一个存在的档,返回 (命中键名, 该档聚合 dict);全缺 → None。"""
for k in keys:
v = by_model.get(k)
if isinstance(v, dict):
return (k, v)
return None
def _present_subset(by_model: dict[str, Any], keys: tuple[str, ...]) -> list[tuple[str, dict]]:
"""取 by_model 里命中 keys 的所有档(去重,保持首次出现顺序),返回 [(键名, 聚合 dict), ...]。"""
out: list[tuple[str, dict]] = []
seen: set[str] = set()
for k in keys:
if k in seen:
continue
v = by_model.get(k)
if isinstance(v, dict):
out.append((k, v))
seen.add(k)
return out
def _merge_fail_systems(models: list[tuple[str, dict]]) -> dict[str, float]:
"""把若干便宜档各自的 fail_system 分布按桶相加,合成一份跨便宜档的整体分布。
仅当 stats 未给 fail_system_overall 时退而用此现合。桶名沿用采集字段表
(resource/merge/order/presentation),其余未知桶原样累加(不丢弃,便于 reasons 追溯)。
"""
merged: dict[str, float] = {}
for _, s in models:
dist = s.get("fail_system") if isinstance(s, dict) else None
if not isinstance(dist, dict):
continue
for bucket, cnt in dist.items():
if isinstance(cnt, (int, float)) and cnt > 0:
merged[bucket] = merged.get(bucket, 0.0) + float(cnt)
return merged
# ──────────────────────────────────────────────────────────────────────────
# __main__ 自测块:构造五组 stats 各触发一个出口,内联断言覆盖五出口(无网络/无 agentscope)
# 校验:python3 -c 'from worker import fallback_tree'(import 干净)+ python3 fallback_tree.py(自测)
# ──────────────────────────────────────────────────────────────────────────
if __name__ == "__main__": # pragma: no cover —— 本地自测,五出口各一次
# ── CLI 模式(U5 S6 取 go/no-go 裁决):python -m worker.fallback_tree --n5 --in <台账> [--no-strong-baseline] ──
import sys as _sys
if "--n5" in _sys.argv:
import argparse
ap = argparse.ArgumentParser(description="n=5 收敛环 go/no-go 裁决(读 JSONL 台账,按 r{N} 分轮)")
ap.add_argument("--n5", action="store_true")
ap.add_argument("--in", dest="inp", required=True, help="收敛环台账 JSONL 路径")
ap.add_argument("--no-strong-baseline", action="store_true",
help="网关无强档对照(M3-only 默认传此 → R3 走 conditional)")
args = ap.parse_args()
verdict = decide_n5_from_jsonl(args.inp, has_strong_baseline=not args.no_strong_baseline)
print(f"\n=== n=5 收敛环裁决 ===\nexit = {verdict['exit']}")
for _r in verdict["reasons"]:
print(f" · {_r}")
_sys.exit(0)
def _check(label: str, stats: dict, expect: str) -> None:
"""跑一次 decide 并断言出口符合预期;打印走过的 reasons 便于人工核对触发线。"""
out = decide(stats)
got = out["exit"]
ok = "OK" if got == expect else "FAIL"
print(f"[{ok}] {label}: exit={got}(期望 {expect})")
for r in out["reasons"]:
print(f" · {r}")
assert got == expect, f"{label}: 期望 {expect},实得 {got}"
# ① GO:最强便宜档 v4-pro 过门率 45% ≥ ★40% → go(达阈值,人锚另判)。
_check(
"GO · v4-pro 45% 达阈值",
{"by_model": {
"deepseek-v4-pro": {"pass_rate": 0.45, "n": 30, "pass_count": 14},
"deepseek-v4-flash": {"pass_rate": 0.33, "n": 30},
"MiniMax-M3": {"pass_rate": 0.55, "n": 30},
}},
EXIT_GO,
)
# ② R1 退模板化:v4-pro 30% < 40%,失败 8/(8+2+1) ≈ 73% 集中 presentation(表现层)→ R1。
_check(
"R1 · v4-pro 30% 且失败集中表现层",
{"by_model": {
"deepseek-v4-pro": {"pass_rate": 0.30, "n": 30},
"deepseek-v4-flash": {"pass_rate": 0.25, "n": 30},
},
"fail_system_overall": {"presentation": 8, "merge": 2, "order": 1}},
EXIT_R1_TEMPLATE,
)
# ③ R2 补骨架:v4-pro 30% < 40%,表现层不集中,失败 7/(7+1+1) ≈ 78% 集中 merge 系统 → R2。
_check(
"R2 · 失败集中在 merge 系统",
{"by_model": {
"deepseek-v4-pro": {"pass_rate": 0.30, "n": 30},
"deepseek-v4-flash": {"pass_rate": 0.28, "n": 30},
},
"fail_system_overall": {"merge": 7, "presentation": 1, "order": 1}},
EXIT_R2_SCAFFOLD,
)
# ④ R3 天花板:便宜档全线 < ★20%(15%/10%),且强基线 Opus 能过(50%>0),失败分散不集中 → R3。
_check(
"R3 · 便宜档全线 <20% 且强基线能过",
{"by_model": {
"deepseek-v4-pro": {"pass_rate": 0.15, "n": 30},
"deepseek-v4-flash": {"pass_rate": 0.10, "n": 30},
"Opus": {"pass_rate": 0.50, "n": 3},
},
# 失败分散到四桶,均不过半,不触发 Q2/Q3。
"fail_system_overall": {"presentation": 6, "resource": 6, "merge": 6, "order": 6}},
EXIT_R3_CEILING,
)
# ⑤ KEEP 留观:v4-pro 30% < 40%,失败分散(无集中),便宜档非全线 <20%(flash 30%)→ KEEP。
_check(
"KEEP · 既非集中也非全线崩",
{"by_model": {
"deepseek-v4-pro": {"pass_rate": 0.30, "n": 30},
"deepseek-v4-flash": {"pass_rate": 0.30, "n": 30},
"Opus": {"pass_rate": 0.50, "n": 3},
},
"fail_system_overall": {"presentation": 5, "resource": 5, "merge": 5, "order": 5}},
EXIT_KEEP,
)
# ⑥ 兜底:数据不足 → KEEP(不当 GO、不当 no-go)。
_check("KEEP · 数据不足兜底", {"by_model": {}}, EXIT_KEEP)
print("\n[fallback_tree] 五出口 + 兜底自测全部通过。")
# ──────────────────────────────────────────────────────────────────────
# n=5 收敛环判定 decide_n5 自测(切片二 U3;M3-only · ≥2轮收敛 · R3_conditional)
# ──────────────────────────────────────────────────────────────────────
try:
from run_record import RunRecord # script 上下文(python3 fallback_tree.py,cwd=worker/)
except ImportError: # package 上下文(python -m worker.fallback_tree)
from worker.run_record import RunRecord
def _mk(ok: bool, fail_system: str | None = None) -> RunRecord:
"""构造一条 RunRecord:ok=True → 过门款(accept/finished/pass);ok=False → 失败款。"""
if ok:
return RunRecord(run_id="cv-r1-MiniMax-M3-面包-0", model="MiniMax-M3",
stage="play", brief_variant="面包", pass_gate=True, repairs=0,
decision="accept", finished=True)
return RunRecord(run_id="cv-r1-MiniMax-M3-面包-0", model="MiniMax-M3",
stage="build", brief_variant="面包", pass_gate=False, repairs=1,
decision="fix", finished=False, fail_system=fail_system)
def _round(n_ok: int, n_fail: int, fail_system: str | None = None) -> list:
"""造一轮:n_ok 个过门 + n_fail 个失败(失败的 fail_system 统一打 fail_system)。"""
return [_mk(True) for _ in range(n_ok)] + [_mk(False, fail_system) for _ in range(n_fail)]
def _check_n5(label: str, rounds: list, has_strong: bool, expect: str) -> None:
out = decide_n5(rounds, has_strong_baseline=has_strong)
got = out["exit"]
ok = "OK" if got == expect else "FAIL"
print(f"[{ok}] n5 {label}: exit={got}(期望 {expect})")
assert got == expect, f"{label}: 期望 {expect},实得 {got}"
# ⓐ 冷首轮 ≤1 单独 → INCONCLUSIVE(防裸运气)
_check_n5("冷首轮 4/5 单独", [_round(4, 1)], False, EXIT_INCONCLUSIVE)
# ⓑ 2 轮均干净 → go
_check_n5("2 轮均 5/5", [_round(5, 0), _round(5, 0)], False, EXIT_GO)
# ⓒ 修复后轮干净(R1 失败2、R2 干净)→ go
_check_n5("修复后干净", [_round(3, 2), _round(5, 0)], False, EXIT_GO)
# ⓓ 2 轮、末轮 1 错 → KEEP_留观
_check_n5("末轮 4/5(≥2轮)", [_round(5, 0), _round(4, 1)], False, EXIT_KEEP)
# ⓔ 全程不收敛、失败集中表现层 → R1
_check_n5("不收敛·表现层集中", [_round(1, 4, "presentation"), _round(1, 4, "presentation")], False, EXIT_R1_TEMPLATE)
# ⓕ 全程不收敛、失败集中 order 系统 → R2
_check_n5("不收敛·order 集中", [_round(1, 4, "order"), _round(1, 4, "order")], False, EXIT_R2_SCAFFOLD)
# ⓖ 不收敛、失败分散、无强档 → R3_conditional
_check_n5("不收敛·分散·无强档", [_round(1, 4, None), _round(1, 4, None)], False, EXIT_R3_CONDITIONAL)
# ⓗ 不收敛、失败分散、有强档 → R3_天花板(前瞻支)
_check_n5("不收敛·分散·有强档", [_round(1, 4, None), _round(1, 4, None)], True, EXIT_R3_CEILING)
# ⓘ 超 4 轮(n>20)→ OVER_CAP 拒裁
_check_n5("超 4 轮拒裁", [_round(1, 4) for _ in range(5)], False, EXIT_OVER_CAP)
print("\n[fallback_tree] decide_n5(n=5 收敛环)自测全部通过。")