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
123 lines
3.7 KiB
Python
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())
|