feat(teaching): implement ADR-0094 — Proposal Source Provenance

Sealed ProposalSource type widening TeachingChainProposal and
PackMutationProposal schemas with typed (kind, source_id,
emitted_at_revision) provenance. Schema-only widening; no runtime
behavior changes. Unblocks ADR-0095 miner-sourced proposals.

- new teaching/source.py: frozen ProposalSource dataclass with sealed
  ProposalKind Literal["operator","miner","curriculum"], runtime
  invariants (operator → empty source_id; miner/curriculum → non-empty),
  serialize() ("operator" / "miner:<id>" / "curriculum:<id>"),
  as_dict/from_dict round-trip, ProposalSource.operator() helper
- TeachingChainProposal.source field added (proposals.py)
- PackMutationProposal.source field added (store.py)
- build_proposal() accepts optional source kwarg; default uses
  _default_operator_source() pinned at cached git HEAD SHA
- ProposalLog.current_state() now strictly requires source on every
  created event; raises ProposalError with migration pointer if missing;
  validates via ProposalSource.from_dict so malformed payloads reject
- teaching/migrate_proposals_source_field.py: deterministic one-shot
  migration script using PRE_MIGRATION_SENTINEL ("pre-adr-0094-migration")
  as the emitted_at_revision so re-runs across commits produce identical
  bytes
- migration applied to live proposals.jsonl: 11 created events gained
  source field; 33 non-created events untouched; idempotent verified
- 29 unit tests in test_proposal_source.py covering construction,
  serialization, exhaustive-match pattern with assert_never,
  migration determinism (3 idempotence/cross-run tests), strict-parse
  rejection, live-log loads
- 2 test fixes in test_epistemic_invariants.py for new required source param
- smoke 67/67, teaching 17/17, cognition 120/121 (1 pre-existing skip),
  runtime 19/19; cognition eval byte-identical 100/100/100/100
This commit is contained in:
Shay 2026-05-21 18:11:09 -07:00
parent afdd2ee413
commit b24796386e
7 changed files with 673 additions and 14 deletions

View file

@ -0,0 +1,123 @@
"""One-shot deterministic migration: attach ``source`` to legacy proposals (ADR-0094).
Walks a ``proposals.jsonl`` file and rewrites every ``event: created``
line whose ``proposal`` dict lacks a ``source`` field, attaching a
deterministic operator-authored :class:`ProposalSource` pinned at the
sentinel revision ``"pre-adr-0094-migration"``.
Determinism guarantee: same input file byte-identical output file.
The sentinel revision (not current HEAD) is used so re-runs across
different commits still produce identical bytes.
Non-``created`` events are passed through unchanged. Lines that
already carry a ``source`` field are not re-migrated.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
PRE_MIGRATION_SENTINEL: str = "pre-adr-0094-migration"
DEFAULT_OPERATOR_SOURCE_PAYLOAD: dict[str, str] = {
"kind": "operator",
"source_id": "",
"emitted_at_revision": PRE_MIGRATION_SENTINEL,
}
def migrate_file(path: Path, *, dry_run: bool = False) -> dict[str, Any]:
"""Migrate ``path`` in place. Returns a summary of changes.
``dry_run`` skips the file write but still reports counts.
"""
if not path.exists():
raise FileNotFoundError(f"proposals log not found at {path}")
original = path.read_bytes()
lines = original.decode("utf-8").splitlines()
migrated_lines: list[str] = []
migrated_count = 0
skipped_count = 0
untouched_count = 0
for raw in lines:
if not raw.strip():
migrated_lines.append(raw)
untouched_count += 1
continue
try:
event = json.loads(raw)
except json.JSONDecodeError:
migrated_lines.append(raw)
untouched_count += 1
continue
if event.get("event") != "created":
migrated_lines.append(raw)
untouched_count += 1
continue
proposal = event.get("proposal")
if not isinstance(proposal, dict):
migrated_lines.append(raw)
untouched_count += 1
continue
if "source" in proposal:
migrated_lines.append(raw)
skipped_count += 1
continue
proposal["source"] = dict(DEFAULT_OPERATOR_SOURCE_PAYLOAD)
event["proposal"] = proposal
migrated_lines.append(
json.dumps(event, sort_keys=True, separators=(",", ":"))
)
migrated_count += 1
new_bytes = ("\n".join(migrated_lines) + "\n").encode("utf-8") if migrated_lines else b""
if not dry_run and new_bytes != original:
path.write_bytes(new_bytes)
return {
"path": str(path),
"total_lines": len(lines),
"migrated_count": migrated_count,
"already_had_source": skipped_count,
"untouched_count": untouched_count,
"bytes_before": len(original),
"bytes_after": len(new_bytes),
"changed": new_bytes != original,
"dry_run": dry_run,
}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="ADR-0094 source-field migration")
parser.add_argument(
"--path",
type=Path,
default=Path(__file__).resolve().parent / "proposals" / "proposals.jsonl",
help="proposals.jsonl path to migrate (default: in-tree live log)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="report what would change without writing",
)
args = parser.parse_args(argv)
summary = migrate_file(args.path, dry_run=args.dry_run)
print(json.dumps(summary, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -26,6 +26,7 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal
from teaching.provenance import Provenance
from teaching.source import ProposalSource
if TYPE_CHECKING:
# Deferred to break a circular import: teaching.discovery →
@ -75,7 +76,13 @@ class ReplayEvidence:
@dataclass(frozen=True, slots=True)
class TeachingChainProposal:
"""One proposed extension of the active teaching corpus."""
"""One proposed extension of the active teaching corpus.
The ``source`` field (ADR-0094) carries typed provenance: operator
versus miner versus curriculum. Operator is the default and is
populated on every existing proposal by the migration utility in
:mod:`teaching.proposals.migrate_source_field`.
"""
proposal_id: str
source_candidate_id: str
@ -83,6 +90,7 @@ class TeachingChainProposal:
polarity: Literal["affirms", "falsifies"]
claim_domain: ClaimDomain
evidence: tuple[EvidencePointer, ...]
source: ProposalSource
review_state: ReviewState = "pending"
operator_note: str = ""
replay_evidence: ReplayEvidence | None = None
@ -96,6 +104,7 @@ class TeachingChainProposal:
"polarity": self.polarity,
"claim_domain": self.claim_domain,
"evidence": [e.as_dict() for e in self.evidence],
"source": self.source.as_dict(),
"review_state": self.review_state,
"operator_note": self.operator_note,
"replay_evidence": (
@ -172,16 +181,24 @@ def _proposal_id(source_candidate_id: str, chain: dict[str, Any]) -> str:
def build_proposal(
candidate: DiscoveryCandidate, *, allow_evaluative: bool = False
candidate: DiscoveryCandidate,
*,
allow_evaluative: bool = False,
source: ProposalSource | None = None,
) -> TeachingChainProposal:
"""Build a ``pending`` proposal from an eligible candidate.
Raises ``ProposalError`` for any failing gate. Idempotent on
(source_candidate_id, proposed_chain): same inputs produce the
same ``proposal_id``.
The ``source`` parameter (ADR-0094) defaults to an operator-authored
source pinned at the current git HEAD. Miner-sourced and
curriculum-sourced callers pass an explicit :class:`ProposalSource`.
"""
check_eligibility(candidate, allow_evaluative=allow_evaluative)
assert candidate.polarity in ("affirms", "falsifies")
resolved_source = source if source is not None else _default_operator_source()
return TeachingChainProposal(
proposal_id=_proposal_id(candidate.candidate_id, candidate.proposed_chain),
source_candidate_id=candidate.candidate_id,
@ -189,9 +206,47 @@ def build_proposal(
polarity=candidate.polarity,
claim_domain=candidate.claim_domain,
evidence=tuple(candidate.evidence),
source=resolved_source,
)
def _default_operator_source() -> ProposalSource:
"""Return an operator-authored source pinned at the current HEAD.
Used by :func:`build_proposal` when no explicit source is given.
Reads ``git rev-parse HEAD``; falls back to ``"unknown"`` when git
is unavailable so the schema invariant
``emitted_at_revision`` non-empty still holds.
"""
return ProposalSource.operator(emitted_at_revision=_current_revision())
def _current_revision() -> str:
"""Return the current git HEAD SHA, or ``"unknown"`` if unavailable.
Pure helper; no side effects. Cached at module load so a long
session sees a stable value even if HEAD moves.
"""
global _CACHED_REVISION
if _CACHED_REVISION is not None:
return _CACHED_REVISION
import subprocess
try:
sha = subprocess.check_output(
["git", "rev-parse", "HEAD"],
cwd=Path(__file__).resolve().parent.parent,
stderr=subprocess.DEVNULL,
text=True,
).strip()
_CACHED_REVISION = sha or "unknown"
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
_CACHED_REVISION = "unknown"
return _CACHED_REVISION
_CACHED_REVISION: str | None = None
# ---------------------------------------------------------------------------
# Append-only proposal log
# ---------------------------------------------------------------------------
@ -272,9 +327,15 @@ class ProposalLog:
def current_state(self) -> dict[str, dict[str, Any]]:
"""Replay the log → ``{proposal_id: {state, proposal, replay,
note, accepted_chain_id}}``.
note, accepted_chain_id, source}}``.
The active view is derived deterministically from the log.
ADR-0094: every ``created`` event must carry a ``source`` field
on its proposal payload. Missing ``source`` raises
:class:`ProposalError`; the live log is migrated via
:mod:`teaching.migrate_proposals_source_field` exactly once at
ADR-0094 landing.
"""
view: dict[str, dict[str, Any]] = {}
for ev in self._events():
@ -284,11 +345,22 @@ class ProposalLog:
pid = p.get("proposal_id")
if not pid:
continue
if "source" not in p:
raise ProposalError(
f"proposal {pid!r} missing required 'source' field; "
"run teaching/migrate_proposals_source_field.py "
"(ADR-0094)"
)
# Validate that source parses as a v1 ProposalSource;
# we keep the raw dict in the view for backward
# compatibility but reject malformed payloads here.
ProposalSource.from_dict(p["source"])
view.setdefault(pid, {
"proposal": p,
"state": p.get("review_state", "pending"),
"replay_evidence": p.get("replay_evidence"),
"operator_note": p.get("operator_note", ""),
"source": p["source"],
"accepted_chain_id": None,
"accepted_provenance": None,
})

View file

@ -1,10 +1,10 @@
{"event":"created","proposal":{"claim_domain":"factual","evidence":[{"epistemic_status":"coherent","polarity":"affirms","ref":"cause_truth_grounds_knowledge","source":"corpus"}],"operator_note":"","polarity":"affirms","proposal_id":"39d09297759f5bfa54e037d089a9fd11","proposed_chain":{"connective":"requires","intent":"cause","object":"knowledge","subject":"understanding"},"provenance":null,"replay_evidence":null,"review_state":"pending","source_candidate_id":"a199f13dba127a34cb8fa4bf28728020bec932e6b55c24c93ee1ef1be8d51063"}}
{"event":"created","proposal":{"claim_domain":"factual","evidence":[{"epistemic_status":"coherent","polarity":"affirms","ref":"cause_truth_grounds_knowledge","source":"corpus"}],"operator_note":"","polarity":"affirms","proposal_id":"39d09297759f5bfa54e037d089a9fd11","proposed_chain":{"connective":"requires","intent":"cause","object":"knowledge","subject":"understanding"},"provenance":null,"replay_evidence":null,"review_state":"pending","source":{"emitted_at_revision":"pre-adr-0094-migration","kind":"operator","source_id":""},"source_candidate_id":"a199f13dba127a34cb8fa4bf28728020bec932e6b55c24c93ee1ef1be8d51063"}}
{"event":"replay","proposal_id":"39d09297759f5bfa54e037d089a9fd11","replay_evidence":{"baseline":{"intent_accuracy":1.0,"surface_groundedness":1.0,"term_capture_rate":0.9167,"versor_closure_rate":1.0},"candidate":{"intent_accuracy":1.0,"surface_groundedness":1.0,"term_capture_rate":0.9167,"versor_closure_rate":1.0},"regressed_metrics":[],"replay_equivalent":true}}
{"event":"created","proposal":{"claim_domain":"factual","evidence":[{"epistemic_status":"coherent","polarity":"affirms","ref":"cause_wisdom_orders_judgment","source":"corpus"}],"operator_note":"","polarity":"affirms","proposal_id":"944c6c5180819c9194765f32dbc31714","proposed_chain":{"connective":"requires","intent":"cause","object":"wisdom","subject":"judgment"},"provenance":null,"replay_evidence":null,"review_state":"pending","source_candidate_id":"1f1a1387a4cd1cecb27dc6fb37af0e3322afd8bc23b6abeb9c9c0e5e606cf91a"}}
{"event":"created","proposal":{"claim_domain":"factual","evidence":[{"epistemic_status":"coherent","polarity":"affirms","ref":"cause_wisdom_orders_judgment","source":"corpus"}],"operator_note":"","polarity":"affirms","proposal_id":"944c6c5180819c9194765f32dbc31714","proposed_chain":{"connective":"requires","intent":"cause","object":"wisdom","subject":"judgment"},"provenance":null,"replay_evidence":null,"review_state":"pending","source":{"emitted_at_revision":"pre-adr-0094-migration","kind":"operator","source_id":""},"source_candidate_id":"1f1a1387a4cd1cecb27dc6fb37af0e3322afd8bc23b6abeb9c9c0e5e606cf91a"}}
{"event":"replay","proposal_id":"944c6c5180819c9194765f32dbc31714","replay_evidence":{"baseline":{"intent_accuracy":1.0,"surface_groundedness":1.0,"term_capture_rate":0.9167,"versor_closure_rate":1.0},"candidate":{"intent_accuracy":1.0,"surface_groundedness":1.0,"term_capture_rate":0.9167,"versor_closure_rate":1.0},"regressed_metrics":[],"replay_equivalent":true}}
{"event":"created","proposal":{"claim_domain":"factual","evidence":[{"epistemic_status":"coherent","polarity":"affirms","ref":"cause_truth_grounds_knowledge","source":"corpus"}],"operator_note":"","polarity":"affirms","proposal_id":"a5647f07a0227d1853f926d439603f28","proposed_chain":{"connective":"grounds","intent":"verification","object":"knowledge","subject":"evidence"},"provenance":null,"replay_evidence":null,"review_state":"pending","source_candidate_id":"b568e12273ddac1c0bf6e611ae331febcffc9f4df7a47df7f575416277d288f1"}}
{"event":"created","proposal":{"claim_domain":"factual","evidence":[{"epistemic_status":"coherent","polarity":"affirms","ref":"cause_truth_grounds_knowledge","source":"corpus"}],"operator_note":"","polarity":"affirms","proposal_id":"a5647f07a0227d1853f926d439603f28","proposed_chain":{"connective":"grounds","intent":"verification","object":"knowledge","subject":"evidence"},"provenance":null,"replay_evidence":null,"review_state":"pending","source":{"emitted_at_revision":"pre-adr-0094-migration","kind":"operator","source_id":""},"source_candidate_id":"b568e12273ddac1c0bf6e611ae331febcffc9f4df7a47df7f575416277d288f1"}}
{"event":"replay","proposal_id":"a5647f07a0227d1853f926d439603f28","replay_evidence":{"baseline":{"intent_accuracy":1.0,"surface_groundedness":1.0,"term_capture_rate":0.9167,"versor_closure_rate":1.0},"candidate":{"intent_accuracy":1.0,"surface_groundedness":1.0,"term_capture_rate":0.9167,"versor_closure_rate":1.0},"regressed_metrics":[],"replay_equivalent":true}}
{"event":"created","proposal":{"claim_domain":"factual","evidence":[{"epistemic_status":"coherent","polarity":"affirms","ref":"cause_knowledge_requires_evidence","source":"corpus"}],"operator_note":"","polarity":"affirms","proposal_id":"bb01f8acd6f5db144bdac21a6f754dc7","proposed_chain":{"connective":"requires","intent":"cause","object":"evidence","subject":"inference"},"provenance":null,"replay_evidence":null,"review_state":"pending","source_candidate_id":"a4e1560e3e65f32d2b77496d9fe819038a9c0df2d7c8112fa8aaf6347b7b7081"}}
{"event":"created","proposal":{"claim_domain":"factual","evidence":[{"epistemic_status":"coherent","polarity":"affirms","ref":"cause_knowledge_requires_evidence","source":"corpus"}],"operator_note":"","polarity":"affirms","proposal_id":"bb01f8acd6f5db144bdac21a6f754dc7","proposed_chain":{"connective":"requires","intent":"cause","object":"evidence","subject":"inference"},"provenance":null,"replay_evidence":null,"review_state":"pending","source":{"emitted_at_revision":"pre-adr-0094-migration","kind":"operator","source_id":""},"source_candidate_id":"a4e1560e3e65f32d2b77496d9fe819038a9c0df2d7c8112fa8aaf6347b7b7081"}}
{"event":"replay","proposal_id":"bb01f8acd6f5db144bdac21a6f754dc7","replay_evidence":{"baseline":{"intent_accuracy":1.0,"surface_groundedness":1.0,"term_capture_rate":0.9167,"versor_closure_rate":1.0},"candidate":{"intent_accuracy":1.0,"surface_groundedness":1.0,"term_capture_rate":0.9167,"versor_closure_rate":1.0},"regressed_metrics":[],"replay_equivalent":true}}
{"event":"transition","note":"Epistemology v1: understanding requires knowledge","proposal_id":"39d09297759f5bfa54e037d089a9fd11","to":"accepted"}
{"chain_id":"cause_understanding_requires_knowledge","event":"accepted_corpus_append","proposal_id":"39d09297759f5bfa54e037d089a9fd11","provenance":{"adr_id":"adr-0057","raw":"adr-0057:discovery_promoted:2026-05-18","review_date":"2026-05-18","source":"discovery_promoted"}}
@ -14,19 +14,19 @@
{"chain_id":"verification_evidence_grounds_knowledge","event":"accepted_corpus_append","proposal_id":"a5647f07a0227d1853f926d439603f28","provenance":{"adr_id":"adr-0057","raw":"adr-0057:discovery_promoted:2026-05-18","review_date":"2026-05-18","source":"discovery_promoted"}}
{"event":"transition","note":"Epistemology v1: inference requires evidence","proposal_id":"bb01f8acd6f5db144bdac21a6f754dc7","to":"accepted"}
{"chain_id":"cause_inference_requires_evidence","event":"accepted_corpus_append","proposal_id":"bb01f8acd6f5db144bdac21a6f754dc7","provenance":{"adr_id":"adr-0057","raw":"adr-0057:discovery_promoted:2026-05-18","review_date":"2026-05-18","source":"discovery_promoted"}}
{"event":"created","proposal":{"claim_domain":"factual","evidence":[{"epistemic_status":"coherent","polarity":"affirms","ref":"cause_creation_reveals_meaning","source":"corpus"}],"operator_note":"","polarity":"affirms","proposal_id":"016252428267e4f339969524988c4794","proposed_chain":{"connective":"reveals","intent":"cause","object":"meaning","subject":"thought"},"provenance":null,"replay_evidence":null,"review_state":"pending","source_candidate_id":"17673a2f15c8da21cbfa35328e7c477d53ba6b401651f0245f22558d2dfdeae8"}}
{"event":"created","proposal":{"claim_domain":"factual","evidence":[{"epistemic_status":"coherent","polarity":"affirms","ref":"cause_creation_reveals_meaning","source":"corpus"}],"operator_note":"","polarity":"affirms","proposal_id":"016252428267e4f339969524988c4794","proposed_chain":{"connective":"reveals","intent":"cause","object":"meaning","subject":"thought"},"provenance":null,"replay_evidence":null,"review_state":"pending","source":{"emitted_at_revision":"pre-adr-0094-migration","kind":"operator","source_id":""},"source_candidate_id":"17673a2f15c8da21cbfa35328e7c477d53ba6b401651f0245f22558d2dfdeae8"}}
{"event":"replay","proposal_id":"016252428267e4f339969524988c4794","replay_evidence":{"baseline":{"intent_accuracy":1.0,"surface_groundedness":1.0,"term_capture_rate":0.9167,"versor_closure_rate":1.0},"candidate":{"intent_accuracy":1.0,"surface_groundedness":1.0,"term_capture_rate":0.9167,"versor_closure_rate":1.0},"regressed_metrics":[],"replay_equivalent":true}}
{"event":"created","proposal":{"claim_domain":"factual","evidence":[{"epistemic_status":"coherent","polarity":"affirms","ref":"cause_truth_grounds_knowledge","source":"corpus"}],"operator_note":"","polarity":"affirms","proposal_id":"d3cd931dea69e06bd2c697276ae650d1","proposed_chain":{"connective":"reveals","intent":"cause","object":"understanding","subject":"question"},"provenance":null,"replay_evidence":null,"review_state":"pending","source_candidate_id":"f91d991bb80a7bb19811dafb1223a80613ded7ad4fcdf0ec0ff987f1a77bc50a"}}
{"event":"created","proposal":{"claim_domain":"factual","evidence":[{"epistemic_status":"coherent","polarity":"affirms","ref":"cause_truth_grounds_knowledge","source":"corpus"}],"operator_note":"","polarity":"affirms","proposal_id":"d3cd931dea69e06bd2c697276ae650d1","proposed_chain":{"connective":"reveals","intent":"cause","object":"understanding","subject":"question"},"provenance":null,"replay_evidence":null,"review_state":"pending","source":{"emitted_at_revision":"pre-adr-0094-migration","kind":"operator","source_id":""},"source_candidate_id":"f91d991bb80a7bb19811dafb1223a80613ded7ad4fcdf0ec0ff987f1a77bc50a"}}
{"event":"replay","proposal_id":"d3cd931dea69e06bd2c697276ae650d1","replay_evidence":{"baseline":{"intent_accuracy":1.0,"surface_groundedness":1.0,"term_capture_rate":0.9167,"versor_closure_rate":1.0},"candidate":{"intent_accuracy":1.0,"surface_groundedness":1.0,"term_capture_rate":0.9167,"versor_closure_rate":1.0},"regressed_metrics":[],"replay_equivalent":true}}
{"event":"created","proposal":{"claim_domain":"factual","evidence":[{"epistemic_status":"coherent","polarity":"affirms","ref":"cause_truth_grounds_knowledge","source":"corpus"}],"operator_note":"","polarity":"affirms","proposal_id":"ef0666a6ee15f7c595efae1d8e5ea028","proposed_chain":{"connective":"grounds","intent":"cause","object":"concept","subject":"definition"},"provenance":null,"replay_evidence":null,"review_state":"pending","source_candidate_id":"60f3b19f100b087dc80555b19de654d70190b7cc096310369fcc6d0b379d4659"}}
{"event":"created","proposal":{"claim_domain":"factual","evidence":[{"epistemic_status":"coherent","polarity":"affirms","ref":"cause_truth_grounds_knowledge","source":"corpus"}],"operator_note":"","polarity":"affirms","proposal_id":"ef0666a6ee15f7c595efae1d8e5ea028","proposed_chain":{"connective":"grounds","intent":"cause","object":"concept","subject":"definition"},"provenance":null,"replay_evidence":null,"review_state":"pending","source":{"emitted_at_revision":"pre-adr-0094-migration","kind":"operator","source_id":""},"source_candidate_id":"60f3b19f100b087dc80555b19de654d70190b7cc096310369fcc6d0b379d4659"}}
{"event":"replay","proposal_id":"ef0666a6ee15f7c595efae1d8e5ea028","replay_evidence":{"baseline":{"intent_accuracy":1.0,"surface_groundedness":1.0,"term_capture_rate":0.9167,"versor_closure_rate":1.0},"candidate":{"intent_accuracy":1.0,"surface_groundedness":1.0,"term_capture_rate":0.9167,"versor_closure_rate":1.0},"regressed_metrics":[],"replay_equivalent":true}}
{"event":"created","proposal":{"claim_domain":"factual","evidence":[{"epistemic_status":"coherent","polarity":"affirms","ref":"cause_truth_grounds_knowledge","source":"corpus"}],"operator_note":"","polarity":"affirms","proposal_id":"a19e8ffc0382c254a8d159cf95eb75aa","proposed_chain":{"connective":"grounds","intent":"cause","object":"understanding","subject":"meaning"},"provenance":null,"replay_evidence":null,"review_state":"pending","source_candidate_id":"7283546caaa0d273aad0b367bd5c556e29a6dffb8b41d66bcf7684a5dd290c58"}}
{"event":"created","proposal":{"claim_domain":"factual","evidence":[{"epistemic_status":"coherent","polarity":"affirms","ref":"cause_truth_grounds_knowledge","source":"corpus"}],"operator_note":"","polarity":"affirms","proposal_id":"a19e8ffc0382c254a8d159cf95eb75aa","proposed_chain":{"connective":"grounds","intent":"cause","object":"understanding","subject":"meaning"},"provenance":null,"replay_evidence":null,"review_state":"pending","source":{"emitted_at_revision":"pre-adr-0094-migration","kind":"operator","source_id":""},"source_candidate_id":"7283546caaa0d273aad0b367bd5c556e29a6dffb8b41d66bcf7684a5dd290c58"}}
{"event":"replay","proposal_id":"a19e8ffc0382c254a8d159cf95eb75aa","replay_evidence":{"baseline":{"intent_accuracy":1.0,"surface_groundedness":1.0,"term_capture_rate":0.9167,"versor_closure_rate":1.0},"candidate":{"intent_accuracy":1.0,"surface_groundedness":1.0,"term_capture_rate":0.9167,"versor_closure_rate":1.0},"regressed_metrics":[],"replay_equivalent":true}}
{"event":"created","proposal":{"claim_domain":"factual","evidence":[{"epistemic_status":"coherent","polarity":"affirms","ref":"cause_light_reveals_truth","source":"corpus"}],"operator_note":"","polarity":"affirms","proposal_id":"b79daee7d266224225c6f45f91ec2434","proposed_chain":{"connective":"reveals","intent":"cause","object":"relation","subject":"analogy"},"provenance":null,"replay_evidence":null,"review_state":"pending","source_candidate_id":"b2239f20b5174439afc1e3dd0834af37cc8dc94df3e01bef0c1d1f013b5ed4a3"}}
{"event":"created","proposal":{"claim_domain":"factual","evidence":[{"epistemic_status":"coherent","polarity":"affirms","ref":"cause_light_reveals_truth","source":"corpus"}],"operator_note":"","polarity":"affirms","proposal_id":"b79daee7d266224225c6f45f91ec2434","proposed_chain":{"connective":"reveals","intent":"cause","object":"relation","subject":"analogy"},"provenance":null,"replay_evidence":null,"review_state":"pending","source":{"emitted_at_revision":"pre-adr-0094-migration","kind":"operator","source_id":""},"source_candidate_id":"b2239f20b5174439afc1e3dd0834af37cc8dc94df3e01bef0c1d1f013b5ed4a3"}}
{"event":"replay","proposal_id":"b79daee7d266224225c6f45f91ec2434","replay_evidence":{"baseline":{"intent_accuracy":1.0,"surface_groundedness":1.0,"term_capture_rate":0.9167,"versor_closure_rate":1.0},"candidate":{"intent_accuracy":1.0,"surface_groundedness":1.0,"term_capture_rate":0.9167,"versor_closure_rate":1.0},"regressed_metrics":[],"replay_equivalent":true}}
{"event":"created","proposal":{"claim_domain":"factual","evidence":[{"epistemic_status":"coherent","polarity":"affirms","ref":"verification_truth_requires_evidence","source":"corpus"}],"operator_note":"","polarity":"affirms","proposal_id":"1652a236fd525f1c5ba3dcd14f8c9b04","proposed_chain":{"connective":"requires","intent":"verification","object":"definition","subject":"concept"},"provenance":null,"replay_evidence":null,"review_state":"pending","source_candidate_id":"d44fe4f0edd15c215bd8989efa63ad4a7f718757119a60477bc7fc12c7aa07f3"}}
{"event":"created","proposal":{"claim_domain":"factual","evidence":[{"epistemic_status":"coherent","polarity":"affirms","ref":"verification_truth_requires_evidence","source":"corpus"}],"operator_note":"","polarity":"affirms","proposal_id":"1652a236fd525f1c5ba3dcd14f8c9b04","proposed_chain":{"connective":"requires","intent":"verification","object":"definition","subject":"concept"},"provenance":null,"replay_evidence":null,"review_state":"pending","source":{"emitted_at_revision":"pre-adr-0094-migration","kind":"operator","source_id":""},"source_candidate_id":"d44fe4f0edd15c215bd8989efa63ad4a7f718757119a60477bc7fc12c7aa07f3"}}
{"event":"replay","proposal_id":"1652a236fd525f1c5ba3dcd14f8c9b04","replay_evidence":{"baseline":{"intent_accuracy":1.0,"surface_groundedness":1.0,"term_capture_rate":0.9167,"versor_closure_rate":1.0},"candidate":{"intent_accuracy":1.0,"surface_groundedness":1.0,"term_capture_rate":0.9167,"versor_closure_rate":1.0},"regressed_metrics":[],"replay_equivalent":true}}
{"event":"created","proposal":{"claim_domain":"factual","evidence":[{"epistemic_status":"coherent","polarity":"affirms","ref":"verification_memory_requires_recall","source":"corpus"}],"operator_note":"","polarity":"affirms","proposal_id":"6ea18d4e5e5a04b8ec5ed6379e0b1140","proposed_chain":{"connective":"reveals","intent":"cause","object":"memory","subject":"recall"},"provenance":null,"replay_evidence":null,"review_state":"pending","source_candidate_id":"23075c87735406d9ef5da5c7e4027de6f89b459adc947d7a5750f767fd6ac441"}}
{"event":"created","proposal":{"claim_domain":"factual","evidence":[{"epistemic_status":"coherent","polarity":"affirms","ref":"verification_memory_requires_recall","source":"corpus"}],"operator_note":"","polarity":"affirms","proposal_id":"6ea18d4e5e5a04b8ec5ed6379e0b1140","proposed_chain":{"connective":"reveals","intent":"cause","object":"memory","subject":"recall"},"provenance":null,"replay_evidence":null,"review_state":"pending","source":{"emitted_at_revision":"pre-adr-0094-migration","kind":"operator","source_id":""},"source_candidate_id":"23075c87735406d9ef5da5c7e4027de6f89b459adc947d7a5750f767fd6ac441"}}
{"event":"replay","proposal_id":"6ea18d4e5e5a04b8ec5ed6379e0b1140","replay_evidence":{"baseline":{"intent_accuracy":1.0,"surface_groundedness":1.0,"term_capture_rate":0.9167,"versor_closure_rate":1.0},"candidate":{"intent_accuracy":1.0,"surface_groundedness":1.0,"term_capture_rate":0.9167,"versor_closure_rate":1.0},"regressed_metrics":[],"replay_equivalent":true}}
{"event":"transition","note":"Curriculum v2: thought reveals meaning (cognition-source cluster)","proposal_id":"016252428267e4f339969524988c4794","to":"accepted"}
{"chain_id":"cause_thought_reveals_meaning","event":"accepted_corpus_append","proposal_id":"016252428267e4f339969524988c4794","provenance":{"adr_id":"adr-0057","raw":"adr-0057:discovery_promoted:2026-05-18","review_date":"2026-05-18","source":"discovery_promoted"}}

130
teaching/source.py Normal file
View file

@ -0,0 +1,130 @@
"""Sealed :class:`ProposalSource` provenance type (ADR-0094).
Widens :class:`teaching.proposals.TeachingChainProposal` and
:class:`teaching.store.PackMutationProposal` with a typed source field.
The widening is schema-only at this ADR; no runtime behavior changes.
The kind field is a sealed :data:`ProposalKind` literal. Adding a new
kind requires a new ADR adding a branch to every consumer.
Consumers must branch on :attr:`ProposalSource.kind` using exhaustive
``match`` statements ended by
:func:`typing.assert_never`. The pattern is::
match proposal.source.kind:
case "operator":
...
case "miner":
...
case "curriculum":
...
case _: # pragma: no cover - exhaustiveness
assert_never(proposal.source.kind)
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Literal, Mapping, get_args
ProposalKind = Literal["operator", "miner", "curriculum"]
ALLOWED_KINDS: frozenset[str] = frozenset(get_args(ProposalKind))
class ProposalSourceError(ValueError):
"""Raised when a proposal source value violates ADR-0094 v1 schema."""
@dataclass(frozen=True, slots=True)
class ProposalSource:
"""Typed provenance for one proposal.
:param kind:
One of ``"operator"``, ``"miner"``, ``"curriculum"``. The set is
sealed; adding a new kind requires a new ADR.
:param source_id:
Empty for ``kind="operator"``. For other kinds, the originating
miner id or curriculum course id.
:param emitted_at_revision:
Git SHA at emission time. Pinned per proposal so replays can
anchor against the substrate state the proposal was derived
from.
"""
kind: ProposalKind
source_id: str
emitted_at_revision: str
def __post_init__(self) -> None:
if self.kind not in ALLOWED_KINDS:
raise ProposalSourceError(
f"ProposalSource.kind must be one of {sorted(ALLOWED_KINDS)}; "
f"got {self.kind!r}"
)
if self.kind == "operator" and self.source_id:
raise ProposalSourceError(
"ProposalSource.kind='operator' requires empty source_id; "
f"got {self.source_id!r}"
)
if self.kind != "operator" and not self.source_id:
raise ProposalSourceError(
f"ProposalSource.kind={self.kind!r} requires non-empty source_id"
)
if not self.emitted_at_revision:
raise ProposalSourceError(
"ProposalSource.emitted_at_revision must be non-empty"
)
def serialize(self) -> str:
"""Compact human-readable form for logs and telemetry.
- ``ProposalSource("operator", "", "<sha>")`` ``"operator"``
- ``ProposalSource("miner", "articulation_quality", "<sha>")``
``"miner:articulation_quality"``
- ``ProposalSource("curriculum", "math_logic_v1", "<sha>")``
``"curriculum:math_logic_v1"``
"""
if not self.source_id:
return self.kind
return f"{self.kind}:{self.source_id}"
def as_dict(self) -> dict[str, str]:
return {
"kind": self.kind,
"source_id": self.source_id,
"emitted_at_revision": self.emitted_at_revision,
}
@classmethod
def from_dict(cls, payload: Any) -> "ProposalSource":
"""Parse a serialized source. Raises on missing or unknown fields.
Accepts ``Any`` because callers typically pass dicts loaded from
untrusted JSONL where static typing cannot guarantee shape.
"""
if not isinstance(payload, Mapping):
raise ProposalSourceError(
f"ProposalSource payload must be a mapping; got {type(payload).__name__}"
)
allowed = {"kind", "source_id", "emitted_at_revision"}
unknown = set(payload.keys()) - allowed
if unknown:
raise ProposalSourceError(
f"ProposalSource payload has unknown fields: {sorted(unknown)}"
)
missing = allowed - set(payload.keys())
if missing:
raise ProposalSourceError(
f"ProposalSource payload missing required fields: {sorted(missing)}"
)
return cls(
kind=payload["kind"],
source_id=payload["source_id"],
emitted_at_revision=payload["emitted_at_revision"],
)
@classmethod
def operator(cls, *, emitted_at_revision: str) -> "ProposalSource":
"""Convenience constructor for the default operator-authored case."""
return cls(kind="operator", source_id="", emitted_at_revision=emitted_at_revision)

View file

@ -16,6 +16,7 @@ from dataclasses import dataclass
from teaching.correction import CorrectionCandidate
from teaching.epistemic import EpistemicStatus
from teaching.review import ReviewedTeachingExample
from teaching.source import ProposalSource
# ADR-0021 §CONTESTED transitions: coherence checker tokens.
@ -97,6 +98,7 @@ class PackMutationProposal:
subject: str
correction_text: str
prior_surface: str
source: ProposalSource
applied: bool = False
triple: tuple[str, str, str] | None = None
epistemic_status: EpistemicStatus = EpistemicStatus.SPECULATIVE
@ -108,6 +110,7 @@ class PackMutationProposal:
"subject": self.subject,
"correction_text": self.correction_text,
"prior_surface": self.prior_surface,
"source": self.source.as_dict(),
"applied": self.applied,
"triple": list(self.triple) if self.triple is not None else None,
"epistemic_status": self.epistemic_status.value,
@ -176,12 +179,17 @@ class TeachingStore:
from teaching.relation_parse import parse_triple
triple = parse_triple(example.candidate.correction_text)
# ADR-0094: PackMutationProposals built from reviewed teaching
# examples are operator-authored; miner-sourced and curriculum-
# sourced construction sites land in ADR-0095 and later.
from teaching.proposals import _default_operator_source
proposal = PackMutationProposal(
proposal_id=_proposal_id(example.candidate),
candidate_id=example.candidate.candidate_id,
subject=example.candidate.intent.subject,
correction_text=example.candidate.correction_text,
prior_surface=example.candidate.prior_surface,
source=_default_operator_source(),
triple=triple,
epistemic_status=example.epistemic_status,
)

View file

@ -22,6 +22,7 @@ import dataclasses
from teaching import EpistemicStatus, PackMutationProposal, ReviewedTeachingExample
from teaching.correction import CorrectionCandidate
from teaching.review import ReviewOutcome
from teaching.source import ProposalSource
_FORBIDDEN_HARDENING_NAMES: frozenset[str] = frozenset({
@ -103,6 +104,7 @@ def test_proposal_default_is_speculative():
subject="z",
correction_text="a knows b",
prior_surface="",
source=ProposalSource.operator(emitted_at_revision="test"),
)
assert proposal.epistemic_status is EpistemicStatus.SPECULATIVE
@ -115,6 +117,7 @@ def test_proposal_with_status_returns_new_immutable_proposal():
subject="z",
correction_text="a knows b",
prior_surface="",
source=ProposalSource.operator(emitted_at_revision="test"),
)
promoted = original.with_status(EpistemicStatus.COHERENT)

View file

@ -0,0 +1,323 @@
"""Unit tests for ProposalSource and ADR-0094 schema widening."""
from __future__ import annotations
import json
from pathlib import Path
from typing import assert_never
import pytest
from teaching.migrate_proposals_source_field import (
DEFAULT_OPERATOR_SOURCE_PAYLOAD,
PRE_MIGRATION_SENTINEL,
migrate_file,
)
from teaching.source import (
ALLOWED_KINDS,
ProposalSource,
ProposalSourceError,
)
class TestProposalSourceConstruction:
def test_operator_default_constructor(self) -> None:
src = ProposalSource.operator(emitted_at_revision="abc123")
assert src.kind == "operator"
assert src.source_id == ""
assert src.emitted_at_revision == "abc123"
def test_miner_requires_source_id(self) -> None:
with pytest.raises(ProposalSourceError, match="non-empty source_id"):
ProposalSource(kind="miner", source_id="", emitted_at_revision="abc")
def test_curriculum_requires_source_id(self) -> None:
with pytest.raises(ProposalSourceError, match="non-empty source_id"):
ProposalSource(kind="curriculum", source_id="", emitted_at_revision="abc")
def test_operator_rejects_source_id(self) -> None:
with pytest.raises(ProposalSourceError, match="empty source_id"):
ProposalSource(kind="operator", source_id="something", emitted_at_revision="abc")
def test_unknown_kind_rejected(self) -> None:
with pytest.raises(ProposalSourceError, match="kind must be one of"):
ProposalSource(
kind="alien", # type: ignore[arg-type]
source_id="x",
emitted_at_revision="abc",
)
def test_empty_revision_rejected(self) -> None:
with pytest.raises(ProposalSourceError, match="emitted_at_revision"):
ProposalSource(kind="operator", source_id="", emitted_at_revision="")
def test_frozen(self) -> None:
src = ProposalSource.operator(emitted_at_revision="abc")
with pytest.raises((AttributeError, TypeError)):
src.kind = "miner" # type: ignore[misc]
class TestSerialization:
def test_operator_serializes_to_kind_only(self) -> None:
src = ProposalSource.operator(emitted_at_revision="abc")
assert src.serialize() == "operator"
def test_miner_serializes_kind_and_id(self) -> None:
src = ProposalSource(
kind="miner",
source_id="articulation_quality",
emitted_at_revision="abc",
)
assert src.serialize() == "miner:articulation_quality"
def test_curriculum_serializes_kind_and_id(self) -> None:
src = ProposalSource(
kind="curriculum",
source_id="math_logic_v1",
emitted_at_revision="abc",
)
assert src.serialize() == "curriculum:math_logic_v1"
def test_round_trip_operator(self) -> None:
src = ProposalSource.operator(emitted_at_revision="abc")
roundtrip = ProposalSource.from_dict(src.as_dict())
assert roundtrip == src
def test_round_trip_miner(self) -> None:
src = ProposalSource(kind="miner", source_id="m1", emitted_at_revision="abc")
roundtrip = ProposalSource.from_dict(src.as_dict())
assert roundtrip == src
def test_from_dict_rejects_unknown_field(self) -> None:
with pytest.raises(ProposalSourceError, match="unknown fields"):
ProposalSource.from_dict(
{
"kind": "operator",
"source_id": "",
"emitted_at_revision": "abc",
"extra": "nope",
}
)
def test_from_dict_rejects_missing_field(self) -> None:
with pytest.raises(ProposalSourceError, match="missing required fields"):
ProposalSource.from_dict({"kind": "operator", "source_id": ""})
def test_from_dict_rejects_non_mapping(self) -> None:
with pytest.raises(ProposalSourceError, match="must be a mapping"):
ProposalSource.from_dict("operator")
class TestExhaustiveMatchPattern:
"""Demonstrate the exhaustive-match pattern enforced by ADR-0094.
A consumer that branches on ``source.kind`` must cover all sealed
values; ``assert_never`` on the catch-all guards against future
additions without ADR widening.
"""
@staticmethod
def _describe(src: ProposalSource) -> str:
match src.kind:
case "operator":
return "op"
case "miner":
return f"m:{src.source_id}"
case "curriculum":
return f"c:{src.source_id}"
case _: # pragma: no cover - exhaustiveness
assert_never(src.kind)
def test_covers_operator(self) -> None:
assert self._describe(ProposalSource.operator(emitted_at_revision="x")) == "op"
def test_covers_miner(self) -> None:
src = ProposalSource(kind="miner", source_id="art", emitted_at_revision="x")
assert self._describe(src) == "m:art"
def test_covers_curriculum(self) -> None:
src = ProposalSource(kind="curriculum", source_id="cur", emitted_at_revision="x")
assert self._describe(src) == "c:cur"
def test_kinds_sealed_at_three(self) -> None:
assert ALLOWED_KINDS == frozenset({"operator", "miner", "curriculum"})
class TestMigrationDeterminism:
def _write_legacy_log(self, tmp_path: Path) -> Path:
path = tmp_path / "proposals.jsonl"
lines = [
json.dumps(
{
"event": "created",
"proposal": {
"proposal_id": "abc123",
"claim_domain": "factual",
"polarity": "affirms",
},
},
sort_keys=True,
separators=(",", ":"),
),
json.dumps(
{"event": "replay", "proposal_id": "abc123", "replay_evidence": {}},
sort_keys=True,
separators=(",", ":"),
),
json.dumps(
{
"event": "created",
"proposal": {"proposal_id": "def456", "polarity": "falsifies"},
},
sort_keys=True,
separators=(",", ":"),
),
]
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return path
def test_migration_attaches_default_source(self, tmp_path: Path) -> None:
path = self._write_legacy_log(tmp_path)
summary = migrate_file(path)
assert summary["migrated_count"] == 2
assert summary["already_had_source"] == 0
assert summary["changed"] is True
events = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line]
created_events = [e for e in events if e["event"] == "created"]
for ev in created_events:
assert ev["proposal"]["source"] == DEFAULT_OPERATOR_SOURCE_PAYLOAD
def test_migration_idempotent(self, tmp_path: Path) -> None:
path = self._write_legacy_log(tmp_path)
migrate_file(path)
bytes_after_first = path.read_bytes()
migrate_file(path)
bytes_after_second = path.read_bytes()
assert bytes_after_first == bytes_after_second
def test_migration_deterministic_across_two_temps(self, tmp_path: Path) -> None:
path_a = tmp_path / "a.jsonl"
path_b = tmp_path / "b.jsonl"
legacy = self._write_legacy_log(tmp_path)
path_a.write_bytes(legacy.read_bytes())
path_b.write_bytes(legacy.read_bytes())
migrate_file(path_a)
migrate_file(path_b)
assert path_a.read_bytes() == path_b.read_bytes()
def test_migration_skips_already_migrated(self, tmp_path: Path) -> None:
path = tmp_path / "proposals.jsonl"
line = json.dumps(
{
"event": "created",
"proposal": {
"proposal_id": "abc",
"source": dict(DEFAULT_OPERATOR_SOURCE_PAYLOAD),
},
},
sort_keys=True,
separators=(",", ":"),
)
path.write_text(line + "\n", encoding="utf-8")
before = path.read_bytes()
summary = migrate_file(path)
assert summary["already_had_source"] == 1
assert summary["migrated_count"] == 0
assert path.read_bytes() == before
def test_migration_dry_run_does_not_write(self, tmp_path: Path) -> None:
path = self._write_legacy_log(tmp_path)
before = path.read_bytes()
summary = migrate_file(path, dry_run=True)
assert summary["migrated_count"] == 2
assert path.read_bytes() == before
def test_sentinel_revision_used(self) -> None:
assert DEFAULT_OPERATOR_SOURCE_PAYLOAD["emitted_at_revision"] == (
PRE_MIGRATION_SENTINEL
)
assert DEFAULT_OPERATOR_SOURCE_PAYLOAD["kind"] == "operator"
assert DEFAULT_OPERATOR_SOURCE_PAYLOAD["source_id"] == ""
class TestProposalLogStrictParsing:
"""ADR-0094 requires that proposal load rejects missing source."""
def test_strict_parse_rejects_missing_source(self, tmp_path: Path) -> None:
from teaching.proposals import ProposalError, ProposalLog
path = tmp_path / "proposals.jsonl"
legacy_line = json.dumps(
{"event": "created", "proposal": {"proposal_id": "abc", "polarity": "affirms"}},
sort_keys=True,
separators=(",", ":"),
)
path.write_text(legacy_line + "\n", encoding="utf-8")
log = ProposalLog(path)
with pytest.raises(ProposalError, match="missing required 'source'"):
log.current_state()
def test_strict_parse_accepts_migrated_source(self, tmp_path: Path) -> None:
from teaching.proposals import ProposalLog
path = tmp_path / "proposals.jsonl"
line = json.dumps(
{
"event": "created",
"proposal": {
"proposal_id": "abc",
"polarity": "affirms",
"source": dict(DEFAULT_OPERATOR_SOURCE_PAYLOAD),
},
},
sort_keys=True,
separators=(",", ":"),
)
path.write_text(line + "\n", encoding="utf-8")
log = ProposalLog(path)
view = log.current_state()
assert "abc" in view
assert view["abc"]["source"]["kind"] == "operator"
def test_strict_parse_rejects_malformed_source(self, tmp_path: Path) -> None:
from teaching.proposals import ProposalLog
path = tmp_path / "proposals.jsonl"
line = json.dumps(
{
"event": "created",
"proposal": {
"proposal_id": "abc",
"polarity": "affirms",
"source": {"kind": "operator"}, # missing required fields
},
},
sort_keys=True,
separators=(",", ":"),
)
path.write_text(line + "\n", encoding="utf-8")
log = ProposalLog(path)
with pytest.raises(ProposalSourceError, match="missing required fields"):
log.current_state()
class TestLiveLogParses:
"""Verify the in-tree proposals.jsonl parses under strict v1."""
def test_live_log_loads(self) -> None:
from teaching.proposals import DEFAULT_PROPOSAL_LOG_PATH, ProposalLog
if not DEFAULT_PROPOSAL_LOG_PATH.exists():
pytest.skip("live proposals log not present in this checkout")
log = ProposalLog(DEFAULT_PROPOSAL_LOG_PATH)
view = log.current_state()
assert len(view) >= 1
for pid, entry in view.items():
assert "source" in entry, f"proposal {pid} missing source after migration"
src = ProposalSource.from_dict(entry["source"])
assert src.kind in ALLOWED_KINDS