core/docs/decisions/ADR-0065-oov-gradient-and-relations-v2.md
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

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.