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).
170 lines
5.7 KiB
Python
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"]
|