The only path by which CORE extends its own active teaching corpus.
Closes ADR-0055 Phase C alongside ADR-0056's cognitive surface.
Three load-bearing calls (recorded in ADR-0057):
1. Replay-equivalence is a precondition, not a permission;
operator --accept remains required.
2. Eligibility = polarity in {affirms, falsifies} AND at least
one source='corpus' evidence pointer AND boundary_clean AND
claim_domain != evaluative (unless --allow-evaluative) AND
proposed_chain complete.
3. Append-only proposal log; corpus history append-only too.
Changes
- teaching/proposals.py — TeachingChainProposal, ReplayEvidence,
ProposalLog (event-sourced replay → current_state), eligibility
predicate, propose_from_candidate, accept/reject/withdraw,
append_chain_to_corpus (the sole corpus-write surface). Uses
TYPE_CHECKING guards to break the circular import with
chat.pack_grounding.
- teaching/replay.py — run_replay_equivalence; swaps _corpus_index
path to a tmp file, runs cognition lane on the active corpus
AND a transient copy with the proposed chain appended, returns
regressed-metrics list; trust-boundary assertion that the active
corpus bytes are byte-identical pre/post.
- teaching/discovery.py — moved chat.pack_grounding /
chat.teaching_grounding imports inside extract_discovery_candidates
to break the cycle (was masked when chat.runtime was the entry
point; surfaced by CLI entry).
- core/cli.py — three new subcommands:
core teaching propose <candidate-jsonl-path> [--allow-evaluative]
core teaching proposals [--state pending|accepted|rejected|withdrawn] [--json]
core teaching review <proposal_id> --accept --review-date YYYY-MM-DD
core teaching review <proposal_id> --reject [--note ...]
core teaching review <proposal_id> --withdraw [--note ...]
- tests/test_teaching_proposals.py — 16 tests covering: every
eligibility gate, proposal_id idempotency, append-only log,
replay-equivalent stays pending, regression auto-rejects with
named regressed metrics, --accept appends one line with typed
Provenance, --accept refused on non-equivalent, state-machine
blocks double-accept, real replay gate runs cognition lane
twice and asserts byte-clean active corpus pre/post.
Invariants preserved
- versor_condition(F) < 1e-6 — C2 touches no algebra path.
- Active corpus bytes byte-identical regardless of replay outcome.
- No clock-time reads, no LLM, no async.
- Proposal-only — accept_proposal is the sole corpus-write path.
Lanes: smoke 67 / cognition 121 / runtime 19 / teaching 17 /
new proposals 16. Cognition eval unchanged.
Open follow-ups (not in scope):
- supersession via operator review action
- cross-pack falsification arbitration (ADR-0056 Call 2 deferred)
- pack-data migration of frame-dependent connectives
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
321 lines
11 KiB
Python
321 lines
11 KiB
Python
"""ADR-0057 Phase C2 — TeachingChainProposal eligibility, replay-
|
|
equivalence gate, append-only proposal log, and operator review
|
|
state machine.
|
|
|
|
Pinned contracts:
|
|
- Eligibility predicate raises on every failing gate.
|
|
- Idempotent proposal_id derivation.
|
|
- Replay-equivalence gate never mutates the active corpus.
|
|
- Regression auto-transitions proposal to rejected.
|
|
- --accept only legal when state==pending AND replay_equivalent.
|
|
- Append-only log: replaying the log reconstructs the same state.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import replace
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from chat.teaching_grounding import _CORPUS_PATH
|
|
from teaching.discovery import (
|
|
DiscoveryCandidate,
|
|
EvidencePointer,
|
|
)
|
|
from teaching.proposals import (
|
|
ProposalError,
|
|
ProposalLog,
|
|
ReplayEvidence,
|
|
accept_proposal,
|
|
append_chain_to_corpus,
|
|
build_proposal,
|
|
check_eligibility,
|
|
propose_from_candidate,
|
|
reject_proposal,
|
|
withdraw_proposal,
|
|
)
|
|
from teaching.provenance import Provenance
|
|
|
|
|
|
CORPUS_BYTES_BEFORE = _CORPUS_PATH.read_bytes() if _CORPUS_PATH.exists() else b""
|
|
|
|
|
|
def _enriched(*, polarity="affirms", claim_domain="factual",
|
|
connective="reveals", obj="truth", subject="light",
|
|
evidence=None, boundary_clean=True):
|
|
if evidence is None:
|
|
evidence = (
|
|
EvidencePointer(
|
|
source="corpus", ref="some_chain",
|
|
polarity=polarity, epistemic_status="coherent",
|
|
),
|
|
)
|
|
return DiscoveryCandidate(
|
|
candidate_id="cand_xyz",
|
|
proposed_chain={
|
|
"subject": subject, "intent": "cause",
|
|
"connective": connective, "object": obj,
|
|
},
|
|
trigger="would_have_grounded",
|
|
source_turn_trace="trace_1",
|
|
pack_consistent=True,
|
|
boundary_clean=boundary_clean,
|
|
polarity=polarity,
|
|
claim_domain=claim_domain,
|
|
evidence=evidence,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Eligibility gates
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_undetermined_polarity_rejected():
|
|
c = _enriched()
|
|
bad = replace(c, polarity="undetermined")
|
|
with pytest.raises(ProposalError, match="polarity"):
|
|
check_eligibility(bad)
|
|
|
|
|
|
def test_missing_corpus_evidence_rejected():
|
|
c = _enriched(evidence=(
|
|
EvidencePointer(
|
|
source="pack", ref="light",
|
|
polarity="affirms", epistemic_status="coherent",
|
|
),
|
|
))
|
|
with pytest.raises(ProposalError, match="corpus"):
|
|
check_eligibility(c)
|
|
|
|
|
|
def test_evaluative_requires_explicit_flag():
|
|
c = _enriched(claim_domain="evaluative")
|
|
with pytest.raises(ProposalError, match="evaluative"):
|
|
check_eligibility(c)
|
|
check_eligibility(c, allow_evaluative=True) # no raise
|
|
|
|
|
|
def test_boundary_unclean_rejected():
|
|
c = _enriched(boundary_clean=False)
|
|
with pytest.raises(ProposalError, match="boundary"):
|
|
check_eligibility(c)
|
|
|
|
|
|
def test_incomplete_chain_rejected():
|
|
base = _enriched()
|
|
incomplete = replace(base, proposed_chain={
|
|
"subject": "light", "intent": "cause",
|
|
"connective": None, "object": None,
|
|
})
|
|
with pytest.raises(ProposalError, match="subject/intent/connective/object"):
|
|
check_eligibility(incomplete)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Proposal id idempotency
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_proposal_id_is_deterministic():
|
|
c = _enriched()
|
|
p1 = build_proposal(c)
|
|
p2 = build_proposal(c)
|
|
assert p1.proposal_id == p2.proposal_id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Append-only log
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_log_append_only_state_machine(tmp_path: Path):
|
|
log = ProposalLog(tmp_path / "proposals.jsonl")
|
|
c = _enriched()
|
|
p = build_proposal(c)
|
|
log.record_created(p)
|
|
assert log.find(p.proposal_id)["state"] == "pending"
|
|
|
|
log.record_transition(p.proposal_id, "rejected", "test note")
|
|
assert log.find(p.proposal_id)["state"] == "rejected"
|
|
|
|
# File is append-only: byte-count grows monotonically.
|
|
size_a = (tmp_path / "proposals.jsonl").stat().st_size
|
|
log.record_transition(p.proposal_id, "withdrawn", "no-op test")
|
|
size_b = (tmp_path / "proposals.jsonl").stat().st_size
|
|
assert size_b > size_a
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Replay gate (with fake replay to avoid running cognition lane)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _fake_replay_equivalent(chain):
|
|
return ReplayEvidence(
|
|
baseline={"intent_accuracy": 1.0, "surface_groundedness": 1.0},
|
|
candidate={"intent_accuracy": 1.0, "surface_groundedness": 1.0},
|
|
regressed_metrics=(),
|
|
replay_equivalent=True,
|
|
)
|
|
|
|
|
|
def _fake_replay_regression(chain):
|
|
return ReplayEvidence(
|
|
baseline={"intent_accuracy": 1.0, "surface_groundedness": 1.0},
|
|
candidate={"intent_accuracy": 1.0, "surface_groundedness": 0.85},
|
|
regressed_metrics=("surface_groundedness",),
|
|
replay_equivalent=False,
|
|
)
|
|
|
|
|
|
def test_propose_from_candidate_pending_on_equivalent(tmp_path: Path):
|
|
log = ProposalLog(tmp_path / "proposals.jsonl")
|
|
c = _enriched()
|
|
proposal = propose_from_candidate(c, log=log, run_replay=_fake_replay_equivalent)
|
|
rec = log.find(proposal.proposal_id)
|
|
assert rec["state"] == "pending"
|
|
assert rec["replay_evidence"]["replay_equivalent"] is True
|
|
|
|
|
|
def test_propose_from_candidate_auto_rejects_on_regression(tmp_path: Path):
|
|
log = ProposalLog(tmp_path / "proposals.jsonl")
|
|
c = _enriched()
|
|
proposal = propose_from_candidate(c, log=log, run_replay=_fake_replay_regression)
|
|
rec = log.find(proposal.proposal_id)
|
|
assert rec["state"] == "rejected"
|
|
assert "auto_rollback_regression" in rec["operator_note"]
|
|
assert "surface_groundedness" in rec["operator_note"]
|
|
|
|
|
|
def test_propose_is_idempotent(tmp_path: Path):
|
|
log = ProposalLog(tmp_path / "proposals.jsonl")
|
|
c = _enriched()
|
|
propose_from_candidate(c, log=log, run_replay=_fake_replay_equivalent)
|
|
size_a = (tmp_path / "proposals.jsonl").stat().st_size
|
|
propose_from_candidate(c, log=log, run_replay=_fake_replay_equivalent)
|
|
size_b = (tmp_path / "proposals.jsonl").stat().st_size
|
|
# Idempotency: second proposal is a no-op; log size unchanged.
|
|
assert size_a == size_b
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Accept / reject / withdraw state machine
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_accept_appends_to_corpus(tmp_path: Path):
|
|
log = ProposalLog(tmp_path / "proposals.jsonl")
|
|
corpus = tmp_path / "corpus.jsonl"
|
|
corpus.write_text("", encoding="utf-8")
|
|
c = _enriched()
|
|
proposal = propose_from_candidate(c, log=log, run_replay=_fake_replay_equivalent)
|
|
|
|
chain_id = accept_proposal(
|
|
proposal.proposal_id,
|
|
log=log,
|
|
corpus_path=corpus,
|
|
review_date="2026-05-18",
|
|
operator_note="looks good",
|
|
)
|
|
assert chain_id
|
|
|
|
lines = [ln for ln in corpus.read_text().splitlines() if ln.strip()]
|
|
assert len(lines) == 1
|
|
payload = json.loads(lines[0])
|
|
assert payload["subject"] == "light"
|
|
assert payload["connective"] == "reveals"
|
|
assert "discovery_promoted" in payload["provenance"]
|
|
|
|
rec = log.find(proposal.proposal_id)
|
|
assert rec["state"] == "accepted"
|
|
assert rec["accepted_chain_id"] == chain_id
|
|
|
|
|
|
def test_accept_refused_on_regression(tmp_path: Path):
|
|
log = ProposalLog(tmp_path / "proposals.jsonl")
|
|
corpus = tmp_path / "corpus.jsonl"
|
|
corpus.write_text("", encoding="utf-8")
|
|
c = _enriched()
|
|
proposal = propose_from_candidate(c, log=log, run_replay=_fake_replay_regression)
|
|
with pytest.raises(ProposalError):
|
|
accept_proposal(
|
|
proposal.proposal_id, log=log,
|
|
corpus_path=corpus, review_date="2026-05-18",
|
|
)
|
|
|
|
|
|
def test_reject_and_withdraw_transitions(tmp_path: Path):
|
|
log = ProposalLog(tmp_path / "proposals.jsonl")
|
|
c = _enriched()
|
|
p1 = propose_from_candidate(c, log=log, run_replay=_fake_replay_equivalent)
|
|
reject_proposal(p1.proposal_id, log=log, operator_note="off doctrine")
|
|
assert log.find(p1.proposal_id)["state"] == "rejected"
|
|
|
|
# Cannot transition from rejected.
|
|
with pytest.raises(ProposalError):
|
|
withdraw_proposal(p1.proposal_id, log=log)
|
|
|
|
|
|
def test_accept_idempotency_blocked_by_state_machine(tmp_path: Path):
|
|
log = ProposalLog(tmp_path / "proposals.jsonl")
|
|
corpus = tmp_path / "corpus.jsonl"
|
|
corpus.write_text("", encoding="utf-8")
|
|
c = _enriched()
|
|
proposal = propose_from_candidate(c, log=log, run_replay=_fake_replay_equivalent)
|
|
accept_proposal(
|
|
proposal.proposal_id, log=log,
|
|
corpus_path=corpus, review_date="2026-05-18",
|
|
)
|
|
with pytest.raises(ProposalError):
|
|
accept_proposal(
|
|
proposal.proposal_id, log=log,
|
|
corpus_path=corpus, review_date="2026-05-18",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Trust boundary: replay gate does not touch active corpus
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_replay_gate_does_not_mutate_active_corpus():
|
|
"""The real replay-equivalence gate runs the cognition lane;
|
|
that's slow, so this test runs it once and asserts byte-equality
|
|
on the active corpus. Marked separately so the rest of the
|
|
suite stays fast."""
|
|
from teaching.replay import run_replay_equivalence
|
|
|
|
chain = {
|
|
"subject": "judgment", "intent": "verification",
|
|
"connective": "requires", "object": "evidence",
|
|
}
|
|
before = _CORPUS_PATH.read_bytes()
|
|
evidence = run_replay_equivalence(chain)
|
|
after = _CORPUS_PATH.read_bytes()
|
|
assert before == after
|
|
assert isinstance(evidence.replay_equivalent, bool)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# append_chain_to_corpus — direct unit
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_append_chain_writes_one_line(tmp_path: Path):
|
|
corpus = tmp_path / "c.jsonl"
|
|
corpus.write_text("", encoding="utf-8")
|
|
prov = Provenance(
|
|
adr_id="adr-0057", source="discovery_promoted",
|
|
review_date="2026-05-18", raw="adr-0057:discovery_promoted:2026-05-18",
|
|
)
|
|
chain_id = append_chain_to_corpus(
|
|
{"subject": "knowledge", "intent": "cause",
|
|
"connective": "requires", "object": "evidence"},
|
|
corpus_path=corpus, provenance=prov,
|
|
)
|
|
payload = json.loads(corpus.read_text().splitlines()[0])
|
|
assert payload["chain_id"] == chain_id
|
|
assert payload["provenance"] == prov.raw
|