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)
177 lines
6.3 KiB
Python
177 lines
6.3 KiB
Python
"""ADR-0036 + ADR-0037 — typed refusal surface.
|
|
|
|
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.
|
|
|
|
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
|
|
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` and
|
|
`docs/decisions/ADR-0037-per-predicate-ethics-refusal.md`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Iterable
|
|
|
|
TYPED_REFUSAL_PREFIX = "I cannot proceed — boundary violated: "
|
|
_SAFETY_TAG = "safety:"
|
|
_ETHICS_TAG = "ethics:"
|
|
|
|
|
|
def violated_runtime_checkable(verdict) -> tuple[str, ...]:
|
|
"""Lex-sorted tuple of runtime-checkable violated boundary ids from a SafetyVerdict.
|
|
|
|
Used by safety; safety is always in scope for refusal.
|
|
"""
|
|
if verdict is None:
|
|
return ()
|
|
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
|
|
):
|
|
yield str(getattr(result, attr))
|
|
|
|
|
|
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}"
|