core/tests/test_ethics_packs.py
Shay dab7b9c061 feat(adr-0033): ethics packs — third pack-layer sibling to identity + safety
Completes the three-layer pack architecture:
  identity (who CORE is)  + safety (universal red lines)
                          + ethics (deployment-specific propositional commitments)

  manifold.boundary_ids = identity.boundary_ids
                        ∪ safety.boundary_ids
                        ∪ ethics.commitment_ids

Ethics packs are swappable like identity (fall back to default on load
failure) but propositional like safety (commitment ids union into the
manifold).  EthicsPackError inherits from ValueError; only when both
the requested and default packs fail does startup refuse.

Ships default_general_ethics_v1 with five commitments:
  - acknowledge_uncertainty
  - defer_high_stakes_to_human_review
  - disclose_limitations
  - no_manipulation
  - respect_user_autonomy

Ratified through identity_anchor template at SHA 81fc9b61c828….

Test coverage: 20 new tests; combined identity/safety/ethics surface
suite is 81 tests, all green.  Cognition (121), teaching (17), runtime
(19), smoke (67), and cognition eval all unaffected.
2026-05-17 20:41:04 -07:00

278 lines
9.4 KiB
Python

"""ADR-0033 — ethics-pack loader, ratification, and composition."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from chat.runtime import ChatRuntime
from core.config import DEFAULT_ETHICS_PACK, RuntimeConfig
from packs.ethics.loader import (
EthicsPack,
EthicsPackError,
available_packs,
load_ethics_pack,
)
ETHICS_DIR = Path(__file__).resolve().parents[1] / "packs" / "ethics"
DEFAULT_PACK_PATH = ETHICS_DIR / f"{DEFAULT_ETHICS_PACK}.json"
# ---------- loader basics ----------
class TestLoaderHappyPath:
def test_loads_default_pack(self) -> None:
pack = load_ethics_pack()
assert isinstance(pack, EthicsPack)
assert pack.pack_id == DEFAULT_ETHICS_PACK
assert pack.domain == "general"
assert pack.commitment_ids
assert pack.ratified
def test_default_pack_has_expected_commitments(self) -> None:
pack = load_ethics_pack()
assert "acknowledge_uncertainty" in pack.commitment_ids
assert "no_manipulation" in pack.commitment_ids
assert "respect_user_autonomy" in pack.commitment_ids
# ---------- bounds checks ----------
class TestLoaderBounds:
def test_missing_pack_raises(self) -> None:
with pytest.raises(EthicsPackError, match="not found"):
load_ethics_pack("nonexistent_pack_v999")
def test_invalid_pack_id_rejected(self, tmp_path: Path) -> None:
with pytest.raises(EthicsPackError, match="invalid"):
load_ethics_pack("../escape", search_paths=[tmp_path])
def test_pack_id_mismatch_rejected(self, tmp_path: Path) -> None:
bad = tmp_path / "real_id.json"
bad.write_text(
json.dumps(
{
"pack_id": "DIFFERENT",
"version": "1.0.0",
"description": "x",
"schema_version": "1.0.0",
"domain": "general",
"commitment_ids": ["a"],
"commitment_descriptions": {"a": "x"},
}
)
)
with pytest.raises(EthicsPackError, match="declares pack_id"):
load_ethics_pack(
"real_id", search_paths=[tmp_path], require_ratified=False,
)
def test_empty_commitments_rejected(self, tmp_path: Path) -> None:
bad = tmp_path / "bad.json"
bad.write_text(
json.dumps(
{
"pack_id": "bad",
"version": "1.0.0",
"description": "x",
"schema_version": "1.0.0",
"domain": "general",
"commitment_ids": [],
"commitment_descriptions": {},
}
)
)
with pytest.raises(EthicsPackError, match="non-empty list"):
load_ethics_pack(
"bad", search_paths=[tmp_path], require_ratified=False,
)
def test_duplicate_commitment_rejected(self, tmp_path: Path) -> None:
bad = tmp_path / "dup.json"
bad.write_text(
json.dumps(
{
"pack_id": "dup",
"version": "1.0.0",
"description": "x",
"schema_version": "1.0.0",
"domain": "general",
"commitment_ids": ["a", "a"],
"commitment_descriptions": {"a": "x"},
}
)
)
with pytest.raises(EthicsPackError, match="duplicate"):
load_ethics_pack(
"dup", search_paths=[tmp_path], require_ratified=False,
)
def test_unknown_domain_rejected(self, tmp_path: Path) -> None:
bad = tmp_path / "weird.json"
bad.write_text(
json.dumps(
{
"pack_id": "weird",
"version": "1.0.0",
"description": "x",
"schema_version": "1.0.0",
"domain": "occult",
"commitment_ids": ["a"],
"commitment_descriptions": {"a": "x"},
}
)
)
with pytest.raises(EthicsPackError, match="domain"):
load_ethics_pack(
"weird", search_paths=[tmp_path], require_ratified=False,
)
def test_unsupported_schema_version_rejected(self, tmp_path: Path) -> None:
bad = tmp_path / "future.json"
bad.write_text(
json.dumps(
{
"pack_id": "future",
"version": "1.0.0",
"description": "x",
"schema_version": "2.0.0",
"domain": "general",
"commitment_ids": ["a"],
"commitment_descriptions": {"a": "x"},
}
)
)
with pytest.raises(EthicsPackError, match="schema_version"):
load_ethics_pack(
"future", search_paths=[tmp_path], require_ratified=False,
)
# ---------- ratification ----------
class TestRatification:
def test_unratified_pack_rejected_in_production_mode(
self, tmp_path: Path,
) -> None:
bad = tmp_path / "unratified.json"
bad.write_text(
json.dumps(
{
"pack_id": "unratified",
"version": "1.0.0",
"description": "x",
"schema_version": "1.0.0",
"domain": "general",
"mastery_report_sha256": "",
"commitment_ids": ["a"],
"commitment_descriptions": {"a": "x"},
}
)
)
with pytest.raises(EthicsPackError, match="not ratified"):
load_ethics_pack(
"unratified",
search_paths=[tmp_path],
require_ratified=True,
)
def test_unratified_accepted_when_explicitly_overridden(
self, tmp_path: Path,
) -> None:
bad = tmp_path / "ok.json"
bad.write_text(
json.dumps(
{
"pack_id": "ok",
"version": "1.0.0",
"description": "x",
"schema_version": "1.0.0",
"domain": "general",
"commitment_ids": ["a"],
"commitment_descriptions": {"a": "x"},
}
)
)
pack = load_ethics_pack(
"ok", search_paths=[tmp_path], require_ratified=False,
)
assert pack.pack_id == "ok"
assert not pack.ratified
def test_default_pack_is_ratified(self) -> None:
pack = load_ethics_pack()
assert pack.ratified
assert len(pack.mastery_report_sha256) == 64
# ---------- discovery ----------
class TestAvailablePacks:
def test_default_pack_discovered(self) -> None:
listing = available_packs()
ids = [p["pack_id"] for p in listing]
assert DEFAULT_ETHICS_PACK in ids
def test_mastery_report_not_listed(self) -> None:
listing = available_packs()
for p in listing:
assert not str(p["pack_id"]).endswith(".mastery_report")
assert not str(p["path"]).endswith(".mastery_report.json")
# ---------- ChatRuntime composition ----------
class TestChatRuntimeComposition:
def test_runtime_exposes_ethics_pack(self) -> None:
rt = ChatRuntime(config=RuntimeConfig())
assert rt.ethics_pack.pack_id == DEFAULT_ETHICS_PACK
assert rt.ethics_pack_id == DEFAULT_ETHICS_PACK
def test_commitments_union_into_manifold_boundaries(self) -> None:
rt = ChatRuntime(config=RuntimeConfig())
# Every commitment id should be present in the composed manifold.
for c in rt.ethics_pack.commitment_ids:
assert c in rt.identity_manifold.boundary_ids
def test_safety_boundaries_still_present_after_ethics_union(self) -> None:
rt = ChatRuntime(config=RuntimeConfig())
for b in rt.safety_pack.boundary_ids:
assert b in rt.identity_manifold.boundary_ids
def test_ethics_commitments_disjoint_from_safety_boundaries(self) -> None:
# Sanity: ethics commitments and safety boundaries should not
# share ids in v1. Same id from two layers is legal (union) but
# would indicate a naming collision worth surfacing.
rt = ChatRuntime(config=RuntimeConfig())
overlap = rt.ethics_pack.commitment_ids & rt.safety_pack.boundary_ids
assert overlap == frozenset()
def test_bad_ethics_pack_id_falls_back_to_default(self) -> None:
rt = ChatRuntime(
config=RuntimeConfig(ethics_pack="this_pack_does_not_exist_v1"),
)
assert rt.ethics_pack_id == DEFAULT_ETHICS_PACK
def test_composition_under_each_identity_pack(self) -> None:
for identity_pack in (
"default_general_v1",
"precision_first_v1",
"generosity_first_v1",
):
rt = ChatRuntime(
config=RuntimeConfig(identity_pack=identity_pack),
)
for c in rt.ethics_pack.commitment_ids:
assert c in rt.identity_manifold.boundary_ids, (
f"commitment {c!r} missing under identity={identity_pack}"
)
for b in rt.safety_pack.boundary_ids:
assert b in rt.identity_manifold.boundary_ids