diff --git a/chat/runtime.py b/chat/runtime.py index 199e303b..14f40985 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -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 diff --git a/docs/decisions/ADR-0034-ethics-check-surface.md b/docs/decisions/ADR-0034-ethics-check-surface.md new file mode 100644 index 00000000..5769f871 --- /dev/null +++ b/docs/decisions/ADR-0034-ethics-check-surface.md @@ -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. diff --git a/docs/ethics_packs.md b/docs/ethics_packs.md index eaebddca..07786481 100644 --- a/docs/ethics_packs.md +++ b/docs/ethics_packs.md @@ -160,20 +160,68 @@ final_boundary_ids = ( | Removing a commitment | Major + new pack ID (`_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 ` 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`. diff --git a/packs/ethics/__init__.py b/packs/ethics/__init__.py index 63b33161..e94c619f 100644 --- a/packs/ethics/__init__.py +++ b/packs/ethics/__init__.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", ] diff --git a/packs/ethics/check.py b/packs/ethics/check.py new file mode 100644 index 00000000..00aaafe3 --- /dev/null +++ b/packs/ethics/check.py @@ -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", +] diff --git a/tests/test_ethics_check.py b/tests/test_ethics_check.py new file mode 100644 index 00000000..90fb8331 --- /dev/null +++ b/tests/test_ethics_check.py @@ -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