diff --git a/AGENTS.md b/AGENTS.md
index 5e3d0eb22..b5961427c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -271,18 +271,18 @@ 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 / 0201) 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
+plus leftover-map rank so rank 0 is not read as structure,
+unexplained leftover, and the ADR 0201 reconstruction evidence. ADR 0201
+is the sole normative reconstruction formula, storage, and audit contract;
+do not duplicate or reinterpret it here. The pairs 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
+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
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 906990241..77add32cb 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -603,10 +603,10 @@ 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 / 0148 / 0158 / 0162 / 0163 / 0164 / 0168 /
-0182 / 0185) persist to `report_leftover_pair` with signed residual `R`,
+0182 / 0185 / 0201) persist to `report_leftover_pair` with signed residual `R`,
observed `Y`, expected `E[Y|θ, item]`, full leftover-map rank, unexplained
-leftover `U = R − R̂` named on the pair row, and leftover-map cross share
-`x = 2 R̂ U / R²` of raw residual. Leftover-map axis share
+leftover, ADR 0201 reconstruction evidence, and ADR 0185 cross-share evidence.
+Those ADRs are the normative mathematical and storage contracts. 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.31-leftover-map-reconstruction.md b/CHANGELOG.d/2.12.31-leftover-map-reconstruction.md
new file mode 100644
index 000000000..1bae12df2
--- /dev/null
+++ b/CHANGELOG.d/2.12.31-leftover-map-reconstruction.md
@@ -0,0 +1,5 @@
+## 2.12.31 — Leftover-map reconstruction
+
+- Period leftover pairs now expose reconstruction evidence governed by ADR
+ 0201. After `make seed`, open a closest or farthest pair to inspect it on
+ the named post. Missing reconstruction omits the badge.
diff --git a/CLAUDE.md b/CLAUDE.md
index f32325abc..eb9e85eab 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 / 0201), 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 6e127bf62..0aa8f382e 100644
--- a/backend/app/report_ingestion.py
+++ b/backend/app/report_ingestion.py
@@ -445,8 +445,9 @@ 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, leftover_map_cross_share
- ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
+ leftover_map_unexplained, leftover_map_cross_share,
+ leftover_map_reconstruction
+ ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
""",
grouping_kind,
grouping_key,
@@ -462,6 +463,7 @@ async def persist_period_report(
pair.leftover_map_rank,
pair.leftover_map_unexplained,
pair.leftover_map_cross_share,
+ pair.leftover_map_reconstruction,
)
for axis in report.leftover_map_axes:
await conn.execute(
@@ -647,7 +649,8 @@ 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, lp.leftover_map_cross_share, p.post_title,
+ lp.leftover_map_unexplained, lp.leftover_map_cross_share,
+ lp.leftover_map_reconstruction, p.post_title,
p.visibility_code, p.corporate_entity_id,
({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context
from report_leftover_pair lp
@@ -796,6 +799,11 @@ async def fetch_period_reports(
if row["leftover_map_cross_share"] is None
else float(row["leftover_map_cross_share"])
),
+ "leftover_map_reconstruction": (
+ None
+ if row["leftover_map_reconstruction"] is None
+ else float(row["leftover_map_reconstruction"])
+ ),
"visibility_code": row["visibility_code"],
"corporate_entity_id": str(row["corporate_entity_id"]),
"has_real_source_context": bool(row["has_real_source_context"]),
@@ -989,6 +997,7 @@ async def fetch_period_comparison(
f"""
select lp.grouping_kind, lp.grouping_key, lp.pair_kind, lp.post_id,
lp.criterion_code, lp.leftover_distance, lp.leftover_residual,
+ lp.leftover_map_reconstruction,
p.post_title, p.visibility_code, p.corporate_entity_id,
({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context
from report_leftover_pair lp
@@ -1033,6 +1042,11 @@ async def fetch_period_comparison(
"criterion_code": str(pair["criterion_code"]),
"leftover_distance": float(pair["leftover_distance"]),
"leftover_residual": float(pair["leftover_residual"]),
+ "leftover_map_reconstruction": (
+ None
+ if pair["leftover_map_reconstruction"] is None
+ else float(pair["leftover_map_reconstruction"])
+ ),
"visibility_code": pair["visibility_code"],
"corporate_entity_id": str(pair["corporate_entity_id"]),
"has_real_source_context": bool(pair["has_real_source_context"]),
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index b1fa4c37c..dee21f626 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -168,6 +168,11 @@
/ "migrations"
/ "0185_report_leftover_map_cross_share.sql"
)
+_LEFTOVER_MAP_RECONSTRUCTION_MIGRATION = (
+ Path(__file__).resolve().parents[2]
+ / "migrations"
+ / "0206_report_leftover_map_reconstruction.sql"
+)
_GLOBAL_ASK_JOB_MIGRATION = (
Path(__file__).resolve().parents[2]
/ "migrations"
@@ -342,6 +347,7 @@ def seeded_db(demo_analyst_token):
cur.execute(_LEFTOVER_MAP_AXIS_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_UNEXPLAINED_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_CROSS_SHARE_MIGRATION.read_text())
+ cur.execute(_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'), "
@@ -5337,11 +5343,20 @@ def test_seed_period_report_surfaces_on_get_reports(client, demo_analyst_token,
assert leftover_kinds <= {"closest", "farthest"}
assert all(pair["post_title"] for pair in high_report.get("leftover_pairs", []))
assert all(pair["leftover_distance"] >= 0 for pair in high_report.get("leftover_pairs", []))
+ assert all(
+ "leftover_map_reconstruction" in pair
+ for pair in high_report.get("leftover_pairs", [])
+ )
+ assert any(
+ pair["leftover_map_reconstruction"] is not None
+ for pair in high_report.get("leftover_pairs", [])
+ )
for pair in high_report.get("leftover_pairs", []):
assert pair["leftover_map_rank"] >= 0
unexplained = pair.get("leftover_map_unexplained")
assert unexplained is None or isinstance(unexplained, (int, float))
- assert "leftover_map_reconstruction" not in pair
+ reconstruction = pair["leftover_map_reconstruction"]
+ assert reconstruction is None or isinstance(reconstruction, (int, float))
observed = pair.get("observed_response")
expected = pair.get("expected_response")
if observed is not None and expected is not None:
@@ -5351,9 +5366,10 @@ def test_seed_period_report_surfaces_on_get_reports(client, demo_analyst_token,
if share is not None:
assert not math.isnan(share)
assert not math.isinf(share)
+ if unexplained is not None and reconstruction is not None:
+ assert unexplained + reconstruction == pytest.approx(pair["leftover_residual"])
assert "leftover_map_explained_share" not in pair
assert "leftover_map_unexplained_share" not in pair
- assert "leftover_map_reconstruction" not in pair
leftover_axes = high_report.get("leftover_map_axes", [])
assert [axis["axis_index"] for axis in leftover_axes] == [1, 2]
assert all(axis["leftover_singular_value"] >= 0 for axis in leftover_axes)
@@ -5407,6 +5423,11 @@ def test_seed_period_report_surfaces_on_get_reports(client, demo_analyst_token,
assert leftover_kinds <= {"closest", "farthest"}
assert all(pair["post_title"] for pair in leftover_thread.get("leftover_pairs", []))
assert all(pair["leftover_distance"] >= 0 for pair in leftover_thread.get("leftover_pairs", []))
+ assert all(
+ pair.get("leftover_map_reconstruction") is None
+ or isinstance(pair["leftover_map_reconstruction"], (int, float))
+ for pair in leftover_thread.get("leftover_pairs", [])
+ )
def test_seed_period_report_includes_fixture_event_lineage_posts(
diff --git a/docs/adr/0003-fast-mlsirm-report-integration.md b/docs/adr/0003-fast-mlsirm-report-integration.md
index 5b87cb5d5..27f9007fe 100644
--- a/docs/adr/0003-fast-mlsirm-report-integration.md
+++ b/docs/adr/0003-fast-mlsirm-report-integration.md
@@ -100,18 +100,15 @@ 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 / 0185): after IRT main effects, persist closest and farthest
- post–criterion pairs from the residual leftover map, and name
- unexplained leftover `U = R − R̂` and leftover-map cross share
- `x = 2 R̂ U / R²` of raw residual when Gabriel coordinates
- exist so the identity remainder after two-axis reconstruction is not
- read as leftover residual, leftover-map distance, explained leftover
- share, or unexplained leftover share. Do not persist leftover-map
- explained leftover share `e`, unexplained leftover share `s`, or any other
- unsupported share alias in this slice. 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
+7. **Leftover-pair slice** (shipped in 0.71.2; ADR 0017 / 0018 / 0048 /
+ 0049): after IRT main effects, persist closest and farthest
+ post–criterion pairs from the residual leftover map. Do not fork LSIRM or
+ invent a leftover-pair API inside `fast-mlsirm` in this slice.
+8. **Leftover evidence extensions:** unexplained leftover shipped in 2.12.26
+ (ADR 0182), cross-share evidence shipped in 2.12.29 (ADR 0185), and
+ reconstruction evidence is Unreleased for 2.12.31 (ADR 0201). Do not
+ persist explained share, unexplained share, or another unsupported alias.
+9. **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
leftover score.
diff --git a/docs/adr/0048-persist-lsirm-leftover-pairs.md b/docs/adr/0048-persist-lsirm-leftover-pairs.md
index 7fdd2df72..613545db4 100644
--- a/docs/adr/0048-persist-lsirm-leftover-pairs.md
+++ b/docs/adr/0048-persist-lsirm-leftover-pairs.md
@@ -47,8 +47,9 @@ distance `d` (ADR 0182), and names leftover-map cross share
exist so the identity remainder after two-axis reconstruction is not
read as leftover residual `R`, leftover-map distance `d`, explained
leftover share `e`, or unexplained leftover share `s` (ADR 0185).
-This ADR does not add a persisted reconstruction column; persisting that
-separate field requires its own accepted decision record.
+ADR 0201 now persists that same signed reconstruction on the pair row so
+`U + R̂ = R` remains directly auditable; it does not change this selection or
+distance contract.
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 bad7b0063..4be472ef9 100644
--- a/docs/adr/0049-leftover-pair-report-ui.md
+++ b/docs/adr/0049-leftover-pair-report-ui.md
@@ -7,7 +7,8 @@
[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 0185](0185-leftover-map-cross-share.md) (leftover-map cross share)
+[ADR 0185](0185-leftover-map-cross-share.md) (leftover-map cross share);
+[ADR 0201](0201-leftover-map-reconstruction.md) (signed reconstruction R̂)
## Context
@@ -25,8 +26,8 @@ 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,
-unexplained leftover `U` when finite, and leftover-map cross share next
-to distance when finite. The next action names every available
+unexplained leftover `U`, signed reconstruction `R̂` when finite, and
+leftover-map cross share next to distance when finite. The next action names every available
measurement before opening the post; no amendment hides another, rank 0
explicitly names no leftover structure, and unexplained leftover names
"leftover map leaves unexplained `U` after IRT main effects; open this
@@ -34,7 +35,7 @@ post to read the named criterion" when present. When leftover-map cross
share is also present, the next action instead names the identity
remainder `x` two leftover-map axes leave in raw residual after
IRT main effects. A missing or non-finite value falls back in order —
-cross share, then unexplained leftover, then the existing
+cross share, then reconstruction, then unexplained leftover, then the existing
closest/farthest next action. Clicking the button opens that post with
leftover focus so Post quality marks the named criterion current
(ADR 0158). Residual naming is
@@ -43,6 +44,7 @@ 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), leftover-map cross
share naming is [ADR 0185](0185-leftover-map-cross-share.md).
+Reconstruction naming is [ADR 0201](0201-leftover-map-reconstruction.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/0182-leftover-map-unexplained.md b/docs/adr/0182-leftover-map-unexplained.md
index e7cc46fca..13ab5a573 100644
--- a/docs/adr/0182-leftover-map-unexplained.md
+++ b/docs/adr/0182-leftover-map-unexplained.md
@@ -3,6 +3,9 @@
**Decision status:** Accepted
**Date:** 2026-08-24
+**Amended by:** [ADR 0201](0201-leftover-map-reconstruction.md)
+(two-axis reconstruction R̂)
+
Amends [ADR 0048](0048-persist-lsirm-leftover-pairs.md) and
[ADR 0049](0049-leftover-pair-report-ui.md).
@@ -21,15 +24,17 @@ shows. Hiding unexplained leftover `U = R − R̂` lets a buyer read
leftover residual `R` or leftover-map distance `d` as the leftover
the two-axis map does not reconstruct.
-This increment does not persist leftover-map reconstruction `R̂`, does
+At ADR 0182's initial acceptance, this increment did not persist
+leftover-map reconstruction `R̂`; ADR 0201 now persists that value so
+`U + R̂ = R` remains directly auditable. It still does
not persist leftover-map coordinates, does not name leftover-map inner
product as a separate full-rank column, does not name leftover-map
cosine, does not name leftover-map length, does not name observed `Y` /
expected `E`, does not name leftover-map rank, does not split leftover-map
distance onto two axes, and does not land Post quality on the leftover
criterion. Leftover-map distance stays full-rank Euclidean.
-Reconstruction `R̂` is computed internally so `U` is honest, then
-discarded.
+Reconstruction `R̂` is computed internally so `U` is honest and is now
+retained under ADR 0201.
The unprotected-stack reconstructions for neighbouring leftover facts
use 0162–0181. This protected-main increment uses **0182** so it does
@@ -53,8 +58,8 @@ without fabricating unexplained leftover. 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̂ = 0`), not a
missing value. A non-finite unexplained leftover stores null rather
-than inventing a leftover score. Do not persist
-`leftover_map_reconstruction`.
+than inventing a leftover score. Persist
+`leftover_map_reconstruction` so `U + R̂ = R` stays auditable.
The pair button shows `U {signed}` next to leftover-map distance `d`
when the value is finite. Next action: leftover map leaves unexplained
diff --git a/docs/adr/0201-leftover-map-reconstruction.md b/docs/adr/0201-leftover-map-reconstruction.md
new file mode 100644
index 000000000..049208105
--- /dev/null
+++ b/docs/adr/0201-leftover-map-reconstruction.md
@@ -0,0 +1,96 @@
+# ADR 0201 — Name leftover-map reconstruction on period-report pair rows
+
+**Decision status:** Accepted
+**Date:** 2026-08-25
+
+Amends [ADR 0048](0048-persist-lsirm-leftover-pairs.md),
+[ADR 0049](0049-leftover-pair-report-ui.md), and
+[ADR 0182](0182-leftover-map-unexplained.md). Independent of landed
+complete-case coverage ([ADR 0168](0168-leftover-map-complete-case-coverage.md)).
+
+## Context
+
+ADR 0182 already persists unexplained leftover `U = R − R̂` after
+two-axis Gabriel reconstruction `R̂ = ξ_{1:2} · ζ_{1:2}`. That
+reconstruction is computed internally so `U` is honest, then discarded.
+A buyer who reads `U` next to leftover residual `R` cannot check
+`U + R̂ = R` without the reconstruction the two-axis map actually
+uses. Hiding `R̂` lets leftover residual `R` or leftover-map distance
+`d` be read as the leftover the map reconstructs.
+
+This increment persists leftover-map reconstruction `R̂`. It does not
+persist leftover-map coordinates, does not name leftover-map inner
+product as a separate full-rank column, does not name leftover-map
+cosine, does not name leftover-map length, does not name leftover-map
+explained share or unexplained share, and does not land
+Post quality on the leftover criterion. Leftover-map distance stays
+two-axis Euclidean. Reconstruction is the same internal two-axis inner
+product already used for `U`, so `U + R̂ = R` remains true. Do not
+substitute a separately centered reconstruction `R̃` that would break
+that identity. ADR 0185 independently persists the raw-residual cross
+share derived from this same `R̂` and `U`; this decision exposes `R̂`
+without changing that formula.
+
+The unprotected-stack reconstructions for neighbouring leftover facts
+use 0162–0181 and 0184–0186. This protected-main increment uses
+**0206** so it does not collide with source-post event time
+(0183), leftover-map reconstruction on the centered stack (0186),
+leftover-map unexplained share (0184 on that stack), leftover-map
+explained share, leftover-map cross share, leftover residual
+disclosure, leftover observed `Y` / expected `E`, leftover-map rank,
+two-axis leftover-map distance, leftover coverage, leftover-map axis
+share (0148), or leftover interaction-map persistence.
+
+## Decision
+
+Each leftover pair names `leftover_map_reconstruction` — two-axis
+Gabriel reconstruction `R̂ = ξ_{1:2} · ζ_{1:2}`. Unused axes pad with
+zero. Hidden SVD axes after the second are dropped. Migration `0206`
+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,
+residual, and unexplained leftover without fabricating reconstruction.
+Fallback pairs that have no complete-case leftover map omit the value
+rather than inventing one. A rank-0 map stores `0.0` for `R̂`; raw residual
+`R` may be a nonzero constant after centering, in which case `U = R` and
+`U + R̂ = R` still holds. A non-finite reconstruction stores
+null rather than inventing a leftover score. A signed reconstruction
+is stored, never clamped. Do not add a nonnegative CHECK.
+
+The pair button shows `R̂ {signed}` next to leftover-map distance `d`
+when the value is finite. Next action: leftover map reconstructs `R̂`
+after IRT main effects; open this post to read the named criterion.
+A missing or non-finite reconstruction omits the badge and keeps the
+existing unexplained-leftover next action. Do not invent a leftover
+score. Do not invent 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 named `R̂` next to `d`;
+click opens that post. Hidden posts stay hidden. When both
+reconstruction and unexplained leftover are finite,
+`U + R̂ = R`.
+
+## 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 unexplained
+share, leftover-map explained share, leftover-map cross share, and
+the centered leftover-map reconstruction stack.
+
+## 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 0b7592883..2d3765e75 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -984,6 +984,7 @@ describe("App, authenticated", () => {
expected_response: 2.0,
leftover_map_rank: 1,
leftover_map_cross_share: 0.12,
+ leftover_map_reconstruction: 0.35,
},
{
pair_kind: "farthest",
@@ -997,6 +998,7 @@ describe("App, authenticated", () => {
expected_response: 2.0,
leftover_map_rank: 1,
leftover_map_cross_share: -0.24,
+ leftover_map_reconstruction: -0.85,
},
],
leftover_map_axes: [
@@ -3840,6 +3842,7 @@ describe("App, authenticated", () => {
expect(closestPair).toHaveTextContent("rank 1");
expect(closestPair).toHaveTextContent("U +0.05");
expect(closestPair).toHaveTextContent("2R̂U/R² 0.12");
+ expect(closestPair).toHaveTextContent("R̂ +0.35");
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");
@@ -3851,6 +3854,7 @@ describe("App, authenticated", () => {
expect(farthestPair).toHaveTextContent("rank 1");
expect(farthestPair).toHaveTextContent("U −0.25");
expect(farthestPair).toHaveTextContent("2R̂U/R² -0.24");
+ expect(farthestPair).toHaveTextContent("R̂ −0.85");
expect(farthestPair).toHaveTextContent("d 1.84");
const memberButton = screen.getByRole("button", { name: /open report post: public post/i });
expect(coverageCaption.compareDocumentPosition(closestPair) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index b57e16ff3..8ee90eeb1 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -888,6 +888,7 @@ export interface LeftoverPair {
leftover_map_rank?: number | null;
leftover_map_unexplained?: number | null;
leftover_map_cross_share?: number | null;
+ leftover_map_reconstruction?: number | null;
}
export interface LeftoverMapAxis {
diff --git a/frontend/src/components/LeftoverPairList.stories.tsx b/frontend/src/components/LeftoverPairList.stories.tsx
index 680640026..637351074 100644
--- a/frontend/src/components/LeftoverPairList.stories.tsx
+++ b/frontend/src/components/LeftoverPairList.stories.tsx
@@ -19,6 +19,8 @@ const meta = {
observed_response: 2.4,
expected_response: 2.0,
leftover_map_rank: 1,
+ leftover_map_unexplained: 0.05,
+ leftover_map_reconstruction: 0.35,
},
{
pair_kind: "farthest",
@@ -30,6 +32,8 @@ const meta = {
observed_response: 0.9,
expected_response: 2.0,
leftover_map_rank: 1,
+ leftover_map_unexplained: -0.25,
+ leftover_map_reconstruction: -0.85,
},
],
},
diff --git a/frontend/src/components/LeftoverPairList.test.tsx b/frontend/src/components/LeftoverPairList.test.tsx
index 4108c3127..36c15715a 100644
--- a/frontend/src/components/LeftoverPairList.test.tsx
+++ b/frontend/src/components/LeftoverPairList.test.tsx
@@ -125,6 +125,45 @@ describe("LeftoverPairList", () => {
expect(screen.getByRole("button")).toHaveTextContent(expectedAction);
});
+ it("names leftover-map reconstruction so the next click opens that post", () => {
+ render(
+ ,
+ );
+
+ const closest = screen.getByRole("button");
+ expect(closest).toHaveTextContent(
+ "Leftover map reconstructs R̂ +0.35 after IRT main effects. Open this post to read sales-lead.",
+ );
+ expect(closest).toHaveTextContent("R̂ +0.35");
+ expect(closest).toHaveTextContent("U +0.05");
+ expect(closest).toHaveTextContent("R +0.40");
+ expect(closest).toHaveTextContent("d 0.12");
+ });
+
+ it("keeps unexplained guidance when reconstruction is missing", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("button")).toHaveTextContent(
+ "Leftover map leaves unexplained U +0.05 after IRT main effects. Open this post to read sales-lead.",
+ );
+ });
+
it("renders nothing when leftover pairs are missing", () => {
const { container } = render(
,
diff --git a/frontend/src/components/LeftoverPairList.tsx b/frontend/src/components/LeftoverPairList.tsx
index 27dfdcb5e..6ded4770d 100644
--- a/frontend/src/components/LeftoverPairList.tsx
+++ b/frontend/src/components/LeftoverPairList.tsx
@@ -4,6 +4,10 @@ import {
formatLeftoverMapCrossShare,
LEFTOVER_MAP_CROSS_SHARE_ACTION,
} from "../leftoverMapCrossShare";
+import {
+ formatLeftoverMapReconstruction,
+ LEFTOVER_MAP_RECONSTRUCTION_ACTION,
+} from "../leftoverMapReconstruction";
import {
formatLeftoverMapRank,
LEFTOVER_RANK_STRUCTURE_ACTION,
@@ -33,7 +37,7 @@ export type LeftoverPairListProps = {
* when finite. When leftover-map cross share ``x = 2 R̂ U / R²`` of
* raw residual is also present (ADR 0185), it names the next action
* instead of unexplained leftover; a missing or non-finite value falls
- * back in order — cross share, then unexplained leftover, then the
+ * back in order — cross share, reconstruction, unexplained leftover, then the
* existing residual/rank/observed-expected next action. Every badge
* still renders together before opening the named post.
*/
@@ -59,6 +63,9 @@ export function LeftoverPairList({
const rankBadge = formatLeftoverMapRank(pair.leftover_map_rank);
const unexplained = formatLeftoverMapUnexplained(pair.leftover_map_unexplained);
const crossShareBadge = formatLeftoverMapCrossShare(pair.leftover_map_cross_share);
+ const reconstruction = formatLeftoverMapReconstruction(
+ pair.leftover_map_reconstruction,
+ );
const crossShareValue =
pair.leftover_map_cross_share != null && Number.isFinite(pair.leftover_map_cross_share)
? pair.leftover_map_cross_share.toFixed(2)
@@ -69,6 +76,13 @@ export function LeftoverPairList({
value: crossShareValue,
criterion,
});
+ } else if (reconstruction !== null) {
+ const signedReconstruction =
+ formatSignedLeftoverValue(pair.leftover_map_reconstruction ?? Number.NaN) ?? "—";
+ nextAction = tf(LEFTOVER_MAP_RECONSTRUCTION_ACTION, {
+ value: signedReconstruction,
+ criterion,
+ });
} else if (unexplained !== null) {
const signedUnexplained =
formatSignedLeftoverValue(pair.leftover_map_unexplained ?? Number.NaN) ?? "—";
@@ -140,6 +154,7 @@ export function LeftoverPairList({
{rankBadge ? {rankBadge} : null}
{unexplained ? {unexplained} : null}
{crossShareBadge ? {crossShareBadge} : null}
+ {reconstruction ? {reconstruction} : null}
d {pair.leftover_distance.toFixed(2)}
diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts
index 69b1994e3..476f60683 100644
--- a/frontend/src/i18n.test.ts
+++ b/frontend/src/i18n.test.ts
@@ -53,6 +53,7 @@ describe("i18n", () => {
"Open this post to read the criterion it sat closest to after main effects.",
"Open this post to read the criterion it sat farthest from after main effects.",
"Leftover map leaves unexplained U {value} after IRT main effects. Open this post to read {criterion}.",
+ "Leftover map reconstructs R̂ {value} after IRT main effects. Open this post to read {criterion}.",
"Two leftover-map axes leave identity remainder {value} of raw residual after IRT main effects. Open this post to read {criterion}.",
"Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.",
"Leftover map has no leftover structure after IRT main effects. Open this post.",
@@ -275,6 +276,21 @@ describe("i18n", () => {
),
).toBe(expected);
});
+
+ it.each([
+ ["ko", "잔여 지도가 IRT 주효과 이후 R̂ +0.35을(를) 재구성합니다. sales-lead 기준을 읽으려면 이 글을 여세요."],
+ ["zh", "残差图在 IRT 主效应后重建 R̂ +0.35。打开这篇帖子阅读 sales-lead。"],
+ ["ja", "残差マップはIRT主効果後の R̂ +0.35 を再構成します。この投稿を開いて sales-lead を読んでください。"],
+ ["vi", "Bản đồ phần dư tái dựng R̂ +0.35 sau hiệu ứng chính IRT. Mở bài viết này để đọc sales-lead."],
+ ] as const)("formats leftover-map reconstruction next action in %s", (locale, expected) => {
+ setLocale(locale);
+ expect(
+ tf(
+ "Leftover map reconstructs R̂ {value} after IRT main effects. Open this post to read {criterion}.",
+ { value: "+0.35", criterion: "sales-lead" },
+ ),
+ ).toBe(expected);
+ });
});
describe("locale-aware source labels", () => {
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
index 53720d549..bebdb9df3 100644
--- a/frontend/src/i18n.ts
+++ b/frontend/src/i18n.ts
@@ -481,6 +481,8 @@ const TRANSLATIONS: Partial>> = {
"주효과 이후 가장 멀리 앉은 기준을 읽으려면 이 글을 여세요.",
"Leftover map leaves unexplained U {value} after IRT main effects. Open this post to read {criterion}.":
"잔여 지도가 IRT 주효과 이후 설명되지 않은 U {value}을(를) 남깁니다. {criterion} 기준을 읽으려면 이 글을 여세요.",
+ "Leftover map reconstructs R̂ {value} after IRT main effects. Open this post to read {criterion}.":
+ "잔여 지도가 IRT 주효과 이후 R̂ {value}을(를) 재구성합니다. {criterion} 기준을 읽으려면 이 글을 여세요.",
"Two leftover-map axes leave identity remainder {value} of raw residual after IRT main effects. Open this post to read {criterion}.":
"잔여 지도의 두 축이 IRT 주효과 이후 원시 잔차의 항등식 나머지 {value}을(를) 남깁니다. {criterion} 기준을 읽으려면 이 글을 여세요.",
"Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.":
@@ -955,6 +957,8 @@ const TRANSLATIONS: Partial>> = {
"打开这篇帖子,阅读主效应后距离最远的准则。",
"Leftover map leaves unexplained U {value} after IRT main effects. Open this post to read {criterion}.":
"残差图在 IRT 主效应后留下未解释的 U {value}。打开这篇帖子阅读 {criterion}。",
+ "Leftover map reconstructs R̂ {value} after IRT main effects. Open this post to read {criterion}.":
+ "残差图在 IRT 主效应后重建 R̂ {value}。打开这篇帖子阅读 {criterion}。",
"Two leftover-map axes leave identity remainder {value} of raw residual after IRT main effects. Open this post to read {criterion}.":
"残差图的两个轴在 IRT 主效应后留下原始残差的恒等式余项 {value}。打开这篇帖子阅读 {criterion}。",
"Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.":
@@ -1430,6 +1434,8 @@ const TRANSLATIONS: Partial>> = {
"主効果後に最も遠くなった基準を読むには、この投稿を開いてください。",
"Leftover map leaves unexplained U {value} after IRT main effects. Open this post to read {criterion}.":
"残差マップはIRT主効果後の未説明 U {value} を残します。この投稿を開いて {criterion} を読んでください。",
+ "Leftover map reconstructs R̂ {value} after IRT main effects. Open this post to read {criterion}.":
+ "残差マップはIRT主効果後の R̂ {value} を再構成します。この投稿を開いて {criterion} を読んでください。",
"Two leftover-map axes leave identity remainder {value} of raw residual after IRT main effects. Open this post to read {criterion}.":
"残差マップの2軸はIRT主効果後の生の残差の恒等式の余り {value} を残します。この投稿を開いて {criterion} を読んでください。",
"Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.":
@@ -1905,6 +1911,8 @@ const TRANSLATIONS: Partial>> = {
"Mở bài viết này để đọc tiêu chí nằm xa nhất sau hiệu ứng chính.",
"Leftover map leaves unexplained U {value} after IRT main effects. Open this post to read {criterion}.":
"Bản đồ phần dư để lại U {value} chưa giải thích sau hiệu ứng chính IRT. Mở bài viết này để đọc {criterion}.",
+ "Leftover map reconstructs R̂ {value} after IRT main effects. Open this post to read {criterion}.":
+ "Bản đồ phần dư tái dựng R̂ {value} sau hiệu ứng chính IRT. Mở bài viết này để đọc {criterion}.",
"Two leftover-map axes leave identity remainder {value} of raw residual after IRT main effects. Open this post to read {criterion}.":
"Hai trục của bản đồ phần dư để lại phần giao {value} của phần dư thô sau hiệu ứng chính IRT. Mở bài viết này để đọc {criterion}.",
"Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.":
diff --git a/frontend/src/leftoverMapReconstruction.test.ts b/frontend/src/leftoverMapReconstruction.test.ts
new file mode 100644
index 000000000..5a35a5168
--- /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.35)).toBe("R\u0302 +0.35");
+ expect(formatLeftoverMapReconstruction(-0.85)).toBe("R\u0302 \u22120.85");
+ expect(formatLeftoverMapReconstruction(0)).toBe("R\u0302 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..895192933
--- /dev/null
+++ b/frontend/src/leftoverMapReconstruction.ts
@@ -0,0 +1,16 @@
+/** Two-axis leftover-map reconstruction ``R̂ = ξ_{1:2} · ζ_{1:2}``. */
+
+import { formatSignedLeftoverValue } from "./leftoverMapUnexplained";
+
+export const LEFTOVER_MAP_RECONSTRUCTION_ACTION =
+ "Leftover map reconstructs R̂ {value} after IRT main effects. Open this post to read {criterion}.";
+
+const RECONSTRUCTION_BADGE = "R\u0302";
+
+export function formatLeftoverMapReconstruction(value: number | null | undefined): string | null {
+ if (value == null) {
+ return null;
+ }
+ const signed = formatSignedLeftoverValue(value);
+ return signed === null ? null : `${RECONSTRUCTION_BADGE} ${signed}`;
+}
diff --git a/lineageweave/leftover_pairs.py b/lineageweave/leftover_pairs.py
index 139f41e8d..070416fe2 100644
--- a/lineageweave/leftover_pairs.py
+++ b/lineageweave/leftover_pairs.py
@@ -26,9 +26,9 @@
so the identity remainder left by the truncation is not confused with
leftover residual ``R``, leftover-map distance ``d``, or unexplained
leftover ``U``. Explained leftover share ``e = R̂² / R²`` and
-unexplained leftover share ``s = U² / R²`` are not persisted.
-Reconstruction ``R̂`` stays internal and is not persisted. ``x`` may be negative
-when reconstruction and unexplained leftover have opposite signs.
+unexplained leftover share ``s = U² / R²`` are not persisted. Signed
+reconstruction ``R̂`` is persisted so ``U + R̂ = R`` stays auditable. ``x``
+may be negative when reconstruction and unexplained leftover have opposite signs.
"""
from __future__ import annotations
@@ -58,6 +58,7 @@ class LeftoverPair:
leftover_map_rank: int
leftover_map_unexplained: float | None = None
leftover_map_cross_share: float | None = None
+ leftover_map_reconstruction: float | None = None
@dataclass(frozen=True)
@@ -101,8 +102,8 @@ def leftover_pairs_from_residual(
the two-axis map does not reconstruct, and leftover-map cross share
``x = 2 R̂ U / R²`` names the identity remainder of raw residual
``R`` after two-axis reconstruction ``R̂ = ξ_{1:2} · ζ_{1:2}`` and
- unexplained leftover ``U = R − R̂``. ``R̂`` stays internal and
- are never persisted. Without a complete-case map there is no pair
+ unexplained leftover ``U = R − R̂``. Signed ``R̂`` is persisted with
+ ``U`` so their raw-residual identity stays auditable. Without a complete-case map there is no pair
to name (ADR 0168); the caller reads coverage counts instead of a
center-distance stand-in pair.
"""
@@ -153,7 +154,10 @@ 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, float | None]
+ tuple[
+ float, str, str, float, float, float,
+ float | None, float | None, float | None,
+ ]
] = []
if person_pos is not None and item_pos is not None:
person_index = np.flatnonzero(keep_person)
@@ -188,6 +192,7 @@ def leftover_map_from_residual(
distance,
unexplained,
share,
+ reconstruction if np.isfinite(reconstruction) else None,
)
)
if not candidates:
@@ -250,8 +255,12 @@ def _candidate_row(
distance: float,
leftover_map_unexplained: float | None,
leftover_map_cross_share: float | None,
-) -> tuple[float, str, str, float, float, float, float | None, float | None]:
- """One observed leftover cell: distance, ids, residual, Y, E, U, cross share."""
+ leftover_map_reconstruction: float | None,
+) -> tuple[
+ float, str, str, float, float, float,
+ float | None, float | None, float | None,
+]:
+ """One observed cell: distance, ids, residual, Y, E, U, cross share, R̂."""
leftover_residual = float(residual[person, item])
observed_response = float(matrix[person, item])
expected_response = float(expected[person, item])
@@ -266,12 +275,16 @@ def _candidate_row(
expected_response,
leftover_map_unexplained,
leftover_map_cross_share,
+ leftover_map_reconstruction,
)
def _pair_from_candidate(
pair_kind: str,
- row: tuple[float, str, str, float, float, float, float | None, float | None],
+ row: tuple[
+ float, str, str, float, float, float,
+ float | None, float | None, float | None,
+ ],
leftover_map_rank: int,
) -> LeftoverPair:
"""Build a leftover pair from a candidate row."""
@@ -288,6 +301,7 @@ def _pair_from_candidate(
leftover_map_rank=leftover_map_rank,
leftover_map_unexplained=row[6],
leftover_map_cross_share=row[7],
+ leftover_map_reconstruction=row[8],
)
@@ -424,8 +438,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 and
- leftover-map cross share are named.
+ reconstruction is persisted with unexplained leftover and cross share so
+ the raw-residual identity remains auditable.
"""
padded = np.zeros((positions.shape[0], _LEFTOVER_MAP_AXES), dtype=np.float64)
width = min(_LEFTOVER_MAP_AXES, positions.shape[1])
diff --git a/migrations/0206_report_leftover_map_reconstruction.sql b/migrations/0206_report_leftover_map_reconstruction.sql
new file mode 100644
index 000000000..9ee52fde9
--- /dev/null
+++ b/migrations/0206_report_leftover_map_reconstruction.sql
@@ -0,0 +1,8 @@
+-- ADR 0201: persist leftover-map reconstruction R̂ = ξ_{1:2} · ζ_{1:2}
+-- so unexplained leftover U = R − R̂ stays auditable as U + R̂ = R.
+-- Distance stays Euclidean leftover-map d. No nonnegative CHECK: a signed
+-- reconstruction is stored, never clamped. The nullable upgrade preserves
+-- older rows without fabricating reconstruction.
+
+alter table report_leftover_pair
+ add column if not exists leftover_map_reconstruction numeric;
diff --git a/migrations/rollback/0206_report_leftover_map_reconstruction.sql b/migrations/rollback/0206_report_leftover_map_reconstruction.sql
new file mode 100644
index 000000000..e4b809ab3
--- /dev/null
+++ b/migrations/rollback/0206_report_leftover_map_reconstruction.sql
@@ -0,0 +1,4 @@
+-- Reverse migration 0206. Distance, residual, and unexplained leftover stay.
+
+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 9203a1824..490c326cb 100644
--- a/scripts/seed_demo_data.py
+++ b/scripts/seed_demo_data.py
@@ -126,6 +126,7 @@ def seed(
cur.execute((migrations / "0169_report_leftover_map_axis.sql").read_text())
cur.execute((migrations / "0182_report_leftover_map_unexplained.sql").read_text())
cur.execute((migrations / "0185_report_leftover_map_cross_share.sql").read_text())
+ cur.execute((migrations / "0206_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())
@@ -1367,8 +1368,9 @@ 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, leftover_map_cross_share"
- ") values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
+ "leftover_map_unexplained, leftover_map_cross_share, "
+ "leftover_map_reconstruction"
+ ") values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
(
grouping_kind,
grouping_key,
@@ -1384,6 +1386,7 @@ def _persist_seed_period_report(
pair.leftover_map_rank,
pair.leftover_map_unexplained,
pair.leftover_map_cross_share,
+ pair.leftover_map_reconstruction,
),
)
for axis in report.leftover_map_axes:
diff --git a/tests/test_leftover_pairs.py b/tests/test_leftover_pairs.py
index 5b9d90a96..a1080e172 100644
--- a/tests/test_leftover_pairs.py
+++ b/tests/test_leftover_pairs.py
@@ -60,10 +60,9 @@ def _assert_residual_reconciles(pair) -> None:
def _assert_never_persists_hidden_shares(pair) -> None:
- """The cross-share increment never persists these adjacent facts (ADR 0185)."""
+ """The cross-share/reconstruction path never persists unsupported shares."""
assert not hasattr(pair, "leftover_map_explained_share")
assert not hasattr(pair, "leftover_map_unexplained_share")
- assert not hasattr(pair, "leftover_map_reconstruction")
def _gabriel_positions(filled: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
@@ -114,7 +113,8 @@ def test_leftover_residual_biplot_separates_aligned_and_opposed_cells() -> None:
assert closest.leftover_map_cross_share == pytest.approx(0.0, abs=1e-6)
# Rank-1 reconstructed opposed cell: U = 0 so x = 0.
assert farthest.leftover_map_cross_share == pytest.approx(0.0, abs=1e-6)
- assert not hasattr(closest, "leftover_map_reconstruction")
+ assert closest.leftover_map_reconstruction == pytest.approx(0.0, abs=1e-6)
+ assert farthest.leftover_map_reconstruction == pytest.approx(-2.0, abs=1e-6)
for pair in pairs:
_assert_residual_reconciles(pair)
_assert_never_persists_hidden_shares(pair)
@@ -147,6 +147,8 @@ def test_zero_residual_still_emits_stable_leftover_pairs() -> None:
assert pairs[1].leftover_map_unexplained == pytest.approx(0.0)
assert pairs[0].leftover_map_cross_share == pytest.approx(0.0)
assert pairs[1].leftover_map_cross_share == pytest.approx(0.0)
+ assert pairs[0].leftover_map_reconstruction == pytest.approx(0.0)
+ assert pairs[1].leftover_map_reconstruction == pytest.approx(0.0)
for pair in pairs:
_assert_residual_reconciles(pair)
assert pair.leftover_map_rank == 0
@@ -156,6 +158,27 @@ def test_zero_residual_still_emits_stable_leftover_pairs() -> None:
assert coverage.incomplete_post_count == 0
+def test_rank_zero_nonzero_constant_residual_keeps_raw_identity() -> None:
+ """Centering a constant nonzero residual gives R̂=0 while U remains R."""
+ matrix = np.ones((2, 2), dtype=np.float64)
+ pairs = leftover_pairs_from_residual(
+ ["post-a", "post-b"],
+ ("item-a", "item-b"),
+ matrix,
+ np.zeros_like(matrix),
+ )
+
+ assert pairs
+ for pair in pairs:
+ assert pair.leftover_map_rank == 0
+ assert pair.leftover_residual == pytest.approx(1.0)
+ assert pair.leftover_map_reconstruction == pytest.approx(0.0)
+ assert pair.leftover_map_unexplained == pytest.approx(1.0)
+ assert pair.leftover_map_unexplained + pair.leftover_map_reconstruction == pytest.approx(
+ pair.leftover_residual
+ )
+
+
def test_partial_observation_does_not_treat_missing_as_zero_residual() -> None:
"""A missing cell must not enter the Gabriel factorization as 0."""
post_ids = ["aligned-post", "opposed-post", "sparse-post"]
@@ -257,6 +280,7 @@ def test_leftover_residual_rejects_database_tolerance_boundary() -> None:
0.0,
None,
None,
+ None,
)
@@ -456,7 +480,10 @@ def test_unexplained_and_cross_share_are_identity_remainder_terms() -> None:
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")
+ assert pair.leftover_map_reconstruction == pytest.approx(recon)
+ assert pair.leftover_map_unexplained + pair.leftover_map_reconstruction == pytest.approx(
+ pair.leftover_residual
+ )
# Raw-residual cross share x = 2 R̂ U / R² (ADR 0185).
residual = float(pair.leftover_residual)
expected_share = (2.0 * recon * expected_unexplained) / (residual * residual)
diff --git a/tests/test_period_report.py b/tests/test_period_report.py
index a7bf86626..086f3ed8c 100644
--- a/tests/test_period_report.py
+++ b/tests/test_period_report.py
@@ -274,9 +274,10 @@ def test_calibrated_report_attaches_leftover_pairs() -> None:
assert np.isfinite(pair.leftover_map_unexplained)
if pair.leftover_map_cross_share is not None:
assert np.isfinite(pair.leftover_map_cross_share)
+ if pair.leftover_map_reconstruction is not None:
+ assert np.isfinite(pair.leftover_map_reconstruction)
assert not hasattr(pair, "leftover_map_explained_share")
assert not hasattr(pair, "leftover_map_unexplained_share")
- assert not hasattr(pair, "leftover_map_reconstruction")
assert [axis.axis_index for axis in report.leftover_map_axes] == [1, 2]
for axis in report.leftover_map_axes:
assert axis.leftover_singular_value >= 0.0
diff --git a/tests/test_schema.py b/tests/test_schema.py
index d2a2c015f..a1ab41869 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -63,6 +63,11 @@
/ "migrations"
/ "0185_report_leftover_map_cross_share.sql"
)
+_LEFTOVER_MAP_RECONSTRUCTION_MIGRATION = (
+ Path(__file__).resolve().parents[1]
+ / "migrations"
+ / "0206_report_leftover_map_reconstruction.sql"
+)
_LEFTOVER_MAP_AXIS_MIGRATION = (
Path(__file__).resolve().parents[1]
/ "migrations"
@@ -121,6 +126,7 @@ def schema_db():
cur.execute(_LEFTOVER_MAP_AXIS_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_UNEXPLAINED_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_CROSS_SHARE_MIGRATION.read_text())
+ cur.execute(_LEFTOVER_MAP_RECONSTRUCTION_MIGRATION.read_text())
conn.commit()
yield conn
finally:
@@ -287,7 +293,7 @@ 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
+ assert columns["leftover_map_reconstruction"] == "YES"
def test_leftover_pair_names_nullable_cross_share_column(schema_db) -> None:
@@ -306,7 +312,7 @@ def test_leftover_pair_names_nullable_cross_share_column(schema_db) -> None:
assert columns["leftover_distance"] == "NO"
assert "leftover_map_explained_share" not in columns
assert "leftover_map_unexplained_share" not in columns
- assert "leftover_map_reconstruction" not in columns
+ assert columns["leftover_map_reconstruction"] == "YES"
with schema_db.cursor() as cur:
cur.execute(
"""