core/docs/briefs/W-017-auto-proposal-pipeline.md
Shay e7e28a2fd5
feat(W-019): learning-arc demo — engine-authored proposal from contemplation (ADR-0152) (#276)
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.
2026-05-25 13:03:10 -07:00

266 lines
11 KiB
Markdown

# 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 = False` in `core/config.py` ✓ (W-018)
- `chat/runtime.py`: `_load_engine_state()` loads `_pending_candidates` from disk ✓ (W-008)
- `chat/runtime.py`: `checkpoint_engine_state()` runs `contemplate()` when `auto_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)` accepts `source`
- `teaching/source.py`: `ProposalSource(kind="contemplation", source_id=..., emitted_at_revision=...)` is valid ✓
- `ProposalKind` sealed literal includes `"contemplation"`
---
## Changes required
### 1. `core/config.py` — add flag
Add to `RuntimeConfig` dataclass (after `auto_contemplate`):
```python
# 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:
```python
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:
```python
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):
```python
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:
1. `any(e.source == "corpus" for e in candidate.evidence)` — corpus evidence floor
2. `candidate.polarity in ("affirms", "falsifies")` — polarity resolved
3. `not allow_evaluative` AND `candidate.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_candidates` reads corpus and pack via `check_eligibility``contemplate()`'s evidence; never writes to corpus
- Flag: `auto_proposal_enabled=False` default; 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 `ChatRuntime` with `engine_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 `DiscoveryCandidate` with `polarity="affirms"`, `claim_domain="factual"`,
`evidence=(EvidencePointer(source="corpus", ...),)`, valid `proposed_chain`
- Save it to a tmpdir engine state store
- Load a `ChatRuntime` with `auto_proposal_enabled=True`
- Assert at least one proposal in `ProposalLog().pending()` with
`record["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 `DiscoveryCandidate` with `polarity=None`, empty `evidence`
- Save to tmpdir engine state
- Load `ChatRuntime` with `auto_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_candidates` at `checkpoint_engine_state()` — it runs at **load**, not at checkpoint
- Do not skip the replay gate — `propose_from_candidate` runs 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
```bash
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.