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)
This commit is contained in:
parent
514ace0cbf
commit
a0372c951f
4 changed files with 531 additions and 3 deletions
71
chat/refusal.py
Normal file
71
chat/refusal.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""ADR-0036 — typed refusal surface for runtime-checkable safety violations.
|
||||
|
||||
The refusal surface is the runtime's response to a SafetyVerdict that
|
||||
reports at least one ``runtime_checkable=True, upheld=False`` result.
|
||||
It is deliberately:
|
||||
|
||||
* **Deterministic.** Same set of violated boundaries → same surface
|
||||
bytes. Replayability is preserved.
|
||||
* **Typed.** A constant prefix (``TYPED_REFUSAL_PREFIX``) plus
|
||||
lex-ordered boundary ids. Audit consumers detect refusals by prefix,
|
||||
not by NLP.
|
||||
* **Safety-only.** Ethics violations are observational at v1
|
||||
(ADR-0035) and do not trigger refusal. Refusing on swappable
|
||||
deployment commitments would let pack-swappers silently change
|
||||
refusal behavior — the wrong coupling.
|
||||
|
||||
See `docs/decisions/ADR-0036-safety-refusal-policy.md`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
TYPED_REFUSAL_PREFIX = "I cannot proceed — safety boundary violated: "
|
||||
|
||||
|
||||
def violated_runtime_checkable(verdict) -> tuple[str, ...]:
|
||||
"""Return the lex-sorted tuple of runtime-checkable violated boundary ids.
|
||||
|
||||
A boundary is reported as violated only when the predicate had
|
||||
enough evidence to make a real claim (``runtime_checkable=True``)
|
||||
AND determined the turn breached the boundary (``upheld=False``).
|
||||
Predicates that report ``runtime_checkable=False`` are honest
|
||||
about lack of evidence and never trigger refusal.
|
||||
"""
|
||||
if verdict is None:
|
||||
return ()
|
||||
boundary_ids: list[str] = []
|
||||
for result in getattr(verdict, "results", ()) or ():
|
||||
if getattr(result, "runtime_checkable", False) and not getattr(
|
||||
result, "upheld", True
|
||||
):
|
||||
boundary_ids.append(str(result.boundary_id))
|
||||
return tuple(sorted(boundary_ids))
|
||||
|
||||
|
||||
def build_refusal_surface(verdict) -> str | None:
|
||||
"""Build a deterministic typed refusal surface, or ``None`` if no refusal.
|
||||
|
||||
The contract:
|
||||
|
||||
* Returns ``None`` when no runtime-checkable safety violation is
|
||||
present. The caller keeps the originally-articulated surface.
|
||||
* Returns ``TYPED_REFUSAL_PREFIX + ", ".join(lex_sorted_ids)`` when
|
||||
one or more runtime-checkable boundaries were violated.
|
||||
|
||||
The same verdict always produces the same string.
|
||||
"""
|
||||
violated = violated_runtime_checkable(verdict)
|
||||
if not violated:
|
||||
return None
|
||||
return _format_refusal(violated)
|
||||
|
||||
|
||||
def _format_refusal(boundary_ids: Iterable[str]) -> str:
|
||||
return TYPED_REFUSAL_PREFIX + ", ".join(boundary_ids)
|
||||
|
||||
|
||||
def is_typed_refusal(surface: str) -> bool:
|
||||
"""Audit helper: does this surface look like a typed refusal?"""
|
||||
return bool(surface) and surface.startswith(TYPED_REFUSAL_PREFIX)
|
||||
|
|
@ -10,6 +10,7 @@ from typing import List
|
|||
import numpy as np
|
||||
|
||||
from algebra.versor import versor_condition
|
||||
from chat.refusal import build_refusal_surface
|
||||
from core.config import DEFAULT_CONFIG, DEFAULT_IDENTITY_PACK, RuntimeConfig
|
||||
from core.physics.drive import DriveGradientMap, GradientField
|
||||
from core.physics.energy import EnergyProfile
|
||||
|
|
@ -497,8 +498,18 @@ class ChatRuntime:
|
|||
disclosure_emitted=True,
|
||||
)
|
||||
ethics_verdict = self.ethics_check.check(ethics_ctx, self.ethics_pack)
|
||||
# ADR-0036 — typed refusal also applies on the stub path. When
|
||||
# a runtime-checkable safety boundary is violated even on the
|
||||
# ungrounded surface (e.g. versor-closure failure), replace the
|
||||
# user-facing ``surface`` with the deterministic typed refusal.
|
||||
refusal_surface = build_refusal_surface(safety_verdict)
|
||||
if refusal_surface is not None:
|
||||
response_surface = refusal_surface
|
||||
self._last_refusal_was_typed = True
|
||||
else:
|
||||
response_surface = _UNKNOWN_DOMAIN_SURFACE
|
||||
return ChatResponse(
|
||||
surface=_UNKNOWN_DOMAIN_SURFACE,
|
||||
surface=response_surface,
|
||||
proposition=prop,
|
||||
articulation=art,
|
||||
articulation_surface=_UNKNOWN_DOMAIN_SURFACE,
|
||||
|
|
@ -670,10 +681,22 @@ class ChatRuntime:
|
|||
disclosure_emitted=not is_grounded,
|
||||
)
|
||||
ethics_verdict = self.ethics_check.check(ethics_ctx, self.ethics_pack)
|
||||
# ADR-0036 — safety-only typed refusal. A runtime-checkable
|
||||
# SafetyVerdict violation replaces the user-facing ``surface``
|
||||
# with a deterministic typed refusal string. ``walk_surface``
|
||||
# and ``articulation_surface`` retain the original token-walk /
|
||||
# realizer evidence for audit (per the runtime surface
|
||||
# contract in CLAUDE.md). Ethics violations remain audit-only.
|
||||
refusal_surface = build_refusal_surface(safety_verdict)
|
||||
if refusal_surface is not None:
|
||||
response_surface = refusal_surface
|
||||
self._last_refusal_was_typed = True
|
||||
else:
|
||||
response_surface = walk_surface
|
||||
turn_event = TurnEvent(
|
||||
turn=self._context.turn - 1,
|
||||
input_tokens=tuple(filtered),
|
||||
surface=surface,
|
||||
surface=response_surface,
|
||||
walk_surface=walk_surface,
|
||||
articulation_surface=articulation.surface,
|
||||
dialogue_role=str(dialogue_role),
|
||||
|
|
@ -688,7 +711,7 @@ class ChatRuntime:
|
|||
)
|
||||
self.turn_log.append(turn_event)
|
||||
return ChatResponse(
|
||||
surface=walk_surface,
|
||||
surface=response_surface,
|
||||
proposition=proposition,
|
||||
articulation=articulation,
|
||||
articulation_surface=articulation.surface,
|
||||
|
|
|
|||
177
docs/decisions/ADR-0036-safety-refusal-policy.md
Normal file
177
docs/decisions/ADR-0036-safety-refusal-policy.md
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
# ADR-0036: Safety-Only Typed Refusal Policy
|
||||
|
||||
**Status:** Accepted (2026-05-17)
|
||||
**Author:** Joshua Shay + planner pass
|
||||
**Companion docs:** [`ADR-0032-safety-check-surface.md`](ADR-0032-safety-check-surface.md), [`ADR-0034-ethics-check-surface.md`](ADR-0034-ethics-check-surface.md), [`ADR-0035-turn-loop-verdict-surfacing.md`](ADR-0035-turn-loop-verdict-surfacing.md)
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0035 wired `SafetyCheck` and `EthicsCheck` into the turn loop as
|
||||
**observation only** — verdicts attach to `ChatResponse` and `TurnEvent`
|
||||
but do not change behavior. The closing notes flagged refusal /
|
||||
re-articulation as the natural follow-up *once real verdict data
|
||||
flowed*.
|
||||
|
||||
With one ADR's worth of verdict surfacing in the runtime, two scope
|
||||
axes had to be decided before wiring refusal:
|
||||
|
||||
1. **Trigger scope.** Safety only? Safety + ethics? Per-predicate?
|
||||
2. **Refusal shape.** Typed refusal surface? Hedge injection?
|
||||
Re-articulation via planner retry?
|
||||
|
||||
The decision was made jointly with the user, surfaced via an explicit
|
||||
scope question before any code landed.
|
||||
|
||||
## Decision
|
||||
|
||||
**Safety-only typed refusal.** A `SafetyVerdict` with at least one
|
||||
`runtime_checkable=True, upheld=False` result replaces
|
||||
`ChatResponse.surface` with a deterministic typed refusal string.
|
||||
Ethics violations remain audit-only.
|
||||
|
||||
### Why safety only
|
||||
|
||||
Safety is the universal floor (ADR-0029): five fixed boundaries, never
|
||||
swappable, fail-closed on load. Ethics is deployment configuration
|
||||
above that floor (ADR-0033): swappable per-deployment, falls back to
|
||||
default. Wiring ethics into refusal would let pack-swappers silently
|
||||
change the runtime's refusal behavior by editing a JSON file — exactly
|
||||
the coupling we want to avoid. Safety is the architectural place to
|
||||
encode "the floor never moves."
|
||||
|
||||
A future ADR can revisit per-predicate ethics refusal once individual
|
||||
ethics commitments have empirical violation rates from real corpora.
|
||||
|
||||
### Why typed refusal (not hedge injection, not re-articulation)
|
||||
|
||||
* **Typed refusal** is deterministic, audit-detectable by prefix, and
|
||||
preserves replayability. The refusal carries the violated
|
||||
boundary ids in lex order. Same violation → same bytes.
|
||||
* **Hedge injection** would blur the boundary between
|
||||
alignment-score-driven hedging (ADR-0028 surface preferences) and
|
||||
predicate-driven refusal. The same surface change could mean two
|
||||
different things. Audit becomes ambiguous.
|
||||
* **Re-articulation** via planner retry is deterministic too, but the
|
||||
planner has no refusal-bias hint surface today — retry with
|
||||
unchanged inputs yields the same surface. Deferred to a future ADR
|
||||
that first lands evidence-threading through the planner.
|
||||
|
||||
### Why only `runtime_checkable=True` violations refuse
|
||||
|
||||
A predicate that reports `runtime_checkable=False` is honestly stating
|
||||
"I have no evidence to make a real claim." Refusing on no-evidence
|
||||
predicates would refuse on architectural absence, not behavioral
|
||||
violation. The ADR-0032/0034 honest-reporting discipline means
|
||||
`runtime_checkable` is exactly the gate for "did we observe a real
|
||||
violation."
|
||||
|
||||
### What the runtime contract looks like now
|
||||
|
||||
`chat/refusal.py`:
|
||||
|
||||
* `TYPED_REFUSAL_PREFIX = "I cannot proceed — safety boundary violated: "`
|
||||
* `build_refusal_surface(verdict) -> str | None` — pure function, no I/O.
|
||||
* `violated_runtime_checkable(verdict) -> tuple[str, ...]` — lex-sorted helper.
|
||||
* `is_typed_refusal(surface) -> bool` — audit helper.
|
||||
|
||||
`chat/runtime.py` — both the main turn path and `_stub_response`
|
||||
invoke `build_refusal_surface(safety_verdict)` after the verdict is
|
||||
computed. On a non-None return:
|
||||
|
||||
* `ChatResponse.surface` = typed refusal
|
||||
* `ChatResponse.walk_surface` = unchanged (audit evidence preserved)
|
||||
* `ChatResponse.articulation_surface` = unchanged (realizer evidence preserved)
|
||||
* `TurnEvent.surface` = typed refusal (main path only; stub path bypasses turn_log by design)
|
||||
* `runtime._last_refusal_was_typed = True` — so the next turn's
|
||||
`no_silent_correction` predicate has live evidence.
|
||||
|
||||
### Surface contract integrity
|
||||
|
||||
The runtime surface contract from CLAUDE.md says:
|
||||
|
||||
```
|
||||
surface = articulation_surface (selected user-facing response)
|
||||
walk_surface = retained telemetry/evidence
|
||||
```
|
||||
|
||||
Refusal changes the *selection* (`surface` no longer equals
|
||||
`articulation_surface`); it does not corrupt the evidence
|
||||
(`walk_surface` and `articulation_surface` retain what the runtime
|
||||
would have said). An auditor reading a refusal turn sees:
|
||||
|
||||
* what the runtime *would have* surfaced (walk_surface / articulation),
|
||||
* what it *did* surface (typed refusal),
|
||||
* and *why* (safety_verdict).
|
||||
|
||||
This is the same audit shape as a non-refusing turn — no new contract.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
* **First load-bearing pack-layer behavior.** The pack-layer surface
|
||||
now has a way to actually stop the runtime from emitting bad output,
|
||||
not just label it.
|
||||
* **Deterministic.** Same forced violation → byte-identical refusal
|
||||
string. Replay invariant preserved.
|
||||
* **Audit-complete.** Every refusal carries the verdict and the
|
||||
preserved walk/articulation evidence. No silent refusals.
|
||||
* **Bookkeeping closes the loop on `no_silent_correction`.** When the
|
||||
runtime refuses, it sets `_last_refusal_was_typed=True`, so the
|
||||
next turn's predicate has live evidence of typed refusal.
|
||||
* **Cheap.** One pure function call per turn. Test suites and
|
||||
cognition eval unchanged.
|
||||
|
||||
### Negative / risks
|
||||
|
||||
* **No per-predicate refusal opt-out.** All `runtime_checkable=True`
|
||||
safety violations refuse. If a future safety pack introduces a
|
||||
predicate that should be observe-only, the surface needs a
|
||||
per-predicate `audit_only` flag. Acceptable today: the v1 safety
|
||||
pack has five boundaries and refusing on each is the right semantics.
|
||||
* **Hedge injection is *not* the refusal path.** A high-confidence
|
||||
emission with low alignment score still passes through unhedged
|
||||
unless the manifold's `surface_preferences` choose to hedge. This
|
||||
is correct: hedging is a surface preference (ADR-0028), refusal is
|
||||
a safety boundary. Conflating them was rejected.
|
||||
* **Stub path refusal happens but `TurnEvent` is not emitted.** Same
|
||||
pre-existing limit as ADR-0035. Audit completeness for stub paths
|
||||
is a separate ADR.
|
||||
|
||||
## Verification
|
||||
|
||||
* `tests/test_safety_refusal.py` — 20 tests covering: pure refusal
|
||||
builder (none, all-upheld, non-checkable violation, single
|
||||
violation, determinism, lex order); helpers
|
||||
(`violated_runtime_checkable`, `is_typed_refusal`); ChatRuntime
|
||||
integration (ordinary turn unchanged, forced violation emits
|
||||
refusal, walk_surface preserved, articulation_surface preserved,
|
||||
verdicts still attached, `_last_refusal_was_typed` bookkeeping,
|
||||
`TurnEvent.surface` carries the refusal); ethics violations do
|
||||
NOT trigger refusal; stub-path refusal.
|
||||
* Combined pack-layer surface suite: **116 tests, all green**
|
||||
(safety pack + safety check + ethics pack + ethics check +
|
||||
turn-loop verdicts + refusal).
|
||||
* CLI suites unaffected: smoke 67, runtime 19, cognition 121.
|
||||
* `core eval cognition`: intent_accuracy 100%, versor_closure_rate 100%
|
||||
(baseline preserved).
|
||||
|
||||
## Open questions deferred to a future ADR
|
||||
|
||||
1. **Per-predicate ethics refusal.** Pack-schema flag to opt specific
|
||||
ethics commitments into refusal once empirical violation rates are
|
||||
available.
|
||||
2. **Hedge-injection as a separate surface affordance.** A
|
||||
below-threshold alignment score could prepend the manifold's hedge
|
||||
without triggering refusal. Today this is partially handled by
|
||||
the assembler's `SurfaceContext`; lifting it to a runtime-level
|
||||
decision is its own ADR.
|
||||
3. **`TurnEvent` for stub paths.** Audit completeness across the
|
||||
refusal-on-stub path.
|
||||
4. **Refusal telemetry sink.** A structured log emitter consumes
|
||||
refusals for operational dashboards.
|
||||
5. **`core chat --show-verdicts` CLI flag.** Per-turn verdict and
|
||||
refusal printout for manual audit.
|
||||
6. **Refusal-bias planner retry.** Re-articulation as a deliberate
|
||||
re-plan with refusal context threaded in. Deferred until
|
||||
evidence-threading through the planner lands.
|
||||
257
tests/test_safety_refusal.py
Normal file
257
tests/test_safety_refusal.py
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
"""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)
|
||||
Loading…
Reference in a new issue