core/tests/test_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

184 lines
6.8 KiB
Python

"""Phase 2.1 — OOV "teach me" surface tests.
The contract these tests pin:
- OOV tokens with a supported intent produce a deterministic
learning-invitation surface tagged ``grounding_source="oov"``.
- Known tokens still ground through the existing pack/teaching
paths byte-identically.
- The OOV surface names the unknown token, lists mounted packs,
and points at the reviewed PackMutationProposal path — never
invents meaning, never auto-mutates.
- UNKNOWN-intent prompts still get the universal disclosure
(the classifier itself failed to extract a confident subject).
- User-text passes through the safe-display sanitiser; control
chars do not leak into surfaces.
"""
from __future__ import annotations
import pytest
from chat.oov_surface import (
is_oov_for_packs,
oov_learning_invitation_surface,
)
from chat.runtime import ChatRuntime
from generate.intent import IntentTag
# ---------------------------------------------------------------------------
# Pure-function contract
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("intent_tag", [
IntentTag.DEFINITION,
IntentTag.RECALL,
IntentTag.CAUSE,
IntentTag.VERIFICATION,
IntentTag.COMPARISON,
IntentTag.PROCEDURE,
IntentTag.CORRECTION,
])
def test_oov_token_with_supported_intent_emits_invitation(
intent_tag: IntentTag,
) -> None:
surface = oov_learning_invitation_surface("photosynthesis", intent_tag)
assert surface is not None
assert "photosynthesis" in surface
assert "haven't learned" in surface
assert "en_core_cognition_v1" in surface
assert "en_core_relations_v1" in surface
assert "PackMutationProposal" in surface
assert intent_tag.name.lower() in surface
def test_known_token_returns_none() -> None:
"""If the token IS resolvable, the composer returns None so the
caller routes through pack-grounded / teaching-grounded paths."""
assert oov_learning_invitation_surface("light", IntentTag.DEFINITION) is None
assert oov_learning_invitation_surface("parent", IntentTag.CAUSE) is None
assert oov_learning_invitation_surface("knowledge", IntentTag.VERIFICATION) is None
@pytest.mark.parametrize("bad", ["", " ", None])
def test_empty_or_invalid_token_returns_none(bad) -> None:
assert oov_learning_invitation_surface(bad, IntentTag.DEFINITION) is None # type: ignore[arg-type]
def test_unknown_intent_returns_none() -> None:
"""UNKNOWN intent means the classifier could not extract a
confident subject; emitting an invitation for an unparsed prompt
would be misleading."""
assert oov_learning_invitation_surface("photosynthesis", IntentTag.UNKNOWN) is None
def test_empty_pack_list_returns_none() -> None:
"""With no mounted packs there is no learnable destination;
the invitation has no targets to suggest."""
surface = oov_learning_invitation_surface(
"photosynthesis", IntentTag.DEFINITION, pack_ids=(),
)
assert surface is None
def test_surface_is_deterministic() -> None:
a = oov_learning_invitation_surface("photosynthesis", IntentTag.DEFINITION)
b = oov_learning_invitation_surface("photosynthesis", IntentTag.DEFINITION)
assert a == b
assert a is not None
def test_surface_includes_explicit_intent_name() -> None:
for tag, expected_token in [
(IntentTag.DEFINITION, "definition"),
(IntentTag.CAUSE, "cause"),
(IntentTag.VERIFICATION, "verification"),
(IntentTag.PROCEDURE, "procedure"),
]:
surface = oov_learning_invitation_surface("xyzunknown", tag)
assert surface is not None
assert f"(intent: {expected_token})" in surface
# ---------------------------------------------------------------------------
# Safety — user text is sanitised at the safe_display boundary
# ---------------------------------------------------------------------------
def test_control_characters_in_token_are_sanitised() -> None:
surface = oov_learning_invitation_surface(
"evil\x00\x07token", IntentTag.DEFINITION,
)
assert surface is not None
# Control bytes must not survive — safe_display strips/escapes them.
assert "\x00" not in surface
assert "\x07" not in surface
# ---------------------------------------------------------------------------
# is_oov_for_packs predicate
# ---------------------------------------------------------------------------
def test_is_oov_for_packs_round_trips() -> None:
assert is_oov_for_packs("photosynthesis") is True
assert is_oov_for_packs("light") is False
assert is_oov_for_packs("parent") is False
assert is_oov_for_packs("") is False
assert is_oov_for_packs(" ") is False
# ---------------------------------------------------------------------------
# Live runtime — OOV converts cliff into gradient
# ---------------------------------------------------------------------------
def test_runtime_definition_on_oov_emits_invitation() -> None:
rt = ChatRuntime()
resp = rt.chat("What is photosynthesis?")
assert resp.grounding_source == "oov"
assert "photosynthesis" in resp.surface
assert "PackMutationProposal" in resp.surface
def test_runtime_cause_on_oov_emits_invitation() -> None:
"""A CAUSE prompt on an OOV subject must hit the OOV branch, not
fall through to the universal disclosure. Previously these went
silent because the CAUSE/VERIFICATION branch early-returned None
when no teaching chain existed."""
rt = ChatRuntime()
resp = rt.chat("Why does mitochondria exist?")
assert resp.grounding_source == "oov"
assert "mitochondria" in resp.surface
def test_runtime_known_subject_still_grounds_pack() -> None:
"""Known cognition lemmas still ground through the pack path —
OOV branch is a fall-through only, not a replacement."""
rt = ChatRuntime()
resp = rt.chat("What is light?")
assert resp.grounding_source == "pack"
assert "light" in resp.surface
def test_runtime_known_subject_still_grounds_teaching() -> None:
"""Reviewed teaching chains still route through teaching-grounded."""
rt = ChatRuntime()
resp = rt.chat("Why does parent exist?")
assert resp.grounding_source == "teaching"
assert "parent" in resp.surface
def test_runtime_unknown_intent_still_emits_universal_disclosure() -> None:
"""If the classifier returns UNKNOWN intent, there is no clean
subject to invite an operator to teach. Universal disclosure
remains the right fall-through."""
rt = ChatRuntime()
resp = rt.chat("Define mitochondria.") # classifier returns UNKNOWN here
# Either UNKNOWN intent → universal disclosure ("none"), OR if
# the classifier improves to read "Define mitochondria." as
# DEFINITION the prompt should switch to OOV invitation. Both
# are acceptable.
assert resp.grounding_source in {"none", "oov"}