diff --git a/app/harness/pools.py b/app/harness/pools.py index c46747e..89763de 100644 --- a/app/harness/pools.py +++ b/app/harness/pools.py @@ -844,6 +844,28 @@ def build_or_load_pools( len(new_types), sorted(new_types), ) + else: + # global:校验 baseline_run_id 与(若有)manifest 内容指纹, + # 拒绝静默加载与 seed 错配 / 被篡改的冻结切分(P5 fail loud)。 + frozen_baseline = raw.get("baseline_run_id") + if frozen_baseline != baseline_run_id: + raise ValueError( + f"冻结 pools.json 的 baseline_run_id={frozen_baseline!r} 与 seed " + f"的 {baseline_run_id!r} 不一致,拒绝静默加载错配切分。" + ) + manifest_path = config.workspace_dir / "split_manifest.json" + if manifest_path.exists(): + import hashlib + + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + actual_sha = hashlib.sha256( + pools_path.read_text(encoding="utf-8").encode("utf-8") + ).hexdigest() + if manifest.get("pools_sha256") != actual_sha: + raise ValueError( + "pools.json 内容指纹与 split_manifest.pools_sha256 不符," + "冻结产物疑被篡改,拒绝加载。" + ) return load_pools(pools_path) diff --git a/tests/unit/test_harness_pools.py b/tests/unit/test_harness_pools.py index 9dd0eb7..2cc1b75 100644 --- a/tests/unit/test_harness_pools.py +++ b/tests/unit/test_harness_pools.py @@ -18,6 +18,7 @@ import pytest from app.harness.pools import ( GlobalPoolStrategy, PerCategoryPoolStrategy, + build_or_load_pools, build_pools, load_pools, save_pools, @@ -291,6 +292,76 @@ class TestBuildOrLoadPoolsFrozen: load_ids = [q.question_id for q in getattr(loaded, pool_name)] assert orig_ids == load_ids, f"{pool_name} 冻结后 ID 顺序不一致" + def _run_config_for_frozen(self, tmp_path: Path, seed_name: str) -> object: + """构造指向 tmp workspace/store + 指定种子名的最小 train RunConfig。""" + from app.harness.config import RunConfig + + return RunConfig( + workspace_dir=tmp_path / "ws", + store_dir=tmp_path / "store", + mode="train", + concurrency=4, + max_steps=10, + skill_mode="auto", + n_samples=0, + questions="benchmarks/Video-MME", + skills_version="v1", + prompts_version="v1", + epochs=1, + diag_size=10, + diag_correct_ratio=0.5, + val_size=10, + val_correct_ratio=0.5, + edit_budget_start=5, + edit_budget_end=2, + batch_size=15, + min_class_per_batch=2, + eval_min_per_class=2, + early_stop_patience=4, + test_size=10, + use_slow_momentum=True, + gate_e_confirm=20.0, + gate_e_provisional=3.0, + gate_w_net_min=2, + gate_delta_min=0.02, + gate_lambda_dir=-0.642, + gate_e_rollback=10.0, + gate_block=8, + gate_n_max=40, + gate_p_low=0.05, + gate_p_high=0.95, + gate_probe_quota=0.2, + gate_gamma_decay=0.9, + gate_cooldown_steps=2, + gate_guard_err=0.10, + skill_update_mode="patch", + appendix_consolidate_threshold=6, + fresh=True, + seed=seed_name, + test_questions="", # 绕过 _to_pool_config 的 resolve_paths(manifest 依赖) + ) + + def test_global_frozen_rejects_baseline_mismatch(self, tmp_path: Path) -> None: + """global 冻结 pools 的 baseline_run_id 与 seed 不符时 fail-loud。""" + seed_name = "myseed" + seed_dir = tmp_path / "store" / "seeds" / seed_name + seed_dir.mkdir(parents=True) + (seed_dir / "seed.json").write_text( + json.dumps({"baseline_run_id": "infer_adhoc", "parent": None}) + ) + + ws = tmp_path / "ws" + ws.mkdir() + (ws / "pools.json").write_text( + json.dumps({"split_mode": "global", "baseline_run_id": "other"}), + encoding="utf-8", + ) + + config = self._run_config_for_frozen(tmp_path, seed_name) + strategy = GlobalPoolStrategy() + with pytest.raises(ValueError, match="baseline_run_id"): + build_or_load_pools(config, strategy, tmp_path / "nonexistent.db") + class TestGlobalPoolStrategy: """GlobalPoolStrategy 封装现有全局三分逻辑。"""