core/teaching/oov_gaps.py
Shay ea298bdc28 feat(teaching): OOV signal flywheel — sink, aggregator, auto-promotion (Phase 2.3)
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).
2026-05-18 16:42:26 -07:00

170 lines
5.7 KiB
Python

"""teaching/oov_gaps.py — Phase 2.3: aggregate emitted OOVCandidates
into a ranked view of unknown tokens.
Sibling to :mod:`teaching.gaps`. Where discovery candidates point at
gaps in the *teaching corpus* (a chain would have helped), OOV
candidates point at gaps in the *lexicon* (a vocabulary entry would
have helped). Both flow through the same operator workflow: rank
by frequency, auto-promote at threshold, surface to an operator who
authors a reviewed mutation.
Design constraints (matching :mod:`teaching.gaps`):
- Pure reader. No mutation of any sink file.
- Deterministic ordering: highest-count tokens first, ties broken
by token then intent set.
- Date filtering via the sink's file naming convention
(``<YYYY>/<YYYY-MM>.jsonl``) — month-level granularity.
- Malformed lines are skipped silently.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
_DEFAULT_ROOT: Path = Path(__file__).resolve().parent / "oov_log"
_MONTH_FILE_RE = re.compile(r"^(\d{4})-(\d{2})\.jsonl$")
_MONTH_TOKEN_RE = re.compile(r"^(\d{4})-(\d{2})$")
@dataclass(frozen=True, slots=True)
class OOVGap:
"""One aggregated OOV token.
Fields:
- ``token``: the unknown vocabulary item (lower-case).
- ``intents``: sorted tuple of intent shapes that hit this
token at least once. A token asked about under multiple
intent shapes is a stronger curriculum signal than one asked
only via ``DEFINITION``.
- ``count``: total emissions.
- ``boundary_clean_count``: subset whose ``boundary_clean=True``.
- ``sample_candidate_ids``: up to N retained ids.
- ``months_seen``: sorted ``YYYY-MM`` months.
"""
token: str
intents: tuple[str, ...]
count: int
boundary_clean_count: int
sample_candidate_ids: tuple[str, ...]
months_seen: tuple[str, ...]
def as_dict(self) -> dict[str, object]:
return {
"token": self.token,
"intents": list(self.intents),
"count": self.count,
"boundary_clean_count": self.boundary_clean_count,
"sample_candidate_ids": list(self.sample_candidate_ids),
"months_seen": list(self.months_seen),
}
def _normalise_since(since: str | None) -> tuple[int, int] | None:
if since is None:
return None
match = _MONTH_TOKEN_RE.match(since.strip())
if not match:
raise ValueError(
f"--since {since!r} is not a YYYY-MM token (e.g. '2026-05')"
)
return int(match.group(1)), int(match.group(2))
def _iter_candidate_files(
root: Path, *, since: tuple[int, int] | None
) -> Iterable[tuple[str, Path]]:
if not root.exists() or not root.is_dir():
return
for path in sorted(root.rglob("*.jsonl")):
m = _MONTH_FILE_RE.match(path.name)
if not m:
continue
year = int(m.group(1))
month = int(m.group(2))
if since is not None and (year, month) < since:
continue
yield f"{year:04d}-{month:02d}", path
def aggregate_oov_gaps(
root: Path = _DEFAULT_ROOT,
*,
since: str | None = None,
sample_limit: int = 5,
) -> tuple[OOVGap, ...]:
"""Aggregate every emitted ``OOVCandidate`` under *root* into a
ranked tuple of :class:`OOVGap` records.
Returned tuple is sorted by ``(count desc, token asc)`` so
identical inputs produce identical orderings.
"""
since_tuple = _normalise_since(since)
counts: dict[str, int] = {}
clean_counts: dict[str, int] = {}
samples: dict[str, list[str]] = {}
months: dict[str, set[str]] = {}
intents_by_token: dict[str, set[str]] = {}
for month_token, path in _iter_candidate_files(root, since=since_tuple):
try:
text = path.read_text(encoding="utf-8")
except OSError:
continue
for line in text.splitlines():
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(entry, dict):
continue
token = entry.get("token")
intent = entry.get("intent")
if not isinstance(token, str) or not isinstance(intent, str):
continue
token = token.strip().lower()
intent = intent.strip().lower()
if not token or not intent:
continue
counts[token] = counts.get(token, 0) + 1
if entry.get("boundary_clean") is True:
clean_counts[token] = clean_counts.get(token, 0) + 1
intents_by_token.setdefault(token, set()).add(intent)
sample_list = samples.setdefault(token, [])
candidate_id = entry.get("candidate_id")
if (
isinstance(candidate_id, str)
and candidate_id
and len(sample_list) < sample_limit
and candidate_id not in sample_list
):
sample_list.append(candidate_id)
months.setdefault(token, set()).add(month_token)
rows: list[OOVGap] = []
for token, total in counts.items():
rows.append(
OOVGap(
token=token,
intents=tuple(sorted(intents_by_token.get(token, ()))),
count=total,
boundary_clean_count=clean_counts.get(token, 0),
sample_candidate_ids=tuple(sorted(samples.get(token, ()))),
months_seen=tuple(sorted(months.get(token, ()))),
)
)
rows.sort(key=lambda g: (-g.count, g.token))
return tuple(rows)
__all__ = ["OOVGap", "aggregate_oov_gaps"]