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
|
# for every caller that constructs ChatResponse without this
|
||||||
# field.
|
# field.
|
||||||
recalled_words: tuple[str, ...] = ()
|
recalled_words: tuple[str, ...] = ()
|
||||||
|
# ADR-0024 Phase 2 — stable refusal reason value
|
||||||
|
refusal_reason: str = ""
|
||||||
|
|
||||||
|
|
||||||
class ChatRuntime:
|
class ChatRuntime:
|
||||||
|
|
@ -1487,6 +1489,7 @@ class ChatRuntime:
|
||||||
composer_graph_atom_overlap_count=atom_equivalence_stub.overlap_count,
|
composer_graph_atom_overlap_count=atom_equivalence_stub.overlap_count,
|
||||||
epistemic_state=stub_epistemic_state,
|
epistemic_state=stub_epistemic_state,
|
||||||
normative_clearance=stub_normative_clearance,
|
normative_clearance=stub_normative_clearance,
|
||||||
|
refusal_reason=refusal_surface if refusal_emitted else "",
|
||||||
)
|
)
|
||||||
|
|
||||||
def chat(self, text: str, max_tokens: int | None = None) -> ChatResponse:
|
def chat(self, text: str, max_tokens: int | None = None) -> ChatResponse:
|
||||||
|
|
@ -1984,6 +1987,7 @@ class ChatRuntime:
|
||||||
recalled_words=walk_tokens,
|
recalled_words=walk_tokens,
|
||||||
epistemic_state=main_epistemic_state,
|
epistemic_state=main_epistemic_state,
|
||||||
normative_clearance=main_normative_clearance,
|
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:
|
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 len(hits) == 1
|
||||||
assert hits[0]["epistemic_state"] == EpistemicState.CONTRADICTED.value
|
assert hits[0]["epistemic_state"] == EpistemicState.CONTRADICTED.value
|
||||||
assert hits[0]["metadata"]["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