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:
parent
81718a0952
commit
df6c9a3206
5 changed files with 284 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
40
docs/decisions/ADR-0151-auto-proposal-pipeline.md
Normal file
40
docs/decisions/ADR-0151-auto-proposal-pipeline.md
Normal 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`.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
211
tests/test_adr_0151_auto_proposal.py
Normal file
211
tests/test_adr_0151_auto_proposal.py
Normal 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
|
||||
Loading…
Reference in a new issue