core/chat/oov_surface.py
Shay 51aad0c2cd feat(adr-0065): OOV cliff → five-tier honesty gradient (Phase 2.1 + 2.2)
Replaces the flat "I don't know — insufficient grounding" disclosure
with a deterministic gradient that names specific vocabulary gaps
and gives operators concrete next steps.

P2.1 — OOV "teach me" surface (chat/oov_surface.py).

  When the intent classifier extracts a clean subject lemma but that
  lemma is not resident in any mounted lexicon pack, the runtime now
  emits a deterministic learning-invitation surface tagged
  ``grounding_source="oov"`` instead of the universal disclosure.

  Surface format (fixed template):

    "I haven't learned '{token}' yet (intent: {intent}).
     Mounted lexicon packs: {pack_list}.
     Teach me via a reviewed PackMutationProposal."

  The OOV token passes through ``core._safe_display.safe_display``
  before persistence — user-input sanitization at the trust boundary.
  No vocabulary is invented; no domain is inferred.  Honours the
  ADR-0027 proposal-only invariant: the surface invites a reviewed
  pack mutation, never silently mutates any pack.

  Refactored ``_maybe_pack_grounded_surface`` so every existing
  intent branch (COMPARISON / CAUSE / VERIFICATION / CORRECTION /
  PROCEDURE / DEFINITION+RECALL) falls through on a None composer
  result instead of early-returning.  The OOV invitation is the
  deterministic fall-through for any clean-subject prompt whose
  subject doesn't resolve.

P2.2 — Partial-grounding tier (chat/partial_surface.py).

  When exactly one of two COMPARISON lemmas resolves, the runtime
  emits a hedged surface that grounds the known side verbatim and
  disclaims the OOV side explicitly:

    "Whatever '{oov}' is, I can ground '{known}' — pack-grounded
     ({pack_id}): {d1}; {d2}.  I cannot ground the comparison
     without learning '{oov}' — teach me via a reviewed
     PackMutationProposal."

  Tagged ``grounding_source="partial"``.  Falls through to OOV
  invitation when both lemmas are OOV, and to full pack-grounded
  COMPARISON when both resolve — partial is the middle tier in the
  five-tier gradient.

  Also normalises trailing sentence punctuation on
  intent.secondary_subject at the COMPARISON boundary so prompts
  like "Compare A and B." (with the period) still resolve B
  correctly.

Five-tier gradient (vault → teaching → pack → partial → oov → none).

Test debt retired: four pre-existing tests asserted "OOV → universal
disclosure", which is exactly the contract P2.1/P2.2 inverted.
Rewritten to the new contract.  Plus test_procedure_surface.py
gained a test for the OOV gradient on procedure intents.

Verification:
  tests/test_oov_surface.py                       22 passed
  tests/test_partial_surface.py                   16 passed
  Cognition eval byte-identical:
    public  100% / 100% / 91.7% / 100%
    holdout 100% / 100% / 83.3% / 100%
  Curated lanes all green.
2026-05-18 16:41:45 -07:00

135 lines
4.9 KiB
Python

"""chat/oov_surface.py — Phase 2.1: OOV "teach me" surface.
When the intent classifier extracts a clean subject lemma but that
lemma is not resident in any mounted lexicon pack, the runtime today
falls through to the universal disclosure
(``_UNKNOWN_DOMAIN_SURFACE``). That surface is *honest* (it does
not pretend to know) but it is also *flat* — it conveys no signal
that a specific vocabulary gap was hit, and it offers the operator
no concrete next step.
This module replaces that cliff with a gradient. Cold-start prompts
whose subject is OOV emit a deterministic learning-invitation
surface that:
1. Names the unknown token explicitly so the operator sees which
word the system could not ground.
2. Lists the currently-mounted lexicon packs so the operator knows
where the token could be added.
3. Points at the existing reviewed-pack-mutation path
(:mod:`teaching.proposals`) as the way to teach the system the
new lemma — never "auto-learn", never invent meaning.
The surface is tagged ``grounding_source="oov"`` so downstream audit,
discovery aggregation, and operator tooling can distinguish
"I haven't learned this yet" from "I refuse" / "I'm unsure" /
"insufficient evidence".
Design constraints (matching ADR-0048..0064 doctrine):
- **Deterministic.** Same OOV token + same mounted-pack list →
byte-identical surface.
- **No synthesis.** The surface composes only:
* the OOV token (verbatim user input — safely escaped at the
:func:`chat._safe_display.safe_display` boundary),
* the mounted-pack ids (declared statically in
:data:`chat.pack_resolver.DEFAULT_RESOLVABLE_PACK_IDS`),
* a fixed-template instruction.
No new vocabulary is invented; no domain inference is performed.
- **Trust boundary preserved.** The surface invites a *reviewed*
pack mutation; it never silently mutates any pack or corpus. The
ADR-0027 proposal-only invariant is intact.
"""
from __future__ import annotations
from chat.pack_resolver import DEFAULT_RESOLVABLE_PACK_IDS, is_resolvable
from core._safe_display import safe_display
from generate.intent import IntentTag
# Intent shapes for which the runtime emits a grounded cold-start
# surface today (ADR-0048 / 0050 / 0052 / 0053 / 0061). OOV
# invitation fires only when the prompt's intent is one of these —
# UNKNOWN-intent prompts get the universal disclosure unchanged
# because the classifier itself could not extract a confident
# subject.
_OOV_INTENT_TAGS: frozenset[IntentTag] = frozenset({
IntentTag.DEFINITION,
IntentTag.RECALL,
IntentTag.CAUSE,
IntentTag.VERIFICATION,
IntentTag.COMPARISON,
IntentTag.PROCEDURE,
IntentTag.CORRECTION,
})
def oov_learning_invitation_surface(
token: str,
intent_tag: IntentTag,
*,
pack_ids: tuple[str, ...] = DEFAULT_RESOLVABLE_PACK_IDS,
) -> str | None:
"""Return a deterministic OOV learning-invitation surface, or ``None``.
The surface format is fixed:
"I haven't learned '{token}' yet (intent: {intent}).
Mounted lexicon packs: {pack_list}.
Teach me via a reviewed PackMutationProposal."
The trailing instruction is the constant trust-boundary label.
It points at the existing reviewed-pack-mutation path; the
surface never invents meaning for the unknown token.
Returns ``None`` (caller falls through to the universal disclosure)
when:
- ``token`` is empty or not a string,
- ``token`` IS resolvable in *pack_ids* (caller routed here by
mistake — keep the explicit fall-through rather than emit a
misleading surface),
- the mounted-pack list is empty (no learnable destination —
emitting an invitation with no targets would be unhelpful).
"""
if not token or not isinstance(token, str):
return None
cleaned = token.strip()
if not cleaned:
return None
if intent_tag not in _OOV_INTENT_TAGS:
return None
if is_resolvable(cleaned, pack_ids):
return None
if not pack_ids:
return None
safe_token = safe_display(cleaned)
pack_list = ", ".join(pack_ids)
intent_name = intent_tag.name.lower()
return (
f"I haven't learned '{safe_token}' yet (intent: {intent_name}). "
f"Mounted lexicon packs: {pack_list}. "
f"Teach me via a reviewed PackMutationProposal."
)
def is_oov_for_packs(
token: str,
pack_ids: tuple[str, ...] = DEFAULT_RESOLVABLE_PACK_IDS,
) -> bool:
"""Return True iff *token* is non-empty and not resolvable in
any of *pack_ids*. Convenience predicate for the runtime
dispatcher (avoids duplicating the ``is_resolvable`` inversion
in caller code)."""
if not token or not isinstance(token, str):
return False
cleaned = token.strip()
if not cleaned:
return False
return not is_resolvable(cleaned, pack_ids)
__all__ = [
"oov_learning_invitation_surface",
"is_oov_for_packs",
]