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:
Shay 2026-05-17 21:23:28 -07:00
parent a0372c951f
commit ad8495d777
9 changed files with 1207 additions and 54 deletions

View file

@ -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}"

View file

@ -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),

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

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

View file

@ -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": []
}

View file

@ -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"
}

View file

@ -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(

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

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