Replace plain ValueError at both inner-loop exhaustion sites in
generate/stream.py with InnerLoopExhaustion, a typed ValueError
subclass carrying machine-readable refusal evidence:
reason : RefusalReason (INNER_LOOP_EXHAUSTION)
region_label : which AdmissibilityRegion blocked
step_index : -1 = pre-walk empty intersection;
>=0 = in-walk per-step exhaustion
rejected_attempts : ordered (idx, word, score) triples
Backward-compat by construction: subclassing ValueError preserves
every pre-Phase-2 `except ValueError` handler in chat/runtime.py,
eval lanes, and tests. No edits to chat/runtime.py, field/propagate.py,
algebra/versor.py, or vault/store.py.
Trace path wired:
- CognitiveTurnResult.refusal_reason (str, default "")
- compute_trace_hash folds refusal_reason only when non-empty
-> byte-identical hashes preserved for non-refused turns
- CognitiveTurnPipeline reads via getattr from ChatResponse and
forwards into both trace_hash and result construction
Contract documented in docs/runtime_contracts.md §"Refusal contract".
Tests (tests/test_refusal_contract.py — 10 passing):
- InnerLoopExhaustion isinstance(ValueError) at both raise sites
- In-walk site carries reason/region_label/step_index>=0/
rejected_attempts with (int,str,float) triples
- Pre-walk site uses step_index=-1 sentinel + empty
rejected_attempts
- Pre-walk fires even when inner_loop_admissibility=False
- Trace hash: empty refusal_reason preserves legacy bytes;
non-empty differs; same inputs are stable
Suite results:
smoke: 67 passed
cognition: 121 passed
runtime: 19 passed
full: 1024 passed, 2 skipped
core eval cognition: 13/13, 100% intent accuracy, 100% versor closure
Residual silent path (documented as out-of-scope for Phase 2):
chat/runtime.respond()/arespond() still convert any ValueError to
"" for their public str return contract. So a refused turn today
produces surface == "" with refusal_reason == "" — the typed
evidence is unread between the raise site and the result. The
plumbing on result + trace + pipeline is in place so a future ADR
can wire materialisation (propagate exception to
ChatResponse.refusal_reason, or catch at the pipeline seam) without
re-deriving the contract.
Phase 1 (commit 3940290) and Phase 2 (this commit) were developed
in parallel with disjoint file scope to avoid conflicts.
112 lines
4.4 KiB
Python
112 lines
4.4 KiB
Python
"""
|
|
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 are minimal in Phase 2. A single
|
|
``INNER_LOOP_EXHAUSTION`` reason covers both raise sites in
|
|
``generate/stream.py``:
|
|
|
|
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`` at the configured threshold.
|
|
``step_index >= 0`` and ``rejected_attempts`` records the tried
|
|
(index, word, score) triples in attempt order.
|
|
|
|
Splitting these into separate reasons can wait for Phase 4, when
|
|
rotor-frame refusal introduces a third structurally distinct mode
|
|
(ADR-0025).
|
|
"""
|
|
|
|
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"
|
|
|
|
|
|
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}."
|
|
)
|