From 514ace0cbfc5a7b136a8bb5d3ded4d59920af47b Mon Sep 17 00:00:00 2001 From: Shay Date: Sun, 17 May 2026 20:57:33 -0700 Subject: [PATCH] =?UTF-8?q?feat(adr-0035):=20turn-loop=20auto-invocation?= =?UTF-8?q?=20=E2=80=94=20surfacing=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- chat/runtime.py | 149 +++++++++++++++- core/physics/identity.py | 5 + .../ADR-0035-turn-loop-verdict-surfacing.md | 129 ++++++++++++++ tests/test_turn_loop_verdicts.py | 164 ++++++++++++++++++ 4 files changed, 444 insertions(+), 3 deletions(-) create mode 100644 docs/decisions/ADR-0035-turn-loop-verdict-surfacing.md create mode 100644 tests/test_turn_loop_verdicts.py diff --git a/chat/runtime.py b/chat/runtime.py index 14f40985..290b3217 100644 --- a/chat/runtime.py +++ b/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: diff --git a/core/physics/identity.py b/core/physics/identity.py index f7a6a61a..ca392a3a 100644 --- a/core/physics/identity.py +++ b/core/physics/identity.py @@ -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 diff --git a/docs/decisions/ADR-0035-turn-loop-verdict-surfacing.md b/docs/decisions/ADR-0035-turn-loop-verdict-surfacing.md new file mode 100644 index 00000000..9749dad3 --- /dev/null +++ b/docs/decisions/ADR-0035-turn-loop-verdict-surfacing.md @@ -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. diff --git a/tests/test_turn_loop_verdicts.py b/tests/test_turn_loop_verdicts.py new file mode 100644 index 00000000..9c63a396 --- /dev/null +++ b/tests/test_turn_loop_verdicts.py @@ -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")