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.
This commit is contained in:
Shay 2026-05-25 12:46:10 -07:00 committed by GitHub
parent 81718a0952
commit df6c9a3206
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 284 additions and 1 deletions

View file

@ -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(<lens_id>):<mode>]`` 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

View file

@ -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"

View file

@ -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`.

View file

@ -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

View file

@ -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