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