""" InnerLoopExhaustion — evidence-carrying honest refusal at the walk site. ADR-0024 Phase 2. When the inner-loop admissibility check leaves no admissible destination for the next step, the walk must refuse rather than silently relax the constraint. Phase 1 raised a plain ``ValueError`` with a message string; Phase 2 promotes that to a typed exception that carries the refusal evidence (region label, step index, rejected attempts, machine-readable reason) so downstream layers can materialise the refusal into trace evidence without re-parsing the message. This is *not* normalization or repair (CLAUDE.md §Normalization Rules). The walk still refuses at the same site, with the same algebra, with the same effect on caller control flow. The exception subclasses ``ValueError`` so every existing ``except ValueError`` handler in the runtime/eval code paths continues to work byte-for-byte — the carrying of structured evidence is additive. Reason codes: ``INNER_LOOP_EXHAUSTION`` covers two raise sites in ``generate/stream.py`` — both express "destination-side admissibility produced no admissible candidate": 1. Pre-walk: the region's allowed-index intersection with the candidate set is empty before any step ran. ``step_index = -1`` and ``rejected_attempts = ()`` distinguish this site — no inner-loop rejections were issued; the region was already empty. 2. In-walk: at some step, every candidate in the admissible set was rejected by ``check_transition`` (threshold mode) or ``check_margin`` (Phase 3 / ADR-0026, margin mode). ``step_index >= 0`` and ``rejected_attempts`` records the tried (index, word, score) triples in attempt order (threshold) or the full ranking (margin). ``ROTOR_REJECTION`` (Phase 4 / ADR-0025) is the rotor-side analogue. Raised when no admissible destination produces a rotor that lands the field within the region's frame versor cone. Carries the same trace shape: ``rejected_attempts`` records the rotor-side scores (``cga_inner(F_after, frame_versor)``) for each rejected candidate. Splitting this from ``INNER_LOOP_EXHAUSTION`` keeps the trace self-explanatory — a refused turn says *which* admissibility axis ran out, destination-blade or rotor-frame. """ from __future__ import annotations from enum import Enum, unique @unique class RefusalReason(Enum): """Machine-readable refusal taxonomy. The string value is what flows into ``trace_hash`` payloads when refusal materialisation is wired through a future ADR. Stable string values are part of the replay contract. """ INNER_LOOP_EXHAUSTION = "inner_loop_exhaustion" # Phase 4 / ADR-0025 — rotor side: every candidate's rotor would # push the field outside the region's frame-versor admissible # cone (``cga_inner(F_after, frame_versor) <= 0`` after # sandwiching). Distinct from INNER_LOOP_EXHAUSTION so the # 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): """Honest refusal raised by the generation walk. Subclasses ``ValueError`` so pre-Phase-2 ``except ValueError`` handlers in ``chat/runtime.py``, ``evals/...``, and tests still catch it without modification. Attributes ---------- reason : RefusalReason Machine-readable taxonomy code. Phase 2 uses a single value; Phase 4 (rotor frame admissibility) is expected to add more. region_label : str The ``AdmissibilityRegion.label`` that produced the refusal — the operator-visible identifier of which constraint blocked propagation. step_index : int Index of the step at which the inner loop exhausted. ``-1`` marks the pre-walk site (region intersection empty before any step ran); non-negative values identify the in-walk site. rejected_attempts : tuple[tuple[int, str, float], ...] Ordered record of ``(candidate_index, word, score)`` triples that the inner-loop check rejected at the step that failed. Empty tuple for the pre-walk site. ``str(exc)`` returns the human-readable message — preserving the Phase 1 exception message contract for callers that only inspected ``str(e)``. Instances are logically immutable: attributes are set once in ``__init__`` and should not be reassigned. """ __slots__ = ("reason", "region_label", "step_index", "rejected_attempts") def __init__( self, *, reason: RefusalReason, region_label: str, step_index: int, rejected_attempts: tuple[tuple[int, str, float], ...] = (), message: str | None = None, ) -> None: self.reason = reason self.region_label = region_label self.step_index = step_index self.rejected_attempts = tuple(rejected_attempts) super().__init__(message if message is not None else self._default_message()) def _default_message(self) -> str: if self.step_index < 0: return ( f"AdmissibilityRegion[{self.region_label}] left no walk candidates." ) return ( f"AdmissibilityRegion[{self.region_label}] inner-loop " f"rejected all candidates at step {self.step_index}." )