From 1574a4b03011e13a0f38b35e3610ba98d99a4e70 Mon Sep 17 00:00:00 2001 From: Shay Date: Sun, 17 May 2026 19:42:54 -0700 Subject: [PATCH] =?UTF-8?q?feat(identity-packs):=20ADR-0028=20=E2=80=94=20?= =?UTF-8?q?pack-driven=20hedge=20&=20claim-strength=20shaping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the 'identity is load-bearing but not visibly differentiated' gap noted at the end of ADR-0027. Pack swap now produces visibly different surfaces on identical trajectories at the same alignment. Schema bump — packs gain an optional 'surface_preferences' block: hedge_threshold_strong, hedge_threshold_soft → band entries preferred_hedge_strong, preferred_hedge_soft → phrases per band claim_strength → balanced|qualified|affirmative qualified_band_high, preferred_qualifier → marginal-band shaping Loader enforces threshold ordering (strong <= soft <= qual_high), phrase length bounds, and the enum-of-three for claim_strength. Missing block resolves to defaults that reproduce pre-ADR behavior byte-for-byte; existing tests pass unchanged. Algorithm (deterministic, surface-only, no sampling/repair/normalize): alignment < strong → preferred_hedge_strong + lower-cased surface alignment < soft → preferred_hedge_soft + lower-cased surface soft <= alignment < qual_high and claim_strength=qualified → preferred_qualifier + lower-cased surface otherwise → bare surface Three v1 pack profiles: default_general_v1 balanced; 0.40 / 0.50 / 0.75 ; 'It seems that' / 'Perhaps' precision_first_v1 qualified; 0.55 / 0.70 / 0.85 ; 'Arguably,' / 'In some cases,' / 'Under certain conditions,' generosity_first_v1 affirmative; 0.20 / 0.30 / 0.50 ; default hedge phrases Re-ratified. New MasteryReport SHAs (superseding Phase-5): default_general_v1 → ddc1ba127231272660e6a435e177227558461b0278572a95635b416c3e1dec5a precision_first_v1 → cb5fb2323214a26afda33f2a67e22f38fe49f4763829d48ef67fd41241aba33c generosity_first_v1 → 94f2f49e1b16c7498fb52b8f9864eecc198618933dc8381a01b809c146826db7 Files touched: * core/physics/identity.py — new SurfacePreferences dataclass; IdentityManifold gains 'surface_preferences' field with defaults. * packs/identity/loader.py — _build_surface_preferences() parses, bounds-checks (threshold ordering, claim_strength enum, phrase length, threshold ranges); SurfacePreferences round-trips. * generate/surface.py — SurfaceContext gains 7 new fields with defaults matching the pre-ADR module-level HEDGE_STRONG_THRESHOLD / HEDGE_SOFT_THRESHOLD; _apply_hedge takes the full context and implements the four-band algorithm; module-level constants retained for back-compat. * chat/runtime.py — _build_surface_context lifts manifold.surface_preferences into SurfaceContext. * packs/identity/*.json — three v1 packs gain surface_preferences blocks tuned to their roles; re-ratified via scripts/ratify_identity_packs.py (idempotent). * tests/test_identity_surface_divergence.py — 15 tests covering hedge bands, claim_strength bands, pack-swap divergence proof, and runtime context wiring. Suite status: cognition 121, teaching 17, runtime 19, formation 182, smoke 67 — all green. test_identity_packs.py 23/23, new test_identity_surface_divergence.py 15/15. Docs: ADR-0028 (Accepted) records the decision and verification; ADR-0027 status updated to point to ADR-0028 for deep realizer wiring; README §Identity Packs notes the visible divergence; docs/identity_packs.md gains a §Surface preferences section and closes the known-limit #1 about invisible surface differentiation. --- README.md | 2 + chat/runtime.py | 8 + core/physics/identity.py | 29 +++ docs/decisions/ADR-0027-identity-packs.md | 2 +- .../ADR-0028-identity-surface-wiring.md | 113 ++++++++++++ docs/identity_packs.md | 36 +++- generate/surface.py | 51 +++++- packs/identity/default_general_v1.json | 11 +- .../default_general_v1.mastery_report.json | 10 +- packs/identity/generosity_first_v1.json | 11 +- .../generosity_first_v1.mastery_report.json | 10 +- packs/identity/loader.py | 102 ++++++++++- packs/identity/precision_first_v1.json | 11 +- .../precision_first_v1.mastery_report.json | 10 +- tests/test_identity_surface_divergence.py | 171 ++++++++++++++++++ 15 files changed, 547 insertions(+), 30 deletions(-) create mode 100644 docs/decisions/ADR-0028-identity-surface-wiring.md create mode 100644 tests/test_identity_surface_divergence.py diff --git a/README.md b/README.md index 19bab5ec..74426f82 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,8 @@ CORE's identity is load-bearing: every reasoning trajectory is scored against an 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 `. +[ADR-0028](docs/decisions/ADR-0028-identity-surface-wiring.md) makes the swap *visibly load-bearing*: each pack carries a `surface_preferences` block (hedge thresholds, hedge phrases, claim-strength policy) consumed by the assembler. On the same prompt at the same alignment, `precision_first_v1` hedges sooner with "Arguably," / "In some cases," while `generosity_first_v1` leaves the assertion bare — see `tests/test_identity_surface_divergence.py` for the proof. + 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). --- diff --git a/chat/runtime.py b/chat/runtime.py index 8a80972f..e8ec3150 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -287,12 +287,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 + prefs = self.identity_manifold.surface_preferences 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", identity_alignment=alignment, valence_delta=current_valence - self._last_valence, elab_conjunction="", + 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, ) def _stub_response(self, field_state: FieldState) -> ChatResponse: diff --git a/core/physics/identity.py b/core/physics/identity.py index dec2ac97..78450ef5 100644 --- a/core/physics/identity.py +++ b/core/physics/identity.py @@ -64,12 +64,41 @@ class IdentityScore: return sorted(self.deviation_axes) +@dataclass(frozen=True) +class SurfacePreferences: + """Pack-supplied surface phrasing preferences (ADR-0028). + + Drives the assembler's hedge and claim-strength decisions so that + swapping identity packs produces visibly different surfaces on the + same prompt. Defaults preserve the pre-ADR-0028 behavior: the + legacy ``HEDGE_STRONG_THRESHOLD`` / ``HEDGE_SOFT_THRESHOLD`` + constants and the canned ``"It seems that"`` / ``"Perhaps"`` hedges. + + ``claim_strength`` semantics: + + * ``"balanced"`` — no claim-strength effect outside the hedge band. + * ``"qualified"`` — when alignment falls in + ``[hedge_threshold_soft, qualified_band_high)``, prepend + ``preferred_qualifier`` instead of leaving the surface bare. + * ``"affirmative"`` — never qualify in the marginal band; let the + assertion stand. + """ + hedge_threshold_strong: float = 0.40 + hedge_threshold_soft: float = 0.50 + preferred_hedge_strong: str = "It seems that" + preferred_hedge_soft: str = "Perhaps" + claim_strength: str = "balanced" + qualified_band_high: float = 0.75 + preferred_qualifier: str = "In some cases," + + @dataclass(frozen=True) class IdentityManifold: """Fixed geometric subspace encoding CORE's stable character.""" value_axes: Tuple = () # Tuple[ValueAxis, ...] boundary_ids: FrozenSet[str] = frozenset() alignment_threshold: float = 0.45 + surface_preferences: SurfacePreferences = SurfacePreferences() class IdentityCheck: diff --git a/docs/decisions/ADR-0027-identity-packs.md b/docs/decisions/ADR-0027-identity-packs.md index 9119a9ca..b1c3675f 100644 --- a/docs/decisions/ADR-0027-identity-packs.md +++ b/docs/decisions/ADR-0027-identity-packs.md @@ -1,6 +1,6 @@ # ADR-0027: Identity Packs — Load-Bearing, Swappable, Ratified -**Status:** Accepted (2026-05-17) — Phases 1–6 complete; Phase 7 (this doc + the operational reference) complete; deep realizer wiring tracked as a follow-up ADR. +**Status:** Accepted (2026-05-17) — Phases 1–6 complete; Phase 7 (this doc + the operational reference) complete; deep realizer wiring landed under [ADR-0028](ADR-0028-identity-surface-wiring.md) (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) diff --git a/docs/decisions/ADR-0028-identity-surface-wiring.md b/docs/decisions/ADR-0028-identity-surface-wiring.md new file mode 100644 index 00000000..bc73c00d --- /dev/null +++ b/docs/decisions/ADR-0028-identity-surface-wiring.md @@ -0,0 +1,113 @@ +# ADR-0028: Identity Surface Wiring — Pack-Driven Hedge & Claim Strength + +**Status:** Accepted (2026-05-17) +**Author:** Joshua Shay + planner pass +**Companion docs:** [`identity_packs.md`](../identity_packs.md), [`ADR-0027-identity-packs.md`](ADR-0027-identity-packs.md), [`runtime_contracts.md`](../runtime_contracts.md) + +## Context + +ADR-0027 landed swappable identity packs and wired them into the runtime through `chat/runtime.py::ChatRuntime`. The identity manifold is now load-bearing in two ways: `PersonaMotor.from_identity_manifold` biases every field walk, and `IdentityCheck.check()` produces an `IdentityScore` whose `alignment` field is passed to the surface assembler as `SurfaceContext.identity_alignment`. + +But the *surface* effect of swapping packs is invisible. The pre-ADR-0028 `_apply_hedge` consults only the alignment scalar: + +```python +if alignment < 0.4: return f"It seems that {surface}" +if alignment < 0.5: return f"Perhaps {surface}" +return surface +``` + +So `default_general_v1` and `precision_first_v1` on the same prompt produce the same hedge decision — both manifolds get hit with similar alignment scores by ordinary input. The pack swap changes upstream scoring and motor bias but does not change the assembled string, which is the user-visible artifact. + +This is the "known limit 1" recorded in [`identity_packs.md`](../identity_packs.md): *"Identity does not yet visibly differentiate articulation at the realizer."* ADR-0028 closes that gap. + +## Decision + +The pack carries surface-shaping preferences alongside its axes. The assembler consults those preferences at the same site it currently consults `alignment`. No upstream pipeline code changes. No motor / field-bias / generation-walk code changes. No CLAUDE.md invariant is touched: the change is deterministic, surface-only, contains no sampling, no normalization, no hot-path repair. + +### Schema extension (pack v1) + +A new optional `surface_preferences` block on the identity pack: + +```json +"surface_preferences": { + "hedge_threshold_strong": 0.40, + "hedge_threshold_soft": 0.50, + "preferred_hedge_strong": "It seems that", + "preferred_hedge_soft": "Perhaps", + "claim_strength": "balanced", + "qualified_band_high": 0.75, + "preferred_qualifier": "In some cases," +} +``` + +The block is **optional** — absent it, the loader supplies defaults that reproduce pre-ADR-0028 behavior byte-for-byte. This keeps the schema bump backwards-compatible at the *pack format* level even though every pack's canonical SHA changes when the block is added. + +### Surface algorithm + +Three nested bands ordered by descending hedge strength. Given an alignment scalar `a` and pack preferences `prefs`: + +1. **Strong hedge:** `a < prefs.hedge_threshold_strong` → prepend `prefs.preferred_hedge_strong`. +2. **Soft hedge:** `a < prefs.hedge_threshold_soft` → prepend `prefs.preferred_hedge_soft`. +3. **Marginal band:** `prefs.hedge_threshold_soft <= a < prefs.qualified_band_high`. Behavior depends on `claim_strength`: + - `"qualified"` → prepend `prefs.preferred_qualifier`. + - `"affirmative"` → leave bare. + - `"balanced"` → leave bare. +4. **Above marginal band:** `a >= prefs.qualified_band_high` → leave bare regardless of `claim_strength`. + +The thresholds must satisfy `hedge_threshold_strong <= hedge_threshold_soft <= qualified_band_high`; the loader enforces this ordering. + +### Three shipping pack profiles + +| Pack | strong | soft | qual_high | claim_strength | Hedge phrases | +|---|---|---|---|---|---| +| `default_general_v1` | 0.40 | 0.50 | 0.75 | balanced | "It seems that" / "Perhaps" / qualifier unused | +| `precision_first_v1` | 0.55 | 0.70 | 0.85 | qualified | "Arguably," / "In some cases," / "Under certain conditions," | +| `generosity_first_v1` | 0.20 | 0.30 | 0.50 | affirmative | "It seems that" / "Perhaps" / qualifier unused | + +Result: at `alignment = 0.45`, default and precision both hedge but with different phrases; generosity leaves bare. At `alignment = 0.80`, precision qualifies; default and generosity leave bare. Visible divergence on identical trajectories — proven by `tests/test_identity_surface_divergence.py`. + +### Implementation + +- `core/physics/identity.py`: new `SurfacePreferences` dataclass; `IdentityManifold` gains a `surface_preferences: SurfacePreferences = SurfacePreferences()` field with defaults that reproduce pre-ADR behavior. +- `packs/identity/loader.py`: `_build_surface_preferences()` parses and bounds-checks the new block; missing block uses defaults; threshold ordering enforced; `claim_strength` constrained to `{"balanced", "qualified", "affirmative"}`. +- `generate/surface.py`: `SurfaceContext` gains seven new fields (defaults preserve pre-ADR behavior); `_apply_hedge` takes the full context, not just a float, and implements the four-band algorithm above; the legacy module-level `HEDGE_STRONG_THRESHOLD` / `HEDGE_SOFT_THRESHOLD` constants are retained as the default values for `SurfaceContext` so existing imports still resolve. +- `chat/runtime.py::ChatRuntime._build_surface_context`: lifts `self.identity_manifold.surface_preferences` into the constructed `SurfaceContext`. +- `packs/identity/*.json`: three v1 packs gain `surface_preferences` blocks tuned to their roles. +- `scripts/ratify_identity_packs.py`: no change needed; runs again idempotently. Pack body changes → `pack_source_sha` changes → MasteryReport regenerated → companion `.mastery_report.json` rewritten → embedded `mastery_report_sha256` updated. + +### Backward compatibility + +- **Pack format.** Packs without `surface_preferences` continue to load and produce pre-ADR behavior. +- **In-code `SurfaceContext()` construction.** Callers who instantiate `SurfaceContext()` directly (without going through `_build_surface_context`) get default values that reproduce pre-ADR behavior. The legacy module-level constants `HEDGE_STRONG_THRESHOLD = 0.4` and `HEDGE_SOFT_THRESHOLD = 0.5` are preserved as the defaults for those fields, so any test or code that imports those names continues to work. +- **Re-ratification cost.** The three v1 packs are re-ratified once when the new block is added. Their MasteryReport SHAs change (this is expected). The previous SHAs are recorded in ADR-0027 §"Phase 5"; the new SHAs are recorded in `docs/identity_packs.md` §"Shipping packs (v1)" and in the ADR-0027 phase table. + +## Consequences + +### Positive + +- **The identity claim is now visibly load-bearing at the surface layer.** Pack swap → different assembled string on the same prompt. The `tests/test_identity_surface_divergence.py::TestPackSwapDivergence::test_same_alignment_different_surfaces` asserts this explicitly. +- **The realizer remains deterministic.** No new operator, no normalization, no sampling, no clock, no PID, no hash-randomization. Same `(prompt, alignment, pack)` triple → same surface bytes. +- **The pack format remains optional-block backward-compatible.** Authors writing new packs may omit `surface_preferences` if defaults suit them. +- **The divergence test makes regressions loud.** Any future change that re-routes `_apply_hedge` or strips the SurfaceContext fields will fail `test_same_alignment_different_surfaces` immediately. + +### Negative / risks + +- **Schema bump invalidates the three Phase-5 MasteryReports.** Cost paid once; re-ratification handled by the existing idempotent script. The previously-ratified SHAs from 2026-05-17 are superseded. +- **English-only differentiation.** `_assemble_he` and `_assemble_grc` do not currently consult `SurfaceContext` for hedge/claim-strength shaping; they call neither `_apply_hedge` nor anything sensitive to the new fields. Per-language hedging is a future concern; identity packs are language-neutral, so the same preferences will eventually drive the same logic in `_assemble_he` and `_assemble_grc` once those gain hedge support. +- **The marginal-band qualifier only fires for `claim_strength="qualified"`.** A future axis profile that wants to *expand* claims in the marginal band (e.g., add "Indeed," before a confident assertion) would require either a new `claim_strength` value or a separate field. Out of scope for ADR-0028. +- **`SurfaceContext` is now bigger.** Seven new fields. The dataclass remains frozen+slots so the cost is small, but every construction site (including tests that build a `SurfaceContext` directly) must accept that the defaults are non-trivial. We rely on the defaults reproducing pre-ADR behavior; the cognition / runtime / smoke suites verify this. + +### Scope limits (explicit non-goals for this ADR) + +- No depth-language (Hebrew, Koine Greek) hedging. Future work; new ADR. +- No `scope`, `qualification_level`, `modal_style`, or `hedge_preferences` (list) fields from the eval-layer YAMLs. Future work. +- No surface-side differentiation by axis-id (e.g., "the truthfulness axis is dominant → use evidential phrasing"). Future work, and likely requires a richer score-decomposition surface than today's scalar alignment. +- No new CLI verb. `core chat --identity ` already exists. + +## Verification + +This ADR is satisfied when: +- `tests/test_identity_surface_divergence.py` passes, in particular `TestPackSwapDivergence::test_same_alignment_different_surfaces`. +- All three v1 packs ratify cleanly under the v1 schema with the `surface_preferences` block and load in production mode (`require_ratified=None`). +- The cognition / runtime / smoke / formation / teaching suites are green at the same revision. +- `tests/test_identity_packs.py` continues to pass (loader bounds checks, runtime wiring, ratification-script idempotency, tamper detection). diff --git a/docs/identity_packs.md b/docs/identity_packs.md index a8ba2e2d..4ae65202 100644 --- a/docs/identity_packs.md +++ b/docs/identity_packs.md @@ -63,11 +63,39 @@ A single JSON file. Strings, ints, bools, lists, dicts only — same canonical-J | `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"`. | +| `surface_preferences` | no | Pack-supplied surface hedge / claim-strength shaping (ADR-0028). Defaults preserve pre-ADR behavior. See §"Surface preferences" below. | | `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`. | +### Surface preferences (ADR-0028) + +Optional block driving the assembler's hedge and claim-strength decisions: + +```json +"surface_preferences": { + "hedge_threshold_strong": 0.40, + "hedge_threshold_soft": 0.50, + "preferred_hedge_strong": "It seems that", + "preferred_hedge_soft": "Perhaps", + "claim_strength": "balanced", + "qualified_band_high": 0.75, + "preferred_qualifier": "In some cases," +} +``` + +Bands (in descending hedge strength): + +1. `alignment < hedge_threshold_strong` → prepend `preferred_hedge_strong`. +2. `alignment < hedge_threshold_soft` → prepend `preferred_hedge_soft`. +3. `hedge_threshold_soft <= alignment < qualified_band_high` and `claim_strength == "qualified"` → prepend `preferred_qualifier`. +4. Otherwise leave the assertion bare. + +Threshold ordering required: `hedge_threshold_strong <= hedge_threshold_soft <= qualified_band_high`. Loader enforces this. + +`claim_strength` must be one of `{"balanced", "qualified", "affirmative"}`. `"balanced"` and `"affirmative"` skip the marginal-band qualifier; only `"qualified"` triggers it. + ### Loader bounds (enforced) - `len(value_axes) >= 1` — empty axes are refused. @@ -118,9 +146,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. Ratified: `0b77357fe4359f161d7ca72f184b6e0db2f9e2de16b32c237a3b80d2bbb005b4`. | -| `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). Ratified: `5f5000dba9a0dd19d831e9ab5d3c0e3b9faf6abdc2648940e96aa6263af3302e`. | -| `generosity_first_v1` | Specialization example B. | Boosts `coherence` weight, broadens reverence direction. Source: `evals/identity_divergence/axes/axis_b.yaml`. Ratified: `91716117558113f74b2c6d07a804cb324f262d62b743523d901d1386a4f85ae4`. | +| `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`. | 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). @@ -141,7 +169,7 @@ Each ratified pack ships alongside a `.mastery_report.json` companion f ## 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. +1. ~~**Identity does not yet visibly differentiate articulation at the realizer.**~~ **Closed by [ADR-0028](decisions/ADR-0028-identity-surface-wiring.md) (2026-05-17).** Pack `surface_preferences` now flow into the assembler's hedge and claim-strength decisions. `core chat --identity precision_first_v1 "Q"` produces a visibly different surface than the default pack on the same prompt at the same alignment. Hedging is English-only at v1; depth-language hedging is a future ADR. 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. diff --git a/generate/surface.py b/generate/surface.py index 97eb94b9..5f1acb79 100644 --- a/generate/surface.py +++ b/generate/surface.py @@ -17,8 +17,16 @@ _STOP_SURFACES: frozenset[str] = frozenset({ "why", "when", "which", "that", "this", "these", "those", }) _MAX_ELAB_TOKENS: int = 4 +# Legacy default thresholds — used when SurfaceContext is constructed +# without pack-supplied identity surface preferences. Identical to the +# pre-ADR-0028 hardcoded values; do not change without bumping every +# downstream test that asserts on these constants. HEDGE_STRONG_THRESHOLD: float = 0.4 HEDGE_SOFT_THRESHOLD: float = 0.5 +_DEFAULT_HEDGE_STRONG_PHRASE: str = "It seems that" +_DEFAULT_HEDGE_SOFT_PHRASE: str = "Perhaps" +_DEFAULT_QUALIFIER_PHRASE: str = "In some cases," +_DEFAULT_QUALIFIED_BAND_HIGH: float = 0.75 CONTRAST_THRESHOLD: float = 0.3 _SLOT_PRONOUN: dict[str, str] = { "neut_sg": "it", @@ -35,6 +43,18 @@ class SurfaceContext: identity_alignment: float = 1.0 valence_delta: float = 0.0 elab_conjunction: str = "" + # ADR-0028 — pack-supplied identity surface preferences. Defaults + # preserve the pre-ADR ``_apply_hedge`` behavior byte-for-byte when + # the chat runtime is constructed without an identity-pack manifold + # (no test should ever exercise that path post-ADR-0027, but the + # defaults keep ``SurfaceContext()`` legal and harmless). + hedge_threshold_strong: float = HEDGE_STRONG_THRESHOLD + hedge_threshold_soft: float = HEDGE_SOFT_THRESHOLD + preferred_hedge_strong: str = _DEFAULT_HEDGE_STRONG_PHRASE + preferred_hedge_soft: str = _DEFAULT_HEDGE_SOFT_PHRASE + claim_strength: str = "balanced" + qualified_band_high: float = _DEFAULT_QUALIFIED_BAND_HIGH + preferred_qualifier: str = _DEFAULT_QUALIFIER_PHRASE @dataclass(frozen=True, slots=True) @@ -100,11 +120,30 @@ def _lower_first(surface: str) -> str: return surface[0].lower() + surface[1:] if surface else surface -def _apply_hedge(surface: str, alignment: float) -> str: - if alignment < HEDGE_STRONG_THRESHOLD: - return f"It seems that {_lower_first(surface)}" - if alignment < HEDGE_SOFT_THRESHOLD: - return f"Perhaps {_lower_first(surface)}" +def _apply_hedge(surface: str, ctx: SurfaceContext) -> str: + """Apply identity-pack-supplied hedge and claim-strength shaping. + + Bands, in descending hedge strength: + + 1. ``alignment < hedge_threshold_strong`` → strong hedge phrase. + 2. ``alignment < hedge_threshold_soft`` → soft hedge phrase. + 3. Otherwise, in the *marginal* band + ``[hedge_threshold_soft, qualified_band_high)``: + - ``claim_strength == "qualified"`` → prepend ``preferred_qualifier``. + - ``claim_strength == "affirmative"`` → leave assertion bare. + - ``claim_strength == "balanced"`` → leave assertion bare. + 4. Above ``qualified_band_high`` → leave assertion bare. + """ + alignment = ctx.identity_alignment + if alignment < ctx.hedge_threshold_strong: + return f"{ctx.preferred_hedge_strong} {_lower_first(surface)}" + if alignment < ctx.hedge_threshold_soft: + return f"{ctx.preferred_hedge_soft} {_lower_first(surface)}" + if ( + ctx.claim_strength == "qualified" + and alignment < ctx.qualified_band_high + ): + return f"{ctx.preferred_qualifier} {_lower_first(surface)}" return surface @@ -160,7 +199,7 @@ def _assemble_en( surface = _apply_subordination(surface, role, ctx, lang="en") if ctx is not None: surface = _apply_contrast(surface, ctx.valence_delta) - surface = _apply_hedge(surface, ctx.identity_alignment) + surface = _apply_hedge(surface, ctx) return surface diff --git a/packs/identity/default_general_v1.json b/packs/identity/default_general_v1.json index ed29d6a9..024127af 100644 --- a/packs/identity/default_general_v1.json +++ b/packs/identity/default_general_v1.json @@ -3,12 +3,21 @@ "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": "0b77357fe4359f161d7ca72f184b6e0db2f9e2de16b32c237a3b80d2bbb005b4", + "mastery_report_sha256": "ddc1ba127231272660e6a435e177227558461b0278572a95635b416c3e1dec5a", "alignment_threshold": 0.45, "boundary_ids": [ "no_fabricated_source", "no_hot_path_repair" ], + "surface_preferences": { + "hedge_threshold_strong": 0.4, + "hedge_threshold_soft": 0.5, + "preferred_hedge_strong": "It seems that", + "preferred_hedge_soft": "Perhaps", + "claim_strength": "balanced", + "qualified_band_high": 0.75, + "preferred_qualifier": "In some cases," + }, "value_axes": [ { "axis_id": "truthfulness", diff --git a/packs/identity/default_general_v1.mastery_report.json b/packs/identity/default_general_v1.mastery_report.json index 46e22bd7..a15a7cb9 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": "e9372f6d4cac876cf5eab46dcd4b547fae1d9fe01d96e9370754c251a6bdf8c7", + "course_sha256": "57295c81036dde60d2108c8c036a2ba5c7cc34c3a6079bc6828db36d37769cca", "failure_reasons": [], "gates": [ { @@ -41,16 +41,16 @@ } ], "issued_at": "2026-05-17T00:00:00Z", - "plan_sha256": "31c92f68bce9f725d1d69421e4a3c611928e1ef745b900f91655f09f0d766d74", + "plan_sha256": "48b5a5b757feae5580387851f4a7c568d2a68b99b3599117f0429f2d2275239b", "ratified": true, - "report_sha256": "0b77357fe4359f161d7ca72f184b6e0db2f9e2de16b32c237a3b80d2bbb005b4", + "report_sha256": "ddc1ba127231272660e6a435e177227558461b0278572a95635b416c3e1dec5a", "schema_version": "1.0.0", - "source_bundle_sha": "a954cdce25f86e681f6f51bed1a2196441d7a7dec4f0e69ecf84502664c938e0", + "source_bundle_sha": "4bbade402c02486e99d5f3c3692039ba36cd29483bf7f6c895e5e35dd97da66a", "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": "32e83f9513004daa743180f00711dd47f841c782679596512fe519143a96b2e0" + "validated_set_sha": "bb45f6d2d2a32cf537298a9f51a367149cbe60457317cf061f892feccfaae49e" } diff --git a/packs/identity/generosity_first_v1.json b/packs/identity/generosity_first_v1.json index a313de5a..4c74c50b 100644 --- a/packs/identity/generosity_first_v1.json +++ b/packs/identity/generosity_first_v1.json @@ -3,12 +3,21 @@ "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": "91716117558113f74b2c6d07a804cb324f262d62b743523d901d1386a4f85ae4", + "mastery_report_sha256": "94f2f49e1b16c7498fb52b8f9864eecc198618933dc8381a01b809c146826db7", "alignment_threshold": 0.4, "boundary_ids": [ "no_fabricated_source", "no_hot_path_repair" ], + "surface_preferences": { + "hedge_threshold_strong": 0.2, + "hedge_threshold_soft": 0.3, + "preferred_hedge_strong": "It seems that", + "preferred_hedge_soft": "Perhaps", + "claim_strength": "affirmative", + "qualified_band_high": 0.5, + "preferred_qualifier": "In some cases," + }, "value_axes": [ { "axis_id": "truthfulness", diff --git a/packs/identity/generosity_first_v1.mastery_report.json b/packs/identity/generosity_first_v1.mastery_report.json index d46a416b..f9a9636f 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": "7ab413aa4bde72f1c3ea960d87facb7a6fe4584c726793d9fb658a00168683c8", + "course_sha256": "c49ff0a29000e600719a5de993a0cfde017c9c57eb1e106337a3dfe4241e72f1", "failure_reasons": [], "gates": [ { @@ -41,16 +41,16 @@ } ], "issued_at": "2026-05-17T00:00:00Z", - "plan_sha256": "10f248704d9220c69a220ca9a24a5494f2756c1d245346217b14abbe57240cd5", + "plan_sha256": "dc463aaf7cd81bbc7b4d6561dcb1ecd4e3c33292c702f96e0bbe9b68bded4ab4", "ratified": true, - "report_sha256": "91716117558113f74b2c6d07a804cb324f262d62b743523d901d1386a4f85ae4", + "report_sha256": "94f2f49e1b16c7498fb52b8f9864eecc198618933dc8381a01b809c146826db7", "schema_version": "1.0.0", - "source_bundle_sha": "56cd97a8449005468af1b8b683318bd61e860aac4dbe689cbc48c28e97e153ce", + "source_bundle_sha": "61d9b0987c1a419141bd7749e56b41dd367d9058176e3223cf6ce95cd245fa1e", "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": "51df14de3b53119c4b179da371b6474e59c3cd7e48796c8edb53b597fe34fb9c" + "validated_set_sha": "7c4b27176349b9d5c9f2d4722c37ff1d69e6a9b503627c230c3630447758e8a4" } diff --git a/packs/identity/loader.py b/packs/identity/loader.py index c6667b56..dde8ffa7 100644 --- a/packs/identity/loader.py +++ b/packs/identity/loader.py @@ -25,7 +25,7 @@ import os from pathlib import Path from typing import Iterable -from core.physics.identity import IdentityManifold, ValueAxis +from core.physics.identity import IdentityManifold, SurfacePreferences, ValueAxis from formation.hashing import verify_seal @@ -79,10 +79,14 @@ def load_identity_manifold( axes = _build_axes(raw["value_axes"], pack_id) threshold = _validate_threshold(raw["alignment_threshold"], pack_id) boundaries = frozenset(_validate_boundaries(raw["boundary_ids"], pack_id)) + surface_prefs = _build_surface_preferences( + raw.get("surface_preferences"), pack_id, + ) return IdentityManifold( value_axes=axes, boundary_ids=boundaries, alignment_threshold=threshold, + surface_preferences=surface_prefs, ) @@ -306,6 +310,102 @@ def _validate_threshold(value: object, pack_id: str) -> float: return fv +_ALLOWED_CLAIM_STRENGTHS: frozenset[str] = frozenset( + {"balanced", "qualified", "affirmative"} +) +_MIN_HEDGE_LEN: int = 1 +_MAX_HEDGE_LEN: int = 64 + + +def _build_surface_preferences( + value: object, pack_id: str, +) -> SurfacePreferences: + """Parse and bounds-check the optional ``surface_preferences`` block. + + Absent block → defaults that reproduce pre-ADR-0028 behavior. + """ + if value is None: + return SurfacePreferences() + if not isinstance(value, dict): + raise IdentityPackError( + f"pack {pack_id!r}: surface_preferences must be a dict, got " + f"{type(value).__name__}" + ) + defaults = SurfacePreferences() + strong = _validate_threshold_field( + value.get("hedge_threshold_strong", defaults.hedge_threshold_strong), + pack_id, "hedge_threshold_strong", + ) + soft = _validate_threshold_field( + value.get("hedge_threshold_soft", defaults.hedge_threshold_soft), + pack_id, "hedge_threshold_soft", + ) + band_high = _validate_threshold_field( + value.get("qualified_band_high", defaults.qualified_band_high), + pack_id, "qualified_band_high", + ) + if not (strong <= soft <= band_high): + raise IdentityPackError( + f"pack {pack_id!r}: surface_preferences thresholds must satisfy " + f"hedge_threshold_strong ({strong}) <= " + f"hedge_threshold_soft ({soft}) <= " + f"qualified_band_high ({band_high})" + ) + claim_strength = str( + value.get("claim_strength", defaults.claim_strength) + ) + if claim_strength not in _ALLOWED_CLAIM_STRENGTHS: + raise IdentityPackError( + f"pack {pack_id!r}: claim_strength={claim_strength!r} not in " + f"{sorted(_ALLOWED_CLAIM_STRENGTHS)}" + ) + return SurfacePreferences( + hedge_threshold_strong=strong, + hedge_threshold_soft=soft, + preferred_hedge_strong=_validate_hedge_phrase( + value.get("preferred_hedge_strong", defaults.preferred_hedge_strong), + pack_id, "preferred_hedge_strong", + ), + preferred_hedge_soft=_validate_hedge_phrase( + value.get("preferred_hedge_soft", defaults.preferred_hedge_soft), + pack_id, "preferred_hedge_soft", + ), + claim_strength=claim_strength, + qualified_band_high=band_high, + preferred_qualifier=_validate_hedge_phrase( + value.get("preferred_qualifier", defaults.preferred_qualifier), + pack_id, "preferred_qualifier", + ), + ) + + +def _validate_threshold_field(value: object, pack_id: str, field: str) -> float: + if not isinstance(value, (int, float)): + raise IdentityPackError( + f"pack {pack_id!r}: surface_preferences.{field} must be numeric" + ) + fv = float(value) + if not _THRESHOLD_LO <= fv <= _THRESHOLD_HI: + raise IdentityPackError( + f"pack {pack_id!r}: surface_preferences.{field}={fv} out of " + f"bounds [{_THRESHOLD_LO}, {_THRESHOLD_HI}]" + ) + return fv + + +def _validate_hedge_phrase(value: object, pack_id: str, field: str) -> str: + if not isinstance(value, str): + raise IdentityPackError( + f"pack {pack_id!r}: surface_preferences.{field} must be a string" + ) + if not _MIN_HEDGE_LEN <= len(value) <= _MAX_HEDGE_LEN: + raise IdentityPackError( + f"pack {pack_id!r}: surface_preferences.{field} length " + f"{len(value)} out of bounds [{_MIN_HEDGE_LEN}, {_MAX_HEDGE_LEN}]" + ) + return value + + def _validate_boundaries(value: object, pack_id: str) -> list[str]: if not isinstance(value, list): raise IdentityPackError( diff --git a/packs/identity/precision_first_v1.json b/packs/identity/precision_first_v1.json index e0bcc787..e989e80a 100644 --- a/packs/identity/precision_first_v1.json +++ b/packs/identity/precision_first_v1.json @@ -3,13 +3,22 @@ "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": "5f5000dba9a0dd19d831e9ab5d3c0e3b9faf6abdc2648940e96aa6263af3302e", + "mastery_report_sha256": "cb5fb2323214a26afda33f2a67e22f38fe49f4763829d48ef67fd41241aba33c", "alignment_threshold": 0.55, "boundary_ids": [ "no_fabricated_source", "no_hot_path_repair", "no_overstatement" ], + "surface_preferences": { + "hedge_threshold_strong": 0.55, + "hedge_threshold_soft": 0.7, + "preferred_hedge_strong": "Arguably,", + "preferred_hedge_soft": "In some cases,", + "claim_strength": "qualified", + "qualified_band_high": 0.85, + "preferred_qualifier": "Under certain conditions," + }, "value_axes": [ { "axis_id": "truthfulness", diff --git a/packs/identity/precision_first_v1.mastery_report.json b/packs/identity/precision_first_v1.mastery_report.json index 6aab8162..26c6a5a6 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": "2c2e0d3e878e9a4e38fbb135c331543221bf82baf543d95788f5e5bfb088386c", + "course_sha256": "eb2b054ad3a621c062c746cbedc2a825e08a490c84bc23343b24a679601aaacd", "failure_reasons": [], "gates": [ { @@ -41,16 +41,16 @@ } ], "issued_at": "2026-05-17T00:00:00Z", - "plan_sha256": "97cd23bc54a1b0d3868986fde82f24475cb5e54994f2a2937e53b3c54263be71", + "plan_sha256": "7f83866948249e19081930b573b75a9fe24ec49bd9ff2f67f35d6f4d9e683b7d", "ratified": true, - "report_sha256": "5f5000dba9a0dd19d831e9ab5d3c0e3b9faf6abdc2648940e96aa6263af3302e", + "report_sha256": "cb5fb2323214a26afda33f2a67e22f38fe49f4763829d48ef67fd41241aba33c", "schema_version": "1.0.0", - "source_bundle_sha": "6d4b1d8cb3414b1a7d67601d53e4c4afad9ec64862627a3eb6660bd2a4ff5534", + "source_bundle_sha": "63f87f07cde290cd82fcdf43f4a685670a08942050dee516997d47f11eeb5abf", "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": "834f1ae0ee30a7d5b0b7ff9e8e9f98d5e0c805696f6920b6b4b0d8830c64427a" + "validated_set_sha": "423185f57cd0832c7af846b7e97dff83c067aa46315016defa1df45d68239ae7" } diff --git a/tests/test_identity_surface_divergence.py b/tests/test_identity_surface_divergence.py new file mode 100644 index 00000000..4de1d39d --- /dev/null +++ b/tests/test_identity_surface_divergence.py @@ -0,0 +1,171 @@ +"""ADR-0028 — verify pack swap produces visibly different surfaces. + +The pre-ADR-0028 hedge logic only consulted ``identity_alignment``. Now +every band of the hedge / claim-strength decision tree is parameterized +by the loaded identity pack's ``surface_preferences``. These tests +prove that swapping packs yields different surfaces on identical +trajectories. + +The tests bypass the cognitive pipeline and hit ``_apply_hedge`` / +``_assemble_en`` directly: we construct a :class:`SurfaceContext` +populated from each pack's preferences and check the output strings. +This isolates the surface-layer change from upstream non-determinism +(referent state, valence, etc.) so test failures point exactly at the +ADR-0028 logic. +""" + +from __future__ import annotations + +from chat.runtime import ChatRuntime +from core.config import RuntimeConfig +from generate.articulation import ArticulationPlan +from generate.surface import SentenceAssembler, SurfaceContext +from packs.identity.loader import load_identity_manifold + + +_ASSEMBLER = SentenceAssembler() + + +def _plan() -> ArticulationPlan: + return ArticulationPlan( + subject="truth", + predicate="reveals", + object="reality", + surface="", + output_language="en", + frame_id="default", + ) + + +def _ctx_from_pack(pack_id: str, alignment: float) -> SurfaceContext: + manifold = load_identity_manifold(pack_id) + prefs = manifold.surface_preferences + 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, + ) + + +def _surface(pack_id: str, alignment: float) -> str: + return _ASSEMBLER.assemble( + _plan(), tokens=[], role="assert", context=_ctx_from_pack(pack_id, alignment), + ).surface + + +# ---------- Phase A: hedge bands per pack ---------- + + +class TestHedgeBands: + def test_default_general_at_high_alignment_is_bare(self) -> None: + # alignment=0.9 is above every pack's hedge band → bare assertion. + s = _surface("default_general_v1", 0.9) + assert s == "Truth reveals reality." + + def test_default_general_strong_hedge(self) -> None: + # alignment=0.3 is below default's hedge_threshold_strong (0.40). + s = _surface("default_general_v1", 0.3) + assert s.startswith("It seems that truth") + + def test_default_general_soft_hedge(self) -> None: + # alignment=0.45 is between strong (0.40) and soft (0.50). + s = _surface("default_general_v1", 0.45) + assert s.startswith("Perhaps truth") + + def test_precision_uses_arguably_at_low_alignment(self) -> None: + # alignment=0.45 is below precision's strong (0.55). + s = _surface("precision_first_v1", 0.45) + assert s.startswith("Arguably, truth"), s + + def test_precision_uses_in_some_cases_in_soft_band(self) -> None: + # alignment=0.60 is in [0.55, 0.70) for precision. + s = _surface("precision_first_v1", 0.60) + assert s.startswith("In some cases, truth"), s + + def test_generosity_skips_hedge_at_default_band(self) -> None: + # alignment=0.45 would hedge under default; generosity's soft is + # 0.30, so 0.45 leaves the assertion bare. + s = _surface("generosity_first_v1", 0.45) + assert s == "Truth reveals reality." + + +# ---------- Phase B: claim_strength outside the hedge band ---------- + + +class TestClaimStrength: + def test_precision_qualified_band_prepends_qualifier(self) -> None: + # alignment=0.80 is above precision's soft (0.70) but below its + # qualified_band_high (0.85); claim_strength="qualified" → prepend + # preferred_qualifier ("Under certain conditions,"). + s = _surface("precision_first_v1", 0.80) + assert s.startswith("Under certain conditions, truth"), s + + def test_precision_above_qualified_band_is_bare(self) -> None: + # alignment=0.90 is above qualified_band_high (0.85) → no qualifier. + s = _surface("precision_first_v1", 0.90) + assert s == "Truth reveals reality." + + def test_default_balanced_no_qualifier_in_marginal_band(self) -> None: + # claim_strength="balanced" → no marginal-band qualifier. + s = _surface("default_general_v1", 0.60) + assert s == "Truth reveals reality." + + def test_generosity_affirmative_never_qualifies(self) -> None: + # claim_strength="affirmative" → no marginal-band qualifier. + s = _surface("generosity_first_v1", 0.40) + assert s == "Truth reveals reality." + + +# ---------- pack-swap divergence proof ---------- + + +class TestPackSwapDivergence: + def test_same_alignment_different_surfaces(self) -> None: + """The ADR-0028 promise: identical inputs, different packs, different surfaces.""" + alignment = 0.45 # in default's hedge band, outside generosity's + a = _surface("default_general_v1", alignment) + b = _surface("precision_first_v1", alignment) + c = _surface("generosity_first_v1", alignment) + assert a != b, "default vs precision should differ at alignment=0.45" + assert a != c, "default vs generosity should differ at alignment=0.45" + assert b != c, "precision vs generosity should differ at alignment=0.45" + + def test_qualified_band_only_for_precision(self) -> None: + alignment = 0.80 # above all soft thresholds; only precision qualifies here + a = _surface("default_general_v1", alignment) + b = _surface("precision_first_v1", alignment) + c = _surface("generosity_first_v1", alignment) + assert a == "Truth reveals reality." + assert b.startswith("Under certain conditions, truth") + assert c == "Truth reveals reality." + + +# ---------- runtime wiring ---------- + + +class TestRuntimeContextWiring: + def test_default_runtime_context_carries_default_prefs(self) -> None: + rt = ChatRuntime(config=RuntimeConfig()) + ctx = rt._build_surface_context(None, current_valence=0.0) + assert ctx.hedge_threshold_strong == 0.40 + assert ctx.preferred_hedge_strong == "It seems that" + assert ctx.claim_strength == "balanced" + + def test_precision_runtime_context_carries_precision_prefs(self) -> None: + rt = ChatRuntime(config=RuntimeConfig(identity_pack="precision_first_v1")) + ctx = rt._build_surface_context(None, current_valence=0.0) + assert ctx.hedge_threshold_strong == 0.55 + assert ctx.preferred_hedge_strong == "Arguably," + assert ctx.claim_strength == "qualified" + assert ctx.qualified_band_high == 0.85 + + def test_generosity_runtime_context_carries_generosity_prefs(self) -> None: + rt = ChatRuntime(config=RuntimeConfig(identity_pack="generosity_first_v1")) + ctx = rt._build_surface_context(None, current_valence=0.0) + assert ctx.hedge_threshold_soft == 0.30 + assert ctx.claim_strength == "affirmative"