core/teaching/migrate_proposals_source_field.py
Shay b24796386e 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
2026-05-21 18:11:09 -07:00

123 lines
3.7 KiB
Python

"""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())