diff --git a/AGENTS.md b/AGENTS.md
index c927f9e61..e677c328a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -271,16 +271,19 @@ 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 / 0201) are computed in `lineageweave/leftover_pairs.py` from the
+0163 / 0164 / 0182 / 0185 / 0201 / 0233) 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,
-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
+unexplained leftover, the ADR 0201 reconstruction evidence, ADR 0185
+cross-share evidence, and ADR 0233 unexplained leftover share
+`s = U² / R²`. ADR 0201 is the sole normative reconstruction formula,
+storage, and audit contract; do not duplicate or reinterpret it here.
+ADR 0233 is the sole unexplained leftover share contract; do not persist
+leftover-map explained share `e` here. The pairs 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`.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 1f1c68f02..bfc0824cc 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -616,9 +616,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 / 0201) persist to `report_leftover_pair` with signed residual `R`,
+0182 / 0185 / 0201 / 0233) persist to `report_leftover_pair` with signed residual `R`,
observed `Y`, expected `E[Y|θ, item]`, full leftover-map rank, unexplained
-leftover, ADR 0201 reconstruction evidence, and ADR 0185 cross-share evidence.
+leftover, ADR 0201 reconstruction evidence, ADR 0185 cross-share evidence,
+and ADR 0233 unexplained leftover share `s`.
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
diff --git a/CHANGELOG.d/2.22.0-leftover-map-unexplained-share.md b/CHANGELOG.d/2.22.0-leftover-map-unexplained-share.md
new file mode 100644
index 000000000..3f1644272
--- /dev/null
+++ b/CHANGELOG.d/2.22.0-leftover-map-unexplained-share.md
@@ -0,0 +1,9 @@
+## 2.22.0 — Leftover-map unexplained leftover share
+
+- Persist leftover-map unexplained leftover share `s = U² / R²` of raw
+ residual on leftover post–criterion pairs (ADR 0233). After
+ `make seed`, closest and farthest leftover pairs sit above the
+ member list with `U²/R²` next to leftover-map distance `d`; click
+ opens that post. Omit the badge when the share is missing. A share
+ greater than 1 is shown, never clamped. Never invent a leftover
+ score. Do not introduce leftover-map explained share `e`.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e59db82d9..4f7cb7188 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -152,6 +152,16 @@ All notable changes to this project are documented here. Format follows
leftover remains the ADR 0182 value `U = R − R̂`. Explained leftover share
`e` and unexplained leftover share `s` are not persisted here.
+- Period leftover pair rows now name leftover-map unexplained leftover share
+ `s = U² / R²` of raw residual next to leftover-map distance `d`, then
+ open that post (Gabriel, 1971; Jeon et al., 2021, eq. 3; ADR 0233). After
+ `make seed`, closest and farthest leftover pairs sit above the member
+ list with `U²/R²` next to `d`. A missing share omits the badge rather
+ than inventing a leftover score. A share greater than 1 is shown, never
+ clamped. Two-axis reconstruction `R̂` and leftover-map cross share `x`
+ stay as already persisted. Explained leftover share `e` is not persisted
+ here.
+
- The grouping comparison strip now names leftover post–criterion
pairs on each visible row (ADR 0149). After `make seed`, open a
leftover pair on A-100 from the strip to read that post. A leftover
diff --git a/CLAUDE.md b/CLAUDE.md
index eb9e85eab..357529b86 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 / 0201), the text-channel embedding swap and cosine
+(ADR 0048–0164 / 0182 / 0185 / 0201 / 0233), 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 f01c15ae0..5d42302d9 100644
--- a/backend/app/report_ingestion.py
+++ b/backend/app/report_ingestion.py
@@ -447,8 +447,8 @@ async def persist_period_report(
pair_kind, post_id, criterion_code, leftover_distance, leftover_residual,
observed_response, expected_response, leftover_map_rank,
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)
+ leftover_map_reconstruction, leftover_map_unexplained_share
+ ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
""",
grouping_kind,
grouping_key,
@@ -465,6 +465,7 @@ async def persist_period_report(
pair.leftover_map_unexplained,
pair.leftover_map_cross_share,
pair.leftover_map_reconstruction,
+ pair.leftover_map_unexplained_share,
)
for axis in report.leftover_map_axes:
await conn.execute(
@@ -652,7 +653,7 @@ async def fetch_period_reports(
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,
- lp.leftover_map_reconstruction, p.post_title,
+ lp.leftover_map_reconstruction, lp.leftover_map_unexplained_share, p.post_title,
p.visibility_code, p.corporate_entity_id, p.process_unit_id,
({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context
from report_leftover_pair lp
@@ -809,6 +810,11 @@ async def fetch_period_reports(
if row["leftover_map_reconstruction"] is None
else float(row["leftover_map_reconstruction"])
),
+ "leftover_map_unexplained_share": (
+ None
+ if row["leftover_map_unexplained_share"] is None
+ else float(row["leftover_map_unexplained_share"])
+ ),
"visibility_code": row["visibility_code"],
"corporate_entity_id": str(row["corporate_entity_id"]),
"process_unit_id": (
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index 036a10131..61b1dfd41 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -181,6 +181,11 @@
/ "migrations"
/ "0206_report_leftover_map_reconstruction.sql"
)
+_LEFTOVER_MAP_UNEXPLAINED_SHARE_MIGRATION = (
+ Path(__file__).resolve().parents[2]
+ / "migrations"
+ / "0233_report_leftover_map_unexplained_share.sql"
+)
_GLOBAL_ASK_JOB_MIGRATION = (
Path(__file__).resolve().parents[2]
/ "migrations"
@@ -410,6 +415,7 @@ def seeded_db(demo_analyst_token):
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(_LEFTOVER_MAP_UNEXPLAINED_SHARE_MIGRATION.read_text())
cur.execute(
"insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values "
"('corporate_entity_level', 'group', 'Group'), "
@@ -5812,10 +5818,15 @@ 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)
+ unexplained_share = pair.get("leftover_map_unexplained_share")
+ assert unexplained_share is None or isinstance(unexplained_share, (int, float))
+ if unexplained_share is not None:
+ assert not math.isnan(unexplained_share)
+ assert not math.isinf(unexplained_share)
+ assert unexplained_share >= 0.0
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
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)
@@ -5878,6 +5889,11 @@ def test_seed_period_report_surfaces_on_get_reports(client, demo_analyst_token,
or isinstance(pair["leftover_map_reconstruction"], (int, float))
for pair in leftover_thread.get("leftover_pairs", [])
)
+ assert all(
+ "leftover_map_unexplained_share" not in pair
+ and "leftover_map_explained_share" not in pair
+ for pair in leftover_thread.get("leftover_pairs", [])
+ )
def test_seed_period_report_includes_fixture_event_lineage_posts(
diff --git a/docs/adr/0048-persist-lsirm-leftover-pairs.md b/docs/adr/0048-persist-lsirm-leftover-pairs.md
index 613545db4..5b53406df 100644
--- a/docs/adr/0048-persist-lsirm-leftover-pairs.md
+++ b/docs/adr/0048-persist-lsirm-leftover-pairs.md
@@ -6,7 +6,9 @@
[ADR 0163](0163-leftover-observed-expected.md) (observed Y and expected E);
[ADR 0164](0164-leftover-map-rank.md) (full map rank);
[ADR 0182](0182-leftover-map-unexplained.md) (unexplained leftover U);
-[ADR 0185](0185-leftover-map-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̂);
+[ADR 0233](0233-leftover-map-unexplained-share.md) (unexplained leftover share s)
## Context
@@ -49,7 +51,11 @@ read as leftover residual `R`, leftover-map distance `d`, explained
leftover share `e`, or unexplained leftover share `s` (ADR 0185).
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.
+distance contract. ADR 0233 persists unexplained leftover share
+`s = U² / R²` of raw residual so the leftover the truncated map cannot
+reconstruct is not read as leftover residual `R`, leftover-map distance
+`d`, unexplained leftover `U`, or leftover-map cross share `x`. This
+increment does not persist leftover-map explained leftover share `e`.
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 4be472ef9..460a0e8c2 100644
--- a/docs/adr/0049-leftover-pair-report-ui.md
+++ b/docs/adr/0049-leftover-pair-report-ui.md
@@ -8,7 +8,8 @@
[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 0201](0201-leftover-map-reconstruction.md) (signed reconstruction R̂)
+[ADR 0201](0201-leftover-map-reconstruction.md) (signed reconstruction R̂);
+[ADR 0233](0233-leftover-map-unexplained-share.md) (unexplained leftover share s)
## Context
@@ -26,17 +27,19 @@ 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`, signed reconstruction `R̂` when finite, and
+unexplained leftover `U`, signed reconstruction `R̂` when finite,
+leftover-map unexplained leftover share `s = U² / 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
-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
+post to read the named criterion" when present. When leftover-map
+unexplained leftover share is also present, the next action instead names
+the square share `s` of raw residual two leftover-map axes leave after
IRT main effects. A missing or non-finite value falls back in order —
-cross share, then reconstruction, then unexplained leftover, then the existing
-closest/farthest next action. Clicking the button opens that post with
+unexplained leftover share, then cross share, then reconstruction, then
+unexplained leftover, then rank / observed `Y` / expected `E`, then the
+existing residual next action. Clicking the button opens that post with
leftover focus so Post quality marks the named criterion current
(ADR 0158). Residual naming is
[ADR 0162](0162-leftover-residual-disclosure.md), observed/expected
@@ -45,6 +48,8 @@ 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).
+Unexplained leftover share naming is
+[ADR 0233](0233-leftover-map-unexplained-share.md).
After `make seed`, closest and farthest leftover pairs sit above the
member list. Click a pair to open that post with the leftover
diff --git a/docs/adr/0233-leftover-map-unexplained-share.md b/docs/adr/0233-leftover-map-unexplained-share.md
new file mode 100644
index 000000000..f326bdd12
--- /dev/null
+++ b/docs/adr/0233-leftover-map-unexplained-share.md
@@ -0,0 +1,106 @@
+# ADR 0233 — Name leftover-map unexplained leftover share on period-report pair rows
+
+**Decision status:** Accepted
+**Date:** 2026-08-27
+
+Amends [ADR 0048](0048-persist-lsirm-leftover-pairs.md) and
+[ADR 0049](0049-leftover-pair-report-ui.md). Independent of leftover-map
+cross share ([ADR 0185](0185-leftover-map-cross-share.md)) and leftover-map
+reconstruction ([ADR 0201](0201-leftover-map-reconstruction.md)).
+
+## Context
+
+ADR 0182 already persists unexplained leftover `U = R − R̂` after
+two-axis Gabriel reconstruction `R̂ = ξ_{1:2} · ζ_{1:2}`. ADR 0185
+already persists leftover-map cross share `x = 2 R̂ U / R²`. The
+raw-residual cell identity `R² = R̂² + U² + 2 R̂ U` therefore yields
+`e + s + x = 1` with explained leftover share `e = R̂² / R²` and
+unexplained leftover share `s = U² / R²`. Hiding `s` lets a buyer
+read leftover residual `R`, leftover-map distance `d`, or unexplained
+leftover `U` as the leftover the truncated map cannot reconstruct,
+even though `s` is the square share of that leftover.
+
+This increment persists leftover-map unexplained leftover share `s`.
+It does not persist leftover-map explained leftover share `e`, does
+not persist leftover-map coordinates, does not name leftover-map inner
+product, cosine, or length, and does not land Post quality on the
+leftover criterion. Leftover-map distance stays two-axis Euclidean.
+Reconstruction `R̂` and unexplained leftover `U` remain the same
+internal two-axis terms already used for `x`, so `e + s + x = 1`
+stays auditable from persisted `R`, `R̂`, `U`, `x`, and `s`.
+
+The unprotected-stack reconstructions for neighbouring leftover facts
+use 0183 for unexplained leftover share. The dashboard stack already
+uses **0232** for leftover-map explained leftover share (PR #728) and
+**0222** for operations-case analysis input. This protected-main
+increment uses **0233** (migration **0233**) so it does not collide with
+GNB chrome (0183), ontology explorer (0184), leftover-map cross share
+(0185), leftover-map reconstruction (0201 / migration 0206), leftover
+residual disclosure, leftover observed `Y` / expected `E`, leftover-map
+rank, two-axis leftover-map distance, leftover coverage, leftover-map
+axis share (0148), leftover interaction-map persistence, leftover-map
+explained leftover share (0232 on the dashboard stack), or
+operations-case analysis input (0222 on that stack).
+
+## Decision
+
+Each leftover pair names `leftover_map_unexplained_share` — leftover-map
+unexplained leftover share `s = U² / R²` of raw residual after
+two-axis Gabriel reconstruction `R̂ = ξ_{1:2} · ζ_{1:2}` and
+unexplained leftover `U = R − R̂`. Migration `0233` 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, unexplained leftover, reconstruction, and cross share
+without fabricating a share. Fallback pairs that have no
+complete-case leftover map omit the value rather than inventing one.
+A rank-0 origin cell stores `0.0` when `R = R̂ = U = 0`, not a missing
+value. A rank-0 constant residual with `R̂ = 0` stores `1.0` (`s = U² / R²`
+with `U = R`). A non-finite share stores null rather than inventing a
+leftover score. `s` is nonnegative because it is a square share; a
+finite share greater than 1 is stored when `|U| > |R|`. Do not add an
+upper-bound CHECK. This increment does not introduce
+`leftover_map_explained_share`.
+
+The pair button shows `U²/R² {share}` next to leftover-map
+distance `d` when the value is a finite number. Next action: leftover
+map leaves unexplained leftover share `s` of raw residual after IRT
+main effects; open this post to read the named criterion. A missing
+or non-finite share omits the badge and keeps the existing
+cross-share / reconstruction / unexplained-leftover next action. Do
+not invent a leftover score. Do not invent a theta.
+
+## Consequences
+
+`GET /api/reports/{grouping}/{period}` returns
+`leftover_map_unexplained_share`. After `make seed`, closest and farthest
+leftover pairs sit above the member list with named `U²/R²` next
+to `d`; click opens that post. Hidden posts stay hidden. When `R`,
+`R̂`, `U`, `x`, and `s` are all finite, `e + s + x = 1` with
+`e = R̂² / R²` computed internally.
+
+The grouping comparison strip (ADR 0149) stays on its reduced leftover
+payload (distance, residual, reconstruction). Unexplained leftover
+share is a period-report pair fact, not a comparison-strip badge.
+
+## Related
+
+Independent of leftover interaction-map persistence, leftover-criterion
+evaluation landing, leftover residual disclosure, leftover observed
+`Y` / expected `E`, leftover-map complete-case coverage, leftover-map
+axis share, leftover pairs on the grouping comparison strip, two-axis
+leftover-map distance, leftover-map rank, leftover-map inner product,
+leftover-map cosine, leftover-map length, leftover-map reconstruction,
+leftover-map unexplained leftover, leftover-map cross share, and
+leftover-map explained leftover share.
+
+## References
+
+Gabriel, K. R. (1971). The biplot graphic display of matrices with
+application to principal component analysis. *Biometrika, 58*(3),
+453–467. https://doi.org/10.1093/biomet/58.3.453
+
+Jeon, M., Jin, I. H., Schweinberger, M., & Baugh, S. (2021). Mapping
+unobserved item–respondent interactions: A latent space item response
+model with interaction map. *Psychometrika, 86*(2), 378–403.
+https://doi.org/10.1007/s11336-021-09762-5
diff --git a/frontend/package.json b/frontend/package.json
index 5acf284d7..ab4431adf 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "2.17.0",
+ "version": "2.22.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 9ffb1920a..141ab5040 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -1002,6 +1002,7 @@ describe("App, authenticated", () => {
leftover_map_rank: 1,
leftover_map_cross_share: 0.12,
leftover_map_reconstruction: 0.35,
+ leftover_map_unexplained_share: 0.02,
},
{
pair_kind: "farthest",
@@ -1016,6 +1017,7 @@ describe("App, authenticated", () => {
leftover_map_rank: 1,
leftover_map_cross_share: -0.24,
leftover_map_reconstruction: -0.85,
+ leftover_map_unexplained_share: 0.05,
},
],
leftover_map_axes: [
@@ -4001,27 +4003,29 @@ describe("App, authenticated", () => {
name: /open leftover farthest pair: specification revision requested/i,
});
expect(closestPair).toHaveTextContent("Closest leftover: Public post · sales-lead");
- // Leftover-map cross share is present, so it names the next action
- // instead of the rank/observed-expected chain (ADR 0185).
+ // Leftover-map unexplained leftover share is present, so it names the
+ // next action instead of leftover-map cross share (ADR 0233).
expect(closestPair).toHaveTextContent(
- "Two leftover-map axes leave identity remainder 0.12 of raw residual after IRT main effects. Open this post to read sales-lead.",
+ "Leftover map leaves unexplained leftover share 0.02 of raw residual after IRT main effects. Open this post to read sales-lead.",
);
expect(closestPair).toHaveTextContent("R +0.40");
expect(closestPair).toHaveTextContent("Y 2.40 · E 2.00");
expect(closestPair).toHaveTextContent("rank 1");
expect(closestPair).toHaveTextContent("U +0.05");
+ expect(closestPair).toHaveTextContent("U²/R² 0.02");
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");
expect(farthestPair).toHaveTextContent(
- "Two leftover-map axes leave identity remainder -0.24 of raw residual after IRT main effects. Open this post to read negative.",
+ "Leftover map leaves unexplained leftover share 0.05 of raw residual after IRT main effects. Open this post to read negative.",
);
expect(farthestPair).toHaveTextContent("R −1.10");
expect(farthestPair).toHaveTextContent("Y 0.90 · E 2.00");
expect(farthestPair).toHaveTextContent("rank 1");
expect(farthestPair).toHaveTextContent("U −0.25");
+ expect(farthestPair).toHaveTextContent("U²/R² 0.05");
expect(farthestPair).toHaveTextContent("2R̂U/R² -0.24");
expect(farthestPair).toHaveTextContent("R̂ −0.85");
expect(farthestPair).toHaveTextContent("d 1.84");
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 9533c605d..d0abb622d 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -1053,6 +1053,7 @@ export interface LeftoverPair {
leftover_map_unexplained?: number | null;
leftover_map_cross_share?: number | null;
leftover_map_reconstruction?: number | null;
+ leftover_map_unexplained_share?: number | null;
}
export interface LeftoverMapAxis {
diff --git a/frontend/src/components/LeftoverPairList.stories.tsx b/frontend/src/components/LeftoverPairList.stories.tsx
index 637351074..98bb55e91 100644
--- a/frontend/src/components/LeftoverPairList.stories.tsx
+++ b/frontend/src/components/LeftoverPairList.stories.tsx
@@ -21,6 +21,8 @@ const meta = {
leftover_map_rank: 1,
leftover_map_unexplained: 0.05,
leftover_map_reconstruction: 0.35,
+ leftover_map_cross_share: 0.12,
+ leftover_map_unexplained_share: 0.02,
},
{
pair_kind: "farthest",
@@ -34,6 +36,8 @@ const meta = {
leftover_map_rank: 1,
leftover_map_unexplained: -0.25,
leftover_map_reconstruction: -0.85,
+ leftover_map_cross_share: -0.24,
+ leftover_map_unexplained_share: 0.05,
},
],
},
diff --git a/frontend/src/components/LeftoverPairList.test.tsx b/frontend/src/components/LeftoverPairList.test.tsx
index 36c15715a..505ceae52 100644
--- a/frontend/src/components/LeftoverPairList.test.tsx
+++ b/frontend/src/components/LeftoverPairList.test.tsx
@@ -125,7 +125,61 @@ describe("LeftoverPairList", () => {
expect(screen.getByRole("button")).toHaveTextContent(expectedAction);
});
- it("names leftover-map reconstruction so the next click opens that post", () => {
+ it("names leftover-map unexplained leftover share so the next click opens that post", () => {
+ render(
+ ,
+ );
+
+ const closest = screen.getByRole("button");
+ expect(closest).toHaveTextContent(
+ "Leftover map leaves unexplained leftover share 0.02 of raw residual after IRT main effects. Open this post to read sales-lead.",
+ );
+ expect(closest).toHaveTextContent("U²/R² 0.02");
+ expect(closest).toHaveTextContent("2R̂U/R² 0.12");
+ 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 leftover-map cross share guidance when unexplained leftover share is missing", () => {
+ render(
+ ,
+ );
+
+ const closest = screen.getByRole("button");
+ expect(closest).toHaveTextContent(
+ "Two leftover-map axes leave identity remainder 0.12 of raw residual after IRT main effects. Open this post to read sales-lead.",
+ );
+ expect(closest).toHaveTextContent("2R̂U/R² 0.12");
+ expect(closest).toHaveTextContent("R̂ +0.35");
+ expect(closest).not.toHaveTextContent("U²/R²");
+ });
+
+ it("keeps leftover-map reconstruction guidance when unexplained leftover share is missing", () => {
render(
{observedExpected} : null}
{rankBadge ? {rankBadge} : null}
{unexplained ? {unexplained} : null}
+ {unexplainedShareBadge ? (
+ {unexplainedShareBadge}
+ ) : 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 93cb5b40c..5c3247c76 100644
--- a/frontend/src/i18n.test.ts
+++ b/frontend/src/i18n.test.ts
@@ -55,6 +55,7 @@ describe("i18n", () => {
"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}.",
+ "Leftover map leaves unexplained leftover share {value} of raw residual after IRT main effects. Open this post to read {criterion}.",
"Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.",
"Leftover map has no leftover structure after IRT main effects. Open this post.",
"Leftover map rank {rank} after IRT main effects. Open this post.",
@@ -219,6 +220,33 @@ describe("i18n", () => {
).toBe(expected);
});
+ it.each([
+ [
+ "ko",
+ "잔여 지도가 IRT 주효과 이후 원시 잔차의 설명되지 않은 잔여 비율 0.02을(를) 남깁니다. sales-lead 기준을 읽으려면 이 글을 여세요.",
+ ],
+ [
+ "zh",
+ "残差图在 IRT 主效应后留下原始残差的未解释残余份额 0.02。打开这篇帖子阅读 sales-lead。",
+ ],
+ [
+ "ja",
+ "残差マップはIRT主効果後の生の残差の未説明残差シェア 0.02 を残します。この投稿を開いて sales-lead を読んでください。",
+ ],
+ [
+ "vi",
+ "Bản đồ phần dư để lại tỷ phần phần dư chưa giải thích 0.02 của phần dư thô sau hiệu ứng chính IRT. Mở bài viết này để đọc sales-lead.",
+ ],
+ ] as const)("formats leftover-map unexplained leftover share next action in %s", (locale, expected) => {
+ setLocale(locale);
+ expect(
+ tf(
+ "Leftover map leaves unexplained leftover share {value} of raw residual after IRT main effects. Open this post to read {criterion}.",
+ { value: "0.02", criterion: "sales-lead" },
+ ),
+ ).toBe(expected);
+ });
+
it.each([
["ko", "IRT 주효과 이후 관측 Y 2.40와 기대 E 2.00를 읽은 다음, 이 글을 여세요."],
["zh", "阅读 IRT 主效应后的观测 Y 2.40 与期望 E 2.00,然后打开这篇帖子。"],
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
index 0c00a53a3..a82094d5a 100644
--- a/frontend/src/i18n.ts
+++ b/frontend/src/i18n.ts
@@ -544,6 +544,8 @@ const TRANSLATIONS: Partial>> = {
"잔여 지도가 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} 기준을 읽으려면 이 글을 여세요.",
+ "Leftover map leaves unexplained leftover share {value} of raw residual after IRT main effects. Open this post to read {criterion}.":
+ "잔여 지도가 IRT 주효과 이후 원시 잔차의 설명되지 않은 잔여 비율 {value}을(를) 남깁니다. {criterion} 기준을 읽으려면 이 글을 여세요.",
"Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.":
"IRT 주효과 이후 관측 Y {observed}와 기대 E {expected}를 읽은 다음, 이 글을 여세요.",
"Leftover map has no leftover structure after IRT main effects. Open this post.":
@@ -1073,6 +1075,8 @@ const TRANSLATIONS: Partial>> = {
"残差图在 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}。",
+ "Leftover map leaves unexplained leftover share {value} of raw residual after IRT main effects. Open this post to read {criterion}.":
+ "残差图在 IRT 主效应后留下原始残差的未解释残余份额 {value}。打开这篇帖子阅读 {criterion}。",
"Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.":
"阅读 IRT 主效应后的观测 Y {observed} 与期望 E {expected},然后打开这篇帖子。",
"Leftover map has no leftover structure after IRT main effects. Open this post.":
@@ -1605,6 +1609,8 @@ const TRANSLATIONS: Partial>> = {
"残差マップは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} を読んでください。",
+ "Leftover map leaves unexplained leftover share {value} of raw residual after IRT main effects. Open this post to read {criterion}.":
+ "残差マップはIRT主効果後の生の残差の未説明残差シェア {value} を残します。この投稿を開いて {criterion} を読んでください。",
"Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.":
"IRT主効果後の観測 Y {observed} と期待 E {expected} を読んでから、この投稿を開いてください。",
"Leftover map has no leftover structure after IRT main effects. Open this post.":
@@ -2137,6 +2143,8 @@ const TRANSLATIONS: Partial>> = {
"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}.",
+ "Leftover map leaves unexplained leftover share {value} of raw residual after IRT main effects. Open this post to read {criterion}.":
+ "Bản đồ phần dư để lại tỷ phần phần dư chưa giải thích {value} của phần dư thô sau hiệu ứng chính IRT. Mở bài viết này để đọc {criterion}.",
"Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.":
"Đọc Y quan sát {observed} và E kỳ vọng {expected} sau hiệu ứng chính IRT, rồi mở bài viết này.",
"Leftover map has no leftover structure after IRT main effects. Open this post.":
diff --git a/frontend/src/leftoverMapUnexplainedShare.test.ts b/frontend/src/leftoverMapUnexplainedShare.test.ts
new file mode 100644
index 000000000..a5ce71272
--- /dev/null
+++ b/frontend/src/leftoverMapUnexplainedShare.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from "vitest";
+import { formatLeftoverMapUnexplainedShare } from "./leftoverMapUnexplainedShare";
+
+describe("formatLeftoverMapUnexplainedShare", () => {
+ it("names leftover-map unexplained leftover share without inventing a leftover score", () => {
+ expect(formatLeftoverMapUnexplainedShare(0.02)).toBe("U\u00b2/R\u00b2 0.02");
+ expect(formatLeftoverMapUnexplainedShare(0)).toBe("U\u00b2/R\u00b2 0.00");
+ expect(formatLeftoverMapUnexplainedShare(1.25)).toBe("U\u00b2/R\u00b2 1.25");
+ });
+
+ it("omits the badge when leftover-map unexplained leftover share is missing or non-finite", () => {
+ expect(formatLeftoverMapUnexplainedShare(null)).toBeNull();
+ expect(formatLeftoverMapUnexplainedShare(undefined)).toBeNull();
+ expect(formatLeftoverMapUnexplainedShare(Number.NaN)).toBeNull();
+ expect(formatLeftoverMapUnexplainedShare(Number.POSITIVE_INFINITY)).toBeNull();
+ expect(formatLeftoverMapUnexplainedShare(Number.NEGATIVE_INFINITY)).toBeNull();
+ });
+});
diff --git a/frontend/src/leftoverMapUnexplainedShare.ts b/frontend/src/leftoverMapUnexplainedShare.ts
new file mode 100644
index 000000000..5189471f4
--- /dev/null
+++ b/frontend/src/leftoverMapUnexplainedShare.ts
@@ -0,0 +1,13 @@
+/** Leftover-map unexplained leftover share ``s = U² / R²`` of raw residual. */
+
+export const LEFTOVER_MAP_UNEXPLAINED_SHARE_ACTION =
+ "Leftover map leaves unexplained leftover share {value} of raw residual after IRT main effects. Open this post to read {criterion}.";
+
+export function formatLeftoverMapUnexplainedShare(
+ value: number | null | undefined,
+): string | null {
+ if (value == null || !Number.isFinite(value)) {
+ return null;
+ }
+ return `U\u00b2/R\u00b2 ${value.toFixed(2)}`;
+}
diff --git a/lineageweave/leftover_pairs.py b/lineageweave/leftover_pairs.py
index 070416fe2..ad53e7cb4 100644
--- a/lineageweave/leftover_pairs.py
+++ b/lineageweave/leftover_pairs.py
@@ -1,7 +1,7 @@
"""Jeon leftover post–criterion pairs after a main-effect IRT.
Implements ADR 0048 as amended by ADR 0119, ADR 0163, ADR 0164, ADR 0182,
-and ADR 0185.
+ADR 0185, ADR 0201, and ADR 0233.
Does not import ``fast_mlsirm`` or ``period_report``. A Gabriel biplot
of the residual ``R = Y − E[Y|θ, item]`` supplies person and item
@@ -25,10 +25,11 @@
residual after that same truncated two-axis reconstruction,
so the identity remainder left by the truncation is not confused with
leftover residual ``R``, leftover-map distance ``d``, or unexplained
-leftover ``U``. Explained leftover share ``e = R̂² / R²`` and
-unexplained leftover share ``s = U² / R²`` are not persisted. Signed
-reconstruction ``R̂`` is persisted so ``U + R̂ = R`` stays auditable. ``x``
-may be negative when reconstruction and unexplained leftover have opposite signs.
+leftover ``U``. Each pair also names leftover-map unexplained leftover
+share ``s = U² / R²`` of raw residual (ADR 0233). Explained leftover
+share ``e = R̂² / R²`` is 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
@@ -59,6 +60,7 @@ class LeftoverPair:
leftover_map_unexplained: float | None = None
leftover_map_cross_share: float | None = None
leftover_map_reconstruction: float | None = None
+ leftover_map_unexplained_share: float | None = None
@dataclass(frozen=True)
@@ -99,11 +101,13 @@ def leftover_pairs_from_residual(
expected ``E[Y|θ, item]``. Stored leftover-map rank is the number
of Gabriel singular values above the floor. When Gabriel coordinates
exist, unexplained leftover ``U = R − R̂`` names the leftover cell
- the two-axis map does not reconstruct, and leftover-map cross share
+ the two-axis map does not reconstruct, 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̂``. Signed ``R̂`` is persisted with
- ``U`` so their raw-residual identity stays auditable. Without a complete-case map there is no pair
+ unexplained leftover ``U = R − R̂``, and leftover-map unexplained
+ leftover share ``s = U² / R²`` names the square share of that
+ leftover. 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.
"""
@@ -156,7 +160,7 @@ def leftover_map_from_residual(
candidates: list[
tuple[
float, str, str, float, float, float,
- float | None, float | None, float | None,
+ float | None, float | None, float | None, float | None,
]
] = []
if person_pos is not None and item_pos is not None:
@@ -180,6 +184,7 @@ def leftover_map_from_residual(
residual_cell = float(residual[person, item])
unexplained = _unexplained_leftover(residual_cell, reconstruction)
share = _leftover_map_cross_share(residual_cell, reconstruction)
+ unexplained_share = _leftover_map_unexplained_share(residual_cell, reconstruction)
candidates.append(
_candidate_row(
post_ids,
@@ -193,6 +198,7 @@ def leftover_map_from_residual(
unexplained,
share,
reconstruction if np.isfinite(reconstruction) else None,
+ unexplained_share,
)
)
if not candidates:
@@ -244,6 +250,25 @@ def _leftover_map_cross_share(residual: float, reconstruction: float) -> float |
return None
+def _leftover_map_unexplained_share(residual: float, reconstruction: float) -> float | None:
+ """Return ``s = U² / R²`` when both terms are finite; otherwise omit.
+
+ Unexplained leftover ``U = R − R̂`` is computed internally.
+ ``s`` is nonnegative because it is a square share. A rank-0 origin
+ cell stores ``0.0`` when ``R = R̂ = U = 0``. A finite share greater
+ than 1 is stored when ``|U| > |R|``; do not clamp.
+ """
+ if not np.isfinite(residual) or not np.isfinite(reconstruction):
+ return None
+ unexplained = float(residual - reconstruction)
+ if abs(residual) > _LEFTOVER_SINGULAR_FLOOR:
+ share = float((unexplained * unexplained) / (residual * residual))
+ return share if np.isfinite(share) else None
+ if abs(reconstruction) <= _LEFTOVER_SINGULAR_FLOOR and abs(unexplained) <= _LEFTOVER_SINGULAR_FLOOR:
+ return 0.0
+ return None
+
+
def _candidate_row(
post_ids: list[str],
item_codes: tuple[str, ...],
@@ -256,11 +281,12 @@ def _candidate_row(
leftover_map_unexplained: float | None,
leftover_map_cross_share: float | None,
leftover_map_reconstruction: float | None,
+ leftover_map_unexplained_share: float | None,
) -> tuple[
float, str, str, float, float, float,
- float | None, float | None, float | None,
+ float | None, float | None, float | None, float | None,
]:
- """One observed cell: distance, ids, residual, Y, E, U, cross share, R̂."""
+ """One observed cell: distance, ids, residual, Y, E, U, cross share, R̂, s."""
leftover_residual = float(residual[person, item])
observed_response = float(matrix[person, item])
expected_response = float(expected[person, item])
@@ -276,6 +302,7 @@ def _candidate_row(
leftover_map_unexplained,
leftover_map_cross_share,
leftover_map_reconstruction,
+ leftover_map_unexplained_share,
)
@@ -283,7 +310,7 @@ def _pair_from_candidate(
pair_kind: str,
row: tuple[
float, str, str, float, float, float,
- float | None, float | None, float | None,
+ float | None, float | None, float | None, float | None,
],
leftover_map_rank: int,
) -> LeftoverPair:
@@ -302,6 +329,7 @@ def _pair_from_candidate(
leftover_map_unexplained=row[6],
leftover_map_cross_share=row[7],
leftover_map_reconstruction=row[8],
+ leftover_map_unexplained_share=row[9],
)
diff --git a/migrations/0233_report_leftover_map_unexplained_share.sql b/migrations/0233_report_leftover_map_unexplained_share.sql
new file mode 100644
index 000000000..2175623fb
--- /dev/null
+++ b/migrations/0233_report_leftover_map_unexplained_share.sql
@@ -0,0 +1,13 @@
+-- ADR 0233: persist leftover-map unexplained leftover share
+-- s = U² / R² of raw residual after two-axis leftover-map
+-- reconstruction (R̂ = ξ_{1:2} · ζ_{1:2}, U = R − R̂). Distance stays
+-- Euclidean leftover-map d. This migration adds only the unexplained-share
+-- column. Upgrade column is nullable so older leftover rows keep distance,
+-- residual, unexplained leftover, reconstruction, and cross share without
+-- fabricating a share. This migration is the single source of the column
+-- on fresh and existing installations. Do not edit shipped migrations
+-- 0001 / 0012 after the fact. Do not persist leftover_map_explained_share.
+-- Do not add an upper-bound CHECK: s may exceed 1 when |U| > |R|.
+
+alter table report_leftover_pair
+ add column if not exists leftover_map_unexplained_share numeric;
diff --git a/migrations/rollback/0233_report_leftover_map_unexplained_share.sql b/migrations/rollback/0233_report_leftover_map_unexplained_share.sql
new file mode 100644
index 000000000..e24c7e44b
--- /dev/null
+++ b/migrations/rollback/0233_report_leftover_map_unexplained_share.sql
@@ -0,0 +1,5 @@
+-- Reverse 0233. Leftover distance, residual, unexplained leftover,
+-- reconstruction, and cross share stay on the pair row.
+
+alter table report_leftover_pair
+ drop column if exists leftover_map_unexplained_share;
diff --git a/pyproject.toml b/pyproject.toml
index b8612dc51..26a10a8fe 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "lineageweave"
-version = "2.20.0"
+version = "2.22.0"
description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication."
readme = "README.md"
license = { text = "MIT" }
diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py
index 53a57e440..bd4d32e46 100644
--- a/scripts/seed_demo_data.py
+++ b/scripts/seed_demo_data.py
@@ -127,6 +127,7 @@ def seed(
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 / "0233_report_leftover_map_unexplained_share.sql").read_text())
cur.execute((migrations / "0060_role_responsibility_agent_type.sql").read_text())
cur.execute((migrations / "0013_person_job_title.sql").read_text())
cur.execute((migrations / "0014_role_responsibility_team_actor_type.sql").read_text())
@@ -1400,8 +1401,8 @@ def _persist_seed_period_report(
"pair_kind, post_id, criterion_code, leftover_distance, leftover_residual, "
"observed_response, expected_response, leftover_map_rank, "
"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)",
+ "leftover_map_reconstruction, leftover_map_unexplained_share"
+ ") values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
(
grouping_kind,
grouping_key,
@@ -1418,6 +1419,7 @@ def _persist_seed_period_report(
pair.leftover_map_unexplained,
pair.leftover_map_cross_share,
pair.leftover_map_reconstruction,
+ pair.leftover_map_unexplained_share,
),
)
for axis in report.leftover_map_axes:
diff --git a/tests/test_leftover_pairs.py b/tests/test_leftover_pairs.py
index a1080e172..b0a9b9fee 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,
-ADR 0182, and ADR 0185.
+ADR 0182, ADR 0185, ADR 0201, and ADR 0233.
Uses a constructed residual matrix so the closest and farthest pair
are known without calling ``fit_polytomous``. Loads
@@ -59,10 +59,10 @@ def _assert_residual_reconciles(pair) -> None:
)
-def _assert_never_persists_hidden_shares(pair) -> None:
- """The cross-share/reconstruction path never persists unsupported shares."""
+def _assert_never_persists_explained_share(pair) -> None:
+ """Unexplained leftover share is persisted; explained leftover share is not."""
assert not hasattr(pair, "leftover_map_explained_share")
- assert not hasattr(pair, "leftover_map_unexplained_share")
+ assert hasattr(pair, "leftover_map_unexplained_share")
def _gabriel_positions(filled: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
@@ -113,11 +113,13 @@ 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 closest.leftover_map_unexplained_share == pytest.approx(0.0, abs=1e-6)
+ assert farthest.leftover_map_unexplained_share == pytest.approx(0.0, abs=1e-6)
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)
+ _assert_never_persists_explained_share(pair)
assert pair.leftover_map_rank == 1
coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected)
assert coverage.map_post_count == 3
@@ -147,6 +149,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_unexplained_share == pytest.approx(0.0)
+ assert pairs[1].leftover_map_unexplained_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:
@@ -174,6 +178,7 @@ def test_rank_zero_nonzero_constant_residual_keeps_raw_identity() -> None:
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_share == pytest.approx(1.0)
assert pair.leftover_map_unexplained + pair.leftover_map_reconstruction == pytest.approx(
pair.leftover_residual
)
@@ -208,6 +213,7 @@ def test_partial_observation_does_not_treat_missing_as_zero_residual() -> None:
assert pair.leftover_map_rank == 1
assert pair.leftover_map_unexplained == pytest.approx(0.0, abs=1e-6)
assert pair.leftover_map_cross_share == pytest.approx(0.0, abs=1e-6)
+ assert pair.leftover_map_unexplained_share == pytest.approx(0.0, abs=1e-6)
coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected)
assert coverage.map_post_count == 2
assert coverage.scored_post_count == 3
@@ -281,6 +287,7 @@ def test_leftover_residual_rejects_database_tolerance_boundary() -> None:
None,
None,
None,
+ None,
)
@@ -333,7 +340,7 @@ def test_rank_one_nonzero_center_is_disclosed_by_raw_residual_cross_share() -> N
assert farthest.leftover_map_cross_share != pytest.approx(farthest.leftover_residual)
for pair in pairs:
_assert_residual_reconciles(pair)
- _assert_never_persists_hidden_shares(pair)
+ _assert_never_persists_explained_share(pair)
def test_rank_one_leftover_map_puts_all_inertia_on_axis_one() -> None:
@@ -490,6 +497,7 @@ def test_unexplained_and_cross_share_are_identity_remainder_terms() -> None:
explained_share = (recon * recon) / (residual * residual)
unexplained_share = (expected_unexplained * expected_unexplained) / (residual * residual)
assert pair.leftover_map_cross_share == pytest.approx(expected_share)
+ assert pair.leftover_map_unexplained_share == pytest.approx(unexplained_share)
assert explained_share + unexplained_share + expected_share == pytest.approx(1.0)
if abs(expected_share) > 1e-6:
saw_nonzero_cross = True
@@ -500,10 +508,20 @@ def test_unexplained_and_cross_share_are_identity_remainder_terms() -> None:
# Gabriel inner product.
assert pair.leftover_distance == pytest.approx(float(map_distances[person, item]))
assert pair.leftover_map_rank == rank
- _assert_never_persists_hidden_shares(pair)
+ _assert_never_persists_explained_share(pair)
assert saw_nonzero_cross
+def test_unexplained_share_stores_square_share_of_raw_residual() -> None:
+ """s = U² / R² is stored; a share greater than 1 is not clamped."""
+ assert leftover._leftover_map_unexplained_share(1.0, 2.0) == pytest.approx(1.0)
+ assert leftover._leftover_map_unexplained_share(1.0, 3.0) == pytest.approx(4.0)
+ assert leftover._leftover_map_unexplained_share(2.0, 2.0) == pytest.approx(0.0)
+ assert leftover._leftover_map_unexplained_share(0.0, 0.0) == pytest.approx(0.0)
+ assert leftover._leftover_map_unexplained_share(float("nan"), 1.0) is None
+ assert leftover._leftover_map_unexplained_share(1.0, float("inf")) is None
+
+
def test_cross_share_stores_negative_finite_identity_remainder() -> None:
"""A negative identity remainder is stored, never omitted or clamped."""
assert leftover._leftover_map_cross_share(1.0, 2.0) == pytest.approx(-4.0)
@@ -582,7 +600,7 @@ def test_leftover_map_rank_rejects_negative_rank() -> None:
with pytest.raises(ValueError, match="non-negative integer"):
leftover._pair_from_candidate(
PAIR_KIND_CLOSEST,
- (0.0, "public-post", "sales_lead_specificity", 0.0, 1.0, 1.0, None),
+ (0.0, "public-post", "sales_lead_specificity", 0.0, 1.0, 1.0, None, None, None, None),
-1,
)
@@ -596,6 +614,8 @@ def test_small_finite_residual_keeps_cross_share() -> None:
"""
share = leftover._leftover_map_cross_share(1e-7, 5e-8)
assert share == pytest.approx(0.5)
+ unexplained_share = leftover._leftover_map_unexplained_share(1e-7, 5e-8)
+ assert unexplained_share == pytest.approx(0.25)
def test_leftover_is_unavailable_without_a_complete_case_rectangle() -> None:
diff --git a/tests/test_period_report.py b/tests/test_period_report.py
index 16a42abab..3543177df 100644
--- a/tests/test_period_report.py
+++ b/tests/test_period_report.py
@@ -276,8 +276,10 @@ def test_calibrated_report_attaches_leftover_pairs() -> None:
assert np.isfinite(pair.leftover_map_cross_share)
if pair.leftover_map_reconstruction is not None:
assert np.isfinite(pair.leftover_map_reconstruction)
+ if pair.leftover_map_unexplained_share is not None:
+ assert np.isfinite(pair.leftover_map_unexplained_share)
+ assert pair.leftover_map_unexplained_share >= 0.0
assert not hasattr(pair, "leftover_map_explained_share")
- assert not hasattr(pair, "leftover_map_unexplained_share")
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 56d9b4928..b71765ca5 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -120,6 +120,11 @@
/ "migrations"
/ "0206_report_leftover_map_reconstruction.sql"
)
+_LEFTOVER_MAP_UNEXPLAINED_SHARE_MIGRATION = (
+ Path(__file__).resolve().parents[1]
+ / "migrations"
+ / "0233_report_leftover_map_unexplained_share.sql"
+)
_LEFTOVER_MAP_AXIS_MIGRATION = (
Path(__file__).resolve().parents[1]
/ "migrations"
@@ -204,6 +209,7 @@ def schema_db():
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(_LEFTOVER_MAP_UNEXPLAINED_SHARE_MIGRATION.read_text())
cur.execute(_SOURCE_EVENT_TIME_MIGRATION.read_text())
# psql sends each statement independently, which is required
# by CREATE INDEX CONCURRENTLY. psycopg2 treats a multi-
@@ -748,7 +754,7 @@ def test_leftover_pair_names_nullable_cross_share_column(schema_db) -> None:
assert columns["leftover_residual"] == "NO"
assert columns["leftover_distance"] == "NO"
assert "leftover_map_explained_share" not in columns
- assert "leftover_map_unexplained_share" not in columns
+ assert columns["leftover_map_unexplained_share"] == "YES"
assert columns["leftover_map_reconstruction"] == "YES"
with schema_db.cursor() as cur:
cur.execute(
diff --git a/uv.lock b/uv.lock
index fca25dd65..8320d33a7 100644
--- a/uv.lock
+++ b/uv.lock
@@ -685,7 +685,7 @@ wheels = [
[[package]]
name = "lineageweave"
-version = "2.20.0"
+version = "2.22.0"
source = { editable = "." }
dependencies = [
{ name = "certifi" },