# Author: Simon-Pierre Boucher — simon-pierre.boucher@uqo.ca """Protocol freeze: results/freeze.json with the SHA-256 of every role prompt, every config file, the schema module, the code of agents/ and debate/, and the model ids of the cost-matched pairs. Any later change is detectable (`av freeze --check`) and must be reported as a protocol deviation.""" from __future__ import annotations import hashlib import json import platform import subprocess from datetime import datetime, timezone from pathlib import Path from typing import Any from .. import __version__ from ..agents.roles import OPTIONAL_ROLE_IDS, ROLES, placebo_roles, role_prompt_hashes from ..config import CONFIGS, RESULTS, ROOT, load_config SRC = ROOT / "src" / "agentic_valuation" FREEZE_PATH = RESULTS / "freeze.json" def _sha_file(p: Path) -> str: return hashlib.sha256(p.read_bytes()).hexdigest() def _sha_text(s: str) -> str: return hashlib.sha256(s.encode("utf-8")).hexdigest() def _code_hashes() -> dict[str, str]: out: dict[str, str] = {} for sub in ("agents", "debate"): for p in sorted((SRC / sub).glob("*.py")): out[f"{sub}/{p.name}"] = _sha_file(p) out["schemas.py"] = _sha_file(SRC / "schemas.py") for p in sorted((SRC / "providers").glob("*.py")): out[f"providers/{p.name}"] = _sha_file(p) return out def _git_commit() -> str | None: try: r = subprocess.run(["git", "-C", str(ROOT), "rev-parse", "HEAD"], capture_output=True, text=True, timeout=5) return r.stdout.strip() or None except Exception: return None def build_freeze() -> dict[str, Any]: models = load_config("models") prompts = {rid: _sha_text(r.system_prompt()) for rid, r in ROLES.items()} prompts |= {r.id: _sha_text(r.system_prompt()) for r in placebo_roles(12)} doc: dict[str, Any] = { "frozen_at": datetime.now(timezone.utc).isoformat(), "package_version": __version__, "git_commit": _git_commit(), "python": platform.python_version(), "role_prompt_sha256": prompts, "role_prompt_hash16": role_prompt_hashes(), "optional_roles": list(OPTIONAL_ROLE_IDS), "configs_sha256": {p.name: _sha_file(p) for p in sorted(CONFIGS.glob("*.yaml"))}, "code_sha256": _code_hashes(), "model_ids": {"cost_matched_pairs": models["cost_matched_pairs"], "default_tier": models.get("default_tier"), "request_defaults": models.get("request_defaults")}, "treatments": load_config("treatments"), } doc["freeze_digest"] = _sha_text(json.dumps({k: v for k, v in doc.items() if k not in ("frozen_at", "git_commit", "python")}, sort_keys=True, default=str)) return doc def write_freeze(path: Path | None = None) -> Path: path = path or FREEZE_PATH path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(build_freeze(), indent=1, ensure_ascii=False, default=str)) return path def check_freeze(path: Path | None = None) -> list[str]: """Return the list of items whose hash differs from the frozen copy (empty = protocol unchanged).""" path = path or FREEZE_PATH if not path.exists(): return [f"no freeze file at {path}"] old = json.loads(path.read_text()) new = build_freeze() diffs: list[str] = [] for key in ("role_prompt_sha256", "configs_sha256", "code_sha256"): for k, v in new.get(key, {}).items(): if old.get(key, {}).get(k) != v: diffs.append(f"{key}:{k}") for k in old.get(key, {}): if k not in new.get(key, {}): diffs.append(f"{key}:{k} (removed)") if old.get("model_ids") != new.get("model_ids"): diffs.append("model_ids") return diffs