core/packs/identity/loader.py
Shay 07ad3af845 feat(surface): ADR-0031 — score-decomposition surface (per-axis hedges)
Closes the 'identity hedges are generic' gap.  When IdentityCheck reports
that a specific axis is deviating AND the pack supplies an axis_hedges
entry for that axis, the assembler uses that axis's phrase instead of
ADR-0028's generic preferred_hedge_*.  The hedge text now names what is
actually at issue.

Selection: lex-smallest axis_id in (ctx.deviation_axes ∩ axis_hedges).
Deterministic; loader emits axis_hedges in lex order on axis_id.

Example surface at alignment=0.30 (strong band) under default pack:
  No deviation             → 'It seems that truth reveals reality.'
  truthfulness deviates    → 'Evidence is thin that truth reveals reality.'
  coherence deviates       → 'This does not yet cohere: truth reveals reality.'
  reverence deviates       → 'Reports suggest truth reveals reality.'

Same trajectory + truthfulness deviation, three different packs:
  default_general_v1   → 'Evidence is thin that truth reveals reality.'
  precision_first_v1   → 'The evidence does not support that truth reveals reality.'
  generosity_first_v1  → 'Truth reveals reality.'  (above generosity's strong=0.20)

Schema (additive, optional):
  surface_preferences.axis_hedges = {
    <axis_id>: { 'strong': str, 'soft': str, 'qualifier': str },
    ...
  }

Bounds: each phrase length 1–64; axis_id non-empty.  Absent block →
ADR-0028 byte-for-byte fallback.  Loader emits pairs in lex order on
axis_id for hashability + deterministic tie-break.

Files:
  core/physics/identity.py
    + class AxisHedge (frozen: strong, soft, qualifier)
    SurfacePreferences gains axis_hedges: Tuple = ()
  packs/identity/loader.py
    + _build_axis_hedges(): parse + bounds-check + emit lex-ordered tuple
  generate/surface.py
    SurfaceContext gains deviation_axes: frozenset[str] + axis_hedges tuple
    + _axis_specific_phrase(ctx): lex-smallest match or None
    _apply_hedge consults axis-specific phrase before ADR-0028 fallback
    Depth languages (he, grc) unchanged — ADR-0030 canonical phrases
  chat/runtime.py
    _build_surface_context lifts identity_score.deviation_axes and
    prefs.axis_hedges into SurfaceContext
  packs/identity/*.json
    Three v1 packs gain axis_hedges blocks (truthfulness, coherence,
    reverence — each pack uses voice consistent with its character)
  scripts/ratify_identity_packs.py (no change — idempotent)
  packs/identity/*.mastery_report.json
    Auto-refreshed.  New SHAs:
      default_general_v1   → 2ab7d469013509ba5030313ca9a609a443d0716e3ddcc5596f59858ce054f5d3
      precision_first_v1   → 78aa1e6a68a35c2c8576b6196a52d421b94f6d11e006128986902a4fd08679af
      generosity_first_v1  → 511f1ce20edd4266239da61443bfc93473a5433f20bfee6692a25a03073dc933

Tests: tests/test_identity_score_decomposition.py — 17 new tests:
  per-axis phrase selection, band gating still applies, pack swap with
  same deviation produces three different phrases, lex tie-break is
  deterministic, depth-language fallback to ADR-0030, backward compat
  with empty deviation_axes, and the contract that all three v1 packs
  ship axis_hedges for all three default-pack axes.

Suite status (all green):
  cognition 121, teaching 17, runtime 19, formation 182, smoke 67
  identity+safety+English+depth divergence 71
  score decomposition 17

Scope limits (documented in ADR-0031):
  - English-only at v1 (depth languages use canonical ADR-0030 phrases)
  - Lex tie-break is operational not semantic — pack authors can re-key
    if they need a different priority
  - No dominance-driven phrasing (Interpretation A); preserved as
    forward-compatible follow-up

Docs: ADR-0031 (Accepted) recorded; docs/identity_packs.md gains
§Axis-specific hedge phrases section and updated v1-pack SHAs; memory
'identity-packs.md' refreshed.
2026-05-17 20:16:22 -07:00

494 lines
18 KiB
Python

"""Identity-pack loader.
Reads a content-addressed identity pack from disk and constructs an
:class:`IdentityManifold` for the runtime. See
``docs/decisions/ADR-0027-identity-packs.md`` for context.
Loader contract (read carefully — this is a trust boundary):
* The loader never mutates a pack on disk. Pack creation goes through
the formation pipeline (``formation.templates.identity_anchor`` ->
compose -> ratify -> promote).
* Bounds checks (axis count, direction magnitude, weight, threshold
range, axis-id uniqueness) are enforced before any field of the
returned manifold 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_IDENTITY=1`` to bypass — this is a
development-only escape hatch and is logged.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Iterable
from core.physics.identity import (
AxisHedge,
IdentityManifold,
SurfacePreferences,
ValueAxis,
)
from formation.hashing import verify_seal
class IdentityPackError(ValueError):
"""Raised when an identity pack is missing, malformed, or out of bounds."""
_DEFAULT_SEARCH_PATHS: tuple[Path, ...] = (
Path(__file__).resolve().parent,
)
_MAX_WEIGHT: float = 10.0
_MIN_AXES: int = 1
_DIRECTION_LEN: int = 3
_DIRECTION_BOUND: float = 1.0
_THRESHOLD_LO: float = 0.0
_THRESHOLD_HI: float = 1.0
def load_identity_manifold(
pack_id: str,
*,
search_paths: Iterable[Path | str] | None = None,
require_ratified: bool | None = None,
) -> IdentityManifold:
"""Load an identity pack and construct its :class:`IdentityManifold`.
Args:
pack_id: Pack identifier (e.g. ``"default_general_v1"``). The
loader looks for ``<pack_id>.json`` in each search path.
search_paths: Iterable of directories to search. Default is the
built-in ``packs/identity/`` directory. Earlier paths take
precedence — pass overlay directories first.
require_ratified: When ``True``, refuse packs whose
``mastery_report_sha256`` is empty. When ``None`` (default),
require ratification unless the env var
``CORE_ALLOW_UNRATIFIED_IDENTITY=1`` is set. When ``False``,
never require ratification (for tests and development).
Returns:
A constructed :class:`IdentityManifold`.
Raises:
IdentityPackError: On any bounds violation, missing file, malformed
JSON, or — in production mode — unverified self-seal.
"""
paths = _resolve_search_paths(search_paths)
pack_path = _find_pack(pack_id, paths)
raw = _read_json(pack_path)
_validate_envelope(raw, pack_id)
_validate_ratification(raw, pack_id, require_ratified, pack_path)
axes = _build_axes(raw["value_axes"], pack_id)
threshold = _validate_threshold(raw["alignment_threshold"], pack_id)
boundaries = frozenset(_validate_boundaries(raw["boundary_ids"], pack_id))
surface_prefs = _build_surface_preferences(
raw.get("surface_preferences"), pack_id,
)
return IdentityManifold(
value_axes=axes,
boundary_ids=boundaries,
alignment_threshold=threshold,
surface_preferences=surface_prefs,
)
def available_packs(
search_paths: Iterable[Path | str] | None = None,
) -> list[dict[str, object]]:
"""Return a list of ``{"pack_id", "version", "description", "ratified"}``
dicts for every JSON pack discoverable on the search paths. Sorted by
``pack_id``.
"""
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")):
# Skip companion artifacts (mastery reports, etc.) — only
# real identity packs declare ``schema_version`` and
# ``value_axes``.
if entry.name.endswith(".mastery_report.json"):
continue
try:
raw = _read_json(entry)
except IdentityPackError:
continue
if not isinstance(raw, dict):
continue
if "schema_version" not in raw or "value_axes" not in raw:
continue
pack_id = str(raw.get("pack_id", entry.stem))
if pack_id in seen:
continue
seen[pack_id] = {
"pack_id": pack_id,
"version": str(raw.get("version", "")),
"description": str(raw.get("description", "")),
"ratified": bool(raw.get("mastery_report_sha256")),
"path": str(entry),
}
return sorted(seen.values(), key=lambda d: str(d["pack_id"]))
# ---------- 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(pack_id: str, paths: tuple[Path, ...]) -> Path:
if not pack_id or "/" in pack_id or ".." in pack_id:
raise IdentityPackError(f"invalid pack_id: {pack_id!r}")
for d in paths:
candidate = d / f"{pack_id}.json"
if candidate.is_file():
return candidate
raise IdentityPackError(
f"identity pack {pack_id!r} not found in {[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 IdentityPackError(f"failed to read pack {path}: {exc}") from exc
if not isinstance(data, dict):
raise IdentityPackError(f"pack {path} did not deserialize to a dict")
return data
def _validate_envelope(raw: dict, pack_id: str) -> None:
required = (
"pack_id",
"version",
"description",
"schema_version",
"alignment_threshold",
"boundary_ids",
"value_axes",
)
missing = [k for k in required if k not in raw]
if missing:
raise IdentityPackError(
f"pack {pack_id!r} missing required fields: {missing}"
)
if raw.get("schema_version") != "1.0.0":
raise IdentityPackError(
f"pack {pack_id!r}: unsupported schema_version "
f"{raw.get('schema_version')!r}"
)
if raw.get("pack_id") != pack_id:
raise IdentityPackError(
f"pack file declares pack_id={raw.get('pack_id')!r} but was "
f"requested as {pack_id!r}"
)
def _validate_ratification(
raw: dict, pack_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_IDENTITY") != "1"
if not require_ratified:
return
declared_sha = raw.get("mastery_report_sha256", "")
if not declared_sha:
raise IdentityPackError(
f"pack {pack_id!r} is not ratified (mastery_report_sha256 empty); "
"set CORE_ALLOW_UNRATIFIED_IDENTITY=1 for development, or "
"ratify the pack through the formation pipeline "
"(scripts/ratify_identity_packs.py)."
)
report_path = pack_path.parent / f"{pack_id}.mastery_report.json"
if not report_path.is_file():
raise IdentityPackError(
f"pack {pack_id!r} declares mastery_report_sha256={declared_sha[:12]}"
f"... but companion 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 IdentityPackError(
f"pack {pack_id!r}: failed to read companion report: {exc}"
) from exc
if not isinstance(report, dict):
raise IdentityPackError(
f"pack {pack_id!r}: companion report is not a JSON object"
)
if report.get("report_sha256") != declared_sha:
raise IdentityPackError(
f"pack {pack_id!r}: companion report SHA "
f"{str(report.get('report_sha256'))[:12]}... does not match pack's "
f"declared {declared_sha[:12]}..."
)
if not verify_seal(report, sha_field="report_sha256"):
raise IdentityPackError(
f"pack {pack_id!r}: companion report failed self-seal verification"
)
if not report.get("ratified", False):
raise IdentityPackError(
f"pack {pack_id!r}: companion report has ratified=False"
)
def _build_axes(axes_raw: list, pack_id: str) -> tuple[ValueAxis, ...]:
if not isinstance(axes_raw, list) or len(axes_raw) < _MIN_AXES:
raise IdentityPackError(
f"pack {pack_id!r}: value_axes must be a list with at least "
f"{_MIN_AXES} axis"
)
seen_ids: set[str] = set()
axes: list[ValueAxis] = []
for i, axis_raw in enumerate(axes_raw):
if not isinstance(axis_raw, dict):
raise IdentityPackError(
f"pack {pack_id!r}: axis[{i}] must be a dict, got "
f"{type(axis_raw).__name__}"
)
for field in ("axis_id", "name", "direction", "weight"):
if field not in axis_raw:
raise IdentityPackError(
f"pack {pack_id!r}: axis[{i}] missing field {field!r}"
)
axis_id = str(axis_raw["axis_id"])
if axis_id in seen_ids:
raise IdentityPackError(
f"pack {pack_id!r}: duplicate axis_id {axis_id!r}"
)
seen_ids.add(axis_id)
direction = axis_raw["direction"]
if not isinstance(direction, list) or len(direction) != _DIRECTION_LEN:
raise IdentityPackError(
f"pack {pack_id!r}: axis {axis_id!r} direction must be a "
f"list of {_DIRECTION_LEN} floats"
)
for j, comp in enumerate(direction):
if not isinstance(comp, (int, float)):
raise IdentityPackError(
f"pack {pack_id!r}: axis {axis_id!r} direction[{j}] "
"must be numeric"
)
if not -_DIRECTION_BOUND <= float(comp) <= _DIRECTION_BOUND:
raise IdentityPackError(
f"pack {pack_id!r}: axis {axis_id!r} direction[{j}]="
f"{comp} out of bounds [-{_DIRECTION_BOUND}, "
f"{_DIRECTION_BOUND}]"
)
weight = axis_raw["weight"]
if not isinstance(weight, (int, float)):
raise IdentityPackError(
f"pack {pack_id!r}: axis {axis_id!r} weight must be numeric"
)
if not 0.0 <= float(weight) <= _MAX_WEIGHT:
raise IdentityPackError(
f"pack {pack_id!r}: axis {axis_id!r} weight={weight} "
f"out of bounds [0, {_MAX_WEIGHT}]"
)
axes.append(
ValueAxis(
name=str(axis_raw["name"]),
direction=tuple(float(x) for x in direction),
axis_id=axis_id,
weight=float(weight),
theological_note=str(axis_raw.get("theological_note", "")),
)
)
return tuple(axes)
def _validate_threshold(value: object, pack_id: str) -> float:
if not isinstance(value, (int, float)):
raise IdentityPackError(
f"pack {pack_id!r}: alignment_threshold must be numeric"
)
fv = float(value)
if not _THRESHOLD_LO <= fv <= _THRESHOLD_HI:
raise IdentityPackError(
f"pack {pack_id!r}: alignment_threshold={fv} out of bounds "
f"[{_THRESHOLD_LO}, {_THRESHOLD_HI}]"
)
return fv
_ALLOWED_CLAIM_STRENGTHS: frozenset[str] = frozenset(
{"balanced", "qualified", "affirmative"}
)
_MIN_HEDGE_LEN: int = 1
_MAX_HEDGE_LEN: int = 64
def _build_surface_preferences(
value: object, pack_id: str,
) -> SurfacePreferences:
"""Parse and bounds-check the optional ``surface_preferences`` block.
Absent block → defaults that reproduce pre-ADR-0028 behavior.
"""
if value is None:
return SurfacePreferences()
if not isinstance(value, dict):
raise IdentityPackError(
f"pack {pack_id!r}: surface_preferences must be a dict, got "
f"{type(value).__name__}"
)
defaults = SurfacePreferences()
strong = _validate_threshold_field(
value.get("hedge_threshold_strong", defaults.hedge_threshold_strong),
pack_id, "hedge_threshold_strong",
)
soft = _validate_threshold_field(
value.get("hedge_threshold_soft", defaults.hedge_threshold_soft),
pack_id, "hedge_threshold_soft",
)
band_high = _validate_threshold_field(
value.get("qualified_band_high", defaults.qualified_band_high),
pack_id, "qualified_band_high",
)
if not (strong <= soft <= band_high):
raise IdentityPackError(
f"pack {pack_id!r}: surface_preferences thresholds must satisfy "
f"hedge_threshold_strong ({strong}) <= "
f"hedge_threshold_soft ({soft}) <= "
f"qualified_band_high ({band_high})"
)
claim_strength = str(
value.get("claim_strength", defaults.claim_strength)
)
if claim_strength not in _ALLOWED_CLAIM_STRENGTHS:
raise IdentityPackError(
f"pack {pack_id!r}: claim_strength={claim_strength!r} not in "
f"{sorted(_ALLOWED_CLAIM_STRENGTHS)}"
)
axis_hedges = _build_axis_hedges(value.get("axis_hedges"), pack_id)
return SurfacePreferences(
hedge_threshold_strong=strong,
hedge_threshold_soft=soft,
preferred_hedge_strong=_validate_hedge_phrase(
value.get("preferred_hedge_strong", defaults.preferred_hedge_strong),
pack_id, "preferred_hedge_strong",
),
preferred_hedge_soft=_validate_hedge_phrase(
value.get("preferred_hedge_soft", defaults.preferred_hedge_soft),
pack_id, "preferred_hedge_soft",
),
claim_strength=claim_strength,
qualified_band_high=band_high,
preferred_qualifier=_validate_hedge_phrase(
value.get("preferred_qualifier", defaults.preferred_qualifier),
pack_id, "preferred_qualifier",
),
axis_hedges=axis_hedges,
)
def _build_axis_hedges(
value: object, pack_id: str,
) -> tuple[tuple[str, AxisHedge], ...]:
"""Parse and bounds-check the optional ``axis_hedges`` sub-block.
Returns a tuple of ``(axis_id, AxisHedge)`` pairs in lex order on
``axis_id``. Absent block → empty tuple → ADR-0028 fallback.
"""
if value is None:
return ()
if not isinstance(value, dict):
raise IdentityPackError(
f"pack {pack_id!r}: surface_preferences.axis_hedges must be a dict"
)
pairs: list[tuple[str, AxisHedge]] = []
for axis_id in sorted(value):
if not isinstance(axis_id, str) or not axis_id:
raise IdentityPackError(
f"pack {pack_id!r}: axis_hedges keys must be non-empty strings"
)
entry = value[axis_id]
if not isinstance(entry, dict):
raise IdentityPackError(
f"pack {pack_id!r}: axis_hedges[{axis_id!r}] must be a dict"
)
missing = [k for k in ("strong", "soft", "qualifier") if k not in entry]
if missing:
raise IdentityPackError(
f"pack {pack_id!r}: axis_hedges[{axis_id!r}] missing fields: "
f"{missing}"
)
pairs.append(
(
axis_id,
AxisHedge(
strong=_validate_hedge_phrase(
entry["strong"], pack_id,
f"axis_hedges[{axis_id!r}].strong",
),
soft=_validate_hedge_phrase(
entry["soft"], pack_id,
f"axis_hedges[{axis_id!r}].soft",
),
qualifier=_validate_hedge_phrase(
entry["qualifier"], pack_id,
f"axis_hedges[{axis_id!r}].qualifier",
),
),
)
)
return tuple(pairs)
def _validate_threshold_field(value: object, pack_id: str, field: str) -> float:
if not isinstance(value, (int, float)):
raise IdentityPackError(
f"pack {pack_id!r}: surface_preferences.{field} must be numeric"
)
fv = float(value)
if not _THRESHOLD_LO <= fv <= _THRESHOLD_HI:
raise IdentityPackError(
f"pack {pack_id!r}: surface_preferences.{field}={fv} out of "
f"bounds [{_THRESHOLD_LO}, {_THRESHOLD_HI}]"
)
return fv
def _validate_hedge_phrase(value: object, pack_id: str, field: str) -> str:
if not isinstance(value, str):
raise IdentityPackError(
f"pack {pack_id!r}: surface_preferences.{field} must be a string"
)
if not _MIN_HEDGE_LEN <= len(value) <= _MAX_HEDGE_LEN:
raise IdentityPackError(
f"pack {pack_id!r}: surface_preferences.{field} length "
f"{len(value)} out of bounds [{_MIN_HEDGE_LEN}, {_MAX_HEDGE_LEN}]"
)
return value
def _validate_boundaries(value: object, pack_id: str) -> list[str]:
if not isinstance(value, list):
raise IdentityPackError(
f"pack {pack_id!r}: boundary_ids must be a list"
)
out: list[str] = []
for i, b in enumerate(value):
if not isinstance(b, str) or not b:
raise IdentityPackError(
f"pack {pack_id!r}: boundary_ids[{i}] must be a non-empty "
"string"
)
out.append(b)
return out
__all__ = ["IdentityPackError", "available_packs", "load_identity_manifold"]