diff --git a/AGENTS.md b/AGENTS.md index 67ea919a8..06999f370 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -209,7 +209,9 @@ list so a click opens that post. Two-axis reconstruction `R̂` is not persisted. 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. +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 e7ac85c9c..235f2a216 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -601,7 +601,9 @@ ADR 0017 / 0048 / 0119 / 0162 / 0163 / 0164 / 0182) persist to `E[Y|θ, item]`, full leftover-map rank, and unexplained leftover `U = R − R̂` named on the pair row. 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; @@ -616,7 +618,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 b2da918ee..b2040d64c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,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 deca8d3d7..a00350c81 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 @@ -478,6 +478,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( @@ -650,6 +671,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"]], @@ -666,6 +699,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"] @@ -770,11 +804,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 39d3f179e..4cdf0729b 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -142,6 +142,11 @@ / "migrations" / "0169_report_leftover_map_axis.sql" ) +_LEFTOVER_MAP_COVERAGE_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0168_report_leftover_map_coverage.sql" +) _LEFTOVER_MAP_UNEXPLAINED_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" @@ -264,6 +269,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(_GLOBAL_ASK_JOB_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_AXIS_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_UNEXPLAINED_MIGRATION.read_text()) @@ -5018,6 +5024,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 fddc028e2..0bab2fc32 100644 --- a/docs/adr/0048-persist-lsirm-leftover-pairs.md +++ b/docs/adr/0048-persist-lsirm-leftover-pairs.md @@ -52,7 +52,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 fdae8f2ee..729857277 100644 --- a/docs/adr/0049-leftover-pair-report-ui.md +++ b/docs/adr/0049-leftover-pair-report-ui.md @@ -53,4 +53,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 fc7e03373..7a2faf0ef 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -997,6 +997,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", @@ -3697,6 +3705,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, @@ -3720,6 +3732,7 @@ describe("App, authenticated", () => { expect(farthestPair).toHaveTextContent("U −0.25"); 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 399485c35..8c1d3b27e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3457,6 +3457,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 d90c2e246..a951207b9 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -886,6 +886,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; @@ -902,6 +911,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 328bcfdc2..55fed1394 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": "관련 대상:", @@ -643,6 +646,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": "相关对象:", @@ -1111,6 +1117,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": "関連対象:", @@ -1556,6 +1565,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 7531607e9..cb7ae265d 100644 --- a/lineageweave/leftover_pairs.py +++ b/lineageweave/leftover_pairs.py @@ -13,11 +13,12 @@ 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). -Each pair also names unexplained leftover ``U = R − R̂`` after two-axis -Gabriel reconstruction ``R̂ = ξ_{1:2} · ζ_{1:2}`` so the leftover cell -the map does not reconstruct is not confused with leftover residual -``R`` or leftover-map distance ``d``. Reconstruction is computed -internally and is not persisted. +Complete-case coverage (ADR 0168) names how many scored posts entered +that rectangle. Each pair also names unexplained leftover ``U = R − R̂`` +after two-axis Gabriel reconstruction ``R̂ = ξ_{1:2} · ζ_{1:2}`` so the +leftover cell the map does not reconstruct is not confused with +leftover residual ``R`` or leftover-map distance ``d``. Reconstruction +is computed internally and is not persisted. """ from __future__ import annotations @@ -57,6 +58,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, ...], @@ -158,22 +171,10 @@ def leftover_map_from_residual( ) ) 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), - None, - ) - ) + # 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 = ( @@ -268,6 +269,47 @@ 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) + # 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) + 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) @@ -276,6 +318,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 cfebeb373..0034b35a6 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -120,6 +120,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 / "0182_report_leftover_map_unexplained.sql").read_text()) cur.execute((migrations / "0060_role_responsibility_agent_type.sql").read_text()) cur.execute((migrations / "0013_person_job_title.sql").read_text()) @@ -1235,6 +1237,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 135ddcd2b..a411e1e13 100644 --- a/tests/test_leftover_pairs.py +++ b/tests/test_leftover_pairs.py @@ -49,6 +49,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: @@ -106,6 +107,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: @@ -129,6 +136,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: @@ -159,6 +170,13 @@ def test_partial_observation_does_not_treat_missing_as_zero_residual() -> None: _assert_residual_reconciles(pair) assert pair.leftover_map_rank == 1 assert pair.leftover_map_unexplained == pytest.approx(0.0, abs=1e-6) + coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected) + assert coverage.map_post_count == 2 + assert coverage.scored_post_count == 3 + 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: @@ -168,6 +186,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: @@ -384,45 +409,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_leftover_fallback_omits_unexplained_without_complete_case_map() -> None: - """No complete-case rectangle: persist distance from |R − center|, omit U.""" - post_ids = ["sparse-a", "sparse-b"] - item_codes = ("item_near", "item_far") - matrix = np.array( - [ - [2.0, np.nan], - [np.nan, -2.0], - ], - dtype=np.float64, - ) - expected = np.zeros_like(matrix) - pairs = leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) - assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST] - assert {pair.post_id for pair in pairs} == {"sparse-a", "sparse-b"} - for pair in pairs: - assert pair.leftover_map_unexplained is None - assert pair.leftover_distance >= 0.0 - assert not hasattr(pair, "leftover_map_reconstruction") - - -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.""" @@ -441,8 +428,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: @@ -470,3 +456,20 @@ def test_leftover_map_rank_rejects_negative_rank() -> None: (0.0, "public-post", "sales_lead_specificity", 0.0, 1.0, 1.0, None), -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 943321394..95021ac4b 100644 --- a/tests/test_period_report.py +++ b/tests/test_period_report.py @@ -280,6 +280,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 f2a295cc6..0f9fd18a3 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" +) _LEFTOVER_MAP_UNEXPLAINED_MIGRATION = ( Path(__file__).resolve().parents[1] / "migrations" @@ -101,6 +106,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()) cur.execute(_LEFTOVER_MAP_UNEXPLAINED_MIGRATION.read_text()) conn.commit() @@ -147,6 +153,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", @@ -264,6 +271,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,