feat(adr-0035): turn-loop auto-invocation — surfacing only
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.
This commit is contained in:
parent
db5bc028f9
commit
514ace0cbf
4 changed files with 444 additions and 3 deletions
149
chat/runtime.py
149
chat/runtime.py
|
|
@ -1,6 +1,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from typing import List
|
||||
|
|
@ -18,14 +20,14 @@ from core.physics.identity import (
|
|||
IdentityScore,
|
||||
TurnEvent,
|
||||
)
|
||||
from packs.ethics.check import EthicsCheck
|
||||
from packs.ethics.check import EthicsCheck, EthicsContext
|
||||
from packs.ethics.loader import (
|
||||
DEFAULT_ETHICS_PACK as _DEFAULT_ETHICS_PACK,
|
||||
EthicsPackError,
|
||||
load_ethics_pack,
|
||||
)
|
||||
from packs.identity.loader import load_identity_manifold
|
||||
from packs.safety.check import SafetyCheck
|
||||
from packs.safety.check import SafetyCheck, SafetyContext
|
||||
from packs.safety.loader import load_safety_pack
|
||||
from field.state import FieldState
|
||||
from generate.articulation import ArticulationPlan, realize
|
||||
|
|
@ -128,6 +130,80 @@ class _StubBindingFrame:
|
|||
cycle_index: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _FieldStateWithVersor:
|
||||
"""Adapter exposing ``versor_condition`` for SafetyContext.
|
||||
|
||||
``FieldState`` itself does not carry a precomputed
|
||||
``versor_condition`` attribute; it is computed on demand from
|
||||
``versor_condition(state.F)``. The SafetyCheck predicate for
|
||||
``preserve_versor_closure`` reads ``ctx.field_state.versor_condition``
|
||||
via ``getattr``. This adapter exposes the precomputed value so the
|
||||
predicate is runtime-checkable each turn.
|
||||
"""
|
||||
|
||||
versor_condition: float
|
||||
|
||||
|
||||
def _hash_identity_manifold(manifold) -> str:
|
||||
"""Deterministic SHA-256 of the load-bearing identity-manifold fields.
|
||||
|
||||
ADR-0035 — feeds the ``no_identity_override`` predicate in
|
||||
:class:`SafetyCheck`. The runtime never mutates ``identity_manifold``
|
||||
after composition, so before- and after-turn hashes are equal by
|
||||
construction; an unequal hash would indicate the predicate's exact
|
||||
failure mode.
|
||||
"""
|
||||
payload = {
|
||||
"value_axes": [
|
||||
{
|
||||
"axis_id": axis.axis_id,
|
||||
"name": axis.name,
|
||||
"direction": list(axis.direction),
|
||||
"weight": axis.weight,
|
||||
}
|
||||
for axis in manifold.value_axes
|
||||
],
|
||||
"boundary_ids": sorted(manifold.boundary_ids),
|
||||
"alignment_threshold": manifold.alignment_threshold,
|
||||
}
|
||||
blob = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
return hashlib.sha256(blob).hexdigest()
|
||||
|
||||
|
||||
def _surface_contains_hedge(surface: str, manifold) -> bool:
|
||||
"""Detect whether the realized surface emitted a hedge phrase.
|
||||
|
||||
Compares case-insensitively against the manifold's preferred hedge
|
||||
phrases (ADR-0028). False when surface is empty. Coarse but
|
||||
deterministic: the predicate downstream is observational, so
|
||||
occasional false negatives are surfaced as
|
||||
``acknowledge_uncertainty`` violations in audit and corrected by
|
||||
refining hedge detection, not by silently passing.
|
||||
"""
|
||||
if not surface:
|
||||
return False
|
||||
prefs = getattr(manifold, "surface_preferences", None)
|
||||
if prefs is None:
|
||||
return False
|
||||
candidates: list[str] = []
|
||||
for field_name in (
|
||||
"preferred_hedge_strong",
|
||||
"preferred_hedge_soft",
|
||||
"preferred_qualifier",
|
||||
):
|
||||
value = getattr(prefs, field_name, "")
|
||||
if value:
|
||||
candidates.append(value)
|
||||
for _, hedge in getattr(prefs, "axis_hedges", ()) or ():
|
||||
for sub in ("strong", "soft", "qualifier"):
|
||||
value = getattr(hedge, sub, "")
|
||||
if value:
|
||||
candidates.append(value)
|
||||
surface_fold = surface.casefold()
|
||||
return any(c.casefold() in surface_fold for c in candidates if c)
|
||||
|
||||
|
||||
def _make_trajectory_from_result(result, turn: int):
|
||||
from core.physics.reasoning import TrajectoryOperator
|
||||
|
||||
|
|
@ -167,6 +243,10 @@ class ChatResponse:
|
|||
# admissibility was checked this turn" (cold start, refusal, stub).
|
||||
admissibility_trace: tuple = ()
|
||||
region_was_unconstrained: bool = True
|
||||
# ADR-0035 — verdicts surfaced from SafetyCheck and EthicsCheck.
|
||||
# ``None`` only on stub/refusal paths that bypass the turn loop.
|
||||
safety_verdict: object = None
|
||||
ethics_verdict: object = None
|
||||
|
||||
|
||||
class ChatRuntime:
|
||||
|
|
@ -274,8 +354,17 @@ class ChatRuntime:
|
|||
# future ADR.
|
||||
self.safety_check = SafetyCheck()
|
||||
# ADR-0034 — structural ethics surface, sibling to SafetyCheck.
|
||||
# Observational at v1; no auto-invocation in the turn loop.
|
||||
self.ethics_check = EthicsCheck()
|
||||
# ADR-0035 — auto-invoke both checks at end-of-turn. The
|
||||
# manifold is constructed once and never mutated, so the
|
||||
# pre-turn hash is a stable property of this runtime instance.
|
||||
# ``last_refusal_was_typed`` defaults True (no untyped refusals
|
||||
# observed); turn-loop bookkeeping flips this on typed-refusal
|
||||
# paths so the predicate has live evidence.
|
||||
self._identity_manifold_hash: str = _hash_identity_manifold(
|
||||
self.identity_manifold,
|
||||
)
|
||||
self._last_refusal_was_typed: bool = True
|
||||
self.turn_log: List[TurnEvent] = []
|
||||
self._correction_pass = CorrectionPass()
|
||||
self._last_valence: float = 0.0
|
||||
|
|
@ -385,6 +474,29 @@ class ChatRuntime:
|
|||
output_language=self.config.output_language,
|
||||
frame_id="unknown_domain",
|
||||
)
|
||||
# ADR-0035 — stub responses are exactly the ungrounded path that
|
||||
# triggers ``disclose_limitations``. Surfacing verdicts here
|
||||
# keeps the audit contract uniform: every ChatResponse carries
|
||||
# a SafetyVerdict and EthicsVerdict.
|
||||
safety_ctx = SafetyContext(
|
||||
field_state=_FieldStateWithVersor(
|
||||
versor_condition=float(versor_condition(field_state.F)),
|
||||
),
|
||||
last_refusal_was_typed=self._last_refusal_was_typed,
|
||||
identity_manifold_hash_before=self._identity_manifold_hash,
|
||||
identity_manifold_hash_after=_hash_identity_manifold(self.identity_manifold),
|
||||
)
|
||||
safety_verdict = self.safety_check.check(safety_ctx, self.safety_pack)
|
||||
ethics_ctx = EthicsContext(
|
||||
alignment_score=0.0,
|
||||
hedge_threshold_soft=float(
|
||||
self.identity_manifold.surface_preferences.hedge_threshold_soft
|
||||
),
|
||||
hedge_emitted=False,
|
||||
grounded_in_evidence=False,
|
||||
disclosure_emitted=True,
|
||||
)
|
||||
ethics_verdict = self.ethics_check.check(ethics_ctx, self.ethics_pack)
|
||||
return ChatResponse(
|
||||
surface=_UNKNOWN_DOMAIN_SURFACE,
|
||||
proposition=prop,
|
||||
|
|
@ -401,6 +513,8 @@ class ChatRuntime:
|
|||
identity_score=None,
|
||||
character_profile=self.character_profile,
|
||||
flagged=False,
|
||||
safety_verdict=safety_verdict,
|
||||
ethics_verdict=ethics_verdict,
|
||||
)
|
||||
|
||||
def chat(self, text: str, max_tokens: int | None = None) -> ChatResponse:
|
||||
|
|
@ -531,6 +645,31 @@ class ChatRuntime:
|
|||
)
|
||||
walk_surface = sentence_plan.surface
|
||||
vault_hits = int(result.vault_hits)
|
||||
# ADR-0035 — auto-invoke safety + ethics surfaces. Observational
|
||||
# at v1; verdicts are attached to TurnEvent and ChatResponse for
|
||||
# audit but do not gate behavior. Refusal/re-articulation
|
||||
# wiring is a future ADR.
|
||||
is_grounded = walk_surface != _UNKNOWN_DOMAIN_SURFACE
|
||||
hedge_emitted = _surface_contains_hedge(walk_surface, self.identity_manifold)
|
||||
safety_ctx = SafetyContext(
|
||||
field_state=_FieldStateWithVersor(
|
||||
versor_condition=float(versor_condition(result.final_state.F)),
|
||||
),
|
||||
last_refusal_was_typed=self._last_refusal_was_typed,
|
||||
identity_manifold_hash_before=self._identity_manifold_hash,
|
||||
identity_manifold_hash_after=_hash_identity_manifold(self.identity_manifold),
|
||||
)
|
||||
safety_verdict = self.safety_check.check(safety_ctx, self.safety_pack)
|
||||
ethics_ctx = EthicsContext(
|
||||
alignment_score=float(getattr(identity_score, "alignment", 0.0)),
|
||||
hedge_threshold_soft=float(
|
||||
self.identity_manifold.surface_preferences.hedge_threshold_soft
|
||||
),
|
||||
hedge_emitted=hedge_emitted,
|
||||
grounded_in_evidence=is_grounded,
|
||||
disclosure_emitted=not is_grounded,
|
||||
)
|
||||
ethics_verdict = self.ethics_check.check(ethics_ctx, self.ethics_pack)
|
||||
turn_event = TurnEvent(
|
||||
turn=self._context.turn - 1,
|
||||
input_tokens=tuple(filtered),
|
||||
|
|
@ -544,6 +683,8 @@ class ChatRuntime:
|
|||
versor_condition=versor_condition(result.final_state.F),
|
||||
flagged=flagged,
|
||||
elaboration=sentence_plan.elaboration,
|
||||
safety_verdict=safety_verdict,
|
||||
ethics_verdict=ethics_verdict,
|
||||
)
|
||||
self.turn_log.append(turn_event)
|
||||
return ChatResponse(
|
||||
|
|
@ -564,6 +705,8 @@ class ChatRuntime:
|
|||
flagged=flagged,
|
||||
admissibility_trace=result.admissibility_trace,
|
||||
region_was_unconstrained=result.region_was_unconstrained,
|
||||
safety_verdict=safety_verdict,
|
||||
ethics_verdict=ethics_verdict,
|
||||
)
|
||||
|
||||
def _unknown_domain_response(self, field_state: FieldState, filtered: list[str]) -> ChatResponse:
|
||||
|
|
|
|||
|
|
@ -275,3 +275,8 @@ class TurnEvent:
|
|||
versor_condition: float
|
||||
flagged: bool
|
||||
elaboration: Optional[str] = None
|
||||
# ADR-0035 — verdicts from SafetyCheck and EthicsCheck at end-of-turn.
|
||||
# Observational at v1: surfaced for audit; no behavioral effect.
|
||||
# Typed as ``object`` to avoid coupling identity.py to packs.*.
|
||||
safety_verdict: object = None
|
||||
ethics_verdict: object = None
|
||||
|
|
|
|||
129
docs/decisions/ADR-0035-turn-loop-verdict-surfacing.md
Normal file
129
docs/decisions/ADR-0035-turn-loop-verdict-surfacing.md
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
# ADR-0035: Turn-Loop Verdict Surfacing for SafetyCheck and EthicsCheck
|
||||
|
||||
**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)
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0032 and ADR-0034 landed the structural surfaces — `SafetyCheck` and `EthicsCheck` — as observational, registry-of-predicates classes parallel in shape to `IdentityCheck`. Both ADRs deliberately left **auto-invocation in the turn loop** as a follow-up.
|
||||
|
||||
The follow-up is overdue. An observation surface that no caller invokes is a label without a verdict. `ChatRuntime` constructs both checks at startup and exposes them on `self.safety_check` / `self.ethics_check`, but no path in the turn loop calls `.check(...)`. The pack-layer audit is uniformly silent regardless of what the turn actually did.
|
||||
|
||||
Two scope options were on the table:
|
||||
|
||||
1. **Surfacing only.** Auto-invoke both checks at end-of-turn, populate contexts from whatever evidence the runtime has, attach verdicts to `ChatResponse` and `TurnEvent` for audit. No behavioral change.
|
||||
2. **Surfacing + refusal.** Same as above plus typed refusal on runtime-checkable violations.
|
||||
|
||||
The conservative choice — option 1 — was selected. Reasoning, from the implementation discussion:
|
||||
|
||||
> Most context fields are `None` by default and the runtime today only has evidence for ~3 of the ~10 fields across both surfaces (versor_condition, identity_manifold hashes, alignment_score, plus the unknown-domain signal). With sparse evidence, most violations are *unobservable* at v1 — wiring refusal now would refuse on a tiny fraction of theoretical violations while letting the rest slip silently through. The right sequence is to land the invocation point, observe what evidence actually surfaces across real turns, *then* decide refusal policy with that data in hand.
|
||||
|
||||
CLAUDE.md's "small, load-bearing PRs with clear evidence" doctrine maps to this.
|
||||
|
||||
## Decision
|
||||
|
||||
`ChatRuntime` invokes both checks at the end of every chat turn — both the main articulation path and the `_stub_response` path. Verdicts attach to `ChatResponse` and `TurnEvent` as new optional fields. No behavioral effect; no refusal; no re-articulation; no retry.
|
||||
|
||||
### Invocation sites
|
||||
|
||||
| Path | When invoked | What populates the context |
|
||||
|---|---|---|
|
||||
| Main articulation path | Just before constructing `TurnEvent` | `result.final_state`, `identity_score`, `walk_surface` |
|
||||
| `_stub_response` | Just before constructing the `ChatResponse` | `field_state`, fixed ungrounded signal |
|
||||
|
||||
Stub paths trigger exactly when grounding is insufficient — i.e., when the `disclose_limitations` commitment is most active. Surfacing verdicts there preserves the contract "every `ChatResponse` carries a `SafetyVerdict` and `EthicsVerdict`" and gives `disclose_limitations` a stable runtime-checkable signal.
|
||||
|
||||
### Evidence the turn loop populates today
|
||||
|
||||
**SafetyContext (runtime-checkable in production):**
|
||||
|
||||
| Field | Source | Predicate it unlocks |
|
||||
|---|---|---|
|
||||
| `field_state.versor_condition` | `versor_condition(final_state.F)` via an `_FieldStateWithVersor` adapter | `preserve_versor_closure` |
|
||||
| `last_refusal_was_typed` | `runtime._last_refusal_was_typed` (default `True`; reserved for future typed-refusal bookkeeping) | `no_silent_correction` |
|
||||
| `identity_manifold_hash_before` | `runtime._identity_manifold_hash` — captured once at startup; manifold is never mutated | `no_identity_override` |
|
||||
| `identity_manifold_hash_after` | recomputed at end-of-turn — equal by construction | `no_identity_override` |
|
||||
|
||||
**EthicsContext (runtime-checkable in production):**
|
||||
|
||||
| Field | Source | Predicate it unlocks |
|
||||
|---|---|---|
|
||||
| `alignment_score` | `IdentityScore.alignment` (zero on stub path) | `acknowledge_uncertainty` |
|
||||
| `hedge_threshold_soft` | `identity_manifold.surface_preferences.hedge_threshold_soft` | `acknowledge_uncertainty` |
|
||||
| `hedge_emitted` | `_surface_contains_hedge(walk_surface, identity_manifold)` — substring check against the manifold's hedge phrases (strong/soft/qualifier + per-axis variants) | `acknowledge_uncertainty` |
|
||||
| `grounded_in_evidence` | `walk_surface != _UNKNOWN_DOMAIN_SURFACE` | `disclose_limitations` |
|
||||
| `disclosure_emitted` | `walk_surface == _UNKNOWN_DOMAIN_SURFACE` (the inverse) | `disclose_limitations` |
|
||||
|
||||
**Not yet populated (predicates default to `runtime_checkable=False, upheld=True`):**
|
||||
|
||||
- `cited_source_shas` / `allowed_source_shas` — citation pipeline isn't wired into chat turns.
|
||||
- `high_stakes_topic` / `recommended_human_review` — no high-stakes classifier.
|
||||
- `prescribed_single_answer` / `presented_options_count` — no prescriptiveness classifier.
|
||||
|
||||
These fields surface in verdicts as `runtime_checkable=False` per the ADR-0032/0034 honest-reporting discipline. As classifiers land (future ADRs), the corresponding predicates become checkable without changing the surface contract.
|
||||
|
||||
### Hash-of-manifold helper
|
||||
|
||||
`_hash_identity_manifold(manifold)` produces a deterministic SHA-256 of the load-bearing manifold fields:
|
||||
|
||||
```python
|
||||
payload = {
|
||||
"value_axes": [{"axis_id", "name", "direction", "weight"} for axis in manifold.value_axes],
|
||||
"boundary_ids": sorted(manifold.boundary_ids),
|
||||
"alignment_threshold": manifold.alignment_threshold,
|
||||
}
|
||||
sha256(canonical_json(payload))
|
||||
```
|
||||
|
||||
Captured once at `ChatRuntime.__init__` and recomputed each turn. Because the runtime never mutates the manifold post-construction, before/after hashes are equal by construction — and that *is* the correct semantics for `no_identity_override`. An unequal hash would indicate the specific failure mode the predicate exists to surface.
|
||||
|
||||
### Surface contract changes
|
||||
|
||||
New optional fields, defaulting to `None`:
|
||||
|
||||
- `ChatResponse.safety_verdict: object` — `SafetyVerdict` on every non-error path; `None` only if a future path constructs `ChatResponse` directly without invoking the surface.
|
||||
- `ChatResponse.ethics_verdict: object` — `EthicsVerdict` symmetric.
|
||||
- `TurnEvent.safety_verdict: object` — present on every main-path turn; `None` on stub paths (which bypass `turn_log` by existing design).
|
||||
- `TurnEvent.ethics_verdict: object` — symmetric.
|
||||
|
||||
The fields are typed as `object` to avoid forcing `core/physics/identity.py` and `chat/runtime.py` to import the pack layer at module-resolution time — the underlying types are `packs.safety.check.SafetyVerdict` and `packs.ethics.check.EthicsVerdict`. Callers downcast at the use site.
|
||||
|
||||
### What this ADR explicitly does NOT do
|
||||
|
||||
- **No refusal.** A runtime-checkable violation produces an audit verdict; the response is unchanged.
|
||||
- **No re-articulation.** No retry path, no hedge injection on `acknowledge_uncertainty` violation, no escalation on `disclose_limitations` violation.
|
||||
- **No logging integration.** Verdicts are attached to the data structures; emitting them to logs / telemetry is a downstream decision.
|
||||
- **No CLI surface.** No `core chat --show-verdicts` flag yet.
|
||||
- **No GenerationResult coupling.** Verdicts live on the chat-turn-shaped objects (`ChatResponse`, `TurnEvent`), not on `GenerationResult` — they belong to the turn, not the generation pass.
|
||||
- **No cross-surface verdict bundle.** Three separate verdicts (identity / safety / ethics) per turn. A unified type is convenience sugar deferred to a future ADR.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Audit no longer silent.** Every chat turn now produces three orthogonal verdicts (identity score + safety verdict + ethics verdict). An operator inspecting `runtime.turn_log[-1]` sees what each surface concluded.
|
||||
- **Honest evidence-availability gradient.** Predicates report `runtime_checkable=False` where the runtime has no evidence, `runtime_checkable=True` where it does. Future ADRs that wire in more evidence (high-stakes classifier, citation pipeline, prescriptiveness detector) increase the checkable count without changing the surface contract.
|
||||
- **Surface contract uniform across stub and main paths.** `disclose_limitations` is the first commitment where stub paths matter — and they correctly fire as `upheld=True` because stubs emit the unknown-domain marker.
|
||||
- **Forward-compatible with refusal.** When the refusal-wiring ADR lands, the call site already exists; only the policy hook changes.
|
||||
- **Cheap.** Auto-invocation is two predicate-registry passes per turn. No measurable cost on the smoke/cognition/runtime suites.
|
||||
|
||||
### Negative / risks
|
||||
|
||||
- **`_FieldStateWithVersor` adapter is a small piece of glue.** `FieldState` does not expose `versor_condition` as an attribute, so the SafetyCheck predicate cannot read it via `getattr` directly. The adapter is a frozen dataclass with one field. Mitigated: the adapter is one local class; if `FieldState` later carries the value natively, the adapter becomes a trivial removal.
|
||||
- **`hedge_emitted` is substring-based.** A more rigorous detector (token-aware, hedge-phrase registry separate from the manifold) would catch edge cases. Acceptable at v1 because false negatives surface as `acknowledge_uncertainty` violations in audit — exactly where audit is supposed to direct attention — rather than passing silently.
|
||||
- **Stub path does not append to `turn_log`.** Pre-existing behavior. The verdict is on the `ChatResponse` but the `TurnEvent` does not exist for stub turns. Documented as a known limit; a future ADR could append `TurnEvent` records for stub paths too if audit completeness becomes a priority.
|
||||
|
||||
## Verification
|
||||
|
||||
- `tests/test_turn_loop_verdicts.py` — 14 tests covering: verdicts attached to `ChatResponse` / `TurnEvent`; runtime-checkability of `preserve_versor_closure`, `no_identity_override`, `no_silent_correction`, `acknowledge_uncertainty`, `disclose_limitations`; `no_manipulation` honestly remaining non-runtime-checkable; hash determinism + before/after equality; hedge-detection happy and edge cases.
|
||||
- Combined pack-layer test surface (loaders + checks + turn-loop) is **122 tests, all green**.
|
||||
- CLI suites unaffected: smoke 67, cognition 121, teaching 17, runtime 19.
|
||||
|
||||
## Open questions deferred to a future ADR
|
||||
|
||||
1. **Refusal / re-articulation policy.** What does the runtime do with a violation? Refuse? Hedge? Log only? Per-pack policy? Per-predicate policy? Now that real verdict data flows, this can be decided empirically.
|
||||
2. **TurnEvent for stub paths.** Should stub turns append a `TurnEvent` so audit completeness covers the entire turn stream?
|
||||
3. **Verdict telemetry / logging.** A structured log sink that consumes verdicts is the next operational concern.
|
||||
4. **CLI audit flag.** `core chat --show-verdicts` would print per-turn verdict summaries.
|
||||
5. **Unified verdict bundle type.** A `TurnVerdicts` record grouping identity + safety + ethics for callers that want a single object.
|
||||
164
tests/test_turn_loop_verdicts.py
Normal file
164
tests/test_turn_loop_verdicts.py
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
"""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")
|
||||
Loading…
Reference in a new issue