core/docs/decisions/ADR-0038-hedge-injection.md
Shay ad8495d777 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)
2026-05-17 21:23:28 -07:00

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