core/tests/test_demo_composition.py
Shay 4f640af40d feat(demos): implement ADR-0098 — Demo Composition Contract
DemoCommand Protocol + thin adapters retrofit shipped tours to a
typed composition contract. Composability becomes a structural
property: the ADR-0099 showcase will consume DemoResult through one
stable type rather than special-casing each tour. No demo behavior
changes — adapters wrap underlying run_tour() entry points.

- new core/demos/ package:
  - contract.py: frozen Claim / DemoResult dataclasses, runtime-checkable
    DemoCommand Protocol, canonical_json() sanctioned serializer
    (sorted keys, 2-space indent, trailing newline), CLAIM_CONTRACT_VERSION
  - audit_tour_adapter.py: AuditTourDemo (5 claims from ADR-0042 scenes
    1-4: identity_pack_swaps_visible, safety_typed_refusal,
    ethics_opt_in_deployment_fires, ethics_default_silent,
    replay_byte_identical)
  - tour_adapters.py: shared pattern for register/anchor-lens/orthogonality
    tours; _extract_claims walks the dict tree for *_supported booleans
    and builds Claim objects in deterministic sorted order

- global-state-mutation detector (ADR-0098 invariant #2):
  capture_state() snapshots a load-bearing subset of process state
  (CORE_* env vars + module identities for chat.telemetry,
  chat.runtime, language_packs.compiler);
  verify_no_global_state_mutation() ignores None→id transitions
  (benign lazy import) and only flags env-var changes or module
  identity rebindings

- new evals/demo_composition/ lane (ADR-0098 invariant proving):
  - 6 cases asserting byte-equality + no-state-mutation across the
    three fast adapters (audit-tour, register-tour, orthogonality-tour)
  - composition_read_only: confirms two adapter results compose into
    a composite claim set without mutating either
  - stateful_fixture_rejected: negative control — a deliberately
    stateful adapter MUST trigger divergence detection
  - anchor-lens-tour adapter is exercised by tests, not the lane,
    to keep wall time bounded
  - byte-identical across runs (sha256 27d838241bf3..)

- 26 unit tests covering Claim/DemoResult validation, canonical_json
  determinism, state-mutation detector (including the lazy-import
  benign case), Protocol conformance (isinstance check + claim
  contract version) for all four adapters, seed-rejection per
  adapter (all current adapters are fully deterministic), and an
  audit-tour integration smoke verifying 5 claims + byte-equality +
  no state mutation across two consecutive runs

- smoke 67/67, cognition eval byte-identical 100/100/100/100, all
  five lanes byte-identical (reviewer_registry 681a2aab..,
  miner_loop_closure 9f071733.., domain_contract_validation f9c06cde..,
  fabrication_control summary 01e1b6b7.., demo_composition 27d83824..)
2026-05-21 19:02:29 -07:00

258 lines
8.9 KiB
Python

"""ADR-0098 Demo Composition Contract — unit tests."""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from core.demos import (
CLAIM_CONTRACT_VERSION,
Claim,
DemoCommand,
DemoContractError,
DemoResult,
canonical_json,
verify_no_global_state_mutation,
)
from core.demos.audit_tour_adapter import AuditTourDemo
from core.demos.contract import capture_state
from core.demos.tour_adapters import (
AnchorLensTourDemo,
OrthogonalityTourDemo,
RegisterTourDemo,
)
# ---------------------------------------------------------------------------
# Schema validation
# ---------------------------------------------------------------------------
class TestClaim:
def test_well_formed_claim(self) -> None:
c = Claim(
claim_id="c1",
statement="x is y",
supported=True,
evidence_locator="report.json",
)
assert c.as_dict() == {
"claim_id": "c1",
"statement": "x is y",
"supported": True,
"evidence_locator": "report.json",
}
@pytest.mark.parametrize(
"field, value",
[
("claim_id", ""),
("statement", " "),
("evidence_locator", ""),
],
)
def test_empty_required_field_rejected(self, field: str, value: str) -> None:
kwargs = dict(
claim_id="c1",
statement="x is y",
supported=True,
evidence_locator="locator",
)
kwargs[field] = value
with pytest.raises(DemoContractError):
Claim(**kwargs)
def test_frozen(self) -> None:
c = Claim(
claim_id="c1",
statement="x",
supported=True,
evidence_locator="y",
)
with pytest.raises((AttributeError, TypeError)):
c.supported = False # type: ignore[misc]
class TestDemoResult:
def _make_claims(self) -> tuple[Claim, ...]:
return (
Claim(claim_id="c1", statement="s1", supported=True, evidence_locator="e1"),
Claim(claim_id="c2", statement="s2", supported=True, evidence_locator="e2"),
)
def test_well_formed_result(self, tmp_path: Path) -> None:
claims = self._make_claims()
result = DemoResult(
demo_id="d1",
claim_contract_version=CLAIM_CONTRACT_VERSION,
claims=claims,
evidence={"c1": "x", "c2": "y"},
all_claims_supported=True,
json_path=tmp_path / "d1.json",
)
assert result.all_claims_supported is True
assert result.demo_id == "d1"
def test_duplicate_claim_id_rejected(self, tmp_path: Path) -> None:
dup = (
Claim(claim_id="c1", statement="s", supported=True, evidence_locator="e"),
Claim(claim_id="c1", statement="s", supported=True, evidence_locator="e"),
)
with pytest.raises(DemoContractError, match="duplicate claim_id"):
DemoResult(
demo_id="d1",
claim_contract_version=CLAIM_CONTRACT_VERSION,
claims=dup,
evidence={"c1": "x"},
all_claims_supported=True,
json_path=tmp_path / "d1.json",
)
def test_wrong_contract_version_rejected(self, tmp_path: Path) -> None:
with pytest.raises(DemoContractError, match="claim_contract_version"):
DemoResult(
demo_id="d1",
claim_contract_version=2,
claims=(),
evidence={},
all_claims_supported=True,
json_path=tmp_path / "d1.json",
)
def test_missing_evidence_rejected(self, tmp_path: Path) -> None:
with pytest.raises(DemoContractError, match="evidence missing"):
DemoResult(
demo_id="d1",
claim_contract_version=CLAIM_CONTRACT_VERSION,
claims=self._make_claims(),
evidence={"c1": "x"}, # missing c2
all_claims_supported=True,
json_path=tmp_path / "d1.json",
)
# ---------------------------------------------------------------------------
# Canonical JSON serializer
# ---------------------------------------------------------------------------
class TestCanonicalJson:
def test_deterministic_across_calls(self) -> None:
payload = {"b": 1, "a": [1, 2, 3], "c": {"y": 0, "x": 1}}
assert canonical_json(payload) == canonical_json(payload)
def test_keys_sorted(self) -> None:
out = canonical_json({"z": 1, "a": 2}).decode()
assert out.index('"a"') < out.index('"z"')
def test_trailing_newline(self) -> None:
assert canonical_json({"x": 1}).endswith(b"\n")
# ---------------------------------------------------------------------------
# Global-state-mutation detector
# ---------------------------------------------------------------------------
class TestGlobalStateDetector:
def test_identical_snapshots_pass(self) -> None:
s = capture_state()
passed, divergences = verify_no_global_state_mutation(before=s, after=s)
assert passed is True
assert divergences == ()
def test_env_var_addition_flagged(self) -> None:
import os
os.environ.pop("CORE_DETECTOR_TEST_FLAG", None)
before = capture_state()
os.environ["CORE_DETECTOR_TEST_FLAG"] = "1"
after = capture_state()
os.environ.pop("CORE_DETECTOR_TEST_FLAG", None)
passed, divergences = verify_no_global_state_mutation(before=before, after=after)
assert passed is False
assert any("env_subset" in d for d in divergences)
def test_lazy_import_not_flagged(self) -> None:
"""None → module id transition is benign (lazy import)."""
before = {"chat.runtime": None, "env_subset": ()}
after = {"chat.runtime": 12345, "env_subset": ()}
passed, divergences = verify_no_global_state_mutation(before=before, after=after)
assert passed is True
assert divergences == ()
def test_id_to_id_rebind_flagged(self) -> None:
"""id → different id is a real rebinding."""
before = {"chat.runtime": 100, "env_subset": ()}
after = {"chat.runtime": 200, "env_subset": ()}
passed, divergences = verify_no_global_state_mutation(before=before, after=after)
assert passed is False
assert any("chat.runtime" in d for d in divergences)
# ---------------------------------------------------------------------------
# Protocol conformance
# ---------------------------------------------------------------------------
class TestProtocolConformance:
@pytest.mark.parametrize(
"adapter_factory",
[
AuditTourDemo,
RegisterTourDemo,
AnchorLensTourDemo,
OrthogonalityTourDemo,
],
)
def test_adapter_is_demo_command(self, adapter_factory: Any) -> None:
adapter = adapter_factory()
assert isinstance(adapter, DemoCommand)
assert adapter.claim_contract_version == CLAIM_CONTRACT_VERSION
assert adapter.demo_id.strip()
@pytest.mark.parametrize(
"adapter_factory",
[AuditTourDemo, RegisterTourDemo, OrthogonalityTourDemo],
)
def test_seed_rejected_when_unsupported(
self, adapter_factory: Any, tmp_path: Path
) -> None:
adapter = adapter_factory()
with pytest.raises(DemoContractError, match="does not accept a seed"):
adapter.run(output_dir=tmp_path, seed=42)
# ---------------------------------------------------------------------------
# Adapter runtime behavior (fast adapters only — audit-tour)
# ---------------------------------------------------------------------------
class TestAuditTourAdapterIntegration:
"""One end-to-end run + byte-equality check on the fastest adapter."""
def test_run_produces_supported_claims(self, tmp_path: Path) -> None:
adapter = AuditTourDemo()
result = adapter.run(output_dir=tmp_path)
assert result.demo_id == "audit-tour"
assert len(result.claims) == 5
assert result.all_claims_supported is True
assert result.json_path.exists()
assert result.json_path.parent == tmp_path
def test_byte_equal_across_two_runs(self, tmp_path: Path) -> None:
adapter = AuditTourDemo()
a = adapter.run(output_dir=tmp_path / "a")
b = adapter.run(output_dir=tmp_path / "b")
assert a.json_path.read_bytes() == b.json_path.read_bytes()
def test_no_global_state_mutation(self, tmp_path: Path) -> None:
before = capture_state()
AuditTourDemo().run(output_dir=tmp_path)
after = capture_state()
passed, divergences = verify_no_global_state_mutation(
before=before, after=after
)
assert passed is True, f"unexpected divergences: {divergences}"