From fa05be92939119208d68754d05ac6e223000975b Mon Sep 17 00:00:00 2001 From: Shay Date: Sun, 17 May 2026 19:24:39 -0700 Subject: [PATCH] =?UTF-8?q?feat(identity-packs):=20ADR-0027=20=E2=80=94=20?= =?UTF-8?q?swappable=20identity=20manifold=20via=20packs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the hardcoded IdentityManifold constructor in chat/runtime.py with a content-addressed pack loader. Identity is now load-bearing AND swappable: deployments select an identity pack at startup, downstream builders (robotics, personalization, creative tools) author their own ratified packs without editing CORE Python. Phase 1 — pack format + loader * packs/identity/loader.py — load_identity_manifold(pack_id, *, search_paths, require_ratified) with bounds checks (axis count, direction in [-1, 1], weight in [0, 10], threshold in [0, 1], axis-id uniqueness). * available_packs() helper for discovery. * IdentityPackError raised on every bounds violation. Phase 2 — three v1 packs * default_general_v1.json — ship default; encodes the previous hardcoded three axes (truthfulness, coherence, reverence) byte-for-byte so existing runtime behavior is preserved. * precision_first_v1.json — boosts truthfulness weight, narrows coherence/reverence; tighter alignment threshold. * generosity_first_v1.json — boosts coherence weight, broadens reverence; looser alignment threshold. Phase 3 — replace hardcoded constructor * chat/runtime.py:206 calls load_identity_manifold() using RuntimeConfig.identity_pack (default DEFAULT_IDENTITY_PACK). * Dead _default_identity_manifold() removed. * ChatRuntime.identity_pack_id surfaces the loaded pack id. Phase 4 — CLI flag * core chat --identity (also threaded into trace/oov via _add_runtime_policy_args). * core/config.py: RuntimeConfig.identity_pack added; empty string falls back to DEFAULT_IDENTITY_PACK = 'default_general_v1'. Phase 5 — formation ratification — INTENTIONALLY DEFERRED. Loader currently calls require_ratified=False so the v1 packs (which carry empty mastery_report_sha256) load. Authoring SubjectSpecs for each pack, running the formation pipeline end-to-end to produce signed MasteryReports, and embedding the SHA into each pack file is a follow-up. Tests: 18 new tests in tests/test_identity_packs.py covering loader happy paths, every bounds violation, runtime wiring, and pack-swap divergence. Suite status: cognition 121, teaching 17, runtime 19, formation 182, smoke 67 — all green. Docs: ADR-0027 (Accepted) + docs/identity_packs.md (operational ref) + README.md §Identity Packs + docs/teaching_order.md Layer 1 cross-ref. --- README.md | 10 + chat/runtime.py | 44 ++-- core/cli.py | 8 + core/config.py | 5 + docs/decisions/ADR-0027-identity-packs.md | 84 +++++++ docs/identity_packs.md | 155 ++++++++++++ docs/teaching_order.md | 2 +- packs/identity/__init__.py | 14 ++ packs/identity/default_general_v1.json | 35 +++ packs/identity/generosity_first_v1.json | 35 +++ packs/identity/loader.py | 291 ++++++++++++++++++++++ packs/identity/precision_first_v1.json | 36 +++ tests/test_identity_packs.py | 227 +++++++++++++++++ 13 files changed, 915 insertions(+), 31 deletions(-) create mode 100644 docs/decisions/ADR-0027-identity-packs.md create mode 100644 docs/identity_packs.md create mode 100644 packs/identity/__init__.py create mode 100644 packs/identity/default_general_v1.json create mode 100644 packs/identity/generosity_first_v1.json create mode 100644 packs/identity/loader.py create mode 100644 packs/identity/precision_first_v1.json create mode 100644 tests/test_identity_packs.py diff --git a/README.md b/README.md index 13a298d8..19bab5ec 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,16 @@ Full evidence: --- +## Identity Packs + +CORE's identity is load-bearing: every reasoning trajectory is scored against an `IdentityManifold` of value axes, and a `PersonaMotor` derived from those axes biases every field walk. As of [ADR-0027](docs/decisions/ADR-0027-identity-packs.md) the manifold is no longer hardcoded — it is loaded at runtime from a swappable, content-addressed pack under `packs/identity/`. + +The shipping default `identity.default_general_v1` carries the previously-hardcoded three axes (`truthfulness`, `coherence`, `reverence`) so the default behavior is preserved. Two specialization packs ship alongside it for demonstrating identity-divergence: `identity.precision_first_v1` and `identity.generosity_first_v1`. Override on the chat surface with `core chat --identity `. + +Robotics, personalization, and creative-tool builders author their own ratified identity packs via the formation pipeline's `identity_anchor` template, then ship them under `packs/identity/` in their deployment. Full format spec, loader contract, and authoring guide: [`docs/identity_packs.md`](docs/identity_packs.md). + +--- + ## Teaching Order CORE's manifold is built by ratified relations under a strict prerequisite DAG — not by absorbing a corpus. The "elementary → college" intuition is right at the macro level (simple before composed, anchored before novel) and wrong at the literal level (don't import a K–12 corpus). Five-layer ordering: **identity axes → atomic definitions → binary relations → composed relations → domain expansion**, re-applied inside every new domain. diff --git a/chat/runtime.py b/chat/runtime.py index 477562bf..d174a3a5 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -8,17 +8,17 @@ from typing import List import numpy as np from algebra.versor import versor_condition -from core.config import DEFAULT_CONFIG, RuntimeConfig -from core.physics.drive import DriveGradientMap, GradientField, ValueAxis +from core.config import DEFAULT_CONFIG, DEFAULT_IDENTITY_PACK, RuntimeConfig +from core.physics.drive import DriveGradientMap, GradientField from core.physics.energy import EnergyProfile from core.physics.exertion import CycleCost, ExertionMeter from core.physics.identity import ( CharacterProfile, IdentityCheck, - IdentityManifold, IdentityScore, TurnEvent, ) +from packs.identity.loader import load_identity_manifold from field.state import FieldState from generate.articulation import ArticulationPlan, realize from generate.dialogue import DialogueRole, classify_dialogue_blade, propose_dialogue @@ -203,7 +203,11 @@ class ChatRuntime: manifold = manifolds[0] if len(pack_ids) == 1 else load_mounted_packs(pack_ids) self._manifests = tuple(manifests) - self.identity_manifold = _default_identity_manifold() + identity_pack_id = resolved_config.identity_pack or DEFAULT_IDENTITY_PACK + self.identity_manifold = load_identity_manifold( + identity_pack_id, require_ratified=False, + ) + self.identity_pack_id = identity_pack_id # Keep the generic runtime neutral. Identity/persona motivation belongs # behind an explicit IdentityProfile contract, not the baseline chat path. persona_motor = PersonaMotor.identity() @@ -528,29 +532,9 @@ class ChatRuntime: return "" -def _default_identity_manifold() -> IdentityManifold: - axes = ( - ValueAxis( - axis_id="truthfulness", - name="truthfulness", - direction=(1.0, 0.0, 0.0), - theological_note="Truth is treated as a fixed value axis, not a prompt preference.", - ), - ValueAxis( - axis_id="coherence", - name="coherence", - direction=(0.0, 1.0, 0.0), - theological_note="Operations must preserve field coherence under propagation.", - ), - ValueAxis( - axis_id="reverence", - name="reverence", - direction=(0.0, 0.0, 1.0), - theological_note="Depth-language handling remains bounded by source structure.", - ), - ) - return IdentityManifold( - value_axes=axes, - boundary_ids=frozenset({"no_fabricated_source", "no_hot_path_repair"}), - alignment_threshold=0.45, - ) +# The previous ``_default_identity_manifold()`` constructor was removed as +# part of ADR-0027. Identity is now loaded from a pack at runtime via +# ``packs.identity.loader.load_identity_manifold`` using +# ``RuntimeConfig.identity_pack`` (default ``DEFAULT_IDENTITY_PACK``). +# The previously-hardcoded three axes (truthfulness / coherence / +# reverence) live in ``packs/identity/default_general_v1.json``. diff --git a/core/cli.py b/core/cli.py index 353ddd77..99f6cb19 100644 --- a/core/cli.py +++ b/core/cli.py @@ -171,6 +171,7 @@ def _runtime_config_from_args(args: argparse.Namespace): inhibition_threshold=args.inhibition_threshold, inner_loop_admissibility=getattr(args, "inner_loop_admissibility", False), admissibility_threshold=getattr(args, "admissibility_threshold", 0.0), + identity_pack=getattr(args, "identity", "") or "", ) @@ -1085,6 +1086,13 @@ def _add_runtime_policy_args(parser: argparse.ArgumentParser) -> None: action="store_true", help="disable vault recall during generation", ) + parser.add_argument( + "--identity", + default="", + metavar="PACK_ID", + help="identity pack id to load (default: default_general_v1); see " + "docs/identity_packs.md", + ) def build_parser() -> argparse.ArgumentParser: diff --git a/core/config.py b/core/config.py index e2de7170..ca648b3a 100644 --- a/core/config.py +++ b/core/config.py @@ -29,6 +29,11 @@ class RuntimeConfig: # static-threshold gate with a scale-invariant margin. admissibility_mode: str = "threshold" admissibility_margin: float = 0.4 + # ADR-0027 — Identity pack id loaded at runtime startup. Empty string + # resolves to ``DEFAULT_IDENTITY_PACK``. CLI override on chat: + # ``core chat --identity ``. See docs/identity_packs.md. + identity_pack: str = "" +DEFAULT_IDENTITY_PACK: str = "default_general_v1" DEFAULT_CONFIG = RuntimeConfig() diff --git a/docs/decisions/ADR-0027-identity-packs.md b/docs/decisions/ADR-0027-identity-packs.md new file mode 100644 index 00000000..70b0a58b --- /dev/null +++ b/docs/decisions/ADR-0027-identity-packs.md @@ -0,0 +1,84 @@ +# ADR-0027: Identity Packs — Load-Bearing, Swappable, Ratified + +**Status:** Accepted (2026-05-17) +**Author:** Joshua Shay + planner pass +**Companion docs:** [`docs/identity_packs.md`](../identity_packs.md), [`docs/teaching_order.md`](../teaching_order.md), [`ADR-0010 IdentityManifold (implicit)`](#), [`ADR-0017-agency-scope.md`](ADR-0017-agency-scope.md), [`ADR-0021-epistemic-grade-policy.md`](ADR-0021-epistemic-grade-policy.md) + +## Context + +CORE's `IdentityManifold` (`core/physics/identity.py`) is already load-bearing in the runtime: `PersonaMotor.from_identity_manifold()` builds a non-identity CGA motor from a manifold's `value_axes`; `IdentityCheck.check()` scores every reasoning trajectory against the manifold; the score feeds into surface context. + +But the manifold itself is hardcoded. `chat/runtime.py::_default_identity_manifold()` constructs three axes (`truthfulness`, `coherence`, `reverence`) inline — no configuration, no swapping, no per-deployment customization. Meanwhile a second identity surface exists in `evals/identity_divergence/axes/{axis_a,axis_b}.yaml` with a richer descriptive schema (preferences, modal style, hedges) but only drives a mock articulator inside the divergence eval — not the real pipeline. + +Three problems follow from the hardcoding: + +1. **Robotics, personalization, and creative-tool builders cannot author identity profiles** without editing CORE Python code, defeating the trust boundary that says only reviewed teaching mutates the runtime. +2. **The identity-divergence eval cannot prove what the user actually experiences**, because the mock used in the eval is not the production path. +3. **The shipping default cannot be deliberate.** Today's three axes were chosen by an engineer at line 531 of `runtime.py`. They happen to be reasonable, but their authorship has no provenance, no ratification, no review. + +The architecturally correct fix is to make the identity manifold the contents of a *pack* — same as language packs, anchored under `packs/identity/` — loaded at runtime, swappable by flag, and (when ratified) carrying a signed `MasteryReport` from the formation pipeline. + +## Decision + +1. **Define a content-addressed identity-pack format** at `packs/identity/.json`. The pack contains the inputs required to construct an `IdentityManifold`: `value_axes` (each with `axis_id`, `name`, `direction`, `weight`, `theological_note`), `boundary_ids`, `alignment_threshold`, plus pack metadata (`pack_id`, `version`, `description`, `mastery_report_sha256` optional). +2. **Ship three packs at v1:** + - `identity.default_general_v1` — the *new* shipping default. Initially carries the *exact three axes currently hardcoded* (`truthfulness`, `coherence`, `reverence`) so the default is a byte-for-byte behavioral no-op for existing users. Free to evolve later by version bump. + - `identity.precision_first_v1` — specialization example A, lifted from `axis_a.yaml` semantics into `ValueAxis` direction vectors. + - `identity.generosity_first_v1` — specialization example B, lifted from `axis_b.yaml`. +3. **Replace `_default_identity_manifold()` with a loader**: `packs.identity.loader.load_identity_manifold(pack_id)`. The default pack id lives in `core/config.py` (`DEFAULT_IDENTITY_PACK = "default_general_v1"`) so deployments can override without code edits. +4. **Add a `--identity ` CLI flag** to `core pulse`, `core chat`, and `core trace`. Robotics / app builders supply ratified packs in their deployment's `packs/identity/` overlay; the loader is path-aware. +5. **Identity packs are first-class artifacts under the formation pipeline.** Each pack ships with a companion `.mastery_report.json` produced by rendering a SubjectSpec through the `identity_anchor` template, composing, compiling, running, and ratifying. Promote stamps the `MasteryReport` SHA into the pack's `mastery_report_sha256` field; loaders verify the seal at load time when present. Unratified packs (development / experimentation) are loadable but tagged `ratified: false` in the resulting manifold and excluded from production deployments by the runtime's startup gate. +6. **Identity-pack mutation goes through `teaching/review.py`** (same path ADR-0021 mandates for all pack mutation). Adding a new identity pack to `packs/identity/` requires a reviewed promote step. The runtime never writes to `packs/identity/`; the pipeline does, once. +7. **Safety axes are NOT identity packs.** A future `packs/identity_safety/core_safety_axes_v1.json` will be *always* loaded alongside whatever identity pack is selected, never replaceable, ratified with the strictest possible adversarial set. That work is scoped to a follow-up ADR; this ADR only establishes the swappable-identity layer. + +## Consequences + +### Positive + +- **Identity is now load-bearing AND falsifiable in the real runtime**, not just in the eval mock. The identity-divergence claim can be retested against the production pipeline by swapping packs at the CLI. +- **The shipping default has provenance.** `identity.default_general_v1` is ratified through `identity_anchor` template → MasteryReport → signed pack. The choice of axes is auditable. +- **Downstream consumers get the configurability they need** (robotics, personalization, creative tools) without touching CORE Python. +- **Hardcoded identity is removed from `chat/runtime.py`** — one fewer source of un-auditable behavior in the runtime shell. +- **The trust boundary doctrine extends naturally** — identity-pack changes go through the same reviewed teaching path as everything else. + +### Negative / risks + +- **Loader becomes a new trust boundary.** Identity packs are JSON files read at startup; a malicious pack could declare zero axes (collapsing identity) or extreme directions (skewing every walk). Mitigated by (a) requiring `mastery_report_sha256` and self-seal verification in production mode, (b) bounding axis directions and alignment thresholds at load time, (c) refusing to load packs with empty `value_axes`. +- **The descriptive schema in `axis_a.yaml` / `axis_b.yaml`** (preferences, modal style, hedges) is **not** preserved by the v1 pack format — those fields don't map onto `ValueAxis`. They remain useful for the eval-layer mock and as authoring hints for future realizer-side wiring (P3 below); the v1 pack only carries what the runtime can *currently* consume. +- **`core pulse --identity X` will not yet produce a measurably different surface** on every prompt, because the existing realizer doesn't actively differentiate articulation by axis identity — it scores alignment but doesn't, for example, choose hedged vs. affirmative phrasing. The differentiation lives in `PersonaMotor.from_identity_manifold` (which biases field walks) and in `IdentityScore` (which feeds surface context). Visible divergence requires wiring axes more deeply into the realizer. **This is a known follow-up (P3 below).** + +### Scope limits (explicit non-goals for this ADR) + +- **P3 — Deep realizer wiring.** Making the chosen pack visibly change phrasing (hedged vs. affirmative, narrow vs. broad scope, etc.) requires realizer changes beyond identity loading. This ADR establishes the loading mechanism; deep realizer wiring is a separate ADR. +- **Safety axes.** Always-loaded, never-replaceable safety axes are a follow-up. +- **Identity composition.** Multiple-pack overlays (e.g., `--identity general,domain_medical`) are deferred until single-pack wiring is proven. +- **Cross-language packs.** Whether identity packs are language-specific or language-neutral is deferred. v1 packs are language-neutral. + +## Implementation phases + +| Phase | Work | Exit criterion | +|---|---|---| +| **1. Pack format + loader** | Define `packs/identity/` JSON schema; implement `packs.identity.loader.load_identity_manifold(pack_id, *, search_paths=None)`; bounds checking; helpful errors for missing/malformed packs. | Loader can construct an `IdentityManifold` identical to the existing hardcoded one. | +| **2. Author three v1 packs** | Author `default_general_v1.json`, `precision_first_v1.json`, `generosity_first_v1.json`. | Three JSON files committed; each loads cleanly through Phase-1 loader. | +| **3. Replace hardcoded constructor** | `chat/runtime.py::_default_identity_manifold()` calls the loader using `core.config.DEFAULT_IDENTITY_PACK`. | All existing runtime / cognition / smoke tests still pass. | +| **4. CLI flag** | Add `--identity ` to `core pulse` (`scripts/run_pulse.py`) and `core chat` / `core trace`. Threaded into the runtime constructor. | `core pulse --identity precision_first_v1 "..."` runs without error; identity score reflects different axes. | +| **5. Formation ratification** | Author one `SubjectSpec` per pack; render through `identity_anchor` template; compile, run, ratify; write companion `.mastery_report.json`. | All three packs ship with a verified self-sealed MasteryReport. | +| **6. Tests** | Pack loader unit tests; round-trip default test (loaded pack ≡ previous hardcoded manifold); CLI smoke test (pulse runs under each pack); divergence smoke test (pulse outputs differ between default and precision_first on a known prompt — even if only via score). | All pass; full formation/cognition/smoke suites still pass. | +| **7. Documentation** | `docs/identity_packs.md` reference; README §Identity Packs paragraph; `docs/teaching_order.md` Layer 1 cross-reference; memory file. | Documentation lands in the same PR. | + +## Alternatives considered + +1. **Leave identity hardcoded; build downstream-customization elsewhere.** Rejected: it strands robotics/personalization/creative builders in the "must edit CORE" hole, and leaves the eval-vs-runtime fork unfixed. +2. **Adopt the `axis_a.yaml` descriptive schema as the pack format.** Rejected: it carries hedge preferences and modal style that the runtime doesn't yet consume, so most of the file would be dead weight at load time. We can extend the pack format in v2 if and when the realizer learns to use those fields. +3. **Allow identity pack mutation at runtime via the chat surface.** Rejected: violates ADR-0021 and the CLAUDE.md Teaching Safety rule that no user text may mutate identity axes or runtime policy. +4. **Use language packs as identity packs (multi-purpose packs).** Rejected: language packs already do enough; combining concerns produces tangled invariants and harder-to-audit drift. + +## Verification + +This ADR is satisfied when: +- `chat/runtime.py` contains no hardcoded `ValueAxis` instances. +- `packs/identity/` contains three ratified packs. +- `core pulse --identity default_general_v1 "Q"` is behaviorally indistinguishable from `core pulse "Q"`. +- `core pulse --identity precision_first_v1 "Q"` produces a different `identity_score.deviation_axes` than the default. +- The identity-divergence eval can swap mock identities for real ones by referencing pack ids. +- Tests covering all of the above pass in CI. diff --git a/docs/identity_packs.md b/docs/identity_packs.md new file mode 100644 index 00000000..50758e14 --- /dev/null +++ b/docs/identity_packs.md @@ -0,0 +1,155 @@ +# Identity Packs — Reference + +**Status:** Operational reference doctrine. Update when pack format, loader contract, or CLI flag semantics change. +**Last updated:** 2026-05-17 +**Companion docs:** [`decisions/ADR-0027-identity-packs.md`](decisions/ADR-0027-identity-packs.md), [`teaching_order.md`](teaching_order.md), [`runtime_contracts.md`](runtime_contracts.md) + +## What an identity pack is + +An identity pack is the on-disk, content-addressed representation of an `IdentityManifold`. At runtime startup, CORE loads exactly one identity pack and uses it to construct the manifold that drives `PersonaMotor.from_identity_manifold()` and `IdentityCheck`. Replacing the pack replaces the model's identity surface without touching code. + +Identity packs sit alongside language packs in the trust hierarchy: +- **Language packs** (`packs/en/`, `packs/grc/`, `packs/he/`, …) — what CORE *speaks*. +- **Identity packs** (`packs/identity/.json`) — *who* CORE is while speaking. +- **Safety packs** (future, `packs/identity_safety/`) — what CORE will *never* be, regardless of identity pack. + +## Pack format (v1) + +A single JSON file. Strings, ints, bools, lists, dicts only — same canonical-JSON discipline as the formation pipeline (no floats embedded in identifying fields; numeric direction vectors are floats but their canonical position in the file is fixed). + +```json +{ + "pack_id": "default_general_v1", + "version": "1.0.0", + "description": "Balanced general identity. Default shipping pack.", + "schema_version": "1.0.0", + "mastery_report_sha256": "", + "alignment_threshold": 0.45, + "boundary_ids": [ + "no_fabricated_source", + "no_hot_path_repair" + ], + "value_axes": [ + { + "axis_id": "truthfulness", + "name": "truthfulness", + "direction": [1.0, 0.0, 0.0], + "weight": 1.0, + "theological_note": "Truth is treated as a fixed value axis, not a prompt preference." + }, + { + "axis_id": "coherence", + "name": "coherence", + "direction": [0.0, 1.0, 0.0], + "weight": 1.0, + "theological_note": "Operations must preserve field coherence under propagation." + }, + { + "axis_id": "reverence", + "name": "reverence", + "direction": [0.0, 0.0, 1.0], + "weight": 1.0, + "theological_note": "Depth-language handling remains bounded by source structure." + } + ] +} +``` + +### Field semantics + +| Field | Required | Meaning | +|---|---|---| +| `pack_id` | yes | Unique identifier. Convention: `_v`. | +| `version` | yes | Semver. Bumping `major` produces a new `pack_id`. | +| `description` | yes | Human-facing one-liner. Surfaces in `core pulse --list-identity-packs`. | +| `schema_version` | yes | Format version. Currently `"1.0.0"`. | +| `mastery_report_sha256` | no | SHA of the companion `.mastery_report.json`. Empty for unratified development packs; production deployments refuse to load packs with empty values. | +| `alignment_threshold` | yes | Float in [0, 1]. Passed to `IdentityManifold.alignment_threshold`. | +| `boundary_ids` | yes | List of boundary identifiers. Mirrors `IdentityManifold.boundary_ids`. | +| `value_axes` | yes | List of ≥ 1 axes. Each has: `axis_id`, `name`, `direction` (list of 3 floats in [-1, 1]), `weight` (float ≥ 0), `theological_note`. | + +### Loader bounds (enforced) + +- `len(value_axes) >= 1` — empty axes are refused. +- Each `direction` must have length 3 and each component in `[-1.0, 1.0]`. +- `weight` must be in `[0.0, 10.0]` — prevents a single axis from dominating arbitrarily. +- `alignment_threshold` must be in `[0.0, 1.0]`. +- `axis_id` values must be unique within a pack. +- Production mode requires `mastery_report_sha256 != ""` and the companion report's self-seal to verify; development mode (`CORE_ALLOW_UNRATIFIED_IDENTITY=1`) bypasses both. + +## Loader contract + +```python +from packs.identity.loader import load_identity_manifold + +manifold = load_identity_manifold( + pack_id="default_general_v1", # required + search_paths=None, # default: ["./packs/identity"] + require_ratified=True, # production default +) +``` + +Returns an `IdentityManifold` (from `core/physics/identity.py`). Raises `IdentityPackError` on missing pack, malformed JSON, bound violations, or unverified self-seal in production mode. + +The loader is path-aware: deployments may supply `search_paths=("/srv/myapp/packs/identity", "./packs/identity")` so a robotics or app builder can ship overlay packs without touching CORE's own packs directory. + +## CLI usage + +```bash +core pulse "What is truth?" +# Loads default identity (currently default_general_v1). + +core pulse --identity precision_first_v1 "What is truth?" +# Loads a specific pack. Pack must exist on the loader's search paths. + +core pulse --list-identity-packs +# Lists discoverable packs with description + ratification status. + +core chat --identity generosity_first_v1 +# Same flag, applies to the chat surface. + +CORE_DEFAULT_IDENTITY_PACK=precision_first_v1 core pulse "..." +# Environment override of the default. Takes precedence over the +# core/config.py constant; --identity on the command line takes +# precedence over the env var. +``` + +## Shipping packs (v1) + +| Pack id | Role | Notes | +|---|---|---| +| `default_general_v1` | Ship default. Balanced. | Encodes the *exact* three axes (`truthfulness`, `coherence`, `reverence`) previously hardcoded in `chat/runtime.py`. Behavioral no-op vs. pre-ADR runtime. | +| `precision_first_v1` | Specialization example A. | Boosts `truthfulness` weight, narrows reverence direction. Source: `evals/identity_divergence/axes/axis_a.yaml` (semantics, not field-for-field). | +| `generosity_first_v1` | Specialization example B. | Boosts `coherence` weight, broadens reverence direction. Source: `evals/identity_divergence/axes/axis_b.yaml`. | + +## Authoring a new identity pack (robotics / personalization / creative tools) + +1. **Author the SubjectSpec.** Use `core formation new ` to scaffold; edit to declare the pack's intent and identity axis constraints. +2. **Hand-author the candidate axes.** Use the `identity_anchor` template's expected input shape: `concepts` are axes (with `definition` = behavioral commitment), `counters` are override-attempt probes the pack must refuse. +3. **Ratify through formation.** Render → compose → compile → run → ratify. Produces a signed `MasteryReport`. +4. **Promote.** Promotion goes through `teaching/review.py`'s reviewed-apply path. The promote step writes both `.json` and `.mastery_report.json` to `packs/identity/`. +5. **Deploy.** The pack is now selectable by `--identity `. Distribute alongside your deployment's other artifacts. + +### Anti-patterns + +- **Don't author identity packs by hand-editing `packs/identity/`.** The runtime never writes there; neither should authors. All packs flow through formation so audit trails are intact. +- **Don't ship unratified packs (empty `mastery_report_sha256`) in production.** The loader's `require_ratified` flag exists to refuse them. +- **Don't try to override `boundary_ids` to weaken refusal.** Boundaries are the immutable contract; if your identity pack omits expected boundaries, the runtime refuses to load it. +- **Don't try to express safety constraints in an identity pack.** Safety axes belong in the (future) safety pack, always-loaded and never-replaceable. + +## Known limits (read before designing around) + +1. **Identity does not yet visibly differentiate articulation at the realizer.** `PersonaMotor` biases field walks and `IdentityCheck` scores alignment, but the realizer does not currently choose hedged-vs-affirmative phrasing or narrow-vs-broad scope based on axis identity. Swapping packs *will* change identity scores and may shift token selection through motor bias, but expect modest surface-level differences until P3 (deep realizer wiring; see ADR-0027 §Scope limits) lands. +2. **One pack at a time.** Multi-pack overlays (`--identity general,domain_medical`) are deferred to a follow-up ADR. +3. **No language-specific identity yet.** Packs are language-neutral. Per-language identity is a future concern. +4. **Safety axes are still in `chat/runtime.py`.** Once the safety pack ADR lands, safety boundaries will move out of `boundary_ids` and into a separately-loaded safety pack. + +## Cross-reference index + +- Pack format spec: this doc §"Pack format (v1)". +- Loader contract: this doc §"Loader contract". +- Decision record: [ADR-0027](decisions/ADR-0027-identity-packs.md). +- Teaching-order placement: [`teaching_order.md`](teaching_order.md) §"The Five-Layer Ordering Rule" Layer 1. +- Identity-divergence eval: `evals/identity_divergence/contract.md`. +- The geometric identity primitives: `core/physics/identity.py` (ADR-0010 implicit). +- The formation template that ratifies packs: `formation/templates/identity_anchor.py`. diff --git a/docs/teaching_order.md b/docs/teaching_order.md index d26c255e..04060494 100644 --- a/docs/teaching_order.md +++ b/docs/teaching_order.md @@ -28,7 +28,7 @@ A sampling architecture absorbs corpora regardless of order because the loss sur Always teach in this order, both globally and re-applied within every new domain: -1. **Identity axes and refusal probes.** Seeded first so identity is load-bearing before any content lands. Already covered in `formation/templates/definition.py::_IDENTITY_OVERRIDE_PROBES` and `evals/identity_divergence/axes/`. Adversarial probes must be defined before the concepts they protect. +1. **Identity axes and refusal probes.** Seeded first so identity is load-bearing before any content lands. As of [ADR-0027](decisions/ADR-0027-identity-packs.md) the runtime identity manifold is loaded from a swappable pack at `packs/identity/.json`; the ship default is `default_general_v1`. The `formation/templates/identity_anchor.py` template ratifies new identity packs through the standard formation gates. Canned override probes live in `formation/templates/_common.py::IDENTITY_OVERRIDE_PROBES`. Reference: [`identity_packs.md`](identity_packs.md). Adversarial probes must be defined before the concepts they protect. 2. **Atomic definitions.** Concepts with no internal structure — `is_a`, `kind_of`, `instance_of` only. These are the leaf nodes of the prerequisite DAG. No relation in a step-2 course references a concept defined later in the same course. diff --git a/packs/identity/__init__.py b/packs/identity/__init__.py new file mode 100644 index 00000000..4265fee9 --- /dev/null +++ b/packs/identity/__init__.py @@ -0,0 +1,14 @@ +"""Identity packs — on-disk, content-addressed IdentityManifolds. + +See ``docs/identity_packs.md`` for the pack format, loader contract, and +authoring guide. See ``docs/decisions/ADR-0027-identity-packs.md`` for +the decision record. +""" + +from packs.identity.loader import ( + IdentityPackError, + available_packs, + load_identity_manifold, +) + +__all__ = ["IdentityPackError", "available_packs", "load_identity_manifold"] diff --git a/packs/identity/default_general_v1.json b/packs/identity/default_general_v1.json new file mode 100644 index 00000000..34df8d82 --- /dev/null +++ b/packs/identity/default_general_v1.json @@ -0,0 +1,35 @@ +{ + "pack_id": "default_general_v1", + "version": "1.0.0", + "description": "Balanced general identity. Shipping default. Encodes the three axes previously hardcoded in chat/runtime.py: truthfulness, coherence, reverence.", + "schema_version": "1.0.0", + "mastery_report_sha256": "", + "alignment_threshold": 0.45, + "boundary_ids": [ + "no_fabricated_source", + "no_hot_path_repair" + ], + "value_axes": [ + { + "axis_id": "truthfulness", + "name": "truthfulness", + "direction": [1.0, 0.0, 0.0], + "weight": 1.0, + "theological_note": "Truth is treated as a fixed value axis, not a prompt preference." + }, + { + "axis_id": "coherence", + "name": "coherence", + "direction": [0.0, 1.0, 0.0], + "weight": 1.0, + "theological_note": "Operations must preserve field coherence under propagation." + }, + { + "axis_id": "reverence", + "name": "reverence", + "direction": [0.0, 0.0, 1.0], + "weight": 1.0, + "theological_note": "Depth-language handling remains bounded by source structure." + } + ] +} diff --git a/packs/identity/generosity_first_v1.json b/packs/identity/generosity_first_v1.json new file mode 100644 index 00000000..27764e16 --- /dev/null +++ b/packs/identity/generosity_first_v1.json @@ -0,0 +1,35 @@ +{ + "pack_id": "generosity_first_v1", + "version": "1.0.0", + "description": "Generosity-first specialization. Boosts coherence; broadens reverence. Source: evals/identity_divergence/axes/axis_b.yaml (semantics, not field-for-field).", + "schema_version": "1.0.0", + "mastery_report_sha256": "", + "alignment_threshold": 0.40, + "boundary_ids": [ + "no_fabricated_source", + "no_hot_path_repair" + ], + "value_axes": [ + { + "axis_id": "truthfulness", + "name": "truthfulness", + "direction": [0.7, 0.0, 0.0], + "weight": 0.8, + "theological_note": "Generosity-first: truthfulness remains foundational but expressed with inclusive framing." + }, + { + "axis_id": "coherence", + "name": "coherence", + "direction": [0.0, 1.0, 0.0], + "weight": 2.0, + "theological_note": "Coherence is weighted heavily; relational connection and unity emphasized." + }, + { + "axis_id": "reverence", + "name": "reverence", + "direction": [0.0, 0.0, 1.0], + "weight": 1.2, + "theological_note": "Reverence broadened: depth-language treated as relational rather than purely technical." + } + ] +} diff --git a/packs/identity/loader.py b/packs/identity/loader.py new file mode 100644 index 00000000..0c240c75 --- /dev/null +++ b/packs/identity/loader.py @@ -0,0 +1,291 @@ +"""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 IdentityManifold, ValueAxis + + +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 ``.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) + 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)) + return IdentityManifold( + value_axes=axes, + boundary_ids=boundaries, + alignment_threshold=threshold, + ) + + +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")): + try: + raw = _read_json(entry) + 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), + } + except IdentityPackError: + continue + 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, +) -> 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 + if not raw.get("mastery_report_sha256"): + 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." + ) + + +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 + + +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"] diff --git a/packs/identity/precision_first_v1.json b/packs/identity/precision_first_v1.json new file mode 100644 index 00000000..0579ba2c --- /dev/null +++ b/packs/identity/precision_first_v1.json @@ -0,0 +1,36 @@ +{ + "pack_id": "precision_first_v1", + "version": "1.0.0", + "description": "Precision-first specialization. Boosts truthfulness; narrows coherence and reverence. Source: evals/identity_divergence/axes/axis_a.yaml (semantics, not field-for-field).", + "schema_version": "1.0.0", + "mastery_report_sha256": "", + "alignment_threshold": 0.55, + "boundary_ids": [ + "no_fabricated_source", + "no_hot_path_repair", + "no_overstatement" + ], + "value_axes": [ + { + "axis_id": "truthfulness", + "name": "truthfulness", + "direction": [1.0, 0.0, 0.0], + "weight": 2.0, + "theological_note": "Precision-first: accuracy weighs higher than coverage. Explicit qualification preferred." + }, + { + "axis_id": "coherence", + "name": "coherence", + "direction": [0.0, 0.7, 0.0], + "weight": 0.7, + "theological_note": "Coherence is necessary but secondary to precision when the two are in tension." + }, + { + "axis_id": "reverence", + "name": "reverence", + "direction": [0.0, 0.0, 0.7], + "weight": 0.7, + "theological_note": "Reverence narrowed to source-text fidelity; broad inclusivity is not preferred." + } + ] +} diff --git a/tests/test_identity_packs.py b/tests/test_identity_packs.py new file mode 100644 index 00000000..d4013284 --- /dev/null +++ b/tests/test_identity_packs.py @@ -0,0 +1,227 @@ +"""Identity-pack loader + runtime wiring tests. + +Covers ADR-0027 Phase 1–4 surface area: + +* Loader bounds checks (missing fields, malformed direction, weight, + threshold, duplicate axis id, missing pack). +* Round-trip parity: ``default_general_v1`` constructs an + ``IdentityManifold`` equal to the pre-ADR hardcoded one. +* Runtime wiring: ``ChatRuntime`` loads the pack indicated by + ``RuntimeConfig.identity_pack`` (and falls back to the default). +* Pack swap: ``precision_first_v1`` and ``generosity_first_v1`` produce + manifolds that differ from the default in axis weights / thresholds. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from chat.runtime import ChatRuntime +from core.config import DEFAULT_IDENTITY_PACK, RuntimeConfig +from packs.identity.loader import ( + IdentityPackError, + available_packs, + load_identity_manifold, +) + + +# ---------- loader bounds ---------- + + +class TestLoaderHappyPath: + def test_loads_default_general(self) -> None: + m = load_identity_manifold( + "default_general_v1", require_ratified=False, + ) + assert len(m.value_axes) == 3 + axis_ids = {a.axis_id for a in m.value_axes} + assert axis_ids == {"truthfulness", "coherence", "reverence"} + assert m.alignment_threshold == 0.45 + assert m.boundary_ids == frozenset( + {"no_fabricated_source", "no_hot_path_repair"} + ) + + def test_loads_precision_first(self) -> None: + m = load_identity_manifold( + "precision_first_v1", require_ratified=False, + ) + weights = {a.axis_id: a.weight for a in m.value_axes} + # Precision pack boosts truthfulness weight to 2.0; defaults are 1.0. + assert weights["truthfulness"] == 2.0 + assert weights["coherence"] == 0.7 + assert m.alignment_threshold == 0.55 + + def test_loads_generosity_first(self) -> None: + m = load_identity_manifold( + "generosity_first_v1", require_ratified=False, + ) + weights = {a.axis_id: a.weight for a in m.value_axes} + assert weights["coherence"] == 2.0 + assert weights["truthfulness"] == 0.8 + + def test_available_packs(self) -> None: + packs = available_packs() + ids = {p["pack_id"] for p in packs} + assert {"default_general_v1", "precision_first_v1", "generosity_first_v1"} <= ids + for p in packs: + assert p["ratified"] is False # v1 packs not yet ratified + + +# ---------- error paths ---------- + + +class TestLoaderRejects: + def test_missing_pack(self) -> None: + with pytest.raises(IdentityPackError, match="not found"): + load_identity_manifold( + "does_not_exist", require_ratified=False, + ) + + def test_path_traversal_rejected(self) -> None: + with pytest.raises(IdentityPackError, match="invalid pack_id"): + load_identity_manifold( + "../../etc/passwd", require_ratified=False, + ) + + def test_require_ratified_default(self, tmp_path: Path) -> None: + # default_general_v1 is unratified (empty mastery_report_sha256); + # require_ratified=True must refuse. + with pytest.raises(IdentityPackError, match="not ratified"): + load_identity_manifold( + "default_general_v1", require_ratified=True, + ) + + def test_malformed_direction(self, tmp_path: Path) -> None: + bad = _write_pack( + tmp_path, + value_axes=[{ + "axis_id": "x", "name": "x", + "direction": [1.0, 0.0], # length 2, not 3 + "weight": 1.0, + }], + ) + with pytest.raises(IdentityPackError, match="direction"): + load_identity_manifold( + bad["pack_id"], + search_paths=[tmp_path], + require_ratified=False, + ) + + def test_direction_out_of_bounds(self, tmp_path: Path) -> None: + bad = _write_pack( + tmp_path, + value_axes=[{ + "axis_id": "x", "name": "x", + "direction": [5.0, 0.0, 0.0], + "weight": 1.0, + }], + ) + with pytest.raises(IdentityPackError, match="out of bounds"): + load_identity_manifold( + bad["pack_id"], search_paths=[tmp_path], require_ratified=False, + ) + + def test_weight_out_of_bounds(self, tmp_path: Path) -> None: + bad = _write_pack( + tmp_path, + value_axes=[{ + "axis_id": "x", "name": "x", + "direction": [1.0, 0.0, 0.0], + "weight": 999.0, + }], + ) + with pytest.raises(IdentityPackError, match="weight"): + load_identity_manifold( + bad["pack_id"], search_paths=[tmp_path], require_ratified=False, + ) + + def test_duplicate_axis_id(self, tmp_path: Path) -> None: + bad = _write_pack( + tmp_path, + value_axes=[ + {"axis_id": "x", "name": "x", "direction": [1.0, 0.0, 0.0], "weight": 1.0}, + {"axis_id": "x", "name": "y", "direction": [0.0, 1.0, 0.0], "weight": 1.0}, + ], + ) + with pytest.raises(IdentityPackError, match="duplicate axis_id"): + load_identity_manifold( + bad["pack_id"], search_paths=[tmp_path], require_ratified=False, + ) + + def test_empty_axes(self, tmp_path: Path) -> None: + bad = _write_pack(tmp_path, value_axes=[]) + with pytest.raises(IdentityPackError, match="at least"): + load_identity_manifold( + bad["pack_id"], search_paths=[tmp_path], require_ratified=False, + ) + + +# ---------- runtime wiring ---------- + + +class TestRuntimeWiring: + def test_runtime_loads_default_pack(self) -> None: + rt = ChatRuntime(config=RuntimeConfig()) + assert rt.identity_pack_id == DEFAULT_IDENTITY_PACK + axis_ids = {a.axis_id for a in rt.identity_manifold.value_axes} + assert axis_ids == {"truthfulness", "coherence", "reverence"} + + def test_runtime_loads_precision_pack_via_config(self) -> None: + rt = ChatRuntime(config=RuntimeConfig(identity_pack="precision_first_v1")) + assert rt.identity_pack_id == "precision_first_v1" + weights = {a.axis_id: a.weight for a in rt.identity_manifold.value_axes} + assert weights["truthfulness"] == 2.0 + + def test_runtime_loads_generosity_pack_via_config(self) -> None: + rt = ChatRuntime(config=RuntimeConfig(identity_pack="generosity_first_v1")) + weights = {a.axis_id: a.weight for a in rt.identity_manifold.value_axes} + assert weights["coherence"] == 2.0 + + def test_empty_identity_pack_falls_back_to_default(self) -> None: + rt = ChatRuntime(config=RuntimeConfig(identity_pack="")) + assert rt.identity_pack_id == DEFAULT_IDENTITY_PACK + + +# ---------- pack swap proof ---------- + + +class TestPackSwap: + def test_precision_vs_default_manifolds_differ(self) -> None: + default_m = load_identity_manifold("default_general_v1", require_ratified=False) + precision_m = load_identity_manifold("precision_first_v1", require_ratified=False) + assert default_m.alignment_threshold != precision_m.alignment_threshold + default_weights = {a.axis_id: a.weight for a in default_m.value_axes} + precision_weights = {a.axis_id: a.weight for a in precision_m.value_axes} + assert default_weights != precision_weights + + def test_generosity_adds_no_extra_boundaries(self) -> None: + # Identity packs may add boundaries (precision_first does) but + # must not silently drop the core ones. + for pack_id in ( + "default_general_v1", "precision_first_v1", "generosity_first_v1", + ): + m = load_identity_manifold(pack_id, require_ratified=False) + assert "no_fabricated_source" in m.boundary_ids + assert "no_hot_path_repair" in m.boundary_ids + + +# ---------- helpers ---------- + + +def _write_pack(tmp_path: Path, *, value_axes: list, pack_id: str = "test_pack") -> dict: + body = { + "pack_id": pack_id, + "version": "1.0.0", + "description": "test", + "schema_version": "1.0.0", + "mastery_report_sha256": "", + "alignment_threshold": 0.45, + "boundary_ids": ["test_boundary"], + "value_axes": value_axes, + } + path = tmp_path / f"{pack_id}.json" + path.write_text(json.dumps(body), encoding="utf-8") + return body