core/packs/anchor_lens/loader.py
Shay 15d34bd2ca
feat(packs): round-4 — he_chesed_v1, he_shalom_v1, he_tzedek_v1 + lossy EN-collapse alignment edges (#54)
Design decision: option (b) — symmetric lossy-collapse pattern.

For each of he-core-cog-021/022/023, two new edges added to
he_core_cognition_v1/alignment.jsonl:

  1. *.en_collapse edge to a synthetic en-collapse-* anchor (weight ~0.62–0.65)
     mirrors the grc-core-cog-021/022 precedent for episteme/synesis.
     Relation format: cross_lang.<lemma>.en_collapse
     Target format: en-collapse-<lemma>  (synthetic, no lexicon entry needed)
     Evidence: adr-0073c:<lemma>_lossy_english_engagement

  2. cross_lang.no_english_collapse edge (weight 0.0) already present —
     RETAINED. Both edges coexist: the protest survives in provenance,
     the engagement edge makes the lens load-bearing on English prompts.

Weight rationale:
  chesed → en-collapse-love: 0.63
    (agape/love pairing already at 0.86 on he-grc edge; EN engagement
     is the weakest link, one lexical step further from Hebrew source)
  shalom → en-collapse-peace: 0.65
    (shalom’s ‘absence of conflict’ reading is closest English overlap;
     wholeness/flourishing dimension is the unrepresented residue)
  tzedek → en-collapse-justice: 0.62
    (justice is the EN collapse — righteousness is the other half;
     ADR-0073a documents the English split explicitly)

New packs:
  he_chesed_v1: logos.chesed.covenant_loyalty via he-core-cog-021;
    cognitive mode: covenant-love; pair: grc_agape_v1 (future)
  he_shalom_v1: logos.shalom.wholeness_peace via he-core-cog-022;
    cognitive mode: wholeness-peace; pair: null (no Greek equivalent)
  he_tzedek_v1: logos.tzedek.right_order via he-core-cog-023;
    cognitive mode: right-order; pair: null (no Greek equivalent)

ratify_anchor_lens_packs.py: LENS_IDS extended with all three.
ISSUED_AT unchanged (same session as round-3).
2026-05-20 07:26:54 -07:00

422 lines
15 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`` / ``substrate``,
list-shaped preferences or scalar ``atom``, ≤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``).
Schema versions supported:
v1-legacy fields: display_name, primary_substrate,
semantic_domain_preferences (list), cognitive_mode_label
v2 fields: substrate, atom (scalar), cognitive_mode,
source_entry_id, pair_lens_id, ratification_method
Both are accepted. New packs should use v2. The dataclass always
exposes the v2 field names; legacy fields are normalised on load.
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 formation.hashing import verify_seal
_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 = 1024
_MAX_DISPLAY_NAME_LEN: int = 128
class AnchorLensError(Exception):
"""Raised when a pack file is invalid or cannot be loaded."""
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)
class AnchorLens:
"""Frozen substantive-axis pack.
Field names follow the v2 schema. Packs that still use v1-legacy
field names are normalised by the loader before construction.
"""
lens_id: str
version: str
description: str
# v2 fields (canonical)
substrate: str = "none"
atom: str = ""
cognitive_mode: str = ""
source_entry_id: str = ""
pair_lens_id: str | None = None
ratification_method: str = "anchor_lens_lifts_proposition"
mastery_report_sha256: str = ""
def is_unanchored(self) -> bool:
"""True for the in-memory sentinel returned by :meth:`unanchored`.
Distinguishes the in-memory ``__unanchored__`` sentinel from the
on-disk ``default_unanchored_v1`` pack — both are structurally
null but only the in-memory one carries the sentinel lens_id.
"""
return self.lens_id == "__unanchored__"
def is_null_lens(self) -> bool:
"""True iff structurally null (no atom, ``substrate='none'``)."""
return (
self.substrate == "none"
and self.atom == ""
and self.cognitive_mode == ""
)
# v1-legacy attribute aliases (back-compat for consumers not yet
# migrated to v2 field names). Read-only views over canonical v2.
@property
def primary_substrate(self) -> str:
return self.substrate
@property
def semantic_domain_preferences(self) -> tuple[str, ...]:
return (self.atom,) if self.atom else ()
@property
def cognitive_mode_label(self) -> str:
return self.cognitive_mode
@classmethod
def unanchored(cls) -> "AnchorLens":
"""Return a frozen in-memory sentinel lens.
Structurally null and tagged with the reserved sentinel
``lens_id='__unanchored__'`` to distinguish from the disk-ratified
``default_unanchored_v1`` pack.
"""
return cls(
lens_id="__unanchored__",
version="1.0.0",
description="In-memory unanchored sentinel.",
substrate="none",
atom="",
cognitive_mode="",
source_entry_id="",
pair_lens_id=None,
ratification_method="anchor_lens_lifts_proposition",
mastery_report_sha256="",
)
def _normalise_raw(raw: dict, lens_id: str) -> dict:
"""Normalise v1-legacy field names to v2 in-place and return raw.
Transforms:
primary_substrate -> substrate
semantic_domain_preferences[0] -> atom (first entry used; must be
exactly one entry for a clean migration)
cognitive_mode_label -> cognitive_mode
display_name is dropped (informational only)
"""
# substrate
if "substrate" not in raw and "primary_substrate" in raw:
raw["substrate"] = raw["primary_substrate"]
# atom (scalar) from list
if "atom" not in raw and "semantic_domain_preferences" in raw:
prefs = raw["semantic_domain_preferences"]
if isinstance(prefs, list) and prefs:
raw["atom"] = prefs[0]
else:
raw["atom"] = ""
# cognitive_mode
if "cognitive_mode" not in raw and "cognitive_mode_label" in raw:
raw["cognitive_mode"] = raw["cognitive_mode_label"]
return raw
def _validate_envelope(raw: dict, lens_id: str) -> None:
"""Validate required fields and value bounds. Accepts v1 and v2."""
# After normalisation every pack must have these:
required_post_normalise = (
"lens_id",
"version",
"description",
"schema_version",
"substrate",
"atom",
)
missing = [k for k in required_post_normalise 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"
)
substrate = raw.get("substrate", "")
if substrate not in _ALLOWED_SUBSTRATES:
raise AnchorLensError(
f"pack {safe_pack_id(lens_id)!r}: substrate {substrate!r} not in "
f"{sorted(_ALLOWED_SUBSTRATES)}"
)
atom = raw.get("atom", "")
if not isinstance(atom, str) or len(atom) > _MAX_ATOM_LEN:
raise AnchorLensError(
f"pack {safe_pack_id(lens_id)!r}: atom must be a string "
f"<= {_MAX_ATOM_LEN} chars"
)
# Non-null lens packs (substrate != 'none') must declare a non-empty
# atom — the engagement path can't fire without one.
if substrate != "none" and not atom:
raise AnchorLensError(
f"pack {safe_pack_id(lens_id)!r}: atom must be non-empty when "
f"substrate is {substrate!r}"
)
cognitive_mode = raw.get("cognitive_mode", "")
if not isinstance(cognitive_mode, str) or len(cognitive_mode) > _MAX_LABEL_LEN:
raise AnchorLensError(
f"pack {safe_pack_id(lens_id)!r}: cognitive_mode must be a string "
f"<= {_MAX_LABEL_LEN} chars"
)
def _validate_lens_id_for_fs(lens_id: object) -> None:
"""Reject path-traversal / slash / empty / non-string lens ids."""
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}"
)
def _find_pack_path(lens_id: str, search_paths: Iterable[Path]) -> Path:
_validate_lens_id_for_fs(lens_id)
for directory in search_paths:
candidate = Path(directory) / f"{lens_id}.json"
if candidate.exists():
return candidate
raise AnchorLensError(
f"anchor-lens pack {safe_pack_id(lens_id)!r} not found in search paths"
)
def load_anchor_lens(
lens_id: str,
*,
search_paths: Iterable[Path | str] | None = None,
require_ratified: bool | None = None,
) -> AnchorLens:
"""Load, validate, and return a frozen :class:`AnchorLens`.
Parameters
----------
lens_id:
The pack identifier, e.g. ``"grc_logos_v1"``.
search_paths:
Directories to search for ``<lens_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_ANCHOR_LENS`` environment variable
(refuse unless the variable is set to ``"1"`` or ``"true"`` or
``"yes"`` case-insensitively).
"""
resolved_paths: list[Path] = [
Path(p) for p in (search_paths or _DEFAULT_SEARCH_PATHS)
]
pack_path = _find_pack_path(lens_id, resolved_paths)
raw: dict = json.loads(pack_path.read_text(encoding="utf-8"))
# Normalise legacy v1 field names to v2 before validation
raw = _normalise_raw(raw, lens_id)
_validate_envelope(raw, lens_id)
if require_ratified is None:
env = os.environ.get("CORE_ALLOW_UNRATIFIED_ANCHOR_LENS", "").lower()
require_ratified = env not in ("1", "true", "yes")
if require_ratified and not raw.get("mastery_report_sha256", ""):
raise AnchorLensError(
f"pack {safe_pack_id(lens_id)!r} is not ratified "
f"(mastery_report_sha256 is empty). Run "
f"scripts/ratify_anchor_lens_packs.py or set "
f"CORE_ALLOW_UNRATIFIED_ANCHOR_LENS=1 for development."
)
# Companion-SHA agreement check: when ratification is required and a
# SHA is declared, the declared SHA must match the on-disk mastery
# report's report_sha256. Tamper-evidence boundary per ADR-0073b.
if require_ratified and raw.get("mastery_report_sha256", ""):
declared = str(raw.get("mastery_report_sha256", ""))
report_path = pack_path.parent / f"{lens_id}.mastery_report.json"
if report_path.is_file():
try:
report = json.loads(report_path.read_text(encoding="utf-8"))
report_sha = str(report.get("report_sha256", ""))
if report_sha != declared:
raise AnchorLensError(
f"pack {safe_pack_id(lens_id)!r}: declared "
f"mastery_report_sha256 does not match companion "
f"report's report_sha256"
)
except (OSError, json.JSONDecodeError) as exc:
raise AnchorLensError(
f"pack {safe_pack_id(lens_id)!r}: companion mastery "
f"report unreadable: {exc}"
) from exc
return AnchorLens(
lens_id=raw["lens_id"],
version=raw["version"],
description=raw["description"],
substrate=raw.get("substrate", "none"),
atom=raw.get("atom", ""),
cognitive_mode=raw.get("cognitive_mode", ""),
source_entry_id=raw.get("source_entry_id", ""),
pair_lens_id=raw.get("pair_lens_id"),
ratification_method=raw.get(
"ratification_method", "anchor_lens_lifts_proposition"
),
mastery_report_sha256=raw.get("mastery_report_sha256", ""),
)
# Module-level singleton sentinel (v1 back-compat). Consumers that
# imported ``UNANCHORED`` directly continue to work; new code should
# prefer ``AnchorLens.unanchored()``.
UNANCHORED: "AnchorLens" = AnchorLens.unanchored()
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; never raises on mismatch — callers that want a hard
failure should use :func:`load_anchor_lens` with ``require_ratified=True``.
"""
resolved_paths: list[Path] = [
Path(p) for p in (search_paths or _DEFAULT_SEARCH_PATHS)
]
try:
pack_path = _find_pack_path(lens_id, resolved_paths)
except AnchorLensError:
return False
try:
raw = json.loads(pack_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
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 = json.loads(report_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return False
if report.get("report_sha256") != declared:
return False
return verify_seal(report, sha_field="report_sha256")
def available_anchor_lens_packs(
search_paths: Iterable[Path | str] | None = None,
) -> list[dict]:
"""Return summary dicts for all ``.json`` packs in the search paths.
Each dict carries the minimum fields callers need for listing UI:
``lens_id``, ``ratified`` (bool from non-empty ``mastery_report_sha256``),
``primary_substrate`` (v1 name back-compat for stable consumers),
and ``substrate`` (v2 canonical).
"""
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"))
raw = _normalise_raw(raw, stem)
except (OSError, json.JSONDecodeError):
continue
substrate = raw.get("substrate", "none")
summaries.append({
"lens_id": stem,
"ratified": bool(raw.get("mastery_report_sha256", "")),
"substrate": substrate,
"primary_substrate": substrate, # v1 back-compat
})
return summaries