core/tests/test_safety_refusal.py
Shay a0372c951f feat(adr-0036): safety-only typed refusal policy
Runtime-checkable SafetyVerdict violations now replace
ChatResponse.surface (and TurnEvent.surface on the main path) with a
deterministic typed refusal string.  Ethics violations remain
audit-only.

Why safety-only: safety is the universal floor (ADR-0029,
never-swappable, fail-closed).  Ethics is swappable per-deployment;
wiring ethics into refusal would let pack-swappers silently change
refusal behavior via JSON edit.  Wrong coupling.

Why typed refusal (not hedge injection / not re-articulation): typed
refusal is deterministic, audit-detectable by prefix, and preserves
replayability.  Hedge injection would blur surface-preferences-driven
hedging vs predicate-driven refusal.  Re-articulation retry yields the
same surface (planner is deterministic; no refusal-bias hint surface
exists).  Deferred to a future ADR.

Refusal contract:
- ChatResponse.surface = typed refusal string
- walk_surface + articulation_surface = unchanged (audit preserved)
- runtime._last_refusal_was_typed = True (next-turn evidence for
  no_silent_correction)
- Only runtime_checkable=True violations refuse
- Stub path symmetric

Files:
- chat/refusal.py (new) — pure refusal builder + audit helpers
- chat/runtime.py — invoke build_refusal_surface after safety_verdict
- tests/test_safety_refusal.py (new) — 20 tests
- docs/decisions/ADR-0036-safety-refusal-policy.md (new)

Verification:
- 20 new tests; combined pack-layer suite 116 green
- CLI suites unchanged: smoke 67, runtime 19, cognition 121
- core eval cognition: intent 100%, versor_closure 100% (baseline)
2026-05-17 21:10:52 -07:00

257 lines
9.6 KiB
Python

"""ADR-0036 — typed refusal on runtime-checkable safety violation.
The refusal surface:
* replaces ``ChatResponse.surface`` with a deterministic typed string,
* leaves ``walk_surface`` and ``articulation_surface`` intact (audit),
* fires only on safety violations (ethics is observational at v1),
* fires only on ``runtime_checkable=True`` results
(no-evidence predicates never refuse),
* sets ``runtime._last_refusal_was_typed = True`` so the next turn's
``no_silent_correction`` predicate has live evidence.
"""
from __future__ import annotations
from chat.refusal import (
TYPED_REFUSAL_PREFIX,
build_refusal_surface,
is_typed_refusal,
violated_runtime_checkable,
)
from chat.runtime import ChatRuntime
from core.config import RuntimeConfig
from packs.safety.check import SafetyCheckResult, SafetyVerdict
# ---------- pure-function refusal builder ----------
class TestBuildRefusalSurface:
def test_none_verdict_returns_none(self) -> None:
assert build_refusal_surface(None) is None
def test_all_upheld_returns_none(self) -> None:
verdict = _verdict(
_result("preserve_versor_closure", upheld=True, runtime_checkable=True),
_result("no_silent_correction", upheld=True, runtime_checkable=True),
)
assert build_refusal_surface(verdict) is None
def test_non_runtime_checkable_violation_does_not_refuse(self) -> None:
"""A predicate that lacks evidence (runtime_checkable=False) and
reports upheld=False must NOT trigger refusal — by design,
no-evidence predicates default to ``upheld=True`` in the
check loop, but even if a deployment forces ``upheld=False``
without evidence, refusal should not fire."""
verdict = _verdict(
_result("no_fabricated_source", upheld=False, runtime_checkable=False),
)
assert build_refusal_surface(verdict) is None
def test_runtime_checkable_violation_returns_typed_surface(self) -> None:
verdict = _verdict(
_result("preserve_versor_closure", upheld=False, runtime_checkable=True),
)
out = build_refusal_surface(verdict)
assert out is not None
assert out.startswith(TYPED_REFUSAL_PREFIX)
assert "preserve_versor_closure" in out
def test_refusal_is_deterministic(self) -> None:
verdict = _verdict(
_result("preserve_versor_closure", upheld=False, runtime_checkable=True),
_result("no_silent_correction", upheld=False, runtime_checkable=True),
)
first = build_refusal_surface(verdict)
second = build_refusal_surface(verdict)
assert first == second
def test_refusal_lists_boundaries_in_lex_order(self) -> None:
verdict = _verdict(
_result("zzz_late", upheld=False, runtime_checkable=True),
_result("aaa_early", upheld=False, runtime_checkable=True),
)
out = build_refusal_surface(verdict)
assert out is not None
assert out.index("aaa_early") < out.index("zzz_late")
class TestViolatedRuntimeCheckable:
def test_filters_non_checkable(self) -> None:
verdict = _verdict(
_result("a", upheld=False, runtime_checkable=False),
_result("b", upheld=False, runtime_checkable=True),
)
assert violated_runtime_checkable(verdict) == ("b",)
def test_filters_upheld(self) -> None:
verdict = _verdict(
_result("a", upheld=True, runtime_checkable=True),
_result("b", upheld=False, runtime_checkable=True),
)
assert violated_runtime_checkable(verdict) == ("b",)
def test_empty_verdict_returns_empty(self) -> None:
verdict = _verdict()
assert violated_runtime_checkable(verdict) == ()
class TestIsTypedRefusal:
def test_typed_refusal_detected(self) -> None:
surface = TYPED_REFUSAL_PREFIX + "preserve_versor_closure"
assert is_typed_refusal(surface)
def test_ordinary_surface_not_a_refusal(self) -> None:
assert not is_typed_refusal("light is")
assert not is_typed_refusal("")
# ---------- ChatRuntime integration ----------
class TestRuntimeRefusalMainPath:
def test_no_violation_returns_ordinary_surface(self) -> None:
rt = ChatRuntime(config=RuntimeConfig())
resp = rt.chat("light is")
# No predicate forced to fail, so surface should NOT be a
# typed refusal. (Stub path may produce the unknown-domain
# surface; both are non-refusal.)
assert not is_typed_refusal(resp.surface)
def test_forced_violation_emits_typed_refusal(self) -> None:
rt = ChatRuntime(config=RuntimeConfig())
_force_violation(rt, "preserve_versor_closure")
resp = rt.chat("light is")
assert is_typed_refusal(resp.surface)
assert "preserve_versor_closure" in resp.surface
def test_refusal_preserves_walk_surface(self) -> None:
"""walk_surface retains the original token-walk evidence even
when the user-facing surface is replaced by a refusal."""
rt = ChatRuntime(config=RuntimeConfig())
_force_violation(rt, "preserve_versor_closure")
resp = rt.chat("light is")
if not is_typed_refusal(resp.surface):
return # stub path; tested separately
assert not is_typed_refusal(resp.walk_surface)
def test_refusal_preserves_articulation_surface(self) -> None:
rt = ChatRuntime(config=RuntimeConfig())
_force_violation(rt, "preserve_versor_closure")
resp = rt.chat("light is")
if not is_typed_refusal(resp.surface):
return
assert not is_typed_refusal(resp.articulation_surface)
def test_refusal_still_attaches_verdicts(self) -> None:
rt = ChatRuntime(config=RuntimeConfig())
_force_violation(rt, "preserve_versor_closure")
resp = rt.chat("light is")
assert resp.safety_verdict is not None
assert resp.ethics_verdict is not None
def test_refusal_sets_last_refusal_was_typed(self) -> None:
rt = ChatRuntime(config=RuntimeConfig())
# Sanity: default is True before any refusal.
assert rt._last_refusal_was_typed is True
_force_violation(rt, "preserve_versor_closure")
rt.chat("light is")
# After a typed refusal, the flag remains True so the
# ``no_silent_correction`` predicate has live evidence next turn.
assert rt._last_refusal_was_typed is True
def test_turn_event_surface_is_refusal_when_refused(self) -> None:
rt = ChatRuntime(config=RuntimeConfig())
_force_violation(rt, "preserve_versor_closure")
rt.chat("light is")
if not rt.turn_log:
return # stub path; bypasses turn_log
event = rt.turn_log[-1]
assert is_typed_refusal(event.surface)
# And walk_surface is retained on the event for audit.
assert not is_typed_refusal(event.walk_surface)
# ---------- Ethics violations must NOT trigger refusal ----------
class TestEthicsViolationDoesNotRefuse:
def test_ethics_violation_audit_only(self) -> None:
"""Ethics is observational at v1. Even a runtime-checkable
ethics violation must NOT produce a typed refusal surface —
the audit verdict still attaches but the user-facing surface
is unchanged."""
rt = ChatRuntime(config=RuntimeConfig())
# Force an ethics predicate to fail with runtime_checkable=True.
from packs.ethics.check import EthicsCheckResult
def _failing_ethics(ctx): # noqa: ANN001 — predicate signature
return EthicsCheckResult(
commitment_id="acknowledge_uncertainty",
upheld=False,
reason="forced for test",
runtime_checkable=True,
)
rt.ethics_check.register("acknowledge_uncertainty", _failing_ethics)
resp = rt.chat("light is")
assert not is_typed_refusal(resp.surface)
# ---------- Stub path refusal ----------
class TestStubPathRefusal:
def test_stub_path_refusal_replaces_unknown_domain_surface(self) -> None:
"""When the stub path triggers AND safety violation fires, the
typed refusal replaces the unknown-domain surface."""
rt = ChatRuntime(config=RuntimeConfig())
_force_violation(rt, "preserve_versor_closure")
# ``light is`` may or may not hit the stub path depending on
# vault state; regardless, if a violation is forced, the
# response surface must be a typed refusal.
resp = rt.chat("light is")
assert is_typed_refusal(resp.surface)
# ---------- helpers ----------
def _result(
boundary_id: str,
*,
upheld: bool,
runtime_checkable: bool,
) -> SafetyCheckResult:
return SafetyCheckResult(
boundary_id=boundary_id,
upheld=upheld,
reason="test",
runtime_checkable=runtime_checkable,
)
def _verdict(*results: SafetyCheckResult) -> SafetyVerdict:
violated = frozenset(r.boundary_id for r in results if not r.upheld)
return SafetyVerdict(
pack_id="test_pack",
results=tuple(results),
upheld=not violated,
violated_boundaries=violated,
runtime_checkable_count=sum(1 for r in results if r.runtime_checkable),
)
def _force_violation(rt: ChatRuntime, boundary_id: str) -> None:
"""Register a synthetic predicate that always fails for ``boundary_id``."""
def _failing(ctx): # noqa: ANN001 — predicate signature
return SafetyCheckResult(
boundary_id=boundary_id,
upheld=False,
reason="forced for test",
runtime_checkable=True,
)
rt.safety_check.register(boundary_id, _failing)