feat(adr-0037,adr-0038): per-predicate ethics refusal + hedge injection
Two sibling escalation tiers above the audit-only ethics baseline, both opt-in per commitment via the ethics pack JSON. ADR-0037 — refusal_commitments - EthicsPack.refusal_commitments (frozenset[str]; subset of commitment_ids; validated at load time, unknown id rejected) - Generic refusal prefix: "I cannot proceed — boundary violated: " - Source-tagged refusal ids: "safety:<id>" / "ethics:<id>" - build_refusal_surface now takes (safety_verdict, ethics_verdict, ethics_pack); ADR-0036 single-arg call remains valid back-compat - Default pack ships refusal_commitments: [] — audit-only floor preserved - Re-ratified default pack (mastery sha changes with schema field) ADR-0038 — hedge_commitments - EthicsPack.hedge_commitments (sibling field; same validator) - Mutually exclusive with refusal_commitments at load time - Runtime prepends manifold's preferred_hedge_soft (fallback preferred_hedge_strong) when an opted-in commitment fires runtime-checkable - Refusal supersedes hedge globally; stub path skips hedge (already a disclosure surface); main path only - Idempotent on prefix (case-insensitive) — defends against ADR-0028 assembler hedges - Does NOT flip _last_refusal_was_typed — hedge is not refusal Surface contract: - ChatResponse.walk_surface + articulation_surface preserved unchanged on both refusal and hedge paths (same audit discipline as ADR-0036) - Only user-facing ChatResponse.surface (and TurnEvent.surface on main path) is mutated Files: - packs/ethics/loader.py — refusal_commitments + hedge_commitments fields; _validate_opt_in_subset; mutual-exclusion check - packs/ethics/default_general_ethics_v1.json — both opt-in lists empty; re-ratified - chat/refusal.py — generic prefix, source-tagged ids, violated_runtime_checkable_ethics, should_inject_hedge, build_hedge_prefix, inject_hedge - chat/runtime.py — passes ethics_verdict + ethics_pack to refusal builder; hedge injection branch after refusal check - tests/test_ethics_refusal_opt_in.py (new) — 16 tests - tests/test_hedge_injection.py (new) — 22 tests - docs/decisions/ADR-0037-per-predicate-ethics-refusal.md (new) - docs/decisions/ADR-0038-hedge-injection.md (new) Verification: - Combined pack-layer suite: 154 green (was 116 after ADR-0036) - CLI suites unchanged: smoke 67, runtime 19, cognition 121 - core eval cognition: intent 100%, versor_closure 100% (baseline)
This commit is contained in:
parent
a0372c951f
commit
ad8495d777
9 changed files with 1207 additions and 54 deletions
192
chat/refusal.py
192
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:<id>`` /
|
||||
``ethics:<id>``). 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:<id>`` / ``ethics:<id>``).
|
||||
* 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}"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
132
docs/decisions/ADR-0037-per-predicate-ethics-refusal.md
Normal file
132
docs/decisions/ADR-0037-per-predicate-ethics-refusal.md
Normal file
|
|
@ -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:<boundary_id>`
|
||||
* `ethics:<commitment_id>`
|
||||
|
||||
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:<id>`).
|
||||
|
||||
## 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.
|
||||
174
docs/decisions/ADR-0038-hedge-injection.md
Normal file
174
docs/decisions/ADR-0038-hedge-injection.md
Normal file
|
|
@ -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).
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
313
tests/test_ethics_refusal_opt_in.py
Normal file
313
tests/test_ethics_refusal_opt_in.py
Normal file
|
|
@ -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:<id>`` / ``ethics:<id>``).
|
||||
"""
|
||||
|
||||
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,
|
||||
)
|
||||
332
tests/test_hedge_injection.py
Normal file
332
tests/test_hedge_injection.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
Loading…
Reference in a new issue