Wires SafetyCheck and EthicsCheck into ChatRuntime at end-of-turn on both the main articulation path and _stub_response. Verdicts attach to ChatResponse.safety_verdict / .ethics_verdict and TurnEvent. Observational at v1: no refusal, no re-articulation, no behavioral change. Refusal policy is the next ADR with real verdict data in hand. Runtime-checkable predicates today: - preserve_versor_closure (via _FieldStateWithVersor adapter) - no_identity_override (manifold hash before vs after; equal by construction) - no_silent_correction (runtime._last_refusal_was_typed bookkeeping) - acknowledge_uncertainty (IdentityScore.alignment + hedge detection) - disclose_limitations (walk_surface == _UNKNOWN_DOMAIN_SURFACE) Predicates with no runtime evidence (no_manipulation, no_fabricated_source, defer_high_stakes_to_human_review, respect_user_autonomy, no_hot_path_repair) honestly report runtime_checkable=False per the ADR-0032/0034 discipline. They become checkable as classifiers and pipelines land — surface contract doesn't change. Test coverage: 14 new tests; combined pack-layer surface suite (loaders + checks + turn-loop) now 122 green. CLI suites unaffected: smoke 67, cognition 121, teaching 17, runtime 19. Cognition eval baseline preserved.
164 lines
5.9 KiB
Python
164 lines
5.9 KiB
Python
"""ADR-0035 — turn-loop auto-invocation of SafetyCheck and EthicsCheck.
|
|
|
|
Observational surfacing: every non-stub turn attaches a SafetyVerdict
|
|
and EthicsVerdict to both the ChatResponse and the TurnEvent. No
|
|
behavioral change. Tests assert presence, shape, and the few
|
|
runtime-evidence fields the turn loop actually populates today.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from chat.runtime import (
|
|
ChatRuntime,
|
|
_hash_identity_manifold,
|
|
_surface_contains_hedge,
|
|
)
|
|
from core.config import RuntimeConfig
|
|
from packs.ethics.check import EthicsVerdict
|
|
from packs.safety.check import SafetyVerdict
|
|
|
|
|
|
# ---------- ChatResponse / TurnEvent contract ----------
|
|
|
|
|
|
class TestVerdictsAttachedToResponse:
|
|
def test_chat_response_carries_safety_verdict(self) -> None:
|
|
rt = ChatRuntime(config=RuntimeConfig())
|
|
resp = rt.chat("light is")
|
|
assert isinstance(resp.safety_verdict, SafetyVerdict)
|
|
assert resp.safety_verdict.pack_id == rt.safety_pack.pack_id
|
|
|
|
def test_chat_response_carries_ethics_verdict(self) -> None:
|
|
rt = ChatRuntime(config=RuntimeConfig())
|
|
resp = rt.chat("light is")
|
|
assert isinstance(resp.ethics_verdict, EthicsVerdict)
|
|
assert resp.ethics_verdict.pack_id == rt.ethics_pack.pack_id
|
|
|
|
def test_turn_event_carries_both_verdicts_when_appended(self) -> None:
|
|
"""When the turn loop appends a TurnEvent (non-stub path), the
|
|
event carries both verdicts."""
|
|
rt = ChatRuntime(config=RuntimeConfig())
|
|
rt.chat("light is")
|
|
# Stub paths bypass turn_log by design; only assert when the
|
|
# main path was taken.
|
|
if rt.turn_log:
|
|
event = rt.turn_log[-1]
|
|
assert isinstance(event.safety_verdict, SafetyVerdict)
|
|
assert isinstance(event.ethics_verdict, EthicsVerdict)
|
|
|
|
|
|
# ---------- runtime evidence the turn loop populates ----------
|
|
|
|
|
|
class TestVersorClosureRuntimeCheckable:
|
|
def test_versor_closure_predicate_runtime_checkable_each_turn(self) -> None:
|
|
rt = ChatRuntime(config=RuntimeConfig())
|
|
resp = rt.chat("light is")
|
|
verdict = resp.safety_verdict
|
|
result = _find_safety(verdict, "preserve_versor_closure")
|
|
assert result.runtime_checkable
|
|
assert result.upheld
|
|
|
|
|
|
class TestIdentityOverrideRuntimeCheckable:
|
|
def test_identity_unchanged_upheld_each_turn(self) -> None:
|
|
rt = ChatRuntime(config=RuntimeConfig())
|
|
resp = rt.chat("light is")
|
|
result = _find_safety(resp.safety_verdict, "no_identity_override")
|
|
assert result.runtime_checkable
|
|
assert result.upheld
|
|
|
|
|
|
class TestNoSilentCorrectionRuntimeCheckable:
|
|
def test_default_path_typed_refusal_upheld(self) -> None:
|
|
rt = ChatRuntime(config=RuntimeConfig())
|
|
resp = rt.chat("light is")
|
|
result = _find_safety(resp.safety_verdict, "no_silent_correction")
|
|
assert result.runtime_checkable
|
|
assert result.upheld
|
|
|
|
|
|
# ---------- ethics evidence the turn loop populates ----------
|
|
|
|
|
|
class TestGroundednessSignal:
|
|
def test_acknowledge_uncertainty_runtime_checkable(self) -> None:
|
|
rt = ChatRuntime(config=RuntimeConfig())
|
|
resp = rt.chat("light is")
|
|
result = _find_ethics(resp.ethics_verdict, "acknowledge_uncertainty")
|
|
assert result.runtime_checkable
|
|
|
|
def test_disclose_limitations_runtime_checkable(self) -> None:
|
|
rt = ChatRuntime(config=RuntimeConfig())
|
|
resp = rt.chat("light is")
|
|
result = _find_ethics(resp.ethics_verdict, "disclose_limitations")
|
|
assert result.runtime_checkable
|
|
|
|
|
|
# ---------- helpers exercised independently ----------
|
|
|
|
|
|
class TestHashIdentityManifold:
|
|
def test_hash_is_deterministic(self) -> None:
|
|
rt = ChatRuntime(config=RuntimeConfig())
|
|
h1 = _hash_identity_manifold(rt.identity_manifold)
|
|
h2 = _hash_identity_manifold(rt.identity_manifold)
|
|
assert h1 == h2
|
|
assert len(h1) == 64
|
|
|
|
def test_pre_turn_hash_matches_post_turn_hash(self) -> None:
|
|
rt = ChatRuntime(config=RuntimeConfig())
|
|
before = _hash_identity_manifold(rt.identity_manifold)
|
|
rt.chat("light is")
|
|
after = _hash_identity_manifold(rt.identity_manifold)
|
|
assert before == after
|
|
|
|
|
|
class TestSurfaceContainsHedge:
|
|
def test_no_hedge_returns_false(self) -> None:
|
|
rt = ChatRuntime(config=RuntimeConfig())
|
|
assert not _surface_contains_hedge(
|
|
"the cat sat on the mat", rt.identity_manifold,
|
|
)
|
|
|
|
def test_empty_surface_returns_false(self) -> None:
|
|
rt = ChatRuntime(config=RuntimeConfig())
|
|
assert not _surface_contains_hedge("", rt.identity_manifold)
|
|
|
|
def test_known_hedge_phrase_detected(self) -> None:
|
|
rt = ChatRuntime(config=RuntimeConfig())
|
|
prefs = rt.identity_manifold.surface_preferences
|
|
hedge_phrase = prefs.preferred_hedge_soft or prefs.preferred_hedge_strong
|
|
if not hedge_phrase:
|
|
return # no hedge phrases configured in this pack
|
|
text = f"the answer is {hedge_phrase} that it works"
|
|
assert _surface_contains_hedge(text, rt.identity_manifold)
|
|
|
|
|
|
# ---------- verdict aggregation (no_manipulation always non-checkable) ----------
|
|
|
|
|
|
class TestNoManipulationStructural:
|
|
def test_no_manipulation_runtime_checkable_false_each_turn(self) -> None:
|
|
rt = ChatRuntime(config=RuntimeConfig())
|
|
resp = rt.chat("light is")
|
|
result = _find_ethics(resp.ethics_verdict, "no_manipulation")
|
|
assert not result.runtime_checkable
|
|
assert result.upheld
|
|
|
|
|
|
# ---------- helpers ----------
|
|
|
|
|
|
def _find_safety(verdict: SafetyVerdict, boundary_id: str):
|
|
for r in verdict.results:
|
|
if r.boundary_id == boundary_id:
|
|
return r
|
|
raise AssertionError(f"{boundary_id!r} not in safety verdict")
|
|
|
|
|
|
def _find_ethics(verdict: EthicsVerdict, commitment_id: str):
|
|
for r in verdict.results:
|
|
if r.commitment_id == commitment_id:
|
|
return r
|
|
raise AssertionError(f"{commitment_id!r} not in ethics verdict")
|