"""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" # Case-insensitive — gloss-backed surface capitalizes ``Light`` # at sentence start. assert "light" in resp.surface.lower() 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"}