143 lines
4.6 KiB
Python
143 lines
4.6 KiB
Python
"""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()
|