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:
Shay 2026-05-17 20:57:33 -07:00
parent db5bc028f9
commit 514ace0cbf
4 changed files with 444 additions and 3 deletions

View file

@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
import hashlib
import json
import re import re
from collections.abc import Sequence from collections.abc import Sequence
from typing import List from typing import List
@ -18,14 +20,14 @@ from core.physics.identity import (
IdentityScore, IdentityScore,
TurnEvent, TurnEvent,
) )
from packs.ethics.check import EthicsCheck from packs.ethics.check import EthicsCheck, EthicsContext
from packs.ethics.loader import ( from packs.ethics.loader import (
DEFAULT_ETHICS_PACK as _DEFAULT_ETHICS_PACK, DEFAULT_ETHICS_PACK as _DEFAULT_ETHICS_PACK,
EthicsPackError, EthicsPackError,
load_ethics_pack, load_ethics_pack,
) )
from packs.identity.loader import load_identity_manifold 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 packs.safety.loader import load_safety_pack
from field.state import FieldState from field.state import FieldState
from generate.articulation import ArticulationPlan, realize from generate.articulation import ArticulationPlan, realize
@ -128,6 +130,80 @@ class _StubBindingFrame:
cycle_index: int 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): def _make_trajectory_from_result(result, turn: int):
from core.physics.reasoning import TrajectoryOperator from core.physics.reasoning import TrajectoryOperator
@ -167,6 +243,10 @@ class ChatResponse:
# admissibility was checked this turn" (cold start, refusal, stub). # admissibility was checked this turn" (cold start, refusal, stub).
admissibility_trace: tuple = () admissibility_trace: tuple = ()
region_was_unconstrained: bool = True 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: class ChatRuntime:
@ -274,8 +354,17 @@ class ChatRuntime:
# future ADR. # future ADR.
self.safety_check = SafetyCheck() self.safety_check = SafetyCheck()
# ADR-0034 — structural ethics surface, sibling to SafetyCheck. # ADR-0034 — structural ethics surface, sibling to SafetyCheck.
# Observational at v1; no auto-invocation in the turn loop.
self.ethics_check = EthicsCheck() 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.turn_log: List[TurnEvent] = []
self._correction_pass = CorrectionPass() self._correction_pass = CorrectionPass()
self._last_valence: float = 0.0 self._last_valence: float = 0.0
@ -385,6 +474,29 @@ class ChatRuntime:
output_language=self.config.output_language, output_language=self.config.output_language,
frame_id="unknown_domain", 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( return ChatResponse(
surface=_UNKNOWN_DOMAIN_SURFACE, surface=_UNKNOWN_DOMAIN_SURFACE,
proposition=prop, proposition=prop,
@ -401,6 +513,8 @@ class ChatRuntime:
identity_score=None, identity_score=None,
character_profile=self.character_profile, character_profile=self.character_profile,
flagged=False, flagged=False,
safety_verdict=safety_verdict,
ethics_verdict=ethics_verdict,
) )
def chat(self, text: str, max_tokens: int | None = None) -> ChatResponse: def chat(self, text: str, max_tokens: int | None = None) -> ChatResponse:
@ -531,6 +645,31 @@ class ChatRuntime:
) )
walk_surface = sentence_plan.surface walk_surface = sentence_plan.surface
vault_hits = int(result.vault_hits) 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_event = TurnEvent(
turn=self._context.turn - 1, turn=self._context.turn - 1,
input_tokens=tuple(filtered), input_tokens=tuple(filtered),
@ -544,6 +683,8 @@ class ChatRuntime:
versor_condition=versor_condition(result.final_state.F), versor_condition=versor_condition(result.final_state.F),
flagged=flagged, flagged=flagged,
elaboration=sentence_plan.elaboration, elaboration=sentence_plan.elaboration,
safety_verdict=safety_verdict,
ethics_verdict=ethics_verdict,
) )
self.turn_log.append(turn_event) self.turn_log.append(turn_event)
return ChatResponse( return ChatResponse(
@ -564,6 +705,8 @@ class ChatRuntime:
flagged=flagged, flagged=flagged,
admissibility_trace=result.admissibility_trace, admissibility_trace=result.admissibility_trace,
region_was_unconstrained=result.region_was_unconstrained, 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: def _unknown_domain_response(self, field_state: FieldState, filtered: list[str]) -> ChatResponse:

View file

@ -275,3 +275,8 @@ class TurnEvent:
versor_condition: float versor_condition: float
flagged: bool flagged: bool
elaboration: Optional[str] = None 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

View 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.

View 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")