diff --git a/AGENTS.md b/AGENTS.md index 1486c6ccf..e22c3f9ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -301,9 +301,12 @@ 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, ADR 0201 reconstruction evidence, leftover-map +cross share `x`, and leftover-map explained share `e = R̂² / R²` of +raw residual (ADR 0232). Unexplained leftover share `s` is not +persisted. ADR 0201 is the sole normative reconstruction formula, +storage, and audit contract; do not duplicate or reinterpret it here. +The pairs sit above the member list so a click opens that post with the leftover criterion current in Post quality (ADR 0158). 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 f02bf22a4..faf49a8e1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -621,9 +621,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 / 0232) 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 leftover-map explained share `e = R̂² / R²` of raw residual (ADR 0232). 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.19.0-leftover-map-explained-share.md b/CHANGELOG.d/2.19.0-leftover-map-explained-share.md new file mode 100644 index 000000000..b02fc8c8e --- /dev/null +++ b/CHANGELOG.d/2.19.0-leftover-map-explained-share.md @@ -0,0 +1,10 @@ +## 2.19.0 — Leftover-map explained share + +- Persist leftover-map explained share `e = R̂² / R²` of raw residual + on leftover post–criterion pairs (ADR 0232). After `make seed`, + closest and farthest leftover pairs sit above the member list with + `R̂²/R²` next to leftover-map distance `d`; click opens that post. + Omit the badge when the share is missing. A finite share greater than + 1 is shown, never clamped. Never invent a leftover score. Do not + introduce leftover-map unexplained share `s`; ADR 0185 remains + authoritative for `x` and ADR 0201 for `R̂`. diff --git a/CLAUDE.md b/CLAUDE.md index eb9e85eab..e612bb29f 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 / 0232), 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..9b2ad2b05 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_explained_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_explained_share, ) for axis in report.leftover_map_axes: await conn.execute( @@ -652,7 +653,8 @@ 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_explained_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 +811,11 @@ async def fetch_period_reports( if row["leftover_map_reconstruction"] is None else float(row["leftover_map_reconstruction"]) ), + "leftover_map_explained_share": ( + None + if row["leftover_map_explained_share"] is None + else float(row["leftover_map_explained_share"]) + ), "visibility_code": row["visibility_code"], "corporate_entity_id": str(row["corporate_entity_id"]), "process_unit_id": ( @@ -1010,7 +1017,8 @@ async def fetch_period_comparison( f""" select lp.grouping_kind, lp.grouping_key, lp.pair_kind, lp.post_id, lp.criterion_code, lp.leftover_distance, lp.leftover_residual, - lp.leftover_map_reconstruction, + lp.leftover_map_cross_share, lp.leftover_map_reconstruction, + lp.leftover_map_explained_share, p.post_title, p.visibility_code, p.corporate_entity_id, ({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context from report_leftover_pair lp @@ -1065,6 +1073,16 @@ async def fetch_period_comparison( if pair["leftover_map_reconstruction"] is None else float(pair["leftover_map_reconstruction"]) ), + "leftover_map_cross_share": ( + None + if pair["leftover_map_cross_share"] is None + else float(pair["leftover_map_cross_share"]) + ), + "leftover_map_explained_share": ( + None + if pair["leftover_map_explained_share"] is None + else float(pair["leftover_map_explained_share"]) + ), "visibility_code": pair["visibility_code"], "corporate_entity_id": str(pair["corporate_entity_id"]), "has_real_source_context": bool(pair["has_real_source_context"]), diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 75d75a7f0..5d095ea77 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -184,6 +184,11 @@ / "migrations" / "0206_report_leftover_map_reconstruction.sql" ) +_LEFTOVER_MAP_EXPLAINED_SHARE_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0236_report_leftover_map_explained_share.sql" +) _GLOBAL_ASK_JOB_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" @@ -458,6 +463,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_EXPLAINED_SHARE_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " @@ -6117,9 +6123,14 @@ 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) + explained = pair.get("leftover_map_explained_share") + assert "leftover_map_explained_share" in pair + assert explained is None or isinstance(explained, (int, float)) + if explained is not None: + assert not math.isnan(explained) + assert not math.isinf(explained) 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] @@ -6183,6 +6194,22 @@ 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_cross_share" in pair + and ( + pair["leftover_map_cross_share"] is None + or isinstance(pair["leftover_map_cross_share"], (int, float)) + ) + for pair in leftover_thread.get("leftover_pairs", []) + ) + assert all( + "leftover_map_explained_share" in pair + and ( + pair["leftover_map_explained_share"] is None + or isinstance(pair["leftover_map_explained_share"], (int, float)) + ) + for pair in leftover_thread.get("leftover_pairs", []) + ) def test_seed_period_report_includes_fixture_event_lineage_posts( diff --git a/docs/adr/0003-fast-mlsirm-report-integration.md b/docs/adr/0003-fast-mlsirm-report-integration.md index 11decffc0..944c396a1 100644 --- a/docs/adr/0003-fast-mlsirm-report-integration.md +++ b/docs/adr/0003-fast-mlsirm-report-integration.md @@ -108,9 +108,10 @@ than one large PR: public Rust-backed prediction API (upstream PR #1279); LineageWeave must not reproduce GRM/GPCM parameter conventions locally. 8. **Leftover evidence extensions:** unexplained leftover shipped in 2.12.26 - (ADR 0182), cross-share evidence shipped in 2.12.29 (ADR 0185), and - reconstruction evidence is Unreleased for 2.12.31 (ADR 0201). Do not - persist explained share, unexplained share, or another unsupported alias. + (ADR 0182), cross-share evidence shipped in 2.12.29 (ADR 0185), + reconstruction evidence shipped in 2.12.31 (ADR 0201), and leftover-map + explained share shipped in 2.19.0 (ADR 0232). Do not persist unexplained + leftover share `s` or another unsupported alias. 9. **Leftover-map axis-share slice** (ADR 0148): persist Gabriel inertia `σ_k² / Σ_j σ_j²` of leftover-map axes 1 and 2 on the same residual SVD. Rank-0 residuals emit two zero-share axes. Do not invent a diff --git a/docs/adr/0048-persist-lsirm-leftover-pairs.md b/docs/adr/0048-persist-lsirm-leftover-pairs.md index 62d2a5e15..050059922 100644 --- a/docs/adr/0048-persist-lsirm-leftover-pairs.md +++ b/docs/adr/0048-persist-lsirm-leftover-pairs.md @@ -51,7 +51,9 @@ 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 0232 persists leftover-map explained share +`e = R̂² / R²` of raw residual so `e + s + x = 1` is not read from `x` +alone; unexplained leftover share `s` is still 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 4be472ef9..f36e98e6a 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 0232](0232-leftover-map-explained-share.md) (leftover-map explained share) ## Context @@ -26,16 +27,17 @@ 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 -leftover-map cross share next to distance when finite. The next action names every available +unexplained leftover `U`, signed reconstruction `R̂` when finite, +leftover-map cross share next to distance when finite, and leftover-map +explained share `e = R̂² / R²` of raw residual 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 -IRT main effects. A missing or non-finite value falls back in order — -cross share, then reconstruction, then unexplained leftover, then the existing +post to read the named criterion" when present. When leftover-map +explained share is also present, the next action names how much of the +raw residual two leftover-map axes explain after IRT main effects. +A missing or non-finite value falls back in order — +explained share, then cross share, then reconstruction, then unexplained leftover, then the existing closest/farthest next action. Clicking the button opens that post with leftover focus so Post quality marks the named criterion current (ADR 0158). Residual naming is @@ -45,6 +47,7 @@ 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). +Explained-share naming is [ADR 0232](0232-leftover-map-explained-share.md). After `make seed`, closest and farthest leftover pairs sit above the member list. Click a pair to open that post with the leftover diff --git a/docs/adr/0185-leftover-map-cross-share.md b/docs/adr/0185-leftover-map-cross-share.md index 755523b43..3beb0bb5a 100644 --- a/docs/adr/0185-leftover-map-cross-share.md +++ b/docs/adr/0185-leftover-map-cross-share.md @@ -2,6 +2,7 @@ **Decision status:** Draft **Date:** 2026-08-24 +**Amended by:** [ADR 0232](0232-leftover-map-explained-share.md) (leftover-map explained share `e = R̂² / R²`) Amends [ADR 0048](0048-persist-lsirm-leftover-pairs.md) and [ADR 0049](0049-leftover-pair-report-ui.md). diff --git a/docs/adr/0201-leftover-map-reconstruction.md b/docs/adr/0201-leftover-map-reconstruction.md index 049208105..43eae9a7c 100644 --- a/docs/adr/0201-leftover-map-reconstruction.md +++ b/docs/adr/0201-leftover-map-reconstruction.md @@ -2,6 +2,7 @@ **Decision status:** Accepted **Date:** 2026-08-25 +**Amended by:** [ADR 0232](0232-leftover-map-explained-share.md) (leftover-map explained share `e = R̂² / R²`) Amends [ADR 0048](0048-persist-lsirm-leftover-pairs.md), [ADR 0049](0049-leftover-pair-report-ui.md), and diff --git a/docs/adr/0232-leftover-map-explained-share.md b/docs/adr/0232-leftover-map-explained-share.md new file mode 100644 index 000000000..42d5d82d7 --- /dev/null +++ b/docs/adr/0232-leftover-map-explained-share.md @@ -0,0 +1,105 @@ +# ADR 0232 — Name leftover-map explained share on period-report pair rows + +**Decision status:** Accepted +**Date:** 2026-08-27 + +Amends [ADR 0048](0048-persist-lsirm-leftover-pairs.md), +[ADR 0049](0049-leftover-pair-report-ui.md), +[ADR 0185](0185-leftover-map-cross-share.md), and +[ADR 0201](0201-leftover-map-reconstruction.md). Independent of leftover-map +unexplained leftover ([ADR 0182](0182-leftover-map-unexplained.md)). + +## Context + +ADR 0185 already persists leftover-map cross share +`x = 2 R̂ U / R²` of raw residual after two-axis Gabriel reconstruction +`R̂ = ξ_{1:2} · ζ_{1:2}` and unexplained leftover `U = R − R̂`. ADR 0201 +already persists signed reconstruction `R̂` so `U + R̂ = R` stays +auditable. The raw-residual cell identity +`R² = R̂² + U² + 2 R̂ U` therefore yields +`e + s + x = 1` with explained leftover share `e = R̂² / R²`, +unexplained leftover share `s = U² / R²`, and leftover-map cross +share `x`. Hiding `e` lets a buyer read `x` (or `R̂`) as the leftover +the two-axis map explains. `e` is nonnegative when finite. Truncated +two-axis reconstruction of a higher-rank cell can make `|R̂| > |R|`, so +a finite explained share may exceed 1; a unit CHECK would reject a +mathematically honest cell. + +This increment does not persist leftover-map unexplained leftover share +`s`, does not persist leftover-map coordinates, does not name leftover-map +inner product, cosine, or length, and does not land Post quality on the +leftover criterion. Leftover-map distance stays two-axis Euclidean. +Reconstruction `R̂` remains the ADR 0201 internal two-axis inner +product already used for `U` and `x`. + +The feature is stacked over the Rust-boundary work in ADR 0208. The decision +number remains 0232, while the schema increment uses migration **0236** so it +does not collide with the parent stack's migrations. It also does not collide +with leftover-map reconstruction (0201 / migration 0206), +leftover-map cross share (0185), leftover-map unexplained leftover +(0182), leftover-map unexplained leftover share, 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, or shared +token-backed status notice (0214 on an open stack). + +## Decision + +fast-mlsirm's Rust `residual_interaction_map` computes the reconstruction, +unexplained residual, cross share, and explained share in one result envelope. +LineageWeave only binds those returned cells to authorized product identifiers; +it never recalculates these quantities in Python. + +Each leftover pair names `leftover_map_explained_share` — leftover-map +explained share `e = R̂² / R²` of raw residual after two-axis Gabriel +reconstruction `R̂ = ξ_{1:2} · ζ_{1:2}`. Migration `0236` 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, cross share, and reconstruction 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̂ = 0`, not a missing value. A non-finite +share stores null rather than inventing a leftover score. A finite +share greater than 1 is stored; do not add a unit or nonnegative CHECK. +This increment does not introduce `leftover_map_unexplained_share`. + +The pair button shows `R̂²/R² {share}` next to leftover-map distance +`d` when the value is a finite number. Next action: leftover map +explains `{share}` of raw residual after IRT main effects; open this +post to read the named criterion. Explained leftover share takes +priority over leftover-map cross share when both are finite. A missing +or non-finite share omits the badge and keeps the existing cross-share +next action. Do not invent a leftover score. Do not invent a theta. + +## Consequences + +`GET /api/reports/{grouping}/{period}` returns +`leftover_map_explained_share`. After `make seed`, closest and farthest +leftover pairs sit above the member list with named `R̂²/R²` next to +`d`; click opens that post. Hidden posts stay hidden. When explained +share, unexplained leftover, reconstruction, and cross share are all +finite and `R ≠ 0`, `e + s + x = 1` with `s = U² / R²` computed only +for the audit identity, not persisted. + +## Related + +Independent of leftover interaction-map persistence, leftover-criterion +evaluation landing, leftover residual disclosure, leftover observed +`Y` / expected `E`, leftover-map complete-case coverage, leftover-map +axis share, leftover pairs on the grouping comparison strip, two-axis +leftover-map distance, leftover-map rank, leftover-map inner product, +leftover-map cosine, leftover-map length, leftover-map unexplained +share, leftover-map unexplained leftover, leftover-map reconstruction, +and leftover-map cross 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..bf3371abe 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.17.0", + "version": "2.19.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index a68b16078..e3b3b618e 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -993,6 +993,7 @@ describe("App, authenticated", () => { leftover_map_rank: 1, leftover_map_cross_share: 0.12, leftover_map_reconstruction: 0.35, + leftover_map_explained_share: 0.76, }, { pair_kind: "farthest", @@ -1007,6 +1008,7 @@ describe("App, authenticated", () => { leftover_map_rank: 1, leftover_map_cross_share: -0.24, leftover_map_reconstruction: -0.85, + leftover_map_explained_share: 0.60, }, ], leftover_map_axes: [ @@ -4032,10 +4034,10 @@ 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 explained share is present, so it names the next action + // ahead of cross share (ADR 0232). 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 explains 0.76 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"); @@ -4043,11 +4045,12 @@ describe("App, authenticated", () => { expect(closestPair).toHaveTextContent("U +0.05"); expect(closestPair).toHaveTextContent("2R̂U/R² 0.12"); expect(closestPair).toHaveTextContent("R̂ +0.35"); + expect(closestPair).toHaveTextContent("R̂²/R² 0.76"); 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 explains 0.60 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"); @@ -4055,6 +4058,7 @@ describe("App, authenticated", () => { expect(farthestPair).toHaveTextContent("U −0.25"); expect(farthestPair).toHaveTextContent("2R̂U/R² -0.24"); expect(farthestPair).toHaveTextContent("R̂ −0.85"); + expect(farthestPair).toHaveTextContent("R̂²/R² 0.60"); 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 656927dd7..b115aabae 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1191,6 +1191,7 @@ export interface LeftoverPair { leftover_map_unexplained?: number | null; leftover_map_cross_share?: number | null; leftover_map_reconstruction?: number | null; + leftover_map_explained_share?: number | null; } export interface LeftoverMapAxis { diff --git a/frontend/src/components/LeftoverPairList.stories.tsx b/frontend/src/components/LeftoverPairList.stories.tsx index 637351074..7e28e4373 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_explained_share: 0.76, }, { 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_explained_share: 0.60, }, ], }, diff --git a/frontend/src/components/LeftoverPairList.test.tsx b/frontend/src/components/LeftoverPairList.test.tsx index 36c15715a..4ddc0cd01 100644 --- a/frontend/src/components/LeftoverPairList.test.tsx +++ b/frontend/src/components/LeftoverPairList.test.tsx @@ -150,6 +150,56 @@ describe("LeftoverPairList", () => { expect(closest).toHaveTextContent("d 0.12"); }); + it("names leftover-map explained share ahead of cross share and reconstruction", () => { + render( + , + ); + + const closest = screen.getByRole("button"); + expect(closest).toHaveTextContent( + "Leftover map explains 0.76 of raw residual after IRT main effects. Open this post to read sales-lead.", + ); + expect(closest).toHaveTextContent("R̂²/R² 0.76"); + 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 cross-share guidance when explained share is missing", () => { + render( + , + ); + + expect(screen.getByRole("button")).toHaveTextContent( + "Two leftover-map axes leave identity remainder 0.12 of raw residual after IRT main effects. Open this post to read sales-lead.", + ); + }); + it("keeps unexplained guidance when reconstruction is missing", () => { render( {unexplained} : null} {crossShareBadge ? {crossShareBadge} : null} {reconstruction ? {reconstruction} : null} + {explainedShareBadge ? {explainedShareBadge} : null} d {pair.leftover_distance.toFixed(2)} diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index b0d7dac8c..05b935388 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -56,6 +56,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 explains {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.", @@ -311,6 +312,33 @@ describe("i18n", () => { ), ).toBe(expected); }); + + it.each([ + [ + "ko", + "잔여 지도가 IRT 주효과 이후 원잔차의 0.76를 설명합니다. sales-lead 기준을 읽으려면 이 글을 여세요.", + ], + [ + "zh", + "残差图解释 IRT 主效应后原始残差的 0.76。打开这篇帖子阅读 sales-lead。", + ], + [ + "ja", + "残差マップはIRT主効果後の生の残差の 0.76 を説明します。この投稿を開いて sales-lead を読んでください。", + ], + [ + "vi", + "Bản đồ phần dư giải thích 0.76 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 explained share next action in %s", (locale, expected) => { + setLocale(locale); + expect( + tf( + "Leftover map explains {value} of raw residual after IRT main effects. Open this post to read {criterion}.", + { value: "0.76", criterion: "sales-lead" }, + ), + ).toBe(expected); + }); }); describe("locale-aware source labels", () => { diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index fd050c3df..13bd81793 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -566,6 +566,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 explains {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.": @@ -1117,6 +1119,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 explains {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.": @@ -1671,6 +1675,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 explains {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.": @@ -2225,6 +2231,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 explains {value} of raw residual after IRT main effects. Open this post to read {criterion}.": + "Bản đồ phần dư 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/leftoverMapExplainedShare.test.ts b/frontend/src/leftoverMapExplainedShare.test.ts new file mode 100644 index 000000000..a1cec6589 --- /dev/null +++ b/frontend/src/leftoverMapExplainedShare.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { formatLeftoverMapExplainedShare } from "./leftoverMapExplainedShare"; + +describe("formatLeftoverMapExplainedShare", () => { + it("names leftover-map explained share without inventing a leftover score", () => { + expect(formatLeftoverMapExplainedShare(0.76)).toBe("R\u0302\u00b2/R\u00b2 0.76"); + expect(formatLeftoverMapExplainedShare(0)).toBe("R\u0302\u00b2/R\u00b2 0.00"); + expect(formatLeftoverMapExplainedShare(1.25)).toBe("R\u0302\u00b2/R\u00b2 1.25"); + }); + + it("omits the badge when leftover-map explained share is missing or non-finite", () => { + expect(formatLeftoverMapExplainedShare(null)).toBeNull(); + expect(formatLeftoverMapExplainedShare(undefined)).toBeNull(); + expect(formatLeftoverMapExplainedShare(Number.NaN)).toBeNull(); + expect(formatLeftoverMapExplainedShare(Number.POSITIVE_INFINITY)).toBeNull(); + expect(formatLeftoverMapExplainedShare(Number.NEGATIVE_INFINITY)).toBeNull(); + }); +}); diff --git a/frontend/src/leftoverMapExplainedShare.ts b/frontend/src/leftoverMapExplainedShare.ts new file mode 100644 index 000000000..e6b4dca15 --- /dev/null +++ b/frontend/src/leftoverMapExplainedShare.ts @@ -0,0 +1,13 @@ +/** Leftover-map explained share ``e = R̂² / R²`` of raw residual. */ + +export const LEFTOVER_MAP_EXPLAINED_SHARE_ACTION = + "Leftover map explains {value} of raw residual after IRT main effects. Open this post to read {criterion}."; + +export function formatLeftoverMapExplainedShare( + value: number | null | undefined, +): string | null { + if (value == null || !Number.isFinite(value)) { + return null; + } + return `R\u0302\u00b2/R\u00b2 ${value.toFixed(2)}`; +} diff --git a/lineageweave/leftover_pairs.py b/lineageweave/leftover_pairs.py index 1254b1139..bf93325bb 100644 --- a/lineageweave/leftover_pairs.py +++ b/lineageweave/leftover_pairs.py @@ -32,6 +32,7 @@ class LeftoverPair: leftover_map_unexplained: float | None = None leftover_map_cross_share: float | None = None leftover_map_reconstruction: float | None = None + leftover_map_explained_share: float | None = None @dataclass(frozen=True) @@ -97,13 +98,27 @@ def leftover_map_from_residual( return (), () rank = int(len(result.singular_values)) - candidates: list[tuple[float, str, str, float, float, float, float, float | None, float]] = [] + candidates: list[ + tuple[ + float, + str, + str, + float, + float, + float, + float, + float | None, + float, + float | None, + ] + ] = [] for local_person, person in enumerate(result.person_indices): for local_item, item in enumerate(result.item_indices): observed = float(matrix[int(person), int(item)]) model_expected = float(expected[int(person), int(item)]) residual = float(result.residual[local_person, local_item]) cross_share = float(result.cross_share[local_person, local_item]) + explained_share = float(result.explained_share[local_person, local_item]) candidates.append( ( float(result.distance[local_person, local_item]), @@ -115,6 +130,7 @@ def leftover_map_from_residual( float(result.unexplained[local_person, local_item]), cross_share if np.isfinite(cross_share) else None, float(result.reconstruction[local_person, local_item]), + explained_share if np.isfinite(explained_share) else None, ) ) closest = min(candidates, key=lambda row: (row[0], row[1], row[2])) @@ -165,7 +181,9 @@ def _validate_shapes( def _pair( kind: str, - row: tuple[float, str, str, float, float, float, float, float | None, float], + row: tuple[ + float, str, str, float, float, float, float, float | None, float, float | None + ], rank: int, ) -> LeftoverPair: """Attach product identifiers to one Rust-computed candidate cell.""" @@ -182,4 +200,5 @@ def _pair( leftover_map_unexplained=row[6], leftover_map_cross_share=row[7], leftover_map_reconstruction=row[8], + leftover_map_explained_share=row[9], ) diff --git a/migrations/0236_report_leftover_map_explained_share.sql b/migrations/0236_report_leftover_map_explained_share.sql new file mode 100644 index 000000000..a4be59896 --- /dev/null +++ b/migrations/0236_report_leftover_map_explained_share.sql @@ -0,0 +1,8 @@ +-- ADR 0232: persist leftover-map explained share e = Rhat^2 / R^2 of raw +-- residual after the Rust-owned two-axis leftover-map reconstruction. +-- Distance stays Euclidean leftover-map d. Upgrade column is nullable so +-- older rows retain their evidence without fabricating a share. Do not add a +-- unit or nonnegative CHECK: truncated reconstruction can make |Rhat| > |R|. + +alter table report_leftover_pair + add column if not exists leftover_map_explained_share numeric; diff --git a/migrations/rollback/0236_report_leftover_map_explained_share.sql b/migrations/rollback/0236_report_leftover_map_explained_share.sql new file mode 100644 index 000000000..0c0bae39c --- /dev/null +++ b/migrations/rollback/0236_report_leftover_map_explained_share.sql @@ -0,0 +1,4 @@ +-- Reverse migration 0236. Other leftover-map evidence remains available. + +alter table report_leftover_pair + drop column if exists leftover_map_explained_share; diff --git a/pyproject.toml b/pyproject.toml index 443b97079..117082808 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.18.0" +version = "2.19.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = "MIT" @@ -56,7 +56,7 @@ backend = [ # Owner arithmetic from protected main (ADR 0208). Product-specific lineage # contracts remain unavailable until a domain-neutral owner contract lands. # PyO3/maturin source builds need the pinned Rust toolchain. - "fast-mlsirm @ git+https://github.com/ContextualWisdomLab/fast-mlsirm.git@09f762ded35786dd1078222a4577ff09d649816f", + "fast-mlsirm @ git+https://github.com/ContextualWisdomLab/fast-mlsirm.git@5c7dc9ead1feddcaff0d6c9032fcace4c7028f7c", ] [tool.setuptools.packages.find] diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index ebdcb37f2..3e807fca2 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -129,6 +129,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 / "0236_report_leftover_map_explained_share.sql").read_text()) cur.execute((migrations / "0060_role_responsibility_agent_type.sql").read_text()) cur.execute((migrations / "0013_person_job_title.sql").read_text()) cur.execute((migrations / "0014_role_responsibility_team_actor_type.sql").read_text()) @@ -1402,8 +1403,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_explained_share" + ") values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", ( grouping_kind, grouping_key, @@ -1420,6 +1421,7 @@ def _persist_seed_period_report( pair.leftover_map_unexplained, pair.leftover_map_cross_share, pair.leftover_map_reconstruction, + pair.leftover_map_explained_share, ), ) for axis in report.leftover_map_axes: diff --git a/tests/test_leftover_pairs.py b/tests/test_leftover_pairs.py index dfd0eec61..a79701711 100644 --- a/tests/test_leftover_pairs.py +++ b/tests/test_leftover_pairs.py @@ -3,6 +3,7 @@ from __future__ import annotations import inspect +from types import SimpleNamespace import numpy as np import pytest @@ -37,6 +38,39 @@ def test_rust_map_projects_pairs_axes_and_coverage() -> None: ) assert pair.leftover_map_unexplained is not None assert pair.leftover_map_reconstruction is not None + assert pair.leftover_map_explained_share is not None + + +def test_explained_share_is_projected_from_rust_without_recomputation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An upstream value is preserved even when local inputs imply another value.""" + + native_result = SimpleNamespace( + person_indices=np.array([0]), + item_indices=np.array([0]), + scored_person_count=1, + scored_item_count=1, + person_coordinates=np.zeros((1, 2)), + item_coordinates=np.zeros((1, 2)), + singular_values=np.array([1.0]), + axis_shares=np.array([1.0, 0.0]), + residual=np.array([[4.0]]), + distance=np.array([[0.0]]), + reconstruction=np.array([[2.0]]), + explained_share=np.array([[0.375]]), + unexplained=np.array([[2.0]]), + cross_share=np.array([[0.5]]), + ) + monkeypatch.setattr( + leftover, "residual_interaction_map", lambda *_args, **_kwargs: native_result + ) + + pairs, _axes = leftover.leftover_map_from_residual( + ["post-a"], ("item-a",), np.array([[9.0]]), np.array([[1.0]]) + ) + + assert [pair.leftover_map_explained_share for pair in pairs] == [0.375, 0.375] def test_rank_zero_keeps_deterministic_pairs_without_inventing_an_axis() -> None: diff --git a/tests/test_migration_replay.py b/tests/test_migration_replay.py index 5348e08b9..f7a2d0eeb 100644 --- a/tests/test_migration_replay.py +++ b/tests/test_migration_replay.py @@ -108,6 +108,29 @@ def test_migrate_sh_replays_leftover_map_axis_migration_on_existing_volumes() -> assert int(migration_name[:4]) >= 12 +def test_migrate_sh_replays_leftover_map_explained_share_on_existing_volumes() -> None: + """migrate.sh's replay window must cover 0232 (leftover-map explained share). + + Volumes created before leftover-map explained share shipped never get + leftover_map_explained_share unless migrate.sh replays 0236 on every + ``docker compose up``. GET /api/reports/{grouping}/{period} then 500s + on undefined_column the first time a period actually has leftover pairs. + + ADR 0166's general four-digit filename boundary covers 0232 without a + per-migration allowlist entry. The column add is nullable and + idempotent so a second start does not invent a leftover score. + """ + migration_name = "0236_report_leftover_map_explained_share.sql" + migration_path = Path(__file__).resolve().parents[1] / "migrations" / migration_name + assert migration_path.exists() + assert re.fullmatch(r"[0-9]{4}_.+\.sql", migration_name) + assert int(migration_name[:4]) >= 12 + sql = migration_path.read_text(encoding="utf-8").casefold() + assert "add column if not exists leftover_map_explained_share" in sql + assert "add column if not exists leftover_map_unexplained_share" not in sql + assert "check (" not in sql + + def test_tenant_settings_migration_is_safe_to_replay() -> None: """The newest migration must survive migrate.sh's every-start replay.""" sql = ( diff --git a/tests/test_period_report.py b/tests/test_period_report.py index 16a42abab..fdb482b9d 100644 --- a/tests/test_period_report.py +++ b/tests/test_period_report.py @@ -276,7 +276,9 @@ 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) - assert not hasattr(pair, "leftover_map_explained_share") + if pair.leftover_map_explained_share is not None: + assert np.isfinite(pair.leftover_map_explained_share) + assert 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: diff --git a/tests/test_schema.py b/tests/test_schema.py index c22be27cb..bc840f244 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -97,6 +97,11 @@ / "migrations" / "0206_report_leftover_map_reconstruction.sql" ) +_LEFTOVER_MAP_EXPLAINED_SHARE_MIGRATION = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0236_report_leftover_map_explained_share.sql" +) _LEFTOVER_MAP_AXIS_MIGRATION = ( Path(__file__).resolve().parents[1] / "migrations" @@ -239,6 +244,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_EXPLAINED_SHARE_MIGRATION.read_text()) cur.execute(_OPERATIONS_CASE_MIGRATION.read_text()) cur.execute(_OPERATIONS_CASE_EVIDENCE_MIGRATION.read_text()) cur.execute(_OPERATIONS_CASE_MISSING_MIGRATION.read_text()) @@ -1006,7 +1012,7 @@ def test_leftover_pair_names_nullable_cross_share_column(schema_db) -> None: assert columns["leftover_map_cross_share"] == "YES" assert columns["leftover_residual"] == "NO" assert columns["leftover_distance"] == "NO" - assert "leftover_map_explained_share" not in columns + assert columns["leftover_map_explained_share"] == "YES" assert "leftover_map_unexplained_share" not in columns assert columns["leftover_map_reconstruction"] == "YES" with schema_db.cursor() as cur: @@ -1021,6 +1027,34 @@ def test_leftover_pair_names_nullable_cross_share_column(schema_db) -> None: assert cur.fetchall() == [] +def test_leftover_pair_names_nullable_explained_share_column(schema_db) -> None: + """Every install path preserves legacy pairs while naming leftover-map explained share.""" + with schema_db.cursor() as cur: + cur.execute( + """ + select column_name, is_nullable + from information_schema.columns + where table_name = 'report_leftover_pair' + """ + ) + columns = dict(cur.fetchall()) + assert columns["leftover_map_explained_share"] == "YES" + assert columns["leftover_residual"] == "NO" + assert columns["leftover_distance"] == "NO" + assert "leftover_map_unexplained_share" not in columns + assert columns["leftover_map_reconstruction"] == "YES" + with schema_db.cursor() as cur: + cur.execute( + """ + select conname + from pg_constraint + where conrelid = 'report_leftover_pair'::regclass + and conname like '%explained_share%chk%' + """ + ) + assert cur.fetchall() == [] + + 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: diff --git a/uv.lock b/uv.lock index d87981a3b..b4acf9668 100644 --- a/uv.lock +++ b/uv.lock @@ -484,7 +484,7 @@ wheels = [ [[package]] name = "fast-mlsirm" version = "0.9.1" -source = { git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=09f762ded35786dd1078222a4577ff09d649816f#09f762ded35786dd1078222a4577ff09d649816f" } +source = { git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=5c7dc9ead1feddcaff0d6c9032fcace4c7028f7c#5c7dc9ead1feddcaff0d6c9032fcace4c7028f7c" } dependencies = [ { name = "numpy" }, ] @@ -657,7 +657,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.18.0" +version = "2.19.0" source = { editable = "." } dependencies = [ { name = "certifi" }, @@ -696,7 +696,7 @@ requires-dist = [ { name = "certifi", specifier = ">=2024.0.0" }, { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6" }, { name = "cryptography", specifier = ">=42.0" }, - { name = "fast-mlsirm", marker = "extra == 'backend'", git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=09f762ded35786dd1078222a4577ff09d649816f" }, + { name = "fast-mlsirm", marker = "extra == 'backend'", git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=5c7dc9ead1feddcaff0d6c9032fcace4c7028f7c" }, { name = "fastapi", marker = "extra == 'backend'", specifier = ">=0.141.1" }, { name = "httpx2", marker = "extra == 'dev'", specifier = ">=2.12.0" }, { name = "mcp", marker = "extra == 'backend'", specifier = "==2.0.0" },