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).
9.8 KiB
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 orNone(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, elseNone.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.
OOVPromotionis an operator-visible signal; the only path to a real pack change is the existing reviewedPackMutationProposal(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.