From e01e911c9dc4cd8eba69a7bc97c63bd76aa1d0f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 21:47:50 +0000 Subject: [PATCH 1/2] feat: name leftover-map length on leftover pairs (v2.12.25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persist leftover-map lengths ‖ξ‖ and ‖ζ‖ on leftover post–criterion pairs so leftover-map magnitude is not read as leftover-map distance. Fallback pairs omit length rather than inventing one. --- AGENTS.md | 7 +- ARCHITECTURE.md | 5 +- CHANGELOG.d/2.12.25-leftover-map-length.md | 8 ++ CHANGELOG.md | 11 ++ CLAUDE.md | 4 +- backend/app/report_ingestion.py | 21 +++- backend/tests/test_api.py | 8 ++ docker/postgres-init/migrate.sh | 2 +- .../0003-fast-mlsirm-report-integration.md | 9 +- docs/adr/0048-persist-lsirm-leftover-pairs.md | 1 + docs/adr/0049-leftover-pair-report-ui.md | 18 ++- docs/adr/0181-leftover-map-length.md | 85 ++++++++++++++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 12 +- frontend/src/App.tsx | 24 +++- frontend/src/api.ts | 2 + frontend/src/i18n.test.ts | 27 +++++ frontend/src/i18n.ts | 8 ++ frontend/src/leftoverMapLength.test.ts | 24 ++++ frontend/src/leftoverMapLength.ts | 21 ++++ lineageweave/leftover_pairs.py | 111 +++++++++++++----- lineageweave/period_report.py | 10 +- .../0181_report_leftover_map_length.sql | 25 ++++ .../0181_report_leftover_map_length.sql | 8 ++ pyproject.toml | 2 +- scripts/seed_demo_data.py | 8 +- tests/test_leftover_pairs.py | 36 +++++- tests/test_migration_replay.py | 1 + tests/test_period_report.py | 10 ++ tests/test_schema.py | 25 ++++ uv.lock | 2 +- 31 files changed, 472 insertions(+), 65 deletions(-) create mode 100644 CHANGELOG.d/2.12.25-leftover-map-length.md create mode 100644 docs/adr/0181-leftover-map-length.md create mode 100644 frontend/src/leftoverMapLength.test.ts create mode 100644 frontend/src/leftoverMapLength.ts create mode 100644 migrations/0181_report_leftover_map_length.sql create mode 100644 migrations/rollback/0181_report_leftover_map_length.sql diff --git a/AGENTS.md b/AGENTS.md index 1728f9e61..e7a9d991e 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 / 0181) 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 lengths `‖ξ‖` +and `‖ζ‖` when a complete-case leftover map exists, 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..66e441bb1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -588,8 +588,9 @@ 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 / 0181) persist to +`report_leftover_pair` with leftover-map lengths `‖ξ‖` and `‖ζ‖` when +the complete-case leftover map exists. Results persist to `report_period_score` / `report_member_score`. `GET /api/reports/{grouping}` lists the trend; `GET /api/reports/{grouping}/{period}` is ABAC-filtered; diff --git a/CHANGELOG.d/2.12.25-leftover-map-length.md b/CHANGELOG.d/2.12.25-leftover-map-length.md new file mode 100644 index 000000000..62842aab4 --- /dev/null +++ b/CHANGELOG.d/2.12.25-leftover-map-length.md @@ -0,0 +1,8 @@ +## 2.12.25 — Leftover-map length + +- Persist leftover-map lengths `‖ξ‖` and `‖ζ‖` on leftover + post–criterion pairs (ADR 0181). After `make seed`, closest and + farthest leftover pairs sit above the member list with `‖ξ‖` and + `‖ζ‖` next to leftover-map distance `d`; click opens that post. + Omit the badges when lengths are missing. Never invent a leftover + score. diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ed1a099..87b09fbc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,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.25] - 2026-08-24 + +### Added + +- Period leftover pairs now name leftover-map lengths `‖ξ‖` and `‖ζ‖` + next to leftover-map distance `d`. After `make seed`, closest and + farthest leftover pairs sit above the member list with named + leftover-map magnitude; click opens that post (Gabriel, 1971; + Jeon et al., 2021, eq. 3; ADR 0181). A missing length omits the + badge. Never invent a leftover score. + ## [2.12.6] - 2026-08-20 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 1bcf50763..9cef06f08 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 lengths `‖ξ‖` and `‖ζ‖` +next to leftover-map distance `d`. 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..b42a84b1f 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_person_length, leftover_map_item_length + ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) """, grouping_kind, grouping_key, @@ -455,6 +456,8 @@ async def persist_period_report( pair.criterion_code, pair.leftover_distance, pair.leftover_residual, + pair.leftover_map_person_length, + pair.leftover_map_item_length, ) @@ -601,7 +604,9 @@ 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_person_length, lp.leftover_map_item_length, + 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 +703,16 @@ 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_person_length": ( + None + if row["leftover_map_person_length"] is None + else float(row["leftover_map_person_length"]) + ), + "leftover_map_item_length": ( + None + if row["leftover_map_item_length"] is None + else float(row["leftover_map_item_length"]) + ), "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..00e619d8e 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -4518,6 +4518,14 @@ 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", [])) + assert all( + pair.get("leftover_map_person_length") is None or pair["leftover_map_person_length"] >= 0 + for pair in high_report.get("leftover_pairs", []) + ) + assert all( + pair.get("leftover_map_item_length") is None or pair["leftover_map_item_length"] >= 0 + for pair in high_report.get("leftover_pairs", []) + ) 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..9513fae22 100644 --- a/docker/postgres-init/migrate.sh +++ b/docker/postgres-init/migrate.sh @@ -18,7 +18,7 @@ for migration in /opt/lineageweave/migrations/*.sql; do migration_name=${migration##*/} 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_*) ;; + 0060_*|0100_*|0101_*|0102_*|0181_*) ;; *) continue ;; esac printf 'Applying %s\n' "$migration_name" diff --git a/docs/adr/0003-fast-mlsirm-report-integration.md b/docs/adr/0003-fast-mlsirm-report-integration.md index bdf234b65..2d3cb2a31 100644 --- a/docs/adr/0003-fast-mlsirm-report-integration.md +++ b/docs/adr/0003-fast-mlsirm-report-integration.md @@ -100,10 +100,13 @@ 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 0048 / 0181): 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. + from the residual leftover map, and name leftover-map lengths + `‖ξ‖` and `‖ζ‖` when a complete-case leftover map exists so + leftover-map magnitude is not read as leftover-map distance. Do not + fork LSIRM; do not invent a leftover-pair API inside `fast-mlsirm` + in this slice. **TEPP boundary.** [ARCHITECTURE.md](../../ARCHITECTURE.md) already assigns calibrated temporal/event measurement to diff --git a/docs/adr/0048-persist-lsirm-leftover-pairs.md b/docs/adr/0048-persist-lsirm-leftover-pairs.md index 8e87383f0..1929521ef 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 0181](0181-leftover-map-length.md) (leftover-map length) ## Context diff --git a/docs/adr/0049-leftover-pair-report-ui.md b/docs/adr/0049-leftover-pair-report-ui.md index a93985b40..837d60643 100644 --- a/docs/adr/0049-leftover-pair-report-ui.md +++ b/docs/adr/0049-leftover-pair-report-ui.md @@ -17,16 +17,24 @@ 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 distance, leftover-map +lengths `‖ξ‖` and `‖ζ‖` when present, and the next action. +Length next action: leftover-map length names leftover-map magnitude +independently of leftover-map distance; open this post to read the +named criterion. When lengths are missing, keep “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. After `make seed`, closest and farthest leftover pairs sit above the -member list. Click a pair to open that post. +member list with leftover-map length next to leftover-map distance +`d`. Click a pair to open that post. Missing leftover rows render nothing — never a placeholder pair. A hidden post never appears as a leftover pair. +A missing leftover-map length omits the `‖ξ‖` / `‖ζ‖` badges and +keeps the closest/farthest next action. See +[ADR 0181](0181-leftover-map-length.md). ## Consequences diff --git a/docs/adr/0181-leftover-map-length.md b/docs/adr/0181-leftover-map-length.md new file mode 100644 index 000000000..b81f2f6a2 --- /dev/null +++ b/docs/adr/0181-leftover-map-length.md @@ -0,0 +1,85 @@ +# ADR 0181 — Name leftover-map length on period-report pair rows + +**Decision status:** Accepted +**Date:** 2026-08-24 + +Amends [ADR 0048](0048-persist-lsirm-leftover-pairs.md) and +[ADR 0049](0049-leftover-pair-report-ui.md). + +## Context + +ADR 0048 already persists leftover-map distance `d = ‖ξ_p − ζ_i‖` and +leftover residual `R = Y − E[Y|θ, item]` on `report_leftover_pair`. +ADR 0049 already renders closest and farthest pairs above the member +list and opens the named post. Distance is the Jeon et al. (2021, +eq. 3) map gap. Gabriel (1971) also names leftover-map vector lengths +`‖ξ‖` and `‖ζ‖`. Hiding those lengths lets a buyer read a close +leftover-map pair as leftover-map aligned magnitude, or a distant pair +as large leftover-map displacement, without the polar magnitude. + +A close leftover-map pair can sit at the origin (`‖ξ‖ = ‖ζ‖ = 0`) or +share a non-origin position (`d = 0` with positive length). Those are +different leftover-map facts. + +This increment does not persist leftover-map coordinates, does not name +observed `Y` / expected `E`, does not name leftover-map cosine, does +not name leftover-map inner product, does not name leftover-map rank, +does not split leftover-map distance onto two axes, and does not land +Post quality on the leftover criterion. + +The unprotected-stack reconstructions for neighbouring leftover facts +use 0121–0180. This protected-main increment uses **0181** so it does +not collide with leftover-map cosine (0180), leftover-map inner product +(0179), leftover residual disclosure (0178), leftover observed `Y` / +expected `E` (0177), leftover-map rank (0172), two-axis leftover-map +distance (0166), leftover coverage (0168), leftover-map axis share +(0148), or leftover interaction-map persistence (0121). + +## Decision + +Each leftover pair names `leftover_map_person_length` and +`leftover_map_item_length` — the Euclidean lengths `‖ξ‖` and `‖ζ‖` of +the leftover-map person and item coordinates that produced leftover-map +distance `d`. Migration `0181` is the single source of the columns on +every install path, fresh or existing -- shipped migrations (`0001` / +`0012`) are never edited after the fact. The columns are nullable so +older leftover rows keep distance and residual without fabricating +lengths. Fallback pairs that have no complete-case leftover map omit +the values rather than inventing them. Origin coordinates persist +length `0` because that is the measured leftover-map magnitude. + +The pair button shows `‖ξ‖ {person}` and `‖ζ‖ {item}` next to +leftover-map distance `d` when both values are finite. Next action: +leftover-map length names leftover-map magnitude independently of +leftover-map distance; open this post to read the named criterion. A +missing or non-finite length omits the badges and keeps the existing +closest/farthest next action. Do not invent a leftover score. Do not +invent a theta. + +## Consequences + +`GET /api/reports/{grouping}/{period}` returns +`leftover_map_person_length` and `leftover_map_item_length`. After +`make seed`, closest and farthest leftover pairs sit above the member +list with named `‖ξ‖` and `‖ζ‖` next to `d`; click opens that post. +Hidden posts stay hidden. + +## Related + +Independent of leftover interaction-map persistence, leftover-criterion +evaluation landing, leftover residual disclosure, leftover observed +`Y` / expected `E`, leftover-map complete-case coverage, leftover-map +axis share, leftover pairs on the grouping comparison strip, two-axis +leftover-map distance, leftover-map rank, leftover-map inner product, +and leftover-map cosine. + +## 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..638f400be 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.12.6", + "version": "2.12.25", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 7462abd2c..3bdebfe8a 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -932,6 +932,8 @@ describe("App, authenticated", () => { criterion_code: "sales_lead_specificity", leftover_distance: 0.12, leftover_residual: 0.4, + leftover_map_person_length: 0.4, + leftover_map_item_length: 0.5, }, { pair_kind: "farthest", @@ -940,6 +942,8 @@ describe("App, authenticated", () => { criterion_code: "general_sentiment_negative", leftover_distance: 1.84, leftover_residual: -1.1, + leftover_map_person_length: 1.2, + leftover_map_item_length: 0.9, }, ], members: [ @@ -3357,13 +3361,17 @@ 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 length ‖ξ‖ 0.40 and ‖ζ‖ 0.50 names leftover-map magnitude independently of leftover-map distance. Open this post to read sales-lead.", ); + expect(closestPair).toHaveTextContent("‖ξ‖ 0.40"); + expect(closestPair).toHaveTextContent("‖ζ‖ 0.50"); 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 length ‖ξ‖ 1.20 and ‖ζ‖ 0.90 names leftover-map magnitude independently of leftover-map distance. Open this post to read negative.", ); + expect(farthestPair).toHaveTextContent("‖ξ‖ 1.20"); + expect(farthestPair).toHaveTextContent("‖ζ‖ 0.90"); 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..696dc22a4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -102,6 +102,12 @@ import { useLocale, } from "./i18n"; import { rememberOidcReturnUrl, returnUrlFromLocation } from "./oidcReturnUrl"; +import { + formatLeftoverMapItemLength, + formatLeftoverMapLength, + formatLeftoverMapPersonLength, + LEFTOVER_MAP_LENGTH_ACTION, +} from "./leftoverMapLength"; import "./App.css"; function orchestratorUnavailableMessage(err: unknown, action: string): string { @@ -3394,11 +3400,19 @@ function ReportsPanel({ {report.leftover_pairs.map((pair) => { const kindLabel = pair.pair_kind === "farthest" ? "Farthest leftover" : "Closest leftover"; - const nextAction = - pair.pair_kind === "farthest" - ? "Open this post to read the criterion it sat farthest from after main effects." - : "Open this post to read the criterion it sat closest to after main effects."; const criterion = criterionShortLabel(pair.criterion_code); + const personLength = formatLeftoverMapPersonLength(pair.leftover_map_person_length); + const itemLength = formatLeftoverMapItemLength(pair.leftover_map_item_length); + const nextAction = + personLength === null || itemLength === null + ? pair.pair_kind === "farthest" + ? "Open this post to read the criterion it sat farthest from after main effects." + : "Open this post to read the criterion it sat closest to after main effects." + : tf(LEFTOVER_MAP_LENGTH_ACTION, { + person: formatLeftoverMapLength(pair.leftover_map_person_length) ?? "—", + item: formatLeftoverMapLength(pair.leftover_map_item_length) ?? "—", + criterion, + }); return (
  • {nextAction} + {personLength ? {personLength} : null} + {itemLength ? {itemLength} : null} d {pair.leftover_distance.toFixed(2)}
  • diff --git a/frontend/src/api.ts b/frontend/src/api.ts index cd0141a32..a04d31f32 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -759,6 +759,8 @@ export interface LeftoverPair { criterion_code: string; leftover_distance: number; leftover_residual: number; + leftover_map_person_length?: number | null; + leftover_map_item_length?: number | null; } export interface PeriodGroupReport { diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index 5a3afbfc3..fab3dcd37 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -74,6 +74,33 @@ describe("i18n", () => { setLocale(locale); expect(tf("{post} is current in Event Lineage. Read Keyman and evaluation next.", { post: "DEMO" })).toBe(expected); }); + + it.each([ + [ + "ko", + "잔여 지도 길이 ‖ξ‖ 0.40 및 ‖ζ‖ 0.50이(가) 잔여 지도 거리와 별개로 잔여 지도 크기를 이름 붙입니다. sales-lead 기준을 읽으려면 이 글을 여세요.", + ], + [ + "zh", + "残差图长度 ‖ξ‖ 0.40 与 ‖ζ‖ 0.50 标明与残差图距离无关的残差图幅度。打开这篇帖子阅读 sales-lead。", + ], + [ + "ja", + "残差マップ長さ ‖ξ‖ 0.40 と ‖ζ‖ 0.50 が残差マップ距離とは独立に残差マップの大きさを示します。この投稿を開いて sales-lead を読んでください。", + ], + [ + "vi", + "Độ dài bản đồ phần dư ‖ξ‖ 0.40 và ‖ζ‖ 0.50 đặt tên độ lớn trên bản đồ phần dư, độc lập với khoảng cách bản đồ phần dư. Mở bài viết này để đọc sales-lead.", + ], + ] as const)("formats leftover-map length next action in %s", (locale, expected) => { + setLocale(locale); + expect( + tf( + "Leftover-map length ‖ξ‖ {person} and ‖ζ‖ {item} names leftover-map magnitude independently of leftover-map distance. Open this post to read {criterion}.", + { person: "0.40", item: "0.50", criterion: "sales-lead" }, + ), + ).toBe(expected); + }); }); describe("locale-aware source labels", () => { diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 650acfca8..8cf4ee48b 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -356,6 +356,8 @@ const TRANSLATIONS: Partial>> = { "관측된 원본 작성자 {total}명 중 게시물 수 기준 상위 {shown}명을 표시합니다.", "Interactive questions are unavailable right now; saved evidence remains available.": "대화형 질문을 지금 사용할 수 없습니다. 저장된 근거는 계속 확인할 수 있습니다.", + "Leftover-map length ‖ξ‖ {person} and ‖ζ‖ {item} names leftover-map magnitude independently of leftover-map distance. Open this post to read {criterion}.": + "잔여 지도 길이 ‖ξ‖ {person} 및 ‖ζ‖ {item}이(가) 잔여 지도 거리와 별개로 잔여 지도 크기를 이름 붙입니다. {criterion} 기준을 읽으려면 이 글을 여세요.", }, zh: { "Unknown": "未知", @@ -694,6 +696,8 @@ const TRANSLATIONS: Partial>> = { "显示按文章数排序的 {total} 位已观察来源作者中的前 {shown} 位。", "Interactive questions are unavailable right now; saved evidence remains available.": "交互式提问暂不可用;已保存的证据仍可查看。", + "Leftover-map length ‖ξ‖ {person} and ‖ζ‖ {item} names leftover-map magnitude independently of leftover-map distance. Open this post to read {criterion}.": + "残差图长度 ‖ξ‖ {person} 与 ‖ζ‖ {item} 标明与残差图距离无关的残差图幅度。打开这篇帖子阅读 {criterion}。", }, ja: { "Unknown": "不明", @@ -1032,6 +1036,8 @@ const TRANSLATIONS: Partial>> = { "投稿数順に、観測された{total}名の元投稿者のうち上位{shown}名を表示しています。", "Interactive questions are unavailable right now; saved evidence remains available.": "対話形式の質問は現在利用できません。保存された証拠は確認できます。", + "Leftover-map length ‖ξ‖ {person} and ‖ζ‖ {item} names leftover-map magnitude independently of leftover-map distance. Open this post to read {criterion}.": + "残差マップ長さ ‖ξ‖ {person} と ‖ζ‖ {item} が残差マップ距離とは独立に残差マップの大きさを示します。この投稿を開いて {criterion} を読んでください。", }, vi: { "Unknown": "Không rõ", @@ -1370,6 +1376,8 @@ 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-map length ‖ξ‖ {person} and ‖ζ‖ {item} names leftover-map magnitude independently of leftover-map distance. Open this post to read {criterion}.": + "Độ dài bản đồ phần dư ‖ξ‖ {person} và ‖ζ‖ {item} đặt tên độ lớn trên bản đồ phần dư, độc lập với khoảng cách bản đồ phần dư. Mở bài viết này để đọc {criterion}.", }, }; diff --git a/frontend/src/leftoverMapLength.test.ts b/frontend/src/leftoverMapLength.test.ts new file mode 100644 index 000000000..0b7277629 --- /dev/null +++ b/frontend/src/leftoverMapLength.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { + formatLeftoverMapItemLength, + formatLeftoverMapLength, + formatLeftoverMapPersonLength, +} from "./leftoverMapLength"; + +describe("formatLeftoverMapLength", () => { + it("names leftover-map length without inventing a leftover score", () => { + expect(formatLeftoverMapPersonLength(0)).toBe("‖ξ‖ 0.00"); + expect(formatLeftoverMapItemLength(1.25)).toBe("‖ζ‖ 1.25"); + expect(formatLeftoverMapLength(0.4)).toBe("0.40"); + expect(formatLeftoverMapPersonLength(0.4)).toBe("‖ξ‖ 0.40"); + expect(formatLeftoverMapItemLength(0.9)).toBe("‖ζ‖ 0.90"); + }); + + it("omits the badge when length is missing, negative, or non-finite", () => { + expect(formatLeftoverMapPersonLength(null)).toBeNull(); + expect(formatLeftoverMapItemLength(undefined)).toBeNull(); + expect(formatLeftoverMapLength(Number.NaN)).toBeNull(); + expect(formatLeftoverMapLength(Number.POSITIVE_INFINITY)).toBeNull(); + expect(formatLeftoverMapLength(-0.01)).toBeNull(); + }); +}); diff --git a/frontend/src/leftoverMapLength.ts b/frontend/src/leftoverMapLength.ts new file mode 100644 index 000000000..bb1f5c426 --- /dev/null +++ b/frontend/src/leftoverMapLength.ts @@ -0,0 +1,21 @@ +/** Gabriel leftover-map lengths ``‖ξ‖`` and ``‖ζ‖`` after IRT main effects. */ + +export const LEFTOVER_MAP_LENGTH_ACTION = + "Leftover-map length ‖ξ‖ {person} and ‖ζ‖ {item} names leftover-map magnitude independently of leftover-map distance. Open this post to read {criterion}."; + +export function formatLeftoverMapLength(value: number | null | undefined): string | null { + if (value == null || !Number.isFinite(value) || value < 0) { + return null; + } + return value.toFixed(2); +} + +export function formatLeftoverMapPersonLength(value: number | null | undefined): string | null { + const formatted = formatLeftoverMapLength(value); + return formatted === null ? null : `‖ξ‖ ${formatted}`; +} + +export function formatLeftoverMapItemLength(value: number | null | undefined): string | null { + const formatted = formatLeftoverMapLength(value); + return formatted === null ? null : `‖ζ‖ ${formatted}`; +} diff --git a/lineageweave/leftover_pairs.py b/lineageweave/leftover_pairs.py index 353b1a6ef..a41514ccc 100644 --- a/lineageweave/leftover_pairs.py +++ b/lineageweave/leftover_pairs.py @@ -1,9 +1,13 @@ -"""Jeon leftover post–criterion pairs after a main-effect IRT (ADR 0017). +"""Jeon leftover post–criterion pairs after a main-effect IRT. + +Implements ADR 0048 as amended by ADR 0181. Does not import ``fast_mlsirm`` or ``period_report``. A Gabriel biplot of the residual ``R = Y − E[Y|θ, item]`` supplies person and item positions. Missing response cells are excluded from the factorization; -they are never treated as zero residuals. +they are never treated as zero residuals. Each map pair names leftover +map lengths ``‖ξ‖`` and ``‖ζ‖`` so leftover-map magnitude is not +confused with leftover-map Euclidean distance ``d``. """ from __future__ import annotations @@ -26,6 +30,8 @@ class LeftoverPair: criterion_code: str leftover_distance: float leftover_residual: float + leftover_map_person_length: float | None = None + leftover_map_item_length: float | None = None def leftover_pairs_from_residual( @@ -40,7 +46,10 @@ def leftover_pairs_from_residual( estimator places persons and items from the residual after IRT main effects (Gabriel, 1971). Only observed cells become pairs. A rank-0 residual still emits a stable closest/farthest pair so seed is not - empty; it does not invent a leftover score. + empty; it does not invent a leftover score. When Gabriel coordinates + exist, leftover-map lengths name leftover-map magnitude independently + of Euclidean distance. Fallback pairs (no complete-case map) omit + lengths rather than fabricating them. """ if matrix.shape != (len(post_ids), len(item_codes)): raise ValueError( @@ -68,7 +77,7 @@ def leftover_pairs_from_residual( else: center = float(np.mean([residual[person, item] for person, item in observed])) person_pos, item_pos = _complete_case_positions(residual, center, keep_person, keep_item) - candidates: list[tuple[float, str, str, float]] = [] + candidates: list[tuple[float, str, str, float, float | None, float | None]] = [] if person_pos is not None and item_pos is not None: person_index = np.flatnonzero(keep_person) item_index = np.flatnonzero(keep_item) @@ -77,47 +86,87 @@ def leftover_pairs_from_residual( for person, item in observed: if person not in local_person or item not in local_item: continue - distance = float( - np.linalg.norm(person_pos[local_person[person]] - item_pos[local_item[item]]) - ) + person_coord = person_pos[local_person[person]] + item_coord = item_pos[local_item[item]] + distance = float(np.linalg.norm(person_coord - item_coord)) if not np.isfinite(distance): continue candidates.append( - ( - max(distance, 0.0), - post_ids[person], - item_codes[item], - float(residual[person, item]), + _candidate_row( + post_ids, + item_codes, + residual, + person, + item, + distance, + _leftover_map_length(person_coord), + _leftover_map_length(item_coord), ) ) if not candidates: for person, item in observed: distance = abs(float(residual[person, item]) - center) candidates.append( - ( - max(distance, 0.0), - post_ids[person], - item_codes[item], - float(residual[person, item]), + _candidate_row( + post_ids, + item_codes, + residual, + person, + item, + distance, + None, + None, ) ) 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 ( - LeftoverPair( - pair_kind=PAIR_KIND_CLOSEST, - post_id=closest[1], - criterion_code=closest[2], - leftover_distance=closest[0], - leftover_residual=closest[3], - ), - LeftoverPair( - pair_kind=PAIR_KIND_FARTHEST, - post_id=farthest[1], - criterion_code=farthest[2], - leftover_distance=farthest[0], - leftover_residual=farthest[3], - ), + _pair_from_candidate(PAIR_KIND_CLOSEST, closest), + _pair_from_candidate(PAIR_KIND_FARTHEST, farthest), + ) + + +def _leftover_map_length(coord: np.ndarray) -> float | None: + """Gabriel leftover-map Euclidean length; omit non-finite norms.""" + value = float(np.linalg.norm(coord)) + if not np.isfinite(value): + return None + return max(0.0, value) + + +def _candidate_row( + post_ids: list[str], + item_codes: tuple[str, ...], + residual: np.ndarray, + person: int, + item: int, + distance: float, + leftover_map_person_length: float | None, + leftover_map_item_length: float | None, +) -> tuple[float, str, str, float, float | None, float | None]: + """One observed leftover cell: distance, ids, residual, map lengths.""" + return ( + max(distance, 0.0), + post_ids[person], + item_codes[item], + float(residual[person, item]), + leftover_map_person_length, + leftover_map_item_length, + ) + + +def _pair_from_candidate( + pair_kind: str, row: tuple[float, str, str, float, float | None, float | None] +) -> LeftoverPair: + """Build a leftover pair from a candidate row.""" + return LeftoverPair( + pair_kind=pair_kind, + post_id=row[1], + criterion_code=row[2], + leftover_distance=row[0], + leftover_residual=row[3], + leftover_map_person_length=row[4], + leftover_map_item_length=row[5], ) diff --git a/lineageweave/period_report.py b/lineageweave/period_report.py index 0b7fabc79..87e2bee37 100644 --- a/lineageweave/period_report.py +++ b/lineageweave/period_report.py @@ -16,13 +16,15 @@ ``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 0048 / 0181) 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). Complete-case +pairs also name leftover-map lengths ``‖ξ‖`` and ``‖ζ‖`` so +leftover-map magnitude is not read as leftover-map distance. +``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 diff --git a/migrations/0181_report_leftover_map_length.sql b/migrations/0181_report_leftover_map_length.sql new file mode 100644 index 000000000..973ee68ed --- /dev/null +++ b/migrations/0181_report_leftover_map_length.sql @@ -0,0 +1,25 @@ +-- ADR 0181: persist leftover-map lengths ‖ξ‖ and ‖ζ‖ on leftover +-- post–criterion pairs. Distance stays Euclidean leftover-map d. +-- Upgrade columns are nullable so older leftover rows keep distance +-- and residual without fabricating lengths. This migration is the +-- single source of the columns on fresh and existing installations. + +alter table report_leftover_pair + add column if not exists leftover_map_person_length numeric, + add column if not exists leftover_map_item_length numeric; + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'report_leftover_pair_map_length_nonnegative' + ) then + alter table report_leftover_pair + add constraint report_leftover_pair_map_length_nonnegative + check ( + (leftover_map_person_length is null or leftover_map_person_length >= 0) + and (leftover_map_item_length is null or leftover_map_item_length >= 0) + ); + end if; +end $$; diff --git a/migrations/rollback/0181_report_leftover_map_length.sql b/migrations/rollback/0181_report_leftover_map_length.sql new file mode 100644 index 000000000..add379776 --- /dev/null +++ b/migrations/rollback/0181_report_leftover_map_length.sql @@ -0,0 +1,8 @@ +-- Reverse 0181. Leftover distance and residual stay on the pair row. + +alter table report_leftover_pair + drop constraint if exists report_leftover_pair_map_length_nonnegative; + +alter table report_leftover_pair + drop column if exists leftover_map_person_length, + drop column if exists leftover_map_item_length; diff --git a/pyproject.toml b/pyproject.toml index cb4be2916..bba5045d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.12.6" +version = "2.12.25" 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 8ec15a065..9c8068d1b 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -117,6 +117,7 @@ def seed( cur.execute((migrations / "0010_report_item_information.sql").read_text()) cur.execute((migrations / "0011_post_chat_result.sql").read_text()) cur.execute((migrations / "0012_report_leftover_pair.sql").read_text()) + cur.execute((migrations / "0181_report_leftover_map_length.sql").read_text()) cur.execute((migrations / "0060_role_responsibility_agent_type.sql").read_text()) cur.execute((migrations / "0013_person_job_title.sql").read_text()) cur.execute((migrations / "0014_role_responsibility_team_actor_type.sql").read_text()) @@ -1195,8 +1196,9 @@ def _persist_seed_period_report( cur.execute( "insert into report_leftover_pair (" "grouping_kind, grouping_key, period_code, rubric_version, " - "pair_kind, post_id, criterion_code, leftover_distance, leftover_residual" - ") values (%s,%s,%s,%s,%s,%s,%s,%s,%s)", + "pair_kind, post_id, criterion_code, leftover_distance, leftover_residual, " + "leftover_map_person_length, leftover_map_item_length" + ") values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", ( grouping_kind, grouping_key, @@ -1207,6 +1209,8 @@ def _persist_seed_period_report( pair.criterion_code, pair.leftover_distance, pair.leftover_residual, + pair.leftover_map_person_length, + pair.leftover_map_item_length, ), ) diff --git a/tests/test_leftover_pairs.py b/tests/test_leftover_pairs.py index 72dea89b6..23c43c15a 100644 --- a/tests/test_leftover_pairs.py +++ b/tests/test_leftover_pairs.py @@ -1,4 +1,6 @@ -"""Leftover post–criterion pairs after the main-effect IRT (ADR 0017). +"""Leftover post–criterion pairs after the main-effect IRT. + +Covers ADR 0048 as amended by ADR 0181. Uses a constructed residual matrix so the closest and farthest pair are known without calling ``fit_polytomous``. Loads @@ -67,6 +69,12 @@ def test_leftover_residual_biplot_separates_aligned_and_opposed_cells() -> None: } assert farthest.leftover_residual == pytest.approx(-2.0) assert farthest.leftover_distance == pytest.approx(2.0 * np.sqrt(2.0), rel=1e-6) + assert closest.post_id == "post-b" + assert closest.criterion_code == "item_mid" + assert closest.leftover_map_person_length == pytest.approx(0.0, abs=1e-9) + assert closest.leftover_map_item_length == pytest.approx(0.0, abs=1e-9) + assert farthest.leftover_map_person_length == pytest.approx(np.sqrt(2.0), rel=1e-6) + assert farthest.leftover_map_item_length == pytest.approx(np.sqrt(2.0), rel=1e-6) def test_zero_residual_still_emits_stable_leftover_pairs() -> None: @@ -82,6 +90,10 @@ def test_zero_residual_still_emits_stable_leftover_pairs() -> None: assert pairs[0].criterion_code == "item_one" assert pairs[1].post_id == "beta-post" assert pairs[1].criterion_code == "item_two" + assert pairs[0].leftover_map_person_length == pytest.approx(0.0, abs=1e-9) + assert pairs[0].leftover_map_item_length == pytest.approx(0.0, abs=1e-9) + assert pairs[1].leftover_map_person_length == pytest.approx(0.0, abs=1e-9) + assert pairs[1].leftover_map_item_length == pytest.approx(0.0, abs=1e-9) def test_partial_observation_does_not_treat_missing_as_zero_residual() -> None: @@ -108,6 +120,10 @@ def test_partial_observation_does_not_treat_missing_as_zero_residual() -> None: ("aligned-post", "item_far"), ("opposed-post", "item_near"), } + assert closest.leftover_map_person_length == pytest.approx(np.sqrt(2.0), rel=1e-6) + assert closest.leftover_map_item_length == pytest.approx(np.sqrt(2.0), rel=1e-6) + assert farthest.leftover_map_person_length == pytest.approx(np.sqrt(2.0), rel=1e-6) + assert farthest.leftover_map_item_length == pytest.approx(np.sqrt(2.0), rel=1e-6) def test_leftover_is_empty_without_observed_cells() -> None: @@ -116,3 +132,21 @@ 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) == () + + +def test_leftover_fallback_omits_length_without_complete_case_map() -> None: + """No complete-case rectangle: persist distance from |R − center|, omit length.""" + 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 all(pair.leftover_map_person_length is None for pair in pairs) + assert all(pair.leftover_map_item_length is None for pair in pairs) diff --git a/tests/test_migration_replay.py b/tests/test_migration_replay.py index 29fe1c176..55d2d79e3 100644 --- a/tests/test_migration_replay.py +++ b/tests/test_migration_replay.py @@ -33,3 +33,4 @@ def test_migrate_sh_replays_leftover_pair_migration_on_existing_volumes() -> Non ).read_text(encoding="utf-8") assert "0012_*" in script + assert "0181_*" in script diff --git a/tests/test_period_report.py b/tests/test_period_report.py index 04c9c3921..6398680fa 100644 --- a/tests/test_period_report.py +++ b/tests/test_period_report.py @@ -225,6 +225,10 @@ def test_leftover_residual_biplot_separates_aligned_and_opposed_cells() -> None: } assert farthest.leftover_residual == pytest.approx(-2.0) assert farthest.leftover_distance == pytest.approx(2.0 * np.sqrt(2.0), rel=1e-6) + assert closest.leftover_map_person_length == pytest.approx(0.0, abs=1e-9) + assert closest.leftover_map_item_length == pytest.approx(0.0, abs=1e-9) + assert farthest.leftover_map_person_length == pytest.approx(np.sqrt(2.0), rel=1e-6) + assert farthest.leftover_map_item_length == pytest.approx(np.sqrt(2.0), rel=1e-6) def test_zero_residual_still_emits_stable_leftover_pairs() -> None: @@ -266,6 +270,12 @@ def test_calibrated_report_attaches_leftover_pairs() -> None: assert pair.criterion_code in items assert pair.leftover_distance >= 0.0 assert np.isfinite(pair.leftover_residual) + if pair.leftover_map_person_length is not None: + assert pair.leftover_map_person_length >= 0.0 + assert np.isfinite(pair.leftover_map_person_length) + if pair.leftover_map_item_length is not None: + assert pair.leftover_map_item_length >= 0.0 + assert np.isfinite(pair.leftover_map_item_length) diff --git a/tests/test_schema.py b/tests/test_schema.py index 1e2c708a3..a0d7000ae 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -43,6 +43,11 @@ / "migrations" / "0102_project_bound_summary_event.sql" ) +_LEFTOVER_MAP_LENGTH_MIGRATION = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0181_report_leftover_map_length.sql" +) def _postgres_available() -> bool: @@ -79,6 +84,7 @@ def schema_db(): cur.execute(_MAJOR_EVENT_ACTION_MIGRATION.read_text()) cur.execute(_PROJECT_BOUND_ACTION_MIGRATION.read_text()) cur.execute(_PROJECT_BOUND_EVENT_MIGRATION.read_text()) + cur.execute(_LEFTOVER_MAP_LENGTH_MIGRATION.read_text()) conn.commit() yield conn finally: @@ -172,6 +178,25 @@ def test_leftover_pair_references_member_and_item_rows(schema_db) -> None: assert "report_period_score" in targets +def test_leftover_pair_map_length_columns_are_nullable(schema_db) -> None: + """0181 adds leftover-map lengths without fabricating values on old rows.""" + with schema_db.cursor() as cur: + cur.execute( + """ + select column_name, is_nullable + from information_schema.columns + where table_name = 'report_leftover_pair' + and column_name in ( + 'leftover_map_person_length', + 'leftover_map_item_length' + ) + """ + ) + columns = {row[0]: row[1] for row in cur.fetchall()} + assert columns["leftover_map_person_length"] == "YES" + assert columns["leftover_map_item_length"] == "YES" + + def test_corporate_hierarchy_recursive_query_returns_correct_shape(schema_db) -> None: """The real product requirement: 'Acme Group -> Acme Electronics Korea diff --git a/uv.lock b/uv.lock index 10bcf9ff1..f6ce0cb92 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.12.6" +version = "2.12.25" source = { editable = "." } dependencies = [ { name = "certifi" }, From ef38a8473bcf79d41a43e3c1d2bf1ced306dfd89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 06:49:36 +0900 Subject: [PATCH 2/2] fix: restore frontend build on leftover-map PR Drop unused OIDC import and unauthenticated AdminPanel that fail tsc (TS6192 / TS2322). AdminPanel stays authenticated-only. --- frontend/src/App.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 696dc22a4..15dcaba37 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -101,7 +101,6 @@ import { tf, useLocale, } from "./i18n"; -import { rememberOidcReturnUrl, returnUrlFromLocation } from "./oidcReturnUrl"; import { formatLeftoverMapItemLength, formatLeftoverMapLength, @@ -4636,7 +4635,6 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean Enterprise SSO Authentication - {destination === "admin" ? : null}