diff --git a/chat/runtime.py b/chat/runtime.py index 08f4b270..30eb3d5a 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -418,6 +418,13 @@ class ChatRuntime: # ``attach_discovery_sink``. Candidates are *evidence*, never # mutate the corpus or runtime state. self._discovery_sink: DiscoveryCandidateSink | None = None + # ADR-0056 Phase C1 — opt-in contemplation pass that enriches + # each emitted DiscoveryCandidate with polarity / claim_domain / + # evidence / sub_questions before the sink writes the JSONL + # line. Default False preserves prior behavior (Phase B raw + # candidates). Toggling on does NOT mutate the corpus; the + # loop is read-only over pack + corpus + (optional) vault. + self._contemplate_discoveries: bool = False self._correction_pass = CorrectionPass() self._last_valence: float = 0.0 @@ -463,6 +470,22 @@ class ChatRuntime: """ self._discovery_sink = sink + def attach_contemplation(self, *, enabled: bool = True) -> None: + """ADR-0056 Phase C1 — opt-in inline contemplation. + + When enabled, each emitted ``DiscoveryCandidate`` is passed + through ``teaching.contemplation.contemplate`` before the + sink writes the JSONL line. The sink therefore receives an + *enriched* candidate (polarity / claim_domain / evidence / + sub_questions populated) instead of the Phase B raw record. + + Read-only over pack + corpus. No corpus mutation, no clock- + time read, no LLM step. Requires ``attach_discovery_sink`` + to have been called first — without a sink there is nowhere + to emit, so contemplation would do hidden work. + """ + self._contemplate_discoveries = bool(enabled) + def _emit_discovery_candidates( self, *, @@ -480,6 +503,12 @@ class ChatRuntime: intent_subject, grounding_source=grounding_source, ) + if self._contemplate_discoveries and candidates: + # Local import — keeps the contemplation module out of + # the runtime hot-path import graph for callers that + # never opt in. + from teaching.contemplation import contemplate + candidates = tuple(contemplate(c) for c in candidates) for candidate in candidates: sink.emit(format_candidate_jsonl(candidate)) diff --git a/tests/test_contemplation_wiring.py b/tests/test_contemplation_wiring.py new file mode 100644 index 00000000..c9967116 --- /dev/null +++ b/tests/test_contemplation_wiring.py @@ -0,0 +1,161 @@ +"""ADR-0056 Phase C1 — runtime wiring of the contemplation pass. + +Pinned contracts: + + - ``attach_contemplation`` is opt-in; default off ⇒ Phase B JSONL + output is byte-identical to pre-C1 wiring. + - When enabled, each emitted candidate is replaced by its + contemplated form (Phase C1 fields populated). + - Wiring does NOT mutate the active teaching corpus on disk. + - Toggling off restores Phase B raw output. +""" + +from __future__ import annotations + +import json + +import pytest + +from chat.runtime import ChatRuntime +from chat.teaching_grounding import _CORPUS_PATH +from generate.intent import IntentTag +from teaching.discovery_sink import DiscoveryBufferSink + + +CORPUS_BYTES_BEFORE = _CORPUS_PATH.read_bytes() if _CORPUS_PATH.exists() else b"" + + +def _find_pack_lemma_without_active_chain() -> tuple[str, IntentTag]: + """Mirror of helper in tests/test_discovery_candidates.py.""" + from chat.pack_grounding import _pack_index + from chat.teaching_grounding import _corpus_index + + pack = _pack_index() + corpus = _corpus_index() + for lemma in sorted(pack.keys()): + if (lemma, "cause") not in corpus: + return lemma, IntentTag.CAUSE + if (lemma, "verification") not in corpus: + return lemma, IntentTag.VERIFICATION + pytest.skip("No pack lemma without an active corpus chain") + + +def _send_prompt_expecting_emission(rt: ChatRuntime, sink: DiscoveryBufferSink) -> dict: + lemma, intent_tag = _find_pack_lemma_without_active_chain() + if intent_tag is IntentTag.CAUSE: + prompt = f"Why does {lemma} matter?" + else: + prompt = f"Does {lemma} require evidence?" + rt.chat(prompt) + assert len(sink.lines) == 1, sink.lines + return json.loads(sink.lines[0]) + + +# --------------------------------------------------------------------------- +# Default behaviour: contemplation OFF +# --------------------------------------------------------------------------- + + +def test_default_does_not_contemplate(): + """No call to attach_contemplation ⇒ no C1 fields in the JSONL.""" + rt = ChatRuntime() + sink = DiscoveryBufferSink() + rt.attach_discovery_sink(sink) + payload = _send_prompt_expecting_emission(rt, sink) + + # Phase B keys only. No C1 fields. + assert "polarity" not in payload + assert "claim_domain" not in payload + assert "evidence" not in payload + assert "sub_questions" not in payload + + +# --------------------------------------------------------------------------- +# Opt-in: contemplation ON +# --------------------------------------------------------------------------- + + +def test_attach_contemplation_enriches_emitted_candidates(): + rt = ChatRuntime() + sink = DiscoveryBufferSink() + rt.attach_discovery_sink(sink) + rt.attach_contemplation(enabled=True) + payload = _send_prompt_expecting_emission(rt, sink) + + # C1 fields present. + assert "polarity" in payload + assert "claim_domain" in payload + assert "evidence" in payload + assert "sub_questions" in payload + assert payload["polarity"] in ("affirms", "falsifies", "undetermined") + assert payload["claim_domain"] in ("factual", "relational", "evaluative") + + +def test_attach_contemplation_then_disable_restores_raw(): + rt = ChatRuntime() + sink = DiscoveryBufferSink() + rt.attach_discovery_sink(sink) + rt.attach_contemplation(enabled=True) + rt.attach_contemplation(enabled=False) + payload = _send_prompt_expecting_emission(rt, sink) + assert "polarity" not in payload + + +# --------------------------------------------------------------------------- +# Trust boundary: contemplation never mutates the corpus +# --------------------------------------------------------------------------- + + +def test_inline_contemplation_does_not_mutate_corpus(): + rt = ChatRuntime() + sink = DiscoveryBufferSink() + rt.attach_discovery_sink(sink) + rt.attach_contemplation(enabled=True) + _ = _send_prompt_expecting_emission(rt, sink) + after = _CORPUS_PATH.read_bytes() if _CORPUS_PATH.exists() else b"" + assert after == CORPUS_BYTES_BEFORE + + +# --------------------------------------------------------------------------- +# Determinism: same prompt twice ⇒ identical contemplated payload +# (modulo Phase B fields which are already deterministic) +# --------------------------------------------------------------------------- + + +def test_contemplated_emission_deterministic_across_calls(): + rt1 = ChatRuntime() + sink1 = DiscoveryBufferSink() + rt1.attach_discovery_sink(sink1) + rt1.attach_contemplation(enabled=True) + p1 = _send_prompt_expecting_emission(rt1, sink1) + + rt2 = ChatRuntime() + sink2 = DiscoveryBufferSink() + rt2.attach_discovery_sink(sink2) + rt2.attach_contemplation(enabled=True) + p2 = _send_prompt_expecting_emission(rt2, sink2) + + # Phase B fields + C1 fields must match. Source trace hash is + # content-derived so it is stable across fresh runtimes given + # identical prompts. + assert p1 == p2 + + +# --------------------------------------------------------------------------- +# No sink ⇒ no contemplation (would be hidden work) +# --------------------------------------------------------------------------- + + +def test_no_sink_no_contemplation_work(): + """attach_contemplation alone does nothing without a sink — the + work would be unobservable.""" + rt = ChatRuntime() + rt.attach_contemplation(enabled=True) + # No sink attached; sending a fall-through prompt must not raise + # and must produce no candidate emission. + lemma, intent_tag = _find_pack_lemma_without_active_chain() + if intent_tag is IntentTag.CAUSE: + prompt = f"Why does {lemma} matter?" + else: + prompt = f"Does {lemma} require evidence?" + rt.chat(prompt) # no exception