Two-session arc where engine derives connective+object from corpus decomposition; operator ratifies rather than authors. Distinguishes from learning-loop (operator-authored) and directly exercises W-018 checkpoint contemplation and W-017 auto-proposal provenance path.
11 KiB
Brief: W-017 — Auto-Proposal Pipeline at Load
Status: Ready to dispatch. W-007 (#274) and W-018 (#273) are both merged to main.
ADR: ADR-0151 (create alongside implementation)
Dispatch to: Gemini or Codex
Test suite: uv run pytest tests/test_adr_0151_auto_proposal.py tests/test_adr_0150_autonomous_contemplation.py tests/test_chat_runtime.py tests/test_architectural_invariants.py -q
What this wires up
W-018 (now merged) enriches DiscoveryCandidate objects via contemplate() at
checkpoint_engine_state(). W-017 completes the loop: on the next session
load, those enriched candidates are run through the ADR-0057 proposal gate
automatically, producing TeachingChainProposal entries with
source.kind="contemplation" in the standard ProposalLog.
The operator still ratifies via core teaching review <id> --accept. Nothing
auto-accepts. The only change is that the engine authors the proposal structure
(connective, object) from the contemplation enrichment rather than the operator
doing it manually.
Prerequisite check
Before starting, confirm all of these exist on the current main:
RuntimeConfig.auto_contemplate: bool = Falseincore/config.py✓ (W-018)chat/runtime.py:_load_engine_state()loads_pending_candidatesfrom disk ✓ (W-008)chat/runtime.py:checkpoint_engine_state()runscontemplate()whenauto_contemplate=True✓ (W-018)teaching/proposals.py:propose_from_candidate(candidate, *, log, run_replay, allow_evaluative)✓teaching/proposals.py:build_proposal(candidate, *, allow_evaluative, source)acceptssource✓teaching/source.py:ProposalSource(kind="contemplation", source_id=..., emitted_at_revision=...)is valid ✓ProposalKindsealed literal includes"contemplation"✓
Changes required
1. core/config.py — add flag
Add to RuntimeConfig dataclass (after auto_contemplate):
# ADR-0151 — generate TeachingChainProposals from enriched candidates on load.
# Requires auto_contemplate=True on the previous session to have enriched the
# candidates. Null-drop when False.
auto_proposal_enabled: bool = False
2. teaching/proposals.py — thread source through propose_from_candidate
propose_from_candidate currently calls build_proposal(candidate, allow_evaluative=...)
without forwarding a source. Add the parameter:
def propose_from_candidate(
candidate: DiscoveryCandidate,
*,
log: ProposalLog,
run_replay: Any = None,
allow_evaluative: bool = False,
source: "ProposalSource | None" = None, # ADD THIS
) -> TeachingChainProposal:
proposal = build_proposal(
candidate,
allow_evaluative=allow_evaluative,
source=source, # AND PASS IT
)
... # rest unchanged
The default source=None preserves existing behaviour — build_proposal
defaults to _default_operator_source() when source is None.
3. chat/runtime.py — run proposal gate at load
In _load_engine_state(), after loading candidates, if
self.config.auto_proposal_enabled is True, run the proposal gate:
def _load_engine_state(self) -> None:
store = self._engine_state_store
if store is None:
return
self._recognizer_registry = RecognizerRegistry.from_recognizers(
store.load_recognizers()
)
self._pending_candidates = store.load_discovery_candidates()
manifest = store.load_manifest() or {}
self._turn_count = int(manifest.get("turn_count", 0))
# ADR-0151 — auto-generate proposals from enriched candidates.
if self.config.auto_proposal_enabled and self._pending_candidates:
_auto_propose_from_candidates(self._pending_candidates)
Implement _auto_propose_from_candidates as a module-level helper (not a
method, keeps ChatRuntime surface clean):
def _auto_propose_from_candidates(
candidates: list[DiscoveryCandidate],
) -> None:
"""Run ADR-0057 proposal gate on enriched candidates.
Uses the standard ProposalLog (DEFAULT_PROPOSAL_LOG_PATH) so
proposals are visible to 'core teaching proposals --state pending'.
ProposalError on eligibility failure → skip silently.
propose_from_candidate is idempotent, so re-loading the same state
does not duplicate proposals.
"""
from teaching.proposals import ProposalError, ProposalLog, propose_from_candidate
from teaching.source import ProposalSource
log = ProposalLog() # uses DEFAULT_PROPOSAL_LOG_PATH
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 # eligibility gate failed — unenriched or evaluative candidate
Add _current_revision() import from teaching.proposals (it's already used
there) or from teaching.source — check where it lives and import from the
same place rather than duplicating it.
Eligibility gate (already enforced by existing code)
check_eligibility() in teaching/proposals.py (called inside build_proposal)
enforces these three conditions — no new gate logic needed in W-017:
any(e.source == "corpus" for e in candidate.evidence)— corpus evidence floorcandidate.polarity in ("affirms", "falsifies")— polarity resolvednot allow_evaluativeANDcandidate.claim_domain != "evaluative"— domain gate
Candidates that fail any condition raise ProposalError → caught and skipped.
Unenriched candidates (those produced without auto_contemplate=True) will
have polarity=None and empty evidence, so they fail at gate 1 or 2 and are
silently dropped. This is correct — auto-proposals only fire on contemplated
candidates.
Determinism contract
Same engine state directory + same corpus state = same set of proposals
generated on load. propose_from_candidate is already idempotent via the
(source_candidate_id, proposed_chain) key check — re-loading the same
state never duplicates an existing proposal. The emitted_at_revision in
ProposalSource is pinned at the git SHA at load time, not at contemplation
time; this is intentional — it records when the proposal was surfaced, not
when the candidate was enriched.
ADR-0151 to create
Minimal decision record covering:
- What the auto-proposal pipeline does and why it differs from operator-authored proposals
- The eligibility gate (existing
check_eligibility— no new gate logic) source.kind="contemplation"provenance and what it means for audit- Determinism contract (idempotent re-loads, same corpus = same proposals)
- Trust boundary:
_auto_propose_from_candidatesreads corpus and pack viacheck_eligibility→contemplate()'s evidence; never writes to corpus - Flag:
auto_proposal_enabled=Falsedefault; null-drop when False
Tests — tests/test_adr_0151_auto_proposal.py
8 tests, module-scoped fixture not needed — each test creates its own tmpdir engine state.
Test 1: test_auto_proposal_off_does_not_generate_proposals
With auto_proposal_enabled=False:
- Save an enriched candidate to a tmpdir engine state store
- Load a
ChatRuntimewithengine_state_dir=tmpdir,auto_proposal_enabled=False - Assert
ProposalLog().pending()does NOT contain the candidate's proposal
Test 2: test_auto_proposal_generates_pending_proposal_from_enriched_candidate
With auto_proposal_enabled=True:
- Build a
DiscoveryCandidatewithpolarity="affirms",claim_domain="factual",evidence=(EvidencePointer(source="corpus", ...),), validproposed_chain - Save it to a tmpdir engine state store
- Load a
ChatRuntimewithauto_proposal_enabled=True - Assert at least one proposal in
ProposalLog().pending()withrecord["proposal"]["source"]["kind"] == "contemplation"
For the replay gate: pass run_replay stub to propose_from_candidate that
returns a ReplayEvidence(replay_equivalent=True, regressed_metrics=[]) —
same pattern as test_learning_loop_demo.py. The runtime calls
_auto_propose_from_candidates which calls propose_from_candidate; to inject
the stub you may need to monkeypatch teaching.replay.run_replay_equivalence
via monkeypatch.setattr.
Test 3: test_unenriched_candidate_skipped_silently
With auto_proposal_enabled=True:
- Build a raw
DiscoveryCandidatewithpolarity=None, emptyevidence - Save to tmpdir engine state
- Load
ChatRuntimewithauto_proposal_enabled=True - Assert no proposals generated, no exception raised
Test 4: test_evaluative_candidate_skipped
With auto_proposal_enabled=True:
- Build an enriched candidate with
claim_domain="evaluative",polarity="affirms",evidence=(EvidencePointer(source="corpus", ...),) - Save to tmpdir engine state
- Assert no proposal generated (evaluative domain fails gate)
Test 5: test_proposal_source_kind_is_contemplation
Verify the generated proposal's source.kind == "contemplation" and
source.source_id == candidate.candidate_id.
Test 6: test_propose_from_candidate_accepts_source_kwarg
Unit test: call propose_from_candidate(candidate, log=log, source=ProposalSource(kind="contemplation", source_id="test_id", emitted_at_revision="abc123")) directly.
Assert proposal record has source.kind == "contemplation".
Test 7: test_idempotent_reload_does_not_duplicate
Load ChatRuntime twice from the same tmpdir (with auto_proposal_enabled=True
and an enriched candidate). Assert len(ProposalLog().pending()) == 1 after
both loads.
Test 8: test_auto_proposal_does_not_write_corpus
Assert that the active corpus (teaching corpus path) is byte-identical before
and after loading a ChatRuntime with auto_proposal_enabled=True and an
enriched candidate. Proposals land in ProposalLog only — never in the corpus.
What NOT to do
- Do not auto-accept proposals — everything lands in
state="pending" - Do not add a new
ProposalKind—"contemplation"is already sealed - Do not add corpus evidence floor logic —
check_eligibility()already enforces it - Do not run
_auto_propose_from_candidatesatcheckpoint_engine_state()— it runs at load, not at checkpoint - Do not skip the replay gate —
propose_from_candidateruns it; keep it - Do not write to
vault/store.py,generate/stream.py,field/propagate.py - Do not weaken
versor_condition(F) < 1e-6
Verification
uv run pytest tests/test_adr_0151_auto_proposal.py tests/test_adr_0150_autonomous_contemplation.py tests/test_chat_runtime.py tests/test_architectural_invariants.py -q
uv run python -m core.cli test --suite smoke -q
Expected: all tests pass.