core/tests/test_recognition_refusal_propagates.py
Shay 9d31f80fc8
fix(W-011/W-012): propagate recognition refusal + catch InnerLoopExhaustion (#258)
W-011: recognition refusal_reason now materializes in
CognitiveTurnResult.refusal_reason via RECOGNITION_REFUSED enum value.
Precedence: recognition wins over generation (earlier-fail boundary).

W-012: ChatRuntime.chat() catches InnerLoopExhaustion from generate()
and returns a typed refusal ChatResponse with refusal_reason populated,
instead of propagating as an unhandled exception.

Adds RefusalReason.RECOGNITION_REFUSED to generate/exhaustion.py.

Lane SHAs: 7/7 match (demos don't exercise refusal paths — no re-pin).
Smoke + cognition suites green. Full suite not run to completion.
2026-05-24 20:46:46 -07:00

76 lines
2.8 KiB
Python

"""W-011 — recognition refusal propagates to CognitiveTurnResult.refusal_reason.
Pin: when a DerivedRecognizer is attached and recognition refuses the input,
CognitiveTurnResult.refusal_reason is populated with the RECOGNITION_REFUSED
code. Happy-path (admitted) turns keep refusal_reason empty.
"""
from __future__ import annotations
import pytest
from chat.runtime import ChatRuntime
from core.cognition.pipeline import CognitiveTurnPipeline
from generate.exhaustion import RefusalReason
from recognition.anti_unifier import DerivedRecognizer, derive_recognizer
from recognition.outcome import (
EvidenceSpan,
FeatureBundle,
NegativeEvidence,
)
def _span(tokens: tuple[str, ...], s: int, e: int) -> EvidenceSpan:
return EvidenceSpan(start=s, end=e, text=" ".join(tokens[s:e]))
def _make_teaching_examples() -> list[tuple[tuple[str, ...], FeatureBundle]]:
rows = [
("John", "has", "5", "apples"),
("Mary", "has", "3", "books"),
]
examples = []
for tokens in rows:
has_idx = tokens.index("has")
bundle = FeatureBundle.from_mapping({
"agent": (tokens[0], _span(tokens, 0, 1)),
"count": (int(tokens[has_idx + 1]), _span(tokens, has_idx + 1, has_idx + 2)),
"relation": ("has", _span(tokens, has_idx, has_idx + 1)),
"unit": (tokens[has_idx + 2], _span(tokens, has_idx + 2, has_idx + 3)),
"polarity": ("+", NegativeEvidence(0, len(tokens), "no negator")),
})
examples.append((tokens, bundle))
return examples
@pytest.fixture(scope="module")
def recognizer() -> DerivedRecognizer:
return derive_recognizer(_make_teaching_examples())
class TestRecognitionRefusalPropagates:
"""W-011: recognition refusal materializes in CognitiveTurnResult."""
def test_refused_turn_has_recognition_refusal_reason(
self, recognizer: DerivedRecognizer
) -> None:
runtime = ChatRuntime()
pipeline = CognitiveTurnPipeline(runtime, recognizer=recognizer)
result = pipeline.run("something completely unrelated to the pattern")
assert result.refusal_reason == RefusalReason.RECOGNITION_REFUSED.value
def test_admitted_turn_has_empty_refusal_reason(
self, recognizer: DerivedRecognizer
) -> None:
runtime = ChatRuntime()
pipeline = CognitiveTurnPipeline(runtime, recognizer=recognizer)
result = pipeline.run("John has 5 apples")
assert result.refusal_reason == ""
def test_recognition_refusal_wins_over_generation_refusal(
self, recognizer: DerivedRecognizer
) -> None:
runtime = ChatRuntime()
pipeline = CognitiveTurnPipeline(runtime, recognizer=recognizer)
result = pipeline.run("xyz totally unknown input")
assert result.refusal_reason == RefusalReason.RECOGNITION_REFUSED.value