From 0cf54a009da4900dbe1e295bf5b4883ab5f91295 Mon Sep 17 00:00:00 2001 From: Shay Date: Wed, 20 May 2026 16:19:36 -0700 Subject: [PATCH] feat(adr-0087): rhetorical-style pack substrate (loader + default_unstyled_v1) (#74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Substrate-only code-side for ADR-0087 (Rhetorical Style as Selection Axis). No composer or realizer touches the new pack yet; consumer integration is the follow-up ADR. packs/rhetorical_style/ (new) - loader.py: RhetoricalStylePack frozen dataclass + load_rhetorical_ style_pack() with fail-closed mastery-report self-seal verification - __init__.py: re-exports (RhetoricalStylePack, RhetoricalStylePack- Error, load_rhetorical_style_pack, DEFAULT_RHETORICAL_STYLE_PACK) - default_unstyled_v1.json + .mastery_report.json: ratified null-lift baseline pack (all three constraint lists empty, default_unstyled=true) scripts/ratify_rhetorical_style_packs.py (new) - Mirror of scripts/ratify_anchor_lens_packs.py for the rhetorical- style pack family. Computes pack_source_sha256 with mastery_report_ sha256 blanked, builds self-sealed mastery report, writes both files. Idempotent. Uses formation.hashing for canonical JSON + self_seal. Schema gate (ADR-0087 §Verification) - Required keys allow-list: pack_id, schema_version, version, issued_at, default_unstyled, permitted_frames, required_moves_per_claim, forbidden_moves, provenance, mastery_report_sha256 - Unknown keys rejected (strict gate) - permitted_frames: allow-list {warrant, concession, hedge, definitional_move} - required_moves_per_claim / forbidden_moves: allow-list {claim, evidence, warrant, concession, hedge, bare_assertion, definitional} - default_unstyled=true ⟺ all three lists empty - non-default pack must declare at least one constraint (distinguishes from null-lift) - Duplicates within a list rejected Ratification gate - require_ratified=True by default - CORE_ALLOW_UNRATIFIED_RHETORICAL_STYLE=1 env-var bypass for dev - Companion mastery report SHA must match pack's declared sha - verify_seal(report) must pass (self-seal integrity) - Sister to packs.safety.SafetyPackError pattern core/config.py - Added RuntimeConfig.rhetorical_style_id: str | None = None - No runtime code reads it yet — that's the consumer ADR's job - Field declared so the interface is stable when consumer lands Tests (tests/test_adr_0087_rhetorical_style_substrate.py — 20) - Default pack loads, is_null_lift, mastery-report self-seal verified, discovery lists it as ratified - Schema gate: missing key, unknown key, unknown frame, unknown move, duplicate frame, default_unstyled-with-constraints, non-default-with-zero-constraints, pack_id mismatch, path traversal - Ratification gate: unratified pack rejected by default, env-var bypass, companion report missing, companion sha mismatch - RuntimeConfig field: default None, accepts string, independent of other axes Lanes smoke 67/0, cognition 120/0/1, packs 6/0. core eval cognition byte-identical 100/91.7/100/100. Null-lift consumer test deferred ADR-0087 §Required tests lists rhetorical_style_null_lift as a required invariant. Today it would be trivially true because no composer reads the field. The invariant becomes meaningful when the consumer ADR wires the field through the dispatch — at that point the null-lift test goes into the consumer PR alongside the three-axis orthogonality test. Scope per ADR-0087 §Scope limits - No consumer code (composer/realizer changes deferred) - No genre packs (en_academic_v1, etc. are content efforts after consumer lands) - No prompt-routing (operator-set only) --- core/config.py | 8 + packs/rhetorical_style/__init__.py | 36 ++ .../rhetorical_style/default_unstyled_v1.json | 1 + .../default_unstyled_v1.mastery_report.json | 1 + packs/rhetorical_style/loader.py | 425 ++++++++++++++++++ scripts/ratify_rhetorical_style_packs.py | 116 +++++ ...est_adr_0087_rhetorical_style_substrate.py | 331 ++++++++++++++ 7 files changed, 918 insertions(+) create mode 100644 packs/rhetorical_style/__init__.py create mode 100644 packs/rhetorical_style/default_unstyled_v1.json create mode 100644 packs/rhetorical_style/default_unstyled_v1.mastery_report.json create mode 100644 packs/rhetorical_style/loader.py create mode 100644 scripts/ratify_rhetorical_style_packs.py create mode 100644 tests/test_adr_0087_rhetorical_style_substrate.py diff --git a/core/config.py b/core/config.py index fd31dc70..2d03ca42 100644 --- a/core/config.py +++ b/core/config.py @@ -98,6 +98,14 @@ class RuntimeConfig: # chain-walk surface byte-identically (null-drop invariant). gloss_aware_cause: bool = False + # ADR-0087 — rhetorical-style selection axis (substrate phase). + # ``None`` resolves to ``DEFAULT_RHETORICAL_STYLE_PACK`` per the + # mounting discipline, which is the null-lift baseline. No + # composer or realizer reads this field yet — that wiring is the + # consumer ADR's job. The field is declared here so the runtime + # interface is stable when the consumer lands. + rhetorical_style_id: str | None = None + # ADR-0066 / P3.2 — opt-in thread anaphora. When enabled, the # runtime prepends a deterministic backreference to a recent # grounded turn when the current turn's subject lemma matches diff --git a/packs/rhetorical_style/__init__.py b/packs/rhetorical_style/__init__.py new file mode 100644 index 00000000..64a6609e --- /dev/null +++ b/packs/rhetorical_style/__init__.py @@ -0,0 +1,36 @@ +"""Rhetorical-style pack layer (ADR-0087, substrate phase). + +A *rhetorical style* is the substantive-frame axis: it constrains +which rhetorical-mode frames the realizer may emit and which +rhetorical-move requirements the composer applies. It is **not** a +motor on the field — see ADR-0087 §Forbidden alternatives. + +Sister to: + +* :mod:`packs.anchor_lens` — substantive-vocabulary axis + (semantic content / tradition). Rhetorical style is the third + selection axis: rhetorical-genre constraint, sibling to anchor lens, + orthogonal to register. +* :mod:`packs.safety` and :mod:`packs.identity` — pack-layer + composition discipline (mastery-report self-seal, fail-closed + loader). + +This is the substrate-only layer. No composer or realizer yet +consumes the pack — that wiring is the consumer ADR's job. Today the +substrate exists so the consumer ADR has something to be wired +against. +""" + +from packs.rhetorical_style.loader import ( + DEFAULT_RHETORICAL_STYLE_PACK, + RhetoricalStylePack, + RhetoricalStylePackError, + load_rhetorical_style_pack, +) + +__all__ = ( + "DEFAULT_RHETORICAL_STYLE_PACK", + "RhetoricalStylePack", + "RhetoricalStylePackError", + "load_rhetorical_style_pack", +) diff --git a/packs/rhetorical_style/default_unstyled_v1.json b/packs/rhetorical_style/default_unstyled_v1.json new file mode 100644 index 00000000..0bb8e348 --- /dev/null +++ b/packs/rhetorical_style/default_unstyled_v1.json @@ -0,0 +1 @@ +{"default_unstyled":true,"forbidden_moves":[],"issued_at":"2026-05-21T00:00:00Z","mastery_report_sha256":"103bcfc8790ad4163ad11d2d5a83d84415a337d5fd4439bbbf2f41d53b250223","pack_id":"default_unstyled_v1","permitted_frames":[],"provenance":"adr-0087:reviewed:2026-05-21","required_moves_per_claim":[],"schema_version":"1.0.0","version":1} diff --git a/packs/rhetorical_style/default_unstyled_v1.mastery_report.json b/packs/rhetorical_style/default_unstyled_v1.mastery_report.json new file mode 100644 index 00000000..b40ad479 --- /dev/null +++ b/packs/rhetorical_style/default_unstyled_v1.mastery_report.json @@ -0,0 +1 @@ +{"evidence":{"default_unstyled":true,"forbidden_moves":[],"permitted_frames":[],"required_moves_per_claim":[],"version":1},"failure_reasons":[],"issued_at":"2026-05-21T00:00:00Z","pack_id":"default_unstyled_v1","pack_source_sha256":"e16b0a66d0b7e92c7d96913ea685c32abb561775be8cd19a13a3655a341a3e88","ratification_method":"rhetorical_style_substrate","ratified":true,"report_sha256":"103bcfc8790ad4163ad11d2d5a83d84415a337d5fd4439bbbf2f41d53b250223","schema_version":"1.0.0"} diff --git a/packs/rhetorical_style/loader.py b/packs/rhetorical_style/loader.py new file mode 100644 index 00000000..681bcf9d --- /dev/null +++ b/packs/rhetorical_style/loader.py @@ -0,0 +1,425 @@ +"""Rhetorical-style pack loader (ADR-0087 substrate phase). + +Reads a ratified rhetorical-style pack from disk and constructs a +frozen :class:`RhetoricalStylePack` for the runtime to mount. + +Pattern is the same as :mod:`packs.anchor_lens.loader`: + +* Self-sealed mastery report verified at load time + (``require_ratified=True`` by default; bypassed by + ``CORE_ALLOW_UNRATIFIED_RHETORICAL_STYLE=1`` for dev). +* Path-traversal-safe ``pack_id`` resolution. +* Allow-list schema validation — unknown keys rejected; ``permitted_frames`` + / ``required_moves_per_claim`` / ``forbidden_moves`` constrained + to known-frame / known-move vocabularies. +* ``default_unstyled: true`` only valid when all three lists are + empty (the null-lift pack). + +The substrate is composer-side only. At this phase no composer or +realizer yet consumes the pack — the loader exists so the consumer +ADR can mount real packs against a stable contract. + +ADR +--- +ADR-0087 — Rhetorical Style as Selection Axis (Pre-Work for Writing +Curriculum) +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +from formation.hashing import verify_seal + + +DEFAULT_RHETORICAL_STYLE_PACK: str = "default_unstyled_v1" + +_DEFAULT_SEARCH_PATHS: tuple[Path, ...] = ( + Path(__file__).resolve().parent, +) + +_SCHEMA_VERSION: str = "1.0.0" + +# v0 allow-lists. Deliberately small per ADR-0087 §Composer & realizer +# contract — additions require ADR amendment, not silent extension. +_ALLOWED_FRAMES: frozenset[str] = frozenset({ + "warrant", # "Therefore X, because Y." + "concession", # "While X, Y." + "hedge", # "This suggests X, though Q." + "definitional_move", # "By X we mean {gloss}." +}) +_ALLOWED_MOVES: frozenset[str] = frozenset({ + "claim", + "evidence", + "warrant", + "concession", + "hedge", + "bare_assertion", + "definitional", +}) + +_REQUIRED_KEYS: frozenset[str] = frozenset({ + "pack_id", + "schema_version", + "version", + "issued_at", + "default_unstyled", + "permitted_frames", + "required_moves_per_claim", + "forbidden_moves", + "provenance", + "mastery_report_sha256", +}) + +_MAX_LIST_LEN: int = 32 +_MAX_PROVENANCE_LEN: int = 256 +_MAX_ISSUED_AT_LEN: int = 64 + + +class RhetoricalStylePackError(RuntimeError): + """Raised when a rhetorical-style pack is missing, malformed, or + unverified. + + Inherits from :class:`RuntimeError` (not :class:`ValueError`) on the + same principle as :class:`packs.safety.SafetyPackError`: a missing + rhetorical-style pack for a deployment that opts in is a + fail-closed runtime condition, not a recoverable input-validation + error. + """ + + +def _safe_pack_id(value: object) -> str: + """Return a printable, length-capped version of a pack id.""" + s = str(value) if value is not None else "" + return s[:64] + + +@dataclass(frozen=True, slots=True) +class RhetoricalStylePack: + """Frozen rhetorical-style pack. + + The substantive content of the pack is three lists: + + * ``permitted_frames`` — the realizer's frame allow-list (the only + rhetorical-mode frames it may emit while this pack is mounted). + * ``required_moves_per_claim`` — moves the composer MUST include for + every claim it surfaces. + * ``forbidden_moves`` — moves the composer MUST refuse to ratify. + + The null-lift pack (``default_unstyled_v1``) has all three lists + empty and ``default_unstyled=True``. Loading it changes nothing. + """ + + pack_id: str + schema_version: str + version: int + issued_at: str + default_unstyled: bool + permitted_frames: tuple[str, ...] + required_moves_per_claim: tuple[str, ...] + forbidden_moves: tuple[str, ...] + provenance: str + mastery_report_sha256: str = "" + + def is_null_lift(self) -> bool: + """True iff this pack imposes no constraints at all. + + The null-lift pack is the substrate equivalent of + ``anchor_lens.AnchorLens.is_null_lens`` — structurally + equivalent to no pack being mounted. + """ + return ( + self.default_unstyled + and not self.permitted_frames + and not self.required_moves_per_claim + and not self.forbidden_moves + ) + + +def _validate_pack_id_for_fs(pack_id: object) -> None: + """Reject path-traversal / slash / empty / non-string pack ids.""" + if ( + not pack_id + or not isinstance(pack_id, str) + or "/" in pack_id + or "\\" in pack_id + or ".." in pack_id + or pack_id.startswith(".") + ): + raise RhetoricalStylePackError( + f"invalid rhetorical-style pack_id: {_safe_pack_id(pack_id)!r}" + ) + for ch in pack_id: + if not (ch.isascii() and (ch.isalnum() or ch in {"_", "-"})): + raise RhetoricalStylePackError( + f"rhetorical-style pack_id must be alphanumeric/_/-: " + f"{_safe_pack_id(pack_id)!r}" + ) + + +def _find_pack_path(pack_id: str, search_paths: Iterable[Path]) -> Path: + _validate_pack_id_for_fs(pack_id) + for directory in search_paths: + candidate = Path(directory) / f"{pack_id}.json" + if candidate.exists(): + return candidate + raise RhetoricalStylePackError( + f"rhetorical-style pack {_safe_pack_id(pack_id)!r} not found in search paths" + ) + + +def _validate_string_list( + raw: object, + *, + pack_id: str, + field_name: str, + allow_list: frozenset[str], +) -> tuple[str, ...]: + if not isinstance(raw, list): + raise RhetoricalStylePackError( + f"pack {_safe_pack_id(pack_id)!r}: {field_name!r} must be a list" + ) + if len(raw) > _MAX_LIST_LEN: + raise RhetoricalStylePackError( + f"pack {_safe_pack_id(pack_id)!r}: {field_name!r} exceeds max len {_MAX_LIST_LEN}" + ) + seen: set[str] = set() + out: list[str] = [] + for item in raw: + if not isinstance(item, str) or not item: + raise RhetoricalStylePackError( + f"pack {_safe_pack_id(pack_id)!r}: {field_name!r} must contain non-empty strings" + ) + if item not in allow_list: + raise RhetoricalStylePackError( + f"pack {_safe_pack_id(pack_id)!r}: {field_name!r} contains " + f"unknown value {item!r} (allowed: {sorted(allow_list)})" + ) + if item in seen: + raise RhetoricalStylePackError( + f"pack {_safe_pack_id(pack_id)!r}: {field_name!r} contains " + f"duplicate value {item!r}" + ) + seen.add(item) + out.append(item) + return tuple(out) + + +def _validate_envelope(raw: dict, pack_id: str) -> None: + if not isinstance(raw, dict): + raise RhetoricalStylePackError( + f"pack {_safe_pack_id(pack_id)!r}: top-level payload must be a JSON object" + ) + unknown = set(raw.keys()) - _REQUIRED_KEYS + if unknown: + raise RhetoricalStylePackError( + f"pack {_safe_pack_id(pack_id)!r}: unrecognised key(s) {sorted(unknown)}; " + f"strict schema gate per ADR-0087" + ) + missing = _REQUIRED_KEYS - set(raw.keys()) + if missing: + raise RhetoricalStylePackError( + f"pack {_safe_pack_id(pack_id)!r}: missing required key(s) {sorted(missing)}" + ) + if raw["pack_id"] != pack_id: + raise RhetoricalStylePackError( + f"pack file declares pack_id={_safe_pack_id(raw['pack_id'])!r} but " + f"was requested as {_safe_pack_id(pack_id)!r}" + ) + if raw["schema_version"] != _SCHEMA_VERSION: + raise RhetoricalStylePackError( + f"pack {_safe_pack_id(pack_id)!r}: unsupported schema_version " + f"{raw['schema_version']!r} (expected {_SCHEMA_VERSION!r})" + ) + if not isinstance(raw["version"], int) or isinstance(raw["version"], bool) or raw["version"] < 1: + raise RhetoricalStylePackError( + f"pack {_safe_pack_id(pack_id)!r}: version must be a positive int" + ) + if not isinstance(raw["issued_at"], str) or not raw["issued_at"] or len(raw["issued_at"]) > _MAX_ISSUED_AT_LEN: + raise RhetoricalStylePackError( + f"pack {_safe_pack_id(pack_id)!r}: issued_at must be a non-empty string <= {_MAX_ISSUED_AT_LEN} chars" + ) + if not isinstance(raw["default_unstyled"], bool): + raise RhetoricalStylePackError( + f"pack {_safe_pack_id(pack_id)!r}: default_unstyled must be a boolean" + ) + if not isinstance(raw["provenance"], str) or not raw["provenance"] or len(raw["provenance"]) > _MAX_PROVENANCE_LEN: + raise RhetoricalStylePackError( + f"pack {_safe_pack_id(pack_id)!r}: provenance must be a non-empty string <= {_MAX_PROVENANCE_LEN} chars" + ) + if not isinstance(raw["mastery_report_sha256"], str): + raise RhetoricalStylePackError( + f"pack {_safe_pack_id(pack_id)!r}: mastery_report_sha256 must be a string" + ) + + +def _validate_unstyled_invariant( + pack_id: str, + default_unstyled: bool, + permitted_frames: tuple[str, ...], + required_moves_per_claim: tuple[str, ...], + forbidden_moves: tuple[str, ...], +) -> None: + """Enforce ADR-0087 §Verification: ``default_unstyled: true`` only + valid when all three constraint lists are empty. + """ + if default_unstyled: + if permitted_frames or required_moves_per_claim or forbidden_moves: + raise RhetoricalStylePackError( + f"pack {_safe_pack_id(pack_id)!r}: default_unstyled=true requires " + f"empty permitted_frames / required_moves_per_claim / forbidden_moves" + ) + else: + # A non-default pack that declares zero constraints is also + # rejected — it would be indistinguishable from null-lift but + # would silently miss the default_unstyled invariant test. + if not permitted_frames and not required_moves_per_claim and not forbidden_moves: + raise RhetoricalStylePackError( + f"pack {_safe_pack_id(pack_id)!r}: non-default pack must declare at " + f"least one of permitted_frames / required_moves_per_claim / forbidden_moves" + ) + + +def load_rhetorical_style_pack( + pack_id: str = DEFAULT_RHETORICAL_STYLE_PACK, + *, + search_paths: Iterable[Path | str] | None = None, + require_ratified: bool | None = None, +) -> RhetoricalStylePack: + """Load, validate, and return a frozen :class:`RhetoricalStylePack`. + + Parameters + ---------- + pack_id: + Pack identifier, e.g. ``"default_unstyled_v1"``. Defaults to + :data:`DEFAULT_RHETORICAL_STYLE_PACK`. + search_paths: + Directories to search for ``.json``. Defaults to the + directory containing this module. + require_ratified: + If ``True``, refuse packs with an empty + ``mastery_report_sha256``. If ``None`` (default), falls back + to the ``CORE_ALLOW_UNRATIFIED_RHETORICAL_STYLE`` environment + variable (refuse unless set to ``1`` / ``true`` / ``yes``). + """ + resolved_paths: list[Path] = [ + Path(p) for p in (search_paths or _DEFAULT_SEARCH_PATHS) + ] + pack_path = _find_pack_path(pack_id, resolved_paths) + try: + raw: dict = json.loads(pack_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise RhetoricalStylePackError( + f"pack {_safe_pack_id(pack_id)!r}: malformed JSON ({exc})" + ) from exc + + _validate_envelope(raw, pack_id) + + permitted_frames = _validate_string_list( + raw["permitted_frames"], pack_id=pack_id, + field_name="permitted_frames", allow_list=_ALLOWED_FRAMES, + ) + required_moves_per_claim = _validate_string_list( + raw["required_moves_per_claim"], pack_id=pack_id, + field_name="required_moves_per_claim", allow_list=_ALLOWED_MOVES, + ) + forbidden_moves = _validate_string_list( + raw["forbidden_moves"], pack_id=pack_id, + field_name="forbidden_moves", allow_list=_ALLOWED_MOVES, + ) + + _validate_unstyled_invariant( + pack_id, + bool(raw["default_unstyled"]), + permitted_frames, + required_moves_per_claim, + forbidden_moves, + ) + + if require_ratified is None: + env = os.environ.get("CORE_ALLOW_UNRATIFIED_RHETORICAL_STYLE", "").lower() + require_ratified = env not in ("1", "true", "yes") + + declared_sha = raw["mastery_report_sha256"] + if require_ratified: + if not declared_sha: + raise RhetoricalStylePackError( + f"pack {_safe_pack_id(pack_id)!r} is not ratified " + f"(mastery_report_sha256 is empty). Run " + f"scripts/ratify_rhetorical_style_packs.py or set " + f"CORE_ALLOW_UNRATIFIED_RHETORICAL_STYLE=1 for development." + ) + # Companion-SHA agreement check. + report_path = pack_path.parent / f"{pack_id}.mastery_report.json" + if not report_path.is_file(): + raise RhetoricalStylePackError( + f"pack {_safe_pack_id(pack_id)!r}: companion mastery report missing " + f"at {report_path}" + ) + try: + report = json.loads(report_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise RhetoricalStylePackError( + f"pack {_safe_pack_id(pack_id)!r}: companion mastery report " + f"unreadable: {exc}" + ) from exc + report_sha = str(report.get("report_sha256", "")) + if report_sha != declared_sha: + raise RhetoricalStylePackError( + f"pack {_safe_pack_id(pack_id)!r}: declared mastery_report_sha256 " + f"does not match companion report's report_sha256" + ) + if not verify_seal(report, sha_field="report_sha256"): + raise RhetoricalStylePackError( + f"pack {_safe_pack_id(pack_id)!r}: companion mastery report self-seal " + f"verification failed" + ) + + return RhetoricalStylePack( + pack_id=raw["pack_id"], + schema_version=raw["schema_version"], + version=raw["version"], + issued_at=raw["issued_at"], + default_unstyled=bool(raw["default_unstyled"]), + permitted_frames=permitted_frames, + required_moves_per_claim=required_moves_per_claim, + forbidden_moves=forbidden_moves, + provenance=raw["provenance"], + mastery_report_sha256=declared_sha, + ) + + +def available_rhetorical_style_packs( + search_paths: Iterable[Path | str] | None = None, +) -> list[dict]: + """Return summary dicts for all ``.json`` packs in the search paths. + + Mirror of :func:`packs.anchor_lens.loader.available_anchor_lens_packs`. + """ + resolved_paths: list[Path] = [ + Path(p) for p in (search_paths or _DEFAULT_SEARCH_PATHS) + ] + summaries: list[dict] = [] + for directory in resolved_paths: + d = Path(directory) + if not d.is_dir(): + continue + for f in sorted(d.glob("*.json")): + stem = f.stem + if stem.startswith("_") or ".mastery_report" in stem: + continue + try: + raw = json.loads(f.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + summaries.append({ + "pack_id": stem, + "ratified": bool(raw.get("mastery_report_sha256", "")), + "default_unstyled": bool(raw.get("default_unstyled", False)), + "version": raw.get("version", 0), + }) + return summaries diff --git a/scripts/ratify_rhetorical_style_packs.py b/scripts/ratify_rhetorical_style_packs.py new file mode 100644 index 00000000..28c545bd --- /dev/null +++ b/scripts/ratify_rhetorical_style_packs.py @@ -0,0 +1,116 @@ +"""Ratify rhetorical-style packs (ADR-0087 substrate phase). + +For each pack in ``packs/rhetorical_style/.json``: + +1. Compute canonical ``pack_source_sha256`` with + ``mastery_report_sha256`` blanked. +2. Build a self-sealed mastery report under the + ``rhetorical_style_substrate`` ratification method. +3. Write ``.mastery_report.json``. +4. Seal the pack with the report's SHA. + +Idempotent: re-running on an already-ratified pack rewrites both +files deterministically. Output is byte-identical when content is +byte-identical (canonical JSON discipline from +:mod:`formation.hashing`). + +Substrate-phase ratification is content-only — no L1 lexicon gate +(the ADR's consumer phase is what teaches the realizer to read the +frames; until that lands, "frame name is in the allow-list" is the +only check the substrate needs). +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +from formation.hashing import canonical_json, self_seal, sha256_of + +PACKS_DIR = Path(__file__).resolve().parents[1] / "packs" / "rhetorical_style" + +# Pack ids to ratify, in declaration order. +PACK_IDS: tuple[str, ...] = ( + # Null-lift baseline — must be first; ADR-0087 byte-identity + # invariant test pins this pack's structural equivalence to + # rhetorical_style_id=None. + "default_unstyled_v1", +) + + +def _pack_source_sha256(pack_payload: dict[str, Any]) -> str: + """SHA-256 of the pack with ``mastery_report_sha256`` blanked.""" + probe = dict(pack_payload) + probe["mastery_report_sha256"] = "" + return sha256_of(probe) + + +def _ratify_one(pack_id: str) -> tuple[bool, str]: + """Ratify a single pack. Returns (changed, message).""" + pack_path = PACKS_DIR / f"{pack_id}.json" + if not pack_path.is_file(): + return False, f"{pack_id}: pack file missing at {pack_path}" + try: + pack_payload = json.loads(pack_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + return False, f"{pack_id}: pack JSON malformed ({exc})" + + # Recompute pack_source_sha256 from the current pack content. + pack_source = _pack_source_sha256(pack_payload) + + # Build the mastery report. + report: dict[str, Any] = { + "pack_id": pack_id, + "schema_version": pack_payload.get("schema_version", "1.0.0"), + "ratification_method": "rhetorical_style_substrate", + "ratified": True, + "issued_at": pack_payload.get("issued_at", ""), + "pack_source_sha256": pack_source, + "failure_reasons": [], + "evidence": { + "default_unstyled": pack_payload.get("default_unstyled", False), + "permitted_frames": pack_payload.get("permitted_frames", []), + "required_moves_per_claim": pack_payload.get("required_moves_per_claim", []), + "forbidden_moves": pack_payload.get("forbidden_moves", []), + "version": pack_payload.get("version", 0), + }, + "report_sha256": "", + } + sealed_report = self_seal(report, sha_field="report_sha256") + report_sha = sealed_report["report_sha256"] + + report_path = PACKS_DIR / f"{pack_id}.mastery_report.json" + report_bytes = canonical_json(sealed_report) + b"\n" + + # Update the pack's mastery_report_sha256 and re-write. + pack_payload["mastery_report_sha256"] = report_sha + pack_bytes = canonical_json(pack_payload) + b"\n" + + existing_pack_bytes = pack_path.read_bytes() if pack_path.is_file() else b"" + existing_report_bytes = report_path.read_bytes() if report_path.is_file() else b"" + + changed = ( + existing_pack_bytes != pack_bytes + or existing_report_bytes != report_bytes + ) + if changed: + report_path.write_bytes(report_bytes) + pack_path.write_bytes(pack_bytes) + return True, f"{pack_id}: ratified (report_sha={report_sha[:12]}..)" + return False, f"{pack_id}: already ratified (report_sha={report_sha[:12]}..)" + + +def main(argv: list[str]) -> int: + any_changed = False + for pack_id in PACK_IDS: + changed, msg = _ratify_one(pack_id) + print(msg) + if changed: + any_changed = True + return 0 if not any_changed or "--check" not in argv else 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/test_adr_0087_rhetorical_style_substrate.py b/tests/test_adr_0087_rhetorical_style_substrate.py new file mode 100644 index 00000000..84b124be --- /dev/null +++ b/tests/test_adr_0087_rhetorical_style_substrate.py @@ -0,0 +1,331 @@ +"""ADR-0087 substrate tests — rhetorical-style pack loader. + +Pins the substrate contract end-to-end: + + 1. ``default_unstyled_v1`` ratifies and loads with the same + mastery-report self-seal discipline as anchor-lens packs. + 2. Schema gate rejects unknown keys, malformed lists, invalid + ``default_unstyled`` interactions. + 3. Frame and move vocabularies are allow-listed; unknown values + refused. + 4. ``RuntimeConfig.rhetorical_style_id`` field exists and defaults + to ``None``. + +No consumer code is invoked — this PR ships substrate only. The +null-lift behavior (loading ``default_unstyled_v1`` changes nothing +about runtime surfaces) is trivially true today because no composer +reads the pack; that becomes a real test when the consumer ADR lands. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from packs.rhetorical_style import ( + DEFAULT_RHETORICAL_STYLE_PACK, + RhetoricalStylePack, + RhetoricalStylePackError, + load_rhetorical_style_pack, +) +from packs.rhetorical_style.loader import ( + available_rhetorical_style_packs, +) + + +# --------------------------------------------------------------------------- # +# Default null-lift pack +# --------------------------------------------------------------------------- # + + +class TestDefaultUnstyledPack: + def test_loads(self) -> None: + pack = load_rhetorical_style_pack() + assert isinstance(pack, RhetoricalStylePack) + assert pack.pack_id == DEFAULT_RHETORICAL_STYLE_PACK + + def test_is_null_lift(self) -> None: + pack = load_rhetorical_style_pack() + assert pack.is_null_lift() is True + assert pack.default_unstyled is True + assert pack.permitted_frames == () + assert pack.required_moves_per_claim == () + assert pack.forbidden_moves == () + + def test_mastery_report_self_seal_verified(self) -> None: + # Loading with require_ratified=True (the default) implicitly + # verifies the companion mastery-report self-seal. If the seal + # is broken the load raises RhetoricalStylePackError — so the + # mere fact load_rhetorical_style_pack() returns at all is the + # assertion. Re-load explicitly for clarity: + pack = load_rhetorical_style_pack(require_ratified=True) + assert pack.mastery_report_sha256, "ratified pack must declare mastery_report_sha256" + # SHA hex string length sanity. + assert len(pack.mastery_report_sha256) == 64 + + def test_discovery_lists_default_pack(self) -> None: + summaries = available_rhetorical_style_packs() + ids = {s["pack_id"] for s in summaries} + assert "default_unstyled_v1" in ids + # The default pack must show as ratified in the discovery list. + default_summary = next(s for s in summaries if s["pack_id"] == "default_unstyled_v1") + assert default_summary["ratified"] is True + assert default_summary["default_unstyled"] is True + + +# --------------------------------------------------------------------------- # +# Schema gate via fixture packs in a tmp_path +# --------------------------------------------------------------------------- # + + +def _write_pack( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, pack_id: str = "fixture_v1", + overrides: dict | None = None, write_report: bool = False, +) -> str: + from packs.rhetorical_style import loader as _loader + + valid = { + "pack_id": pack_id, + "schema_version": "1.0.0", + "version": 1, + "issued_at": "2026-05-21T00:00:00Z", + "default_unstyled": True, + "permitted_frames": [], + "required_moves_per_claim": [], + "forbidden_moves": [], + "provenance": "test:fixture", + "mastery_report_sha256": "", + } + if overrides: + valid.update(overrides) + (tmp_path / f"{pack_id}.json").write_text(json.dumps(valid), encoding="utf-8") + + if write_report: + # Build a minimal self-sealed report that agrees with the pack's + # mastery_report_sha256. Easiest path: use the existing ratify + # script's seal helper directly. + from formation.hashing import self_seal, sha256_of + probe = dict(valid) + probe["mastery_report_sha256"] = "" + pack_source = sha256_of(probe) + report = { + "pack_id": pack_id, + "schema_version": "1.0.0", + "ratification_method": "rhetorical_style_substrate", + "ratified": True, + "issued_at": valid["issued_at"], + "pack_source_sha256": pack_source, + "failure_reasons": [], + "evidence": {}, + "report_sha256": "", + } + sealed = self_seal(report, sha_field="report_sha256") + (tmp_path / f"{pack_id}.mastery_report.json").write_text( + json.dumps(sealed), encoding="utf-8" + ) + # Update pack to declare matching sha + valid["mastery_report_sha256"] = sealed["report_sha256"] + (tmp_path / f"{pack_id}.json").write_text(json.dumps(valid), encoding="utf-8") + + monkeypatch.setattr(_loader, "_DEFAULT_SEARCH_PATHS", (tmp_path,)) + return pack_id + + +class TestSchemaGate: + def test_missing_required_key_rejected( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + pack_id = _write_pack( + tmp_path, monkeypatch, + overrides={"version": None}, # we'll delete a key below + ) + # Write a pack missing 'version' + bad = { + "pack_id": pack_id, + "schema_version": "1.0.0", + "issued_at": "2026-05-21T00:00:00Z", + "default_unstyled": True, + "permitted_frames": [], + "required_moves_per_claim": [], + "forbidden_moves": [], + "provenance": "test:fixture", + "mastery_report_sha256": "", + } + (tmp_path / f"{pack_id}.json").write_text(json.dumps(bad), encoding="utf-8") + with pytest.raises(RhetoricalStylePackError, match=r"missing required key"): + load_rhetorical_style_pack(pack_id, require_ratified=False) + + def test_unknown_key_rejected( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + pack_id = _write_pack( + tmp_path, monkeypatch, overrides={"rogue_field": True}, + ) + with pytest.raises(RhetoricalStylePackError, match=r"unrecognised key"): + load_rhetorical_style_pack(pack_id, require_ratified=False) + + def test_unknown_frame_rejected( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + pack_id = _write_pack( + tmp_path, monkeypatch, + overrides={ + "default_unstyled": False, + "permitted_frames": ["rogue_frame"], + }, + ) + with pytest.raises(RhetoricalStylePackError, match=r"unknown value 'rogue_frame'"): + load_rhetorical_style_pack(pack_id, require_ratified=False) + + def test_unknown_move_rejected( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + pack_id = _write_pack( + tmp_path, monkeypatch, + overrides={ + "default_unstyled": False, + "required_moves_per_claim": ["rogue_move"], + }, + ) + with pytest.raises(RhetoricalStylePackError, match=r"unknown value 'rogue_move'"): + load_rhetorical_style_pack(pack_id, require_ratified=False) + + def test_duplicate_frame_rejected( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + pack_id = _write_pack( + tmp_path, monkeypatch, + overrides={ + "default_unstyled": False, + "permitted_frames": ["warrant", "warrant"], + }, + ) + with pytest.raises(RhetoricalStylePackError, match=r"duplicate value 'warrant'"): + load_rhetorical_style_pack(pack_id, require_ratified=False) + + def test_default_unstyled_with_constraints_rejected( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + # ADR-0087 invariant: default_unstyled=true ⟺ all three lists empty. + pack_id = _write_pack( + tmp_path, monkeypatch, + overrides={ + "default_unstyled": True, + "permitted_frames": ["warrant"], + }, + ) + with pytest.raises( + RhetoricalStylePackError, + match=r"default_unstyled=true requires empty", + ): + load_rhetorical_style_pack(pack_id, require_ratified=False) + + def test_non_default_with_no_constraints_rejected( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + # A non-default pack with zero constraints would silently look + # like null-lift but bypass the default_unstyled invariant test + # — rejected to keep the two states distinguishable. + pack_id = _write_pack( + tmp_path, monkeypatch, overrides={"default_unstyled": False}, + ) + with pytest.raises( + RhetoricalStylePackError, + match=r"non-default pack must declare at least one", + ): + load_rhetorical_style_pack(pack_id, require_ratified=False) + + def test_pack_id_mismatch_rejected( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + pack_id = _write_pack( + tmp_path, monkeypatch, + overrides={"pack_id": "different_id_v1"}, + ) + with pytest.raises( + RhetoricalStylePackError, + match=r"declares pack_id=", + ): + load_rhetorical_style_pack(pack_id, require_ratified=False) + + def test_path_traversal_pack_id_rejected(self) -> None: + with pytest.raises(RhetoricalStylePackError, match=r"invalid rhetorical-style pack_id"): + load_rhetorical_style_pack("../escape", require_ratified=False) + + +# --------------------------------------------------------------------------- # +# Ratification gate +# --------------------------------------------------------------------------- # + + +class TestRatificationGate: + def test_unratified_pack_rejected_by_default( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + # require_ratified defaults to True; an empty mastery_report_sha256 + # is refused absent the env-var bypass. + pack_id = _write_pack(tmp_path, monkeypatch) + monkeypatch.delenv("CORE_ALLOW_UNRATIFIED_RHETORICAL_STYLE", raising=False) + with pytest.raises(RhetoricalStylePackError, match=r"not ratified"): + load_rhetorical_style_pack(pack_id) + + def test_env_var_bypass_permits_unratified( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + pack_id = _write_pack(tmp_path, monkeypatch) + monkeypatch.setenv("CORE_ALLOW_UNRATIFIED_RHETORICAL_STYLE", "1") + pack = load_rhetorical_style_pack(pack_id) + assert pack.mastery_report_sha256 == "" + + def test_companion_report_missing_rejected( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Pack declares a mastery_report_sha256 but no companion file + # on disk — refused. + pack_id = _write_pack( + tmp_path, monkeypatch, + overrides={"mastery_report_sha256": "deadbeef" * 8}, + ) + with pytest.raises(RhetoricalStylePackError, match=r"companion mastery report missing"): + load_rhetorical_style_pack(pack_id, require_ratified=True) + + def test_companion_sha_mismatch_rejected( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Pack declares one SHA; companion report has a different one. + pack_id = _write_pack(tmp_path, monkeypatch, write_report=True) + # Tamper with the declared SHA on the pack to break agreement. + pack_path = tmp_path / f"{pack_id}.json" + raw = json.loads(pack_path.read_text()) + raw["mastery_report_sha256"] = "f" * 64 + pack_path.write_text(json.dumps(raw)) + with pytest.raises(RhetoricalStylePackError, match=r"does not match companion"): + load_rhetorical_style_pack(pack_id, require_ratified=True) + + +# --------------------------------------------------------------------------- # +# RuntimeConfig field +# --------------------------------------------------------------------------- # + + +class TestRuntimeConfigField: + def test_default_is_none(self) -> None: + from core.config import RuntimeConfig + cfg = RuntimeConfig() + assert cfg.rhetorical_style_id is None + + def test_field_accepts_string(self) -> None: + from core.config import RuntimeConfig + cfg = RuntimeConfig(rhetorical_style_id="default_unstyled_v1") + assert cfg.rhetorical_style_id == "default_unstyled_v1" + + def test_field_independent_of_other_axes(self) -> None: + # Sanity: setting rhetorical_style_id does not perturb other + # axes' defaults (register, anchor lens, ADR-0085 flag, etc.). + from core.config import RuntimeConfig + cfg = RuntimeConfig(rhetorical_style_id="default_unstyled_v1") + assert cfg.gloss_aware_cause is False + assert cfg.transitive_surface is False + assert cfg.composed_surface is False