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