From 4541749c4190d9970e0a6b32afc4deaf4fd5853f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 17:12:43 +0000 Subject: [PATCH 1/4] feat: name leftover-map rank on leftover pairs (v2.12.15) Persist Gabriel leftover-map rank so a rank-0 collapse is not read as leftover structure. After seed, closest and farthest pairs sit above the member list with rank next to leftover-map distance; click opens that post. Never invent a leftover score or a theta. --- AGENTS.md | 7 ++- ARCHITECTURE.md | 7 ++- CHANGELOG.d/2.12.15-leftover-map-rank.md | 8 +++ CHANGELOG.md | 9 +++ CLAUDE.md | 4 +- backend/app/report_ingestion.py | 13 +++- backend/tests/test_api.py | 6 ++ docker/postgres-init/migrate.sh | 1 + docs/adr/0048-persist-lsirm-leftover-pairs.md | 5 +- docs/adr/0049-leftover-pair-report-ui.md | 12 ++-- docs/adr/0164-leftover-map-rank.md | 62 +++++++++++++++++++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 8 ++- frontend/src/App.tsx | 21 +++++-- frontend/src/api.ts | 1 + frontend/src/i18n.test.ts | 19 ++++++ frontend/src/i18n.ts | 44 +++++++++++++ frontend/src/leftoverMapRank.test.ts | 34 ++++++++++ frontend/src/leftoverMapRank.ts | 23 +++++++ lineageweave/leftover_pairs.py | 61 +++++++++++------- migrations/0001_initial_schema.sql | 4 +- migrations/0012_report_leftover_pair.sql | 4 +- migrations/0164_report_leftover_map_rank.sql | 24 +++++++ .../0164_report_leftover_map_rank.sql | 6 ++ pyproject.toml | 2 +- scripts/seed_demo_data.py | 7 ++- tests/test_leftover_pairs.py | 34 +++++++++- tests/test_migration_replay.py | 1 + tests/test_period_report.py | 1 + tests/test_schema.py | 16 +++++ uv.lock | 2 +- 31 files changed, 394 insertions(+), 54 deletions(-) create mode 100644 CHANGELOG.d/2.12.15-leftover-map-rank.md create mode 100644 docs/adr/0164-leftover-map-rank.md create mode 100644 frontend/src/leftoverMapRank.test.ts create mode 100644 frontend/src/leftoverMapRank.ts create mode 100644 migrations/0164_report_leftover_map_rank.sql create mode 100644 migrations/rollback/0164_report_leftover_map_rank.sql diff --git a/AGENTS.md b/AGENTS.md index 1728f9e61..906e17f9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -186,12 +186,13 @@ 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 0017 / 0018) are computed in +Period leftover pairs (ADR 0048 / 0049 / 0164) are computed in `lineageweave/leftover_pairs.py` from the residual after a real GRM/GPCM score, never invented. Missing cells stay out of the Gabriel factorization. Closest and farthest post–criterion pairs -persist to `report_leftover_pair` and sit above the member list so -a click opens that post. +persist to `report_leftover_pair` with leftover-map rank so a rank-0 +collapse is not read as leftover structure, and sit above the member +list so a click opens that post. `frontend/` has its own toolchain (Node pinned via `frontend/mise.toml`, pnpm via Corepack -- do not add a second Node package manager or a diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d0280ff97..1fb94f704 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -588,8 +588,8 @@ on those same fixed parameters (Kim, 2006 FIPC). After scoring, `information_polytomous` ranks the shared-bank items by Fisher 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 (Jeon et al., 2021; ADR 0017) persist to -`report_leftover_pair`. Results persist to +residual SVD leftover pairs (Jeon et al., 2021; ADR 0048 / 0164) persist to +`report_leftover_pair` with leftover-map rank. Results persist to `report_period_score` / `report_member_score`. `GET /api/reports/{grouping}` lists the trend; `GET /api/reports/{grouping}/{period}` is ABAC-filtered; @@ -601,7 +601,8 @@ bank as the dummy high/low band rows, so comparison-strip click through opens those DAG posts. Report members include the earliest open ticket title, status lookup label, and due date when one exists. The home page renders the actual mean θ, the FIPC delta, the CAT-selected item, leftover -closest/farthest pairs above the member list, and the +closest/farthest pairs (leftover-map rank after IRT main effects plus +leftover-map distance `d`) above the member list, 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.15-leftover-map-rank.md b/CHANGELOG.d/2.12.15-leftover-map-rank.md new file mode 100644 index 000000000..9f2e670c2 --- /dev/null +++ b/CHANGELOG.d/2.12.15-leftover-map-rank.md @@ -0,0 +1,8 @@ +## 2.12.15 — Leftover-map rank + +- Persist leftover-map rank on leftover post–criterion pairs (ADR 0164). + Rank is the number of Gabriel singular values above the floor. After + `make seed`, closest and farthest leftover pairs sit above the member + list with `rank {n}` next to leftover-map distance `d`; click opens + that post. Rank 0 names no leftover structure. Never invent a leftover + score or a theta. diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ed1a099..140b490c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,15 @@ 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.15] - 2026-08-24 + +### Added + +- Period leftover pair rows now name leftover-map rank after IRT main + effects next to leftover-map distance `d`, then open that post + (Jeon et al., 2021, eq. 3; ADR 0164). Rank 0 names no leftover + structure rather than inventing a leftover score. + ## [2.12.6] - 2026-08-20 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 1bcf50763..42b091e42 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,9 @@ chip name contains `Corporate entity: Demo Corp` and the persisted mean θ. The period-report panel says Demo Corp is the opened grouping and to read its mean θ and member posts, then open a post. Those members land immediately under that next action, ahead of Other Corp -and the week strip. Opening Public post names the next action: read +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 Event Lineage, Keyman, and evaluation on that post. The popup Event Lineage DAG marks that post current. After that current node, the popup names Keyman and evaluation as the next read. After landed diff --git a/backend/app/report_ingestion.py b/backend/app/report_ingestion.py index 50614b0ad..2ddf536d2 100644 --- a/backend/app/report_ingestion.py +++ b/backend/app/report_ingestion.py @@ -443,8 +443,9 @@ async def persist_period_report( """ insert into report_leftover_pair ( grouping_kind, grouping_key, period_code, rubric_version, - pair_kind, post_id, criterion_code, leftover_distance, leftover_residual - ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9) + pair_kind, post_id, criterion_code, leftover_distance, leftover_residual, + leftover_map_rank + ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) """, grouping_kind, grouping_key, @@ -455,6 +456,7 @@ async def persist_period_report( pair.criterion_code, pair.leftover_distance, pair.leftover_residual, + pair.leftover_map_rank, ) @@ -601,7 +603,7 @@ async def fetch_period_reports( leftover = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli f""" select lp.grouping_key, lp.pair_kind, lp.post_id, lp.criterion_code, - lp.leftover_distance, lp.leftover_residual, p.post_title, + lp.leftover_distance, lp.leftover_residual, lp.leftover_map_rank, p.post_title, p.visibility_code, p.corporate_entity_id, ({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context from report_leftover_pair lp @@ -698,6 +700,11 @@ async def fetch_period_reports( "criterion_code": str(row["criterion_code"]), "leftover_distance": float(row["leftover_distance"]), "leftover_residual": float(row["leftover_residual"]), + "leftover_map_rank": ( + None + if row["leftover_map_rank"] is None + else int(row["leftover_map_rank"]) + ), "visibility_code": row["visibility_code"], "corporate_entity_id": str(row["corporate_entity_id"]), "has_real_source_context": bool(row["has_real_source_context"]), diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 438b4786a..e6a0da81e 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -4518,6 +4518,12 @@ def test_seed_period_report_surfaces_on_get_reports(client, demo_analyst_token, assert leftover_kinds <= {"closest", "farthest"} assert all(pair["post_title"] for pair in high_report.get("leftover_pairs", [])) assert all(pair["leftover_distance"] >= 0 for pair in high_report.get("leftover_pairs", [])) + for pair in high_report.get("leftover_pairs", []): + rank = pair.get("leftover_map_rank") + if rank is None: + continue + assert isinstance(rank, int) + assert rank >= 0 week3 = client.get( "/api/reports/process_unit/2026-W03", diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh index f329117d6..3b3646f7f 100644 --- a/docker/postgres-init/migrate.sh +++ b/docker/postgres-init/migrate.sh @@ -19,6 +19,7 @@ for migration in /opt/lineageweave/migrations/*.sql; do case "$migration_name" in 0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*) ;; 0060_*|0100_*|0101_*|0102_*) ;; + 0164_*) ;; *) continue ;; esac printf 'Applying %s\n' "$migration_name" diff --git a/docs/adr/0048-persist-lsirm-leftover-pairs.md b/docs/adr/0048-persist-lsirm-leftover-pairs.md index 8e87383f0..90d266e8c 100644 --- a/docs/adr/0048-persist-lsirm-leftover-pairs.md +++ b/docs/adr/0048-persist-lsirm-leftover-pairs.md @@ -2,6 +2,7 @@ **Decision status:** Accepted **Date:** 2026-08-17 +**Amended by:** [ADR 0164](0164-leftover-map-rank.md) (leftover-map rank) ## Context @@ -30,7 +31,9 @@ and one `farthest` observed cell per period report in `report_leftover_pair` (3NF, two-or-more-word `snake_case`). The biplot lives in `lineageweave/leftover_pairs.py` so leftover -tests do not import `period_report` or `fast_mlsirm`. +tests do not import `period_report` or `fast_mlsirm`. Each leftover +row also names leftover-map rank so a rank-0 collapse is not read as +leftover structure (ADR 0164). Cascade the rows with `report_period_score`. A leftover post must also be a `report_member_score` row, and the leftover criterion diff --git a/docs/adr/0049-leftover-pair-report-ui.md b/docs/adr/0049-leftover-pair-report-ui.md index a93985b40..b3cc06666 100644 --- a/docs/adr/0049-leftover-pair-report-ui.md +++ b/docs/adr/0049-leftover-pair-report-ui.md @@ -2,6 +2,7 @@ **Decision status:** Accepted **Date:** 2026-08-17 +**Amended by:** [ADR 0164](0164-leftover-map-rank.md) (leftover-map rank) ## Context @@ -17,10 +18,13 @@ second navigation surface. On each period-report group, render leftover pairs **above** the member list. Each pair is a button: closest or farthest label, post -title, criterion short label, leftover-map distance, and the next -action (“Open this post to read the criterion it sat closest to / -farthest from after main effects.”). Clicking the button opens that -post with the same handler as a member row. +title, criterion short label, leftover-map rank when finite, leftover-map +distance, and the next action. Rank 0 says leftover map has no leftover +structure after IRT main effects; rank ≥ 1 says read leftover map rank +after IRT main effects, then open this post. Missing rank keeps the +original open-this-post copy. Clicking the button opens that post with +the same handler as a member row. Leftover-map rank is +[ADR 0164](0164-leftover-map-rank.md). After `make seed`, closest and farthest leftover pairs sit above the member list. Click a pair to open that post. diff --git a/docs/adr/0164-leftover-map-rank.md b/docs/adr/0164-leftover-map-rank.md new file mode 100644 index 000000000..6e573458d --- /dev/null +++ b/docs/adr/0164-leftover-map-rank.md @@ -0,0 +1,62 @@ +# ADR 0164 — Name leftover-map rank on leftover pairs + +**Decision status:** Accepted +**Date:** 2026-08-24 + +Amends [ADR 0048](0048-persist-lsirm-leftover-pairs.md) and +[ADR 0049](0049-leftover-pair-report-ui.md). + +## Context + +ADR 0048 already persists leftover-map distance and leftover residual +`R = Y − E[Y|θ, item]` on `report_leftover_pair`. A rank-0 residual +still emits a stable closest/farthest pair so `make seed` is not empty; +the stored distance is then zero, not a fabricated interaction. ADR 0049 +renders those pairs above the member list. Without leftover-map rank, a +buyer cannot tell a Gabriel biplot with leftover structure (Jeon et al., +2021, eq. 3) from an origin collapse that still shows `d 0.00`. + +This increment does not persist leftover-map coordinates, does not name +observed `Y` / expected `E`, does not change leftover-map axis count, and +does not land Post quality on the leftover criterion. + +## Decision + +Each leftover pair names `leftover_map_rank`: the number of Gabriel +singular values above the leftover singular floor on the complete-case +residual rectangle. Closest and farthest pairs on one period report share +that rank. A fallback pair that is not placed on a leftover map stores +rank `0`. Fresh `0001` / `0012` tables require the column. Migration +`0164` adds a nullable column so older leftover rows keep distance and +residual without fabricating a rank. + +The pair button shows `rank {n}` when the value is a finite +non-negative integer. Rank `0` next action: leftover map has no leftover +structure after IRT main effects; open this post. Rank `≥ 1` next action: +read leftover map rank after IRT main effects, then open this post. Omit +the rank badge when the value is missing. Do not invent a leftover score. +Do not invent a theta. + +## Consequences + +`GET /api/reports/{grouping}/{period}` returns `leftover_map_rank`. After +`make seed`, closest and farthest leftover pairs sit above the member +list with leftover-map rank; click opens that post. Hidden posts stay +hidden. + +## Related + +Independent of leftover interaction-map persistence, leftover-criterion +evaluation landing, leftover residual UI extraction, two-axis leftover-map +distance, and leftover observed `Y` / expected `E`. + +## 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 e2e996bbe..cc9f4ce0e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.12.6", + "version": "2.12.15", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 7462abd2c..adab96219 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -932,6 +932,7 @@ describe("App, authenticated", () => { criterion_code: "sales_lead_specificity", leftover_distance: 0.12, leftover_residual: 0.4, + leftover_map_rank: 1, }, { pair_kind: "farthest", @@ -940,6 +941,7 @@ describe("App, authenticated", () => { criterion_code: "general_sentiment_negative", leftover_distance: 1.84, leftover_residual: -1.1, + leftover_map_rank: 1, }, ], members: [ @@ -3357,13 +3359,15 @@ describe("App, authenticated", () => { }); expect(closestPair).toHaveTextContent("Closest leftover: Public post · sales-lead"); expect(closestPair).toHaveTextContent( - "Open this post to read the criterion it sat closest to after main effects.", + "Leftover map rank 1 after IRT main effects. Open this post.", ); + expect(closestPair).toHaveTextContent("rank 1"); expect(closestPair).toHaveTextContent("d 0.12"); expect(farthestPair).toHaveTextContent("Farthest leftover: Specification revision requested · negative"); expect(farthestPair).toHaveTextContent( - "Open this post to read the criterion it sat farthest from after main effects.", + "Leftover map rank 1 after IRT main effects. Open this post.", ); + expect(farthestPair).toHaveTextContent("rank 1"); expect(farthestPair).toHaveTextContent("d 1.84"); const memberButton = screen.getByRole("button", { name: /open report post: public post/i }); expect(closestPair.compareDocumentPosition(memberButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6fba0dd41..3c38e473c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -102,6 +102,7 @@ import { useLocale, } from "./i18n"; import { rememberOidcReturnUrl, returnUrlFromLocation } from "./oidcReturnUrl"; +import { formatLeftoverMapRank, LEFTOVER_RANK_STRUCTURE_ACTION, LEFTOVER_RANK_ZERO_ACTION } from "./leftoverMapRank"; import "./App.css"; function orchestratorUnavailableMessage(err: unknown, action: string): string { @@ -3390,15 +3391,22 @@ function ReportsPanel({ )} {report.leftover_pairs && report.leftover_pairs.length > 0 && ( -