把「设计团队超时降级 / 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>
620 lines
38 KiB
Python
620 lines
38 KiB
Python
"""fallback_tree.py —— tier2 0号 spike 退路树五出口判定器(纯函数,零副作用)。
|
||
|
||
职责(对 docs/architecture/架构/生成引擎/tier2细节图说-G-spike-runbook.md 图 G5「退路树」
|
||
与 tier2实现详设.md §退路树):
|
||
spike 在 mini-desktop 跑完一整张模型矩阵后,把 run 级采集字段汇成「矩阵级聚合」(过门率 /
|
||
¥/成功款 / 收敛中位数 / fail_system 分布);本模块吃这份聚合,按 Q1–Q4 三条数字触发线把它
|
||
纵向分流成五个确定出口,让 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,按退路树 Q1–Q4 三条数字触发线分流到五个出口。
|
||
|
||
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 收敛环)自测全部通过。")
|