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)
174 lines
7.9 KiB
Markdown
174 lines
7.9 KiB
Markdown
# 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).
|