"""Persistent state for the Lint pass. A single JSON file at `lint/_contradictions.json` stores every pair we have already evaluated, keyed by a stable pair_hash. Re-runs skip any pair already evaluated at the current prompt version. Only CONTRADICTORY verdicts are the "interesting output" but we keep EQUIVALENT / INCOMPARABLE too because: - EQUIVALENT pairs are paraphrase clusters (useful later for canonicalization) - All labels matter for the idempotency ledger """ from __future__ import annotations import hashlib import json from dataclasses import asdict, dataclass, field from datetime import datetime, timezone from pathlib import Path _STATE_FILE = Path(__file__).resolve().parent / "_contradictions.json" STATE_VERSION = "v1" def pair_hash(atom_a_id: str, atom_b_id: str) -> str: """Canonical hash of an (a, b) pair, invariant under swap.""" lo, hi = sorted((atom_a_id, atom_b_id)) return hashlib.sha1(f"{lo}|{hi}".encode("utf-8")).hexdigest()[:16] @dataclass(slots=True, frozen=True) class PairVerdict: pair_hash: str atom_a_id: str atom_b_id: str atom_a_url: str atom_b_url: str atom_a_claim: str atom_b_claim: str label: str # EQUIVALENT / CONTRADICTORY / INCOMPARABLE confidence: float similarity: float # the first-stage embedding similarity that selected this pair detected_at: str prompt_version: str error: str | None = None @dataclass(slots=True) class LintStats: atoms_seen: int = 0 candidates_generated: int = 0 pairs_evaluated: int = 0 pairs_skipped_cached: int = 0 contradictory: int = 0 equivalent: int = 0 incomparable: int = 0 errors: int = 0 class LintState: """Loads/saves the contradiction ledger JSON with idempotency support.""" def __init__(self, path: Path | None = None): self._path = path or _STATE_FILE self._evaluated: dict[str, PairVerdict] = {} self._prompt_version: str = "v1" self._last_run: str | None = None self._load() def _load(self) -> None: if not self._path.exists(): return try: data = json.loads(self._path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): # Corrupt or unreadable state — start fresh rather than crash. return self._prompt_version = data.get("prompt_version", "v1") self._last_run = data.get("last_run") for raw in data.get("evaluated", []): try: pv = PairVerdict(**raw) except TypeError: continue self._evaluated[pv.pair_hash] = pv def set_prompt_version(self, pv: str) -> None: self._prompt_version = pv def already_evaluated(self, h: str, prompt_version: str) -> bool: pv = self._evaluated.get(h) return pv is not None and pv.prompt_version == prompt_version def get(self, h: str) -> PairVerdict | None: return self._evaluated.get(h) def upsert(self, verdict: PairVerdict) -> None: self._evaluated[verdict.pair_hash] = verdict @property def all_verdicts(self) -> list[PairVerdict]: return list(self._evaluated.values()) @property def all_contradictions(self) -> list[PairVerdict]: return [v for v in self._evaluated.values() if v.label == "CONTRADICTORY"] @property def all_equivalents(self) -> list[PairVerdict]: return [v for v in self._evaluated.values() if v.label == "EQUIVALENT"] def save(self) -> None: """Write the full ledger to disk atomically (write-temp + rename).""" self._path.parent.mkdir(parents=True, exist_ok=True) contras = self.all_contradictions equivs = self.all_equivalents body = { "version": STATE_VERSION, "prompt_version": self._prompt_version, "last_run": datetime.now(timezone.utc).isoformat(), "stats": { "total_pairs": len(self._evaluated), "contradictory": len(contras), "equivalent": len(equivs), "incomparable": len(self._evaluated) - len(contras) - len(equivs), }, "evaluated": [asdict(v) for v in self._evaluated.values()], } tmp = self._path.with_suffix(self._path.suffix + ".tmp") tmp.write_text( json.dumps(body, indent=2, ensure_ascii=False), encoding="utf-8", ) tmp.replace(self._path) @property def path(self) -> Path: return self._path def now_iso() -> str: return datetime.now(timezone.utc).isoformat()