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:
Shay 2026-05-17 20:46:34 -07:00
parent dab7b9c061
commit db5bc028f9
6 changed files with 905 additions and 2 deletions

View file

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

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

View file

@ -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`.

View file

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