From 9d31f80fc82ff6193777f239e3181e30b09f809f Mon Sep 17 00:00:00 2001 From: Shay Date: Sun, 24 May 2026 20:46:46 -0700 Subject: [PATCH] fix(W-011/W-012): propagate recognition refusal + catch InnerLoopExhaustion (#258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- chat/runtime.py | 65 ++++++++++------ core/cognition/pipeline.py | 16 ++-- generate/exhaustion.py | 6 ++ ...test_inner_loop_exhaustion_materializes.py | 52 +++++++++++++ tests/test_recognition_refusal_propagates.py | 76 +++++++++++++++++++ 5 files changed, 185 insertions(+), 30 deletions(-) create mode 100644 tests/test_inner_loop_exhaustion_materializes.py create mode 100644 tests/test_recognition_refusal_propagates.py diff --git a/chat/runtime.py b/chat/runtime.py index f1f664eb..93462f5f 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -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 obj slots diff --git a/core/cognition/pipeline.py b/core/cognition/pipeline.py index 0f6369f1..c602d62f 100644 --- a/core/cognition/pipeline.py +++ b/core/cognition/pipeline.py @@ -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, diff --git a/generate/exhaustion.py b/generate/exhaustion.py index 5b51f926..d7f4ec1c 100644 --- a/generate/exhaustion.py +++ b/generate/exhaustion.py @@ -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): diff --git a/tests/test_inner_loop_exhaustion_materializes.py b/tests/test_inner_loop_exhaustion_materializes.py new file mode 100644 index 00000000..d142f43b --- /dev/null +++ b/tests/test_inner_loop_exhaustion_materializes.py @@ -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 == "" diff --git a/tests/test_recognition_refusal_propagates.py b/tests/test_recognition_refusal_propagates.py new file mode 100644 index 00000000..2b494c69 --- /dev/null +++ b/tests/test_recognition_refusal_propagates.py @@ -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