core/packs/rhetorical_style/loader.py
Shay 0cf54a009d
feat(adr-0087): rhetorical-style pack substrate (loader + default_unstyled_v1) (#74)
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)
2026-05-20 16:19:36 -07:00

425 lines
15 KiB
Python

"""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 ``<pack_id>.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