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).
242 lines
9.8 KiB
Markdown
242 lines
9.8 KiB
Markdown
# 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.
|