Files
Video-Tree-TRM5/app/harness/runner.py
T
iomgaa 2296134f73 feat(harness): runner.py — train loop orchestrator (#13 algorithm fidelity)
Three-level nesting (epoch -> step -> per-skill), slow update 10-step
sequence, checkpoint/resume, early stop, probation accept/reject/rollback.

Key TRM4->TRM5 changes:
- sync -> async (all inference/diagnosis/evolve/validate awaited)
- LLMClient.from_env -> injected LLMProvider (DI via constructor)
- Direct DB/file access -> module functions (workspace/store/log)
- _TrainState as train() local, explicit param passing to helpers

Module-level pure functions extracted for testability:
resume_plan, _guard_infra_failures, _apply_batch_correctness,
_compute_total_steps, _should_early_stop, _format_applied_edits,
_fallback_summary, _write_skip_report, _outcome_to_quadrant_pairs,
_build_comparison_pairs, _batch_from_ids, _snapshot_current_skills.

Tests: 34 unit tests covering 13a-13e sub-tasks.
Radon: all functions Grade B or better.
2026-07-07 13:43:20 -04:00

2147 lines
83 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.
"""实验运行器(瘦编排器),对标 PyTorch Trainer。
三级嵌套(epoch → step → per-skill)训练循环 + 慢更新十步序 + 断点续训。
算法保真 #13:训练循环编排从 TRM4 runner.py(2273 行)迁移,逻辑不可简化。
关键重构(TRM4 → TRM5):
- sync → asyncawait run_inference / run_diagnosis / evolve_* / validate_*
- LLMClient.from_env → 注入 LLMProviderself._llm / self._evolve_llm
- 直接 DB/文件操作 → 通过模块函数(workspace / store / log / observation
- 瘦身 2273 → ~500 行(推理/诊断/进化/验证全委托模块函数)
"""
from __future__ import annotations
import json
import math
import random
import shutil
import sqlite3
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any
from loguru import logger
from app.harness.batching import build_batches
from app.harness.checkpoint import (
check_fingerprint,
deserialize_state_fields,
load_checkpoint,
write_checkpoint,
)
from app.harness.config import RunConfig # noqa: TC001 — 运行时 _compute_total_steps 使用
from app.harness.gate_ladder import BaselineCache, GatePools, build_or_load_gate_pools
from app.harness.observation import (
write_dual_metric,
write_epoch_report,
write_gate_evidence,
write_holdout_eval,
write_quadrant_pairs,
write_shadow_gate,
write_step_report,
)
from app.harness.store import advance_version
from app.harness.validate import Probation, ValidationOutcome
from app.harness.workspace import (
ResolvedPaths,
archive_workspace,
init_workspace,
init_workspace_from_seed,
load_manifest,
read_best,
resolve_paths,
update_best,
update_manifest,
)
from core.evolution import (
DiagnosisResult,
GateParams,
RejectedEdit,
edit_budget_at,
momentum_inner,
probation_verdict,
replace_momentum,
resolve_skill_file,
)
from core.evolution.diagnose import merge_system_packs, merge_tool_packs
if TYPE_CHECKING:
from app.harness.inference import InferenceResult
from app.harness.pools import Pools
from core.evolution.types import (
EvolutionRecord,
SystemCasePack,
ToolCasePack,
)
from core.protocols import LLMProvider, TelemetryRecorder, VLMProvider
from core.types import GeneratedQuestion
class _InterruptError(RuntimeError):
"""测试用中断注入信号:_run_step 末尾可选抛出以模拟进程中断。"""
# ---------------------------------------------------------------------------
# _TrainState: 19 个可变字段
# ---------------------------------------------------------------------------
@dataclass
class _TrainState:
"""一次 train() 的跨 step 可变状态(训练循环的"权重/缓冲")。
字段说明见 TRM4 同名 dataclass(完整保留 19 字段语义)。
TRM5 移除 evolve_client(改走构造注入),其余 18 字段 + gate_epoch_observed 不变。
"""
correctness: dict[str, bool]
gate_pools: GatePools
baseline_cache: BaselineCache
eval_prev_acc: float
eval_prev_run_id: str
best_val_acc: float
best_skills_version: str
best_prompts_version: str
baseline_skills_version: str = ""
baseline_prompts_version: str = ""
rejected_buffer: dict[str, list[RejectedEdit]] = field(default_factory=dict)
system_packs: list[SystemCasePack] = field(default_factory=list)
tool_packs: list[ToolCasePack] = field(default_factory=list)
global_step: int = 0
changed_task_types_this_epoch: set[str] = field(default_factory=set)
epoch_start_skills: dict[str, str] = field(default_factory=dict)
steps_since_best_improved: int = 0
gate_epoch_observed: bool = False
probations: dict[str, Probation] = field(default_factory=dict)
gate_cooldown: dict[str, int] = field(default_factory=dict)
# ---------------------------------------------------------------------------
# 纯函数辅助(不依赖 self
# ---------------------------------------------------------------------------
def resume_plan(epoch: int, phase: str, step_completed: int) -> dict:
"""据 checkpoint 进度算续跑计划(纯函数,便于单测)。
参数:
epoch: checkpoint 落库时的 epoch 序号。
phase: "in_epoch" 或 "epoch_done"。
step_completed: 该 epoch 内最后完整完成的 step 序号。
返回:
{"first_epoch": int, "resume_epoch": int | None, "resume_step_from": int}。
"""
if phase == "epoch_done":
return {"first_epoch": epoch + 1, "resume_epoch": None, "resume_step_from": 0}
return {
"first_epoch": epoch,
"resume_epoch": epoch,
"resume_step_from": step_completed + 1,
}
def _guard_infra_failures(result: InferenceResult, context: str) -> None:
"""基础设施失败护栏:stop_reason="error" 占比 > 10% 即硬终止。
参数:
result: 推理聚合结果。
context: 出错时报错的推理路径名(仅诊断用)。
异常:
RuntimeError: error 占比 > 10%。
"""
error_rate = result.stop_reason_counts.get("error", 0) / max(result.total, 1)
if error_rate > 0.1:
raise RuntimeError(
f"{context} 推理基础设施失败率过高 {error_rate:.0%}stop_reason=error),中止本轮"
)
def _apply_batch_correctness(
correctness: dict[str, bool],
log: Any,
run_id: str,
batch: list[GeneratedQuestion],
) -> None:
"""从该 run 的 predictions 读 batch 各题新对错,就地增量更新 correctness。
参数:
correctness: question_id -> 是否答对,就地更新。
log: HarnessLog 实例。
run_id: rollout 的 run_id。
batch: 本 step 的题目列表。
异常:
RuntimeError: rollout 不完整(缺预测行)。
"""
from app.harness.validate import _load_run_rows
rows = _load_run_rows(log, run_id)
missing = [q.question_id for q in batch if q.question_id not in rows]
if missing:
raise RuntimeError(
f"rollout 不完整:run_id={run_id}{len(missing)} 道题预测行 {missing},中止本步"
)
for q in batch:
correctness[q.question_id] = rows[q.question_id]["_correct"]
def _accumulate_slow_packs(diagnosis: DiagnosisResult, state: _TrainState) -> None:
"""把本 step 诊断的 system/tool 案例包只累加不更新,留给 epoch 末慢更新消费。"""
if diagnosis.system_case_pack is not None:
state.system_packs.append(diagnosis.system_case_pack)
state.tool_packs.extend(diagnosis.tool_case_packs.values())
def _batch_from_ids(pools: Pools, ids: list[str]) -> list[GeneratedQuestion]:
"""按 question_id 从诊断池重建一个 batch(保持原 epoch 划分)。
参数:
pools: 三池容器。
ids: 一个 batch 的 question_id 列表。
返回:
按 ids 顺序取出的 GeneratedQuestion 列表。
"""
by_id = {q.question_id: q for q in pools.diagnosis}
return [by_id[i] for i in ids]
def _snapshot_current_skills(skills_dir: Path) -> dict[str, str]:
"""快照当前 skills 版本目录下各 skill 文件的正文(文件名 -> 全文)。
参数:
skills_dir: 当前 skills 版本目录。
返回:
{文件名: 全文},作 momentum 的上一版基准。
"""
snapshot: dict[str, str] = {}
for path in sorted(skills_dir.glob("*.md")):
snapshot[path.name] = path.read_text(encoding="utf-8")
return snapshot
def _should_early_stop(
workspace_dir: Path,
epoch: int,
steps_this_epoch: int,
state: _TrainState,
patience: int,
) -> bool:
"""步粒度 early stop:本 epoch best 未刷新则累加本 epoch 步数。
参数:
workspace_dir: workspace 目录(读 manifest best)。
epoch: 当前 epoch。
steps_this_epoch: 本 epoch 的 step 总数。
state: 训练状态(steps_since_best_improved 就地更新)。
patience: early_stop_patience。
返回:
是否触发 early stop。
"""
best = read_best(workspace_dir)
improved_this_epoch = best is not None and best.get("epoch") == epoch
if improved_this_epoch:
state.steps_since_best_improved = 0
return False
state.steps_since_best_improved += steps_this_epoch
return state.steps_since_best_improved >= patience
def _compute_total_steps(pools: Pools, correctness: dict[str, bool], config: RunConfig) -> int:
"""退火地平线:用 build_batches 试切一轮拿 selected_count,再乘 epochs。"""
_, selected_count = build_batches(
pools.diagnosis,
correctness,
config.batch_size,
config.min_class_per_batch,
seed=1,
correct_ratio=config.batch_correct_ratio,
)
steps_per_epoch = max(1, math.ceil(selected_count / config.batch_size))
return config.epochs * steps_per_epoch
def _outcome_to_quadrant_pairs(task_type: str, outcome: ValidationOutcome) -> list[dict]:
"""把 ValidationOutcome 的四象限拍平为逐题 pair(供 quadrant_pair 表落库观测)。
参数:
task_type: 该批 gate 的任务类型。
outcome: 局部验证决策结果。
返回:
每条含 question_id/task_type/prev_correct/curr_correct/category 的 dict 列表。
"""
from app.harness.momentum import (
IMPROVED,
PERSISTENT_FAIL,
REGRESSED,
STABLE_SUCCESS,
)
spec = [
(outcome.improvements, IMPROVED, False, True),
(outcome.regressions, REGRESSED, True, False),
(outcome.persistent_fails, PERSISTENT_FAIL, False, False),
(outcome.stable_successes, STABLE_SUCCESS, True, True),
]
pairs: list[dict] = []
for qids, category, prev_ok, curr_ok in spec:
for qid in qids:
pairs.append(
{
"question_id": qid,
"task_type": task_type,
"prev_correct": prev_ok,
"curr_correct": curr_ok,
"category": category,
}
)
return pairs
def _build_comparison_pairs(
sampled: list[GeneratedQuestion],
prev_rows: dict[str, dict],
curr_rows: dict[str, dict],
) -> list[dict]:
"""为采样好的诊断池题目构造 momentum 纵向对比对。"""
pairs: list[dict] = []
for q in sampled:
prev = prev_rows.get(q.question_id, {})
curr = curr_rows.get(q.question_id, {})
pairs.append(
{
"question": q.question,
"prev_prediction": prev.get("prediction", ""),
"curr_prediction": curr.get("prediction", ""),
"correct_prev": prev.get("_correct", False),
"correct_curr": curr.get("_correct", False),
}
)
return pairs
def _filter_applied_edits(edits: list[dict], reports: list[dict]) -> list[dict] | str:
"""按 apply_report 过滤出真正 applied 的 edit。
参数:
edits: EvolutionRecord.edits 列表。
reports: EvolutionRecord.apply_report 列表(与 edits 同序对齐)。
返回:
过滤后的 edit 列表;0 applied 时返回信息性消息字符串。
reports 为空时返回原 edits 不过滤。
"""
if not reports:
return edits
applied = [
edit
for edit, report in zip(edits, reports, strict=True)
if str(report.get("status", "")).startswith("applied")
]
if not applied:
return "上轮改法全部未成功应用(0 applied),本条无已验证信息"
return applied
def _format_applied_edits(record: Any) -> str | None:
"""从 EvolutionRecord 中提取真正 applied 的 edit 并格式化为摘要。
参数:
record: EvolutionRecordduck-typed,需含 edits / apply_report)。
返回:
已 applied edit 的格式化摘要;无 edit 或无 applied 时返回 None
0 applied 时返回信息性消息(非 None)。
"""
rec_edits = getattr(record, "edits", []) or []
if not rec_edits:
return None
filtered = _filter_applied_edits(rec_edits, getattr(record, "apply_report", []) or [])
if isinstance(filtered, str):
return filtered
summary = "; ".join(
f"[{edit.get('op')}]{(edit.get('target') or edit.get('content', ''))[:40]}"
for edit in filtered
if isinstance(edit, dict)
)
return summary or None
def _fallback_summary(record: Any, outcome: Any) -> str:
"""从 suggestions 或跌幅信息构造兜底黑名单摘要。
参数:
record: EvolutionRecordduck-typed,需含 suggestions)。
outcome: ValidationOutcomeduck-typed,需含 delta_hat)。
返回:
兜底摘要字符串。
"""
return "; ".join(s.get("change", "") for s in record.suggestions) or (
f"上轮对本文件改写被拒(delta {outcome.delta_hat:+.2f}),换方向"
)
def _write_skip_report(
workspace_dir: Path,
epoch: int,
step: int,
global_step: int,
task_type: str,
action: str,
baseline_acc: float,
budget: int,
rank_clip_triggered: bool = False,
) -> None:
"""为 cooldown / skipped 路径写 step_report(无 gate 证据)。
参数:
workspace_dir: 工作区目录。
epoch: 轮次。
step: epoch 内 step。
global_step: 全局步计数。
task_type: 任务类型。
action: "cooldown" 或 "skipped"。
baseline_acc: 当前类基线准确率。
budget: 编辑预算。
rank_clip_triggered: 是否触发 rank 裁剪(skipped 路径需要)。
"""
write_step_report(
workspace_dir,
epoch=epoch,
step=step,
global_step=global_step,
task_type=task_type,
gate_action=action,
candidate_acc=baseline_acc,
class_baseline_acc=baseline_acc,
edit_budget=budget,
rank_clip_triggered=rank_clip_triggered,
gate_w=None,
gate_l=None,
gate_e_value=None,
gate_n_used=None,
gate_stop_reason=None,
)
# ---------------------------------------------------------------------------
# Runner 主类
# ---------------------------------------------------------------------------
class Runner:
"""实验运行器(瘦编排器),通过 RunConfig 驱动训练/推理/诊断/评估等模式。
DI 纪律:self 只持注入依赖 + _paths。_TrainState 是 train() 内局部变量,
显式传参给模块函数。
参数:
config: 运行配置。
llm: 推理用 LLMProvider。
evolve_llm: 进化用 LLMProviderthinking=True)。
vlm: VLMProvider。
telemetry: 遥测记录端口。
"""
def __init__(
self,
config: RunConfig,
*,
llm: LLMProvider,
evolve_llm: LLMProvider,
vlm: VLMProvider,
telemetry: TelemetryRecorder,
) -> None:
self._config = config
self._llm = llm
self._evolve_llm = evolve_llm
self._vlm = vlm
self._telemetry = telemetry
self._ensure_workspace()
self._paths: ResolvedPaths = resolve_paths(config.workspace_dir)
# -----------------------------------------------------------------------
# workspace 三态逻辑
# -----------------------------------------------------------------------
def _ensure_workspace(self) -> None:
"""train 模式按 --resume/--fresh 分三态;其余模式仅要求 ws 已存在并复用。
三态逻辑:
resume+fresh → ValueError
fresh+已有 → archive + init_from_seed
resume+无进度 → RuntimeError
无flag+已有 → SystemExit
"""
manifest = self._config.workspace_dir / "manifest.json"
has_progress = manifest.exists()
if self._config.mode != "train":
if not has_progress:
raise RuntimeError(
f"{self._config.mode} 模式要求 workspace 已存在: {self._config.workspace_dir}"
)
return
if self._config.resume and self._config.fresh:
raise ValueError("--resume 与 --fresh 互斥")
if self._config.fresh:
if has_progress:
logger.info(
"旧 workspace 已归档: {}",
archive_workspace(self._config.workspace_dir),
)
init_workspace_from_seed(
self._config.workspace_dir,
self._config.store_dir,
self._config.seed,
self._config.questions,
)
return
if self._config.resume:
if not has_progress:
raise RuntimeError("--resume 但 workspace 无已有进度")
return
if has_progress:
raise SystemExit("workspace 已有进度;用 --resume 续训 或 --fresh 归档重开")
init_workspace(
self._config.workspace_dir,
self._config.store_dir,
self._config.questions,
self._config.skills_version,
self._config.prompts_version,
)
# -----------------------------------------------------------------------
# 公共入口:infer / eval / diagnose / promote
# -----------------------------------------------------------------------
async def infer(self, task_types: list[str] | None = None) -> InferenceResult:
"""执行单次推理(forward-only)。
参数:
task_types: 若非 None,只保留指定题型。
返回:
InferenceResult 冻结实例。
"""
from app.harness.inference import run_inference
from app.harness.log import HarnessLog
from app.question_gen import load_benchmark
questions = load_benchmark(self._paths.questions_dir)
if task_types:
allowed = set(task_types)
questions = [q for q in questions if q.task_type in allowed]
if self._config.n_samples > 0:
questions = questions[: self._config.n_samples]
run_id = f"infer_{self._config.run_id}" if self._config.run_id else "infer_adhoc"
record_run_dir = self._record_run(run_id) # noqa: F841
logger.info(
"启动推理: {} 道题, concurrency={}, max_steps={}, skill_mode={}",
len(questions),
self._config.concurrency,
self._config.max_steps,
self._config.skill_mode,
)
with HarnessLog(str(self._paths.db_path), run_id) as log:
return await run_inference(
questions=questions,
llm=self._llm,
tool_dispatch_fn=self._make_tool_dispatch_fn(),
prompt_builder=self._make_prompt_builder(),
log=log,
run_id=run_id,
concurrency=self._config.concurrency,
max_steps=self._config.max_steps,
skill_mode=self._config.skill_mode,
)
async def eval(self, version: str) -> InferenceResult:
"""用指定 skills 版本跑完整题库,全量记录落 db + 版本回填。
参数:
version: skills 版本号。
返回:
InferenceResult。
"""
from datetime import UTC, datetime
from app.harness.inference import run_inference
from app.harness.log import HarnessLog
from app.question_gen import load_benchmark
cur = load_manifest(self._config.workspace_dir)["current"]
prompts_v = cur["prompts"].split("/")[-1]
skills_dir = self._paths.workspace_dir / "skills" / version
prompts_dir = self._paths.workspace_dir / "prompts" / prompts_v
if not skills_dir.is_dir():
raise FileNotFoundError(f"skills 版本目录不存在: {skills_dir}")
if not prompts_dir.is_dir():
raise FileNotFoundError(f"prompts 版本目录不存在: {prompts_dir}")
questions = load_benchmark(self._paths.questions_dir)
if self._config.n_samples > 0:
questions = questions[: self._config.n_samples]
ts = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
run_id = f"eval_{version}-{prompts_v}_{ts}"
self._record_run(run_id)
logger.info(
"eval: 版本 skills/{}+prompts/{}{} 题 (run_id={})",
version,
prompts_v,
len(questions),
run_id,
)
with HarnessLog(str(self._paths.db_path), run_id) as log:
result = await run_inference(
questions=questions,
llm=self._llm,
tool_dispatch_fn=self._make_tool_dispatch_fn(skills_dir=skills_dir),
prompt_builder=self._make_prompt_builder(
skills_dir=skills_dir, prompts_dir=prompts_dir
),
log=log,
run_id=run_id,
concurrency=self._config.concurrency,
max_steps=self._config.max_steps,
skill_mode=self._config.skill_mode,
)
# C4 回填
self._backfill_run_versions(run_id, version, prompts_v)
self._write_eval_report(run_id, version, prompts_v, result)
return result
async def diagnose(self, run_id: str) -> DiagnosisResult:
"""执行指定 run 的两阶段诊断。
参数:
run_id: 待诊断的 run_id。
返回:
DiagnosisResult。
"""
return await self._run_diagnosis(run_id)
def promote(self, version: str, eval_run_id: str, name: str) -> None:
"""把当前 ws 的指定版本提升为 Store 新种子。
参数:
version: 要提升的 skills 版本号。
eval_run_id: canonical eval run。
name: 新种子名。
"""
from app.harness.store import promote_to_seed
seed_dir = promote_to_seed(
self._config.workspace_dir,
self._config.store_dir,
version,
eval_run_id,
name,
description=f"promote from {self._config.workspace_dir.name} {version}",
)
logger.info("已提升为种子: {}", seed_dir)
# -----------------------------------------------------------------------
# train() 三级嵌套(核心)
# -----------------------------------------------------------------------
async def train(self, pools: Pools) -> None:
"""mini-batch 快慢双速闭环:epoch 内多 step、每 step 按类 per-skill gate。
三级嵌套:epoch → batch(step) → per-skill。
epoch 末 _slow_update_cycle 十步序。
训练收尾 _deliver_best + _final_test_eval。
"""
state, total_steps, plan, saved_batches = await self._setup_train_run(pools)
for epoch in range(plan["first_epoch"], self._config.epochs + 1):
if epoch == plan["resume_epoch"]:
batches = [_batch_from_ids(pools, ids) for ids in saved_batches]
step_from = plan["resume_step_from"]
else:
logger.info("=== Epoch {} ===", epoch)
state.system_packs = []
state.tool_packs = []
state.changed_task_types_this_epoch = set()
state.epoch_start_skills = _snapshot_current_skills(self._paths.skills_dir)
batches, _ = build_batches(
pools.diagnosis,
state.correctness,
self._config.batch_size,
self._config.min_class_per_batch,
seed=epoch,
correct_ratio=self._config.batch_correct_ratio,
)
step_from = 0
batch_ids = [[q.question_id for q in b] for b in batches]
for step in range(step_from, len(batches)):
await self._run_step(epoch, step, total_steps, batches[step], pools, state)
state.global_step += 1
write_checkpoint(
self._config.workspace_dir,
state=state,
epoch=epoch,
step_completed=step,
phase="in_epoch",
global_step=state.global_step,
total_steps=total_steps,
version_snapshot=self._current_version_snapshot(),
epoch_batches=batch_ids,
config=self._config,
)
await self._slow_update_cycle(epoch, pools, state)
state.system_packs = []
state.tool_packs = []
state.changed_task_types_this_epoch = set()
write_checkpoint(
self._config.workspace_dir,
state=state,
epoch=epoch,
step_completed=len(batches) - 1,
phase="epoch_done",
global_step=state.global_step,
total_steps=total_steps,
version_snapshot=self._current_version_snapshot(),
epoch_batches=batch_ids,
config=self._config,
)
if _should_early_stop(
self._config.workspace_dir,
epoch,
len(batches),
state,
self._config.early_stop_patience,
):
logger.info("Epoch {} 触发 early stopbest 连续无新高)", epoch)
break
self._deliver_best(state.best_skills_version, state.best_prompts_version)
await self._final_test_eval(pools)
# -----------------------------------------------------------------------
# 训练初始化
# -----------------------------------------------------------------------
async def _setup_train_run(self, pools: Pools) -> tuple[_TrainState, int, dict, list | None]:
"""据是否 --resume 准备训练起点。
返回:
(state, total_steps, plan, saved_batches)。
"""
ckpt = load_checkpoint(self._config.workspace_dir) if self._config.resume else None
if self._config.resume and ckpt is None:
raise RuntimeError("--resume 但 checkpoint.json 不存在,拒绝静默从头重训")
gate_pools, baseline_cache = self._init_gate_pools(pools)
if not ckpt:
state = self._init_train_state(pools, gate_pools, baseline_cache)
total_steps = _compute_total_steps(pools, state.correctness, self._config)
plan = {"first_epoch": 1, "resume_epoch": None, "resume_step_from": 0}
return state, total_steps, plan, None
struct, decision = check_fingerprint(ckpt["config_fingerprint"], self._config)
if struct:
raise RuntimeError(f"结构性配置变化,拒绝 resume: {struct}")
if decision:
logger.warning("决策性配置变化,继续 resume: {}", decision)
state = self._restore_train_state(ckpt, pools, gate_pools, baseline_cache)
state.global_step = ckpt["progress"]["global_step"]
update_manifest(
self._config.workspace_dir,
skills=ckpt["version_snapshot"]["skills"],
prompts=ckpt["version_snapshot"]["prompts"],
)
self._paths = resolve_paths(self._config.workspace_dir)
plan = resume_plan(
ckpt["progress"]["epoch"],
ckpt["progress"]["phase"],
ckpt["progress"]["step_completed"],
)
return state, ckpt["progress"]["total_steps"], plan, ckpt["epoch_batches"]
def _init_gate_pools(self, pools: Pools) -> tuple[GatePools, BaselineCache]:
"""构建/加载 CE-Gate 信息量阶梯与基线缓存。
副作用:设置 self._gate_questions_by_id(不进 checkpoint)。
参数:
pools: 冻结三池。
返回:
(GatePools, BaselineCache)。
"""
from app.harness.log import HarnessLog
from app.question_gen import load_benchmark
questions = load_benchmark(self._paths.questions_dir)
self._gate_questions_by_id: dict[str, GeneratedQuestion] = {
q.question_id: q for q in questions
}
with HarnessLog(str(self._paths.db_path), pools.baseline_run_id) as log:
rows = log.query(
"SELECT question_id, prediction, answer FROM predictions WHERE run_id=?",
(pools.baseline_run_id,),
)
if not rows:
raise RuntimeError(
f"基线 run {pools.baseline_run_id} 在 predictions 表无任何行,"
"无法构建 gate 阶梯(检查种子基线是否完整落库)"
)
baseline_correctness = {r["question_id"]: r["prediction"] == r["answer"] for r in rows}
logger.info("gate 阶梯基线对错覆盖 {} 题", len(baseline_correctness))
gate_task_types = sorted({q.task_type for q in pools.diagnosis})
gate_pools = build_or_load_gate_pools(
workspace_dir=self._config.workspace_dir,
questions=questions,
test_qids={q.question_id for q in pools.test},
baseline_correctness=baseline_correctness,
task_types=gate_task_types,
probe_quota=self._config.gate_probe_quota,
seed=1,
baseline_run_id=pools.baseline_run_id,
)
baseline_cache = BaselineCache(self._config.workspace_dir / "baseline_cache.json")
return gate_pools, baseline_cache
def _init_train_state(
self, pools: Pools, gate_pools: GatePools, baseline_cache: BaselineCache
) -> _TrainState:
"""初始化跨 step 训练状态。"""
skills_v = self._current_version("skills")
prompts_v = self._current_version("prompts")
update_best(
self._config.workspace_dir,
skills=f"skills/{skills_v}",
prompts=f"prompts/{prompts_v}",
val_acc=pools.baseline_val_accuracy,
run_id=pools.baseline_run_id,
epoch=0,
)
if read_best(self._config.workspace_dir) is None:
raise RuntimeError("best 指针初始化失败")
return _TrainState(
correctness=dict(pools.correctness),
gate_pools=gate_pools,
baseline_cache=baseline_cache,
eval_prev_acc=pools.baseline_val_accuracy,
eval_prev_run_id=pools.baseline_run_id,
best_val_acc=pools.baseline_val_accuracy,
best_skills_version=skills_v,
best_prompts_version=prompts_v,
baseline_skills_version=skills_v,
baseline_prompts_version=prompts_v,
)
def _restore_train_state(
self,
ckpt: dict,
pools: Pools,
gate_pools: GatePools,
baseline_cache: BaselineCache,
) -> _TrainState:
"""从 checkpoint 重建 _TrainState。"""
fields = deserialize_state_fields(ckpt["state"])
best = read_best(self._config.workspace_dir) or {}
return _TrainState(
gate_pools=gate_pools,
baseline_cache=baseline_cache,
best_val_acc=best.get("val_acc", pools.baseline_val_accuracy),
best_skills_version=best.get("skills", "skills/v1").split("/")[-1],
best_prompts_version=best.get("prompts", "prompts/v1").split("/")[-1],
**fields,
)
# -----------------------------------------------------------------------
# _run_steprollout → correctness → diagnose → accumulate → gate → cooldown
# -----------------------------------------------------------------------
async def _run_step(
self,
epoch: int,
step: int,
total_steps: int,
batch: list[GeneratedQuestion],
pools: Pools,
state: _TrainState,
) -> None:
"""单 steprollout → correctness 增量 → 诊断 → 累加 system/tool → 按类 gate。"""
run_id = f"{pools.baseline_run_id}_e{epoch}_s{step}"
await self._rollout_batch(batch, run_id)
from app.harness.log import HarnessLog
with HarnessLog(str(self._paths.db_path), run_id) as log:
_apply_batch_correctness(state.correctness, log, run_id, batch)
diagnosis = await self._run_diagnosis(run_id, question_ids=[q.question_id for q in batch])
_accumulate_slow_packs(diagnosis, state)
await self._gate_batch_skills(epoch, step, diagnosis, total_steps, pools, state)
# 冷却计数每 step 递减、归零剔除
state.gate_cooldown = {t: n - 1 for t, n in state.gate_cooldown.items() if n - 1 > 0}
# 测试中断注入点
if getattr(self, "_interrupt_after_step", None) == step:
raise _InterruptError(f"模拟中断于 epoch{epoch} step{step}")
async def _rollout_batch(self, batch: list[GeneratedQuestion], run_id: str) -> None:
"""用当前 skill 版本重推该 batch。"""
result = await self._run_inference_on_pool(
batch, run_id, self._paths.skills_dir, self._paths.prompts_dir
)
_guard_infra_failures(result, context="rollout")
# -----------------------------------------------------------------------
# _gate_batch_skillsper task_type gate
# -----------------------------------------------------------------------
async def _gate_batch_skills(
self,
epoch: int,
step: int,
diagnosis: DiagnosisResult,
total_steps: int,
pools: Pools,
state: _TrainState,
) -> None:
"""按 task_type 独立 evolve → 局部验证 → accept/reject。"""
from app.harness.workspace import VersionedSkillStore
from core.evolution import evolve_single_skill
budget = edit_budget_at(
global_step=state.global_step,
total_steps=total_steps,
start=self._config.edit_budget_start,
end=self._config.edit_budget_end,
)
for task_type in sorted(diagnosis.skill_case_packs):
# 冷却 admission control
if state.gate_cooldown.get(task_type, 0) > 0:
_write_skip_report(
self._config.workspace_dir,
epoch,
step,
state.global_step,
task_type,
action="cooldown",
baseline_acc=self._class_baseline_acc(
task_type, pools.validation, state.correctness
),
budget=budget,
)
continue
pack = diagnosis.skill_case_packs[task_type]
skill_store = VersionedSkillStore(self._paths.skills_dir)
evolve_prompts = self._load_evolve_prompts()
record = await evolve_single_skill(
self._evolve_llm,
pack,
skill_store,
evolve_prompts,
self._current_version("skills"),
budget,
self._config.appendix_consolidate_threshold,
skill_update_mode=self._config.skill_update_mode,
rejected=state.rejected_buffer.get(task_type, []),
)
# 进化未产出真实改动
if record.status in ("rejected", "skipped") or (
record.evolved_content == record.original_content
):
_write_skip_report(
self._config.workspace_dir,
epoch,
step,
state.global_step,
task_type,
action="skipped",
baseline_acc=self._class_baseline_acc(
task_type, pools.validation, state.correctness
),
budget=budget,
rank_clip_triggered=bool(record.clip_info.get("triggered", False)),
)
continue
outcome = await self._run_gate_validation(
epoch, step, task_type, pack, record, pools, state
)
# 观测落库
write_gate_evidence(
str(self._paths.db_path),
run_id=pools.baseline_run_id,
epoch=epoch,
step=step,
rows=outcome.evidence_rows,
)
write_step_report(
self._config.workspace_dir,
epoch=epoch,
step=step,
global_step=state.global_step,
task_type=task_type,
gate_action=outcome.action,
candidate_acc=outcome.candidate_acc,
class_baseline_acc=outcome.baseline_acc,
edit_budget=budget,
rank_clip_triggered=bool(record.clip_info.get("triggered", False)),
gate_w=outcome.w,
gate_l=outcome.l,
gate_e_value=outcome.e_value,
gate_n_used=outcome.n_used,
gate_stop_reason=outcome.stop_reason,
)
write_quadrant_pairs(
str(self._paths.db_path),
run_id=pools.baseline_run_id,
epoch=epoch,
step=step,
pairs=_outcome_to_quadrant_pairs(task_type, outcome),
)
if outcome.accepted:
self._accept_skill(task_type, record, outcome, state, pools)
else:
self._record_rejected_skill(
state.rejected_buffer, task_type, record, outcome, state.global_step
)
async def _run_gate_validation(
self,
epoch: int,
step: int,
task_type: str,
pack: Any,
record: EvolutionRecord,
pools: Pools,
state: _TrainState,
) -> ValidationOutcome:
"""CE-Gate 块序贯配对验证:阶梯出题 → 基线/候选逐块配对 → e-process 四出口。
参数:
epoch: 轮次。
step: epoch 内 step。
task_type: 待验证题型。
pack: SkillCasePack。
record: 进化产物。
pools: 冻结三池。
state: 训练状态。
返回:
ValidationOutcome。
"""
from app.harness.log import HarnessLog
from app.harness.validate import validate_skill_local
exclude_qids = {c.question_id for c in pack.failure_cases + pack.success_cases}
ladder_qids = state.gate_pools.ladder_for(
task_type,
exclude_qids,
p_low=self._config.gate_p_low,
p_high=self._config.gate_p_high,
cold=not state.gate_epoch_observed,
)
missing = [qid for qid in ladder_qids if qid not in self._gate_questions_by_id]
if missing:
raise ValueError(
f"gate 阶梯[{task_type}] 含 benchmark 中不存在的题: "
f"{missing[:5]}gate_pools.json 与题库失配)"
)
ladder_items = [self._gate_questions_by_id[qid] for qid in ladder_qids]
base_skill_content = (self._paths.skills_dir / record.target_file).read_text(
encoding="utf-8"
)
slug = task_type.lower().replace(" ", "-")
run_inference_fn = self._make_validate_run_inference_fn()
with HarnessLog(str(self._paths.db_path), f"gate_{slug}") as gate_log:
return await validate_skill_local(
workspace_dir=self._config.workspace_dir,
base_skills_version=self._current_version("skills"),
task_type=task_type,
target_file=record.target_file,
candidate_content=record.evolved_content,
base_skill_content=base_skill_content,
ladder_items=ladder_items,
gate_params=GateParams(
e_confirm=self._config.gate_e_confirm,
e_provisional=self._config.gate_e_provisional,
w_net_min=self._config.gate_w_net_min,
delta_min=self._config.gate_delta_min,
lambda_dir=self._config.gate_lambda_dir,
e_rollback=self._config.gate_e_rollback,
),
gate_block=self._config.gate_block,
gate_n_max=self._config.gate_n_max,
gate_guard_err=self._config.gate_guard_err,
baseline_cache=state.baseline_cache,
prompts_version=self._current_version("prompts"),
run_inference=run_inference_fn,
log=gate_log,
gate_run_prefix=(f"{pools.baseline_run_id}_e{epoch}_s{step}_gate_{slug}"),
)
# -----------------------------------------------------------------------
# accept / reject / probation
# -----------------------------------------------------------------------
def _accept_skill(
self,
task_type: str,
record: EvolutionRecord,
outcome: ValidationOutcome,
state: _TrainState,
pools: Pools,
) -> None:
"""accept:写候选为新 skills 版本 → manifest → 路径 → 前移 correctness。
probation 分岔:provisional + 无现有试用 + 非 default-strategy.md → 开账。
试用中追加 pending_edits。
"""
pre_accept_version = self._current_version("skills")
# 开账快照在合并前拍取
pre_merge_snapshot = {
q.question_id: state.correctness.get(q.question_id, False)
for q in pools.validation
if q.task_type == task_type
}
new_version = self._promote_skill_version(record.evolved_content, record.target_file)
update_manifest(self._config.workspace_dir, skills=f"skills/{new_version}")
self._paths = resolve_paths(self._config.workspace_dir)
# correctness 二轨合并(只合并已观测题)
state.correctness.update(outcome.candidate_correctness)
# 清该类黑名单
state.rejected_buffer.pop(task_type, None)
state.changed_task_types_this_epoch.add(task_type)
# probation 分岔
if (
outcome.action == "accept_provisional"
and task_type not in state.probations
and record.target_file != "default-strategy.md"
):
state.probations[task_type] = Probation(
task_type=task_type,
anchor_skills_version=pre_accept_version,
target_file=record.target_file,
correctness_snapshot=pre_merge_snapshot,
opened_step=state.global_step,
)
elif outcome.action == "accept_provisional" and record.target_file == "default-strategy.md":
logger.warning(
"按类 gate[{}] provisional 落在共享 default-strategy.md,跳过试用直接转正",
task_type,
)
if task_type in state.probations:
state.probations[task_type].pending_edits.append(
RejectedEdit(
target_file=record.target_file,
target_type=record.target_type,
change_summary=self._rejected_summary(record, outcome),
delta=outcome.delta_hat,
source_version=record.source_version,
epoch=state.global_step,
gate_w=outcome.w,
gate_l=outcome.l,
gate_e_value=outcome.e_value,
gate_delta_shrunk=outcome.delta_shrunk,
)
)
logger.info(
"按类 gate[{}] accept: 候选{:.1%} (观测基线{:.1%}) → skills/{}",
task_type,
outcome.candidate_acc,
outcome.baseline_acc,
new_version,
)
def _rollback_probation(self, probation: Probation, state: _TrainState) -> None:
"""试用期回滚:文件级 revert 到锚版本 + 恢复快照 + 冷却 + 证据入黑名单。"""
anchor_content = (
self._config.workspace_dir
/ "skills"
/ probation.anchor_skills_version
/ probation.target_file
).read_text(encoding="utf-8")
new_version = self._promote_skill_version(anchor_content, probation.target_file)
update_manifest(self._config.workspace_dir, skills=f"skills/{new_version}")
self._paths = resolve_paths(self._config.workspace_dir)
state.correctness.update(probation.correctness_snapshot)
state.gate_cooldown[probation.task_type] = self._config.gate_cooldown_steps
state.rejected_buffer.setdefault(probation.task_type, []).extend(probation.pending_edits)
logger.info(
"probation 回滚[{}]: skills 文件 {} 恢复至锚版本 {} → 新版本 {},冷却 {} step",
probation.task_type,
probation.target_file,
probation.anchor_skills_version,
new_version,
self._config.gate_cooldown_steps,
)
@staticmethod
def _record_rejected_skill(
rejected_buffer: dict[str, list],
task_type: str,
record: EvolutionRecord,
outcome: ValidationOutcome,
global_step: int,
) -> None:
"""reject:按 task_type 累加 A5 黑名单。"""
rejected_buffer.setdefault(task_type, []).append(
RejectedEdit(
target_file=record.target_file,
target_type=record.target_type,
change_summary=Runner._rejected_summary_static(record, outcome),
delta=outcome.delta_hat,
source_version=record.source_version,
epoch=global_step,
gate_w=outcome.w,
gate_l=outcome.l,
gate_e_value=outcome.e_value,
gate_delta_shrunk=outcome.delta_shrunk,
)
)
logger.info(
"按类 gate[{}] reject: 候选{:.1%} (观测基线{:.1%}) 回退该 skill",
task_type,
outcome.candidate_acc,
outcome.baseline_acc,
)
def _rejected_summary(self, record: EvolutionRecord, outcome: ValidationOutcome) -> str:
"""为被拒进化记录生成黑名单摘要:只拼真正 applied 的 edit。"""
return Runner._rejected_summary_static(record, outcome)
@staticmethod
def _rejected_summary_static(record: EvolutionRecord, outcome: ValidationOutcome) -> str:
"""黑名单摘要实现(静态方法,供 accept / reject 两侧复用)。
为何只记 applied:未 applied 的 edit 从未写进候选正文、从未被 gate
验证过,进黑名单会污染「已验证无效」语义。
"""
applied_summary = _format_applied_edits(record)
if applied_summary is not None:
return applied_summary
return _fallback_summary(record, outcome)
# -----------------------------------------------------------------------
# _slow_update_cycle 十步序
# -----------------------------------------------------------------------
async def _slow_update_cycle(self, epoch: int, pools: Pools, state: _TrainState) -> None:
"""epoch 末慢更新十步序。
1. 捕获版本快照 → 全 val 重跑 R
2. soft score + dual_metric 落库
3. R 逐题对错无条件回写
4. probation 结算(回滚者覆盖 step 3
5. best argmax(严格大于)
6. momentum(不可变新版本,按 skill 文件分组)
7. system/tool 慢更新(edit_budget_end
8. R2 闭环
9. 三态标签 + epoch_report + 四向 held-out
10. gate 阶梯刷新
"""
# Phase 1
eval_skills_version = self._current_version("skills")
eval_prompts_version = self._current_version("prompts")
eval_r = await self._eval_full_val(epoch, pools)
# Phase 2: soft + dual_metric
eval_soft = await self._try_soft_score(eval_r.run_id, pools.validation)
write_dual_metric(
str(self._paths.db_path),
run_id=self._config.run_id,
epoch=epoch,
version_kind="final",
skills_version=eval_skills_version,
prompts_version=eval_prompts_version,
pool="val",
hard_acc=eval_r.accuracy,
soft_score=eval_soft,
mixed_score=(None if eval_soft is None else 0.5 * eval_r.accuracy + 0.5 * eval_soft),
)
# Phase 3: 无条件回写
self._writeback_val_correctness(eval_r.run_id, pools, state)
# Phase 4: probation 结算
self._settle_probations(eval_r.run_id, state)
# Phase 5: best argmax
self._maybe_promote_best(
eval_skills_version,
eval_prompts_version,
eval_r.accuracy,
eval_r.run_id,
epoch,
state,
)
# Phase 6: momentum
momentum_task_types = await self._write_momentum_for_changed_skills(
state, pools, epoch, eval_skills_version
)
# Phase 7: system/tool 慢更新
pre_prompts_version = self._current_version("prompts")
system_tool_updated = await self._update_system_tool(epoch, state)
system_tool_reverted = False
# Phase 8: R2 闭环
r2_kept_run_ids: list[str] | None = None
if system_tool_updated:
r2_skills_version = self._current_version("skills")
new_prompts_version = self._current_version("prompts")
eval_r2 = await self._eval_full_val(epoch, pools, run_suffix="_p2")
write_dual_metric(
str(self._paths.db_path),
run_id=self._config.run_id,
epoch=epoch,
version_kind="final",
skills_version=r2_skills_version,
prompts_version=new_prompts_version,
pool="val",
hard_acc=eval_r2.accuracy,
soft_score=None,
mixed_score=None,
)
system_tool_reverted = eval_r2.accuracy < eval_r.accuracy
if system_tool_reverted:
self._revert_system_tool(pre_prompts_version)
else:
self._writeback_val_correctness(eval_r2.run_id, pools, state)
self._maybe_promote_best(
r2_skills_version,
new_prompts_version,
eval_r2.accuracy,
eval_r2.run_id,
epoch,
state,
)
state.eval_prev_acc = eval_r2.accuracy
state.eval_prev_run_id = eval_r2.run_id
r2_kept_run_ids = [eval_r2.run_id]
if (not system_tool_updated) or system_tool_reverted:
state.eval_prev_acc = eval_r.accuracy
state.eval_prev_run_id = eval_r.run_id
# Phase 9: 三态标签 + epoch_report + held-out
if system_tool_reverted:
system_tool_action = "reverted"
elif system_tool_updated:
system_tool_action = "updated"
else:
system_tool_action = "none"
write_epoch_report(
self._config.workspace_dir,
epoch=epoch,
system_tool_action=system_tool_action,
momentum_updated_task_types=momentum_task_types,
best_val_acc=state.best_val_acc,
)
await self._holdout_four_way(epoch, pools, state, eval_skills_version, eval_prompts_version)
# Phase 10: gate 阶梯刷新
self._refresh_gate_ladder(
epoch, pools.baseline_run_id, state, extra_run_ids=r2_kept_run_ids
)
# -----------------------------------------------------------------------
# 慢更新内部方法
# -----------------------------------------------------------------------
def _settle_probations(self, eval_run_id: str, state: _TrainState) -> None:
"""epoch 末试用期一次性结算:全 val 重跑逐题结果与锚快照配对。
参数:
eval_run_id: 本 epoch 全 val 重跑(R)的 run_id。
state: 训练状态(probations 结算后清空)。
异常:
RuntimeError: 重跑缺某快照题的预测行。
"""
if not state.probations:
return
from app.harness.log import HarnessLog
from app.harness.validate import _load_run_rows
with HarnessLog(str(self._paths.db_path), eval_run_id) as log:
rows = _load_run_rows(log, eval_run_id)
params = GateParams(
e_confirm=self._config.gate_e_confirm,
e_provisional=self._config.gate_e_provisional,
w_net_min=self._config.gate_w_net_min,
delta_min=self._config.gate_delta_min,
lambda_dir=self._config.gate_lambda_dir,
e_rollback=self._config.gate_e_rollback,
)
for task_type in sorted(state.probations):
probation = state.probations[task_type]
w = l = 0 # noqa: E741
for qid, snap_correct in probation.correctness_snapshot.items():
row = rows.get(qid)
if row is None:
raise RuntimeError(
f"probation 结算缺预测行: {task_type}/{qid}run={eval_run_id}"
)
cur = row["_correct"]
if not snap_correct and cur:
w += 1
elif snap_correct and not cur:
l += 1 # noqa: E741
verdict = probation_verdict(w, l, params=params)
logger.info("probation 结算[{}]: W={} L={}{}", task_type, w, l, verdict)
if verdict == "rollback":
self._rollback_probation(probation, state)
state.probations.clear()
async def _eval_full_val(
self, epoch: int, pools: Pools, run_suffix: str = ""
) -> InferenceResult:
"""全验证池重跑一次并护栏。"""
run_id = f"{self._config.run_id}_slow_e{epoch}{run_suffix}"
result = await self._run_inference_on_pool(
pools.validation, run_id, self._paths.skills_dir, self._paths.prompts_dir
)
_guard_infra_failures(result, context="全 val 重跑")
return result
def _writeback_val_correctness(
self, eval_run_id: str, pools: Pools, state: _TrainState
) -> None:
"""把全 val 重跑逐题对错回写进 state.correctness。"""
from app.harness.log import HarnessLog
from app.harness.validate import _load_run_rows
with HarnessLog(str(self._paths.db_path), eval_run_id) as log:
rows = _load_run_rows(log, eval_run_id)
for q in pools.validation:
row = rows.get(q.question_id)
if row is not None:
state.correctness[q.question_id] = row["_correct"]
def _maybe_promote_best(
self,
skills_v: str,
prompts_v: str,
eval_acc: float,
run_id: str,
epoch: int,
state: _TrainState,
) -> None:
"""全局 best argmax(严格大于才推进)。"""
if eval_acc <= state.best_val_acc:
return
state.best_val_acc = eval_acc
state.best_skills_version = skills_v
state.best_prompts_version = prompts_v
state.steps_since_best_improved = 0
update_best(
self._config.workspace_dir,
skills=f"skills/{skills_v}",
prompts=f"prompts/{prompts_v}",
val_acc=eval_acc,
run_id=run_id,
epoch=epoch,
)
logger.info(
"全局 best argmax 刷新: {:.1%} → skills/{} prompts/{}",
eval_acc,
skills_v,
prompts_v,
)
async def _write_momentum_for_changed_skills(
self,
state: _TrainState,
pools: Pools,
epoch: int,
eval_skills_version: str,
) -> list[str]:
"""为本 epoch 改过的题型写 momentum:推进不可变新版本。
返回:
实际写过 momentum 的题型列表。
"""
if not self._config.use_slow_momentum:
return []
if not state.changed_task_types_this_epoch:
return []
file_to_task_types = self._group_changed_task_types_by_file(
state.changed_task_types_this_epoch
)
with tempfile.TemporaryDirectory() as tmp:
staged_skills = Path(tmp) / "skills"
shutil.copytree(self._paths.skills_dir, staged_skills, dirs_exist_ok=True)
for target_file in sorted(file_to_task_types):
await self._stage_momentum_for_file(
target_file,
file_to_task_types[target_file],
state,
pools,
staged_skills,
epoch,
)
new_version = advance_version(
self._paths.workspace_dir,
"skills",
staged_skills,
{
"source": "slow_momentum",
"parent": eval_skills_version,
"description": "epoch 末 momentum(不可变新版本)",
},
)
update_manifest(self._config.workspace_dir, skills=f"skills/{new_version}")
self._paths = resolve_paths(self._config.workspace_dir)
logger.info(
"Epoch 末 momentum → skills/{}(不改 eval 版本 {}",
new_version,
eval_skills_version,
)
return sorted(state.changed_task_types_this_epoch)
async def _stage_momentum_for_file(
self,
target_file: str,
task_types: list[str],
state: _TrainState,
pools: Pools,
staged_skills: Path,
epoch: int,
) -> None:
"""单个 skill 文件的 momentum 生成:诊断池采样 → 两版 rollout → 纵向对比。"""
from app.harness.log import HarnessLog
from app.harness.momentum import run_slow_momentum
from app.harness.validate import _load_run_rows
skill_path = staged_skills / target_file
skill_content = skill_path.read_text(encoding="utf-8")
prev_skill = state.epoch_start_skills.get(target_file, skill_content)
prev_guidance = momentum_inner(skill_content)
# 采样
allowed = set(task_types)
candidates = [q for q in pools.diagnosis if q.task_type in allowed]
rng = random.Random(epoch)
n = min(self._config.momentum_samples, len(candidates))
sampled = rng.sample(candidates, n) if n > 0 else []
if not sampled:
skill_path.write_text(
replace_momentum(skill_content, prev_guidance or ""),
encoding="utf-8",
)
logger.debug(
"Epoch {} momentum 跳过 {}:诊断池无匹配题型 {} 的样本",
epoch,
target_file,
sorted(task_types),
)
return
# 两版 rollout
prev_run_id = f"momentum_prev_e{epoch}_{target_file.replace('.md', '')}"
curr_run_id = f"momentum_curr_e{epoch}_{target_file.replace('.md', '')}"
with tempfile.TemporaryDirectory() as prev_tmp:
prev_skills_dir = Path(prev_tmp) / "skills"
shutil.copytree(self._paths.skills_dir, prev_skills_dir, dirs_exist_ok=True)
(prev_skills_dir / target_file).write_text(prev_skill, encoding="utf-8")
await self._run_inference_on_pool(
sampled, prev_run_id, prev_skills_dir, self._paths.prompts_dir
)
await self._run_inference_on_pool(
sampled, curr_run_id, self._paths.skills_dir, self._paths.prompts_dir
)
with HarnessLog(str(self._paths.db_path), prev_run_id) as log:
prev_rows = _load_run_rows(log, prev_run_id)
with HarnessLog(str(self._paths.db_path), curr_run_id) as log:
curr_rows = _load_run_rows(log, curr_run_id)
comparison_pairs = _build_comparison_pairs(sampled, prev_rows, curr_rows)
guidance = await run_slow_momentum(
llm=self._evolve_llm,
diagnose_prompts_dir=Path("prompts"),
skill_content=skill_content,
prev_skill=prev_skill,
prev_guidance=prev_guidance,
comparison_pairs=comparison_pairs,
)
new_content = replace_momentum(skill_content, guidance)
skill_path.write_text(new_content, encoding="utf-8")
logger.info(
"Epoch {} momentum 写入 skill 文件 {}(题型 {},采样 {} 题)",
epoch,
target_file,
sorted(task_types),
len(sampled),
)
async def _update_system_tool(self, epoch: int, state: _TrainState) -> bool:
"""merge 本 epoch 累加的 system/tool 案例包 → 进化 → accept 写新 prompts 版本。
返回:
是否实际写了新 prompts 版本。
"""
from app.harness.workspace import VersionedPromptStore
from core.evolution import evolve_single_tool, evolve_system_prompt
merged_system = merge_system_packs(state.system_packs)
merged_tools = merge_tool_packs(state.tool_packs)
source_version = self._current_version("prompts")
max_edits = self._config.edit_budget_end
evolve_prompts = self._load_evolve_prompts()
prompt_store = VersionedPromptStore(self._paths.prompts_dir)
records: list[EvolutionRecord] = []
if merged_system is not None:
records.append(
await evolve_system_prompt(
self._evolve_llm,
merged_system,
prompt_store,
evolve_prompts,
source_version,
max_edits,
)
)
for tool_name in sorted(merged_tools):
records.append(
await evolve_single_tool(
self._evolve_llm,
merged_tools[tool_name],
prompt_store,
evolve_prompts,
source_version,
max_edits,
)
)
accepted = [r for r in records if r.status == "accepted"]
if not accepted:
logger.debug("Epoch {} 慢更新:无 system/tool 改动被接受", epoch)
return False
new_version = self._write_accepted_prompts_version(accepted, source_version)
if new_version is None:
return False
update_manifest(self._config.workspace_dir, prompts=f"prompts/{new_version}")
self._paths = resolve_paths(self._config.workspace_dir)
logger.info("Epoch {} 慢更新:system/tool → prompts/{}", epoch, new_version)
return True
def _write_accepted_prompts_version(
self, accepted: list[EvolutionRecord], source_version: str
) -> str | None:
"""将 accepted system/tool records 写成新 prompts 版本。
参数:
accepted: 状态为 accepted 的 EvolutionRecord 列表。
source_version: 改写前 prompts 版本。
返回:
新版本号,或 None(无实际变化时)。
"""
with tempfile.TemporaryDirectory() as tmp:
staged = Path(tmp) / "prompts"
shutil.copytree(self._paths.prompts_dir, staged, dirs_exist_ok=True)
any_changed = False
for rec in accepted:
if rec.target_type == "tool":
# tool: evolved_content = json.dumps({"extract": ..., "verify": ...})
combined = json.loads(rec.evolved_content)
for key in ("extract", "verify"):
fname = rec.target_file.replace("_extract.md", f"_{key}.md")
(staged / fname).write_text(combined[key], encoding="utf-8")
any_changed = True
else:
(staged / rec.target_file).write_text(rec.evolved_content, encoding="utf-8")
any_changed = True
if not any_changed:
return None
return advance_version(
self._paths.workspace_dir,
"prompts",
staged,
{
"source": "evolution",
"parent": source_version,
"description": "epoch 末 system/tool 慢更新",
},
)
def _revert_system_tool(self, pre_prompts_version: str) -> None:
"""prompts-only delta 退步时回退到更新前版本。"""
update_manifest(self._config.workspace_dir, prompts=f"prompts/{pre_prompts_version}")
self._paths = resolve_paths(self._config.workspace_dir)
logger.info(
"慢更新 prompts-only delta 退步:system/tool 回退到 prompts/{}",
pre_prompts_version,
)
def _refresh_gate_ladder(
self,
epoch: int,
base_run_id: str,
state: _TrainState,
extra_run_ids: list[str] | None = None,
) -> None:
"""用本 epoch 非 gate run 的逐题观测 γ-EMA 更新阶梯 p-hat 并落盘。
精确三源:step rollout GLOB 排除 _gate_ + slow R + kept R2。
"""
db_path = resolve_paths(self._config.workspace_dir).db_path
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
try:
rows = conn.execute(
"SELECT question_id, prediction, answer FROM predictions "
"WHERE run_id GLOB ? AND run_id NOT GLOB '*_gate_*' "
"ORDER BY rowid",
(f"{base_run_id}_e{epoch}_s*",),
).fetchall()
slow_rows = conn.execute(
"SELECT question_id, prediction, answer FROM predictions "
"WHERE run_id=? ORDER BY rowid",
(f"{self._config.run_id}_slow_e{epoch}",),
).fetchall()
extra_rows_lists = [
conn.execute(
"SELECT question_id, prediction, answer FROM predictions "
"WHERE run_id=? ORDER BY rowid",
(rid,),
).fetchall()
for rid in (extra_run_ids or [])
]
finally:
conn.close()
obs = {r["question_id"]: r["prediction"] == r["answer"] for r in rows}
obs.update({r["question_id"]: r["prediction"] == r["answer"] for r in slow_rows})
for extra_rows in extra_rows_lists:
obs.update({r["question_id"]: r["prediction"] == r["answer"] for r in extra_rows})
state.gate_pools.update_probs(obs, gamma=self._config.gate_gamma_decay)
state.gate_pools.save(self._config.workspace_dir / "gate_pools.json")
state.gate_epoch_observed = True
async def _holdout_four_way(
self,
epoch: int,
pools: Pools,
state: _TrainState,
eval_skills_version: str,
eval_prompts_version: str,
) -> None:
"""四向 held-outbaseline/best_hard/final/best_mixed 各在 test 池评估。
test 池仅观测落库,绝不进 gate/best/early-stop/调参。
"""
best_mixed = await self._pick_mixed_best(
epoch, pools, state, eval_skills_version, eval_prompts_version
)
versions: dict[str, tuple[str, str] | None] = {
"baseline": (state.baseline_skills_version, state.baseline_prompts_version),
"best_hard": (state.best_skills_version, state.best_prompts_version),
"final": (eval_skills_version, eval_prompts_version),
"best_mixed": best_mixed,
}
for version_kind, version in versions.items():
if version is None:
continue
sv, pv = version
run_id = f"{self._config.run_id}_holdout_{version_kind}_e{epoch}"
res = await self._eval_version_on_pool(
sv, pv, pools.test, run_id, context=f"held-out {version_kind}"
)
soft = await self._try_soft_score(run_id, pools.test)
mixed = None if soft is None else 0.5 * res.accuracy + 0.5 * soft
write_holdout_eval(
str(self._paths.db_path),
run_id=self._config.run_id,
epoch=epoch,
version_kind=version_kind,
hard_acc=res.accuracy,
soft_score=soft,
mixed_score=mixed,
per_task_type_json=json.dumps(res.per_task_type, ensure_ascii=False),
)
async def _pick_mixed_best(
self,
epoch: int,
pools: Pools,
state: _TrainState,
eval_skills_version: str,
eval_prompts_version: str,
) -> tuple[str, str] | None:
"""在 val 池对候选集算 mixed,落 shadow_gate,返回 argmax mixed 版本。
只观测落库,绝不改 manifest/best/early-stop。
"""
candidates = {
"best_hard": (state.best_skills_version, state.best_prompts_version),
"final": (eval_skills_version, eval_prompts_version),
}
best_kind: str | None = None
best_mixed: float | None = None
for kind, (sv, pv) in candidates.items():
run_id = f"{self._config.run_id}_shadow_{kind}_e{epoch}"
res = await self._eval_version_on_pool(
sv, pv, pools.validation, run_id, context=f"mixed 影子 {kind}"
)
soft = await self._try_soft_score(run_id, pools.validation)
mixed = None if soft is None else 0.5 * res.accuracy + 0.5 * soft
write_shadow_gate(
str(self._paths.db_path),
run_id=self._config.run_id,
epoch=epoch,
candidate_version=f"skills/{sv}+prompts/{pv}",
hard_acc=res.accuracy,
soft_score=soft,
mixed_score=mixed,
is_mixed_best=False,
)
if mixed is not None and (best_mixed is None or mixed > best_mixed):
best_mixed, best_kind = mixed, kind
if best_kind is None:
return None
self._mark_shadow_best(epoch, candidates[best_kind])
return candidates[best_kind]
def _mark_shadow_best(self, epoch: int, best_version: tuple[str, str]) -> None:
"""回标 shadow_gate 中 argmax mixed 选中的版本 is_mixed_best=1。"""
sv, pv = best_version
candidate_version = f"skills/{sv}+prompts/{pv}"
conn = sqlite3.connect(str(self._paths.db_path))
try:
conn.execute(
"UPDATE shadow_gate SET is_mixed_best=1 WHERE rowid = ("
" SELECT rowid FROM shadow_gate "
" WHERE run_id=? AND epoch=? AND candidate_version=? LIMIT 1"
")",
(self._config.run_id, epoch, candidate_version),
)
conn.commit()
finally:
conn.close()
# -----------------------------------------------------------------------
# 收尾
# -----------------------------------------------------------------------
def _deliver_best(self, best_skills_version: str, best_prompts_version: str) -> None:
"""若当前 current 不是历史最优,回滚 manifest 到 best 并刷新路径。"""
cur = load_manifest(self._config.workspace_dir)["current"]
if (
cur["skills"] != f"skills/{best_skills_version}"
or cur["prompts"] != f"prompts/{best_prompts_version}"
):
update_manifest(
self._config.workspace_dir,
skills=f"skills/{best_skills_version}",
prompts=f"prompts/{best_prompts_version}",
)
self._paths = resolve_paths(self._config.workspace_dir)
logger.info(
"收尾交付 bestcurrent → skills/{} prompts/{}",
best_skills_version,
best_prompts_version,
)
async def _final_test_eval(self, pools: Pools) -> None:
"""收尾在 held-out test 池跑一次评估。"""
run_id = f"{self._config.run_id}_final_test"
result = await self._run_inference_on_pool(
pools.test, run_id, self._paths.skills_dir, self._paths.prompts_dir
)
_guard_infra_failures(result, context="held-out test 评估")
report = {
"run_id": result.run_id,
"accuracy": result.accuracy,
"total": result.total,
"correct": result.correct,
"per_task_type": result.per_task_type,
}
path = self._config.workspace_dir / "analyses" / "final_test_eval.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
logger.info("held-out test 评估写入: {} (acc={:.1%})", path, result.accuracy)
# -----------------------------------------------------------------------
# 私有辅助
# -----------------------------------------------------------------------
def _current_version(self, kind: str) -> str:
"""读取 manifest current 指针中某类资源的当前版本名。"""
return load_manifest(self._config.workspace_dir)["current"][kind].split("/")[-1]
def _current_version_snapshot(self) -> dict[str, str]:
"""读 manifest.current 的 skills/prompts 指针。"""
cur = load_manifest(self._config.workspace_dir)["current"]
return {"skills": cur["skills"], "prompts": cur["prompts"]}
def _class_baseline_acc(
self,
task_type: str,
validation: list[GeneratedQuestion],
correctness: dict[str, bool],
) -> float:
"""该 task_type 验证子集在当前 correctness 下的准确率。"""
class_items = [q for q in validation if q.task_type == task_type]
assert class_items, f"task_type={task_type} 在验证池中无对应题目"
correct = sum(1 for q in class_items if correctness.get(q.question_id, False))
return correct / len(class_items)
def _promote_skill_version(self, content: str, target_file: str) -> str:
"""把候选 skill 内容写成新正式 skills 版本。"""
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "skills"
shutil.copytree(self._paths.skills_dir, src, dirs_exist_ok=True)
(src / target_file).write_text(content, encoding="utf-8")
return advance_version(
self._paths.workspace_dir,
"skills",
src,
{
"source": "evolution",
"parent": self._current_version("skills"),
"description": f"按类 gate accept {target_file}",
},
)
def _group_changed_task_types_by_file(
self, changed_task_types: set[str]
) -> dict[str, list[str]]:
"""把改过的 task_type 集合经 fallback 解析映射到 skill 文件,按文件分组。"""
from app.harness.workspace import VersionedSkillStore
skill_store = VersionedSkillStore(self._paths.skills_dir)
grouped: dict[str, list[str]] = {}
for task_type in changed_task_types:
skill_file = resolve_skill_file(skill_store, task_type)
grouped.setdefault(skill_file, []).append(task_type)
return grouped
def _record_run(self, run_id: str) -> Path:
"""将 current 版本快照追加到 manifest history,创建 run 目录。"""
from app.harness.workspace import record_run
return record_run(self._config.workspace_dir, run_id)
def _backfill_run_versions(
self, run_id: str, skills_version: str, prompts_version: str
) -> None:
"""eval run 的 skills/prompts 版本对 + questions_ref 回填进 _runs。"""
from app.harness.log import HarnessLog
with HarnessLog(str(self._paths.db_path), run_id) as log:
log.execute(
"UPDATE _runs SET skills_version = ?, prompts_version = ?, "
"questions_ref = ? WHERE run_id = ?",
(skills_version, prompts_version, self._config.questions, run_id),
)
def _write_eval_report(
self,
run_id: str,
skills_version: str,
prompts_version: str,
result: InferenceResult,
) -> None:
"""写 eval 评测报告 analyses/eval_{run_id}.json。"""
report = {
"run_id": run_id,
"skills_version": skills_version,
"prompts_version": prompts_version,
"accuracy": result.accuracy,
"total": result.total,
"correct": result.correct,
"stop_reason_counts": result.stop_reason_counts,
}
analyses_dir = self._config.workspace_dir / "analyses"
analyses_dir.mkdir(parents=True, exist_ok=True)
path = analyses_dir / f"eval_{run_id}.json"
path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
logger.info("eval 报告写入: {} (acc={:.1%})", path, result.accuracy)
async def _run_inference_on_pool(
self,
questions: list[GeneratedQuestion],
run_id: str,
skills_dir: Path,
prompts_dir: Path,
) -> InferenceResult:
"""用指定版本在给定题池跑一次 run_inference。"""
from app.harness.inference import run_inference
from app.harness.log import HarnessLog
self._record_run(run_id)
with HarnessLog(str(self._paths.db_path), run_id) as log:
return await run_inference(
questions=questions,
llm=self._llm,
tool_dispatch_fn=self._make_tool_dispatch_fn(skills_dir=skills_dir),
prompt_builder=self._make_prompt_builder(
skills_dir=skills_dir, prompts_dir=prompts_dir
),
log=log,
run_id=run_id,
concurrency=self._config.concurrency,
max_steps=self._config.max_steps,
skill_mode=self._config.skill_mode,
)
async def _eval_version_on_pool(
self,
skills_version: str,
prompts_version: str,
questions: list[GeneratedQuestion],
run_id: str,
context: str,
) -> InferenceResult:
"""用指定版本在给定池跑一次推理并护栏。"""
skills_dir = self._paths.workspace_dir / "skills" / skills_version
prompts_dir = self._paths.workspace_dir / "prompts" / prompts_version
result = await self._run_inference_on_pool(questions, run_id, skills_dir, prompts_dir)
_guard_infra_failures(result, context=context)
return result
async def _run_diagnosis(
self, run_id: str, *, question_ids: list[str] | None = None
) -> DiagnosisResult:
"""执行两阶段诊断。"""
from app.harness.log import RunLogImpl
from app.harness.workspace import VersionedSkillStore
from app.question_gen import load_benchmark
from core.evolution.diagnose import run_diagnosis
questions = load_benchmark(self._paths.questions_dir)
run_log = RunLogImpl(str(self._paths.db_path))
skill_store = VersionedSkillStore(self._paths.skills_dir)
diagnose_prompts = self._load_diagnose_prompts()
return await run_diagnosis(
run_id=run_id,
questions=questions,
tree_data={}, # tree_data 由诊断管线内部按需加载
llm=self._llm,
run_log=run_log,
skill_store=skill_store,
prompts=diagnose_prompts,
concurrency=self._config.concurrency,
question_ids=question_ids,
)
async def _try_soft_score(
self, run_id: str, questions: list[GeneratedQuestion]
) -> float | None:
"""尝试计算 soft score,失败降级为 None。"""
try:
# soft score 暂不实现(需诊断 span_evaluations 表),降级为 None
return None
except Exception:
logger.warning("soft score 计算失败(run={}),降级为 None", run_id)
return None
# -----------------------------------------------------------------------
# 注入工厂(暂用占位,由 main.py 绑定实际实现)
# -----------------------------------------------------------------------
def _make_tool_dispatch_fn(self, *, skills_dir: Path | None = None):
"""构造工具调度函数(由子类或 main.py 覆盖)。"""
async def _noop_dispatch(tool_name: str, args: dict, *, context: dict) -> str:
raise NotImplementedError(
f"工具 {tool_name} 调度未配置(需由 main.py 注入 tool_dispatch_fn"
)
return _noop_dispatch
def _make_prompt_builder(
self, *, skills_dir: Path | None = None, prompts_dir: Path | None = None
):
"""构造 prompt 构建函数(由子类或 main.py 覆盖)。"""
def _noop_builder(qa: GeneratedQuestion) -> tuple[str, str]:
raise NotImplementedError("prompt_builder 未配置(需由 main.py 注入)")
return _noop_builder
def _make_validate_run_inference_fn(self):
"""构造 validate 用的 RunInferenceFn(绑定共享依赖)。"""
from app.harness.inference import run_inference
from app.harness.log import HarnessLog
async def _run(
questions: list[GeneratedQuestion],
*,
run_id: str,
skills_dir: Path,
) -> InferenceResult:
self._record_run(run_id)
with HarnessLog(str(self._paths.db_path), run_id) as log:
return await run_inference(
questions=questions,
llm=self._llm,
tool_dispatch_fn=self._make_tool_dispatch_fn(skills_dir=skills_dir),
prompt_builder=self._make_prompt_builder(
skills_dir=skills_dir, prompts_dir=self._paths.prompts_dir
),
log=log,
run_id=run_id,
concurrency=self._config.concurrency,
max_steps=self._config.max_steps,
skill_mode=self._config.skill_mode,
)
return _run
def _load_evolve_prompts(self):
"""加载进化模板束(从项目根 prompts/ 读取诊断标尺模板)。"""
from core.evolution.types import EvolvePrompts
def _read(name: str) -> str:
p = Path("prompts") / name
return p.read_text(encoding="utf-8") if p.exists() else ""
return EvolvePrompts(
evolve_skill=_read("evolve_skill.md"),
evolve_system=_read("evolve_system.md"),
evolve_tool=_read("evolve_tool.md"),
evolve_rank=_read("evolve_rank.md"),
consolidate_system=_read("consolidate_system.md"),
)
def _load_diagnose_prompts(self):
"""加载诊断模板束(从项目根 prompts/ 读取)。"""
from core.evolution.types import DiagnosePrompts
def _read(name: str) -> str:
p = Path("prompts") / name
return p.read_text(encoding="utf-8") if p.exists() else ""
return DiagnosePrompts(
defect_vs_lapse=_read("defect_vs_lapse.md"),
reasoning_sub=_read("reasoning_sub.md"),
span_eval_system=_read("span_eval_system.md"),
span_eval_user=_read("span_eval_user.md"),
missed_nodes=_read("missed_nodes.md"),
skill_adherence=_read("skill_adherence.md"),
confirmation_bias=_read("confirmation_bias.md"),
evidence_sufficiency=_read("evidence_sufficiency.md"),
)