core/tests/test_safety_check.py
Shay 6f67e9a616 feat(safety): ADR-0032 — SafetyCheck structural surface
Closes the 'boundaries are checked at scattered call sites' gap noted
in ADR-0029.  Adds a centralized observational surface parallel in
shape to IdentityCheck — produces a verdict, does not refuse.  Wiring
verdicts into refusal paths is a future ADR.

Shape (parallel to IdentityCheck, different in mechanism):

  SafetyContext     — duck-typed input bag (field_state, citations,
                       refusal-was-typed flag, identity manifold hashes
                       before/after).  Every field optional with safe
                       defaults; absence of evidence is not evidence of
                       violation.
  SafetyCheckResult — per-boundary: boundary_id, upheld, reason,
                       runtime_checkable, evidence tuple.
  SafetyVerdict     — aggregate: pack_id, results (lex order on
                       boundary_id), upheld, violated_boundaries,
                       runtime_checkable_count.
  SafetyCheck       — registry of predicates; check(ctx, pack) returns
                       SafetyVerdict.  register(boundary_id, predicate)
                       adds custom predicates.

Five default predicates for v1 boundaries:

  preserve_versor_closure   runtime_checkable=True   field.versor_condition < 1e-6
  no_fabricated_source      runtime_checkable=True*  cited ⊆ allowed
  no_silent_correction      runtime_checkable=True   last refusal was typed
  no_identity_override      runtime_checkable=True*  hash before == hash after
  no_hot_path_repair        runtime_checkable=FALSE  code-path; static-analysis

  *Conditional on the caller supplying the necessary fields.

The honest answer on no_hot_path_repair: it is a code-path boundary
enforced by static analysis + code review.  Runtime cannot judge it.
A predicate that silently reported upheld=True would be a small lie —
exactly the kind of thing CLAUDE.md forbids.  SafetyCheck reports
runtime_checkable=False with a clear reason so auditors see the truth.

ChatRuntime integration:
  ChatRuntime.__init__ now constructs self.safety_check = SafetyCheck()
  alongside self._identity_check.  Turn loop does NOT auto-invoke at
  v1 — operators and future ADRs decide when/where to call it.

Files:
  packs/safety/check.py            new — SafetyCheck + value types +
                                   default predicates
  packs/safety/__init__.py         re-exports the new public surface
  chat/runtime.py                  constructs self.safety_check
  tests/test_safety_check.py       new — 20 tests covering each
                                   default predicate (positive +
                                   negative), unknown-boundary
                                   fallback, custom registration,
                                   defensive boundary-id rebinding,
                                   verdict aggregation, ChatRuntime
                                   integration
  docs/decisions/ADR-0032-safety-check-surface.md  Accepted
  docs/safety_packs.md             §SafetyCheck section added,
                                   known-limit #1 struck through
  memory/safety-pack.md            refreshed; new follow-up about
                                   turn-loop auto-invocation

Suite status (all green):
  cognition 121, teaching 17, runtime 19, formation 182, smoke 67
  identity / safety / surface divergence suites: 108 tests passing
  (was 88 before this ADR; +20 safety-check tests)

Scope limits (documented):
  - No auto-invocation in the turn loop.
  - No refusal wiring on violation.
  - No refactoring of existing scattered enforcement sites.
  - Defensive boundary-id rebinding masks predicate bugs; debug-mode
    surfacing is a future enhancement.
2026-05-17 20:25:22 -07:00

290 lines
10 KiB
Python

"""ADR-0032 — SafetyCheck structural surface.
These tests cover the five default predicates (positive + negative
paths), the unknown-boundary fallback, custom predicate registration,
and integration with ``ChatRuntime``.
Mechanics intentionally mirror ``tests/test_identity_packs.py`` /
``tests/test_safety_pack.py`` patterns: small, deterministic, no
fixtures that go through the full cognitive pipeline. SafetyCheck is
observational; every test asserts on the verdict, not on runtime
behavior.
"""
from __future__ import annotations
from dataclasses import dataclass
from chat.runtime import ChatRuntime
from core.config import RuntimeConfig
from packs.safety.check import (
SafetyCheck,
SafetyCheckResult,
SafetyContext,
SafetyVerdict,
)
from packs.safety.loader import load_safety_pack
@dataclass(frozen=True)
class _FakeFieldState:
"""Stand-in for FieldState — only ``versor_condition`` is checked."""
versor_condition: float
# ---------- preserve_versor_closure ----------
class TestVersorClosurePredicate:
def test_under_threshold_upheld(self) -> None:
check = SafetyCheck()
pack = load_safety_pack()
ctx = SafetyContext(field_state=_FakeFieldState(versor_condition=1.0e-9))
verdict = check.check(ctx, pack)
result = _find(verdict, "preserve_versor_closure")
assert result.upheld
assert result.runtime_checkable
def test_at_or_above_threshold_violated(self) -> None:
check = SafetyCheck()
pack = load_safety_pack()
ctx = SafetyContext(field_state=_FakeFieldState(versor_condition=1.0e-3))
verdict = check.check(ctx, pack)
result = _find(verdict, "preserve_versor_closure")
assert not result.upheld
assert result.runtime_checkable
assert "preserve_versor_closure" in verdict.violated_boundaries
def test_missing_field_state_not_runtime_checkable(self) -> None:
check = SafetyCheck()
pack = load_safety_pack()
verdict = check.check(SafetyContext(), pack)
result = _find(verdict, "preserve_versor_closure")
assert result.upheld
assert not result.runtime_checkable
# ---------- no_fabricated_source ----------
class TestNoFabricatedSourcePredicate:
def test_all_citations_in_allowlist_upheld(self) -> None:
check = SafetyCheck()
pack = load_safety_pack()
allowed = frozenset({"a" * 64, "b" * 64})
ctx = SafetyContext(
cited_source_shas=frozenset({"a" * 64}),
allowed_source_shas=allowed,
)
result = _find(check.check(ctx, pack), "no_fabricated_source")
assert result.upheld
assert result.runtime_checkable
def test_unknown_citation_violates(self) -> None:
check = SafetyCheck()
pack = load_safety_pack()
ctx = SafetyContext(
cited_source_shas=frozenset({"c" * 64}),
allowed_source_shas=frozenset({"a" * 64}),
)
result = _find(check.check(ctx, pack), "no_fabricated_source")
assert not result.upheld
assert result.runtime_checkable
def test_empty_allowlist_not_runtime_checkable(self) -> None:
check = SafetyCheck()
pack = load_safety_pack()
ctx = SafetyContext(
cited_source_shas=frozenset({"a" * 64}),
allowed_source_shas=frozenset(),
)
result = _find(check.check(ctx, pack), "no_fabricated_source")
# Empty allowlist → not in use → predicate cannot judge.
assert result.upheld
assert not result.runtime_checkable
# ---------- no_silent_correction ----------
class TestNoSilentCorrectionPredicate:
def test_typed_refusal_upheld(self) -> None:
check = SafetyCheck()
pack = load_safety_pack()
verdict = check.check(SafetyContext(last_refusal_was_typed=True), pack)
result = _find(verdict, "no_silent_correction")
assert result.upheld
assert result.runtime_checkable
def test_swallowed_refusal_violates(self) -> None:
check = SafetyCheck()
pack = load_safety_pack()
ctx = SafetyContext(last_refusal_was_typed=False)
result = _find(check.check(ctx, pack), "no_silent_correction")
assert not result.upheld
assert result.runtime_checkable
# ---------- no_identity_override ----------
class TestNoIdentityOverridePredicate:
def test_unchanged_manifold_upheld(self) -> None:
check = SafetyCheck()
pack = load_safety_pack()
h = "a" * 64
ctx = SafetyContext(
identity_manifold_hash_before=h,
identity_manifold_hash_after=h,
)
result = _find(check.check(ctx, pack), "no_identity_override")
assert result.upheld
assert result.runtime_checkable
def test_mutated_manifold_violates(self) -> None:
check = SafetyCheck()
pack = load_safety_pack()
ctx = SafetyContext(
identity_manifold_hash_before="a" * 64,
identity_manifold_hash_after="b" * 64,
)
result = _find(check.check(ctx, pack), "no_identity_override")
assert not result.upheld
assert result.runtime_checkable
def test_missing_hashes_not_runtime_checkable(self) -> None:
check = SafetyCheck()
pack = load_safety_pack()
result = _find(check.check(SafetyContext(), pack), "no_identity_override")
assert result.upheld
assert not result.runtime_checkable
# ---------- no_hot_path_repair ----------
class TestNoHotPathRepairPredicate:
def test_always_upheld_never_runtime_checkable(self) -> None:
"""``no_hot_path_repair`` is structural; runtime cannot judge it.
The honest answer: enforcement lives in static analysis + code
review. SafetyCheck reports this transparently.
"""
check = SafetyCheck()
pack = load_safety_pack()
result = _find(check.check(SafetyContext(), pack), "no_hot_path_repair")
assert result.upheld
assert not result.runtime_checkable
assert "static analysis" in result.reason
# ---------- unknown-boundary fallback ----------
class TestUnknownBoundary:
def test_unknown_boundary_defaults_to_upheld_not_runtime_checkable(
self,
) -> None:
# Build a SafetyCheck with NO predicates, then check against the
# real safety pack. Every boundary becomes "unknown".
check = SafetyCheck(predicates={})
pack = load_safety_pack()
verdict = check.check(SafetyContext(), pack)
assert verdict.upheld
assert verdict.runtime_checkable_count == 0
for r in verdict.results:
assert r.upheld
assert not r.runtime_checkable
assert "no predicate registered" in r.reason
# ---------- custom predicate registration ----------
class TestCustomPredicateRegistration:
def test_register_custom_predicate(self) -> None:
def my_pred(ctx: SafetyContext) -> SafetyCheckResult:
return SafetyCheckResult(
boundary_id="my_custom_boundary",
upheld=False,
reason="always fails for testing",
runtime_checkable=True,
)
check = SafetyCheck()
check.register("my_custom_boundary", my_pred)
# Build a pack-like object with our custom boundary.
from packs.safety.loader import SafetyPack
custom_pack = SafetyPack(
pack_id="custom_test",
version="1.0.0",
description="test",
boundary_ids=frozenset({"my_custom_boundary"}),
boundary_descriptions={"my_custom_boundary": "test"},
mastery_report_sha256="",
ratified=False,
)
verdict = check.check(SafetyContext(), custom_pack)
assert not verdict.upheld
assert "my_custom_boundary" in verdict.violated_boundaries
def test_predicate_misreporting_boundary_id_is_corrected(self) -> None:
# Defensive behavior: if a registered predicate returns a result
# with the wrong boundary_id, SafetyCheck rebinds it.
def lying_pred(ctx: SafetyContext) -> SafetyCheckResult:
return SafetyCheckResult(
boundary_id="WRONG",
upheld=True,
reason="defensive test",
runtime_checkable=False,
)
check = SafetyCheck()
check.register("no_silent_correction", lying_pred)
verdict = check.check(SafetyContext(), load_safety_pack())
result = _find(verdict, "no_silent_correction")
assert result.boundary_id == "no_silent_correction"
# ---------- verdict aggregation ----------
class TestVerdictAggregation:
def test_pack_id_recorded(self) -> None:
verdict = SafetyCheck().check(SafetyContext(), load_safety_pack())
assert verdict.pack_id == "core_safety_axes_v1"
def test_results_in_lex_order_on_boundary_id(self) -> None:
verdict = SafetyCheck().check(SafetyContext(), load_safety_pack())
ids = [r.boundary_id for r in verdict.results]
assert ids == sorted(ids)
def test_runtime_checkable_count_under_default_context(self) -> None:
verdict = SafetyCheck().check(SafetyContext(), load_safety_pack())
# With an empty SafetyContext, only ``no_silent_correction`` is
# runtime-checkable (default last_refusal_was_typed=True).
assert verdict.runtime_checkable_count == 1
# ---------- ChatRuntime integration ----------
class TestChatRuntimeIntegration:
def test_runtime_exposes_safety_check(self) -> None:
rt = ChatRuntime(config=RuntimeConfig())
assert isinstance(rt.safety_check, SafetyCheck)
def test_runtime_safety_check_can_evaluate_loaded_pack(self) -> None:
rt = ChatRuntime(config=RuntimeConfig())
verdict = rt.safety_check.check(SafetyContext(), rt.safety_pack)
assert isinstance(verdict, SafetyVerdict)
assert verdict.pack_id == rt.safety_pack.pack_id
assert verdict.upheld # empty ctx → no violations observed
# ---------- helpers ----------
def _find(verdict: SafetyVerdict, boundary_id: str) -> SafetyCheckResult:
for r in verdict.results:
if r.boundary_id == boundary_id:
return r
raise AssertionError(f"boundary {boundary_id!r} not in verdict")