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)
This commit is contained in:
parent
351a083bb4
commit
0cf54a009d
7 changed files with 918 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
36
packs/rhetorical_style/__init__.py
Normal file
36
packs/rhetorical_style/__init__.py
Normal file
|
|
@ -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",
|
||||
)
|
||||
1
packs/rhetorical_style/default_unstyled_v1.json
Normal file
1
packs/rhetorical_style/default_unstyled_v1.json
Normal file
|
|
@ -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}
|
||||
|
|
@ -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"}
|
||||
425
packs/rhetorical_style/loader.py
Normal file
425
packs/rhetorical_style/loader.py
Normal file
|
|
@ -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 ``<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
|
||||
116
scripts/ratify_rhetorical_style_packs.py
Normal file
116
scripts/ratify_rhetorical_style_packs.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""Ratify rhetorical-style packs (ADR-0087 substrate phase).
|
||||
|
||||
For each pack in ``packs/rhetorical_style/<pack_id>.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 ``<pack_id>.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:]))
|
||||
331
tests/test_adr_0087_rhetorical_style_substrate.py
Normal file
331
tests/test_adr_0087_rhetorical_style_substrate.py
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue