From df6c9a3206dcbb0e9013c4006880562e7dd99252 Mon Sep 17 00:00:00 2001 From: Shay Date: Mon, 25 May 2026 12:46:10 -0700 Subject: [PATCH] feat(W-017): load-time auto-proposal pipeline from enriched candidates (ADR-0151) (#275) Wires contemplation-enriched DiscoveryCandidates into the ADR-0057 proposal gate at _load_engine_state(). Proposals land in ProposalLog with source.kind="contemplation"; operator ratification via existing core teaching review path unchanged. --- chat/runtime.py | 24 ++ core/config.py | 3 + .../ADR-0151-auto-proposal-pipeline.md | 40 ++++ teaching/proposals.py | 7 +- tests/test_adr_0151_auto_proposal.py | 211 ++++++++++++++++++ 5 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 docs/decisions/ADR-0151-auto-proposal-pipeline.md create mode 100644 tests/test_adr_0151_auto_proposal.py diff --git a/chat/runtime.py b/chat/runtime.py index d70acbe3..489bd4f7 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -107,6 +107,28 @@ _TOKEN_RE = re.compile(r"\w+", re.UNICODE) _ANCHOR_LENS_ANNOTATION_RE = re.compile(r"\[lens\(([^):]+)\):([^\]]+)\]") +def _auto_propose_from_candidates(candidates: list[DiscoveryCandidate]) -> None: + from teaching.proposals import ( + ProposalError, + ProposalLog, + _current_revision, + propose_from_candidate, + ) + from teaching.source import ProposalSource + + log = ProposalLog() + for candidate in candidates: + source = ProposalSource( + kind="contemplation", + source_id=candidate.candidate_id, + emitted_at_revision=_current_revision(), + ) + try: + propose_from_candidate(candidate, log=log, source=source) + except ProposalError: + pass + + def _extract_anchor_lens_mode_label(surface: str, lens_id: str) -> str: """Return the engaged mode_label if *surface* carries a ``[lens():]`` annotation for the given ``lens_id``. @@ -652,6 +674,8 @@ class ChatRuntime: self._pending_candidates = store.load_discovery_candidates() manifest = store.load_manifest() or {} self._turn_count = int(manifest.get("turn_count", 0)) + if self.config.auto_proposal_enabled and self._pending_candidates: + _auto_propose_from_candidates(self._pending_candidates) def checkpoint_engine_state(self) -> None: store = self._engine_state_store diff --git a/core/config.py b/core/config.py index e00d92fd..48f6160e 100644 --- a/core/config.py +++ b/core/config.py @@ -273,6 +273,9 @@ class RuntimeConfig: # Activates ADR-0056 Phase C1. Null-drop when False. auto_contemplate: bool = False + # ADR-0151 — generate TeachingChainProposals from enriched candidates on load. + auto_proposal_enabled: bool = False + DEFAULT_IDENTITY_PACK: str = "default_general_v1" DEFAULT_ETHICS_PACK: str = "default_general_ethics_v1" diff --git a/docs/decisions/ADR-0151-auto-proposal-pipeline.md b/docs/decisions/ADR-0151-auto-proposal-pipeline.md new file mode 100644 index 00000000..216dc90a --- /dev/null +++ b/docs/decisions/ADR-0151-auto-proposal-pipeline.md @@ -0,0 +1,40 @@ +# ADR-0151 — Auto-Proposal Pipeline at Load + +Status: Accepted +Date: 2026-05-25 + +## Context +ADR-0150 stores enriched `DiscoveryCandidate` records in engine state at +checkpoint. Those candidates can already be converted into +`TeachingChainProposal` records through `teaching.proposals.propose_from_candidate`, +which applies the existing eligibility gate, replay-equivalence gate, and +append-only `ProposalLog`. + +## Decision +When `RuntimeConfig.auto_proposal_enabled` is true, `ChatRuntime._load_engine_state()` +attempts to propose from loaded pending discovery candidates. The pipeline runs +at load, not checkpoint, so turn completion remains a pure engine-state +checkpoint and proposal construction happens when persisted candidates re-enter +the runtime. + +Each auto-generated proposal is stamped with: + +```text +source.kind = "contemplation" +source.source_id = candidate.candidate_id +``` + +The proposal remains in `review_state="pending"` unless the replay gate rejects +it for regression. Operators still ratify accepted memory through +`core teaching review`; this path never auto-accepts. + +## Determinism Contract +`TeachingChainProposal.proposal_id` is deterministic over +`(candidate_id, proposed_chain)`. Re-loading the same engine state therefore +reaches the same proposal id, and `ProposalLog` idempotency prevents duplicate +`created` events. + +## Trust Boundary +Auto-proposal writes only to the append-only proposal log. It never writes the +active teaching corpus. Corpus mutation remains review-gated through +`accept_proposal`. diff --git a/teaching/proposals.py b/teaching/proposals.py index be55976d..cfc27993 100644 --- a/teaching/proposals.py +++ b/teaching/proposals.py @@ -449,6 +449,7 @@ def propose_from_candidate( log: ProposalLog, run_replay: Any = None, allow_evaluative: bool = False, + source: ProposalSource | None = None, ) -> TeachingChainProposal: """End-to-end: build proposal, run replay-equivalence gate, auto-reject on regression, otherwise leave pending. @@ -461,7 +462,11 @@ def propose_from_candidate( Idempotent on (candidate_id, chain): re-proposing returns the existing proposal record if any. """ - proposal = build_proposal(candidate, allow_evaluative=allow_evaluative) + proposal = build_proposal( + candidate, + allow_evaluative=allow_evaluative, + source=source, + ) existing = log.find(proposal.proposal_id) if existing is not None: return proposal diff --git a/tests/test_adr_0151_auto_proposal.py b/tests/test_adr_0151_auto_proposal.py new file mode 100644 index 00000000..882b70d0 --- /dev/null +++ b/tests/test_adr_0151_auto_proposal.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path + +from chat.runtime import ChatRuntime +from chat.teaching_grounding import _CORPUS_PATH +from core.config import RuntimeConfig +from engine_state import EngineStateStore +from teaching.discovery import DiscoveryCandidate, EvidencePointer +from teaching.proposals import ( + ProposalLog, + ReplayEvidence, + build_proposal, + propose_from_candidate, +) +from teaching.source import ProposalSource + + +def _replay_equivalent(_chain: dict) -> ReplayEvidence: + return ReplayEvidence( + baseline={"intent_accuracy": 1.0}, + candidate={"intent_accuracy": 1.0}, + regressed_metrics=(), + replay_equivalent=True, + ) + + +def _candidate( + *, + candidate_id: str = "cand-auto-1", + polarity: str = "affirms", + claim_domain: str = "factual", + evidence: tuple[EvidencePointer, ...] | None = None, +) -> DiscoveryCandidate: + if evidence is None: + evidence = ( + EvidencePointer( + source="corpus", + ref="light_reveals_truth", + polarity="affirms", + epistemic_status="coherent", + ), + ) + return DiscoveryCandidate( + candidate_id=candidate_id, + proposed_chain={ + "subject": "light", + "intent": "verification", + "connective": "reveals", + "object": "truth", + }, + trigger="would_have_grounded", + source_turn_trace="trace-auto-1", + pack_consistent=True, + boundary_clean=True, + polarity=polarity, # type: ignore[arg-type] + claim_domain=claim_domain, # type: ignore[arg-type] + evidence=evidence, + contemplation_depth=1, + ) + + +def _write_engine_state(path: Path, candidates: list[DiscoveryCandidate]) -> None: + store = EngineStateStore(path) + store.save_discovery_candidates(candidates) + store.save_manifest(0) + + +def _install_proposal_log(monkeypatch, path: Path) -> Path: + import teaching.proposals as proposals + + proposal_path = path / "proposals.jsonl" + monkeypatch.setattr(proposals, "DEFAULT_PROPOSAL_LOG_PATH", proposal_path) + monkeypatch.setattr( + "teaching.replay.run_replay_equivalence", + _replay_equivalent, + ) + return proposal_path + + +def test_auto_proposal_off_does_not_generate_proposals(tmp_path: Path, monkeypatch) -> None: + proposal_path = _install_proposal_log(monkeypatch, tmp_path) + state_path = tmp_path / "engine_state" + _write_engine_state(state_path, [_candidate()]) + + ChatRuntime( + config=RuntimeConfig(auto_proposal_enabled=False), + engine_state_path=state_path, + ) + + assert not proposal_path.exists() + + +def test_auto_proposal_generates_pending_proposal_from_enriched_candidate( + tmp_path: Path, + monkeypatch, +) -> None: + proposal_path = _install_proposal_log(monkeypatch, tmp_path) + state_path = tmp_path / "engine_state" + candidate = _candidate() + _write_engine_state(state_path, [candidate]) + + ChatRuntime( + config=RuntimeConfig(auto_proposal_enabled=True), + engine_state_path=state_path, + ) + + proposal = build_proposal(candidate) + record = ProposalLog(proposal_path).find(proposal.proposal_id) + assert record is not None + assert record["state"] == "pending" + assert record["source"]["kind"] == "contemplation" + + +def test_unenriched_candidate_skipped_silently(tmp_path: Path, monkeypatch) -> None: + proposal_path = _install_proposal_log(monkeypatch, tmp_path) + state_path = tmp_path / "engine_state" + candidate = replace(_candidate(), polarity="undetermined", evidence=()) + _write_engine_state(state_path, [candidate]) + + ChatRuntime( + config=RuntimeConfig(auto_proposal_enabled=True), + engine_state_path=state_path, + ) + + assert ProposalLog(proposal_path).current_state() == {} + + +def test_evaluative_candidate_skipped(tmp_path: Path, monkeypatch) -> None: + proposal_path = _install_proposal_log(monkeypatch, tmp_path) + state_path = tmp_path / "engine_state" + _write_engine_state(state_path, [_candidate(claim_domain="evaluative")]) + + ChatRuntime( + config=RuntimeConfig(auto_proposal_enabled=True), + engine_state_path=state_path, + ) + + assert ProposalLog(proposal_path).current_state() == {} + + +def test_proposal_source_kind_is_contemplation(tmp_path: Path, monkeypatch) -> None: + proposal_path = _install_proposal_log(monkeypatch, tmp_path) + state_path = tmp_path / "engine_state" + candidate = _candidate(candidate_id="cand-source-1") + _write_engine_state(state_path, [candidate]) + + ChatRuntime( + config=RuntimeConfig(auto_proposal_enabled=True), + engine_state_path=state_path, + ) + + proposal = build_proposal(candidate) + record = ProposalLog(proposal_path).find(proposal.proposal_id) + assert record is not None + assert record["source"]["kind"] == "contemplation" + assert record["source"]["source_id"] == candidate.candidate_id + + +def test_propose_from_candidate_accepts_source_kwarg(tmp_path: Path) -> None: + log = ProposalLog(tmp_path / "proposals.jsonl") + source = ProposalSource( + kind="contemplation", + source_id="cand-direct-1", + emitted_at_revision="test-revision", + ) + + proposal = propose_from_candidate( + _candidate(candidate_id="cand-direct-1"), + log=log, + run_replay=_replay_equivalent, + source=source, + ) + + record = log.find(proposal.proposal_id) + assert record is not None + assert record["source"] == source.as_dict() + + +def test_idempotent_reload_does_not_duplicate(tmp_path: Path, monkeypatch) -> None: + proposal_path = _install_proposal_log(monkeypatch, tmp_path) + state_path = tmp_path / "engine_state" + _write_engine_state(state_path, [_candidate()]) + + config = RuntimeConfig(auto_proposal_enabled=True) + ChatRuntime(config=config, engine_state_path=state_path) + ChatRuntime(config=config, engine_state_path=state_path) + + created_events = [ + line + for line in proposal_path.read_text(encoding="utf-8").splitlines() + if '"event":"created"' in line + ] + assert len(created_events) == 1 + assert len(ProposalLog(proposal_path).current_state()) == 1 + + +def test_auto_proposal_does_not_write_corpus(tmp_path: Path, monkeypatch) -> None: + _install_proposal_log(monkeypatch, tmp_path) + state_path = tmp_path / "engine_state" + _write_engine_state(state_path, [_candidate()]) + before = _CORPUS_PATH.read_bytes() if _CORPUS_PATH.exists() else b"" + + ChatRuntime( + config=RuntimeConfig(auto_proposal_enabled=True), + engine_state_path=state_path, + ) + + after = _CORPUS_PATH.read_bytes() if _CORPUS_PATH.exists() else b"" + assert after == before