From 310793a4ea5d96f84ad3df3ffe93adaf84e96ab9 Mon Sep 17 00:00:00 2001 From: Shay Date: Sun, 17 May 2026 14:49:08 -0700 Subject: [PATCH] =?UTF-8?q?feat(adr-0024):=20Phase=202=20=E2=80=94=20hones?= =?UTF-8?q?t=20refusal=20with=20typed=20evidence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/cognition/pipeline.py | 8 + core/cognition/result.py | 13 ++ core/cognition/trace.py | 9 + docs/runtime_contracts.md | 41 +++++ generate/exhaustion.py | 112 ++++++++++++ generate/stream.py | 39 +++- tests/test_refusal_contract.py | 315 +++++++++++++++++++++++++++++++++ 7 files changed, 529 insertions(+), 8 deletions(-) create mode 100644 generate/exhaustion.py create mode 100644 tests/test_refusal_contract.py diff --git a/core/cognition/pipeline.py b/core/cognition/pipeline.py index f0512667..e2424cb5 100644 --- a/core/cognition/pipeline.py +++ b/core/cognition/pipeline.py @@ -254,6 +254,12 @@ class CognitiveTurnPipeline: ) admissibility_trace_hash = hash_admissibility_trace(admissibility_trace) ratification_outcome = ratified.outcome.value + # ADR-0024 Phase 2 — refusal_reason flows from a future + # materialisation site on ChatResponse. For Phase 2 it is + # absent on every non-refused turn; reading via getattr keeps + # the trace_hash byte-identical to pre-Phase-2 when no refusal + # was materialised (the empty string skips the payload fold). + refusal_reason = getattr(response, "refusal_reason", "") or "" trace_hash = compute_trace_hash( input_text=text, filtered_tokens=filtered_tokens, @@ -271,6 +277,7 @@ class CognitiveTurnPipeline: admissibility_trace_hash=admissibility_trace_hash, ratification_outcome=ratification_outcome, region_was_unconstrained=region_was_unconstrained, + refusal_reason=refusal_reason, ) return CognitiveTurnResult( @@ -298,6 +305,7 @@ class CognitiveTurnPipeline: admissibility_trace_hash=admissibility_trace_hash, ratification_outcome=ratification_outcome, region_was_unconstrained=region_was_unconstrained, + refusal_reason=refusal_reason, versor_condition=response.versor_condition, trace_hash=trace_hash, ) diff --git a/core/cognition/result.py b/core/cognition/result.py index 43a7a68b..87924f62 100644 --- a/core/cognition/result.py +++ b/core/cognition/result.py @@ -87,6 +87,19 @@ class CognitiveTurnResult: ratification_outcome: str = "" region_was_unconstrained: bool = True + # --- inner-loop refusal evidence (ADR-0024 Phase 2) --- + # ``refusal_reason`` is the stable string value of a + # ``generate.exhaustion.RefusalReason`` when the walk refused this + # turn, or the empty string otherwise. Empty-string default is + # the contract for "no refusal materialised"; 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). Phase 2 leaves the materialisation site + # in chat/runtime.py untouched per the ADR-0024 Phase 2 scope + # decision — this field exists so the trace contract is already + # in place when a future ADR wires the materialisation path. + refusal_reason: str = "" + # --- invariant bookkeeping --- versor_condition: float = 0.0 # must be < 1e-6 trace_hash: str = "" # SHA-256 over deterministic key fields diff --git a/core/cognition/trace.py b/core/cognition/trace.py index 5e0bcf2d..277719fa 100644 --- a/core/cognition/trace.py +++ b/core/cognition/trace.py @@ -41,6 +41,7 @@ def compute_trace_hash( admissibility_trace_hash: str = "", ratification_outcome: str = "", region_was_unconstrained: bool = True, + refusal_reason: str = "", ) -> str: """Return a deterministic SHA-256 hex digest over the turn's key outputs. @@ -85,6 +86,13 @@ def compute_trace_hash( payload["ratification_outcome"] = ratification_outcome if not region_was_unconstrained: payload["region_was_unconstrained"] = False + # ADR-0024 Phase 2 — fold refusal_reason only when non-empty so a + # turn that did not refuse keeps byte-identical payload bytes (and + # therefore byte-identical trace_hash) relative to pre-Phase-2. + # Once a turn does materialise a refusal, the reason becomes + # load-bearing in replay equality. + if refusal_reason: + payload["refusal_reason"] = refusal_reason serialized = json.dumps(payload, sort_keys=True, ensure_ascii=False) return hashlib.sha256(serialized.encode("utf-8")).hexdigest() @@ -141,4 +149,5 @@ def trace_hash_from_result(result: "CognitiveTurnResult") -> str: admissibility_trace_hash=getattr(result, "admissibility_trace_hash", ""), ratification_outcome=getattr(result, "ratification_outcome", ""), region_was_unconstrained=getattr(result, "region_was_unconstrained", True), + refusal_reason=getattr(result, "refusal_reason", ""), ) diff --git a/docs/runtime_contracts.md b/docs/runtime_contracts.md index f807b755..1a28b2ad 100644 --- a/docs/runtime_contracts.md +++ b/docs/runtime_contracts.md @@ -53,6 +53,47 @@ gated. This closes `evals/calibration/gaps.md` Finding 2. Future realizer work may change the selection policy, but must update this document and the contract tests in the same PR. +### Refusal contract (ADR-0024 Phase 2) + +When the inner-loop admissibility check leaves no admissible destination +for the next step, the generation walk in `generate/stream.py` raises +`generate.exhaustion.InnerLoopExhaustion`, a typed subclass of +`ValueError` carrying: + +```text +reason : RefusalReason (machine-readable taxonomy) +region_label : str (which AdmissibilityRegion blocked) +step_index : int (-1 = pre-walk empty intersection; + >=0 = in-walk per-step exhaustion) +rejected_attempts : tuple[(int, str, float), ...] (per-step evidence) +``` + +Reason codes are minimal in Phase 2: a single `INNER_LOOP_EXHAUSTION` +covers both raise sites. Phase 4 (rotor-frame admissibility, ADR-0025) +is expected to add a second reason for rotor exhaustion. + +`CognitiveTurnResult.refusal_reason` carries the stable string value of +the `RefusalReason` when a turn refuses, and the empty string otherwise. +`compute_trace_hash` folds `refusal_reason` into the payload only when +non-empty, preserving byte-identical hashes for non-refused turns +relative to pre-Phase-2 (determinism invariant). When the field is +non-empty, it becomes load-bearing in replay equality. + +Backward compatibility: `InnerLoopExhaustion` is a `ValueError`, so +every pre-Phase-2 `except ValueError` handler in `chat/runtime.py`, +eval lanes, and tests continues to catch it without modification. + +Residual silent path (out of scope for Phase 2, future ADR): +`ChatRuntime.respond()` and `arespond()` still convert any `ValueError` +to the empty string for their public `str` return contract, so a real +turn that refuses today produces `surface == ""` with +`refusal_reason == ""` — the typed evidence is unread between the +raise site and the result. The plumbing on `CognitiveTurnResult`, +`compute_trace_hash`, and `CognitiveTurnPipeline` is in place so a +future ADR can wire materialisation (e.g. propagate the typed +exception to `ChatResponse.refusal_reason` or catch at the pipeline +seam) without re-deriving the contract. + ## TurnEvent contract `TurnEvent.surface` diff --git a/generate/exhaustion.py b/generate/exhaustion.py new file mode 100644 index 00000000..69451a17 --- /dev/null +++ b/generate/exhaustion.py @@ -0,0 +1,112 @@ +""" +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}." + ) diff --git a/generate/stream.py b/generate/stream.py index 22079b2f..4e80a9bb 100644 --- a/generate/stream.py +++ b/generate/stream.py @@ -27,6 +27,7 @@ from generate.admissibility import ( filter_candidates, ) from generate.attention import AttentionOperator +from generate.exhaustion import InnerLoopExhaustion, RefusalReason from generate.result import GenerationResult from generate.salience import SalienceOperator @@ -339,8 +340,18 @@ def generate( if region is not None and not region.is_unconstrained(): candidate_indices = filter_candidates(region, candidate_indices) if candidate_indices is not None and len(candidate_indices) == 0: - raise ValueError( - f"AdmissibilityRegion[{region.label}] left no walk candidates." + # ADR-0024 Phase 2 — pre-walk exhaustion site. 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 from the + # in-walk exhaustion site below; no inner-loop rejections were + # issued because the region was already empty. Subclasses + # ValueError so existing handlers continue to catch it. + raise InnerLoopExhaustion( + reason=RefusalReason.INNER_LOOP_EXHAUSTION, + region_label=region.label, + step_index=-1, + rejected_attempts=(), ) candidates_used = None if candidate_indices is None else len(candidate_indices) admissibility_trace: list[AdmissibilityTraceStep] = [] @@ -417,17 +428,29 @@ def generate( if int(word_idx) in step_exclude: # Selector returned the same exhausted candidate — no # further admissible destinations. Honest refusal. - raise ValueError( - f"AdmissibilityRegion[{effective_region_label}] inner-loop " - f"rejected all candidates at step {step_index}." + # ADR-0024 Phase 2 — in-walk exhaustion site; carries the + # ordered ``rejected_attempts`` accumulated this step so + # downstream layers can read refusal evidence without + # re-parsing the exception message. + raise InnerLoopExhaustion( + reason=RefusalReason.INNER_LOOP_EXHAUSTION, + region_label=effective_region_label, + step_index=step_index, + rejected_attempts=tuple(rejected_attempts), ) step_exclude.add(int(word_idx)) else: # max_attempts exhausted without break — every admissible # candidate was rejected by the inner-loop threshold. - raise ValueError( - f"AdmissibilityRegion[{effective_region_label}] inner-loop " - f"rejected all candidates at step {step_index}." + # Same refusal shape as the same-candidate-loop site above; + # both are structurally "inner-loop produced no admissible + # candidate at this step". Splitting into separate reasons + # can wait for Phase 4 (rotor frame, ADR-0025). + raise InnerLoopExhaustion( + reason=RefusalReason.INNER_LOOP_EXHAUSTION, + region_label=effective_region_label, + step_index=step_index, + rejected_attempts=tuple(rejected_attempts), ) tokens.append(_articulate(vocab, word)) diff --git a/tests/test_refusal_contract.py b/tests/test_refusal_contract.py new file mode 100644 index 00000000..8b81b96a --- /dev/null +++ b/tests/test_refusal_contract.py @@ -0,0 +1,315 @@ +"""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 == ""