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.
76 lines
2.8 KiB
Python
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
|