diff --git a/AGENTS.md b/AGENTS.md
index 6544da08d..ce89c932c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -259,23 +259,24 @@ 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 / 0186) 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
-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
+`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 two-axis
+leftover-map reconstruction `R̂` of centered leftover, plus unexplained
+leftover `U = R − R̂` next to leftover-map distance `d`. They sit above
+the member list so a click opens that post with the leftover criterion
+current 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.
+many scored posts entered the map; without a complete-case rectangle
+there is no pair to caption, only coverage counts.
Period leftover pairs (ADR 0017 / 0018 / 0048 / 0049 / 0149) are computed in
`lineageweave/leftover_pairs.py` from the residual after a real
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 46a8dd7e2..6026c3063 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
+ADR 0017 / 0048 / 0049 / 0119 / 0158 / 0162 / 0163 / 0164 / 0182 / 0186) 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
+`E[Y|θ, item]`, full leftover-map rank, two-axis leftover-map
+reconstruction `R̂`, 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
`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
@@ -617,7 +618,8 @@ through opens those DAG posts. Report members include the earliest
open ticket title, status lookup label, and due date when one exists. The home page renders
the actual mean θ, the FIPC delta, the CAT-selected item, leftover
closest/farthest pairs (signed residual `R`, observed `Y`, expected
-`E`, full rank, and two-axis leftover-map distance `d` after IRT main
+`E`, full rank, two-axis leftover-map reconstruction `R̂`, and
+two-axis leftover-map distance `d` after IRT main
effects) above the member list, leftover-map axis share for residual
SVD axes 1 and 2, and complete-case coverage captions (map used N of M
scored posts), plus the
diff --git a/CHANGELOG.d/2.12.30-leftover-map-reconstruction.md b/CHANGELOG.d/2.12.30-leftover-map-reconstruction.md
new file mode 100644
index 000000000..f4280e5f5
--- /dev/null
+++ b/CHANGELOG.d/2.12.30-leftover-map-reconstruction.md
@@ -0,0 +1,8 @@
+## 2.12.30 — Leftover-map reconstruction
+
+- Persist two-axis leftover-map reconstruction `R̂_c = ξ_{1:2} · ζ_{1:2}`
+ of centered leftover on leftover post–criterion pairs (ADR 0186).
+ After `make seed`, closest and farthest leftover pairs sit above the
+ member list with `R̂ {value}` next to leftover-map distance `d`; click
+ opens that post. Fallback pairs omit reconstruction. Rank-0 origin
+ maps store `R̂ = 0`. Never invent a leftover score or a theta.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 94e653987..99f54d00c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -107,6 +107,16 @@ All notable changes to this project are documented here. Format follows
is unwired, with `이 범위의 일정을 아직 받을 수 없습니다`. Weekly VOC and
newspaper stay on the board.
+## [2.12.30] - 2026-08-24
+
+### Added
+
+- Period leftover pair rows now name two-axis leftover-map reconstruction
+ `R̂` of centered leftover next to leftover-map distance `d`, then open
+ that post (Gabriel, 1971; Jeon et al., 2021, eq. 3; ADR 0186). Fallback
+ pairs omit the reconstruction rather than inventing one. Rank-0 origin
+ maps store `R̂ = 0`. Never invent a leftover score or a theta.
+
## [2.12.26] - 2026-08-24
### Added
@@ -114,8 +124,7 @@ All notable changes to this project are documented here. Format follows
- Period leftover pair rows now name unexplained leftover `U = R − R̂`
next to leftover-map distance `d`, then open that post (Gabriel, 1971;
Jeon et al., 2021, eq. 3; ADR 0182). A missing unexplained leftover
- omits the badge rather than inventing a leftover score. Two-axis
- reconstruction `R̂` stays internal and is not persisted.
+ omits the badge rather than inventing a leftover score.
## [2.12.19] - 2026-08-24
diff --git a/CLAUDE.md b/CLAUDE.md
index f32325abc..3bb87b48c 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 / 0186), 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..64d3605d7 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_reconstruction, leftover_map_unexplained
+ ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
""",
grouping_kind,
grouping_key,
@@ -460,6 +460,7 @@ async def persist_period_report(
pair.observed_response,
pair.expected_response,
pair.leftover_map_rank,
+ pair.leftover_map_reconstruction,
pair.leftover_map_unexplained,
)
for axis in report.leftover_map_axes:
@@ -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_reconstruction, lp.leftover_map_unexplained, p.post_title,
p.visibility_code, p.corporate_entity_id,
({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context
from report_leftover_pair lp
@@ -785,6 +786,11 @@ async def fetch_period_reports(
if row["leftover_map_rank"] is None
else int(row["leftover_map_rank"])
),
+ "leftover_map_reconstruction": (
+ None
+ if row["leftover_map_reconstruction"] is None
+ else float(row["leftover_map_reconstruction"])
+ ),
"leftover_map_unexplained": (
None
if row["leftover_map_unexplained"] is None
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index ac546b63b..fe9adae1a 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
@@ -162,6 +163,11 @@
/ "migrations"
/ "0182_report_leftover_map_unexplained.sql"
)
+_LEFTOVER_MAP_RECONSTRUCTION_MIGRATION = (
+ Path(__file__).resolve().parents[2]
+ / "migrations"
+ / "0186_report_leftover_map_reconstruction.sql"
+)
def _postgres_available() -> bool:
@@ -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_RECONSTRUCTION_MIGRATION.read_text())
cur.execute(
"insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values "
"('corporate_entity_level', 'group', 'Group'), "
@@ -5044,9 +5051,11 @@ def test_seed_period_report_surfaces_on_get_reports(client, demo_analyst_token,
assert all(pair["leftover_distance"] >= 0 for pair in high_report.get("leftover_pairs", []))
for pair in high_report.get("leftover_pairs", []):
assert pair["leftover_map_rank"] >= 0
+ reconstruction = pair.get("leftover_map_reconstruction")
+ if reconstruction is not None:
+ assert math.isfinite(reconstruction)
unexplained = pair.get("leftover_map_unexplained")
assert unexplained is None or isinstance(unexplained, (int, float))
- 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:
diff --git a/docs/adr/0003-fast-mlsirm-report-integration.md b/docs/adr/0003-fast-mlsirm-report-integration.md
index ed4ca22c8..08f95df9a 100644
--- a/docs/adr/0003-fast-mlsirm-report-integration.md
+++ b/docs/adr/0003-fast-mlsirm-report-integration.md
@@ -100,14 +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
- 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
- leftover-pair API inside `fast-mlsirm` in this slice.
+7. **Leftover-pair slice** (shipped in 0.71.2; ADR 0017 / 0018 / 0048 /
+ 0049 / 0182, amended by ADR 0186): after IRT main effects, persist
+ closest and farthest post–criterion pairs from the residual leftover
+ map, including two-axis leftover-map reconstruction `R̂` and
+ 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 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
SVD. Rank-0 residuals emit two zero-share axes. Do not invent a
diff --git a/docs/adr/0048-persist-lsirm-leftover-pairs.md b/docs/adr/0048-persist-lsirm-leftover-pairs.md
index 0bab2fc32..797cfbddf 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 0186](0186-leftover-map-reconstruction.md) (two-axis reconstruction R̂)
## Context
@@ -38,11 +39,11 @@ Euclidean on the two leftover-map axes (ADR 0119). Each leftover row
also names observed `Y` and expected `E[Y|θ, item]` so residual
reconciles to `Y − E` (ADR 0163), and names the full singular-value
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.
+leftover row also names two-axis leftover-map reconstruction
+`R̂_c = ξ_{1:2} · ζ_{1:2}` of centered leftover (ADR 0186) and
+unexplained leftover `U = R − R̂_c` 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).
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..986863d45 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 0186](0186-leftover-map-reconstruction.md) (two-axis reconstruction R̂)
## Context
@@ -23,8 +24,9 @@ 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.
+distance, full map rank, leftover-map reconstruction `R̂` when finite,
+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
@@ -35,8 +37,9 @@ 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 0164](0164-leftover-map-rank.md), reconstruction naming is
+[ADR 0186](0186-leftover-map-reconstruction.md), unexplained leftover
+naming is [ADR 0182](0182-leftover-map-unexplained.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/0186-leftover-map-reconstruction.md b/docs/adr/0186-leftover-map-reconstruction.md
new file mode 100644
index 000000000..f2c299919
--- /dev/null
+++ b/docs/adr/0186-leftover-map-reconstruction.md
@@ -0,0 +1,93 @@
+# ADR 0186 — Name leftover-map reconstruction on leftover pairs
+
+**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` and not leftover-map distance
+`d`. Hiding `R̂_c` lets a buyer read distance as if it named how much
+leftover the two leftover-map axes reconstruct.
+
+This increment does not persist leftover-map coordinates, does not
+persist leftover-map unexplained leftover `U` or `U_c`, does not
+persist leftover-map explained leftover share `e`, unexplained leftover
+share `s`, or leftover-map cross share `x`, does not name leftover-map
+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 two-axis Euclidean (ADR 0119).
+
+The unprotected-stack reconstructions for neighbouring leftover facts
+use 0162–0185. This protected-main increment uses **0186** so it does
+not collide with leftover-map cross share (0185), leftover-map
+explained leftover share (0184), leftover-map unexplained leftover
+share (0183), leftover-map unexplained leftover (0182), leftover-map
+length (0181 on the length stack), leftover-map cosine (0180),
+leftover-map inner product (0179), leftover residual disclosure
+(0162 / 0178), leftover observed `Y` / expected `E` (0163 / 0170),
+leftover-map rank (0164 / 0172), two-axis leftover-map distance
+(0119 / 0166), leftover coverage (0165 / 0168), leftover-map axis
+share (0148), or leftover interaction-map persistence (0121).
+
+## Decision
+
+Each leftover pair names `leftover_map_reconstruction` — two-axis
+Gabriel reconstruction `R̂_c = ξ_{1:2} · ζ_{1:2}` of centered leftover
+after IRT main effects. Migration `0186` 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 reconstruction. Fallback pairs that have no
+complete-case leftover map omit the value rather than inventing one.
+A rank-0 origin map stores `0.0` (`ξ = 0`, `ζ = 0`), not a missing
+value. A non-finite reconstruction stores null rather than inventing
+a leftover score. A finite negative reconstruction is stored; do not
+add a nonnegative CHECK. Do not persist
+`leftover_map_explained_share`, `leftover_map_unexplained_share`,
+`leftover_map_cross_share`, or leftover-map coordinates. The signed
+`leftover_map_unexplained` value `U = R - R̂` remains persisted under ADR
+0182; it is evidence, not one of the prohibited derived share aliases.
+
+The pair button shows `R̂ {reconstruction}` next to leftover-map
+distance `d` when the value is a finite number, including a signed
+negative reconstruction. Next action: two leftover-map axes
+reconstruct centered leftover `R̂` after IRT main effects; open this
+post. A missing or non-finite reconstruction omits the badge and
+keeps the existing closest/farthest next action. Do not invent a
+leftover score or a theta.
+
+## Consequences
+
+`GET /api/reports/{grouping}/{period}` returns
+`leftover_map_reconstruction`. After `make seed`, closest and farthest
+leftover pairs sit above the member list with leftover-map
+reconstruction; click opens that post. Hidden posts stay hidden.
+
+Independent of leftover interaction-map persistence, leftover-criterion
+evaluation landing, leftover residual UI extraction, two-axis leftover-map
+distance, leftover observed `Y` / expected `E`, leftover-map rank, and
+leftover-map share identities.
+
+## 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..3170f465c 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_reconstruction: 0.4,
},
{
pair_kind: "farthest",
@@ -993,6 +994,7 @@ describe("App, authenticated", () => {
observed_response: 0.9,
expected_response: 2.0,
leftover_map_rank: 1,
+ leftover_map_reconstruction: -1.1,
},
],
leftover_map_axes: [
@@ -3730,6 +3732,7 @@ describe("App, authenticated", () => {
expect(closestPair).toHaveTextContent("R +0.40");
expect(closestPair).toHaveTextContent("Y 2.40 · E 2.00");
expect(closestPair).toHaveTextContent("rank 1");
+ expect(closestPair).toHaveTextContent("R̂ +0.40");
expect(closestPair).toHaveTextContent("U +0.05");
expect(closestPair).toHaveTextContent("d 0.12");
expect(closestPair).toHaveAccessibleName("Open leftover closest pair: Public post · sales-lead");
@@ -3740,6 +3743,7 @@ describe("App, authenticated", () => {
expect(farthestPair).toHaveTextContent("R −1.10");
expect(farthestPair).toHaveTextContent("Y 0.90 · E 2.00");
expect(farthestPair).toHaveTextContent("rank 1");
+ expect(farthestPair).toHaveTextContent("R̂ −1.10");
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..c3d198bc2 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -877,6 +877,7 @@ export interface LeftoverPair {
observed_response?: number | null;
expected_response?: number | null;
leftover_map_rank?: number | null;
+ leftover_map_reconstruction?: number | null;
leftover_map_unexplained?: number | null;
}
diff --git a/frontend/src/components/LeftoverPairList.stories.tsx b/frontend/src/components/LeftoverPairList.stories.tsx
index 680640026..7834b8fa0 100644
--- a/frontend/src/components/LeftoverPairList.stories.tsx
+++ b/frontend/src/components/LeftoverPairList.stories.tsx
@@ -19,6 +19,7 @@ const meta = {
observed_response: 2.4,
expected_response: 2.0,
leftover_map_rank: 1,
+ leftover_map_reconstruction: 0.4,
},
{
pair_kind: "farthest",
@@ -30,6 +31,7 @@ const meta = {
observed_response: 0.9,
expected_response: 2.0,
leftover_map_rank: 1,
+ leftover_map_reconstruction: -1.1,
},
],
},
diff --git a/frontend/src/components/LeftoverPairList.test.tsx b/frontend/src/components/LeftoverPairList.test.tsx
index 4108c3127..836689de9 100644
--- a/frontend/src/components/LeftoverPairList.test.tsx
+++ b/frontend/src/components/LeftoverPairList.test.tsx
@@ -15,6 +15,7 @@ const PAIRS: LeftoverPair[] = [
observed_response: 2.4,
expected_response: 2.0,
leftover_map_rank: 1,
+ leftover_map_reconstruction: 0.4,
},
{
pair_kind: "farthest",
@@ -26,6 +27,7 @@ const PAIRS: LeftoverPair[] = [
observed_response: 0.9,
expected_response: 2.0,
leftover_map_rank: 1,
+ leftover_map_reconstruction: -1.1,
},
];
@@ -50,11 +52,12 @@ describe("LeftoverPairList", () => {
});
expect(closest).toHaveTextContent("Closest leftover: Public post · sales-lead");
expect(closest).toHaveTextContent(
- "Read leftover map rank 1, observed Y 2.40, and expected E 2.00 after IRT main effects, then open this post.",
+ "Two leftover-map axes reconstruct centered leftover R̂ +0.40 after IRT main effects. Open this post.",
);
expect(closest).toHaveTextContent("R +0.40");
expect(closest).toHaveTextContent("Y 2.40 · E 2.00");
expect(closest).toHaveTextContent("rank 1");
+ expect(closest).toHaveTextContent("R̂ +0.40");
expect(closest).toHaveTextContent("d 0.12");
const farthest = screen.getByRole("button", {
@@ -63,9 +66,10 @@ describe("LeftoverPairList", () => {
expect(farthest).toHaveTextContent("R −1.10");
expect(farthest).toHaveTextContent("Y 0.90 · E 2.00");
expect(farthest).toHaveTextContent("rank 1");
+ expect(farthest).toHaveTextContent("R̂ −1.10");
expect(farthest).toHaveTextContent("d 1.84");
expect(farthest).toHaveTextContent(
- "Read leftover map rank 1, observed Y 0.90, and expected E 2.00 after IRT main effects, then open this post.",
+ "Two leftover-map axes reconstruct centered leftover R̂ −1.10 after IRT main effects. Open this post.",
);
await userEvent.click(closest);
@@ -81,7 +85,7 @@ describe("LeftoverPairList", () => {
it("keeps residual guidance for an older payload without rank or Y/E", () => {
render(
,
@@ -95,24 +99,29 @@ describe("LeftoverPairList", () => {
it.each([
[
"rank-zero observed evidence",
- { observed_response: 1, expected_response: 1, leftover_map_rank: 0 },
+ { observed_response: 1, expected_response: 1, leftover_map_rank: 0, leftover_map_reconstruction: null },
"Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y 1.00 and expected E 1.00, then open this post.",
],
[
"rank-only evidence",
- { observed_response: null, expected_response: null, leftover_map_rank: 2 },
+ { observed_response: null, expected_response: null, leftover_map_rank: 2, leftover_map_reconstruction: null },
"Leftover map rank 2 after IRT main effects. Open this post.",
],
[
"rank-zero-only evidence",
- { observed_response: null, expected_response: null, leftover_map_rank: 0 },
+ { observed_response: null, expected_response: null, leftover_map_rank: 0, leftover_map_reconstruction: null },
"Leftover map has no leftover structure after IRT main effects. Open this post.",
],
[
"observed and expected evidence",
- { observed_response: 2.4, expected_response: 2, leftover_map_rank: null },
+ { observed_response: 2.4, expected_response: 2, leftover_map_rank: null, leftover_map_reconstruction: null },
"Read observed Y 2.40 and expected E 2.00 after IRT main effects, then open this post.",
],
+ [
+ "reconstruction evidence",
+ { leftover_map_reconstruction: -0.25 },
+ "Two leftover-map axes reconstruct centered leftover R̂ −0.25 after IRT main effects. Open this post.",
+ ],
] as const)("selects the next action for %s", (_label, evidence, expectedAction) => {
render(
R {residual}
{observedExpected ? {observedExpected} : null}
{rankBadge ? {rankBadge} : null}
+ {reconstructionBadge ? {reconstructionBadge} : 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..ddbae1395 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 reconstruct centered leftover R̂ {reconstruction} after IRT main effects. Open this post.",
"Showing the first {shown} of {total} posts known at this cutoff.",
"Inspect ontology neighborhood",
"Ontology neighborhood",
@@ -186,6 +187,21 @@ describe("i18n", () => {
).toBe(expected);
});
+ it.each([
+ ["ko", "IRT 주효과 이후 두 잔여 맵 축이 중심화된 잔여 R̂ +0.40을 재구성합니다. 이 글을 여세요."],
+ ["zh", "IRT 主效应后,两个残余图轴重构中心化残余 R̂ +0.40。打开这篇帖子。"],
+ ["ja", "IRT主効果後、残差マップの2軸が中心化残差 R̂ +0.40 を再構成します。この投稿を開いてください。"],
+ ["vi", "Hai trục bản đồ phần dư tái tạo phần dư đã tâm R̂ +0.40 sau hiệu ứng chính IRT. Mở bài viết này."],
+ ] as const)("formats leftover-map reconstruction next action in %s", (locale, expected) => {
+ setLocale(locale);
+ expect(
+ tf(
+ "Two leftover-map axes reconstruct centered leftover R̂ {reconstruction} after IRT main effects. Open this post.",
+ { reconstruction: "+0.40" },
+ ),
+ ).toBe(expected);
+ });
+
it.each([
["ko", "IRT 주효과 이후 잔여 맵 랭크 1, 관측 Y 2.40, 기대 E 2.00를 읽은 다음, 이 글을 여세요."],
["zh", "阅读 IRT 主效应后的残余图秩 1、观测 Y 2.40 与期望 E 2.00,然后打开这篇帖子。"],
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
index 64d4385c8..b0e53ca28 100644
--- a/frontend/src/i18n.ts
+++ b/frontend/src/i18n.ts
@@ -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 reconstruct centered leftover R̂ {reconstruction} after IRT main effects. Open this post.":
+ "IRT 주효과 이후 두 잔여 맵 축이 중심화된 잔여 R̂ {reconstruction}을 재구성합니다. 이 글을 여세요.",
},
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 reconstruct centered leftover R̂ {reconstruction} after IRT main effects. Open this post.":
+ "IRT 主效应后,两个残余图轴重构中心化残余 R̂ {reconstruction}。打开这篇帖子。",
},
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 reconstruct centered leftover R̂ {reconstruction} after IRT main effects. Open this post.":
+ "IRT主効果後、残差マップの2軸が中心化残差 R̂ {reconstruction} を再構成します。この投稿を開いてください。",
},
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 reconstruct centered leftover R̂ {reconstruction} after IRT main effects. Open this post.":
+ "Hai trục bản đồ phần dư tái tạo phần dư đã tâm R̂ {reconstruction} sau hiệu ứng chính IRT. Mở bài viết này.",
},
};
diff --git a/frontend/src/leftoverMapReconstruction.test.ts b/frontend/src/leftoverMapReconstruction.test.ts
new file mode 100644
index 000000000..a50b7aa25
--- /dev/null
+++ b/frontend/src/leftoverMapReconstruction.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from "vitest";
+import { formatLeftoverMapReconstruction } from "./leftoverMapReconstruction";
+
+describe("formatLeftoverMapReconstruction", () => {
+ it("names leftover-map reconstruction without inventing a leftover score", () => {
+ expect(formatLeftoverMapReconstruction(0.4)).toBe("R̂ +0.40");
+ expect(formatLeftoverMapReconstruction(-1.1)).toBe("R̂ −1.10");
+ expect(formatLeftoverMapReconstruction(0)).toBe("R̂ 0.00");
+ });
+
+ it("omits the badge when reconstruction is missing or non-finite", () => {
+ expect(formatLeftoverMapReconstruction(null)).toBeNull();
+ expect(formatLeftoverMapReconstruction(undefined)).toBeNull();
+ expect(formatLeftoverMapReconstruction(Number.NaN)).toBeNull();
+ expect(formatLeftoverMapReconstruction(Number.POSITIVE_INFINITY)).toBeNull();
+ });
+});
diff --git a/frontend/src/leftoverMapReconstruction.ts b/frontend/src/leftoverMapReconstruction.ts
new file mode 100644
index 000000000..9699ab8ee
--- /dev/null
+++ b/frontend/src/leftoverMapReconstruction.ts
@@ -0,0 +1,20 @@
+/** Two-axis leftover-map reconstruction ``R̂_c = ξ_{1:2} · ζ_{1:2}``. */
+
+export const LEFTOVER_RECONSTRUCTION_ACTION =
+ "Two leftover-map axes reconstruct centered leftover R̂ {reconstruction} after IRT main effects. Open this post.";
+
+export function formatLeftoverMapReconstruction(
+ reconstruction: number | null | undefined,
+): string | null {
+ if (reconstruction == null || !Number.isFinite(reconstruction)) {
+ return null;
+ }
+ const magnitude = Math.abs(reconstruction).toFixed(2);
+ if (reconstruction > 0) {
+ return `R̂ +${magnitude}`;
+ }
+ if (reconstruction < 0) {
+ return `R̂ \u2212${magnitude}`;
+ }
+ return `R̂ ${magnitude}`;
+}
diff --git a/lineageweave/leftover_pairs.py b/lineageweave/leftover_pairs.py
index cb7ae265d..7159c0cdc 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 0186.
Does not import ``fast_mlsirm`` or ``period_report``. A Gabriel biplot
of the residual ``R = Y − E[Y|θ, item]`` supplies person and item
@@ -11,14 +12,17 @@
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
-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.
+rank so a rank-0 collapse is not read as leftover structure, and names
+two-axis leftover-map reconstruction ``R̂_c = ξ_{1:2} · ζ_{1:2}`` of
+centered leftover so truncated reconstruction is not confused with
+residual ``R`` or leftover-map distance ``d``. Each pair also names
+unexplained leftover ``U = R − R̂_c`` so the leftover cell the map
+does not reconstruct has its own auditable field alongside the
+reconstruction it derives from. 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, only
+coverage counts.
"""
from __future__ import annotations
@@ -46,6 +50,7 @@ class LeftoverPair:
observed_response: float
expected_response: float
leftover_map_rank: int
+ leftover_map_reconstruction: float | None = None
leftover_map_unexplained: float | None = None
@@ -86,10 +91,12 @@ def leftover_pairs_from_residual(
invent a leftover score. Stored residual equals observed ``Y`` minus
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.
+ exist, leftover-map reconstruction ``R̂_c = ξ_{1:2} · ζ_{1:2}``
+ names the two-axis reconstruction of centered leftover
+ ``R̃ = R − center``, and unexplained leftover ``U = R − R̂_c`` names
+ the leftover cell the two-axis map does not reconstruct. Without a
+ complete-case map there is no leftover pair to name; the caller
+ reads coverage counts instead (ADR 0168).
"""
pairs, _axes = leftover_map_from_residual(post_ids, item_codes, matrix, expected)
return pairs
@@ -137,7 +144,7 @@ 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)
@@ -153,8 +160,8 @@ def leftover_map_from_residual(
)
if not np.isfinite(distance):
continue
- reconstruction = float(
- np.dot(person_xy[local_person[person]], item_xy[local_item[item]])
+ reconstruction = _leftover_map_reconstruction(
+ person_xy[local_person[person]], item_xy[local_item[item]]
)
unexplained = _unexplained_leftover(float(residual[person, item]), reconstruction)
candidates.append(
@@ -167,6 +174,7 @@ def leftover_map_from_residual(
person,
item,
distance,
+ reconstruction,
unexplained,
)
)
@@ -184,9 +192,24 @@ def leftover_map_from_residual(
return pairs, axes
-def _unexplained_leftover(residual: float, reconstruction: float) -> float | None:
- """Return ``U = R − R̂`` when both terms are finite; otherwise omit."""
- if not np.isfinite(reconstruction):
+def _leftover_map_reconstruction(
+ person_xy: np.ndarray, item_xy: np.ndarray
+) -> float | None:
+ """Return two-axis reconstruction ``R̂_c = ξ_{1:2} · ζ_{1:2}`` when finite.
+
+ Rank-0 origin coordinates store ``0.0``. A non-finite inner product
+ is omitted rather than invented. Negative finite reconstruction is
+ stored; do not clamp or take an absolute value.
+ """
+ reconstruction = float(np.dot(person_xy, item_xy))
+ if np.isfinite(reconstruction):
+ return reconstruction
+ return None
+
+
+def _unexplained_leftover(residual: float, reconstruction: float | None) -> float | None:
+ """Return ``U = R − R̂`` when reconstruction is known and finite; otherwise omit."""
+ if reconstruction is None or not np.isfinite(reconstruction):
return None
unexplained = residual - reconstruction
if not np.isfinite(unexplained):
@@ -203,9 +226,10 @@ def _candidate_row(
person: int,
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_reconstruction: float | None = None,
+ leftover_map_unexplained: float | None = None,
+) -> tuple[float, str, str, float, float, float, float | None, float | None]:
+ """One observed leftover cell: distance, ids, residual, Y, E, reconstruction, unexplained U."""
leftover_residual = float(residual[person, item])
observed_response = float(matrix[person, item])
expected_response = float(expected[person, item])
@@ -218,13 +242,14 @@ def _candidate_row(
leftover_residual,
observed_response,
expected_response,
+ leftover_map_reconstruction,
leftover_map_unexplained,
)
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."""
@@ -239,7 +264,8 @@ def _pair_from_candidate(
observed_response=row[4],
expected_response=row[5],
leftover_map_rank=leftover_map_rank,
- leftover_map_unexplained=row[6],
+ leftover_map_reconstruction=row[6],
+ leftover_map_unexplained=row[7],
)
@@ -375,8 +401,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.
+ ``ξ_{1:2} · ζ_{1:2}``, not the full-rank inner product. Both that
+ reconstruction and the unexplained leftover derived from it 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/0186_report_leftover_map_reconstruction.sql b/migrations/0186_report_leftover_map_reconstruction.sql
new file mode 100644
index 000000000..6ea2c4275
--- /dev/null
+++ b/migrations/0186_report_leftover_map_reconstruction.sql
@@ -0,0 +1,9 @@
+-- ADR 0186: persist leftover-map reconstruction on leftover post–criterion pairs.
+-- Reconstruction is two-axis Gabriel inner product R̂_c = ξ_{1:2} · ζ_{1:2}
+-- of centered leftover. Upgrade columns are nullable so older leftover
+-- rows keep distance and residual without fabricating a reconstruction.
+-- No nonnegative CHECK: reconstruction may be negative. This sequential
+-- migration is the single source of the column on every install path.
+
+alter table report_leftover_pair
+ add column if not exists leftover_map_reconstruction double precision;
diff --git a/migrations/rollback/0186_report_leftover_map_reconstruction.sql b/migrations/rollback/0186_report_leftover_map_reconstruction.sql
new file mode 100644
index 000000000..ab7a9c9c2
--- /dev/null
+++ b/migrations/rollback/0186_report_leftover_map_reconstruction.sql
@@ -0,0 +1,4 @@
+-- Reverse 0186. Leftover distance, residual, Y, E, and rank stay on the pair row.
+
+alter table report_leftover_pair
+ drop column if exists leftover_map_reconstruction;
diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py
index 1418d9af0..58f9c97d4 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 / "0186_report_leftover_map_reconstruction.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_reconstruction, leftover_map_unexplained"
+ ") values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
(
grouping_kind,
grouping_key,
@@ -1302,6 +1303,7 @@ def _persist_seed_period_report(
pair.observed_response,
pair.expected_response,
pair.leftover_map_rank,
+ pair.leftover_map_reconstruction,
pair.leftover_map_unexplained,
),
)
diff --git a/tests/test_leftover_pairs.py b/tests/test_leftover_pairs.py
index a411e1e13..25f4a5b14 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 0186.
Uses a constructed residual matrix so the closest and farthest pair
are known without calling ``fit_polytomous``. Loads
@@ -103,10 +103,10 @@ 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)
- assert not hasattr(closest, "leftover_map_reconstruction")
for pair in pairs:
_assert_residual_reconciles(pair)
assert pair.leftover_map_rank == 1
+ assert pair.leftover_map_reconstruction == pytest.approx(pair.leftover_residual)
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 +136,7 @@ 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 pair.leftover_map_reconstruction == 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 +170,8 @@ 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_reconstruction is not None
+ assert np.isfinite(pair.leftover_map_reconstruction)
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
@@ -321,7 +324,10 @@ def test_rank_four_pair_distances_match_two_dimensional_gabriel_coords() -> None
item_map = _pad_map_axes(item_full)
full_distances = np.linalg.norm(person_full[:, None, :] - item_full[None, :, :], axis=2)
map_distances = np.linalg.norm(person_map[:, None, :] - item_map[None, :, :], axis=2)
+ map_reconstruction = person_map @ item_map.T
+ full_reconstruction = person_full @ item_full.T
assert float(np.max(np.abs(full_distances - map_distances))) > 1e-6
+ assert float(np.max(np.abs(full_reconstruction - map_reconstruction))) > 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]
@@ -335,6 +341,14 @@ def test_rank_four_pair_distances_match_two_dimensional_gabriel_coords() -> None
assert pair.leftover_distance != pytest.approx(
float(full_distances[person, item]), abs=1e-9
)
+ assert pair.leftover_map_reconstruction == pytest.approx(
+ float(map_reconstruction[person, item])
+ )
+ assert pair.leftover_map_reconstruction != pytest.approx(
+ float(full_reconstruction[person, item]), abs=1e-9
+ )
+ assert pair.leftover_map_reconstruction != pytest.approx(pair.leftover_residual, abs=1e-9)
+ assert pair.leftover_map_reconstruction != pytest.approx(pair.leftover_distance, abs=1e-9)
farthest_map = np.unravel_index(int(np.argmax(map_distances)), map_distances.shape)
farthest = pairs[1]
@@ -378,10 +392,10 @@ def test_unexplained_equals_residual_minus_two_axis_reconstruction() -> None:
person = post_index[pair.post_id]
item = item_index[pair.criterion_code]
expected_unexplained = float(pair.leftover_residual) - float(reconstruction[person, item])
+ assert pair.leftover_map_reconstruction == pytest.approx(float(reconstruction[person, item]))
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")
def test_pad_map_axes_truncates_hidden_svd_components() -> None:
@@ -458,6 +472,27 @@ def test_leftover_map_rank_rejects_negative_rank() -> None:
)
+def test_nonfinite_reconstruction_is_omitted_not_invented() -> None:
+ """A non-finite two-axis inner product cannot become persisted reconstruction."""
+ assert leftover._leftover_map_reconstruction(
+ np.array([np.inf, 0.0], dtype=np.float64),
+ np.array([1.0, 0.0], dtype=np.float64),
+ ) is None
+ assert leftover._leftover_map_reconstruction(
+ np.array([0.0, 0.0], dtype=np.float64),
+ np.array([0.0, 0.0], dtype=np.float64),
+ ) == pytest.approx(0.0)
+
+
+def test_negative_reconstruction_is_stored() -> None:
+ """Opposite-sign two-axis coordinates keep a signed reconstruction."""
+ reconstruction = leftover._leftover_map_reconstruction(
+ np.array([1.0, 0.0], dtype=np.float64),
+ np.array([-2.0, 0.0], dtype=np.float64),
+ )
+ assert reconstruction == pytest.approx(-2.0)
+
+
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..2c1d58381 100644
--- a/tests/test_period_report.py
+++ b/tests/test_period_report.py
@@ -270,9 +270,11 @@ def test_calibrated_report_attaches_leftover_pairs() -> None:
pair.observed_response - pair.expected_response, abs=1e-6
)
assert pair.leftover_map_rank >= 0
+ reconstruction = pair.leftover_map_reconstruction
+ if reconstruction is not None:
+ assert np.isfinite(reconstruction)
if pair.leftover_map_unexplained is not None:
assert np.isfinite(pair.leftover_map_unexplained)
- 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:
assert axis.leftover_singular_value >= 0.0
diff --git a/tests/test_schema.py b/tests/test_schema.py
index 0f9fd18a3..bbd4a4a40 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -68,6 +68,11 @@
/ "migrations"
/ "0182_report_leftover_map_unexplained.sql"
)
+_LEFTOVER_MAP_RECONSTRUCTION_MIGRATION = (
+ Path(__file__).resolve().parents[1]
+ / "migrations"
+ / "0186_report_leftover_map_reconstruction.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_RECONSTRUCTION_MIGRATION.read_text())
conn.commit()
yield conn
finally:
@@ -240,6 +246,17 @@ def test_leftover_pair_names_leftover_map_rank_column(schema_db) -> None:
assert columns["leftover_map_rank"] == "YES"
+def test_leftover_pair_names_leftover_map_reconstruction_column(schema_db) -> None:
+ """Fresh leftover rows name reconstruction without backfilling legacy evidence."""
+ 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_reconstruction"] == "YES"
+
+
def test_leftover_pair_names_nullable_unexplained_column(schema_db) -> None:
"""Every install path preserves legacy pairs while naming unexplained leftover."""
with schema_db.cursor() as cur:
@@ -254,7 +271,6 @@ def test_leftover_pair_names_nullable_unexplained_column(schema_db) -> None:
assert columns["leftover_map_unexplained"] == "YES"
assert columns["leftover_residual"] == "NO"
assert columns["leftover_distance"] == "NO"
- assert "leftover_map_reconstruction" not in columns
def test_leftover_map_axis_references_period_score(schema_db) -> None: