feat(adr-0056): wire contemplation into live turn path (opt-in)

ChatRuntime.attach_contemplation(enabled=True) flips an opt-in
flag; when on, each emitted DiscoveryCandidate runs through
teaching.contemplation.contemplate before the sink writes the
JSONL line.  Default off ⇒ Phase B raw output preserved byte-
identical.

Trust boundary
- Contemplation is read-only over pack + corpus.
- Without an attached discovery sink the flag is inert (no hidden
  work — emission requires an observable destination).
- Active teaching corpus on disk byte-identical pre/post.

Lanes: smoke 67 / runtime 19 / cognition 121 / contemplation-
wiring 6 — all green.  Cognition eval unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Shay 2026-05-18 10:13:44 -07:00
parent f78def7f3a
commit db6ce08589
2 changed files with 190 additions and 0 deletions

View file

@ -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))

View file

@ -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