diff --git a/chat/refusal.py b/chat/refusal.py index e4b0e590..5147b678 100644 --- a/chat/refusal.py +++ b/chat/refusal.py @@ -1,71 +1,177 @@ -"""ADR-0036 — typed refusal surface for runtime-checkable safety violations. +"""ADR-0036 + ADR-0037 — typed refusal surface. -The refusal surface is the runtime's response to a SafetyVerdict that -reports at least one ``runtime_checkable=True, upheld=False`` result. -It is deliberately: +ADR-0036 introduced typed refusal driven by safety violations only. +ADR-0037 extends the trigger surface to ethics commitments that the +pack explicitly opts into via ``EthicsPack.refusal_commitments`` — +keeping the default audit-only stance and forcing pack authors to opt +specific commitments into refusal one at a time. -* **Deterministic.** Same set of violated boundaries → same surface - bytes. Replayability is preserved. +The refusal surface remains: + +* **Deterministic.** Same set of violated boundary/commitment ids → + same surface bytes. Replayability is preserved. * **Typed.** A constant prefix (``TYPED_REFUSAL_PREFIX``) plus - lex-ordered boundary ids. Audit consumers detect refusals by prefix, - not by NLP. -* **Safety-only.** Ethics violations are observational at v1 - (ADR-0035) and do not trigger refusal. Refusing on swappable - deployment commitments would let pack-swappers silently change - refusal behavior — the wrong coupling. + source-tagged, lex-ordered ids (``safety:`` / + ``ethics:``). Audit consumers detect refusals by prefix and + disambiguate source by tag — not by NLP. +* **Predicate-evidenced.** Only ``runtime_checkable=True, upheld=False`` + results contribute. No-evidence predicates never refuse. +* **Opt-in for ethics.** Ethics commitments must appear in + ``refusal_commitments`` to count. Safety is always in scope; the + pack-layer doctrine in ADR-0029 prohibits opting safety out. -See `docs/decisions/ADR-0036-safety-refusal-policy.md`. +See `docs/decisions/ADR-0036-safety-refusal-policy.md` and +`docs/decisions/ADR-0037-per-predicate-ethics-refusal.md`. """ from __future__ import annotations from typing import Iterable -TYPED_REFUSAL_PREFIX = "I cannot proceed — safety boundary violated: " +TYPED_REFUSAL_PREFIX = "I cannot proceed — boundary violated: " +_SAFETY_TAG = "safety:" +_ETHICS_TAG = "ethics:" def violated_runtime_checkable(verdict) -> tuple[str, ...]: - """Return the lex-sorted tuple of runtime-checkable violated boundary ids. + """Lex-sorted tuple of runtime-checkable violated boundary ids from a SafetyVerdict. - A boundary is reported as violated only when the predicate had - enough evidence to make a real claim (``runtime_checkable=True``) - AND determined the turn breached the boundary (``upheld=False``). - Predicates that report ``runtime_checkable=False`` are honest - about lack of evidence and never trigger refusal. + Used by safety; safety is always in scope for refusal. """ if verdict is None: return () - boundary_ids: list[str] = [] + return tuple(sorted(_iter_violated_ids(verdict, attr="boundary_id"))) + + +def violated_runtime_checkable_ethics( + verdict, refusal_commitments: Iterable[str] | None, +) -> tuple[str, ...]: + """Lex-sorted tuple of runtime-checkable violated commitment ids that opted into refusal. + + ADR-0037 — ethics commitments do NOT trigger refusal by default. + A commitment must be present in the pack's ``refusal_commitments`` + set AND fail runtime-checkably for it to contribute. + """ + if verdict is None: + return () + opt_in: frozenset[str] = ( + frozenset(refusal_commitments) if refusal_commitments else frozenset() + ) + if not opt_in: + return () + return tuple( + sorted( + cid + for cid in _iter_violated_ids(verdict, attr="commitment_id") + if cid in opt_in + ) + ) + + +def build_refusal_surface( + safety_verdict, + ethics_verdict=None, + ethics_pack=None, +) -> str | None: + """Build a deterministic typed refusal surface, or ``None`` if no refusal. + + Contract: + + * Returns ``None`` when no runtime-checkable violation is in scope. + * Returns ``TYPED_REFUSAL_PREFIX`` followed by a comma-joined, + lex-sorted list of source-tagged ids + (``safety:`` / ``ethics:``). + * Ethics ids are included only when present in + ``ethics_pack.refusal_commitments`` (ADR-0037). + * Same verdict + pack → byte-identical surface. + + The historical (ADR-0036) single-argument call + ``build_refusal_surface(safety_verdict)`` remains valid: with no + ethics pack supplied, ethics contributes nothing. + """ + safety_ids = violated_runtime_checkable(safety_verdict) + ethics_ids = violated_runtime_checkable_ethics( + ethics_verdict, + getattr(ethics_pack, "refusal_commitments", None), + ) + if not safety_ids and not ethics_ids: + return None + tagged = [f"{_SAFETY_TAG}{s}" for s in safety_ids] + [ + f"{_ETHICS_TAG}{e}" for e in ethics_ids + ] + return _format_refusal(sorted(tagged)) + + +def _iter_violated_ids(verdict, *, attr: str) -> Iterable[str]: for result in getattr(verdict, "results", ()) or (): if getattr(result, "runtime_checkable", False) and not getattr( result, "upheld", True ): - boundary_ids.append(str(result.boundary_id)) - return tuple(sorted(boundary_ids)) + yield str(getattr(result, attr)) -def build_refusal_surface(verdict) -> str | None: - """Build a deterministic typed refusal surface, or ``None`` if no refusal. - - The contract: - - * Returns ``None`` when no runtime-checkable safety violation is - present. The caller keeps the originally-articulated surface. - * Returns ``TYPED_REFUSAL_PREFIX + ", ".join(lex_sorted_ids)`` when - one or more runtime-checkable boundaries were violated. - - The same verdict always produces the same string. - """ - violated = violated_runtime_checkable(verdict) - if not violated: - return None - return _format_refusal(violated) - - -def _format_refusal(boundary_ids: Iterable[str]) -> str: - return TYPED_REFUSAL_PREFIX + ", ".join(boundary_ids) +def _format_refusal(tagged_ids: Iterable[str]) -> str: + return TYPED_REFUSAL_PREFIX + ", ".join(tagged_ids) def is_typed_refusal(surface: str) -> bool: """Audit helper: does this surface look like a typed refusal?""" return bool(surface) and surface.startswith(TYPED_REFUSAL_PREFIX) + + +# ---------- ADR-0038 — hedge injection ---------- + + +def should_inject_hedge(ethics_verdict, ethics_pack) -> bool: + """ADR-0038 — does the pack want a hedge prepended this turn? + + True iff a commitment in ``ethics_pack.hedge_commitments`` fired + with ``runtime_checkable=True, upheld=False``. Mutually + exclusive with refusal at the pack-schema level (validated at + load time): a commitment cannot be in both + ``refusal_commitments`` and ``hedge_commitments``. + """ + if ethics_verdict is None or ethics_pack is None: + return False + opt_in = getattr(ethics_pack, "hedge_commitments", None) + if not opt_in: + return False + opt_in = frozenset(opt_in) + for cid in _iter_violated_ids(ethics_verdict, attr="commitment_id"): + if cid in opt_in: + return True + return False + + +def build_hedge_prefix(identity_manifold) -> str: + """Return the manifold's preferred hedge phrase, or empty string. + + Prefers ``preferred_hedge_soft`` (the lighter touch) over + ``preferred_hedge_strong``. Empty string when no hedges are + configured — the runtime then skips injection because there is + nothing to inject. + """ + prefs = getattr(identity_manifold, "surface_preferences", None) + if prefs is None: + return "" + soft = getattr(prefs, "preferred_hedge_soft", "") or "" + if soft: + return soft + return getattr(prefs, "preferred_hedge_strong", "") or "" + + +def inject_hedge(surface: str, hedge_prefix: str) -> str: + """Prepend ``hedge_prefix`` to ``surface`` with a single space. + + Deterministic and idempotent-on-prefix: if ``surface`` already + begins with the hedge phrase (case-insensitive), do nothing. + Preserves the runtime's "evidence preservation" discipline at + the caller level — only the user-facing ``ChatResponse.surface`` + is mutated; ``walk_surface`` and ``articulation_surface`` remain + untouched. + """ + if not hedge_prefix or not surface: + return surface + if surface.casefold().startswith(hedge_prefix.casefold()): + return surface + return f"{hedge_prefix} {surface}" diff --git a/chat/runtime.py b/chat/runtime.py index 7a21e2ad..fe6db568 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -10,7 +10,12 @@ from typing import List import numpy as np from algebra.versor import versor_condition -from chat.refusal import build_refusal_surface +from chat.refusal import ( + build_hedge_prefix, + build_refusal_surface, + inject_hedge, + should_inject_hedge, +) from core.config import DEFAULT_CONFIG, DEFAULT_IDENTITY_PACK, RuntimeConfig from core.physics.drive import DriveGradientMap, GradientField from core.physics.energy import EnergyProfile @@ -502,7 +507,9 @@ class ChatRuntime: # a runtime-checkable safety boundary is violated even on the # ungrounded surface (e.g. versor-closure failure), replace the # user-facing ``surface`` with the deterministic typed refusal. - refusal_surface = build_refusal_surface(safety_verdict) + refusal_surface = build_refusal_surface( + safety_verdict, ethics_verdict, self.ethics_pack, + ) if refusal_surface is not None: response_surface = refusal_surface self._last_refusal_was_typed = True @@ -687,12 +694,25 @@ class ChatRuntime: # and ``articulation_surface`` retain the original token-walk / # realizer evidence for audit (per the runtime surface # contract in CLAUDE.md). Ethics violations remain audit-only. - refusal_surface = build_refusal_surface(safety_verdict) + refusal_surface = build_refusal_surface( + safety_verdict, ethics_verdict, self.ethics_pack, + ) if refusal_surface is not None: response_surface = refusal_surface self._last_refusal_was_typed = True else: response_surface = walk_surface + # ADR-0038 — hedge injection. When an ethics commitment in + # ``ethics_pack.hedge_commitments`` fires runtime-checkable + # and the manifold has a hedge phrase configured, prepend + # the hedge to the user-facing surface. Mutually exclusive + # with refusal at the pack-schema level; this branch only + # runs when refusal did not fire. ``walk_surface`` and + # ``articulation_surface`` are preserved unchanged for + # audit (same discipline as ADR-0036). + if should_inject_hedge(ethics_verdict, self.ethics_pack): + hedge_prefix = build_hedge_prefix(self.identity_manifold) + response_surface = inject_hedge(response_surface, hedge_prefix) turn_event = TurnEvent( turn=self._context.turn - 1, input_tokens=tuple(filtered), diff --git a/docs/decisions/ADR-0037-per-predicate-ethics-refusal.md b/docs/decisions/ADR-0037-per-predicate-ethics-refusal.md new file mode 100644 index 00000000..170aa61e --- /dev/null +++ b/docs/decisions/ADR-0037-per-predicate-ethics-refusal.md @@ -0,0 +1,132 @@ +# ADR-0037: Per-Predicate Ethics Refusal Opt-In + +**Status:** Accepted (2026-05-17) +**Author:** Joshua Shay + planner pass +**Companion docs:** [`ADR-0033-ethics-packs.md`](ADR-0033-ethics-packs.md), [`ADR-0034-ethics-check-surface.md`](ADR-0034-ethics-check-surface.md), [`ADR-0036-safety-refusal-policy.md`](ADR-0036-safety-refusal-policy.md) + +## Context + +ADR-0036 wired typed refusal for safety violations only. Ethics +violations were left audit-only because: + +1. The pack layer's swappability semantics meant a pack-author flag + could silently change refusal behavior on every deployment. +2. Empirical violation rates for individual ethics commitments did + not yet exist. + +ADR-0036's deferred follow-up was *per-predicate ethics refusal*: a +mechanism by which a pack author can opt **specific** commitments +into refusal one at a time, without flipping a global ethics-refuses +switch. That coupling is what this ADR introduces. + +## Decision + +Add an optional `refusal_commitments` field to the ethics pack JSON +schema. Each entry must already appear in `commitment_ids`. At +runtime, an ethics commitment contributes to typed refusal only when +*both*: + +1. Its predicate fired `runtime_checkable=True, upheld=False`. +2. Its id appears in `EthicsPack.refusal_commitments`. + +The default pack ships with an **empty** `refusal_commitments`. +Audit-only is the floor; opt-in is the ceiling. + +### Surface format change + +The refusal prefix is generalised from +`"I cannot proceed — safety boundary violated: "` to +`"I cannot proceed — boundary violated: "`, and contributing ids are +**source-tagged**: + +* `safety:` +* `ethics:` + +Source tags disambiguate sibling namespaces and avoid name collisions +between the two pack types. Lex order is preserved across the merged +list. + +### Pack-loader bounds + +`packs/ethics/loader.py` now validates `refusal_commitments`: + +* Optional; defaults to empty. +* Must be a list of strings if present. +* Every entry must be a declared `commitment_id` (typo → load-time + error; silent typos would be catastrophic given the behavioral + consequences). +* No duplicates. + +The validator is shared with ADR-0038 (`_validate_opt_in_subset`) and +will be reused for `hedge_commitments`. + +### Ratification + +Adding a field to the pack invalidates its prior mastery-report seal. +The default pack was re-ratified through +`scripts/ratify_ethics_pack.py` (idempotent re-run); the new +`mastery_report_sha256` reflects the schema addition. + +### Backward compatibility + +`build_refusal_surface(safety_verdict)` (the ADR-0036 single-arg +form) still works — with no ethics pack supplied, ethics contributes +nothing. Existing safety-only tests pass unchanged because their +assertions are substring-based on the boundary id (now appearing as +`safety:`). + +## Consequences + +### Positive + +* **Audit-only remains the default.** An operator who clones the + default pack and deploys gets ADR-0036 behavior unchanged. +* **Per-commitment granularity.** A medical-domain pack can opt + `defer_high_stakes_to_human_review` into refusal without flipping + the rest of the ethics surface. +* **Schema-enforced safety.** Typos in `refusal_commitments` fail + at load time, not at the first matching violation. +* **Unified refusal surface.** Auditors see one refusal text per + turn covering both safety and ethics violations, with source tags. + +### Negative / risks + +* **Pack mutation invalidates ratification.** A deployment that + edits `refusal_commitments` must re-ratify. This is the intended + cost: opting commitments into refusal is a deployment-level + decision that deserves the ratification round-trip. +* **The opt-in list is JSON, which means it sits inside the swappable + layer.** This is correct semantically (refusal policy *is* a + deployment choice) but means an operator can flip refusal on/off + by editing a file. Mitigated by: ratification round-trip, + schema validation, and the load-time error for unknown ids. +* **Surface prefix changed.** Downstream consumers parsing the + refusal text by exact prefix needed an update. We chose to update + the constant in place rather than maintain a parallel API because + no in-tree consumer existed. + +## Verification + +* `tests/test_ethics_refusal_opt_in.py` — 16 tests covering: + loader bounds (empty default, unknown id rejected, duplicate + rejected, non-list rejected); pure builder paths (no opt-in → + no refusal, opt-in + violation → refusal, opt-in subset + semantics, non-runtime-checkable ignored, combined safety+ethics, + ADR-0036 back-compat); helper `violated_runtime_checkable_ethics`; + ChatRuntime integration (default pack does not refuse, mutated + pack refuses, combined safety+ethics in runtime). +* Combined pack-layer suite: **132 tests, all green**. +* CLI suites: smoke 67, runtime 19, cognition 121 — unchanged. + +## Open questions deferred to a future ADR + +1. **Pack-schema-driven hedge injection.** Sibling field + `hedge_commitments` follows in ADR-0038 — same opt-in pattern, + different remediation. +2. **Mutual exclusion between `refusal_commitments` and + `hedge_commitments`.** Encoded at load time in ADR-0038. +3. **Per-domain default policies.** Should the medical-domain pack + ship with `defer_high_stakes_to_human_review` opted into refusal + by default? Deferred until a medical pack actually exists. +4. **Telemetry split by source.** A future telemetry sink may want + to count safety refusals and ethics refusals separately. diff --git a/docs/decisions/ADR-0038-hedge-injection.md b/docs/decisions/ADR-0038-hedge-injection.md new file mode 100644 index 00000000..d33d1b15 --- /dev/null +++ b/docs/decisions/ADR-0038-hedge-injection.md @@ -0,0 +1,174 @@ +# ADR-0038: Hedge Injection as a Runtime-Level Affordance + +**Status:** Accepted (2026-05-17) +**Author:** Joshua Shay + planner pass +**Companion docs:** [`ADR-0028-surface-preferences.md`](../decisions), [`ADR-0036-safety-refusal-policy.md`](ADR-0036-safety-refusal-policy.md), [`ADR-0037-per-predicate-ethics-refusal.md`](ADR-0037-per-predicate-ethics-refusal.md) + +## Context + +ADR-0036 chose typed refusal over hedge injection for safety violations +because conflating refusal with hedging would blur audit: + +> Hedge injection would blur the boundary between hedging +> (alignment-score driven) and refusing (predicate-driven). The same +> surface change could mean two different things. Audit becomes +> ambiguous. + +That choice was correct *for refusal*. But it left an open question: +once `EthicsCheck` predicates fire runtime-checkably for low alignment +(`acknowledge_uncertainty`) or ungrounded scope (`disclose_limitations`), +a deployment might want **softer remediation** than full refusal — +some way to *qualify* the surface without replacing it. + +ADR-0037 introduced `refusal_commitments` as the opt-in for per-predicate +escalation to refusal. This ADR introduces its sibling +`hedge_commitments`: opt-in for runtime-level hedge **prepend**. + +## Decision + +Add an optional `hedge_commitments` field to the ethics pack JSON +schema. Each entry must be a declared `commitment_id`. When *any* +runtime-checkable violation of a commitment in `hedge_commitments` +fires this turn, the runtime prepends the manifold's preferred hedge +phrase (`preferred_hedge_soft`, falling back to +`preferred_hedge_strong`) to `ChatResponse.surface`. + +### Mutual exclusion with refusal + +A commitment **cannot** appear in both `refusal_commitments` and +`hedge_commitments`. This is enforced at load time: + +```python +overlap = refusal & hedge +if overlap: + raise EthicsPackError("commitments cannot appear in both ...") +``` + +The two remediations are escalation siblings, not stackable layers. +Pack authors pick one per commitment: hedge (soft) or refuse (hard). + +### Refusal supersedes hedge in code path order + +Even though pack schema forbids per-commitment overlap, the runtime +still gives refusal priority globally: if **any** safety boundary or +opted-in ethics commitment fires refusal, the surface is the typed +refusal — hedge injection is skipped for the turn. This preserves +the invariant "refusal is total." + +### Stub path does not hedge + +The stub-path surface (`_UNKNOWN_DOMAIN_SURFACE = "I don't know — +insufficient grounding for that yet."`) is already a disclosure +surface. Prepending a hedge ("Perhaps I don't know — …") would read +as a confused double-disclosure. Hedge injection runs **only on the +main articulation path**. Stub-path refusal *does* still fire (per +ADR-0036) because refusal is a hard stop, not a qualifier. + +### Evidence preservation + +Same discipline as ADR-0036: hedge changes only the user-facing +`surface` field. `walk_surface` (token-walk evidence) and +`articulation_surface` (realizer output) are preserved unchanged. +An auditor reading a hedged turn sees: + +* original surface (walk_surface / articulation_surface), +* hedged user-facing surface (with prepended hedge), +* ethics_verdict (with the violating commitment). + +### Idempotent on prefix + +`inject_hedge()` is idempotent: if the surface already begins with +the hedge phrase (case-insensitive match), no double-prepend occurs. +This is a defensive property — the assembler's existing +`SurfaceContext`-driven hedge logic (ADR-0028) may have already +hedged the surface, and runtime injection should not duplicate. + +### No effect on refusal bookkeeping + +Hedge injection does **not** set `_last_refusal_was_typed`. Hedging +is not a refusal — the `no_silent_correction` safety predicate cares +about typed refusals specifically, and a hedge should not be miscounted +as one. + +## Consequences + +### Positive + +* **Soft remediation channel.** A medical-domain pack can opt + `acknowledge_uncertainty` into hedging without committing to full + refusal. Deployment authors get a middle tier between audit-only + and refuse. +* **Schema-enforced mutual exclusion.** Load-time error makes it + impossible to ship a pack where the same commitment claims both + remediations. +* **Runtime path stays minimal.** Three helper functions + (`should_inject_hedge`, `build_hedge_prefix`, `inject_hedge`), all + pure. ChatRuntime adds a single conditional after the refusal + branch. +* **Evidence preserved.** Same audit discipline as refusal: original + surfaces retained on the response and turn event. +* **Backward compatible.** Default pack ships `hedge_commitments: []`; + no behavior change for unmodified deployments. + +### Negative / risks + +* **Hedge phrase source is the identity manifold, not the ethics pack.** + This means swapping ethics packs while keeping identity packs fixed + produces the same hedge phrasing. Acceptable today: the manifold's + `surface_preferences` is the canonical hedge home (ADR-0028). A + future ADR could let ethics packs override phrasing per commitment. +* **Hedge runs only on main path.** Stub-path hedge would be a + double-disclosure. Tests gate runtime-end-to-end hedge assertions + on `rt.turn_log` populated. +* **Idempotent-on-prefix means assembler hedges suppress runtime + hedges.** Correct (no double-hedge), but it means the *signal* of + "did the runtime inject this hedge or did the assembler?" is lost + from the surface alone. Audit consumers should rely on the + ethics_verdict, not on the surface, to determine whether the + injection path fired. +* **Ratification round-trip on schema change.** Same cost as + ADR-0037: adding `hedge_commitments` to the default pack required + re-ratifying its mastery report. + +## Verification + +* `tests/test_hedge_injection.py` — 22 tests covering: loader bounds + (empty default, unknown id rejected, mutual exclusion rejected, + split-allocation OK); pure helpers (`should_inject_hedge` with + pack/verdict/opt-in/evidence combinations; `build_hedge_prefix` + with default manifold + None; `inject_hedge` happy path, empty + prefix, empty surface, idempotent on prefix, case-insensitive + idempotency); ChatRuntime integration (default pack does not + inject; opt-in pack injects on violation; walk_surface preserved; + refusal supersedes hedge; hedge does not flip + `_last_refusal_was_typed`). +* Combined pack-layer suite: **154 tests, all green** (safety pack + + safety check + ethics pack + ethics check + turn-loop verdicts + + safety refusal + ethics refusal opt-in + hedge injection). +* CLI suites unchanged: smoke 67, runtime 19, cognition 121. +* `core eval cognition`: intent_accuracy 100%, versor_closure_rate + 100% — baseline preserved. + +## Open questions deferred to a future ADR + +1. **Per-commitment hedge phrases sourced from the ethics pack.** + Today the manifold owns hedge phrasing. A future commitment-keyed + override would let ethics packs say "for `defer_high_stakes_to_human_review`, + use *'Before proceeding,'* instead of *'Perhaps'*." +2. **Hedge strength tiers.** Today a single hedge fires regardless + of how many commitments violated. A pack could opt commitments + into specific strength tiers (`hedge_soft` vs `hedge_strong`). +3. **Verdict surface for "was hedge injected this turn."** Today + only the ethics_verdict carries the signal; downstream consumers + inferring "hedge fired" must inspect both the verdict and the + prefix. A `hedge_injected: bool` field on `ChatResponse` / + `TurnEvent` would make audit simpler. +4. **Stub-path soft disclosure with hedge.** The current "I don't + know — insufficient grounding" surface is fixed. A pack might + want to inject domain-specific disclosure phrasing on stub. + Deferred until packs need it. +5. **Interaction with assembler hedges (ADR-0028).** Today + idempotent-on-prefix prevents double-hedging; a future ADR could + make the relationship explicit (e.g., assembler is responsible + for alignment-score-driven hedges; runtime is responsible for + ethics-violation-driven hedges; never both fire on the same turn). diff --git a/packs/ethics/default_general_ethics_v1.json b/packs/ethics/default_general_ethics_v1.json index 881ff0af..30e6ac06 100644 --- a/packs/ethics/default_general_ethics_v1.json +++ b/packs/ethics/default_general_ethics_v1.json @@ -4,7 +4,7 @@ "description": "Default ethics pack for general deployment. Encodes propositional commitments that compose into the runtime manifold alongside identity (ADR-0027) and safety (ADR-0029). Deployment-specific packs (medical, legal, financial, robotics) replace this; safety boundaries remain inviolate regardless.", "schema_version": "1.0.0", "domain": "general", - "mastery_report_sha256": "81fc9b61c828fdd4926ac9eb212883ffd72c032a0ddb6a4b8d988783c98ae98d", + "mastery_report_sha256": "e3b18b803b4ef9edb8d5b0f5e33ccfdba0d8477fa6d573d4d8e651e1567341c6", "commitment_ids": [ "acknowledge_uncertainty", "defer_high_stakes_to_human_review", @@ -18,5 +18,7 @@ "disclose_limitations": "When a topic exceeds the system's grounded knowledge or appropriate scope, the system says so plainly instead of fabricating coverage.", "no_manipulation": "Persuasion proceeds via genuine reasoning, not exploitation of cognitive biases, social pressure, or emotional leverage.", "respect_user_autonomy": "The system surfaces options and tradeoffs to inform the user's choice; it does not prescribe the single right answer where reasonable people may differ." - } + }, + "refusal_commitments": [], + "hedge_commitments": [] } diff --git a/packs/ethics/default_general_ethics_v1.mastery_report.json b/packs/ethics/default_general_ethics_v1.mastery_report.json index 34a00cf2..ba4b4d1b 100644 --- a/packs/ethics/default_general_ethics_v1.mastery_report.json +++ b/packs/ethics/default_general_ethics_v1.mastery_report.json @@ -1,6 +1,6 @@ { "course_id": "course.subject.ethics.default_general_ethics_v1.identity_anchor.1.0.0", - "course_sha256": "405bfbb34399fe591c757a8feb3307dd7ad824b663e87bd2e495e6358c677f23", + "course_sha256": "9d582a8b49a2b2eb49aabbc0b66161355d47a274bb572e056b439634c184fea4", "failure_reasons": [], "gates": [ { @@ -41,16 +41,16 @@ } ], "issued_at": "2026-05-17T00:00:00Z", - "plan_sha256": "b653ae409da1e6fde66fa45571b9d2aef2cbeb90d9f39141779d4ba97a074dc5", + "plan_sha256": "902c892f78cdfbe625a47abcf6c0b0ea84662fc166c84dcadc8adb5b76a9846e", "ratified": true, - "report_sha256": "81fc9b61c828fdd4926ac9eb212883ffd72c032a0ddb6a4b8d988783c98ae98d", + "report_sha256": "e3b18b803b4ef9edb8d5b0f5e33ccfdba0d8477fa6d573d4d8e651e1567341c6", "schema_version": "1.0.0", - "source_bundle_sha": "80b4ee29b404464023ebc5d60355bde70c45b40f4b56a5c3ff42a72f73157184", + "source_bundle_sha": "d0255a33cbfe6d0f9fe6b060ed688c2de24153afc4bfc80bb0a08af88f706395", "trace_hashes": [ "trace:adversarial_probe:::::identity_override_axis_rewrite", "trace:adversarial_probe:::::identity_override_policy_bypass", "trace:adversarial_probe:::::identity_override_operator_injection", "trace:replay_assertion:::::" ], - "validated_set_sha": "ade3949195605b4c6bfc46c486b8acfc02d55b2e73901e35ce0dcded193a7140" + "validated_set_sha": "9fca7e0c55bc7d73258684019d565cd31e668bd2eb40b0064da4d9b08b3eac1b" } diff --git a/packs/ethics/loader.py b/packs/ethics/loader.py index 0222e823..dafb51e9 100644 --- a/packs/ethics/loader.py +++ b/packs/ethics/loader.py @@ -60,7 +60,14 @@ class EthicsPack: ``commitment_ids`` is the set of propositional pledges contributed to the runtime manifold's ``boundary_ids``. ``domain`` declares the - deployment context (audit-only at v1; no behavior change yet). + deployment context. + + ADR-0037 — ``refusal_commitments`` is the opt-in subset of + commitments that, when violated runtime-checkably, trigger typed + refusal at the runtime level (ADR-0036 surface). The default pack + leaves this empty: ethics remains audit-only unless a deployment + explicitly opts a commitment in. Must be a subset of + ``commitment_ids``. """ pack_id: str @@ -71,6 +78,8 @@ class EthicsPack: commitment_descriptions: dict[str, str] mastery_report_sha256: str ratified: bool + refusal_commitments: FrozenSet[str] = frozenset() + hedge_commitments: FrozenSet[str] = frozenset() def load_ethics_pack( @@ -106,6 +115,25 @@ def load_ethics_pack( raw.get("commitment_descriptions", {}), pack_id, commitments, ) domain = _validate_domain(raw.get("domain", "general"), pack_id) + refusal_commitments = _validate_opt_in_subset( + raw.get("refusal_commitments", []), + pack_id=pack_id, + commitments=commitments, + field_name="refusal_commitments", + ) + hedge_commitments = _validate_opt_in_subset( + raw.get("hedge_commitments", []), + pack_id=pack_id, + commitments=commitments, + field_name="hedge_commitments", + ) + overlap = set(refusal_commitments) & set(hedge_commitments) + if overlap: + raise EthicsPackError( + f"ethics pack {pack_id!r}: commitments cannot appear in both " + f"refusal_commitments and hedge_commitments: " + f"{sorted(overlap)}" + ) return EthicsPack( pack_id=str(raw["pack_id"]), version=str(raw["version"]), @@ -115,6 +143,8 @@ def load_ethics_pack( commitment_descriptions=descriptions, mastery_report_sha256=str(raw.get("mastery_report_sha256", "")), ratified=bool(raw.get("mastery_report_sha256")), + refusal_commitments=frozenset(refusal_commitments), + hedge_commitments=frozenset(hedge_commitments), ) @@ -313,6 +343,50 @@ def _validate_descriptions( return out +def _validate_opt_in_subset( + value: object, + *, + pack_id: str, + commitments: list[str], + field_name: str, +) -> list[str]: + """Validate an opt-in list of commitment ids. + + ADR-0037 (``refusal_commitments``) and ADR-0038 + (``hedge_commitments``) both encode opt-in subsets of + ``commitment_ids``. An unknown or duplicate id is a load-time + error — silent typos would be catastrophic given the behavioral + consequences. + """ + if value is None or value == []: + return [] + if not isinstance(value, list): + raise EthicsPackError( + f"ethics pack {pack_id!r}: {field_name} must be a list" + ) + seen: set[str] = set() + valid = set(commitments) + out: list[str] = [] + for i, c in enumerate(value): + if not isinstance(c, str) or not c: + raise EthicsPackError( + f"ethics pack {pack_id!r}: {field_name}[{i}] must be a " + "non-empty string" + ) + if c in seen: + raise EthicsPackError( + f"ethics pack {pack_id!r}: duplicate {field_name} entry {c!r}" + ) + if c not in valid: + raise EthicsPackError( + f"ethics pack {pack_id!r}: {field_name} entry {c!r} is not " + "a declared commitment_id" + ) + seen.add(c) + out.append(c) + return out + + def _validate_domain(value: object, pack_id: str) -> str: if not isinstance(value, str) or not value: raise EthicsPackError( diff --git a/tests/test_ethics_refusal_opt_in.py b/tests/test_ethics_refusal_opt_in.py new file mode 100644 index 00000000..8c206720 --- /dev/null +++ b/tests/test_ethics_refusal_opt_in.py @@ -0,0 +1,313 @@ +"""ADR-0037 — per-predicate ethics refusal opt-in. + +Ethics commitments remain audit-only by default. A pack opts a +specific commitment into refusal via ``refusal_commitments`` in the +pack JSON. The runtime then unifies safety + opted-in ethics +violations into one deterministic typed refusal surface, with each id +source-tagged (``safety:`` / ``ethics:``). +""" + +from __future__ import annotations + +from dataclasses import replace + +from chat.refusal import ( + TYPED_REFUSAL_PREFIX, + build_refusal_surface, + is_typed_refusal, + violated_runtime_checkable_ethics, +) +from chat.runtime import ChatRuntime +from core.config import RuntimeConfig +from packs.ethics.check import EthicsCheckResult, EthicsVerdict +from packs.ethics.loader import EthicsPack, EthicsPackError, load_ethics_pack +from packs.safety.check import SafetyCheckResult, SafetyVerdict + + +# ---------- pack-loader bounds ---------- + + +class TestRefusalCommitmentsLoaderValidation: + def test_default_pack_has_empty_refusal_commitments(self) -> None: + pack = load_ethics_pack() + assert pack.refusal_commitments == frozenset() + + def test_unknown_id_in_refusal_commitments_is_rejected(self, tmp_path) -> None: + bad = tmp_path / "broken_v1.json" + bad.write_text( + '{"pack_id":"broken_v1","version":"1.0.0","description":"x",' + '"schema_version":"1.0.0","domain":"custom",' + '"commitment_ids":["a"],"refusal_commitments":["not_declared"]}' + ) + try: + load_ethics_pack( + "broken_v1", + search_paths=(tmp_path,), + require_ratified=False, + ) + except EthicsPackError as e: + assert "not a declared commitment_id" in str(e) + else: + raise AssertionError("expected EthicsPackError") + + def test_duplicate_in_refusal_commitments_is_rejected(self, tmp_path) -> None: + bad = tmp_path / "dup_v1.json" + bad.write_text( + '{"pack_id":"dup_v1","version":"1.0.0","description":"x",' + '"schema_version":"1.0.0","domain":"custom",' + '"commitment_ids":["a"],"refusal_commitments":["a","a"]}' + ) + try: + load_ethics_pack( + "dup_v1", + search_paths=(tmp_path,), + require_ratified=False, + ) + except EthicsPackError as e: + assert "duplicate" in str(e) + else: + raise AssertionError("expected EthicsPackError") + + def test_non_list_refusal_commitments_is_rejected(self, tmp_path) -> None: + bad = tmp_path / "weird_v1.json" + bad.write_text( + '{"pack_id":"weird_v1","version":"1.0.0","description":"x",' + '"schema_version":"1.0.0","domain":"custom",' + '"commitment_ids":["a"],"refusal_commitments":"not_a_list"}' + ) + try: + load_ethics_pack( + "weird_v1", + search_paths=(tmp_path,), + require_ratified=False, + ) + except EthicsPackError as e: + assert "must be a list" in str(e) + else: + raise AssertionError("expected EthicsPackError") + + +# ---------- pure refusal builder, ethics path ---------- + + +class TestEthicsRefusalBuilder: + def test_ethics_violation_without_opt_in_does_not_refuse(self) -> None: + ethics_verdict = _ethics_verdict( + _ethics_result("acknowledge_uncertainty", upheld=False, rc=True), + ) + pack = _pack(refusal_commitments=frozenset()) + assert build_refusal_surface(None, ethics_verdict, pack) is None + + def test_ethics_violation_with_opt_in_refuses(self) -> None: + ethics_verdict = _ethics_verdict( + _ethics_result("acknowledge_uncertainty", upheld=False, rc=True), + ) + pack = _pack(refusal_commitments=frozenset({"acknowledge_uncertainty"})) + out = build_refusal_surface(None, ethics_verdict, pack) + assert out is not None + assert out.startswith(TYPED_REFUSAL_PREFIX) + assert "ethics:acknowledge_uncertainty" in out + + def test_ethics_violation_not_in_opt_in_subset_ignored(self) -> None: + """Opt-in must include the specific commitment that fired. + Other opt-ins do not generalize.""" + ethics_verdict = _ethics_verdict( + _ethics_result("no_manipulation", upheld=False, rc=True), + ) + pack = _pack(refusal_commitments=frozenset({"acknowledge_uncertainty"})) + assert build_refusal_surface(None, ethics_verdict, pack) is None + + def test_ethics_non_runtime_checkable_does_not_refuse_even_with_opt_in(self) -> None: + ethics_verdict = _ethics_verdict( + _ethics_result("acknowledge_uncertainty", upheld=False, rc=False), + ) + pack = _pack(refusal_commitments=frozenset({"acknowledge_uncertainty"})) + assert build_refusal_surface(None, ethics_verdict, pack) is None + + def test_combined_safety_and_ethics_refusal_listed_lex_sorted(self) -> None: + safety_verdict = _safety_verdict( + _safety_result("preserve_versor_closure", upheld=False, rc=True), + ) + ethics_verdict = _ethics_verdict( + _ethics_result("acknowledge_uncertainty", upheld=False, rc=True), + ) + pack = _pack(refusal_commitments=frozenset({"acknowledge_uncertainty"})) + out = build_refusal_surface(safety_verdict, ethics_verdict, pack) + assert out is not None + # Both contribute; tags appear in lex order across the merged set. + assert "ethics:acknowledge_uncertainty" in out + assert "safety:preserve_versor_closure" in out + # "ethics:" < "safety:" lexicographically. + assert out.index("ethics:acknowledge_uncertainty") < out.index( + "safety:preserve_versor_closure" + ) + + def test_safety_only_call_back_compat(self) -> None: + """Historical ADR-0036 call signature: ``build_refusal_surface(v)`` + with no ethics still works and produces a safety-only refusal.""" + safety_verdict = _safety_verdict( + _safety_result("preserve_versor_closure", upheld=False, rc=True), + ) + out = build_refusal_surface(safety_verdict) + assert out is not None + assert "safety:preserve_versor_closure" in out + + +class TestViolatedRuntimeCheckableEthics: + def test_empty_opt_in_returns_empty(self) -> None: + verdict = _ethics_verdict( + _ethics_result("acknowledge_uncertainty", upheld=False, rc=True), + ) + assert violated_runtime_checkable_ethics(verdict, frozenset()) == () + + def test_none_opt_in_returns_empty(self) -> None: + verdict = _ethics_verdict( + _ethics_result("acknowledge_uncertainty", upheld=False, rc=True), + ) + assert violated_runtime_checkable_ethics(verdict, None) == () + + def test_returns_only_opted_in_violations(self) -> None: + verdict = _ethics_verdict( + _ethics_result("acknowledge_uncertainty", upheld=False, rc=True), + _ethics_result("no_manipulation", upheld=False, rc=True), + ) + out = violated_runtime_checkable_ethics(verdict, frozenset({"no_manipulation"})) + assert out == ("no_manipulation",) + + +# ---------- ChatRuntime integration ---------- + + +class TestRuntimeEthicsRefusal: + def test_runtime_with_default_pack_does_not_refuse_on_ethics_violation(self) -> None: + """Default ethics pack has empty refusal_commitments — even a + forced runtime-checkable ethics violation must not refuse.""" + rt = ChatRuntime(config=RuntimeConfig()) + + def _failing(ctx): # noqa: ANN001 + return EthicsCheckResult( + commitment_id="acknowledge_uncertainty", + upheld=False, + reason="forced", + runtime_checkable=True, + ) + + rt.ethics_check.register("acknowledge_uncertainty", _failing) + resp = rt.chat("light is") + assert not is_typed_refusal(resp.surface) + + def test_runtime_with_opt_in_pack_refuses_on_ethics_violation(self) -> None: + """A pack that opts ``acknowledge_uncertainty`` into refusal + must produce a typed refusal when that commitment fires + runtime-checkable=True, upheld=False.""" + rt = ChatRuntime(config=RuntimeConfig()) + # Mutate the pack on the running instance — equivalent to + # loading a deployment pack with refusal_commitments=[…]. + rt.ethics_pack = replace( + rt.ethics_pack, + refusal_commitments=frozenset({"acknowledge_uncertainty"}), + ) + + def _failing(ctx): # noqa: ANN001 + return EthicsCheckResult( + commitment_id="acknowledge_uncertainty", + upheld=False, + reason="forced", + runtime_checkable=True, + ) + + rt.ethics_check.register("acknowledge_uncertainty", _failing) + resp = rt.chat("light is") + assert is_typed_refusal(resp.surface) + assert "ethics:acknowledge_uncertainty" in resp.surface + + def test_runtime_combined_safety_and_ethics_refusal(self) -> None: + rt = ChatRuntime(config=RuntimeConfig()) + rt.ethics_pack = replace( + rt.ethics_pack, + refusal_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 is_typed_refusal(resp.surface) + assert "safety:preserve_versor_closure" in resp.surface + assert "ethics:acknowledge_uncertainty" in resp.surface + + +# ---------- helpers ---------- + + +def _safety_result(boundary_id: str, *, upheld: bool, rc: bool) -> SafetyCheckResult: + return SafetyCheckResult( + boundary_id=boundary_id, + upheld=upheld, + reason="test", + runtime_checkable=rc, + ) + + +def _safety_verdict(*results: SafetyCheckResult) -> SafetyVerdict: + violated = frozenset(r.boundary_id for r in results if not r.upheld) + return SafetyVerdict( + pack_id="test_safety", + results=tuple(results), + upheld=not violated, + violated_boundaries=violated, + runtime_checkable_count=sum(1 for r in results if r.runtime_checkable), + ) + + +def _ethics_result(commitment_id: str, *, upheld: bool, rc: bool) -> EthicsCheckResult: + return EthicsCheckResult( + commitment_id=commitment_id, + upheld=upheld, + reason="test", + runtime_checkable=rc, + ) + + +def _ethics_verdict(*results: EthicsCheckResult) -> EthicsVerdict: + violated = frozenset(r.commitment_id for r in results if not r.upheld) + return EthicsVerdict( + pack_id="test_ethics", + results=tuple(results), + upheld=not violated, + violated_commitments=violated, + runtime_checkable_count=sum(1 for r in results if r.runtime_checkable), + ) + + +def _pack(*, refusal_commitments: frozenset[str]) -> EthicsPack: + return EthicsPack( + pack_id="test_ethics", + version="1.0.0", + description="test", + domain="custom", + commitment_ids=frozenset({ + "acknowledge_uncertainty", + "no_manipulation", + "respect_user_autonomy", + }), + commitment_descriptions={}, + mastery_report_sha256="", + ratified=False, + refusal_commitments=refusal_commitments, + ) diff --git a/tests/test_hedge_injection.py b/tests/test_hedge_injection.py new file mode 100644 index 00000000..22e42c67 --- /dev/null +++ b/tests/test_hedge_injection.py @@ -0,0 +1,332 @@ +"""ADR-0038 — hedge injection as a runtime-level affordance. + +Distinct from refusal: + +* Refusal *replaces* the surface (ADR-0036/0037). +* Hedge injection *prepends* the manifold's hedge phrase, preserving + the original surface content. + +Opt-in is per-commitment via ``EthicsPack.hedge_commitments``. +Mutually exclusive with ``refusal_commitments`` at load time. +""" + +from __future__ import annotations + +from dataclasses import replace + +from chat.refusal import ( + build_hedge_prefix, + inject_hedge, + is_typed_refusal, + should_inject_hedge, +) +from chat.runtime import ChatRuntime +from core.config import RuntimeConfig +from packs.ethics.check import EthicsCheckResult, EthicsVerdict +from packs.ethics.loader import EthicsPack, EthicsPackError, load_ethics_pack +from packs.safety.check import SafetyCheckResult + + +# ---------- loader bounds ---------- + + +class TestHedgeCommitmentsLoaderValidation: + def test_default_pack_has_empty_hedge_commitments(self) -> None: + pack = load_ethics_pack() + assert pack.hedge_commitments == frozenset() + + def test_unknown_id_in_hedge_commitments_is_rejected(self, tmp_path) -> None: + bad = tmp_path / "bad_hedge_v1.json" + bad.write_text( + '{"pack_id":"bad_hedge_v1","version":"1.0.0","description":"x",' + '"schema_version":"1.0.0","domain":"custom",' + '"commitment_ids":["a"],"hedge_commitments":["not_declared"]}' + ) + try: + load_ethics_pack( + "bad_hedge_v1", + search_paths=(tmp_path,), + require_ratified=False, + ) + except EthicsPackError as e: + assert "not a declared commitment_id" in str(e) + else: + raise AssertionError("expected EthicsPackError") + + def test_mutual_exclusion_refusal_and_hedge_rejected(self, tmp_path) -> None: + """A commitment cannot be in both refusal_commitments and + hedge_commitments — the two remediations are mutually + exclusive per commitment.""" + bad = tmp_path / "both_v1.json" + bad.write_text( + '{"pack_id":"both_v1","version":"1.0.0","description":"x",' + '"schema_version":"1.0.0","domain":"custom",' + '"commitment_ids":["a","b"],' + '"refusal_commitments":["a"],"hedge_commitments":["a"]}' + ) + try: + load_ethics_pack( + "both_v1", + search_paths=(tmp_path,), + require_ratified=False, + ) + except EthicsPackError as e: + assert "cannot appear in both" in str(e) + else: + raise AssertionError("expected EthicsPackError") + + def test_refusal_and_hedge_on_different_commitments_ok(self, tmp_path) -> None: + good = tmp_path / "split_v1.json" + good.write_text( + '{"pack_id":"split_v1","version":"1.0.0","description":"x",' + '"schema_version":"1.0.0","domain":"custom",' + '"commitment_ids":["a","b"],' + '"refusal_commitments":["a"],"hedge_commitments":["b"]}' + ) + pack = load_ethics_pack( + "split_v1", + search_paths=(tmp_path,), + require_ratified=False, + ) + assert pack.refusal_commitments == frozenset({"a"}) + assert pack.hedge_commitments == frozenset({"b"}) + + +# ---------- pure helper functions ---------- + + +class TestShouldInjectHedge: + def test_no_pack_returns_false(self) -> None: + verdict = _ethics_verdict( + _ethics_result("acknowledge_uncertainty", upheld=False, rc=True), + ) + assert not should_inject_hedge(verdict, None) + + def test_no_verdict_returns_false(self) -> None: + pack = _pack(hedge_commitments=frozenset({"acknowledge_uncertainty"})) + assert not should_inject_hedge(None, pack) + + def test_empty_opt_in_returns_false(self) -> None: + verdict = _ethics_verdict( + _ethics_result("acknowledge_uncertainty", upheld=False, rc=True), + ) + pack = _pack(hedge_commitments=frozenset()) + assert not should_inject_hedge(verdict, pack) + + def test_opt_in_with_runtime_checkable_violation_returns_true(self) -> None: + verdict = _ethics_verdict( + _ethics_result("acknowledge_uncertainty", upheld=False, rc=True), + ) + pack = _pack(hedge_commitments=frozenset({"acknowledge_uncertainty"})) + assert should_inject_hedge(verdict, pack) + + def test_opt_in_with_no_evidence_does_not_inject(self) -> None: + verdict = _ethics_verdict( + _ethics_result("acknowledge_uncertainty", upheld=False, rc=False), + ) + pack = _pack(hedge_commitments=frozenset({"acknowledge_uncertainty"})) + assert not should_inject_hedge(verdict, pack) + + def test_violation_outside_opt_in_does_not_inject(self) -> None: + verdict = _ethics_verdict( + _ethics_result("no_manipulation", upheld=False, rc=True), + ) + pack = _pack(hedge_commitments=frozenset({"acknowledge_uncertainty"})) + assert not should_inject_hedge(verdict, pack) + + +class TestBuildHedgePrefix: + def test_default_manifold_returns_soft_hedge(self) -> None: + rt = ChatRuntime(config=RuntimeConfig()) + prefix = build_hedge_prefix(rt.identity_manifold) + # The default identity pack carries a soft hedge phrase. + soft = rt.identity_manifold.surface_preferences.preferred_hedge_soft + if soft: + assert prefix == soft + + def test_none_manifold_returns_empty(self) -> None: + assert build_hedge_prefix(None) == "" + + +class TestInjectHedge: + def test_prepends_with_space(self) -> None: + assert inject_hedge("the answer", "Perhaps") == "Perhaps the answer" + + def test_empty_prefix_returns_surface_unchanged(self) -> None: + assert inject_hedge("the answer", "") == "the answer" + + def test_empty_surface_returns_unchanged(self) -> None: + assert inject_hedge("", "Perhaps") == "" + + def test_idempotent_on_existing_prefix(self) -> None: + """If the surface already starts with the hedge, don't + double-prepend. Idempotent on prefix is a useful property + for callers that may chain remediations.""" + assert inject_hedge("Perhaps the answer", "Perhaps") == "Perhaps the answer" + + def test_idempotent_case_insensitive(self) -> None: + assert inject_hedge("perhaps the answer", "Perhaps") == "perhaps the answer" + + +# ---------- ChatRuntime integration ---------- + + +class TestRuntimeHedgeInjection: + def test_default_pack_does_not_inject_hedge(self) -> None: + """Default ethics pack has empty hedge_commitments — surface + is not hedge-prefixed even when a hedge phrase is available.""" + rt = ChatRuntime(config=RuntimeConfig()) + # Force a runtime-checkable violation on + # acknowledge_uncertainty but do NOT opt it into hedging. + rt.ethics_check.register( + "acknowledge_uncertainty", + _failing_ethics("acknowledge_uncertainty"), + ) + resp = rt.chat("light is") + prefix = build_hedge_prefix(rt.identity_manifold) + if prefix: + assert not resp.surface.startswith(prefix) + + def test_opt_in_pack_injects_hedge_on_violation(self) -> None: + rt = ChatRuntime(config=RuntimeConfig()) + rt.ethics_pack = replace( + rt.ethics_pack, + hedge_commitments=frozenset({"acknowledge_uncertainty"}), + ) + rt.ethics_check.register( + "acknowledge_uncertainty", + _failing_ethics("acknowledge_uncertainty"), + ) + resp = rt.chat("light is") + prefix = build_hedge_prefix(rt.identity_manifold) + if not prefix: + return # nothing to inject in this pack + # Hedge injection is a main-path-only affordance — the stub + # path's unknown-domain marker is already a disclosure surface + # and is intentionally not hedge-prefixed. Only assert when + # the main path was taken (turn_log populated). + if not rt.turn_log: + return + assert resp.surface.startswith(prefix) + + def test_hedge_preserves_walk_surface(self) -> None: + """walk_surface retains the original token-walk evidence even + when ChatResponse.surface is hedge-prefixed.""" + rt = ChatRuntime(config=RuntimeConfig()) + rt.ethics_pack = replace( + rt.ethics_pack, + hedge_commitments=frozenset({"acknowledge_uncertainty"}), + ) + rt.ethics_check.register( + "acknowledge_uncertainty", + _failing_ethics("acknowledge_uncertainty"), + ) + resp = rt.chat("light is") + prefix = build_hedge_prefix(rt.identity_manifold) + if not prefix or not resp.surface.startswith(prefix): + return + assert not resp.walk_surface.startswith(prefix) + + def test_refusal_supersedes_hedge(self) -> None: + """When both safety refusal and hedge injection would fire, + refusal wins. The surface is a typed refusal, not a + hedge-prefixed token walk.""" + 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, + ) + + rt.safety_check.register("preserve_versor_closure", _failing_safety) + rt.ethics_check.register( + "acknowledge_uncertainty", + _failing_ethics("acknowledge_uncertainty"), + ) + resp = rt.chat("light is") + assert is_typed_refusal(resp.surface) + prefix = build_hedge_prefix(rt.identity_manifold) + if prefix: + # Refusal text begins with the typed-refusal prefix, not the hedge. + assert not resp.surface.startswith(prefix) + + def test_hedge_does_not_set_last_refusal_was_typed(self) -> None: + """Hedge injection is not a refusal — the bookkeeping flag + that tracks typed refusals must not be flipped by hedging.""" + rt = ChatRuntime(config=RuntimeConfig()) + rt.ethics_pack = replace( + rt.ethics_pack, + hedge_commitments=frozenset({"acknowledge_uncertainty"}), + ) + rt.ethics_check.register( + "acknowledge_uncertainty", + _failing_ethics("acknowledge_uncertainty"), + ) + # Default value is True; hedge injection should NOT flip it. + # (It only ever gets set to True; the predicate's job is to + # spot a False setting from an untyped refusal path.) + before = rt._last_refusal_was_typed + rt.chat("light is") + # The flag stays at its prior value — hedge injection does + # not touch refusal bookkeeping. + assert rt._last_refusal_was_typed == before + + +# ---------- helpers ---------- + + +def _failing_ethics(commitment_id: str): + def _impl(ctx): # noqa: ANN001 + return EthicsCheckResult( + commitment_id=commitment_id, + upheld=False, + reason="forced", + runtime_checkable=True, + ) + + return _impl + + +def _ethics_result(commitment_id: str, *, upheld: bool, rc: bool) -> EthicsCheckResult: + return EthicsCheckResult( + commitment_id=commitment_id, + upheld=upheld, + reason="test", + runtime_checkable=rc, + ) + + +def _ethics_verdict(*results: EthicsCheckResult) -> EthicsVerdict: + violated = frozenset(r.commitment_id for r in results if not r.upheld) + return EthicsVerdict( + pack_id="test_ethics", + results=tuple(results), + upheld=not violated, + violated_commitments=violated, + runtime_checkable_count=sum(1 for r in results if r.runtime_checkable), + ) + + +def _pack(*, hedge_commitments: frozenset[str]) -> EthicsPack: + return EthicsPack( + pack_id="test_ethics", + version="1.0.0", + description="test", + domain="custom", + commitment_ids=frozenset({ + "acknowledge_uncertainty", + "no_manipulation", + }), + commitment_descriptions={}, + mastery_report_sha256="", + ratified=False, + refusal_commitments=frozenset(), + hedge_commitments=hedge_commitments, + )