diff --git a/AGENTS.md b/AGENTS.md
index 6544da08d..74919a1c4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -259,21 +259,25 @@ stops startup instead of leaving a healthy-looking partial schema, and
application code must not compensate for a missing table.
Period leftover pairs (ADR 0017 / 0018 / 0048 / 0049 / 0119 / 0158 / 0162 /
-0163 / 0164 / 0182) are computed in `lineageweave/leftover_pairs.py` from the
+0163 / 0164 / 0182 / 0185) are computed in `lineageweave/leftover_pairs.py` from the
residual after a real GRM/GPCM score, never invented. Distances are
-Euclidean on the two-dimensional Gabriel leftover map; missing cells stay
-out of the factorization. Closest and farthest post–criterion pairs
-persist to `report_leftover_pair` with signed residual `R`, observed
-`Y`, and expected `E[Y|θ, item]` so `R = Y − E` remains auditable,
-plus leftover-map rank so rank 0 is not read as structure, and
-unexplained leftover `U = R − R̂` next to leftover-map distance `d`
-after two-axis Gabriel reconstruction. They sit above the member
+Euclidean on the two-dimensional Gabriel leftover map; missing cells
+stay out of the factorization. Closest and farthest post–criterion
+pairs persist to `report_leftover_pair` with signed residual `R`,
+observed `Y`, and expected `E[Y|θ, item]` so `R = Y − E` remains
+auditable, plus leftover-map rank so rank 0 is not read as structure,
+plus unexplained leftover `U = R − R̂` after two-axis Gabriel
+reconstruction, plus explained leftover share `e = R̂_c² / R̃²` of
+centered leftover, next to leftover-map distance `d`. Two-axis
+reconstruction `R̂` / `R̂_c` is computed internally and is not
+persisted. They sit above the member
list so a click opens that post with the leftover criterion current
-in Post quality (ADR 0158). Two-axis reconstruction `R̂` is not
-persisted. Leftover-map axis share (ADR 0148) is Gabriel inertia of
-residual SVD axes 1 and 2 and persists to `report_leftover_map_axis`.
-Rank-0 residuals emit two zero-share axes; the shares are report-level
-and are not a leftover score. Complete-case coverage (ADR 0168) persists to
+in Post quality (ADR 0158).
+Leftover-map axis share (ADR 0148) is Gabriel inertia of residual SVD
+axes 1 and 2 and persists to `report_leftover_map_axis`. Rank-0
+residuals emit two zero-share axes; the shares are report-level and
+are not a leftover score.
+Complete-case coverage (ADR 0168) persists to
`report_leftover_map_coverage` and captions the pair list with how
many scored posts entered the map.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 46a8dd7e2..b238a2f12 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -596,10 +596,11 @@ 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
+ADR 0017 / 0048 / 0049 / 0119 / 0158 / 0162 / 0163 / 0164 / 0182 / 0185) 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
+`E[Y|θ, item]`, full leftover-map rank, unexplained leftover
+`U = R − R̂` named on the pair row, and explained leftover share
+`e = R̂_c² / R̃²` of centered leftover. 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
diff --git a/CHANGELOG.d/2.12.28-leftover-map-explained-share.md b/CHANGELOG.d/2.12.28-leftover-map-explained-share.md
new file mode 100644
index 000000000..0c5ac2ca8
--- /dev/null
+++ b/CHANGELOG.d/2.12.28-leftover-map-explained-share.md
@@ -0,0 +1,9 @@
+## 2.12.28 — Leftover-map explained leftover share
+
+- Persist explained leftover share `e = R̂_c² / R̃²` of centered
+ leftover on leftover post–criterion pairs (ADR 0185). After
+ `make seed`, closest and farthest leftover pairs sit above the
+ member list with `R̂²/R̃²` next to leftover-map distance `d`; click
+ opens that post. Omit the badge when the share is missing. Never
+ invent a leftover score. Do not persist leftover-map unexplained
+ leftover share `s`, unexplained leftover `U`, or reconstruction `R̂`.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 94e653987..74dd13820 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -107,6 +107,17 @@ All notable changes to this project are documented here. Format follows
is unwired, with `이 범위의 일정을 아직 받을 수 없습니다`. Weekly VOC and
newspaper stay on the board.
+## [2.12.28] - 2026-08-24
+
+### Added
+
+- Period leftover pair rows now name explained leftover share
+ `e = R̂_c² / R̃²` of centered leftover 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. Two-axis reconstruction `R̂_c` stays internal and is
+ not persisted. Unexplained leftover share `s` is not persisted here.
+
## [2.12.26] - 2026-08-24
### Added
diff --git a/CLAUDE.md b/CLAUDE.md
index f32325abc..ce9bda4b4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -48,7 +48,7 @@ cutoff.
Create/start endpoint rules (ADR 0017 / 0021), tie-vs-miss similarity
(ADR 0026), R&R catalog ids (ADR 0019 / 0027), leftover pairs
-(ADR 0048–0164 / 0182), the text-channel embedding swap and cosine
+(ADR 0048–0164 / 0182 / 0185), the text-channel embedding swap and cosine
clamp (ADR 0190), per-edge channel-score persistence (ADR 0195),
migration replay (ADR 0166), docstring coverage, and the measurement
boundary are all stated in [AGENTS.md](AGENTS.md) -- read it before
diff --git a/backend/app/report_ingestion.py b/backend/app/report_ingestion.py
index ac65e6af2..11212e1f6 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_explained_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_explained_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_explained_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_explained_share": (
+ None
+ if row["leftover_map_explained_share"] is None
+ else float(row["leftover_map_explained_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..fdd13d719 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -162,6 +162,11 @@
/ "migrations"
/ "0182_report_leftover_map_unexplained.sql"
)
+_LEFTOVER_MAP_EXPLAINED_SHARE_MIGRATION = (
+ Path(__file__).resolve().parents[2]
+ / "migrations"
+ / "0185_report_leftover_map_explained_share.sql"
+)
def _postgres_available() -> bool:
@@ -305,6 +310,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_EXPLAINED_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 +5055,14 @@ 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_explained_share")
+ assert share is None or isinstance(share, (int, float))
+ if share is not None:
+ assert share >= 0
+ 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..c2d8ae19d 100644
--- a/docs/adr/0003-fast-mlsirm-report-integration.md
+++ b/docs/adr/0003-fast-mlsirm-report-integration.md
@@ -100,12 +100,14 @@ 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
+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̂` 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
+ `U = R − R̂` and explained leftover share `e = R̂_c² / R̃²` of
+ centered leftover when Gabriel coordinates exist so the leftover
+ cell the two-axis map does and does not reconstruct is not read as
+ leftover residual or leftover-map distance. Do not persist
+ leftover-map 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
diff --git a/docs/adr/0048-persist-lsirm-leftover-pairs.md b/docs/adr/0048-persist-lsirm-leftover-pairs.md
index 0bab2fc32..6a824c46d 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-explained-share.md) (explained leftover share)
## Context
@@ -41,8 +42,11 @@ 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 explained leftover share
+`e = R̂_c² / R̃²` of centered leftover so the leftover cell the
+two-axis map reconstructs is likewise not read as leftover residual
+`R` or leftover-map distance `d` (ADR 0185). Two-axis reconstruction
+`R̂` / `R̂_c` is computed internally and is 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..fde9aa34e 100644
--- a/docs/adr/0049-leftover-pair-report-ui.md
+++ b/docs/adr/0049-leftover-pair-report-ui.md
@@ -6,12 +6,13 @@
[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 0185](0185-leftover-map-explained-share.md) (explained leftover share);
[ADR 0158](0158-leftover-criterion-evaluation-landing.md) (criterion evaluation landing)
## Context
ADR 0048 persists closest and farthest leftover post–criterion pairs.
-Those pairs only help if a buyer can see them on the Period reports
+Those pairs only help if a reader can see them on the Period reports
panel and open the named post without hunting through the member list.
The member list is already the click-through to Event Lineage, Keyman,
@@ -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.
+distance, full map rank, observed `Y`, expected `E` when finite,
+unexplained leftover `U` when finite, and explained leftover share `e`
+when Gabriel coordinates exist. Every available measurement renders as
+its own badge before opening the post; no amendment hides another, a
+missing share or a missing unexplained leftover keeps its badge off
+without removing the others, and rank 0 explicitly names no leftover
+structure. The single next-action sentence follows a priority order
+among the amendments: explained share (the newest, most specific
+measurement) leads when present, then unexplained leftover names
+"leftover map leaves unexplained `U` after IRT main effects; open this
+post to read the named criterion", then the residual/observed-expected/
+rank fallbacks. A missing share or missing unexplained leftover falls
+through to the next entry in that order.
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).
+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),
+explained-share naming is [ADR 0185](0185-leftover-map-explained-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
@@ -49,9 +55,12 @@ A hidden post never appears as a leftover pair.
The authorized report payload carries `leftover_pairs` next to
`members` and `selected_items`. Screen-reader names are
-`Open leftover closest pair: {title}` and
-`Open leftover farthest pair: {title}` so the control announces the
-next action, not only the distance.
+The visible pair label `{kind}: {title} · {criterion}` (for example
+`Closest leftover: Public post · sales-lead`) doubles as the
+screen-reader name so the announced text matches what the reader sees
+and names the criterion, not only the distance. Earlier increments
+documented an `Open leftover closest pair: {title}` shape; the
+criterion-bearing label supersedes it.
## Related
diff --git a/docs/adr/0185-leftover-map-explained-share.md b/docs/adr/0185-leftover-map-explained-share.md
new file mode 100644
index 000000000..76dc7b53a
--- /dev/null
+++ b/docs/adr/0185-leftover-map-explained-share.md
@@ -0,0 +1,104 @@
+# ADR 0185 — Name explained leftover share on period-report pair rows
+
+**Decision status:** Accepted
+**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) reconstructs a *centered* matrix from
+the biplot as the inner product of person and item coordinates. The
+leftover map buyers read is two-axis: unused axes pad with zero, and
+hidden SVD axes after the second are dropped. Two-axis reconstruction
+`R̂_c = ξ_{1:2} · ζ_{1:2}` therefore recovers centered leftover
+`R̃ = R − center`, not raw residual `R`. Hiding explained leftover
+share `e = R̂_c² / R̃²` lets a buyer read leftover residual `R` or
+leftover-map distance `d` as the leftover the two-axis map reconstructs.
+Using raw `R` in the denominator leaves the grand mean inside the named
+leftover, so a fully reconstructed rank-1 cell would look only partly
+explained whenever `center ≠ 0`. Per-cell `e` is not `1 − U_c² / R̃²`:
+truncated two-axis reconstruction of a higher-rank cell keeps a cross
+term `2 R̂_c U_c`.
+
+This increment does not persist leftover-map reconstruction `R̂` or
+`R̂_c`, does not persist leftover-map unexplained leftover `U` or
+`U_c`, does not persist leftover-map unexplained leftover share `s`,
+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̂_c` is computed internally so `e` is
+honest, then discarded.
+
+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 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_explained_share` — explained
+leftover share `e = R̂_c² / R̃²` of centered leftover after two-axis
+Gabriel reconstruction `R̂_c = ξ_{1:2} · ζ_{1:2}`. 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 map stores `0.0` (`R̃ = 0` and `R̂_c = 0`), not a
+missing value. A fully reconstructed rank-1 cell stores `1.0` even when
+the grand mean of `R` is nonzero. A non-finite or negative share stores
+null rather than inventing a leftover score. Named check
+`leftover_pair_explained_share_nonnegative_chk` rejects a stored
+negative share. Do not persist `leftover_map_unexplained_share`,
+`leftover_map_unexplained`, or `leftover_map_reconstruction`.
+
+The pair button shows `R̂²/R̃² {share}` next to leftover-map distance
+`d` when the value is a finite non-negative number. Next action: two
+leftover-map axes explain `e` of centered leftover 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_explained_share`. After `make seed`, closest and
+farthest leftover pairs sit above the member list with named
+`R̂²/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, and leftover-map unexplained 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..261264d0b 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_explained_share: 0.88,
},
{
pair_kind: "farthest",
@@ -993,6 +994,7 @@ describe("App, authenticated", () => {
observed_response: 0.9,
expected_response: 2.0,
leftover_map_rank: 1,
+ leftover_map_explained_share: 0.55,
},
],
leftover_map_axes: [
@@ -3725,21 +3727,23 @@ describe("App, authenticated", () => {
});
expect(closestPair).toHaveTextContent("Closest leftover: Public post · sales-lead");
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 explain 0.88 of centered leftover 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("R̂c²/R̃² 0.88");
expect(closestPair).toHaveTextContent("U +0.05");
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 explain 0.55 of centered leftover 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("R̂c²/R̃² 0.55");
expect(farthestPair).toHaveTextContent("U −0.25");
expect(farthestPair).toHaveTextContent("d 1.84");
const memberButton = screen.getByRole("button", { name: /open report post: public post/i });
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index cb8bdd628..0bc55fdea 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_explained_share?: number | null;
}
export interface LeftoverMapAxis {
diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx
index 4f5ac2172..21d5e4b12 100644
--- a/frontend/src/components/AdminPanel.tsx
+++ b/frontend/src/components/AdminPanel.tsx
@@ -5,7 +5,7 @@ import { updateTenantConfig } from "../api";
export type AdminPanelProps = {
currentBrandName: string;
onBrandNameChange: (newName: string) => void;
- accessToken: string;
+ accessToken?: string;
};
export function AdminPanel({ currentBrandName, onBrandNameChange, accessToken }: AdminPanelProps) {
@@ -17,6 +17,10 @@ export function AdminPanel({ currentBrandName, onBrandNameChange, accessToken }:
async function handleSave(e: React.FormEvent) {
e.preventDefault();
+ if (!accessToken) {
+ setError("Log in");
+ return;
+ }
if (!normalizedName || normalizedName === currentBrandName) return;
setSaving(true);
setError(null);
diff --git a/frontend/src/components/LeftoverPairList.tsx b/frontend/src/components/LeftoverPairList.tsx
index 92b8b9b1a..e20cf0fb9 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 {
+ formatLeftoverMapExplainedShare,
+ LEFTOVER_MAP_EXPLAINED_SHARE_ACTION,
+} from "../leftoverMapExplainedShare";
import {
formatLeftoverMapRank,
LEFTOVER_RANK_STRUCTURE_ACTION,
@@ -23,11 +27,12 @@ export type LeftoverPairListProps = {
* Closest and farthest leftover post–criterion pairs after IRT main effects.
*
* Distance is the two-axis leftover-map Euclidean gap. Residual is
- * ``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.
+ * ``R = Y − E[Y|θ, item]`` (Jeon et al., 2021, eq. 3 input). Explained
+ * leftover share ``e = R̂_c² / R̃²`` of centered leftover (ADR 0185)
+ * takes priority over unexplained leftover ``U = R − R̂`` (ADR 0182),
+ * which takes priority over the residual/observed-expected/rank next
+ * action, when finite; every badge still renders together before
+ * opening the named post -- no amendment hides another.
*/
export function LeftoverPairList({
pairs,
@@ -49,9 +54,22 @@ export function LeftoverPairList({
pair.expected_response,
);
const rankBadge = formatLeftoverMapRank(pair.leftover_map_rank);
+ const shareBadge = formatLeftoverMapExplainedShare(pair.leftover_map_explained_share);
const unexplained = formatLeftoverMapUnexplained(pair.leftover_map_unexplained);
let nextAction: string;
- if (unexplained !== null) {
+ if (shareBadge !== null) {
+ // Explained share (ADR 0185) is the newest, most specific
+ // leftover-map amendment, so it leads the next-action cascade.
+ // Unexplained leftover (ADR 0182) still renders as its own
+ // badge below -- no amendment hides another.
+ nextAction = tf(LEFTOVER_MAP_EXPLAINED_SHARE_ACTION, {
+ value:
+ pair.leftover_map_explained_share != null
+ ? pair.leftover_map_explained_share.toFixed(2)
+ : "—",
+ criterion,
+ });
+ } else if (unexplained !== null) {
const signedUnexplained =
formatSignedLeftoverValue(pair.leftover_map_unexplained ?? Number.NaN) ?? "—";
nextAction = tf(LEFTOVER_MAP_UNEXPLAINED_ACTION, {
@@ -120,6 +138,7 @@ export function LeftoverPairList({
R {residual}
{observedExpected ? {observedExpected} : null}
{rankBadge ? {rankBadge} : null}
+ {shareBadge ? {shareBadge} : null}
{unexplained ? {unexplained} : null}
d {pair.leftover_distance.toFixed(2)}
diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts
index 78b693313..563a05821 100644
--- a/frontend/src/i18n.test.ts
+++ b/frontend/src/i18n.test.ts
@@ -58,6 +58,7 @@ describe("i18n", () => {
"Leftover map rank {rank} after IRT main effects. Open this post.",
"Read leftover map rank {rank}, observed Y {observed}, and expected E {expected} after IRT main effects, then open this post.",
"Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y {observed} and expected E {expected}, then open this post.",
+ "Two leftover-map axes explain {value} of centered leftover after IRT main effects. Open this post to read {criterion}.",
"Showing the first {shown} of {total} posts known at this cutoff.",
"Inspect ontology neighborhood",
"Ontology neighborhood",
@@ -201,6 +202,33 @@ describe("i18n", () => {
).toBe(expected);
});
+ it.each([
+ [
+ "ko",
+ "잔여 지도의 두 축이 IRT 주효과 이후 중심화 잔여의 0.88만큼을 설명합니다. sales-lead 기준을 읽으려면 이 글을 여세요.",
+ ],
+ [
+ "zh",
+ "残差图的两个轴解释 IRT 主效应后中心化残差的 0.88。打开这篇帖子阅读 sales-lead。",
+ ],
+ [
+ "ja",
+ "残差マップの2軸はIRT主効果後の中心化残差の 0.88 を説明します。この投稿を開いて sales-lead を読んでください。",
+ ],
+ [
+ "vi",
+ "Hai trục của bản đồ phần dư giải thích 0.88 phần dư đã căn giữa sau hiệu ứng chính IRT. Mở bài viết này để đọc sales-lead.",
+ ],
+ ] as const)("formats leftover-map explained share next action in %s", (locale, expected) => {
+ setLocale(locale);
+ expect(
+ tf(
+ "Two leftover-map axes explain {value} of centered leftover after IRT main effects. Open this post to read {criterion}.",
+ { value: "0.88", criterion: "sales-lead" },
+ ),
+ ).toBe(expected);
+ });
+
it.each([
[
"ko",
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
index 64d4385c8..d1af13dd6 100644
--- a/frontend/src/i18n.ts
+++ b/frontend/src/i18n.ts
@@ -458,9 +458,9 @@ const TRANSLATIONS: Partial>> = {
"Open leftover {kind} pair: {title} · {criterion}":
"잔여 {kind} 쌍 열기: {title} · {criterion}",
"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}.":
"잔여 지도가 IRT 주효과 이후 설명되지 않은 U {value}을(를) 남깁니다. {criterion} 기준을 읽으려면 이 글을 여세요.",
"Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.":
@@ -473,6 +473,8 @@ const TRANSLATIONS: Partial>> = {
"IRT 주효과 이후 잔여 맵 랭크 {rank}, 관측 Y {observed}, 기대 E {expected}를 읽은 다음, 이 글을 여세요.",
"Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y {observed} and expected E {expected}, then open this post.":
"IRT 주효과 이후 잔여 맵 랭크 0은 잔여 구조가 없음을 뜻합니다. 관측 Y {observed}와 기대 E {expected}를 읽은 다음, 이 글을 여세요.",
+ "Two leftover-map axes explain {value} of centered leftover after IRT main effects. Open this post to read {criterion}.":
+ "잔여 지도의 두 축이 IRT 주효과 이후 중심화 잔여의 {value}만큼을 설명합니다. {criterion} 기준을 읽으려면 이 글을 여세요.",
},
zh: {
"Unknown": "未知",
@@ -927,6 +929,8 @@ const TRANSLATIONS: Partial>> = {
"阅读 IRT 主效应后的残余图秩 {rank}、观测 Y {observed} 与期望 E {expected},然后打开这篇帖子。",
"Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y {observed} and expected E {expected}, then open this post.":
"残余图秩 0 表示 IRT 主效应后没有残余结构。阅读观测 Y {observed} 与期望 E {expected},然后打开这篇帖子。",
+ "Two leftover-map axes explain {value} of centered leftover after IRT main effects. Open this post to read {criterion}.":
+ "残差图的两个轴解释 IRT 主效应后中心化残差的 {value}。打开这篇帖子阅读 {criterion}。",
},
ja: {
"Unknown": "不明",
@@ -1382,6 +1386,8 @@ const TRANSLATIONS: Partial>> = {
"IRT主効果後の残差マップランク {rank}、観測 Y {observed}、期待 E {expected} を読んでから、この投稿を開いてください。",
"Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y {observed} and expected E {expected}, then open this post.":
"残差マップランク 0 は IRT 主効果後に残差構造がないことを示します。観測 Y {observed} と期待 E {expected} を読んでから、この投稿を開いてください。",
+ "Two leftover-map axes explain {value} of centered leftover after IRT main effects. Open this post to read {criterion}.":
+ "残差マップの2軸はIRT主効果後の中心化残差の {value} を説明します。この投稿を開いて {criterion} を読んでください。",
},
vi: {
"Unknown": "Không rõ",
@@ -1837,6 +1843,8 @@ const TRANSLATIONS: Partial>> = {
"Đọc hạng bản đồ phần dư {rank}, 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 rank 0 means no leftover structure after IRT main effects. Read observed Y {observed} and expected E {expected}, then open this post.":
"Hạng bản đồ phần dư 0 nghĩa là không có cấu trúc phần dư sau hiệu ứng chính IRT. Đọc Y quan sát {observed} và E kỳ vọng {expected}, rồi mở bài viết này.",
+ "Two leftover-map axes explain {value} of centered leftover after IRT main effects. Open this post to read {criterion}.":
+ "Hai trục của bản đồ phần dư giải thích {value} phần dư đã căn giữa sau hiệu ứng chính IRT. Mở bài viết này để đọc {criterion}.",
},
};
diff --git a/frontend/src/leftoverMapExplainedShare.test.ts b/frontend/src/leftoverMapExplainedShare.test.ts
new file mode 100644
index 000000000..fcde8a631
--- /dev/null
+++ b/frontend/src/leftoverMapExplainedShare.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from "vitest";
+import { formatLeftoverMapExplainedShare } from "./leftoverMapExplainedShare";
+
+describe("formatLeftoverMapExplainedShare", () => {
+ it("names explained leftover share without inventing a leftover score", () => {
+ expect(formatLeftoverMapExplainedShare(0.88)).toBe("R\u0302c\u00b2/R\u0303\u00b2 0.88");
+ expect(formatLeftoverMapExplainedShare(0)).toBe("R\u0302c\u00b2/R\u0303\u00b2 0.00");
+ expect(formatLeftoverMapExplainedShare(1)).toBe("R\u0302c\u00b2/R\u0303\u00b2 1.00");
+ });
+
+ it("omits the badge when explained leftover share is missing, negative, or non-finite", () => {
+ expect(formatLeftoverMapExplainedShare(null)).toBeNull();
+ expect(formatLeftoverMapExplainedShare(undefined)).toBeNull();
+ expect(formatLeftoverMapExplainedShare(Number.NaN)).toBeNull();
+ expect(formatLeftoverMapExplainedShare(Number.POSITIVE_INFINITY)).toBeNull();
+ expect(formatLeftoverMapExplainedShare(-0.01)).toBeNull();
+ });
+});
diff --git a/frontend/src/leftoverMapExplainedShare.ts b/frontend/src/leftoverMapExplainedShare.ts
new file mode 100644
index 000000000..08e9d8718
--- /dev/null
+++ b/frontend/src/leftoverMapExplainedShare.ts
@@ -0,0 +1,13 @@
+/** Explained leftover share ``e = R̂_c² / R̃²`` of centered leftover. */
+
+export const LEFTOVER_MAP_EXPLAINED_SHARE_ACTION =
+ "Two leftover-map axes explain {value} of centered leftover after IRT main effects. Open this post to read {criterion}.";
+
+export function formatLeftoverMapExplainedShare(
+ value: number | null | undefined,
+): string | null {
+ if (value == null || !Number.isFinite(value) || value < 0) {
+ return null;
+ }
+ return `R\u0302c\u00b2/R\u0303\u00b2 ${value.toFixed(2)}`;
+}
diff --git a/lineageweave/leftover_pairs.py b/lineageweave/leftover_pairs.py
index cb7ae265d..29bc97368 100644
--- a/lineageweave/leftover_pairs.py
+++ b/lineageweave/leftover_pairs.py
@@ -1,6 +1,7 @@
"""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
@@ -14,11 +15,16 @@
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
-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.
+that rectangle. 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``. Each pair also names explained
+leftover share ``e = R̂_c² / R̃²`` of the *centered* leftover ``R̃ = R −
+center`` that two-axis reconstruction ``R̂_c`` recovers, so that share
+is not confused with unexplained leftover ``U`` (which uses raw
+residual, not centered) or leftover-map distance ``d``. Reconstruction
+``R̂`` / ``R̂_c`` and centered leftover ``U_c`` are computed internally
+and are not persisted.
"""
from __future__ import annotations
@@ -47,6 +53,7 @@ class LeftoverPair:
expected_response: float
leftover_map_rank: int
leftover_map_unexplained: float | None = None
+ leftover_map_explained_share: float | None = None
@dataclass(frozen=True)
@@ -87,9 +94,11 @@ 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 explained leftover share
+ ``e = R̂_c² / R̃²`` names the fraction of *centered* leftover ``R̃ =
+ R − center`` that reconstruction ``R̂_c`` recovers; ``R̂`` and
+ ``R̂_c`` stay internal and are never persisted. Fallback pairs (no
+ complete-case map) omit both rather than fabricating them.
"""
pairs, _axes = leftover_map_from_residual(post_ids, item_codes, matrix, expected)
return pairs
@@ -137,7 +146,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 +167,9 @@ def leftover_map_from_residual(
reconstruction = float(
np.dot(person_xy[local_person[person]], item_xy[local_item[item]])
)
+ filled = float(residual[person, item]) - center
unexplained = _unexplained_leftover(float(residual[person, item]), reconstruction)
+ share = _explained_leftover_share(filled, reconstruction)
candidates.append(
_candidate_row(
post_ids,
@@ -168,6 +181,7 @@ def leftover_map_from_residual(
item,
distance,
unexplained,
+ share,
)
)
if not candidates:
@@ -194,6 +208,30 @@ def _unexplained_leftover(residual: float, reconstruction: float) -> float | Non
return float(unexplained)
+def _explained_leftover_share(filled: float, reconstruction: float) -> float | None:
+ """Return ``e = R̂_c² / R̃²`` when both terms are finite; otherwise omit.
+
+ ``filled`` is centered leftover ``R̃ = R − center``. Gabriel
+ reconstruction recovers that centered matrix, not raw residual
+ ``R``. Using raw ``R`` in the denominator leaves the grand mean
+ inside the named leftover and makes a fully reconstructed rank-1
+ cell look only partly explained whenever ``center ≠ 0``. Per-cell
+ ``e`` is not ``1 − U_c² / R̃²``: truncated two-axis reconstruction
+ of a higher-rank cell keeps a cross term ``2 R̂_c U_c``.
+ """
+ if not np.isfinite(filled) or not np.isfinite(reconstruction):
+ return None
+ # Compare the absolute magnitudes against the singular floor before
+ # squaring: squaring first makes the effective threshold sqrt(1e-12)
+ # and can overflow large finite inputs to inf.
+ if abs(filled) > _LEFTOVER_SINGULAR_FLOOR:
+ share = float((reconstruction / filled) ** 2)
+ return share if np.isfinite(share) and share >= 0.0 else None
+ if abs(reconstruction) <= _LEFTOVER_SINGULAR_FLOOR:
+ return 0.0
+ return None
+
+
def _candidate_row(
post_ids: list[str],
item_codes: tuple[str, ...],
@@ -204,8 +242,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_explained_share: float | None,
+) -> tuple[float, str, str, float, float, float, float | None, float | None]:
+ """One observed leftover cell: distance, ids, residual, Y, E, unexplained U, explained share."""
leftover_residual = float(residual[person, item])
observed_response = float(matrix[person, item])
expected_response = float(expected[person, item])
@@ -219,12 +258,13 @@ def _candidate_row(
observed_response,
expected_response,
leftover_map_unexplained,
+ leftover_map_explained_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 +280,7 @@ def _pair_from_candidate(
expected_response=row[5],
leftover_map_rank=leftover_map_rank,
leftover_map_unexplained=row[6],
+ leftover_map_explained_share=row[7],
)
@@ -376,7 +417,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
+ explained leftover 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_explained_share.sql b/migrations/0185_report_leftover_map_explained_share.sql
new file mode 100644
index 000000000..c3d40b804
--- /dev/null
+++ b/migrations/0185_report_leftover_map_explained_share.sql
@@ -0,0 +1,31 @@
+-- ADR 0185: persist explained leftover share e = R̂_c² / R̃² of the
+-- centered leftover the two-axis leftover map reconstructs
+-- (R̃ = R − center, R̂_c = ξ_{1:2} · ζ_{1:2}).
+-- 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_unexplained_share, leftover_map_unexplained, or
+-- leftover_map_reconstruction.
+
+alter table report_leftover_pair
+ add column if not exists leftover_map_explained_share numeric;
+
+do $$
+begin
+ if not exists (
+ select 1
+ from pg_constraint
+ where conname = 'leftover_pair_explained_share_nonnegative_chk'
+ ) then
+ alter table report_leftover_pair
+ add constraint leftover_pair_explained_share_nonnegative_chk
+ check (
+ leftover_map_explained_share is null
+ or leftover_map_explained_share >= 0
+ );
+ end if;
+end
+$$;
diff --git a/migrations/rollback/0185_report_leftover_map_explained_share.sql b/migrations/rollback/0185_report_leftover_map_explained_share.sql
new file mode 100644
index 000000000..d7758bf69
--- /dev/null
+++ b/migrations/rollback/0185_report_leftover_map_explained_share.sql
@@ -0,0 +1,7 @@
+-- Reverse 0185. Leftover distance and residual stay on the pair row.
+
+alter table report_leftover_pair
+ drop constraint if exists leftover_pair_explained_share_nonnegative_chk;
+
+alter table report_leftover_pair
+ drop column if exists leftover_map_explained_share;
diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py
index 1418d9af0..81e80ba4b 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_explained_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_explained_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_explained_share,
),
)
for axis in report.leftover_map_axes:
diff --git a/tests/test_leftover_pairs.py b/tests/test_leftover_pairs.py
index a411e1e13..e4221bf1a 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
@@ -107,6 +107,12 @@ def test_leftover_residual_biplot_separates_aligned_and_opposed_cells() -> None:
for pair in pairs:
_assert_residual_reconciles(pair)
assert pair.leftover_map_rank == 1
+ # Closest is the origin cell (R̃ = 0, R̂_c = 0): 0/0 stores 0, not 1.
+ assert closest.leftover_residual == pytest.approx(0.0)
+ assert closest.leftover_map_explained_share == pytest.approx(0.0, abs=1e-6)
+ assert farthest.leftover_map_explained_share == pytest.approx(1.0, abs=1e-6)
+ assert not hasattr(closest, "leftover_map_unexplained_share")
+ assert not hasattr(closest, "leftover_map_reconstruction")
coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected)
assert coverage.map_post_count == 3
assert coverage.scored_post_count == 3
@@ -136,6 +142,8 @@ def test_zero_residual_still_emits_stable_leftover_pairs() -> None:
for pair in pairs:
_assert_residual_reconciles(pair)
assert pair.leftover_map_rank == 0
+ assert pairs[0].leftover_map_explained_share == pytest.approx(0.0)
+ assert pairs[1].leftover_map_explained_share == pytest.approx(0.0)
coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected)
assert coverage.map_post_count == 2
assert coverage.scored_post_count == 2
@@ -169,6 +177,7 @@ def test_partial_observation_does_not_treat_missing_as_zero_residual() -> None:
for pair in pairs:
_assert_residual_reconciles(pair)
assert pair.leftover_map_rank == 1
+ assert pair.leftover_map_explained_share == pytest.approx(1.0, abs=1e-6)
assert pair.leftover_map_unexplained == 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
@@ -195,6 +204,55 @@ def test_leftover_is_empty_without_observed_cells() -> None:
assert coverage.incomplete_item_count == 0
+def test_leftover_fallback_names_no_pairs_without_complete_case_map() -> None:
+ """No complete-case rectangle names no pairs at all (ADR 0168).
+
+ The report carries coverage counts instead of a center-distance
+ stand-in pair, so there is no pair on which an explained share
+ could be omitted.
+ """
+ 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)
+ pairs = leftover_pairs_from_residual(post_ids, item_codes, matrix, expected)
+ assert pairs == ()
+
+
+def test_rank_one_nonzero_center_still_has_full_explained_share() -> None:
+ """Share uses centered leftover, so a reconstructed rank-1 cell stays e=1 when mean(R) ≠ 0."""
+ 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
+ # Origin cell after centering is 0/0 → e = 0. A reconstructed
+ # nonzero cell stays e = 1 even though raw R is not 0.
+ assert closest.leftover_map_explained_share == pytest.approx(0.0, abs=1e-6)
+ assert farthest.leftover_residual != pytest.approx(0.0)
+ assert farthest.leftover_map_explained_share == pytest.approx(1.0, abs=1e-6)
+ uncentered_share = (2.0 * 2.0) / (farthest.leftover_residual * farthest.leftover_residual)
+ assert uncentered_share != pytest.approx(1.0)
+ assert farthest.leftover_map_explained_share != pytest.approx(uncentered_share)
+ assert not hasattr(farthest, "leftover_map_unexplained_share")
+
+
def test_leftover_residual_equals_observed_minus_expected() -> None:
"""Named Y and E on leftover pairs must reconcile to R = Y − E."""
post_ids = ["public-post", "spec-post"]
@@ -241,9 +299,101 @@ def test_leftover_residual_rejects_database_tolerance_boundary() -> None:
0,
0.0,
None,
+ None,
)
+def test_rank_three_explained_share_equals_centered_reconstruction_fraction() -> None:
+ """Explained leftover share is R̂_c²/R̃², not leftover residual R, not leftover-map distance d."""
+ post_ids = ["post-a", "post-b", "post-c", "post-d"]
+ item_codes = ("item-a", "item-b", "item-c", "item-d")
+ matrix = np.array(
+ [
+ [4.0, 1.0, 0.0, -1.0],
+ [0.0, 3.0, 1.0, -2.0],
+ [-2.0, 0.0, 2.0, 1.0],
+ [1.0, -1.0, 0.0, 4.0],
+ ],
+ dtype=np.float64,
+ )
+ expected = np.zeros_like(matrix)
+ center = float(np.mean(matrix))
+ filled = matrix - center
+ person_full, item_full, _rank = leftover._leftover_map_positions(filled)
+ 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
+ # leftover_distance is Euclidean on the two-axis padded map (same as
+ # test_rank_four_pair_distances_match_two_dimensional_gabriel_coords),
+ # not on the full-rank Gabriel positions.
+ 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_cross_term = False
+ for pair in pairs:
+ person = post_index[pair.post_id]
+ item = item_index[pair.criterion_code]
+ centered = float(pair.leftover_residual) - center
+ recon = float(reconstruction[person, item])
+ expected_share = (recon * recon) / (centered * centered)
+ unexplained = centered - recon
+ unexplained_share = (unexplained * unexplained) / (centered * centered)
+ assert pair.leftover_map_explained_share == pytest.approx(expected_share)
+ if abs(expected_share + unexplained_share - 1.0) > 1e-6:
+ saw_cross_term = True
+ assert pair.leftover_map_explained_share != pytest.approx(pair.leftover_residual)
+ assert pair.leftover_map_explained_share != pytest.approx(pair.leftover_distance)
+ assert pair.leftover_distance == pytest.approx(float(map_distances[person, item]))
+ assert pair.leftover_map_explained_share >= 0.0
+ assert not hasattr(pair, "leftover_map_unexplained_share")
+ assert not hasattr(pair, "leftover_map_reconstruction")
+ assert saw_cross_term
+
+
+def test_pad_map_axes_truncates_hidden_svd_components() -> None:
+ """Axes after the second leftover-map axis do not enter reconstruction."""
+ padded = leftover._pad_map_axes(np.array([[1.0, 2.0, 9.0]], dtype=np.float64))
+ assert padded.shape == (1, 2)
+ assert padded[0].tolist() == pytest.approx([1.0, 2.0])
+
+
+def test_small_rank1_cell_keeps_nonzero_explained_share() -> None:
+ """A tiny-but-valid rank-1 cell keeps its share instead of collapsing to 0.
+
+ The singular floor applies to absolute magnitudes, not squared values, so
+ a 1e-7-scale residual pair is still measured rather than floored to zero.
+ """
+ post_ids = ["post-a", "post-b"]
+ item_codes = ("item-a", "item-b")
+ matrix = np.array(
+ [
+ [1e-7 + 3.0, 3.0],
+ [3.0, 3.0 - 1e-7],
+ ],
+ dtype=np.float64,
+ )
+ expected = np.zeros_like(matrix)
+ pairs = leftover_pairs_from_residual(post_ids, item_codes, matrix, expected)
+ for pair in pairs:
+ if abs(pair.leftover_residual) > 0.0:
+ assert pair.leftover_map_explained_share is not None
+ assert pair.leftover_map_explained_share == pytest.approx(1.0, abs=1e-6)
+
+
+def test_large_finite_cell_returns_none_instead_of_inf() -> None:
+ """Squaring large finite values must not leak inf into the persisted share."""
+ share = leftover._explained_leftover_share(1e200, 2e200)
+ assert share is None or np.isfinite(share)
+
+
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"]
@@ -384,13 +534,6 @@ def test_unexplained_equals_residual_minus_two_axis_reconstruction() -> None:
assert not hasattr(pair, "leftover_map_reconstruction")
-def test_pad_map_axes_truncates_hidden_svd_components() -> None:
- """Axes after the second leftover-map axis do not enter reconstruction."""
- padded = leftover._pad_map_axes(np.array([[1.0, 2.0, 9.0]], dtype=np.float64))
- assert padded.shape == (1, 2)
- assert padded[0].tolist() == pytest.approx([1.0, 2.0])
-
-
def test_rejects_response_and_expectation_shape_mismatches() -> None:
"""Scientific inputs must match their declared post and criterion axes."""
with pytest.raises(ValueError, match="matrix shape"):
@@ -453,7 +596,9 @@ def test_leftover_map_rank_rejects_negative_rank() -> None:
with pytest.raises(ValueError, match="non-negative integer"):
leftover._pair_from_candidate(
PAIR_KIND_CLOSEST,
- (0.0, "public-post", "sales_lead_specificity", 0.0, 1.0, 1.0, None),
+ # Full 8-element candidate row: the guard must fire on the rank,
+ # not survive by short-circuiting before row[7] is read.
+ (0.0, "public-post", "sales_lead_specificity", 0.0, 1.0, 1.0, None, None),
-1,
)
diff --git a/tests/test_period_report.py b/tests/test_period_report.py
index 95021ac4b..52dec2a3f 100644
--- a/tests/test_period_report.py
+++ b/tests/test_period_report.py
@@ -270,8 +270,12 @@ def test_calibrated_report_attaches_leftover_pairs() -> None:
pair.observed_response - pair.expected_response, abs=1e-6
)
assert pair.leftover_map_rank >= 0
+ if pair.leftover_map_explained_share is not None:
+ assert np.isfinite(pair.leftover_map_explained_share)
+ assert pair.leftover_map_explained_share >= 0.0
if pair.leftover_map_unexplained is not None:
assert np.isfinite(pair.leftover_map_unexplained)
+ 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..1318a8356 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -68,6 +68,11 @@
/ "migrations"
/ "0182_report_leftover_map_unexplained.sql"
)
+_LEFTOVER_MAP_EXPLAINED_SHARE_MIGRATION = (
+ Path(__file__).resolve().parents[1]
+ / "migrations"
+ / "0185_report_leftover_map_explained_share.sql"
+)
def _postgres_available() -> bool:
@@ -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_EXPLAINED_SHARE_MIGRATION.read_text())
conn.commit()
yield conn
finally:
@@ -257,6 +263,34 @@ def test_leftover_pair_names_nullable_unexplained_column(schema_db) -> None:
assert "leftover_map_reconstruction" not in columns
+def test_leftover_pair_names_nullable_explained_share_column(schema_db) -> None:
+ """Every install path preserves legacy pairs while naming explained leftover 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_explained_share"] == "YES"
+ assert columns["leftover_residual"] == "NO"
+ assert columns["leftover_distance"] == "NO"
+ assert "leftover_map_unexplained_share" not in columns
+ assert "leftover_map_reconstruction" not in columns
+ with schema_db.cursor() as cur:
+ cur.execute(
+ """
+ select 1
+ from pg_constraint
+ where conrelid = 'report_leftover_pair'::regclass
+ and conname = 'leftover_pair_explained_share_nonnegative_chk'
+ """
+ )
+ assert cur.fetchone() is not None
+
+
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: