From 07ad3af845335f3f5a1d07f71ac4cb2337f016b4 Mon Sep 17 00:00:00 2001 From: Shay Date: Sun, 17 May 2026 20:16:22 -0700 Subject: [PATCH] =?UTF-8?q?feat(surface):=20ADR-0031=20=E2=80=94=20score-d?= =?UTF-8?q?ecomposition=20surface=20(per-axis=20hedges)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 = { : { '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. --- chat/runtime.py | 15 ++ core/physics/identity.py | 21 ++ .../ADR-0031-score-decomposition-surface.md | 131 +++++++++++ docs/identity_packs.md | 24 +- generate/surface.py | 43 +++- packs/identity/default_general_v1.json | 21 +- .../default_general_v1.mastery_report.json | 10 +- packs/identity/generosity_first_v1.json | 21 +- .../generosity_first_v1.mastery_report.json | 10 +- packs/identity/loader.py | 62 +++++- packs/identity/precision_first_v1.json | 21 +- .../precision_first_v1.mastery_report.json | 10 +- tests/test_identity_score_decomposition.py | 209 ++++++++++++++++++ 13 files changed, 570 insertions(+), 28 deletions(-) create mode 100644 docs/decisions/ADR-0031-score-decomposition-surface.md create mode 100644 tests/test_identity_score_decomposition.py diff --git a/chat/runtime.py b/chat/runtime.py index ea7b5c63..361c9c63 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -302,7 +302,20 @@ class ChatRuntime: def _build_surface_context(self, identity_score, current_valence: float) -> SurfaceContext: active = self._context.referents.active_referent() alignment = float(identity_score.alignment) if identity_score is not None else 1.0 + deviation_axes = ( + frozenset(identity_score.deviation_axes) + if identity_score is not None + else frozenset() + ) prefs = self.identity_manifold.surface_preferences + # ADR-0031 — flatten the manifold's axis_hedges (tuple of + # (axis_id, AxisHedge)) into the wire-format quadruples that + # SurfaceContext carries. Order is preserved (loader emits in + # lex order); _axis_specific_phrase relies on this. + axis_hedges = tuple( + (axis_id, hedge.strong, hedge.soft, hedge.qualifier) + for axis_id, hedge in prefs.axis_hedges + ) return SurfaceContext( active_referent_surface=active.surface if active is not None else "", active_referent_slot=active.slot if active is not None else "neut_sg", @@ -316,6 +329,8 @@ class ChatRuntime: claim_strength=prefs.claim_strength, qualified_band_high=prefs.qualified_band_high, preferred_qualifier=prefs.preferred_qualifier, + deviation_axes=deviation_axes, + axis_hedges=axis_hedges, ) def _stub_response(self, field_state: FieldState) -> ChatResponse: diff --git a/core/physics/identity.py b/core/physics/identity.py index 78450ef5..f7a6a61a 100644 --- a/core/physics/identity.py +++ b/core/physics/identity.py @@ -64,6 +64,20 @@ class IdentityScore: return sorted(self.deviation_axes) +@dataclass(frozen=True) +class AxisHedge: + """Per-axis hedge phrases for ADR-0031 score-decomposition. + + When ``IdentityCheck`` flags one or more axes as deviating, the + assembler can call out the specific axis instead of using the + generic hedge. v1 is English-only; depth-language axis hedges are + a future ADR. + """ + strong: str + soft: str + qualifier: str + + @dataclass(frozen=True) class SurfacePreferences: """Pack-supplied surface phrasing preferences (ADR-0028). @@ -90,6 +104,13 @@ class SurfacePreferences: claim_strength: str = "balanced" qualified_band_high: float = 0.75 preferred_qualifier: str = "In some cases," + # ADR-0031 — per-axis hedge phrases keyed by axis_id. When a + # deviating axis matches an entry, the assembler uses that axis's + # phrase instead of the generic ``preferred_hedge_*`` above. + # Tuple of ``(axis_id, AxisHedge)`` pairs for hashability under + # frozen dataclass semantics; pairs are kept in lex order on + # ``axis_id`` so determinism is preserved across loads. + axis_hedges: Tuple = () # Tuple[Tuple[str, AxisHedge], ...] @dataclass(frozen=True) diff --git a/docs/decisions/ADR-0031-score-decomposition-surface.md b/docs/decisions/ADR-0031-score-decomposition-surface.md new file mode 100644 index 00000000..9a012035 --- /dev/null +++ b/docs/decisions/ADR-0031-score-decomposition-surface.md @@ -0,0 +1,131 @@ +# ADR-0031: Score-Decomposition Surface — Per-Axis Hedge Phrases + +**Status:** Accepted (2026-05-17) +**Author:** Joshua Shay + planner pass +**Companion docs:** [`identity_packs.md`](../identity_packs.md), [`ADR-0028-identity-surface-wiring.md`](ADR-0028-identity-surface-wiring.md), [`ADR-0030-depth-language-hedge.md`](ADR-0030-depth-language-hedge.md) + +## Context + +[ADR-0028](ADR-0028-identity-surface-wiring.md) and [ADR-0030](ADR-0030-depth-language-hedge.md) made identity-pack swap visibly affect the surface across English, Hebrew, and Koine Greek. But the differentiation today consults a **single scalar** — `SurfaceContext.identity_alignment`. The system can hedge harder when the trajectory drifts; it cannot say *which* aspect of identity is at issue when it hedges. + +`IdentityScore` already carries the information we need: `deviation_axes: FrozenSet[str]` names the specific axes the `IdentityCheck` flagged. Today that field is computed and then ignored at the surface layer. Wiring it through closes the gap: when the system hedges on a trajectory whose deviation is *truthfulness*, the hedge can read "Evidence is thin that…"; on a *coherence* deviation, "This does not yet cohere:…"; on *reverence*, "Reports suggest…". The user learns *why* the system is hedging. + +This is the score-decomposition surface. + +## Two interpretations considered + +**Interpretation A — Dominance-driven phrasing.** Every assertion's character shifts based on which axis is the *leader* of the manifold. Truthfulness-dominant identity → precise phrasing on every assertion; coherence-dominant → unifying phrasing; reverence-dominant → deferential. Rejected for this ADR: requires new dominance scoring, changes confident assertions too (large blast radius), and isn't structurally connected to anything already computed. + +**Interpretation B — Deviation-driven hedge phrasing (this ADR).** When the hedge band fires *and* the score reports a specific deviating axis for which the pack supplies an `axis_hedges` entry, the assembler uses that axis's phrase instead of the generic `preferred_hedge_*`. Otherwise the ADR-0028 generic phrase fires. The data we need (`deviation_axes`) already exists; we just plumb it through. + +Interpretation A is preserved as a future possibility — nothing in this ADR forecloses it. The pack schema extension is named `axis_hedges` (not `axis_phrasing`) precisely so a future "axis phrasing" concept doesn't collide. + +## Decision + +### Pack schema extension (optional, additive) + +A new optional `axis_hedges` sub-block inside `surface_preferences`: + +```json +"surface_preferences": { + "...existing ADR-0028 fields...": "...", + "axis_hedges": { + "truthfulness": { + "strong": "Evidence is thin that", + "soft": "It is hard to confirm that", + "qualifier": "Where evidence is partial," + }, + "coherence": { + "strong": "This does not yet cohere:", + "soft": "The threads loosely connect:", + "qualifier": "Where the connection holds," + }, + "reverence": { + "strong": "Reports suggest", + "soft": "It is said that", + "qualifier": "By some accounts," + } + } +} +``` + +Each axis entry is keyed by `axis_id` (must match an existing `value_axes[*].axis_id` semantically, though the loader doesn't enforce that — a pack may declare hedges for axes it doesn't expose, which is harmless because no deviation will reference them). Each entry has three required phrases: `strong`, `soft`, `qualifier`, matching the three bands of the ADR-0028 hedge algorithm. + +### Selection algorithm + +When the English hedge band fires (after threshold gating): + +1. Compute `matching_axes = ctx.deviation_axes ∩ {ah.axis_id for ah in ctx.axis_hedges}`. +2. If `matching_axes` is empty → use the pack's generic `preferred_hedge_*` (ADR-0028 behavior). +3. Otherwise → use the **lex-smallest** matching axis's phrase. The loader emits `axis_hedges` in lex order on `axis_id` for hashability + determinism; the assembler does a linear scan and takes the first match, which is the lex-smallest. + +Lex tie-break is deliberate: when multiple axes deviate, the assembler must pick one phrase. Lex order is the simplest deterministic choice that doesn't require additional scoring. If a deployment cares about a different priority (e.g., "always prefer the truthfulness phrase when truthfulness is among the deviators"), they can re-key their `axis_hedges` so the preferred axis sorts earliest (`a_truthfulness`, `b_coherence`, …) — operational discipline, not architectural. + +### Three v1 pack profiles + +Each pack ships its own English `axis_hedges` block tuned to its character: + +| Pack | Truthfulness strong | Coherence strong | Reverence strong | +|---|---|---|---| +| `default_general_v1` | "Evidence is thin that" | "This does not yet cohere:" | "Reports suggest" | +| `precision_first_v1` | "The evidence does not support that" | "This contradicts what is established:" | "Source attestation is weak:" | +| `generosity_first_v1` | "Some hold that" | "There is a thread connecting this:" | "It is reported that" | + +Result at `alignment=0.30` (strong band) with `deviation_axes={"truthfulness"}`: + +| Pack | Surface | +|---|---| +| `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." *(generosity's strong threshold is 0.20; 0.30 is above the hedge band so no phrase prepends regardless of deviation)* | + +Same trajectory, same deviating axis, three different surfaces. + +### Implementation + +- `core/physics/identity.py`: new `AxisHedge` frozen dataclass (strong / soft / qualifier strings); `SurfacePreferences` gains `axis_hedges: Tuple = ()` field (tuple of `(axis_id, AxisHedge)` pairs, lex order). +- `packs/identity/loader.py`: `_build_axis_hedges()` parses the optional sub-block, bounds-checks each phrase via the existing `_validate_hedge_phrase` (length 1–64), emits pairs in lex order on `axis_id`. +- `generate/surface.py`: `SurfaceContext` gains two new frozen-and-hashable fields — `deviation_axes: frozenset[str]` and `axis_hedges: tuple[tuple[str, str, str, str], ...]` (flattened quadruples for hashability). New helper `_axis_specific_phrase(ctx)` returns the lex-smallest matching axis's `(strong, soft, qualifier)` or `None`. `_apply_hedge` consults it before falling back to ADR-0028 generic phrases. +- `chat/runtime.py::ChatRuntime._build_surface_context`: lifts `identity_score.deviation_axes` and `prefs.axis_hedges` into the constructed `SurfaceContext`. +- `packs/identity/*.json`: three v1 packs gain `axis_hedges` blocks. Pack body changed → re-ratified. + +### Re-ratification + +Adding `axis_hedges` to each pack changed the canonical body → new `pack_source_sha` → new `MasteryReport`. `scripts/ratify_identity_packs.py` handled it idempotently. Updated SHAs: + +- `default_general_v1` → `2ab7d469013509ba5030313ca9a609a443d0716e3ddcc5596f59858ce054f5d3` +- `precision_first_v1` → `78aa1e6a68a35c2c8576b6196a52d421b94f6d11e006128986902a4fd08679af` +- `generosity_first_v1` → `511f1ce20edd4266239da61443bfc93473a5433f20bfee6692a25a03073dc933` + +## Consequences + +### Positive + +- **Hedges now name what's at issue.** When the system hedges on a trajectory whose truthfulness axis is flagged, the user reads "Evidence is thin that…" — the refusal text is informative, not a generic disclaimer. This is meaningfully better epistemic communication. +- **Per-axis hedges are pack-tuned.** A precision-first deployment hedges with evidential vocabulary; a generosity-first deployment hedges by attribution to "some". Same architecture, different voice. +- **Forward-compatible with Interpretation A.** Dominance-driven phrasing (when a single axis *leads* rather than *deviates*) would slot in alongside `axis_hedges` without changing this ADR's shape. +- **No new scoring infrastructure.** `IdentityScore.deviation_axes` already existed; this ADR is purely plumbing + a phrase table. +- **Backward compatible at every layer.** Packs without `axis_hedges` fall through to ADR-0028 byte-for-byte. `SurfaceContext()` (no-args) carries `deviation_axes=frozenset()` and `axis_hedges=()`, so legacy callers see no behavioral change. + +### Negative / risks + +- **English-only at v1.** Depth languages still use the canonical `_DEPTH_HEDGE_PHRASES` from ADR-0030 regardless of which axis deviates. Closing this requires either a pack-schema bump (axis_hedges per language) or canonical depth-language axis hedges in `surface.py`. Both are tractable; neither belongs in this ADR. +- **Lex tie-break is operational, not semantic.** When multiple axes deviate simultaneously, the chosen phrase is whichever axis_id sorts earliest — not necessarily the "most relevant" one. Deployments that need a different priority must use operational discipline (re-keying axis_ids) or wait for a follow-up ADR introducing per-pack axis priority. +- **Pack body grew.** Three new phrases per axis × three axes = nine new strings per pack. The canonical JSON is still well under any practical size limit, and the ratification driver handled the change without issue. +- **`SurfaceContext` is bigger again.** Two more fields. Both have safe defaults so direct `SurfaceContext()` construction in tests continues to work. + +### Scope limits (explicit non-goals for this ADR) + +- No per-language axis hedges. v1 axis_hedges are English-only. +- No dominance-driven phrasing (Interpretation A). Phrasing changes only when the score reports deviation, not when a particular axis happens to lead. +- No per-pack axis priority. Lex order is the tie-break. +- No realizer-side use of `deviation_axes` beyond hedging (no rotor bias, no token selection shift, no separate refusal surface). + +## Verification + +This ADR is satisfied when: + +- `tests/test_identity_score_decomposition.py` passes — 17 tests covering per-axis phrase selection, band gating still applies, pack-swap with deviation, lex tie-break, depth-language fallback, backward compatibility, and the contract that all three v1 packs ship axis_hedges for all three default axes. +- Cognition (121), teaching (17), runtime (19), formation (182), smoke (67) suites green. +- `tests/test_identity_surface_divergence.py` (ADR-0028) and `tests/test_identity_surface_divergence_depth.py` (ADR-0030) — both still passing (no regressions in the generic-phrase or depth-language paths). +- All three v1 identity packs re-ratified with the new SHAs recorded above. diff --git a/docs/identity_packs.md b/docs/identity_packs.md index f6897753..840f23e1 100644 --- a/docs/identity_packs.md +++ b/docs/identity_packs.md @@ -69,6 +69,24 @@ A single JSON file. Strings, ints, bools, lists, dicts only — same canonical-J | `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`. | +### Axis-specific hedge phrases (ADR-0031) + +Optional sub-block inside `surface_preferences` that lets the assembler call out *which* axis is deviating when it hedges. When `IdentityScore.deviation_axes` names an axis and the pack supplies an `axis_hedges` entry for that axis, the lex-smallest match is used instead of the generic `preferred_hedge_*`: + +```json +"axis_hedges": { + "truthfulness": { + "strong": "Evidence is thin that", + "soft": "It is hard to confirm that", + "qualifier": "Where evidence is partial," + }, + "coherence": { "strong": "...", "soft": "...", "qualifier": "..." }, + "reverence": { "strong": "...", "soft": "...", "qualifier": "..." } +} +``` + +Each axis entry must supply `strong`, `soft`, and `qualifier` phrases (length 1–64). When no deviating axis matches an `axis_hedges` entry, the generic phrases from §"Surface preferences" fire. Depth languages (Hebrew, Koine Greek) ignore `axis_hedges` at v1 and continue to use the canonical phrases from [ADR-0030](decisions/ADR-0030-depth-language-hedge.md). + ### Surface preferences (ADR-0028) Optional block driving the assembler's hedge and claim-strength decisions: @@ -151,9 +169,9 @@ CORE_DEFAULT_IDENTITY_PACK=precision_first_v1 core pulse "..." | 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. ADR-0028 surface_preferences: balanced; hedge thresholds 0.40/0.50/0.75. Ratified: `ddc1ba127231272660e6a435e177227558461b0278572a95635b416c3e1dec5a`. | -| `precision_first_v1` | Specialization example A. | Boosts `truthfulness` weight, narrows reverence direction. Surface: hedges sooner (0.55/0.70/0.85), uses "Arguably,"/"In some cases,"/"Under certain conditions,"; claim_strength=qualified. Source: `evals/identity_divergence/axes/axis_a.yaml`. Ratified: `cb5fb2323214a26afda33f2a67e22f38fe49f4763829d48ef67fd41241aba33c`. | -| `generosity_first_v1` | Specialization example B. | Boosts `coherence` weight, broadens reverence direction. Surface: hedges later (0.20/0.30/0.50); claim_strength=affirmative. Source: `evals/identity_divergence/axes/axis_b.yaml`. Ratified: `94f2f49e1b16c7498fb52b8f9864eecc198618933dc8381a01b809c146826db7`. | +| `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. ADR-0028 surface_preferences: balanced; hedge thresholds 0.40/0.50/0.75. ADR-0031 axis_hedges: "Evidence is thin that" / "This does not yet cohere:" / "Reports suggest". Ratified: `2ab7d469013509ba5030313ca9a609a443d0716e3ddcc5596f59858ce054f5d3`. | +| `precision_first_v1` | Specialization example A. | Boosts `truthfulness` weight, narrows reverence direction. Surface: hedges sooner (0.55/0.70/0.85), uses "Arguably,"/"In some cases,"/"Under certain conditions,"; claim_strength=qualified. ADR-0031 axis_hedges: "The evidence does not support that" / "This contradicts what is established:" / "Source attestation is weak:". Source: `evals/identity_divergence/axes/axis_a.yaml`. Ratified: `78aa1e6a68a35c2c8576b6196a52d421b94f6d11e006128986902a4fd08679af`. | +| `generosity_first_v1` | Specialization example B. | Boosts `coherence` weight, broadens reverence direction. Surface: hedges later (0.20/0.30/0.50); claim_strength=affirmative. ADR-0031 axis_hedges: "Some hold that" / "There is a thread connecting this:" / "It is reported that". Source: `evals/identity_divergence/axes/axis_b.yaml`. Ratified: `511f1ce20edd4266239da61443bfc93473a5433f20bfee6692a25a03073dc933`. | Each ratified pack ships alongside a `.mastery_report.json` companion file. The loader, in production mode, verifies the companion's self-seal and cross-checks its `report_sha256` against the pack's `mastery_report_sha256`. To re-ratify after editing a pack's axes, run `python scripts/ratify_identity_packs.py` (idempotent — re-running on already-current packs is a no-op). diff --git a/generate/surface.py b/generate/surface.py index f9ea2bba..ba229e23 100644 --- a/generate/surface.py +++ b/generate/surface.py @@ -72,6 +72,14 @@ class SurfaceContext: claim_strength: str = "balanced" qualified_band_high: float = _DEFAULT_QUALIFIED_BAND_HIGH preferred_qualifier: str = _DEFAULT_QUALIFIER_PHRASE + # ADR-0031 — score decomposition surface. When ``deviation_axes`` + # is non-empty and at least one of its axis_ids appears in + # ``axis_hedges``, ``_apply_hedge`` uses the lex-smallest matching + # axis's phrase instead of the generic ``preferred_hedge_*``. + # ``axis_hedges`` is a tuple of ``(axis_id, strong, soft, qualifier)`` + # quadruples kept in lex order for hashability + determinism. + deviation_axes: frozenset[str] = frozenset() + axis_hedges: tuple[tuple[str, str, str, str], ...] = () @dataclass(frozen=True, slots=True) @@ -158,11 +166,21 @@ def _apply_hedge(surface: str, ctx: SurfaceContext, lang: str = "en") -> str: """ alignment = ctx.identity_alignment if lang in _DEPTH_HEDGE_PHRASES: + # ADR-0030 — depth languages use canonical phrases; per-axis + # decomposition (ADR-0031) is English-only at v1. strong_phrase, soft_phrase, qualifier_phrase = _DEPTH_HEDGE_PHRASES[lang] else: - strong_phrase = ctx.preferred_hedge_strong - soft_phrase = ctx.preferred_hedge_soft - qualifier_phrase = ctx.preferred_qualifier + # ADR-0031 — when the score reports specific deviating axes and + # the pack supplies axis-specific phrases for any of them, use + # the lex-smallest matching axis's phrase. Otherwise fall + # through to ADR-0028 generic phrases. + axis_phrase = _axis_specific_phrase(ctx) + if axis_phrase is not None: + strong_phrase, soft_phrase, qualifier_phrase = axis_phrase + else: + strong_phrase = ctx.preferred_hedge_strong + soft_phrase = ctx.preferred_hedge_soft + qualifier_phrase = ctx.preferred_qualifier if alignment < ctx.hedge_threshold_strong: return f"{strong_phrase} {_lower_first(surface)}" if alignment < ctx.hedge_threshold_soft: @@ -175,6 +193,25 @@ def _apply_hedge(surface: str, ctx: SurfaceContext, lang: str = "en") -> str: return surface +def _axis_specific_phrase( + ctx: SurfaceContext, +) -> tuple[str, str, str] | None: + """Return (strong, soft, qualifier) for the most-relevant deviating axis, + or ``None`` if no match. + + Match rule: the lex-smallest ``axis_id`` that appears in both + ``ctx.deviation_axes`` and the keys of ``ctx.axis_hedges``. Lex + tie-break keeps output deterministic when multiple axes deviate. + """ + if not ctx.deviation_axes or not ctx.axis_hedges: + return None + for axis_id, strong, soft, qualifier in ctx.axis_hedges: + # ``axis_hedges`` is already in lex order — first match wins. + if axis_id in ctx.deviation_axes: + return (strong, soft, qualifier) + return None + + def _apply_contrast(surface: str, valence_delta: float) -> str: if valence_delta < -CONTRAST_THRESHOLD: return f"However, {_lower_first(surface)}" diff --git a/packs/identity/default_general_v1.json b/packs/identity/default_general_v1.json index 024127af..464e7cc7 100644 --- a/packs/identity/default_general_v1.json +++ b/packs/identity/default_general_v1.json @@ -3,7 +3,7 @@ "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": "ddc1ba127231272660e6a435e177227558461b0278572a95635b416c3e1dec5a", + "mastery_report_sha256": "2ab7d469013509ba5030313ca9a609a443d0716e3ddcc5596f59858ce054f5d3", "alignment_threshold": 0.45, "boundary_ids": [ "no_fabricated_source", @@ -16,7 +16,24 @@ "preferred_hedge_soft": "Perhaps", "claim_strength": "balanced", "qualified_band_high": 0.75, - "preferred_qualifier": "In some cases," + "preferred_qualifier": "In some cases,", + "axis_hedges": { + "truthfulness": { + "strong": "Evidence is thin that", + "soft": "It is hard to confirm that", + "qualifier": "Where evidence is partial," + }, + "coherence": { + "strong": "This does not yet cohere:", + "soft": "The threads loosely connect:", + "qualifier": "Where the connection holds," + }, + "reverence": { + "strong": "Reports suggest", + "soft": "It is said that", + "qualifier": "By some accounts," + } + } }, "value_axes": [ { diff --git a/packs/identity/default_general_v1.mastery_report.json b/packs/identity/default_general_v1.mastery_report.json index a15a7cb9..b8010e0f 100644 --- a/packs/identity/default_general_v1.mastery_report.json +++ b/packs/identity/default_general_v1.mastery_report.json @@ -1,6 +1,6 @@ { "course_id": "course.subject.identity.default_general_v1.identity_anchor.1.0.0", - "course_sha256": "57295c81036dde60d2108c8c036a2ba5c7cc34c3a6079bc6828db36d37769cca", + "course_sha256": "eed629e0afef206df5b31660af5d5b6ba95db8d164556f285d14ed2aa69c9daa", "failure_reasons": [], "gates": [ { @@ -41,16 +41,16 @@ } ], "issued_at": "2026-05-17T00:00:00Z", - "plan_sha256": "48b5a5b757feae5580387851f4a7c568d2a68b99b3599117f0429f2d2275239b", + "plan_sha256": "415e164ebe144fb9b14903dbf4727f818a9a052fae3822d44b7441d05755dd76", "ratified": true, - "report_sha256": "ddc1ba127231272660e6a435e177227558461b0278572a95635b416c3e1dec5a", + "report_sha256": "2ab7d469013509ba5030313ca9a609a443d0716e3ddcc5596f59858ce054f5d3", "schema_version": "1.0.0", - "source_bundle_sha": "4bbade402c02486e99d5f3c3692039ba36cd29483bf7f6c895e5e35dd97da66a", + "source_bundle_sha": "e131fbdd8d52c6bd78acce2c93a352a03f20f8e9c6296de9261cacc45663e27c", "trace_hashes": [ "trace:adversarial_probe:::::identity_override_axis_rewrite", "trace:adversarial_probe:::::identity_override_policy_bypass", "trace:adversarial_probe:::::identity_override_operator_injection", "trace:replay_assertion:::::" ], - "validated_set_sha": "bb45f6d2d2a32cf537298a9f51a367149cbe60457317cf061f892feccfaae49e" + "validated_set_sha": "998bf39f226847f64f28ab0bd5961a8cf8fd40ea7333cd848acdde7a10b688d4" } diff --git a/packs/identity/generosity_first_v1.json b/packs/identity/generosity_first_v1.json index 4c74c50b..f5b2e53f 100644 --- a/packs/identity/generosity_first_v1.json +++ b/packs/identity/generosity_first_v1.json @@ -3,7 +3,7 @@ "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": "94f2f49e1b16c7498fb52b8f9864eecc198618933dc8381a01b809c146826db7", + "mastery_report_sha256": "511f1ce20edd4266239da61443bfc93473a5433f20bfee6692a25a03073dc933", "alignment_threshold": 0.4, "boundary_ids": [ "no_fabricated_source", @@ -16,7 +16,24 @@ "preferred_hedge_soft": "Perhaps", "claim_strength": "affirmative", "qualified_band_high": 0.5, - "preferred_qualifier": "In some cases," + "preferred_qualifier": "In some cases,", + "axis_hedges": { + "truthfulness": { + "strong": "Some hold that", + "soft": "Many would say that", + "qualifier": "As is commonly understood," + }, + "coherence": { + "strong": "There is a thread connecting this:", + "soft": "Loosely, this connects:", + "qualifier": "Where these meanings meet," + }, + "reverence": { + "strong": "It is reported that", + "soft": "Tradition holds that", + "qualifier": "In the broader telling," + } + } }, "value_axes": [ { diff --git a/packs/identity/generosity_first_v1.mastery_report.json b/packs/identity/generosity_first_v1.mastery_report.json index f9a9636f..612e3655 100644 --- a/packs/identity/generosity_first_v1.mastery_report.json +++ b/packs/identity/generosity_first_v1.mastery_report.json @@ -1,6 +1,6 @@ { "course_id": "course.subject.identity.generosity_first_v1.identity_anchor.1.0.0", - "course_sha256": "c49ff0a29000e600719a5de993a0cfde017c9c57eb1e106337a3dfe4241e72f1", + "course_sha256": "9dbbfbd0c04f6f28c18b007894d66849278e8a95706a12ebde31570db6eb2eb7", "failure_reasons": [], "gates": [ { @@ -41,16 +41,16 @@ } ], "issued_at": "2026-05-17T00:00:00Z", - "plan_sha256": "dc463aaf7cd81bbc7b4d6561dcb1ecd4e3c33292c702f96e0bbe9b68bded4ab4", + "plan_sha256": "8194f8422cbb1d2e3e2d4b876eeda8bd1c641a7d7afa75f768def799927f3148", "ratified": true, - "report_sha256": "94f2f49e1b16c7498fb52b8f9864eecc198618933dc8381a01b809c146826db7", + "report_sha256": "511f1ce20edd4266239da61443bfc93473a5433f20bfee6692a25a03073dc933", "schema_version": "1.0.0", - "source_bundle_sha": "61d9b0987c1a419141bd7749e56b41dd367d9058176e3223cf6ce95cd245fa1e", + "source_bundle_sha": "41165d722a6d71f80b09e36b1d67d3fea71449747c4420e25f1bde865b484835", "trace_hashes": [ "trace:adversarial_probe:::::identity_override_axis_rewrite", "trace:adversarial_probe:::::identity_override_policy_bypass", "trace:adversarial_probe:::::identity_override_operator_injection", "trace:replay_assertion:::::" ], - "validated_set_sha": "7c4b27176349b9d5c9f2d4722c37ff1d69e6a9b503627c230c3630447758e8a4" + "validated_set_sha": "ca6acdfac6cdecd29004146ae93d125e7ef550d17f6708e6fe8e7b6f62d0fa70" } diff --git a/packs/identity/loader.py b/packs/identity/loader.py index a0087abb..1d4112bc 100644 --- a/packs/identity/loader.py +++ b/packs/identity/loader.py @@ -25,7 +25,12 @@ import os from pathlib import Path from typing import Iterable -from core.physics.identity import IdentityManifold, SurfacePreferences, ValueAxis +from core.physics.identity import ( + AxisHedge, + IdentityManifold, + SurfacePreferences, + ValueAxis, +) from formation.hashing import verify_seal @@ -368,6 +373,7 @@ def _build_surface_preferences( 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, @@ -385,9 +391,63 @@ def _build_surface_preferences( 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( diff --git a/packs/identity/precision_first_v1.json b/packs/identity/precision_first_v1.json index e989e80a..d5fda6a1 100644 --- a/packs/identity/precision_first_v1.json +++ b/packs/identity/precision_first_v1.json @@ -3,7 +3,7 @@ "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": "cb5fb2323214a26afda33f2a67e22f38fe49f4763829d48ef67fd41241aba33c", + "mastery_report_sha256": "78aa1e6a68a35c2c8576b6196a52d421b94f6d11e006128986902a4fd08679af", "alignment_threshold": 0.55, "boundary_ids": [ "no_fabricated_source", @@ -17,7 +17,24 @@ "preferred_hedge_soft": "In some cases,", "claim_strength": "qualified", "qualified_band_high": 0.85, - "preferred_qualifier": "Under certain conditions," + "preferred_qualifier": "Under certain conditions,", + "axis_hedges": { + "truthfulness": { + "strong": "The evidence does not support that", + "soft": "The evidence is mixed on whether", + "qualifier": "On the available evidence," + }, + "coherence": { + "strong": "This contradicts what is established:", + "soft": "This sits uneasily with what is established:", + "qualifier": "Within the bounds of what coheres," + }, + "reverence": { + "strong": "Source attestation is weak:", + "soft": "Source attestation is partial:", + "qualifier": "On the sources available," + } + } }, "value_axes": [ { diff --git a/packs/identity/precision_first_v1.mastery_report.json b/packs/identity/precision_first_v1.mastery_report.json index 26c6a5a6..02bce57a 100644 --- a/packs/identity/precision_first_v1.mastery_report.json +++ b/packs/identity/precision_first_v1.mastery_report.json @@ -1,6 +1,6 @@ { "course_id": "course.subject.identity.precision_first_v1.identity_anchor.1.0.0", - "course_sha256": "eb2b054ad3a621c062c746cbedc2a825e08a490c84bc23343b24a679601aaacd", + "course_sha256": "add71261fc02069db97bf8c3f586630daa21b090ff66b696b2bd3699b9800c2b", "failure_reasons": [], "gates": [ { @@ -41,16 +41,16 @@ } ], "issued_at": "2026-05-17T00:00:00Z", - "plan_sha256": "7f83866948249e19081930b573b75a9fe24ec49bd9ff2f67f35d6f4d9e683b7d", + "plan_sha256": "a98d7b35ce9d55398c0f7ec6fdab9bb933f2227331d009f56058f004be6f1aeb", "ratified": true, - "report_sha256": "cb5fb2323214a26afda33f2a67e22f38fe49f4763829d48ef67fd41241aba33c", + "report_sha256": "78aa1e6a68a35c2c8576b6196a52d421b94f6d11e006128986902a4fd08679af", "schema_version": "1.0.0", - "source_bundle_sha": "63f87f07cde290cd82fcdf43f4a685670a08942050dee516997d47f11eeb5abf", + "source_bundle_sha": "0b24c82e5f1dfbb660575427968bd9a19e80e420bb4ddf6565f033f206b4904c", "trace_hashes": [ "trace:adversarial_probe:::::identity_override_axis_rewrite", "trace:adversarial_probe:::::identity_override_policy_bypass", "trace:adversarial_probe:::::identity_override_operator_injection", "trace:replay_assertion:::::" ], - "validated_set_sha": "423185f57cd0832c7af846b7e97dff83c067aa46315016defa1df45d68239ae7" + "validated_set_sha": "1a8879e7630801cfaeaae56c691eaa1cea4ea6ce57c32fe35665f3e6c0fcf4ca" } diff --git a/tests/test_identity_score_decomposition.py b/tests/test_identity_score_decomposition.py new file mode 100644 index 00000000..993c37a1 --- /dev/null +++ b/tests/test_identity_score_decomposition.py @@ -0,0 +1,209 @@ +"""ADR-0031 — score decomposition: per-axis hedge phrases. + +When a hedge band fires AND ``IdentityScore.deviation_axes`` names an +axis for which the pack supplies an ``axis_hedges`` entry, the +assembler uses that axis's phrase instead of the generic +``preferred_hedge_*``. These tests prove: + +* Per-axis phrases fire when the score reports the axis as deviating. +* Generic phrase still fires when no specific axis matches. +* Pack swap produces different axis-specific phrases on the same + trajectory + deviation. +* Lex tie-break is deterministic when multiple axes deviate. +* Depth languages (he, grc) still use canonical ADR-0030 phrases + regardless of which axis deviates (English-only at v1). +* Backward compatibility: ``deviation_axes=frozenset()`` falls back to + ADR-0028 generic behavior byte-for-byte. +""" + +from __future__ import annotations + +from generate.articulation import ArticulationPlan +from generate.surface import SentenceAssembler, SurfaceContext +from packs.identity.loader import load_identity_manifold + +_ASSEMBLER = SentenceAssembler() + + +def _plan(lang: str = "en") -> ArticulationPlan: + if lang == "he": + return ArticulationPlan( + "אמת", "מגלה", "מציאות", "", "he", "default", + ) + if lang == "grc": + return ArticulationPlan( + "logos", "ἀποκαλύπτει", "πραγματικότητα", "", "grc", "default", + ) + return ArticulationPlan( + "truth", "reveals", "reality", "", "en", "default", + ) + + +def _ctx( + pack_id: str, alignment: float, deviation_axes: set[str], lang: str = "en", +) -> SurfaceContext: + prefs = load_identity_manifold(pack_id).surface_preferences + axis_hedges = tuple( + (aid, h.strong, h.soft, h.qualifier) for aid, h in prefs.axis_hedges + ) + return SurfaceContext( + identity_alignment=alignment, + hedge_threshold_strong=prefs.hedge_threshold_strong, + hedge_threshold_soft=prefs.hedge_threshold_soft, + preferred_hedge_strong=prefs.preferred_hedge_strong, + preferred_hedge_soft=prefs.preferred_hedge_soft, + claim_strength=prefs.claim_strength, + qualified_band_high=prefs.qualified_band_high, + preferred_qualifier=prefs.preferred_qualifier, + deviation_axes=frozenset(deviation_axes), + axis_hedges=axis_hedges, + ) + + +def _surface( + pack_id: str, alignment: float, deviation_axes: set[str], lang: str = "en", +) -> str: + return _ASSEMBLER.assemble( + _plan(lang), + tokens=[], + role="assert", + context=_ctx(pack_id, alignment, deviation_axes, lang), + ).surface + + +# ---------- per-axis phrases under default pack ---------- + + +class TestAxisSpecificPhrases: + def test_truthfulness_deviation_uses_truthfulness_phrase(self) -> None: + s = _surface("default_general_v1", 0.30, {"truthfulness"}) + assert s.startswith("Evidence is thin that"), s + + def test_coherence_deviation_uses_coherence_phrase(self) -> None: + s = _surface("default_general_v1", 0.30, {"coherence"}) + assert s.startswith("This does not yet cohere:"), s + + def test_reverence_deviation_uses_reverence_phrase(self) -> None: + s = _surface("default_general_v1", 0.30, {"reverence"}) + assert s.startswith("Reports suggest"), s + + def test_no_deviation_falls_back_to_generic(self) -> None: + s = _surface("default_general_v1", 0.30, set()) + assert s.startswith("It seems that"), s + + def test_deviation_outside_pack_axis_hedges_falls_back(self) -> None: + # An axis_id that the pack doesn't provide an axis_hedge for. + s = _surface("default_general_v1", 0.30, {"unfamiliar_axis"}) + assert s.startswith("It seems that"), s + + +# ---------- band gating still applies ---------- + + +class TestBandGating: + def test_above_hedge_band_leaves_bare_even_with_deviation(self) -> None: + # alignment=0.90 is above default's hedge_threshold_soft (0.50) + # and qualified_band_high (0.75). No phrase prepends. + s = _surface("default_general_v1", 0.90, {"truthfulness"}) + assert s == "Truth reveals reality." + + def test_soft_band_uses_axis_soft_phrase(self) -> None: + # alignment=0.45 is in default's soft band [0.40, 0.50). + s = _surface("default_general_v1", 0.45, {"truthfulness"}) + assert s.startswith("It is hard to confirm that"), s + + +# ---------- pack swap with same deviation ---------- + + +class TestPackSwapWithDeviation: + def test_truthfulness_deviation_three_packs_three_phrases(self) -> None: + a = _surface("default_general_v1", 0.30, {"truthfulness"}) + b = _surface("precision_first_v1", 0.30, {"truthfulness"}) + c = _surface("generosity_first_v1", 0.30, {"truthfulness"}) + assert a.startswith("Evidence is thin that"), a + assert b.startswith("The evidence does not support that"), b + # generosity's strong threshold is 0.20; alignment=0.30 is above + # it, so no hedge fires regardless of deviation. + assert c == "Truth reveals reality." + + def test_coherence_deviation_three_packs(self) -> None: + a = _surface("default_general_v1", 0.30, {"coherence"}) + b = _surface("precision_first_v1", 0.30, {"coherence"}) + # default and precision both hedge but with different + # coherence-specific phrasing. + assert a.startswith("This does not yet cohere:"), a + assert b.startswith("This contradicts what is established:"), b + + +# ---------- lex tie-break ---------- + + +class TestLexTieBreak: + def test_multiple_deviations_lex_smallest_wins(self) -> None: + # All three axes deviating — lex-smallest is "coherence" since + # axis_hedges is in lex order. + s = _surface( + "default_general_v1", 0.30, + {"truthfulness", "coherence", "reverence"}, + ) + assert s.startswith("This does not yet cohere:"), s + + def test_truthfulness_and_reverence_uses_reverence_via_lex(self) -> None: + # Lex order: "reverence" < "truthfulness", so reverence wins. + s = _surface( + "default_general_v1", 0.30, + {"truthfulness", "reverence"}, + ) + assert s.startswith("Reports suggest"), s + + +# ---------- depth-language fallback ---------- + + +class TestDepthLanguageFallback: + def test_hebrew_ignores_axis_hedges(self) -> None: + s = _surface("default_general_v1", 0.30, {"truthfulness"}, lang="he") + # Hebrew uses canonical ADR-0030 phrase regardless of deviation. + assert s.startswith("נראה ש"), s + + def test_greek_ignores_axis_hedges(self) -> None: + s = _surface("default_general_v1", 0.30, {"coherence"}, lang="grc") + assert s.startswith("δοκεῖ ὅτι"), s + + +# ---------- backward compatibility ---------- + + +class TestBackwardCompatibility: + def test_empty_deviation_axes_matches_adr_0028_behavior(self) -> None: + # ADR-0028 baseline: default pack at alignment=0.30 with no + # deviation_axes should produce "It seems that truth reveals reality." + s = _surface("default_general_v1", 0.30, set()) + assert s == "It seems that truth reveals reality." + + def test_default_surfacecontext_has_empty_decomposition(self) -> None: + ctx = SurfaceContext() + assert ctx.deviation_axes == frozenset() + assert ctx.axis_hedges == () + + +# ---------- pack contract: all three v1 packs ship axis_hedges ---------- + + +class TestPackContract: + def test_all_three_v1_packs_supply_axis_hedges(self) -> None: + for pack_id in ( + "default_general_v1", "precision_first_v1", "generosity_first_v1", + ): + prefs = load_identity_manifold(pack_id).surface_preferences + # Every v1 pack provides hedges for the three default-pack axes. + axis_ids = {a for a, _ in prefs.axis_hedges} + assert {"truthfulness", "coherence", "reverence"} <= axis_ids, ( + f"{pack_id} missing one of the three default-pack axes" + ) + + def test_axis_hedges_in_lex_order(self) -> None: + prefs = load_identity_manifold("default_general_v1").surface_preferences + ids = [a for a, _ in prefs.axis_hedges] + assert ids == sorted(ids)