core/teaching/ratification.py
Claude 71bf04fb44
feat(provenance,teaching): close the lateral gaps the assessment actually found
Squashes the arc's work into one commit; the workflow-file edit it originally
carried is excluded (see the end of this message).

## Lane 1 — Workbench recorded a proved answer as ungrounded

With deduction_serving_enabled ratified ON (ADR-0256), workbench/api.py's live
chat route builds a bare ChatRuntime(), so the deduction composer decides
Workbench turns and stamps grounding_source="deduction" — but
_coerce_grounding_source carried a hand-copied whitelist of the six pre-arc
labels and silently rewrote anything else to "none". The runtime comment
reasoned this was inert because "REPL turns do not flow through Workbench's
CognitivePipelineRecord path". True, and irrelevant: the traffic flows the
other way. Stale since 2026-07-24.

Scope is one field. workbench/api.py:818 prefers TurnEvent.epistemic_state,
which read epistemic_state_needed — honest. So the UNregistered path degraded
honestly while the hand-copied whitelist asserted a falsehood; a second copy of
a closed enum was worse than no copy. Hence registration AND derivation:
GROUNDING_SOURCES exposes the Literal's members, and the coercion reads it.
workbench-ui badges/tokens/snapshot follow; enumCoverage.test.ts forces atomicity.

## Lane 2 — the ratification ceremony

The discovery loop was instrumented but not closed. teaching/ratification.py
turns a reviewed decision into a chain record, a corpus commit, and a receipt.

Its design turns on one observation: _ratified_rows DROPS unadmissible rows
silently — correct when serving, a trap when ratifying, because the file grows,
the commit lands, and the band count does not move. So the ceremony refuses to
call an append a ratification until it has re-read the curriculum through the
real loader and seen the chain arrive; a non-admitted append is rolled back.
Validation is a pre-flight courtesy, admission is the proof.

Arena queue entry and ledger reseal are deliberately NOT performed (bridge rule
1); the receipt names them. Front door: `core proposal-queue ratify`, a sibling
of `review` rather than a flag on it.

## Lane 3 — structural closures

- ADR-0263 gains rule 5: absence policy is DECLARED in CAPABILITY_LEDGERS, not
  passed at the call site. An AST-matched test fails if a serving path passes
  missing_ok again.
- Deductive suite added WHOLE to the pre-push gate: 285 tests in 29s against
  smoke's 216 in 62s, so no coverage trade was needed.
- Smoke/CI parity assertion made bidirectional. It was one-directional, and had
  drifted.
- test_prior_surface_deduction_binding.py pins correction binding on the
  deduction path. The review's diagnosis did NOT reproduce — hash_surface moves
  in lockstep — so it pins what is there. Mutation-checked.
- Domain-keyed ADR index over 312 flat-numbered files, explicitly partial.
- Arc-close brief template, plus this arc's own brief filled in against it.

## Lanes 4 and 5 — two premises falsified by measurement, one of them mine

Math 4.2: baseline reproduced (correct=5 wrong=0 refused=495); all four named
cases traced to one seam with each gap isolated by one-variable probes. Then the
number that changes the recommendation: the gap blocking case 0000 affects 1
case in 500, the 'than' gap blocking 0001 affects 2. ADR-0251's prohibition on
per-case growth now rests on a count. No reader change made.

CGA: versor_condition is 0.22% of a turn, not the "~10x proof latency" I claimed
— that multiplied an isolated microbenchmark by a call count and compared it to
a single verdict's latency. The real cost is geometric_product at 33,986
calls/turn (~73%) via cga_inner in search paths. The obvious closed form is NOT
bit-exact (954/4000 in f32); backend.vault_recall's serial fold IS (3000/3000,
worst-rel 0) and is the correct target. cargo test could not run —
static.crates.io is denied by the sandbox network policy — so the Rust parity
question stays open and the typestate lane is carried forward, not shipped
uncompiled.

## Not landed: three lines owed to .github/workflows/smoke.yml

The CI smoke gate is narrower than the local one —
test_pack_draft_serve_boundary.py (ADR-0253 INV-33) has been local-only, unseen
because the parity pin checked one direction. The edit was authored and rejected
at push for lacking the `workflow` OAuth scope, so it is recorded as a named,
dated PENDING_IN_CI exception rather than dropped: the assertion still fires on
any new divergence, and a second guard fires once the three land.

[Verification]: pre-push gates all green — smoke 236 passed, warmed_session 10
passed, deductive 285 passed. Ratification 14, ADR index 5, CLI suites 10.
Grounding/epistemic sweep 741 passed 1 skipped. workbench-ui 598 passed across
73 files, tsc -b clean. capability index 11 passed, digest unchanged. Math
holdout correct=5 wrong=0 refused=495. Committed chain corpora byte-unchanged
after the tests that write to them.
Environment caveat: the repo pins requires-python ==3.12.13, which uv cannot
fetch for linux-x86_64, so `uv sync --locked` fails. All Python runs used a
scratch venv on 3.12.11 with declared deps — not the locked universe, not the
full ~12k suite. The pin was left untouched. Re-run on a 3.12.13 host before
treating this as merge evidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FduW6Krm3PPQv3P5iwBYtx
2026-07-25 04:51:15 +00:00

382 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""teaching/ratification.py — the curriculum ratification ceremony.
The discovery loop is instrumented but not closed. Proposals flow into
``teaching/proposals/`` and a human can read them via ``core proposal-queue
review``, but `review_log.jsonl` explicitly *never* ratifies — it records that
someone looked. Nothing converts a reviewed proposal into a ratified chain, so
the loop terminates in a human decision that produces no artifact.
That gap is the binding constraint. Curriculum serving is OFF because no band
can earn a license from present volume (ADR-0262 §5) — every one of the eleven
live bands is 24×73× short of the entailed-bucket floor — and the only way
volume grows is one ratified chain at a time.
This module is the code-bound ceremony: **a reviewed decision produces a chain
record, a corpus commit, and a receipt that proves the chain is actually
routable.**
Why the last clause is the whole design
---------------------------------------
``teaching.curriculum_premises._ratified_rows`` DROPS every row it cannot
admit — wrong ``review_status``, wrong domain, empty terms, a term absent from
its declared pack, a connective outside ``CONNECTIVE_FAMILY``. It drops
silently, by ``continue``, because at *serving* time that is right: a curriculum
must never route on a row it cannot license.
At *ratification* time the same silence is a trap. Appending a row that the
loader will drop looks exactly like appending a row that works: the file grows,
the commit lands, the band count does not move, and nobody learns why for weeks.
So this ceremony refuses to call an append a ratification until it has re-read
the curriculum through the real loader and observed the chain arrive. Validation
is a pre-flight courtesy; **admission is the proof**.
Scope, deliberately narrow
--------------------------
Nothing here decides *whether* a proposal is true. It cannot auto-ratify, has no
opinion on content, and requires a human-supplied reviewer identity and rationale
on every call. It converts an already-made decision into the artifacts that
decision implies — which is precisely what was missing.
The two downstream stages of the full ceremony, arena queue entry and ledger
reseal, are intentionally NOT performed here. Both are sealed-practice outputs,
and bridge rule 1 is that nothing outside a practice run may write a ledger
(``core.ratified_ledger``). :func:`ratify_chain` returns the receipt those
stages consume; running them stays an explicit, separate operator step.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from core.capability.domains import (
DOMAIN_CAPABILITY_CORPORA,
DOMAIN_CORPORA,
DOMAIN_PACKS,
)
from teaching.curriculum_premises import CONNECTIVE_FAMILY, load_curriculum
_REPO_ROOT = Path(__file__).resolve().parents[1]
#: Chain ids are ``<domain>-<family>-<NNN>``, zero-padded to three.
_CHAIN_ID_RE = re.compile(r"^(?P<domain>[a-z_]+)-(?P<family>[a-z]+)-(?P<seq>\d{3,})$")
#: The committed row's field order. Chain corpora are reviewed as diffs, so the
#: key order is part of the artifact, not an implementation detail.
_ROW_FIELDS: tuple[str, ...] = (
"chain_id",
"domain",
"operator_family",
"subject",
"intent",
"connective",
"object",
"subject_pack_id",
"object_pack_id",
"review_status",
"provenance",
)
class RatificationError(ValueError):
"""A proposed chain cannot be ratified, with the reason stated."""
@dataclass(frozen=True, slots=True)
class ChainRecord:
"""One curriculum edge, in the committed on-disk shape."""
chain_id: str
domain: str
operator_family: str
subject: str
intent: str
connective: str
object: str
subject_pack_id: str
object_pack_id: str
review_status: str
provenance: str
def as_row(self) -> dict[str, Any]:
return {name: getattr(self, name) for name in _ROW_FIELDS}
def as_jsonl_line(self) -> str:
"""Byte-compatible with the committed corpora: compact separators,
insertion order preserved, one trailing newline."""
return json.dumps(self.as_row(), separators=(",", ":")) + "\n"
@dataclass(frozen=True, slots=True)
class RatificationReceipt:
"""Proof that a ratification actually changed what the engine can route."""
chain: ChainRecord
corpus_id: str
corpus_path: str
chains_before: int
chains_after: int
family_chains_before: int
family_chains_after: int
#: Follow-on stages this receipt authorizes but does NOT perform.
pending_stages: tuple[str, ...] = field(
default=("arena_queue_entry", "ledger_reseal")
)
@property
def admitted(self) -> bool:
return self.chains_after == self.chains_before + 1
def as_dict(self) -> dict[str, Any]:
return {
"chain": self.chain.as_row(),
"corpus_id": self.corpus_id,
"corpus_path": self.corpus_path,
"chains_before": self.chains_before,
"chains_after": self.chains_after,
"family_chains_before": self.family_chains_before,
"family_chains_after": self.family_chains_after,
"admitted": self.admitted,
"pending_stages": list(self.pending_stages),
}
def _pack_lemmas(pack_id: str) -> frozenset[str]:
"""Mirror of the loader's pack reader, so validation sees what it sees."""
path = _REPO_ROOT / "packs" / "data" / pack_id / "lexicon.jsonl"
if not path.exists():
return frozenset()
lemmas: set[str] = set()
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
try:
row = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(row, dict):
lemma = str(row.get("lemma") or "").strip()
if lemma:
lemmas.add(lemma)
return frozenset(lemmas)
def corpus_path_for(domain: str) -> tuple[str, Path]:
"""The single writable chain corpus for *domain*.
A domain with several corpora (``philosophy_theology``) has no unambiguous
append target, so the ceremony refuses rather than guessing which file a
reviewer meant.
"""
corpora = DOMAIN_CORPORA.get(domain, ())
if not corpora:
raise RatificationError(f"unknown domain: {domain!r}")
writable = [c for c in corpora if c in DOMAIN_CAPABILITY_CORPORA]
if len(writable) != 1:
raise RatificationError(
f"{domain}: expected exactly one writable chain corpus, found "
f"{writable!r} — name the target explicitly before ratifying"
)
corpus_id = writable[0]
return corpus_id, _REPO_ROOT / DOMAIN_CAPABILITY_CORPORA[corpus_id]
def existing_rows(domain: str) -> list[dict[str, Any]]:
"""Every row in *domain*'s corpus, admitted or not (raw file read)."""
_, path = corpus_path_for(domain)
if not path.exists():
return []
rows: list[dict[str, Any]] = []
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
try:
row = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(row, dict):
rows.append(row)
return rows
def next_chain_id(domain: str, family: str) -> str:
"""The next free ``<domain>-<family>-NNN``, deterministic from the corpus."""
highest = 0
for row in existing_rows(domain):
m = _CHAIN_ID_RE.match(str(row.get("chain_id") or ""))
if m and m.group("domain") == domain and m.group("family") == family:
highest = max(highest, int(m.group("seq")))
return f"{domain}-{family}-{highest + 1:03d}"
def build_chain_record(
*,
domain: str,
subject: str,
connective: str,
obj: str,
reviewer: str,
rationale: str,
intent: str = "cause",
chain_id: str | None = None,
) -> ChainRecord:
"""Construct the record a reviewed decision implies.
``reviewer`` and ``rationale`` are required and land in ``provenance``: a
ratified chain must carry who ratified it and why, or the corpus loses the
audit lineage that makes it reviewable as a diff.
"""
reviewer = reviewer.strip()
rationale = rationale.strip()
if not reviewer:
raise RatificationError("ratification requires a reviewer identity")
if not rationale:
raise RatificationError("ratification requires a stated rationale")
family = CONNECTIVE_FAMILY.get(connective.strip())
if family is None:
raise RatificationError(
f"connective {connective!r} is outside CONNECTIVE_FAMILY "
f"{sorted(CONNECTIVE_FAMILY)} — the loader would drop this row"
)
packs = DOMAIN_PACKS.get(domain, ())
if not packs:
raise RatificationError(f"domain {domain!r} mounts no packs")
subject, obj = subject.strip(), obj.strip()
subject_pack = next((p for p in packs if subject in _pack_lemmas(p)), "")
object_pack = next((p for p in packs if obj in _pack_lemmas(p)), "")
if not subject_pack:
raise RatificationError(
f"subject {subject!r} is taught by no pack mounted for {domain} "
f"({list(packs)}) — the loader would drop this row"
)
if not object_pack:
raise RatificationError(
f"object {obj!r} is taught by no pack mounted for {domain} "
f"({list(packs)}) — the loader would drop this row"
)
return ChainRecord(
chain_id=chain_id or next_chain_id(domain, family),
domain=domain,
operator_family=family,
subject=subject,
intent=intent,
connective=connective.strip(),
object=obj,
subject_pack_id=subject_pack,
object_pack_id=object_pack,
review_status="reviewed",
provenance=f"ratification-ceremony:{reviewer}:{rationale}",
)
def validate_admissible(record: ChainRecord) -> None:
"""Pre-flight: every rule ``_ratified_rows`` drops on, raised LOUDLY.
Serving is right to drop silently. Ratification is not: an append the
loader will discard looks identical to one that works.
"""
if record.review_status != "reviewed":
raise RatificationError(
f"review_status={record.review_status!r} — only 'reviewed' is admitted"
)
if not record.subject or not record.object:
raise RatificationError("subject and object must both be non-empty")
if CONNECTIVE_FAMILY.get(record.connective) is None:
raise RatificationError(f"connective {record.connective!r} has no family")
if record.subject_pack_id and record.subject not in _pack_lemmas(record.subject_pack_id):
raise RatificationError(
f"subject {record.subject!r} absent from pack {record.subject_pack_id}"
)
if record.object_pack_id and record.object not in _pack_lemmas(record.object_pack_id):
raise RatificationError(
f"object {record.object!r} absent from pack {record.object_pack_id}"
)
for row in existing_rows(record.domain):
if str(row.get("chain_id")) == record.chain_id:
raise RatificationError(f"chain_id {record.chain_id} already committed")
if (
str(row.get("subject")) == record.subject
and str(row.get("connective")) == record.connective
and str(row.get("object")) == record.object
):
raise RatificationError(
f"duplicate edge: {row.get('chain_id')} already teaches "
f"{record.subject} {record.connective} {record.object}"
)
def _count_chains(domain: str, family: str) -> tuple[int, int]:
load_curriculum.cache_clear()
curriculum = load_curriculum(domain)
return len(curriculum.chains), sum(
1 for c in curriculum.chains if c.family == family
)
def ratify_chain(record: ChainRecord, *, dry_run: bool = False) -> RatificationReceipt:
"""The ceremony: validate → append → **confirm admission** → receipt.
Raises :class:`RatificationError` if the appended row does not arrive in the
reloaded curriculum. On that failure the append is rolled back, because a
corpus carrying rows the engine silently ignores is worse than one that
never grew: it makes the volume ledger lie.
"""
validate_admissible(record)
corpus_id, path = corpus_path_for(record.domain)
before_total, before_family = _count_chains(record.domain, record.operator_family)
if dry_run:
return RatificationReceipt(
chain=record,
corpus_id=corpus_id,
corpus_path=str(path),
chains_before=before_total,
chains_after=before_total + 1,
family_chains_before=before_family,
family_chains_after=before_family + 1,
)
original = path.read_text(encoding="utf-8") if path.exists() else ""
if original and not original.endswith("\n"):
original += "\n"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(original + record.as_jsonl_line(), encoding="utf-8")
after_total, after_family = _count_chains(record.domain, record.operator_family)
if after_total != before_total + 1:
path.write_text(original, encoding="utf-8")
_count_chains(record.domain, record.operator_family)
raise RatificationError(
f"{record.chain_id} was appended but the curriculum loader did not "
f"admit it ({before_total} -> {after_total}); append rolled back"
)
return RatificationReceipt(
chain=record,
corpus_id=corpus_id,
corpus_path=str(path),
chains_before=before_total,
chains_after=after_total,
family_chains_before=before_family,
family_chains_after=after_family,
)
__all__ = [
"ChainRecord",
"RatificationError",
"RatificationReceipt",
"build_chain_record",
"corpus_path_for",
"existing_rows",
"next_chain_id",
"ratify_chain",
"validate_admissible",
]