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.
This commit is contained in:
Shay 2026-05-24 20:46:46 -07:00 committed by GitHub
parent db0f34f4d2
commit 9d31f80fc8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 185 additions and 30 deletions

View file

@ -1823,30 +1823,47 @@ class ChatRuntime:
)
)
result = generate(
field_state,
self._context.vocab,
self._context.persona,
max_tokens=self.config.max_tokens if max_tokens is None else max_tokens,
record_trajectory=True,
vault=self._context.vault,
recall_top_k=3 if self.config.allow_cross_language_recall else 0,
output_lang=self.config.output_language,
allow_cross_language_generation=self.config.allow_cross_language_generation,
use_salience=self.config.use_salience,
salience_top_k=self.config.salience_top_k,
inhibition_threshold=self.config.inhibition_threshold,
region=forward_region,
inner_loop_admissibility=self.config.inner_loop_admissibility,
admissibility_threshold=self.config.admissibility_threshold,
admissibility_mode=self.config.admissibility_mode,
admissibility_margin=self.config.admissibility_margin,
stop_tokens=(
frozenset(self.config.stop_tokens)
if self.config.stop_tokens is not None
else None
),
)
# W-012 — catch InnerLoopExhaustion so the caller receives a
# typed refusal ChatResponse instead of an unhandled exception.
from generate.exhaustion import InnerLoopExhaustion as _ILE
try:
result = generate(
field_state,
self._context.vocab,
self._context.persona,
max_tokens=self.config.max_tokens if max_tokens is None else max_tokens,
record_trajectory=True,
vault=self._context.vault,
recall_top_k=3 if self.config.allow_cross_language_recall else 0,
output_lang=self.config.output_language,
allow_cross_language_generation=self.config.allow_cross_language_generation,
use_salience=self.config.use_salience,
salience_top_k=self.config.salience_top_k,
inhibition_threshold=self.config.inhibition_threshold,
region=forward_region,
inner_loop_admissibility=self.config.inner_loop_admissibility,
admissibility_threshold=self.config.admissibility_threshold,
admissibility_mode=self.config.admissibility_mode,
admissibility_margin=self.config.admissibility_margin,
stop_tokens=(
frozenset(self.config.stop_tokens)
if self.config.stop_tokens is not None
else None
),
)
except _ILE as _exhaustion_exc:
self._context.finalize_turn(
GenerationResult(tokens=(), final_state=field_state, vault_hits=0),
tokens_in=tuple(filtered),
input_versor=field_state.F,
dialogue_role="assert",
metadata={"exhaustion": True, "refusal_reason": _exhaustion_exc.reason.value},
)
stub = self._stub_response(
field_state,
tokens=tuple(filtered),
)
return replace(stub, refusal_reason=_exhaustion_exc.reason.value)
# --- Articulation fidelity: replace bare S-P-O join with intent-aware surface ---
# Phase 2: pass proposition so the bridge grounds <pending> obj slots

View file

@ -156,6 +156,9 @@ class CognitiveTurnPipeline:
# Admitted → wrap in EpistemicGraph for observability and optional
# connector-grounded articulation. Refused or absent → None.
epistemic_graph: EpistemicGraph | None = None
# W-011 — recognition refusal_reason, materialized below into
# CognitiveTurnResult.refusal_reason when non-empty.
_recognition_refusal_reason: str = ""
if self._recognizer is not None:
_rec_outcome = recognize(self._recognizer, raw_tokens)
if _rec_outcome.admitted:
@ -167,6 +170,9 @@ class CognitiveTurnPipeline:
nodes=(_ep_node,),
recognizer_id=self._recognizer.teaching_set_id,
)
elif _rec_outcome.refusal_reason is not None:
from generate.exhaustion import RefusalReason as _ExhaustionRefusalReason
_recognition_refusal_reason = _ExhaustionRefusalReason.RECOGNITION_REFUSED.value
# 1. LISTEN — capture pre-turn field state
field_state_before: FieldState | None = self._capture_field_state()
@ -390,12 +396,10 @@ class CognitiveTurnPipeline:
else _ratification_outcome_raw
)
_trace_ratification_outcome = ratification_outcome
# ADR-0024 Phase 2 — refusal_reason flows from a future
# materialisation site on ChatResponse. Empty string on every
# non-refused turn; folding into trace_hash is gated on
# non-emptiness so non-refused turns keep byte-identical hashes
# relative to pre-Phase-2 (CLAUDE.md determinism invariant).
refusal_reason = getattr(response, "refusal_reason", "") or ""
# ADR-0024 Phase 2 + W-011 — refusal_reason precedence:
# recognition wins (earlier-fail boundary) over generation.
_generation_refusal_reason = getattr(response, "refusal_reason", "") or ""
refusal_reason = _recognition_refusal_reason or _generation_refusal_reason
trace_hash = compute_trace_hash(
input_text=text,
filtered_tokens=filtered_tokens,

View file

@ -67,6 +67,12 @@ class RefusalReason(Enum):
# trace can tell destination-blade refusal from rotor-frame
# refusal without re-parsing the message.
ROTOR_REJECTION = "rotor_rejection"
# W-011 — recognition-side refusals. The DerivedRecognizer
# refused the input (shape mismatch, missing feature evidence,
# or feature contradiction). Folded into CognitiveTurnResult
# .refusal_reason so the pipeline boundary does not discard the
# typed recognition refusal.
RECOGNITION_REFUSED = "recognition_refused"
class InnerLoopExhaustion(ValueError):

View file

@ -0,0 +1,52 @@
"""W-012 — InnerLoopExhaustion materializes as ChatResponse (not unhandled exception).
Pin: when the generation walk raises InnerLoopExhaustion, ChatRuntime.chat()
catches it and returns a ChatResponse with refusal_reason populated with the
typed exhaustion code.
"""
from __future__ import annotations
from unittest.mock import patch
from chat.runtime import ChatResponse, ChatRuntime
from generate.exhaustion import InnerLoopExhaustion, RefusalReason
def _make_exhaustion(reason: RefusalReason = RefusalReason.INNER_LOOP_EXHAUSTION):
return InnerLoopExhaustion(
reason=reason,
region_label="test-region",
step_index=0,
rejected_attempts=((-1, "word", 0.1),),
)
class TestInnerLoopExhaustionMaterializes:
"""W-012: InnerLoopExhaustion returns a ChatResponse, not a crash."""
def test_exhaustion_returns_chat_response_with_refusal_reason(self) -> None:
runtime = ChatRuntime()
# Warm the vault so the generate() path is reached.
runtime.chat("what is truth")
with patch("chat.runtime.generate", side_effect=_make_exhaustion()):
response = runtime.chat("what is truth")
assert isinstance(response, ChatResponse)
assert response.refusal_reason == RefusalReason.INNER_LOOP_EXHAUSTION.value
def test_rotor_rejection_materializes(self) -> None:
runtime = ChatRuntime()
runtime.chat("what is truth")
with patch(
"chat.runtime.generate",
side_effect=_make_exhaustion(RefusalReason.ROTOR_REJECTION),
):
response = runtime.chat("what is truth")
assert isinstance(response, ChatResponse)
assert response.refusal_reason == RefusalReason.ROTOR_REJECTION.value
def test_happy_path_no_refusal_reason(self) -> None:
runtime = ChatRuntime()
response = runtime.chat("what is truth")
assert isinstance(response, ChatResponse)
assert response.refusal_reason == ""

View file

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