diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 46a8dd7e2..dc46ba4a4 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -596,11 +596,12 @@ on those same fixed parameters (Kim, 2006 FIPC). After scoring,
information at the group's mean θ (Lord, 1980 max-info CAT). Rankings
persist to `report_item_information`. After those IRT main effects,
residual SVD leftover pairs on two Gabriel axes (Jeon et al., 2021;
-ADR 0017 / 0048 / 0049 / 0119 / 0158 / 0162 / 0163 / 0164 / 0182) persist to
-`report_leftover_pair` with signed residual `R`, observed `Y`, expected
-`E[Y|θ, item]`, full leftover-map rank, and unexplained leftover
-`U = R − R̂` named on the pair row. Leftover-map axis share (Gabriel
-inertia of residual SVD axes 1 and 2; ADR 0148) persists to
+ADR 0017 / 0048 / 0049 / 0119 / 0148 / 0158 / 0162 / 0163 / 0164 / 0168 /
+0182 / 0185) persist to `report_leftover_pair` with signed residual `R`,
+observed `Y`, expected `E[Y|θ, item]`, full leftover-map rank, unexplained
+leftover `U = R − R̂` named on the pair row, and leftover-map cross share
+`x = 2 R̂ U / R²` of raw residual. Leftover-map axis share
+(Gabriel inertia of residual SVD axes 1 and 2; ADR 0148) persists to
`report_leftover_map_axis`. Complete-case leftover-map coverage (ADR
0168) persists to `report_leftover_map_coverage` so readers see how
many scored posts entered the factorization. Results persist to
diff --git a/CHANGELOG.d/2.12.29-leftover-map-cross-share.md b/CHANGELOG.d/2.12.29-leftover-map-cross-share.md
new file mode 100644
index 000000000..0c1cf6ba5
--- /dev/null
+++ b/CHANGELOG.d/2.12.29-leftover-map-cross-share.md
@@ -0,0 +1,10 @@
+## 2.12.29 — Leftover-map cross share
+
+- Persist leftover-map cross share `x = 2 R̂ U / R²` of raw residual
+ on leftover post–criterion pairs (ADR 0185). After
+ `make seed`, closest and farthest leftover pairs sit above the
+ member list with `2R̂U/R²` next to leftover-map distance `d`; click
+ opens that post. Omit the badge when the share is missing. A signed
+ remainder is shown, never clamped. Never invent a leftover score. Do
+ not introduce leftover-map explained share `e`, unexplained share
+ `s`, or reconstruction `R̂`; ADR 0182 remains authoritative for `U`.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 94e653987..b3d7f3e71 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -42,6 +42,14 @@ All notable changes to this project are documented here. Format follows
list is captioned “Leftover map used N of M scored posts
(complete-case)”; incomplete rows stay excluded, never filled with
zero.
+- Period leftover pair rows now name leftover-map cross share
+ `x = 2 R̂ U / R²` of raw residual next to leftover-map
+ distance `d`, then open that post (Gabriel, 1971; Jeon et al., 2021,
+ eq. 3; ADR 0185). A missing share omits the badge rather than
+ inventing a leftover score. A signed remainder is shown, never
+ clamped. Two-axis reconstruction `R̂` stays internal; unexplained
+ leftover remains the ADR 0182 value `U = R − R̂`. Explained leftover share
+ `e` and unexplained leftover share `s` are not persisted here.
- The grouping comparison strip now names leftover post–criterion
pairs on each visible row (ADR 0149). After `make seed`, open a
diff --git a/backend/app/report_ingestion.py b/backend/app/report_ingestion.py
index ac65e6af2..6e127bf62 100644
--- a/backend/app/report_ingestion.py
+++ b/backend/app/report_ingestion.py
@@ -445,8 +445,8 @@ async def persist_period_report(
grouping_kind, grouping_key, period_code, rubric_version,
pair_kind, post_id, criterion_code, leftover_distance, leftover_residual,
observed_response, expected_response, leftover_map_rank,
- leftover_map_unexplained
- ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
+ leftover_map_unexplained, leftover_map_cross_share
+ ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
""",
grouping_kind,
grouping_key,
@@ -461,6 +461,7 @@ async def persist_period_report(
pair.expected_response,
pair.leftover_map_rank,
pair.leftover_map_unexplained,
+ pair.leftover_map_cross_share,
)
for axis in report.leftover_map_axes:
await conn.execute(
@@ -646,7 +647,7 @@ async def fetch_period_reports(
select lp.grouping_key, lp.pair_kind, lp.post_id, lp.criterion_code,
lp.leftover_distance, lp.leftover_residual,
lp.observed_response, lp.expected_response, lp.leftover_map_rank,
- lp.leftover_map_unexplained, p.post_title,
+ lp.leftover_map_unexplained, lp.leftover_map_cross_share, p.post_title,
p.visibility_code, p.corporate_entity_id,
({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context
from report_leftover_pair lp
@@ -790,6 +791,11 @@ async def fetch_period_reports(
if row["leftover_map_unexplained"] is None
else float(row["leftover_map_unexplained"])
),
+ "leftover_map_cross_share": (
+ None
+ if row["leftover_map_cross_share"] is None
+ else float(row["leftover_map_cross_share"])
+ ),
"visibility_code": row["visibility_code"],
"corporate_entity_id": str(row["corporate_entity_id"]),
"has_real_source_context": bool(row["has_real_source_context"]),
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index ac546b63b..a14dba373 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -12,6 +12,7 @@
from __future__ import annotations
+import math
import os
import uuid
from contextlib import closing
@@ -142,6 +143,11 @@
/ "migrations"
/ "0164_report_leftover_map_rank.sql"
)
+_LEFTOVER_MAP_CROSS_SHARE_MIGRATION = (
+ Path(__file__).resolve().parents[2]
+ / "migrations"
+ / "0185_report_leftover_map_cross_share.sql"
+)
_GLOBAL_ASK_JOB_MIGRATION = (
Path(__file__).resolve().parents[2]
/ "migrations"
@@ -305,6 +311,7 @@ def seeded_db(demo_analyst_token):
cur.execute(_GLOBAL_ASK_JOB_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_AXIS_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_UNEXPLAINED_MIGRATION.read_text())
+ cur.execute(_LEFTOVER_MAP_CROSS_SHARE_MIGRATION.read_text())
cur.execute(
"insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values "
"('corporate_entity_level', 'group', 'Group'), "
@@ -5049,9 +5056,16 @@ def test_seed_period_report_surfaces_on_get_reports(client, demo_analyst_token,
assert "leftover_map_reconstruction" not in pair
observed = pair.get("observed_response")
expected = pair.get("expected_response")
- if observed is None or expected is None:
- continue
- assert abs(pair["leftover_residual"] - (observed - expected)) < 1e-6
+ if observed is not None and expected is not None:
+ assert abs(pair["leftover_residual"] - (observed - expected)) < 1e-6
+ share = pair.get("leftover_map_cross_share")
+ assert share is None or isinstance(share, (int, float))
+ if share is not None:
+ assert not math.isnan(share)
+ assert not math.isinf(share)
+ assert "leftover_map_explained_share" not in pair
+ assert "leftover_map_unexplained_share" not in pair
+ assert "leftover_map_reconstruction" not in pair
leftover_axes = high_report.get("leftover_map_axes", [])
assert [axis["axis_index"] for axis in leftover_axes] == [1, 2]
assert all(axis["leftover_singular_value"] >= 0 for axis in leftover_axes)
diff --git a/docs/adr/0003-fast-mlsirm-report-integration.md b/docs/adr/0003-fast-mlsirm-report-integration.md
index ed4ca22c8..455f3d90f 100644
--- a/docs/adr/0003-fast-mlsirm-report-integration.md
+++ b/docs/adr/0003-fast-mlsirm-report-integration.md
@@ -100,13 +100,16 @@ than one large PR:
`information_polytomous` (Lord, 1980 max-info). Persist the ranking
(`report_item_information`) and show the rank-1 item on the Period
reports panel. Do not reimplement an information function here.
-7. **Leftover-pair slice** (shipped in 0.71.2; ADR 0017 / 0018 / 0048 / 0049 / 0182): after
- IRT main effects, persist closest and farthest post–criterion pairs
- from the residual leftover map, and name unexplained leftover
- `U = R − R̂` when Gabriel coordinates exist so the leftover cell the
- two-axis map does not reconstruct is not read as leftover residual
- or leftover-map distance. Do not persist two-axis reconstruction
- `R̂`. Do not fork LSIRM; do not invent a
+7. **Leftover-pair slice** (shipped in 0.71.2; ADR 0017 / 0018 / 0048 / 0049 /
+ 0182 / 0185): after IRT main effects, persist closest and farthest
+ post–criterion pairs from the residual leftover map, and name
+ unexplained leftover `U = R − R̂` and leftover-map cross share
+ `x = 2 R̂_c U_c / R̃²` of centered leftover when Gabriel coordinates
+ exist so the identity remainder after two-axis reconstruction is not
+ read as leftover residual, leftover-map distance, explained leftover
+ share, or unexplained leftover share. Do not persist leftover-map
+ explained leftover share `e`, unexplained leftover share `s`, or
+ two-axis reconstruction `R̂`. Do not fork LSIRM; do not invent a
leftover-pair API inside `fast-mlsirm` in this slice.
8. **Leftover-map axis-share slice** (ADR 0148): persist Gabriel inertia
`σ_k² / Σ_j σ_j²` of leftover-map axes 1 and 2 on the same residual
diff --git a/docs/adr/0048-persist-lsirm-leftover-pairs.md b/docs/adr/0048-persist-lsirm-leftover-pairs.md
index 0bab2fc32..d8957230a 100644
--- a/docs/adr/0048-persist-lsirm-leftover-pairs.md
+++ b/docs/adr/0048-persist-lsirm-leftover-pairs.md
@@ -5,7 +5,8 @@
**Amended by:** [ADR 0119](0119-leftover-map-two-dimensional-distance.md) (two leftover-map axes);
[ADR 0163](0163-leftover-observed-expected.md) (observed Y and expected E);
[ADR 0164](0164-leftover-map-rank.md) (full map rank);
-[ADR 0182](0182-leftover-map-unexplained.md) (unexplained leftover U)
+[ADR 0182](0182-leftover-map-unexplained.md) (unexplained leftover U);
+[ADR 0185](0185-leftover-map-cross-share.md) (leftover-map cross share)
## Context
@@ -41,8 +42,13 @@ rank while distance remains on the first two axes (ADR 0164). Each
leftover row also names unexplained leftover `U = R − R̂` when
Gabriel coordinates exist so the leftover cell the two-axis map does
not reconstruct is not read as leftover residual `R` or leftover-map
-distance `d` (ADR 0182). Two-axis reconstruction `R̂` is computed
-internally and is not persisted.
+distance `d` (ADR 0182), and names leftover-map cross share
+`x = 2 R̂_c U_c / R̃²` of centered leftover when Gabriel coordinates
+exist so the identity remainder after two-axis reconstruction is not
+read as leftover residual `R`, leftover-map distance `d`, explained
+leftover share `e`, or unexplained leftover share `s` (ADR 0185).
+Two-axis reconstruction `R̂` / `R̂_c` and centered unexplained leftover
+`U_c` are computed internally and are not persisted.
Cascade the rows with `report_period_score`. A leftover post must
also be a `report_member_score` row, and the leftover criterion
diff --git a/docs/adr/0049-leftover-pair-report-ui.md b/docs/adr/0049-leftover-pair-report-ui.md
index 3d939f8b2..8a8477845 100644
--- a/docs/adr/0049-leftover-pair-report-ui.md
+++ b/docs/adr/0049-leftover-pair-report-ui.md
@@ -6,7 +6,8 @@
[ADR 0163](0163-leftover-observed-expected.md) (observed Y and expected E);
[ADR 0164](0164-leftover-map-rank.md) (full map rank);
[ADR 0182](0182-leftover-map-unexplained.md) (unexplained leftover U);
-[ADR 0158](0158-leftover-criterion-evaluation-landing.md) (criterion evaluation landing)
+[ADR 0158](0158-leftover-criterion-evaluation-landing.md) (criterion evaluation landing);
+[ADR 0185](0185-leftover-map-cross-share.md) (leftover-map cross share)
## Context
@@ -23,20 +24,25 @@ second navigation surface.
On each period-report group, render leftover pairs **above** the
member list. Each pair is a button: closest or farthest label, post
title, criterion short label, signed residual `R`, two-axis leftover-map
-distance, full map rank, observed `Y`, expected `E` when finite, and
-unexplained leftover `U` when finite.
-The next action names every available measurement before opening the
-post; no amendment hides another, rank 0 explicitly names no
-leftover structure, and unexplained leftover names "leftover map leaves
-unexplained `U` after IRT main effects; open this post to read the
-named criterion" when present. A missing unexplained leftover keeps
-the existing next action.
-Clicking the button opens that post with leftover focus so Post
-quality marks the named criterion current (ADR 0158). Residual naming
-is [ADR 0162](0162-leftover-residual-disclosure.md), observed/expected
+distance, full map rank, observed `Y`, expected `E` when finite,
+unexplained leftover `U` when finite, and leftover-map cross share next
+to distance when finite. The next action names every available
+measurement before opening the post; no amendment hides another, rank 0
+explicitly names no leftover structure, and unexplained leftover names
+"leftover map leaves unexplained `U` after IRT main effects; open this
+post to read the named criterion" when present. When leftover-map cross
+share is also present, the next action instead names the identity
+remainder `x` two leftover-map axes leave of centered leftover after
+IRT main effects. A missing or non-finite value falls back in order —
+cross share, then unexplained leftover, then the existing
+closest/farthest next action. Clicking the button opens that post with
+leftover focus so Post quality marks the named criterion current
+(ADR 0158). Residual naming is
+[ADR 0162](0162-leftover-residual-disclosure.md), observed/expected
naming is [ADR 0163](0163-leftover-observed-expected.md), rank naming
is [ADR 0164](0164-leftover-map-rank.md), unexplained leftover naming
-is [ADR 0182](0182-leftover-map-unexplained.md).
+is [ADR 0182](0182-leftover-map-unexplained.md), leftover-map cross
+share naming is [ADR 0185](0185-leftover-map-cross-share.md).
After `make seed`, closest and farthest leftover pairs sit above the
member list. Click a pair to open that post with the leftover
diff --git a/docs/adr/0185-leftover-map-cross-share.md b/docs/adr/0185-leftover-map-cross-share.md
new file mode 100644
index 000000000..755523b43
--- /dev/null
+++ b/docs/adr/0185-leftover-map-cross-share.md
@@ -0,0 +1,108 @@
+# ADR 0185 — Name leftover-map cross share on period-report pair rows
+
+**Decision status:** Draft
+**Date:** 2026-08-24
+
+Amends [ADR 0048](0048-persist-lsirm-leftover-pairs.md) and
+[ADR 0049](0049-leftover-pair-report-ui.md).
+
+## Context
+
+ADR 0048 already persists leftover-map distance `d = ‖ξ_p − ζ_i‖` and
+leftover residual `R = Y − E[Y|θ, item]` on `report_leftover_pair`.
+ADR 0049 already renders closest and farthest pairs above the member
+list and opens the named post. Distance is the Jeon et al. (2021,
+eq. 3) map gap. Gabriel (1971) derives coordinates from the centered
+matrix, while ADR 0182 defines the auditable cell reconstruction as
+`R̂ = ξ_{1:2} · ζ_{1:2}` and unexplained leftover as `U = R − R̂`.
+The leftover map is two-axis: unused axes pad with zero, and hidden SVD
+axes after the second are dropped. The raw-residual cell identity
+`R² = R̂² + U² + 2 R̂ U` therefore yields
+`e + s + x = 1` with explained leftover share `e = R̂² / R²`,
+unexplained leftover share `s = U² / R²`, and leftover-map cross
+share `x = 2 R̂ U / R²`. Hiding `x` lets a buyer read `e + s`
+as a complete leftover partition even though the truncated map leaves
+an identity remainder. `x` may be negative when reconstruction and
+unexplained leftover have opposite signs; a nonnegative CHECK would
+reject a mathematically honest cell.
+
+This increment does not persist leftover-map reconstruction `R̂`, does
+not add another unexplained-leftover column, does not persist
+leftover-map unexplained leftover share `s`,
+does not persist leftover-map explained leftover share `e`, does not
+persist leftover-map coordinates, does not name leftover-map inner
+product, cosine, or length, does not name observed `Y` / expected
+`E`, does not name leftover-map rank, does not split leftover-map
+distance onto two axes, and does not land Post quality on the leftover
+criterion. Leftover-map distance stays full-rank Euclidean.
+Reconstruction `R̂` is computed internally so `x` reconciles with the
+persisted raw residual and ADR 0182 unexplained leftover.
+
+The unprotected-stack reconstructions for neighbouring leftover facts
+use 0162–0184. This protected-main increment uses **0185** so it does
+not collide with leftover-map explained leftover share (0184),
+leftover-map unexplained leftover share (0183), leftover-map
+unexplained leftover (0182), leftover-map reconstruction (0181),
+leftover-map length (0181 on the length stack), leftover-map cosine
+(0180), leftover-map inner product (0179), leftover residual
+disclosure (0178), leftover observed `Y` / expected `E` (0170),
+leftover-map rank (0172), two-axis leftover-map distance (0166),
+leftover coverage (0165 / 0168), leftover-map axis share (0148), or
+leftover interaction-map persistence (0121).
+
+## Decision
+
+Each leftover pair names `leftover_map_cross_share` — leftover-map
+cross share `x = 2 R̂ U / R²` of raw residual after
+two-axis Gabriel reconstruction `R̂ = ξ_{1:2} · ζ_{1:2}` and
+unexplained leftover `U = R − R̂`. Migration `0185` is the
+single source of the column on every install path, fresh or existing
+-- shipped migrations (`0001` / `0012`) are never edited after the
+fact. The column is nullable so older leftover rows keep distance and
+residual without fabricating a share. Fallback pairs that have no
+complete-case leftover map omit the value rather than inventing one.
+A rank-0 origin cell stores `0.0` when `R = R̂ = U = 0`, not a missing
+value. A rank-1 cell may retain a nonzero raw-residual cross term when
+centering removed a nonzero mean. A non-finite share stores null rather than
+inventing a leftover score. A finite negative share is stored; do not
+add a nonnegative CHECK. This increment does not introduce
+`leftover_map_explained_share`, `leftover_map_unexplained_share`, or
+`leftover_map_reconstruction`; ADR 0182 remains authoritative for
+`leftover_map_unexplained`.
+
+The pair button shows `2R̂U/R² {share}` next to leftover-map
+distance `d` when the value is a finite number, including a signed
+negative remainder. Next action: two leftover-map axes leave identity
+remainder `x` of raw residual after IRT main effects; open this
+post to read the named criterion. A missing or non-finite share omits
+the badge and keeps the existing closest/farthest next action. Do not
+invent a leftover score. Do not invent a theta.
+
+## Consequences
+
+`GET /api/reports/{grouping}/{period}` returns
+`leftover_map_cross_share`. After `make seed`, closest and farthest
+leftover pairs sit above the member list with named `2R̂U/R²` next
+to `d`; click opens that post. Hidden posts stay hidden.
+
+## Related
+
+Independent of leftover interaction-map persistence, leftover-criterion
+evaluation landing, leftover residual disclosure, leftover observed
+`Y` / expected `E`, leftover-map complete-case coverage, leftover-map
+axis share, leftover pairs on the grouping comparison strip, two-axis
+leftover-map distance, leftover-map rank, leftover-map inner product,
+leftover-map cosine, leftover-map length, leftover-map reconstruction,
+leftover-map unexplained leftover, leftover-map unexplained leftover
+share, and leftover-map explained leftover share.
+
+## References
+
+Gabriel, K. R. (1971). The biplot graphic display of matrices with
+application to principal component analysis. *Biometrika, 58*(3),
+453–467. https://doi.org/10.1093/biomet/58.3.453
+
+Jeon, M., Jin, I. H., Schweinberger, M., & Baugh, S. (2021). Mapping
+unobserved item–respondent interactions: A latent space item response
+model with interaction map. *Psychometrika, 86*(2), 378–403.
+https://doi.org/10.1007/s11336-021-09762-5
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index a7c586f01..79351b77f 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -981,6 +981,7 @@ describe("App, authenticated", () => {
observed_response: 2.4,
expected_response: 2.0,
leftover_map_rank: 1,
+ leftover_map_cross_share: 0.12,
},
{
pair_kind: "farthest",
@@ -993,6 +994,7 @@ describe("App, authenticated", () => {
observed_response: 0.9,
expected_response: 2.0,
leftover_map_rank: 1,
+ leftover_map_cross_share: -0.24,
},
],
leftover_map_axes: [
@@ -3724,23 +3726,27 @@ describe("App, authenticated", () => {
name: /open leftover farthest pair: specification revision requested/i,
});
expect(closestPair).toHaveTextContent("Closest leftover: Public post · sales-lead");
+ // Leftover-map cross share is present, so it names the next action
+ // instead of the rank/observed-expected chain (ADR 0185).
expect(closestPair).toHaveTextContent(
- "Leftover map leaves unexplained U +0.05 after IRT main effects. Open this post to read sales-lead.",
+ "Two leftover-map axes leave identity remainder 0.12 of raw residual after IRT main effects. Open this post to read sales-lead.",
);
expect(closestPair).toHaveTextContent("R +0.40");
expect(closestPair).toHaveTextContent("Y 2.40 · E 2.00");
expect(closestPair).toHaveTextContent("rank 1");
expect(closestPair).toHaveTextContent("U +0.05");
+ expect(closestPair).toHaveTextContent("2R̂U/R² 0.12");
expect(closestPair).toHaveTextContent("d 0.12");
expect(closestPair).toHaveAccessibleName("Open leftover closest pair: Public post · sales-lead");
expect(farthestPair).toHaveTextContent("Farthest leftover: Specification revision requested · negative");
expect(farthestPair).toHaveTextContent(
- "Leftover map leaves unexplained U −0.25 after IRT main effects. Open this post to read negative.",
+ "Two leftover-map axes leave identity remainder -0.24 of raw residual after IRT main effects. Open this post to read negative.",
);
expect(farthestPair).toHaveTextContent("R −1.10");
expect(farthestPair).toHaveTextContent("Y 0.90 · E 2.00");
expect(farthestPair).toHaveTextContent("rank 1");
expect(farthestPair).toHaveTextContent("U −0.25");
+ expect(farthestPair).toHaveTextContent("2R̂U/R² -0.24");
expect(farthestPair).toHaveTextContent("d 1.84");
const memberButton = screen.getByRole("button", { name: /open report post: public post/i });
expect(coverageCaption.compareDocumentPosition(closestPair) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index cb8bdd628..e38e9ee91 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -878,6 +878,7 @@ export interface LeftoverPair {
expected_response?: number | null;
leftover_map_rank?: number | null;
leftover_map_unexplained?: number | null;
+ leftover_map_cross_share?: number | null;
}
export interface LeftoverMapAxis {
diff --git a/frontend/src/components/LeftoverPairList.tsx b/frontend/src/components/LeftoverPairList.tsx
index 92b8b9b1a..27dfdcb5e 100644
--- a/frontend/src/components/LeftoverPairList.tsx
+++ b/frontend/src/components/LeftoverPairList.tsx
@@ -1,5 +1,9 @@
import type { LeftoverPair } from "../api";
import { t, tf } from "../i18n";
+import {
+ formatLeftoverMapCrossShare,
+ LEFTOVER_MAP_CROSS_SHARE_ACTION,
+} from "../leftoverMapCrossShare";
import {
formatLeftoverMapRank,
LEFTOVER_RANK_STRUCTURE_ACTION,
@@ -26,8 +30,12 @@ export type LeftoverPairListProps = {
* ``R = Y − E[Y|θ, item]`` (Jeon et al., 2021, eq. 3 input). Unexplained
* leftover ``U = R − R̂`` after two-axis Gabriel reconstruction (ADR 0182)
* takes priority over the residual/observed-expected/rank next action
- * when finite; every badge still renders together before opening the
- * named post.
+ * when finite. When leftover-map cross share ``x = 2 R̂ U / R²`` of
+ * raw residual is also present (ADR 0185), it names the next action
+ * instead of unexplained leftover; a missing or non-finite value falls
+ * back in order — cross share, then unexplained leftover, then the
+ * existing residual/rank/observed-expected next action. Every badge
+ * still renders together before opening the named post.
*/
export function LeftoverPairList({
pairs,
@@ -50,8 +58,18 @@ export function LeftoverPairList({
);
const rankBadge = formatLeftoverMapRank(pair.leftover_map_rank);
const unexplained = formatLeftoverMapUnexplained(pair.leftover_map_unexplained);
+ const crossShareBadge = formatLeftoverMapCrossShare(pair.leftover_map_cross_share);
+ const crossShareValue =
+ pair.leftover_map_cross_share != null && Number.isFinite(pair.leftover_map_cross_share)
+ ? pair.leftover_map_cross_share.toFixed(2)
+ : "—";
let nextAction: string;
- if (unexplained !== null) {
+ if (crossShareBadge !== null) {
+ nextAction = tf(LEFTOVER_MAP_CROSS_SHARE_ACTION, {
+ value: crossShareValue,
+ criterion,
+ });
+ } else if (unexplained !== null) {
const signedUnexplained =
formatSignedLeftoverValue(pair.leftover_map_unexplained ?? Number.NaN) ?? "—";
nextAction = tf(LEFTOVER_MAP_UNEXPLAINED_ACTION, {
@@ -121,6 +139,7 @@ export function LeftoverPairList({
{observedExpected ? {observedExpected} : null}
{rankBadge ? {rankBadge} : null}
{unexplained ? {unexplained} : null}
+ {crossShareBadge ? {crossShareBadge} : null}
d {pair.leftover_distance.toFixed(2)}
diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts
index 78b693313..034ca312d 100644
--- a/frontend/src/i18n.test.ts
+++ b/frontend/src/i18n.test.ts
@@ -53,6 +53,7 @@ describe("i18n", () => {
"Open this post to read the criterion it sat closest to after main effects.",
"Open this post to read the criterion it sat farthest from after main effects.",
"Leftover map leaves unexplained U {value} after IRT main effects. Open this post to read {criterion}.",
+ "Two leftover-map axes leave identity remainder {value} of raw residual after IRT main effects. Open this post to read {criterion}.",
"Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.",
"Leftover map has no leftover structure after IRT main effects. Open this post.",
"Leftover map rank {rank} after IRT main effects. Open this post.",
@@ -159,6 +160,33 @@ describe("i18n", () => {
).toBe(expected);
});
+ it.each([
+ [
+ "ko",
+ "잔여 지도의 두 축이 IRT 주효과 이후 원시 잔차의 항등식 나머지 -0.24을(를) 남깁니다. sales-lead 기준을 읽으려면 이 글을 여세요.",
+ ],
+ [
+ "zh",
+ "残差图的两个轴在 IRT 主效应后留下原始残差的恒等式余项 -0.24。打开这篇帖子阅读 sales-lead。",
+ ],
+ [
+ "ja",
+ "残差マップの2軸はIRT主効果後の生の残差の恒等式の余り -0.24 を残します。この投稿を開いて sales-lead を読んでください。",
+ ],
+ [
+ "vi",
+ "Hai trục của bản đồ phần dư để lại phần giao -0.24 của phần dư thô sau hiệu ứng chính IRT. Mở bài viết này để đọc sales-lead.",
+ ],
+ ] as const)("formats leftover-map cross share next action in %s", (locale, expected) => {
+ setLocale(locale);
+ expect(
+ tf(
+ "Two leftover-map axes leave identity remainder {value} of raw residual after IRT main effects. Open this post to read {criterion}.",
+ { value: "-0.24", criterion: "sales-lead" },
+ ),
+ ).toBe(expected);
+ });
+
it.each([
["ko", "IRT 주효과 이후 관측 Y 2.40와 기대 E 2.00를 읽은 다음, 이 글을 여세요."],
["zh", "阅读 IRT 主效应后的观测 Y 2.40 与期望 E 2.00,然后打开这篇帖子。"],
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
index 64d4385c8..8701052dc 100644
--- a/frontend/src/i18n.ts
+++ b/frontend/src/i18n.ts
@@ -463,6 +463,8 @@ const TRANSLATIONS: Partial>> = {
"주효과 이후 가장 멀리 앉은 기준을 읽으려면 이 글을 여세요.",
"Leftover map leaves unexplained U {value} after IRT main effects. Open this post to read {criterion}.":
"잔여 지도가 IRT 주효과 이후 설명되지 않은 U {value}을(를) 남깁니다. {criterion} 기준을 읽으려면 이 글을 여세요.",
+ "Two leftover-map axes leave identity remainder {value} of raw residual after IRT main effects. Open this post to read {criterion}.":
+ "잔여 지도의 두 축이 IRT 주효과 이후 원시 잔차의 항등식 나머지 {value}을(를) 남깁니다. {criterion} 기준을 읽으려면 이 글을 여세요.",
"Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.":
"IRT 주효과 이후 관측 Y {observed}와 기대 E {expected}를 읽은 다음, 이 글을 여세요.",
"Leftover map has no leftover structure after IRT main effects. Open this post.":
@@ -917,6 +919,8 @@ const TRANSLATIONS: Partial>> = {
"打开这篇帖子,阅读主效应后距离最远的准则。",
"Leftover map leaves unexplained U {value} after IRT main effects. Open this post to read {criterion}.":
"残差图在 IRT 主效应后留下未解释的 U {value}。打开这篇帖子阅读 {criterion}。",
+ "Two leftover-map axes leave identity remainder {value} of raw residual after IRT main effects. Open this post to read {criterion}.":
+ "残差图的两个轴在 IRT 主效应后留下原始残差的恒等式余项 {value}。打开这篇帖子阅读 {criterion}。",
"Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.":
"阅读 IRT 主效应后的观测 Y {observed} 与期望 E {expected},然后打开这篇帖子。",
"Leftover map has no leftover structure after IRT main effects. Open this post.":
@@ -1372,6 +1376,8 @@ const TRANSLATIONS: Partial>> = {
"主効果後に最も遠くなった基準を読むには、この投稿を開いてください。",
"Leftover map leaves unexplained U {value} after IRT main effects. Open this post to read {criterion}.":
"残差マップはIRT主効果後の未説明 U {value} を残します。この投稿を開いて {criterion} を読んでください。",
+ "Two leftover-map axes leave identity remainder {value} of raw residual after IRT main effects. Open this post to read {criterion}.":
+ "残差マップの2軸はIRT主効果後の生の残差の恒等式の余り {value} を残します。この投稿を開いて {criterion} を読んでください。",
"Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.":
"IRT主効果後の観測 Y {observed} と期待 E {expected} を読んでから、この投稿を開いてください。",
"Leftover map has no leftover structure after IRT main effects. Open this post.":
@@ -1827,6 +1833,8 @@ const TRANSLATIONS: Partial>> = {
"Mở bài viết này để đọc tiêu chí nằm xa nhất sau hiệu ứng chính.",
"Leftover map leaves unexplained U {value} after IRT main effects. Open this post to read {criterion}.":
"Bản đồ phần dư để lại U {value} chưa giải thích sau hiệu ứng chính IRT. Mở bài viết này để đọc {criterion}.",
+ "Two leftover-map axes leave identity remainder {value} of raw residual after IRT main effects. Open this post to read {criterion}.":
+ "Hai trục của bản đồ phần dư để lại phần giao {value} của phần dư thô sau hiệu ứng chính IRT. Mở bài viết này để đọc {criterion}.",
"Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.":
"Đọc Y quan sát {observed} và E kỳ vọng {expected} sau hiệu ứng chính IRT, rồi mở bài viết này.",
"Leftover map has no leftover structure after IRT main effects. Open this post.":
diff --git a/frontend/src/leftoverMapCrossShare.test.ts b/frontend/src/leftoverMapCrossShare.test.ts
new file mode 100644
index 000000000..350e202ed
--- /dev/null
+++ b/frontend/src/leftoverMapCrossShare.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from "vitest";
+import { formatLeftoverMapCrossShare } from "./leftoverMapCrossShare";
+
+describe("formatLeftoverMapCrossShare", () => {
+ it("names leftover-map cross share without inventing a leftover score", () => {
+ expect(formatLeftoverMapCrossShare(0.12)).toBe("2R\u0302U/R\u00b2 0.12");
+ expect(formatLeftoverMapCrossShare(0)).toBe("2R\u0302U/R\u00b2 0.00");
+ expect(formatLeftoverMapCrossShare(-0.24)).toBe("2R\u0302U/R\u00b2 -0.24");
+ });
+
+ it("omits the badge when leftover-map cross share is missing or non-finite", () => {
+ expect(formatLeftoverMapCrossShare(null)).toBeNull();
+ expect(formatLeftoverMapCrossShare(undefined)).toBeNull();
+ expect(formatLeftoverMapCrossShare(Number.NaN)).toBeNull();
+ expect(formatLeftoverMapCrossShare(Number.POSITIVE_INFINITY)).toBeNull();
+ expect(formatLeftoverMapCrossShare(Number.NEGATIVE_INFINITY)).toBeNull();
+ });
+});
diff --git a/frontend/src/leftoverMapCrossShare.ts b/frontend/src/leftoverMapCrossShare.ts
new file mode 100644
index 000000000..a113ee9dc
--- /dev/null
+++ b/frontend/src/leftoverMapCrossShare.ts
@@ -0,0 +1,13 @@
+/** Leftover-map cross share ``x = 2 R̂ U / R²`` of raw residual. */
+
+export const LEFTOVER_MAP_CROSS_SHARE_ACTION =
+ "Two leftover-map axes leave identity remainder {value} of raw residual after IRT main effects. Open this post to read {criterion}.";
+
+export function formatLeftoverMapCrossShare(
+ value: number | null | undefined,
+): string | null {
+ if (value == null || !Number.isFinite(value)) {
+ return null;
+ }
+ return `2R\u0302U/R\u00b2 ${value.toFixed(2)}`;
+}
diff --git a/lineageweave/leftover_pairs.py b/lineageweave/leftover_pairs.py
index cb7ae265d..139f41e8d 100644
--- a/lineageweave/leftover_pairs.py
+++ b/lineageweave/leftover_pairs.py
@@ -1,24 +1,34 @@
"""Jeon leftover post–criterion pairs after a main-effect IRT.
-Implements ADR 0048 as amended by ADR 0119, ADR 0163, ADR 0164, and ADR 0182.
+Implements ADR 0048 as amended by ADR 0119, ADR 0163, ADR 0164, ADR 0182,
+and ADR 0185.
Does not import ``fast_mlsirm`` or ``period_report``. A Gabriel biplot
of the residual ``R = Y − E[Y|θ, item]`` supplies person and item
positions. Missing response cells are excluded from the factorization;
they are never treated as zero residuals. Each pair names observed
``Y`` and expected ``E`` so residual always reconciles to ``Y − E``.
-Pair distances are Euclidean
-on the two leftover-map axes (Jeon et al., 2021); unused axes pad with
-zero rather than inventing a second component, and hidden SVD axes
-after the second are dropped. Each pair also names the full leftover-map
-rank so a rank-0 collapse is not read as leftover structure. Axis share
-is the Gabriel inertia of the first two leftover-map axes (ADR 0148).
-Complete-case coverage (ADR 0168) names how many scored posts entered
-that rectangle. Each pair also names unexplained leftover ``U = R − R̂``
-after two-axis Gabriel reconstruction ``R̂ = ξ_{1:2} · ζ_{1:2}`` so the
+Pair distances are Euclidean on the two leftover-map axes (Jeon et al.,
+2021); unused axes pad with zero rather than inventing a second
+component, and hidden SVD axes after the second are dropped. Each pair
+also names the full leftover-map rank so a rank-0 collapse is not read
+as leftover structure. Axis share is the Gabriel inertia of the first
+two leftover-map axes (ADR 0148). Complete-case coverage (ADR 0168)
+names how many scored posts entered that rectangle; without a
+complete-case rectangle there is no leftover pair to name, and the
+report carries coverage counts instead of a center-distance stand-in
+pair. Each pair also names unexplained leftover ``U = R − R̂`` after
+two-axis Gabriel reconstruction ``R̂ = ξ_{1:2} · ζ_{1:2}`` so the
leftover cell the map does not reconstruct is not confused with
-leftover residual ``R`` or leftover-map distance ``d``. Reconstruction
-is computed internally and is not persisted.
+leftover residual ``R`` or leftover-map distance ``d``. Each pair
+further names leftover-map cross share ``x = 2 R̂ U / R²`` of the raw
+residual after that same truncated two-axis reconstruction,
+so the identity remainder left by the truncation is not confused with
+leftover residual ``R``, leftover-map distance ``d``, or unexplained
+leftover ``U``. Explained leftover share ``e = R̂² / R²`` and
+unexplained leftover share ``s = U² / R²`` are not persisted.
+Reconstruction ``R̂`` stays internal and is not persisted. ``x`` may be negative
+when reconstruction and unexplained leftover have opposite signs.
"""
from __future__ import annotations
@@ -47,6 +57,7 @@ class LeftoverPair:
expected_response: float
leftover_map_rank: int
leftover_map_unexplained: float | None = None
+ leftover_map_cross_share: float | None = None
@dataclass(frozen=True)
@@ -87,9 +98,13 @@ def leftover_pairs_from_residual(
expected ``E[Y|θ, item]``. Stored leftover-map rank is the number
of Gabriel singular values above the floor. When Gabriel coordinates
exist, unexplained leftover ``U = R − R̂`` names the leftover cell
- the two-axis map does not reconstruct; ``R̂`` stays internal and is
- never persisted. Fallback pairs (no complete-case map) omit
- unexplained leftover rather than fabricating one.
+ the two-axis map does not reconstruct, and leftover-map cross share
+ ``x = 2 R̂ U / R²`` names the identity remainder of raw residual
+ ``R`` after two-axis reconstruction ``R̂ = ξ_{1:2} · ζ_{1:2}`` and
+ unexplained leftover ``U = R − R̂``. ``R̂`` stays internal and
+ are never persisted. Without a complete-case map there is no pair
+ to name (ADR 0168); the caller reads coverage counts instead of a
+ center-distance stand-in pair.
"""
pairs, _axes = leftover_map_from_residual(post_ids, item_codes, matrix, expected)
return pairs
@@ -137,7 +152,9 @@ def leftover_map_from_residual(
)
axes = leftover_map_axes_from_singular(singular)
leftover_map_rank = int(singular.size)
- candidates: list[tuple[float, str, str, float, float, float, float | None]] = []
+ candidates: list[
+ tuple[float, str, str, float, float, float, float | None, float | None]
+ ] = []
if person_pos is not None and item_pos is not None:
person_index = np.flatnonzero(keep_person)
item_index = np.flatnonzero(keep_item)
@@ -156,7 +173,9 @@ def leftover_map_from_residual(
reconstruction = float(
np.dot(person_xy[local_person[person]], item_xy[local_item[item]])
)
- unexplained = _unexplained_leftover(float(residual[person, item]), reconstruction)
+ residual_cell = float(residual[person, item])
+ unexplained = _unexplained_leftover(residual_cell, reconstruction)
+ share = _leftover_map_cross_share(residual_cell, reconstruction)
candidates.append(
_candidate_row(
post_ids,
@@ -168,6 +187,7 @@ def leftover_map_from_residual(
item,
distance,
unexplained,
+ share,
)
)
if not candidates:
@@ -194,6 +214,31 @@ def _unexplained_leftover(residual: float, reconstruction: float) -> float | Non
return float(unexplained)
+def _leftover_map_cross_share(residual: float, reconstruction: float) -> float | None:
+ """Return ``x = 2 R̂ U / R²`` when both terms are finite; otherwise omit.
+
+ Unexplained leftover ``U = R − R̂`` is computed internally.
+ Truncated two-axis reconstruction of a higher-rank cell keeps a
+ cross term ``2 R̂ U``, so per-cell ``e + s ≠ 1``. The identity
+ remainder ``x`` names that cross term as a share of raw residual.
+ ``x`` may be negative when reconstruction and unexplained
+ leftover have opposite signs; a negative finite share is stored,
+ not omitted.
+ """
+ if not np.isfinite(residual) or not np.isfinite(reconstruction):
+ return None
+ unexplained = float(residual - reconstruction)
+ # Threshold on absolute magnitudes, not squares: squaring first makes the
+ # effective floor sqrt(1e-12) = 1e-6 and collapses small-but-finite cells
+ # (e.g. R = 1e-7 with a valid cross term) to an omitted badge.
+ if abs(residual) > _LEFTOVER_SINGULAR_FLOOR:
+ share = float(2.0 * reconstruction * unexplained / (residual * residual))
+ return share if np.isfinite(share) else None
+ if abs(reconstruction) <= _LEFTOVER_SINGULAR_FLOOR and abs(unexplained) <= _LEFTOVER_SINGULAR_FLOOR:
+ return 0.0
+ return None
+
+
def _candidate_row(
post_ids: list[str],
item_codes: tuple[str, ...],
@@ -204,8 +249,9 @@ def _candidate_row(
item: int,
distance: float,
leftover_map_unexplained: float | None,
-) -> tuple[float, str, str, float, float, float, float | None]:
- """One observed leftover cell: distance, ids, residual, Y, E, unexplained U."""
+ leftover_map_cross_share: float | None,
+) -> tuple[float, str, str, float, float, float, float | None, float | None]:
+ """One observed leftover cell: distance, ids, residual, Y, E, U, cross share."""
leftover_residual = float(residual[person, item])
observed_response = float(matrix[person, item])
expected_response = float(expected[person, item])
@@ -219,12 +265,13 @@ def _candidate_row(
observed_response,
expected_response,
leftover_map_unexplained,
+ leftover_map_cross_share,
)
def _pair_from_candidate(
pair_kind: str,
- row: tuple[float, str, str, float, float, float, float | None],
+ row: tuple[float, str, str, float, float, float, float | None, float | None],
leftover_map_rank: int,
) -> LeftoverPair:
"""Build a leftover pair from a candidate row."""
@@ -240,6 +287,7 @@ def _pair_from_candidate(
expected_response=row[5],
leftover_map_rank=leftover_map_rank,
leftover_map_unexplained=row[6],
+ leftover_map_cross_share=row[7],
)
@@ -376,7 +424,8 @@ def _pad_map_axes(positions: np.ndarray) -> np.ndarray:
Unused axes pad with zero rather than inventing a second component.
Hidden SVD axes after the second are dropped so reconstruction is
``ξ_{1:2} · ζ_{1:2}``, not the full-rank inner product. That
- reconstruction stays internal; only unexplained leftover is named.
+ reconstruction stays internal; only unexplained leftover and
+ leftover-map cross share are named.
"""
padded = np.zeros((positions.shape[0], _LEFTOVER_MAP_AXES), dtype=np.float64)
width = min(_LEFTOVER_MAP_AXES, positions.shape[1])
diff --git a/migrations/0185_report_leftover_map_cross_share.sql b/migrations/0185_report_leftover_map_cross_share.sql
new file mode 100644
index 000000000..7174c9d1a
--- /dev/null
+++ b/migrations/0185_report_leftover_map_cross_share.sql
@@ -0,0 +1,16 @@
+-- ADR 0185: persist leftover-map cross share x = 2 R̂_c U_c / R̃² of
+-- the centered leftover after two-axis leftover-map reconstruction
+-- (R̃ = R − center, R̂_c = ξ_{1:2} · ζ_{1:2}, U_c = R̃ − R̂_c).
+-- Distance stays Euclidean leftover-map d. Centered leftover U_c and
+-- reconstruction R̂_c are computed internally and are not persisted.
+-- Upgrade column is nullable so older leftover rows keep distance and
+-- residual without fabricating a share. This migration is the single
+-- source of the column on fresh and existing installations. Do not
+-- edit shipped migrations 0001 / 0012 after the fact. Do not persist
+-- leftover_map_explained_share, leftover_map_unexplained_share,
+-- leftover_map_unexplained, or leftover_map_reconstruction.
+-- Do not add a nonnegative CHECK: x may be negative when reconstruction
+-- and unexplained leftover have opposite signs.
+
+alter table report_leftover_pair
+ add column if not exists leftover_map_cross_share numeric;
diff --git a/migrations/rollback/0185_report_leftover_map_cross_share.sql b/migrations/rollback/0185_report_leftover_map_cross_share.sql
new file mode 100644
index 000000000..6cda83b0c
--- /dev/null
+++ b/migrations/rollback/0185_report_leftover_map_cross_share.sql
@@ -0,0 +1,4 @@
+-- Reverse 0185. Leftover distance and residual stay on the pair row.
+
+alter table report_leftover_pair
+ drop column if exists leftover_map_cross_share;
diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py
index 1418d9af0..b6d1402b9 100644
--- a/scripts/seed_demo_data.py
+++ b/scripts/seed_demo_data.py
@@ -123,6 +123,7 @@ def seed(
cur.execute((migrations / "0168_report_leftover_map_coverage.sql").read_text())
cur.execute((migrations / "0169_report_leftover_map_axis.sql").read_text())
cur.execute((migrations / "0182_report_leftover_map_unexplained.sql").read_text())
+ cur.execute((migrations / "0185_report_leftover_map_cross_share.sql").read_text())
cur.execute((migrations / "0060_role_responsibility_agent_type.sql").read_text())
cur.execute((migrations / "0013_person_job_title.sql").read_text())
cur.execute((migrations / "0014_role_responsibility_team_actor_type.sql").read_text())
@@ -1287,8 +1288,8 @@ def _persist_seed_period_report(
"grouping_kind, grouping_key, period_code, rubric_version, "
"pair_kind, post_id, criterion_code, leftover_distance, leftover_residual, "
"observed_response, expected_response, leftover_map_rank, "
- "leftover_map_unexplained"
- ") values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
+ "leftover_map_unexplained, leftover_map_cross_share"
+ ") values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
(
grouping_kind,
grouping_key,
@@ -1303,6 +1304,7 @@ def _persist_seed_period_report(
pair.expected_response,
pair.leftover_map_rank,
pair.leftover_map_unexplained,
+ pair.leftover_map_cross_share,
),
)
for axis in report.leftover_map_axes:
diff --git a/tests/test_leftover_pairs.py b/tests/test_leftover_pairs.py
index a411e1e13..5b9d90a96 100644
--- a/tests/test_leftover_pairs.py
+++ b/tests/test_leftover_pairs.py
@@ -1,7 +1,7 @@
"""Leftover post–criterion pairs after the main-effect IRT.
Covers ADR 0048 as amended by ADR 0119, ADR 0148, ADR 0163, ADR 0164,
-and ADR 0182.
+ADR 0182, and ADR 0185.
Uses a constructed residual matrix so the closest and farthest pair
are known without calling ``fit_polytomous``. Loads
@@ -59,6 +59,13 @@ def _assert_residual_reconciles(pair) -> None:
)
+def _assert_never_persists_hidden_shares(pair) -> None:
+ """The cross-share increment never persists these adjacent facts (ADR 0185)."""
+ assert not hasattr(pair, "leftover_map_explained_share")
+ assert not hasattr(pair, "leftover_map_unexplained_share")
+ assert not hasattr(pair, "leftover_map_reconstruction")
+
+
def _gabriel_positions(filled: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Independent Gabriel coordinates used to prove leftover_distance axes."""
left, singular, right = np.linalg.svd(filled, full_matrices=False)
@@ -103,9 +110,14 @@ def test_leftover_residual_biplot_separates_aligned_and_opposed_cells() -> None:
assert farthest.leftover_distance == pytest.approx(2.0 * np.sqrt(2.0), rel=1e-6)
assert closest.leftover_map_unexplained == pytest.approx(0.0, abs=1e-6)
assert farthest.leftover_map_unexplained == pytest.approx(0.0, abs=1e-6)
+ # Closest is the origin cell (R = 0, R̂ = 0, U = 0): 0/0 stores 0.
+ assert closest.leftover_map_cross_share == pytest.approx(0.0, abs=1e-6)
+ # Rank-1 reconstructed opposed cell: U = 0 so x = 0.
+ assert farthest.leftover_map_cross_share == pytest.approx(0.0, abs=1e-6)
assert not hasattr(closest, "leftover_map_reconstruction")
for pair in pairs:
_assert_residual_reconciles(pair)
+ _assert_never_persists_hidden_shares(pair)
assert pair.leftover_map_rank == 1
coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected)
assert coverage.map_post_count == 3
@@ -133,6 +145,8 @@ def test_zero_residual_still_emits_stable_leftover_pairs() -> None:
assert pairs[1].criterion_code == "item_two"
assert pairs[0].leftover_map_unexplained == pytest.approx(0.0)
assert pairs[1].leftover_map_unexplained == pytest.approx(0.0)
+ assert pairs[0].leftover_map_cross_share == pytest.approx(0.0)
+ assert pairs[1].leftover_map_cross_share == pytest.approx(0.0)
for pair in pairs:
_assert_residual_reconciles(pair)
assert pair.leftover_map_rank == 0
@@ -170,6 +184,7 @@ def test_partial_observation_does_not_treat_missing_as_zero_residual() -> None:
_assert_residual_reconciles(pair)
assert pair.leftover_map_rank == 1
assert pair.leftover_map_unexplained == pytest.approx(0.0, abs=1e-6)
+ assert pair.leftover_map_cross_share == pytest.approx(0.0, abs=1e-6)
coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected)
assert coverage.map_post_count == 2
assert coverage.scored_post_count == 3
@@ -241,9 +256,62 @@ def test_leftover_residual_rejects_database_tolerance_boundary() -> None:
0,
0.0,
None,
+ None,
)
+def test_leftover_pairs_empty_without_complete_case_map() -> None:
+ """No complete-case rectangle (ADR 0168): no stand-in pair, coverage instead."""
+ post_ids = ["sparse-a", "sparse-b"]
+ item_codes = ("item_near", "item_far")
+ matrix = np.array(
+ [
+ [2.0, np.nan],
+ [np.nan, -2.0],
+ ],
+ dtype=np.float64,
+ )
+ expected = np.zeros_like(matrix)
+ assert leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) == ()
+ coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected)
+ assert coverage.map_post_count == 0
+ assert coverage.scored_post_count == 2
+ assert coverage.incomplete_post_count == 2
+
+
+def test_rank_one_nonzero_center_is_disclosed_by_raw_residual_cross_share() -> None:
+ """Raw-residual cross share retains the mean omitted by centered SVD."""
+ post_ids = ["post-a", "post-b", "post-c"]
+ item_codes = ("item_near", "item_mid", "item_far")
+ matrix = np.array(
+ [
+ [5.0, 3.0, 1.0],
+ [3.0, 3.0, 3.0],
+ [1.0, 3.0, 5.0],
+ ],
+ dtype=np.float64,
+ )
+ expected = np.zeros_like(matrix)
+ assert float(np.mean(matrix)) == pytest.approx(3.0)
+ pairs = leftover_pairs_from_residual(post_ids, item_codes, matrix, expected)
+ assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST]
+ closest, farthest = pairs
+ person_full, item_full, _singular = leftover._leftover_map_positions(matrix - np.mean(matrix))
+ reconstruction = leftover._pad_map_axes(person_full) @ leftover._pad_map_axes(item_full).T
+ post_index = {post_id: index for index, post_id in enumerate(post_ids)}
+ item_index = {code: index for index, code in enumerate(item_codes)}
+ for pair in pairs:
+ residual = pair.leftover_residual
+ recon = float(reconstruction[post_index[pair.post_id], item_index[pair.criterion_code]])
+ expected_share = 0.0 if residual == 0.0 and recon == 0.0 else 2.0 * recon * (residual - recon) / residual**2
+ assert pair.leftover_map_cross_share == pytest.approx(expected_share, abs=1e-6)
+ assert farthest.leftover_residual != pytest.approx(0.0)
+ assert farthest.leftover_map_cross_share != pytest.approx(farthest.leftover_residual)
+ for pair in pairs:
+ _assert_residual_reconciles(pair)
+ _assert_never_persists_hidden_shares(pair)
+
+
def test_rank_one_leftover_map_puts_all_inertia_on_axis_one() -> None:
"""A rank-1 residual must report leftover-map share 1 on axis 1, 0 on axis 2."""
post_ids = ["post-a", "post-b", "post-c"]
@@ -341,8 +409,8 @@ def test_rank_four_pair_distances_match_two_dimensional_gabriel_coords() -> None
assert (post_index[farthest.post_id], item_index[farthest.criterion_code]) == farthest_map
-def test_unexplained_equals_residual_minus_two_axis_reconstruction() -> None:
- """Unexplained leftover U is R − R̂, not leftover residual R, not leftover-map distance d.
+def test_unexplained_and_cross_share_are_identity_remainder_terms() -> None:
+ """Unexplained U is R − R̂; cross share is 2 R̂ U / R². Neither is R or d.
Uses the same rank-4 matrix as the two-axis distance proof above: R̂ is
the two-axis Gabriel reconstruction ``person_map @ item_map.T``, built
@@ -360,28 +428,62 @@ def test_unexplained_equals_residual_minus_two_axis_reconstruction() -> None:
dtype=np.float64,
)
expected = np.zeros_like(matrix)
- filled = matrix - float(np.mean(matrix))
- person_full, item_full = _gabriel_positions(filled)
- assert person_full.shape[1] == 4
- person_map = _pad_map_axes(person_full)
- item_map = _pad_map_axes(item_full)
+ center = float(np.mean(matrix))
+ filled = matrix - center
+ person_full, item_full, singular = leftover._leftover_map_positions(filled)
+ rank = int(singular.size)
+ assert person_full.shape[1] >= 3
+ person_map = leftover._pad_map_axes(person_full)
+ item_map = leftover._pad_map_axes(item_full)
reconstruction = person_map @ item_map.T
full_inner = person_full @ item_full.T
+ map_distances = np.linalg.norm(person_map[:, None, :] - item_map[None, :, :], axis=2)
assert float(np.max(np.abs(reconstruction - filled))) > 1e-6
assert float(np.max(np.abs(reconstruction - full_inner))) > 1e-6
+ assert abs(center) > 1e-6
pairs = leftover_pairs_from_residual(post_ids, item_codes, matrix, expected)
assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST]
post_index = {post_id: index for index, post_id in enumerate(post_ids)}
item_index = {code: index for index, code in enumerate(item_codes)}
+ saw_nonzero_cross = False
for pair in pairs:
person = post_index[pair.post_id]
item = item_index[pair.criterion_code]
- expected_unexplained = float(pair.leftover_residual) - float(reconstruction[person, item])
+ recon = float(reconstruction[person, item])
+ # Raw unexplained leftover U = R − R̂ (ADR 0182).
+ expected_unexplained = float(pair.leftover_residual) - recon
assert pair.leftover_map_unexplained == pytest.approx(expected_unexplained)
assert pair.leftover_map_unexplained != pytest.approx(pair.leftover_residual)
assert pair.leftover_map_unexplained != pytest.approx(pair.leftover_distance)
assert not hasattr(pair, "leftover_map_reconstruction")
+ # Raw-residual cross share x = 2 R̂ U / R² (ADR 0185).
+ residual = float(pair.leftover_residual)
+ expected_share = (2.0 * recon * expected_unexplained) / (residual * residual)
+ explained_share = (recon * recon) / (residual * residual)
+ unexplained_share = (expected_unexplained * expected_unexplained) / (residual * residual)
+ assert pair.leftover_map_cross_share == pytest.approx(expected_share)
+ assert explained_share + unexplained_share + expected_share == pytest.approx(1.0)
+ if abs(expected_share) > 1e-6:
+ saw_nonzero_cross = True
+ assert pair.leftover_map_cross_share != pytest.approx(pair.leftover_residual)
+ assert pair.leftover_map_cross_share != pytest.approx(pair.leftover_distance)
+ # Distance is Euclidean on the two leftover-map axes (ADR 0119), the
+ # same basis the reconstruction above uses -- not the full-rank
+ # Gabriel inner product.
+ assert pair.leftover_distance == pytest.approx(float(map_distances[person, item]))
+ assert pair.leftover_map_rank == rank
+ _assert_never_persists_hidden_shares(pair)
+ assert saw_nonzero_cross
+
+
+def test_cross_share_stores_negative_finite_identity_remainder() -> None:
+ """A negative identity remainder is stored, never omitted or clamped."""
+ assert leftover._leftover_map_cross_share(1.0, 2.0) == pytest.approx(-4.0)
+ assert leftover._leftover_map_cross_share(2.0, 2.0) == pytest.approx(0.0)
+ assert leftover._leftover_map_cross_share(0.0, 0.0) == pytest.approx(0.0)
+ assert leftover._leftover_map_cross_share(float("nan"), 1.0) is None
+ assert leftover._leftover_map_cross_share(1.0, float("inf")) is None
def test_pad_map_axes_truncates_hidden_svd_components() -> None:
@@ -458,6 +560,17 @@ def test_leftover_map_rank_rejects_negative_rank() -> None:
)
+def test_small_finite_residual_keeps_cross_share() -> None:
+ """A tiny-but-finite residual keeps its cross share.
+
+ Squaring before the floor made the effective threshold 1e-6, so
+ R = 1e-7 with reconstruction 5e-8 collapsed to an omitted badge
+ even though x = 0.5 is well-defined (coderabbit review thread).
+ """
+ share = leftover._leftover_map_cross_share(1e-7, 5e-8)
+ assert share == pytest.approx(0.5)
+
+
def test_leftover_is_unavailable_without_a_complete_case_rectangle() -> None:
"""Observed cells alone cannot invent Gabriel positions or map coverage."""
post_ids = ["post-a", "post-b"]
diff --git a/tests/test_period_report.py b/tests/test_period_report.py
index 95021ac4b..a7bf86626 100644
--- a/tests/test_period_report.py
+++ b/tests/test_period_report.py
@@ -272,6 +272,10 @@ def test_calibrated_report_attaches_leftover_pairs() -> None:
assert pair.leftover_map_rank >= 0
if pair.leftover_map_unexplained is not None:
assert np.isfinite(pair.leftover_map_unexplained)
+ if pair.leftover_map_cross_share is not None:
+ assert np.isfinite(pair.leftover_map_cross_share)
+ assert not hasattr(pair, "leftover_map_explained_share")
+ assert not hasattr(pair, "leftover_map_unexplained_share")
assert not hasattr(pair, "leftover_map_reconstruction")
assert [axis.axis_index for axis in report.leftover_map_axes] == [1, 2]
for axis in report.leftover_map_axes:
diff --git a/tests/test_schema.py b/tests/test_schema.py
index 0f9fd18a3..328720f3b 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -53,6 +53,11 @@
/ "migrations"
/ "0164_report_leftover_map_rank.sql"
)
+_LEFTOVER_MAP_CROSS_SHARE_MIGRATION = (
+ Path(__file__).resolve().parents[1]
+ / "migrations"
+ / "0185_report_leftover_map_cross_share.sql"
+)
_LEFTOVER_MAP_AXIS_MIGRATION = (
Path(__file__).resolve().parents[1]
/ "migrations"
@@ -109,6 +114,7 @@ def schema_db():
cur.execute(_LEFTOVER_MAP_COVERAGE_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_AXIS_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_UNEXPLAINED_MIGRATION.read_text())
+ cur.execute(_LEFTOVER_MAP_CROSS_SHARE_MIGRATION.read_text())
conn.commit()
yield conn
finally:
@@ -257,6 +263,35 @@ def test_leftover_pair_names_nullable_unexplained_column(schema_db) -> None:
assert "leftover_map_reconstruction" not in columns
+def test_leftover_pair_names_nullable_cross_share_column(schema_db) -> None:
+ """Every install path preserves legacy pairs while naming leftover-map cross share."""
+ with schema_db.cursor() as cur:
+ cur.execute(
+ """
+ select column_name, is_nullable
+ from information_schema.columns
+ where table_name = 'report_leftover_pair'
+ """
+ )
+ columns = dict(cur.fetchall())
+ assert columns["leftover_map_cross_share"] == "YES"
+ assert columns["leftover_residual"] == "NO"
+ assert columns["leftover_distance"] == "NO"
+ assert "leftover_map_explained_share" not in columns
+ assert "leftover_map_unexplained_share" not in columns
+ assert "leftover_map_reconstruction" not in columns
+ with schema_db.cursor() as cur:
+ cur.execute(
+ """
+ select conname
+ from pg_constraint
+ where conrelid = 'report_leftover_pair'::regclass
+ and conname like '%share%chk'
+ """
+ )
+ assert cur.fetchall() == []
+
+
def test_leftover_map_axis_references_period_score(schema_db) -> None:
"""Axis share is report-level; it must cascade with the period score."""
with schema_db.cursor() as cur: