From ac5de72a805025290d39b296b13e53e59f89cf1e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 05:20:24 +0000 Subject: [PATCH 1/2] feat: name leftover-map reconstruction on leftover pairs (v2.12.30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persist two-axis Gabriel reconstruction R̂ of centered leftover on closest and farthest leftover pairs so buyers can read reconstructed leftover next to distance d, then open that post. --- AGENTS.md | 7 +- ARCHITECTURE.md | 10 +- .../2.12.30-leftover-map-reconstruction.md | 8 ++ CHANGELOG.md | 10 ++ CLAUDE.md | 5 +- backend/app/report_ingestion.py | 14 ++- backend/tests/test_api.py | 10 ++ .../0003-fast-mlsirm-report-integration.md | 9 +- docs/adr/0048-persist-lsirm-leftover-pairs.md | 9 +- docs/adr/0049-leftover-pair-report-ui.md | 9 +- docs/adr/0186-leftover-map-reconstruction.md | 92 +++++++++++++++++++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 8 +- frontend/src/api.ts | 1 + .../components/LeftoverPairList.stories.tsx | 2 + .../src/components/LeftoverPairList.test.tsx | 23 +++-- frontend/src/components/LeftoverPairList.tsx | 20 +++- frontend/src/i18n.test.ts | 16 ++++ frontend/src/i18n.ts | 8 ++ .../src/leftoverMapReconstruction.test.ts | 17 ++++ frontend/src/leftoverMapReconstruction.ts | 20 ++++ lineageweave/leftover_pairs.py | 64 +++++++++++-- ...186_report_leftover_map_reconstruction.sql | 9 ++ ...186_report_leftover_map_reconstruction.sql | 4 + pyproject.toml | 2 +- scripts/seed_demo_data.py | 6 +- tests/test_leftover_pairs.py | 45 ++++++++- tests/test_period_report.py | 3 + tests/test_schema.py | 15 +++ uv.lock | 2 +- 30 files changed, 397 insertions(+), 53 deletions(-) create mode 100644 CHANGELOG.d/2.12.30-leftover-map-reconstruction.md create mode 100644 docs/adr/0186-leftover-map-reconstruction.md create mode 100644 frontend/src/leftoverMapReconstruction.test.ts create mode 100644 frontend/src/leftoverMapReconstruction.ts create mode 100644 migrations/0186_report_leftover_map_reconstruction.sql create mode 100644 migrations/rollback/0186_report_leftover_map_reconstruction.sql diff --git a/AGENTS.md b/AGENTS.md index b34194c8d..1f407e310 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -192,15 +192,16 @@ in the same spirit) -- never against real data, per the hard rule above. against a live local stack (`make up`) and self-skip without one -- see [README.md](README.md#local-product-stack-docker-compose). -Period leftover pairs (ADR 0048 / 0049 / 0119 / 0162 / 0163 / 0164) are computed in +Period leftover pairs (ADR 0048 / 0049 / 0119 / 0162 / 0163 / 0164 / 0186) 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. They sit -above the member list so a click opens that post. +plus leftover-map rank so rank 0 is not read as structure, plus +two-axis leftover-map reconstruction `R̂` of centered leftover. +They sit above the member list so a click opens that post. `frontend/` has its own toolchain (Node pinned via `frontend/mise.toml`, pnpm via Corepack -- do not add a second Node package manager or a diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fe6a94941..c34f6ce9a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -594,9 +594,10 @@ on those same fixed parameters (Kim, 2006 FIPC). After scoring, information at the group's mean θ (Lord, 1980 max-info CAT). Rankings persist to `report_item_information`. After those IRT main effects, residual SVD leftover pairs on two Gabriel axes (Jeon et al., 2021; -ADR 0048 / 0119 / 0162 / 0163 / 0164) persist to `report_leftover_pair` with -signed residual `R`, observed `Y`, expected `E[Y|θ, item]`, and full -leftover-map rank. Results persist to +ADR 0048 / 0119 / 0162 / 0163 / 0164 / 0186) persist to `report_leftover_pair` with +signed residual `R`, observed `Y`, expected `E[Y|θ, item]`, full +leftover-map rank, and two-axis leftover-map reconstruction `R̂`. +Results persist to `report_period_score` / `report_member_score`. `GET /api/reports/{grouping}` lists the trend; `GET /api/reports/{grouping}/{period}` is ABAC-filtered; @@ -609,7 +610,8 @@ through opens those DAG posts. Report members include the earliest open ticket title, status lookup label, and due date when one exists. The home page renders the actual mean θ, the FIPC delta, the CAT-selected item, leftover closest/farthest pairs (signed residual `R`, observed `Y`, expected -`E`, full rank, and two-axis leftover-map distance `d` after IRT main +`E`, full rank, two-axis leftover-map reconstruction `R̂`, and +two-axis leftover-map distance `d` after IRT main effects) above the member list, and the PU / corp / thread comparison -- never a placeholder. TEPP is unchanged. diff --git a/CHANGELOG.d/2.12.30-leftover-map-reconstruction.md b/CHANGELOG.d/2.12.30-leftover-map-reconstruction.md new file mode 100644 index 000000000..f4280e5f5 --- /dev/null +++ b/CHANGELOG.d/2.12.30-leftover-map-reconstruction.md @@ -0,0 +1,8 @@ +## 2.12.30 — Leftover-map reconstruction + +- Persist two-axis leftover-map reconstruction `R̂_c = ξ_{1:2} · ζ_{1:2}` + of centered leftover on leftover post–criterion pairs (ADR 0186). + After `make seed`, closest and farthest leftover pairs sit above the + member list with `R̂ {value}` next to leftover-map distance `d`; click + opens that post. Fallback pairs omit reconstruction. Rank-0 origin + maps store `R̂ = 0`. Never invent a leftover score or a theta. diff --git a/CHANGELOG.md b/CHANGELOG.md index a46223352..900da4504 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,16 @@ All notable changes to this project are documented here. Format follows environment, so local OIDC and synthetic-data workflows resolve the same pinned dependencies as CI. +## [2.12.30] - 2026-08-24 + +### Added + +- Period leftover pair rows now name two-axis leftover-map reconstruction + `R̂` of centered leftover next to leftover-map distance `d`, then open + that post (Gabriel, 1971; Jeon et al., 2021, eq. 3; ADR 0186). Fallback + pairs omit the reconstruction rather than inventing one. Rank-0 origin + maps store `R̂ = 0`. Never invent a leftover score or a theta. + ## [2.12.18] - 2026-08-24 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 42b091e42..5807ca417 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,8 +55,9 @@ mean θ. The period-report panel says Demo Corp is the opened grouping and to read its mean θ and member posts, then open a post. Those members land immediately under that next action, ahead of Other Corp and the week strip. After `make seed`, leftover closest/farthest pairs -sit above the member list with leftover-map rank; rank 0 names no -leftover structure. Opening Public post names the next action: read +sit above the member list with leftover-map reconstruction `R̂` next +to leftover-map distance `d`; rank 0 names no leftover structure. +Opening Public post names the next action: read Event Lineage, Keyman, and evaluation on that post. The popup Event Lineage DAG marks that post current. After that current node, the popup names Keyman and evaluation as the next read. After landed diff --git a/backend/app/report_ingestion.py b/backend/app/report_ingestion.py index b714f3667..b3059bcc5 100644 --- a/backend/app/report_ingestion.py +++ b/backend/app/report_ingestion.py @@ -444,8 +444,9 @@ async def persist_period_report( insert into report_leftover_pair ( grouping_kind, grouping_key, period_code, rubric_version, pair_kind, post_id, criterion_code, leftover_distance, leftover_residual, - observed_response, expected_response, leftover_map_rank - ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) + observed_response, expected_response, leftover_map_rank, + leftover_map_reconstruction + ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) """, grouping_kind, grouping_key, @@ -459,6 +460,7 @@ async def persist_period_report( pair.observed_response, pair.expected_response, pair.leftover_map_rank, + pair.leftover_map_reconstruction, ) @@ -606,7 +608,8 @@ async def fetch_period_reports( f""" select lp.grouping_key, lp.pair_kind, lp.post_id, lp.criterion_code, lp.leftover_distance, lp.leftover_residual, - lp.observed_response, lp.expected_response, lp.leftover_map_rank, p.post_title, + lp.observed_response, lp.expected_response, lp.leftover_map_rank, + lp.leftover_map_reconstruction, p.post_title, p.visibility_code, p.corporate_entity_id, ({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context from report_leftover_pair lp @@ -718,6 +721,11 @@ async def fetch_period_reports( if row["leftover_map_rank"] is None else int(row["leftover_map_rank"]) ), + "leftover_map_reconstruction": ( + None + if row["leftover_map_reconstruction"] is None + else float(row["leftover_map_reconstruction"]) + ), "visibility_code": row["visibility_code"], "corporate_entity_id": str(row["corporate_entity_id"]), "has_real_source_context": bool(row["has_real_source_context"]), diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index cef8eafb8..b1268acbd 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -12,6 +12,7 @@ from __future__ import annotations +import math import os import uuid from contextlib import closing @@ -132,6 +133,11 @@ / "migrations" / "0164_report_leftover_map_rank.sql" ) +_LEFTOVER_MAP_RECONSTRUCTION_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0186_report_leftover_map_reconstruction.sql" +) def _postgres_available() -> bool: @@ -249,6 +255,7 @@ def seeded_db(demo_analyst_token): cur.execute(_CHANNEL_WEIGHT_MIGRATION.read_text()) cur.execute(_LEFTOVER_OBSERVED_EXPECTED_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_RANK_MIGRATION.read_text()) + cur.execute(_LEFTOVER_MAP_RECONSTRUCTION_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " @@ -4734,6 +4741,9 @@ def test_seed_period_report_surfaces_on_get_reports(client, demo_analyst_token, assert all(pair["leftover_distance"] >= 0 for pair in high_report.get("leftover_pairs", [])) for pair in high_report.get("leftover_pairs", []): assert pair["leftover_map_rank"] >= 0 + reconstruction = pair.get("leftover_map_reconstruction") + if reconstruction is not None: + assert math.isfinite(reconstruction) observed = pair.get("observed_response") expected = pair.get("expected_response") if observed is None or expected is None: diff --git a/docs/adr/0003-fast-mlsirm-report-integration.md b/docs/adr/0003-fast-mlsirm-report-integration.md index bdf234b65..651f26af9 100644 --- a/docs/adr/0003-fast-mlsirm-report-integration.md +++ b/docs/adr/0003-fast-mlsirm-report-integration.md @@ -100,10 +100,11 @@ than one large PR: `information_polytomous` (Lord, 1980 max-info). Persist the ranking (`report_item_information`) and show the rank-1 item on the Period reports panel. Do not reimplement an information function here. -7. **Leftover-pair slice** (shipped in 0.71.2; ADR 0017 / 0018): after - IRT main effects, persist closest and farthest post–criterion pairs - from the residual leftover map. Do not fork LSIRM; do not invent a - leftover-pair API inside `fast-mlsirm` in this slice. +7. **Leftover-pair slice** (shipped in 0.71.2; ADR 0048 / 0049, amended + by ADR 0186): after IRT main effects, persist closest and farthest + post–criterion pairs from the residual leftover map, including + two-axis leftover-map reconstruction `R̂`. Do not fork LSIRM; do + not invent a leftover-pair API inside `fast-mlsirm` in this slice. **TEPP boundary.** [ARCHITECTURE.md](../../ARCHITECTURE.md) already assigns calibrated temporal/event measurement to diff --git a/docs/adr/0048-persist-lsirm-leftover-pairs.md b/docs/adr/0048-persist-lsirm-leftover-pairs.md index bc79cb17e..e15ba4cb7 100644 --- a/docs/adr/0048-persist-lsirm-leftover-pairs.md +++ b/docs/adr/0048-persist-lsirm-leftover-pairs.md @@ -4,7 +4,8 @@ **Date:** 2026-08-17 **Amended by:** [ADR 0119](0119-leftover-map-two-dimensional-distance.md) (two leftover-map axes); [ADR 0163](0163-leftover-observed-expected.md) (observed Y and expected E); -[ADR 0164](0164-leftover-map-rank.md) (full map rank) +[ADR 0164](0164-leftover-map-rank.md) (full map rank); +[ADR 0186](0186-leftover-map-reconstruction.md) (two-axis reconstruction R̂) ## Context @@ -36,8 +37,10 @@ The biplot lives in `lineageweave/leftover_pairs.py` so leftover tests do not import `period_report` or `fast_mlsirm`. Distances are Euclidean on the two leftover-map axes (ADR 0119). Each leftover row also names observed `Y` and expected `E[Y|θ, item]` so residual -reconciles to `Y − E` (ADR 0163), and names the full singular-value -rank while distance remains on the first two axes (ADR 0164). +reconciles to `Y − E` (ADR 0163), names the full singular-value +rank while distance remains on the first two axes (ADR 0164), and +names two-axis leftover-map reconstruction `R̂_c = ξ_{1:2} · ζ_{1:2}` +of centered leftover (ADR 0186). 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 3b6e4ce43..a09905657 100644 --- a/docs/adr/0049-leftover-pair-report-ui.md +++ b/docs/adr/0049-leftover-pair-report-ui.md @@ -4,7 +4,8 @@ **Date:** 2026-08-17 **Amended by:** [ADR 0162](0162-leftover-residual-disclosure.md) (signed residual R); [ADR 0163](0163-leftover-observed-expected.md) (observed Y and expected E); -[ADR 0164](0164-leftover-map-rank.md) (full map rank) +[ADR 0164](0164-leftover-map-rank.md) (full map rank); +[ADR 0186](0186-leftover-map-reconstruction.md) (two-axis reconstruction R̂) ## Context @@ -21,14 +22,16 @@ second navigation surface. On each period-report group, render leftover pairs **above** the member list. Each pair is a button: closest or farthest label, post title, criterion short label, signed residual `R`, two-axis leftover-map -distance, full map rank, observed `Y`, and expected `E` when finite. +distance, full map rank, leftover-map reconstruction `R̂`, observed `Y`, +and expected `E` when finite. The next action names every available measurement before opening the post; no amendment hides another, and rank 0 explicitly names no leftover structure. Clicking the button opens that post with the same handler as a member row. Residual naming is [ADR 0162](0162-leftover-residual-disclosure.md), observed/expected naming is [ADR 0163](0163-leftover-observed-expected.md), -rank naming is [ADR 0164](0164-leftover-map-rank.md). +rank naming is [ADR 0164](0164-leftover-map-rank.md), reconstruction +naming is [ADR 0186](0186-leftover-map-reconstruction.md). After `make seed`, closest and farthest leftover pairs sit above the member list. Click a pair to open that post. diff --git a/docs/adr/0186-leftover-map-reconstruction.md b/docs/adr/0186-leftover-map-reconstruction.md new file mode 100644 index 000000000..0068e002c --- /dev/null +++ b/docs/adr/0186-leftover-map-reconstruction.md @@ -0,0 +1,92 @@ +# ADR 0186 — Name leftover-map reconstruction on leftover pairs + +**Decision status:** Accepted +**Date:** 2026-08-24 + +Amends [ADR 0048](0048-persist-lsirm-leftover-pairs.md) and +[ADR 0049](0049-leftover-pair-report-ui.md). + +## Context + +ADR 0048 already persists leftover-map distance `d = ‖ξ_p − ζ_i‖` and +leftover residual `R = Y − E[Y|θ, item]` on `report_leftover_pair`. +ADR 0049 already renders closest and farthest pairs above the member +list and opens the named post. Distance is the Jeon et al. (2021, +eq. 3) map gap. Gabriel (1971) reconstructs a *centered* matrix from +the biplot as the inner product of person and item coordinates. The +leftover map buyers read is two-axis: unused axes pad with zero, and +hidden SVD axes after the second are dropped. Two-axis reconstruction +`R̂_c = ξ_{1:2} · ζ_{1:2}` therefore recovers centered leftover +`R̃ = R − center`, not raw residual `R` and not leftover-map distance +`d`. Hiding `R̂_c` lets a buyer read distance as if it named how much +leftover the two leftover-map axes reconstruct. + +This increment does not persist leftover-map coordinates, does not +persist leftover-map unexplained leftover `U` or `U_c`, does not +persist leftover-map explained leftover share `e`, unexplained leftover +share `s`, or leftover-map cross share `x`, does not name leftover-map +cosine or length, does not name observed `Y` / expected `E`, does not +name leftover-map rank, does not split leftover-map distance onto two +axes, and does not land Post quality on the leftover criterion. +Leftover-map distance stays two-axis Euclidean (ADR 0119). + +The unprotected-stack reconstructions for neighbouring leftover facts +use 0162–0185. This protected-main increment uses **0186** so it does +not collide with leftover-map cross share (0185), leftover-map +explained leftover share (0184), leftover-map unexplained leftover +share (0183), leftover-map unexplained leftover (0182), leftover-map +length (0181 on the length stack), leftover-map cosine (0180), +leftover-map inner product (0179), leftover residual disclosure +(0162 / 0178), leftover observed `Y` / expected `E` (0163 / 0170), +leftover-map rank (0164 / 0172), two-axis leftover-map distance +(0119 / 0166), leftover coverage (0165 / 0168), leftover-map axis +share (0148), or leftover interaction-map persistence (0121). + +## Decision + +Each leftover pair names `leftover_map_reconstruction` — two-axis +Gabriel reconstruction `R̂_c = ξ_{1:2} · ζ_{1:2}` of centered leftover +after IRT main effects. Migration `0186` is the single source of the +column on every install path, fresh or existing -- shipped migrations +(`0001` / `0012`) are never edited after the fact. The column is +nullable so older leftover rows keep distance and residual without +fabricating a reconstruction. Fallback pairs that have no +complete-case leftover map omit the value rather than inventing one. +A rank-0 origin map stores `0.0` (`ξ = 0`, `ζ = 0`), not a missing +value. A non-finite reconstruction stores null rather than inventing +a leftover score. A finite negative reconstruction is stored; do not +add a nonnegative CHECK. Do not persist +`leftover_map_explained_share`, `leftover_map_unexplained_share`, +`leftover_map_unexplained`, `leftover_map_cross_share`, or leftover-map +coordinates. + +The pair button shows `R̂ {reconstruction}` next to leftover-map +distance `d` when the value is a finite number, including a signed +negative reconstruction. Next action: two leftover-map axes +reconstruct centered leftover `R̂` after IRT main effects; open this +post. A missing or non-finite reconstruction omits the badge and +keeps the existing closest/farthest next action. Do not invent a +leftover score or a theta. + +## Consequences + +`GET /api/reports/{grouping}/{period}` returns +`leftover_map_reconstruction`. After `make seed`, closest and farthest +leftover pairs sit above the member list with leftover-map +reconstruction; click opens that post. Hidden posts stay hidden. + +Independent of leftover interaction-map persistence, leftover-criterion +evaluation landing, leftover residual UI extraction, two-axis leftover-map +distance, leftover observed `Y` / expected `E`, leftover-map rank, and +leftover-map share identities. + +## 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 594ba6bee..f5c3e9395 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.12.18", + "version": "2.12.30", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index cb52559b7..4b5b49b83 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -952,6 +952,7 @@ describe("App, authenticated", () => { observed_response: 2.4, expected_response: 2.0, leftover_map_rank: 1, + leftover_map_reconstruction: 0.4, }, { pair_kind: "farthest", @@ -963,6 +964,7 @@ describe("App, authenticated", () => { observed_response: 0.9, expected_response: 2.0, leftover_map_rank: 1, + leftover_map_reconstruction: -1.1, }, ], members: [ @@ -3486,19 +3488,21 @@ describe("App, authenticated", () => { }); expect(closestPair).toHaveTextContent("Closest leftover: Public post · sales-lead"); expect(closestPair).toHaveTextContent( - "Read leftover map rank 1, observed Y 2.40, and expected E 2.00 after IRT main effects, then open this post.", + "Two leftover-map axes reconstruct centered leftover R̂ +0.40 after IRT main effects. Open this post.", ); expect(closestPair).toHaveTextContent("R +0.40"); expect(closestPair).toHaveTextContent("Y 2.40 · E 2.00"); expect(closestPair).toHaveTextContent("rank 1"); + expect(closestPair).toHaveTextContent("R̂ +0.40"); expect(closestPair).toHaveTextContent("d 0.12"); expect(farthestPair).toHaveTextContent("Farthest leftover: Specification revision requested · negative"); expect(farthestPair).toHaveTextContent( - "Read leftover map rank 1, observed Y 0.90, and expected E 2.00 after IRT main effects, then open this post.", + "Two leftover-map axes reconstruct centered leftover R̂ −1.10 after IRT main effects. Open this post.", ); expect(farthestPair).toHaveTextContent("R −1.10"); expect(farthestPair).toHaveTextContent("Y 0.90 · E 2.00"); expect(farthestPair).toHaveTextContent("rank 1"); + expect(farthestPair).toHaveTextContent("R̂ −1.10"); expect(farthestPair).toHaveTextContent("d 1.84"); const memberButton = screen.getByRole("button", { name: /open report post: public post/i }); expect(closestPair.compareDocumentPosition(memberButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index fe1438b6c..4f9ea0e2e 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -773,6 +773,7 @@ export interface LeftoverPair { observed_response?: number | null; expected_response?: number | null; leftover_map_rank?: number | null; + leftover_map_reconstruction?: number | null; } export interface PeriodGroupReport { diff --git a/frontend/src/components/LeftoverPairList.stories.tsx b/frontend/src/components/LeftoverPairList.stories.tsx index 680640026..7834b8fa0 100644 --- a/frontend/src/components/LeftoverPairList.stories.tsx +++ b/frontend/src/components/LeftoverPairList.stories.tsx @@ -19,6 +19,7 @@ const meta = { observed_response: 2.4, expected_response: 2.0, leftover_map_rank: 1, + leftover_map_reconstruction: 0.4, }, { pair_kind: "farthest", @@ -30,6 +31,7 @@ const meta = { observed_response: 0.9, expected_response: 2.0, leftover_map_rank: 1, + leftover_map_reconstruction: -1.1, }, ], }, diff --git a/frontend/src/components/LeftoverPairList.test.tsx b/frontend/src/components/LeftoverPairList.test.tsx index 19e306040..b1bc047af 100644 --- a/frontend/src/components/LeftoverPairList.test.tsx +++ b/frontend/src/components/LeftoverPairList.test.tsx @@ -15,6 +15,7 @@ const PAIRS: LeftoverPair[] = [ observed_response: 2.4, expected_response: 2.0, leftover_map_rank: 1, + leftover_map_reconstruction: 0.4, }, { pair_kind: "farthest", @@ -26,6 +27,7 @@ const PAIRS: LeftoverPair[] = [ observed_response: 0.9, expected_response: 2.0, leftover_map_rank: 1, + leftover_map_reconstruction: -1.1, }, ]; @@ -50,11 +52,12 @@ describe("LeftoverPairList", () => { }); expect(closest).toHaveTextContent("Closest leftover: Public post · sales-lead"); expect(closest).toHaveTextContent( - "Read leftover map rank 1, observed Y 2.40, and expected E 2.00 after IRT main effects, then open this post.", + "Two leftover-map axes reconstruct centered leftover R̂ +0.40 after IRT main effects. Open this post.", ); expect(closest).toHaveTextContent("R +0.40"); expect(closest).toHaveTextContent("Y 2.40 · E 2.00"); expect(closest).toHaveTextContent("rank 1"); + expect(closest).toHaveTextContent("R̂ +0.40"); expect(closest).toHaveTextContent("d 0.12"); const farthest = screen.getByRole("button", { @@ -63,9 +66,10 @@ describe("LeftoverPairList", () => { expect(farthest).toHaveTextContent("R −1.10"); expect(farthest).toHaveTextContent("Y 0.90 · E 2.00"); expect(farthest).toHaveTextContent("rank 1"); + expect(farthest).toHaveTextContent("R̂ −1.10"); expect(farthest).toHaveTextContent("d 1.84"); expect(farthest).toHaveTextContent( - "Read leftover map rank 1, observed Y 0.90, and expected E 2.00 after IRT main effects, then open this post.", + "Two leftover-map axes reconstruct centered leftover R̂ −1.10 after IRT main effects. Open this post.", ); await userEvent.click(closest); @@ -75,7 +79,7 @@ describe("LeftoverPairList", () => { it("keeps residual guidance for an older payload without rank or Y/E", () => { render( , @@ -89,24 +93,29 @@ describe("LeftoverPairList", () => { it.each([ [ "rank-zero observed evidence", - { observed_response: 1, expected_response: 1, leftover_map_rank: 0 }, + { observed_response: 1, expected_response: 1, leftover_map_rank: 0, leftover_map_reconstruction: null }, "Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y 1.00 and expected E 1.00, then open this post.", ], [ "rank-only evidence", - { observed_response: null, expected_response: null, leftover_map_rank: 2 }, + { observed_response: null, expected_response: null, leftover_map_rank: 2, leftover_map_reconstruction: null }, "Leftover map rank 2 after IRT main effects. Open this post.", ], [ "rank-zero-only evidence", - { observed_response: null, expected_response: null, leftover_map_rank: 0 }, + { observed_response: null, expected_response: null, leftover_map_rank: 0, leftover_map_reconstruction: null }, "Leftover map has no leftover structure after IRT main effects. Open this post.", ], [ "observed and expected evidence", - { observed_response: 2.4, expected_response: 2, leftover_map_rank: null }, + { observed_response: 2.4, expected_response: 2, leftover_map_rank: null, leftover_map_reconstruction: null }, "Read observed Y 2.40 and expected E 2.00 after IRT main effects, then open this post.", ], + [ + "reconstruction evidence", + { leftover_map_reconstruction: -0.25 }, + "Two leftover-map axes reconstruct centered leftover R̂ −0.25 after IRT main effects. Open this post.", + ], ] as const)("selects the next action for %s", (_label, evidence, expectedAction) => { render( R {residual} {observedExpected ? {observedExpected} : null} {rankBadge ? {rankBadge} : null} + {reconstructionBadge ? {reconstructionBadge} : null} d {pair.leftover_distance.toFixed(2)} diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index 608f95858..72a9f2042 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -47,6 +47,7 @@ describe("i18n", () => { "Leftover map rank {rank} after IRT main effects. Open this post.", "Read leftover map rank {rank}, observed Y {observed}, and expected E {expected} after IRT main effects, then open this post.", "Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y {observed} and expected E {expected}, then open this post.", + "Two leftover-map axes reconstruct centered leftover R̂ {reconstruction} after IRT main effects. Open this post.", "Showing the first {shown} of {total} posts known at this cutoff.", "Rankings", "Title overlap", @@ -132,6 +133,21 @@ describe("i18n", () => { ).toBe(expected); }); + it.each([ + ["ko", "IRT 주효과 이후 두 잔여 맵 축이 중심화된 잔여 R̂ +0.40을 재구성합니다. 이 글을 여세요."], + ["zh", "IRT 主效应后,两个残余图轴重构中心化残余 R̂ +0.40。打开这篇帖子。"], + ["ja", "IRT主効果後、残差マップの2軸が中心化残差 R̂ +0.40 を再構成します。この投稿を開いてください。"], + ["vi", "Hai trục bản đồ phần dư tái tạo phần dư đã tâm R̂ +0.40 sau hiệu ứng chính IRT. Mở bài viết này."], + ] as const)("formats leftover-map reconstruction next action in %s", (locale, expected) => { + setLocale(locale); + expect( + tf( + "Two leftover-map axes reconstruct centered leftover R̂ {reconstruction} after IRT main effects. Open this post.", + { reconstruction: "+0.40" }, + ), + ).toBe(expected); + }); + it.each([ ["ko", "IRT 주효과 이후 잔여 맵 랭크 1, 관측 Y 2.40, 기대 E 2.00를 읽은 다음, 이 글을 여세요."], ["zh", "阅读 IRT 主效应后的残余图秩 1、观测 Y 2.40 与期望 E 2.00,然后打开这篇帖子。"], diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 06545e35a..1c14edcbe 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -395,6 +395,8 @@ const TRANSLATIONS: Partial>> = { "IRT 주효과 이후 잔여 맵 랭크 {rank}, 관측 Y {observed}, 기대 E {expected}를 읽은 다음, 이 글을 여세요.", "Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y {observed} and expected E {expected}, then open this post.": "IRT 주효과 이후 잔여 맵 랭크 0은 잔여 구조가 없음을 뜻합니다. 관측 Y {observed}와 기대 E {expected}를 읽은 다음, 이 글을 여세요.", + "Two leftover-map axes reconstruct centered leftover R̂ {reconstruction} after IRT main effects. Open this post.": + "IRT 주효과 이후 두 잔여 맵 축이 중심화된 잔여 R̂ {reconstruction}을 재구성합니다. 이 글을 여세요.", }, zh: { "Unknown": "未知", @@ -772,6 +774,8 @@ const TRANSLATIONS: Partial>> = { "阅读 IRT 主效应后的残余图秩 {rank}、观测 Y {observed} 与期望 E {expected},然后打开这篇帖子。", "Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y {observed} and expected E {expected}, then open this post.": "残余图秩 0 表示 IRT 主效应后没有残余结构。阅读观测 Y {observed} 与期望 E {expected},然后打开这篇帖子。", + "Two leftover-map axes reconstruct centered leftover R̂ {reconstruction} after IRT main effects. Open this post.": + "IRT 主效应后,两个残余图轴重构中心化残余 R̂ {reconstruction}。打开这篇帖子。", }, ja: { "Unknown": "不明", @@ -1149,6 +1153,8 @@ const TRANSLATIONS: Partial>> = { "IRT主効果後の残差マップランク {rank}、観測 Y {observed}、期待 E {expected} を読んでから、この投稿を開いてください。", "Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y {observed} and expected E {expected}, then open this post.": "残差マップランク 0 は IRT 主効果後に残差構造がないことを示します。観測 Y {observed} と期待 E {expected} を読んでから、この投稿を開いてください。", + "Two leftover-map axes reconstruct centered leftover R̂ {reconstruction} after IRT main effects. Open this post.": + "IRT主効果後、残差マップの2軸が中心化残差 R̂ {reconstruction} を再構成します。この投稿を開いてください。", }, vi: { "Unknown": "Không rõ", @@ -1526,6 +1532,8 @@ const TRANSLATIONS: Partial>> = { "Đọc hạng bản đồ phần dư {rank}, Y quan sát {observed} và E kỳ vọng {expected} sau hiệu ứng chính IRT, rồi mở bài viết này.", "Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y {observed} and expected E {expected}, then open this post.": "Hạng bản đồ phần dư 0 nghĩa là không có cấu trúc phần dư sau hiệu ứng chính IRT. Đọc Y quan sát {observed} và E kỳ vọng {expected}, rồi mở bài viết này.", + "Two leftover-map axes reconstruct centered leftover R̂ {reconstruction} after IRT main effects. Open this post.": + "Hai trục bản đồ phần dư tái tạo phần dư đã tâm R̂ {reconstruction} sau hiệu ứng chính IRT. Mở bài viết này.", }, }; diff --git a/frontend/src/leftoverMapReconstruction.test.ts b/frontend/src/leftoverMapReconstruction.test.ts new file mode 100644 index 000000000..a50b7aa25 --- /dev/null +++ b/frontend/src/leftoverMapReconstruction.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { formatLeftoverMapReconstruction } from "./leftoverMapReconstruction"; + +describe("formatLeftoverMapReconstruction", () => { + it("names leftover-map reconstruction without inventing a leftover score", () => { + expect(formatLeftoverMapReconstruction(0.4)).toBe("R̂ +0.40"); + expect(formatLeftoverMapReconstruction(-1.1)).toBe("R̂ −1.10"); + expect(formatLeftoverMapReconstruction(0)).toBe("R̂ 0.00"); + }); + + it("omits the badge when reconstruction is missing or non-finite", () => { + expect(formatLeftoverMapReconstruction(null)).toBeNull(); + expect(formatLeftoverMapReconstruction(undefined)).toBeNull(); + expect(formatLeftoverMapReconstruction(Number.NaN)).toBeNull(); + expect(formatLeftoverMapReconstruction(Number.POSITIVE_INFINITY)).toBeNull(); + }); +}); diff --git a/frontend/src/leftoverMapReconstruction.ts b/frontend/src/leftoverMapReconstruction.ts new file mode 100644 index 000000000..9699ab8ee --- /dev/null +++ b/frontend/src/leftoverMapReconstruction.ts @@ -0,0 +1,20 @@ +/** Two-axis leftover-map reconstruction ``R̂_c = ξ_{1:2} · ζ_{1:2}``. */ + +export const LEFTOVER_RECONSTRUCTION_ACTION = + "Two leftover-map axes reconstruct centered leftover R̂ {reconstruction} after IRT main effects. Open this post."; + +export function formatLeftoverMapReconstruction( + reconstruction: number | null | undefined, +): string | null { + if (reconstruction == null || !Number.isFinite(reconstruction)) { + return null; + } + const magnitude = Math.abs(reconstruction).toFixed(2); + if (reconstruction > 0) { + return `R̂ +${magnitude}`; + } + if (reconstruction < 0) { + return `R̂ \u2212${magnitude}`; + } + return `R̂ ${magnitude}`; +} diff --git a/lineageweave/leftover_pairs.py b/lineageweave/leftover_pairs.py index 297d47012..9ea15c331 100644 --- a/lineageweave/leftover_pairs.py +++ b/lineageweave/leftover_pairs.py @@ -1,6 +1,7 @@ """Jeon leftover post–criterion pairs after a main-effect IRT. -Implements ADR 0048 as amended by ADR 0119, ADR 0163, and ADR 0164. +Implements ADR 0048 as amended by ADR 0119, ADR 0163, ADR 0164, and +ADR 0186. Does not import ``fast_mlsirm`` or ``period_report``. A Gabriel biplot of the residual ``R = Y − E[Y|θ, item]`` supplies person and item @@ -11,7 +12,10 @@ on the two leftover-map axes (Jeon et al., 2021); unused axes pad with zero rather than inventing a second component, and hidden SVD axes after the second are dropped. Each pair also names the full leftover-map -rank so a rank-0 collapse is not read as leftover structure. +rank so a rank-0 collapse is not read as leftover structure, and names +two-axis leftover-map reconstruction ``R̂_c = ξ_{1:2} · ζ_{1:2}`` of +centered leftover so truncated reconstruction is not confused with +residual ``R`` or leftover-map distance ``d``. """ from __future__ import annotations @@ -39,6 +43,7 @@ class LeftoverPair: observed_response: float expected_response: float leftover_map_rank: int + leftover_map_reconstruction: float | None = None def leftover_pairs_from_residual( @@ -56,7 +61,11 @@ def leftover_pairs_from_residual( stable closest/farthest pair so seed is not empty and does not invent a leftover score. Stored residual equals observed ``Y`` minus expected ``E[Y|θ, item]``. Stored leftover-map rank is the number - of Gabriel singular values above the floor. + of Gabriel singular values above the floor. When Gabriel coordinates + exist, leftover-map reconstruction ``R̂_c = ξ_{1:2} · ζ_{1:2}`` + names the two-axis reconstruction of centered leftover + ``R̃ = R − center``. Fallback pairs (no complete-case map) omit the + reconstruction rather than fabricating one. """ if matrix.shape != (len(post_ids), len(item_codes)): raise ValueError( @@ -86,7 +95,7 @@ def leftover_pairs_from_residual( person_pos, item_pos, leftover_map_rank = _complete_case_positions( residual, center, keep_person, keep_item ) - candidates: list[tuple[float, str, str, float, float, float]] = [] + candidates: list[tuple[float, str, str, float, float, float, float | None]] = [] if person_pos is not None and item_pos is not None: person_index = np.flatnonzero(keep_person) item_index = np.flatnonzero(keep_item) @@ -102,8 +111,21 @@ def leftover_pairs_from_residual( ) if not np.isfinite(distance): continue + reconstruction = _leftover_map_reconstruction( + person_xy[local_person[person]], item_xy[local_item[item]] + ) candidates.append( - _candidate_row(post_ids, item_codes, matrix, expected, residual, person, item, distance) + _candidate_row( + post_ids, + item_codes, + matrix, + expected, + residual, + person, + item, + distance, + reconstruction, + ) ) if not candidates: leftover_map_rank = 0 @@ -119,6 +141,7 @@ def leftover_pairs_from_residual( person, item, max(distance, 0.0), + None, ) ) closest = min(candidates, key=lambda row: (row[0], row[1], row[2])) @@ -129,6 +152,21 @@ def leftover_pairs_from_residual( ) +def _leftover_map_reconstruction( + person_xy: np.ndarray, item_xy: np.ndarray +) -> float | None: + """Return two-axis reconstruction ``R̂_c = ξ_{1:2} · ζ_{1:2}`` when finite. + + Rank-0 origin coordinates store ``0.0``. A non-finite inner product + is omitted rather than invented. Negative finite reconstruction is + stored; do not clamp or take an absolute value. + """ + reconstruction = float(np.dot(person_xy, item_xy)) + if np.isfinite(reconstruction): + return reconstruction + return None + + def _candidate_row( post_ids: list[str], item_codes: tuple[str, ...], @@ -138,8 +176,9 @@ def _candidate_row( person: int, item: int, distance: float, -) -> tuple[float, str, str, float, float, float]: - """One observed leftover cell: distance, ids, residual, Y, E.""" + leftover_map_reconstruction: float | None = None, +) -> tuple[float, str, str, float, float, float, float | None]: + """One observed leftover cell: distance, ids, residual, Y, E, reconstruction.""" leftover_residual = float(residual[person, item]) observed_response = float(matrix[person, item]) expected_response = float(expected[person, item]) @@ -152,12 +191,13 @@ def _candidate_row( leftover_residual, observed_response, expected_response, + leftover_map_reconstruction, ) def _pair_from_candidate( pair_kind: str, - row: tuple[float, str, str, float, float, float], + row: tuple[float, str, str, float, float, float, float | None], leftover_map_rank: int, ) -> LeftoverPair: """Build a leftover pair from a candidate row.""" @@ -172,6 +212,7 @@ def _pair_from_candidate( observed_response=row[4], expected_response=row[5], leftover_map_rank=leftover_map_rank, + leftover_map_reconstruction=row[6], ) @@ -225,7 +266,12 @@ def _leftover_map_positions(filled: np.ndarray) -> tuple[np.ndarray, np.ndarray, def _pad_map_axes(positions: np.ndarray) -> np.ndarray: - """Pad or truncate Gabriel coordinates to two leftover-map axes.""" + """Pad or truncate Gabriel coordinates to two leftover-map axes. + + Unused axes pad with zero rather than inventing a second component. + Hidden SVD axes after the second are dropped so reconstruction is + ``ξ_{1:2} · ζ_{1:2}``, not the full-rank inner product. + """ padded = np.zeros((positions.shape[0], _LEFTOVER_MAP_AXES), dtype=np.float64) width = min(_LEFTOVER_MAP_AXES, positions.shape[1]) padded[:, :width] = positions[:, :width] diff --git a/migrations/0186_report_leftover_map_reconstruction.sql b/migrations/0186_report_leftover_map_reconstruction.sql new file mode 100644 index 000000000..6ea2c4275 --- /dev/null +++ b/migrations/0186_report_leftover_map_reconstruction.sql @@ -0,0 +1,9 @@ +-- ADR 0186: persist leftover-map reconstruction on leftover post–criterion pairs. +-- Reconstruction is two-axis Gabriel inner product R̂_c = ξ_{1:2} · ζ_{1:2} +-- of centered leftover. Upgrade columns are nullable so older leftover +-- rows keep distance and residual without fabricating a reconstruction. +-- No nonnegative CHECK: reconstruction may be negative. This sequential +-- migration is the single source of the column on every install path. + +alter table report_leftover_pair + add column if not exists leftover_map_reconstruction double precision; diff --git a/migrations/rollback/0186_report_leftover_map_reconstruction.sql b/migrations/rollback/0186_report_leftover_map_reconstruction.sql new file mode 100644 index 000000000..ab7a9c9c2 --- /dev/null +++ b/migrations/rollback/0186_report_leftover_map_reconstruction.sql @@ -0,0 +1,4 @@ +-- Reverse 0186. Leftover distance, residual, Y, E, and rank stay on the pair row. + +alter table report_leftover_pair + drop column if exists leftover_map_reconstruction; diff --git a/pyproject.toml b/pyproject.toml index 9a3975230..f1b9da764 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.12.18" +version = "2.12.30" 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 04a1f9c00..759afa84a 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -119,6 +119,7 @@ def seed( cur.execute((migrations / "0012_report_leftover_pair.sql").read_text()) cur.execute((migrations / "0163_report_leftover_observed_expected.sql").read_text()) cur.execute((migrations / "0164_report_leftover_map_rank.sql").read_text()) + cur.execute((migrations / "0186_report_leftover_map_reconstruction.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()) @@ -1198,8 +1199,8 @@ def _persist_seed_period_report( "insert into report_leftover_pair (" "grouping_kind, grouping_key, period_code, rubric_version, " "pair_kind, post_id, criterion_code, leftover_distance, leftover_residual, " - "observed_response, expected_response, leftover_map_rank" - ") values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", + "observed_response, expected_response, leftover_map_rank, leftover_map_reconstruction" + ") values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", ( grouping_kind, grouping_key, @@ -1213,6 +1214,7 @@ def _persist_seed_period_report( pair.observed_response, pair.expected_response, pair.leftover_map_rank, + pair.leftover_map_reconstruction, ), ) diff --git a/tests/test_leftover_pairs.py b/tests/test_leftover_pairs.py index 72a2de975..82a973bfa 100644 --- a/tests/test_leftover_pairs.py +++ b/tests/test_leftover_pairs.py @@ -1,6 +1,6 @@ """Leftover post–criterion pairs after the main-effect IRT. -Covers ADR 0048 as amended by ADR 0119, ADR 0163, and ADR 0164. +Covers ADR 0048 as amended by ADR 0119, ADR 0163, ADR 0164, and ADR 0186. Uses a constructed residual matrix so the closest and farthest pair are known without calling ``fit_polytomous``. Loads @@ -100,6 +100,7 @@ def test_leftover_residual_biplot_separates_aligned_and_opposed_cells() -> None: for pair in pairs: _assert_residual_reconciles(pair) assert pair.leftover_map_rank == 1 + assert pair.leftover_map_reconstruction == pytest.approx(pair.leftover_residual) def test_zero_residual_still_emits_stable_leftover_pairs() -> None: @@ -121,6 +122,7 @@ def test_zero_residual_still_emits_stable_leftover_pairs() -> None: for pair in pairs: _assert_residual_reconciles(pair) assert pair.leftover_map_rank == 0 + assert pair.leftover_map_reconstruction == pytest.approx(0.0) def test_partial_observation_does_not_treat_missing_as_zero_residual() -> None: @@ -150,9 +152,8 @@ def test_partial_observation_does_not_treat_missing_as_zero_residual() -> None: for pair in pairs: _assert_residual_reconciles(pair) assert pair.leftover_map_rank == 1 - - -def test_leftover_is_empty_without_observed_cells() -> None: + assert pair.leftover_map_reconstruction is not None + assert np.isfinite(pair.leftover_map_reconstruction) """An entirely missing response matrix yields no invented pair.""" post_ids = ["post-empty"] item_codes = ("item_one",) @@ -230,7 +231,10 @@ def test_rank_four_pair_distances_match_two_dimensional_gabriel_coords() -> None item_map = _pad_map_axes(item_full) full_distances = np.linalg.norm(person_full[:, None, :] - item_full[None, :, :], axis=2) map_distances = np.linalg.norm(person_map[:, None, :] - item_map[None, :, :], axis=2) + map_reconstruction = person_map @ item_map.T + full_reconstruction = person_full @ item_full.T assert float(np.max(np.abs(full_distances - map_distances))) > 1e-6 + assert float(np.max(np.abs(full_reconstruction - map_reconstruction))) > 1e-6 pairs = leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST] @@ -244,6 +248,14 @@ def test_rank_four_pair_distances_match_two_dimensional_gabriel_coords() -> None assert pair.leftover_distance != pytest.approx( float(full_distances[person, item]), abs=1e-9 ) + assert pair.leftover_map_reconstruction == pytest.approx( + float(map_reconstruction[person, item]) + ) + assert pair.leftover_map_reconstruction != pytest.approx( + float(full_reconstruction[person, item]), abs=1e-9 + ) + assert pair.leftover_map_reconstruction != pytest.approx(pair.leftover_residual, abs=1e-9) + assert pair.leftover_map_reconstruction != pytest.approx(pair.leftover_distance, abs=1e-9) farthest_map = np.unravel_index(int(np.argmax(map_distances)), map_distances.shape) farthest = pairs[1] @@ -283,6 +295,7 @@ def test_sparse_residual_uses_only_observed_cells_for_fallback_distance() -> Non ] assert [pair.leftover_distance for pair in pairs] == pytest.approx([1.0, 1.0]) assert [pair.leftover_map_rank for pair in pairs] == [0, 0] + assert [pair.leftover_map_reconstruction for pair in pairs] == [None, None] def test_nonfinite_map_distance_falls_back_to_centered_residual( @@ -306,6 +319,7 @@ def test_nonfinite_map_distance_falls_back_to_centered_residual( ) assert [pair.leftover_distance for pair in pairs] == [0.0, 0.0] assert [pair.leftover_map_rank for pair in pairs] == [0, 0] + assert [pair.leftover_map_reconstruction for pair in pairs] == [None, None] def test_empty_observation_mask_has_no_complete_case_axes() -> None: @@ -330,6 +344,27 @@ 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), + (0.0, "public-post", "sales_lead_specificity", 0.0, 1.0, 1.0, None), -1, ) + + +def test_nonfinite_reconstruction_is_omitted_not_invented() -> None: + """A non-finite two-axis inner product cannot become persisted reconstruction.""" + assert leftover._leftover_map_reconstruction( + np.array([np.inf, 0.0], dtype=np.float64), + np.array([1.0, 0.0], dtype=np.float64), + ) is None + assert leftover._leftover_map_reconstruction( + np.array([0.0, 0.0], dtype=np.float64), + np.array([0.0, 0.0], dtype=np.float64), + ) == pytest.approx(0.0) + + +def test_negative_reconstruction_is_stored() -> None: + """Opposite-sign two-axis coordinates keep a signed reconstruction.""" + reconstruction = leftover._leftover_map_reconstruction( + np.array([1.0, 0.0], dtype=np.float64), + np.array([-2.0, 0.0], dtype=np.float64), + ) + assert reconstruction == pytest.approx(-2.0) diff --git a/tests/test_period_report.py b/tests/test_period_report.py index 318ad23ae..539427712 100644 --- a/tests/test_period_report.py +++ b/tests/test_period_report.py @@ -270,6 +270,9 @@ def test_calibrated_report_attaches_leftover_pairs() -> None: pair.observed_response - pair.expected_response, abs=1e-6 ) assert pair.leftover_map_rank >= 0 + reconstruction = pair.leftover_map_reconstruction + if reconstruction is not None: + assert np.isfinite(reconstruction) diff --git a/tests/test_schema.py b/tests/test_schema.py index 81cc3fcfd..f8f34514f 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -53,6 +53,11 @@ / "migrations" / "0164_report_leftover_map_rank.sql" ) +_LEFTOVER_MAP_RECONSTRUCTION_MIGRATION = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0186_report_leftover_map_reconstruction.sql" +) def _postgres_available() -> bool: @@ -91,6 +96,7 @@ def schema_db(): cur.execute(_PROJECT_BOUND_EVENT_MIGRATION.read_text()) cur.execute(_LEFTOVER_OBSERVED_EXPECTED_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_RANK_MIGRATION.read_text()) + cur.execute(_LEFTOVER_MAP_RECONSTRUCTION_MIGRATION.read_text()) conn.commit() yield conn finally: @@ -220,6 +226,15 @@ def test_leftover_pair_names_leftover_map_rank_column(schema_db) -> None: assert columns["leftover_map_rank"] == "YES" +def test_leftover_pair_names_leftover_map_reconstruction_column(schema_db) -> None: + """Fresh leftover rows name reconstruction without backfilling legacy evidence.""" + 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_reconstruction"] == "YES" def test_corporate_hierarchy_recursive_query_returns_correct_shape(schema_db) -> None: diff --git a/uv.lock b/uv.lock index 112d46840..728e27a9c 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.12.18" +version = "2.12.30" source = { editable = "." } dependencies = [ { name = "certifi" }, From 78afd5b8e1ba4e784d828aab988278e90d4b50cb Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 12:26:24 +0900 Subject: [PATCH 2/2] docs(adr): preserve signed unexplained leftover --- docs/adr/0186-leftover-map-reconstruction.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/adr/0186-leftover-map-reconstruction.md b/docs/adr/0186-leftover-map-reconstruction.md index 0068e002c..f2c299919 100644 --- a/docs/adr/0186-leftover-map-reconstruction.md +++ b/docs/adr/0186-leftover-map-reconstruction.md @@ -57,8 +57,9 @@ value. A non-finite reconstruction stores null rather than inventing a leftover score. A finite negative reconstruction is stored; do not add a nonnegative CHECK. Do not persist `leftover_map_explained_share`, `leftover_map_unexplained_share`, -`leftover_map_unexplained`, `leftover_map_cross_share`, or leftover-map -coordinates. +`leftover_map_cross_share`, or leftover-map coordinates. The signed +`leftover_map_unexplained` value `U = R - R̂` remains persisted under ADR +0182; it is evidence, not one of the prohibited derived share aliases. The pair button shows `R̂ {reconstruction}` next to leftover-map distance `d` when the value is a finite number, including a signed