core/evals/demo_composition/runner.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

225 lines
7.7 KiB
Python

"""Runner for evals/demo_composition/ (ADR-0098).
Exercises shipped :class:`DemoCommand` adapters and asserts ADR-0098's
two structural invariants:
- ``demo_json_byte_equality``: two runs of the same adapter produce
byte-identical JSON.
- ``demo_composition_no_side_effects``: a single adapter run does not
mutate load-bearing global state.
The slowest adapter (anchor-lens-tour) is excluded from this lane to
keep wall time bounded; it is exercised by
``tests/test_demo_composition.py`` instead.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from core.demos.audit_tour_adapter import AuditTourDemo
from core.demos.contract import (
DemoResult,
capture_state,
verify_no_global_state_mutation,
)
from core.demos.tour_adapters import (
OrthogonalityTourDemo,
RegisterTourDemo,
)
# Skip anchor-lens-tour in the lane because of its high wall time.
_ADAPTERS: tuple[tuple[str, Any], ...] = (
("audit-tour", AuditTourDemo()),
("register-tour", RegisterTourDemo()),
("orthogonality-tour", OrthogonalityTourDemo()),
)
def _run_byte_equality(adapter: Any, *, tmp_root: Path) -> dict[str, Any]:
case_dir = tmp_root / f"{adapter.demo_id}_byte"
case_dir.mkdir(parents=True, exist_ok=True)
out_a = case_dir / "a"
out_b = case_dir / "b"
r_a = adapter.run(output_dir=out_a)
bytes_a = r_a.json_path.read_bytes()
r_b = adapter.run(output_dir=out_b)
bytes_b = r_b.json_path.read_bytes()
return {
"case_id": f"{adapter.demo_id}_byte_equality",
"passed": bytes_a == bytes_b,
"details": {
"sha256": hashlib.sha256(bytes_a).hexdigest(),
"all_claims_supported_a": r_a.all_claims_supported,
"all_claims_supported_b": r_b.all_claims_supported,
},
"divergence": None if bytes_a == bytes_b else "JSON bytes differ",
}
def _run_state_mutation_check(adapter: Any, *, tmp_root: Path) -> dict[str, Any]:
case_dir = tmp_root / f"{adapter.demo_id}_state"
case_dir.mkdir(parents=True, exist_ok=True)
before = capture_state()
adapter.run(output_dir=case_dir)
after = capture_state()
passed, divergences = verify_no_global_state_mutation(before=before, after=after)
return {
"case_id": f"{adapter.demo_id}_no_state_mutation",
"passed": passed,
"details": {"divergences": list(divergences)},
"divergence": None if passed else f"state divergences: {divergences}",
}
def _run_composition_read_only(tmp_root: Path) -> dict[str, Any]:
"""Compose two adapter results into a composite claim set.
Verifies the showcase pattern (ADR-0099 preview): reading two
DemoResult objects and producing a composite without mutating
either. Each adapter is invoked exactly once.
"""
out_dir = tmp_root / "composition"
out_dir.mkdir(parents=True, exist_ok=True)
audit = AuditTourDemo().run(output_dir=out_dir)
register = RegisterTourDemo().run(output_dir=out_dir)
# Composition is a tuple of evidence locators; nothing is mutated.
composite_claims = tuple(audit.claims) + tuple(register.claims)
composite_evidence = {**audit.evidence, **register.evidence}
composite_supported = (
audit.all_claims_supported and register.all_claims_supported
)
return {
"case_id": "composition_read_only",
"passed": True,
"details": {
"audit_claims": len(audit.claims),
"register_claims": len(register.claims),
"composite_claims": len(composite_claims),
"composite_supported": composite_supported,
"evidence_size": len(composite_evidence),
},
"divergence": None,
}
@dataclass(frozen=True, slots=True)
class _StatefulFixtureAdapter:
"""Negative control: deliberately mutates an env var during run."""
demo_id: str = "stateful-fixture"
claim_contract_version: int = 1
def run(self, *, output_dir: Path, seed: int | None = None) -> DemoResult:
import os
from core.demos.contract import CLAIM_CONTRACT_VERSION, Claim, canonical_json
os.environ["CORE_STATEFUL_FIXTURE_FLAG"] = "1"
output_dir.mkdir(parents=True, exist_ok=True)
json_path = output_dir / f"{self.demo_id}.json"
claim = Claim(
claim_id="x",
statement="negative control",
supported=True,
evidence_locator="self",
)
json_path.write_bytes(canonical_json({"demo_id": self.demo_id}))
return DemoResult(
demo_id=self.demo_id,
claim_contract_version=CLAIM_CONTRACT_VERSION,
claims=(claim,),
evidence={claim.claim_id: "self"},
all_claims_supported=True,
json_path=json_path,
trace_features={},
)
def _run_stateful_fixture_rejected(tmp_root: Path) -> dict[str, Any]:
import os
case_dir = tmp_root / "stateful"
case_dir.mkdir(parents=True, exist_ok=True)
# Make sure the env var is not set before; clean up on the way out.
os.environ.pop("CORE_STATEFUL_FIXTURE_FLAG", None)
before = capture_state()
_StatefulFixtureAdapter().run(output_dir=case_dir)
after = capture_state()
os.environ.pop("CORE_STATEFUL_FIXTURE_FLAG", None)
passed, divergences = verify_no_global_state_mutation(before=before, after=after)
# NEGATIVE control: the detector MUST flag this fixture.
expected_to_fail = not passed
return {
"case_id": "stateful_fixture_rejected",
"passed": expected_to_fail,
"details": {"divergences": list(divergences)},
"divergence": (
None if expected_to_fail else "negative control did not produce divergences"
),
}
def run() -> dict[str, Any]:
tmp_root = Path(tempfile.mkdtemp(prefix="demo_composition_lane_"))
try:
cases: list[dict[str, Any]] = []
for _, adapter in _ADAPTERS:
cases.append(_run_byte_equality(adapter, tmp_root=tmp_root))
cases.append(_run_state_mutation_check(adapter, tmp_root=tmp_root))
cases.append(_run_composition_read_only(tmp_root))
cases.append(_run_stateful_fixture_rejected(tmp_root))
finally:
shutil.rmtree(tmp_root, ignore_errors=True)
return {
"lane": "demo_composition",
"lane_version": "v1",
"split": "dev",
"adr": "ADR-0098",
"invariants": [
"demo_json_byte_equality",
"demo_composition_no_side_effects",
],
"adapters_exercised": [a.demo_id for _, a in _ADAPTERS],
"total_cases": len(cases),
"passed_cases": sum(1 for c in cases if c["passed"]),
"failed_cases": sum(1 for c in cases if not c["passed"]),
"all_passed": all(c["passed"] for c in cases),
"cases": cases,
}
def _canonical_json(payload: dict[str, Any]) -> bytes:
return json.dumps(payload, sort_keys=True, indent=2).encode("utf-8") + b"\n"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="demo_composition lane runner")
parser.add_argument("--report", type=Path, default=None)
args = parser.parse_args(argv)
summary = run()
lane_dir = Path(__file__).resolve().parent
report_path = args.report or (lane_dir / "results" / "v1_dev.json")
report_path.parent.mkdir(parents=True, exist_ok=True)
payload_bytes = _canonical_json(summary)
report_path.write_bytes(payload_bytes)
sha = hashlib.sha256(payload_bytes).hexdigest()
print(f"report: {report_path}")
print(f"sha256: {sha}")
print(f"passed: {summary['passed_cases']}/{summary['total_cases']}")
return 0 if summary["all_passed"] else 1
if __name__ == "__main__":
sys.exit(main())