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.
This commit is contained in:
Shay 2026-05-18 16:41:45 -07:00
parent 34295e55ce
commit 51aad0c2cd
9 changed files with 779 additions and 63 deletions

135
chat/oov_surface.py Normal file
View file

@ -0,0 +1,135 @@
"""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",
]

115
chat/partial_surface.py Normal file
View file

@ -0,0 +1,115 @@
"""chat/partial_surface.py — Phase 2.2: partial-grounding tier.
When a prompt contains both an OOV token AND a pack-resident token,
the runtime today has two choices:
1. ``pack_grounded_*`` composers require *both* tokens to resolve
(ADR-0050 COMPARISON: identical-lemma None; OOV-lemma None).
2. The OOV invitation (P2.1) names one unknown token but ignores
the known one entirely.
Both miss a real signal: the known token is actually grounded, the
relation is partially representable, and the operator deserves to
see *which* side is OOV instead of a flat "I don't know one of these".
This module composes a **partial-grounding** surface that:
- Grounds the pack-resident token verbatim from its lexicon
(same atoms a full pack-grounded surface would emit).
- Names the OOV token explicitly under a "whatever ... is" hedge
no synthesis, no inferred meaning, no domain guess.
- States the contract: the relation cannot be grounded until the
OOV token is ratified into a pack.
- Tags ``grounding_source="partial"`` so audit and downstream
aggregation distinguish this from full pack-grounded
surfaces or the universal disclosure.
Today's scope is the COMPARISON intent (two subject lemmas, one OOV +
one known). CAUSE/VERIFICATION extract a single subject; if it's
OOV the OOV invitation surface (P2.1) is the right surface there
is no second lemma to partially ground against. Future ADRs can
extend partial-grounding to other intent shapes as the classifier
grows multi-lemma extraction.
Trust boundary:
- The partial surface composes only the known-side lexicon atoms,
the (safely-displayed) OOV token, and a fixed template.
- No vocabulary is invented; no meaning is inferred for the OOV
side.
- The trailing instruction points at the reviewed pack-mutation
path partial grounding never auto-mutates state.
"""
from __future__ import annotations
from chat.pack_resolver import DEFAULT_RESOLVABLE_PACK_IDS, resolve_lemma
from core._safe_display import safe_display
def partial_comparison_surface(
lemma_a: str,
lemma_b: str,
*,
pack_ids: tuple[str, ...] = DEFAULT_RESOLVABLE_PACK_IDS,
) -> tuple[str, str] | None:
"""Return ``(surface, known_side)`` where ``known_side`` is ``"a"``
or ``"b"`` depending on which lemma resolved, or ``None``.
The surface format is fixed:
"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."
The composer returns ``None`` when:
- either lemma is empty / not a string,
- both lemmas resolve (route through the full
``pack_grounded_comparison_surface`` instead),
- neither lemma resolves (route through the OOV invitation;
partial-grounding has nothing to anchor on),
- the two lemmas are identical strings (same-lemma comparison
carries no contrastive evidence at any tier).
"""
if not lemma_a or not isinstance(lemma_a, str):
return None
if not lemma_b or not isinstance(lemma_b, str):
return None
key_a = lemma_a.strip().lower()
key_b = lemma_b.strip().lower()
if not key_a or not key_b or key_a == key_b:
return None
resolved_a = resolve_lemma(key_a, pack_ids)
resolved_b = resolve_lemma(key_b, pack_ids)
# Partial-grounding requires exactly one side to resolve.
if resolved_a is None and resolved_b is None:
return None
if resolved_a is not None and resolved_b is not None:
return None
if resolved_a is not None:
known_lemma = key_a
known_pack_id, known_domains = resolved_a
oov_lemma = key_b
known_side = "a"
else:
assert resolved_b is not None
known_lemma = key_b
known_pack_id, known_domains = resolved_b
oov_lemma = key_a
known_side = "b"
safe_oov = safe_display(oov_lemma)
head = "; ".join(known_domains[:2])
surface = (
f"Whatever '{safe_oov}' is, I can ground '{known_lemma}' "
f"— pack-grounded ({known_pack_id}): {head}. "
f"I cannot ground the comparison without learning '{safe_oov}' "
f"— teach me via a reviewed PackMutationProposal."
)
return (surface, known_side)
__all__ = ["partial_comparison_surface"]

View file

@ -424,6 +424,11 @@ class ChatRuntime:
# ``attach_discovery_sink``. Candidates are *evidence*, never # ``attach_discovery_sink``. Candidates are *evidence*, never
# mutate the corpus or runtime state. # mutate the corpus or runtime state.
self._discovery_sink: DiscoveryCandidateSink | None = None self._discovery_sink: DiscoveryCandidateSink | None = None
# Phase 2.3 — opt-in OOV candidate sink. Default None preserves
# prior behavior; callers attach via ``attach_oov_sink``.
# Candidates are evidence; ratified-pack-mutation is the only
# path an OOV promotion becomes a real pack change.
self._oov_sink: Any = None
# ADR-0056 Phase C1 — opt-in contemplation pass that enriches # ADR-0056 Phase C1 — opt-in contemplation pass that enriches
# each emitted DiscoveryCandidate with polarity / claim_domain / # each emitted DiscoveryCandidate with polarity / claim_domain /
# evidence / sub_questions before the sink writes the JSONL # evidence / sub_questions before the sink writes the JSONL
@ -458,6 +463,21 @@ class ChatRuntime:
self._telemetry_sink = sink self._telemetry_sink = sink
self._telemetry_include_content = bool(include_content) self._telemetry_include_content = bool(include_content)
def attach_oov_sink(self, sink: Any) -> None:
"""Phase 2.3 — attach an OOV candidate sink.
After each turn whose surface fired the P2.1 OOV invitation
(``grounding_source="oov"``), the runtime emits one
:class:`teaching.oov_sink.OOVCandidate` JSONL line to the
attached sink. Passing ``None`` detaches.
Candidates are evidence: emission never mutates any pack.
The ratified pack-mutation path (ADR-0027 +
:mod:`teaching.proposals`) is the only way an OOV promotion
becomes a real pack change.
"""
self._oov_sink = sink
def attach_discovery_sink( def attach_discovery_sink(
self, self,
sink: DiscoveryCandidateSink | None, sink: DiscoveryCandidateSink | None,
@ -492,6 +512,55 @@ class ChatRuntime:
""" """
self._contemplate_discoveries = bool(enabled) self._contemplate_discoveries = bool(enabled)
def _emit_oov_candidate(
self,
*,
turn_event: TurnEvent,
intent_tag: Any,
token: str | None,
) -> None:
"""P2.3 — emit one OOVCandidate per OOV-grounded turn.
No-op unless ``attach_oov_sink`` was called. The token is
already safe-displayed at the surface composer; persistence
carries the same sanitised form.
"""
sink = self._oov_sink
if sink is None or not token:
return
# Local imports — keep OOV machinery out of the runtime
# hot-path import graph for callers that never opt in.
from teaching.oov_sink import (
OOVCandidate,
format_oov_candidate_jsonl,
hash_oov_candidate_id,
)
from generate.intent import IntentTag
if intent_tag is None or not isinstance(intent_tag, IntentTag):
return
intent_name = intent_tag.name.lower()
# Pull trace hash from the turn event when present so the
# candidate_id replays deterministically.
trace_hash = getattr(turn_event, "trace_hash", "") or ""
boundary_clean = (
not getattr(turn_event, "refusal_emitted", False)
and not getattr(turn_event, "hedge_injected", False)
)
cleaned_token = (token or "").strip().lower()
if not cleaned_token:
return
candidate_id = hash_oov_candidate_id(cleaned_token, intent_name, trace_hash)
candidate = OOVCandidate(
candidate_id=candidate_id,
token=cleaned_token,
intent=intent_name, # type: ignore[arg-type]
trigger="unresolved_subject",
source_turn_trace=trace_hash,
boundary_clean=boundary_clean,
)
sink.emit(format_oov_candidate_jsonl(candidate))
def _emit_discovery_candidates( def _emit_discovery_candidates(
self, self,
*, *,
@ -655,32 +724,51 @@ class ChatRuntime:
# composed from both lemmas' pack semantic_domains. Engages only # composed from both lemmas' pack semantic_domains. Engages only
# when both subject and secondary_subject are pack lemmas. # when both subject and secondary_subject are pack lemmas.
if intent.tag is IntentTag.COMPARISON: if intent.tag is IntentTag.COMPARISON:
lemma_a = (intent.subject or "").strip() # The intent classifier may retain terminal punctuation on
lemma_b = (intent.secondary_subject or "").strip() # secondary_subject when it falls at the end of the prompt
if not lemma_a or not lemma_b: # ("Compare A and B."). Strip terminal sentence punctuation
return None # so the resolver can find the underlying lemma. This is
surface = pack_grounded_comparison_surface(lemma_a, lemma_b) # a normalization at the runtime boundary, not in the
return (surface, "pack") if surface is not None else None # classifier itself, to keep the classifier's verbatim
# extraction available to other consumers.
lemma_a = (intent.subject or "").strip().rstrip(".,?!;:")
lemma_b = (intent.secondary_subject or "").strip().rstrip(".,?!;:")
if lemma_a and lemma_b:
surface = pack_grounded_comparison_surface(lemma_a, lemma_b)
if surface is not None:
return (surface, "pack")
# P2.2 — Partial-grounding tier. When exactly one of
# the two compared lemmas is pack-resident, emit a
# hedged surface that grounds the known side and
# explicitly disclaims the OOV side. Better than
# falling through to OOV invitation (which would name
# only one token while ignoring the other's actual
# grounding).
from chat.partial_surface import partial_comparison_surface
partial = partial_comparison_surface(lemma_a, lemma_b)
if partial is not None:
return (partial[0], "partial")
# ADR-0052 — teaching-grounded CAUSE / VERIFICATION. The chain # ADR-0052 — teaching-grounded CAUSE / VERIFICATION. The chain
# corpus is reviewed memory; every emitted atom is either a # corpus is reviewed memory; every emitted atom is either a
# lemma, a verbatim pack semantic_domains string, or a fixed # lemma, a verbatim pack semantic_domains string, or a fixed
# connective from humanize_predicate. # connective from humanize_predicate.
if intent.tag in (IntentTag.CAUSE, IntentTag.VERIFICATION): if intent.tag in (IntentTag.CAUSE, IntentTag.VERIFICATION):
lemma = (intent.subject or "").strip() lemma = (intent.subject or "").strip()
if not lemma: if lemma:
return None # ADR-0062 — when ``composed_surface`` is enabled, the
# ADR-0062 — when ``composed_surface`` is enabled, the # teaching-grounded composer extends the single-chain
# teaching-grounded composer extends the single-chain # surface with a follow-up chain whose subject equals
# surface with a follow-up chain whose subject equals the # the initial chain's object. Backward-compatible:
# initial chain's object. Backward-compatible: with the # with the flag off, the single-chain composer is
# flag off, the single-chain composer is used; with the # used; with the flag on and no follow-up chain
# flag on and no follow-up chain available, the composer # available, the composer degrades to the single-
# degrades to the single-chain surface byte-identically. # chain surface byte-identically.
if self.config.composed_surface: if self.config.composed_surface:
surface = teaching_grounded_surface_composed(lemma, intent.tag) surface = teaching_grounded_surface_composed(lemma, intent.tag)
else: else:
surface = teaching_grounded_surface(lemma, intent.tag) surface = teaching_grounded_surface(lemma, intent.tag)
return (surface, "teaching") if surface is not None else None if surface is not None:
return (surface, "teaching")
# ADR-0053 — CORRECTION acknowledgement. Cold-start CORRECTION # ADR-0053 — CORRECTION acknowledgement. Cold-start CORRECTION
# has no prior session turn to apply to; emit a pack-grounded # has no prior session turn to apply to; emit a pack-grounded
# surface that acknowledges the correction was received and # surface that acknowledges the correction was received and
@ -694,7 +782,8 @@ class ChatRuntime:
# topical lemma present, the surface degrades to the # topical lemma present, the surface degrades to the
# ADR-0053 topic-less template. # ADR-0053 topic-less template.
surface = pack_grounded_correction_surface(text) surface = pack_grounded_correction_surface(text)
return (surface, "pack") if surface is not None else None if surface is not None:
return (surface, "pack")
# ADR-0061 — PROCEDURE pack-grounded surface. Procedural # ADR-0061 — PROCEDURE pack-grounded surface. Procedural
# chains are not part of the reviewed teaching corpus today # chains are not part of the reviewed teaching corpus today
# (CAUSE/VERIFICATION only). Rather than fall through to the # (CAUSE/VERIFICATION only). Rather than fall through to the
@ -705,17 +794,32 @@ class ChatRuntime:
# ratified. Honest, deterministic, pack-grounded. # ratified. Honest, deterministic, pack-grounded.
if intent.tag is IntentTag.PROCEDURE: if intent.tag is IntentTag.PROCEDURE:
subject_text = (intent.subject or "").strip() subject_text = (intent.subject or "").strip()
if not subject_text: if subject_text:
surface = pack_grounded_procedure_surface(subject_text)
if surface is not None:
return (surface, "pack")
if intent.tag in (IntentTag.DEFINITION, IntentTag.RECALL):
lemma = (intent.subject or "").strip()
if not lemma:
return None return None
surface = pack_grounded_procedure_surface(subject_text) surface = pack_grounded_surface(lemma)
return (surface, "pack") if surface is not None else None if surface is not None:
if intent.tag not in (IntentTag.DEFINITION, IntentTag.RECALL): return (surface, "pack")
return None # P2.1 — OOV "teach me" surface. If the classified intent is
lemma = (intent.subject or "").strip() # one of the surface-supported shapes AND the subject lemma
if not lemma: # does not resolve in any mounted lexicon pack, emit a
return None # deterministic learning-invitation surface tagged
surface = pack_grounded_surface(lemma) # ``grounding_source="oov"`` instead of falling through to
return (surface, "pack") if surface is not None else None # the universal disclosure. Converts the OOV cliff into a
# gradient that names the unknown token + points the operator
# at the reviewed pack-mutation path.
oov_lemma = (intent.subject or "").strip()
if oov_lemma:
from chat.oov_surface import oov_learning_invitation_surface
oov_surface = oov_learning_invitation_surface(oov_lemma, intent.tag)
if oov_surface is not None:
return (oov_surface, "oov")
return None
def _stub_response( def _stub_response(
self, self,
@ -848,6 +952,15 @@ class ChatRuntime:
intent_subject=discovery_intent_subject, intent_subject=discovery_intent_subject,
grounding_source=grounding_source, grounding_source=grounding_source,
) )
# P2.3 — emit OOV candidate when the surface fired the
# OOV invitation. Only when an operator has attached
# a sink — otherwise this is a no-op.
if grounding_source == "oov":
self._emit_oov_candidate(
turn_event=stub_event,
intent_tag=discovery_intent_tag,
token=discovery_intent_subject,
)
return ChatResponse( return ChatResponse(
surface=response_surface, surface=response_surface,
proposition=prop, proposition=prop,
@ -926,8 +1039,13 @@ class ChatRuntime:
# no sink is attached. # no sink is attached.
discovery_intent_tag = None discovery_intent_tag = None
discovery_intent_subject: str | None = None discovery_intent_subject: str | None = None
# Classify intent up-front when EITHER the discovery sink
# OR the OOV sink is attached (P2.3) — both downstream
# emission paths need it. Without either sink attached
# this is a no-op, so behaviour stays identical to the
# pre-Phase-2 path.
if ( if (
self._discovery_sink is not None (self._discovery_sink is not None or self._oov_sink is not None)
and gate_decision.source == "empty_vault" and gate_decision.source == "empty_vault"
and self.config.output_language == "en" and self.config.output_language == "en"
): ):

184
tests/test_oov_surface.py Normal file
View file

@ -0,0 +1,184 @@
"""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"}

View file

@ -96,13 +96,21 @@ def test_cold_start_comparison_returns_pack_grounded_surface() -> None:
assert "contrasts with" in resp.surface assert "contrasts with" in resp.surface
def test_cold_start_comparison_with_unknown_lemma_disclosure() -> None: def test_cold_start_comparison_with_unknown_lemma_routes_to_partial() -> None:
"""When one term is not a pack lemma, the COMPARISON path returns """ADR-0065 / P2.2 — when exactly one COMPARISON lemma resolves
None and the runtime falls through to the universal disclosure.""" and the other is OOV, the runtime emits the partial-grounding
surface (grounds the known side, hedges the OOV side) instead of
the universal disclosure.
Pre-P2.2 this returned the flat disclosure; post-P2.2 it emits
an explicit partial surface that names which side could be
grounded and which side needs a reviewed PackMutationProposal."""
rt = ChatRuntime() rt = ChatRuntime()
resp = rt.chat("Compare memory and zigzagxyz") resp = rt.chat("Compare memory and zigzagxyz")
assert resp.surface == _UNKNOWN_DOMAIN_SURFACE assert resp.grounding_source == "partial"
assert resp.grounding_source == "none" assert "memory" in resp.surface
assert "zigzagxyz" in resp.surface
assert "PackMutationProposal" in resp.surface
def test_cold_start_comparison_with_identical_lemmas_disclosure() -> None: def test_cold_start_comparison_with_identical_lemmas_disclosure() -> None:

View file

@ -88,32 +88,33 @@ def test_cold_start_recall_returns_pack_grounded_surface() -> None:
assert "light" in resp.surface assert "light" in resp.surface
def test_cold_start_unknown_lemma_returns_universal_disclosure() -> None: def test_cold_start_unknown_lemma_routes_to_oov_invitation() -> None:
"""When the gate fires AND no lemma in the utterance resolves in any """ADR-0065 / P2.1 — when the classifier extracts a clean subject
mounted pack, we fall through to the universal disclosure unchanged. that is OOV, the runtime emits the OOV "teach me" invitation
surface instead of the universal disclosure.
ADR-0061 + ADR-0063 the PROCEDURE composer scans the subject ``How can I quoxulate the wxyzabc?`` is PROCEDURE intent;
phrase for any mounted-pack lemma. This test deliberately uses ``quoxulate`` is OOV. Pre-P2.1 this produced the universal
a fully out-of-pack prompt so neither the cognition nor the disclosure; post-P2.1 it produces an OOV invitation naming the
relations pack catches a topic anchor.""" unknown token + the mounted-pack list."""
rt = ChatRuntime() rt = ChatRuntime()
resp = rt.chat("How can I quoxulate the wxyzabc?") resp = rt.chat("How can I quoxulate the wxyzabc?")
assert resp.surface == _UNKNOWN_DOMAIN_SURFACE assert resp.grounding_source == "oov"
assert resp.grounding_source == "none" assert "quoxulate" in resp.surface or "wxyzabc" in resp.surface
assert "PackMutationProposal" in resp.surface
def test_cold_start_non_definition_intent_no_pack_grounding() -> None: def test_cold_start_cause_on_oov_routes_to_oov_invitation() -> None:
"""CAUSE on a non-pack subject lemma does not engage the """ADR-0065 / P2.1 — CAUSE on an OOV subject also routes to the
pack-grounded DEFINITION path pack-grounded surfaces require a OOV invitation, not the universal disclosure.
pack-resident lemma in any mounted lexicon (ADR-0048 + ADR-0063).
ADR-0052 teaching-grounded surfaces handle CAUSE on subjects that Pre-P2.1 these prompts went silent (CAUSE branch early-returned
appear in the reviewed cognition chains corpus; ``wxyzabc`` is in None when no teaching chain existed). Post-P2.1 the runtime
neither the pack nor the corpus, so the universal disclosure fires.""" explicitly names the gap."""
rt = ChatRuntime() rt = ChatRuntime()
resp = rt.chat("Why does wxyzabc exist?") resp = rt.chat("Why does wxyzabc exist?")
assert resp.grounding_source == "none" assert resp.grounding_source == "oov"
assert resp.surface == _UNKNOWN_DOMAIN_SURFACE assert "wxyzabc" in resp.surface
def test_turn_event_carries_grounding_source() -> None: def test_turn_event_carries_grounding_source() -> None:

View file

@ -0,0 +1,145 @@
"""Phase 2.2 — partial-grounding tier tests.
The contract these tests pin:
- When exactly one of the two compared lemmas resolves in a
mounted pack, the partial composer emits a hedged surface
grounding the known side and disclaiming the OOV side.
- When both resolve OR neither resolves, the composer returns
``None`` the caller routes through the full pack-grounded
composer or the OOV invitation respectively.
- The OOV token is sanitised through ``safe_display``.
- Live runtime: COMPARISON with mixed-residency lemmas tags
``grounding_source="partial"``.
- Terminal punctuation on secondary_subject does not defeat
resolution.
"""
from __future__ import annotations
import pytest
from chat.partial_surface import partial_comparison_surface
from chat.runtime import ChatRuntime
# ---------------------------------------------------------------------------
# Pure-function contract
# ---------------------------------------------------------------------------
def test_oov_first_known_second_emits_partial() -> None:
result = partial_comparison_surface("photosynthesis", "knowledge")
assert result is not None
surface, known_side = result
assert known_side == "b"
assert "knowledge" in surface
assert "photosynthesis" in surface
assert "Whatever 'photosynthesis' is" in surface
assert "pack-grounded (en_core_cognition_v1)" in surface
assert "PackMutationProposal" in surface
def test_known_first_oov_second_emits_partial() -> None:
result = partial_comparison_surface("knowledge", "photosynthesis")
assert result is not None
surface, known_side = result
assert known_side == "a"
assert "Whatever 'photosynthesis' is" in surface
assert "I can ground 'knowledge'" in surface
def test_both_known_returns_none() -> None:
"""Both lemmas resolve — caller should route through the full
pack-grounded comparison composer instead."""
assert partial_comparison_surface("knowledge", "truth") is None
assert partial_comparison_surface("parent", "child") is None
def test_both_oov_returns_none() -> None:
"""Neither lemma resolves — partial-grounding has nothing to
anchor on. Caller routes to the OOV invitation."""
assert partial_comparison_surface("photosynthesis", "mitochondria") is None
assert partial_comparison_surface("aaa", "bbb") is None
def test_identical_lemmas_return_none() -> None:
"""Same-lemma comparison has no contrastive evidence at any tier."""
assert partial_comparison_surface("knowledge", "knowledge") is None
@pytest.mark.parametrize("bad", ["", " ", None])
def test_empty_or_invalid_lemma_returns_none(bad) -> None:
assert partial_comparison_surface(bad, "knowledge") is None # type: ignore[arg-type]
assert partial_comparison_surface("knowledge", bad) is None # type: ignore[arg-type]
def test_surface_is_deterministic() -> None:
a = partial_comparison_surface("photosynthesis", "knowledge")
b = partial_comparison_surface("photosynthesis", "knowledge")
assert a == b
def test_oov_side_is_safe_displayed() -> None:
"""The OOV token comes from user input and must pass through the
safe-display sanitiser; control chars do not leak."""
result = partial_comparison_surface("evil\x00token", "knowledge")
assert result is not None
surface, _ = result
assert "\x00" not in surface
# ---------------------------------------------------------------------------
# Live runtime — COMPARISON with mixed-residency lemmas
# ---------------------------------------------------------------------------
def test_runtime_comparison_known_oov_routes_to_partial() -> None:
rt = ChatRuntime()
resp = rt.chat("Compare knowledge and photosynthesis.")
assert resp.grounding_source == "partial"
assert "photosynthesis" in resp.surface
assert "knowledge" in resp.surface
def test_runtime_comparison_oov_known_routes_to_partial() -> None:
rt = ChatRuntime()
resp = rt.chat("Compare photosynthesis and knowledge.")
assert resp.grounding_source == "partial"
assert "knowledge" in resp.surface
def test_runtime_comparison_both_known_routes_to_pack() -> None:
"""Mixed residency triggers partial; both-known still hits the
full pack-grounded comparison composer (ADR-0050)."""
rt = ChatRuntime()
resp = rt.chat("Compare knowledge and truth.")
assert resp.grounding_source == "pack"
assert "contrasts with" in resp.surface
def test_runtime_comparison_both_oov_routes_to_oov() -> None:
rt = ChatRuntime()
resp = rt.chat("Compare photosynthesis and mitochondria.")
assert resp.grounding_source == "oov"
def test_runtime_comparison_cross_pack_known_routes_to_pack() -> None:
"""Cross-pack comparison (cognition × relations) still pack-grounds
when both lemmas resolve the partial tier is a fallback, not
an interception."""
rt = ChatRuntime()
resp = rt.chat("Compare knowledge and parent.")
assert resp.grounding_source == "pack"
def test_runtime_terminal_punctuation_does_not_defeat_resolution() -> None:
"""The intent classifier may leave a trailing period on
secondary_subject ('Compare A and B.'). The runtime strips
terminal sentence punctuation at the COMPARISON boundary so
resolution finds the underlying lemma."""
rt = ChatRuntime()
resp = rt.chat("Compare knowledge and truth.")
# Without normalization, "truth." was OOV → fired partial.
# With normalization, "truth" resolves → pack.
assert resp.grounding_source == "pack"

View file

@ -147,13 +147,20 @@ def test_procedure_define_010_now_emits_concept() -> None:
assert "concept" in response.surface.lower() assert "concept" in response.surface.lower()
def test_procedure_with_no_pack_lemma_falls_through() -> None: def test_procedure_with_no_pack_lemma_routes_to_oov_invitation() -> None:
"""A procedure utterance with no pack-resident lemma still """ADR-0065 / P2.1 — a procedure utterance with no pack-resident
receives the universal disclosure (no surface fabrication).""" lemma now routes to the OOV invitation surface (names the unknown
topic, points at PackMutationProposal path) instead of the flat
universal disclosure. No surface fabrication: the invitation
only references the OOV token and the mounted-pack list."""
rt = ChatRuntime() rt = ChatRuntime()
response = rt.chat("How do I do stuff?") response = rt.chat("How do I do stuff?")
assert response.grounding_source == "none" # Either UNKNOWN-intent → "none", or PROCEDURE-intent on OOV
assert "insufficient grounding" in response.surface.lower() # subject → "oov" invitation. Both honour the no-fabrication
# contract; the surface text differs by intent classification.
assert response.grounding_source in {"oov", "none"}
if response.grounding_source == "oov":
assert "PackMutationProposal" in response.surface
def test_procedure_verify_a_claim_grounds() -> None: def test_procedure_verify_a_claim_grounds() -> None:

View file

@ -181,11 +181,14 @@ def test_cold_start_verification_memory_returns_teaching_surface() -> None:
assert "recall" in resp.surface assert "recall" in resp.surface
def test_cold_start_cause_unknown_subject_disclosure() -> None: def test_cold_start_cause_unknown_subject_routes_to_oov_invitation() -> None:
"""ADR-0065 / P2.1 — CAUSE on an OOV subject routes through the
OOV invitation surface (subject is OOV no chain fall-through
to OOV). Pre-P2.1 this returned the universal disclosure."""
rt = ChatRuntime() rt = ChatRuntime()
resp = rt.chat("Why does dragon exist?") resp = rt.chat("Why does dragon exist?")
assert resp.surface == _UNKNOWN_DOMAIN_SURFACE assert resp.grounding_source == "oov"
assert resp.grounding_source == "none" assert "dragon" in resp.surface
def test_turn_event_carries_grounding_source_teaching() -> None: def test_turn_event_carries_grounding_source_teaching() -> None: