core/tests/test_supersede.py
Shay 8d2c84a041 feat(adr-0057): operator supersede CLI — retire active chain by appended replacement
`core teaching supersede <old_chain_id> --subject ... --intent ... --connective ...
--object ... --review-date YYYY-MM-DD` is the second corpus mutation surface
(alongside accept_proposal). No replay gate — it's a deliberate operator action
that replaces a hand-authored or previously discovery-promoted chain.

- teaching/supersede.py — `supersede_chain()` orchestrator with pre-checks
  (review_date format, intent whitelist, pack-consistency via re-audit,
  no double-supersede, no self-supersede, no new-chain-id collision) and
  byte-identical rollback on post-audit failure.
- teaching/proposals.py — extended `append_chain_to_corpus` with optional
  `superseded_by` kwarg; remains the only function in the codebase that
  writes to the active teaching corpus.
- core/cli.py — `core teaching supersede` subcommand wired to the live
  `_CORPUS_PATH`; EPILOG updated with example.
- tests/test_supersede.py — 13 tests pin every gate, byte-identical
  rollback on rejection, append-only at disk level, audit-and-runtime
  parity after supersession, hand_authored provenance with
  `supersede(<old_chain_id>)` tag.

Lane state: smoke 67 / cognition 121 / teaching 17 / supersede 13 / audit 23 /
proposals 16 / contemplation 16 / contemplation-wiring 6 / discovery 24 — green.
`core eval cognition`: intent 100% / surface 100% / term 91.7% / versor 100% — unchanged.
2026-05-18 10:35:49 -07:00

288 lines
10 KiB
Python

"""ADR-0057 follow-up — operator-driven supersession.
Pins:
- ``supersede_chain`` is the only path that emits a chain with a
``superseded_by`` field.
- Single write surface preserved: it composes around
``proposals.append_chain_to_corpus``, not its own writer.
- Pack-consistency, intent whitelist, and self-supersede gates
fire before any byte is written.
- Already-superseded targets cannot be double-retired.
- Post-audit shifts exactly one chain from active → dropped and
adds the replacement to active.
- Failure modes leave the corpus byte-identical.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from teaching.audit import audit_corpus
from teaching.supersede import SupersessionError, supersede_chain
def _seed(tmp_path: Path) -> Path:
"""Two pack-consistent active chains; we'll retire the first."""
p = tmp_path / "cognition_chains_v1.jsonl"
lines = [
json.dumps({
"chain_id": "cause_light_reveals_truth",
"subject": "light", "intent": "cause", "connective": "reveals",
"object": "truth",
"provenance": "adr-test:hand_authored:2026-05-18",
}),
json.dumps({
"chain_id": "verification_memory_requires_recall",
"subject": "memory", "intent": "verification", "connective": "requires",
"object": "recall",
"provenance": "adr-test:hand_authored:2026-05-18",
}),
]
p.write_text("\n".join(lines) + "\n", encoding="utf-8")
return p
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
def test_supersede_appends_one_line_and_retires_target(tmp_path: Path) -> None:
corpus = _seed(tmp_path)
pre_lines = corpus.read_text(encoding="utf-8").splitlines()
new_id = supersede_chain(
old_chain_id="cause_light_reveals_truth",
subject="light", intent="cause",
connective="grounds", object_="truth",
review_date="2026-05-18",
corpus_path=corpus,
)
assert new_id == "cause_light_grounds_truth"
post_lines = corpus.read_text(encoding="utf-8").splitlines()
assert len(post_lines) == len(pre_lines) + 1
# Earlier lines are byte-identical (append-only at disk level).
assert post_lines[: len(pre_lines)] == pre_lines
last = json.loads(post_lines[-1])
assert last["chain_id"] == "cause_light_grounds_truth"
assert last["superseded_by"] == "cause_light_reveals_truth"
assert "supersede(cause_light_reveals_truth)" in last["provenance"]
def test_audit_after_supersede_shifts_active_set(tmp_path: Path) -> None:
corpus = _seed(tmp_path)
supersede_chain(
old_chain_id="cause_light_reveals_truth",
subject="light", intent="cause",
connective="grounds", object_="truth",
review_date="2026-05-18",
corpus_path=corpus,
)
report = audit_corpus(corpus)
active_ids = {c.chain_id for c in report.loaded}
dropped_ids = {d.chain_id for d in report.dropped}
assert "cause_light_reveals_truth" not in active_ids
assert "cause_light_reveals_truth" in dropped_ids
assert "cause_light_grounds_truth" in active_ids
# Unrelated chain untouched.
assert "verification_memory_requires_recall" in active_ids
def test_explicit_new_chain_id_honoured(tmp_path: Path) -> None:
corpus = _seed(tmp_path)
new_id = supersede_chain(
old_chain_id="cause_light_reveals_truth",
subject="light", intent="cause",
connective="grounds", object_="truth",
review_date="2026-05-18",
corpus_path=corpus,
new_chain_id="cause_light_revised_v2",
)
assert new_id == "cause_light_revised_v2"
# ---------------------------------------------------------------------------
# Eligibility / validation gates
# ---------------------------------------------------------------------------
def test_rejects_unknown_old_chain_id(tmp_path: Path) -> None:
corpus = _seed(tmp_path)
bytes_before = corpus.read_bytes()
with pytest.raises(SupersessionError, match="not in the active corpus"):
supersede_chain(
old_chain_id="does_not_exist",
subject="light", intent="cause",
connective="grounds", object_="truth",
review_date="2026-05-18",
corpus_path=corpus,
)
assert corpus.read_bytes() == bytes_before
def test_rejects_double_supersede(tmp_path: Path) -> None:
corpus = _seed(tmp_path)
supersede_chain(
old_chain_id="cause_light_reveals_truth",
subject="light", intent="cause",
connective="grounds", object_="truth",
review_date="2026-05-18",
corpus_path=corpus,
)
bytes_after_first = corpus.read_bytes()
with pytest.raises(SupersessionError, match="already inactive"):
supersede_chain(
old_chain_id="cause_light_reveals_truth",
subject="light", intent="cause",
connective="orders", object_="truth",
review_date="2026-05-18",
corpus_path=corpus,
)
assert corpus.read_bytes() == bytes_after_first
def test_rejects_pack_missing_subject(tmp_path: Path) -> None:
corpus = _seed(tmp_path)
bytes_before = corpus.read_bytes()
with pytest.raises(SupersessionError):
supersede_chain(
old_chain_id="cause_light_reveals_truth",
subject="zzznotalemma", intent="cause",
connective="grounds", object_="truth",
review_date="2026-05-18",
corpus_path=corpus,
)
assert corpus.read_bytes() == bytes_before
def test_rejects_unsupported_intent(tmp_path: Path) -> None:
corpus = _seed(tmp_path)
bytes_before = corpus.read_bytes()
with pytest.raises(SupersessionError, match="whitelist"):
supersede_chain(
old_chain_id="cause_light_reveals_truth",
subject="light", intent="definition",
connective="grounds", object_="truth",
review_date="2026-05-18",
corpus_path=corpus,
)
assert corpus.read_bytes() == bytes_before
def test_rejects_self_supersede(tmp_path: Path) -> None:
corpus = _seed(tmp_path)
bytes_before = corpus.read_bytes()
with pytest.raises(SupersessionError, match="identical"):
supersede_chain(
old_chain_id="cause_light_reveals_truth",
subject="light", intent="cause",
connective="reveals", object_="truth",
review_date="2026-05-18",
corpus_path=corpus,
)
assert corpus.read_bytes() == bytes_before
def test_rejects_collision_with_active_chain_id(tmp_path: Path) -> None:
corpus = _seed(tmp_path)
bytes_before = corpus.read_bytes()
with pytest.raises(SupersessionError, match="already active"):
supersede_chain(
old_chain_id="cause_light_reveals_truth",
subject="memory", intent="verification",
connective="requires", object_="recall",
review_date="2026-05-18",
corpus_path=corpus,
)
assert corpus.read_bytes() == bytes_before
def test_rejects_bad_review_date(tmp_path: Path) -> None:
corpus = _seed(tmp_path)
bytes_before = corpus.read_bytes()
with pytest.raises(SupersessionError, match="YYYY-MM-DD"):
supersede_chain(
old_chain_id="cause_light_reveals_truth",
subject="light", intent="cause",
connective="grounds", object_="truth",
review_date="May 18 2026",
corpus_path=corpus,
)
assert corpus.read_bytes() == bytes_before
def test_rejects_empty_required_field(tmp_path: Path) -> None:
corpus = _seed(tmp_path)
bytes_before = corpus.read_bytes()
with pytest.raises(SupersessionError, match="required"):
supersede_chain(
old_chain_id="cause_light_reveals_truth",
subject="light", intent="cause",
connective=" ", object_="truth",
review_date="2026-05-18",
corpus_path=corpus,
)
assert corpus.read_bytes() == bytes_before
# ---------------------------------------------------------------------------
# Runtime parity
# ---------------------------------------------------------------------------
def test_runtime_loader_honours_supersede_chain_output(tmp_path: Path) -> None:
"""After supersede_chain runs, the live runtime loader (pointed
at this tmp corpus) sees the same active set as the audit report."""
corpus = _seed(tmp_path)
supersede_chain(
old_chain_id="cause_light_reveals_truth",
subject="light", intent="cause",
connective="grounds", object_="truth",
review_date="2026-05-18",
corpus_path=corpus,
)
from unittest.mock import patch
from chat import teaching_grounding as tg
with patch.object(tg, "_CORPUS_PATH", corpus):
tg._corpus_index.cache_clear()
try:
index = tg._corpus_index()
runtime_ids = {c.chain_id for c in index.values()}
finally:
tg._corpus_index.cache_clear()
audit_ids = {c.chain_id for c in audit_corpus(corpus).loaded}
assert runtime_ids == audit_ids
assert "cause_light_reveals_truth" not in runtime_ids
assert "cause_light_grounds_truth" in runtime_ids
# ---------------------------------------------------------------------------
# Provenance shape
# ---------------------------------------------------------------------------
def test_provenance_is_hand_authored_with_supersede_tag(tmp_path: Path) -> None:
corpus = _seed(tmp_path)
supersede_chain(
old_chain_id="cause_light_reveals_truth",
subject="light", intent="cause",
connective="grounds", object_="truth",
review_date="2026-05-18",
corpus_path=corpus,
)
report = audit_corpus(corpus)
new_entry = next(
c for c in report.loaded if c.chain_id == "cause_light_grounds_truth"
)
assert new_entry.provenance.source == "hand_authored"
assert new_entry.provenance.review_date is not None
assert new_entry.provenance.review_date.startswith("2026-05-18")
assert "supersede(cause_light_reveals_truth)" in new_entry.provenance.raw