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).
This commit is contained in:
parent
a435411be5
commit
ea298bdc28
7 changed files with 1134 additions and 0 deletions
163
core/cli.py
163
core/cli.py
|
|
@ -571,6 +571,119 @@ def cmd_teaching_gaps(args: argparse.Namespace) -> int:
|
|||
return 0
|
||||
|
||||
|
||||
def cmd_teaching_oov_gaps(args: argparse.Namespace) -> int:
|
||||
"""Phase 2.3 — rank OOV tokens emitted by the runtime's
|
||||
OOV "teach me" surface.
|
||||
|
||||
Reads JSONL files written by
|
||||
:class:`teaching.oov_sink.OOVMonthlyFileSink` under *root*
|
||||
(default ``teaching/oov_log``) and emits a ranked table of
|
||||
tokens ordered by emission count.
|
||||
|
||||
Pure read — never mutates the sink.
|
||||
"""
|
||||
from teaching.oov_gaps import _DEFAULT_ROOT, aggregate_oov_gaps
|
||||
|
||||
root = Path(args.root) if args.root else _DEFAULT_ROOT
|
||||
try:
|
||||
rows = aggregate_oov_gaps(
|
||||
root=root,
|
||||
since=args.since,
|
||||
sample_limit=max(1, int(args.sample_limit)),
|
||||
)
|
||||
except ValueError as exc:
|
||||
_die(str(exc), code=2)
|
||||
|
||||
if args.top is not None and args.top > 0:
|
||||
rows = rows[: args.top]
|
||||
|
||||
if args.json:
|
||||
payload = {
|
||||
"root": str(root),
|
||||
"since": args.since,
|
||||
"total_tokens": len(rows),
|
||||
"oov_gaps": [g.as_dict() for g in rows],
|
||||
}
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
|
||||
return 0 if rows else 1
|
||||
|
||||
if not rows:
|
||||
print("No OOV candidates found.")
|
||||
if root is not None and not root.exists():
|
||||
print(f" (root path does not exist: {root})")
|
||||
return 1
|
||||
|
||||
print(f"{'rank':>4} {'token':<28}{'count':>6} {'clean':>6} intents")
|
||||
print("-" * 80)
|
||||
for i, gap in enumerate(rows, 1):
|
||||
intents = ",".join(gap.intents) if gap.intents else "—"
|
||||
print(
|
||||
f"{i:>4} {gap.token[:28]:<28}{gap.count:>6} "
|
||||
f"{gap.boundary_clean_count:>6} {intents}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_teaching_oov_queue(args: argparse.Namespace) -> int:
|
||||
"""Phase 2.3 — show the auto-promoted OOV-token queue.
|
||||
|
||||
Same shape as ``core teaching queue`` but for vocabulary gaps:
|
||||
tokens whose boundary-clean emission count meets ``--threshold``
|
||||
are surfaced as PackMutationProposal candidates that an operator
|
||||
can author via the reviewed ADR-0027 path.
|
||||
|
||||
Never auto-mutates a pack — operator-visible signal only.
|
||||
"""
|
||||
from teaching.oov_gaps import _DEFAULT_ROOT, aggregate_oov_gaps
|
||||
from teaching.oov_promotion import promote_oov_gaps
|
||||
|
||||
root = Path(args.root) if args.root else _DEFAULT_ROOT
|
||||
try:
|
||||
gaps = aggregate_oov_gaps(root=root, since=args.since, sample_limit=5)
|
||||
except ValueError as exc:
|
||||
_die(str(exc), code=2)
|
||||
|
||||
if args.threshold < 1:
|
||||
_die(f"--threshold must be >= 1 (got {args.threshold})", code=2)
|
||||
|
||||
promoted = promote_oov_gaps(
|
||||
gaps,
|
||||
threshold=args.threshold,
|
||||
include_tainted=args.include_tainted,
|
||||
)
|
||||
|
||||
if args.json:
|
||||
payload = {
|
||||
"root": str(root),
|
||||
"since": args.since,
|
||||
"threshold": args.threshold,
|
||||
"include_tainted": args.include_tainted,
|
||||
"total_promoted": len(promoted),
|
||||
"queue": [p.as_dict() for p in promoted],
|
||||
}
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
|
||||
return 0 if promoted else 1
|
||||
|
||||
if not promoted:
|
||||
print(f"No OOV tokens met threshold {args.threshold}.")
|
||||
return 1
|
||||
|
||||
print(f"{'rank':>4} {'queue_id':<40}{'count':>6} {'clean':>6} intents")
|
||||
print("-" * 96)
|
||||
for i, p in enumerate(promoted, 1):
|
||||
intents = ",".join(p.intents) if p.intents else "—"
|
||||
print(
|
||||
f"{i:>4} {p.queue_id[:40]:<40}{p.count:>6} "
|
||||
f"{p.boundary_clean_count:>6} {intents}"
|
||||
)
|
||||
print()
|
||||
print(
|
||||
f"Add each token to one of: {', '.join(promoted[0].suggested_packs)}. "
|
||||
f"Use a reviewed PackMutationProposal — never auto-applies."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_teaching_queue(args: argparse.Namespace) -> int:
|
||||
"""Phase 1.2 — show the auto-promoted gap queue.
|
||||
|
||||
|
|
@ -1966,6 +2079,56 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
)
|
||||
teaching_audit.set_defaults(func=cmd_teaching_audit)
|
||||
|
||||
teaching_oov_gaps = teaching_sub.add_parser(
|
||||
"oov-gaps",
|
||||
help="rank OOV tokens emitted by the runtime's teach-me surface",
|
||||
)
|
||||
teaching_oov_gaps.add_argument(
|
||||
"--root", default=None,
|
||||
help="OOV-sink root (default: teaching/oov_log)",
|
||||
)
|
||||
teaching_oov_gaps.add_argument(
|
||||
"--since", default=None,
|
||||
help="lower-bound month token YYYY-MM",
|
||||
)
|
||||
teaching_oov_gaps.add_argument(
|
||||
"--top", type=int, default=None,
|
||||
help="show only the top N tokens by emission count",
|
||||
)
|
||||
teaching_oov_gaps.add_argument(
|
||||
"--sample-limit", type=int, default=5,
|
||||
help="max candidate_ids retained per token as samples (default: 5)",
|
||||
)
|
||||
teaching_oov_gaps.add_argument(
|
||||
"--json", action="store_true", help="machine-readable output",
|
||||
)
|
||||
teaching_oov_gaps.set_defaults(func=cmd_teaching_oov_gaps)
|
||||
|
||||
teaching_oov_queue = teaching_sub.add_parser(
|
||||
"oov-queue",
|
||||
help="show auto-promoted OOV-token queue (tokens crossing --threshold)",
|
||||
)
|
||||
teaching_oov_queue.add_argument(
|
||||
"--root", default=None,
|
||||
help="OOV-sink root (default: teaching/oov_log)",
|
||||
)
|
||||
teaching_oov_queue.add_argument(
|
||||
"--since", default=None,
|
||||
help="lower-bound month token YYYY-MM",
|
||||
)
|
||||
teaching_oov_queue.add_argument(
|
||||
"--threshold", type=int, default=3,
|
||||
help="minimum (boundary-clean) emissions to promote (default: 3)",
|
||||
)
|
||||
teaching_oov_queue.add_argument(
|
||||
"--include-tainted", action="store_true",
|
||||
help="count refusal/hedge-tainted emissions toward the threshold",
|
||||
)
|
||||
teaching_oov_queue.add_argument(
|
||||
"--json", action="store_true", help="machine-readable output",
|
||||
)
|
||||
teaching_oov_queue.set_defaults(func=cmd_teaching_oov_queue)
|
||||
|
||||
teaching_queue = teaching_sub.add_parser(
|
||||
"queue",
|
||||
help="show auto-promoted high-priority gaps (cells crossing --threshold)",
|
||||
|
|
|
|||
242
docs/decisions/ADR-0065-oov-gradient-and-relations-v2.md
Normal file
242
docs/decisions/ADR-0065-oov-gradient-and-relations-v2.md
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
# ADR-0065 — OOV gradient + relations v2 (Plan Phase 2)
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-05-18
|
||||
**Author:** Shay
|
||||
**Phase:** Plan Phase 2 (OOV cliff → gradient)
|
||||
**Builds on:** ADR-0048 / ADR-0050 / ADR-0052 / ADR-0061 / ADR-0063 / ADR-0064
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Phase 1 closed the corpus flywheel: discovery candidates aggregate
|
||||
into operator-visible signals; the relations pack joined the live
|
||||
runtime; cross-pack teaching corpora register and surface
|
||||
deterministically.
|
||||
|
||||
But the **vocabulary** layer was still a cliff. When the runtime
|
||||
saw a token it didn't know — `photosynthesis`, `mitochondria`,
|
||||
`grandparent` — every cold-start prompt fell through to the flat
|
||||
universal disclosure:
|
||||
|
||||
```
|
||||
I don't know — insufficient grounding for that yet.
|
||||
```
|
||||
|
||||
That surface was honest but flat. It conveyed no signal that a
|
||||
*specific* vocabulary gap was hit, offered the operator no concrete
|
||||
next step, and dropped the gap on the floor — no aggregation, no
|
||||
queue, no path from "system saw an unknown" to "operator can act
|
||||
on it".
|
||||
|
||||
Phase 2 converts the OOV cliff into a five-tier gradient and closes
|
||||
the OOV signal into the same flywheel the chain-gap signal closed
|
||||
in Phase 1.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. Three new surface tiers (P2.1, P2.2)
|
||||
|
||||
The runtime's surface composer now has five honesty tiers, ordered
|
||||
by available evidence:
|
||||
|
||||
| Tier | grounding_source | Example surface |
|
||||
|---|---|---|
|
||||
| Vault | `vault` | Walk path, session-grounded |
|
||||
| Reviewed corpus | `teaching` | `light reveals truth (cognition.truth).` |
|
||||
| Reviewed lexicon | `pack` | `light — pack-grounded (en_core_cognition_v1): cognition.illumination; logos.core.` |
|
||||
| **Partial** *(new, P2.2)* | `partial` | `Whatever 'photosynthesis' is, I can ground 'knowledge' — pack-grounded (en_core_cognition_v1): ...` |
|
||||
| **OOV invitation** *(new, P2.1)* | `oov` | `I haven't learned 'photosynthesis' yet (intent: definition). Mounted lexicon packs: ... . Teach me via a reviewed PackMutationProposal.` |
|
||||
| Universal disclosure | `none` | `I don't know — insufficient grounding for that yet.` |
|
||||
|
||||
The new tiers are *honest gradients*, not synthesized content. Every
|
||||
visible token in `partial` and `oov` surfaces is either a verbatim
|
||||
lexicon atom (known side), the safely-displayed user input (OOV
|
||||
side), or a fixed-template instruction. **No vocabulary is invented.**
|
||||
**No domain is inferred.**
|
||||
|
||||
### 2. New modules
|
||||
|
||||
- `chat/oov_surface.py` — `oov_learning_invitation_surface(token,
|
||||
intent_tag, pack_ids)`. Returns the OOV surface or `None` (caller
|
||||
routes to universal disclosure).
|
||||
- `chat/partial_surface.py` — `partial_comparison_surface(a, b,
|
||||
pack_ids)`. Returns `(surface, known_side)` when exactly one of
|
||||
the two compared lemmas resolves, else `None`.
|
||||
- `teaching/oov_sink.py` — `OOVCandidate` + `OOVBufferSink` +
|
||||
`OOVMonthlyFileSink`. Same on-disk shape as the discovery sink.
|
||||
- `teaching/oov_gaps.py` — `aggregate_oov_gaps(root, since,
|
||||
sample_limit) → tuple[OOVGap, ...]`. Pure reader over the OOV
|
||||
sink layout.
|
||||
- `teaching/oov_promotion.py` — `promote_oov_gaps(gaps, threshold,
|
||||
include_tainted, suggested_packs) → tuple[OOVPromotion, ...]`.
|
||||
|
||||
### 3. Runtime wiring
|
||||
|
||||
`chat/runtime.py:_maybe_pack_grounded_surface` was refactored so
|
||||
every existing intent branch *falls through* on a `None` composer
|
||||
result instead of early-returning `None`. The OOV invitation
|
||||
becomes the deterministic fall-through for any clean-subject
|
||||
prompt whose subject doesn't resolve in any mounted pack.
|
||||
|
||||
`ChatRuntime.attach_oov_sink(sink)` mirrors `attach_discovery_sink`
|
||||
— the runtime emits one `OOVCandidate` JSONL line per turn whose
|
||||
`grounding_source == "oov"` and is a no-op when no sink is attached.
|
||||
|
||||
### 4. Relations pack v2 (P2.4)
|
||||
|
||||
`en_core_relations_v2` — 8 pronoun + role-filler lemmas, each a
|
||||
specialization of a v1 primitive:
|
||||
|
||||
| Lemma | Specialization of | Primary domain |
|
||||
|---|---|---|
|
||||
| mother | parent | `kinship.parent.female` |
|
||||
| father | parent | `kinship.parent.male` |
|
||||
| daughter | child | `kinship.child.female` |
|
||||
| son | child | `kinship.child.male` |
|
||||
| brother | sibling | `kinship.sibling.male` |
|
||||
| sister | sibling | `kinship.sibling.female` |
|
||||
| grandparent | ancestor (1-step) | `kinship.ascendant.transitive_1step` |
|
||||
| grandchild | descendant (1-step) | `kinship.descendant.transitive_1step` |
|
||||
|
||||
Mounted by default. Orthogonal to v1 and cognition (no lemma
|
||||
collision). Companion `relations_chains_v2` corpus seeds 7 v2-internal
|
||||
reviewed chains so v2 lemmas ground via CAUSE + VERIFICATION, not
|
||||
just DEFINITION/RECALL.
|
||||
|
||||
### 5. Two new CLI surfaces
|
||||
|
||||
```
|
||||
core teaching oov-gaps [--top N] [--since YYYY-MM] [--root PATH]
|
||||
core teaching oov-queue [--threshold N] [--include-tainted]
|
||||
```
|
||||
|
||||
Same shape as `core teaching gaps` / `core teaching queue` from
|
||||
Phase 1 — operators get a consistent workflow whether the signal is
|
||||
a chain gap or a lexicon gap.
|
||||
|
||||
---
|
||||
|
||||
## Operator workflow (closed loop, both axes)
|
||||
|
||||
```
|
||||
operator → core chat
|
||||
← cold turn
|
||||
- lemma resolves + chain exists → teaching surface
|
||||
- lemma resolves, no chain → discovery sink + universal/teaching tier
|
||||
- lemma OOV → OOV invitation surface + OOV sink
|
||||
- one lemma OOV in comparison → partial surface
|
||||
|
||||
operator → core teaching gaps # chain-gap aggregation
|
||||
operator → core teaching queue # chain-gap auto-promotion
|
||||
operator → core teaching oov-gaps # vocabulary-gap aggregation
|
||||
operator → core teaching oov-queue # vocabulary-gap auto-promotion
|
||||
|
||||
operator → for chain gaps: core teaching propose <path>
|
||||
operator → for vocab gaps: author PackMutationProposal (ADR-0027 path)
|
||||
operator → core teaching review <id> --accept
|
||||
```
|
||||
|
||||
Two independent signal streams, identical structural shape, both
|
||||
feed the same reviewed mutation path.
|
||||
|
||||
---
|
||||
|
||||
## Trust boundaries
|
||||
|
||||
- **No content synthesis.** OOV surface names the unknown token
|
||||
verbatim (safe-displayed); partial surface composes known-side
|
||||
atoms verbatim. Neither composer invents vocabulary or guesses
|
||||
domain.
|
||||
- **Sink emission is opt-in.** Without `attach_oov_sink`, the OOV
|
||||
surface still fires (P2.1 is unconditional), but nothing is
|
||||
persisted. Identical to the pre-Phase-2 path when no sink is
|
||||
attached.
|
||||
- **Auto-promotion never mutates a pack.** `OOVPromotion` is an
|
||||
operator-visible signal; the only path to a real pack change is
|
||||
the existing reviewed `PackMutationProposal` (ADR-0027).
|
||||
- **Suggested packs are mounted-pack list.** The promotion does
|
||||
NOT recommend a single destination — domain inference is out of
|
||||
scope (would require a stochastic classifier).
|
||||
|
||||
---
|
||||
|
||||
## Files changed
|
||||
|
||||
```
|
||||
chat/oov_surface.py NEW (~125 lines)
|
||||
chat/partial_surface.py NEW (~105 lines)
|
||||
chat/pack_resolver.py relations_v2 added to defaults
|
||||
chat/runtime.py fall-through refactor + attach_oov_sink + emission
|
||||
chat/teaching_grounding.py relations_chains_v2 registered
|
||||
core/cli.py oov-gaps + oov-queue subcommands
|
||||
core/config.py relations_v2 in input_packs defaults
|
||||
language_packs/data/en_core_relations_v2/ NEW pack (8 lemmas + manifest)
|
||||
teaching/oov_sink.py NEW (~150 lines)
|
||||
teaching/oov_gaps.py NEW (~165 lines)
|
||||
teaching/oov_promotion.py NEW (~120 lines)
|
||||
teaching/relations_chains_v2/ NEW corpus (7 reviewed chains)
|
||||
tests/test_oov_surface.py NEW (22 tests)
|
||||
tests/test_partial_surface.py NEW (16 tests)
|
||||
tests/test_oov_pipeline.py NEW (24 tests)
|
||||
tests/test_en_core_relations_v2_pack.py NEW (10 tests)
|
||||
docs/decisions/ADR-0065-oov-gradient-and-relations-v2.md NEW (this file)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
```
|
||||
tests/test_oov_surface.py 22 passed
|
||||
tests/test_partial_surface.py 16 passed
|
||||
tests/test_oov_pipeline.py 24 passed
|
||||
tests/test_en_core_relations_v2_pack.py 10 passed
|
||||
|
||||
Curated lanes (all green):
|
||||
core test --suite smoke 67 passed
|
||||
core test --suite cognition 121 passed
|
||||
core test --suite teaching 17 passed
|
||||
core test --suite packs 6 passed
|
||||
core test --suite runtime 19 passed
|
||||
core test --suite algebra 132 passed
|
||||
|
||||
Cognition eval (byte-identical to pre-ADR baseline):
|
||||
public: intent 100% / surface 100% / term 91.7% / closure 100%
|
||||
holdout: intent 100% / surface 100% / term 83.3% / closure 100%
|
||||
|
||||
Live verification:
|
||||
> What is photosynthesis?
|
||||
[oov] I haven't learned 'photosynthesis' yet (intent: definition). ...
|
||||
> Compare knowledge and photosynthesis.
|
||||
[partial] Whatever 'photosynthesis' is, I can ground 'knowledge' ...
|
||||
> What is mother?
|
||||
[pack] mother — pack-grounded (en_core_relations_v2): kinship.parent.female; ...
|
||||
> Why does mother exist?
|
||||
[teaching] mother — teaching-grounded (relations_chains_v2): mother precedes daughter ...
|
||||
```
|
||||
|
||||
The non-negotiable field invariant `versor_condition(F) < 1e-6` is
|
||||
unaffected.
|
||||
|
||||
---
|
||||
|
||||
## Future ADRs unlocked
|
||||
|
||||
- **ADR-0066 — Multi-lemma CAUSE/VERIFICATION partial grounding.**
|
||||
Today the partial tier engages only on COMPARISON. CAUSE and
|
||||
VERIFICATION carry a single subject; once the intent classifier
|
||||
grows multi-lemma extraction (e.g. "Why does photosynthesis
|
||||
produce energy?" → CAUSE + subject=photosynthesis + secondary
|
||||
object-side hint=energy), partial-grounding extends to those
|
||||
intents too.
|
||||
- **Phase 3 — turn-level composition.** Anaphora / NARRATIVE /
|
||||
EXAMPLE intents. Requires Phase 1+2 corpus density first.
|
||||
- **Domain classifier for OOV promotion suggestions.** Today the
|
||||
OOV queue lists every mounted pack. A small deterministic
|
||||
domain heuristic (token affix matches a pack's primary domain
|
||||
prefix?) could narrow the suggestion — only if it stays
|
||||
deterministic and the operator can override.
|
||||
|
|
@ -72,6 +72,7 @@ ADRs record significant architectural decisions: what was decided, why, what alt
|
|||
| [ADR-0060](ADR-0060-correction-acknowledgment-topic-lemma.md) | CORRECTION acknowledgement surface weaves the first pack-resident topical lemma from the utterance (left-to-right, excluding `correction` itself and `be`/`have` fillers) into a fixed template; backward-compatible with ADR-0053 (no-arg path byte-identical); closes `correction_truth_040` holdout miss; holdout `term_capture_rate` 75.0% → 79.2% | **Accepted** (2026-05-18) |
|
||||
| [ADR-0061](ADR-0061-procedure-intent-pack-grounded-surface.md) | PROCEDURE intent (`"How do I X?"`) routes to new `pack_grounded_procedure_surface`; selector picks **last** pack-resident lemma from verb-phrase subject (object > verb), falls back to verb when object is OOV, returns `None` (→ universal disclosure) for no-pack-lemma utterances; closes `procedure_define_010` (term `concept`) + `procedure_verify_034` (surface); holdout `surface_groundedness` 94.7% → 100.0%; `term_capture_rate` 79.2% → 83.3% | **Accepted** (2026-05-18) |
|
||||
| [ADR-0062](ADR-0062-composed-teaching-grounded-surface.md) | Composed teaching-grounded surface: when a chain `(A, intent_A, conn_A, B)` has a follow-up chain `(B, ?, conn_B, C)`, emit `"{A} {conn_A} {B}, which {conn_B} {C}"` instead of just `"{A} {conn_A} {B}"`; depth-1 (one hop) + cycle guard + pack-residency guard; degrades to single-chain byte-identically when no follow-up survives the guards; opt-in via `RuntimeConfig.composed_surface=False` default; cognition lane null-drop invariant (metrics byte-identical flag OFF/ON) CI-pinned | **Accepted** (2026-05-18) |
|
||||
| [ADR-0065](ADR-0065-oov-gradient-and-relations-v2.md) | OOV gradient + relations v2 (Plan Phase 2): five-tier honesty gradient replaces the OOV cliff — pack / teaching / partial (one OOV + one known) / oov (learning invitation surface naming the unknown token + mounted-pack list) / universal disclosure; sink-emit OOVCandidates → `core teaching oov-gaps` aggregator → `core teaching oov-queue` auto-promotion mirrors P1.1+P1.2 architecture for vocab gaps; `en_core_relations_v2` adds 8 pronoun + role-filler lemmas (mother/father/son/daughter/brother/sister/grandparent/grandchild) with 7 reviewed v2-internal chains; no content synthesis, no domain inference, no auto-pack-mutation | **Accepted** (2026-05-18) |
|
||||
| [ADR-0064](ADR-0064-cross-pack-teaching-chains.md) | Cross-pack teaching chains: `chat/teaching_grounding.py` registers a tuple of `TeachingCorpusSpec(corpus_id, path, pack_id)`; each corpus is 1:1-bound to one lexicon pack (cross-domain triples deferred per teaching_order.md §5); new `_all_chains_index()` aggregates across registered corpora (first-match-wins); surface composers + discovery gate consult the aggregated view; `TeachingChain` gains `corpus_id` field; surface tag follows the resolving corpus id; replay-equivalence gate rewrites registry path during transient phase; `relations_chains_v1` seeded with 7 reviewed kinship chains; cognition lane byte-identical | **Accepted** (2026-05-18) |
|
||||
| [ADR-0063](ADR-0063-cross-pack-surface-resolver.md) | Cross-pack surface resolver: `chat/pack_resolver.py` introduces `resolve_lemma(lemma, pack_ids)` that maps a lemma to `(resolving_pack_id, semantic_domains)` across an ordered tuple of mounted lexicon packs (first-match-wins); pack-grounded DEFINITION / RECALL / COMPARISON / CORRECTION / PROCEDURE composers now consult the resolver instead of a hardcoded `en_core_cognition_v1`; surface trust-boundary tag follows the resolving pack id; `en_core_relations_v1` joins `RuntimeConfig.input_packs` defaults — kinship lemmas now ground on the live path without a separate composer module; cognition-lane surfaces remain byte-identical (cognition is resolved first) | **Accepted** (2026-05-18) |
|
||||
|
||||
|
|
|
|||
170
teaching/oov_gaps.py
Normal file
170
teaching/oov_gaps.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
"""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"]
|
||||
119
teaching/oov_promotion.py
Normal file
119
teaching/oov_promotion.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"""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"]
|
||||
160
teaching/oov_sink.py
Normal file
160
teaching/oov_sink.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"""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",
|
||||
]
|
||||
279
tests/test_oov_pipeline.py
Normal file
279
tests/test_oov_pipeline.py
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
"""Phase 2.3 — OOV sink, aggregation, and auto-promotion tests.
|
||||
|
||||
The contract these tests pin:
|
||||
|
||||
- The runtime emits an ``OOVCandidate`` JSONL line to the attached
|
||||
sink on every turn whose ``grounding_source == "oov"``; no-op
|
||||
when no sink is attached.
|
||||
- The candidate_id is deterministic on (token, intent, trace_hash).
|
||||
- The aggregator groups by token, ranks by frequency, supports
|
||||
``--since YYYY-MM`` filtering.
|
||||
- The promoter respects the boundary-clean filter by default and
|
||||
refuses ``threshold < 1``.
|
||||
- The promotion suggests mounted packs but never names a single
|
||||
destination — domain inference is out of scope.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from chat.runtime import ChatRuntime
|
||||
from teaching.oov_gaps import OOVGap, aggregate_oov_gaps
|
||||
from teaching.oov_promotion import OOVPromotion, promote_oov_gaps
|
||||
from teaching.oov_sink import (
|
||||
OOVBufferSink,
|
||||
OOVCandidate,
|
||||
format_oov_candidate_jsonl,
|
||||
hash_oov_candidate_id,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sink contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_buffer_sink_captures_each_emit() -> None:
|
||||
sink = OOVBufferSink()
|
||||
sink.emit("one")
|
||||
sink.emit("two")
|
||||
assert sink.lines == ["one", "two"]
|
||||
|
||||
|
||||
def test_candidate_id_is_deterministic() -> None:
|
||||
a = hash_oov_candidate_id("photosynthesis", "definition", "trace-1")
|
||||
b = hash_oov_candidate_id("photosynthesis", "definition", "trace-1")
|
||||
assert a == b
|
||||
assert len(a) == 32
|
||||
|
||||
|
||||
def test_candidate_id_changes_with_token() -> None:
|
||||
a = hash_oov_candidate_id("photosynthesis", "definition", "trace-1")
|
||||
b = hash_oov_candidate_id("mitochondria", "definition", "trace-1")
|
||||
assert a != b
|
||||
|
||||
|
||||
def test_candidate_id_changes_with_trace() -> None:
|
||||
a = hash_oov_candidate_id("photosynthesis", "definition", "trace-1")
|
||||
b = hash_oov_candidate_id("photosynthesis", "definition", "trace-2")
|
||||
assert a != b
|
||||
|
||||
|
||||
def test_candidate_jsonl_is_sorted_compact() -> None:
|
||||
cand = OOVCandidate(
|
||||
candidate_id="x",
|
||||
token="photosynthesis",
|
||||
intent="definition",
|
||||
trigger="unresolved_subject",
|
||||
source_turn_trace="t",
|
||||
boundary_clean=True,
|
||||
)
|
||||
line = format_oov_candidate_jsonl(cand)
|
||||
parsed = json.loads(line)
|
||||
assert parsed["token"] == "photosynthesis"
|
||||
assert parsed["intent"] == "definition"
|
||||
assert parsed["boundary_clean"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime integration — sink receives one line per OOV turn
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_runtime_emits_when_oov_sink_attached() -> None:
|
||||
rt = ChatRuntime()
|
||||
sink = OOVBufferSink()
|
||||
rt.attach_oov_sink(sink)
|
||||
rt.chat("What is photosynthesis?")
|
||||
assert len(sink.lines) == 1
|
||||
parsed = json.loads(sink.lines[0])
|
||||
assert parsed["token"] == "photosynthesis"
|
||||
assert parsed["intent"] == "definition"
|
||||
assert parsed["trigger"] == "unresolved_subject"
|
||||
|
||||
|
||||
def test_runtime_does_not_emit_without_sink() -> None:
|
||||
"""Sink emission is opt-in; runtime behaviour is identical when
|
||||
no sink is attached."""
|
||||
rt = ChatRuntime()
|
||||
resp = rt.chat("What is photosynthesis?")
|
||||
# OOV surface still fires (P2.1 is unconditional), but nothing
|
||||
# is persisted anywhere — there is no sink to receive it.
|
||||
assert resp.grounding_source == "oov"
|
||||
|
||||
|
||||
def test_runtime_does_not_emit_on_known_lemma() -> None:
|
||||
rt = ChatRuntime()
|
||||
sink = OOVBufferSink()
|
||||
rt.attach_oov_sink(sink)
|
||||
rt.chat("What is light?")
|
||||
assert sink.lines == []
|
||||
|
||||
|
||||
def test_runtime_emits_across_intent_shapes() -> None:
|
||||
"""Every intent shape that triggers OOV (definition, cause,
|
||||
verification, comparison, procedure) emits a candidate."""
|
||||
rt = ChatRuntime()
|
||||
sink = OOVBufferSink()
|
||||
rt.attach_oov_sink(sink)
|
||||
rt.chat("What is photosynthesis?")
|
||||
intents = set()
|
||||
for line in sink.lines:
|
||||
intents.add(json.loads(line)["intent"])
|
||||
assert "definition" in intents
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Aggregator — file walking + deterministic ordering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _write_oov_line(path: Path, **kwargs) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"candidate_id": kwargs.get("candidate_id", "x"),
|
||||
"token": kwargs.get("token", "photosynthesis"),
|
||||
"intent": kwargs.get("intent", "definition"),
|
||||
"trigger": "unresolved_subject",
|
||||
"source_turn_trace": kwargs.get("trace", "t"),
|
||||
"boundary_clean": kwargs.get("boundary_clean", True),
|
||||
"review_state": "unreviewed",
|
||||
}
|
||||
with path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(payload, sort_keys=True, separators=(",", ":")))
|
||||
fh.write("\n")
|
||||
|
||||
|
||||
def test_aggregates_by_token(tmp_path: Path) -> None:
|
||||
sink = tmp_path / "2026" / "2026-05.jsonl"
|
||||
_write_oov_line(sink, candidate_id="a", token="photosynthesis", intent="definition")
|
||||
_write_oov_line(sink, candidate_id="b", token="photosynthesis", intent="cause")
|
||||
_write_oov_line(sink, candidate_id="c", token="mitochondria", intent="definition")
|
||||
|
||||
rows = aggregate_oov_gaps(tmp_path)
|
||||
assert len(rows) == 2
|
||||
photo = next(g for g in rows if g.token == "photosynthesis")
|
||||
assert photo.count == 2
|
||||
assert photo.intents == ("cause", "definition")
|
||||
assert photo.boundary_clean_count == 2
|
||||
|
||||
|
||||
def test_rank_order_is_count_desc(tmp_path: Path) -> None:
|
||||
sink = tmp_path / "2026" / "2026-05.jsonl"
|
||||
for i in range(3):
|
||||
_write_oov_line(sink, candidate_id=f"a{i}", token="photosynthesis")
|
||||
_write_oov_line(sink, candidate_id="b0", token="mitochondria")
|
||||
rows = aggregate_oov_gaps(tmp_path)
|
||||
assert [g.token for g in rows] == ["photosynthesis", "mitochondria"]
|
||||
|
||||
|
||||
def test_tainted_counted_but_split(tmp_path: Path) -> None:
|
||||
sink = tmp_path / "2026" / "2026-05.jsonl"
|
||||
_write_oov_line(sink, candidate_id="a", boundary_clean=True)
|
||||
_write_oov_line(sink, candidate_id="b", boundary_clean=False)
|
||||
rows = aggregate_oov_gaps(tmp_path)
|
||||
assert rows[0].count == 2
|
||||
assert rows[0].boundary_clean_count == 1
|
||||
|
||||
|
||||
def test_since_filter(tmp_path: Path) -> None:
|
||||
_write_oov_line(tmp_path / "2026" / "2026-04.jsonl", candidate_id="april")
|
||||
_write_oov_line(tmp_path / "2026" / "2026-05.jsonl", candidate_id="may")
|
||||
rows = aggregate_oov_gaps(tmp_path, since="2026-05")
|
||||
assert len(rows) == 1
|
||||
assert rows[0].sample_candidate_ids == ("may",)
|
||||
|
||||
|
||||
def test_malformed_lines_skipped(tmp_path: Path) -> None:
|
||||
sink = tmp_path / "2026" / "2026-05.jsonl"
|
||||
sink.parent.mkdir(parents=True, exist_ok=True)
|
||||
sink.write_text(
|
||||
"not json\n{}\n" + json.dumps({
|
||||
"candidate_id": "ok", "token": "photosynthesis",
|
||||
"intent": "definition", "trigger": "unresolved_subject",
|
||||
"source_turn_trace": "t", "boundary_clean": True,
|
||||
}) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
rows = aggregate_oov_gaps(tmp_path)
|
||||
assert len(rows) == 1
|
||||
|
||||
|
||||
def test_aggregator_missing_root_returns_empty(tmp_path: Path) -> None:
|
||||
assert aggregate_oov_gaps(tmp_path / "does_not_exist") == ()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Promotion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _gap(token: str, count: int = 3, clean: int | None = None) -> OOVGap:
|
||||
return OOVGap(
|
||||
token=token,
|
||||
intents=("definition",),
|
||||
count=count,
|
||||
boundary_clean_count=count if clean is None else clean,
|
||||
sample_candidate_ids=("a", "b"),
|
||||
months_seen=("2026-05",),
|
||||
)
|
||||
|
||||
|
||||
def test_promotion_respects_threshold() -> None:
|
||||
gaps = (_gap("photosynthesis", count=5, clean=5),)
|
||||
promoted = promote_oov_gaps(gaps, threshold=3)
|
||||
assert len(promoted) == 1
|
||||
assert promoted[0].token == "photosynthesis"
|
||||
|
||||
|
||||
def test_promotion_excludes_below_threshold() -> None:
|
||||
gaps = (_gap("rare", count=1, clean=1),)
|
||||
assert promote_oov_gaps(gaps, threshold=3) == ()
|
||||
|
||||
|
||||
def test_promotion_excludes_tainted_only_by_default() -> None:
|
||||
gaps = (_gap("forbidden", count=5, clean=0),)
|
||||
assert promote_oov_gaps(gaps, threshold=3) == ()
|
||||
|
||||
|
||||
def test_include_tainted_counts_all() -> None:
|
||||
gaps = (_gap("forbidden", count=5, clean=0),)
|
||||
promoted = promote_oov_gaps(gaps, threshold=3, include_tainted=True)
|
||||
assert len(promoted) == 1
|
||||
|
||||
|
||||
def test_threshold_must_be_positive() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
promote_oov_gaps((_gap("photosynthesis"),), threshold=0)
|
||||
|
||||
|
||||
def test_queue_id_format() -> None:
|
||||
promoted = promote_oov_gaps((_gap("photosynthesis", count=5, clean=5),), threshold=3)
|
||||
assert promoted[0].queue_id == "oov:photosynthesis@3"
|
||||
|
||||
|
||||
def test_promotion_suggests_mounted_packs() -> None:
|
||||
promoted = promote_oov_gaps((_gap("photosynthesis", count=5, clean=5),), threshold=3)
|
||||
assert "en_core_cognition_v1" in promoted[0].suggested_packs
|
||||
|
||||
|
||||
def test_promotion_is_deterministic() -> None:
|
||||
gaps = (
|
||||
_gap("photosynthesis", count=5, clean=5),
|
||||
_gap("mitochondria", count=5, clean=5),
|
||||
)
|
||||
a = promote_oov_gaps(gaps, threshold=3)
|
||||
b = promote_oov_gaps(gaps, threshold=3)
|
||||
assert a == b
|
||||
assert [p.token for p in a] == ["mitochondria", "photosynthesis"]
|
||||
|
||||
|
||||
def test_promotion_does_not_mutate_input() -> None:
|
||||
gaps = (_gap("photosynthesis", count=3, clean=3),)
|
||||
snapshot = gaps[0]
|
||||
promote_oov_gaps(gaps, threshold=3)
|
||||
assert gaps[0] == snapshot
|
||||
Loading…
Reference in a new issue