Mirrors the chain-gap pipeline (Phase 1.1+1.2) for vocabulary gaps:
the OOV invitation surface (P2.1) now emits structured signals that
operators can aggregate, rank, and auto-promote into reviewed
PackMutationProposal candidates — closing the OOV loop the same way
Phase 1 closed the chain loop.
Three new modules + two new CLI surfaces:
teaching/oov_sink.py.
OOVCandidate dataclass mirroring teaching.discovery.DiscoveryCandidate.
OOVBufferSink (in-memory) + OOVMonthlyFileSink (append-only JSONL
under <root>/<YYYY>/<YYYY-MM>.jsonl — same layout as discovery sink
so the aggregator reuses the file-walk machinery).
hash_oov_candidate_id(token, intent, trace_hash) — deterministic
32-char hex id matching DiscoveryCandidate's replay invariant.
format_oov_candidate_jsonl — sorted-keys compact JSONL line.
teaching/oov_gaps.py.
aggregate_oov_gaps(root, since, sample_limit) groups emitted
candidates by token, tracks intent-shape union (a token asked under
multiple intents is a stronger curriculum signal), splits
boundary_clean from boundary_tainted counts, supports --since
YYYY-MM filtering via the sink's file naming convention.
Pure reader; never mutates the sink. Deterministic ordering:
(count desc, token asc).
teaching/oov_promotion.py.
promote_oov_gaps(gaps, threshold, include_tainted, suggested_packs)
lifts threshold-crossing tokens to OOVPromotion records.
- boundary_clean_count gates promotion by default (tainted-only
tokens may indicate the prompt hit a safety axis rather than a
vocab gap).
- --include-tainted flag for operator override.
- threshold < 1 raises.
- queue_id deterministic: ``oov:<token>@<threshold>`` — diffable
across runs.
- suggested_packs lists mounted packs but does NOT recommend one
— domain inference is out of scope (would require a stochastic
classifier). Operator picks the destination.
Runtime wiring:
ChatRuntime.attach_oov_sink(sink) mirrors attach_discovery_sink.
Runtime emits one OOVCandidate JSONL line per turn whose
grounding_source == "oov", no-op when no sink is attached.
Intent classifier is now invoked when EITHER sink is attached
(was: only discovery sink) — both downstream paths need it.
CLI:
core teaching oov-gaps [--top N] [--since YYYY-MM] [--root PATH]
[--sample-limit N] [--json]
core teaching oov-queue [--threshold N] [--include-tainted]
[--root PATH] [--since YYYY-MM] [--json]
ADR-0065 documents the full design (five-tier honesty gradient,
P2.1-P2.4 architecture). README.md updated with the ADR-0065
index entry.
Verification:
tests/test_oov_pipeline.py 24 passed
Operator workflow round-trip verified live:
> rt.attach_oov_sink(sink); rt.chat("What is photosynthesis?")
→ sink receives:
{"boundary_clean":true,"candidate_id":"f51bf8...",
"intent":"definition","token":"photosynthesis","trigger":"unresolved_subject",
"source_turn_trace":"","review_state":"unreviewed"}
> core teaching oov-gaps --root /tmp/oov_demo
→ ranked table by count, intent-set per token
> core teaching oov-queue --root /tmp/oov_demo --threshold 2
→ promoted tokens + suggested mounted packs
Full lane: 2005 passed, 2 skipped, 0 failed in 2:34 (xdist).
160 lines
5 KiB
Python
160 lines
5 KiB
Python
"""teaching/oov_sink.py — Phase 2.3 emission for OOV "teach me" turns.
|
|
|
|
Mirrors :mod:`teaching.discovery_sink`. When the runtime emits a P2.1
|
|
OOV invitation surface (``grounding_source="oov"``), it forwards a
|
|
structured :class:`OOVCandidate` JSONL line to the attached sink so
|
|
the operator's aggregation tooling can rank vocabulary gaps the same
|
|
way discovery candidates surface chain gaps.
|
|
|
|
Trust boundary:
|
|
|
|
- Append-only. No truncation, no rewrite. Each ``emit()`` flushes
|
|
so a crashed runtime keeps its prior OOV signals durable on disk.
|
|
- Sink errors are NOT swallowed — fail-fast contract matches
|
|
discovery and telemetry sinks.
|
|
- The sink receives a sanitised candidate (the token has already
|
|
passed through ``core._safe_display.safe_display`` at the runtime
|
|
boundary before any persistence).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import IO, Callable, Literal, Protocol
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class OOVCandidate:
|
|
"""Structured evidence that a turn hit an OOV token.
|
|
|
|
Fields parallel :class:`teaching.discovery.DiscoveryCandidate`
|
|
but the schema is OOV-specific. ``trigger="unresolved_subject"``
|
|
is the only v1 trigger; future Phase 2 work can add others
|
|
(e.g. ``"unresolved_secondary_subject"`` for partial-grounding
|
|
sinks).
|
|
"""
|
|
|
|
candidate_id: str
|
|
token: str
|
|
intent: Literal[
|
|
"definition", "recall", "cause", "verification",
|
|
"comparison", "procedure", "correction",
|
|
]
|
|
trigger: Literal["unresolved_subject"]
|
|
source_turn_trace: str
|
|
boundary_clean: bool
|
|
review_state: Literal["unreviewed"] = "unreviewed"
|
|
|
|
def as_dict(self) -> dict[str, object]:
|
|
return {
|
|
"candidate_id": self.candidate_id,
|
|
"token": self.token,
|
|
"intent": self.intent,
|
|
"trigger": self.trigger,
|
|
"source_turn_trace": self.source_turn_trace,
|
|
"boundary_clean": self.boundary_clean,
|
|
"review_state": self.review_state,
|
|
}
|
|
|
|
|
|
def hash_oov_candidate_id(token: str, intent: str, trace_hash: str) -> str:
|
|
"""Deterministic 32-char hex id for an OOV candidate.
|
|
|
|
Identical ``(token, intent, trace_hash)`` always produces the
|
|
identical id — the load-bearing replay property analogous to
|
|
:func:`teaching.discovery._hash_candidate_id`.
|
|
"""
|
|
payload = json.dumps(
|
|
{"token": token, "intent": intent, "source_turn_trace": trace_hash},
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
)
|
|
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:32]
|
|
|
|
|
|
def format_oov_candidate_jsonl(candidate: OOVCandidate) -> str:
|
|
"""Render a candidate as one canonical JSONL line."""
|
|
return json.dumps(candidate.as_dict(), sort_keys=True, separators=(",", ":"))
|
|
|
|
|
|
class OOVCandidateSink(Protocol):
|
|
"""Minimal sink contract — one JSONL line per emission."""
|
|
|
|
def emit(self, line: str) -> None: ...
|
|
|
|
|
|
@dataclass
|
|
class OOVBufferSink:
|
|
"""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 OOVMonthlyFileSink:
|
|
"""Append-only JSONL sink with monthly rollover.
|
|
|
|
Path is computed at each ``emit()`` from the injected clock as
|
|
``<root>/<YYYY>/<YYYY-MM>.jsonl``. Same on-disk shape as
|
|
:class:`teaching.discovery_sink.DiscoveryMonthlyFileSink` so the
|
|
aggregator can reuse the file-walk machinery.
|
|
"""
|
|
|
|
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) -> "OOVMonthlyFileSink":
|
|
return self
|
|
|
|
def __exit__(self, *exc_info) -> None:
|
|
self.close()
|
|
|
|
|
|
__all__ = [
|
|
"OOVCandidate",
|
|
"OOVCandidateSink",
|
|
"OOVBufferSink",
|
|
"OOVMonthlyFileSink",
|
|
"format_oov_candidate_jsonl",
|
|
"hash_oov_candidate_id",
|
|
]
|