diff --git a/chat/curriculum_serve_license.py b/chat/curriculum_serve_license.py index 032a391e..49084bee 100644 --- a/chat/curriculum_serve_license.py +++ b/chat/curriculum_serve_license.py @@ -10,63 +10,40 @@ never writes it — the sealed-practice output of its ``content_sha256`` is verified on load so a hand-edited ledger is rejected rather than silently trusted. -A deliberate near-copy of ``chat.deduction_serve_license``: the second -instance of the seal → ratify → SHA-verify → serve-gate pattern in this arc, -kept explicit here so the shared bridge (generalization plan Phase 3.3) is -extracted from two working instances rather than designed from one. +The third instance of the seal → ratify → SHA-verify → serve-gate pattern, and +the one that made the shared bridge worth extracting: this module is now an +ADAPTER over ``core.ratified_ledger`` (ADR-0263). It names the artifact, keeps +the memoization, and declares the one thing that genuinely differs — this +ledger is legitimately ABSENT, because no curriculum band has earned anything +yet (ADR-0262 §5.1: the binding constraint is ratified curriculum volume). An +absent ledger reads as an empty table, so every answer is served DISCLOSED. """ from __future__ import annotations -import json from functools import lru_cache from pathlib import Path -from core.reliability_gate import Action, Ceilings, ClassTally, LicenseDecision, license_for -from formation.hashing import sha256_of +from core.ratified_ledger import ( + RatifiedLedgerError as RatifiedCurriculumLedgerError, + load_sealed_ledger, + serve_license, +) +from core.reliability_gate import Ceilings, ClassTally, LicenseDecision _LEDGER_PATH = Path(__file__).resolve().parent / "data" / "curriculum_serve_ledger.json" -class RatifiedCurriculumLedgerError(ValueError): - """The committed curriculum-serve ledger is missing, malformed, or tampered with.""" - - @lru_cache(maxsize=1) def load_ratified_ledger() -> dict[str, ClassTally]: """Load + verify the ratified curriculum-serve ledger → per-band tallies. - An ABSENT ledger is not an error: it means no curriculum band has earned - anything yet, and every answer is served DISCLOSED. That is the honest - default for a capability whose practice volume is still being built, and - it keeps the composer usable before any license exists. + An ABSENT ledger is not an error here: no curriculum band has earned + anything yet, and the honest reading of "no file" is "no committed + evidence", which the gate turns into a disclosed answer rather than a + withheld one. """ - if not _LEDGER_PATH.exists(): - return {} - try: - artifact = json.loads(_LEDGER_PATH.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: # pragma: no cover - defensive - raise RatifiedCurriculumLedgerError(f"cannot read ratified ledger: {exc}") from exc - - classes = artifact.get("classes") - if not isinstance(classes, dict): - raise RatifiedCurriculumLedgerError("ratified ledger has no 'classes' table") - if sha256_of(classes) != artifact.get("content_sha256"): - raise RatifiedCurriculumLedgerError( - "ratified ledger content_sha256 mismatch — not the sealed-practice output" - ) - - ledger: dict[str, ClassTally] = {} - for cls, counts in classes.items(): - ledger[cls] = ClassTally( - class_name=cls, - correct=int(counts.get("correct", 0)), - wrong=int(counts.get("wrong", 0)), - refused=int(counts.get("refused", 0)), - t2_verified=int(counts.get("t2_verified", 0)), - t2_agrees_gold=int(counts.get("t2_agrees_gold", 0)), - ) - return ledger + return load_sealed_ledger(_LEDGER_PATH, missing_ok=True) def curriculum_serve_license( @@ -81,11 +58,7 @@ def curriculum_serve_license( caller serves a disclosed (hedged) surface, the safe default. """ ledger = ledger if ledger is not None else load_ratified_ledger() - tally = ledger.get(band) - if tally is None: - return None - ceilings = ceilings if ceilings is not None else Ceilings.default() - return license_for(tally, Action.SERVE, ceilings) + return serve_license(band, ledger, ceilings=ceilings) __all__ = [ diff --git a/chat/deduction_serve_license.py b/chat/deduction_serve_license.py index 55397aac..2fcc2579 100644 --- a/chat/deduction_serve_license.py +++ b/chat/deduction_serve_license.py @@ -10,27 +10,27 @@ artifact is the sealed-practice output of is verified on load, so a hand-edited (un-ratified) ledger is rejected rather than silently trusted. -Mirrors ``generate.determine.estimation_license`` exactly: immutable ratified -data parsed once and cached; the gate (``license_for``) is pure; ceilings stay -at the safe defaults (invariant #4 — the engine cannot raise its own bar). +The load/verify/gate mechanics live in ``core.ratified_ledger`` (ADR-0263) — +this module is the deduction-serve ADAPTER over that bridge: it names the +artifact, keeps the memoization, and preserves its own public API. Ceilings +stay at the safe defaults (invariant #4 — the engine cannot raise its own bar). """ from __future__ import annotations -import json from functools import lru_cache from pathlib import Path -from core.reliability_gate import Action, Ceilings, ClassTally, LicenseDecision, license_for -from formation.hashing import sha256_of +from core.ratified_ledger import ( + RatifiedLedgerError, + load_sealed_ledger, + serve_license, +) +from core.reliability_gate import Ceilings, ClassTally, LicenseDecision _LEDGER_PATH = Path(__file__).resolve().parent / "data" / "deduction_serve_ledger.json" -class RatifiedLedgerError(ValueError): - """The committed deduction-serve ledger is missing, malformed, or tampered with.""" - - @lru_cache(maxsize=1) def load_ratified_ledger() -> dict[str, ClassTally]: """Load + verify the ratified deduction-serve ledger → per-band ``ClassTally``. @@ -39,30 +39,7 @@ def load_ratified_ledger() -> dict[str, ClassTally]: recomputed ``content_sha256`` does not match the committed one (tamper-evidence: only the sealed-practice output is trusted, never a hand-edited ledger). """ - try: - artifact = json.loads(_LEDGER_PATH.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: # pragma: no cover - defensive - raise RatifiedLedgerError(f"cannot read ratified ledger: {exc}") from exc - - classes = artifact.get("classes") - if not isinstance(classes, dict): - raise RatifiedLedgerError("ratified ledger has no 'classes' table") - if sha256_of(classes) != artifact.get("content_sha256"): - raise RatifiedLedgerError( - "ratified ledger content_sha256 mismatch — not the sealed-practice output" - ) - - ledger: dict[str, ClassTally] = {} - for cls, counts in classes.items(): - ledger[cls] = ClassTally( - class_name=cls, - correct=int(counts.get("correct", 0)), - wrong=int(counts.get("wrong", 0)), - refused=int(counts.get("refused", 0)), - t2_verified=int(counts.get("t2_verified", 0)), - t2_agrees_gold=int(counts.get("t2_agrees_gold", 0)), - ) - return ledger + return load_sealed_ledger(_LEDGER_PATH) def deduction_serve_license( @@ -79,11 +56,7 @@ def deduction_serve_license( safe default ceilings. """ ledger = ledger if ledger is not None else load_ratified_ledger() - tally = ledger.get(shape_band) - if tally is None: - return None - ceilings = ceilings if ceilings is not None else Ceilings.default() - return license_for(tally, Action.SERVE, ceilings) + return serve_license(shape_band, ledger, ceilings=ceilings) __all__ = ["RatifiedLedgerError", "deduction_serve_license", "load_ratified_ledger"] diff --git a/core/cli_test.py b/core/cli_test.py index 417c4540..a804ae4f 100644 --- a/core/cli_test.py +++ b/core/cli_test.py @@ -184,6 +184,7 @@ TEST_SUITES: dict[str, tuple[str, ...]] = { "tests/test_exist_argument_reader.py", "tests/test_deduction_serve_e2e.py", "tests/test_curriculum_serve.py", + "tests/test_ratified_ledger_bridge.py", ), "full": ("tests/",), } diff --git a/core/ratified_ledger.py b/core/ratified_ledger.py new file mode 100644 index 00000000..0bc55061 --- /dev/null +++ b/core/ratified_ledger.py @@ -0,0 +1,157 @@ +"""The ratified-ledger bridge — seal → ratify → SHA-verify → serve-gate. + +ADR-0175 Phase 5's consumption bridge (generalization plan Phase 3.3), +extracted from three working instances rather than designed ahead of them: + +- ``generate/determine/estimation_license.py`` (ADR-0175, the first) +- ``chat/deduction_serve_license.py`` (ADR-0256, the second) +- ``chat/curriculum_serve_license.py`` (ADR-0262, the third) + +All three had converged on the same artifact and the same four rules, which is +what makes this an extraction and not a speculation. The rules, stated once: + +1. **The engine reads; only sealed practice writes.** A ledger is the output + of a practice run over a gold corpus, never of a serving turn. Nothing in a + serving path may call :func:`write_sealed_ledger`. +2. **Tamper-evidence is structural.** The artifact carries + ``content_sha256`` over its ``classes`` table; a load that cannot reproduce + it REFUSES. A hand-edited ledger is not a slightly-wrong ledger, it is an + unratified one. +3. **Ceilings are not negotiable at the call site.** The gate always runs at + the safe defaults unless a caller passes ceilings explicitly, and no + production path does — ADR-0175 invariant #4: an engine cannot raise its own + bar. +4. **Absent evidence is never a license.** A class missing from the ledger + yields ``None``, and every caller's ``None`` branch serves the disclosed + (hedged) surface. A capability with no track record is served honestly, not + withheld and not asserted. + +Byte-compatibility is deliberate: :func:`seal_artifact` and +:func:`write_sealed_ledger` reproduce the exact bytes the three existing +sealers wrote, so adopting the bridge re-seals every committed ledger +identically and no lane pin moves. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from core.reliability_gate import ( + Action, + Ceilings, + ClassTally, + LicenseDecision, + license_for, +) +from formation.hashing import sha256_of + + +class RatifiedLedgerError(ValueError): + """A committed ledger is malformed or does not verify against its own hash.""" + + +def tally_dict(tally: ClassTally) -> dict[str, Any]: + """The committed per-class row. The field set is the contract — a reader + of an older ledger must be able to name every field it finds.""" + return { + "correct": tally.correct, + "wrong": tally.wrong, + "refused": tally.refused, + "t2_verified": tally.t2_verified, + "t2_agrees_gold": tally.t2_agrees_gold, + } + + +def seal_artifact( + ledger: dict[str, ClassTally], *, schema: str, note: str, provenance: str +) -> dict[str, Any]: + """The self-verifying sealed-ledger dict for *ledger*. + + Classes are sorted, so the artifact is a pure function of the practice + result: the same corpus and solver seal byte-identically, which is what + makes a committed ledger reviewable as a diff. + """ + classes = {name: tally_dict(tally) for name, tally in sorted(ledger.items())} + return { + "schema": schema, + "classes": classes, + "content_sha256": sha256_of(classes), + "note": note, + "provenance": provenance, + } + + +def write_sealed_ledger(path: Path, artifact: dict[str, Any]) -> dict[str, Any]: + """Write *artifact* to *path* in the committed formatting.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(artifact, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return artifact + + +def load_sealed_ledger(path: Path, *, missing_ok: bool = False) -> dict[str, ClassTally]: + """Load + verify a sealed ledger → per-class ``ClassTally``. + + ``missing_ok`` distinguishes two genuinely different situations. A ledger + that a capability *ships with* is required: its absence means the + deployment is broken, and refusing is right. A ledger for a capability + whose practice volume is still being built is legitimately absent, and the + honest reading of "no file" is "no class has earned anything yet" — an + empty table, every answer disclosed. Neither case may be answered by + guessing a license. + """ + if not path.exists(): + if missing_ok: + return {} + raise RatifiedLedgerError(f"ratified ledger not found: {path}") + try: + artifact = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise RatifiedLedgerError(f"cannot read ratified ledger: {exc}") from exc + + classes = artifact.get("classes") if isinstance(artifact, dict) else None + if not isinstance(classes, dict): + raise RatifiedLedgerError("ratified ledger has no 'classes' table") + if sha256_of(classes) != artifact.get("content_sha256"): + raise RatifiedLedgerError( + "ratified ledger content_sha256 mismatch — not the sealed-practice output" + ) + + return { + name: ClassTally( + class_name=name, + correct=int(counts.get("correct", 0)), + wrong=int(counts.get("wrong", 0)), + refused=int(counts.get("refused", 0)), + t2_verified=int(counts.get("t2_verified", 0)), + t2_agrees_gold=int(counts.get("t2_agrees_gold", 0)), + ) + for name, counts in classes.items() + } + + +def serve_license( + class_name: str, + ledger: dict[str, ClassTally], + *, + ceilings: Ceilings | None = None, +) -> LicenseDecision | None: + """The ``Action.SERVE`` verdict for *class_name*, or ``None`` when the + class has no committed evidence (never a license — rule 4).""" + tally = ledger.get(class_name) + if tally is None: + return None + return license_for(tally, Action.SERVE, ceilings or Ceilings.default()) + + +__all__ = [ + "RatifiedLedgerError", + "load_sealed_ledger", + "seal_artifact", + "serve_license", + "tally_dict", + "write_sealed_ledger", +] diff --git a/docs/adr/ADR-0263-ratified-ledger-bridge.md b/docs/adr/ADR-0263-ratified-ledger-bridge.md new file mode 100644 index 00000000..7d4536d8 --- /dev/null +++ b/docs/adr/ADR-0263-ratified-ledger-bridge.md @@ -0,0 +1,80 @@ +# ADR-0263 — The ratified-ledger bridge + +- **Status:** Proposed +- **Date:** 2026-07-24 +- **Arc:** generalization Phase 3.3 (docs/plans/generalization-arc-2026-07-24.md §2) +- **Governs:** `core/ratified_ledger.py` and the three adapters over it — + `generate/determine/estimation_license.py`, `chat/deduction_serve_license.py`, + `chat/curriculum_serve_license.py` — plus the sealed-artifact writer in + `evals/deduction_serve/practice/runner.py`. + +## 1. Context + +Three capabilities had independently converged on the same four-step pattern: +sealed practice writes a per-class tally table → the table is committed as a +ratified artifact → serving verifies its `content_sha256` on load → a per-class +gate decides SERVE against fixed ceilings. + +Three near-identical implementations is the point at which a shared component +is an extraction rather than a guess. It matters now because the generalization +plan puts a per-subject arena behind every new subject: without a bridge, each +subject copies fifty lines of verification, and the copies drift. + +## 2. Decision + +`core/ratified_ledger.py` owns the mechanics — `seal_artifact`, +`write_sealed_ledger`, `load_sealed_ledger`, `serve_license`, `tally_dict` — +and states the four rules once: + +1. **The engine reads; only sealed practice writes.** +2. **Tamper-evidence is structural** — a load that cannot reproduce + `content_sha256` REFUSES. A hand-edited ledger is not a slightly-wrong + ledger; it is an unratified one. +3. **Ceilings are not negotiable at the call site** (ADR-0175 invariant #4). +4. **Absent evidence is never a license** — a missing class yields `None`, and + every caller's `None` branch serves the disclosed surface. + +Each capability keeps a thin adapter that names its artifact, keeps its +memoization, and preserves its public API. One genuine difference is now +declared rather than implied: `load_sealed_ledger(..., missing_ok=True)` +distinguishes a ledger a capability *ships with* (absence = broken deployment, +refuse) from one whose practice volume is still being built (absence = no class +has earned anything, serve disclosed). Curriculum serving is the second kind +today (ADR-0262 §5.1). + +## 3. Why this is safe + +The extraction's safety property is byte-identity: `seal_artifact` and +`write_sealed_ledger` reproduce exactly what the bespoke sealers wrote, so +re-sealing through the bridge leaves every committed artifact and every lane +pin unmoved. That is asserted, not assumed — +`test_committed_deduction_ledger_reseals_byte_identically` re-derives the +committed 25-band ledger through the bridge and compares the bytes. + +## 4. What this unblocks + +A new subject arena now needs a gold corpus and a band key. It does not need to +re-implement ratification — which is what the plan meant by "so each subject +arena plugs in without bespoke wiring", and why §3 sequenced this ahead of the +second subject. + +## 5. Scope-outs + +- **The estimation adapter keeps its predicate → converse-class naming.** That + mapping is genuinely local to estimation; the bridge gates a class name and + does not know how one is derived. +- **No ledger migration.** All three artifacts already share the schema shape; + nothing is rewritten, versioned, or moved. +- **`core/learning_arena`'s practice engine is untouched.** The bridge covers + the consumption half (seal → serve); the production half (run practice, tally) + is already shared. + +## 6. Verification + +`tests/test_ratified_ledger_bridge.py` — 8 tests: round-trip, hand-edit +rejection, required-vs-optional absence, absent class never licensed, the +Wilson volume floor, a single `wrong` costing the license, committed-ledger +byte-identity, and a guard that all three adapters read through the bridge +(they no longer import a hashing module of their own). Plus the existing +consumers unchanged: `core test --suite deductive` 252 passed; the estimation +and license test set 355 passed. diff --git a/evals/deduction_serve/practice/runner.py b/evals/deduction_serve/practice/runner.py index 5c9498b9..236644e2 100644 --- a/evals/deduction_serve/practice/runner.py +++ b/evals/deduction_serve/practice/runner.py @@ -18,11 +18,11 @@ commit and SHA-verify on load. from __future__ import annotations import argparse -import json from pathlib import Path from typing import Any from core.learning_arena.engine import run_practice +from core.ratified_ledger import seal_artifact, tally_dict, write_sealed_ledger from core.reliability_gate import Action, Ceilings, ClassTally, license_for from evals.deduction_serve.practice.gold import ( ConstructionGoldTether, @@ -30,7 +30,6 @@ from evals.deduction_serve.practice.gold import ( all_gold_problems, assert_corpus_sound, ) -from formation.hashing import sha256_of #: The committed sealed ledger lives next to its serving READER (chat/), mirroring #: the estimation ledger's topology (producer in evals/, artifact by the reader). @@ -46,13 +45,7 @@ def build_ledger() -> dict[str, ClassTally]: def _tally_dict(tally: ClassTally) -> dict[str, Any]: - return { - "correct": tally.correct, - "wrong": tally.wrong, - "refused": tally.refused, - "t2_verified": tally.t2_verified, - "t2_agrees_gold": tally.t2_agrees_gold, - } + return tally_dict(tally) def run(ceilings: Ceilings | None = None) -> dict[str, Any]: @@ -81,31 +74,30 @@ def run(ceilings: Ceilings | None = None) -> dict[str, Any]: def build_sealed_artifact() -> dict[str, Any]: - """The committed sealed-ledger dict (self-verifying ``content_sha256``).""" - ledger = build_ledger() - classes = {cls: _tally_dict(tally) for cls, tally in sorted(ledger.items())} - return { - "schema": "deduction_serve_ledger_v1", - "classes": classes, - "content_sha256": sha256_of(classes), - "note": ( + """The committed sealed-ledger dict (self-verifying ``content_sha256``). + + Formatting and hashing come from the shared bridge (ADR-0263) — byte + -identical to what this module wrote before the extraction, which is how + the refactor is proven safe: re-sealing must not move the artifact. + """ + return seal_artifact( + build_ledger(), + schema="deduction_serve_ledger_v1", + note=( "Sealed-practice committed ledger for deduction serving (ADR-0256). " "Engine reads, never writes. Ceilings stay at safe defaults " "(theta_SERVE=0.99). A band earns SERVE by demonstrated pipeline " "reliability (reader+projector+engine) at volume >= 657 committed." ), - "provenance": "evals.deduction_serve.practice.runner.seal_ledger", - } + provenance="evals.deduction_serve.practice.runner.seal_ledger", + ) def seal_ledger(path: Path = _SEALED_LEDGER_PATH) -> dict[str, Any]: """Regenerate + write the committed sealed ledger. Verifies corpus soundness against the independent oracle first (a mis-stated gold can never seal).""" assert_corpus_sound() - artifact = build_sealed_artifact() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(artifact, indent=2, sort_keys=True) + "\n", encoding="utf-8") - return artifact + return write_sealed_ledger(path, build_sealed_artifact()) def main(argv: list[str] | None = None) -> int: diff --git a/generate/determine/estimation_license.py b/generate/determine/estimation_license.py index a10a3e38..ce8110b4 100644 --- a/generate/determine/estimation_license.py +++ b/generate/determine/estimation_license.py @@ -8,27 +8,28 @@ never writes it. The artifact is the sealed-practice output of load, so a hand-edited (un-ratified) ledger is rejected rather than silently trusted. Determinism: the ledger is immutable ratified data, parsed once and cached; the gate -(``license_for``) is pure. No engine self-authorization — ceilings stay at the safe -defaults (raising one's own bar is structurally impossible, ADR-0175 invariant #4). +is pure. No engine self-authorization — ceilings stay at the safe defaults (raising +one's own bar is structurally impossible, ADR-0175 invariant #4). + +The load/verify/gate mechanics live in ``core.ratified_ledger`` (ADR-0263), the +bridge extracted from this module and its two successors; this is the estimation +ADAPTER over it, preserving its own public API (including the predicate → +converse-class naming, which is the one thing genuinely local to estimation). """ from __future__ import annotations -import json from functools import lru_cache from pathlib import Path -from core.reliability_gate import Action, Ceilings, ClassTally, LicenseDecision, license_for -from formation.hashing import sha256_of +from core.ratified_ledger import RatifiedLedgerError, load_sealed_ledger +from core.ratified_ledger import serve_license as _serve_license +from core.reliability_gate import Ceilings, ClassTally, LicenseDecision from generate.determine.estimate import converse_class_name _LEDGER_PATH = Path(__file__).resolve().parent / "data" / "estimation_ledger.json" -class RatifiedLedgerError(ValueError): - """The committed estimation ledger is missing, malformed, or tampered with.""" - - @lru_cache(maxsize=1) def load_ratified_ledger() -> dict[str, ClassTally]: """Load + verify the ratified estimation ledger → per-class ``ClassTally``. @@ -37,30 +38,7 @@ def load_ratified_ledger() -> dict[str, ClassTally]: recomputed ``content_sha256`` does not match the committed one (tamper-evidence: only the sealed-practice output is trusted, never a hand-edited ledger). """ - try: - artifact = json.loads(_LEDGER_PATH.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: # pragma: no cover - defensive - raise RatifiedLedgerError(f"cannot read ratified ledger: {exc}") from exc - - classes = artifact.get("classes") - if not isinstance(classes, dict): - raise RatifiedLedgerError("ratified ledger has no 'classes' table") - if sha256_of(classes) != artifact.get("content_sha256"): - raise RatifiedLedgerError( - "ratified ledger content_sha256 mismatch — not the sealed-practice output" - ) - - ledger: dict[str, ClassTally] = {} - for cls, counts in classes.items(): - ledger[cls] = ClassTally( - class_name=cls, - correct=int(counts.get("correct", 0)), - wrong=int(counts.get("wrong", 0)), - refused=int(counts.get("refused", 0)), - t2_verified=int(counts.get("t2_verified", 0)), - t2_agrees_gold=int(counts.get("t2_agrees_gold", 0)), - ) - return ledger + return load_sealed_ledger(_LEDGER_PATH) def serve_license( @@ -76,11 +54,7 @@ def serve_license( deterministic ``license_for`` verdict under the safe default ceilings. """ ledger = ledger if ledger is not None else load_ratified_ledger() - tally = ledger.get(converse_class_name(predicate)) - if tally is None: - return None - ceilings = ceilings if ceilings is not None else Ceilings.default() - return license_for(tally, Action.SERVE, ceilings) + return _serve_license(converse_class_name(predicate), ledger, ceilings=ceilings) __all__ = ["RatifiedLedgerError", "load_ratified_ledger", "serve_license"] diff --git a/tests/test_ratified_ledger_bridge.py b/tests/test_ratified_ledger_bridge.py new file mode 100644 index 00000000..107be40a --- /dev/null +++ b/tests/test_ratified_ledger_bridge.py @@ -0,0 +1,113 @@ +"""The ratified-ledger bridge (ADR-0263) — seal → ratify → SHA-verify → gate. + +These tests pin the four rules the three adapters depend on, and the property +that made the extraction safe: re-sealing through the bridge reproduces the +committed artifacts byte-for-byte. +""" + +from __future__ import annotations + +import json + +import pytest + +from core.ratified_ledger import ( + RatifiedLedgerError, + load_sealed_ledger, + seal_artifact, + serve_license, + write_sealed_ledger, +) +from core.reliability_gate import ClassTally + + +def _tally(name: str, correct: int, wrong: int = 0) -> ClassTally: + return ClassTally(class_name=name, correct=correct, wrong=wrong, refused=0) + + +def test_seal_then_load_round_trips(tmp_path) -> None: + ledger = {"alpha": _tally("alpha", 700), "beta": _tally("beta", 12)} + path = tmp_path / "ledger.json" + write_sealed_ledger( + path, seal_artifact(ledger, schema="t_v1", note="n", provenance="p") + ) + loaded = load_sealed_ledger(path) + assert set(loaded) == {"alpha", "beta"} + assert loaded["alpha"].correct == 700 + + +def test_a_hand_edited_ledger_is_rejected(tmp_path) -> None: + """Rule 2 — tamper-evidence is structural. Editing a tally without + re-sealing does not produce a slightly-wrong ledger; it produces an + unratified one, and loading REFUSES.""" + path = tmp_path / "ledger.json" + write_sealed_ledger( + path, + seal_artifact({"alpha": _tally("alpha", 10)}, schema="t_v1", note="n", provenance="p"), + ) + artifact = json.loads(path.read_text()) + artifact["classes"]["alpha"]["correct"] = 9_999 + path.write_text(json.dumps(artifact, indent=2, sort_keys=True) + "\n") + with pytest.raises(RatifiedLedgerError): + load_sealed_ledger(path) + + +def test_missing_ledger_refuses_unless_declared_optional(tmp_path) -> None: + """A capability that SHIPS with a ledger is broken without it; one whose + practice volume is still being built legitimately has none. Neither is + answered by guessing a license.""" + path = tmp_path / "absent.json" + with pytest.raises(RatifiedLedgerError): + load_sealed_ledger(path) + assert load_sealed_ledger(path, missing_ok=True) == {} + + +def test_absent_class_is_never_licensed() -> None: + """Rule 4 — no evidence is not a license; the caller's ``None`` branch is + what serves the disclosed surface.""" + assert serve_license("nobody", {"alpha": _tally("alpha", 700)}) is None + + +def test_volume_floor_is_enforced_by_the_gate() -> None: + """A perfect but small record does not clear θ_SERVE=0.99 — the Wilson + floor is what makes a license *earned* rather than merely clean.""" + small = serve_license("alpha", {"alpha": _tally("alpha", 12)}) + large = serve_license("alpha", {"alpha": _tally("alpha", 720)}) + assert small is not None and not small.licensed + assert large is not None and large.licensed + + +def test_a_single_wrong_costs_the_license() -> None: + dirty = serve_license("alpha", {"alpha": _tally("alpha", 720, wrong=1)}) + assert dirty is not None and not dirty.licensed + + +def test_committed_deduction_ledger_reseals_byte_identically() -> None: + """The extraction's safety property: the bridge writes what the bespoke + sealers wrote, so adopting it moves no committed artifact and no lane pin.""" + from pathlib import Path + + from evals.deduction_serve.practice.runner import build_sealed_artifact + + committed = Path("chat/data/deduction_serve_ledger.json") + expected = json.dumps(build_sealed_artifact(), indent=2, sort_keys=True) + "\n" + assert committed.read_text(encoding="utf-8") == expected + + +def test_every_adapter_reads_through_the_bridge() -> None: + """All three instances now share one loader — the property that makes a + future change to the ratification rule land everywhere at once.""" + import chat.curriculum_serve_license as curriculum + import chat.deduction_serve_license as deduction + import generate.determine.estimation_license as estimation + + for module in (deduction, estimation, curriculum): + source = module.__file__ or "" + assert source + text = open(source, encoding="utf-8").read() + assert "core.ratified_ledger" in text + # The signal that an adapter still verifies for itself is that it + # hashes for itself; docstrings may (and do) still explain the rule. + assert "formation.hashing" not in text, ( + f"{module.__name__} still re-implements verification" + )