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.
315 lines
11 KiB
Python
315 lines
11 KiB
Python
"""ADR-0024 Phase 2 — refusal contract.
|
|
|
|
Pins the typed-refusal contract introduced in Phase 2:
|
|
|
|
1. ``generate.stream`` raises ``InnerLoopExhaustion`` (not bare
|
|
``ValueError``) at both inner-loop exhaustion sites.
|
|
2. ``InnerLoopExhaustion`` is a ``ValueError`` subclass, so every
|
|
pre-Phase-2 ``except ValueError`` handler still catches it.
|
|
3. Each raise carries evidence: ``reason``, ``region_label``,
|
|
``step_index``, and (for in-walk site) ``rejected_attempts``.
|
|
4. Pre-walk site uses ``step_index == -1`` and empty
|
|
``rejected_attempts``; in-walk site uses ``step_index >= 0`` and
|
|
non-empty ``rejected_attempts``.
|
|
5. ``compute_trace_hash`` accepts ``refusal_reason`` and:
|
|
a. byte-identical hash when ``refusal_reason == ""`` (default),
|
|
relative to the same call without the kwarg → preserves
|
|
pre-Phase-2 hashes for non-refused turns;
|
|
b. different hash when ``refusal_reason`` is set to a non-empty
|
|
value, so refusal is load-bearing in replay equality once
|
|
materialised.
|
|
6. ``CognitiveTurnResult`` exposes ``refusal_reason`` defaulting to
|
|
"" so call sites can be ignorant of refusal materialisation
|
|
during the Phase 2 → Phase ? transition window.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from core.cognition.result import CognitiveTurnResult
|
|
from core.cognition.trace import compute_trace_hash
|
|
from field.state import FieldState
|
|
from generate.admissibility import AdmissibilityRegion, RegionSource
|
|
from generate.exhaustion import InnerLoopExhaustion, RefusalReason
|
|
from generate.stream import generate
|
|
|
|
|
|
# --------------------------------------------------------------------
|
|
# Stub vocab / persona — mirrors test_inner_loop_admissibility.py
|
|
# --------------------------------------------------------------------
|
|
|
|
|
|
class _ControllableVocab:
|
|
def __init__(self, *, words, preference, versor_signs):
|
|
self._words = words
|
|
self._preference = preference
|
|
self._versors = []
|
|
for sign in versor_signs:
|
|
v = np.zeros(32, dtype=np.float32)
|
|
v[1] = float(sign)
|
|
self._versors.append(v)
|
|
|
|
def __len__(self):
|
|
return len(self._words)
|
|
|
|
def nearest(self, F, exclude_idx=-1, exclude_indices=None, candidate_indices=None):
|
|
blocked = set(exclude_indices or ())
|
|
if candidate_indices is not None:
|
|
allowed = {int(i) for i in candidate_indices}
|
|
else:
|
|
allowed = set(range(len(self._words)))
|
|
for idx in self._preference:
|
|
if idx == exclude_idx or idx in blocked or idx not in allowed:
|
|
continue
|
|
return self._words[idx], idx
|
|
raise ValueError("No candidate word available after exclusions.")
|
|
|
|
def get_versor_at(self, idx):
|
|
return self._versors[idx]
|
|
|
|
def index_of(self, word):
|
|
try:
|
|
return self._words.index(word)
|
|
except ValueError:
|
|
raise KeyError(word)
|
|
|
|
|
|
class _IdentityPersona:
|
|
def apply(self, F):
|
|
return F
|
|
|
|
|
|
def _initial_state():
|
|
F = np.zeros(32, dtype=np.float32)
|
|
F[1] = 1.0
|
|
return FieldState(F=F, node=0, step=0)
|
|
|
|
|
|
def _positive_blade_region(allowed, label="adr0024-phase2-test"):
|
|
blade = np.zeros(32, dtype=np.float32)
|
|
blade[1] = 1.0
|
|
return AdmissibilityRegion(
|
|
allowed_indices=np.asarray(allowed, dtype=np.int64),
|
|
relation_blade=blade,
|
|
source=RegionSource.RELATION,
|
|
label=label,
|
|
)
|
|
|
|
|
|
def _empty_region(label="adr0024-phase2-empty"):
|
|
"""Region whose allowed-index intersection with the candidate set
|
|
is empty before any step runs — exercises the pre-walk raise site."""
|
|
blade = np.zeros(32, dtype=np.float32)
|
|
blade[1] = 1.0
|
|
return AdmissibilityRegion(
|
|
allowed_indices=np.asarray([], dtype=np.int64),
|
|
relation_blade=blade,
|
|
source=RegionSource.RELATION,
|
|
label=label,
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------
|
|
# Property 1 + 2 + 3 + 4: exception type, subclassing, evidence
|
|
# --------------------------------------------------------------------
|
|
|
|
|
|
class TestExceptionIsValueErrorSubclass:
|
|
"""Pre-Phase-2 handlers caught ValueError; Phase 2 must remain
|
|
backwards-compatible."""
|
|
|
|
def test_inner_loop_exhaustion_is_value_error(self) -> None:
|
|
exc = InnerLoopExhaustion(
|
|
reason=RefusalReason.INNER_LOOP_EXHAUSTION,
|
|
region_label="r",
|
|
step_index=-1,
|
|
)
|
|
assert isinstance(exc, ValueError)
|
|
|
|
def test_in_walk_exhaustion_caught_as_value_error(self) -> None:
|
|
# Both admissible candidates rejected — exercises the in-walk
|
|
# raise site at the loop's ``else`` branch.
|
|
vocab = _ControllableVocab(
|
|
words=["seed", "alpha", "beta"],
|
|
preference=[1, 2],
|
|
versor_signs=[+1.0, -1.0, -1.0],
|
|
)
|
|
with pytest.raises(ValueError):
|
|
generate(
|
|
_initial_state(),
|
|
vocab,
|
|
_IdentityPersona(),
|
|
max_tokens=1,
|
|
region=_positive_blade_region((1, 2)),
|
|
inner_loop_admissibility=True,
|
|
)
|
|
|
|
def test_pre_walk_exhaustion_caught_as_value_error(self) -> None:
|
|
vocab = _ControllableVocab(
|
|
words=["seed", "alpha", "beta"],
|
|
preference=[1, 2],
|
|
versor_signs=[+1.0, +1.0, +1.0],
|
|
)
|
|
with pytest.raises(ValueError):
|
|
generate(
|
|
_initial_state(),
|
|
vocab,
|
|
_IdentityPersona(),
|
|
max_tokens=1,
|
|
region=_empty_region(),
|
|
inner_loop_admissibility=True,
|
|
)
|
|
|
|
|
|
class TestInWalkExhaustionCarriesEvidence:
|
|
def test_typed_exception_with_full_evidence(self) -> None:
|
|
vocab = _ControllableVocab(
|
|
words=["seed", "alpha", "beta"],
|
|
preference=[1, 2],
|
|
versor_signs=[+1.0, -1.0, -1.0],
|
|
)
|
|
with pytest.raises(InnerLoopExhaustion) as excinfo:
|
|
generate(
|
|
_initial_state(),
|
|
vocab,
|
|
_IdentityPersona(),
|
|
max_tokens=1,
|
|
region=_positive_blade_region((1, 2), label="in-walk-label"),
|
|
inner_loop_admissibility=True,
|
|
)
|
|
exc = excinfo.value
|
|
assert exc.reason is RefusalReason.INNER_LOOP_EXHAUSTION
|
|
assert exc.region_label == "in-walk-label"
|
|
# In-walk site fires at step_index >= 0.
|
|
assert exc.step_index >= 0
|
|
# Both admissible candidates rejected ⇒ rejected_attempts
|
|
# records each in attempt order.
|
|
assert len(exc.rejected_attempts) >= 1
|
|
for idx, word, score in exc.rejected_attempts:
|
|
assert isinstance(idx, int)
|
|
assert isinstance(word, str)
|
|
assert isinstance(score, float)
|
|
# Message preserves the Phase 1 contract — region label is
|
|
# embedded so existing ``match=region.label`` callers still pass.
|
|
assert "in-walk-label" in str(exc)
|
|
|
|
|
|
class TestPreWalkExhaustionCarriesEvidence:
|
|
def test_typed_exception_with_pre_walk_sentinel(self) -> None:
|
|
vocab = _ControllableVocab(
|
|
words=["seed", "alpha", "beta"],
|
|
preference=[1, 2],
|
|
versor_signs=[+1.0, +1.0, +1.0],
|
|
)
|
|
with pytest.raises(InnerLoopExhaustion) as excinfo:
|
|
generate(
|
|
_initial_state(),
|
|
vocab,
|
|
_IdentityPersona(),
|
|
max_tokens=1,
|
|
region=_empty_region(label="pre-walk-label"),
|
|
inner_loop_admissibility=True,
|
|
)
|
|
exc = excinfo.value
|
|
assert exc.reason is RefusalReason.INNER_LOOP_EXHAUSTION
|
|
assert exc.region_label == "pre-walk-label"
|
|
# Pre-walk site is marked with the -1 sentinel.
|
|
assert exc.step_index == -1
|
|
# No inner-loop rejections were issued — the intersection was
|
|
# empty before any step ran.
|
|
assert exc.rejected_attempts == ()
|
|
assert "pre-walk-label" in str(exc)
|
|
|
|
|
|
class TestPreWalkSiteFiresEvenWithoutInnerLoop:
|
|
"""The pre-walk site predates inner-loop wiring (ADR-0023) — it
|
|
still must raise the typed exception so refusal evidence is
|
|
uniform whether or not the inner loop is on."""
|
|
|
|
def test_pre_walk_raise_typed_when_inner_loop_off(self) -> None:
|
|
vocab = _ControllableVocab(
|
|
words=["seed", "alpha", "beta"],
|
|
preference=[1, 2],
|
|
versor_signs=[+1.0, +1.0, +1.0],
|
|
)
|
|
with pytest.raises(InnerLoopExhaustion) as excinfo:
|
|
generate(
|
|
_initial_state(),
|
|
vocab,
|
|
_IdentityPersona(),
|
|
max_tokens=1,
|
|
region=_empty_region(label="pre-walk-no-inner"),
|
|
inner_loop_admissibility=False,
|
|
)
|
|
assert excinfo.value.step_index == -1
|
|
assert excinfo.value.rejected_attempts == ()
|
|
|
|
|
|
# --------------------------------------------------------------------
|
|
# Property 5: compute_trace_hash determinism contract
|
|
# --------------------------------------------------------------------
|
|
|
|
|
|
_BASE_HASH_KWARGS = dict(
|
|
input_text="hello",
|
|
filtered_tokens=("hello",),
|
|
surface="hi",
|
|
walk_surface="hi",
|
|
articulation_surface="hi",
|
|
dialogue_role="assert",
|
|
versor_condition=0.0,
|
|
vault_hits=0,
|
|
intent_tag="declare",
|
|
)
|
|
|
|
|
|
class TestTraceHashRefusalReasonFold:
|
|
def test_default_refusal_reason_preserves_legacy_hash(self) -> None:
|
|
"""A non-refused turn (refusal_reason="") must produce the
|
|
same trace_hash as a call that omits the kwarg entirely. This
|
|
is what protects every pre-Phase-2 turn hash from drift."""
|
|
legacy = compute_trace_hash(**_BASE_HASH_KWARGS)
|
|
with_kwarg = compute_trace_hash(refusal_reason="", **_BASE_HASH_KWARGS)
|
|
assert legacy == with_kwarg
|
|
|
|
def test_non_empty_refusal_reason_changes_hash(self) -> None:
|
|
"""Once a refusal is materialised, the reason becomes
|
|
load-bearing — different reasons must yield different hashes."""
|
|
base = compute_trace_hash(**_BASE_HASH_KWARGS)
|
|
with_reason = compute_trace_hash(
|
|
refusal_reason=RefusalReason.INNER_LOOP_EXHAUSTION.value,
|
|
**_BASE_HASH_KWARGS,
|
|
)
|
|
assert base != with_reason
|
|
|
|
def test_same_refusal_reason_is_stable(self) -> None:
|
|
"""Determinism: identical inputs ⇒ identical hash."""
|
|
h1 = compute_trace_hash(
|
|
refusal_reason=RefusalReason.INNER_LOOP_EXHAUSTION.value,
|
|
**_BASE_HASH_KWARGS,
|
|
)
|
|
h2 = compute_trace_hash(
|
|
refusal_reason=RefusalReason.INNER_LOOP_EXHAUSTION.value,
|
|
**_BASE_HASH_KWARGS,
|
|
)
|
|
assert h1 == h2
|
|
|
|
|
|
# --------------------------------------------------------------------
|
|
# Property 6: CognitiveTurnResult exposes refusal_reason with safe default
|
|
# --------------------------------------------------------------------
|
|
|
|
|
|
class TestCognitiveTurnResultRefusalField:
|
|
def test_refusal_reason_defaults_to_empty(self) -> None:
|
|
# We do not need to construct a full CognitiveTurnResult here;
|
|
# we only need to confirm the dataclass declares the field with
|
|
# the empty-string default. Use dataclasses.fields() so we
|
|
# don't have to assemble all the other required fields.
|
|
import dataclasses
|
|
|
|
names = {f.name: f for f in dataclasses.fields(CognitiveTurnResult)}
|
|
assert "refusal_reason" in names
|
|
assert names["refusal_reason"].default == ""
|