diff --git a/chat/runtime.py b/chat/runtime.py index ffc3e981..1841f5f7 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -177,6 +177,13 @@ def _build_vault_probe(vault, vocab): return _probe +def _vault_probe_for_context(context: SessionContext | None) -> Any | None: + """Return a _VaultProbe callable or None for the given session context.""" + if context is None: + return None + return _build_vault_probe(context.vault, context.vocab) + + def _energy_scalar(energy_obj) -> float: if energy_obj is None: return 1.0 @@ -646,7 +653,15 @@ class ChatRuntime: if store is None: return store.save_recognizers(self._recognizer_registry.all()) - store.save_discovery_candidates(self._pending_candidates) + candidates_to_save = self._pending_candidates + if self.config.auto_contemplate and candidates_to_save: + from teaching.contemplation import contemplate + vault_probe = _vault_probe_for_context(self._context) if self._context else None + candidates_to_save = [ + contemplate(c, vault_probe=vault_probe) + for c in candidates_to_save + ] + store.save_discovery_candidates(candidates_to_save) store.save_manifest(self._turn_count) def _checkpointed_response(self, response: ChatResponse) -> ChatResponse: diff --git a/core/config.py b/core/config.py index 4084e10f..e00d92fd 100644 --- a/core/config.py +++ b/core/config.py @@ -269,6 +269,10 @@ class RuntimeConfig: # Unlocks W-007 (DerivedRecognizer derivation from promoted COHERENT entries). vault_promotion_enabled: bool = False + # ADR-0150 — run contemplation on pending discovery candidates at checkpoint. + # Activates ADR-0056 Phase C1. Null-drop when False. + auto_contemplate: bool = False + DEFAULT_IDENTITY_PACK: str = "default_general_v1" DEFAULT_ETHICS_PACK: str = "default_general_ethics_v1" diff --git a/docs/decisions/ADR-0150-autonomous-inter-session-contemplation.md b/docs/decisions/ADR-0150-autonomous-inter-session-contemplation.md new file mode 100644 index 00000000..d4f4444c --- /dev/null +++ b/docs/decisions/ADR-0150-autonomous-inter-session-contemplation.md @@ -0,0 +1,33 @@ +# ADR-0150 — Autonomous Inter-Session Contemplation + +Status: Accepted +Date: 2026-05-25 + +## Context +ADR-0056 Phase C1 shipped `contemplate()` as a pure function that enriches +DiscoveryCandidate with polarity, evidence, claim_domain, and sub_questions. +It ran inline (opt-in via attach_contemplation) or via CLI batch. Neither path +ran at session boundaries. Engine state (ADR-0146) persists discovery candidates +to disk, but stored candidates were unenriched (raw Phase B output). + +## Decision +Run `contemplate()` on pending session candidates at `checkpoint_engine_state()` +before persisting to `engine_state/discovery_candidates.jsonl`. Enriched +candidates (polarity/evidence/claim_domain populated) are stored instead of +raw ones. + +Flag: `RuntimeConfig.auto_contemplate = False` (null-drop default). + +## Trust boundary +`contemplate()` is read-only w.r.t. corpus, pack, and vault per ADR-0056. +It enriches the in-memory candidate struct only. Nothing is written to any +shared store during enrichment. + +## Why checkpoint, not inline +Fresh candidates are produced during the turn and accumulated in +`_pending_candidates`. Contemplation at checkpoint runs after the session +completes, not on the hot turn path. This avoids blocking turn latency. + +## Unlocks +W-017: auto-proposal pipeline can filter enriched candidates (polarity, +evidence) to generate TeachingChainProposals. diff --git a/tests/test_adr_0150_autonomous_contemplation.py b/tests/test_adr_0150_autonomous_contemplation.py new file mode 100644 index 00000000..4af0ab25 --- /dev/null +++ b/tests/test_adr_0150_autonomous_contemplation.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import json +from pathlib import Path +from core.config import RuntimeConfig +from chat.runtime import ChatRuntime +from engine_state import EngineStateStore +from teaching.discovery import DiscoveryCandidate, EvidencePointer, SubQuestion +from chat.pack_grounding import _pack_index +from chat.teaching_grounding import _corpus_index +from teaching.contemplation import contemplate + +def _raw_candidate() -> DiscoveryCandidate: + return DiscoveryCandidate( + candidate_id="cand-1", + proposed_chain={"subject": "light", "intent": "verification", "connective": None, "object": None}, + trigger="would_have_grounded", + source_turn_trace="trace-1", + pack_consistent=True, + boundary_clean=True, + ) + +def test_auto_contemplate_off_stores_raw_candidates(tmp_path: Path) -> None: + config = RuntimeConfig(auto_contemplate=False) + runtime = ChatRuntime(config=config, engine_state_path=tmp_path) + + cand = _raw_candidate() + runtime._pending_candidates.append(cand) + runtime.checkpoint_engine_state() + + store = EngineStateStore(tmp_path) + loaded = store.load_discovery_candidates() + assert len(loaded) == 1 + assert loaded[0].polarity == "undetermined" + assert len(loaded[0].evidence) == 0 + +def test_auto_contemplate_enriches_at_checkpoint(tmp_path: Path) -> None: + config = RuntimeConfig(auto_contemplate=True) + runtime = ChatRuntime(config=config, engine_state_path=tmp_path) + + cand = _raw_candidate() + runtime._pending_candidates.append(cand) + runtime.checkpoint_engine_state() + + # Clear pending candidates and load from disk + runtime._pending_candidates = [] + runtime._load_engine_state() + + assert len(runtime._pending_candidates) == 1 + loaded_cand = runtime._pending_candidates[0] + + # Verify loaded candidate has claim_domain and evidence populated (not defaults) + assert loaded_cand.claim_domain == "factual" + assert len(loaded_cand.evidence) > 0 + # The default polarity is undetermined, but evidence is populated + assert any(e.source == "pack" and e.ref == "light" for e in loaded_cand.evidence) + +def test_enriched_candidates_survive_jsonl_round_trip(tmp_path: Path) -> None: + cand = _raw_candidate() + enriched = contemplate(cand) + + store = EngineStateStore(tmp_path) + store.save_discovery_candidates([enriched]) + + loaded = store.load_discovery_candidates() + assert len(loaded) == 1 + loaded_cand = loaded[0] + + assert loaded_cand.polarity == enriched.polarity + assert loaded_cand.claim_domain == enriched.claim_domain + assert loaded_cand.evidence == enriched.evidence + assert loaded_cand.sub_questions == enriched.sub_questions + assert loaded_cand.contemplation_depth == enriched.contemplation_depth + assert loaded_cand.recursion_overflow == enriched.recursion_overflow + +def test_contemplate_does_not_write_corpus_or_pack() -> None: + corpus_before = dict(_corpus_index()) + pack_before = dict(_pack_index()) + + cand = _raw_candidate() + _ = contemplate(cand) + + corpus_after = dict(_corpus_index()) + pack_after = dict(_pack_index()) + + assert corpus_before == corpus_after + assert pack_before == pack_after