Lands the first deterministic trigger of the discovery → reviewed-
memory loop. Candidates are structured evidence; emission is
opt-in via attach_discovery_sink and NEVER mutates the active
teaching corpus.
- teaching/discovery.py: DiscoveryCandidate dataclass + pure
extract_discovery_candidates(turn_event, intent, subject) rule
firing. Phase B fires only the would_have_grounded trigger:
grounding_source == "none"
AND intent ∈ {CAUSE, VERIFICATION}
AND subject lemma in ratified cognition pack
AND (subject, intent) NOT in active corpus
candidate_id = SHA-256 of canonical JSON payload — replay-stable.
Other DiscoveryTrigger literals (successful_comparison,
hedge_acknowledged, oov_resolved_via_decomp) are reserved for
later phases.
- teaching/discovery_sink.py: DiscoveryCandidateSink protocol,
DiscoveryBufferSink (in-memory), DiscoveryMonthlyFileSink
(append-only JSONL, <root>/<YYYY>/<YYYY-MM>.jsonl rollover,
injectable clock).
- chat/runtime.py: opt-in attach_discovery_sink, post-turn
emission inside _stub_response only when caller threads
classified intent forward (gate-fire fall-through site).
Intent classification at the call site reuses the same
deterministic classifier already invoked by
_maybe_pack_grounded_surface for the empty-vault English path.
Trust boundary: candidates write to a separate sink/file path
only; the active corpus on disk is never touched. Tests
explicitly assert corpus bytes are byte-identical before and
after a candidate-emitting turn.
Tests: tests/test_discovery_candidates.py — 24 tests covering
pure-predicate rule firing, every short-circuit path,
deterministic candidate_id, sink opt-in, runtime parity with no
sink, monthly rollover semantics, append-only behaviour, no
corpus mutation.
Lanes: smoke 67, cognition 121, runtime 19, teaching 17, packs 6
— all green. Cognition eval metrics unchanged on dev / public /
holdout splits. versor_condition < 1e-6 invariant untouched.
396 lines
13 KiB
Python
396 lines
13 KiB
Python
"""ADR-0055 Phase B — DiscoveryCandidate emission + sink contracts.
|
|
|
|
Pinned contracts:
|
|
|
|
- ``extract_discovery_candidates`` is pure and deterministic; same
|
|
inputs ⇒ identical ``candidate_id``.
|
|
- The ``would_have_grounded`` trigger fires only when ALL of:
|
|
fall-through (grounding_source == "none"), intent ∈ {CAUSE,
|
|
VERIFICATION}, subject lemma is a pack lemma, (subject, intent)
|
|
is NOT in the active corpus.
|
|
- Candidate emission **never** mutates the active teaching corpus
|
|
or runtime state.
|
|
- Sink is opt-in. No sink attached ⇒ behaviour identical to pre-B.
|
|
- ``DiscoveryMonthlyFileSink`` rolls over by month; previous file
|
|
is closed cleanly.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from datetime import datetime, timezone
|
|
import pytest
|
|
|
|
from chat.runtime import ChatRuntime
|
|
from generate.intent import IntentTag
|
|
from teaching.discovery import (
|
|
DiscoveryCandidate,
|
|
extract_discovery_candidates,
|
|
format_candidate_jsonl,
|
|
)
|
|
from teaching.discovery_sink import (
|
|
DiscoveryBufferSink,
|
|
DiscoveryMonthlyFileSink,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Synthetic TurnEvent shim (avoids needing a full runtime turn just to test
|
|
# the pure rule firing)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _TE:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
grounding_source: str = "none",
|
|
trace_hash: str = "abcd",
|
|
refusal_emitted: bool = False,
|
|
hedge_injected: bool = False,
|
|
) -> None:
|
|
self.grounding_source = grounding_source
|
|
self.trace_hash = trace_hash
|
|
self.refusal_emitted = refusal_emitted
|
|
self.hedge_injected = hedge_injected
|
|
self.verdicts = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# extract_discovery_candidates — pure predicate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_fires_on_would_have_grounded_for_cause_on_unknown_pack_lemma() -> None:
|
|
"""``judgment`` is a pack lemma; (judgment, cause) is NOT in the
|
|
corpus today (the existing chains for ``judgment`` are
|
|
VERIFICATION + CAUSE→ordered_by_wisdom, but no ``(judgment,
|
|
cause)`` SUBJECT key exists)."""
|
|
# First check the pack actually has the lemma we're claiming.
|
|
from chat.pack_grounding import _pack_index
|
|
from chat.teaching_grounding import _corpus_index
|
|
|
|
assert "judgment" in _pack_index()
|
|
if ("judgment", "cause") in _corpus_index():
|
|
pytest.skip("judgment/cause is in the active corpus; pick another fixture")
|
|
|
|
cands = extract_discovery_candidates(
|
|
_TE(grounding_source="none"),
|
|
IntentTag.CAUSE,
|
|
"judgment",
|
|
)
|
|
assert len(cands) == 1
|
|
c = cands[0]
|
|
assert c.trigger == "would_have_grounded"
|
|
assert c.proposed_chain["subject"] == "judgment"
|
|
assert c.proposed_chain["intent"] == "cause"
|
|
assert c.proposed_chain["connective"] is None
|
|
assert c.proposed_chain["object"] is None
|
|
assert c.pack_consistent is True
|
|
assert c.boundary_clean is True
|
|
assert c.review_state == "unreviewed"
|
|
|
|
|
|
def test_does_not_fire_when_grounding_source_is_pack() -> None:
|
|
cands = extract_discovery_candidates(
|
|
_TE(grounding_source="pack"),
|
|
IntentTag.CAUSE,
|
|
"judgment",
|
|
)
|
|
assert cands == ()
|
|
|
|
|
|
def test_does_not_fire_when_grounding_source_is_teaching() -> None:
|
|
cands = extract_discovery_candidates(
|
|
_TE(grounding_source="teaching"),
|
|
IntentTag.CAUSE,
|
|
"judgment",
|
|
)
|
|
assert cands == ()
|
|
|
|
|
|
def test_does_not_fire_for_definition_intent() -> None:
|
|
cands = extract_discovery_candidates(
|
|
_TE(grounding_source="none"),
|
|
IntentTag.DEFINITION,
|
|
"judgment",
|
|
)
|
|
assert cands == ()
|
|
|
|
|
|
def test_does_not_fire_for_unknown_intent() -> None:
|
|
cands = extract_discovery_candidates(
|
|
_TE(grounding_source="none"),
|
|
IntentTag.UNKNOWN,
|
|
"judgment",
|
|
)
|
|
assert cands == ()
|
|
|
|
|
|
def test_does_not_fire_for_non_pack_lemma() -> None:
|
|
cands = extract_discovery_candidates(
|
|
_TE(grounding_source="none"),
|
|
IntentTag.CAUSE,
|
|
"zzznotalemma",
|
|
)
|
|
assert cands == ()
|
|
|
|
|
|
def test_does_not_fire_when_chain_already_in_corpus() -> None:
|
|
"""``(light, cause)`` IS in the corpus today (``cause_light_reveals_truth``)
|
|
— the would_have_grounded predicate must not fire."""
|
|
cands = extract_discovery_candidates(
|
|
_TE(grounding_source="none"),
|
|
IntentTag.CAUSE,
|
|
"light",
|
|
)
|
|
assert cands == ()
|
|
|
|
|
|
def test_does_not_fire_on_empty_lemma() -> None:
|
|
cands = extract_discovery_candidates(
|
|
_TE(grounding_source="none"),
|
|
IntentTag.CAUSE,
|
|
"",
|
|
)
|
|
assert cands == ()
|
|
|
|
|
|
def test_does_not_fire_on_none_lemma() -> None:
|
|
cands = extract_discovery_candidates(
|
|
_TE(grounding_source="none"),
|
|
IntentTag.CAUSE,
|
|
None,
|
|
)
|
|
assert cands == ()
|
|
|
|
|
|
def test_candidate_id_is_deterministic() -> None:
|
|
"""Identical inputs MUST produce the identical candidate_id —
|
|
this is the load-bearing replay property."""
|
|
a = extract_discovery_candidates(
|
|
_TE(grounding_source="none", trace_hash="seed-1"),
|
|
IntentTag.CAUSE,
|
|
"judgment",
|
|
)
|
|
b = extract_discovery_candidates(
|
|
_TE(grounding_source="none", trace_hash="seed-1"),
|
|
IntentTag.CAUSE,
|
|
"judgment",
|
|
)
|
|
assert a[0].candidate_id == b[0].candidate_id
|
|
|
|
|
|
def test_candidate_id_changes_with_trace_hash() -> None:
|
|
a = extract_discovery_candidates(
|
|
_TE(grounding_source="none", trace_hash="seed-1"),
|
|
IntentTag.CAUSE,
|
|
"judgment",
|
|
)
|
|
b = extract_discovery_candidates(
|
|
_TE(grounding_source="none", trace_hash="seed-2"),
|
|
IntentTag.CAUSE,
|
|
"judgment",
|
|
)
|
|
assert a[0].candidate_id != b[0].candidate_id
|
|
|
|
|
|
def test_boundary_clean_false_when_refusal_emitted() -> None:
|
|
cands = extract_discovery_candidates(
|
|
_TE(grounding_source="none", refusal_emitted=True),
|
|
IntentTag.CAUSE,
|
|
"judgment",
|
|
)
|
|
# The trigger still fires (refusal does not block evidence), but
|
|
# boundary_clean flips to False so reviewers can filter these out
|
|
# downstream.
|
|
assert len(cands) == 1
|
|
assert cands[0].boundary_clean is False
|
|
|
|
|
|
def test_boundary_clean_false_when_hedge_injected() -> None:
|
|
cands = extract_discovery_candidates(
|
|
_TE(grounding_source="none", hedge_injected=True),
|
|
IntentTag.CAUSE,
|
|
"judgment",
|
|
)
|
|
assert len(cands) == 1
|
|
assert cands[0].boundary_clean is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# format_candidate_jsonl — stable on-disk shape
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_format_jsonl_is_sorted_keys_compact() -> None:
|
|
cand = DiscoveryCandidate(
|
|
candidate_id="x",
|
|
proposed_chain={"subject": "judgment", "intent": "cause", "connective": None, "object": None},
|
|
trigger="would_have_grounded",
|
|
source_turn_trace="t",
|
|
pack_consistent=True,
|
|
boundary_clean=True,
|
|
)
|
|
line = format_candidate_jsonl(cand)
|
|
parsed = json.loads(line)
|
|
assert parsed["candidate_id"] == "x"
|
|
# Compact separators — no whitespace between key/value or fields.
|
|
assert ", " not in line
|
|
assert ": " not in line
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Runtime integration — sink opt-in + behaviour parity
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _find_pack_lemma_without_active_chain() -> tuple[str, IntentTag]:
|
|
"""Pick a (lemma, intent) that lives in the pack but not in the
|
|
corpus. Used so the integration tests are not coupled to a
|
|
specific lemma whose status might change as the corpus grows."""
|
|
from chat.pack_grounding import _pack_index
|
|
from chat.teaching_grounding import _corpus_index
|
|
|
|
pack = _pack_index()
|
|
corpus = _corpus_index()
|
|
for lemma in pack:
|
|
if (lemma, "cause") not in corpus:
|
|
return lemma, IntentTag.CAUSE
|
|
if (lemma, "verification") not in corpus:
|
|
return lemma, IntentTag.VERIFICATION
|
|
pytest.skip("Every pack lemma already has a corpus chain — no would-have-grounded candidate possible")
|
|
|
|
|
|
def test_runtime_no_sink_no_emission() -> None:
|
|
"""No sink attached ⇒ no error, no side effect."""
|
|
rt = ChatRuntime()
|
|
rt.chat("Why does judgment matter?")
|
|
# Just must not crash and turn_log must have grown.
|
|
assert len(rt.turn_log) >= 1
|
|
|
|
|
|
def test_runtime_emits_to_buffer_sink_on_would_have_grounded() -> None:
|
|
"""Wire a buffer sink, send a CAUSE prompt on a lemma without a
|
|
corpus chain, expect one JSONL line."""
|
|
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 = ChatRuntime()
|
|
sink = DiscoveryBufferSink()
|
|
rt.attach_discovery_sink(sink)
|
|
rt.chat(prompt)
|
|
|
|
assert len(sink.lines) == 1
|
|
payload = json.loads(sink.lines[0])
|
|
assert payload["trigger"] == "would_have_grounded"
|
|
assert payload["proposed_chain"]["subject"] == lemma
|
|
assert payload["review_state"] == "unreviewed"
|
|
|
|
|
|
def test_runtime_does_not_emit_when_turn_is_grounded() -> None:
|
|
"""``light`` + CAUSE grounds via the teaching corpus today; no
|
|
candidate should be emitted on a grounded turn."""
|
|
rt = ChatRuntime()
|
|
sink = DiscoveryBufferSink()
|
|
rt.attach_discovery_sink(sink)
|
|
rt.chat("Why does light matter?")
|
|
assert sink.lines == []
|
|
|
|
|
|
def test_runtime_detach_sink_stops_emission() -> None:
|
|
lemma, _ = _find_pack_lemma_without_active_chain()
|
|
rt = ChatRuntime()
|
|
sink = DiscoveryBufferSink()
|
|
rt.attach_discovery_sink(sink)
|
|
rt.attach_discovery_sink(None)
|
|
rt.chat(f"Why does {lemma} matter?")
|
|
assert sink.lines == []
|
|
|
|
|
|
def test_runtime_emission_does_not_mutate_corpus_on_disk() -> None:
|
|
"""Phase B's core trust-boundary claim: emission writes nothing
|
|
to the corpus."""
|
|
from chat.teaching_grounding import _CORPUS_PATH
|
|
|
|
lemma, _ = _find_pack_lemma_without_active_chain()
|
|
before = _CORPUS_PATH.read_bytes()
|
|
rt = ChatRuntime()
|
|
rt.attach_discovery_sink(DiscoveryBufferSink())
|
|
rt.chat(f"Why does {lemma} matter?")
|
|
after = _CORPUS_PATH.read_bytes()
|
|
assert before == after
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DiscoveryMonthlyFileSink — rollover semantics
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_monthly_sink_writes_jsonl_in_expected_month_path(tmp_path: Path) -> None:
|
|
fixed = datetime(2026, 5, 18, tzinfo=timezone.utc)
|
|
sink = DiscoveryMonthlyFileSink(tmp_path, clock=lambda: fixed)
|
|
sink.emit('{"a":1}')
|
|
sink.close()
|
|
written = tmp_path / "2026" / "2026-05.jsonl"
|
|
assert written.exists()
|
|
assert written.read_text(encoding="utf-8").splitlines() == ['{"a":1}']
|
|
|
|
|
|
def test_monthly_sink_rolls_over_on_month_change(tmp_path: Path) -> None:
|
|
fake = {"now": datetime(2026, 5, 31, tzinfo=timezone.utc)}
|
|
sink = DiscoveryMonthlyFileSink(tmp_path, clock=lambda: fake["now"])
|
|
sink.emit('{"a":1}')
|
|
fake["now"] = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
|
sink.emit('{"b":2}')
|
|
sink.close()
|
|
may = (tmp_path / "2026" / "2026-05.jsonl").read_text(encoding="utf-8")
|
|
jun = (tmp_path / "2026" / "2026-06.jsonl").read_text(encoding="utf-8")
|
|
assert may.splitlines() == ['{"a":1}']
|
|
assert jun.splitlines() == ['{"b":2}']
|
|
|
|
|
|
def test_monthly_sink_append_only_existing_lines_preserved(tmp_path: Path) -> None:
|
|
target = tmp_path / "2026" / "2026-05.jsonl"
|
|
target.parent.mkdir(parents=True)
|
|
target.write_text('{"x":0}\n', encoding="utf-8")
|
|
fixed = datetime(2026, 5, 18, tzinfo=timezone.utc)
|
|
sink = DiscoveryMonthlyFileSink(tmp_path, clock=lambda: fixed)
|
|
sink.emit('{"y":1}')
|
|
sink.close()
|
|
lines = target.read_text(encoding="utf-8").splitlines()
|
|
assert lines == ['{"x":0}', '{"y":1}']
|
|
|
|
|
|
def test_monthly_sink_context_manager_closes(tmp_path: Path) -> None:
|
|
fixed = datetime(2026, 5, 18, tzinfo=timezone.utc)
|
|
with DiscoveryMonthlyFileSink(tmp_path, clock=lambda: fixed) as sink:
|
|
sink.emit('{"a":1}')
|
|
# After exit, internal file handle is closed.
|
|
assert sink._fh is None # type: ignore[attr-defined]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Doctrine — no corpus mutation, no runtime state mutation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_pure_extract_does_not_load_or_mutate_runtime_state() -> None:
|
|
"""Calling the extractor repeatedly must not change pack or
|
|
corpus internal state — a smoke test that the extractor is
|
|
a pure read."""
|
|
from chat.pack_grounding import _pack_index
|
|
from chat.teaching_grounding import _corpus_index
|
|
|
|
pack_before = dict(_pack_index())
|
|
corpus_before = dict(_corpus_index())
|
|
for _ in range(5):
|
|
extract_discovery_candidates(_TE(), IntentTag.CAUSE, "judgment")
|
|
pack_after = dict(_pack_index())
|
|
corpus_after = dict(_corpus_index())
|
|
assert pack_before.keys() == pack_after.keys()
|
|
assert corpus_before.keys() == corpus_after.keys()
|