core/teaching/ratification.py
Shay 08f6d6802c feat(curriculum): taught negatives — polarity is read (ADR-0264 R1-R4, R8)
`polarity` was read by NOTHING. `CurriculumChain.sentence` was unconditionally
affirmative, so a row authored to REFUTE an atom compiled to a premise
ASSERTING it and the question came back `entailed`. The independent oracle
ignored the field too — so gold agreed and wrong=0 stayed green. That is the
shape of defect no gate in this repo can catch, which is why ADR-0264 settled
the epistemology before any code moved.

R1 — row-level `polarity`, absent => affirmative. Not an `intent` value and not
a new `operator_family`: bands key on the connective-derived family, so
`modal_negative` would open a fresh band at n=0 instead of adding refuted
volume to the band it belongs to. An UNRECOGNIZED token is dropped at the
admission boundary rather than read as affirmative — that is the one direction
of error the rule exists to prevent. `as_row` OMITS the field when affirmative,
so committed corpora stay byte-identical and no lane hash moves.

R2 — a negative row compiles under the sentential-negation prefix the argument
reader already parses, so `(¬p, therefore p)` is a tautological refutation.

R3 — `atom_sentence` is polarity-independent, which IS the rule: a negative row
must mint the SAME propositional atom. Demonstrated, not just asserted —
`test_r3_a_different_connective_would_silently_fail_to_refute` shows a negated
paraphrase returning UNKNOWN, i.e. a taught refutation that quietly fails to
refute with nothing going red.

R4 — one atom cannot hold both polarities; rejected at ratification, naming
both polarities and the consequence. Left to serve time it would surface as
INCONSISTENT_PREMISES: the band silently going dark across the whole family
instead of the authoring mistake being named. Duplicate-edge and contradiction
stay distinguishable failures.

R8 — the oracle learns polarity INDEPENDENTLY (its own constants, its own row
reader; asserted at source level that it imports nothing from
teaching.curriculum_premises) and EXCLUDES negatives from the reachability
adjacency, because a denial supplies no step from subject to object.

NO negative row is committed — authoring curriculum is Phase F and Shay's. So
the shipped gold mix is still entailed 72 / unknown 4968, zero refuted, and
Phase C's `test_gold_mix_has_no_refuted_class` stays green with its message
retargeted from "unimplemented" to "no content yet".

Also adds `--polarity` to `core proposal-queue ratify`, so the mechanism is
reachable from the operator surface rather than only from Python.

The end-to-end tests repoint BOTH loaders at a temp corpus instead of writing
the committed one. An earlier draft appended to physics_chains_v1.jsonl and
restored it, which is safe under smoke/deductive (serial) but NOT under `full`,
which injects `-n auto`: a parallel worker reading mid-mutation would see an
uncommitted row and the failure would look like a corpus problem rather than
test isolation. Each loader is still redirected separately at its own module
constant and still parses with its own code, so R8's independence is untouched.

[Verification]: in-worktree on canonical CPython 3.12.13 with `uv sync
--locked`: smoke 593 (571 + 22), deductive 332 (310 + 22). curriculum_serve
lane unchanged at n=32 correct=32 wrong=0 anti_recall=5; all 11 lane SHA pins
match; committed corpora untouched (git status clean under
teaching/domain_chains/). Mutation-checked all three load-bearing rules: R2
sentence ignoring polarity -> 5 red; R8 negatives back in the adjacency -> the
exclusion test red; R4 check removed -> both R4 tests red. The R8 test has an
affirmative control proving the 2-hop path is real when the row is positive,
so `depth == 0` cannot pass vacuously.
2026-07-26 12:19:19 -07:00

451 lines
17 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 (
AFFIRMATIVE,
CONNECTIVE_FAMILY,
NEGATIVE,
POLARITIES,
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",
# ADR-0264 R1 — row-level polarity, emitted only for NEGATIVE rows (see
# ``ChainRecord.as_row``). An affirmative row omits it, so every committed
# corpus stays byte-identical and no existing lane hash moves.
"polarity",
"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
#: ADR-0264 R1. Defaulted so existing construction sites are unchanged.
polarity: str = AFFIRMATIVE
def as_row(self) -> dict[str, Any]:
"""The committed row.
``polarity`` is OMITTED when affirmative. An absent field already reads
as affirmative (ADR-0264 R1), so emitting it would add a redundant key
to every future row while the committed corpora lack it — two spellings
of the same fact, and a diff that looks like a schema change on rows
whose meaning did not move.
"""
row = {
name: getattr(self, name)
for name in _ROW_FIELDS
if name != "polarity"
}
if self.polarity != AFFIRMATIVE:
# Re-insert in _ROW_FIELDS order — chain corpora are reviewed as
# diffs, so key order is part of the artifact.
ordered = {}
for name in _ROW_FIELDS:
if name == "polarity":
ordered["polarity"] = self.polarity
else:
ordered[name] = row[name]
return ordered
return row
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",
polarity: str = AFFIRMATIVE,
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")
polarity = polarity.strip().lower()
if polarity not in POLARITIES:
raise RatificationError(
f"polarity {polarity!r} is not one of {list(POLARITIES)} — the "
"curriculum loader would drop this row (ADR-0264 R1)"
)
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. "
"A NEGATIVE row must reuse the affirmative connective (ADR-0264 R3), "
"never a negated paraphrase of it."
)
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}",
polarity=polarity,
)
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}"
)
if record.polarity not in POLARITIES:
raise RatificationError(
f"polarity {record.polarity!r} has no meaning (ADR-0264 R1)"
)
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
):
# ADR-0264 R4 — a corpus stating both polarities of ONE atom is
# contradictory, and the contradiction is rejected HERE rather than
# discovered at serve time. `evaluate_entailment_with_trace` would
# meet `p ∧ ¬p` and refuse INCONSISTENT_PREMISES, which is honest
# but arrives as a band that has stopped answering anything in that
# family — the curriculum silently going dark instead of the
# authoring mistake being named.
existing_polarity = (
str(row.get("polarity") or AFFIRMATIVE).strip().lower()
)
if existing_polarity != record.polarity:
raise RatificationError(
f"contradiction: {row.get('chain_id')} already teaches "
f"{existing_polarity} '{record.subject} {record.connective} "
f"{record.object}'; this row would teach {record.polarity}. "
"One atom cannot hold both polarities (ADR-0264 R4) — the "
"compiled premises would be inconsistent and the band would "
"refuse every question in the family."
)
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",
]