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.
266 lines
11 KiB
Markdown
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.
|