diff --git a/AGENTS.md b/AGENTS.md index 6544da08d..d94161326 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -259,7 +259,7 @@ 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 / 0203) 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 @@ -267,13 +267,15 @@ persist to `report_leftover_pair` with signed residual `R`, observed `Y`, and expected `E[Y|θ, item]` so `R = Y − E` remains auditable, plus leftover-map rank so rank 0 is not read as structure, and unexplained leftover `U = R − R̂` next to leftover-map distance `d` -after two-axis Gabriel reconstruction. They sit above the member -list so a click opens that post with the leftover criterion current -in Post quality (ADR 0158). Two-axis reconstruction `R̂` is not -persisted. Leftover-map axis share (ADR 0148) is Gabriel inertia of -residual SVD axes 1 and 2 and persists to `report_leftover_map_axis`. -Rank-0 residuals emit two zero-share axes; the shares are report-level -and are not a leftover score. Complete-case coverage (ADR 0168) persists to +after two-axis Gabriel reconstruction, plus unexplained leftover share +`s = U_c² / R̃²` of centered leftover ([ADR 0203](docs/adr/0203-leftover-map-unexplained-share.md)). +Two-axis reconstruction `R̂` / `R̂_c` and centered leftover `U_c` are not +persisted. They sit above the member list so a click opens that post with +the leftover criterion current in Post quality (ADR 0158). Leftover-map +axis share (ADR 0148) is Gabriel inertia of residual SVD axes 1 and 2 and +persists to `report_leftover_map_axis`. Rank-0 residuals emit two +zero-share axes; the shares are report-level and are not a leftover score. +Complete-case coverage (ADR 0168) persists to `report_leftover_map_coverage` and captions the pair list with how many scored posts entered the map. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 46a8dd7e2..408f08e48 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -596,11 +596,13 @@ 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 / +0203) persist to `report_leftover_pair` with signed residual `R`, observed `Y`, expected -`E[Y|θ, item]`, full leftover-map rank, and unexplained leftover -`U = R − R̂` named on the pair row. Leftover-map axis share (Gabriel -inertia of residual SVD axes 1 and 2; ADR 0148) persists to +`E[Y|θ, item]`, full leftover-map rank, unexplained leftover +`U = R − R̂`, and unexplained leftover share `s = U_c² / R̃²` of +centered leftover named on the pair row. Leftover-map axis share +(Gabriel inertia of residual SVD axes 1 and 2; ADR 0148) persists to `report_leftover_map_axis`. Complete-case leftover-map coverage (ADR 0168) persists to `report_leftover_map_coverage` so readers see how many scored posts entered the factorization. Results persist to diff --git a/CHANGELOG.d/2.12.27-leftover-map-unexplained-share.md b/CHANGELOG.d/2.12.27-leftover-map-unexplained-share.md new file mode 100644 index 000000000..ff802d84e --- /dev/null +++ b/CHANGELOG.d/2.12.27-leftover-map-unexplained-share.md @@ -0,0 +1,9 @@ +## 2.12.27 — Leftover-map unexplained leftover share + +- Persist unexplained leftover share `s = U_c² / R̃²` of centered + leftover on leftover post–criterion pairs (ADR 0203). 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. Never + invent a leftover score. Do not persist leftover-map unexplained + leftover `U` or reconstruction `R̂`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 94e653987..581e98d25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,12 @@ All notable changes to this project are documented here. Format follows list is captioned “Leftover map used N of M scored posts (complete-case)”; incomplete rows stay excluded, never filled with zero. +- Period leftover pair rows now name unexplained leftover share + `s = U_c² / R̃²` of centered leftover next to leftover-map distance + `d`, then open that post (Gabriel, 1971; Jeon et al., 2021, eq. 3; + ADR 0202). A missing share omits the badge rather than inventing a + leftover score. Centered leftover `U_c` and two-axis reconstruction + `R̂_c` stay internal and are not persisted. - The grouping comparison strip now names leftover post–criterion pairs on each visible row (ADR 0149). After `make seed`, open a diff --git a/backend/app/report_ingestion.py b/backend/app/report_ingestion.py index ac65e6af2..42321e489 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_unexplained_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_unexplained_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_unexplained_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_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"]), "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..4ac6b8187 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_UNEXPLAINED_SHARE_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0202_report_leftover_map_unexplained_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_UNEXPLAINED_SHARE_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " @@ -5046,6 +5052,10 @@ def test_seed_period_report_surfaces_on_get_reports(client, demo_analyst_token, assert pair["leftover_map_rank"] >= 0 unexplained = pair.get("leftover_map_unexplained") assert unexplained is None or isinstance(unexplained, (int, float)) + share = pair.get("leftover_map_unexplained_share") + assert share is None or isinstance(share, (int, float)) + if share is not None: + assert share >= 0 assert "leftover_map_reconstruction" not in pair observed = pair.get("observed_response") expected = pair.get("expected_response") diff --git a/docs/adr/0003-fast-mlsirm-report-integration.md b/docs/adr/0003-fast-mlsirm-report-integration.md index ed4ca22c8..970efe306 100644 --- a/docs/adr/0003-fast-mlsirm-report-integration.md +++ b/docs/adr/0003-fast-mlsirm-report-integration.md @@ -100,13 +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 / 0202): after IRT main effects, persist closest and farthest post–criterion pairs from the residual leftover map, and name unexplained leftover - `U = R − R̂` when Gabriel coordinates exist so the leftover cell the - two-axis map does not reconstruct is not read as leftover residual - or leftover-map distance. Do not persist two-axis reconstruction - `R̂`. Do not fork LSIRM; do not invent a + `U = R − R̂` and unexplained leftover share `s = U_c² / R̃²` of + centered leftover when Gabriel coordinates exist so the leftover + cell the two-axis map does not reconstruct is not read as leftover + residual or leftover-map distance. Do not persist two-axis + reconstruction `R̂` / `R̂_c` or centered leftover `U_c`. Do not fork LSIRM; do not invent a leftover-pair API inside `fast-mlsirm` in this slice. 8. **Leftover-map axis-share slice** (ADR 0148): persist Gabriel inertia `σ_k² / Σ_j σ_j²` of leftover-map axes 1 and 2 on the same residual diff --git a/docs/adr/0048-persist-lsirm-leftover-pairs.md b/docs/adr/0048-persist-lsirm-leftover-pairs.md index 0bab2fc32..6a5a2044e 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 0202](0202-leftover-map-unexplained-share.md) (unexplained leftover share s) ## Context @@ -41,8 +42,10 @@ 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 unexplained leftover share +`s = U_c² / R̃²` of centered leftover the same way (ADR 0202). +Two-axis reconstruction `R̂` / `R̂_c` and centered leftover `U_c` are +computed internally and are 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..8960c85bb 100644 --- a/docs/adr/0049-leftover-pair-report-ui.md +++ b/docs/adr/0049-leftover-pair-report-ui.md @@ -6,7 +6,8 @@ [ADR 0163](0163-leftover-observed-expected.md) (observed Y and expected E); [ADR 0164](0164-leftover-map-rank.md) (full map rank); [ADR 0182](0182-leftover-map-unexplained.md) (unexplained leftover U); -[ADR 0158](0158-leftover-criterion-evaluation-landing.md) (criterion evaluation landing) +[ADR 0158](0158-leftover-criterion-evaluation-landing.md) (criterion evaluation landing); +[ADR 0203](0203-leftover-map-unexplained-share.md) (unexplained leftover share s) ## Context @@ -24,19 +25,23 @@ 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. +unexplained leftover `U` when finite, and unexplained leftover share +`s` of centered leftover after IRT main effects when Gabriel +coordinates exist. 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. +named criterion" when present. A missing unexplained leftover or a +missing share keeps the existing 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 naming is [ADR 0163](0163-leftover-observed-expected.md), rank naming is [ADR 0164](0164-leftover-map-rank.md), unexplained leftover naming -is [ADR 0182](0182-leftover-map-unexplained.md). +is [ADR 0182](0182-leftover-map-unexplained.md), and unexplained +leftover share naming is +[ADR 0203](0203-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/0203-leftover-map-unexplained-share.md b/docs/adr/0203-leftover-map-unexplained-share.md new file mode 100644 index 000000000..a0803b737 --- /dev/null +++ b/docs/adr/0203-leftover-map-unexplained-share.md @@ -0,0 +1,105 @@ +# ADR 0203 — Name unexplained 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 unexplained leftover +share `s = U_c² / R̃²` (`U_c = R̃ − R̂_c`) lets a buyer read leftover +residual `R` or leftover-map distance `d` as the leftover the two-axis +map does not reconstruct. Subtracting reconstruction from raw `R` +(or dividing by `R²`) leaves the grand mean inside the named leftover, +so a fully reconstructed rank-1 cell would look unexplained whenever +`center ≠ 0`. + +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 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. Centered leftover `U_c` and reconstruction `R̂_c` are +computed internally so `s` is honest, then discarded. + +The unprotected-stack reconstructions for neighbouring leftover facts +use 0162–0182. This increment originally claimed **0183**, but that +number collided with an unrelated, already-merged GNB navigation ADR +(`0183-gnb-four-korean-chrome.md`) by the time this branch reached +`main`, so it renumbers to **0203** at merge -- above every ADR number +in use on `main` (≤ 0201) so it cannot collide again. It does not +collide with 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_unexplained_share` — unexplained +leftover share `s = U_c² / R̃²` of centered leftover after two-axis +Gabriel reconstruction `R̂_c = ξ_{1:2} · ζ_{1:2}`. Migration `0202` +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. A leftover map with no +complete-case rectangle names no pair at all (ADR 0168), so there is +no row to fabricate a share on. A rank-0 origin map that still has a +complete-case rectangle stores `0.0` (`R̃ = 0` and `U_c = 0`), not a +missing value. A fully reconstructed rank-1 cell stores `0.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_unexplained_share_nonnegative_chk` rejects a stored +negative share. Do not persist `leftover_map_unexplained` or +`leftover_map_reconstruction`. + +The pair button shows `U²/R̃² {share}` next to leftover-map distance +`d` when the value is a finite non-negative number. Next action: +leftover map leaves unexplained share `s` 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_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. + +## 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, +and leftover-map unexplained leftover. + +## 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..7ccf9cd49 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_unexplained_share: 0.12, }, { pair_kind: "farthest", @@ -993,6 +994,7 @@ describe("App, authenticated", () => { observed_response: 0.9, expected_response: 2.0, leftover_map_rank: 1, + leftover_map_unexplained_share: 0.45, }, ], leftover_map_axes: [ @@ -3725,22 +3727,24 @@ 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.", + "Leftover map leaves unexplained share 0.12 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("U +0.05"); + expect(closestPair).toHaveTextContent("U²/R̃² 0.12"); 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.", + "Leftover map leaves unexplained share 0.45 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("U −0.25"); + expect(farthestPair).toHaveTextContent("U²/R̃² 0.45"); expect(farthestPair).toHaveTextContent("d 1.84"); const memberButton = screen.getByRole("button", { name: /open report post: public post/i }); expect(coverageCaption.compareDocumentPosition(closestPair) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index cb8bdd628..3439294dd 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_unexplained_share?: number | null; } export interface LeftoverMapAxis { diff --git a/frontend/src/components/LeftoverPairList.tsx b/frontend/src/components/LeftoverPairList.tsx index 92b8b9b1a..1eb959ae6 100644 --- a/frontend/src/components/LeftoverPairList.tsx +++ b/frontend/src/components/LeftoverPairList.tsx @@ -5,6 +5,10 @@ import { LEFTOVER_RANK_STRUCTURE_ACTION, LEFTOVER_RANK_ZERO_ACTION, } from "../leftoverMapRank"; +import { + formatLeftoverMapUnexplainedShare, + LEFTOVER_MAP_UNEXPLAINED_SHARE_ACTION, +} from "../leftoverMapUnexplainedShare"; import { formatLeftoverObservedExpected } from "../leftoverObservedExpected"; import { formatLeftoverResidual } from "../leftoverResidual"; import { @@ -24,10 +28,12 @@ export type LeftoverPairListProps = { * * 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. + * leftover share ``s = U_c² / R̃²`` of centered leftover (ADR 0203) takes + * priority over unexplained leftover ``U = R − R̂`` (ADR 0182), which in + * turn takes priority over the residual/observed-expected/rank next + * action, whenever finite; every badge still renders together before + * opening the named post. A missing share or unexplained value (no + * complete-case map) omits that badge rather than inventing one. */ export function LeftoverPairList({ pairs, @@ -49,9 +55,17 @@ export function LeftoverPairList({ pair.expected_response, ); const rankBadge = formatLeftoverMapRank(pair.leftover_map_rank); + const shareBadge = formatLeftoverMapUnexplainedShare( + pair.leftover_map_unexplained_share, + ); const unexplained = formatLeftoverMapUnexplained(pair.leftover_map_unexplained); let nextAction: string; - if (unexplained !== null) { + if (shareBadge !== null) { + nextAction = tf(LEFTOVER_MAP_UNEXPLAINED_SHARE_ACTION, { + value: Number(pair.leftover_map_unexplained_share).toFixed(2), + criterion, + }); + } else if (unexplained !== null) { const signedUnexplained = formatSignedLeftoverValue(pair.leftover_map_unexplained ?? Number.NaN) ?? "—"; nextAction = tf(LEFTOVER_MAP_UNEXPLAINED_ACTION, { @@ -121,6 +135,7 @@ export function LeftoverPairList({ {observedExpected ? {observedExpected} : null} {rankBadge ? {rankBadge} : null} {unexplained ? {unexplained} : null} + {shareBadge ? {shareBadge} : null} d {pair.leftover_distance.toFixed(2)} diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index 78b693313..4be291e03 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -53,11 +53,14 @@ describe("i18n", () => { "Open this post to read the criterion it sat closest to after main effects.", "Open this post to read the criterion it sat farthest from after main effects.", "Leftover map leaves unexplained U {value} after IRT main effects. Open this post to read {criterion}.", + "Leftover map leaves unexplained share {value} of centered leftover 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.", "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.", + "Read leftover map rank {rank}, observed Y {observed}, expected E {expected}, and unexplained share {value} after IRT main effects, then open this post.", + "Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y {observed}, expected E {expected}, and unexplained share {value}, then open this post.", "Showing the first {shown} of {total} posts known at this cutoff.", "Inspect ontology neighborhood", "Ontology neighborhood", @@ -134,6 +137,33 @@ describe("i18n", () => { expect(tf("{post} is current in Event Lineage. Read Keyman and evaluation next.", { post: "DEMO" })).toBe(expected); }); + it.each([ + [ + "ko", + "잔여 지도가 IRT 주효과 이후 중심화 잔여의 설명되지 않은 몫 0.12을(를) 남깁니다. sales-lead 기준을 읽으려면 이 글을 여세요.", + ], + [ + "zh", + "残差图在 IRT 主效应后留下中心化残差中未解释的份额 0.12。打开这篇帖子阅读 sales-lead。", + ], + [ + "ja", + "残差マップはIRT主効果後の中心化残差のうち未説明の割合 0.12 を残します。この投稿を開いて sales-lead を読んでください。", + ], + [ + "vi", + "Bản đồ phần dư để lại phần chưa giải thích 0.12 của 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 unexplained share next action in %s", (locale, expected) => { + setLocale(locale); + expect( + tf( + "Leftover map leaves unexplained share {value} of centered leftover after IRT main effects. Open this post to read {criterion}.", + { value: "0.12", criterion: "sales-lead" }, + ), + ).toBe(expected); + }); + it.each([ ["ko", "영역 위치"], ["zh", "区域位置"], @@ -227,6 +257,36 @@ describe("i18n", () => { ), ).toBe(expected); }); + + it.each([ + [ + "ko", + "IRT 주효과 이후 잔여 맵 랭크 1, 관측 Y 2.40, 기대 E 2.00, 설명되지 않은 몫 0.12를 읽은 다음, 이 글을 여세요.", + ], + [ + "zh", + "阅读 IRT 主效应后的残余图秩 1、观测 Y 2.40、期望 E 2.00 与未解释份额 0.12,然后打开这篇帖子。", + ], + [ + "ja", + "IRT主効果後の残差マップランク 1、観測 Y 2.40、期待 E 2.00、未説明の割合 0.12 を読んでから、この投稿を開いてください。", + ], + [ + "vi", + "Đọc hạng bản đồ phần dư 1, Y quan sát 2.40, E kỳ vọng 2.00, và phần chưa giải thích 0.12 sau hiệu ứng chính IRT, rồi mở bài viết này.", + ], + ] as const)( + "formats combined leftover evidence with unexplained share next action in %s", + (locale, expected) => { + setLocale(locale); + expect( + tf( + "Read leftover map rank {rank}, observed Y {observed}, expected E {expected}, and unexplained share {value} after IRT main effects, then open this post.", + { rank: "1", observed: "2.40", expected: "2.00", value: "0.12" }, + ), + ).toBe(expected); + }, + ); }); describe("locale-aware source labels", () => { diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 64d4385c8..310449f22 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -463,6 +463,8 @@ const TRANSLATIONS: Partial>> = { "주효과 이후 가장 멀리 앉은 기준을 읽으려면 이 글을 여세요.", "Leftover map leaves unexplained U {value} after IRT main effects. Open this post to read {criterion}.": "잔여 지도가 IRT 주효과 이후 설명되지 않은 U {value}을(를) 남깁니다. {criterion} 기준을 읽으려면 이 글을 여세요.", + "Leftover map leaves unexplained share {value} of centered leftover 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.": @@ -473,6 +475,10 @@ 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}를 읽은 다음, 이 글을 여세요.", + "Read leftover map rank {rank}, observed Y {observed}, expected E {expected}, and unexplained share {value} after IRT main effects, then open this post.": + "IRT 주효과 이후 잔여 맵 랭크 {rank}, 관측 Y {observed}, 기대 E {expected}, 설명되지 않은 몫 {value}를 읽은 다음, 이 글을 여세요.", + "Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y {observed}, expected E {expected}, and unexplained share {value}, then open this post.": + "IRT 주효과 이후 잔여 맵 랭크 0은 잔여 구조가 없음을 뜻합니다. 관측 Y {observed}, 기대 E {expected}, 설명되지 않은 몫 {value}를 읽은 다음, 이 글을 여세요.", }, zh: { "Unknown": "未知", @@ -917,6 +923,8 @@ const TRANSLATIONS: Partial>> = { "打开这篇帖子,阅读主效应后距离最远的准则。", "Leftover map leaves unexplained U {value} after IRT main effects. Open this post to read {criterion}.": "残差图在 IRT 主效应后留下未解释的 U {value}。打开这篇帖子阅读 {criterion}。", + "Leftover map leaves unexplained share {value} of centered leftover 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.": @@ -927,6 +935,10 @@ 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},然后打开这篇帖子。", + "Read leftover map rank {rank}, observed Y {observed}, expected E {expected}, and unexplained share {value} after IRT main effects, then open this post.": + "阅读 IRT 主效应后的残余图秩 {rank}、观测 Y {observed}、期望 E {expected} 与未解释份额 {value},然后打开这篇帖子。", + "Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y {observed}, expected E {expected}, and unexplained share {value}, then open this post.": + "残余图秩 0 表示 IRT 主效应后没有残余结构。阅读观测 Y {observed}、期望 E {expected} 与未解释份额 {value},然后打开这篇帖子。", }, ja: { "Unknown": "不明", @@ -1372,6 +1384,8 @@ const TRANSLATIONS: Partial>> = { "主効果後に最も遠くなった基準を読むには、この投稿を開いてください。", "Leftover map leaves unexplained U {value} after IRT main effects. Open this post to read {criterion}.": "残差マップはIRT主効果後の未説明 U {value} を残します。この投稿を開いて {criterion} を読んでください。", + "Leftover map leaves unexplained share {value} of centered leftover 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.": @@ -1382,6 +1396,10 @@ 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} を読んでから、この投稿を開いてください。", + "Read leftover map rank {rank}, observed Y {observed}, expected E {expected}, and unexplained share {value} after IRT main effects, then open this post.": + "IRT主効果後の残差マップランク {rank}、観測 Y {observed}、期待 E {expected}、未説明の割合 {value} を読んでから、この投稿を開いてください。", + "Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y {observed}, expected E {expected}, and unexplained share {value}, then open this post.": + "残差マップランク 0 は IRT 主効果後に残差構造がないことを示します。観測 Y {observed}、期待 E {expected}、未説明の割合 {value} を読んでから、この投稿を開いてください。", }, vi: { "Unknown": "Không rõ", @@ -1827,6 +1845,8 @@ const TRANSLATIONS: Partial>> = { "Mở bài viết này để đọc tiêu chí nằm xa nhất sau hiệu ứng chính.", "Leftover map leaves unexplained U {value} after IRT main effects. Open this post to read {criterion}.": "Bản đồ phần dư để lại U {value} chưa giải thích sau hiệu ứng chính IRT. Mở bài viết này để đọc {criterion}.", + "Leftover map leaves unexplained share {value} of centered leftover after IRT main effects. Open this post to read {criterion}.": + "Bản đồ phần dư để lại phần chưa giải thích {value} của phần dư đã căn giữa 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.": @@ -1837,6 +1857,10 @@ 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.", + "Read leftover map rank {rank}, observed Y {observed}, expected E {expected}, and unexplained share {value} after IRT main effects, then open this post.": + "Đọc hạng bản đồ phần dư {rank}, Y quan sát {observed}, E kỳ vọng {expected}, và phần chưa giải thích {value} 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}, expected E {expected}, and unexplained share {value}, 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}, E kỳ vọng {expected}, và phần chưa giải thích {value}, rồi mở bài viết này.", }, }; diff --git a/frontend/src/leftoverMapUnexplainedShare.test.ts b/frontend/src/leftoverMapUnexplainedShare.test.ts new file mode 100644 index 000000000..59f74afa7 --- /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 unexplained leftover share without inventing a leftover score", () => { + expect(formatLeftoverMapUnexplainedShare(0.12)).toBe("U\u00b2/R\u0303\u00b2 0.12"); + expect(formatLeftoverMapUnexplainedShare(0)).toBe("U\u00b2/R\u0303\u00b2 0.00"); + expect(formatLeftoverMapUnexplainedShare(1)).toBe("U\u00b2/R\u0303\u00b2 1.00"); + }); + + it("omits the badge when unexplained leftover share is missing, negative, or non-finite", () => { + expect(formatLeftoverMapUnexplainedShare(null)).toBeNull(); + expect(formatLeftoverMapUnexplainedShare(undefined)).toBeNull(); + expect(formatLeftoverMapUnexplainedShare(Number.NaN)).toBeNull(); + expect(formatLeftoverMapUnexplainedShare(Number.POSITIVE_INFINITY)).toBeNull(); + expect(formatLeftoverMapUnexplainedShare(-0.01)).toBeNull(); + }); +}); diff --git a/frontend/src/leftoverMapUnexplainedShare.ts b/frontend/src/leftoverMapUnexplainedShare.ts new file mode 100644 index 000000000..058ddb1cc --- /dev/null +++ b/frontend/src/leftoverMapUnexplainedShare.ts @@ -0,0 +1,13 @@ +/** Unexplained leftover share ``s = U_c² / R̃²`` of centered leftover. */ + +export const LEFTOVER_MAP_UNEXPLAINED_SHARE_ACTION = + "Leftover map leaves unexplained share {value} of centered leftover 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) || value < 0) { + return null; + } + return `U\u00b2/R\u0303\u00b2 ${value.toFixed(2)}`; +} diff --git a/lineageweave/leftover_pairs.py b/lineageweave/leftover_pairs.py index cb7ae265d..40e989d66 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 0168, +ADR 0182, and ADR 0203. Does not import ``fast_mlsirm`` or ``period_report``. A Gabriel biplot of the residual ``R = Y − E[Y|θ, item]`` supplies person and item @@ -11,7 +12,13 @@ on the two leftover-map axes (Jeon et al., 2021); unused axes pad with zero rather than inventing a second component, and hidden SVD axes after the second are dropped. Each pair also names the full leftover-map -rank so a rank-0 collapse is not read as leftover structure. Axis share +rank so a rank-0 collapse is not read as leftover structure, and +unexplained leftover share ``s = U_c² / R̃²`` of the *centered* leftover +the two-axis map factorizes, so that share is not confused with +leftover residual ``R``, leftover-map distance ``d``, or the uncentered +leftover ``U = R − ξ·ζ``. Reconstruction ``R̂_c`` and centered leftover +``U_c`` stay internal and are not persisted; a missing share (no +complete-case map) omits the badge rather than inventing one. 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̂`` @@ -47,6 +54,7 @@ class LeftoverPair: expected_response: float leftover_map_rank: int leftover_map_unexplained: float | None = None + leftover_map_unexplained_share: float | None = None @dataclass(frozen=True) @@ -87,9 +95,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; ``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 unexplained leftover + share ``s = U_c² / R̃²`` names the fraction of *centered* leftover + ``R̃ = R − center`` that two-axis reconstruction + ``R̂_c = ξ_{1:2} · ζ_{1:2}`` does not recover (``U_c = R̃ − R̂_c``). + ``R̂``, ``R̂_c``, and ``U_c`` stay internal and are never persisted. + Fallback pairs (no complete-case map) omit unexplained leftover and + the share rather than fabricating one. """ pairs, _axes = leftover_map_from_residual(post_ids, item_codes, matrix, expected) return pairs @@ -137,7 +149,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 +170,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 = _unexplained_leftover_share(filled, reconstruction) candidates.append( _candidate_row( post_ids, @@ -168,6 +184,7 @@ def leftover_map_from_residual( item, distance, unexplained, + share, ) ) if not candidates: @@ -194,6 +211,30 @@ def _unexplained_leftover(residual: float, reconstruction: float) -> float | Non return float(unexplained) +def _unexplained_leftover_share(filled: float, reconstruction: float) -> float | None: + """Return ``s = U_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 (or subtracting + reconstruction from ``R``) leaves the grand mean inside the named + leftover and makes a fully reconstructed rank-1 cell look + unexplained whenever ``center ≠ 0``. + """ + if not np.isfinite(filled) or not np.isfinite(reconstruction): + return None + unexplained = filled - reconstruction + if not np.isfinite(unexplained): + return None + filled_sq = float(filled * filled) + unexplained_sq = float(unexplained * unexplained) + if filled_sq > _LEFTOVER_SINGULAR_FLOOR: + return float(unexplained_sq / filled_sq) + if unexplained_sq <= _LEFTOVER_SINGULAR_FLOOR: + return 0.0 + return None + + def _candidate_row( post_ids: list[str], item_codes: tuple[str, ...], @@ -204,8 +245,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_unexplained_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, share.""" leftover_residual = float(residual[person, item]) observed_response = float(matrix[person, item]) expected_response = float(expected[person, item]) @@ -219,12 +261,13 @@ def _candidate_row( observed_response, expected_response, leftover_map_unexplained, + leftover_map_unexplained_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 +283,7 @@ def _pair_from_candidate( expected_response=row[5], leftover_map_rank=leftover_map_rank, leftover_map_unexplained=row[6], + leftover_map_unexplained_share=row[7], ) @@ -376,7 +420,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 + unexplained 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/0202_report_leftover_map_unexplained_share.sql b/migrations/0202_report_leftover_map_unexplained_share.sql new file mode 100644 index 000000000..47d65c2a1 --- /dev/null +++ b/migrations/0202_report_leftover_map_unexplained_share.sql @@ -0,0 +1,29 @@ +-- ADR 0202: persist unexplained leftover share s = U_c² / R̃² of the +-- centered leftover the two-axis leftover map factorizes +-- (R̃ = R − center, R̂_c = ξ_{1:2} · ζ_{1:2}, U_c = R̃ − R̂_c). +-- 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. + +alter table report_leftover_pair + add column if not exists leftover_map_unexplained_share numeric; + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'leftover_pair_unexplained_share_nonnegative_chk' + ) then + alter table report_leftover_pair + add constraint leftover_pair_unexplained_share_nonnegative_chk + check ( + leftover_map_unexplained_share is null + or leftover_map_unexplained_share >= 0 + ); + end if; +end +$$; diff --git a/migrations/rollback/0202_report_leftover_map_unexplained_share.sql b/migrations/rollback/0202_report_leftover_map_unexplained_share.sql new file mode 100644 index 000000000..49a4a1e41 --- /dev/null +++ b/migrations/rollback/0202_report_leftover_map_unexplained_share.sql @@ -0,0 +1,7 @@ +-- Reverse 0202. Leftover distance and residual stay on the pair row. + +alter table report_leftover_pair + drop constraint if exists leftover_pair_unexplained_share_nonnegative_chk; + +alter table report_leftover_pair + drop column if exists leftover_map_unexplained_share; diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index 1418d9af0..562c5b962 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 / "0202_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()) @@ -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_unexplained_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_unexplained_share, ), ) for axis in report.leftover_map_axes: diff --git a/tests/test_leftover_pairs.py b/tests/test_leftover_pairs.py index a411e1e13..b09c83349 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 0168, ADR 0182, and ADR 0203. Uses a constructed residual matrix so the closest and farthest pair are known without calling ``fit_polytomous``. Loads @@ -103,6 +103,8 @@ def test_leftover_residual_biplot_separates_aligned_and_opposed_cells() -> None: assert farthest.leftover_distance == pytest.approx(2.0 * np.sqrt(2.0), rel=1e-6) assert closest.leftover_map_unexplained == pytest.approx(0.0, abs=1e-6) assert farthest.leftover_map_unexplained == pytest.approx(0.0, abs=1e-6) + assert 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 not hasattr(closest, "leftover_map_reconstruction") for pair in pairs: _assert_residual_reconciles(pair) @@ -133,6 +135,8 @@ def test_zero_residual_still_emits_stable_leftover_pairs() -> None: assert pairs[1].criterion_code == "item_two" assert pairs[0].leftover_map_unexplained == pytest.approx(0.0) assert pairs[1].leftover_map_unexplained == 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) for pair in pairs: _assert_residual_reconciles(pair) assert pair.leftover_map_rank == 0 @@ -167,6 +171,7 @@ def test_partial_observation_does_not_treat_missing_as_zero_residual() -> None: ("opposed-post", "item_near"), } for pair in pairs: + assert pair.leftover_map_unexplained_share == pytest.approx(0.0, abs=1e-6) _assert_residual_reconciles(pair) assert pair.leftover_map_rank == 1 assert pair.leftover_map_unexplained == pytest.approx(0.0, abs=1e-6) @@ -195,6 +200,48 @@ def test_leftover_is_empty_without_observed_cells() -> None: assert coverage.incomplete_item_count == 0 +def test_leftover_pairs_unavailable_without_complete_case_map_omit_share() -> None: + """No complete-case rectangle (ADR 0168): no pair, so no unexplained-share badge either.""" + 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) + assert leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) == () + + +def test_rank_one_nonzero_center_still_has_zero_unexplained_share() -> None: + """Share uses centered leftover, so a reconstructed rank-1 cell stays s=0 when mean(R) ≠ 0. + + Uncentered unexplained ``U = R − R̂`` still equals the grand mean in + this same cell (ADR 0203 context): that is exactly why share, not U, + is the honest "fully reconstructed" signal when ``center ≠ 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] + for pair in pairs: + assert pair.leftover_map_unexplained_share == pytest.approx(0.0, abs=1e-6) + assert pair.leftover_residual != pytest.approx(0.0) + assert pair.leftover_map_unexplained == pytest.approx(3.0, abs=1e-6) + + 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 +288,65 @@ def test_leftover_residual_rejects_database_tolerance_boundary() -> None: 0, 0.0, None, + None, ) +def test_rank_three_unexplained_share_equals_centered_leftover_fraction() -> None: + """Unexplained leftover share is U_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, _singular = 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 + 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)} + for pair in pairs: + person = post_index[pair.post_id] + item = item_index[pair.criterion_code] + centered = float(pair.leftover_residual) - center + unexplained = centered - float(reconstruction[person, item]) + expected_share = (unexplained * unexplained) / (centered * centered) + assert pair.leftover_map_unexplained_share == pytest.approx(expected_share) + uncentered_u = float(pair.leftover_residual) - float(reconstruction[person, item]) + assert pair.leftover_map_unexplained_share != pytest.approx(uncentered_u) + assert pair.leftover_map_unexplained_share != pytest.approx(pair.leftover_residual) + assert pair.leftover_map_unexplained_share != pytest.approx(pair.leftover_distance) + assert pair.leftover_distance == pytest.approx(float(map_distances[person, item])) + assert pair.leftover_map_unexplained_share >= 0.0 + assert pair.leftover_map_unexplained == pytest.approx(uncentered_u) + 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_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"] diff --git a/tests/test_period_report.py b/tests/test_period_report.py index 95021ac4b..03188e48a 100644 --- a/tests/test_period_report.py +++ b/tests/test_period_report.py @@ -272,6 +272,9 @@ def test_calibrated_report_attaches_leftover_pairs() -> None: assert pair.leftover_map_rank >= 0 if pair.leftover_map_unexplained is not None: assert np.isfinite(pair.leftover_map_unexplained) + 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_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..5620d5e3a 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -68,6 +68,11 @@ / "migrations" / "0182_report_leftover_map_unexplained.sql" ) +_LEFTOVER_MAP_UNEXPLAINED_SHARE_MIGRATION = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0202_report_leftover_map_unexplained_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_UNEXPLAINED_SHARE_MIGRATION.read_text()) conn.commit() yield conn finally: @@ -257,6 +263,33 @@ def test_leftover_pair_names_nullable_unexplained_column(schema_db) -> None: assert "leftover_map_reconstruction" not in columns +def test_leftover_pair_names_nullable_unexplained_share_column(schema_db) -> None: + """Every install path preserves legacy pairs while naming unexplained 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_unexplained_share"] == "YES" + assert columns["leftover_residual"] == "NO" + assert columns["leftover_distance"] == "NO" + 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_unexplained_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: