core/teaching/discovery_sink.py
Shay 07d35c0f54 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.
2026-05-18 08:26:04 -07:00

105 lines
3.2 KiB
Python

"""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",
]