core/packs/anchor_lens/loader.py
Shay 9b1b63b253 feat(anchor_lens): ADR-0073b — L1.2 class + loader + unanchored sentinel
L1.2 of the anchor-lens inside-out rollout — pack class, loader,
ratified sentinel pack, and runtime threading.  Mirrors the
ADR-0068 register-class pattern exactly.  No composer consumes the
lens yet — that's L1.3.

AnchorLens frozen dataclass (packs/anchor_lens/loader.py)
  - lens_id / version / description / display_name
  - primary_substrate ∈ {grc, he, en, none}
  - semantic_domain_preferences: tuple[str, ...] (ordered, ≤64 atoms
    of ≤64 chars each, no duplicates)
  - cognitive_mode_label: str (≤64 chars)
  - mastery_report_sha256
  - is_unanchored() / is_null_lens() predicates
  - unanchored() classmethod + module-level UNANCHORED singleton

Loader contract (mirror of packs/register/loader.py)
  - safe_pack_id path-traversal rejection
  - Schema validation + envelope bounds checks
  - Companion mastery report self-seal + report_sha256 verification
  - CORE_ALLOW_UNRATIFIED_ANCHOR_LENS=1 dev bypass
  - require_ratified default True
  - No truth-path imports (pinned by seam test)

default_unanchored_v1 ratified pack
  - Null lens: primary_substrate="none", empty preferences,
    empty cognitive_mode_label
  - Self-sealed at b3235072fdbb2219...
  - Ratification method: byte_identity_null_lift
  - scripts/ratify_anchor_lens_packs.py L1.2 gate accepts only
    null lenses; L1.3 will widen.  Idempotent.

RuntimeConfig threading
  - new field: anchor_lens_id: str | None = None
  - new constant: DEFAULT_ANCHOR_LENS = "default_unanchored_v1"
  - ChatRuntime.__init__ loads the lens (None → AnchorLens.
    unanchored(); otherwise load_anchor_lens(id)) and stores as
    self.anchor_lens + self.anchor_lens_id.  Invalid ids fail-fast
    at init via AnchorLensError, not at first turn.
  - No composer reads the attribute yet.

Tests pinned (37 total)
  - tests/test_anchor_lens_pack_loader.py (24) — load happy path,
    sentinel structural identity, invalid id rejection (traversal,
    empty, slashes, missing), ratification bypass paths, companion
    SHA mismatch, bounds (substrate / preferences / atoms / label /
    duplicates / capacity), field-missing, lens_id mismatch with
    filename, unsupported schema_version.
  - tests/test_anchor_lens_null_lift.py (4) — load-bearing L1.2
    invariant `anchor_lens_byte_identity_null_lift`: full public
    cognition lane byte-identical for surface, trace_hash, and
    aggregate metrics between anchor_lens_id=None and
    "default_unanchored_v1".
  - tests/test_anchor_lens_pack_seam.py (9) — AST refuses any
    `packs.anchor_lens` import from truth-path modules (cognition /
    trace / pipeline / intent / propagation / vault / algebra) AND
    refuses any truth-path import from the loader itself.

Lane evidence
  - All 37 anchor-lens tests pass.
  - python -m core.cli eval cognition → public 100/100/91.7/100
    byte-identical (lens loaded but no composer reads it).
  - core demo register-tour --json → all_claims_supported: True
    (R5 seam still holds; L1.2 doesn't perturb register).
  - Full lane: 2669 passed / 4 skipped / 1 pre-existing failure
    (+37 over L1.1's 2632; the one failure remains
    test_all_preamble_explains_combined_run, unrelated).

Trust boundaries (per CLAUDE.md / ADR-0051)
  - safe_pack_id path-traversal rejection at loader entry.
  - No dynamic imports.
  - Loader is read-only; mutation only via ratify script.
  - Seam test refuses any new anchor-lens import upstream of the
    realizer.  L1.3 will widen the allow-list to include composer
    files at the same time it adds composer behaviour — exactly the
    way the register seam was widened at R2.

What L1.2 deliberately does NOT do
  - No composer consumes the lens (that's L1.3).
  - No TurnEvent / ChatResponse telemetry fields (L1.4).
  - No `core chat --anchor-lens` CLI flag (L1.4).
  - No anchor-lens-tour demo (L1.4).
2026-05-19 19:46:34 -07:00

456 lines
16 KiB
Python

"""Anchor-lens pack loader (ADR-0073b, Plan Phase L1.2).
Reads a ratified anchor-lens pack from disk and constructs a frozen
:class:`AnchorLens` for the runtime. See
``docs/decisions/ADR-0073-anchor-lens-substrate.md`` (umbrella) and
``docs/decisions/ADR-0073b-anchor-lens-class-loader.md`` (this phase)
for context.
Loader contract (trust boundary):
* Anchor-lens packs are composer-side only. They parameterise the
proposition-construction step at L1.3 and never contribute to the
runtime manifold, ``boundary_ids``, safety/ethics composition, or
the trace hash directly (the *output* trace hash deliberately moves
when the lens changes because the proposition changes — but the
hash function does not depend on the lens object).
* The loader never mutates a pack on disk. Pack creation goes through
``scripts/ratify_anchor_lens_packs.py``.
* Bounds checks (allowed ``primary_substrate``, list-shaped
preferences, ≤64-char atoms, ≤64-char label) are enforced before
any field of the returned :class:`AnchorLens` is observable to
runtime code.
* When ``require_ratified=True`` and the pack's
``mastery_report_sha256`` is empty, the loader refuses. Development
environments may set ``CORE_ALLOW_UNRATIFIED_ANCHOR_LENS=1`` to
bypass.
* :meth:`AnchorLens.unanchored` returns a frozen sentinel matching
the in-memory shape of ``default_unanchored_v1``. At L1.2 no
composer reads this module (pinned by
``tests/test_anchor_lens_pack_seam.py``).
Mirror of ``packs/register/loader.py`` — anchor lens is the
substantive-axis sibling of the presentation-axis register class.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
from core._safe_display import safe_pack_id
from formation.hashing import verify_seal
class AnchorLensError(ValueError):
"""Raised when an anchor-lens pack is missing, malformed, or out of bounds."""
_DEFAULT_SEARCH_PATHS: tuple[Path, ...] = (
Path(__file__).resolve().parent,
)
_ALLOWED_SUBSTRATES: frozenset[str] = frozenset(
{"grc", "he", "en", "none"}
)
_SCHEMA_VERSION: str = "1.0.0"
_MAX_ATOM_LEN: int = 64
_MAX_PREFERENCES: int = 64
_MAX_LABEL_LEN: int = 64
_MAX_DESCRIPTION_LEN: int = 512
_MAX_DISPLAY_NAME_LEN: int = 128
@dataclass(frozen=True)
class AnchorLens:
"""Frozen substantive-axis pack.
Composes into the proposition-construction step at L1.3; at L1.2
nothing consumes it. The lens directs which lemma's
``semantic_domains`` the composer prefers at proposition-build
time, in English compound phrasing
(e.g. ``"knowing-as-experience"``) — never raw non-English
glyphs at the user surface.
"""
lens_id: str
version: str
description: str
display_name: str
primary_substrate: str
semantic_domain_preferences: tuple[str, ...] = ()
cognitive_mode_label: str = ""
mastery_report_sha256: str = ""
def is_unanchored(self) -> bool:
"""True for the in-memory sentinel returned by :meth:`unanchored`."""
return self.lens_id == "__unanchored__"
def is_null_lens(self) -> bool:
"""True iff no atoms, ``primary_substrate='none'``, empty label."""
return (
self.primary_substrate == "none"
and not self.semantic_domain_preferences
and not self.cognitive_mode_label
)
@classmethod
def unanchored(cls) -> "AnchorLens":
"""Return the in-memory sentinel used when no anchor lens is selected.
Structurally identical to ``default_unanchored_v1`` (null
preferences, ``"none"`` substrate, empty mode label). L1.2
requires byte-identical lane output between this sentinel and
``default_unanchored_v1`` — the ``anchor_lens_byte_identity_
null_lift`` invariant.
"""
return cls(
lens_id="__unanchored__",
version="0.0.0",
description="In-memory sentinel; never serialised to disk.",
display_name="Unanchored",
primary_substrate="none",
semantic_domain_preferences=(),
cognitive_mode_label="",
mastery_report_sha256="",
)
def load_anchor_lens(
lens_id: str,
*,
search_paths: Iterable[Path | str] | None = None,
require_ratified: bool | None = None,
) -> AnchorLens:
"""Load an anchor-lens pack and construct its :class:`AnchorLens`.
Args:
lens_id: Pack identifier (e.g. ``"default_unanchored_v1"``).
The loader looks for ``<lens_id>.json`` in each search
path.
search_paths: Iterable of directories to search. Default is the
built-in ``packs/anchor_lens/`` directory. Earlier paths
take precedence.
require_ratified: When ``True``, refuse packs whose
``mastery_report_sha256`` is empty. When ``None`` (default),
require ratification unless the env var
``CORE_ALLOW_UNRATIFIED_ANCHOR_LENS=1`` is set. When
``False``, never require ratification (for tests).
Raises:
AnchorLensError: On any bounds violation, missing file,
malformed JSON, or — in production mode — unverified seal.
"""
paths = _resolve_search_paths(search_paths)
pack_path = _find_pack(lens_id, paths)
raw = _read_json(pack_path)
_validate_envelope(raw, lens_id)
_validate_ratification(raw, lens_id, require_ratified, pack_path)
substrate = _validate_substrate(raw["primary_substrate"], lens_id)
preferences = _validate_preferences(
raw["semantic_domain_preferences"], lens_id,
)
label = _validate_label(raw["cognitive_mode_label"], lens_id)
return AnchorLens(
lens_id=str(raw["lens_id"]),
version=str(raw["version"]),
description=str(raw["description"]),
display_name=str(raw["display_name"]),
primary_substrate=substrate,
semantic_domain_preferences=preferences,
cognitive_mode_label=label,
mastery_report_sha256=str(raw.get("mastery_report_sha256", "")),
)
def available_anchor_lens_packs(
search_paths: Iterable[Path | str] | None = None,
) -> list[dict[str, object]]:
"""Return a sorted list of metadata dicts for every discoverable pack."""
paths = _resolve_search_paths(search_paths)
seen: dict[str, dict[str, object]] = {}
for d in paths:
if not d.is_dir():
continue
for entry in sorted(d.glob("*.json")):
if entry.name.endswith(".mastery_report.json"):
continue
try:
raw = _read_json(entry)
except AnchorLensError:
continue
if not isinstance(raw, dict):
continue
if "schema_version" not in raw or "lens_id" not in raw:
continue
lens_id = str(raw.get("lens_id", entry.stem))
if lens_id in seen:
continue
seen[lens_id] = {
"lens_id": lens_id,
"version": str(raw.get("version", "")),
"description": str(raw.get("description", "")),
"primary_substrate": str(raw.get("primary_substrate", "")),
"ratified": bool(raw.get("mastery_report_sha256")),
"path": str(entry),
}
return sorted(seen.values(), key=lambda d: str(d["lens_id"]))
def verify_anchor_lens_seal(
lens_id: str,
*,
search_paths: Iterable[Path | str] | None = None,
) -> bool:
"""Return True iff the pack's companion mastery report is self-sealed
and the pack's declared SHA matches the report's SHA.
Read-only. Does not raise on mismatch — callers that want a hard
failure should use :func:`load_anchor_lens` with
``require_ratified=True``.
"""
paths = _resolve_search_paths(search_paths)
try:
pack_path = _find_pack(lens_id, paths)
except AnchorLensError:
return False
try:
raw = _read_json(pack_path)
except AnchorLensError:
return False
declared = str(raw.get("mastery_report_sha256", ""))
if not declared:
return False
report_path = pack_path.parent / f"{lens_id}.mastery_report.json"
if not report_path.is_file():
return False
try:
report = _read_json(report_path)
except AnchorLensError:
return False
if report.get("report_sha256") != declared:
return False
return verify_seal(report, sha_field="report_sha256")
# ---------- internals ----------
def _resolve_search_paths(
search_paths: Iterable[Path | str] | None,
) -> tuple[Path, ...]:
if search_paths is None:
return _DEFAULT_SEARCH_PATHS
return tuple(Path(p) for p in search_paths)
def _find_pack(lens_id: str, paths: tuple[Path, ...]) -> Path:
if (
not lens_id
or not isinstance(lens_id, str)
or "/" in lens_id
or "\\" in lens_id
or ".." in lens_id
):
raise AnchorLensError(
f"invalid lens_id: {safe_pack_id(lens_id)!r}"
)
for d in paths:
candidate = d / f"{lens_id}.json"
if candidate.is_file():
return candidate
raise AnchorLensError(
f"anchor-lens pack {safe_pack_id(lens_id)!r} not found in "
f"{[str(p) for p in paths]}"
)
def _read_json(path: Path) -> dict:
try:
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError) as exc:
raise AnchorLensError(f"failed to read pack {path}: {exc}") from exc
if not isinstance(data, dict):
raise AnchorLensError(f"pack {path} did not deserialize to a dict")
return data
def _validate_envelope(raw: dict, lens_id: str) -> None:
required = (
"lens_id",
"version",
"description",
"schema_version",
"display_name",
"primary_substrate",
"semantic_domain_preferences",
"cognitive_mode_label",
)
missing = [k for k in required if k not in raw]
if missing:
raise AnchorLensError(
f"pack {safe_pack_id(lens_id)!r} missing required fields: "
f"{missing}"
)
if raw.get("schema_version") != _SCHEMA_VERSION:
raise AnchorLensError(
f"pack {safe_pack_id(lens_id)!r}: unsupported schema_version "
f"{raw.get('schema_version')!r} (expected {_SCHEMA_VERSION!r})"
)
if raw.get("lens_id") != lens_id:
raise AnchorLensError(
f"pack file declares lens_id="
f"{safe_pack_id(raw.get('lens_id'))!r} but was requested as "
f"{safe_pack_id(lens_id)!r}"
)
desc = raw.get("description", "")
if not isinstance(desc, str) or len(desc) > _MAX_DESCRIPTION_LEN:
raise AnchorLensError(
f"pack {safe_pack_id(lens_id)!r}: description must be a string "
f"{_MAX_DESCRIPTION_LEN} chars"
)
display_name = raw.get("display_name", "")
if (
not isinstance(display_name, str)
or not display_name
or len(display_name) > _MAX_DISPLAY_NAME_LEN
):
raise AnchorLensError(
f"pack {safe_pack_id(lens_id)!r}: display_name must be a non-empty "
f"string ≤ {_MAX_DISPLAY_NAME_LEN} chars"
)
def _validate_ratification(
raw: dict,
lens_id: str,
require_ratified: bool | None,
pack_path: Path,
) -> None:
if require_ratified is False:
return
if require_ratified is None:
require_ratified = (
os.environ.get("CORE_ALLOW_UNRATIFIED_ANCHOR_LENS") != "1"
)
if not require_ratified:
return
declared_sha = raw.get("mastery_report_sha256", "")
if not declared_sha:
raise AnchorLensError(
f"pack {safe_pack_id(lens_id)!r} is not ratified "
"(mastery_report_sha256 empty); set "
"CORE_ALLOW_UNRATIFIED_ANCHOR_LENS=1 for development, or "
"ratify via scripts/ratify_anchor_lens_packs.py."
)
report_path = pack_path.parent / f"{lens_id}.mastery_report.json"
if not report_path.is_file():
raise AnchorLensError(
f"pack {safe_pack_id(lens_id)!r} declares "
f"mastery_report_sha256={str(declared_sha)[:12]}... but companion "
f"report file {report_path.name!r} is missing"
)
try:
with report_path.open("r", encoding="utf-8") as f:
report = json.load(f)
except (OSError, json.JSONDecodeError) as exc:
raise AnchorLensError(
f"pack {safe_pack_id(lens_id)!r}: failed to read companion "
f"report: {exc}"
) from exc
if not isinstance(report, dict):
raise AnchorLensError(
f"pack {safe_pack_id(lens_id)!r}: companion report is not a "
"JSON object"
)
if report.get("report_sha256") != declared_sha:
raise AnchorLensError(
f"pack {safe_pack_id(lens_id)!r}: companion report SHA "
f"{str(report.get('report_sha256'))[:12]}... does not match pack's "
f"declared {str(declared_sha)[:12]}..."
)
if not verify_seal(report, sha_field="report_sha256"):
raise AnchorLensError(
f"pack {safe_pack_id(lens_id)!r}: companion report failed "
"self-seal verification"
)
if not report.get("ratified", False):
raise AnchorLensError(
f"pack {safe_pack_id(lens_id)!r}: companion report has "
"ratified=False"
)
def _validate_substrate(value: object, lens_id: str) -> str:
if not isinstance(value, str) or value not in _ALLOWED_SUBSTRATES:
raise AnchorLensError(
f"pack {safe_pack_id(lens_id)!r}: primary_substrate="
f"{value!r} not in {sorted(_ALLOWED_SUBSTRATES)}"
)
return value
def _validate_preferences(
value: object, lens_id: str,
) -> tuple[str, ...]:
if not isinstance(value, list):
raise AnchorLensError(
f"pack {safe_pack_id(lens_id)!r}: semantic_domain_preferences "
f"must be a list, got {type(value).__name__}"
)
if len(value) > _MAX_PREFERENCES:
raise AnchorLensError(
f"pack {safe_pack_id(lens_id)!r}: semantic_domain_preferences "
f"has {len(value)} entries; max is {_MAX_PREFERENCES}"
)
cleaned: list[str] = []
seen: set[str] = set()
for i, atom in enumerate(value):
if (
not isinstance(atom, str)
or not atom
or len(atom) > _MAX_ATOM_LEN
):
raise AnchorLensError(
f"pack {safe_pack_id(lens_id)!r}: "
f"semantic_domain_preferences[{i}] must be a non-empty "
f"string ≤ {_MAX_ATOM_LEN} chars"
)
if atom in seen:
raise AnchorLensError(
f"pack {safe_pack_id(lens_id)!r}: "
f"semantic_domain_preferences contains duplicate atom "
f"{safe_pack_id(atom)!r}"
)
seen.add(atom)
cleaned.append(atom)
return tuple(cleaned)
def _validate_label(value: object, lens_id: str) -> str:
if not isinstance(value, str) or len(value) > _MAX_LABEL_LEN:
raise AnchorLensError(
f"pack {safe_pack_id(lens_id)!r}: cognitive_mode_label must be "
f"a string ≤ {_MAX_LABEL_LEN} chars"
)
return value
#: Module-level unanchored sentinel. Importable by composers (at L1.3)
#: so they can use it as a keyword-only default without re-evaluating
#: :meth:`AnchorLens.unanchored` on every call. Frozen and shared.
UNANCHORED: AnchorLens = AnchorLens.unanchored()
__all__ = (
"AnchorLens",
"AnchorLensError",
"UNANCHORED",
"available_anchor_lens_packs",
"load_anchor_lens",
"verify_anchor_lens_seal",
)