core/tests/test_turn_verdicts_bundle.py
Shay f3cc408f82 feat(adr-0039): audit completeness — TurnVerdicts bundle, stub TurnEvent, hedge_injected
Closes three audit gaps left by the ADR-0035→ADR-0038 pack-layer
surface:

1. TurnVerdicts bundle (chat/verdicts.py) — frozen dataclass
   aggregating identity_score + safety_verdict + ethics_verdict +
   refusal_emitted + hedge_injected.  Attached to both
   ChatResponse.verdicts and TurnEvent.verdicts.  Fields typed as
   object for the same module-coupling reason as
   TurnEvent.safety_verdict.

2. Stub-path TurnEvent emission — _stub_response accepts optional
   tokens kwarg and appends a TurnEvent to turn_log when invoked
   from a real turn.  Audit consumers can now iterate turn_log
   end-to-end without missing stub paths.  Defensive call sites
   (correct() fallback) bypass the append by omitting tokens.

3. refusal_emitted / hedge_injected flags — runtime tracks whether
   it actually mutated the surface this turn.  hedge_injected uses
   idempotent-on-prefix semantics (True iff the runtime ADDED a
   hedge, not iff a hedge happens to be present).

Test-pattern note: previous "gate on rt.turn_log to detect main vs
stub" pattern is now broken; updated to gate on walk_surface ==
_UNKNOWN_DOMAIN_SURFACE.  One existing hedge-injection test gate
updated accordingly.

Back-compat: ADR-0035→0038 per-field accessors
(response.safety_verdict, etc.) still work.  New consumers should
read response.verdicts.

Files:
- chat/verdicts.py (new) — TurnVerdicts dataclass
- chat/runtime.py — _stub_response tokens kwarg + stub TurnEvent
  append + hedge_injected tracking + bundle construction
- core/physics/identity.py — TurnEvent.verdicts: object = None
- tests/test_turn_verdicts_bundle.py (new) — 16 tests
- tests/test_hedge_injection.py — gate fix for stub detection
- docs/decisions/ADR-0039-audit-completeness.md (new)

Verification:
- Combined pack-layer suite: 170 green (was 154 after ADR-0038)
- CLI suites unchanged: smoke 67, runtime 19, cognition 121
- core eval cognition: intent 100%, versor_closure 100% (baseline)
2026-05-17 21:32:46 -07:00

242 lines
8.7 KiB
Python

"""ADR-0039 — audit completeness: TurnVerdicts bundle, stub-path
TurnEvent emission, hedge_injected signal.
The bundle replaces the per-field correlation pattern downstream
audit consumers had to do (read identity_score, safety_verdict,
ethics_verdict, then infer remediation from surface text). The
remediation flags ``refusal_emitted`` and ``hedge_injected`` make
the runtime's decisions auditable without text inspection.
"""
from __future__ import annotations
from dataclasses import replace
from chat.runtime import ChatRuntime, _UNKNOWN_DOMAIN_SURFACE
from chat.verdicts import TurnVerdicts
from core.config import RuntimeConfig
from packs.ethics.check import EthicsCheckResult, EthicsVerdict
from packs.safety.check import SafetyCheckResult, SafetyVerdict
# ---------- TurnVerdicts shape ----------
class TestTurnVerdictsShape:
def test_construct_with_required_fields(self) -> None:
v = TurnVerdicts(
identity_score=None,
safety_verdict=None,
ethics_verdict=None,
refusal_emitted=False,
hedge_injected=False,
)
assert v.refusal_emitted is False
assert v.hedge_injected is False
def test_is_frozen(self) -> None:
v = TurnVerdicts(
identity_score=None,
safety_verdict=None,
ethics_verdict=None,
refusal_emitted=False,
hedge_injected=False,
)
try:
v.refusal_emitted = True # type: ignore[misc]
except (AttributeError, Exception):
return
else:
raise AssertionError("TurnVerdicts should be frozen")
# ---------- ChatResponse and TurnEvent carry the bundle ----------
class TestBundleAttachment:
def test_chat_response_carries_verdicts_bundle(self) -> None:
rt = ChatRuntime(config=RuntimeConfig())
resp = rt.chat("light is")
assert isinstance(resp.verdicts, TurnVerdicts)
# Bundle's individual verdicts match the per-field versions.
assert resp.verdicts.safety_verdict is resp.safety_verdict
assert resp.verdicts.ethics_verdict is resp.ethics_verdict
def test_turn_event_carries_verdicts_bundle(self) -> None:
rt = ChatRuntime(config=RuntimeConfig())
rt.chat("light is")
assert rt.turn_log, "stub path should now append TurnEvent (ADR-0039)"
event = rt.turn_log[-1]
assert isinstance(event.verdicts, TurnVerdicts)
# ---------- stub-path TurnEvent emission (ADR-0039) ----------
class TestStubPathTurnEvent:
def test_stub_path_appends_turn_event(self) -> None:
rt = ChatRuntime(config=RuntimeConfig())
before = len(rt.turn_log)
resp = rt.chat("light is")
# Cold start with no vault hits → stub path fires.
assert resp.walk_surface == _UNKNOWN_DOMAIN_SURFACE
assert len(rt.turn_log) == before + 1
def test_stub_event_records_unknown_walk_surface(self) -> None:
rt = ChatRuntime(config=RuntimeConfig())
rt.chat("light is")
event = rt.turn_log[-1]
assert event.walk_surface == _UNKNOWN_DOMAIN_SURFACE
assert event.articulation_surface == _UNKNOWN_DOMAIN_SURFACE
def test_stub_event_carries_input_tokens(self) -> None:
rt = ChatRuntime(config=RuntimeConfig())
rt.chat("light is")
event = rt.turn_log[-1]
assert event.input_tokens, "stub TurnEvent must record input tokens"
def test_stub_event_identity_score_is_none(self) -> None:
"""No reasoning trajectory ran on stub path, so no
IdentityScore is computed."""
rt = ChatRuntime(config=RuntimeConfig())
rt.chat("light is")
event = rt.turn_log[-1]
assert event.identity_score is None
def test_stub_event_versor_condition_recorded(self) -> None:
rt = ChatRuntime(config=RuntimeConfig())
rt.chat("light is")
event = rt.turn_log[-1]
assert isinstance(event.versor_condition, float)
# ---------- refusal_emitted flag ----------
class TestRefusalEmittedFlag:
def test_no_violation_flag_is_false(self) -> None:
rt = ChatRuntime(config=RuntimeConfig())
resp = rt.chat("light is")
assert resp.verdicts.refusal_emitted is False
def test_safety_violation_sets_flag(self) -> None:
rt = ChatRuntime(config=RuntimeConfig())
def _failing(ctx): # noqa: ANN001
return SafetyCheckResult(
boundary_id="preserve_versor_closure",
upheld=False,
reason="forced",
runtime_checkable=True,
)
rt.safety_check.register("preserve_versor_closure", _failing)
resp = rt.chat("light is")
assert resp.verdicts.refusal_emitted is True
# And the flag matches the surface state.
assert resp.verdicts.refusal_emitted == (resp.surface != resp.walk_surface)
def test_flag_appears_on_turn_event_too(self) -> None:
rt = ChatRuntime(config=RuntimeConfig())
def _failing(ctx): # noqa: ANN001
return SafetyCheckResult(
boundary_id="preserve_versor_closure",
upheld=False,
reason="forced",
runtime_checkable=True,
)
rt.safety_check.register("preserve_versor_closure", _failing)
rt.chat("light is")
event = rt.turn_log[-1]
assert event.verdicts.refusal_emitted is True
# ---------- hedge_injected flag ----------
class TestHedgeInjectedFlag:
def test_no_opt_in_flag_is_false(self) -> None:
rt = ChatRuntime(config=RuntimeConfig())
resp = rt.chat("light is")
assert resp.verdicts.hedge_injected is False
def test_stub_path_never_sets_hedge_injected(self) -> None:
"""Even with opted-in hedge_commitments and a forced
violation, the stub path must report hedge_injected=False
(stub never hedges — the unknown-domain marker is already
a disclosure)."""
rt = ChatRuntime(config=RuntimeConfig())
rt.ethics_pack = replace(
rt.ethics_pack,
hedge_commitments=frozenset({"acknowledge_uncertainty"}),
)
def _failing_ethics(ctx): # noqa: ANN001
return EthicsCheckResult(
commitment_id="acknowledge_uncertainty",
upheld=False,
reason="forced",
runtime_checkable=True,
)
rt.ethics_check.register("acknowledge_uncertainty", _failing_ethics)
resp = rt.chat("light is")
if resp.walk_surface == _UNKNOWN_DOMAIN_SURFACE:
assert resp.verdicts.hedge_injected is False
# ---------- mutual exclusion refusal vs hedge in the bundle ----------
class TestRemediationMutualExclusion:
def test_refusal_and_hedge_never_both_true(self) -> None:
"""The runtime contract: refusal supersedes hedge. When both
a runtime-checkable safety violation AND an opted-in ethics
hedge would fire, refusal wins and hedge_injected stays
False."""
rt = ChatRuntime(config=RuntimeConfig())
rt.ethics_pack = replace(
rt.ethics_pack,
hedge_commitments=frozenset({"acknowledge_uncertainty"}),
)
def _failing_safety(ctx): # noqa: ANN001
return SafetyCheckResult(
boundary_id="preserve_versor_closure",
upheld=False,
reason="forced",
runtime_checkable=True,
)
def _failing_ethics(ctx): # noqa: ANN001
return EthicsCheckResult(
commitment_id="acknowledge_uncertainty",
upheld=False,
reason="forced",
runtime_checkable=True,
)
rt.safety_check.register("preserve_versor_closure", _failing_safety)
rt.ethics_check.register("acknowledge_uncertainty", _failing_ethics)
resp = rt.chat("light is")
assert resp.verdicts.refusal_emitted is True
assert resp.verdicts.hedge_injected is False
# ---------- response and event bundles agree ----------
class TestBundleConsistency:
def test_response_and_event_bundle_are_consistent(self) -> None:
"""The TurnVerdicts on ChatResponse and TurnEvent for the same
turn carry the same remediation flags and reference the same
underlying verdicts."""
rt = ChatRuntime(config=RuntimeConfig())
resp = rt.chat("light is")
event = rt.turn_log[-1]
assert resp.verdicts.refusal_emitted == event.verdicts.refusal_emitted
assert resp.verdicts.hedge_injected == event.verdicts.hedge_injected
assert resp.verdicts.safety_verdict is event.verdicts.safety_verdict
assert resp.verdicts.ethics_verdict is event.verdicts.ethics_verdict