Files
Video-Tree-TRM5/research-wiki/plans/2026-07-16-preflight-wp3-train-loop.md

32 KiB
Raw Permalink Blame History

WP3 训练循环与进化引擎 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use subagent-driven-development to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. 前置依赖:WP1(模板已迁移,进化引擎可运行)+ WP4(cache_salt 能力已就绪)必须先完成。

Goal: 修复训练循环与进化引擎的 9 处缺陷,使诊断拿到真实轨迹、早停按 epoch 语义、微型题型不崩、崩溃可幂等续跑、进化 patch 不破坏冻结区、降级信号不驱动错误进化、跨 epoch 评估真实重采样。

Architecture: 诊断经 StepsJsonRunLog 从 steps_json 重建轨迹(算法 #7 恢复);早停计数单位 step→epoch;可训练性预检在 gate 建立前剔除微型题型;_run_step 幂等(先 DELETE 再写);patch 冻结区检查整个 target 跨度(算法 #8 加固);降级/未判定题按 lapse 保守分流;训练推理用 run_id 作 cache_saltrun_id 已含 epoch,天然跨 epoch 重采样)。

Tech Stack: Python 3.11、asyncio、SQLite、pytest。

设计源research-wiki/designs/2026-07-16-preflight-fixes-design.md §6-7


关键锚点(实现前必读)

用途 位置
训练主流程 app/harness/runner.py:789-859 train_setup_train_run:865-879(预检插入点 L874 前);_run_step:1001-1026run_id L1011、诊断 L1019、无 DELETE
早停 app/harness/runner.py:291-316 _should_early_stopL315 += steps_this_epoch);_TrainState:95-121steps_since_best_improved L118);_maybe_promote_best:1587(置 0
诊断调用 app/harness/runner.py:2163-2196 _run_diagnosisRunLogImpl L2173 未包 StepsJsonRunLog);DiagnosisResult degraded_count 未被引用
gate 刷新 app/harness/runner.py:1833-1879 _refresh_gate_laddersave L1878 → set observed L1879);checkpoint 落盘晚在 train L837
holdout app/harness/runner.py:1881-1921 _holdout_four_way_pick_mixed_best:1923-1963_eval_version_on_pool:2148-2161
推理落库 app/harness/inference.py:363-447 _run_single_questionprediction L422 未归一、insert L446 try 外);_to_text_field:147-162run_inference:473
Agent Loop core/agent/loop.py:103 runsession_id L110);_call_llm chat 调用 :329self._llm.chat(messages, session_id=session_id)
batching app/harness/batching.py:186-202 _classify_unitL200 缺 correctness→None
checkpoint app/harness/checkpoint.py:37-44 _STRUCTURAL_KEYSserialize_state:76-106write_checkpoint:212-260(原子写)
诊断分流 core/evolution/diagnose.py:1485-1519 _build_skill_case_packslapse 分流 L1492);_process_question:2139-2206except L2194 cause_category 留 None
traces 适配 app/harness/baseline_run_log.py:13 StepsJsonRunLogsteps_json_traces.py:13
patch core/evolution/patch.py:285-287 _in_ranges_do_insert_after:309-326L320);_do_replace_delete:329-347L343);markers L11-18validate_skill in evolve.py:296

核心算法保真校验

触及算法 #7(诊断瀑布,Task 1 恢复轨迹)、#8(patch 引擎,Task 5 冻结区加固)、#10Agent LoopTask 9 透传 cache_salt)、#5/#12(信息阶梯/训练编排,Task 7 checkpoint 时序)。均为恢复/加固/透传,不改算法逻辑:Task 1 让诊断拿到本就该有的轨迹;Task 5 把"只查起点"补成"查整跨度"(保护方向不变);Task 9 只加透传参数;Task 7 只调 checkpoint 落盘时机。每个相关 Task 设保真检查点。


Task 1: traces 适配(诊断拿到真实轨迹,算法 #7)

Files:

  • Modify: app/harness/runner.py:2163-2196_run_diagnosis

  • Test: tests/unit/test_runner_diag_tree_inject.pytest_harness_runner.py

  • Step 1: 写失败测试

tests/unit/test_runner_diag_tree_inject.py 追加(构造只写 steps_json 不写 traces 表的 run,断言诊断能拿到轨迹):

@pytest.mark.asyncio
async def test_diagnosis_reads_traces_from_steps_json(...):
    """traces 表为空但 predictions.steps_json 有轨迹时,诊断仍拿到非空 traces。"""
    # 依现有 runner 测试 fixture 造一个 runpredictions 有 steps_jsontraces 表空;
    # 调 _run_diagnosis 后断言 diagnose 收到的 traces 非空(可 patch run_diagnosis 捕获入参)
    ...

test_runner_diag_tree_inject.py 现有 fixture;实现前读对齐 runner 构造与 patch 点。

  • Step 2: 运行确认失败

Run: conda run -n Video-Tree-TRM python -m pytest tests/unit/test_runner_diag_tree_inject.py -k reads_traces_from_steps_json -v Expected: FAIL(当前 RunLogImpl 直读空 traces 表)。

  • Step 3: 包 StepsJsonRunLog

app/harness/runner.py _run_diagnosis,把传给 run_diagnosisrun_log(当前 RunLogImpl(...)L2173 附近)包一层:

    from app.harness.baseline_run_log import StepsJsonRunLog
    from app.harness.log import RunLogImpl

    run_log = StepsJsonRunLog(RunLogImpl(str(self._paths.db_path)))

StepsJsonRunLog.get_traces 在底层 traces 空时从 predictions.steps_json 经 steps_json_to_trace_rows 重建;get_predictions 透传。)

  • Step 4: 保真检查点 + 测试通过

Run: conda run -n Video-Tree-TRM python -m pytest tests/unit/test_runner_diag_tree_inject.py tests/unit/test_baseline_run_log.py -q Expected: 全 PASS。保真:确认诊断瀑布拿到的是逐 step {tool_name,tool_args,tool_output,thought} 行(对齐 TRM4 诊断输入)。

  • Step 5: 提交
git add app/harness/runner.py tests/unit/test_runner_diag_tree_inject.py
git commit -m "fix: wrap diagnosis run_log with StepsJsonRunLog (restore algo #7 traces)"

Task 2: prediction 归一化 + 落库加固

Files:

  • Modify: app/harness/inference.py:417-447

  • Test: tests/unit/test_harness_inference.py

  • Step 1: 写失败测试

tests/unit/test_harness_inference.py 追加(LLM 提交非标量 answer 不崩 gather):

@pytest.mark.asyncio
async def test_nonscalar_prediction_does_not_crash(...):
    """submit_answer 返回 {'answer': ['B']} 等非标量时归一化落库,不抛 sqlite 绑定异常。"""
    # 依现有 inference 测试 fixture,让 AgentLoop 返回 result={'answer': ['B']}
    # run_inference 应正常完成、predictions 行 prediction 为字符串(如 '["B"]'),不崩
    ...

test_harness_inference.py 现有 fake loop/dispatch fixture;实现前读对齐。

  • Step 2: 运行确认失败

Run: conda run -n Video-Tree-TRM python -m pytest tests/unit/test_harness_inference.py -k nonscalar_prediction -v Expected: FAILsqlite InterfaceError: Error binding parameter)。

  • Step 3: 归一化 prediction + insert 加固

app/harness/inference.py:新增归一化 helper(None 保留、str 原样、其余 _to_text_field):

def _normalize_prediction(answer: object) -> str | None:
    """归一化 predictionNone 保留(INFRA 空预测语义),str 原样,其余 JSON 序列化。"""
    if answer is None or isinstance(answer, str):
        return answer
    return _to_text_field(answer)

L422 "prediction": result_dict.get("answer"), 改为 "prediction": _normalize_prediction(result_dict.get("answer")),。 L446 的 await asyncio.to_thread(log.insert, "predictions", record) 包 try,绑定异常降级为最小 error 行不击穿 gather

    try:
        await asyncio.to_thread(log.insert, "predictions", record)
    except (sqlite3.InterfaceError, sqlite3.ProgrammingError):
        logger.exception("[{}] QA {} 落库绑定异常,降级为 error 行", qa.video_id, qa.question_id)
        record["prediction"] = None
        record["stop_reason"] = "error"
        await asyncio.to_thread(
            log.insert, "predictions",
            {k: v for k, v in record.items() if isinstance(v, (str, int, float, type(None)))},
        )

确认 import sqlite3 在文件顶部(无则加)。

  • Step 4: 测试通过 + 回归

Run: conda run -n Video-Tree-TRM python -m pytest tests/unit/test_harness_inference.py -q Expected: 全 PASS。

  • Step 5: 提交
git add app/harness/inference.py tests/unit/test_harness_inference.py
git commit -m "fix: normalize non-scalar prediction; harden predictions insert"

Task 3: early_stop 改 epoch 计数

Files:

  • Modify: app/harness/runner.py:291-316,118,1587,849-857

  • Modify: app/harness/checkpoint.py(若字段入 state

  • Test: tests/unit/test_harness_runner.py

  • Step 1: 写失败测试

tests/unit/test_harness_runner.py 追加:

def test_early_stop_counts_epochs_not_steps(tmp_path):
    """patience=2 表示连续 2 个 epoch 无 best 刷新才停(不是步数)。"""
    from app.harness.runner import _should_early_stop, _TrainState
    # 造 state + workspacebest 停在 epoch 1
    # epoch 2 无刷新 → epochs_since_best_improved=1 → 不停;
    # epoch 3 无刷新 → =2 → 停
    ...

依现有 _TrainState/read_best fixture;实现前读对齐 workspace best 写入。

  • Step 2: 运行确认失败

Run: conda run -n Video-Tree-TRM python -m pytest tests/unit/test_harness_runner.py -k early_stop_counts_epochs -v Expected: FAIL(当前累加 steps_this_epoch)。

  • Step 3: 字段改名 + 计数改 epoch

全局把 steps_since_best_improved 改名 epochs_since_best_improvedgrep -rn steps_since_best_improved app/_TrainState:118_should_early_stop:313,315_maybe_promote_best:1587,以及 checkpoint serialize/deserialize 若含此字段)。 _should_early_stopL315state.steps_since_best_improved += steps_this_epoch 改为 state.epochs_since_best_improved += 1;签名删除 steps_this_epoch 参数(改为 _should_early_stop(workspace_dir, epoch, state, patience)),train 调用点(L849-857)同步去掉 len(batches) 实参。docstring 改为"epoch 粒度"。

  • Step 4: checkpoint 兼容

epochs_since_best_improved 入 checkpoint stategrep -n steps_since_best_improved app/harness/checkpoint.py),同步改名。本轮为 fresh 训练无旧 checkpoint,无迁移负担。

  • Step 5: 测试通过 + 回归

Run: conda run -n Video-Tree-TRM python -m pytest tests/unit/test_harness_runner.py tests/unit/test_harness_checkpoint.py -q Expected: 全 PASS。

  • Step 6: 提交
git add app/harness/runner.py app/harness/checkpoint.py tests/unit/test_harness_runner.py
git commit -m "fix: early_stop patience counts epochs not steps"

Task 4: 可训练性预检

Files:

  • Modify: app/harness/runner.pytrain 入口过滤 + _setup_train_run 接收 filtered task_types

  • Modify: app/harness/config.pyRunConfig 加 trainable_min_units + 正整数校验)

  • Modify: app/harness/checkpoint.py:37trainable_min_units_STRUCTURAL_KEYS 指纹)

  • Test: tests/unit/test_harness_runner.pytests/unit/test_harness_checkpoint.py

  • Step 1: 写失败测试

def test_untrainable_types_filtered_before_gate():
    """val<eval_min_per_class 或 非test单元<trainable_min_units 的题型从 diag/val/task_types 剔除。"""
    from app.harness.runner import _filter_untrainable_types
    # 构造 pools:题型 Aval=5, units=40)可训;Bval=0, units=2)不可训
    # 调 _filter_untrainable_types(pools, task_types=[A,B], eval_min_per_class=2, trainable_min_units=8)
    # 断言返回 pools 不含 B、task_types 不含 B
    ...
  • Step 2: 运行确认失败

Run: conda run -n Video-Tree-TRM python -m pytest tests/unit/test_harness_runner.py -k untrainable_types_filtered -v Expected: FAIL_filter_untrainable_types 不存在)。

  • Step 3: 实现纯函数预检

app/harness/runner.py 新增模块级纯函数:

def _filter_untrainable_types(
    pools: Pools,
    task_types: list[str] | None,
    eval_min_per_class: int,
    trainable_min_units: int,
) -> tuple[Pools, list[str] | None]:
    """剔除不可训练题型(val<eval_min_per_class 或 非test单元<trainable_min_units)。

    非test单元数 = 该题型 diag+val 题数(single 题 unit==题;等于 gate 阶梯该类候选数)。
    test 池不过滤(继续报告全题型准确率)。返回过滤后 (pools, task_types)。
    """
    from collections import Counter

    diag_by_type = Counter(q.task_type for q in pools.diagnosis)
    val_by_type = Counter(q.task_type for q in pools.validation)
    keep: set[str] = set()
    dropped: list[tuple[str, str]] = []
    for tt in set(diag_by_type) | set(val_by_type):
        n_val = val_by_type.get(tt, 0)
        n_units = diag_by_type.get(tt, 0) + n_val
        if n_val < eval_min_per_class:
            dropped.append((tt, f"val={n_val}<{eval_min_per_class}"))
        elif n_units < trainable_min_units:
            dropped.append((tt, f"units={n_units}<{trainable_min_units}"))
        else:
            keep.add(tt)
    for tt, why in sorted(dropped):
        logger.warning("可训练性预检剔除题型 {}{}", tt, why)
    new_pools = replace(
        pools,
        diagnosis=[q for q in pools.diagnosis if q.task_type in keep],
        validation=[q for q in pools.validation if q.task_type in keep],
    )
    new_types = [t for t in task_types if t in keep] if task_types is not None else sorted(keep)
    return new_pools, new_types

(确认 from dataclasses import replace 已 importPools 是否 frozen dataclass 支持 replace——若非,按其构造方式重建。)

过滤结果必须回传主循环(Codex CriticalRunConfig@dataclass(frozen=True)不能 self._config.task_types = ...(会 FrozenInstanceError),且过滤后的 pools 必须被 train() 后续的 batch/step/slow-update/final-eval 全部使用。实现方式:

  • train(pools) 入口第一步_setup_train_run 调用之前)过滤:
        pools, filtered_task_types = _filter_untrainable_types(
            pools, self._config.task_types,
            self._config.eval_min_per_class, self._config.trainable_min_units,
        )
  • 把 filtered pools 传给 _setup_train_run(pools) 与 train() 后续所有消费点(build_batches / slow_update / final_eval 均用这个 filtered pools,不再触碰原始 pools)。
  • filtered_task_types 传给 gate 建立(_setup_train_run/_init_gate_pools 用它而非 self._config.task_types)——新增参数透传,不改 frozen config。

app/harness/config.pyRunConfig 加字段 trainable_min_units: int(无默认,显式配置;train yaml 提供);在配置校验函数(如 validate_configconfig.py:277 附近)加 trainable_min_units >= 1 断言(<1 报错)。 app/harness/checkpoint.py:37 _STRUCTURAL_KEYS 加入 "trainable_min_units"——该值改变会改变 pools 过滤结果与训练轨迹,必须纳入 checkpoint 结构指纹,resume 时变化即拒绝复用旧 checkpoint。

  • Step 4: 测试通过 + 回归

Run: conda run -n Video-Tree-TRM python -m pytest tests/unit/test_harness_runner.py -q Expected: 全 PASS。

  • Step 5: 提交
git add app/harness/runner.py app/harness/config.py tests/unit/test_harness_runner.py
git commit -m "feat: pre-flight filter of untrainable task types before gate"

Task 5: patch 冻结区跨度 + 注入 + marker 校验(算法 #8

Files:

  • Modify: core/evolution/patch.py:285-347

  • Modify: core/evolution/evolve.py:296validate_skill

  • Test: tests/unit/test_patch.py

  • Step 1: 写失败测试

tests/unit/test_patch.py 追加:

def test_replace_spanning_into_protected_is_skipped():
    """target 起点在正文、末端伸入冻结区的 replace 被跳过(不破坏 marker)。"""
    from core.evolution.patch import apply_patch_with_report, APPENDIX_START, APPENDIX_END

    body = "正文最后一段。"
    appendix = f"{APPENDIX_START}\n## 执行提醒\n- 规则A\n{APPENDIX_END}"
    content = body + "\n\n" + appendix
    # target 从正文末尾跨入 APPENDIX_START
    target = "正文最后一段。\n\n" + APPENDIX_START
    edits = [{"op": "delete", "target": target, "content": ""}]
    new_content, report = apply_patch_with_report(content, edits, protected_spans=[appendix])
    assert APPENDIX_START in new_content and APPENDIX_END in new_content  # marker 未被破坏


def test_edit_payload_with_marker_literal_rejected():
    """edit payload/target 含 marker 字面量 → 拒绝该 edit。"""
    from core.evolution.patch import apply_patch_with_report, APPENDIX_START
    edits = [{"op": "append", "target": "", "content": f"注入 {APPENDIX_START} 破坏"}]
    _, report = apply_patch_with_report("正文", edits, protected_spans=[])
    assert any("marker" in str(s).lower() or "reject" in str(s).lower() for s in report)

marker 常量名以 core/evolution/patch.py:11-18 为准(APPENDIX_START 等),实现前读对齐 import 名。

  • Step 2: 运行确认失败

Run: conda run -n Video-Tree-TRM python -m pytest tests/unit/test_patch.py -k "spanning_into_protected or marker_literal" -v Expected: FAIL(当前只查起点 pos,跨入未拦;无注入检查)。

  • Step 3: 跨度检查 + 注入检查

core/evolution/patch.py 新增跨度 helper

def _span_overlaps_ranges(pos: int, length: int, ranges: list[tuple[int, int]]) -> bool:
    """判断 [pos, pos+length) 是否与任一冻结区间相交(不止起点)。"""
    end = pos + length
    return any(start < end and pos < r_end for start, r_end in ranges)

_do_insert_after L320 if _in_ranges(pos, ranges): 改为 if _span_overlaps_ranges(pos, len(target), ranges):_do_replace_delete L343 if _in_ranges(pos, ranges): 改为 if _span_overlaps_ranges(pos, len(target), ranges):。 在 apply_patch_with_report(L387)应用每个 edit 前加注入检查:payload/target 含任一 marker 字面量(APPENDIX_START/ENDMOMENTUM_START/END)→ 跳过该 edit 并记 skipped_marker_injection

  • Step 4: validate_skill 加 marker 完整性校验

core/evolution/evolve.py:296 validate_skill:在现有 frontmatter/长度/代码块校验后加——统计 evolved 中 APPENDIX_START/ENDMOMENTUM_START/END 出现次数,要求成对(START 数==END 数)、各至多一对、START 在 END 前;违反则返回校验失败(该候选整体 reject)。

  • Step 5: 保真检查点 + 测试通过

Run: conda run -n Video-Tree-TRM python -m pytest tests/unit/test_patch.py tests/unit/test_evolve.py -q Expected: 全 PASS。保真:确认"保护跨度"方向未变(仍是保护 appendix/momentum 不被误改),只是从"查起点"补成"查整跨度"+ 注入/完整性双防线。

  • Step 6: 提交
git add core/evolution/patch.py core/evolution/evolve.py tests/unit/test_patch.py
git commit -m "fix: patch checks full target span + marker injection/integrity (algo #8)"

Task 6: 诊断降级分流 + 占比中止

Files:

  • Modify: core/evolution/diagnose.py:1485-1497

  • Modify: app/harness/runner.py:1019(诊断后 degraded 占比检查)

  • Test: tests/unit/test_diagnose.pytests/unit/test_harness_runner.py

  • Step 1: 写失败测试(分流)

tests/unit/test_diagnose.py 追加:

def test_none_cause_and_degraded_route_to_lapse():
    """cause_category=None(判别失败)与 degraded 题按 lapse 处置,不进 defect 正文路径。"""
    from core.evolution.diagnose import _build_skill_case_packs
    # 构造 metrics_group:一题 attr.cause_category=None(非 degraded)、一题 qm.degraded=True
    # 断言二者都不出现在 failure_caseswrong_by_error),只可能进 lapse_notes
    ...

test_diagnose.py 现有 QuestionMetrics/ErrorAttribution 构造(:753 附近);实现前读对齐字段。

  • Step 2: 运行确认失败

Run: conda run -n Video-Tree-TRM python -m pytest tests/unit/test_diagnose.py -k none_cause_and_degraded_route -v Expected: FAIL(当前 None → wrong_by_error 走 defect 正文)。

  • Step 3: 改分流逻辑

core/evolution/diagnose.py _build_skill_case_packs 的分流循环(L1488-1497)改为"仅明确 defect 且非 degraded 才进正文路径"

        for qm in metrics_group:
            if qm.correct:
                continue
            attr = attribution_map.get(qm.question_id)
            is_defect = (
                attr is not None
                and attr.cause_category == "defect"
                and not qm.degraded
            )
            if not is_defect:
                # lapse / None(判别失败)/ degraded → 保守,不驱动正文进化
                if attr is not None and attr.lapse_note and attr.lapse_note.strip():
                    lapse_notes.append(attr.lapse_note)
                continue
            wrong_by_error[attr.error_type].append(qm)
  • Step 4: 写失败测试(占比中止)+ 实现

tests/unit/test_harness_runner.py 追加:诊断结果 degraded_count/总题数 > 0.5 时 _run_step 后应 raise(疑似基础设施故障)。 app/harness/runner.py _run_stepL1019 拿到 diagnosis 后)加:

        n_wrong = sum(1 for q in batch if not state.correctness.get(q.question_id, True))
        if n_wrong > 0 and diagnosis.degraded_count / n_wrong > 0.5:
            raise RuntimeError(
                f"本 step 诊断降级占比 {diagnosis.degraded_count}/{n_wrong} > 50%"
                "疑似 judge 基础设施故障,中止训练(不以降级信号驱动进化)。"
            )

(确认 DiagnosisResult.degraded_count 字段可用,定义在 core/evolution/types.py:299。)

  • Step 5: 测试通过 + 回归

Run: conda run -n Video-Tree-TRM python -m pytest tests/unit/test_diagnose.py tests/unit/test_harness_runner.py -q Expected: 全 PASS。

  • Step 6: 提交
git add core/evolution/diagnose.py app/harness/runner.py tests/unit/test_diagnose.py tests/unit/test_harness_runner.py
git commit -m "fix: route None/degraded diagnoses to lapse; abort on high degrade rate"

Task 7: step 幂等(DELETE+ gate_epoch_observed 立即落盘

Files:

  • Modify: app/harness/runner.py:1001-1026_run_step 开头 DELETE

  • Modify: app/harness/runner.py:1378-1498,1833-1879checkpoint 提到 gate save 之后)

  • Test: tests/unit/test_harness_runner.py

  • Step 1: 写失败测试(幂等)

@pytest.mark.asyncio
async def test_run_step_deletes_stale_rows_before_rerun(...):
    """同 run_id 重跑前先清 predictions/traces,避免重复行双计。"""
    # 预置该 step run_id 的旧 predictions 行;调 _run_step;断言旧行被清、只剩本次
    ...
  • Step 2: 运行确认失败

Run: conda run -n Video-Tree-TRM python -m pytest tests/unit/test_harness_runner.py -k deletes_stale_rows -v Expected: FAIL(当前 append,无 DELETE)。

  • Step 3: _run_step 开头 DELETE

app/harness/runner.py _run_step,在 rolloutL1012)之前加(IF EXISTS 避免 fresh workspace 首跑时 predictions/traces 表尚未由 run_inference._ensure_tables 创建导致 OperationalError: no such table):

        with HarnessLog(str(self._paths.db_path), run_id, register_run=False) as log:
            log.execute("DELETE FROM predictions WHERE run_id=?", (run_id,))
            log.execute("DELETE FROM traces WHERE run_id=?", (run_id,))

SQLite DELETE FROM <t> 对不存在的表会抛 no such table。两种消解方式择一(实现前读 log.py 确认):① rollout 由 run_inference 先建表——把 DELETE 移到首次 rollout 之后、诊断之前并只在 resume 重跑(step 已有旧行)时执行;② 或 DELETE 前先 CREATE TABLE IF NOT EXISTS(复用 inference 的 PREDICTIONS_SCHEMA/TRACES_SCHEMA),保证幂等无害。推荐 ②(无害且简单)。register_run=False 来自 WP4;若 HarnessLog 无 execute 便捷方法,用其现有连接接口。

  • Step 4: checkpoint 提到 gate save 之后(消除双计窗口)

目标:_refresh_gate_laddergate_pools.saveL1878+ gate_epoch_observed=TrueL1879)之后,立即 write_checkpointphase="epoch_done"),不等到 train L837。实现:把 _slow_update_cycle Phase 10(调 _refresh_gate_ladder L1496-1498)之后的 checkpoint 落盘从 trainL837)移入 _slow_update_cycle 末尾,或让 _refresh_gate_ladder 接收 checkpoint 所需上下文(epoch/progress/batches)并在 save 后落盘。实现前读 write_checkpoint 签名(checkpoint.py:212)与 train L833-848 对齐参数,确保 gate_pools.json 与 checkpoint 的 gate_epoch_observed 同一时刻一致。

  • Step 5: 保真检查点 + 测试通过

Run: conda run -n Video-Tree-TRM python -m pytest tests/unit/test_harness_runner.py tests/unit/test_harness_checkpoint.py -q Expected: 全 PASS。保真(算法 #5/#12):确认 γ-EMA 更新(update_probs)仍每 epoch 一次、checkpoint 落盘不改变慢更新十步序的语义顺序。

  • Step 6: 提交
git add app/harness/runner.py tests/unit/test_harness_runner.py
git commit -m "fix: idempotent _run_step (DELETE stale) + checkpoint after gate save"

Task 8: holdout 四向去重

Files:

  • Modify: app/harness/runner.py:1881-1963_holdout_four_way / _pick_mixed_best
  • Test: tests/unit/test_harness_runner.py

说明:目标是每 epoch 的四向 test 评估从"4×600 全跑"降为"仅 final 必跑 + best_hard 未评过才跑 + baseline 从基线预测推导 + best_mixed 引用赢家"。去重做在 harness 逻辑层(配合 WP4 epoch 盐,同版本不重采样)。

  • Step 1: 写失败测试
@pytest.mark.asyncio
async def test_holdout_dedup_skips_reevaluated_versions(...):
    """baseline 不跑推理(从基线预测推导);best_hard==final 时不重复评估。"""
    # 统计 _eval_version_on_pool 被调次数:baseline=0best_hard==final 时该向复用不重跑
    ...

依现有 runner holdout fixture;实现前读 _holdout_four_way/write_holdout_eval 对齐。

  • Step 2: 运行确认失败

Run: conda run -n Video-Tree-TRM python -m pytest tests/unit/test_harness_runner.py -k holdout_dedup -v Expected: FAIL(当前四向各跑一次)。

  • Step 3: 实现去重(进程内备忘录,不改 holdout_eval schema

schema 约束(Codex Critical:当前 holdout_eval 表(observation.py:78)不存 skills_version/prompts_version/pointer,无法按版本反查做跨-run hydrate。本 task 不扩展该 schema(避免结构性风险),改用 train() 进程内备忘录 dict[(skills_v,prompts_v), float] 去重。代价:resume 后备忘录清空、已评版本会重评一次——resume 是异常路径、重评 600 题成本可接受,换取零 schema 变更风险。完整跨-run hydrate 记 future work。

app/harness/runner.py _holdout_four_way(在 _TrainState 加一个 holdout_memo: dict[tuple[str,str], float] = field(default_factory=dict) 字段):

  • baseline 向:不调 _eval_version_on_pool,改从基线 predictionsbaseline_run_id)读 test 题对错算 acctest 题在 infer_adhoc 已全推理过);结果存 memoepoch>1 直接复用(0 推理)。
  • final 向:真评 600,算完存 memo[(final_sv,final_pv)]
  • best_hard 向:若 (best_sv,best_pv) 已在 memo(== final 或往轮已评)则引用,否则真评并存 memo。
  • best_mixed 向_pick_mixed_best 选出的赢家必是 best_hard 或 final 之一,其 test acc 已在 memo,直接引用写 holdout_eval0 推理。

实现前完整读 _holdout_four_way~1885)、write_holdout_eval_eval_version_on_pool 对齐;write_holdout_eval 调用保持不变(仍逐向写观测行,只是 acc 来源改为 memo 复用/基线推导)。

  • Step 4: 测试通过 + 回归

Run: conda run -n Video-Tree-TRM python -m pytest tests/unit/test_harness_runner.py -q Expected: 全 PASS。

  • Step 5: 提交
git add app/harness/runner.py tests/unit/test_harness_runner.py
git commit -m "perf: dedup holdout four-way eval (baseline derive, best_hard memo)"

Task 9: 训练推理注入 epoch 盐(cache_salt=run_id,算法 #10 透传)

Files:

  • Modify: core/agent/loop.py:103-141,271-329
  • Modify: app/harness/inference.py:408-415,473
  • Test: tests/unit/test_agent_loop(或现有 loop 测试)、tests/unit/test_harness_inference.py

原理:训练/val/test/holdout 推理的 run_id 已含 _e{epoch}(如 {base}_e{epoch}_s{step}{run_id}_holdout_{kind}_e{epoch}),用 run_id 作 cache_salt 即天然跨 epoch 重采样、同 epoch 续跑仍命中。judge/evolve 不经此路径(默认 salt=None)。gate 基线臂走 BaselineCache 不受影响;候选臂 messages 含 skill 版本天然区分。

  • Step 1: 写失败测试
@pytest.mark.asyncio
async def test_agent_loop_forwards_cache_salt():
    """AgentLoop.run(cache_salt=...) 透传到 llm.chat。"""
    from core.agent.loop import AgentLoop
    # fake llm 记录 chat 收到的 cache_salt kwargloop.run(..., cache_salt='run:e2')
    # 断言 fake_llm.chat 收到 cache_salt='run:e2'
    ...
  • Step 2: 运行确认失败

Run: conda run -n Video-Tree-TRM python -m pytest -k agent_loop_forwards_cache_salt -v Expected: FAILrun() 无 cache_salt 参数)。

  • Step 3: AgentLoop 透传 cache_salt

core/agent/loop.pyrunL103)、_step/_call_llmL271,317)签名加 cache_salt: str | None = Nonekeyword,随 session_id 透传);L329 self._llm.chat(messages, session_id=session_id) 改为 self._llm.chat(messages, session_id=session_id, cache_salt=cache_salt)

  • Step 4: inference 用 run_id 作 salt

app/harness/inference.py _run_single_questionloop.run(...)L409)加 cache_salt=run_idrun_id 从 run_inference 透传到每题;_run_single_question 已有 run_id 上下文——若无则从 run_inference 参数透传)。确认 run_inference→_run_single_question 的 run_id 传递链完整。

  • Step 5: 保真检查点 + 测试通过

Run: conda run -n Video-Tree-TRM python -m pytest tests/unit/test_harness_inference.py tests/integration/test_agent_governed_e2e.py -q Expected: 全 PASS。保真(算法 #10):确认只加透传参数,Thinking+JSON/json_repair/pluggy hook 逻辑不变。

  • Step 6: 提交
git add core/agent/loop.py app/harness/inference.py tests/unit/
git commit -m "feat: inject run_id as cache_salt for per-epoch resampling (algo #10)"

Self-Review(作者自查,执行者复核)

  • traces 适配后诊断拿到真实轨迹(算法 #7 恢复)。
  • prediction 归一化 None 保留、非标量序列化;insert 绑定异常不击穿 gather。
  • early_stop 字段全局改名一致、计数改 epoch。
  • 预检剔除不可训练题型(test 池不动)。
  • patch 查整跨度 + 注入 + marker 完整性三防线(算法 #8 保护方向不变)。
  • None/degraded 按 lapse 保守分流;降级占比>50% 中止。
  • _run_step 幂等;gate_pools 与 checkpoint 的 gate_epoch_observed 同刻一致。
  • holdout 去重后 baseline 0 推理、best_hard 备忘录 resume 可 hydrate。
  • cache_salt=run_id 贯穿 AgentLooprun_id 含 epoch 保证跨 epoch 重采样。

核心算法保真校验结论

触及算法 #5/#7/#8/#10/#12,均为恢复(#7 轨迹)/加固(#8 跨度)/透传(#10 salt/时序(#5/#12 checkpoint),各 Task 已设保真检查点,不改算法核心逻辑。Task 5/7/9 需在实现时对照 TRM4 参考确认无行为漂移。

验收标准

  1. pytest tests/unit/test_harness_runner.py tests/unit/test_harness_inference.py tests/unit/test_diagnose.py tests/unit/test_patch.py tests/unit/test_harness_checkpoint.py tests/unit/test_baseline_run_log.py tests/unit/test_runner_diag_tree_inject.py 全绿。
  2. 诊断拿到非空轨迹;非标量 prediction 不崩;early_stop 按 epoch。
  3. 微型题型被预检剔除;patch 不破坏 marker;降级题不驱动进化。
  4. _run_step 幂等;cache_salt=run_id 贯穿。