feat(harness): checkpoint.py — TrainState 序列化 + 原子写 + 指纹校验

This commit is contained in:
2026-07-07 13:13:37 -04:00
parent d7f1bdeea6
commit a6b816db94
2 changed files with 714 additions and 0 deletions
+301
View File
@@ -0,0 +1,301 @@
"""step 级续训 checkpoint_TrainState 可持久化字段的序列化 / 反序列化。
_TrainState 的累加包均为扁平纯数据 dataclass,经 dataclasses.asdict 序列化为
纯 JSON dict;反序列化时用 Cls(**d) 还原,其中 SystemCasePack 含嵌套 CaseSample
列表、Probation 含嵌套 RejectedEdit 列表,需逐个重建。
不持久化的字段:gate_pools / baseline_cache(各自文件级自持久化,resume 时按
指纹重载)、best_*(从 manifest best 指针读)、global_step(存 progress 块,
由 train 单独赋值)。gate_epoch_observed 持久化:warm p-hat 在 gate_pools.json
幸存,观测开关须随行,否则 resume 后阶梯排序回退冷启动序。
"""
from __future__ import annotations
import json
import os
from dataclasses import asdict, dataclass, field
from typing import TYPE_CHECKING, Any
from core.evolution.types import (
CaseSample,
RejectedEdit,
SystemCasePack,
ToolCasePack,
)
if TYPE_CHECKING:
from pathlib import Path
CHECKPOINT_SCHEMA_VERSION = 1
# ---------------------------------------------------------------------------
# Probation 类型(Task 10 validate.py 尚未就绪,暂定义在此供 checkpoint 使用)
# Task 10 完成后迁移至 app/harness/validate.py 并改为 re-export。
# ---------------------------------------------------------------------------
@dataclass
class Probation:
"""一个题型的在途试用账本(每题型至多一个)。
属性:
task_type: 题型。
anchor_skills_version: 锚版本名(最近一个 CONFIRMED 的 skills 版本)。
target_file: 该题型解析后的 skill 文件名。
correctness_snapshot: 开账时该题型 val 题的对错快照(回滚时恢复)。
opened_step: 开账时的 global_step(观测用)。
pending_edits: 试用链上全部候选 edit 的黑名单素材(回滚时整链入黑名单)。
"""
task_type: str
anchor_skills_version: str
target_file: str
correctness_snapshot: dict[str, bool]
opened_step: int
pending_edits: list[RejectedEdit] = field(default_factory=list)
# ---------------------------------------------------------------------------
# 结构性 / 决策性指纹键
# ---------------------------------------------------------------------------
_STRUCTURAL_KEYS = (
"batch_size",
"min_class_per_batch",
"epochs",
"diag_size",
"val_size",
"batch_correct_ratio",
)
_DECISION_KEYS = (
"edit_budget_start",
"edit_budget_end",
"early_stop_patience",
"use_slow_momentum",
"skill_update_mode",
"appendix_consolidate_threshold",
"momentum_samples",
"gate_e_confirm",
"gate_e_provisional",
"gate_w_net_min",
"gate_delta_min",
"gate_lambda_dir",
"gate_e_rollback",
"gate_block",
"gate_n_max",
"gate_p_low",
"gate_p_high",
"gate_probe_quota",
"gate_gamma_decay",
"gate_cooldown_steps",
"gate_guard_err",
)
# ---------------------------------------------------------------------------
# 序列化 / 反序列化
# ---------------------------------------------------------------------------
def serialize_state(state: Any) -> dict[str, Any]:
"""把 _TrainState 的可持久化字段转为纯 JSON dict。
参数:
state: _TrainState 实例(duck-typed,仅需含可持久化字段)。
返回:
纯 JSON 可序列化的 dict,不含 gate_pools / baseline_cache /
best_* / global_step。
关键实现细节:
- changed_task_types_this_epoch 是 setJSON 无 set,故 sorted 成有序列表。
- dataclass 均经 asdict 递归转 dict(含 SystemCasePack 嵌套 CaseSample、
Probation 嵌套 RejectedEdit)。
"""
return {
"correctness": state.correctness,
"eval_prev_acc": state.eval_prev_acc,
"eval_prev_run_id": state.eval_prev_run_id,
"baseline_skills_version": state.baseline_skills_version,
"baseline_prompts_version": state.baseline_prompts_version,
"steps_since_best_improved": state.steps_since_best_improved,
"epoch_start_skills": state.epoch_start_skills,
"changed_task_types_this_epoch": sorted(state.changed_task_types_this_epoch),
"rejected_buffer": {k: [asdict(x) for x in v] for k, v in state.rejected_buffer.items()},
"system_packs": [asdict(x) for x in state.system_packs],
"tool_packs": [asdict(x) for x in state.tool_packs],
"probations": {t: asdict(p) for t, p in state.probations.items()},
"gate_cooldown": state.gate_cooldown,
"gate_epoch_observed": state.gate_epoch_observed,
}
def _restore_system_pack(d: dict[str, Any]) -> SystemCasePack:
"""还原 SystemCasePack,含嵌套 CaseSample 列表。
参数:
d: asdict(SystemCasePack) 产出的 dict。
返回:
复活的 SystemCasePackfailure_cases / success_cases 重建为 CaseSample 实例。
"""
return SystemCasePack(
stats=d["stats"],
failure_cases=[CaseSample(**c) for c in d["failure_cases"]],
success_cases=[CaseSample(**c) for c in d["success_cases"]],
)
def deserialize_state_fields(d: dict[str, Any]) -> dict[str, Any]:
"""把序列化 dict 还原为可填入 _TrainState 的字段字典(dataclass 复活)。
参数:
d: serialize_state 产出并经 JSON 往返的 dict。
返回:
字段名 -> 值的 dict,可直接铺到 _TrainState;其中各 dataclass 已复活、
changed_task_types_this_epoch 还原为 set。
关键实现细节:
- RejectedEdit / ToolCasePack 字段均为标量/dict/list[dict]Cls(**d) 直接构造。
- SystemCasePack 含嵌套 CaseSample,交由 _restore_system_pack 重建。
- Probation 含嵌套 RejectedEdit 列表(pending_edits),先重建内层再构造外层。
- 直接取 d[...] 不用 .get 兜底:serialize 后的 checkpoint 必带全部键,
缺键即 checkpoint 损坏,应硬失败(P5 不掩盖)。
"""
return {
"correctness": d["correctness"],
"eval_prev_acc": d["eval_prev_acc"],
"eval_prev_run_id": d["eval_prev_run_id"],
"baseline_skills_version": d["baseline_skills_version"],
"baseline_prompts_version": d["baseline_prompts_version"],
"steps_since_best_improved": d["steps_since_best_improved"],
"epoch_start_skills": d["epoch_start_skills"],
"changed_task_types_this_epoch": set(d["changed_task_types_this_epoch"]),
"rejected_buffer": {
k: [RejectedEdit(**x) for x in v] for k, v in d["rejected_buffer"].items()
},
"system_packs": [_restore_system_pack(x) for x in d["system_packs"]],
"tool_packs": [ToolCasePack(**x) for x in d["tool_packs"]],
"probations": {
t: Probation(
**{
**d_p,
"pending_edits": [RejectedEdit(**x) for x in d_p["pending_edits"]],
}
)
for t, d_p in d["probations"].items()
},
"gate_cooldown": d["gate_cooldown"],
"gate_epoch_observed": d["gate_epoch_observed"],
}
# ---------------------------------------------------------------------------
# 配置指纹
# ---------------------------------------------------------------------------
def compute_fingerprint(config: Any) -> dict[str, Any]:
"""采集影响训练轨迹的配置项(结构性 + 决策性)。
参数:
config: 训练配置对象(duck-typed,需含 _STRUCTURAL_KEYS + _DECISION_KEYS 属性)。
返回:
指纹 dict,键为配置项名,值为对应配置值。
"""
return {k: getattr(config, k) for k in _STRUCTURAL_KEYS + _DECISION_KEYS}
def check_fingerprint(saved: dict[str, Any], config: Any) -> tuple[list[str], list[str]]:
"""比对保存的指纹与当前配置。返回 (结构性不一致项, 决策性不一致项)。
参数:
saved: checkpoint 中保存的 config_fingerprint。
config: 当前训练配置对象。
返回:
(structural, decision) 两个不一致项名列表。
关键实现细节:
结构性不一致(batch_size/min_class_per_batch/epochs/diag_size/val_size/
batch_correct_ratio)→ 调用方应拒绝 resume;决策性不一致 → 仅告警放行。
"""
cur = compute_fingerprint(config)
structural = [k for k in _STRUCTURAL_KEYS if saved.get(k) != cur[k]]
decision = [k for k in _DECISION_KEYS if saved.get(k) != cur[k]]
return structural, decision
# ---------------------------------------------------------------------------
# 读写 checkpoint
# ---------------------------------------------------------------------------
def write_checkpoint(
workspace_dir: Path,
*,
state: Any,
epoch: int,
step_completed: int,
phase: str,
global_step: int,
total_steps: int,
version_snapshot: dict[str, str],
epoch_batches: list[list[str]],
config: Any,
) -> None:
"""原子写 checkpoint.json.tmp 再 os.replace)。
参数:
workspace_dir: workspace 目录,checkpoint.json 写入其下。
state: _TrainState 实例,交由 serialize_state 序列化。
epoch: 当前 epoch 序号。
step_completed: 本 epoch 内已完成的 step 数。
phase: 续训阶段标识(如 "in_epoch")。
global_step: 全局 step 序号。
total_steps: 全局总 step 数。
version_snapshot: skills/prompts 版本快照。
epoch_batches: 本 epoch 的 batch 划分(question_id 列表的列表)。
config: 训练配置对象,用于计算 config_fingerprint。
关键实现细节:
先写 checkpoint.json.tmp 再 os.replace,保证 checkpoint 不被写一半的中断破坏。
"""
payload = {
"schema_version": CHECKPOINT_SCHEMA_VERSION,
"progress": {
"epoch": epoch,
"step_completed": step_completed,
"phase": phase,
"global_step": global_step,
"total_steps": total_steps,
},
"version_snapshot": version_snapshot,
"epoch_batches": epoch_batches,
"config_fingerprint": compute_fingerprint(config),
"state": serialize_state(state),
}
path = workspace_dir / "checkpoint.json"
tmp = path.with_name("checkpoint.json.tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2))
os.replace(tmp, path)
def load_checkpoint(workspace_dir: Path) -> dict[str, Any] | None:
"""读 checkpoint.json,不存在返回 None。
参数:
workspace_dir: workspace 目录。
返回:
checkpoint payload dictcheckpoint.json 不存在时返回 None。
"""
path = workspace_dir / "checkpoint.json"
if not path.exists():
return None
return json.loads(path.read_text())