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:
parent
db0f34f4d2
commit
9d31f80fc8
5 changed files with 185 additions and 30 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
52
tests/test_inner_loop_exhaustion_materializes.py
Normal file
52
tests/test_inner_loop_exhaustion_materializes.py
Normal 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 == ""
|
||||
76
tests/test_recognition_refusal_propagates.py
Normal file
76
tests/test_recognition_refusal_propagates.py
Normal 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
|
||||
Loading…
Reference in a new issue