core/core/demos/contract.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

228 lines
8 KiB
Python

"""ADR-0098 Demo Composition Contract — protocol + dataclasses.
The contract has three structural rules. Each is enforced by code in
this module or in the demo_composition lane, not by trust:
1. **Deterministic JSON.** Two runs with identical inputs and seed
produce byte-identical ``json_path`` contents. Verified by the
demo_composition lane's per-adapter byte-equality cases and by the
:func:`canonical_json` helper, which is the only sanctioned
serializer for adapter output.
2. **No global state mutation.** An adapter may not mutate process-
global registries (runtime singletons, module-level telemetry
sinks, ``os.environ``) across its :meth:`DemoCommand.run` call.
:func:`verify_no_global_state_mutation` snapshots a load-bearing
subset of process state before and after a run; the lane fails
when an adapter's snapshot changes.
3. **Declared output paths only.** Adapters write only under the
``output_dir`` they were handed. The path is sanitized via the
existing ``core._safe_display.safe_pack_id`` discipline (ADR-0051).
Composability is read-only: a composing demo (the showcase under
ADR-0099) may read another adapter's :class:`DemoResult` but never
mutates it.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Mapping, Protocol, runtime_checkable
CLAIM_CONTRACT_VERSION: int = 1
class DemoContractError(ValueError):
"""Raised when an ADR-0098 contract rule is violated."""
@dataclass(frozen=True, slots=True)
class Claim:
"""One falsifiable claim a demo asserts and supplies evidence for."""
claim_id: str
statement: str
supported: bool
evidence_locator: str
def __post_init__(self) -> None:
if not self.claim_id.strip():
raise DemoContractError("Claim.claim_id must be non-empty")
if not self.statement.strip():
raise DemoContractError("Claim.statement must be non-empty")
if not self.evidence_locator.strip():
raise DemoContractError("Claim.evidence_locator must be non-empty")
def as_dict(self) -> dict[str, Any]:
return {
"claim_id": self.claim_id,
"statement": self.statement,
"supported": self.supported,
"evidence_locator": self.evidence_locator,
}
@dataclass(frozen=True, slots=True)
class DemoResult:
"""Typed result returned by every :class:`DemoCommand`.
``json_path`` is the on-disk artifact whose bytes are pinned by
the byte-equality invariant. ``evidence`` maps each claim id to a
short evidence locator (path or hash) that the composing demo can
use without reading the underlying demo's internals.
``trace_features`` exposes lightweight canonical evidence (e.g.
``trace_hash``, ``grounding_source``) the showcase can spot-check
when composing claims across demos.
"""
demo_id: str
claim_contract_version: int
claims: tuple[Claim, ...]
evidence: Mapping[str, str]
all_claims_supported: bool
json_path: Path
trace_features: Mapping[str, str] = field(default_factory=dict)
def __post_init__(self) -> None:
if not self.demo_id.strip():
raise DemoContractError("DemoResult.demo_id must be non-empty")
if self.claim_contract_version != CLAIM_CONTRACT_VERSION:
raise DemoContractError(
f"DemoResult.claim_contract_version must be {CLAIM_CONTRACT_VERSION}; "
f"got {self.claim_contract_version!r}"
)
claim_ids = [c.claim_id for c in self.claims]
if len(set(claim_ids)) != len(claim_ids):
raise DemoContractError(
f"DemoResult.claims contains duplicate claim_id: {claim_ids}"
)
missing_evidence = [
c.claim_id for c in self.claims if c.claim_id not in self.evidence
]
if missing_evidence:
raise DemoContractError(
"DemoResult.evidence missing locator for claim_id(s): "
f"{missing_evidence}"
)
def as_dict(self) -> dict[str, Any]:
return {
"demo_id": self.demo_id,
"claim_contract_version": self.claim_contract_version,
"claims": [c.as_dict() for c in self.claims],
"evidence": dict(sorted(self.evidence.items())),
"all_claims_supported": self.all_claims_supported,
"json_path": str(self.json_path),
"trace_features": dict(sorted(self.trace_features.items())),
}
@runtime_checkable
class DemoCommand(Protocol):
"""Composable demo protocol.
Implementations declare a stable ``demo_id`` and the contract
version they target, then expose a ``run`` method that produces a
:class:`DemoResult`. The showcase composer (ADR-0099) consumes
that protocol without depending on any adapter's internals.
"""
demo_id: str
claim_contract_version: int
def run(
self, *, output_dir: Path, seed: int | None = None
) -> DemoResult: ...
def canonical_json(payload: dict[str, Any]) -> bytes:
"""Single sanctioned serializer for adapter JSON output.
Sorted keys, two-space indent, trailing newline. Any adapter that
rolls its own serializer risks violating the
:data:`demo_json_byte_equality` invariant on platforms that disagree
on default formatting; routing through this helper avoids that.
"""
return json.dumps(payload, sort_keys=True, indent=2).encode("utf-8") + b"\n"
# ---------------------------------------------------------------------------
# Global-state-mutation detector (ADR-0098 invariant #2)
# ---------------------------------------------------------------------------
_TRACKED_MODULES: tuple[str, ...] = (
"chat.telemetry",
"chat.runtime",
"language_packs.compiler",
)
def _global_state_snapshot() -> dict[str, Any]:
"""Capture a load-bearing subset of process state for diff checking.
The set is deliberately narrow: only state we expect demos to
leave untouched. Capturing all of ``sys.modules`` would produce
too many false positives from lazy imports.
"""
snapshot: dict[str, Any] = {
"env_subset": tuple(
sorted(
(k, os.environ[k])
for k in os.environ
if k.startswith("CORE_") or k == "PYTHONHASHSEED"
)
),
}
import sys
for mod_name in _TRACKED_MODULES:
module = sys.modules.get(mod_name)
if module is None:
snapshot[mod_name] = None
continue
# Capture id() of module-level singletons we know demos must
# not swap. Identity is sufficient because rebinding implies
# mutation of the module's namespace.
snapshot[mod_name] = id(module)
return snapshot
def verify_no_global_state_mutation(
*, before: dict[str, Any], after: dict[str, Any]
) -> tuple[bool, tuple[str, ...]]:
"""Compare two state snapshots; return ``(passed, divergences)``.
Adapters that follow the protocol leave every snapshot key
unchanged across their :meth:`DemoCommand.run` call. Lazy imports
(``None`` → module id transitions) are *not* contract violations:
importing a previously-unloaded module is benign and unavoidable
when the adapter does its own deferred imports. Only id → id
rebindings (the module object was replaced) and value-set
divergences on env vars are flagged.
"""
divergences: list[str] = []
for key in set(before.keys()) | set(after.keys()):
b = before.get(key)
a = after.get(key)
if b == a:
continue
if b is None and a is not None:
# Lazy import: a module that wasn't yet loaded is now
# loaded. Benign and unavoidable.
continue
divergences.append(
f"{key}: before={b!r} after={a!r}"
)
return (not divergences, tuple(divergences))
def capture_state() -> dict[str, Any]:
"""Public alias for the snapshot helper, for test fixtures."""
return _global_state_snapshot()