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).
119 lines
4.1 KiB
Python
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"]
|