feat(adr-0034): EthicsCheck — structural surface parallel to SafetyCheck
Completes the predicate-surface layer for ethics packs, sibling to ADR-0032's SafetyCheck. Same registry-of-predicates shape; same observational discipline; same honest reporting of runtime-checkable=False for structural commitments that cannot be evaluated from per-turn evidence. Five default predicates for the v1 commitments: acknowledge_uncertainty — alignment < threshold ⇒ requires hedge defer_high_stakes_to_human_review — high_stakes ⇒ requires recommend_review disclose_limitations — ungrounded ⇒ requires disclosure marker no_manipulation — structural; runtime_checkable=False respect_user_autonomy — prescriptive ⇒ requires ≥2 options surfaced `no_manipulation` is the ethics-side analogue of `no_hot_path_repair` in SafetyCheck — an aggregate property enforced by realizer design and review, not a per-turn metric. Honest reporting rather than a silent upheld pass. ChatRuntime exposes `runtime.ethics_check`; turn loop does not auto-invoke. Refusal / re-articulation wiring is a future ADR. Test coverage: 27 new tests; combined pack-layer surface suite (identity + safety + ethics, loaders + checks) is now 108 tests, all green. Cognition (121), teaching (17), runtime (19), smoke (67) unaffected.
This commit is contained in:
parent
dab7b9c061
commit
db5bc028f9
6 changed files with 905 additions and 2 deletions
|
|
@ -18,6 +18,7 @@ from core.physics.identity import (
|
|||
IdentityScore,
|
||||
TurnEvent,
|
||||
)
|
||||
from packs.ethics.check import EthicsCheck
|
||||
from packs.ethics.loader import (
|
||||
DEFAULT_ETHICS_PACK as _DEFAULT_ETHICS_PACK,
|
||||
EthicsPackError,
|
||||
|
|
@ -272,6 +273,9 @@ class ChatRuntime:
|
|||
# the turn loop. Wiring violations into refusal paths is a
|
||||
# future ADR.
|
||||
self.safety_check = SafetyCheck()
|
||||
# ADR-0034 — structural ethics surface, sibling to SafetyCheck.
|
||||
# Observational at v1; no auto-invocation in the turn loop.
|
||||
self.ethics_check = EthicsCheck()
|
||||
self.turn_log: List[TurnEvent] = []
|
||||
self._correction_pass = CorrectionPass()
|
||||
self._last_valence: float = 0.0
|
||||
|
|
|
|||
123
docs/decisions/ADR-0034-ethics-check-surface.md
Normal file
123
docs/decisions/ADR-0034-ethics-check-surface.md
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# ADR-0034: EthicsCheck — Structural Surface for Ethics-Pack Commitments
|
||||
|
||||
**Status:** Accepted (2026-05-17)
|
||||
**Author:** Joshua Shay + planner pass
|
||||
**Companion docs:** [`../ethics_packs.md`](../ethics_packs.md), [`ADR-0032-safety-check-surface.md`](ADR-0032-safety-check-surface.md), [`ADR-0033-ethics-packs.md`](ADR-0033-ethics-packs.md)
|
||||
|
||||
## Context
|
||||
|
||||
[ADR-0033](ADR-0033-ethics-packs.md) introduced ethics packs as the third pack-layer sibling to identity and safety. The pack contributes `commitment_ids` to the runtime manifold's `boundary_ids`. What ADR-0033 did *not* establish was a structural surface for *evaluating* those commitments per turn — the parallel to `SafetyCheck` (ADR-0032) for the ethics layer.
|
||||
|
||||
The argument for adding the surface now (rather than deferring) is the same as it was for safety:
|
||||
|
||||
- Commitments without an observation surface decay into labels. The runtime declares it commits to `acknowledge_uncertainty`, but nothing produces a per-turn verdict on whether the commitment held.
|
||||
- Downstream domain deployments need a registration point to add deployment-specific predicates (`informed_consent_required_before_disclosure` for a medical pack, etc.). Without `EthicsCheck`, the registration point doesn't exist.
|
||||
- The shape of the surface is already known and tested (SafetyCheck is the precedent). Building the parallel keeps the architecture coherent.
|
||||
|
||||
## Decision
|
||||
|
||||
`EthicsCheck` is a registry of named predicates, one per commitment id, with defaults for the five v1 commitments. **Observational** at v1: it produces an `EthicsVerdict`; it does not refuse and does not auto-invoke in the turn loop. Wiring verdicts into refusal / re-articulation paths is a future ADR (parallel scope to the future safety-auto-invocation ADR).
|
||||
|
||||
### Why a parallel surface rather than a shared one
|
||||
|
||||
The temptation to fold ethics into `SafetyCheck` is real — same shape, same registry pattern, same fallback semantics. We resist it for the same reason ethics is a separate pack layer:
|
||||
|
||||
- Safety verdicts are **floor violations**. A safety violation is a system fault.
|
||||
- Ethics verdicts are **pledge failures**. An ethics violation is a deployment-commitment failure, not a fault of the floor.
|
||||
|
||||
Conflating them in a single surface would obscure the structural difference. An auditor reviewing a turn benefits from reading two distinct verdicts: "did the floor hold?" and "did the deployment honor its pledges?" One verdict object mixing both flattens that distinction.
|
||||
|
||||
### Default predicates per v1 commitment
|
||||
|
||||
| Commitment | Runtime-checkable? | What it checks |
|
||||
|---|---|---|
|
||||
| `acknowledge_uncertainty` | Yes (when `alignment_score` + `hedge_emitted` supplied) | `alignment < hedge_threshold_soft` requires `hedge_emitted=True` |
|
||||
| `defer_high_stakes_to_human_review` | Yes (when flags supplied) | `high_stakes_topic=True` requires `recommended_human_review=True` |
|
||||
| `disclose_limitations` | Yes (when flags supplied) | `grounded_in_evidence=False` requires `disclosure_emitted=True` |
|
||||
| `no_manipulation` | **No** | aggregate property; enforced by realizer design + review |
|
||||
| `respect_user_autonomy` | Yes (when flags supplied) | `prescribed_single_answer=True` requires `presented_options_count >= 2` |
|
||||
|
||||
`no_manipulation` is the structural analogue of `no_hot_path_repair` in SafetyCheck: an aggregate property that cannot be evaluated from per-turn evidence. A predicate that silently reported `upheld=True` would be the kind of small lie CLAUDE.md forbids. The honest answer is `runtime_checkable=False, upheld=True` with a reason that names where enforcement actually lives.
|
||||
|
||||
### API shape
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EthicsContext:
|
||||
# acknowledge_uncertainty
|
||||
alignment_score: float | None = None
|
||||
hedge_threshold_soft: float = 0.65
|
||||
hedge_emitted: bool | None = None
|
||||
# defer_high_stakes_to_human_review
|
||||
high_stakes_topic: bool | None = None
|
||||
recommended_human_review: bool | None = None
|
||||
# disclose_limitations
|
||||
grounded_in_evidence: bool | None = None
|
||||
disclosure_emitted: bool | None = None
|
||||
# respect_user_autonomy
|
||||
prescribed_single_answer: bool | None = None
|
||||
presented_options_count: int | None = None
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EthicsCheckResult:
|
||||
commitment_id: str
|
||||
upheld: bool
|
||||
reason: str
|
||||
runtime_checkable: bool
|
||||
evidence: tuple[tuple[str, str], ...] = ()
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EthicsVerdict:
|
||||
pack_id: str
|
||||
results: tuple[EthicsCheckResult, ...] # lex order on commitment_id
|
||||
upheld: bool
|
||||
violated_commitments: frozenset[str]
|
||||
runtime_checkable_count: int
|
||||
|
||||
class EthicsCheck:
|
||||
def __init__(self, predicates: Mapping[str, EthicsPredicate] | None = None) -> None: ...
|
||||
def register(self, commitment_id: str, predicate: EthicsPredicate) -> None: ...
|
||||
def check(self, ctx: EthicsContext, ethics_pack: EthicsPack) -> EthicsVerdict: ...
|
||||
```
|
||||
|
||||
Every field on `EthicsContext` is optional; `None` defaults express "caller did not supply this evidence." Predicates over absent evidence return `upheld=True, runtime_checkable=False` — absence of evidence is not evidence of commitment violation. This is the same composability discipline as SafetyCheck.
|
||||
|
||||
### Unknown-commitment behavior
|
||||
|
||||
When a pack declares a commitment for which no predicate is registered, the verdict records `upheld=True, runtime_checkable=False, reason="no predicate registered for commitment"`. Downstream domain deployments can author packs with novel commitments; the runtime doesn't crash, the audit surfaces the gap.
|
||||
|
||||
### Defensive: predicate-result rebinding
|
||||
|
||||
Identical to SafetyCheck: if a registered predicate returns a `EthicsCheckResult` whose `commitment_id` doesn't match the slot it was registered under, `EthicsCheck.check` rebinds the id. A buggy predicate should not silently misroute its verdict in audit.
|
||||
|
||||
### ChatRuntime integration
|
||||
|
||||
`ChatRuntime` instantiates `self.ethics_check = EthicsCheck()` alongside `self.safety_check`. The turn loop **does not** auto-invoke either surface at v1. Callers (audit / logging / future enforcement) call `runtime.ethics_check.check(ctx, runtime.ethics_pack)` whenever they want a verdict.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Three observation surfaces, three orthogonal verdicts.** Identity (manifold score), safety (boundary verdict), ethics (commitment verdict). An auditor reviewing a turn can answer three distinct questions independently.
|
||||
- **Honest reporting on `no_manipulation`.** Following the precedent set by `no_hot_path_repair` in SafetyCheck — structural commitments report `runtime_checkable=False` rather than passing silently.
|
||||
- **Extensible.** Domain packs ship custom predicates that register without touching CORE code.
|
||||
- **Forward-compatible with auto-invocation.** When the future ADR wires ethics evaluation into the turn loop, the surface won't need to change.
|
||||
|
||||
### Negative / risks
|
||||
|
||||
- **Observation isn't enforcement.** A violation reported by EthicsCheck at v1 has no automatic consequence. Deliberate (same scope discipline as ADR-0032).
|
||||
- **Predicate authoring is per-deployment work** for any commitment beyond the five v1 defaults. Domain packs will need their own predicates — documentation in `docs/ethics_packs.md` covers the authoring pattern.
|
||||
- **Two parallel surfaces (Safety + Ethics) is more API.** Mitigated by the fact that they share *exactly* the same shape; a caller who understands one understands the other. A future "unified verdict bundle" type could group both verdicts for callers that want a single pass.
|
||||
|
||||
### Scope limits (explicit non-goals)
|
||||
|
||||
- No auto-invocation in the turn loop.
|
||||
- No refusal / re-articulation wiring.
|
||||
- No cross-surface aggregation (one unified verdict object combining safety + ethics + identity).
|
||||
- No structural difference between "violated" and "would-have-been-violated-if-checkable" within the verdict — same as ADR-0032.
|
||||
|
||||
## Verification
|
||||
|
||||
- `tests/test_ethics_check.py` — 27 tests covering each default predicate (positive / negative / not-supplied paths), the unknown-commitment fallback, custom predicate registration, defensive rebinding, verdict aggregation, and `ChatRuntime` integration.
|
||||
- Existing pack-layer suites unaffected; combined identity/safety/ethics surface suite is now 108 tests across loader + check surfaces, all green at this revision.
|
||||
- Cognition (121), teaching (17), runtime (19), smoke (67), formation suites continue green.
|
||||
|
|
@ -160,20 +160,68 @@ final_boundary_ids = (
|
|||
| Removing a commitment | Major + new pack ID (`<slug>_v2`) |
|
||||
| Schema format change | Major + new `schema_version` |
|
||||
|
||||
## EthicsCheck — structural surface (ADR-0034)
|
||||
|
||||
A centralized, observational surface for evaluating commitments at runtime, parallel in shape to `SafetyCheck` (ADR-0032). Produces an `EthicsVerdict`; does not refuse. Wiring violations into refusal / re-articulation paths is a future ADR.
|
||||
|
||||
```python
|
||||
from packs.ethics import EthicsCheck, EthicsContext
|
||||
|
||||
check = EthicsCheck() # ships with default predicates for the five v1 commitments
|
||||
ctx = EthicsContext(
|
||||
alignment_score=score,
|
||||
hedge_emitted=hedged,
|
||||
high_stakes_topic=is_high_stakes,
|
||||
recommended_human_review=review_recommended,
|
||||
grounded_in_evidence=grounded,
|
||||
disclosure_emitted=disclosed,
|
||||
prescribed_single_answer=prescriptive,
|
||||
presented_options_count=n_options,
|
||||
)
|
||||
verdict = check.check(ctx, ethics_pack)
|
||||
# verdict.upheld: bool, verdict.violated_commitments: frozenset[str]
|
||||
# verdict.results: tuple of per-commitment EthicsCheckResult
|
||||
```
|
||||
|
||||
Every `EthicsContext` field is `None`-default. Predicates over fields the caller didn't populate return `upheld=True, runtime_checkable=False` — absence of evidence is not evidence of pledge violation. `ChatRuntime` exposes a pre-constructed instance as `runtime.ethics_check`; the turn loop does not auto-invoke it at v1.
|
||||
|
||||
### Default predicates per v1 commitment
|
||||
|
||||
| Commitment | Runtime-checkable? | What it checks |
|
||||
|---|---|---|
|
||||
| `acknowledge_uncertainty` | Yes (when supplied) | `alignment < hedge_threshold_soft` requires `hedge_emitted=True` |
|
||||
| `defer_high_stakes_to_human_review` | Yes (when supplied) | `high_stakes_topic=True` requires `recommended_human_review=True` |
|
||||
| `disclose_limitations` | Yes (when supplied) | `grounded_in_evidence=False` requires `disclosure_emitted=True` |
|
||||
| `no_manipulation` | **No** | aggregate property; enforced by realizer design + review |
|
||||
| `respect_user_autonomy` | Yes (when supplied) | `prescribed_single_answer=True` requires `presented_options_count >= 2` |
|
||||
|
||||
`no_manipulation` is the structural analogue of `no_hot_path_repair` in SafetyCheck. Honest `runtime_checkable=False` rather than a silent pass.
|
||||
|
||||
### Custom predicates
|
||||
|
||||
```python
|
||||
check = EthicsCheck()
|
||||
check.register("my_domain_commitment", my_predicate)
|
||||
```
|
||||
|
||||
Unknown commitments default to `upheld=True, runtime_checkable=False, reason="no predicate registered for commitment"`. Surfaces in audit; doesn't crash.
|
||||
|
||||
## Known limits / future ADRs
|
||||
|
||||
1. **No `EthicsCheck` predicate surface** parallel to `SafetyCheck` (ADR-0032). Future ADR.
|
||||
1. ~~**No `EthicsCheck` predicate surface** parallel to `SafetyCheck`.~~ **Closed by [ADR-0034](decisions/ADR-0034-ethics-check-surface.md) (2026-05-17).** v1 is observational; turn-loop auto-invocation and refusal wiring are future ADRs.
|
||||
2. **No deliberation surface.** Ethics-as-deliberation (multi-candidate trajectory selection, typed-tradeoff evaluation) needs multi-trajectory articulation first.
|
||||
3. **No pack inheritance.** Domain packs must declare full commitment lists; no `extends` mechanism.
|
||||
4. **No domain-driven behavior.** `domain` is audit-only.
|
||||
5. **No CLI flag** for `--ethics <pack_id>` yet — `RuntimeConfig(ethics_pack=...)` only. Future ADR.
|
||||
6. **English-only commitment descriptions** at v1.
|
||||
7. **No cross-surface verdict aggregation.** Identity / Safety / Ethics produce three independent verdicts; no unified bundle type. Future convenience ADR if call sites grow.
|
||||
|
||||
## Cross-reference index
|
||||
|
||||
- Pack format spec: this doc §"Pack format (v1)".
|
||||
- Loader contract: this doc §"Loader contract".
|
||||
- Decision record: [ADR-0033](decisions/ADR-0033-ethics-packs.md).
|
||||
- Decision records: [ADR-0033](decisions/ADR-0033-ethics-packs.md), [ADR-0034](decisions/ADR-0034-ethics-check-surface.md).
|
||||
- Identity composition: [`identity_packs.md`](identity_packs.md).
|
||||
- Safety composition: [`safety_packs.md`](safety_packs.md).
|
||||
- Safety predicate surface: [`safety_packs.md`](safety_packs.md) §SafetyCheck.
|
||||
- Formation template used for ratification: `formation/templates/identity_anchor.py`.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,13 @@ or *boundaries* (universal red lines).
|
|||
See ``docs/decisions/ADR-0033-ethics-packs.md``.
|
||||
"""
|
||||
|
||||
from packs.ethics.check import (
|
||||
EthicsCheck,
|
||||
EthicsCheckResult,
|
||||
EthicsContext,
|
||||
EthicsPredicate,
|
||||
EthicsVerdict,
|
||||
)
|
||||
from packs.ethics.loader import (
|
||||
DEFAULT_ETHICS_PACK,
|
||||
EthicsPack,
|
||||
|
|
@ -19,8 +26,13 @@ from packs.ethics.loader import (
|
|||
|
||||
__all__ = [
|
||||
"DEFAULT_ETHICS_PACK",
|
||||
"EthicsCheck",
|
||||
"EthicsCheckResult",
|
||||
"EthicsContext",
|
||||
"EthicsPack",
|
||||
"EthicsPackError",
|
||||
"EthicsPredicate",
|
||||
"EthicsVerdict",
|
||||
"available_packs",
|
||||
"load_ethics_pack",
|
||||
]
|
||||
|
|
|
|||
408
packs/ethics/check.py
Normal file
408
packs/ethics/check.py
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
"""EthicsCheck — structural surface for ethics-pack commitment checks.
|
||||
|
||||
Parallel in shape to :class:`packs.safety.check.SafetyCheck` (ADR-0032),
|
||||
but evaluates *propositional commitments* rather than safety boundaries.
|
||||
The two layers occupy distinct architectural niches:
|
||||
|
||||
* SafetyCheck evaluates universal red lines ("never X"). Violations
|
||||
are violations of the floor.
|
||||
* EthicsCheck evaluates deployment commitments ("we commit to Y").
|
||||
Violations are failures of pledge, not floor violations.
|
||||
|
||||
Per ADR-0034:
|
||||
|
||||
* EthicsCheck is **observational** at v1. It produces an
|
||||
:class:`EthicsVerdict` per turn; it does NOT refuse. Wiring into
|
||||
refusal / re-articulation paths is a future ADR.
|
||||
* Predicates report ``runtime_checkable=False`` honestly when a
|
||||
commitment cannot be evaluated from runtime evidence — the canonical
|
||||
example is ``no_manipulation``, an aggregate property enforced by
|
||||
realizer design and review, not a per-turn metric.
|
||||
* Unknown commitments — packs that declare ids for which no predicate
|
||||
is registered — default to ``upheld=True, runtime_checkable=False``.
|
||||
Downstream domain deployments can register custom predicates without
|
||||
modifying CORE code.
|
||||
|
||||
See ``docs/decisions/ADR-0034-ethics-check-surface.md``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Mapping
|
||||
|
||||
from packs.ethics.loader import EthicsPack
|
||||
|
||||
|
||||
# ---------- value types ----------
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EthicsContext:
|
||||
"""Inputs available to ethics predicates per turn.
|
||||
|
||||
Every field is optional; defaults express "caller did not supply
|
||||
this evidence." Predicates that depend on absent evidence return
|
||||
``upheld=True, runtime_checkable=False`` — absence of evidence is
|
||||
not evidence of commitment violation.
|
||||
"""
|
||||
|
||||
# --- acknowledge_uncertainty ---
|
||||
# Manifold alignment score for this turn's reasoning trajectory
|
||||
# (typically the scalar ``IdentityScore.alignment``). ``None``
|
||||
# means the caller did not supply it.
|
||||
alignment_score: float | None = None
|
||||
# Soft hedge threshold: below this, an unhedged claim is a
|
||||
# violation of ``acknowledge_uncertainty``. Default mirrors the
|
||||
# ADR-0028 default ``hedge_threshold_soft``.
|
||||
hedge_threshold_soft: float = 0.65
|
||||
# Whether the realized surface emitted a hedge phrase.
|
||||
hedge_emitted: bool | None = None
|
||||
|
||||
# --- defer_high_stakes_to_human_review ---
|
||||
# Whether the current topic falls in a high-stakes category as
|
||||
# classified by the caller (e.g. medical diagnosis, legal advice).
|
||||
high_stakes_topic: bool | None = None
|
||||
# Whether the response recommended human review for this turn.
|
||||
recommended_human_review: bool | None = None
|
||||
|
||||
# --- disclose_limitations ---
|
||||
# Whether the response was grounded in retrievable evidence.
|
||||
grounded_in_evidence: bool | None = None
|
||||
# Whether the realized surface emitted a limitation-disclosure
|
||||
# marker (e.g. "I don't know — insufficient grounding").
|
||||
disclosure_emitted: bool | None = None
|
||||
|
||||
# --- respect_user_autonomy ---
|
||||
# Whether the response prescribed a single answer rather than
|
||||
# surfacing options. ``None`` means the caller did not classify
|
||||
# the response's prescriptiveness.
|
||||
prescribed_single_answer: bool | None = None
|
||||
# Count of distinct options / approaches surfaced. When
|
||||
# ``prescribed_single_answer`` is True and this is < 2, the
|
||||
# commitment is violated.
|
||||
presented_options_count: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EthicsCheckResult:
|
||||
"""Outcome of one commitment's predicate evaluation."""
|
||||
|
||||
commitment_id: str
|
||||
upheld: bool
|
||||
reason: str
|
||||
runtime_checkable: bool
|
||||
evidence: tuple[tuple[str, str], ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EthicsVerdict:
|
||||
"""Aggregate verdict over every commitment in the pack."""
|
||||
|
||||
pack_id: str
|
||||
results: tuple[EthicsCheckResult, ...]
|
||||
upheld: bool
|
||||
violated_commitments: frozenset[str]
|
||||
runtime_checkable_count: int
|
||||
|
||||
|
||||
# ---------- predicate signature ----------
|
||||
|
||||
|
||||
EthicsPredicate = Callable[[EthicsContext], EthicsCheckResult]
|
||||
|
||||
|
||||
# ---------- the check ----------
|
||||
|
||||
|
||||
class EthicsCheck:
|
||||
"""Structural ethics surface. Observational; never refuses.
|
||||
|
||||
Canonical call style::
|
||||
|
||||
verdict = EthicsCheck().check(ctx, ethics_pack)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
predicates: Mapping[str, EthicsPredicate] | None = None,
|
||||
) -> None:
|
||||
if predicates is None:
|
||||
self._predicates: dict[str, EthicsPredicate] = dict(_DEFAULT_PREDICATES)
|
||||
else:
|
||||
self._predicates = dict(predicates)
|
||||
|
||||
def register(self, commitment_id: str, predicate: EthicsPredicate) -> None:
|
||||
"""Register / replace a predicate for ``commitment_id``."""
|
||||
self._predicates[commitment_id] = predicate
|
||||
|
||||
def check(
|
||||
self,
|
||||
ctx: EthicsContext,
|
||||
ethics_pack: EthicsPack,
|
||||
) -> EthicsVerdict:
|
||||
"""Run every predicate. Returns an aggregate verdict.
|
||||
|
||||
Commitments are evaluated in lex order on ``commitment_id`` so
|
||||
``results`` is deterministic regardless of how the pack
|
||||
enumerates ``commitment_ids``.
|
||||
"""
|
||||
results: list[EthicsCheckResult] = []
|
||||
runtime_checkable_count = 0
|
||||
violated: set[str] = set()
|
||||
for commitment in sorted(ethics_pack.commitment_ids):
|
||||
predicate = self._predicates.get(commitment)
|
||||
if predicate is None:
|
||||
result = EthicsCheckResult(
|
||||
commitment_id=commitment,
|
||||
upheld=True,
|
||||
reason="no predicate registered for commitment",
|
||||
runtime_checkable=False,
|
||||
)
|
||||
else:
|
||||
result = predicate(ctx)
|
||||
if result.commitment_id != commitment:
|
||||
# Defensive: rebinding a misbehaving predicate's
|
||||
# result keeps the audit trail correctly indexed.
|
||||
result = EthicsCheckResult(
|
||||
commitment_id=commitment,
|
||||
upheld=result.upheld,
|
||||
reason=result.reason,
|
||||
runtime_checkable=result.runtime_checkable,
|
||||
evidence=result.evidence,
|
||||
)
|
||||
results.append(result)
|
||||
if result.runtime_checkable:
|
||||
runtime_checkable_count += 1
|
||||
if not result.upheld:
|
||||
violated.add(commitment)
|
||||
return EthicsVerdict(
|
||||
pack_id=ethics_pack.pack_id,
|
||||
results=tuple(results),
|
||||
upheld=not violated,
|
||||
violated_commitments=frozenset(violated),
|
||||
runtime_checkable_count=runtime_checkable_count,
|
||||
)
|
||||
|
||||
|
||||
# ---------- default predicates for the five v1 commitments ----------
|
||||
|
||||
|
||||
def _predicate_acknowledge_uncertainty(
|
||||
ctx: EthicsContext,
|
||||
) -> EthicsCheckResult:
|
||||
"""``acknowledge_uncertainty`` — low-alignment claims must be hedged."""
|
||||
if ctx.alignment_score is None or ctx.hedge_emitted is None:
|
||||
return EthicsCheckResult(
|
||||
commitment_id="acknowledge_uncertainty",
|
||||
upheld=True,
|
||||
reason="alignment_score or hedge_emitted not supplied",
|
||||
runtime_checkable=False,
|
||||
)
|
||||
below_threshold = ctx.alignment_score < ctx.hedge_threshold_soft
|
||||
if below_threshold and not ctx.hedge_emitted:
|
||||
return EthicsCheckResult(
|
||||
commitment_id="acknowledge_uncertainty",
|
||||
upheld=False,
|
||||
reason=(
|
||||
f"alignment_score={ctx.alignment_score:.3f} below "
|
||||
f"hedge_threshold_soft={ctx.hedge_threshold_soft:.3f} "
|
||||
"but no hedge emitted"
|
||||
),
|
||||
runtime_checkable=True,
|
||||
evidence=(
|
||||
("alignment_score", f"{ctx.alignment_score:.6f}"),
|
||||
("hedge_threshold_soft", f"{ctx.hedge_threshold_soft:.6f}"),
|
||||
("hedge_emitted", "False"),
|
||||
),
|
||||
)
|
||||
return EthicsCheckResult(
|
||||
commitment_id="acknowledge_uncertainty",
|
||||
upheld=True,
|
||||
reason=(
|
||||
f"alignment={ctx.alignment_score:.3f}, "
|
||||
f"hedge_emitted={ctx.hedge_emitted}"
|
||||
),
|
||||
runtime_checkable=True,
|
||||
)
|
||||
|
||||
|
||||
def _predicate_defer_high_stakes(ctx: EthicsContext) -> EthicsCheckResult:
|
||||
"""``defer_high_stakes_to_human_review`` — high-stakes turns recommend review."""
|
||||
if ctx.high_stakes_topic is None:
|
||||
return EthicsCheckResult(
|
||||
commitment_id="defer_high_stakes_to_human_review",
|
||||
upheld=True,
|
||||
reason="high_stakes_topic flag not supplied",
|
||||
runtime_checkable=False,
|
||||
)
|
||||
if not ctx.high_stakes_topic:
|
||||
return EthicsCheckResult(
|
||||
commitment_id="defer_high_stakes_to_human_review",
|
||||
upheld=True,
|
||||
reason="topic not classified as high-stakes; commitment dormant",
|
||||
runtime_checkable=True,
|
||||
)
|
||||
if ctx.recommended_human_review is None:
|
||||
return EthicsCheckResult(
|
||||
commitment_id="defer_high_stakes_to_human_review",
|
||||
upheld=True,
|
||||
reason=(
|
||||
"high_stakes_topic=True but recommended_human_review not "
|
||||
"supplied — cannot judge"
|
||||
),
|
||||
runtime_checkable=False,
|
||||
)
|
||||
if ctx.recommended_human_review:
|
||||
return EthicsCheckResult(
|
||||
commitment_id="defer_high_stakes_to_human_review",
|
||||
upheld=True,
|
||||
reason="high-stakes topic; human review recommended",
|
||||
runtime_checkable=True,
|
||||
)
|
||||
return EthicsCheckResult(
|
||||
commitment_id="defer_high_stakes_to_human_review",
|
||||
upheld=False,
|
||||
reason=(
|
||||
"high-stakes topic but no human review recommended"
|
||||
),
|
||||
runtime_checkable=True,
|
||||
evidence=(
|
||||
("high_stakes_topic", "True"),
|
||||
("recommended_human_review", "False"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _predicate_disclose_limitations(ctx: EthicsContext) -> EthicsCheckResult:
|
||||
"""``disclose_limitations`` — ungrounded turns must emit disclosure."""
|
||||
if ctx.grounded_in_evidence is None:
|
||||
return EthicsCheckResult(
|
||||
commitment_id="disclose_limitations",
|
||||
upheld=True,
|
||||
reason="grounded_in_evidence flag not supplied",
|
||||
runtime_checkable=False,
|
||||
)
|
||||
if ctx.grounded_in_evidence:
|
||||
return EthicsCheckResult(
|
||||
commitment_id="disclose_limitations",
|
||||
upheld=True,
|
||||
reason="response grounded; no disclosure obligation",
|
||||
runtime_checkable=True,
|
||||
)
|
||||
if ctx.disclosure_emitted is None:
|
||||
return EthicsCheckResult(
|
||||
commitment_id="disclose_limitations",
|
||||
upheld=True,
|
||||
reason=(
|
||||
"grounded_in_evidence=False but disclosure_emitted not "
|
||||
"supplied — cannot judge"
|
||||
),
|
||||
runtime_checkable=False,
|
||||
)
|
||||
if ctx.disclosure_emitted:
|
||||
return EthicsCheckResult(
|
||||
commitment_id="disclose_limitations",
|
||||
upheld=True,
|
||||
reason="ungrounded response disclosed its limitation",
|
||||
runtime_checkable=True,
|
||||
)
|
||||
return EthicsCheckResult(
|
||||
commitment_id="disclose_limitations",
|
||||
upheld=False,
|
||||
reason="ungrounded response did not disclose its limitation",
|
||||
runtime_checkable=True,
|
||||
evidence=(
|
||||
("grounded_in_evidence", "False"),
|
||||
("disclosure_emitted", "False"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _predicate_no_manipulation(ctx: EthicsContext) -> EthicsCheckResult:
|
||||
"""``no_manipulation`` — structural commitment; not runtime-checkable.
|
||||
|
||||
Absence of manipulation is an aggregate property of realizer
|
||||
design, template curation, and review — not a per-turn metric.
|
||||
A predicate that silently reported ``upheld=True`` would be
|
||||
misleading; the honest answer is ``runtime_checkable=False``,
|
||||
same shape as ``no_hot_path_repair`` in SafetyCheck (ADR-0032).
|
||||
"""
|
||||
return EthicsCheckResult(
|
||||
commitment_id="no_manipulation",
|
||||
upheld=True,
|
||||
reason=(
|
||||
"aggregate commitment; enforced by realizer design, template "
|
||||
"curation, and review — not by per-turn runtime check"
|
||||
),
|
||||
runtime_checkable=False,
|
||||
)
|
||||
|
||||
|
||||
def _predicate_respect_user_autonomy(ctx: EthicsContext) -> EthicsCheckResult:
|
||||
"""``respect_user_autonomy`` — prescriptive turns must surface options."""
|
||||
if ctx.prescribed_single_answer is None:
|
||||
return EthicsCheckResult(
|
||||
commitment_id="respect_user_autonomy",
|
||||
upheld=True,
|
||||
reason="prescribed_single_answer flag not supplied",
|
||||
runtime_checkable=False,
|
||||
)
|
||||
if not ctx.prescribed_single_answer:
|
||||
return EthicsCheckResult(
|
||||
commitment_id="respect_user_autonomy",
|
||||
upheld=True,
|
||||
reason="response did not prescribe a single answer",
|
||||
runtime_checkable=True,
|
||||
)
|
||||
options = ctx.presented_options_count
|
||||
if options is None:
|
||||
return EthicsCheckResult(
|
||||
commitment_id="respect_user_autonomy",
|
||||
upheld=True,
|
||||
reason=(
|
||||
"prescribed_single_answer=True but presented_options_count "
|
||||
"not supplied — cannot judge"
|
||||
),
|
||||
runtime_checkable=False,
|
||||
)
|
||||
if options >= 2:
|
||||
return EthicsCheckResult(
|
||||
commitment_id="respect_user_autonomy",
|
||||
upheld=True,
|
||||
reason=(
|
||||
f"single prescription but {options} options also surfaced"
|
||||
),
|
||||
runtime_checkable=True,
|
||||
)
|
||||
return EthicsCheckResult(
|
||||
commitment_id="respect_user_autonomy",
|
||||
upheld=False,
|
||||
reason=(
|
||||
f"prescribed a single answer with only {options} option(s) "
|
||||
"presented"
|
||||
),
|
||||
runtime_checkable=True,
|
||||
evidence=(
|
||||
("prescribed_single_answer", "True"),
|
||||
("presented_options_count", str(options)),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_DEFAULT_PREDICATES: dict[str, EthicsPredicate] = {
|
||||
"acknowledge_uncertainty": _predicate_acknowledge_uncertainty,
|
||||
"defer_high_stakes_to_human_review": _predicate_defer_high_stakes,
|
||||
"disclose_limitations": _predicate_disclose_limitations,
|
||||
"no_manipulation": _predicate_no_manipulation,
|
||||
"respect_user_autonomy": _predicate_respect_user_autonomy,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EthicsCheck",
|
||||
"EthicsCheckResult",
|
||||
"EthicsContext",
|
||||
"EthicsPredicate",
|
||||
"EthicsVerdict",
|
||||
]
|
||||
308
tests/test_ethics_check.py
Normal file
308
tests/test_ethics_check.py
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
"""ADR-0034 — EthicsCheck structural surface.
|
||||
|
||||
Mirrors ``tests/test_safety_check.py``. Each of the five default
|
||||
predicates is exercised on its positive and negative paths plus the
|
||||
"caller didn't supply evidence" path; unknown-commitment fallback,
|
||||
custom predicate registration, defensive id rebinding, and
|
||||
``ChatRuntime`` integration are covered.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from chat.runtime import ChatRuntime
|
||||
from core.config import RuntimeConfig
|
||||
from packs.ethics.check import (
|
||||
EthicsCheck,
|
||||
EthicsCheckResult,
|
||||
EthicsContext,
|
||||
EthicsVerdict,
|
||||
)
|
||||
from packs.ethics.loader import EthicsPack, load_ethics_pack
|
||||
|
||||
|
||||
# ---------- acknowledge_uncertainty ----------
|
||||
|
||||
|
||||
class TestAcknowledgeUncertainty:
|
||||
def test_low_alignment_unhedged_violates(self) -> None:
|
||||
ctx = EthicsContext(
|
||||
alignment_score=0.3,
|
||||
hedge_threshold_soft=0.65,
|
||||
hedge_emitted=False,
|
||||
)
|
||||
result = _find(_check(ctx), "acknowledge_uncertainty")
|
||||
assert not result.upheld
|
||||
assert result.runtime_checkable
|
||||
|
||||
def test_low_alignment_hedged_upheld(self) -> None:
|
||||
ctx = EthicsContext(
|
||||
alignment_score=0.3,
|
||||
hedge_threshold_soft=0.65,
|
||||
hedge_emitted=True,
|
||||
)
|
||||
result = _find(_check(ctx), "acknowledge_uncertainty")
|
||||
assert result.upheld
|
||||
assert result.runtime_checkable
|
||||
|
||||
def test_high_alignment_unhedged_upheld(self) -> None:
|
||||
ctx = EthicsContext(
|
||||
alignment_score=0.9,
|
||||
hedge_threshold_soft=0.65,
|
||||
hedge_emitted=False,
|
||||
)
|
||||
result = _find(_check(ctx), "acknowledge_uncertainty")
|
||||
assert result.upheld
|
||||
|
||||
def test_missing_fields_not_runtime_checkable(self) -> None:
|
||||
result = _find(_check(EthicsContext()), "acknowledge_uncertainty")
|
||||
assert result.upheld
|
||||
assert not result.runtime_checkable
|
||||
|
||||
|
||||
# ---------- defer_high_stakes_to_human_review ----------
|
||||
|
||||
|
||||
class TestDeferHighStakes:
|
||||
def test_high_stakes_with_review_upheld(self) -> None:
|
||||
ctx = EthicsContext(
|
||||
high_stakes_topic=True, recommended_human_review=True,
|
||||
)
|
||||
result = _find(_check(ctx), "defer_high_stakes_to_human_review")
|
||||
assert result.upheld
|
||||
assert result.runtime_checkable
|
||||
|
||||
def test_high_stakes_without_review_violates(self) -> None:
|
||||
ctx = EthicsContext(
|
||||
high_stakes_topic=True, recommended_human_review=False,
|
||||
)
|
||||
result = _find(_check(ctx), "defer_high_stakes_to_human_review")
|
||||
assert not result.upheld
|
||||
|
||||
def test_low_stakes_dormant(self) -> None:
|
||||
ctx = EthicsContext(high_stakes_topic=False)
|
||||
result = _find(_check(ctx), "defer_high_stakes_to_human_review")
|
||||
assert result.upheld
|
||||
assert result.runtime_checkable
|
||||
assert "dormant" in result.reason
|
||||
|
||||
def test_high_stakes_unspecified_review_not_checkable(self) -> None:
|
||||
ctx = EthicsContext(high_stakes_topic=True)
|
||||
result = _find(_check(ctx), "defer_high_stakes_to_human_review")
|
||||
assert result.upheld
|
||||
assert not result.runtime_checkable
|
||||
|
||||
def test_missing_flag_not_checkable(self) -> None:
|
||||
result = _find(
|
||||
_check(EthicsContext()), "defer_high_stakes_to_human_review",
|
||||
)
|
||||
assert result.upheld
|
||||
assert not result.runtime_checkable
|
||||
|
||||
|
||||
# ---------- disclose_limitations ----------
|
||||
|
||||
|
||||
class TestDiscloseLimitations:
|
||||
def test_grounded_no_obligation(self) -> None:
|
||||
ctx = EthicsContext(grounded_in_evidence=True)
|
||||
result = _find(_check(ctx), "disclose_limitations")
|
||||
assert result.upheld
|
||||
assert result.runtime_checkable
|
||||
|
||||
def test_ungrounded_disclosed_upheld(self) -> None:
|
||||
ctx = EthicsContext(
|
||||
grounded_in_evidence=False, disclosure_emitted=True,
|
||||
)
|
||||
result = _find(_check(ctx), "disclose_limitations")
|
||||
assert result.upheld
|
||||
|
||||
def test_ungrounded_silent_violates(self) -> None:
|
||||
ctx = EthicsContext(
|
||||
grounded_in_evidence=False, disclosure_emitted=False,
|
||||
)
|
||||
result = _find(_check(ctx), "disclose_limitations")
|
||||
assert not result.upheld
|
||||
assert result.runtime_checkable
|
||||
|
||||
def test_missing_flag_not_checkable(self) -> None:
|
||||
result = _find(_check(EthicsContext()), "disclose_limitations")
|
||||
assert result.upheld
|
||||
assert not result.runtime_checkable
|
||||
|
||||
|
||||
# ---------- no_manipulation ----------
|
||||
|
||||
|
||||
class TestNoManipulation:
|
||||
def test_always_upheld_never_runtime_checkable(self) -> None:
|
||||
result = _find(_check(EthicsContext()), "no_manipulation")
|
||||
assert result.upheld
|
||||
assert not result.runtime_checkable
|
||||
assert (
|
||||
"realizer design" in result.reason
|
||||
or "review" in result.reason
|
||||
)
|
||||
|
||||
|
||||
# ---------- respect_user_autonomy ----------
|
||||
|
||||
|
||||
class TestRespectUserAutonomy:
|
||||
def test_non_prescriptive_upheld(self) -> None:
|
||||
ctx = EthicsContext(prescribed_single_answer=False)
|
||||
result = _find(_check(ctx), "respect_user_autonomy")
|
||||
assert result.upheld
|
||||
assert result.runtime_checkable
|
||||
|
||||
def test_prescribed_with_alternatives_upheld(self) -> None:
|
||||
ctx = EthicsContext(
|
||||
prescribed_single_answer=True, presented_options_count=3,
|
||||
)
|
||||
result = _find(_check(ctx), "respect_user_autonomy")
|
||||
assert result.upheld
|
||||
|
||||
def test_prescribed_without_alternatives_violates(self) -> None:
|
||||
ctx = EthicsContext(
|
||||
prescribed_single_answer=True, presented_options_count=1,
|
||||
)
|
||||
result = _find(_check(ctx), "respect_user_autonomy")
|
||||
assert not result.upheld
|
||||
assert result.runtime_checkable
|
||||
|
||||
def test_missing_options_count_not_checkable(self) -> None:
|
||||
ctx = EthicsContext(prescribed_single_answer=True)
|
||||
result = _find(_check(ctx), "respect_user_autonomy")
|
||||
assert result.upheld
|
||||
assert not result.runtime_checkable
|
||||
|
||||
|
||||
# ---------- unknown-commitment fallback ----------
|
||||
|
||||
|
||||
class TestUnknownCommitment:
|
||||
def test_unknown_commitment_defaults_upheld_not_runtime_checkable(
|
||||
self,
|
||||
) -> None:
|
||||
check = EthicsCheck(predicates={})
|
||||
verdict = check.check(EthicsContext(), load_ethics_pack())
|
||||
assert verdict.upheld
|
||||
assert verdict.runtime_checkable_count == 0
|
||||
for r in verdict.results:
|
||||
assert r.upheld
|
||||
assert not r.runtime_checkable
|
||||
assert "no predicate registered" in r.reason
|
||||
|
||||
|
||||
# ---------- custom predicate registration ----------
|
||||
|
||||
|
||||
class TestCustomPredicateRegistration:
|
||||
def test_register_custom_predicate(self) -> None:
|
||||
def my_pred(ctx: EthicsContext) -> EthicsCheckResult:
|
||||
return EthicsCheckResult(
|
||||
commitment_id="domain_specific_pledge",
|
||||
upheld=False,
|
||||
reason="custom predicate violation for test",
|
||||
runtime_checkable=True,
|
||||
)
|
||||
check = EthicsCheck()
|
||||
check.register("domain_specific_pledge", my_pred)
|
||||
custom = EthicsPack(
|
||||
pack_id="custom_test",
|
||||
version="1.0.0",
|
||||
description="test",
|
||||
domain="custom",
|
||||
commitment_ids=frozenset({"domain_specific_pledge"}),
|
||||
commitment_descriptions={"domain_specific_pledge": "test"},
|
||||
mastery_report_sha256="",
|
||||
ratified=False,
|
||||
)
|
||||
verdict = check.check(EthicsContext(), custom)
|
||||
assert not verdict.upheld
|
||||
assert "domain_specific_pledge" in verdict.violated_commitments
|
||||
|
||||
def test_misreporting_predicate_id_is_rebound(self) -> None:
|
||||
def lying_pred(ctx: EthicsContext) -> EthicsCheckResult:
|
||||
return EthicsCheckResult(
|
||||
commitment_id="WRONG",
|
||||
upheld=True,
|
||||
reason="defensive test",
|
||||
runtime_checkable=False,
|
||||
)
|
||||
check = EthicsCheck()
|
||||
check.register("no_manipulation", lying_pred)
|
||||
verdict = check.check(EthicsContext(), load_ethics_pack())
|
||||
result = _find(verdict, "no_manipulation")
|
||||
assert result.commitment_id == "no_manipulation"
|
||||
|
||||
|
||||
# ---------- verdict aggregation ----------
|
||||
|
||||
|
||||
class TestVerdictAggregation:
|
||||
def test_pack_id_recorded(self) -> None:
|
||||
verdict = EthicsCheck().check(EthicsContext(), load_ethics_pack())
|
||||
assert verdict.pack_id == "default_general_ethics_v1"
|
||||
|
||||
def test_results_in_lex_order(self) -> None:
|
||||
verdict = EthicsCheck().check(EthicsContext(), load_ethics_pack())
|
||||
ids = [r.commitment_id for r in verdict.results]
|
||||
assert ids == sorted(ids)
|
||||
|
||||
def test_empty_context_no_violations(self) -> None:
|
||||
# With an empty EthicsContext, every default predicate either
|
||||
# reports runtime_checkable=False or upholds.
|
||||
verdict = EthicsCheck().check(EthicsContext(), load_ethics_pack())
|
||||
assert verdict.upheld
|
||||
|
||||
def test_violation_aggregated_into_verdict(self) -> None:
|
||||
ctx = EthicsContext(
|
||||
alignment_score=0.2,
|
||||
hedge_threshold_soft=0.65,
|
||||
hedge_emitted=False,
|
||||
high_stakes_topic=True,
|
||||
recommended_human_review=False,
|
||||
)
|
||||
verdict = EthicsCheck().check(ctx, load_ethics_pack())
|
||||
assert not verdict.upheld
|
||||
assert "acknowledge_uncertainty" in verdict.violated_commitments
|
||||
assert (
|
||||
"defer_high_stakes_to_human_review" in verdict.violated_commitments
|
||||
)
|
||||
|
||||
|
||||
# ---------- ChatRuntime integration ----------
|
||||
|
||||
|
||||
class TestChatRuntimeIntegration:
|
||||
def test_runtime_exposes_ethics_check(self) -> None:
|
||||
rt = ChatRuntime(config=RuntimeConfig())
|
||||
assert isinstance(rt.ethics_check, EthicsCheck)
|
||||
|
||||
def test_runtime_ethics_check_evaluates_loaded_pack(self) -> None:
|
||||
rt = ChatRuntime(config=RuntimeConfig())
|
||||
verdict = rt.ethics_check.check(EthicsContext(), rt.ethics_pack)
|
||||
assert isinstance(verdict, EthicsVerdict)
|
||||
assert verdict.pack_id == rt.ethics_pack.pack_id
|
||||
assert verdict.upheld # empty ctx → no violations observed
|
||||
|
||||
|
||||
# ---------- helpers ----------
|
||||
|
||||
|
||||
def _check(ctx: EthicsContext) -> EthicsVerdict:
|
||||
return EthicsCheck().check(ctx, load_ethics_pack())
|
||||
|
||||
|
||||
def _find(verdict: EthicsVerdict, commitment_id: str) -> EthicsCheckResult:
|
||||
for r in verdict.results:
|
||||
if r.commitment_id == commitment_id:
|
||||
return r
|
||||
raise AssertionError(f"commitment {commitment_id!r} not in verdict")
|
||||
|
||||
|
||||
# Suppress an unused-import lint warning in environments where pytest
|
||||
# decorators aren't applied — the import stays useful for typing.
|
||||
_ = pytest
|
||||
Loading…
Reference in a new issue