core/teaching/oov_promotion.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

119 lines
4.1 KiB
Python

"""teaching/oov_promotion.py — Phase 2.3: auto-promote high-frequency
OOV tokens to operator-visible PackMutationProposal candidates.
Sibling to :mod:`teaching.promotion`. Where chain-gap promotion says
"author a chain for this (subject, intent) cell", OOV promotion says
"add this token to a lexicon pack".
Trust boundary — same as :mod:`teaching.promotion`:
- Pure derivation from :class:`OOVGap` records. No persistent
queue. Re-running ``promote_oov_gaps`` on the same sink contents
produces the same result deterministically.
- **No domain inference.** The promotion does NOT recommend a
target pack — that would require a stochastic classifier. It
surfaces the mounted-pack list and lets the operator decide
which pack the token belongs in.
- The ratified-pack-mutation path (ADR-0027 + ADR-0033 +
:mod:`teaching.proposals`) is the only way an OOV promotion
becomes a real pack change. Auto-promotion never writes a
pack file directly.
- Boundary-clean filter on by default (matches
:func:`teaching.promotion.promote_gaps`).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable
from chat.pack_resolver import DEFAULT_RESOLVABLE_PACK_IDS
from teaching.oov_gaps import OOVGap
@dataclass(frozen=True, slots=True)
class OOVPromotion:
"""An OOV token whose emission count met the threshold.
Operator surface signal: "this vocabulary item has been asked
about N times across these intent shapes; add it to one of the
mounted packs."
"""
token: str
intents: tuple[str, ...]
count: int
boundary_clean_count: int
sample_candidate_ids: tuple[str, ...]
months_seen: tuple[str, ...]
threshold: int
suggested_packs: tuple[str, ...]
@property
def queue_id(self) -> str:
"""Stable, deterministic identifier — diffable across runs."""
return f"oov:{self.token}@{self.threshold}"
def as_dict(self) -> dict[str, object]:
return {
"queue_id": self.queue_id,
"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),
"threshold": self.threshold,
"suggested_packs": list(self.suggested_packs),
}
def promote_oov_gaps(
gaps: Iterable[OOVGap],
*,
threshold: int = 3,
include_tainted: bool = False,
suggested_packs: tuple[str, ...] = DEFAULT_RESOLVABLE_PACK_IDS,
) -> tuple[OOVPromotion, ...]:
"""Return the subset of *gaps* whose effective count meets *threshold*.
Effective count:
- ``include_tainted=False`` (default): boundary_clean_count gates
the promotion.
- ``include_tainted=True``: every emission counts.
``suggested_packs`` is the list of mounted-pack ids that operators
can mutate via the reviewed-proposal path. Defaults to the
cross-pack resolver's mounted set; operators can pass a narrower
list when they want the queue surface to recommend a subset.
"""
if threshold < 1:
raise ValueError(f"threshold must be >= 1 (got {threshold!r})")
promoted: list[OOVPromotion] = []
for gap in gaps:
effective_count = gap.count if include_tainted else gap.boundary_clean_count
if effective_count < threshold:
continue
promoted.append(
OOVPromotion(
token=gap.token,
intents=gap.intents,
count=gap.count,
boundary_clean_count=gap.boundary_clean_count,
sample_candidate_ids=gap.sample_candidate_ids,
months_seen=gap.months_seen,
threshold=threshold,
suggested_packs=suggested_packs,
)
)
promoted.sort(
key=lambda p: (
-(p.count if include_tainted else p.boundary_clean_count),
p.token,
)
)
return tuple(promoted)
__all__ = ["OOVPromotion", "promote_oov_gaps"]