From b24796386ee98ad4b7df30377ec4c80940ed38b6 Mon Sep 17 00:00:00 2001 From: Shay Date: Thu, 21 May 2026 18:11:09 -0700 Subject: [PATCH] =?UTF-8?q?feat(teaching):=20implement=20ADR-0094=20?= =?UTF-8?q?=E2=80=94=20Proposal=20Source=20Provenance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:" / "curriculum:"), 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 --- teaching/migrate_proposals_source_field.py | 123 ++++++++ teaching/proposals.py | 78 ++++- teaching/proposals/proposals.jsonl | 22 +- teaching/source.py | 130 +++++++++ teaching/store.py | 8 + tests/test_epistemic_invariants.py | 3 + tests/test_proposal_source.py | 323 +++++++++++++++++++++ 7 files changed, 673 insertions(+), 14 deletions(-) create mode 100644 teaching/migrate_proposals_source_field.py create mode 100644 teaching/source.py create mode 100644 tests/test_proposal_source.py diff --git a/teaching/migrate_proposals_source_field.py b/teaching/migrate_proposals_source_field.py new file mode 100644 index 00000000..122b08a4 --- /dev/null +++ b/teaching/migrate_proposals_source_field.py @@ -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()) diff --git a/teaching/proposals.py b/teaching/proposals.py index af53c067..be55976d 100644 --- a/teaching/proposals.py +++ b/teaching/proposals.py @@ -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, }) diff --git a/teaching/proposals/proposals.jsonl b/teaching/proposals/proposals.jsonl index dc241501..68b1db68 100644 --- a/teaching/proposals/proposals.jsonl +++ b/teaching/proposals/proposals.jsonl @@ -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"}} diff --git a/teaching/source.py b/teaching/source.py new file mode 100644 index 00000000..e8e4cfa3 --- /dev/null +++ b/teaching/source.py @@ -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", "", "")`` → ``"operator"`` + - ``ProposalSource("miner", "articulation_quality", "")`` + → ``"miner:articulation_quality"`` + - ``ProposalSource("curriculum", "math_logic_v1", "")`` + → ``"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) diff --git a/teaching/store.py b/teaching/store.py index b61bdfde..22fe0f11 100644 --- a/teaching/store.py +++ b/teaching/store.py @@ -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, ) diff --git a/tests/test_epistemic_invariants.py b/tests/test_epistemic_invariants.py index 4885b4f8..6dbdd268 100644 --- a/tests/test_epistemic_invariants.py +++ b/tests/test_epistemic_invariants.py @@ -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) diff --git a/tests/test_proposal_source.py b/tests/test_proposal_source.py new file mode 100644 index 00000000..cfc4ee55 --- /dev/null +++ b/tests/test_proposal_source.py @@ -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