From 5a4a41b8b5a785f2acee8de136d4b9a99c377848 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Mon, 24 Aug 2026 16:03:05 +0900 Subject: [PATCH 1/3] feat: persist leftover-map axis share on period reports (v2.12.16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gabriel inertia of residual SVD axes 1 and 2 (σ²/Σσ²) is a report-level 3NF slice next to leftover pairs (ADR 0148). Rank-0 residuals emit two zero-share axes. Do not invent a leftover score. --- AGENTS.md | 21 +++-- ARCHITECTURE.md | 11 ++- .../2.12.19-leftover-map-axis-share.md | 11 +++ CHANGELOG.md | 11 +++ CLAUDE.md | 4 +- backend/app/main.py | 9 +- backend/app/report_ingestion.py | 40 ++++++++- backend/tests/test_api.py | 10 +++ .../0003-fast-mlsirm-report-integration.md | 6 +- docs/adr/0148-leftover-map-axis-share.md | 60 +++++++++++++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 17 ++++ frontend/src/App.tsx | 15 ++++ frontend/src/api.ts | 7 ++ frontend/src/i18n.test.ts | 3 + frontend/src/i18n.ts | 16 ++++ lineageweave/leftover_pairs.py | 84 ++++++++++++++++--- lineageweave/period_report.py | 46 +++++++--- migrations/0169_report_leftover_map_axis.sql | 20 +++++ .../0169_report_leftover_map_axis.sql | 1 + pyproject.toml | 2 +- scripts/seed_demo_data.py | 16 ++++ tests/test_leftover_pairs.py | 66 ++++++++++++++- tests/test_migration_replay.py | 22 +++++ tests/test_period_report.py | 7 ++ tests/test_schema.py | 19 +++++ uv.lock | 2 +- 27 files changed, 482 insertions(+), 46 deletions(-) create mode 100644 CHANGELOG.d/2.12.19-leftover-map-axis-share.md create mode 100644 docs/adr/0148-leftover-map-axis-share.md create mode 100644 migrations/0169_report_leftover_map_axis.sql create mode 100644 migrations/rollback/0169_report_leftover_map_axis.sql diff --git a/AGENTS.md b/AGENTS.md index b34194c8d..0bb84cfac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -192,15 +192,18 @@ 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 -`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. +Period leftover pairs (ADR 0017 / 0018 / 0048 / 0049 / 0119 / 0162 / 0163 / +0164) 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. Leftover-map axis share +(ADR 0148) is Gabriel inertia of residual SVD axes 1 and 2 and persists +to `report_leftover_map_axis`. Rank-0 residuals emit two zero-share +axes; the shares are report-level and are not a leftover score. `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..9dbf19f31 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -594,9 +594,11 @@ on those same fixed parameters (Kim, 2006 FIPC). After scoring, information at the group's mean θ (Lord, 1980 max-info CAT). Rankings persist to `report_item_information`. After those IRT main effects, residual SVD leftover pairs on two Gabriel axes (Jeon et al., 2021; -ADR 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 0017 / 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. Leftover-map axis share +(Gabriel inertia of residual SVD axes 1 and 2; ADR 0148) persists to +`report_leftover_map_axis`. Results persist to `report_period_score` / `report_member_score`. `GET /api/reports/{grouping}` lists the trend; `GET /api/reports/{grouping}/{period}` is ABAC-filtered; @@ -610,7 +612,8 @@ open ticket title, status lookup label, and due date when one exists. The home p 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 -effects) above the member list, and the +effects) above the member list, leftover-map axis share for residual +SVD axes 1 and 2, and the PU / corp / thread comparison -- never a placeholder. TEPP is unchanged. ## Phase 6b: Knowledge Graph as a real Ontology + Semantic Layer diff --git a/CHANGELOG.d/2.12.19-leftover-map-axis-share.md b/CHANGELOG.d/2.12.19-leftover-map-axis-share.md new file mode 100644 index 000000000..360fb34b5 --- /dev/null +++ b/CHANGELOG.d/2.12.19-leftover-map-axis-share.md @@ -0,0 +1,11 @@ +# 2.12.19 — Leftover-map axis share + +## Added + +- Persist leftover-map axis share (Gabriel inertia of residual SVD + axes 1 and 2, `σ²/Σσ²`) on each period report (ADR 0148). Rank-0 + residuals emit two zero-share axes; missing cells stay out of the + factorization. After `make seed`, leftover-axis badges sit with the + leftover pairs; the caption tells the buyer to open a leftover pair. + Axis share is report-level 3NF and is not hidden when leftover pairs + are ABAC-filtered. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d0005ecd..5d67eb0e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,17 @@ 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.19] - 2026-08-24 + +### Added + +- Period reports now persist leftover-map axis share (Gabriel inertia of + residual SVD axes 1 and 2) next to leftover pairs (ADR 0148). Rank-0 + residuals emit two zero-share axes. After `make seed`, leftover-axis + badges sit with the leftover pairs; the caption tells the buyer to open + a leftover pair. Axis share is report-level 3NF and is not hidden when + leftover pairs are ABAC-filtered. + ## [2.12.18] - 2026-08-24 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 42b091e42..6f1af23b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,9 @@ 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 +leftover structure. Leftover-map axis share badges name Gabriel inertia +of axes 1 and 2; open a leftover pair to read the post–criterion cell. +The shares do not invent a leftover score. 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/main.py b/backend/app/main.py index 0e6fb68f2..a963b25d1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2379,8 +2379,15 @@ async def read_period_reports( {key: value for key, value in pair.items() if key != "has_real_source_context"} for pair in leftover_pairs ] + leftover_map_axes = list(report.get("leftover_map_axes", [])) visible.append( - {**report, "members": members, "leftover_pairs": leftover_pairs, "post_count": len(members)} + { + **report, + "members": members, + "leftover_pairs": leftover_pairs, + "leftover_map_axes": leftover_map_axes, + "post_count": len(members), + } ) return {"grouping_kind": grouping_kind, "period_code": period_code, "reports": visible} diff --git a/backend/app/report_ingestion.py b/backend/app/report_ingestion.py index b714f3667..022daed3e 100644 --- a/backend/app/report_ingestion.py +++ b/backend/app/report_ingestion.py @@ -351,7 +351,7 @@ async def persist_period_report( period_code: str, report: PeriodReport, ) -> None: - """Replace the stored report, member scores, leftover pairs, and item bank.""" + """Replace the stored report, member scores, leftover pairs, leftover-map axes, and item bank.""" await conn.execute( """ delete from report_period_score @@ -460,6 +460,22 @@ async def persist_period_report( pair.expected_response, pair.leftover_map_rank, ) + for axis in report.leftover_map_axes: + await conn.execute( + """ + insert into report_leftover_map_axis ( + grouping_kind, grouping_key, period_code, rubric_version, + axis_index, leftover_singular_value, leftover_share + ) values ($1,$2,$3,$4,$5,$6,$7) + """, + grouping_kind, + grouping_key, + period_code, + RUBRIC_VERSION, + axis.axis_index, + axis.leftover_singular_value, + axis.leftover_share, + ) def _groups_from_rows( @@ -620,6 +636,17 @@ async def fetch_period_reports( period_code, RUBRIC_VERSION, ) + leftover_axes = await conn.fetch( + """ + select grouping_key, axis_index, leftover_singular_value, leftover_share + from report_leftover_map_axis + where grouping_kind = $1 and period_code = $2 and rubric_version = $3 + order by grouping_key, axis_index + """, + grouping_kind, + period_code, + RUBRIC_VERSION, + ) status_labels = await labels_for_codes( conn, [row["ticket_status_code"] for row in members if row["ticket_status_code"]], @@ -633,6 +660,9 @@ async def fetch_period_reports( leftover_by_group: dict[str, list[asyncpg.Record]] = defaultdict(list) for row in leftover: leftover_by_group[row["grouping_key"]].append(row) + leftover_axes_by_group: dict[str, list[asyncpg.Record]] = defaultdict(list) + for row in leftover_axes: + leftover_axes_by_group[row["grouping_key"]].append(row) payload: list[dict[str, Any]] = [] for header in headers: grouping_key = header["grouping_key"] @@ -724,6 +754,14 @@ async def fetch_period_reports( } for row in leftover_by_group.get(header["grouping_key"], []) ], + "leftover_map_axes": [ + { + "axis_index": int(row["axis_index"]), + "leftover_singular_value": float(row["leftover_singular_value"]), + "leftover_share": float(row["leftover_share"]), + } + for row in leftover_axes_by_group.get(header["grouping_key"], []) + ], } ) return payload diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index cef8eafb8..db59bdea1 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -132,6 +132,11 @@ / "migrations" / "0164_report_leftover_map_rank.sql" ) +_LEFTOVER_MAP_AXIS_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0169_report_leftover_map_axis.sql" +) def _postgres_available() -> bool: @@ -249,6 +254,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_AXIS_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " @@ -4739,6 +4745,10 @@ def test_seed_period_report_surfaces_on_get_reports(client, demo_analyst_token, if observed is None or expected is None: continue assert abs(pair["leftover_residual"] - (observed - expected)) < 1e-6 + leftover_axes = high_report.get("leftover_map_axes", []) + assert [axis["axis_index"] for axis in leftover_axes] == [1, 2] + assert all(axis["leftover_singular_value"] >= 0 for axis in leftover_axes) + assert all(0.0 <= axis["leftover_share"] <= 1.0 for axis in leftover_axes) week3 = client.get( "/api/reports/process_unit/2026-W03", diff --git a/docs/adr/0003-fast-mlsirm-report-integration.md b/docs/adr/0003-fast-mlsirm-report-integration.md index bdf234b65..439380d00 100644 --- a/docs/adr/0003-fast-mlsirm-report-integration.md +++ b/docs/adr/0003-fast-mlsirm-report-integration.md @@ -100,10 +100,14 @@ than one large PR: `information_polytomous` (Lord, 1980 max-info). Persist the ranking (`report_item_information`) and show the rank-1 item on the Period reports panel. Do not reimplement an information function here. -7. **Leftover-pair slice** (shipped in 0.71.2; ADR 0017 / 0018): after +7. **Leftover-pair slice** (shipped in 0.71.2; ADR 0017 / 0018 / 0048 / 0049): 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. +8. **Leftover-map axis-share slice** (ADR 0148): persist Gabriel inertia + `σ_k² / Σ_j σ_j²` of leftover-map axes 1 and 2 on the same residual + SVD. Rank-0 residuals emit two zero-share axes. Do not invent a + leftover score. **TEPP boundary.** [ARCHITECTURE.md](../../ARCHITECTURE.md) already assigns calibrated temporal/event measurement to diff --git a/docs/adr/0148-leftover-map-axis-share.md b/docs/adr/0148-leftover-map-axis-share.md new file mode 100644 index 000000000..2540ddf91 --- /dev/null +++ b/docs/adr/0148-leftover-map-axis-share.md @@ -0,0 +1,60 @@ +# ADR 0148 — Persist leftover-map axis share + +**Decision status:** Accepted +**Date:** 2026-08-24 + +## Context + +ADR 0048 persists closest and farthest leftover post–criterion pairs +from a Gabriel (1971) biplot of the residual `R = Y − E[Y|θ, item]` +after a real GRM/GPCM score (Jeon et al., 2021, eq. 3). Buyers can +open those pairs (ADR 0049) but cannot yet read how much leftover-map +structure sits on axis 1 versus axis 2. + +Gabriel inertia of leftover-map axis `k` is `σ_k² / Σ_j σ_j²`. That +share is a report-level property of the residual SVD, not a +post-identifying leftover score and not a second theta. Denormalizing +it onto each leftover pair would violate 3NF. + +`fast-mlsirm` still exposes no leftover-pair or leftover-map API. +LineageWeave must not fork LSIRM or invent leftover numbers when the +residual is rank-0. + +## Decision + +After the same residual SVD that produces leftover pairs, persist +exactly two leftover-map axes (axis 1 and axis 2) per period report in +`report_leftover_map_axis` (3NF, two-or-more-word `snake_case`). + +Share is `σ_k² / Σ_j σ_j²` from the leftover singular values that +survive the leftover singular floor. Rank-0 residuals emit two +zero-share axes so `make seed` can name leftover-map structure without +inventing a leftover score. Missing response cells stay out of the +factorization. + +Cascade the rows with `report_period_score`. Axes are aggregate and +non-identifying: ABAC that hides leftover pairs does not hide axis +share. Do not store a second theta. Do not invent leftover numbers. + +The biplot lives in `lineageweave/leftover_pairs.py` so leftover tests +do not import `period_report` or `fast_mlsirm`. + +## Consequences + +Rebuild and seed write leftover-map axes in the same transaction as +leftover pairs. `GET /api/reports/{grouping}/{period}` returns +`leftover_map_axes` next to `leftover_pairs`. The Period reports panel +shows leftover-axis share badges and a caption that tells the buyer to +open a leftover pair. Migration `0169_report_leftover_map_axis.sql` +upgrades volumes that already applied `0001`. + +## 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..844794550 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.12.18", + "version": "2.12.19", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 065c8ef40..b738e142e 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -966,6 +966,18 @@ describe("App, authenticated", () => { leftover_map_rank: 1, }, ], + leftover_map_axes: [ + { + axis_index: 1, + leftover_singular_value: 1.84, + leftover_share: 0.82, + }, + { + axis_index: 2, + leftover_singular_value: 0.86, + leftover_share: 0.18, + }, + ], members: [ { post_id: "post-1", @@ -3531,6 +3543,11 @@ describe("App, authenticated", () => { expect(screen.getByText(/TEST-PU-REPORT/)).toBeInTheDocument(); expect(screen.getAllByText("shared metric").length).toBeGreaterThan(0); expect(screen.getAllByText(/CAT: sales-lead I=0\.70/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/leftover axis 1 82%/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/leftover axis 2 18%/).length).toBeGreaterThan(0); + expect(screen.getByLabelText("Leftover-map axis share")).toHaveTextContent( + "Open a leftover pair to read the post–criterion cell", + ); expect(screen.getByRole("button", { name: /open report period 2026-W03/i })).toHaveTextContent( "vs 2026-W02: +0.92", ); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6aa467231..470ae8014 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3409,6 +3409,21 @@ function ReportsPanel({ {report.selected_items[0].information.toFixed(2)} )} + {report.leftover_map_axes?.map((axis) => ( + + {tf("leftover axis {axis} {share}%", { + axis: axis.axis_index, + share: (axis.leftover_share * 100).toFixed(0), + })} + + ))} + {report.leftover_map_axes && report.leftover_map_axes.length > 0 && ( +

+ {t( + "Leftover-map axis share is Gabriel inertia of residual SVD axes 1 and 2. Open a leftover pair to read the post–criterion cell. The shares do not invent a leftover score.", + )} +

+ )} {report.leftover_pairs && report.leftover_pairs.length > 0 && ( { "Search", "Page", "Answer", + "leftover axis {axis} {share}%", + "Leftover-map axis share", + "Leftover-map axis share is Gabriel inertia of residual SVD axes 1 and 2. Open a leftover pair to read the post–criterion cell. The shares do not invent a leftover score.", "Leftover pairs", "Closest leftover", "Farthest leftover", diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 06545e35a..ef451f253 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -374,6 +374,10 @@ const TRANSLATIONS: Partial>> = { "관측된 원본 작성자 {total}명 중 게시물 수 기준 상위 {shown}명을 표시합니다.", "Interactive questions are unavailable right now; saved evidence remains available.": "대화형 질문을 지금 사용할 수 없습니다. 저장된 근거는 계속 확인할 수 있습니다.", + "leftover axis {axis} {share}%": "잔차 축 {axis} {share}%", + "Leftover-map axis share": "잔차 지도 축 비율", + "Leftover-map axis share is Gabriel inertia of residual SVD axes 1 and 2. Open a leftover pair to read the post–criterion cell. The shares do not invent a leftover score.": + "잔차 지도 축 비율은 잔차 SVD 축 1과 2의 Gabriel 관성입니다. 글–기준 셀을 읽으려면 잔차 쌍을 여세요. 이 비율은 잔차 점수를 만들어내지 않습니다.", "Leftover pairs": "잔여 쌍", "Closest leftover": "가장 가까운 잔여", "Farthest leftover": "가장 먼 잔여", @@ -751,6 +755,10 @@ const TRANSLATIONS: Partial>> = { "显示按文章数排序的 {total} 位已观察来源作者中的前 {shown} 位。", "Interactive questions are unavailable right now; saved evidence remains available.": "交互式提问暂不可用;已保存的证据仍可查看。", + "leftover axis {axis} {share}%": "残差轴 {axis} {share}%", + "Leftover-map axis share": "残差图轴占比", + "Leftover-map axis share is Gabriel inertia of residual SVD axes 1 and 2. Open a leftover pair to read the post–criterion cell. The shares do not invent a leftover score.": + "残差图轴占比是残差 SVD 第 1、2 轴的 Gabriel 惯量。打开一个残差配对可查看文章–准则单元格。这些占比不会虚构残差分数。", "Leftover pairs": "残余配对", "Closest leftover": "最近残余", "Farthest leftover": "最远残余", @@ -1128,6 +1136,10 @@ const TRANSLATIONS: Partial>> = { "投稿数順に、観測された{total}名の元投稿者のうち上位{shown}名を表示しています。", "Interactive questions are unavailable right now; saved evidence remains available.": "対話形式の質問は現在利用できません。保存された証拠は確認できます。", + "leftover axis {axis} {share}%": "残差軸 {axis} {share}%", + "Leftover-map axis share": "残差マップ軸の比率", + "Leftover-map axis share is Gabriel inertia of residual SVD axes 1 and 2. Open a leftover pair to read the post–criterion cell. The shares do not invent a leftover score.": + "残差マップ軸の比率は、残差 SVD の第1軸と第2軸の Gabriel 慣性です。投稿–基準セルを読むには残差ペアを開いてください。この比率から残差スコアを作りません。", "Leftover pairs": "残差ペア", "Closest leftover": "最も近い残差", "Farthest leftover": "最も遠い残差", @@ -1505,6 +1517,10 @@ const TRANSLATIONS: Partial>> = { "Đang hiển thị {shown} tác giả nguồn hàng đầu trong số {total} tác giả đã quan sát, xếp theo số bài viết.", "Interactive questions are unavailable right now; saved evidence remains available.": "Câu hỏi tương tác hiện không khả dụng; bằng chứng đã lưu vẫn có thể xem.", + "leftover axis {axis} {share}%": "trục phần dư {axis} {share}%", + "Leftover-map axis share": "Tỷ trọng trục bản đồ phần dư", + "Leftover-map axis share is Gabriel inertia of residual SVD axes 1 and 2. Open a leftover pair to read the post–criterion cell. The shares do not invent a leftover score.": + "Tỷ trọng trục bản đồ phần dư là quán tính Gabriel của các trục SVD phần dư 1 và 2. Mở một cặp phần dư để đọc ô bài viết–tiêu chí. Các tỷ trọng này không tạo ra điểm phần dư.", "Leftover pairs": "Cặp phần dư", "Closest leftover": "Phần dư gần nhất", "Farthest leftover": "Phần dư xa nhất", diff --git a/lineageweave/leftover_pairs.py b/lineageweave/leftover_pairs.py index 297d47012..06a85d715 100644 --- a/lineageweave/leftover_pairs.py +++ b/lineageweave/leftover_pairs.py @@ -11,7 +11,8 @@ 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. Axis share +is the Gabriel inertia of the first two leftover-map axes (ADR 0148). """ from __future__ import annotations @@ -41,6 +42,15 @@ class LeftoverPair: leftover_map_rank: int +@dataclass(frozen=True) +class LeftoverMapAxis: + """Gabriel inertia for one leftover-map axis on a period report.""" + + axis_index: int + leftover_singular_value: float + leftover_share: float + + def leftover_pairs_from_residual( post_ids: list[str], item_codes: tuple[str, ...], @@ -58,6 +68,22 @@ def leftover_pairs_from_residual( expected ``E[Y|θ, item]``. Stored leftover-map rank is the number of Gabriel singular values above the floor. """ + pairs, _axes = leftover_map_from_residual(post_ids, item_codes, matrix, expected) + return pairs + + +def leftover_map_from_residual( + post_ids: list[str], + item_codes: tuple[str, ...], + matrix: np.ndarray, + expected: np.ndarray, +) -> tuple[tuple[LeftoverPair, ...], tuple[LeftoverMapAxis, ...]]: + """Leftover pairs plus the first two Gabriel leftover-map axis shares. + + Axis share is ``σ_k² / Σ_j σ_j²`` for leftover-map axes 1 and 2. + Rank-0 residuals emit two zero-share axes so seed can name leftover-map + structure without inventing a leftover score. + """ if matrix.shape != (len(post_ids), len(item_codes)): raise ValueError( f"matrix shape {matrix.shape} does not match {len(post_ids)} posts × {len(item_codes)} items" @@ -74,7 +100,7 @@ def leftover_pairs_from_residual( if observed_mask[person, item] ] if not observed: - return () + return (), () keep_person, keep_item = _complete_case_masks(observed_mask) person_index = np.flatnonzero(keep_person) @@ -83,9 +109,11 @@ def leftover_pairs_from_residual( center = float(np.mean(residual[np.ix_(person_index, item_index)])) else: center = float(np.mean([residual[person, item] for person, item in observed])) - person_pos, item_pos, leftover_map_rank = _complete_case_positions( + person_pos, item_pos, singular = _complete_case_positions( residual, center, keep_person, keep_item ) + axes = leftover_map_axes_from_singular(singular) + leftover_map_rank = int(singular.size) candidates: list[tuple[float, str, str, float, float, float]] = [] if person_pos is not None and item_pos is not None: person_index = np.flatnonzero(keep_person) @@ -123,10 +151,11 @@ def leftover_pairs_from_residual( ) closest = min(candidates, key=lambda row: (row[0], row[1], row[2])) farthest = max(candidates, key=lambda row: (row[0], row[1], row[2])) - return ( + pairs = ( _pair_from_candidate(PAIR_KIND_CLOSEST, closest, leftover_map_rank), _pair_from_candidate(PAIR_KIND_FARTHEST, farthest, leftover_map_rank), ) + return pairs, axes def _candidate_row( @@ -175,6 +204,32 @@ def _pair_from_candidate( ) +def leftover_map_axes_from_singular(singular: np.ndarray) -> tuple[LeftoverMapAxis, ...]: + """Gabriel axis inertia for leftover-map axes 1 and 2. + + Share is ``σ_k² / Σ_j σ_j²``. Values at or below the leftover + singular floor do not enter the denominator. Rank-0 residuals emit + two zero-share axes. + """ + values = np.asarray(singular, dtype=np.float64).reshape(-1) + kept = values[np.isfinite(values) & (values > _LEFTOVER_SINGULAR_FLOOR)] + total = float(np.sum(kept * kept)) if kept.size else 0.0 + axes: list[LeftoverMapAxis] = [] + for index in (1, 2): + value = float(kept[index - 1]) if kept.size >= index else 0.0 + if not np.isfinite(value) or value < 0.0: + value = 0.0 + share = (value * value / total) if total > 0.0 else 0.0 + axes.append( + LeftoverMapAxis( + axis_index=index, + leftover_singular_value=value, + leftover_share=max(share, 0.0), + ) + ) + return tuple(axes) + + def _complete_case_masks(observed: np.ndarray) -> tuple[np.ndarray, np.ndarray]: """Drop incomplete rows, then incomplete columns among remaining rows.""" keep_person = observed.any(axis=1) @@ -191,17 +246,20 @@ def _complete_case_positions( center: float, keep_person: np.ndarray, keep_item: np.ndarray, -) -> tuple[np.ndarray | None, np.ndarray | None, int]: +) -> tuple[np.ndarray | None, np.ndarray | None, np.ndarray]: """Gabriel coordinates on the complete-case residual rectangle only.""" person_index = np.flatnonzero(keep_person) item_index = np.flatnonzero(keep_item) + empty_singular = np.zeros(0, dtype=np.float64) if person_index.size == 0 or item_index.size == 0: - return None, None, 0 + return None, None, empty_singular filled = residual[np.ix_(person_index, item_index)] - center return _leftover_map_positions(filled) -def _leftover_map_positions(filled: np.ndarray) -> tuple[np.ndarray, np.ndarray, int]: +def _leftover_map_positions( + filled: np.ndarray, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Gabriel coordinates ordered by descending singular value. NumPy's SVD contract returns singular values largest-first, so filtering @@ -209,19 +267,25 @@ def _leftover_map_positions(filled: np.ndarray) -> tuple[np.ndarray, np.ndarray, the two leading leftover-map axes. Rank-0 residuals collapse to the origin. """ n_persons, n_items = filled.shape + empty_singular = np.zeros(0, dtype=np.float64) if n_persons == 0 or n_items == 0 or not np.any(np.abs(filled) > _LEFTOVER_SINGULAR_FLOOR): return ( np.zeros((n_persons, 1), dtype=np.float64), np.zeros((n_items, 1), dtype=np.float64), - 0, + empty_singular, ) left, singular, right = np.linalg.svd(filled, full_matrices=False) keep = singular > _LEFTOVER_SINGULAR_FLOOR - leftover_map_rank = int(np.count_nonzero(keep)) + if not np.any(keep): + return ( + np.zeros((n_persons, 1), dtype=np.float64), + np.zeros((n_items, 1), dtype=np.float64), + empty_singular, + ) scale = np.sqrt(singular[keep]) person_pos = left[:, keep] * scale item_pos = right[keep, :].T * scale - return person_pos, item_pos, leftover_map_rank + return person_pos, item_pos, singular[keep] def _pad_map_axes(positions: np.ndarray) -> np.ndarray: diff --git a/lineageweave/period_report.py b/lineageweave/period_report.py index 0b7fabc79..6dedaf742 100644 --- a/lineageweave/period_report.py +++ b/lineageweave/period_report.py @@ -16,13 +16,14 @@ ``fast_mlsirm.information_polytomous`` -- Samejima (1969) GRM / Muraki (1993) GPCM, computed in Rust. A missing bank is not invented. -Leftover post–criterion pairs (ADR 0017) come from the residual +Leftover post–criterion pairs (ADR 0017 / 0048) come from the residual interaction after those IRT main effects: ``R = Y − E[Y|θ, item]``. A Gabriel biplot of ``R`` supplies person and item leftover-map positions. Closest / farthest pairs are the min / max Euclidean -distances on that map (Jeon et al., 2021, eq. 3). ``fast-mlsirm`` -has no leftover-pair API; this module does not invent a second IRT -fit and does not fork LSIRM. +distances on that map (Jeon et al., 2021, eq. 3). Leftover-map axis +share is Gabriel inertia ``σ_k² / Σ_j σ_j²`` of residual SVD axes 1 +and 2 (ADR 0148). ``fast-mlsirm`` has no leftover-pair API; this +module does not invent a second IRT fit and does not fork LSIRM. This module is pure compute. Persistence lives in ``backend/app/report_ingestion.py``. TEPP is not used here; temporal @@ -43,7 +44,8 @@ validate_irt_response_matrix, ) -from .leftover_pairs import LeftoverPair, leftover_pairs_from_residual +from .leftover_pairs import LeftoverMapAxis, LeftoverPair, leftover_map_from_residual +from .leftover_pairs import leftover_pairs_from_residual as leftover_pairs_from_residual from .leftover_pairs import PAIR_KIND_CLOSEST as PAIR_KIND_CLOSEST from .leftover_pairs import PAIR_KIND_FARTHEST as PAIR_KIND_FARTHEST from .post_evaluation import CRITERION_CODES, IRT_CATEGORY_COUNT @@ -111,6 +113,7 @@ class PeriodReport: delta_mean_theta: float | None = None selected_items: tuple[SelectedItem, ...] = () leftover_pairs: tuple[LeftoverPair, ...] = () + leftover_map_axes: tuple[LeftoverMapAxis, ...] = () def _sigmoid(value: np.ndarray) -> np.ndarray: @@ -243,6 +246,20 @@ def expected_category_matrix(matrix: np.ndarray, probs: np.ndarray) -> np.ndarra return np.where(np.isnan(matrix), np.nan, expected) +def leftover_map_for_fit( + post_ids: list[str], + item_codes: tuple[str, ...], + matrix: np.ndarray, + model: str, + theta: np.ndarray, + fit: PolytomousFit, +) -> tuple[tuple[LeftoverPair, ...], tuple[LeftoverMapAxis, ...]]: + """Leftover pairs and leftover-map axis share from fitted GRM/GPCM.""" + probs = _category_probabilities(model, theta, fit) + expected = expected_category_matrix(matrix, probs) + return leftover_map_from_residual(post_ids, item_codes, matrix, expected) + + def leftover_pairs_for_fit( post_ids: list[str], item_codes: tuple[str, ...], @@ -252,9 +269,8 @@ def leftover_pairs_for_fit( fit: PolytomousFit, ) -> tuple[LeftoverPair, ...]: """Leftover pairs from the already-fitted GRM/GPCM main effects.""" - probs = _category_probabilities(model, theta, fit) - expected = expected_category_matrix(matrix, probs) - return leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) + pairs, _axes = leftover_map_for_fit(post_ids, item_codes, matrix, model, theta, fit) + return pairs def _member_scores(post_ids: list[str], scores: dict[str, np.ndarray]) -> tuple[MemberScore, ...]: @@ -306,6 +322,9 @@ def calibrate_period_report( theta = np.asarray(scores["theta_eap"], dtype=np.float64) mean_theta = float(theta.mean()) item_bank = item_bank_from_fit(fit, item_codes, source_period_code) + leftover_pairs, leftover_map_axes = leftover_map_for_fit( + post_ids, item_codes, matrix, selected, theta, fit + ) return PeriodReport( selected_model=selected, mean_theta=mean_theta, @@ -319,7 +338,8 @@ def calibrate_period_report( item_bank=item_bank, link_method=LINK_METHOD_FREE, selected_items=rank_items_by_information(item_bank, mean_theta), - leftover_pairs=leftover_pairs_for_fit(post_ids, item_codes, matrix, selected, theta, fit), + leftover_pairs=leftover_pairs, + leftover_map_axes=leftover_map_axes, ) @@ -348,6 +368,9 @@ def score_period_on_bank( item_type="polytomous", response_process="cumulative", ) + leftover_pairs, leftover_map_axes = leftover_map_for_fit( + post_ids, item_bank.item_codes, matrix, item_bank.model, theta, fit + ) return PeriodReport( selected_model=item_bank.model, mean_theta=mean_theta, @@ -367,9 +390,8 @@ def score_period_on_bank( else mean_theta - float(previous_mean_theta) ), selected_items=rank_items_by_information(item_bank, mean_theta), - leftover_pairs=leftover_pairs_for_fit( - post_ids, item_bank.item_codes, matrix, item_bank.model, theta, fit - ), + leftover_pairs=leftover_pairs, + leftover_map_axes=leftover_map_axes, ) diff --git a/migrations/0169_report_leftover_map_axis.sql b/migrations/0169_report_leftover_map_axis.sql new file mode 100644 index 000000000..03b4c4ec4 --- /dev/null +++ b/migrations/0169_report_leftover_map_axis.sql @@ -0,0 +1,20 @@ +-- ADR 0148: persist leftover-map axis share (Gabriel inertia of residual +-- SVD axes 1 and 2). CREATE IF NOT EXISTS so a volume that already ran +-- 0001 still upgrades. Report-level 3NF; cascade with the period score. + +create table if not exists report_leftover_map_axis ( + grouping_kind text not null, + grouping_key text not null, + period_code text not null, + rubric_version text not null, + axis_index integer not null, + leftover_singular_value numeric not null, + leftover_share numeric not null, + primary key (grouping_kind, grouping_key, period_code, rubric_version, axis_index), + foreign key (grouping_kind, grouping_key, period_code, rubric_version) + references report_period_score (grouping_kind, grouping_key, period_code, rubric_version) + on delete cascade, + check (axis_index in (1, 2)), + check (leftover_singular_value >= 0), + check (leftover_share >= 0 and leftover_share <= 1) +); diff --git a/migrations/rollback/0169_report_leftover_map_axis.sql b/migrations/rollback/0169_report_leftover_map_axis.sql new file mode 100644 index 000000000..9dfca69fe --- /dev/null +++ b/migrations/rollback/0169_report_leftover_map_axis.sql @@ -0,0 +1 @@ +drop table if exists report_leftover_map_axis; diff --git a/pyproject.toml b/pyproject.toml index 9a3975230..8b01c9850 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.12.18" +version = "2.12.19" 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..514ae2768 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -1215,6 +1215,22 @@ def _persist_seed_period_report( pair.leftover_map_rank, ), ) + for axis in report.leftover_map_axes: + cur.execute( + "insert into report_leftover_map_axis (" + "grouping_kind, grouping_key, period_code, rubric_version, " + "axis_index, leftover_singular_value, leftover_share" + ") values (%s,%s,%s,%s,%s,%s,%s)", + ( + grouping_kind, + grouping_key, + period_code, + RUBRIC_VERSION, + axis.axis_index, + axis.leftover_singular_value, + axis.leftover_share, + ), + ) def _seed_demo_period_report(cur, author_account_id, corporate_entity_id, process_unit_id) -> None: diff --git a/tests/test_leftover_pairs.py b/tests/test_leftover_pairs.py index 72a2de975..2e6881801 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 0148, ADR 0163, and ADR 0164. Uses a constructed residual matrix so the closest and farthest pair are known without calling ``fit_polytomous``. Loads @@ -46,6 +46,8 @@ def _load_leftover(): PAIR_KIND_CLOSEST = leftover.PAIR_KIND_CLOSEST PAIR_KIND_FARTHEST = leftover.PAIR_KIND_FARTHEST leftover_pairs_from_residual = leftover.leftover_pairs_from_residual +leftover_map_from_residual = leftover.leftover_map_from_residual +leftover_map_axes_from_singular = leftover.leftover_map_axes_from_singular def _assert_residual_reconciles(pair) -> None: @@ -209,6 +211,62 @@ def test_leftover_residual_rejects_database_tolerance_boundary() -> None: ) +def test_rank_one_leftover_map_puts_all_inertia_on_axis_one() -> None: + """A rank-1 residual must report leftover-map share 1 on axis 1, 0 on axis 2.""" + post_ids = ["post-a", "post-b", "post-c"] + item_codes = ("item_near", "item_mid", "item_far") + matrix = np.array( + [ + [2.0, 0.0, -2.0], + [0.0, 0.0, 0.0], + [-2.0, 0.0, 2.0], + ], + dtype=np.float64, + ) + expected = np.zeros_like(matrix) + pairs, axes = leftover_map_from_residual(post_ids, item_codes, matrix, expected) + assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST] + assert [axis.axis_index for axis in axes] == [1, 2] + assert axes[0].leftover_share == pytest.approx(1.0) + assert axes[1].leftover_share == pytest.approx(0.0) + assert axes[0].leftover_singular_value > 0.0 + assert axes[1].leftover_singular_value == pytest.approx(0.0) + assert leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) == pairs + + +def test_zero_residual_emits_two_zero_share_leftover_map_axes() -> None: + post_ids = ["alpha-post", "beta-post"] + item_codes = ("item_one", "item_two") + matrix = np.ones((2, 2), dtype=np.float64) + expected = np.ones((2, 2), dtype=np.float64) + pairs, axes = leftover_map_from_residual(post_ids, item_codes, matrix, expected) + assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST] + assert [axis.axis_index for axis in axes] == [1, 2] + assert axes[0].leftover_share == pytest.approx(0.0) + assert axes[1].leftover_share == pytest.approx(0.0) + assert axes[0].leftover_singular_value == pytest.approx(0.0) + assert axes[1].leftover_singular_value == pytest.approx(0.0) + + +def test_leftover_map_axes_from_singular_use_gabriel_inertia() -> None: + """Share is σ_k² / Σ_j σ_j² from the actual singular values, never a leftover score.""" + singular = np.array([3.0, 1.0, 0.5], dtype=np.float64) + total = float(np.sum(singular * singular)) + axes = leftover_map_axes_from_singular(singular) + assert [axis.axis_index for axis in axes] == [1, 2] + assert axes[0].leftover_singular_value == pytest.approx(3.0) + assert axes[1].leftover_singular_value == pytest.approx(1.0) + assert axes[0].leftover_share == pytest.approx(9.0 / total) + assert axes[1].leftover_share == pytest.approx(1.0 / total) + assert leftover_map_axes_from_singular(np.zeros(0))[0].leftover_share == pytest.approx(0.0) + assert leftover_map_from_residual( + ["post-empty"], + ("item_one",), + np.array([[np.nan]], dtype=np.float64), + np.array([[0.0]], dtype=np.float64), + ) == ((), ()) + + def test_rank_four_pair_distances_match_two_dimensional_gabriel_coords() -> None: """Jeon leftover_distance is Euclidean on the 2D map, not the full SVD rank.""" post_ids = ["post-a", "post-b", "post-c", "post-d"] @@ -295,7 +353,7 @@ def test_nonfinite_map_distance_falls_back_to_centered_residual( lambda *_args: ( np.array([[np.inf]], dtype=np.float64), np.array([[-np.inf]], dtype=np.float64), - 1, + np.array([1.0], dtype=np.float64), ), ) pairs = leftover_pairs_from_residual( @@ -314,7 +372,7 @@ def test_empty_observation_mask_has_no_complete_case_axes() -> None: keep_person, keep_item = leftover._complete_case_masks(observed) assert not keep_person.any() assert not keep_item.any() - person_pos, item_pos, rank = leftover._complete_case_positions( + person_pos, item_pos, singular = leftover._complete_case_positions( np.zeros((1, 1), dtype=np.float64), 0.0, keep_person, @@ -322,7 +380,7 @@ def test_empty_observation_mask_has_no_complete_case_axes() -> None: ) assert person_pos is None assert item_pos is None - assert rank == 0 + assert singular.size == 0 def test_leftover_map_rank_rejects_negative_rank() -> None: diff --git a/tests/test_migration_replay.py b/tests/test_migration_replay.py index 8af142a75..5216baf7e 100644 --- a/tests/test_migration_replay.py +++ b/tests/test_migration_replay.py @@ -1,3 +1,4 @@ +import re import subprocess from pathlib import Path @@ -49,6 +50,27 @@ def test_migrate_sh_replays_leftover_pair_migration_on_existing_volumes() -> Non subprocess.run(["sh", "-n", str(migration_script)], check=True) +def test_migrate_sh_replays_leftover_map_axis_migration_on_existing_volumes() -> None: + """migrate.sh's replay window must cover 0169 (report_leftover_map_axis). + + Volumes created before leftover-map axis share shipped never get + report_leftover_map_axis unless migrate.sh replays 0169 on every + `docker compose up`. GET /api/reports/{grouping}/{period} then 500s + on undefined_table the first time a period actually has leftover-map + axes. + + ADR 0166's general four-digit filename boundary covers 0169 without a + per-migration allowlist entry, so this asserts the migration file's + own name still matches that boundary shape rather than a stale + literal `migrate.sh` no longer contains. + """ + migration_name = "0169_report_leftover_map_axis.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 + + 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 318ad23ae..f5cee497b 100644 --- a/tests/test_period_report.py +++ b/tests/test_period_report.py @@ -270,6 +270,13 @@ def test_calibrated_report_attaches_leftover_pairs() -> None: pair.observed_response - pair.expected_response, abs=1e-6 ) assert pair.leftover_map_rank >= 0 + assert [axis.axis_index for axis in report.leftover_map_axes] == [1, 2] + for axis in report.leftover_map_axes: + assert axis.leftover_singular_value >= 0.0 + assert 0.0 <= axis.leftover_share <= 1.0 + assert np.isfinite(axis.leftover_singular_value) + assert np.isfinite(axis.leftover_share) + assert sum(axis.leftover_share for axis in report.leftover_map_axes) <= 1.0 + 1e-9 diff --git a/tests/test_schema.py b/tests/test_schema.py index 81cc3fcfd..253ec3a84 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -53,6 +53,11 @@ / "migrations" / "0164_report_leftover_map_rank.sql" ) +_LEFTOVER_MAP_AXIS_MIGRATION = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0169_report_leftover_map_axis.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_AXIS_MIGRATION.read_text()) conn.commit() yield conn finally: @@ -134,6 +140,7 @@ def test_migration_applies_cleanly(schema_db) -> None: "report_item_parameter", "report_item_information", "report_leftover_pair", + "report_leftover_map_axis", "post_summary_result", "post_summary_event", "post_summary_role", @@ -220,6 +227,18 @@ def test_leftover_pair_names_leftover_map_rank_column(schema_db) -> None: assert columns["leftover_map_rank"] == "YES" +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: + cur.execute( + """ + select confrelid::regclass::text + from pg_constraint + where conrelid = 'report_leftover_map_axis'::regclass and contype = 'f' + """ + ) + targets = {row[0] for row in cur.fetchall()} + assert "report_period_score" in targets def test_corporate_hierarchy_recursive_query_returns_correct_shape(schema_db) -> None: diff --git a/uv.lock b/uv.lock index 112d46840..095fa6696 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.12.18" +version = "2.12.19" source = { editable = "." } dependencies = [ { name = "certifi" }, From eb44e641d63ad32a11112eb8f3ae296305e6e4b7 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Mon, 24 Aug 2026 16:54:17 +0900 Subject: [PATCH 2/3] feat: name leftover complete-case coverage (v2.12.17) Stacked on the axis-share rebased branch. Complete-case coverage (ADR 0168) names how many scored posts entered the Gabriel factorization; incomplete rows are excluded, never filled with zero. A leftover map without a complete-case rectangle emits no pair instead of a fallback. --- AGENTS.md | 3 + ARCHITECTURE.md | 7 +- CHANGELOG.d/2.12.17-leftover-map-coverage.md | 11 +++ CHANGELOG.md | 5 ++ backend/app/report_ingestion.py | 53 ++++++++++++- backend/tests/test_api.py | 13 ++++ docs/adr/0048-persist-lsirm-leftover-pairs.md | 2 +- docs/adr/0049-leftover-pair-report-ui.md | 3 +- ...168-leftover-map-complete-case-coverage.md | 74 +++++++++++++++++++ frontend/src/App.test.tsx | 13 ++++ frontend/src/App.tsx | 8 ++ frontend/src/api.ts | 10 +++ frontend/src/i18n.ts | 12 +++ lineageweave/leftover_pairs.py | 73 ++++++++++++++---- lineageweave/period_report.py | 35 ++++++++- .../0168_report_leftover_map_coverage.sql | 27 +++++++ .../0168_report_leftover_map_coverage.sql | 1 + scripts/seed_demo_data.py | 23 ++++++ tests/test_leftover_pairs.py | 64 +++++++++++----- tests/test_period_report.py | 7 ++ tests/test_schema.py | 21 ++++++ 21 files changed, 423 insertions(+), 42 deletions(-) create mode 100644 CHANGELOG.d/2.12.17-leftover-map-coverage.md create mode 100644 docs/adr/0168-leftover-map-complete-case-coverage.md create mode 100644 migrations/0168_report_leftover_map_coverage.sql create mode 100644 migrations/rollback/0168_report_leftover_map_coverage.sql diff --git a/AGENTS.md b/AGENTS.md index 0bb84cfac..f7031cc96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -204,6 +204,9 @@ member list so a click opens that post. Leftover-map axis share (ADR 0148) is Gabriel inertia of residual SVD axes 1 and 2 and persists to `report_leftover_map_axis`. Rank-0 residuals emit two zero-share axes; the shares are report-level and are not a leftover score. +Complete-case coverage (ADR 0168) persists to +`report_leftover_map_coverage` and captions the pair list with how +many scored posts entered the map. `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 9dbf19f31..a0d5d5932 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -598,7 +598,9 @@ ADR 0017 / 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. Leftover-map axis share (Gabriel inertia of residual SVD axes 1 and 2; ADR 0148) persists to -`report_leftover_map_axis`. Results persist to +`report_leftover_map_axis`. Complete-case leftover-map coverage (ADR +0168) persists to `report_leftover_map_coverage` so readers see how +many scored posts entered the factorization. Results persist to `report_period_score` / `report_member_score`. `GET /api/reports/{grouping}` lists the trend; `GET /api/reports/{grouping}/{period}` is ABAC-filtered; @@ -613,7 +615,8 @@ 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 effects) above the member list, leftover-map axis share for residual -SVD axes 1 and 2, and the +SVD axes 1 and 2, and complete-case coverage captions (map used N of M +scored posts), plus the PU / corp / thread comparison -- never a placeholder. TEPP is unchanged. ## Phase 6b: Knowledge Graph as a real Ontology + Semantic Layer diff --git a/CHANGELOG.d/2.12.17-leftover-map-coverage.md b/CHANGELOG.d/2.12.17-leftover-map-coverage.md new file mode 100644 index 000000000..02c6858b3 --- /dev/null +++ b/CHANGELOG.d/2.12.17-leftover-map-coverage.md @@ -0,0 +1,11 @@ +# 2.12.17 — Leftover map complete-case coverage + +## Added + +- Period leftover maps now persist how many scored posts entered the + complete-case Gabriel factorization (ADR 0168). Missing cells stay + out of the map; they are never treated as zero. +- After seed, the leftover pair list is captioned + “Leftover map used N of M scored posts (complete-case)” so a sparse + post that was scored but excluded is visible as coverage, not as a + fabricated pair. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d67eb0e5..e179f3d5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ All notable changes to this project are documented here. Format follows compatibility vocabulary after validating every mapping's term kind. - The PROV-O support profile now mints its product class mappings only in the canonical lowercase namespace while importing the legacy compatibility map. +- Period leftover maps now persist how many scored posts entered the + complete-case Gabriel factorization (ADR 0168). The leftover pair + list is captioned “Leftover map used N of M scored posts + (complete-case)”; incomplete rows stay excluded, never filled with + zero. ### Fixed diff --git a/backend/app/report_ingestion.py b/backend/app/report_ingestion.py index 022daed3e..fca0e5eb6 100644 --- a/backend/app/report_ingestion.py +++ b/backend/app/report_ingestion.py @@ -351,7 +351,7 @@ async def persist_period_report( period_code: str, report: PeriodReport, ) -> None: - """Replace the stored report, member scores, leftover pairs, leftover-map axes, and item bank.""" + """Replace the stored report, member scores, leftover pairs, leftover-map axes, leftover coverage, and item bank.""" await conn.execute( """ delete from report_period_score @@ -476,6 +476,27 @@ async def persist_period_report( axis.leftover_singular_value, axis.leftover_share, ) + if report.leftover_map_coverage is not None: + coverage = report.leftover_map_coverage + await conn.execute( + """ + insert into report_leftover_map_coverage ( + grouping_kind, grouping_key, period_code, rubric_version, + map_post_count, scored_post_count, map_item_count, scored_item_count, + incomplete_post_count, incomplete_item_count + ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + """, + grouping_kind, + grouping_key, + period_code, + RUBRIC_VERSION, + coverage.map_post_count, + coverage.scored_post_count, + coverage.map_item_count, + coverage.scored_item_count, + coverage.incomplete_post_count, + coverage.incomplete_item_count, + ) def _groups_from_rows( @@ -647,6 +668,18 @@ async def fetch_period_reports( period_code, RUBRIC_VERSION, ) + leftover_coverage = await conn.fetch( + """ + select grouping_key, map_post_count, scored_post_count, + map_item_count, scored_item_count, + incomplete_post_count, incomplete_item_count + from report_leftover_map_coverage + where grouping_kind = $1 and period_code = $2 and rubric_version = $3 + """, + grouping_kind, + period_code, + RUBRIC_VERSION, + ) status_labels = await labels_for_codes( conn, [row["ticket_status_code"] for row in members if row["ticket_status_code"]], @@ -663,6 +696,7 @@ async def fetch_period_reports( leftover_axes_by_group: dict[str, list[asyncpg.Record]] = defaultdict(list) for row in leftover_axes: leftover_axes_by_group[row["grouping_key"]].append(row) + leftover_coverage_by_group = {row["grouping_key"]: row for row in leftover_coverage} payload: list[dict[str, Any]] = [] for header in headers: grouping_key = header["grouping_key"] @@ -762,11 +796,28 @@ async def fetch_period_reports( } for row in leftover_axes_by_group.get(header["grouping_key"], []) ], + "leftover_map_coverage": _leftover_map_coverage_payload( + leftover_coverage_by_group.get(header["grouping_key"]) + ), } ) return payload +def _leftover_map_coverage_payload(row: asyncpg.Record | None) -> dict[str, int] | None: + """One complete-case leftover-map coverage row, or None when unpersisted.""" + if row is None: + return None + return { + "map_post_count": int(row["map_post_count"]), + "scored_post_count": int(row["scored_post_count"]), + "map_item_count": int(row["map_item_count"]), + "scored_item_count": int(row["scored_item_count"]), + "incomplete_post_count": int(row["incomplete_post_count"]), + "incomplete_item_count": int(row["incomplete_item_count"]), + } + + async def list_period_report_summaries( conn: asyncpg.Connection, grouping_kind: str, diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index db59bdea1..2db7125a3 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -137,6 +137,11 @@ / "migrations" / "0169_report_leftover_map_axis.sql" ) +_LEFTOVER_MAP_COVERAGE_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0168_report_leftover_map_coverage.sql" +) def _postgres_available() -> bool: @@ -254,6 +259,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_COVERAGE_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_AXIS_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " @@ -4749,6 +4755,13 @@ def test_seed_period_report_surfaces_on_get_reports(client, demo_analyst_token, assert [axis["axis_index"] for axis in leftover_axes] == [1, 2] assert all(axis["leftover_singular_value"] >= 0 for axis in leftover_axes) assert all(0.0 <= axis["leftover_share"] <= 1.0 for axis in leftover_axes) + leftover_coverage = high_report.get("leftover_map_coverage") + assert leftover_coverage is not None + assert leftover_coverage["map_post_count"] <= leftover_coverage["scored_post_count"] + assert leftover_coverage["incomplete_post_count"] == ( + leftover_coverage["scored_post_count"] - leftover_coverage["map_post_count"] + ) + assert leftover_coverage["scored_post_count"] >= 2 week3 = client.get( "/api/reports/process_unit/2026-W03", diff --git a/docs/adr/0048-persist-lsirm-leftover-pairs.md b/docs/adr/0048-persist-lsirm-leftover-pairs.md index bc79cb17e..fc04fd287 100644 --- a/docs/adr/0048-persist-lsirm-leftover-pairs.md +++ b/docs/adr/0048-persist-lsirm-leftover-pairs.md @@ -47,7 +47,7 @@ the IRT matrix is unusable. A rank-0 residual still emits a stable pair so `make seed` is not empty; the stored distance is then zero, not a fabricated interaction. -The UI contract is ADR 0049. +The UI contract is ADR 0049. Complete-case coverage is ADR 0168. ## Consequences diff --git a/docs/adr/0049-leftover-pair-report-ui.md b/docs/adr/0049-leftover-pair-report-ui.md index 3b6e4ce43..142320b76 100644 --- a/docs/adr/0049-leftover-pair-report-ui.md +++ b/docs/adr/0049-leftover-pair-report-ui.md @@ -47,4 +47,5 @@ next action, not only the distance. ## Related Depends on [ADR 0048](0048-persist-lsirm-leftover-pairs.md) and -[ADR 0003](0003-fast-mlsirm-report-integration.md). +[ADR 0003](0003-fast-mlsirm-report-integration.md). Complete-case +coverage of the leftover map is [ADR 0168](0168-leftover-map-complete-case-coverage.md). diff --git a/docs/adr/0168-leftover-map-complete-case-coverage.md b/docs/adr/0168-leftover-map-complete-case-coverage.md new file mode 100644 index 000000000..59f4753fd --- /dev/null +++ b/docs/adr/0168-leftover-map-complete-case-coverage.md @@ -0,0 +1,74 @@ +# ADR 0168 — Name leftover complete-case coverage + +**Decision status:** Accepted +**Date:** 2026-08-24 + +## Context + +ADR 0048 already factorizes the leftover residual +`R = Y − E[Y|θ, item]` on the **complete-case** rectangle (Gabriel, +1971). Incomplete rows are dropped; missing cells are never filled +with zero. ADR 0049 then shows closest and farthest pairs above the +member list. + +The period report still does not tell the buyer how many scored +posts entered that factorization. A sparse post with one missing +criterion is excluded from the map, so closest/farthest pairs can +name two posts while three posts were scored. Without a coverage +caption, the buyer cannot tell whether the map used every scored +post or dropped incomplete rows. + +This slice does not persist leftover-map coordinates (that is a +separate increment), does not disclose residual `R` on pair rows, +and does not change pair click-through. + +## Decision + +After a real GRM/GPCM score, persist one +`report_leftover_map_coverage` row per period report (3NF, +two-or-more-word `snake_case`): + +- `map_post_count` — posts that entered the complete-case leftover + map +- `scored_post_count` — posts with at least one observed cell +- `map_item_count` / `scored_item_count` — the same counts for + criteria +- `incomplete_post_count` / `incomplete_item_count` — scored minus + map, stored so the buyer fact is durable and check-constrained + +Do not store a second theta. Do not fill missing cells with zero. +Do not invent coverage when the IRT matrix is unusable. + +`GET /api/reports/{grouping}/{period}` returns +`leftover_map_coverage` next to `leftover_pairs`. The Period +reports panel renders a caption **above** the leftover pair list: + +> Leftover map used N of M scored posts (complete-case) + +Missing coverage renders nothing. A hidden post never appears as a +leftover pair (ADR 0049); coverage counts remain the fitted map, +not a visibility-filtered recount. + +## Consequences + +Rebuild and seed write coverage in the same transaction as leftover +pairs. Migration `0168_report_leftover_map_coverage.sql` upgrades +volumes that already applied `0001`. `migrate.sh` replays `0168_*` +on existing volumes. + +## 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 + +## Related + +Depends on [ADR 0048](0048-persist-lsirm-leftover-pairs.md) and +[ADR 0049](0049-leftover-pair-report-ui.md). Complements, and does +not replace, leftover-map coordinates or residual-row disclosure. diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index b738e142e..30a4e9bde 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -978,6 +978,14 @@ describe("App, authenticated", () => { leftover_share: 0.18, }, ], + leftover_map_coverage: { + map_post_count: 2, + scored_post_count: 3, + map_item_count: 2, + scored_item_count: 2, + incomplete_post_count: 1, + incomplete_item_count: 0, + }, members: [ { post_id: "post-1", @@ -3559,6 +3567,10 @@ describe("App, authenticated", () => { expect(screen.getByRole("button", { name: /open report post: public post/i })).toHaveTextContent("Open"); expect(screen.getByRole("button", { name: /open report post: public post/i })).toHaveTextContent("due 2026-01-12"); expect(screen.getByLabelText("Leftover pairs")).toBeInTheDocument(); + expect(screen.getByLabelText("Leftover map coverage")).toHaveTextContent( + "Leftover map used 2 of 3 scored posts (complete-case)", + ); + const coverageCaption = screen.getByLabelText("Leftover map coverage"); const closestPair = screen.getByRole("button", { name: /open leftover closest pair: public post/i }); const farthestPair = screen.getByRole("button", { name: /open leftover farthest pair: specification revision requested/i, @@ -3580,6 +3592,7 @@ describe("App, authenticated", () => { expect(farthestPair).toHaveTextContent("rank 1"); 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(); expect(closestPair.compareDocumentPosition(memberButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect( screen.getByRole("button", { name: /open report post: specification revision requested/i }), diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 470ae8014..cf95d794e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3409,6 +3409,14 @@ function ReportsPanel({ {report.selected_items[0].information.toFixed(2)} )} + {report.leftover_map_coverage && report.leftover_map_coverage.scored_post_count > 0 && ( +

+ {tf("Leftover map used {used} of {scored} scored posts (complete-case)", { + used: report.leftover_map_coverage.map_post_count, + scored: report.leftover_map_coverage.scored_post_count, + })} +

+ )} {report.leftover_map_axes?.map((axis) => ( {tf("leftover axis {axis} {share}%", { diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 98c7bb91a..8ccda133b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -782,6 +782,15 @@ export interface LeftoverMapAxis { leftover_share: number; } +export interface LeftoverMapCoverage { + map_post_count: number; + scored_post_count: number; + map_item_count: number; + scored_item_count: number; + incomplete_post_count: number; + incomplete_item_count: number; +} + export interface PeriodGroupReport { grouping_key: string; grouping_label?: string; @@ -798,6 +807,7 @@ export interface PeriodGroupReport { selected_items: SelectedReportItem[]; leftover_pairs: LeftoverPair[]; leftover_map_axes?: LeftoverMapAxis[]; + leftover_map_coverage?: LeftoverMapCoverage | null; } export interface PeriodReports { diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index ef451f253..3ea860c80 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -197,6 +197,9 @@ const TRANSLATIONS: Partial>> = { "Choose a post": "글을 선택하세요", "Analysis runs": "분석 실행", "Period reports": "기간 리포트", + "Leftover map coverage": "잔여 지도 포함 범위", + "Leftover map used {used} of {scored} scored posts (complete-case)": + "잔여 지도는 채점된 글 {scored}개 중 {used}개를 사용했습니다(완전사례)", "Event Lineage": "이벤트 계보", "Related posts": "관련 글", "Related to": "관련 대상:", @@ -579,6 +582,9 @@ const TRANSLATIONS: Partial>> = { "Choose a post": "选择文章", "Analysis runs": "分析运行", "Period reports": "周期报告", + "Leftover map coverage": "残差地图覆盖范围", + "Leftover map used {used} of {scored} scored posts (complete-case)": + "残差地图使用了 {scored} 篇已评分帖文中的 {used} 篇(完全案例)", "Event Lineage": "事件谱系", "Related posts": "相关文章", "Related to": "相关对象:", @@ -984,6 +990,9 @@ const TRANSLATIONS: Partial>> = { "Choose a post": "投稿を選択", "Analysis runs": "分析実行", "Period reports": "期間レポート", + "Leftover map coverage": "残差マップの対象範囲", + "Leftover map used {used} of {scored} scored posts (complete-case)": + "残差マップは採点済み投稿 {scored} 件のうち {used} 件を使いました(完全ケース)", "Event Lineage": "イベント系譜", "Related posts": "関連する投稿", "Related to": "関連対象:", @@ -1365,6 +1374,9 @@ const TRANSLATIONS: Partial>> = { "Choose a post": "Chọn bài viết", "Analysis runs": "Lần chạy phân tích", "Period reports": "Báo cáo theo kỳ", + "Leftover map coverage": "Phạm vi bản đồ phần dư", + "Leftover map used {used} of {scored} scored posts (complete-case)": + "Bản đồ phần dư dùng {used} trên {scored} bài đã chấm (trường hợp đầy đủ)", "Event Lineage": "Dòng sự kiện", "Related posts": "Bài viết liên quan", "Related to": "Liên quan đến:", diff --git a/lineageweave/leftover_pairs.py b/lineageweave/leftover_pairs.py index 06a85d715..23d5df8b4 100644 --- a/lineageweave/leftover_pairs.py +++ b/lineageweave/leftover_pairs.py @@ -13,6 +13,8 @@ after the second are dropped. Each pair also names the full leftover-map rank so a rank-0 collapse is not read as leftover structure. Axis share is the Gabriel inertia of the first two leftover-map axes (ADR 0148). +Complete-case coverage (ADR 0168) names how many scored posts entered +that rectangle. """ from __future__ import annotations @@ -51,6 +53,18 @@ class LeftoverMapAxis: leftover_share: float +@dataclass(frozen=True) +class LeftoverMapCoverage: + """Complete-case counts for the leftover interaction map.""" + + map_post_count: int + scored_post_count: int + map_item_count: int + scored_item_count: int + incomplete_post_count: int + incomplete_item_count: int + + def leftover_pairs_from_residual( post_ids: list[str], item_codes: tuple[str, ...], @@ -134,21 +148,10 @@ def leftover_map_from_residual( _candidate_row(post_ids, item_codes, matrix, expected, residual, person, item, distance) ) if not candidates: - leftover_map_rank = 0 - for person, item in observed: - distance = abs(float(residual[person, item]) - center) - candidates.append( - _candidate_row( - post_ids, - item_codes, - matrix, - expected, - residual, - person, - item, - max(distance, 0.0), - ) - ) + # ADR 0168: without a complete-case Gabriel map there is no + # leftover pair to name. The report carries coverage counts + # instead of a center-distance stand-in pair. + return (), () closest = min(candidates, key=lambda row: (row[0], row[1], row[2])) farthest = max(candidates, key=lambda row: (row[0], row[1], row[2])) pairs = ( @@ -230,6 +233,44 @@ def leftover_map_axes_from_singular(singular: np.ndarray) -> tuple[LeftoverMapAx return tuple(axes) +def leftover_map_coverage_from_residual( + post_ids: list[str], + item_codes: tuple[str, ...], + matrix: np.ndarray, + expected: np.ndarray, +) -> LeftoverMapCoverage: + """Name how many scored posts entered the complete-case leftover map. + + Gabriel (1971) factorizes the complete-case residual rectangle. + Missing cells stay out of that rectangle; they are never filled + with zero. ``map_post_count`` is the number of posts that entered + the factorization. ``scored_post_count`` is posts with at least + one observed cell. Incomplete rows are excluded, never zeroed. + """ + if matrix.shape != (len(post_ids), len(item_codes)): + raise ValueError( + f"matrix shape {matrix.shape} does not match {len(post_ids)} posts × {len(item_codes)} items" + ) + if expected.shape != matrix.shape: + raise ValueError(f"expected shape {expected.shape} does not match matrix {matrix.shape}") + + residual = matrix.astype(np.float64) - expected.astype(np.float64) + observed_mask = (~np.isnan(matrix)) & np.isfinite(residual) + scored_post_count = int(observed_mask.any(axis=1).sum()) + scored_item_count = int(observed_mask.any(axis=0).sum()) + keep_person, keep_item = _complete_case_masks(observed_mask) + map_post_count = int(keep_person.sum()) + map_item_count = int(keep_item.sum()) + return LeftoverMapCoverage( + map_post_count=map_post_count, + scored_post_count=scored_post_count, + map_item_count=map_item_count, + scored_item_count=scored_item_count, + incomplete_post_count=scored_post_count - map_post_count, + incomplete_item_count=scored_item_count - map_item_count, + ) + + def _complete_case_masks(observed: np.ndarray) -> tuple[np.ndarray, np.ndarray]: """Drop incomplete rows, then incomplete columns among remaining rows.""" keep_person = observed.any(axis=1) @@ -238,6 +279,8 @@ def _complete_case_masks(observed: np.ndarray) -> tuple[np.ndarray, np.ndarray]: keep_person = keep_person & observed[:, keep_item].all(axis=1) if np.any(keep_person): keep_item = keep_item & observed[keep_person, :].all(axis=0) + else: + keep_item = np.zeros_like(keep_item) return keep_person, keep_item diff --git a/lineageweave/period_report.py b/lineageweave/period_report.py index 6dedaf742..04e4eef4f 100644 --- a/lineageweave/period_report.py +++ b/lineageweave/period_report.py @@ -22,7 +22,9 @@ positions. Closest / farthest pairs are the min / max Euclidean distances on that map (Jeon et al., 2021, eq. 3). Leftover-map axis share is Gabriel inertia ``σ_k² / Σ_j σ_j²`` of residual SVD axes 1 -and 2 (ADR 0148). ``fast-mlsirm`` has no leftover-pair API; this +and 2 (ADR 0148). Complete-case coverage (ADR 0168) names how many +scored posts entered the factorization; incomplete rows are excluded, +never filled with zero. ``fast-mlsirm`` has no leftover-pair API; this module does not invent a second IRT fit and does not fork LSIRM. This module is pure compute. Persistence lives in @@ -44,7 +46,13 @@ validate_irt_response_matrix, ) -from .leftover_pairs import LeftoverMapAxis, LeftoverPair, leftover_map_from_residual +from .leftover_pairs import ( + LeftoverMapAxis, + LeftoverMapCoverage, + LeftoverPair, + leftover_map_coverage_from_residual, + leftover_map_from_residual, +) from .leftover_pairs import leftover_pairs_from_residual as leftover_pairs_from_residual from .leftover_pairs import PAIR_KIND_CLOSEST as PAIR_KIND_CLOSEST from .leftover_pairs import PAIR_KIND_FARTHEST as PAIR_KIND_FARTHEST @@ -114,6 +122,7 @@ class PeriodReport: selected_items: tuple[SelectedItem, ...] = () leftover_pairs: tuple[LeftoverPair, ...] = () leftover_map_axes: tuple[LeftoverMapAxis, ...] = () + leftover_map_coverage: LeftoverMapCoverage | None = None def _sigmoid(value: np.ndarray) -> np.ndarray: @@ -273,6 +282,20 @@ def leftover_pairs_for_fit( return pairs +def leftover_map_coverage_for_fit( + post_ids: list[str], + item_codes: tuple[str, ...], + matrix: np.ndarray, + model: str, + theta: np.ndarray, + fit: PolytomousFit, +) -> LeftoverMapCoverage: + """Complete-case leftover-map coverage from the fitted main effects.""" + probs = _category_probabilities(model, theta, fit) + expected = expected_category_matrix(matrix, probs) + return leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected) + + def _member_scores(post_ids: list[str], scores: dict[str, np.ndarray]) -> tuple[MemberScore, ...]: """Implement the _member_scores operation for this channel.""" theta = np.asarray(scores["theta_eap"], dtype=np.float64) @@ -325,6 +348,9 @@ def calibrate_period_report( leftover_pairs, leftover_map_axes = leftover_map_for_fit( post_ids, item_codes, matrix, selected, theta, fit ) + leftover_map_coverage = leftover_map_coverage_for_fit( + post_ids, item_codes, matrix, selected, theta, fit + ) return PeriodReport( selected_model=selected, mean_theta=mean_theta, @@ -340,6 +366,7 @@ def calibrate_period_report( selected_items=rank_items_by_information(item_bank, mean_theta), leftover_pairs=leftover_pairs, leftover_map_axes=leftover_map_axes, + leftover_map_coverage=leftover_map_coverage, ) @@ -371,6 +398,9 @@ def score_period_on_bank( leftover_pairs, leftover_map_axes = leftover_map_for_fit( post_ids, item_bank.item_codes, matrix, item_bank.model, theta, fit ) + leftover_map_coverage = leftover_map_coverage_for_fit( + post_ids, item_bank.item_codes, matrix, item_bank.model, theta, fit + ) return PeriodReport( selected_model=item_bank.model, mean_theta=mean_theta, @@ -392,6 +422,7 @@ def score_period_on_bank( selected_items=rank_items_by_information(item_bank, mean_theta), leftover_pairs=leftover_pairs, leftover_map_axes=leftover_map_axes, + leftover_map_coverage=leftover_map_coverage, ) diff --git a/migrations/0168_report_leftover_map_coverage.sql b/migrations/0168_report_leftover_map_coverage.sql new file mode 100644 index 000000000..26d61738d --- /dev/null +++ b/migrations/0168_report_leftover_map_coverage.sql @@ -0,0 +1,27 @@ +-- ADR 0168: persist leftover complete-case coverage (map used N of M +-- scored posts). CREATE IF NOT EXISTS so a volume that already ran +-- 0001 still upgrades. Incomplete rows stay excluded; missing cells +-- are never stored as zero. + +create table if not exists report_leftover_map_coverage ( + grouping_kind text not null, + grouping_key text not null, + period_code text not null, + rubric_version text not null, + map_post_count integer not null, + scored_post_count integer not null, + map_item_count integer not null, + scored_item_count integer not null, + incomplete_post_count integer not null, + incomplete_item_count integer not null, + primary key (grouping_kind, grouping_key, period_code, rubric_version), + foreign key (grouping_kind, grouping_key, period_code, rubric_version) + references report_period_score (grouping_kind, grouping_key, period_code, rubric_version) + on delete cascade, + check (map_post_count >= 0), + check (scored_post_count >= map_post_count), + check (map_item_count >= 0), + check (scored_item_count >= map_item_count), + check (incomplete_post_count = scored_post_count - map_post_count), + check (incomplete_item_count = scored_item_count - map_item_count) +); diff --git a/migrations/rollback/0168_report_leftover_map_coverage.sql b/migrations/rollback/0168_report_leftover_map_coverage.sql new file mode 100644 index 000000000..58a5ee541 --- /dev/null +++ b/migrations/rollback/0168_report_leftover_map_coverage.sql @@ -0,0 +1 @@ +drop table if exists report_leftover_map_coverage; diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index 514ae2768..717b84c50 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -119,6 +119,8 @@ 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 / "0168_report_leftover_map_coverage.sql").read_text()) + cur.execute((migrations / "0169_report_leftover_map_axis.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()) @@ -1231,6 +1233,27 @@ def _persist_seed_period_report( axis.leftover_share, ), ) + if report.leftover_map_coverage is not None: + coverage = report.leftover_map_coverage + cur.execute( + "insert into report_leftover_map_coverage (" + "grouping_kind, grouping_key, period_code, rubric_version, " + "map_post_count, scored_post_count, map_item_count, scored_item_count, " + "incomplete_post_count, incomplete_item_count" + ") values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", + ( + grouping_kind, + grouping_key, + period_code, + RUBRIC_VERSION, + coverage.map_post_count, + coverage.scored_post_count, + coverage.map_item_count, + coverage.scored_item_count, + coverage.incomplete_post_count, + coverage.incomplete_item_count, + ), + ) def _seed_demo_period_report(cur, author_account_id, corporate_entity_id, process_unit_id) -> None: diff --git a/tests/test_leftover_pairs.py b/tests/test_leftover_pairs.py index 2e6881801..a941a9d97 100644 --- a/tests/test_leftover_pairs.py +++ b/tests/test_leftover_pairs.py @@ -48,6 +48,7 @@ def _load_leftover(): leftover_pairs_from_residual = leftover.leftover_pairs_from_residual leftover_map_from_residual = leftover.leftover_map_from_residual leftover_map_axes_from_singular = leftover.leftover_map_axes_from_singular +leftover_map_coverage_from_residual = leftover.leftover_map_coverage_from_residual def _assert_residual_reconciles(pair) -> None: @@ -102,6 +103,12 @@ def test_leftover_residual_biplot_separates_aligned_and_opposed_cells() -> None: for pair in pairs: _assert_residual_reconciles(pair) assert pair.leftover_map_rank == 1 + coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected) + assert coverage.map_post_count == 3 + assert coverage.scored_post_count == 3 + assert coverage.incomplete_post_count == 0 + assert coverage.map_item_count == 3 + assert coverage.scored_item_count == 3 def test_zero_residual_still_emits_stable_leftover_pairs() -> None: @@ -123,6 +130,10 @@ def test_zero_residual_still_emits_stable_leftover_pairs() -> None: for pair in pairs: _assert_residual_reconciles(pair) assert pair.leftover_map_rank == 0 + coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected) + assert coverage.map_post_count == 2 + assert coverage.scored_post_count == 2 + assert coverage.incomplete_post_count == 0 def test_partial_observation_does_not_treat_missing_as_zero_residual() -> None: @@ -152,6 +163,13 @@ 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 + coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected) + assert coverage.map_post_count == 2 + assert coverage.scored_post_count == 3 + assert coverage.incomplete_post_count == 1 + assert coverage.map_item_count == 2 + assert coverage.scored_item_count == 2 + assert coverage.incomplete_item_count == 0 def test_leftover_is_empty_without_observed_cells() -> None: @@ -161,6 +179,13 @@ def test_leftover_is_empty_without_observed_cells() -> None: matrix = np.array([[np.nan]], dtype=np.float64) expected = np.array([[0.0]], dtype=np.float64) assert leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) == () + coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected) + assert coverage.map_post_count == 0 + assert coverage.scored_post_count == 0 + assert coverage.incomplete_post_count == 0 + assert coverage.map_item_count == 0 + assert coverage.scored_item_count == 0 + assert coverage.incomplete_item_count == 0 def test_leftover_residual_equals_observed_minus_expected() -> None: @@ -326,24 +351,7 @@ def test_rejects_response_and_expectation_shape_mismatches() -> None: ) -def test_sparse_residual_uses_only_observed_cells_for_fallback_distance() -> None: - """No complete rectangle still yields finite observed-cell distances.""" - matrix = np.array([[1.0, np.nan], [np.nan, -1.0]], dtype=np.float64) - pairs = leftover_pairs_from_residual( - ["post-a", "post-b"], - ("item-a", "item-b"), - matrix, - np.zeros_like(matrix), - ) - assert [(pair.post_id, pair.criterion_code) for pair in pairs] == [ - ("post-a", "item-a"), - ("post-b", "item-b"), - ] - 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] - - -def test_nonfinite_map_distance_falls_back_to_centered_residual( +def test_nonfinite_map_distance_emits_no_pair( monkeypatch: pytest.MonkeyPatch, ) -> None: """An unusable factorization coordinate cannot become persisted distance.""" @@ -362,8 +370,7 @@ def test_nonfinite_map_distance_falls_back_to_centered_residual( np.array([[1.0]], dtype=np.float64), np.array([[0.0]], dtype=np.float64), ) - assert [pair.leftover_distance for pair in pairs] == [0.0, 0.0] - assert [pair.leftover_map_rank for pair in pairs] == [0, 0] + assert pairs == () def test_empty_observation_mask_has_no_complete_case_axes() -> None: @@ -391,3 +398,20 @@ def test_leftover_map_rank_rejects_negative_rank() -> None: (0.0, "public-post", "sales_lead_specificity", 0.0, 1.0, 1.0), -1, ) + + +def test_leftover_is_unavailable_without_a_complete_case_rectangle() -> None: + """Observed cells alone cannot invent Gabriel positions or map coverage.""" + post_ids = ["post-a", "post-b"] + item_codes = ("item-one", "item-two") + matrix = np.array([[1.0, np.nan], [np.nan, 1.0]], dtype=np.float64) + expected = np.zeros_like(matrix) + + assert leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) == () + coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected) + assert coverage.map_post_count == 0 + assert coverage.scored_post_count == 2 + assert coverage.map_item_count == 0 + assert coverage.scored_item_count == 2 + assert coverage.incomplete_post_count == 2 + assert coverage.incomplete_item_count == 2 diff --git a/tests/test_period_report.py b/tests/test_period_report.py index f5cee497b..76fd0034b 100644 --- a/tests/test_period_report.py +++ b/tests/test_period_report.py @@ -277,6 +277,13 @@ def test_calibrated_report_attaches_leftover_pairs() -> None: assert np.isfinite(axis.leftover_singular_value) assert np.isfinite(axis.leftover_share) assert sum(axis.leftover_share for axis in report.leftover_map_axes) <= 1.0 + 1e-9 + coverage = report.leftover_map_coverage + assert coverage is not None + assert coverage.map_post_count == 8 + assert coverage.scored_post_count == 8 + assert coverage.incomplete_post_count == 0 + assert coverage.map_item_count == len(items) + assert coverage.scored_item_count == len(items) diff --git a/tests/test_schema.py b/tests/test_schema.py index 253ec3a84..16919fa10 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -58,6 +58,11 @@ / "migrations" / "0169_report_leftover_map_axis.sql" ) +_LEFTOVER_MAP_COVERAGE_MIGRATION = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0168_report_leftover_map_coverage.sql" +) def _postgres_available() -> bool: @@ -96,6 +101,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_COVERAGE_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_AXIS_MIGRATION.read_text()) conn.commit() yield conn @@ -141,6 +147,7 @@ def test_migration_applies_cleanly(schema_db) -> None: "report_item_information", "report_leftover_pair", "report_leftover_map_axis", + "report_leftover_map_coverage", "post_summary_result", "post_summary_event", "post_summary_role", @@ -241,6 +248,20 @@ def test_leftover_map_axis_references_period_score(schema_db) -> None: assert "report_period_score" in targets +def test_leftover_map_coverage_references_period_score(schema_db) -> None: + """Complete-case leftover coverage is 1:1 with the period report.""" + with schema_db.cursor() as cur: + cur.execute( + """ + select confrelid::regclass::text + from pg_constraint + where conrelid = 'report_leftover_map_coverage'::regclass and contype = 'f' + """ + ) + targets = {row[0] for row in cur.fetchall()} + assert "report_period_score" in targets + + def test_corporate_hierarchy_recursive_query_returns_correct_shape(schema_db) -> None: """The real product requirement: 'Acme Group -> Acme Electronics Korea -> Acme Electronics Gwangju Plant' must be walkable with one query, From fa772cf9318a91efa98ac0f165fafba2045da923 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Mon, 24 Aug 2026 19:45:08 +0900 Subject: [PATCH 3/3] fix(lineage): unify the coverage mask with the pair-map mask leftover_map_coverage_from_residual accepted cells where residual is finite while leftover_map_from_residual also requires finite expected. Today expected is always finite at observed cells so counts agree, but the two masks could drift apart and make map_post_count over-count posts the pair map excluded. Reuse the identical three-clause mask; all 15 leftover-pairs tests pass (devin review thread). --- lineageweave/leftover_pairs.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lineageweave/leftover_pairs.py b/lineageweave/leftover_pairs.py index 23d5df8b4..86be87084 100644 --- a/lineageweave/leftover_pairs.py +++ b/lineageweave/leftover_pairs.py @@ -255,7 +255,10 @@ def leftover_map_coverage_from_residual( raise ValueError(f"expected shape {expected.shape} does not match matrix {matrix.shape}") residual = matrix.astype(np.float64) - expected.astype(np.float64) - observed_mask = (~np.isnan(matrix)) & np.isfinite(residual) + # Identical mask to leftover_map_from_residual's: a cell the pair map + # scores must be exactly a cell coverage counts, so map_post_count and + # the caption can never drift apart if expected ever goes non-finite. + observed_mask = (~np.isnan(matrix)) & np.isfinite(residual) & np.isfinite(expected) scored_post_count = int(observed_mask.any(axis=1).sum()) scored_item_count = int(observed_mask.any(axis=0).sum()) keep_person, keep_item = _complete_case_masks(observed_mask)