feat: materialize refusal_reason in CognitiveTurnResult when safety/ethics refusal fires (#222)

This commit is contained in:
Shay 2026-05-24 11:53:06 -07:00 committed by GitHub
parent 03adedde01
commit 9ef609c460
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 53 additions and 0 deletions

View file

@ -405,6 +405,8 @@ class ChatResponse:
# for every caller that constructs ChatResponse without this
# field.
recalled_words: tuple[str, ...] = ()
# ADR-0024 Phase 2 — stable refusal reason value
refusal_reason: str = ""
class ChatRuntime:
@ -1487,6 +1489,7 @@ class ChatRuntime:
composer_graph_atom_overlap_count=atom_equivalence_stub.overlap_count,
epistemic_state=stub_epistemic_state,
normative_clearance=stub_normative_clearance,
refusal_reason=refusal_surface if refusal_emitted else "",
)
def chat(self, text: str, max_tokens: int | None = None) -> ChatResponse:
@ -1984,6 +1987,7 @@ class ChatRuntime:
recalled_words=walk_tokens,
epistemic_state=main_epistemic_state,
normative_clearance=main_normative_clearance,
refusal_reason=refusal_surface if refusal_emitted else "",
)
def _unknown_domain_response(self, field_state: FieldState, filtered: list[str]) -> ChatResponse:

View file

@ -107,3 +107,52 @@ def test_vault_recall_exposes_epistemic_state_metadata() -> None:
assert len(hits) == 1
assert hits[0]["epistemic_state"] == EpistemicState.CONTRADICTED.value
assert hits[0]["metadata"]["epistemic_state"] == EpistemicState.CONTRADICTED.value
def test_refusal_reason_materialized_in_cognitive_turn_result() -> None:
from chat.runtime import ChatRuntime
from core.config import RuntimeConfig
from core.cognition import CognitiveTurnPipeline
from chat.refusal import TYPED_REFUSAL_PREFIX
from packs.safety.check import SafetyCheckResult
from core.cognition.trace import trace_hash_from_result
import dataclasses
runtime = ChatRuntime(config=RuntimeConfig())
pipeline = CognitiveTurnPipeline(runtime)
# 1. Normal turn: refusal_reason must be empty
result_normal = pipeline.run("light logos", max_tokens=8)
assert result_normal.refusal_reason == ""
normal_hash = result_normal.trace_hash
# 2. Force safety violation to trigger refusal
boundary_id = "preserve_versor_closure"
def _failing(ctx) -> SafetyCheckResult:
return SafetyCheckResult(
boundary_id=boundary_id,
upheld=False,
reason="forced for test",
runtime_checkable=True,
)
runtime.safety_check.register(boundary_id, _failing)
# 3. Refusal turn: refusal_reason must be populated
result_refusal = pipeline.run("light logos", max_tokens=8)
refusal_reason = result_refusal.refusal_reason
assert refusal_reason != ""
assert refusal_reason.startswith(TYPED_REFUSAL_PREFIX)
assert boundary_id in refusal_reason
# 4. Check trace hash is different due to refusal_reason fold
refusal_hash = result_refusal.trace_hash
assert refusal_hash != normal_hash
# 5. Verify trace hashing conditional fold explicitly
# If we compute the trace hash from a modified result with empty refusal_reason,
# it must differ from the refusal_hash.
modified_result = dataclasses.replace(result_refusal, refusal_reason="")
recomputed_without_reason = trace_hash_from_result(modified_result)
assert recomputed_without_reason != refusal_hash