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" },