feat(adr-0055): Phase B — DiscoveryCandidate emission from turn loop

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.
This commit is contained in:
Shay 2026-05-18 08:26:04 -07:00
parent 7aa77806f9
commit 07d35c0f54
5 changed files with 815 additions and 2 deletions

View file

@ -5,7 +5,7 @@ import hashlib
import json import json
import re import re
from collections.abc import Sequence from collections.abc import Sequence
from typing import List from typing import Any, List
import numpy as np import numpy as np
@ -28,6 +28,11 @@ from chat.refusal import (
) )
from chat.telemetry import TurnEventSink, format_turn_event_jsonl from chat.telemetry import TurnEventSink, format_turn_event_jsonl
from chat.verdicts import TurnVerdicts from chat.verdicts import TurnVerdicts
from teaching.discovery import (
extract_discovery_candidates,
format_candidate_jsonl,
)
from teaching.discovery_sink import DiscoveryCandidateSink
from core.config import DEFAULT_CONFIG, DEFAULT_IDENTITY_PACK, RuntimeConfig from core.config import DEFAULT_CONFIG, DEFAULT_IDENTITY_PACK, RuntimeConfig
from core.physics.drive import DriveGradientMap, GradientField from core.physics.drive import DriveGradientMap, GradientField
from core.physics.energy import EnergyProfile from core.physics.energy import EnergyProfile
@ -408,6 +413,11 @@ class ChatRuntime:
# trust boundary. # trust boundary.
self._telemetry_sink: TurnEventSink | None = None self._telemetry_sink: TurnEventSink | None = None
self._telemetry_include_content: bool = False self._telemetry_include_content: bool = False
# ADR-0055 Phase B — opt-in DiscoveryCandidate sink. Default
# None preserves prior behavior; callers attach via
# ``attach_discovery_sink``. Candidates are *evidence*, never
# mutate the corpus or runtime state.
self._discovery_sink: DiscoveryCandidateSink | None = None
self._correction_pass = CorrectionPass() self._correction_pass = CorrectionPass()
self._last_valence: float = 0.0 self._last_valence: float = 0.0
@ -435,6 +445,44 @@ class ChatRuntime:
self._telemetry_sink = sink self._telemetry_sink = sink
self._telemetry_include_content = bool(include_content) self._telemetry_include_content = bool(include_content)
def attach_discovery_sink(
self,
sink: DiscoveryCandidateSink | None,
) -> None:
"""ADR-0055 Phase B — attach a DiscoveryCandidate sink.
After each turn, the runtime extracts zero-or-more candidates
from the most recent ``TurnEvent`` (deterministic rule firing
on the audit trail) and forwards each as one JSONL line.
Passing ``None`` detaches.
Candidates are **evidence**: emission never mutates the
active teaching corpus. Phase C's ``TeachingChainProposal``
is the only path to corpus extension and runs through
review + replay.
"""
self._discovery_sink = sink
def _emit_discovery_candidates(
self,
*,
turn_event: TurnEvent,
intent_tag: Any,
intent_subject: str | None,
grounding_source: str | None,
) -> None:
sink = self._discovery_sink
if sink is None:
return
candidates = extract_discovery_candidates(
turn_event,
intent_tag,
intent_subject,
grounding_source=grounding_source,
)
for candidate in candidates:
sink.emit(format_candidate_jsonl(candidate))
def _emit_turn_event(self, event: TurnEvent) -> None: def _emit_turn_event(self, event: TurnEvent) -> None:
"""Internal — emit one serialised line for the current event. """Internal — emit one serialised line for the current event.
@ -612,6 +660,8 @@ class ChatRuntime:
tokens: tuple[str, ...] = (), tokens: tuple[str, ...] = (),
pack_grounded_surface: str | None = None, pack_grounded_surface: str | None = None,
grounded_source_tag: str = "pack", grounded_source_tag: str = "pack",
discovery_intent_tag: Any = None,
discovery_intent_subject: str | None = None,
) -> ChatResponse: ) -> ChatResponse:
zero = np.zeros(field_state.F.shape, dtype=np.float32) zero = np.zeros(field_state.F.shape, dtype=np.float32)
prop = Proposition( prop = Proposition(
@ -723,6 +773,17 @@ class ChatRuntime:
) )
self.turn_log.append(stub_event) self.turn_log.append(stub_event)
self._emit_turn_event(stub_event) self._emit_turn_event(stub_event)
# ADR-0055 Phase B — opt-in discovery candidate emission.
# Only meaningful when the caller threads classified
# intent forward (gate-fire / fall-through site). Pure
# rule firing on the just-appended TurnEvent.
if discovery_intent_tag is not None:
self._emit_discovery_candidates(
turn_event=stub_event,
intent_tag=discovery_intent_tag,
intent_subject=discovery_intent_subject,
grounding_source=grounding_source,
)
return ChatResponse( return ChatResponse(
surface=response_surface, surface=response_surface,
proposition=prop, proposition=prop,
@ -793,11 +854,30 @@ class ChatRuntime:
"grounding_source": pack_source_tag if pack_surface else "none", "grounding_source": pack_source_tag if pack_surface else "none",
}, },
) )
# ADR-0055 Phase B — thread classified intent forward only
# when a sink is attached. Discovery emission is opt-in;
# the deterministic classification used here is the same
# call ``_maybe_pack_grounded_surface`` already ran for the
# empty-vault English path, so behaviour is identical when
# no sink is attached.
discovery_intent_tag = None
discovery_intent_subject: str | None = None
if (
self._discovery_sink is not None
and gate_decision.source == "empty_vault"
and self.config.output_language == "en"
):
from generate.intent_bridge import classify_intent_from_input
_intent = classify_intent_from_input(text)
discovery_intent_tag = _intent.tag
discovery_intent_subject = _intent.subject
return self._stub_response( return self._stub_response(
committed, committed,
tokens=tuple(filtered), tokens=tuple(filtered),
pack_grounded_surface=pack_surface, pack_grounded_surface=pack_surface,
grounded_source_tag=pack_source_tag, grounded_source_tag=pack_source_tag,
discovery_intent_tag=discovery_intent_tag,
discovery_intent_subject=discovery_intent_subject,
) )
field_state = self._context.commit_ingest(filtered) field_state = self._context.commit_ingest(filtered)

View file

@ -1,6 +1,6 @@
# ADR-0055 — Inter-Session Memory: Reviewed Discovery Promotion # ADR-0055 — Inter-Session Memory: Reviewed Discovery Promotion
**Status:** Proposed **Status:** Phase A + Phase B Accepted; Phases CE Proposed
**Date:** 2026-05-18 **Date:** 2026-05-18
**Author:** Shay **Author:** Shay

232
teaching/discovery.py Normal file
View file

@ -0,0 +1,232 @@
"""ADR-0055 Phase B — DiscoveryCandidate emission from the turn loop.
A ``DiscoveryCandidate`` is **structured evidence** never a
mutation. When a turn's audit trail satisfies a deterministic
predicate, an entry is emitted to the discovery candidate stream.
Candidates **never** load into the active teaching corpus; the only
path to corpus extension is the review-gated
``TeachingChainProposal`` (Phase C, not yet built).
Trigger set (Phase B lands the first; the others are stubbed in the
``Literal`` so the structure is stable when later phases add them):
- ``would_have_grounded`` the turn fell through to the universal
"insufficient grounding" disclosure, the classified intent was
``CAUSE`` or ``VERIFICATION``, the subject lemma is in the
ratified cognition pack, and no active chain matched
``(subject, intent)``. A reviewed chain of that subject/intent
would have grounded the turn.
- ``successful_comparison`` open question §5 in ADR-0055; not
fired in Phase B.
- ``hedge_acknowledged`` open question §5 in ADR-0055; not
fired in Phase B.
- ``oov_resolved_via_decomp`` not fired in Phase B.
Determinism contract:
- ``extract_discovery_candidates`` is a pure function of its
inputs.
- ``candidate_id`` is a SHA-256 hash of a canonical JSON encoding
of the candidate's load-bearing fields; identical inputs always
produce the identical id.
- No LLM, no stochastic sampling, no clock-time read.
Trust boundary:
- This module reads pack + corpus indices and a ``TurnEvent``.
It never writes to the corpus, the pack, or runtime state.
- The ``source_turn_trace`` is the upstream ``TurnEvent.trace_hash``
when present; absent that, the empty string. Tying every
candidate to a replayable turn is the load-bearing audit
property.
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from typing import Any, Literal
from chat.pack_grounding import _pack_index
from chat.teaching_grounding import _corpus_index
from generate.intent import IntentTag
DiscoveryTrigger = Literal[
"would_have_grounded",
"successful_comparison",
"hedge_acknowledged",
"oov_resolved_via_decomp",
]
@dataclass(frozen=True, slots=True)
class DiscoveryCandidate:
"""Structured evidence that a reviewed chain would have helped.
``proposed_chain`` is *partial* by design: Phase B can only see
that a chain of a given ``(subject, intent)`` would have grounded
the turn it cannot infer the connective or object. Phase C's
``TeachingChainProposal`` is the place where a complete proposed
entry is constructed and gated through review + replay.
"""
candidate_id: str
proposed_chain: dict[str, Any]
trigger: DiscoveryTrigger
source_turn_trace: str
pack_consistent: bool
boundary_clean: bool
review_state: Literal["unreviewed"] = "unreviewed"
def as_dict(self) -> dict[str, Any]:
return {
"candidate_id": self.candidate_id,
"proposed_chain": self.proposed_chain,
"trigger": self.trigger,
"source_turn_trace": self.source_turn_trace,
"pack_consistent": self.pack_consistent,
"boundary_clean": self.boundary_clean,
"review_state": self.review_state,
}
_TEACHING_INTENT_NAME: dict[IntentTag, str] = {
IntentTag.CAUSE: "cause",
IntentTag.VERIFICATION: "verification",
}
def _hash_candidate_id(payload: dict[str, Any]) -> str:
"""Deterministic SHA-256 over a canonical JSON encoding.
Sorted keys + tight separators keep the hash stable across
Python runtimes and dict-insertion order. This is the
``candidate_id`` used both as the on-disk JSONL line key and
by Phase C to look up the originating candidate.
"""
blob = json.dumps(payload, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
def _boundary_clean(turn_event: Any) -> bool:
"""Return True iff the source turn produced no safety/ethics
refusal and no hedge injection.
Tolerates events that pre-date the bundled-verdicts era (ADR-0039
onward) by reading the canonical fields directly.
"""
refusal_emitted = bool(getattr(turn_event, "refusal_emitted", False) or False)
hedge_injected = bool(getattr(turn_event, "hedge_injected", False) or False)
if refusal_emitted or hedge_injected:
return False
verdicts = getattr(turn_event, "verdicts", None)
if verdicts is not None:
if bool(getattr(verdicts, "refusal_emitted", False) or False):
return False
if bool(getattr(verdicts, "hedge_injected", False) or False):
return False
return True
def _trace_hash(turn_event: Any) -> str:
value = getattr(turn_event, "trace_hash", "") or ""
return str(value)
def extract_discovery_candidates(
turn_event: Any,
intent_tag: IntentTag | None,
intent_subject_lemma: str | None,
*,
grounding_source: str | None = None,
) -> tuple[DiscoveryCandidate, ...]:
"""Return zero or more DiscoveryCandidates for a single turn.
Phase B only fires the ``would_have_grounded`` trigger. All
other triggers in the ``DiscoveryTrigger`` Literal are reserved
for later phases.
Fires when **every** condition holds (deterministic predicate):
1. ``grounding_source`` is ``"none"`` or absent the turn
fell through to the universal disclosure.
2. ``intent_tag`` is ``CAUSE`` or ``VERIFICATION`` the
intent set the teaching-grounded surface answers.
3. ``intent_subject_lemma`` is a non-empty pack lemma in the
ratified cognition pack.
4. ``(subject_lemma, intent_name)`` is **not** in the active
corpus a chain of that shape would have grounded the
turn but does not exist.
Order of conditions matters for tests: short-circuit on the
cheapest predicate first.
"""
source = (grounding_source or getattr(turn_event, "grounding_source", "none") or "none").lower()
if source != "none":
return ()
if intent_tag is None or intent_tag not in _TEACHING_INTENT_NAME:
return ()
if not intent_subject_lemma or not isinstance(intent_subject_lemma, str):
return ()
lemma = intent_subject_lemma.strip().lower()
if not lemma:
return ()
pack = _pack_index()
if lemma not in pack:
return ()
intent_name = _TEACHING_INTENT_NAME[intent_tag]
if (lemma, intent_name) in _corpus_index():
return ()
# The candidate's proposed_chain is intentionally partial: Phase B
# can only assert that a chain of this (subject, intent) would
# have helped. Connective and object remain null; Phase C is
# where a complete proposed entry is constructed and review-gated.
proposed_chain = {
"subject": lemma,
"intent": intent_name,
"connective": None,
"object": None,
}
trace_hash = _trace_hash(turn_event)
boundary_clean = _boundary_clean(turn_event)
trigger: DiscoveryTrigger = "would_have_grounded"
hash_payload = {
"proposed_chain": proposed_chain,
"trigger": trigger,
"source_turn_trace": trace_hash,
}
candidate_id = _hash_candidate_id(hash_payload)
candidate = DiscoveryCandidate(
candidate_id=candidate_id,
proposed_chain=proposed_chain,
trigger=trigger,
source_turn_trace=trace_hash,
pack_consistent=True, # subject is in pack; object is null pending Phase C
boundary_clean=boundary_clean,
review_state="unreviewed",
)
return (candidate,)
def format_candidate_jsonl(candidate: DiscoveryCandidate) -> str:
"""Serialise to one JSONL line (sorted keys, no trailing newline)."""
return json.dumps(candidate.as_dict(), sort_keys=True, separators=(",", ":"))
__all__ = [
"DiscoveryCandidate",
"DiscoveryTrigger",
"extract_discovery_candidates",
"format_candidate_jsonl",
]

105
teaching/discovery_sink.py Normal file
View file

@ -0,0 +1,105 @@
"""ADR-0055 Phase B — sinks for DiscoveryCandidate emission.
Mirrors the telemetry-sink shape from ADR-0040 / ADR-0041:
- Protocol ``DiscoveryCandidateSink.emit(line)``.
- ``DiscoveryBufferSink`` in-memory, useful for tests and small-
volume audit where persistence is the caller's responsibility.
- ``DiscoveryMonthlyFileSink`` append-only JSONL with per-month
rollover under ``<root>/<YYYY>/<YYYY-MM>.jsonl``. Path computed
deterministically from an injected ``Clock`` so tests can pin
the rollover instant without monkey-patching ``datetime``.
Trust boundary:
- Append-only. No truncation, no rewrite. Each ``emit()`` flushes
so a crashed runtime keeps its prior candidates durable on disk.
- Sink errors are NOT swallowed by the runtime Phase B keeps
telemetry's fail-fast contract.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import IO, Callable, Protocol
class DiscoveryCandidateSink(Protocol):
"""Minimal sink contract — one JSONL line per emission."""
def emit(self, line: str) -> None: ...
@dataclass
class DiscoveryBufferSink:
"""In-memory sink that captures every emitted candidate line."""
lines: list[str] = field(default_factory=list)
def emit(self, line: str) -> None:
self.lines.append(line)
Clock = Callable[[], datetime]
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
class DiscoveryMonthlyFileSink:
"""Append-only JSONL sink with monthly rollover.
Path is computed at each ``emit()`` from the injected clock as
``<root>/<YYYY>/<YYYY-MM>.jsonl``. The file handle is reopened
when the month rolls over; the previous month's file is closed
cleanly.
The clock is injected (default UTC) so tests can pin the
rollover instant without monkey-patching ``datetime``.
"""
def __init__(self, root: str | Path, *, clock: Clock = _utc_now) -> None:
self._root = Path(root)
self._clock = clock
self._fh: IO[str] | None = None
self._current_path: Path | None = None
def _path_for_now(self) -> Path:
now = self._clock()
return self._root / f"{now.year:04d}" / f"{now.year:04d}-{now.month:02d}.jsonl"
def emit(self, line: str) -> None:
target = self._path_for_now()
if target != self._current_path:
if self._fh is not None:
self._fh.close()
self._fh = None
target.parent.mkdir(parents=True, exist_ok=True)
self._fh = target.open("a", encoding="utf-8")
self._current_path = target
assert self._fh is not None
self._fh.write(line)
self._fh.write("\n")
self._fh.flush()
def close(self) -> None:
if self._fh is not None:
self._fh.close()
self._fh = None
self._current_path = None
def __enter__(self) -> "DiscoveryMonthlyFileSink":
return self
def __exit__(self, *exc_info) -> None:
self.close()
__all__ = [
"DiscoveryCandidateSink",
"DiscoveryBufferSink",
"DiscoveryMonthlyFileSink",
]

View file

@ -0,0 +1,396 @@
"""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 + CAUSEordered_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()