@@ -0,0 +1,119 @@
---
type: design
node_id: design:fix-diagnosis-tree-data-link
title: "修复诊断 tree_data 断链 bug( TRM4→TRM5 迁移 regression) "
date: 2026-07-15
---
# 修复诊断 tree_data 断链 bug( TRM4→TRM5 迁移 regression)
> 诊断编排全程传 `tree_data={}`,导致 ground_truth 恒空、error_type 归因 100% 坍缩成 extraction_failure。本设计接通断掉的树加载环,补齐 P5 fail-loud,双注入点(离线诊断 + 训练循环)一并修复。
>
> **状态**:已含 Codex 独立审查修订(2 Critical + 8 Important 全部核验并采纳;展平实现从"复用 to_dict"改为"遍历 json", level 改由遍历深度赋值,补视频覆盖 fail-loud,重冻 test 成员变化诚实化)。
## 1. 根因(已坐实)
| 环节 | 位置 | 事实 |
|------|------|------|
| CLI 注入空树 | `app/harness/video_split_cli.py:335` | `tree_data={}` ,注释谎称"由诊断管线内部按需加载" |
| 训练循环同病 | `app/harness/runner.py:2180` | `tree_data={}` + 同一句假注释 |
| 编排层无加载 | `core/evolution/diagnose.py:2068-2074` | `if tree_data and "nodes"...` → `{}` 为假 → else 分支 `tree_data_by_video={}` , **根本无加载逻辑** |
| ground_truth 恒空 | `diagnose.py:736,755` | `{}.get("nodes",{})` → node card 取空 → `""` |
| span 评估无参照 | `diagnose.py:509-515` | judge 看不到"应提取什么", `extraction_completeness` 系统性偏低,解析失败还默认 0.0 |
| 归因瀑布短路 | `diagnose.py:928` | `avg_completeness < 0.5` 恒真 → **100% extraction_failure ** |
实测:82 个 T2 全部 extraction_failure, evolution_target 全 tool, 48 格多样性退化成 11 格(error_type 维坍缩成单值)。`judge_missed_nodes` 的 `tree_content` 同样为空,search_failure 分支永不触发。
## 2. Regression 溯源:TRM4 无此 bug
| 层面 | TRM4(正常) | TRM5(坏了) |
|------|------|------|
| tree.json 结构 | 扁平 `{"nodes": {id: {node_id,level,time_range,parent_id,children_ids,card}}}` | 嵌套 `{"metadata", "roots": [{id,card,time_range,children}]}` |
| tree_cache 填充 | `diagnose.py:1677` `_load_json(tree.json)` 直接得 nodes | `tree_data={}` ,填充逻辑被删 |
| 诊断拿 ground_truth | ✅ `_load_json(...)["nodes"]` 直接可用 | ❌ 恒空 |
**双重 regression ** :(a) 建树模块重写把产物格式从扁平 nodes 演进成嵌套 roots(合理架构升级);(b) 诊断代码迁移时既未适配新格式、又把加载逻辑删成空 dict。因此 TRM5 需要一个 TRM4 不需要的**格式桥接展平器**( roots → nodes)。
## 3. 影响范围
| 维度 | 是否污染 | 原因 |
|------|---------|------|
| tier 分层 T0/T1/T2/uncertain | ✅ 干净 | `classify_defect_vs_lapse` **不吃 tree_data ** |
| floor_k / test 代表性 | ✅ 干净 | 按 task_type 总数,与 error_type 无关 |
| error_type 归因 | ❌ 全坍缩 | ground_truth 恒空 |
| evolution_target 路由 | ❌ 全 tool | error_type 的下游派生 |
| 48 格多样性覆盖 | ⚠️ 退化 | error_type 维失效 → 实际仅按 task_type 单维 |
| `judge_missed_nodes` | ⚠️ 退化 | tree_content 空 |
结论:已冻结 `pools.json` 的 tier/floor/代表性**可信**,仅 error_type 维及其下游被污染。
## 4. 修复设计
### 4.1 单元拆分(单一职责)
| 单元 | 位置 | 职责 | 依赖 |
|------|------|------|------|
| 树展平器 | `app/harness/tree_nodes.py` (新) | `load_tree_nodes(store_dir, video_id) -> dict` : **递归遍历 tree.json 的 `roots` (嵌套 dict) **,按遍历深度赋 `level` ( root=1/child=2/孙=3),抽取每节点 `id/card/time_range` ,组装成扁平 `{"nodes": {node_id: {card, level, time_range}}}` | 只读文件 + json( **不走 TreeIndex 对象层**,见 §4.2 核验) |
| 离线注入 | `app/harness/video_split_cli.py` ( `build_diagnosis_deps` /`run_pipeline` ) | 由 `wrong_ids` → 涉及 video 去重加载成 `{video_id: {"nodes":...}}` ,填充 `DiagnosisDeps.tree_data` ( `baseline_diagnosis` 仅透传) | ← 展平器 |
| 训练注入 | `app/harness/runner.py` ( `_run_diagnosis` ) | 由 batch `question_ids` → 涉及 video 同法加载注入,删 `tree_data={}` 假注释 | ← 展平器 |
### 4.2 关键决策
| 边界 | 决策 | 理由 |
|------|------|------|
| 加载职责 | **app 层 ** ( video_split_cli / runner),core 只消费 dict | Clean Architecture: core 不碰文件系统;`run_diagnosis` else 分支已支持 `{video_id:...}` 形态,core 零改动 |
| 展平实现 | **递归遍历 tree.json `roots` dict ** (原始 json,card 已是 dict 直接取),不走对象层 | 核验:仅 `L1Node` 有 `to_dict` ( `index.py:260` ),L2/L3 是其内部闭包;且 to_dict 输出无 `level` 、L3 无 `time_range` (用 `timestamp` )。遍历 json 更省且零改建树模块 |
| level 字段 | **由遍历深度直接赋值 ** ( root=1/child=2/孙=3),不解析 node_id | node_id 累积式 `..._L1_..._L2_..._L3_` ,正则首匹配会把 L2/L3 误判成 1( Codex I7) |
| 预加载范围 | 只加载被诊断题涉及的 video,按 video 缓存去重 | YAGNI; 236 错题涉及 <200 视频,避免加载无关树 |
| fail-loud | tree.json 缺失 → `FileNotFoundError` (沿用 `factory.py:87` 先例);展平后 nodes 为空 → `ValueError` ;**本次诊断涉及的每个 video 必须被 tree_data 覆盖**, `run_diagnosis` 的 `.get(vid, {})` 静默回退(`diagnose.py:2144-2145` )改为缺失即 raise | 补 P5:空树/漏加载本应报错,不再静默退化 |
| 诊断瀑布本身 | **不动 ** ( algo §4.7 #7 ) | 修复只接通输入,不改归因逻辑 |
> **L3 无 `time_range` 的已知次要行为**: `node.to_dict` 语义下 L3 节点用 `timestamp`; `_load_tree_content` 取 `time_range` 时 L3 退化为默认 `[0,0]`,仅影响 `judge_missed_nodes` 文本里 L3 的时间显示,不影响 ground_truth( card)。展平器可选:从 L3 `timestamp` 合成 `[timestamp, timestamp]`。
## 5. 非功能性四维
| 维度 | 结论 |
|------|------|
| 持久化 | 展平器纯内存只读,不落盘;诊断结果仍逐题 upsert(不变) |
| 幂等性 | 展平确定性(同 tree.json → 同 nodes);诊断 upsert 幂等(不变) |
| 断点续跑 | `done_question_ids` 续跑机制不变;fingerprint 因代码改动而变 → 全量重跑一次 |
| 原子性 | 无新写操作;pools.json 重冻仍走既有原子写 |
## 6. 重跑与重冻衔接
代码改动 → git short SHA 变 → `diag_fingerprint` 变 → 全量重跑 236 题诊断。
- `classify_defect_vs_lapse` / evidence / bias / skill judge 输入不含 tree_data → **预期 Redis 缓存命中,tier 分层 T2/T1 应不变 ** 。但这是"大概率"而非"必然":缓存键内容未逐字段核证,且存在非 tree_data 的不稳定源(如 C3 异常吞并 attribution, `diagnose.py:2186` )。**重跑后须实测校验 T2=82/T1=152 是否保持**,偏差需归因。
- `evaluate_span` ( ground_truth 由空变有)+ `judge_missed_nodes` ( tree_content 由空变有)缓存失效 → 重算(约半数 LLM 调用,~1h)。
- 诊断完成后重跑 Phase 2 重新冻结覆盖 `pools.json` : **error_type 恢复判别力会改变多样性阶段的选择顺序**(cells 按 `(task_type, error_type)` 排序,`split_selection.py:139/193` )→ trainval 组成变 → **test 补集成员随之变化,不保证稳定 ** 。floor 与代表性 ε 约束仍保证 test **代表性合格 ** ,但具体成员会变——用户已接受重冻覆盖,这是预期结果而非风险。
## 7. 测试策略
| 测试 | 类型 | 验证点 |
|------|------|--------|
| 展平器正确性 | unit | 真实 tree.json → `{"nodes":{...}}` ,节点数=递归总数,node 含 card(dict)/level/time_range |
| 展平器 level 赋值 | unit | 遍历深度 → level 1/2/3,与节点嵌套层级一致(不依赖 node_id 解析) |
| 展平器 fail-loud | unit | tree.json 缺失 → FileNotFoundError;空树/空 nodes → ValueError |
| 视频覆盖校验 | unit | 诊断涉及的 video 未被 tree_data 覆盖 → raise(不静默回退) |
| ground_truth 接通 | integration | 对真实 T2 样本注入真实树,断言 `evaluate_span` 收到**非空 ground_truth**(充分条件);**观察** error_type 恢复多值(软验收——分布是否脱离单值取决于 judge,非硬保证) |
| core 依赖方向 | 结构检查 | `core/evolution/diagnose.py` 不 import `app.tree` |
## 8. 算法保真声明
本修复触及核心算法 * * §4.7 #7 诊断瀑布**:仅接通其输入(tree_data),**不改** `attribute_error` 归因分支、severity 函数或 defect/lapse 判定逻辑。参考 TRM4 `core/harness/diagnose.py` 的 tree_cache 语义对照,确保 TRM5 诊断拿到与 TRM4 一致的 ground_truth。
## 9. 被拒方案
| 方案 | 拒因 |
|------|------|
| 复用 `TreeIndex.load_json` + `node.to_dict` 走对象层 | 核验否决:仅 `L1Node` 有 `to_dict` ( `index.py:260` ),L2/L3 为其内部闭包;输出无 `level` 、L3 无 `time_range` ,补了也要后处理 |
| 补 L2/L3Node.to_dict 再走对象层 | 改核心建树模块 `index.py` (推理/save_json/建树全链路依赖,algo §4.7 #1 -3);为诊断读取需求反向改生产方接口,违反 YAGNI |
| 让 core 诊断直接吃 `TreeEnvironment` 对象 | 破坏依赖方向(core 依赖 app.tree);且需构建 env(要 frames_dir)过重 |
| 保留现有 pools.json 不重冻 | train 多样性仍基于污染的 error_type,违背修复初衷 |
| 只修离线诊断、不修 runner | 训练循环 backward 每轮复现坍缩,进化盲目只改 tool |
## 关联
- 影响指标:`metric:split-cell-coverage` ( 48 格覆盖)、`metric:split-signal-tier-distribution`
- 数据表:`schema:baseline-diagnosis`
- 上游设计:`design:results-driven-video-split`
- 参考实现:TRM4 `core/harness/diagnose.py:1677` ( tree_cache 填充)