"""app/harness/checkpoint.py 单元测试。 覆盖序列化/反序列化往返、嵌套 dataclass 复活、缺键硬失败、 配置指纹结构性 vs 决策性判定、原子写与 load 缺失场景。 """ from __future__ import annotations import json from dataclasses import dataclass from typing import TYPE_CHECKING, Any import pytest if TYPE_CHECKING: from pathlib import Path from app.harness.checkpoint import ( CHECKPOINT_SCHEMA_VERSION, Probation, check_fingerprint, compute_fingerprint, deserialize_state_fields, load_checkpoint, serialize_state, write_checkpoint, ) from core.evolution.types import ( CaseSample, RejectedEdit, SystemCasePack, ToolCasePack, ) # --------------------------------------------------------------------------- # fixtures: 模拟 _TrainState 与 RunConfig # --------------------------------------------------------------------------- def _make_case_sample(**overrides: Any) -> CaseSample: """构造一个最小可用 CaseSample。""" defaults: dict[str, Any] = { "question_id": "q001", "video_id": "v001", "task_type": "temporal", "question": "What happened?", "options": ["A", "B", "C"], "answer": "A", "prediction": "B", "correct": False, "error_type": "reasoning", "selection_reason": "worst", "metrics": {"acc": 0.5}, "trace": [{"step": 1, "action": "search"}], } defaults.update(overrides) return CaseSample(**defaults) def _make_rejected_edit(**overrides: Any) -> RejectedEdit: """构造一个最小可用 RejectedEdit。""" defaults: dict[str, Any] = { "target_file": "temporal-reasoning.md", "target_type": "skill", "change_summary": "added step", "delta": -0.05, "source_version": "v2", "epoch": 1, "gate_w": 3, "gate_l": 5, "gate_e_value": 0.8, "gate_delta_shrunk": -0.02, } defaults.update(overrides) return RejectedEdit(**defaults) def _make_system_pack() -> SystemCasePack: """构造包含嵌套 CaseSample 的 SystemCasePack。""" return SystemCasePack( stats={"pattern": "repeat_visit", "count": 3}, failure_cases=[_make_case_sample(question_id="q010")], success_cases=[_make_case_sample(question_id="q011", correct=True, error_type=None)], ) def _make_tool_pack() -> ToolCasePack: """构造 ToolCasePack。""" return ToolCasePack( tool_name="search_subtree", target_files=["search_subtree_extract.md"], stats={"completeness": 0.8}, failure_spans=[{"step": 2, "issue": "missing"}], success_spans=[{"step": 3, "quality": "good"}], ) def _make_probation() -> Probation: """构造包含嵌套 RejectedEdit 的 Probation。""" return Probation( task_type="temporal", anchor_skills_version="v1", target_file="temporal-reasoning.md", correctness_snapshot={"q001": True, "q002": False}, opened_step=5, pending_edits=[_make_rejected_edit()], ) @dataclass class _FakeState: """模拟 _TrainState 全部可持久化字段。""" correctness: dict[str, bool] eval_prev_acc: float eval_prev_run_id: str baseline_skills_version: str baseline_prompts_version: str steps_since_best_improved: int epoch_start_skills: str changed_task_types_this_epoch: set[str] rejected_buffer: dict[str, list[RejectedEdit]] system_packs: list[SystemCasePack] tool_packs: list[ToolCasePack] probations: dict[str, Probation] gate_cooldown: dict[str, int] gate_epoch_observed: dict[str, bool] def _make_state() -> _FakeState: """构造一个填满全部字段的 _FakeState。""" return _FakeState( correctness={"q001": True, "q002": False}, eval_prev_acc=0.65, eval_prev_run_id="run-abc", baseline_skills_version="v1", baseline_prompts_version="v1", steps_since_best_improved=2, epoch_start_skills="v1", changed_task_types_this_epoch={"temporal", "causal"}, rejected_buffer={"temporal": [_make_rejected_edit()]}, system_packs=[_make_system_pack()], tool_packs=[_make_tool_pack()], probations={"temporal": _make_probation()}, gate_cooldown={"temporal": 3}, gate_epoch_observed={"temporal": True}, ) @dataclass(frozen=True) class _FakeConfig: """模拟 RunConfig 的指纹相关字段。""" batch_size: int = 8 min_class_per_batch: int = 2 epochs: int = 5 diag_size: int = 30 val_size: int = 50 batch_correct_ratio: float = 0.5 edit_budget_start: int = 6 edit_budget_end: int = 3 early_stop_patience: int = 3 use_slow_momentum: bool = True skill_update_mode: str = "patch" appendix_consolidate_threshold: int = 10 momentum_samples: int = 20 gate_e_confirm: float = 20.0 gate_e_provisional: float = 6.0 gate_w_net_min: int = 2 gate_delta_min: float = 0.02 gate_lambda_dir: float = -3.0 gate_e_rollback: float = 10.0 gate_block: int = 4 gate_n_max: int = 40 gate_p_low: float = 0.1 gate_p_high: float = 0.9 gate_probe_quota: float = 0.2 gate_gamma_decay: float = 0.9 gate_cooldown_steps: int = 2 gate_guard_err: float = 0.3 # ========================================================================= # 测试用例 # ========================================================================= class TestSerializeDeserializeRoundtrip: """序列化 → JSON 往返 → 反序列化应完全复原。""" def test_serialize_deserialize_roundtrip(self) -> None: state = _make_state() serialized = serialize_state(state) # JSON 往返(模拟实际落盘-读回) json_str = json.dumps(serialized, ensure_ascii=False) loaded = json.loads(json_str) restored = deserialize_state_fields(loaded) assert restored["correctness"] == state.correctness assert restored["eval_prev_acc"] == state.eval_prev_acc assert restored["eval_prev_run_id"] == state.eval_prev_run_id assert restored["baseline_skills_version"] == state.baseline_skills_version assert restored["baseline_prompts_version"] == state.baseline_prompts_version assert restored["steps_since_best_improved"] == state.steps_since_best_improved assert restored["epoch_start_skills"] == state.epoch_start_skills assert restored["changed_task_types_this_epoch"] == state.changed_task_types_this_epoch assert restored["gate_cooldown"] == state.gate_cooldown assert restored["gate_epoch_observed"] == state.gate_epoch_observed class TestSerializeSetToSortedList: """set 字段序列化为排序列表。""" def test_serialize_set_to_sorted_list(self) -> None: state = _make_state() state.changed_task_types_this_epoch = {"z_type", "a_type", "m_type"} serialized = serialize_state(state) assert serialized["changed_task_types_this_epoch"] == ["a_type", "m_type", "z_type"] class TestDeserializeNestedSystemPack: """SystemCasePack 内嵌套的 CaseSample 正确复活。""" def test_deserialize_nested_system_pack(self) -> None: state = _make_state() serialized = serialize_state(state) json_str = json.dumps(serialized, ensure_ascii=False) loaded = json.loads(json_str) restored = deserialize_state_fields(loaded) packs = restored["system_packs"] assert len(packs) == 1 pack = packs[0] assert isinstance(pack, SystemCasePack) assert len(pack.failure_cases) == 1 assert isinstance(pack.failure_cases[0], CaseSample) assert pack.failure_cases[0].question_id == "q010" assert len(pack.success_cases) == 1 assert isinstance(pack.success_cases[0], CaseSample) assert pack.success_cases[0].question_id == "q011" class TestDeserializeNestedProbation: """Probation 内嵌套的 RejectedEdit 正确复活。""" def test_deserialize_nested_probation(self) -> None: state = _make_state() serialized = serialize_state(state) json_str = json.dumps(serialized, ensure_ascii=False) loaded = json.loads(json_str) restored = deserialize_state_fields(loaded) probations = restored["probations"] assert "temporal" in probations prob = probations["temporal"] assert isinstance(prob, Probation) assert prob.task_type == "temporal" assert prob.anchor_skills_version == "v1" assert prob.correctness_snapshot == {"q001": True, "q002": False} assert len(prob.pending_edits) == 1 edit = prob.pending_edits[0] assert isinstance(edit, RejectedEdit) assert edit.target_file == "temporal-reasoning.md" assert edit.delta == -0.05 class TestDeserializeMissingKeyRaises: """缺键即 checkpoint 损坏,应硬失败。""" def test_deserialize_missing_key_raises(self) -> None: state = _make_state() serialized = serialize_state(state) del serialized["gate_epoch_observed"] with pytest.raises(KeyError): deserialize_state_fields(serialized) class TestFingerprintStructuralVsDecision: """compute_fingerprint 包含全部结构性 + 决策性键。""" def test_fingerprint_structural_vs_decision(self) -> None: config = _FakeConfig() fp = compute_fingerprint(config) structural = { "batch_size", "min_class_per_batch", "epochs", "diag_size", "val_size", "batch_correct_ratio", } decision = { "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", } assert structural | decision == set(fp.keys()) assert fp["batch_size"] == 8 assert fp["gate_e_confirm"] == 20.0 class TestCheckFingerprintStructuralReject: """结构性键变化应出现在 structural 列表中。""" def test_check_fingerprint_structural_reject(self) -> None: config_old = _FakeConfig() saved = compute_fingerprint(config_old) # 修改结构性参数 config_new = _FakeConfig(batch_size=16, epochs=10) structural, decision = check_fingerprint(saved, config_new) assert "batch_size" in structural assert "epochs" in structural assert len(decision) == 0 class TestCheckFingerprintDecisionWarn: """决策性键变化应出现在 decision 列表中,structural 为空。""" def test_check_fingerprint_decision_warn(self) -> None: config_old = _FakeConfig() saved = compute_fingerprint(config_old) config_new = _FakeConfig(early_stop_patience=10, gate_e_confirm=50.0) structural, decision = check_fingerprint(saved, config_new) assert len(structural) == 0 assert "early_stop_patience" in decision assert "gate_e_confirm" in decision class TestWriteCheckpointAtomic: """原子写:先 .tmp 再 os.replace。""" def test_write_checkpoint_atomic(self, tmp_path: Path) -> None: state = _make_state() config = _FakeConfig() write_checkpoint( tmp_path, state=state, epoch=2, step_completed=5, phase="in_epoch", global_step=15, total_steps=40, version_snapshot={"skills": "v3", "prompts": "v2"}, epoch_batches=[["q001", "q002"], ["q003"]], config=config, ) ckpt_path = tmp_path / "checkpoint.json" assert ckpt_path.exists() # .tmp 应已被 os.replace 移除 assert not (tmp_path / "checkpoint.json.tmp").exists() payload = json.loads(ckpt_path.read_text()) assert payload["schema_version"] == CHECKPOINT_SCHEMA_VERSION assert payload["progress"]["epoch"] == 2 assert payload["progress"]["step_completed"] == 5 assert payload["progress"]["phase"] == "in_epoch" assert payload["progress"]["global_step"] == 15 assert payload["progress"]["total_steps"] == 40 assert payload["version_snapshot"] == {"skills": "v3", "prompts": "v2"} assert payload["epoch_batches"] == [["q001", "q002"], ["q003"]] assert "config_fingerprint" in payload assert "state" in payload class TestLoadCheckpointMissing: """checkpoint.json 不存在时返回 None。""" def test_load_checkpoint_missing(self, tmp_path: Path) -> None: result = load_checkpoint(tmp_path) assert result is None def test_load_checkpoint_exists(self, tmp_path: Path) -> None: """checkpoint.json 存在时正确读回。""" state = _make_state() config = _FakeConfig() write_checkpoint( tmp_path, state=state, epoch=1, step_completed=3, phase="post_evolve", global_step=8, total_steps=20, version_snapshot={"skills": "v2", "prompts": "v1"}, epoch_batches=[["q001"]], config=config, ) loaded = load_checkpoint(tmp_path) assert loaded is not None assert loaded["schema_version"] == CHECKPOINT_SCHEMA_VERSION assert loaded["progress"]["epoch"] == 1 # 完整往返测试:state 可 deserialize restored = deserialize_state_fields(loaded["state"]) assert restored["eval_prev_acc"] == 0.65