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

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 = 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):

# 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:

  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_eligibilitycontemplate()'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

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.