feat: materialize refusal_reason in CognitiveTurnResult when safety/ethics refusal fires
This commit is contained in:
parent
ec40b5c639
commit
ca9212b5f7
2 changed files with 53 additions and 0 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue