From e2d13019004a5d8c019fecf7a39ceeef4093b8dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 04:39:53 +0000 Subject: [PATCH 1/7] feat(reports): persist leftover-map coordinates (v2.24.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Name leftover-map coordinates ξ_{1:2} and ζ_{1:2} on leftover post–criterion pairs (ADR 0267, migration 0245). After make seed, closest and farthest leftover pairs sit above the member list with ξ (x, y) ζ (x, y) next to leftover-map distance d; click opens that post. Rank-0 unused axes store 0.0. When coordinates, reconstruction, and distance are finite, R̂ = ξ · ζ and d = ‖ξ − ζ‖. Never invent a leftover score. Never invent a theta. 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. Gabriel, K. R. (1971). The biplot graphic display of matrices with application to principal component analysis. Biometrika, 58(3), 453–467. --- AGENTS.md | 12 +- ARCHITECTURE.md | 9 +- .../2.24.0-leftover-map-coordinates.md | 10 ++ CHANGELOG.md | 9 ++ CLAUDE.md | 2 +- backend/app/report_ingestion.py | 34 ++++- backend/tests/test_api.py | 20 +++ docs/adr/0048-persist-lsirm-leftover-pairs.md | 6 +- docs/adr/0049-leftover-pair-report-ui.md | 15 ++- docs/adr/0201-leftover-map-reconstruction.md | 3 + docs/adr/0266-leftover-map-explained-share.md | 3 + docs/adr/0267-leftover-map-coordinates.md | 116 ++++++++++++++++++ docs/product-technical-gap-baseline.md | 11 ++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 18 ++- frontend/src/api.ts | 4 + .../components/LeftoverPairList.stories.tsx | 8 ++ .../src/components/LeftoverPairList.test.tsx | 63 ++++++++++ frontend/src/components/LeftoverPairList.tsx | 44 +++++-- frontend/src/i18n.test.ts | 28 +++++ frontend/src/i18n.ts | 8 ++ frontend/src/leftoverMapCoordinates.test.ts | 39 ++++++ frontend/src/leftoverMapCoordinates.ts | 38 ++++++ lineageweave/leftover_pairs.py | 86 ++++++++++--- .../0245_report_leftover_map_coordinates.sql | 17 +++ .../0245_report_leftover_map_coordinates.sql | 9 ++ pyproject.toml | 2 +- scripts/seed_demo_data.py | 11 +- tests/test_leftover_pairs.py | 81 +++++++++++- tests/test_period_report.py | 21 ++++ tests/test_schema.py | 10 ++ uv.lock | 2 +- 32 files changed, 688 insertions(+), 53 deletions(-) create mode 100644 CHANGELOG.d/2.24.0-leftover-map-coordinates.md create mode 100644 docs/adr/0267-leftover-map-coordinates.md create mode 100644 frontend/src/leftoverMapCoordinates.test.ts create mode 100644 frontend/src/leftoverMapCoordinates.ts create mode 100644 migrations/0245_report_leftover_map_coordinates.sql create mode 100644 migrations/rollback/0245_report_leftover_map_coordinates.sql diff --git a/AGENTS.md b/AGENTS.md index 008a807a1..52649f853 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -273,7 +273,7 @@ stops startup instead of leaving a healthy-looking partial schema, and application code must not compensate for a missing table. Period leftover pairs (ADR 0017 / 0018 / 0048 / 0049 / 0119 / 0158 / 0162 / -0163 / 0164 / 0182 / 0185 / 0201 / 0233 / 0266) are computed in `lineageweave/leftover_pairs.py` from the +0163 / 0164 / 0182 / 0185 / 0201 / 0233 / 0266 / 0267) are computed in `lineageweave/leftover_pairs.py` from the residual after a real GRM/GPCM score, never invented. Distances are Euclidean on the two-dimensional Gabriel leftover map; missing cells stay out of the factorization. Closest and farthest post–criterion pairs @@ -282,12 +282,16 @@ persist to `report_leftover_pair` with signed residual `R`, observed plus leftover-map rank so rank 0 is not read as structure, unexplained leftover, the ADR 0201 reconstruction evidence, ADR 0185 cross-share evidence, ADR 0233 unexplained leftover share -`s = U² / R²`, and ADR 0266 explained leftover share `e = R̂² / R²`. +`s = U² / R²`, ADR 0266 explained leftover share `e = R̂² / R²`, and +ADR 0267 leftover-map coordinates `ξ_{1:2}` / `ζ_{1:2}`. ADR 0201 is the sole normative reconstruction formula, storage, and audit contract; do not duplicate or reinterpret it here. ADR 0233 is the sole unexplained leftover share contract. ADR 0266 is -the sole explained leftover share contract. When `R`, `R̂`, `U`, `x`, -`s`, and `e` are finite, `e + s + x = 1`. The pairs sit above the member +the sole explained leftover share contract. ADR 0267 is the sole +leftover-map coordinate contract. When `R`, `R̂`, `U`, `x`, +`s`, and `e` are finite, `e + s + x = 1`. When coordinates, +reconstruction, and distance are finite, `R̂ = ξ · ζ` and +`d = ‖ξ − ζ‖`. The pairs sit above the member list so a click opens that post with the leftover criterion current in Post quality (ADR 0158). Leftover-map axis share (ADR 0148) is Gabriel inertia of residual SVD axes 1 and 2 and persists to `report_leftover_map_axis`. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index af43a8d8f..90c80dd58 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -637,11 +637,11 @@ information at the group's mean θ (Lord, 1980 max-info CAT). Rankings persist to `report_item_information`. After those IRT main effects, residual SVD leftover pairs on two Gabriel axes (Jeon et al., 2021; ADR 0017 / 0048 / 0049 / 0119 / 0148 / 0158 / 0162 / 0163 / 0164 / 0168 / -0182 / 0185 / 0201 / 0233 / 0266) persist to `report_leftover_pair` with signed residual `R`, +0182 / 0185 / 0201 / 0233 / 0266 / 0267) persist to `report_leftover_pair` with signed residual `R`, observed `Y`, expected `E[Y|θ, item]`, full leftover-map rank, unexplained leftover, ADR 0201 reconstruction evidence, ADR 0185 cross-share evidence, -ADR 0233 unexplained leftover share `s`, and ADR 0266 explained leftover -share `e`. +ADR 0233 unexplained leftover share `s`, ADR 0266 explained leftover +share `e`, and ADR 0267 leftover-map coordinates `ξ_{1:2}` / `ζ_{1:2}`. Those ADRs are the normative mathematical and storage contracts. Leftover-map axis share (Gabriel inertia of residual SVD axes 1 and 2; ADR 0148) persists to `report_leftover_map_axis`. Complete-case leftover-map coverage (ADR @@ -660,7 +660,8 @@ 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 (signed residual `R`, observed `Y`, expected -`E`, full rank, and two-axis leftover-map distance `d` after IRT main +`E`, full rank, two-axis leftover-map distance `d`, and leftover-map +coordinates `ξ` / `ζ` after IRT main effects) above the member list, leftover-map axis share for residual SVD axes 1 and 2, and complete-case coverage captions (map used N of M scored posts), plus the diff --git a/CHANGELOG.d/2.24.0-leftover-map-coordinates.md b/CHANGELOG.d/2.24.0-leftover-map-coordinates.md new file mode 100644 index 000000000..68615b6e2 --- /dev/null +++ b/CHANGELOG.d/2.24.0-leftover-map-coordinates.md @@ -0,0 +1,10 @@ +## 2.24.0 — Leftover-map coordinates + +- Persist leftover-map coordinates `ξ_{1:2}` and `ζ_{1:2}` on leftover + post–criterion pairs (ADR 0267). After `make seed`, closest and + farthest leftover pairs sit above the member list with `ξ (x, y) + ζ (x, y)` next to leftover-map distance `d`; click opens that post. + Omit the badge when any coordinate is missing. Rank-0 unused axes + store `0.0`. When coordinates, reconstruction, and distance are + finite, `R̂ = ξ · ζ` and `d = ‖ξ − ζ‖`. Never invent a leftover + score. Never invent a theta. diff --git a/CHANGELOG.md b/CHANGELOG.md index cd32fefe7..84f6d2414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ All notable changes to this project are documented here. Format follows ### Added +- Period leftover pair rows now name leftover-map coordinates `ξ_{1:2}` + and `ζ_{1:2}` after two-axis Gabriel reconstruction (ADR 0267 / + v2.24.0). After `make seed`, closest and farthest leftover pairs sit + above the member list with `ξ (x, y) ζ (x, y)` next to leftover-map + distance `d`; click opens that post. Rank-0 unused axes store `0.0`. + When coordinates, reconstruction, and distance are finite, + `R̂ = ξ · ζ` and `d = ‖ξ − ζ‖`. Never invent a leftover score or a + theta. + - Period leftover pair rows now name leftover-map explained leftover share `e = R̂² / R²` of raw residual after two-axis Gabriel reconstruction (ADR 0266 / v2.23.0). After `make seed`, closest and farthest leftover diff --git a/CLAUDE.md b/CLAUDE.md index 87aa84643..bc12eb165 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ cutoff. Global Ask optional `knowledge_cutoff` uses the same cover Create/start endpoint rules (ADR 0017 / 0021), tie-vs-miss similarity (ADR 0026), R&R catalog ids (ADR 0019 / 0027), leftover pairs -(ADR 0048–0164 / 0182 / 0185 / 0201 / 0233 / 0266), occupational construct catalog search +(ADR 0048–0164 / 0182 / 0185 / 0201 / 0233 / 0266 / 0267), occupational construct catalog search (ADR 0257), the text-channel embedding swap and cosine clamp (ADR 0190), per-edge channel-score persistence (ADR 0195), token-backed status notices (ADR 0220), diff --git a/backend/app/report_ingestion.py b/backend/app/report_ingestion.py index e39594d0e..8a8d37bc2 100644 --- a/backend/app/report_ingestion.py +++ b/backend/app/report_ingestion.py @@ -448,8 +448,10 @@ async def persist_period_report( observed_response, expected_response, leftover_map_rank, leftover_map_unexplained, leftover_map_cross_share, leftover_map_reconstruction, leftover_map_unexplained_share, - leftover_map_explained_share - ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) + leftover_map_explained_share, leftover_map_person_axis_1, + leftover_map_person_axis_2, leftover_map_item_axis_1, + leftover_map_item_axis_2 + ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21) """, grouping_kind, grouping_key, @@ -468,6 +470,10 @@ async def persist_period_report( pair.leftover_map_reconstruction, pair.leftover_map_unexplained_share, pair.leftover_map_explained_share, + pair.leftover_map_person_axis_1, + pair.leftover_map_person_axis_2, + pair.leftover_map_item_axis_1, + pair.leftover_map_item_axis_2, ) for axis in report.leftover_map_axes: await conn.execute( @@ -656,7 +662,9 @@ async def fetch_period_reports( lp.observed_response, lp.expected_response, lp.leftover_map_rank, lp.leftover_map_unexplained, lp.leftover_map_cross_share, lp.leftover_map_reconstruction, lp.leftover_map_unexplained_share, - lp.leftover_map_explained_share, p.post_title, + lp.leftover_map_explained_share, lp.leftover_map_person_axis_1, + lp.leftover_map_person_axis_2, lp.leftover_map_item_axis_1, + lp.leftover_map_item_axis_2, p.post_title, p.visibility_code, p.corporate_entity_id, p.process_unit_id, ({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context from report_leftover_pair lp @@ -823,6 +831,26 @@ async def fetch_period_reports( if row["leftover_map_explained_share"] is None else float(row["leftover_map_explained_share"]) ), + "leftover_map_person_axis_1": ( + None + if row["leftover_map_person_axis_1"] is None + else float(row["leftover_map_person_axis_1"]) + ), + "leftover_map_person_axis_2": ( + None + if row["leftover_map_person_axis_2"] is None + else float(row["leftover_map_person_axis_2"]) + ), + "leftover_map_item_axis_1": ( + None + if row["leftover_map_item_axis_1"] is None + else float(row["leftover_map_item_axis_1"]) + ), + "leftover_map_item_axis_2": ( + None + if row["leftover_map_item_axis_2"] is None + else float(row["leftover_map_item_axis_2"]) + ), "visibility_code": row["visibility_code"], "corporate_entity_id": str(row["corporate_entity_id"]), "process_unit_id": ( diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index e4e61ffc4..10ca4993f 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -5731,6 +5731,26 @@ def test_seed_period_report_surfaces_on_get_reports(client, demo_analyst_token, assert unexplained_share >= 0.0 if explained_share is not None: assert explained_share >= 0.0 + person_axis_1 = pair.get("leftover_map_person_axis_1") + person_axis_2 = pair.get("leftover_map_person_axis_2") + item_axis_1 = pair.get("leftover_map_item_axis_1") + item_axis_2 = pair.get("leftover_map_item_axis_2") + axes = (person_axis_1, person_axis_2, item_axis_1, item_axis_2) + if any(axis is None for axis in axes): + assert axes == (None, None, None, None) + else: + for axis in axes: + assert isinstance(axis, (int, float)) + assert not math.isnan(axis) + assert not math.isinf(axis) + reconstruction = pair.get("leftover_map_reconstruction") + if reconstruction is not None: + assert reconstruction == pytest.approx( + person_axis_1 * item_axis_1 + person_axis_2 * item_axis_2 + ) + assert pair["leftover_distance"] == pytest.approx( + math.hypot(person_axis_1 - item_axis_1, person_axis_2 - item_axis_2) + ) if ( explained_share is not None and unexplained_share is not None diff --git a/docs/adr/0048-persist-lsirm-leftover-pairs.md b/docs/adr/0048-persist-lsirm-leftover-pairs.md index 0e74631e7..079c647f6 100644 --- a/docs/adr/0048-persist-lsirm-leftover-pairs.md +++ b/docs/adr/0048-persist-lsirm-leftover-pairs.md @@ -9,7 +9,8 @@ [ADR 0185](0185-leftover-map-cross-share.md) (leftover-map cross share); [ADR 0201](0201-leftover-map-reconstruction.md) (signed reconstruction R̂); [ADR 0233](0233-leftover-map-unexplained-share.md) (unexplained leftover share s); -[ADR 0266](0266-leftover-map-explained-share.md) (explained leftover share e) +[ADR 0266](0266-leftover-map-explained-share.md) (explained leftover share e); +[ADR 0267](0267-leftover-map-coordinates.md) (leftover-map coordinates ξ, ζ) ## Context @@ -61,6 +62,9 @@ residual so the leftover the truncated map reconstructs is not read as leftover residual `R`, leftover-map distance `d`, unexplained leftover `U`, leftover-map cross share `x`, or unexplained leftover share `s`. When `R`, `R̂`, `U`, `x`, `s`, and `e` are finite, `e + s + x = 1`. +ADR 0267 persists leftover-map coordinates `ξ_{1:2}` and `ζ_{1:2}` so +reconstruction `R̂ = ξ · ζ` and distance `d = ‖ξ − ζ‖` stay auditable +from the pair row. 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 5351dfd0c..3b4238dcf 100644 --- a/docs/adr/0049-leftover-pair-report-ui.md +++ b/docs/adr/0049-leftover-pair-report-ui.md @@ -10,7 +10,8 @@ [ADR 0185](0185-leftover-map-cross-share.md) (leftover-map cross share); [ADR 0201](0201-leftover-map-reconstruction.md) (signed reconstruction R̂); [ADR 0233](0233-leftover-map-unexplained-share.md) (unexplained leftover share s); -[ADR 0266](0266-leftover-map-explained-share.md) (explained leftover share e) +[ADR 0266](0266-leftover-map-explained-share.md) (explained leftover share e); +[ADR 0267](0267-leftover-map-coordinates.md) (leftover-map coordinates ξ, ζ) ## Context @@ -30,16 +31,16 @@ title, criterion short label, signed residual `R`, two-axis leftover-map distance, full map rank, observed `Y`, expected `E` when finite, unexplained leftover `U`, signed reconstruction `R̂` when finite, leftover-map unexplained leftover share `s = U² / R²` when finite, -leftover-map explained leftover share `e = R̂² / R²` when finite, and +leftover-map explained leftover share `e = R̂² / R²` when finite, +leftover-map coordinates `ξ_{1:2}` and `ζ_{1:2}` when finite, and leftover-map cross share next to distance when finite. The next action names every available measurement before opening the post; no amendment hides another, rank 0 explicitly names no leftover structure, and unexplained leftover names "leftover map leaves unexplained `U` after IRT main effects; open this post to read the named criterion" when present. When leftover-map -explained leftover share is also present, the next action instead names -the square share `e` of raw residual two leftover-map axes reconstruct -after IRT main effects. A missing or non-finite value falls back in order — -explained leftover share, then unexplained leftover share, then cross share, then reconstruction, then +coordinates are also present, the next action instead names the two-axis +positions `ξ` and `ζ` after IRT main effects. A missing or non-finite value falls back in order — +leftover-map coordinates, then explained leftover share, then unexplained leftover share, then cross share, then reconstruction, then unexplained leftover, then rank / observed `Y` / expected `E`, then the existing residual next action. Clicking the button opens that post with leftover focus so Post quality marks the named criterion current @@ -54,6 +55,8 @@ Unexplained leftover share naming is [ADR 0233](0233-leftover-map-unexplained-share.md). Explained leftover share naming is [ADR 0266](0266-leftover-map-explained-share.md). +Leftover-map coordinate naming is +[ADR 0267](0267-leftover-map-coordinates.md). After `make seed`, closest and farthest leftover pairs sit above the member list. Click a pair to open that post with the leftover diff --git a/docs/adr/0201-leftover-map-reconstruction.md b/docs/adr/0201-leftover-map-reconstruction.md index 049208105..2071ad646 100644 --- a/docs/adr/0201-leftover-map-reconstruction.md +++ b/docs/adr/0201-leftover-map-reconstruction.md @@ -3,6 +3,9 @@ **Decision status:** Accepted **Date:** 2026-08-25 +**Amended by:** [ADR 0267](0267-leftover-map-coordinates.md) +(leftover-map coordinates ξ, ζ) + Amends [ADR 0048](0048-persist-lsirm-leftover-pairs.md), [ADR 0049](0049-leftover-pair-report-ui.md), and [ADR 0182](0182-leftover-map-unexplained.md). Independent of landed diff --git a/docs/adr/0266-leftover-map-explained-share.md b/docs/adr/0266-leftover-map-explained-share.md index d8253fa4e..81a66105e 100644 --- a/docs/adr/0266-leftover-map-explained-share.md +++ b/docs/adr/0266-leftover-map-explained-share.md @@ -3,6 +3,9 @@ **Decision status:** Accepted **Date:** 2026-08-28 +**Amended by:** [ADR 0267](0267-leftover-map-coordinates.md) +(leftover-map coordinates ξ, ζ) + Amends [ADR 0048](0048-persist-lsirm-leftover-pairs.md) and [ADR 0049](0049-leftover-pair-report-ui.md). Independent of leftover-map unexplained leftover share ([ADR 0233](0233-leftover-map-unexplained-share.md)) diff --git a/docs/adr/0267-leftover-map-coordinates.md b/docs/adr/0267-leftover-map-coordinates.md new file mode 100644 index 000000000..1b3877f20 --- /dev/null +++ b/docs/adr/0267-leftover-map-coordinates.md @@ -0,0 +1,116 @@ +# ADR 0267 — Name leftover-map coordinates on period-report pair rows + +**Decision status:** Accepted +**Date:** 2026-08-28 + +Amends [ADR 0048](0048-persist-lsirm-leftover-pairs.md), +[ADR 0049](0049-leftover-pair-report-ui.md), and +[ADR 0201](0201-leftover-map-reconstruction.md). Independent of leftover-map +explained leftover share ([ADR 0266](0266-leftover-map-explained-share.md)), +leftover-map unexplained leftover share ([ADR 0233](0233-leftover-map-unexplained-share.md)), +and leftover-map reconstruction ([ADR 0201](0201-leftover-map-reconstruction.md)). + +## Context + +ADR 0201 already persists two-axis Gabriel reconstruction +`R̂ = ξ_{1:2} · ζ_{1:2}`. ADR 0119 already persists leftover-map +distance `d = ‖ξ_{1:2} − ζ_{1:2}‖`. Those terms are computed from +person and item coordinates that `_pad_map_axes` already produces, +then discards after `R̂` and `d`. A buyer who reads `R̂` next to `d` +cannot check either identity without the two leftover-map positions +the truncated map actually uses. Hiding `ξ` and `ζ` lets leftover +residual `R`, leftover-map distance `d`, or reconstruction `R̂` be +read as leftover-map location. + +This increment persists leftover-map coordinates `ξ_{1:2}` and +`ζ_{1:2}`. It does not name leftover-map inner product, cosine, or +length as separate columns (`R̂` and `d` already are those two-axis +facts), and does not land Post quality on the leftover criterion. +Leftover-map distance stays two-axis Euclidean. Reconstruction `R̂` +and unexplained leftover `U` remain the same internal two-axis terms +already used for `e`, `s`, and `x`, so +`R̂ = ξ_{1:2} · ζ_{1:2}` and `d = ‖ξ_{1:2} − ζ_{1:2}‖` stay +auditable from persisted coordinates. + +The dashboard stack already used neighbouring leftover facts under +other numbers. This protected-main increment uses **0267** +(migration **0245**) so it does not collide with leftover-map +explained leftover share (0266 / migration 0244), leftover-map +unexplained leftover share (0233 / migration 0233), leftover-map +reconstruction (0201 / migration 0206), leftover-map cross share +(0185), leftover residual disclosure, leftover observed `Y` / +expected `E`, leftover-map rank, two-axis leftover-map distance, +leftover coverage, leftover-map axis share (0148), leftover +interaction-map persistence, occupational construct catalog search +(0265), I/O occupational taxonomy (0245), or source-post voice +history (migration 0243). + +## Decision + +Each leftover pair names `leftover_map_person_axis_1`, +`leftover_map_person_axis_2`, `leftover_map_item_axis_1`, and +`leftover_map_item_axis_2` — two-axis Gabriel person coordinates +`ξ_{1:2}` and item coordinates `ζ_{1:2}`. Unused axes pad with +zero. Hidden SVD axes after the second are dropped. Migration +`0245` 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, residual, unexplained leftover, reconstruction, +cross share, unexplained leftover share, and explained leftover +share without fabricating a location. Fallback pairs that have no +complete-case leftover map omit the four values rather than +inventing them. A rank-0 origin cell stores `0.0` on every unused +axis, not a missing value. A non-finite coordinate omits all four +rather than inventing a leftover score. Signed coordinates are +stored, never clamped. Do not add an upper-bound or nonnegative +CHECK. Persist the four columns together so a buyer never reads a +partial location. + +The pair button shows `ξ (x, y) ζ (x, y)` next to leftover-map +distance `d` when all four values are finite. Next action: leftover +map places this post at `ξ` and the criterion at `ζ` after IRT main +effects; open this post to read the named criterion. A missing or +non-finite coordinate omits the badge and keeps the existing +explained-share / unexplained-share / cross-share / reconstruction +/ unexplained-leftover next action. Do not invent a leftover score. +Do not invent a theta. + +## Consequences + +`GET /api/reports/{grouping}/{period}` returns +`leftover_map_person_axis_1`, `leftover_map_person_axis_2`, +`leftover_map_item_axis_1`, and `leftover_map_item_axis_2`. 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. When coordinates, reconstruction, +and distance are all finite, +`R̂ = ξ_{1:2} · ζ_{1:2}` and `d = ‖ξ_{1:2} − ζ_{1:2}‖`. + +The grouping comparison strip (ADR 0149) stays on its reduced leftover +payload (distance, residual, reconstruction). Leftover-map coordinates +are a period-report pair fact, not a comparison-strip badge. + +## 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, +leftover-map cosine, leftover-map length, leftover-map reconstruction, +leftover-map unexplained leftover, leftover-map cross share, +leftover-map unexplained leftover share, and leftover-map explained +leftover share. + +## 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 +(LSIRM interaction `−γ‖ξ_j − ζ_i‖` after main effects +`α_j − β_i`; typically `p = 2` for the interaction map.) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index e2ad42646..4a8cff8cb 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,16 @@ # Product & Technical Gap Baseline +> Exact-head loop overlay: 2026-08-28 13:00 KST. Protected `main` is +> `fc13acaa20adca11968238e398d4aafcf62b6cee` (v2.23.0 leftover-map +> explained leftover share, #775). Open ready PRs still lack independent +> APPROVE. Drafts remain dirty against `main`. #96 stays closed as a +> weaker duplicate of #91. GitHub writes through `gh` succeed. +> +> Next buyer increment on this cycle: leftover-map coordinates +> `ξ_{1:2}` / `ζ_{1:2}` (ADR 0267 / migration 0245 / v2.24.0) so +> `R̂ = ξ · ζ` and `d = ‖ξ − ζ‖` are buyer-auditable. Do not name +> leftover-map inner product, cosine, or length as separate columns. + > Exact-head loop overlay: 2026-08-28 10:00 KST. Protected `main` was > `edf22ee39aee2a8481f9bda8fff59801821e79c2` (#773 similar-VOC coverage). > Open ready PRs: #772 (ask_time_axis coverage), #771 (fixtures/vision diff --git a/frontend/package.json b/frontend/package.json index 485a9a809..ad19c550a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.23.0", + "version": "2.24.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 13733e4a9..a3f27348d 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1045,6 +1045,10 @@ describe("App, authenticated", () => { leftover_map_reconstruction: 0.35, leftover_map_unexplained_share: 0.02, leftover_map_explained_share: 0.76, + leftover_map_person_axis_1: 0.5, + leftover_map_person_axis_2: 0.1, + leftover_map_item_axis_1: 0.5, + leftover_map_item_axis_2: -0.02, }, { pair_kind: "farthest", @@ -1061,6 +1065,10 @@ describe("App, authenticated", () => { leftover_map_reconstruction: -0.85, leftover_map_unexplained_share: 0.05, leftover_map_explained_share: 0.60, + leftover_map_person_axis_1: 0.9, + leftover_map_person_axis_2: 0.8, + leftover_map_item_axis_1: -0.7, + leftover_map_item_axis_2: -0.4, }, ], leftover_map_axes: [ @@ -4144,10 +4152,10 @@ describe("App, authenticated", () => { name: /open leftover farthest pair: specification revision requested/i, }); expect(closestPair).toHaveTextContent("Closest leftover: Public post · sales-lead"); - // Leftover-map explained leftover share is present, so it names the - // next action instead of leftover-map unexplained leftover share (ADR 0266). + // Leftover-map coordinates are present, so they name the next action + // instead of leftover-map explained leftover share (ADR 0267). expect(closestPair).toHaveTextContent( - "Leftover map leaves explained leftover share 0.76 of raw residual after IRT main effects. Open this post to read sales-lead.", + "Leftover map places this post at ξ (+0.50, +0.10) and the criterion at ζ (+0.50, −0.02) after IRT main effects. Open this post to read sales-lead.", ); expect(closestPair).toHaveTextContent("R +0.40"); expect(closestPair).toHaveTextContent("Y 2.40 · E 2.00"); @@ -4157,11 +4165,12 @@ describe("App, authenticated", () => { expect(closestPair).toHaveTextContent("R̂²/R² 0.76"); expect(closestPair).toHaveTextContent("2R̂U/R² 0.12"); expect(closestPair).toHaveTextContent("R̂ +0.35"); + expect(closestPair).toHaveTextContent("ξ (+0.50, +0.10) ζ (+0.50, −0.02)"); expect(closestPair).toHaveTextContent("d 0.12"); expect(closestPair).toHaveAccessibleName("Open leftover closest pair: Public post · sales-lead"); expect(farthestPair).toHaveTextContent("Farthest leftover: Specification revision requested · negative"); expect(farthestPair).toHaveTextContent( - "Leftover map leaves explained leftover share 0.60 of raw residual after IRT main effects. Open this post to read negative.", + "Leftover map places this post at ξ (+0.90, +0.80) and the criterion at ζ (−0.70, −0.40) after IRT main effects. Open this post to read negative.", ); expect(farthestPair).toHaveTextContent("R −1.10"); expect(farthestPair).toHaveTextContent("Y 0.90 · E 2.00"); @@ -4171,6 +4180,7 @@ describe("App, authenticated", () => { expect(farthestPair).toHaveTextContent("R̂²/R² 0.60"); expect(farthestPair).toHaveTextContent("2R̂U/R² -0.24"); expect(farthestPair).toHaveTextContent("R̂ −0.85"); + expect(farthestPair).toHaveTextContent("ξ (+0.90, +0.80) ζ (−0.70, −0.40)"); 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(); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 2b462eaa1..5db021a05 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1208,6 +1208,10 @@ export interface LeftoverPair { leftover_map_reconstruction?: number | null; leftover_map_unexplained_share?: number | null; leftover_map_explained_share?: number | null; + leftover_map_person_axis_1?: number | null; + leftover_map_person_axis_2?: number | null; + leftover_map_item_axis_1?: number | null; + leftover_map_item_axis_2?: number | null; } export interface LeftoverMapAxis { diff --git a/frontend/src/components/LeftoverPairList.stories.tsx b/frontend/src/components/LeftoverPairList.stories.tsx index c322ed3a8..20d081740 100644 --- a/frontend/src/components/LeftoverPairList.stories.tsx +++ b/frontend/src/components/LeftoverPairList.stories.tsx @@ -24,6 +24,10 @@ const meta = { leftover_map_cross_share: 0.12, leftover_map_unexplained_share: 0.02, leftover_map_explained_share: 0.76, + leftover_map_person_axis_1: 0.5, + leftover_map_person_axis_2: 0.1, + leftover_map_item_axis_1: 0.5, + leftover_map_item_axis_2: -0.02, }, { pair_kind: "farthest", @@ -40,6 +44,10 @@ const meta = { leftover_map_cross_share: -0.24, leftover_map_unexplained_share: 0.05, leftover_map_explained_share: 0.60, + leftover_map_person_axis_1: 0.9, + leftover_map_person_axis_2: 0.8, + leftover_map_item_axis_1: -0.7, + leftover_map_item_axis_2: -0.4, }, ], }, diff --git a/frontend/src/components/LeftoverPairList.test.tsx b/frontend/src/components/LeftoverPairList.test.tsx index ed5ac4fdc..9a8a6bfec 100644 --- a/frontend/src/components/LeftoverPairList.test.tsx +++ b/frontend/src/components/LeftoverPairList.test.tsx @@ -125,6 +125,42 @@ describe("LeftoverPairList", () => { expect(screen.getByRole("button")).toHaveTextContent(expectedAction); }); + it("names leftover-map coordinates so the next click opens that post", () => { + render( + , + ); + + const closest = screen.getByRole("button"); + expect(closest).toHaveTextContent( + "Leftover map places this post at ξ (+0.50, +0.10) and the criterion at ζ (+0.50, −0.02) after IRT main effects. Open this post to read sales-lead.", + ); + expect(closest).toHaveTextContent("ξ (+0.50, +0.10) ζ (+0.50, −0.02)"); + expect(closest).toHaveTextContent("R̂²/R² 0.76"); + expect(closest).toHaveTextContent("U²/R² 0.02"); + expect(closest).toHaveTextContent("2R̂U/R² 0.12"); + expect(closest).toHaveTextContent("R̂ +0.35"); + expect(closest).toHaveTextContent("U +0.05"); + expect(closest).toHaveTextContent("R +0.40"); + expect(closest).toHaveTextContent("d 0.12"); + }); + it("names leftover-map explained leftover share so the next click opens that post", () => { render( { expect(closest).toHaveTextContent("d 0.12"); }); + it("keeps leftover-map explained leftover share guidance when leftover-map coordinates are missing", () => { + render( + , + ); + + const closest = screen.getByRole("button"); + expect(closest).toHaveTextContent( + "Leftover map leaves explained leftover share 0.76 of raw residual after IRT main effects. Open this post to read sales-lead.", + ); + expect(closest).toHaveTextContent("R̂²/R² 0.76"); + expect(closest).not.toHaveTextContent("ξ"); + expect(closest).toHaveTextContent("d 0.12"); + }); + it("keeps leftover-map unexplained leftover share guidance when explained leftover share is missing", () => { render( {crossShareBadge} : null} {reconstruction ? {reconstruction} : null} + {coordinatesBadge ? {coordinatesBadge} : null} d {pair.leftover_distance.toFixed(2)} diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index 788da8f48..bcca442b3 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -57,6 +57,7 @@ describe("i18n", () => { "Two leftover-map axes leave identity remainder {value} of raw residual after IRT main effects. Open this post to read {criterion}.", "Leftover map leaves unexplained leftover share {value} of raw residual after IRT main effects. Open this post to read {criterion}.", "Leftover map leaves explained leftover share {value} of raw residual after IRT main effects. Open this post to read {criterion}.", + "Leftover map places this post at ξ {person} and the criterion at ζ {item} after IRT main effects. Open this post to read {criterion}.", "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.", "Leftover map has no leftover structure after IRT main effects. Open this post.", "Leftover map rank {rank} after IRT main effects. Open this post.", @@ -289,6 +290,33 @@ describe("i18n", () => { ).toBe(expected); }); + it.each([ + [ + "ko", + "잔여 지도가 IRT 주효과 이후 이 글을 ξ (+0.50, +0.10)에, 기준을 ζ (+0.50, −0.02)에 둡니다. sales-lead 기준을 읽으려면 이 글을 여세요.", + ], + [ + "zh", + "残差图在 IRT 主效应后将这篇帖子放在 ξ (+0.50, +0.10),将准则放在 ζ (+0.50, −0.02)。打开这篇帖子阅读 sales-lead。", + ], + [ + "ja", + "残差マップはIRT主効果後にこの投稿を ξ (+0.50, +0.10) に、基準を ζ (+0.50, −0.02) に置きます。この投稿を開いて sales-lead を読んでください。", + ], + [ + "vi", + "Bản đồ phần dư đặt bài viết này tại ξ (+0.50, +0.10) và tiêu chí tại ζ (+0.50, −0.02) sau hiệu ứng chính IRT. Mở bài viết này để đọc sales-lead.", + ], + ] as const)("formats leftover-map coordinates next action in %s", (locale, expected) => { + setLocale(locale); + expect( + tf( + "Leftover map places this post at ξ {person} and the criterion at ζ {item} after IRT main effects. Open this post to read {criterion}.", + { person: "(+0.50, +0.10)", item: "(+0.50, −0.02)", criterion: "sales-lead" }, + ), + ).toBe(expected); + }); + it.each([ ["ko", "IRT 주효과 이후 관측 Y 2.40와 기대 E 2.00를 읽은 다음, 이 글을 여세요."], ["zh", "阅读 IRT 主效应后的观测 Y 2.40 与期望 E 2.00,然后打开这篇帖子。"], diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 0ef440d2a..a7547dea4 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -581,6 +581,8 @@ const TRANSLATIONS: Partial>> = { "잔여 지도가 IRT 주효과 이후 원시 잔차의 설명되지 않은 잔여 비율 {value}을(를) 남깁니다. {criterion} 기준을 읽으려면 이 글을 여세요.", "Leftover map leaves explained leftover share {value} of raw residual after IRT main effects. Open this post to read {criterion}.": "잔여 지도가 IRT 주효과 이후 원시 잔차의 설명된 잔여 비율 {value}을(를) 남깁니다. {criterion} 기준을 읽으려면 이 글을 여세요.", + "Leftover map places this post at ξ {person} and the criterion at ζ {item} after IRT main effects. Open this post to read {criterion}.": + "잔여 지도가 IRT 주효과 이후 이 글을 ξ {person}에, 기준을 ζ {item}에 둡니다. {criterion} 기준을 읽으려면 이 글을 여세요.", "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.": "IRT 주효과 이후 관측 Y {observed}와 기대 E {expected}를 읽은 다음, 이 글을 여세요.", "Leftover map has no leftover structure after IRT main effects. Open this post.": @@ -1146,6 +1148,8 @@ const TRANSLATIONS: Partial>> = { "残差图在 IRT 主效应后留下原始残差的未解释残余份额 {value}。打开这篇帖子阅读 {criterion}。", "Leftover map leaves explained leftover share {value} of raw residual after IRT main effects. Open this post to read {criterion}.": "残差图在 IRT 主效应后留下原始残差的已解释残余份额 {value}。打开这篇帖子阅读 {criterion}。", + "Leftover map places this post at ξ {person} and the criterion at ζ {item} after IRT main effects. Open this post to read {criterion}.": + "残差图在 IRT 主效应后将这篇帖子放在 ξ {person},将准则放在 ζ {item}。打开这篇帖子阅读 {criterion}。", "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.": "阅读 IRT 主效应后的观测 Y {observed} 与期望 E {expected},然后打开这篇帖子。", "Leftover map has no leftover structure after IRT main effects. Open this post.": @@ -1715,6 +1719,8 @@ const TRANSLATIONS: Partial>> = { "残差マップはIRT主効果後の生の残差の未説明残差シェア {value} を残します。この投稿を開いて {criterion} を読んでください。", "Leftover map leaves explained leftover share {value} of raw residual after IRT main effects. Open this post to read {criterion}.": "残差マップはIRT主効果後の生の残差の説明済み残差シェア {value} を残します。この投稿を開いて {criterion} を読んでください。", + "Leftover map places this post at ξ {person} and the criterion at ζ {item} after IRT main effects. Open this post to read {criterion}.": + "残差マップはIRT主効果後にこの投稿を ξ {person} に、基準を ζ {item} に置きます。この投稿を開いて {criterion} を読んでください。", "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.": "IRT主効果後の観測 Y {observed} と期待 E {expected} を読んでから、この投稿を開いてください。", "Leftover map has no leftover structure after IRT main effects. Open this post.": @@ -2284,6 +2290,8 @@ const TRANSLATIONS: Partial>> = { "Bản đồ phần dư để lại tỷ phần phần dư chưa giải thích {value} của phần dư thô sau hiệu ứng chính IRT. Mở bài viết này để đọc {criterion}.", "Leftover map leaves explained leftover share {value} of raw residual after IRT main effects. Open this post to read {criterion}.": "Bản đồ phần dư để lại tỷ phần phần dư đã giải thích {value} của phần dư thô sau hiệu ứng chính IRT. Mở bài viết này để đọc {criterion}.", + "Leftover map places this post at ξ {person} and the criterion at ζ {item} after IRT main effects. Open this post to read {criterion}.": + "Bản đồ phần dư đặt bài viết này tại ξ {person} và tiêu chí tại ζ {item} sau hiệu ứng chính IRT. Mở bài viết này để đọc {criterion}.", "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.": "Đọc Y quan sát {observed} và E kỳ vọng {expected} sau hiệu ứng chính IRT, rồi mở bài viết này.", "Leftover map has no leftover structure after IRT main effects. Open this post.": diff --git a/frontend/src/leftoverMapCoordinates.test.ts b/frontend/src/leftoverMapCoordinates.test.ts new file mode 100644 index 000000000..95acbd551 --- /dev/null +++ b/frontend/src/leftoverMapCoordinates.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { + formatLeftoverMapCoordinatePair, + formatLeftoverMapCoordinates, +} from "./leftoverMapCoordinates"; + +describe("formatLeftoverMapCoordinates", () => { + it("names leftover-map coordinates without inventing a leftover score", () => { + expect(formatLeftoverMapCoordinates(0.5, 0.1, 0.5, -0.02)).toBe( + "\u03BE (+0.50, +0.10) \u03B6 (+0.50, \u22120.02)", + ); + expect(formatLeftoverMapCoordinates(0, 0, 0, 0)).toBe( + "\u03BE (0.00, 0.00) \u03B6 (0.00, 0.00)", + ); + expect(formatLeftoverMapCoordinates(-1.25, 2, 0.5, 0)).toBe( + "\u03BE (\u22121.25, +2.00) \u03B6 (+0.50, 0.00)", + ); + }); + + it("omits the badge when any leftover-map coordinate is missing or non-finite", () => { + expect(formatLeftoverMapCoordinates(null, 0, 0, 0)).toBeNull(); + expect(formatLeftoverMapCoordinates(0, undefined, 0, 0)).toBeNull(); + expect(formatLeftoverMapCoordinates(0, 0, Number.NaN, 0)).toBeNull(); + expect(formatLeftoverMapCoordinates(0, 0, 0, Number.POSITIVE_INFINITY)).toBeNull(); + expect(formatLeftoverMapCoordinates(0, Number.NEGATIVE_INFINITY, 0, 0)).toBeNull(); + }); +}); + +describe("formatLeftoverMapCoordinatePair", () => { + it("names one leftover-map position without inventing a leftover score", () => { + expect(formatLeftoverMapCoordinatePair(0.5, -0.02)).toBe("(+0.50, \u22120.02)"); + expect(formatLeftoverMapCoordinatePair(0, 0)).toBe("(0.00, 0.00)"); + }); + + it("omits a pair when either leftover-map axis is missing or non-finite", () => { + expect(formatLeftoverMapCoordinatePair(null, 0)).toBeNull(); + expect(formatLeftoverMapCoordinatePair(0, Number.NaN)).toBeNull(); + }); +}); diff --git a/frontend/src/leftoverMapCoordinates.ts b/frontend/src/leftoverMapCoordinates.ts new file mode 100644 index 000000000..3382f7882 --- /dev/null +++ b/frontend/src/leftoverMapCoordinates.ts @@ -0,0 +1,38 @@ +/** Two-axis leftover-map coordinates ``ξ_{1:2}`` and ``ζ_{1:2}``. */ + +import { formatSignedLeftoverValue } from "./leftoverMapUnexplained"; + +export const LEFTOVER_MAP_COORDINATES_ACTION = + "Leftover map places this post at ξ {person} and the criterion at ζ {item} after IRT main effects. Open this post to read {criterion}."; + +const PERSON_BADGE = "\u03BE"; +const ITEM_BADGE = "\u03B6"; + +export function formatLeftoverMapCoordinatePair( + axis1: number | null | undefined, + axis2: number | null | undefined, +): string | null { + if (axis1 == null || axis2 == null) { + return null; + } + const first = formatSignedLeftoverValue(axis1); + const second = formatSignedLeftoverValue(axis2); + if (first === null || second === null) { + return null; + } + return `(${first}, ${second})`; +} + +export function formatLeftoverMapCoordinates( + personAxis1: number | null | undefined, + personAxis2: number | null | undefined, + itemAxis1: number | null | undefined, + itemAxis2: number | null | undefined, +): string | null { + const person = formatLeftoverMapCoordinatePair(personAxis1, personAxis2); + const item = formatLeftoverMapCoordinatePair(itemAxis1, itemAxis2); + if (person === null || item === null) { + return null; + } + return `${PERSON_BADGE} ${person} ${ITEM_BADGE} ${item}`; +} diff --git a/lineageweave/leftover_pairs.py b/lineageweave/leftover_pairs.py index 7ef046a1b..46cf1f878 100644 --- a/lineageweave/leftover_pairs.py +++ b/lineageweave/leftover_pairs.py @@ -1,7 +1,7 @@ """Jeon leftover post–criterion pairs after a main-effect IRT. Implements ADR 0048 as amended by ADR 0119, ADR 0163, ADR 0164, ADR 0182, -ADR 0185, ADR 0201, ADR 0233, and ADR 0266. +ADR 0185, ADR 0201, ADR 0233, ADR 0266, and ADR 0267. Does not import ``fast_mlsirm`` or ``period_report``. A Gabriel biplot of the residual ``R = Y − E[Y|θ, item]`` supplies person and item @@ -28,9 +28,12 @@ leftover ``U``. Each pair also names leftover-map unexplained leftover share ``s = U² / R²`` of raw residual (ADR 0233) and leftover-map explained leftover share ``e = R̂² / R²`` of raw residual (ADR 0266). -Signed reconstruction ``R̂`` is persisted so ``U + R̂ = R`` stays -auditable and ``e + s + x = 1`` stays auditable from persisted -``R``, ``R̂``, ``U``, ``x``, ``s``, and ``e``. ``x`` may be negative when +Each pair further names leftover-map coordinates ``ξ_{1:2}`` and +``ζ_{1:2}`` (ADR 0267) so reconstruction ``R̂ = ξ · ζ`` and distance +``d = ‖ξ − ζ‖`` stay auditable from the same pair row. Signed +reconstruction ``R̂`` is persisted so ``U + R̂ = R`` stays auditable +and ``e + s + x = 1`` stays auditable from persisted ``R``, ``R̂``, +``U``, ``x``, ``s``, and ``e``. ``x`` may be negative when reconstruction and unexplained leftover have opposite signs. """ @@ -64,6 +67,10 @@ class LeftoverPair: leftover_map_reconstruction: float | None = None leftover_map_unexplained_share: float | None = None leftover_map_explained_share: float | None = None + leftover_map_person_axis_1: float | None = None + leftover_map_person_axis_2: float | None = None + leftover_map_item_axis_1: float | None = None + leftover_map_item_axis_2: float | None = None @dataclass(frozen=True) @@ -109,8 +116,10 @@ def leftover_pairs_from_residual( ``R`` after two-axis reconstruction ``R̂ = ξ_{1:2} · ζ_{1:2}`` and unexplained leftover ``U = R − R̂``, leftover-map unexplained leftover share ``s = U² / R²`` names the square share of that - leftover, and leftover-map explained leftover share ``e = R̂² / R²`` - names the square share the truncated map reconstructs. Signed ``R̂`` + leftover, leftover-map explained leftover share ``e = R̂² / R²`` + names the square share the truncated map reconstructs, and leftover-map + coordinates ``ξ_{1:2}`` / ``ζ_{1:2}`` name the two-axis positions + so ``R̂ = ξ · ζ`` and ``d = ‖ξ − ζ‖`` stay auditable. Signed ``R̂`` is persisted with ``U``, ``s``, and ``e`` so their raw-residual identity stays auditable. Without a complete-case map there is no pair to name (ADR 0168); the caller reads coverage counts instead of a @@ -166,6 +175,7 @@ def leftover_map_from_residual( tuple[ float, str, str, float, float, float, float | None, float | None, float | None, float | None, float | None, + float | None, float | None, float | None, float | None, ] ] = [] if person_pos is not None and item_pos is not None: @@ -178,19 +188,21 @@ def leftover_map_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_xy[local_person[person]] - item_xy[local_item[item]]) - ) + person_coord = person_xy[local_person[person]] + item_coord = item_xy[local_item[item]] + distance = float(np.linalg.norm(person_coord - item_coord)) if not np.isfinite(distance): continue - reconstruction = float( - np.dot(person_xy[local_person[person]], item_xy[local_item[item]]) - ) + reconstruction = float(np.dot(person_coord, item_coord)) residual_cell = float(residual[person, item]) unexplained = _unexplained_leftover(residual_cell, reconstruction) share = _leftover_map_cross_share(residual_cell, reconstruction) unexplained_share = _leftover_map_unexplained_share(residual_cell, reconstruction) explained_share = _leftover_map_explained_share(residual_cell, reconstruction) + coordinates = _leftover_map_coordinates(person_coord, item_coord) + person_axis_1, person_axis_2, item_axis_1, item_axis_2 = ( + coordinates if coordinates is not None else (None, None, None, None) + ) candidates.append( _candidate_row( post_ids, @@ -206,6 +218,10 @@ def leftover_map_from_residual( reconstruction if np.isfinite(reconstruction) else None, unexplained_share, explained_share, + person_axis_1, + person_axis_2, + item_axis_1, + item_axis_2, ) ) if not candidates: @@ -296,6 +312,32 @@ def _leftover_map_explained_share(residual: float, reconstruction: float) -> flo return None +def _leftover_map_coordinates( + person_xy: np.ndarray, + item_xy: np.ndarray, +) -> tuple[float, float, float, float] | None: + """Return two-axis ``ξ`` and ``ζ`` when all four values are finite. + + Rank-0 unused axes already pad with 0. A non-finite coordinate omits + all four rather than inventing a leftover score. Callers persist the + four together so reconstruction ``R̂ = ξ · ζ`` and distance + ``d = ‖ξ − ζ‖`` stay auditable from the same pair row. + """ + person = np.asarray(person_xy, dtype=np.float64).reshape(-1) + item = np.asarray(item_xy, dtype=np.float64).reshape(-1) + if person.size < _LEFTOVER_MAP_AXES or item.size < _LEFTOVER_MAP_AXES: + return None + coords = ( + float(person[0]), + float(person[1]), + float(item[0]), + float(item[1]), + ) + if not all(np.isfinite(value) for value in coords): + return None + return coords + + def _candidate_row( post_ids: list[str], item_codes: tuple[str, ...], @@ -310,11 +352,16 @@ def _candidate_row( leftover_map_reconstruction: float | None, leftover_map_unexplained_share: float | None, leftover_map_explained_share: float | None, + leftover_map_person_axis_1: float | None, + leftover_map_person_axis_2: float | None, + leftover_map_item_axis_1: float | None, + leftover_map_item_axis_2: float | None, ) -> tuple[ float, str, str, float, float, float, float | None, float | None, float | None, float | None, float | None, + float | None, float | None, float | None, float | None, ]: - """One observed cell: distance, ids, residual, Y, E, U, cross share, R̂, s, e.""" + """One observed cell: distance, ids, residual, Y, E, U, cross share, R̂, s, e, ξ, ζ.""" leftover_residual = float(residual[person, item]) observed_response = float(matrix[person, item]) expected_response = float(expected[person, item]) @@ -332,6 +379,10 @@ def _candidate_row( leftover_map_reconstruction, leftover_map_unexplained_share, leftover_map_explained_share, + leftover_map_person_axis_1, + leftover_map_person_axis_2, + leftover_map_item_axis_1, + leftover_map_item_axis_2, ) @@ -340,6 +391,7 @@ def _pair_from_candidate( row: tuple[ float, str, str, float, float, float, float | None, float | None, float | None, float | None, float | None, + float | None, float | None, float | None, float | None, ], leftover_map_rank: int, ) -> LeftoverPair: @@ -360,6 +412,10 @@ def _pair_from_candidate( leftover_map_reconstruction=row[8], leftover_map_unexplained_share=row[9], leftover_map_explained_share=row[10], + leftover_map_person_axis_1=row[11], + leftover_map_person_axis_2=row[12], + leftover_map_item_axis_1=row[13], + leftover_map_item_axis_2=row[14], ) @@ -497,7 +553,9 @@ def _pad_map_axes(positions: np.ndarray) -> np.ndarray: Hidden SVD axes after the second are dropped so reconstruction is ``ξ_{1:2} · ζ_{1:2}``, not the full-rank inner product. That reconstruction is persisted with unexplained leftover and cross share so - the raw-residual identity remains auditable. + the raw-residual identity remains auditable. The padded coordinates + themselves persist so ``R̂ = ξ · ζ`` and ``d = ‖ξ − ζ‖`` stay + auditable from the pair row. """ padded = np.zeros((positions.shape[0], _LEFTOVER_MAP_AXES), dtype=np.float64) width = min(_LEFTOVER_MAP_AXES, positions.shape[1]) diff --git a/migrations/0245_report_leftover_map_coordinates.sql b/migrations/0245_report_leftover_map_coordinates.sql new file mode 100644 index 000000000..0af975a92 --- /dev/null +++ b/migrations/0245_report_leftover_map_coordinates.sql @@ -0,0 +1,17 @@ +-- ADR 0267: persist leftover-map coordinates ξ_{1:2} and ζ_{1:2} +-- after two-axis leftover-map reconstruction (R̂ = ξ_{1:2} · ζ_{1:2}) +-- and two-axis leftover-map distance (d = ‖ξ_{1:2} − ζ_{1:2}‖). +-- Distance stays Euclidean leftover-map d. This migration adds only the +-- four coordinate columns. Upgrade columns are nullable so older leftover +-- rows keep distance, residual, unexplained leftover, reconstruction, +-- cross share, unexplained leftover share, and explained leftover share +-- without fabricating a location. This migration is the single source of +-- the columns on fresh and existing installations. Do not edit shipped +-- migrations 0001 / 0012 after the fact. Do not add an upper-bound or +-- nonnegative CHECK: signed coordinates are stored, never clamped. + +alter table report_leftover_pair + add column if not exists leftover_map_person_axis_1 numeric, + add column if not exists leftover_map_person_axis_2 numeric, + add column if not exists leftover_map_item_axis_1 numeric, + add column if not exists leftover_map_item_axis_2 numeric; diff --git a/migrations/rollback/0245_report_leftover_map_coordinates.sql b/migrations/rollback/0245_report_leftover_map_coordinates.sql new file mode 100644 index 000000000..1169c408e --- /dev/null +++ b/migrations/rollback/0245_report_leftover_map_coordinates.sql @@ -0,0 +1,9 @@ +-- Reverse 0245. Leftover distance, residual, unexplained leftover, +-- reconstruction, cross share, unexplained leftover share, and explained +-- leftover share stay on the pair row. + +alter table report_leftover_pair + drop column if exists leftover_map_person_axis_1, + drop column if exists leftover_map_person_axis_2, + drop column if exists leftover_map_item_axis_1, + drop column if exists leftover_map_item_axis_2; diff --git a/pyproject.toml b/pyproject.toml index 813206ad1..f915e1bdd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.23.0" +version = "2.24.0" 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 679ce8746..ca1718751 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -129,6 +129,7 @@ def seed( cur.execute((migrations / "0206_report_leftover_map_reconstruction.sql").read_text()) cur.execute((migrations / "0233_report_leftover_map_unexplained_share.sql").read_text()) cur.execute((migrations / "0244_report_leftover_map_explained_share.sql").read_text()) + cur.execute((migrations / "0245_report_leftover_map_coordinates.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()) @@ -1403,8 +1404,10 @@ def _persist_seed_period_report( "observed_response, expected_response, leftover_map_rank, " "leftover_map_unexplained, leftover_map_cross_share, " "leftover_map_reconstruction, leftover_map_unexplained_share, " - "leftover_map_explained_share" - ") values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", + "leftover_map_explained_share, leftover_map_person_axis_1, " + "leftover_map_person_axis_2, leftover_map_item_axis_1, " + "leftover_map_item_axis_2" + ") values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", ( grouping_kind, grouping_key, @@ -1423,6 +1426,10 @@ def _persist_seed_period_report( pair.leftover_map_reconstruction, pair.leftover_map_unexplained_share, pair.leftover_map_explained_share, + pair.leftover_map_person_axis_1, + pair.leftover_map_person_axis_2, + pair.leftover_map_item_axis_1, + pair.leftover_map_item_axis_2, ), ) for axis in report.leftover_map_axes: diff --git a/tests/test_leftover_pairs.py b/tests/test_leftover_pairs.py index 3f521b08f..f04b2da87 100644 --- a/tests/test_leftover_pairs.py +++ b/tests/test_leftover_pairs.py @@ -1,7 +1,7 @@ """Leftover post–criterion pairs after the main-effect IRT. Covers ADR 0048 as amended by ADR 0119, ADR 0148, ADR 0163, ADR 0164, -ADR 0182, ADR 0185, ADR 0201, ADR 0233, and ADR 0266. +ADR 0182, ADR 0185, ADR 0201, ADR 0233, ADR 0266, and ADR 0267. Uses a constructed residual matrix so the closest and farthest pair are known without calling ``fit_polytomous``. Loads @@ -65,6 +65,31 @@ def _assert_persists_explained_share(pair) -> None: assert hasattr(pair, "leftover_map_unexplained_share") +def _assert_persists_coordinates(pair) -> None: + """Leftover-map coordinates stay together and reconcile to R̂ and d.""" + axes = ( + pair.leftover_map_person_axis_1, + pair.leftover_map_person_axis_2, + pair.leftover_map_item_axis_1, + pair.leftover_map_item_axis_2, + ) + if any(axis is None for axis in axes): + assert axes == (None, None, None, None) + return + person_axis_1, person_axis_2, item_axis_1, item_axis_2 = axes + assert np.isfinite(person_axis_1) + assert np.isfinite(person_axis_2) + assert np.isfinite(item_axis_1) + assert np.isfinite(item_axis_2) + reconstruction = person_axis_1 * item_axis_1 + person_axis_2 * item_axis_2 + distance = float( + np.hypot(person_axis_1 - item_axis_1, person_axis_2 - item_axis_2) + ) + if pair.leftover_map_reconstruction is not None: + assert pair.leftover_map_reconstruction == pytest.approx(reconstruction) + assert pair.leftover_distance == pytest.approx(distance) + + def _gabriel_positions(filled: np.ndarray) -> tuple[np.ndarray, np.ndarray]: """Independent Gabriel coordinates used to prove leftover_distance axes.""" left, singular, right = np.linalg.svd(filled, full_matrices=False) @@ -122,6 +147,7 @@ def test_leftover_residual_biplot_separates_aligned_and_opposed_cells() -> None: for pair in pairs: _assert_residual_reconciles(pair) _assert_persists_explained_share(pair) + _assert_persists_coordinates(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 @@ -159,7 +185,12 @@ def test_zero_residual_still_emits_stable_leftover_pairs() -> None: assert pairs[1].leftover_map_reconstruction == pytest.approx(0.0) for pair in pairs: _assert_residual_reconciles(pair) + _assert_persists_coordinates(pair) assert pair.leftover_map_rank == 0 + assert pair.leftover_map_person_axis_1 == pytest.approx(0.0) + assert pair.leftover_map_person_axis_2 == pytest.approx(0.0) + assert pair.leftover_map_item_axis_1 == pytest.approx(0.0) + assert pair.leftover_map_item_axis_2 == pytest.approx(0.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 @@ -187,6 +218,11 @@ def test_rank_zero_nonzero_constant_residual_keeps_raw_identity() -> None: assert pair.leftover_map_unexplained + pair.leftover_map_reconstruction == pytest.approx( pair.leftover_residual ) + _assert_persists_coordinates(pair) + assert pair.leftover_map_person_axis_1 == pytest.approx(0.0) + assert pair.leftover_map_person_axis_2 == pytest.approx(0.0) + assert pair.leftover_map_item_axis_1 == pytest.approx(0.0) + assert pair.leftover_map_item_axis_2 == pytest.approx(0.0) def test_partial_observation_does_not_treat_missing_as_zero_residual() -> None: @@ -220,6 +256,7 @@ def test_partial_observation_does_not_treat_missing_as_zero_residual() -> None: assert pair.leftover_map_cross_share == pytest.approx(0.0, abs=1e-6) assert pair.leftover_map_unexplained_share == pytest.approx(0.0, abs=1e-6) assert pair.leftover_map_explained_share == pytest.approx(1.0, abs=1e-6) + _assert_persists_coordinates(pair) coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected) assert coverage.map_post_count == 2 assert coverage.scored_post_count == 3 @@ -276,6 +313,7 @@ def test_leftover_residual_equals_observed_minus_expected() -> None: assert farthest_cell.leftover_residual == pytest.approx(-1.1) for pair in pairs: _assert_residual_reconciles(pair) + _assert_persists_coordinates(pair) def test_leftover_residual_rejects_database_tolerance_boundary() -> None: @@ -295,6 +333,10 @@ def test_leftover_residual_rejects_database_tolerance_boundary() -> None: None, None, None, + None, + None, + None, + None, ) @@ -348,6 +390,7 @@ def test_rank_one_nonzero_center_is_disclosed_by_raw_residual_cross_share() -> N for pair in pairs: _assert_residual_reconciles(pair) _assert_persists_explained_share(pair) + _assert_persists_coordinates(pair) def test_rank_one_leftover_map_puts_all_inertia_on_axis_one() -> None: @@ -371,6 +414,8 @@ def test_rank_one_leftover_map_puts_all_inertia_on_axis_one() -> None: assert axes[0].leftover_singular_value > 0.0 assert axes[1].leftover_singular_value == pytest.approx(0.0) assert leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) == pairs + for pair in pairs: + _assert_persists_coordinates(pair) def test_zero_residual_emits_two_zero_share_leftover_map_axes() -> None: @@ -385,6 +430,12 @@ def test_zero_residual_emits_two_zero_share_leftover_map_axes() -> None: assert axes[1].leftover_share == pytest.approx(0.0) assert axes[0].leftover_singular_value == pytest.approx(0.0) assert axes[1].leftover_singular_value == pytest.approx(0.0) + for pair in pairs: + _assert_persists_coordinates(pair) + assert pair.leftover_map_person_axis_1 == pytest.approx(0.0) + assert pair.leftover_map_person_axis_2 == pytest.approx(0.0) + assert pair.leftover_map_item_axis_1 == pytest.approx(0.0) + assert pair.leftover_map_item_axis_2 == pytest.approx(0.0) def test_leftover_map_axes_from_singular_use_gabriel_inertia() -> None: @@ -441,6 +492,7 @@ def test_rank_four_pair_distances_match_two_dimensional_gabriel_coords() -> None assert pair.leftover_distance != pytest.approx( float(full_distances[person, item]), abs=1e-9 ) + _assert_persists_coordinates(pair) farthest_map = np.unravel_index(int(np.argmax(map_distances)), map_distances.shape) farthest = pairs[1] @@ -517,6 +569,7 @@ def test_unexplained_and_cross_share_are_identity_remainder_terms() -> None: assert pair.leftover_distance == pytest.approx(float(map_distances[person, item])) assert pair.leftover_map_rank == rank _assert_persists_explained_share(pair) + _assert_persists_coordinates(pair) assert saw_nonzero_cross @@ -530,6 +583,30 @@ def test_explained_share_stores_square_share_of_raw_residual() -> None: assert leftover._leftover_map_explained_share(1.0, float("inf")) is None +def test_leftover_map_coordinates_omit_nonfinite() -> None: + """A non-finite leftover-map axis omits all four coordinates.""" + assert leftover._leftover_map_coordinates( + np.array([1.0, 2.0], dtype=np.float64), + np.array([3.0, 4.0], dtype=np.float64), + ) == pytest.approx((1.0, 2.0, 3.0, 4.0)) + assert leftover._leftover_map_coordinates( + np.array([0.0, 0.0], dtype=np.float64), + np.array([0.0, 0.0], dtype=np.float64), + ) == pytest.approx((0.0, 0.0, 0.0, 0.0)) + assert leftover._leftover_map_coordinates( + np.array([np.nan, 0.0], dtype=np.float64), + np.array([0.0, 0.0], dtype=np.float64), + ) is None + assert leftover._leftover_map_coordinates( + np.array([1.0, np.inf], dtype=np.float64), + np.array([0.0, 0.0], dtype=np.float64), + ) is None + assert leftover._leftover_map_coordinates( + np.array([1.0], dtype=np.float64), + np.array([0.0, 0.0], dtype=np.float64), + ) is None + + def test_unexplained_share_stores_square_share_of_raw_residual() -> None: """s = U² / R² is stored; a share greater than 1 is not clamped.""" assert leftover._leftover_map_unexplained_share(1.0, 2.0) == pytest.approx(1.0) @@ -618,7 +695,7 @@ def test_leftover_map_rank_rejects_negative_rank() -> None: with pytest.raises(ValueError, match="non-negative integer"): leftover._pair_from_candidate( PAIR_KIND_CLOSEST, - (0.0, "public-post", "sales_lead_specificity", 0.0, 1.0, 1.0, None, None, None, None, None), + (0.0, "public-post", "sales_lead_specificity", 0.0, 1.0, 1.0, None, None, None, None, None, None, None, None, None), -1, ) diff --git a/tests/test_period_report.py b/tests/test_period_report.py index b1d4d7db0..accb0cd06 100644 --- a/tests/test_period_report.py +++ b/tests/test_period_report.py @@ -282,6 +282,27 @@ def test_calibrated_report_attaches_leftover_pairs() -> None: if pair.leftover_map_explained_share is not None: assert np.isfinite(pair.leftover_map_explained_share) assert pair.leftover_map_explained_share >= 0.0 + axes = ( + pair.leftover_map_person_axis_1, + pair.leftover_map_person_axis_2, + pair.leftover_map_item_axis_1, + pair.leftover_map_item_axis_2, + ) + if any(axis is None for axis in axes): + assert axes == (None, None, None, None) + else: + person_axis_1, person_axis_2, item_axis_1, item_axis_2 = axes + assert np.isfinite(person_axis_1) + assert np.isfinite(person_axis_2) + assert np.isfinite(item_axis_1) + assert np.isfinite(item_axis_2) + if pair.leftover_map_reconstruction is not None: + assert pair.leftover_map_reconstruction == pytest.approx( + person_axis_1 * item_axis_1 + person_axis_2 * item_axis_2 + ) + assert pair.leftover_distance == pytest.approx( + float(np.hypot(person_axis_1 - item_axis_1, person_axis_2 - item_axis_2)) + ) if ( pair.leftover_map_explained_share is not None and pair.leftover_map_unexplained_share is not None diff --git a/tests/test_schema.py b/tests/test_schema.py index e5b4291f4..94876295f 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -136,6 +136,11 @@ / "migrations" / "0244_report_leftover_map_explained_share.sql" ) +_LEFTOVER_MAP_COORDINATES_MIGRATION = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0245_report_leftover_map_coordinates.sql" +) _LEFTOVER_MAP_AXIS_MIGRATION = ( Path(__file__).resolve().parents[1] / "migrations" @@ -231,6 +236,7 @@ def schema_db(): cur.execute(_LEFTOVER_MAP_RECONSTRUCTION_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_UNEXPLAINED_SHARE_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_EXPLAINED_SHARE_MIGRATION.read_text()) + cur.execute(_LEFTOVER_MAP_COORDINATES_MIGRATION.read_text()) cur.execute(_SOURCE_EVENT_TIME_MIGRATION.read_text()) cur.execute(_GLOBAL_ASK_JOB_MIGRATION.read_text()) cur.execute(_GLOBAL_ASK_SCOPE_MIGRATION.read_text()) @@ -794,6 +800,10 @@ def test_leftover_pair_names_nullable_cross_share_column(schema_db) -> None: assert columns["leftover_map_explained_share"] == "YES" assert columns["leftover_map_unexplained_share"] == "YES" assert columns["leftover_map_reconstruction"] == "YES" + assert columns["leftover_map_person_axis_1"] == "YES" + assert columns["leftover_map_person_axis_2"] == "YES" + assert columns["leftover_map_item_axis_1"] == "YES" + assert columns["leftover_map_item_axis_2"] == "YES" with schema_db.cursor() as cur: cur.execute( """ diff --git a/uv.lock b/uv.lock index ca3833c09..79f7d12e5 100644 --- a/uv.lock +++ b/uv.lock @@ -685,7 +685,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.23.0" +version = "2.24.0" source = { editable = "." } dependencies = [ { name = "certifi" }, From 2f7e9c8df695f12d03964d5caa68fa3355bdd923 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 03:18:04 -0700 Subject: [PATCH 2/7] feat(reports): leftover-map graphic display (v2.25.0) (#783) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(reports): leftover-map graphic display (v2.25.0) Draw persisted leftover-map coordinates ξ and ζ as a Gabriel biplot above leftover pair buttons (ADR 0268). Click a post marker to open that post. Omit the plot when coordinates are missing. No new columns. Never invent a leftover score or a theta. * fix(reports): preserve leftover-map distances Signed-off-by: Codex * fix(reports): keep plot labels readable Signed-off-by: Codex * docs(reports): correct leftover plot omission rule --------- Signed-off-by: Codex Co-authored-by: Codex --- AGENTS.md | 7 +- ARCHITECTURE.md | 7 +- .../2.25.0-leftover-map-graphic-display.md | 11 + CHANGELOG.md | 10 + CLAUDE.md | 2 +- docs/adr/0048-persist-lsirm-leftover-pairs.md | 6 +- docs/adr/0049-leftover-pair-report-ui.md | 10 +- docs/adr/0267-leftover-map-coordinates.md | 7 +- docs/adr/0268-leftover-map-graphic-display.md | 104 ++++++++++ docs/product-technical-gap-baseline.md | 14 ++ docs/storybook-inventory.md | 2 + frontend/package.json | 2 +- frontend/src/App.test.tsx | 6 + frontend/src/components/LeftoverMapPlot.css | 123 +++++++++++ .../components/LeftoverMapPlot.stories.tsx | 98 +++++++++ .../src/components/LeftoverMapPlot.test.tsx | 113 ++++++++++ frontend/src/components/LeftoverMapPlot.tsx | 136 ++++++++++++ .../src/components/LeftoverPairList.test.tsx | 41 +++- frontend/src/components/LeftoverPairList.tsx | 20 +- frontend/src/i18n.test.ts | 31 +++ frontend/src/i18n.ts | 40 ++++ frontend/src/leftoverMapPlotLayout.test.ts | 172 +++++++++++++++ frontend/src/leftoverMapPlotLayout.ts | 195 ++++++++++++++++++ pyproject.toml | 2 +- 24 files changed, 1140 insertions(+), 19 deletions(-) create mode 100644 CHANGELOG.d/2.25.0-leftover-map-graphic-display.md create mode 100644 docs/adr/0268-leftover-map-graphic-display.md create mode 100644 frontend/src/components/LeftoverMapPlot.css create mode 100644 frontend/src/components/LeftoverMapPlot.stories.tsx create mode 100644 frontend/src/components/LeftoverMapPlot.test.tsx create mode 100644 frontend/src/components/LeftoverMapPlot.tsx create mode 100644 frontend/src/leftoverMapPlotLayout.test.ts create mode 100644 frontend/src/leftoverMapPlotLayout.ts diff --git a/AGENTS.md b/AGENTS.md index 52649f853..4b82dc788 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -273,7 +273,7 @@ stops startup instead of leaving a healthy-looking partial schema, and application code must not compensate for a missing table. Period leftover pairs (ADR 0017 / 0018 / 0048 / 0049 / 0119 / 0158 / 0162 / -0163 / 0164 / 0182 / 0185 / 0201 / 0233 / 0266 / 0267) are computed in `lineageweave/leftover_pairs.py` from the +0163 / 0164 / 0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268) are computed in `lineageweave/leftover_pairs.py` from the residual after a real GRM/GPCM score, never invented. Distances are Euclidean on the two-dimensional Gabriel leftover map; missing cells stay out of the factorization. Closest and farthest post–criterion pairs @@ -288,7 +288,10 @@ ADR 0201 is the sole normative reconstruction formula, storage, and audit contract; do not duplicate or reinterpret it here. ADR 0233 is the sole unexplained leftover share contract. ADR 0266 is the sole explained leftover share contract. ADR 0267 is the sole -leftover-map coordinate contract. When `R`, `R̂`, `U`, `x`, +leftover-map coordinate contract. ADR 0268 is the sole leftover-map +graphic-display contract: draw persisted `ξ` and `ζ` above the pair +buttons; omit the plot when coordinates are missing; click a post +marker to open that post. When `R`, `R̂`, `U`, `x`, `s`, and `e` are finite, `e + s + x = 1`. When coordinates, reconstruction, and distance are finite, `R̂ = ξ · ζ` and `d = ‖ξ − ζ‖`. The pairs sit above the member diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 90c80dd58..c515fec1c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -637,12 +637,15 @@ information at the group's mean θ (Lord, 1980 max-info CAT). Rankings persist to `report_item_information`. After those IRT main effects, residual SVD leftover pairs on two Gabriel axes (Jeon et al., 2021; ADR 0017 / 0048 / 0049 / 0119 / 0148 / 0158 / 0162 / 0163 / 0164 / 0168 / -0182 / 0185 / 0201 / 0233 / 0266 / 0267) persist to `report_leftover_pair` with signed residual `R`, +0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268) persist to `report_leftover_pair` with signed residual `R`, observed `Y`, expected `E[Y|θ, item]`, full leftover-map rank, unexplained leftover, ADR 0201 reconstruction evidence, ADR 0185 cross-share evidence, ADR 0233 unexplained leftover share `s`, ADR 0266 explained leftover share `e`, and ADR 0267 leftover-map coordinates `ξ_{1:2}` / `ζ_{1:2}`. -Those ADRs are the normative mathematical and storage contracts. Leftover-map axis share +ADR 0268 draws those persisted coordinates as the leftover-map graphic +display above the pair buttons. Click a post marker or a pair button +opens that post. Those ADRs are the normative mathematical, storage, and +display contracts. Leftover-map axis share (Gabriel inertia of residual SVD axes 1 and 2; ADR 0148) persists to `report_leftover_map_axis`. Complete-case leftover-map coverage (ADR 0168) persists to `report_leftover_map_coverage` so readers see how diff --git a/CHANGELOG.d/2.25.0-leftover-map-graphic-display.md b/CHANGELOG.d/2.25.0-leftover-map-graphic-display.md new file mode 100644 index 000000000..cf5a83c5e --- /dev/null +++ b/CHANGELOG.d/2.25.0-leftover-map-graphic-display.md @@ -0,0 +1,11 @@ +## 2.25.0 — Leftover-map graphic display + +- Draw the leftover-map graphic display of persisted `ξ_{1:2}` and + `ζ_{1:2}` above leftover post–criterion pairs (ADR 0268). After + `make seed`, closest and farthest leftover pairs sit above the + member list with the Gabriel biplot of already-named coordinates; + click a post marker or a pair button opens that post. Omit the plot + when no pair has four finite leftover-map coordinates. Rank-0 + unused axes plot at the origin. When coordinates, reconstruction, + and distance are finite, `R̂ = ξ · ζ` and `d = ‖ξ − ζ‖`. Never + invent a leftover score. Never invent a theta. No new columns. diff --git a/CHANGELOG.md b/CHANGELOG.md index 84f6d2414..e67f7e2ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ All notable changes to this project are documented here. Format follows ### Added +- Period leftover pairs now draw the leftover-map graphic display of + persisted `ξ_{1:2}` and `ζ_{1:2}` (ADR 0268 / v2.25.0). After + `make seed`, closest and farthest leftover pairs sit above the + member list with the Gabriel biplot of already-named coordinates; + click a post marker or a pair button opens that post. Omit the plot + when no pair has four finite leftover-map coordinates. Rank-0 unused + axes plot at the origin. When coordinates, reconstruction, and + distance are finite, `R̂ = ξ · ζ` and `d = ‖ξ − ζ‖`. Never invent + a leftover score or a theta. No new columns. + - Period leftover pair rows now name leftover-map coordinates `ξ_{1:2}` and `ζ_{1:2}` after two-axis Gabriel reconstruction (ADR 0267 / v2.24.0). After `make seed`, closest and farthest leftover pairs sit diff --git a/CLAUDE.md b/CLAUDE.md index bc12eb165..f8c7fd19c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ cutoff. Global Ask optional `knowledge_cutoff` uses the same cover Create/start endpoint rules (ADR 0017 / 0021), tie-vs-miss similarity (ADR 0026), R&R catalog ids (ADR 0019 / 0027), leftover pairs -(ADR 0048–0164 / 0182 / 0185 / 0201 / 0233 / 0266 / 0267), occupational construct catalog search +(ADR 0048–0164 / 0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268), occupational construct catalog search (ADR 0257), the text-channel embedding swap and cosine clamp (ADR 0190), per-edge channel-score persistence (ADR 0195), token-backed status notices (ADR 0220), diff --git a/docs/adr/0048-persist-lsirm-leftover-pairs.md b/docs/adr/0048-persist-lsirm-leftover-pairs.md index 079c647f6..53e6053aa 100644 --- a/docs/adr/0048-persist-lsirm-leftover-pairs.md +++ b/docs/adr/0048-persist-lsirm-leftover-pairs.md @@ -10,7 +10,8 @@ [ADR 0201](0201-leftover-map-reconstruction.md) (signed reconstruction R̂); [ADR 0233](0233-leftover-map-unexplained-share.md) (unexplained leftover share s); [ADR 0266](0266-leftover-map-explained-share.md) (explained leftover share e); -[ADR 0267](0267-leftover-map-coordinates.md) (leftover-map coordinates ξ, ζ) +[ADR 0267](0267-leftover-map-coordinates.md) (leftover-map coordinates ξ, ζ); +[ADR 0268](0268-leftover-map-graphic-display.md) (leftover-map graphic display) ## Context @@ -64,7 +65,8 @@ leftover residual `R`, leftover-map distance `d`, unexplained leftover When `R`, `R̂`, `U`, `x`, `s`, and `e` are finite, `e + s + x = 1`. ADR 0267 persists leftover-map coordinates `ξ_{1:2}` and `ζ_{1:2}` so reconstruction `R̂ = ξ · ζ` and distance `d = ‖ξ − ζ‖` stay auditable -from the pair row. +from the pair row. ADR 0268 draws those persisted coordinates as the +leftover-map graphic display; it adds no columns. 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 3b4238dcf..ee14cb697 100644 --- a/docs/adr/0049-leftover-pair-report-ui.md +++ b/docs/adr/0049-leftover-pair-report-ui.md @@ -11,7 +11,8 @@ [ADR 0201](0201-leftover-map-reconstruction.md) (signed reconstruction R̂); [ADR 0233](0233-leftover-map-unexplained-share.md) (unexplained leftover share s); [ADR 0266](0266-leftover-map-explained-share.md) (explained leftover share e); -[ADR 0267](0267-leftover-map-coordinates.md) (leftover-map coordinates ξ, ζ) +[ADR 0267](0267-leftover-map-coordinates.md) (leftover-map coordinates ξ, ζ); +[ADR 0268](0268-leftover-map-graphic-display.md) (leftover-map graphic display) ## Context @@ -33,7 +34,10 @@ unexplained leftover `U`, signed reconstruction `R̂` when finite, leftover-map unexplained leftover share `s = U² / R²` when finite, leftover-map explained leftover share `e = R̂² / R²` when finite, leftover-map coordinates `ξ_{1:2}` and `ζ_{1:2}` when finite, and -leftover-map cross share next to distance when finite. The next action names every available +leftover-map cross share next to distance when finite. When four finite +coordinates exist, the leftover-map graphic display of those positions +sits above the pair buttons (ADR 0268); click a post marker to open +that post. The next action names every available measurement before opening the post; no amendment hides another, rank 0 explicitly names no leftover structure, and unexplained leftover names "leftover map leaves unexplained `U` after IRT main effects; open this @@ -57,6 +61,8 @@ Explained leftover share naming is [ADR 0266](0266-leftover-map-explained-share.md). Leftover-map coordinate naming is [ADR 0267](0267-leftover-map-coordinates.md). +Leftover-map graphic display is +[ADR 0268](0268-leftover-map-graphic-display.md). After `make seed`, closest and farthest leftover pairs sit above the member list. Click a pair to open that post with the leftover diff --git a/docs/adr/0267-leftover-map-coordinates.md b/docs/adr/0267-leftover-map-coordinates.md index 1b3877f20..be4919955 100644 --- a/docs/adr/0267-leftover-map-coordinates.md +++ b/docs/adr/0267-leftover-map-coordinates.md @@ -3,6 +3,9 @@ **Decision status:** Accepted **Date:** 2026-08-28 +**Amended by:** [ADR 0268](0268-leftover-map-graphic-display.md) +(leftover-map graphic display) + Amends [ADR 0048](0048-persist-lsirm-leftover-pairs.md), [ADR 0049](0049-leftover-pair-report-ui.md), and [ADR 0201](0201-leftover-map-reconstruction.md). Independent of leftover-map @@ -99,8 +102,8 @@ axis share, leftover pairs on the grouping comparison strip, two-axis leftover-map distance, leftover-map rank, leftover-map inner product, leftover-map cosine, leftover-map length, leftover-map reconstruction, leftover-map unexplained leftover, leftover-map cross share, -leftover-map unexplained leftover share, and leftover-map explained -leftover share. +leftover-map unexplained leftover share, leftover-map explained +leftover share, and leftover-map graphic display. ## References diff --git a/docs/adr/0268-leftover-map-graphic-display.md b/docs/adr/0268-leftover-map-graphic-display.md new file mode 100644 index 000000000..05dd751b5 --- /dev/null +++ b/docs/adr/0268-leftover-map-graphic-display.md @@ -0,0 +1,104 @@ +# ADR 0268 — Show leftover-map graphic display of persisted coordinates + +**Decision status:** Accepted +**Date:** 2026-08-28 + +Amends [ADR 0049](0049-leftover-pair-report-ui.md) and +[ADR 0267](0267-leftover-map-coordinates.md). Independent of leftover-map +explained leftover share ([ADR 0266](0266-leftover-map-explained-share.md)), +leftover-map unexplained leftover share ([ADR 0233](0233-leftover-map-unexplained-share.md)), +and leftover-map reconstruction ([ADR 0201](0201-leftover-map-reconstruction.md)). + +## Context + +ADR 0267 already persists two-axis Gabriel person coordinates +`ξ_{1:2}` and item coordinates `ζ_{1:2}` on leftover pair rows so +`R̂ = ξ_{1:2} · ζ_{1:2}` and `d = ‖ξ_{1:2} − ζ_{1:2}‖` stay +auditable. Those four numbers still read as a badge next to distance. +Gabriel (1971) is a *graphic display* of the same two marker sets; +Jeon et al. (2021) plot the leftover interaction map as person and +item positions after IRT main effects. Hiding the plot lets leftover +residual `R`, leftover-map distance `d`, or reconstruction `R̂` be +read as leftover-map location even after the coordinates themselves +are named. + +This increment draws the leftover-map graphic display from already +persisted `ξ` and `ζ`. It does not add columns. It does not persist +leftover-map inner product, cosine, or length (`R̂` and `d` already +are those two-axis facts). It does not land Post quality on the +leftover criterion. Leftover-map distance stays two-axis Euclidean. +Do not invent a leftover score. Do not invent a theta. + +The dashboard stack already used neighbouring leftover facts under +other numbers. This protected increment uses **0268** so it does not +collide with leftover-map coordinates (0267 / migration 0245), +leftover-map explained leftover share (0266 / migration 0244), +leftover-map unexplained leftover share (0233 / migration 0233), +leftover-map reconstruction (0201 / migration 0206), leftover-map +cross share (0185), leftover residual disclosure, leftover observed +`Y` / expected `E`, leftover-map rank, two-axis leftover-map +distance, leftover coverage, leftover-map axis share (0148), leftover +interaction-map persistence, occupational construct catalog search +(0265), or the dashboard stacks. + +## Decision + +On each period-report group that already lists leftover pairs, render +a two-axis leftover-map graphic display **above** the leftover pair +buttons when at least one pair has four finite coordinates. Person +markers are posts at persisted `ξ_{1:2}`; item markers are criteria +at persisted `ζ_{1:2}`. A faint segment joins each closest or +farthest pair so leftover-map distance `d` is the drawn length, not a +second score. The origin stays in view because it is the rank-0 +unused-axis location. Scale is isotropic so Euclidean `d` is visually +comparable on both axes. A rank-0 origin cell plots at `(0, 0)` with +a unit display window; that window is drawing scale, not a leftover +score. + +Click a post marker to open that post with leftover focus so Post +quality marks the named criterion current (ADR 0158). Criterion +markers are not post buttons. A missing or non-finite coordinate omits +that pair from the plot rather than inventing a location. When no +pair has four finite coordinates, omit the plot and keep the existing +pair-list next action. Duplicate posts share one person marker; +duplicate criteria share one item marker. The grouping comparison +strip (ADR 0149) stays on its reduced leftover payload and does not +gain this plot. + +Do not add SQL. Do not edit shipped migrations. Do not persist inner +product, cosine, or length as separate columns. + +## Consequences + +After `make seed`, closest and farthest leftover pairs sit above the +member list with the leftover-map graphic display of persisted `ξ` +and `ζ`; click a post marker or a pair button opens that post. +Hidden posts stay hidden. When coordinates, reconstruction, and +distance are all finite, `R̂ = ξ_{1:2} · ζ_{1:2}` and +`d = ‖ξ_{1:2} − ζ_{1:2}‖` remain the same identities already +persisted by ADR 0267. + +## 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, +leftover-map cosine, leftover-map length, leftover-map reconstruction, +leftover-map unexplained leftover, leftover-map cross share, +leftover-map unexplained leftover share, leftover-map explained +leftover share, and leftover-map coordinate persistence. + +## 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 +(LSIRM interaction `−γ‖ξ_j − ζ_i‖` after main effects +`α_j − β_i`; typically `p = 2` for the interaction map.) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 4a8cff8cb..8ae2beddf 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,19 @@ # Product & Technical Gap Baseline +> Exact-head loop overlay: 2026-08-28 16:05 KST. Protected `main` is +> `fc13acaa20adca11968238e398d4aafcf62b6cee` (v2.23.0 leftover-map +> explained leftover share, #775). Open ready PRs still lack independent +> APPROVE. #782 leftover-map coordinates (v2.24.0 / ADR 0267) is on +> `e2d13019004a5d8c019fecf7a39ceeef4093b8dd`; Strix fail-closed and no +> independent APPROVE. Drafts remain dirty against `main`. #96 stays +> closed as a weaker duplicate of #91. GitHub writes through MCP succeed. +> +> Next buyer increment on this cycle: leftover-map graphic display +> of already-persisted `ξ_{1:2}` / `ζ_{1:2}` (ADR 0268 / v2.25.0). +> UI-only; no new columns. `R̂` and `d` already are inner product and +> length. Do not invent leftover scores. Do not mix into dashboard +> stacks #640/#778/#781. + > Exact-head loop overlay: 2026-08-28 13:00 KST. Protected `main` is > `fc13acaa20adca11968238e398d4aafcf62b6cee` (v2.23.0 leftover-map > explained leftover share, #775). Open ready PRs still lack independent diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 5fa3d8a5e..094452a31 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -5,6 +5,8 @@ operator-facing control you can click before changing product CSS. | Story | Operator next action | Token / module | |---|---|---| +| `Reports/LeftoverMapPlot` | Read the leftover-map graphic display of persisted `ξ` (posts) and `ζ` (criteria), then click a post marker to open that post. `ClosestAndFarthest`, `RankZeroOrigin`, and `MissingCoordinates` cover two-pair maps, rank-0 origin, and omitted plots. The plot does not invent a leftover score. | `LeftoverMapPlot`, `leftoverMapPlotLayout`, `--color-primary`, `--color-palette-blue-mid` | +| `Reports/LeftoverPairList` | Read closest/farthest leftover pairs with named `R`, `Y`/`E`, rank, `U`, `s`, `e`, `x`, `R̂`, `ξ`/`ζ`, and `d`, then open that post. The leftover-map graphic display sits above the pair buttons when coordinates are finite. | `LeftoverPairList`, `LeftoverMapPlot`, `ticket-list`, `post-badge` | | `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, or repeat-issue fact. `EvidenceReady`, `NarrowViewport`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover populated, mobile, unavailable-evidence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard` | | `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` | | `Post/Recorded perspectives` | Read the imported primary and every evidence-connected additional Voice with its recorded truth state instead of flattening them into one compound category. `CombinedEvidence`, `RejectedEvidence`, and `NarrowViewport` cover desktop, rejected-evidence, and narrow layouts. | `VoicePerspectiveList`, `ticket-list`, `post-meta` | diff --git a/frontend/package.json b/frontend/package.json index ad19c550a..87b052607 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.24.0", + "version": "2.25.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index a3f27348d..d7a847147 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -4143,6 +4143,12 @@ 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 graphic display")).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: /open leftover-map post public post at ξ \(\+0\.50, \+0\.10\)/i, + }), + ).toBeInTheDocument(); expect(screen.getByLabelText("Leftover map coverage")).toHaveTextContent( "Leftover map used 2 of 3 scored posts (complete-case)", ); diff --git a/frontend/src/components/LeftoverMapPlot.css b/frontend/src/components/LeftoverMapPlot.css new file mode 100644 index 000000000..d3dff89d6 --- /dev/null +++ b/frontend/src/components/LeftoverMapPlot.css @@ -0,0 +1,123 @@ +.leftover-map-plot { + margin: 0 0 var(--space-panel-block); + padding: var(--space-panel-block); + border: 1px solid var(--color-border); + border-radius: var(--radius-panel); + background: var(--color-background); +} + +.leftover-map-plot-caption { + margin: 0 0 var(--space-control-gap); + color: var(--color-text); + font-size: 0.85rem; + line-height: 1.45; +} + +.leftover-map-plot-legend { + display: flex; + flex-wrap: wrap; + gap: var(--space-chip-gap) var(--space-chip-inline); + margin: 0 0 var(--space-control-gap); + padding: 0; + list-style: none; + color: var(--color-text-heading); + font-size: 0.8rem; +} + +.leftover-map-plot-legend-swatch { + display: inline-block; + width: 0.7rem; + height: 0.7rem; + margin-right: var(--space-chip-gap); + vertical-align: -0.05rem; + border: 1px solid var(--color-border); +} + +.leftover-map-plot-legend-swatch.person { + border-radius: 999px; + background: var(--color-primary); +} + +.leftover-map-plot-legend-swatch.item { + background: var(--color-palette-blue-mid); + transform: rotate(45deg); +} + +.leftover-map-plot-viewport { + max-width: 100%; + overflow-x: auto; + overscroll-behavior-inline: contain; + scrollbar-gutter: stable; + -webkit-overflow-scrolling: touch; +} + +.leftover-map-plot-viewport:focus-visible { + outline: 2px solid var(--color-focus-border); + outline-offset: 2px; + border-radius: var(--radius-control); +} + +.leftover-map-plot-svg { + display: block; + width: 480px; + max-width: none; + height: auto; +} + +.leftover-map-plot-axis { + stroke: var(--color-border); + stroke-width: 1; +} + +.leftover-map-plot-segment { + fill: none; + stroke: var(--color-palette-gray-400); + stroke-width: 1; + stroke-dasharray: 4 3; +} + +.leftover-map-plot-segment.closest { + stroke: var(--color-primary); +} + +.leftover-map-plot-segment.farthest { + stroke: var(--color-text); +} + +.leftover-map-plot-item { + fill: var(--color-palette-blue-mid); +} + +.leftover-map-plot-person { + fill: var(--color-primary); +} + +.leftover-map-plot-person-hit { + fill: transparent; +} + +.leftover-map-plot-marker:focus-visible .leftover-map-plot-person, +.leftover-map-plot-marker:focus-visible .leftover-map-plot-person-hit { + outline: none; +} + +.leftover-map-plot-marker:focus-visible { + outline: 2px solid var(--color-focus-border); + outline-offset: 2px; +} + +.leftover-map-plot-label { + fill: var(--color-text-heading); + font-size: 0.7rem; +} + +.leftover-map-plot-axis-label { + fill: var(--color-text); + font-size: 0.7rem; +} + +@media (max-width: 768px) { + .leftover-map-plot { + padding: var(--space-control-gap); + } +} diff --git a/frontend/src/components/LeftoverMapPlot.stories.tsx b/frontend/src/components/LeftoverMapPlot.stories.tsx new file mode 100644 index 000000000..8b397271f --- /dev/null +++ b/frontend/src/components/LeftoverMapPlot.stories.tsx @@ -0,0 +1,98 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LeftoverMapPlot } from "./LeftoverMapPlot"; + +const meta = { + title: "Reports/LeftoverMapPlot", + component: LeftoverMapPlot, + args: { + criterionLabel: (code: string) => + code === "sales_lead_quality" ? "sales-lead" : "negative", + onSelectPost: () => undefined, + pairs: [ + { + pair_kind: "closest", + post_id: "post-demo-public", + post_title: "Public post", + criterion_code: "sales_lead_quality", + leftover_distance: 0.12, + leftover_residual: 0.4, + observed_response: 2.4, + expected_response: 2.0, + leftover_map_rank: 1, + leftover_map_unexplained: 0.05, + leftover_map_reconstruction: 0.35, + leftover_map_cross_share: 0.12, + leftover_map_unexplained_share: 0.02, + leftover_map_explained_share: 0.76, + leftover_map_person_axis_1: 0.5, + leftover_map_person_axis_2: 0.1, + leftover_map_item_axis_1: 0.5, + leftover_map_item_axis_2: -0.02, + }, + { + pair_kind: "farthest", + post_id: "post-demo-spec", + post_title: "Specification revision requested", + criterion_code: "negative_sentiment", + leftover_distance: 1.84, + leftover_residual: -1.1, + observed_response: 0.9, + expected_response: 2.0, + leftover_map_rank: 1, + leftover_map_unexplained: -0.25, + leftover_map_reconstruction: -0.85, + leftover_map_cross_share: -0.24, + leftover_map_unexplained_share: 0.05, + leftover_map_explained_share: 0.6, + leftover_map_person_axis_1: 0.9, + leftover_map_person_axis_2: 0.8, + leftover_map_item_axis_1: -0.7, + leftover_map_item_axis_2: -0.4, + }, + ], + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const ClosestAndFarthest: Story = {}; + +export const RankZeroOrigin: Story = { + args: { + pairs: [ + { + pair_kind: "closest", + post_id: "post-demo-public", + post_title: "Public post", + criterion_code: "sales_lead_quality", + leftover_distance: 0, + leftover_residual: 0, + observed_response: 1, + expected_response: 1, + leftover_map_rank: 0, + leftover_map_person_axis_1: 0, + leftover_map_person_axis_2: 0, + leftover_map_item_axis_1: 0, + leftover_map_item_axis_2: 0, + }, + ], + }, +}; + +export const MissingCoordinates: Story = { + args: { + pairs: [ + { + pair_kind: "closest", + post_id: "post-demo-public", + post_title: "Public post", + criterion_code: "sales_lead_quality", + leftover_distance: 0.12, + leftover_residual: 0.4, + leftover_map_rank: 1, + }, + ], + }, +}; diff --git a/frontend/src/components/LeftoverMapPlot.test.tsx b/frontend/src/components/LeftoverMapPlot.test.tsx new file mode 100644 index 000000000..9da61f53b --- /dev/null +++ b/frontend/src/components/LeftoverMapPlot.test.tsx @@ -0,0 +1,113 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { LeftoverPair } from "../api"; +import { LeftoverMapPlot } from "./LeftoverMapPlot"; + +const PAIRS: LeftoverPair[] = [ + { + pair_kind: "closest", + post_id: "post-demo-public", + post_title: "Public post", + criterion_code: "sales_lead_quality", + leftover_distance: 0.12, + leftover_residual: 0.4, + leftover_map_person_axis_1: 0.5, + leftover_map_person_axis_2: 0.1, + leftover_map_item_axis_1: 0.5, + leftover_map_item_axis_2: -0.02, + }, + { + pair_kind: "farthest", + post_id: "post-demo-spec", + post_title: "Specification revision requested", + criterion_code: "negative_sentiment", + leftover_distance: 1.84, + leftover_residual: -1.1, + leftover_map_person_axis_1: 0.9, + leftover_map_person_axis_2: 0.8, + leftover_map_item_axis_1: -0.7, + leftover_map_item_axis_2: -0.4, + }, +]; + +function criterionLabel(code: string): string { + return code === "sales_lead_quality" ? "sales-lead" : "negative"; +} + +describe("LeftoverMapPlot", () => { + it("draws persisted leftover-map coordinates so a post marker opens that post", async () => { + const onSelectPost = vi.fn(); + render( + , + ); + + expect(screen.getByLabelText("Leftover-map graphic display")).toBeInTheDocument(); + expect( + screen.getByText( + "Leftover map after IRT main effects. Click a post marker to open that post. The plot does not invent a leftover score.", + ), + ).toBeInTheDocument(); + expect(screen.getByText("Post ξ")).toBeInTheDocument(); + expect(screen.getByText("Criterion ζ")).toBeInTheDocument(); + + const postMarker = screen.getByRole("button", { + name: "Open leftover-map post Public post at ξ (+0.50, +0.10)", + }); + await userEvent.click(postMarker); + expect(onSelectPost).toHaveBeenCalledWith( + expect.objectContaining({ + pair_kind: "closest", + post_id: "post-demo-public", + criterion_code: "sales_lead_quality", + }), + ); + }); + + it("omits the plot when leftover-map coordinates are missing", () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("plots a rank-0 origin without inventing leftover structure", () => { + render( + , + ); + expect( + screen.getByRole("button", { name: "Open leftover-map post Public post at ξ (0.00, 0.00)" }), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/LeftoverMapPlot.tsx b/frontend/src/components/LeftoverMapPlot.tsx new file mode 100644 index 000000000..998e96ac1 --- /dev/null +++ b/frontend/src/components/LeftoverMapPlot.tsx @@ -0,0 +1,136 @@ +import type { LeftoverPair } from "../api"; +import { t, tf } from "../i18n"; +import { formatLeftoverMapCoordinatePair } from "../leftoverMapCoordinates"; +import { + firstPlottablePairForPost, + layoutLeftoverMapPlot, + LEFTOVER_MAP_PLOT_CAPTION, + LEFTOVER_MAP_PLOT_POST_ACTION, +} from "../leftoverMapPlotLayout"; +import "./LeftoverMapPlot.css"; + +export type LeftoverMapPlotProps = { + pairs: LeftoverPair[]; + criterionLabel: (criterionCode: string) => string; + onSelectPost: (pair: LeftoverPair) => void; +}; + +function diamondPoints(x: number, y: number, radius: number): string { + return `${x},${y - radius} ${x + radius},${y} ${x},${y + radius} ${x - radius},${y}`; +} + +/** + * Gabriel leftover-map graphic display of persisted ``ξ_{1:2}`` / ``ζ_{1:2}``. + * + * Person markers are posts; item markers are leftover criteria. Click a + * post marker to open that post. Omit the plot when no pair has four + * finite leftover-map coordinates. Never invent a leftover score. + */ +export function LeftoverMapPlot({ + pairs, + criterionLabel, + onSelectPost, +}: LeftoverMapPlotProps) { + const layout = layoutLeftoverMapPlot(pairs, criterionLabel); + if (layout === null) { + return null; + } + + const openPost = (postId: string) => { + const pair = firstPlottablePairForPost(pairs, postId); + if (pair) { + onSelectPost(pair as LeftoverPair); + } + }; + + return ( +
+
{t(LEFTOVER_MAP_PLOT_CAPTION)}
+
    +
  • +
  • +
  • +
  • +
+
+ + + + + {t("leftover-map axis 1")} + + + {t("leftover-map axis 2")} + + {layout.segments.map((segment) => ( + + ))} + {layout.items.map((marker) => ( + + + + {marker.label} + + + ))} + {layout.persons.map((marker) => { + const person = formatLeftoverMapCoordinatePair(marker.axis1, marker.axis2) ?? ""; + return ( + openPost(marker.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openPost(marker.id); + } + }} + > + + + + {marker.label} + + + ); + })} + +
+
+ ); +} diff --git a/frontend/src/components/LeftoverPairList.test.tsx b/frontend/src/components/LeftoverPairList.test.tsx index 9a8a6bfec..965188a1c 100644 --- a/frontend/src/components/LeftoverPairList.test.tsx +++ b/frontend/src/components/LeftoverPairList.test.tsx @@ -147,7 +147,9 @@ describe("LeftoverPairList", () => { />, ); - const closest = screen.getByRole("button"); + const closest = screen.getByRole("button", { + name: "Open leftover closest pair: Public post · sales-lead", + }); expect(closest).toHaveTextContent( "Leftover map places this post at ξ (+0.50, +0.10) and the criterion at ζ (+0.50, −0.02) after IRT main effects. Open this post to read sales-lead.", ); @@ -159,6 +161,43 @@ describe("LeftoverPairList", () => { expect(closest).toHaveTextContent("U +0.05"); expect(closest).toHaveTextContent("R +0.40"); expect(closest).toHaveTextContent("d 0.12"); + expect(screen.getByLabelText("Leftover-map graphic display")).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "Open leftover-map post Public post at ξ (+0.50, +0.10)", + }), + ).toBeInTheDocument(); + }); + + it("opens the named post from a leftover-map graphic display marker", async () => { + const onSelectPost = vi.fn(); + render( + , + ); + + await userEvent.click( + screen.getByRole("button", { + name: "Open leftover-map post Public post at ξ (+0.50, +0.10)", + }), + ); + expect(onSelectPost).toHaveBeenCalledWith( + expect.objectContaining({ + post_id: "post-demo-public", + criterion_code: "sales_lead_quality", + }), + ); }); it("names leftover-map explained leftover share so the next click opens that post", () => { diff --git a/frontend/src/components/LeftoverPairList.tsx b/frontend/src/components/LeftoverPairList.tsx index 6e20b674f..5ff92e9e2 100644 --- a/frontend/src/components/LeftoverPairList.tsx +++ b/frontend/src/components/LeftoverPairList.tsx @@ -33,6 +33,7 @@ import { formatLeftoverMapUnexplainedShare, LEFTOVER_MAP_UNEXPLAINED_SHARE_ACTION, } from "../leftoverMapUnexplainedShare"; +import { LeftoverMapPlot } from "./LeftoverMapPlot"; export type LeftoverPairListProps = { pairs: LeftoverPair[]; @@ -53,8 +54,10 @@ export type LeftoverPairListProps = { * falls back in order — leftover-map coordinates, explained leftover * share, unexplained leftover share, cross share, reconstruction, * unexplained leftover, then the existing residual/rank/observed-expected - * next action. Every badge still renders together before opening the - * named post. + * next action. When four finite coordinates exist, ADR 0268 draws the + * leftover-map graphic display above the pair buttons; click a post + * marker opens that post. Every badge still renders together before + * opening the named post. */ export function LeftoverPairList({ pairs, @@ -65,8 +68,14 @@ export function LeftoverPairList({ return null; } return ( -
    - {pairs.map((pair) => { +
    + +
      + {pairs.map((pair) => { const kindLabel = pair.pair_kind === "farthest" ? t("Farthest leftover") : t("Closest leftover"); const criterion = criterionLabel(pair.criterion_code); @@ -228,6 +237,7 @@ export function LeftoverPairList({ ); })} -
    +
+ ); } diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index bcca442b3..b3a319c0f 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -58,6 +58,14 @@ describe("i18n", () => { "Leftover map leaves unexplained leftover share {value} of raw residual after IRT main effects. Open this post to read {criterion}.", "Leftover map leaves explained leftover share {value} of raw residual after IRT main effects. Open this post to read {criterion}.", "Leftover map places this post at ξ {person} and the criterion at ζ {item} after IRT main effects. Open this post to read {criterion}.", + "Leftover-map graphic display", + "Leftover map", + "Post ξ", + "Criterion ζ", + "leftover-map axis 1", + "leftover-map axis 2", + "Leftover map after IRT main effects. Click a post marker to open that post. The plot does not invent a leftover score.", + "Open leftover-map post {title} at ξ {person}", "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.", "Leftover map has no leftover structure after IRT main effects. Open this post.", "Leftover map rank {rank} after IRT main effects. Open this post.", @@ -317,6 +325,29 @@ describe("i18n", () => { ).toBe(expected); }); + it.each([ + [ + "ko", + "IRT 주효과 이후 잔여 지도입니다. 글 표식을 눌러 그 글을 여세요. 이 그림은 잔여 점수를 만들어내지 않습니다.", + ], + ["zh", "IRT 主效应后的残差图。点击帖子标记打开该帖子。此图不会虚构残差分数。"], + [ + "ja", + "IRT主効果後の残差マップです。投稿マーカーをクリックしてその投稿を開いてください。この図は残差スコアを作りません。", + ], + [ + "vi", + "Bản đồ phần dư sau hiệu ứng chính IRT. Nhấn dấu bài viết để mở bài đó. Hình này không tạo ra điểm phần dư.", + ], + ] as const)("formats leftover-map graphic display caption in %s", (locale, expected) => { + setLocale(locale); + expect( + t( + "Leftover map after IRT main effects. Click a post marker to open that post. The plot does not invent a leftover score.", + ), + ).toBe(expected); + }); + it.each([ ["ko", "IRT 주효과 이후 관측 Y 2.40와 기대 E 2.00를 읽은 다음, 이 글을 여세요."], ["zh", "阅读 IRT 主效应后的观测 Y 2.40 与期望 E 2.00,然后打开这篇帖子。"], diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index a7547dea4..df34daa5a 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -583,6 +583,16 @@ const TRANSLATIONS: Partial>> = { "잔여 지도가 IRT 주효과 이후 원시 잔차의 설명된 잔여 비율 {value}을(를) 남깁니다. {criterion} 기준을 읽으려면 이 글을 여세요.", "Leftover map places this post at ξ {person} and the criterion at ζ {item} after IRT main effects. Open this post to read {criterion}.": "잔여 지도가 IRT 주효과 이후 이 글을 ξ {person}에, 기준을 ζ {item}에 둡니다. {criterion} 기준을 읽으려면 이 글을 여세요.", + "Leftover-map graphic display": "잔여 지도 그림 표시", + "Leftover map": "잔여 지도", + "Post ξ": "글 ξ", + "Criterion ζ": "기준 ζ", + "leftover-map axis 1": "잔여 지도 축 1", + "leftover-map axis 2": "잔여 지도 축 2", + "Leftover map after IRT main effects. Click a post marker to open that post. The plot does not invent a leftover score.": + "IRT 주효과 이후 잔여 지도입니다. 글 표식을 눌러 그 글을 여세요. 이 그림은 잔여 점수를 만들어내지 않습니다.", + "Open leftover-map post {title} at ξ {person}": + "잔여 지도 글 {title} 열기 (ξ {person})", "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.": "IRT 주효과 이후 관측 Y {observed}와 기대 E {expected}를 읽은 다음, 이 글을 여세요.", "Leftover map has no leftover structure after IRT main effects. Open this post.": @@ -1150,6 +1160,16 @@ const TRANSLATIONS: Partial>> = { "残差图在 IRT 主效应后留下原始残差的已解释残余份额 {value}。打开这篇帖子阅读 {criterion}。", "Leftover map places this post at ξ {person} and the criterion at ζ {item} after IRT main effects. Open this post to read {criterion}.": "残差图在 IRT 主效应后将这篇帖子放在 ξ {person},将准则放在 ζ {item}。打开这篇帖子阅读 {criterion}。", + "Leftover-map graphic display": "残差图图形显示", + "Leftover map": "残差图", + "Post ξ": "帖子 ξ", + "Criterion ζ": "准则 ζ", + "leftover-map axis 1": "残差图轴 1", + "leftover-map axis 2": "残差图轴 2", + "Leftover map after IRT main effects. Click a post marker to open that post. The plot does not invent a leftover score.": + "IRT 主效应后的残差图。点击帖子标记打开该帖子。此图不会虚构残差分数。", + "Open leftover-map post {title} at ξ {person}": + "打开残差图帖子 {title}(ξ {person})", "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.": "阅读 IRT 主效应后的观测 Y {observed} 与期望 E {expected},然后打开这篇帖子。", "Leftover map has no leftover structure after IRT main effects. Open this post.": @@ -1721,6 +1741,16 @@ const TRANSLATIONS: Partial>> = { "残差マップはIRT主効果後の生の残差の説明済み残差シェア {value} を残します。この投稿を開いて {criterion} を読んでください。", "Leftover map places this post at ξ {person} and the criterion at ζ {item} after IRT main effects. Open this post to read {criterion}.": "残差マップはIRT主効果後にこの投稿を ξ {person} に、基準を ζ {item} に置きます。この投稿を開いて {criterion} を読んでください。", + "Leftover-map graphic display": "残差マップの図表示", + "Leftover map": "残差マップ", + "Post ξ": "投稿 ξ", + "Criterion ζ": "基準 ζ", + "leftover-map axis 1": "残差マップ軸 1", + "leftover-map axis 2": "残差マップ軸 2", + "Leftover map after IRT main effects. Click a post marker to open that post. The plot does not invent a leftover score.": + "IRT主効果後の残差マップです。投稿マーカーをクリックしてその投稿を開いてください。この図は残差スコアを作りません。", + "Open leftover-map post {title} at ξ {person}": + "残差マップの投稿 {title} を開く(ξ {person})", "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.": "IRT主効果後の観測 Y {observed} と期待 E {expected} を読んでから、この投稿を開いてください。", "Leftover map has no leftover structure after IRT main effects. Open this post.": @@ -2292,6 +2322,16 @@ const TRANSLATIONS: Partial>> = { "Bản đồ phần dư để lại tỷ phần phần dư đã giải thích {value} của phần dư thô sau hiệu ứng chính IRT. Mở bài viết này để đọc {criterion}.", "Leftover map places this post at ξ {person} and the criterion at ζ {item} after IRT main effects. Open this post to read {criterion}.": "Bản đồ phần dư đặt bài viết này tại ξ {person} và tiêu chí tại ζ {item} sau hiệu ứng chính IRT. Mở bài viết này để đọc {criterion}.", + "Leftover-map graphic display": "Hiển thị đồ họa bản đồ phần dư", + "Leftover map": "Bản đồ phần dư", + "Post ξ": "Bài viết ξ", + "Criterion ζ": "Tiêu chí ζ", + "leftover-map axis 1": "trục bản đồ phần dư 1", + "leftover-map axis 2": "trục bản đồ phần dư 2", + "Leftover map after IRT main effects. Click a post marker to open that post. The plot does not invent a leftover score.": + "Bản đồ phần dư sau hiệu ứng chính IRT. Nhấn dấu bài viết để mở bài đó. Hình này không tạo ra điểm phần dư.", + "Open leftover-map post {title} at ξ {person}": + "Mở bài viết bản đồ phần dư {title} tại ξ {person}", "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.": "Đọc Y quan sát {observed} và E kỳ vọng {expected} sau hiệu ứng chính IRT, rồi mở bài viết này.", "Leftover map has no leftover structure after IRT main effects. Open this post.": diff --git a/frontend/src/leftoverMapPlotLayout.test.ts b/frontend/src/leftoverMapPlotLayout.test.ts new file mode 100644 index 000000000..f3169dc44 --- /dev/null +++ b/frontend/src/leftoverMapPlotLayout.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vitest"; +import type { LeftoverMapPlottablePair } from "./leftoverMapPlotLayout"; +import { + firstPlottablePairForPost, + hasLeftoverMapPlotCoordinates, + layoutLeftoverMapPlot, + PLOT_HEIGHT, + PLOT_PADDING, + PLOT_WIDTH, +} from "./leftoverMapPlotLayout"; + +function pair( + overrides: Partial = {}, +): LeftoverMapPlottablePair { + return { + pair_kind: "closest", + post_id: "post-demo-public", + post_title: "Public post", + criterion_code: "sales_lead_quality", + leftover_map_person_axis_1: 0.5, + leftover_map_person_axis_2: 0.1, + leftover_map_item_axis_1: 0.5, + leftover_map_item_axis_2: -0.02, + ...overrides, + }; +} + +function criterionLabel(code: string): string { + return code === "sales_lead_quality" ? "sales-lead" : "negative"; +} + +describe("hasLeftoverMapPlotCoordinates", () => { + it("requires four finite leftover-map coordinates", () => { + expect(hasLeftoverMapPlotCoordinates(pair())).toBe(true); + expect(hasLeftoverMapPlotCoordinates(pair({ leftover_map_person_axis_1: null }))).toBe(false); + expect(hasLeftoverMapPlotCoordinates(pair({ leftover_map_item_axis_2: Number.NaN }))).toBe(false); + expect( + hasLeftoverMapPlotCoordinates(pair({ leftover_map_person_axis_2: Number.POSITIVE_INFINITY })), + ).toBe(false); + }); +}); + +describe("layoutLeftoverMapPlot", () => { + it("omits the plot when no pair has leftover-map coordinates", () => { + expect( + layoutLeftoverMapPlot( + [pair({ leftover_map_person_axis_1: null, leftover_map_item_axis_1: null })], + criterionLabel, + ), + ).toBeNull(); + expect(layoutLeftoverMapPlot([], criterionLabel)).toBeNull(); + }); + + it("places persisted ξ and ζ without inventing a leftover score", () => { + const layout = layoutLeftoverMapPlot( + [ + pair(), + pair({ + pair_kind: "farthest", + post_id: "post-demo-spec", + post_title: "Specification revision requested", + criterion_code: "negative_sentiment", + leftover_map_person_axis_1: 0.9, + leftover_map_person_axis_2: 0.8, + leftover_map_item_axis_1: -0.7, + leftover_map_item_axis_2: -0.4, + }), + ], + criterionLabel, + ); + expect(layout).not.toBeNull(); + expect(layout?.width).toBe(PLOT_WIDTH); + expect(layout?.height).toBe(PLOT_HEIGHT); + expect(layout?.persons).toHaveLength(2); + expect(layout?.items).toHaveLength(2); + expect(layout?.segments).toHaveLength(2); + const publicPost = layout?.persons.find((marker) => marker.id === "post-demo-public"); + expect(publicPost).toMatchObject({ axis1: 0.5, axis2: 0.1, label: "Public post" }); + const salesLead = layout?.items.find((marker) => marker.id === "sales_lead_quality"); + expect(salesLead).toMatchObject({ axis1: 0.5, axis2: -0.02, label: "sales-lead" }); + expect(layout?.originX).toBeGreaterThan(PLOT_PADDING - 0.01); + expect(layout?.originY).toBeLessThan(PLOT_HEIGHT - PLOT_PADDING + 0.01); + }); + + it("uses one centered isotropic scale for both leftover-map axes", () => { + const layout = layoutLeftoverMapPlot( + [ + pair({ + leftover_map_person_axis_1: 0, + leftover_map_person_axis_2: 0, + leftover_map_item_axis_1: 1, + leftover_map_item_axis_2: 0, + }), + pair({ + pair_kind: "farthest", + post_id: "post-demo-spec", + criterion_code: "negative_sentiment", + leftover_map_person_axis_1: 0, + leftover_map_person_axis_2: 0, + leftover_map_item_axis_1: 0, + leftover_map_item_axis_2: 1, + }), + ], + criterionLabel, + ); + const horizontal = layout?.segments[0]; + const vertical = layout?.segments[1]; + expect(horizontal).toBeDefined(); + expect(vertical).toBeDefined(); + expect(Math.abs((horizontal?.x2 ?? 0) - (horizontal?.x1 ?? 0))).toBeCloseTo( + Math.abs((vertical?.y2 ?? 0) - (vertical?.y1 ?? 0)), + 5, + ); + expect(layout?.originX).toBeCloseTo(PLOT_WIDTH / 2 - (PLOT_HEIGHT - PLOT_PADDING * 2) / 2, 5); + expect(layout?.originY).toBeCloseTo(PLOT_HEIGHT - PLOT_PADDING, 5); + }); + + it("plots a rank-0 origin cell at (0, 0) without inventing leftover structure", () => { + const layout = layoutLeftoverMapPlot( + [ + pair({ + leftover_map_person_axis_1: 0, + leftover_map_person_axis_2: 0, + leftover_map_item_axis_1: 0, + leftover_map_item_axis_2: 0, + }), + ], + criterionLabel, + ); + expect(layout).not.toBeNull(); + expect(layout?.persons[0]).toMatchObject({ axis1: 0, axis2: 0, x: layout?.originX, y: layout?.originY }); + expect(layout?.items[0]).toMatchObject({ axis1: 0, axis2: 0, x: layout?.originX, y: layout?.originY }); + expect(layout?.originX).toBeCloseTo(PLOT_WIDTH / 2, 5); + expect(layout?.originY).toBeCloseTo(PLOT_HEIGHT / 2, 5); + }); + + it("deduplicates posts and criteria that appear on more than one pair", () => { + const layout = layoutLeftoverMapPlot( + [ + pair(), + pair({ + pair_kind: "farthest", + leftover_map_item_axis_1: -0.7, + leftover_map_item_axis_2: -0.4, + criterion_code: "negative_sentiment", + }), + ], + criterionLabel, + ); + expect(layout?.persons).toHaveLength(1); + expect(layout?.items).toHaveLength(2); + expect(layout?.segments).toHaveLength(2); + }); + + it("skips a pair with any missing leftover-map coordinate instead of inventing one", () => { + const layout = layoutLeftoverMapPlot( + [pair(), pair({ post_id: "post-hidden-coord", leftover_map_item_axis_2: undefined })], + criterionLabel, + ); + expect(layout?.persons.map((marker) => marker.id)).toEqual(["post-demo-public"]); + expect(layout?.segments).toHaveLength(1); + }); +}); + +describe("firstPlottablePairForPost", () => { + it("returns the first leftover pair whose coordinates can open that post", () => { + const closest = pair(); + const farthest = pair({ pair_kind: "farthest", leftover_map_item_axis_1: -0.2 }); + expect(firstPlottablePairForPost([closest, farthest], "post-demo-public")).toBe(closest); + expect(firstPlottablePairForPost([pair({ leftover_map_person_axis_1: null })], "post-demo-public")).toBeNull(); + }); +}); diff --git a/frontend/src/leftoverMapPlotLayout.ts b/frontend/src/leftoverMapPlotLayout.ts new file mode 100644 index 000000000..eef687d5e --- /dev/null +++ b/frontend/src/leftoverMapPlotLayout.ts @@ -0,0 +1,195 @@ +/** Gabriel leftover-map graphic display of persisted ``ξ_{1:2}`` / ``ζ_{1:2}``. */ + +import { formatLeftoverMapCoordinatePair } from "./leftoverMapCoordinates"; +import type { LeftoverPair } from "./api"; + +export const LEFTOVER_MAP_PLOT_CAPTION = + "Leftover map after IRT main effects. Click a post marker to open that post. The plot does not invent a leftover score."; + +export const LEFTOVER_MAP_PLOT_POST_ACTION = + "Open leftover-map post {title} at ξ {person}"; + +export const PLOT_WIDTH = 480; +export const PLOT_HEIGHT = 320; +export const PLOT_PADDING = 40; +const UNIT_DISPLAY_SPAN = 2; +const COLLAPSED_SPAN = 1e-12; + +export type LeftoverMapPlottablePair = { + pair_kind: LeftoverPair["pair_kind"]; + post_id: string; + post_title: string; + criterion_code: string; + leftover_map_person_axis_1?: number | null; + leftover_map_person_axis_2?: number | null; + leftover_map_item_axis_1?: number | null; + leftover_map_item_axis_2?: number | null; +}; + +export type LeftoverMapPlotPoint = { + kind: "person" | "item"; + id: string; + label: string; + axis1: number; + axis2: number; + x: number; + y: number; +}; + +export type LeftoverMapPlotSegment = { + pairKind: "closest" | "farthest"; + postId: string; + criterionCode: string; + x1: number; + y1: number; + x2: number; + y2: number; +}; + +export type LeftoverMapPlotLayout = { + width: number; + height: number; + originX: number; + originY: number; + persons: LeftoverMapPlotPoint[]; + items: LeftoverMapPlotPoint[]; + segments: LeftoverMapPlotSegment[]; +}; + +export function hasLeftoverMapPlotCoordinates( + pair: LeftoverMapPlottablePair, +): boolean { + return ( + formatLeftoverMapCoordinatePair( + pair.leftover_map_person_axis_1, + pair.leftover_map_person_axis_2, + ) !== null && + formatLeftoverMapCoordinatePair( + pair.leftover_map_item_axis_1, + pair.leftover_map_item_axis_2, + ) !== null + ); +} + +function toSvg( + axis1: number, + axis2: number, + minAxis: number, + scaleSpan: number, + width: number, + height: number, + pad: number, +): { x: number; y: number } { + const innerW = width - pad * 2; + const innerH = height - pad * 2; + const pixelsPerUnit = Math.min(innerW, innerH) / scaleSpan; + const plotWidth = scaleSpan * pixelsPerUnit; + const plotHeight = scaleSpan * pixelsPerUnit; + const offsetX = pad + (innerW - plotWidth) / 2; + const offsetY = pad + (innerH - plotHeight) / 2; + return { + x: offsetX + (axis1 - minAxis) * pixelsPerUnit, + y: offsetY + (minAxis + scaleSpan - axis2) * pixelsPerUnit, + }; +} + +export function layoutLeftoverMapPlot( + pairs: LeftoverMapPlottablePair[], + criterionLabel: (criterionCode: string) => string, + size?: { width?: number; height?: number }, +): LeftoverMapPlotLayout | null { + const plottable = pairs.filter(hasLeftoverMapPlotCoordinates); + if (plottable.length === 0) { + return null; + } + + const width = size?.width ?? PLOT_WIDTH; + const height = size?.height ?? PLOT_HEIGHT; + const axes: number[] = []; + for (const pair of plottable) { + axes.push( + pair.leftover_map_person_axis_1 as number, + pair.leftover_map_person_axis_2 as number, + pair.leftover_map_item_axis_1 as number, + pair.leftover_map_item_axis_2 as number, + ); + } + // Keep the origin in view: it is the rank-0 unused-axis location, not a score. + const minObserved = Math.min(...axes, 0); + const maxObserved = Math.max(...axes, 0); + const observedSpan = maxObserved - minObserved; + const scaleSpan = observedSpan < COLLAPSED_SPAN ? UNIT_DISPLAY_SPAN : observedSpan; + const minAxis = observedSpan < COLLAPSED_SPAN ? -1 : minObserved; + + const persons = new Map(); + const items = new Map(); + const segments: LeftoverMapPlotSegment[] = []; + + for (const pair of plottable) { + const personPos = toSvg( + pair.leftover_map_person_axis_1 as number, + pair.leftover_map_person_axis_2 as number, + minAxis, + scaleSpan, + width, + height, + PLOT_PADDING, + ); + const itemPos = toSvg( + pair.leftover_map_item_axis_1 as number, + pair.leftover_map_item_axis_2 as number, + minAxis, + scaleSpan, + width, + height, + PLOT_PADDING, + ); + if (!persons.has(pair.post_id)) { + persons.set(pair.post_id, { + kind: "person", + id: pair.post_id, + label: pair.post_title, + axis1: pair.leftover_map_person_axis_1 as number, + axis2: pair.leftover_map_person_axis_2 as number, + ...personPos, + }); + } + if (!items.has(pair.criterion_code)) { + items.set(pair.criterion_code, { + kind: "item", + id: pair.criterion_code, + label: criterionLabel(pair.criterion_code), + axis1: pair.leftover_map_item_axis_1 as number, + axis2: pair.leftover_map_item_axis_2 as number, + ...itemPos, + }); + } + segments.push({ + pairKind: pair.pair_kind === "farthest" ? "farthest" : "closest", + postId: pair.post_id, + criterionCode: pair.criterion_code, + x1: personPos.x, + y1: personPos.y, + x2: itemPos.x, + y2: itemPos.y, + }); + } + + const origin = toSvg(0, 0, minAxis, scaleSpan, width, height, PLOT_PADDING); + return { + width, + height, + originX: origin.x, + originY: origin.y, + persons: [...persons.values()], + items: [...items.values()], + segments, + }; +} + +export function firstPlottablePairForPost( + pairs: LeftoverMapPlottablePair[], + postId: string, +): LeftoverMapPlottablePair | null { + return pairs.find((pair) => pair.post_id === postId && hasLeftoverMapPlotCoordinates(pair)) ?? null; +} diff --git a/pyproject.toml b/pyproject.toml index f915e1bdd..54deb8566 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.24.0" +version = "2.25.0" 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" } From d7a8c2bc2bfeed6b650ad64b5196d9c75c6d0c15 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 03:32:07 -0700 Subject: [PATCH 3/7] feat(reports): leftover-map axis share on graphic display (v2.26.0) (#787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caption leftover-map plot axes with persisted ADR 0148 leftover-map axis share σ_k² / Σ_j σ_j² (ADR 0269). Rank-0 zero-share axes still name 0%. A missing or non-finite share omits that axis badge and keeps the existing leftover-map axis text. Click a post marker to open that post. No new columns. Never invent a leftover score or a theta. --- AGENTS.md | 9 +- ARCHITECTURE.md | 5 +- .../2.26.0-leftover-map-axis-share-plot.md | 11 +++ CHANGELOG.md | 10 ++ CLAUDE.md | 2 +- docs/adr/0048-persist-lsirm-leftover-pairs.md | 7 +- docs/adr/0049-leftover-pair-report-ui.md | 6 +- docs/adr/0148-leftover-map-axis-share.md | 6 +- docs/adr/0267-leftover-map-coordinates.md | 3 +- docs/adr/0268-leftover-map-graphic-display.md | 9 +- docs/adr/0269-leftover-map-axis-share-plot.md | 95 +++++++++++++++++++ docs/product-technical-gap-baseline.md | 19 ++++ docs/storybook-inventory.md | 4 +- frontend/package.json | 2 +- frontend/src/App.test.tsx | 4 +- frontend/src/App.tsx | 1 + .../components/LeftoverMapPlot.stories.tsx | 20 ++++ .../src/components/LeftoverMapPlot.test.tsx | 41 ++++++++ frontend/src/components/LeftoverMapPlot.tsx | 31 +++++- .../components/LeftoverPairList.stories.tsx | 4 + .../src/components/LeftoverPairList.test.tsx | 6 ++ frontend/src/components/LeftoverPairList.tsx | 10 +- frontend/src/i18n.test.ts | 11 +++ frontend/src/i18n.ts | 4 + frontend/src/leftoverMapPlotAxisShare.test.ts | 50 ++++++++++ frontend/src/leftoverMapPlotAxisShare.ts | 30 ++++++ frontend/src/leftoverMapPlotLayout.ts | 4 +- pyproject.toml | 2 +- uv.lock | 2 +- 29 files changed, 380 insertions(+), 28 deletions(-) create mode 100644 CHANGELOG.d/2.26.0-leftover-map-axis-share-plot.md create mode 100644 docs/adr/0269-leftover-map-axis-share-plot.md create mode 100644 frontend/src/leftoverMapPlotAxisShare.test.ts create mode 100644 frontend/src/leftoverMapPlotAxisShare.ts diff --git a/AGENTS.md b/AGENTS.md index 4b82dc788..eefa47f47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -273,7 +273,7 @@ stops startup instead of leaving a healthy-looking partial schema, and application code must not compensate for a missing table. Period leftover pairs (ADR 0017 / 0018 / 0048 / 0049 / 0119 / 0158 / 0162 / -0163 / 0164 / 0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268) are computed in `lineageweave/leftover_pairs.py` from the +0163 / 0164 / 0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269) are computed in `lineageweave/leftover_pairs.py` from the residual after a real GRM/GPCM score, never invented. Distances are Euclidean on the two-dimensional Gabriel leftover map; missing cells stay out of the factorization. Closest and farthest post–criterion pairs @@ -288,10 +288,13 @@ ADR 0201 is the sole normative reconstruction formula, storage, and audit contract; do not duplicate or reinterpret it here. ADR 0233 is the sole unexplained leftover share contract. ADR 0266 is the sole explained leftover share contract. ADR 0267 is the sole -leftover-map coordinate contract. ADR 0268 is the sole leftover-map +leftover-map coordinate contract. ADR 0268 is the leftover-map graphic-display contract: draw persisted `ξ` and `ζ` above the pair buttons; omit the plot when coordinates are missing; click a post -marker to open that post. When `R`, `R̂`, `U`, `x`, +marker to open that post. ADR 0269 captions those leftover-map axes +with persisted leftover-map axis share `σ_k² / Σ_j σ_j²` when finite, +including rank-0 zero-share axes; a missing or non-finite share omits +that axis badge and keeps the existing leftover-map axis text. When `R`, `R̂`, `U`, `x`, `s`, and `e` are finite, `e + s + x = 1`. When coordinates, reconstruction, and distance are finite, `R̂ = ξ · ζ` and `d = ‖ξ − ζ‖`. The pairs sit above the member diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c515fec1c..2c087fd1e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -637,13 +637,14 @@ information at the group's mean θ (Lord, 1980 max-info CAT). Rankings persist to `report_item_information`. After those IRT main effects, residual SVD leftover pairs on two Gabriel axes (Jeon et al., 2021; ADR 0017 / 0048 / 0049 / 0119 / 0148 / 0158 / 0162 / 0163 / 0164 / 0168 / -0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268) persist to `report_leftover_pair` with signed residual `R`, +0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269) persist to `report_leftover_pair` with signed residual `R`, observed `Y`, expected `E[Y|θ, item]`, full leftover-map rank, unexplained leftover, ADR 0201 reconstruction evidence, ADR 0185 cross-share evidence, ADR 0233 unexplained leftover share `s`, ADR 0266 explained leftover share `e`, and ADR 0267 leftover-map coordinates `ξ_{1:2}` / `ζ_{1:2}`. ADR 0268 draws those persisted coordinates as the leftover-map graphic -display above the pair buttons. Click a post marker or a pair button +display above the pair buttons. ADR 0269 captions leftover-map axes 1 +and 2 with persisted leftover-map axis share. Click a post marker or a pair button opens that post. Those ADRs are the normative mathematical, storage, and display contracts. Leftover-map axis share (Gabriel inertia of residual SVD axes 1 and 2; ADR 0148) persists to diff --git a/CHANGELOG.d/2.26.0-leftover-map-axis-share-plot.md b/CHANGELOG.d/2.26.0-leftover-map-axis-share-plot.md new file mode 100644 index 000000000..0340b6cd4 --- /dev/null +++ b/CHANGELOG.d/2.26.0-leftover-map-axis-share-plot.md @@ -0,0 +1,11 @@ +## 2.26.0 — Leftover-map axis share on the graphic display + +- Caption leftover-map graphic-display axes with persisted leftover-map + axis share `σ_k² / Σ_j σ_j²` (ADR 0269). After `make seed`, closest + and farthest leftover pairs sit above the member list with the + Gabriel biplot of already-named coordinates, and each leftover-map + axis names its inertia share; click a post marker or a pair button + opens that post. Rank-0 zero-share axes still name 0%. A missing or + non-finite share omits that axis badge and keeps the existing axis + text. Never invent a leftover score. Never invent a theta. No new + columns. diff --git a/CHANGELOG.md b/CHANGELOG.md index e67f7e2ec..e4c98aa57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ All notable changes to this project are documented here. Format follows ### Added +- Period leftover pairs now caption leftover-map graphic-display axes + with persisted leftover-map axis share `σ_k² / Σ_j σ_j²` (ADR 0269 / + v2.26.0). After `make seed`, closest and farthest leftover pairs sit + above the member list with the Gabriel biplot of already-named + coordinates, and each leftover-map axis names its inertia share; + click a post marker or a pair button opens that post. Rank-0 + zero-share axes still name 0%. A missing or non-finite share omits + that axis badge and keeps the existing leftover-map axis text. Never + invent a leftover score or a theta. No new columns. + - Period leftover pairs now draw the leftover-map graphic display of persisted `ξ_{1:2}` and `ζ_{1:2}` (ADR 0268 / v2.25.0). After `make seed`, closest and farthest leftover pairs sit above the diff --git a/CLAUDE.md b/CLAUDE.md index f8c7fd19c..3073348c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ cutoff. Global Ask optional `knowledge_cutoff` uses the same cover Create/start endpoint rules (ADR 0017 / 0021), tie-vs-miss similarity (ADR 0026), R&R catalog ids (ADR 0019 / 0027), leftover pairs -(ADR 0048–0164 / 0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268), occupational construct catalog search +(ADR 0048–0164 / 0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269), occupational construct catalog search (ADR 0257), the text-channel embedding swap and cosine clamp (ADR 0190), per-edge channel-score persistence (ADR 0195), token-backed status notices (ADR 0220), diff --git a/docs/adr/0048-persist-lsirm-leftover-pairs.md b/docs/adr/0048-persist-lsirm-leftover-pairs.md index 53e6053aa..248cba6d6 100644 --- a/docs/adr/0048-persist-lsirm-leftover-pairs.md +++ b/docs/adr/0048-persist-lsirm-leftover-pairs.md @@ -11,7 +11,8 @@ [ADR 0233](0233-leftover-map-unexplained-share.md) (unexplained leftover share s); [ADR 0266](0266-leftover-map-explained-share.md) (explained leftover share e); [ADR 0267](0267-leftover-map-coordinates.md) (leftover-map coordinates ξ, ζ); -[ADR 0268](0268-leftover-map-graphic-display.md) (leftover-map graphic display) +[ADR 0268](0268-leftover-map-graphic-display.md) (leftover-map graphic display); +[ADR 0269](0269-leftover-map-axis-share-plot.md) (leftover-map axis share on the graphic display) ## Context @@ -66,7 +67,9 @@ When `R`, `R̂`, `U`, `x`, `s`, and `e` are finite, `e + s + x = 1`. ADR 0267 persists leftover-map coordinates `ξ_{1:2}` and `ζ_{1:2}` so reconstruction `R̂ = ξ · ζ` and distance `d = ‖ξ − ζ‖` stay auditable from the pair row. ADR 0268 draws those persisted coordinates as the -leftover-map graphic display; it adds no columns. +leftover-map graphic display; it adds no columns. ADR 0269 captions +those leftover-map axes with persisted leftover-map axis share; it +adds no columns. 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 ee14cb697..2673dec32 100644 --- a/docs/adr/0049-leftover-pair-report-ui.md +++ b/docs/adr/0049-leftover-pair-report-ui.md @@ -12,7 +12,8 @@ [ADR 0233](0233-leftover-map-unexplained-share.md) (unexplained leftover share s); [ADR 0266](0266-leftover-map-explained-share.md) (explained leftover share e); [ADR 0267](0267-leftover-map-coordinates.md) (leftover-map coordinates ξ, ζ); -[ADR 0268](0268-leftover-map-graphic-display.md) (leftover-map graphic display) +[ADR 0268](0268-leftover-map-graphic-display.md) (leftover-map graphic display); +[ADR 0269](0269-leftover-map-axis-share-plot.md) (leftover-map axis share on the graphic display) ## Context @@ -37,7 +38,8 @@ leftover-map coordinates `ξ_{1:2}` and `ζ_{1:2}` when finite, and leftover-map cross share next to distance when finite. When four finite coordinates exist, the leftover-map graphic display of those positions sits above the pair buttons (ADR 0268); click a post marker to open -that post. The next action names every available +that post. Leftover-map axis share captions those leftover-map axes +when finite (ADR 0269). The next action names every available measurement before opening the post; no amendment hides another, rank 0 explicitly names no leftover structure, and unexplained leftover names "leftover map leaves unexplained `U` after IRT main effects; open this diff --git a/docs/adr/0148-leftover-map-axis-share.md b/docs/adr/0148-leftover-map-axis-share.md index 2540ddf91..a9d554176 100644 --- a/docs/adr/0148-leftover-map-axis-share.md +++ b/docs/adr/0148-leftover-map-axis-share.md @@ -2,6 +2,8 @@ **Decision status:** Accepted **Date:** 2026-08-24 +**Amended by:** [ADR 0269](0269-leftover-map-axis-share-plot.md) +(leftover-map axis share on the graphic display) ## Context @@ -45,7 +47,9 @@ Rebuild and seed write leftover-map axes in the same transaction as leftover pairs. `GET /api/reports/{grouping}/{period}` returns `leftover_map_axes` next to `leftover_pairs`. The Period reports panel shows leftover-axis share badges and a caption that tells the buyer to -open a leftover pair. Migration `0169_report_leftover_map_axis.sql` +open a leftover pair. The leftover-map graphic display captions those +same leftover-map axes with the persisted share when finite (ADR 0269). +Migration `0169_report_leftover_map_axis.sql` upgrades volumes that already applied `0001`. ## References diff --git a/docs/adr/0267-leftover-map-coordinates.md b/docs/adr/0267-leftover-map-coordinates.md index be4919955..99dce6dd6 100644 --- a/docs/adr/0267-leftover-map-coordinates.md +++ b/docs/adr/0267-leftover-map-coordinates.md @@ -103,7 +103,8 @@ leftover-map distance, leftover-map rank, leftover-map inner product, leftover-map cosine, leftover-map length, leftover-map reconstruction, leftover-map unexplained leftover, leftover-map cross share, leftover-map unexplained leftover share, leftover-map explained -leftover share, and leftover-map graphic display. +leftover share, leftover-map graphic display, and leftover-map axis +share on the graphic display. ## References diff --git a/docs/adr/0268-leftover-map-graphic-display.md b/docs/adr/0268-leftover-map-graphic-display.md index 05dd751b5..a758d6532 100644 --- a/docs/adr/0268-leftover-map-graphic-display.md +++ b/docs/adr/0268-leftover-map-graphic-display.md @@ -3,6 +3,9 @@ **Decision status:** Accepted **Date:** 2026-08-28 +**Amended by:** [ADR 0269](0269-leftover-map-axis-share-plot.md) +(leftover-map axis share on the graphic display) + Amends [ADR 0049](0049-leftover-pair-report-ui.md) and [ADR 0267](0267-leftover-map-coordinates.md). Independent of leftover-map explained leftover share ([ADR 0266](0266-leftover-map-explained-share.md)), @@ -73,7 +76,8 @@ product, cosine, or length as separate columns. After `make seed`, closest and farthest leftover pairs sit above the member list with the leftover-map graphic display of persisted `ξ` and `ζ`; click a post marker or a pair button opens that post. -Hidden posts stay hidden. When coordinates, reconstruction, and +Hidden posts stay hidden. Leftover-map axis share captions those +leftover-map axes when finite (ADR 0269). When coordinates, reconstruction, and distance are all finite, `R̂ = ξ_{1:2} · ζ_{1:2}` and `d = ‖ξ_{1:2} − ζ_{1:2}‖` remain the same identities already persisted by ADR 0267. @@ -88,7 +92,8 @@ leftover-map distance, leftover-map rank, leftover-map inner product, leftover-map cosine, leftover-map length, leftover-map reconstruction, leftover-map unexplained leftover, leftover-map cross share, leftover-map unexplained leftover share, leftover-map explained -leftover share, and leftover-map coordinate persistence. +leftover share, leftover-map coordinate persistence, and leftover-map +axis share on the graphic display. ## References diff --git a/docs/adr/0269-leftover-map-axis-share-plot.md b/docs/adr/0269-leftover-map-axis-share-plot.md new file mode 100644 index 000000000..5785d42c0 --- /dev/null +++ b/docs/adr/0269-leftover-map-axis-share-plot.md @@ -0,0 +1,95 @@ +# ADR 0269 — Name leftover-map axis share on the graphic display + +**Decision status:** Accepted +**Date:** 2026-08-28 + +Amends [ADR 0148](0148-leftover-map-axis-share.md) and +[ADR 0268](0268-leftover-map-graphic-display.md). Independent of leftover-map +explained leftover share ([ADR 0266](0266-leftover-map-explained-share.md)), +leftover-map unexplained leftover share ([ADR 0233](0233-leftover-map-unexplained-share.md)), +leftover-map reconstruction ([ADR 0201](0201-leftover-map-reconstruction.md)), +and leftover-map coordinates ([ADR 0267](0267-leftover-map-coordinates.md)). + +## Context + +ADR 0148 already persists leftover-map axis share +`σ_k² / Σ_j σ_j²` on each period report as two leftover-map axes. +ADR 0268 already draws the leftover-map graphic display of persisted +`ξ_{1:2}` and `ζ_{1:2}`. Those plot axes still read only +"leftover-map axis 1" and "leftover-map axis 2", so Gabriel inertia +lives on report badges and not on the graphic display Gabriel (1971) +captioned. Hiding share on the axes lets leftover residual `R`, +leftover-map distance `d`, or reconstruction `R̂` be read as leftover-map +structure even after axis share itself is persisted. + +This increment captions the leftover-map graphic display with already +persisted leftover-map axis share. It does not add columns. It does not +persist leftover-map inner product, cosine, or length. It does not land +Post quality on the leftover criterion. Leftover-map distance stays +two-axis Euclidean. Do not invent a leftover score. Do not invent a +theta. + +The dashboard stack already used neighbouring leftover facts under +other numbers. This protected increment uses **0269** so it does not +collide with leftover-map graphic display (0268), leftover-map +coordinates (0267 / migration 0245), leftover-map explained leftover +share (0266 / migration 0244), leftover-map unexplained leftover share +(0233 / migration 0233), leftover-map reconstruction (0201 / migration +0206), leftover-map cross share (0185), leftover residual disclosure, +leftover observed `Y` / expected `E`, leftover-map rank, two-axis +leftover-map distance, leftover coverage, leftover-map axis share +persistence (0148), leftover interaction-map persistence, occupational +construct catalog search (0265), or the dashboard stacks. + +## Decision + +On the leftover-map graphic display, caption leftover-map axis `k` +with persisted leftover-map axis share when that share is finite, +including rank-0 zero-share axes. The caption is +`leftover-map axis {k} ({share}%)` from `σ_k² / Σ_j σ_j²`. A missing +or non-finite share omits that axis badge and keeps the existing +leftover-map axis text. Axis 1 and axis 2 stay independently named: +one missing share does not hide the other. Click a post marker to +open that post. Criterion markers are not post buttons. The grouping +comparison strip (ADR 0149) stays on its reduced leftover payload and +does not gain this caption. + +Do not add SQL. Do not edit shipped migrations. Do not persist inner +product, cosine, or length as separate columns. Do not invent a +leftover score. Do not invent a theta. + +## Consequences + +After `make seed`, closest and farthest leftover pairs sit above the +member list with the leftover-map graphic display of persisted `ξ` +and `ζ`, and each leftover-map axis names its persisted Gabriel +inertia share; click a post marker or a pair button opens that post. +Hidden posts stay hidden. Rank-0 unused axes still plot at the origin +and still name zero leftover-map axis share. Report-level leftover-axis +badges (ADR 0148) remain. + +## 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 persistence, leftover pairs on the grouping comparison +strip, two-axis leftover-map distance, leftover-map rank, leftover-map +inner product, leftover-map cosine, leftover-map length, leftover-map +reconstruction, leftover-map unexplained leftover, leftover-map cross +share, leftover-map unexplained leftover share, leftover-map explained +leftover share, leftover-map coordinate persistence, and leftover-map +graphic display. + +## 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 +(LSIRM interaction `−γ‖ξ_j − ζ_i‖` after main effects +`α_j − β_i`; typically `p = 2` for the interaction map.) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 8ae2beddf..3b7a48b87 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,24 @@ # Product & Technical Gap Baseline +> Exact-head loop overlay: 2026-08-28 19:15 KST. Protected `main` is +> `fc13acaa20adca11968238e398d4aafcf62b6cee` (v2.23.0 leftover-map +> explained leftover share, #775). Open ready PRs still lack independent +> APPROVE. #782 leftover-map coordinates + graphic display (v2.24.0 / +> v2.25.0 / ADR 0267 / ADR 0268) is on +> `2f7e9c8df695f12d03964d5caa68fa3355bdd923`; Strix fail-closed and no +> independent APPROVE. Drafts remain dirty against `main`. #96 stays +> closed as a weaker duplicate of #91. GitHub writes through MCP succeed +> (comment/create-branch/git push/auto-merge). Copilot review is not +> independent APPROVE. Do not self-approve. +> +> Next buyer increment on this cycle: leftover-map axis share on the +> graphic display (ADR 0269 / v2.26.0). Caption plot axes with persisted +> ADR 0148 `leftover_map_axes` inertia `σ_k² / Σ_j σ_j²`. UI-only; no +> new columns. Rank-0 zero-share axes still named. Missing/non-finite +> share omits that axis badge and keeps existing leftover-map axis +> text. Do not invent leftover scores. Do not mix into dashboard stacks +> #640/#778/#781. + > Exact-head loop overlay: 2026-08-28 16:05 KST. Protected `main` is > `fc13acaa20adca11968238e398d4aafcf62b6cee` (v2.23.0 leftover-map > explained leftover share, #775). Open ready PRs still lack independent diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 094452a31..712509da2 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -5,8 +5,8 @@ operator-facing control you can click before changing product CSS. | Story | Operator next action | Token / module | |---|---|---| -| `Reports/LeftoverMapPlot` | Read the leftover-map graphic display of persisted `ξ` (posts) and `ζ` (criteria), then click a post marker to open that post. `ClosestAndFarthest`, `RankZeroOrigin`, and `MissingCoordinates` cover two-pair maps, rank-0 origin, and omitted plots. The plot does not invent a leftover score. | `LeftoverMapPlot`, `leftoverMapPlotLayout`, `--color-primary`, `--color-palette-blue-mid` | -| `Reports/LeftoverPairList` | Read closest/farthest leftover pairs with named `R`, `Y`/`E`, rank, `U`, `s`, `e`, `x`, `R̂`, `ξ`/`ζ`, and `d`, then open that post. The leftover-map graphic display sits above the pair buttons when coordinates are finite. | `LeftoverPairList`, `LeftoverMapPlot`, `ticket-list`, `post-badge` | +| `Reports/LeftoverMapPlot` | Read the leftover-map graphic display of persisted `ξ` (posts) and `ζ` (criteria), then click a post marker to open that post. Leftover-map axes name persisted leftover-map axis share when finite. `ClosestAndFarthest`, `RankZeroOrigin`, `MissingCoordinates`, and `MissingAxisShare` cover two-pair maps, rank-0 origin with 0% share, omitted plots, and missing share that keeps existing leftover-map axis text. The plot does not invent a leftover score. | `LeftoverMapPlot`, `leftoverMapPlotLayout`, `leftoverMapPlotAxisShare`, `--color-primary`, `--color-palette-blue-mid` | +| `Reports/LeftoverPairList` | Read closest/farthest leftover pairs with named `R`, `Y`/`E`, rank, `U`, `s`, `e`, `x`, `R̂`, `ξ`/`ζ`, and `d`, then open that post. The leftover-map graphic display sits above the pair buttons when coordinates are finite, and leftover-map axes name persisted leftover-map axis share. | `LeftoverPairList`, `LeftoverMapPlot`, `ticket-list`, `post-badge` | | `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, or repeat-issue fact. `EvidenceReady`, `NarrowViewport`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover populated, mobile, unavailable-evidence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard` | | `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` | | `Post/Recorded perspectives` | Read the imported primary and every evidence-connected additional Voice with its recorded truth state instead of flattening them into one compound category. `CombinedEvidence`, `RejectedEvidence`, and `NarrowViewport` cover desktop, rejected-evidence, and narrow layouts. | `VoicePerspectiveList`, `ticket-list`, `post-meta` | diff --git a/frontend/package.json b/frontend/package.json index 87b052607..8c35c576e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.25.0", + "version": "2.26.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index d7a847147..f4f6f51ed 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -4142,8 +4142,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(await screen.findByLabelText("Leftover pairs")).toBeInTheDocument(); expect(screen.getByLabelText("Leftover-map graphic display")).toBeInTheDocument(); + expect(screen.getByText("leftover-map axis 1 (82%)")).toBeInTheDocument(); + expect(screen.getByText("leftover-map axis 2 (18%)")).toBeInTheDocument(); expect( screen.getByRole("button", { name: /open leftover-map post public post at ξ \(\+0\.50, \+0\.10\)/i, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e3fd0d8d6..e3fb6c796 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3868,6 +3868,7 @@ function ReportsPanel({ { onSelectPost(pair.post_id, { diff --git a/frontend/src/components/LeftoverMapPlot.stories.tsx b/frontend/src/components/LeftoverMapPlot.stories.tsx index 8b397271f..4cf7de2b7 100644 --- a/frontend/src/components/LeftoverMapPlot.stories.tsx +++ b/frontend/src/components/LeftoverMapPlot.stories.tsx @@ -8,6 +8,10 @@ const meta = { criterionLabel: (code: string) => code === "sales_lead_quality" ? "sales-lead" : "negative", onSelectPost: () => undefined, + leftoverMapAxes: [ + { axis_index: 1, leftover_singular_value: 1.84, leftover_share: 0.82 }, + { axis_index: 2, leftover_singular_value: 0.86, leftover_share: 0.18 }, + ], pairs: [ { pair_kind: "closest", @@ -61,6 +65,10 @@ export const ClosestAndFarthest: Story = {}; export const RankZeroOrigin: Story = { args: { + leftoverMapAxes: [ + { axis_index: 1, leftover_singular_value: 0, leftover_share: 0 }, + { axis_index: 2, leftover_singular_value: 0, leftover_share: 0 }, + ], pairs: [ { pair_kind: "closest", @@ -83,6 +91,10 @@ export const RankZeroOrigin: Story = { export const MissingCoordinates: Story = { args: { + leftoverMapAxes: [ + { axis_index: 1, leftover_singular_value: 1.84, leftover_share: 0.82 }, + { axis_index: 2, leftover_singular_value: 0.86, leftover_share: 0.18 }, + ], pairs: [ { pair_kind: "closest", @@ -96,3 +108,11 @@ export const MissingCoordinates: Story = { ], }, }; + +export const MissingAxisShare: Story = { + args: { + leftoverMapAxes: [ + { axis_index: 1, leftover_singular_value: 1.84, leftover_share: Number.NaN }, + ], + }, +}; diff --git a/frontend/src/components/LeftoverMapPlot.test.tsx b/frontend/src/components/LeftoverMapPlot.test.tsx index 9da61f53b..df6b65576 100644 --- a/frontend/src/components/LeftoverMapPlot.test.tsx +++ b/frontend/src/components/LeftoverMapPlot.test.tsx @@ -50,6 +50,8 @@ describe("LeftoverMapPlot", () => { ).toBeInTheDocument(); expect(screen.getByText("Post ξ")).toBeInTheDocument(); expect(screen.getByText("Criterion ζ")).toBeInTheDocument(); + expect(screen.getByText("leftover-map axis 1")).toBeInTheDocument(); + expect(screen.getByText("leftover-map axis 2")).toBeInTheDocument(); const postMarker = screen.getByRole("button", { name: "Open leftover-map post Public post at ξ (+0.50, +0.10)", @@ -102,6 +104,10 @@ describe("LeftoverMapPlot", () => { leftover_map_item_axis_2: 0, }, ]} + leftoverMapAxes={[ + { axis_index: 1, leftover_singular_value: 0, leftover_share: 0 }, + { axis_index: 2, leftover_singular_value: 0, leftover_share: 0 }, + ]} criterionLabel={criterionLabel} onSelectPost={vi.fn()} />, @@ -109,5 +115,40 @@ describe("LeftoverMapPlot", () => { expect( screen.getByRole("button", { name: "Open leftover-map post Public post at ξ (0.00, 0.00)" }), ).toBeInTheDocument(); + expect(screen.getByText("leftover-map axis 1 (0%)")).toBeInTheDocument(); + expect(screen.getByText("leftover-map axis 2 (0%)")).toBeInTheDocument(); + }); + + it("captions leftover-map axes with persisted leftover-map axis share", () => { + render( + , + ); + expect(screen.getByText("leftover-map axis 1 (82%)")).toBeInTheDocument(); + expect(screen.getByText("leftover-map axis 2 (18%)")).toBeInTheDocument(); + }); + + it("keeps existing leftover-map axis text when share is missing or non-finite", () => { + render( + , + ); + expect(screen.getByText("leftover-map axis 1")).toBeInTheDocument(); + expect(screen.getByText("leftover-map axis 2")).toBeInTheDocument(); + expect(screen.queryByText(/leftover-map axis 1 \(/)).not.toBeInTheDocument(); + expect(screen.queryByText(/leftover-map axis 2 \(/)).not.toBeInTheDocument(); }); }); diff --git a/frontend/src/components/LeftoverMapPlot.tsx b/frontend/src/components/LeftoverMapPlot.tsx index 998e96ac1..2b58a0c82 100644 --- a/frontend/src/components/LeftoverMapPlot.tsx +++ b/frontend/src/components/LeftoverMapPlot.tsx @@ -1,6 +1,11 @@ -import type { LeftoverPair } from "../api"; +import type { LeftoverMapAxis, LeftoverPair } from "../api"; import { t, tf } from "../i18n"; import { formatLeftoverMapCoordinatePair } from "../leftoverMapCoordinates"; +import { + formatLeftoverMapPlotAxisShare, + leftoverShareForAxis, + LEFTOVER_MAP_PLOT_AXIS_SHARE, +} from "../leftoverMapPlotAxisShare"; import { firstPlottablePairForPost, layoutLeftoverMapPlot, @@ -11,6 +16,7 @@ import "./LeftoverMapPlot.css"; export type LeftoverMapPlotProps = { pairs: LeftoverPair[]; + leftoverMapAxes?: LeftoverMapAxis[]; criterionLabel: (criterionCode: string) => string; onSelectPost: (pair: LeftoverPair) => void; }; @@ -19,15 +25,32 @@ function diamondPoints(x: number, y: number, radius: number): string { return `${x},${y - radius} ${x + radius},${y} ${x},${y + radius} ${x - radius},${y}`; } +function leftoverMapPlotAxisText( + axisIndex: 1 | 2, + leftoverMapAxes: LeftoverMapAxis[] | undefined, +): string { + const percent = formatLeftoverMapPlotAxisShare( + leftoverShareForAxis(leftoverMapAxes, axisIndex), + ); + if (percent === null) { + return t(axisIndex === 1 ? "leftover-map axis 1" : "leftover-map axis 2"); + } + return tf(LEFTOVER_MAP_PLOT_AXIS_SHARE, { axis: axisIndex, share: percent }); +} + /** * Gabriel leftover-map graphic display of persisted ``ξ_{1:2}`` / ``ζ_{1:2}``. * * Person markers are posts; item markers are leftover criteria. Click a - * post marker to open that post. Omit the plot when no pair has four + * post marker to open that post. Caption leftover-map axes with persisted + * Gabriel inertia share when finite, including rank-0 zero-share axes. + * Omit that axis badge when share is missing or non-finite and keep the + * existing leftover-map axis text. Omit the plot when no pair has four * finite leftover-map coordinates. Never invent a leftover score. */ export function LeftoverMapPlot({ pairs, + leftoverMapAxes, criterionLabel, onSelectPost, }: LeftoverMapPlotProps) { @@ -78,10 +101,10 @@ export function LeftoverMapPlot({ y2={layout.originY} /> - {t("leftover-map axis 1")} + {leftoverMapPlotAxisText(1, leftoverMapAxes)} - {t("leftover-map axis 2")} + {leftoverMapPlotAxisText(2, leftoverMapAxes)} {layout.segments.map((segment) => ( code === "sales_lead_quality" ? "sales-lead" : "negative", onSelectPost: () => undefined, + leftoverMapAxes: [ + { axis_index: 1, leftover_singular_value: 1.84, leftover_share: 0.82 }, + { axis_index: 2, leftover_singular_value: 0.86, leftover_share: 0.18 }, + ], pairs: [ { pair_kind: "closest", diff --git a/frontend/src/components/LeftoverPairList.test.tsx b/frontend/src/components/LeftoverPairList.test.tsx index 965188a1c..74f4e8a64 100644 --- a/frontend/src/components/LeftoverPairList.test.tsx +++ b/frontend/src/components/LeftoverPairList.test.tsx @@ -182,6 +182,10 @@ describe("LeftoverPairList", () => { leftover_map_item_axis_2: -0.02, }, ]} + leftoverMapAxes={[ + { axis_index: 1, leftover_singular_value: 1.84, leftover_share: 0.82 }, + { axis_index: 2, leftover_singular_value: 0.86, leftover_share: 0.18 }, + ]} criterionLabel={criterionLabel} onSelectPost={onSelectPost} />, @@ -192,6 +196,8 @@ describe("LeftoverPairList", () => { name: "Open leftover-map post Public post at ξ (+0.50, +0.10)", }), ); + expect(screen.getByText("leftover-map axis 1 (82%)")).toBeInTheDocument(); + expect(screen.getByText("leftover-map axis 2 (18%)")).toBeInTheDocument(); expect(onSelectPost).toHaveBeenCalledWith( expect.objectContaining({ post_id: "post-demo-public", diff --git a/frontend/src/components/LeftoverPairList.tsx b/frontend/src/components/LeftoverPairList.tsx index 5ff92e9e2..452bc9d65 100644 --- a/frontend/src/components/LeftoverPairList.tsx +++ b/frontend/src/components/LeftoverPairList.tsx @@ -1,4 +1,4 @@ -import type { LeftoverPair } from "../api"; +import type { LeftoverMapAxis, LeftoverPair } from "../api"; import { t, tf } from "../i18n"; import { formatLeftoverMapCrossShare, @@ -37,6 +37,7 @@ import { LeftoverMapPlot } from "./LeftoverMapPlot"; export type LeftoverPairListProps = { pairs: LeftoverPair[]; + leftoverMapAxes?: LeftoverMapAxis[]; criterionLabel: (criterionCode: string) => string; onSelectPost: (pair: LeftoverPair) => void; }; @@ -56,11 +57,13 @@ export type LeftoverPairListProps = { * unexplained leftover, then the existing residual/rank/observed-expected * next action. When four finite coordinates exist, ADR 0268 draws the * leftover-map graphic display above the pair buttons; click a post - * marker opens that post. Every badge still renders together before - * opening the named post. + * marker opens that post. ADR 0269 captions those leftover-map axes with + * persisted leftover-map axis share when finite. Every badge still + * renders together before opening the named post. */ export function LeftoverPairList({ pairs, + leftoverMapAxes, criterionLabel, onSelectPost, }: LeftoverPairListProps) { @@ -71,6 +74,7 @@ export function LeftoverPairList({
diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index b3a319c0f..b311760f8 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -64,6 +64,7 @@ describe("i18n", () => { "Criterion ζ", "leftover-map axis 1", "leftover-map axis 2", + "leftover-map axis {axis} ({share}%)", "Leftover map after IRT main effects. Click a post marker to open that post. The plot does not invent a leftover score.", "Open leftover-map post {title} at ξ {person}", "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.", @@ -348,6 +349,16 @@ describe("i18n", () => { ).toBe(expected); }); + it.each([ + ["ko", "잔여 지도 축 1 (82%)"], + ["zh", "残差图轴 1 (82%)"], + ["ja", "残差マップ軸 1 (82%)"], + ["vi", "trục bản đồ phần dư 1 (82%)"], + ] as const)("formats leftover-map plot axis share in %s", (locale, expected) => { + setLocale(locale); + expect(tf("leftover-map axis {axis} ({share}%)", { axis: 1, share: "82" })).toBe(expected); + }); + it.each([ ["ko", "IRT 주효과 이후 관측 Y 2.40와 기대 E 2.00를 읽은 다음, 이 글을 여세요."], ["zh", "阅读 IRT 主效应后的观测 Y 2.40 与期望 E 2.00,然后打开这篇帖子。"], diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index df34daa5a..32d194ce3 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -589,6 +589,7 @@ const TRANSLATIONS: Partial>> = { "Criterion ζ": "기준 ζ", "leftover-map axis 1": "잔여 지도 축 1", "leftover-map axis 2": "잔여 지도 축 2", + "leftover-map axis {axis} ({share}%)": "잔여 지도 축 {axis} ({share}%)", "Leftover map after IRT main effects. Click a post marker to open that post. The plot does not invent a leftover score.": "IRT 주효과 이후 잔여 지도입니다. 글 표식을 눌러 그 글을 여세요. 이 그림은 잔여 점수를 만들어내지 않습니다.", "Open leftover-map post {title} at ξ {person}": @@ -1166,6 +1167,7 @@ const TRANSLATIONS: Partial>> = { "Criterion ζ": "准则 ζ", "leftover-map axis 1": "残差图轴 1", "leftover-map axis 2": "残差图轴 2", + "leftover-map axis {axis} ({share}%)": "残差图轴 {axis} ({share}%)", "Leftover map after IRT main effects. Click a post marker to open that post. The plot does not invent a leftover score.": "IRT 主效应后的残差图。点击帖子标记打开该帖子。此图不会虚构残差分数。", "Open leftover-map post {title} at ξ {person}": @@ -1747,6 +1749,7 @@ const TRANSLATIONS: Partial>> = { "Criterion ζ": "基準 ζ", "leftover-map axis 1": "残差マップ軸 1", "leftover-map axis 2": "残差マップ軸 2", + "leftover-map axis {axis} ({share}%)": "残差マップ軸 {axis} ({share}%)", "Leftover map after IRT main effects. Click a post marker to open that post. The plot does not invent a leftover score.": "IRT主効果後の残差マップです。投稿マーカーをクリックしてその投稿を開いてください。この図は残差スコアを作りません。", "Open leftover-map post {title} at ξ {person}": @@ -2328,6 +2331,7 @@ const TRANSLATIONS: Partial>> = { "Criterion ζ": "Tiêu chí ζ", "leftover-map axis 1": "trục bản đồ phần dư 1", "leftover-map axis 2": "trục bản đồ phần dư 2", + "leftover-map axis {axis} ({share}%)": "trục bản đồ phần dư {axis} ({share}%)", "Leftover map after IRT main effects. Click a post marker to open that post. The plot does not invent a leftover score.": "Bản đồ phần dư sau hiệu ứng chính IRT. Nhấn dấu bài viết để mở bài đó. Hình này không tạo ra điểm phần dư.", "Open leftover-map post {title} at ξ {person}": diff --git a/frontend/src/leftoverMapPlotAxisShare.test.ts b/frontend/src/leftoverMapPlotAxisShare.test.ts new file mode 100644 index 000000000..08611b707 --- /dev/null +++ b/frontend/src/leftoverMapPlotAxisShare.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + formatLeftoverMapPlotAxisShare, + leftoverShareForAxis, +} from "./leftoverMapPlotAxisShare"; + +describe("leftoverShareForAxis", () => { + const axes = [ + { axis_index: 1, leftover_share: 0.82 }, + { axis_index: 2, leftover_share: 0.18 }, + ]; + + it("reads persisted leftover-map axis share without inventing a leftover score", () => { + expect(leftoverShareForAxis(axes, 1)).toBe(0.82); + expect(leftoverShareForAxis(axes, 2)).toBe(0.18); + }); + + it("names a rank-0 zero-share leftover-map axis", () => { + expect(leftoverShareForAxis([{ axis_index: 1, leftover_share: 0 }, { axis_index: 2, leftover_share: 0 }], 1)).toBe( + 0, + ); + }); + + it("omits a leftover-map axis when share is missing or non-finite", () => { + expect(leftoverShareForAxis(undefined, 1)).toBeNull(); + expect(leftoverShareForAxis([], 1)).toBeNull(); + expect(leftoverShareForAxis([{ axis_index: 1, leftover_share: null }], 1)).toBeNull(); + expect(leftoverShareForAxis([{ axis_index: 1, leftover_share: Number.NaN }], 1)).toBeNull(); + expect( + leftoverShareForAxis([{ axis_index: 1, leftover_share: Number.POSITIVE_INFINITY }], 1), + ).toBeNull(); + expect(leftoverShareForAxis(axes, 3)).toBeNull(); + }); +}); + +describe("formatLeftoverMapPlotAxisShare", () => { + it("formats persisted leftover-map axis share as percent without inventing a leftover score", () => { + expect(formatLeftoverMapPlotAxisShare(0.82)).toBe("82"); + expect(formatLeftoverMapPlotAxisShare(0.18)).toBe("18"); + expect(formatLeftoverMapPlotAxisShare(0)).toBe("0"); + }); + + it("omits leftover-map axis share when the value is missing or non-finite", () => { + expect(formatLeftoverMapPlotAxisShare(null)).toBeNull(); + expect(formatLeftoverMapPlotAxisShare(undefined)).toBeNull(); + expect(formatLeftoverMapPlotAxisShare(Number.NaN)).toBeNull(); + expect(formatLeftoverMapPlotAxisShare(Number.POSITIVE_INFINITY)).toBeNull(); + expect(formatLeftoverMapPlotAxisShare(Number.NEGATIVE_INFINITY)).toBeNull(); + }); +}); diff --git a/frontend/src/leftoverMapPlotAxisShare.ts b/frontend/src/leftoverMapPlotAxisShare.ts new file mode 100644 index 000000000..57d45aef4 --- /dev/null +++ b/frontend/src/leftoverMapPlotAxisShare.ts @@ -0,0 +1,30 @@ +/** Caption leftover-map plot axes with persisted Gabriel inertia share. */ + +import type { LeftoverMapAxis } from "./api"; + +export const LEFTOVER_MAP_PLOT_AXIS_SHARE = "leftover-map axis {axis} ({share}%)"; + +export type LeftoverMapPlotAxisShare = { + axis_index: LeftoverMapAxis["axis_index"]; + leftover_share?: LeftoverMapAxis["leftover_share"] | null; +}; + +export function leftoverShareForAxis( + axes: ReadonlyArray | null | undefined, + axisIndex: number, +): number | null { + const axis = axes?.find((candidate) => candidate.axis_index === axisIndex); + if (axis == null || axis.leftover_share == null || !Number.isFinite(axis.leftover_share)) { + return null; + } + return axis.leftover_share; +} + +export function formatLeftoverMapPlotAxisShare( + leftoverShare: number | null | undefined, +): string | null { + if (leftoverShare == null || !Number.isFinite(leftoverShare)) { + return null; + } + return (leftoverShare * 100).toFixed(0); +} diff --git a/frontend/src/leftoverMapPlotLayout.ts b/frontend/src/leftoverMapPlotLayout.ts index eef687d5e..80095b51e 100644 --- a/frontend/src/leftoverMapPlotLayout.ts +++ b/frontend/src/leftoverMapPlotLayout.ts @@ -1,4 +1,6 @@ -/** Gabriel leftover-map graphic display of persisted ``ξ_{1:2}`` / ``ζ_{1:2}``. */ +/** Gabriel leftover-map graphic display of persisted ``ξ_{1:2}`` / ``ζ_{1:2}``. + * Leftover-map axis share captions axes 1 and 2 when finite (ADR 0269). + */ import { formatLeftoverMapCoordinatePair } from "./leftoverMapCoordinates"; import type { LeftoverPair } from "./api"; diff --git a/pyproject.toml b/pyproject.toml index 54deb8566..0e37b92aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.25.0" +version = "2.26.0" 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/uv.lock b/uv.lock index 79f7d12e5..73a4f913c 100644 --- a/uv.lock +++ b/uv.lock @@ -685,7 +685,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.24.0" +version = "2.26.0" source = { editable = "." } dependencies = [ { name = "certifi" }, From 7ffc665823e114fa6cbb843f8e79ed07cba8a82a Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 28 Aug 2026 19:59:24 +0900 Subject: [PATCH 4/7] test(reports): apply leftover-map migrations in live fixture Signed-off-by: Codex --- backend/tests/test_api.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 10ca4993f..520277031 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -182,6 +182,21 @@ / "migrations" / "0206_report_leftover_map_reconstruction.sql" ) +_LEFTOVER_MAP_UNEXPLAINED_SHARE_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0233_report_leftover_map_unexplained_share.sql" +) +_LEFTOVER_MAP_EXPLAINED_SHARE_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0244_report_leftover_map_explained_share.sql" +) +_LEFTOVER_MAP_COORDINATES_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0245_report_leftover_map_coordinates.sql" +) _GLOBAL_ASK_JOB_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" @@ -407,6 +422,9 @@ def seeded_db(demo_analyst_token): cur.execute(_LEFTOVER_MAP_UNEXPLAINED_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_CROSS_SHARE_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_RECONSTRUCTION_MIGRATION.read_text()) + cur.execute(_LEFTOVER_MAP_UNEXPLAINED_SHARE_MIGRATION.read_text()) + cur.execute(_LEFTOVER_MAP_EXPLAINED_SHARE_MIGRATION.read_text()) + cur.execute(_LEFTOVER_MAP_COORDINATES_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " From 4a0afbf4804d9862bba58869db20ccdfb0a0b37e Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 29 Aug 2026 00:14:07 +0900 Subject: [PATCH 5/7] fix(reports): align leftover map evidence --- CHANGELOG.d/2.24.0-leftover-map-coordinates.md | 3 ++- docs/adr/0049-leftover-pair-report-ui.md | 5 +++-- docs/adr/0267-leftover-map-coordinates.md | 9 +++++---- frontend/src/App.test.tsx | 16 ++++++++++------ .../src/components/LeftoverMapPlot.stories.tsx | 6 +++--- frontend/src/components/LeftoverMapPlot.tsx | 2 +- .../src/components/LeftoverPairList.stories.tsx | 6 +++--- 7 files changed, 27 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.d/2.24.0-leftover-map-coordinates.md b/CHANGELOG.d/2.24.0-leftover-map-coordinates.md index 68615b6e2..b3ab4e388 100644 --- a/CHANGELOG.d/2.24.0-leftover-map-coordinates.md +++ b/CHANGELOG.d/2.24.0-leftover-map-coordinates.md @@ -4,7 +4,8 @@ post–criterion pairs (ADR 0267). After `make seed`, closest and farthest leftover pairs sit above the member list with `ξ (x, y) ζ (x, y)` next to leftover-map distance `d`; click opens that post. - Omit the badge when any coordinate is missing. Rank-0 unused axes + Omit all four coordinate values when any coordinate is missing or non-finite, + including `NaN` and positive or negative infinity. Rank-0 unused axes store `0.0`. When coordinates, reconstruction, and distance are finite, `R̂ = ξ · ζ` and `d = ‖ξ − ζ‖`. Never invent a leftover score. Never invent a theta. diff --git a/docs/adr/0049-leftover-pair-report-ui.md b/docs/adr/0049-leftover-pair-report-ui.md index 2673dec32..0074826ef 100644 --- a/docs/adr/0049-leftover-pair-report-ui.md +++ b/docs/adr/0049-leftover-pair-report-ui.md @@ -39,8 +39,9 @@ leftover-map cross share next to distance when finite. When four finite coordinates exist, the leftover-map graphic display of those positions sits above the pair buttons (ADR 0268); click a post marker to open that post. Leftover-map axis share captions those leftover-map axes -when finite (ADR 0269). The next action names every available -measurement before opening the post; no amendment hides another, rank 0 +when finite (ADR 0269). The pair renders every available finite measurement. +The next action uses the first available value in the priority below; no +amendment hides another badge, rank 0 explicitly names no leftover structure, and unexplained leftover names "leftover map leaves unexplained `U` after IRT main effects; open this post to read the named criterion" when present. When leftover-map diff --git a/docs/adr/0267-leftover-map-coordinates.md b/docs/adr/0267-leftover-map-coordinates.md index 99dce6dd6..995f993ba 100644 --- a/docs/adr/0267-leftover-map-coordinates.md +++ b/docs/adr/0267-leftover-map-coordinates.md @@ -8,10 +8,11 @@ Amends [ADR 0048](0048-persist-lsirm-leftover-pairs.md), [ADR 0049](0049-leftover-pair-report-ui.md), and -[ADR 0201](0201-leftover-map-reconstruction.md). Independent of leftover-map +[ADR 0201](0201-leftover-map-reconstruction.md). Builds on ADR 0201's +leftover-map reconstruction. Coordinate publication is independent of the explained leftover share ([ADR 0266](0266-leftover-map-explained-share.md)), -leftover-map unexplained leftover share ([ADR 0233](0233-leftover-map-unexplained-share.md)), -and leftover-map reconstruction ([ADR 0201](0201-leftover-map-reconstruction.md)). +and leftover-map unexplained leftover share +([ADR 0233](0233-leftover-map-unexplained-share.md)). ## Context @@ -45,7 +46,7 @@ reconstruction (0201 / migration 0206), leftover-map cross share expected `E`, leftover-map rank, two-axis leftover-map distance, leftover coverage, leftover-map axis share (0148), leftover interaction-map persistence, occupational construct catalog search -(0265), I/O occupational taxonomy (0245), or source-post voice +(0265), I/O occupational taxonomy (ADR 0245), or source-post voice history (migration 0243). ## Decision diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index f4f6f51ed..790c4da69 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1042,7 +1042,7 @@ describe("App, authenticated", () => { expected_response: 2.0, leftover_map_rank: 1, leftover_map_cross_share: 0.12, - leftover_map_reconstruction: 0.35, + leftover_map_reconstruction: 0.248, leftover_map_unexplained_share: 0.02, leftover_map_explained_share: 0.76, leftover_map_person_axis_1: 0.5, @@ -1055,14 +1055,14 @@ describe("App, authenticated", () => { post_id: "post-2", post_title: "Specification revision requested", criterion_code: "general_sentiment_negative", - leftover_distance: 1.84, + leftover_distance: 2.0, leftover_residual: -1.1, leftover_map_unexplained: -0.25, observed_response: 0.9, expected_response: 2.0, leftover_map_rank: 1, leftover_map_cross_share: -0.24, - leftover_map_reconstruction: -0.85, + leftover_map_reconstruction: -0.95, leftover_map_unexplained_share: 0.05, leftover_map_explained_share: 0.60, leftover_map_person_axis_1: 0.9, @@ -4172,7 +4172,7 @@ describe("App, authenticated", () => { expect(closestPair).toHaveTextContent("U²/R² 0.02"); expect(closestPair).toHaveTextContent("R̂²/R² 0.76"); expect(closestPair).toHaveTextContent("2R̂U/R² 0.12"); - expect(closestPair).toHaveTextContent("R̂ +0.35"); + expect(closestPair).toHaveTextContent("R̂ +0.25"); expect(closestPair).toHaveTextContent("ξ (+0.50, +0.10) ζ (+0.50, −0.02)"); expect(closestPair).toHaveTextContent("d 0.12"); expect(closestPair).toHaveAccessibleName("Open leftover closest pair: Public post · sales-lead"); @@ -4187,9 +4187,13 @@ describe("App, authenticated", () => { expect(farthestPair).toHaveTextContent("U²/R² 0.05"); expect(farthestPair).toHaveTextContent("R̂²/R² 0.60"); expect(farthestPair).toHaveTextContent("2R̂U/R² -0.24"); - expect(farthestPair).toHaveTextContent("R̂ −0.85"); + expect(farthestPair).toHaveTextContent("R̂ −0.95"); expect(farthestPair).toHaveTextContent("ξ (+0.90, +0.80) ζ (−0.70, −0.40)"); - expect(farthestPair).toHaveTextContent("d 1.84"); + expect(farthestPair).toHaveTextContent("d 2.00"); + expect(0.5 * 0.5 + 0.1 * -0.02).toBeCloseTo(0.248); + expect(Math.hypot(0.5 - 0.5, 0.1 - -0.02)).toBeCloseTo(0.12); + expect(0.9 * -0.7 + 0.8 * -0.4).toBeCloseTo(-0.95); + expect(Math.hypot(0.9 - -0.7, 0.8 - -0.4)).toBeCloseTo(2.0); 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(); diff --git a/frontend/src/components/LeftoverMapPlot.stories.tsx b/frontend/src/components/LeftoverMapPlot.stories.tsx index 4cf7de2b7..16c869e26 100644 --- a/frontend/src/components/LeftoverMapPlot.stories.tsx +++ b/frontend/src/components/LeftoverMapPlot.stories.tsx @@ -24,7 +24,7 @@ const meta = { expected_response: 2.0, leftover_map_rank: 1, leftover_map_unexplained: 0.05, - leftover_map_reconstruction: 0.35, + leftover_map_reconstruction: 0.248, leftover_map_cross_share: 0.12, leftover_map_unexplained_share: 0.02, leftover_map_explained_share: 0.76, @@ -38,13 +38,13 @@ const meta = { post_id: "post-demo-spec", post_title: "Specification revision requested", criterion_code: "negative_sentiment", - leftover_distance: 1.84, + leftover_distance: 2.0, leftover_residual: -1.1, observed_response: 0.9, expected_response: 2.0, leftover_map_rank: 1, leftover_map_unexplained: -0.25, - leftover_map_reconstruction: -0.85, + leftover_map_reconstruction: -0.95, leftover_map_cross_share: -0.24, leftover_map_unexplained_share: 0.05, leftover_map_explained_share: 0.6, diff --git a/frontend/src/components/LeftoverMapPlot.tsx b/frontend/src/components/LeftoverMapPlot.tsx index 2b58a0c82..46743866a 100644 --- a/frontend/src/components/LeftoverMapPlot.tsx +++ b/frontend/src/components/LeftoverMapPlot.tsx @@ -83,7 +83,7 @@ export function LeftoverMapPlot({ Date: Fri, 28 Aug 2026 21:16:00 -0700 Subject: [PATCH 6/7] feat(reports): leftover-map coordinate ticks (v2.27.0) (#799) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caption leftover-map graphic-display axes with persisted leftover-map coordinate ticks (ADR 0270). After make seed, closest and farthest leftover pairs sit above the member list with the Gabriel biplot of already-named coordinates, leftover-map axis share when finite, and axis ticks that match ξ (x, y) ζ (x, y) on the pair row; click a post marker or a pair button opens that post. Origin is always named 0. Rank-0 unused axes do not invent −1 / +1 drawing-scale ticks. Never invent a leftover score or a theta. No new columns. --- AGENTS.md | 8 +- ARCHITECTURE.md | 6 +- .../2.27.0-leftover-map-coordinate-ticks.md | 11 ++ CHANGELOG.md | 10 ++ CLAUDE.md | 2 +- docs/adr/0048-persist-lsirm-leftover-pairs.md | 6 +- docs/adr/0049-leftover-pair-report-ui.md | 10 +- docs/adr/0267-leftover-map-coordinates.md | 14 ++- docs/adr/0268-leftover-map-graphic-display.md | 11 +- docs/adr/0269-leftover-map-axis-share-plot.md | 6 +- .../adr/0270-leftover-map-coordinate-ticks.md | 106 ++++++++++++++++++ docs/product-technical-gap-baseline.md | 21 ++++ docs/storybook-inventory.md | 4 +- frontend/package.json | 2 +- frontend/src/components/LeftoverMapPlot.css | 10 ++ .../src/components/LeftoverMapPlot.test.tsx | 7 +- frontend/src/components/LeftoverMapPlot.tsx | 26 ++++- frontend/src/components/LeftoverPairList.tsx | 4 +- frontend/src/i18n.test.ts | 23 +++- frontend/src/i18n.ts | 20 ++-- frontend/src/leftoverMapPlotLayout.test.ts | 45 ++++++++ frontend/src/leftoverMapPlotLayout.ts | 89 ++++++++++++++- pyproject.toml | 2 +- uv.lock | 2 +- 24 files changed, 400 insertions(+), 45 deletions(-) create mode 100644 CHANGELOG.d/2.27.0-leftover-map-coordinate-ticks.md create mode 100644 docs/adr/0270-leftover-map-coordinate-ticks.md diff --git a/AGENTS.md b/AGENTS.md index eefa47f47..d9e17152c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -273,7 +273,7 @@ stops startup instead of leaving a healthy-looking partial schema, and application code must not compensate for a missing table. Period leftover pairs (ADR 0017 / 0018 / 0048 / 0049 / 0119 / 0158 / 0162 / -0163 / 0164 / 0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269) are computed in `lineageweave/leftover_pairs.py` from the +0163 / 0164 / 0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269 / 0270) are computed in `lineageweave/leftover_pairs.py` from the residual after a real GRM/GPCM score, never invented. Distances are Euclidean on the two-dimensional Gabriel leftover map; missing cells stay out of the factorization. Closest and farthest post–criterion pairs @@ -294,7 +294,11 @@ buttons; omit the plot when coordinates are missing; click a post marker to open that post. ADR 0269 captions those leftover-map axes with persisted leftover-map axis share `σ_k² / Σ_j σ_j²` when finite, including rank-0 zero-share axes; a missing or non-finite share omits -that axis badge and keeps the existing leftover-map axis text. When `R`, `R̂`, `U`, `x`, +that axis badge and keeps the existing leftover-map axis text. ADR 0270 +ticks leftover-map axes at the origin and at each unique finite +persisted `ξ` / `ζ` coordinate so the pair-row badge matches the +plot; rank-0 unused axes name only `0` and do not invent drawing-scale +ticks. When `R`, `R̂`, `U`, `x`, `s`, and `e` are finite, `e + s + x = 1`. When coordinates, reconstruction, and distance are finite, `R̂ = ξ · ζ` and `d = ‖ξ − ζ‖`. The pairs sit above the member diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2c087fd1e..0da6e0d22 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -637,14 +637,16 @@ information at the group's mean θ (Lord, 1980 max-info CAT). Rankings persist to `report_item_information`. After those IRT main effects, residual SVD leftover pairs on two Gabriel axes (Jeon et al., 2021; ADR 0017 / 0048 / 0049 / 0119 / 0148 / 0158 / 0162 / 0163 / 0164 / 0168 / -0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269) persist to `report_leftover_pair` with signed residual `R`, +0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269 / 0270) persist to `report_leftover_pair` with signed residual `R`, observed `Y`, expected `E[Y|θ, item]`, full leftover-map rank, unexplained leftover, ADR 0201 reconstruction evidence, ADR 0185 cross-share evidence, ADR 0233 unexplained leftover share `s`, ADR 0266 explained leftover share `e`, and ADR 0267 leftover-map coordinates `ξ_{1:2}` / `ζ_{1:2}`. ADR 0268 draws those persisted coordinates as the leftover-map graphic display above the pair buttons. ADR 0269 captions leftover-map axes 1 -and 2 with persisted leftover-map axis share. Click a post marker or a pair button +and 2 with persisted leftover-map axis share. ADR 0270 ticks those +leftover-map axes at persisted `ξ` / `ζ` coordinates so the pair-row +badge matches the plot. Click a post marker or a pair button opens that post. Those ADRs are the normative mathematical, storage, and display contracts. Leftover-map axis share (Gabriel inertia of residual SVD axes 1 and 2; ADR 0148) persists to diff --git a/CHANGELOG.d/2.27.0-leftover-map-coordinate-ticks.md b/CHANGELOG.d/2.27.0-leftover-map-coordinate-ticks.md new file mode 100644 index 000000000..a7b7b74b1 --- /dev/null +++ b/CHANGELOG.d/2.27.0-leftover-map-coordinate-ticks.md @@ -0,0 +1,11 @@ +## 2.27.0 — Leftover-map coordinate ticks on the graphic display + +- Caption leftover-map graphic-display axes with persisted leftover-map + coordinate ticks (ADR 0270). After `make seed`, closest and farthest + leftover pairs sit above the member list with the Gabriel biplot of + already-named coordinates, leftover-map axis share when finite, and + axis ticks that match `ξ (x, y) ζ (x, y)` on the pair row; click a + post marker or a pair button opens that post. Origin is always named + `0`. Rank-0 unused axes do not invent `−1` / `+1` drawing-scale + ticks. Never invent a leftover score. Never invent a theta. No new + columns. diff --git a/CHANGELOG.md b/CHANGELOG.md index e4c98aa57..d9f305408 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ All notable changes to this project are documented here. Format follows ### Added +- Period leftover pairs now caption leftover-map graphic-display axes + with persisted leftover-map coordinate ticks (ADR 0270 / v2.27.0). + After `make seed`, closest and farthest leftover pairs sit above the + member list with the Gabriel biplot of already-named coordinates, + leftover-map axis share when finite, and axis ticks that match + `ξ (x, y) ζ (x, y)` on the pair row; click a post marker or a pair + button opens that post. Origin is always named `0`. Rank-0 unused + axes do not invent `−1` / `+1` drawing-scale ticks. Never invent a + leftover score or a theta. No new columns. + - Period leftover pairs now caption leftover-map graphic-display axes with persisted leftover-map axis share `σ_k² / Σ_j σ_j²` (ADR 0269 / v2.26.0). After `make seed`, closest and farthest leftover pairs sit diff --git a/CLAUDE.md b/CLAUDE.md index 3073348c9..daaf3a583 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ cutoff. Global Ask optional `knowledge_cutoff` uses the same cover Create/start endpoint rules (ADR 0017 / 0021), tie-vs-miss similarity (ADR 0026), R&R catalog ids (ADR 0019 / 0027), leftover pairs -(ADR 0048–0164 / 0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269), occupational construct catalog search +(ADR 0048–0164 / 0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269 / 0270), occupational construct catalog search (ADR 0257), the text-channel embedding swap and cosine clamp (ADR 0190), per-edge channel-score persistence (ADR 0195), token-backed status notices (ADR 0220), diff --git a/docs/adr/0048-persist-lsirm-leftover-pairs.md b/docs/adr/0048-persist-lsirm-leftover-pairs.md index 248cba6d6..75ed90cc0 100644 --- a/docs/adr/0048-persist-lsirm-leftover-pairs.md +++ b/docs/adr/0048-persist-lsirm-leftover-pairs.md @@ -12,7 +12,8 @@ [ADR 0266](0266-leftover-map-explained-share.md) (explained leftover share e); [ADR 0267](0267-leftover-map-coordinates.md) (leftover-map coordinates ξ, ζ); [ADR 0268](0268-leftover-map-graphic-display.md) (leftover-map graphic display); -[ADR 0269](0269-leftover-map-axis-share-plot.md) (leftover-map axis share on the graphic display) +[ADR 0269](0269-leftover-map-axis-share-plot.md) (leftover-map axis share on the graphic display); +[ADR 0270](0270-leftover-map-coordinate-ticks.md) (leftover-map coordinate ticks) ## Context @@ -69,7 +70,8 @@ reconstruction `R̂ = ξ · ζ` and distance `d = ‖ξ − ζ‖` stay auditabl from the pair row. ADR 0268 draws those persisted coordinates as the leftover-map graphic display; it adds no columns. ADR 0269 captions those leftover-map axes with persisted leftover-map axis share; it -adds no columns. +adds no columns. ADR 0270 ticks leftover-map axes at persisted `ξ` / +`ζ` coordinates; it adds no columns. 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 0074826ef..8d8627529 100644 --- a/docs/adr/0049-leftover-pair-report-ui.md +++ b/docs/adr/0049-leftover-pair-report-ui.md @@ -13,7 +13,8 @@ [ADR 0266](0266-leftover-map-explained-share.md) (explained leftover share e); [ADR 0267](0267-leftover-map-coordinates.md) (leftover-map coordinates ξ, ζ); [ADR 0268](0268-leftover-map-graphic-display.md) (leftover-map graphic display); -[ADR 0269](0269-leftover-map-axis-share-plot.md) (leftover-map axis share on the graphic display) +[ADR 0269](0269-leftover-map-axis-share-plot.md) (leftover-map axis share on the graphic display); +[ADR 0270](0270-leftover-map-coordinate-ticks.md) (leftover-map coordinate ticks) ## Context @@ -39,7 +40,8 @@ leftover-map cross share next to distance when finite. When four finite coordinates exist, the leftover-map graphic display of those positions sits above the pair buttons (ADR 0268); click a post marker to open that post. Leftover-map axis share captions those leftover-map axes -when finite (ADR 0269). The pair renders every available finite measurement. +when finite (ADR 0269). Leftover-map axis ticks name persisted `ξ` / +`ζ` coordinates (ADR 0270). The pair renders every available finite measurement. The next action uses the first available value in the priority below; no amendment hides another badge, rank 0 explicitly names no leftover structure, and unexplained leftover names @@ -66,6 +68,10 @@ Leftover-map coordinate naming is [ADR 0267](0267-leftover-map-coordinates.md). Leftover-map graphic display is [ADR 0268](0268-leftover-map-graphic-display.md). +Leftover-map axis share on the graphic display is +[ADR 0269](0269-leftover-map-axis-share-plot.md). +Leftover-map coordinate ticks are +[ADR 0270](0270-leftover-map-coordinate-ticks.md). After `make seed`, closest and farthest leftover pairs sit above the member list. Click a pair to open that post with the leftover diff --git a/docs/adr/0267-leftover-map-coordinates.md b/docs/adr/0267-leftover-map-coordinates.md index 995f993ba..60e64ae66 100644 --- a/docs/adr/0267-leftover-map-coordinates.md +++ b/docs/adr/0267-leftover-map-coordinates.md @@ -4,15 +4,19 @@ **Date:** 2026-08-28 **Amended by:** [ADR 0268](0268-leftover-map-graphic-display.md) -(leftover-map graphic display) +(leftover-map graphic display); +[ADR 0270](0270-leftover-map-coordinate-ticks.md) +(leftover-map coordinate ticks) Amends [ADR 0048](0048-persist-lsirm-leftover-pairs.md), [ADR 0049](0049-leftover-pair-report-ui.md), and [ADR 0201](0201-leftover-map-reconstruction.md). Builds on ADR 0201's leftover-map reconstruction. Coordinate publication is independent of the explained leftover share ([ADR 0266](0266-leftover-map-explained-share.md)), -and leftover-map unexplained leftover share -([ADR 0233](0233-leftover-map-unexplained-share.md)). +leftover-map unexplained leftover share +([ADR 0233](0233-leftover-map-unexplained-share.md)), and leftover-map +axis share on the graphic display +([ADR 0269](0269-leftover-map-axis-share-plot.md)). ## Context @@ -104,8 +108,8 @@ leftover-map distance, leftover-map rank, leftover-map inner product, leftover-map cosine, leftover-map length, leftover-map reconstruction, leftover-map unexplained leftover, leftover-map cross share, leftover-map unexplained leftover share, leftover-map explained -leftover share, leftover-map graphic display, and leftover-map axis -share on the graphic display. +leftover share, leftover-map graphic display, leftover-map axis +share on the graphic display, and leftover-map coordinate ticks. ## References diff --git a/docs/adr/0268-leftover-map-graphic-display.md b/docs/adr/0268-leftover-map-graphic-display.md index a758d6532..3cac93fb6 100644 --- a/docs/adr/0268-leftover-map-graphic-display.md +++ b/docs/adr/0268-leftover-map-graphic-display.md @@ -4,7 +4,9 @@ **Date:** 2026-08-28 **Amended by:** [ADR 0269](0269-leftover-map-axis-share-plot.md) -(leftover-map axis share on the graphic display) +(leftover-map axis share on the graphic display); +[ADR 0270](0270-leftover-map-coordinate-ticks.md) +(leftover-map coordinate ticks) Amends [ADR 0049](0049-leftover-pair-report-ui.md) and [ADR 0267](0267-leftover-map-coordinates.md). Independent of leftover-map @@ -77,7 +79,8 @@ After `make seed`, closest and farthest leftover pairs sit above the member list with the leftover-map graphic display of persisted `ξ` and `ζ`; click a post marker or a pair button opens that post. Hidden posts stay hidden. Leftover-map axis share captions those -leftover-map axes when finite (ADR 0269). When coordinates, reconstruction, and +leftover-map axes when finite (ADR 0269). Leftover-map axis ticks name +persisted `ξ` / `ζ` coordinates (ADR 0270). When coordinates, reconstruction, and distance are all finite, `R̂ = ξ_{1:2} · ζ_{1:2}` and `d = ‖ξ_{1:2} − ζ_{1:2}‖` remain the same identities already persisted by ADR 0267. @@ -92,8 +95,8 @@ leftover-map distance, leftover-map rank, leftover-map inner product, leftover-map cosine, leftover-map length, leftover-map reconstruction, leftover-map unexplained leftover, leftover-map cross share, leftover-map unexplained leftover share, leftover-map explained -leftover share, leftover-map coordinate persistence, and leftover-map -axis share on the graphic display. +leftover share, leftover-map coordinate persistence, leftover-map +axis share on the graphic display, and leftover-map coordinate ticks. ## References diff --git a/docs/adr/0269-leftover-map-axis-share-plot.md b/docs/adr/0269-leftover-map-axis-share-plot.md index 5785d42c0..9d5f1ea50 100644 --- a/docs/adr/0269-leftover-map-axis-share-plot.md +++ b/docs/adr/0269-leftover-map-axis-share-plot.md @@ -9,6 +9,8 @@ explained leftover share ([ADR 0266](0266-leftover-map-explained-share.md)), leftover-map unexplained leftover share ([ADR 0233](0233-leftover-map-unexplained-share.md)), leftover-map reconstruction ([ADR 0201](0201-leftover-map-reconstruction.md)), and leftover-map coordinates ([ADR 0267](0267-leftover-map-coordinates.md)). +Independent of leftover-map coordinate ticks +([ADR 0270](0270-leftover-map-coordinate-ticks.md)). ## Context @@ -78,8 +80,8 @@ strip, two-axis leftover-map distance, leftover-map rank, leftover-map inner product, leftover-map cosine, leftover-map length, leftover-map reconstruction, leftover-map unexplained leftover, leftover-map cross share, leftover-map unexplained leftover share, leftover-map explained -leftover share, leftover-map coordinate persistence, and leftover-map -graphic display. +leftover share, leftover-map coordinate persistence, leftover-map +graphic display, and leftover-map coordinate ticks. ## References diff --git a/docs/adr/0270-leftover-map-coordinate-ticks.md b/docs/adr/0270-leftover-map-coordinate-ticks.md new file mode 100644 index 000000000..e40bba33b --- /dev/null +++ b/docs/adr/0270-leftover-map-coordinate-ticks.md @@ -0,0 +1,106 @@ +# ADR 0270 — Name leftover-map coordinates as graphic-display axis ticks + +**Decision status:** Accepted +**Date:** 2026-08-29 + +Amends [ADR 0268](0268-leftover-map-graphic-display.md) and +[ADR 0267](0267-leftover-map-coordinates.md). Independent of leftover-map +axis share on the graphic display +([ADR 0269](0269-leftover-map-axis-share-plot.md)), leftover-map +explained leftover share ([ADR 0266](0266-leftover-map-explained-share.md)), +leftover-map unexplained leftover share +([ADR 0233](0233-leftover-map-unexplained-share.md)), leftover-map +reconstruction ([ADR 0201](0201-leftover-map-reconstruction.md)), and +leftover-map axis share persistence ([ADR 0148](0148-leftover-map-axis-share.md)). + +## Context + +ADR 0267 already persists two-axis Gabriel person coordinates +`ξ_{1:2}` and item coordinates `ζ_{1:2}` on leftover pair rows so +`R̂ = ξ_{1:2} · ζ_{1:2}` and `d = ‖ξ_{1:2} − ζ_{1:2}‖` stay +auditable. ADR 0268 already draws those positions. ADR 0269 captions +leftover-map axes with persisted Gabriel inertia share. The plot still +has no coordinate scale, so a buyer who reads `ξ (x, y) ζ (x, y)` on +the pair row cannot match those numbers to leftover-map location. +Hiding ticks lets leftover residual `R`, leftover-map distance `d`, or +reconstruction `R̂` be read as leftover-map location even after the +coordinates themselves are named and plotted. + +This increment names leftover-map graphic-display axes with persisted +coordinate ticks. It does not add columns. It does not persist +leftover-map inner product, cosine, or length. It does not land Post +quality on the leftover criterion. Leftover-map distance stays +two-axis Euclidean. Do not invent a leftover score. Do not invent a +theta. + +The dashboard stack already used neighbouring leftover facts under +other numbers. This protected increment uses **0270** so it does not +collide with leftover-map axis share on the graphic display (0269), +leftover-map graphic display (0268), leftover-map coordinates (0267 / +migration 0245), leftover-map explained leftover share (0266 / +migration 0244), leftover-map unexplained leftover share (0233 / +migration 0233), leftover-map reconstruction (0201 / migration 0206), +leftover-map cross share (0185), leftover residual disclosure, +leftover observed `Y` / expected `E`, leftover-map rank, two-axis +leftover-map distance, leftover coverage, leftover-map axis share +persistence (0148), leftover interaction-map persistence, occupational +construct catalog search (0265), or the dashboard stacks. + +## Decision + +On the leftover-map graphic display, tick leftover-map axis 1 and +leftover-map axis 2 at the origin and at each unique finite persisted +`ξ` / `ζ` coordinate projected onto that axis. Tick labels use the +same signed leftover-map coordinate formatter as the pair-row badge. +The origin is always named `0` because it is the rank-0 unused-axis +location, not a leftover score. Duplicate coordinates share one tick. +A rank-0 origin cell names only `0`; the unit drawing window used to +keep that origin visible is drawing scale and must not sprout `−1` or +`+1` ticks. Do not invent evenly spaced ticks that no persisted +coordinate occupies. Click a post marker to open that post. Criterion +markers are not post buttons. The grouping comparison strip +(ADR 0149) stays on its reduced leftover payload and does not gain +these ticks. + +Do not add SQL. Do not edit shipped migrations. Do not persist inner +product, cosine, or length as separate columns. Do not invent a +leftover score. Do not invent a theta. + +## Consequences + +After `make seed`, closest and farthest leftover pairs sit above the +member list with the leftover-map graphic display of persisted `ξ` +and `ζ`, leftover-map axes name persisted Gabriel inertia share when +finite, and leftover-map axis ticks name the same coordinates shown +on the pair row; click a post marker or a pair button opens that +post. Hidden posts stay hidden. Rank-0 unused axes still plot at the +origin and still name zero leftover-map axis share with a `0` tick. +When coordinates, reconstruction, and distance are all finite, +`R̂ = ξ_{1:2} · ζ_{1:2}` and `d = ‖ξ_{1:2} − ζ_{1:2}‖` remain the +same identities already persisted by ADR 0267. + +## 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 persistence, leftover pairs on the grouping comparison +strip, two-axis leftover-map distance, leftover-map rank, leftover-map +inner product, leftover-map cosine, leftover-map length, leftover-map +reconstruction, leftover-map unexplained leftover, leftover-map cross +share, leftover-map unexplained leftover share, leftover-map explained +leftover share, leftover-map coordinate persistence, leftover-map +graphic display, and leftover-map axis share on the graphic display. + +## 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 +(LSIRM interaction `−γ‖ξ_j − ζ_i‖` after main effects +`α_j − β_i`; typically `p = 2` for the interaction map.) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 3b7a48b87..89f07d7cf 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,26 @@ # Product & Technical Gap Baseline +> Exact-head loop overlay: 2026-08-29 13:15 KST. Protected `main` is +> `fc13acaa20adca11968238e398d4aafcf62b6cee` (v2.23.0 leftover-map +> explained leftover share, #775). Open ready PRs still lack independent +> APPROVE. #782 leftover-map coordinates + graphic display + axis share +> (v2.24.0 / v2.25.0 / v2.26.0 / ADR 0267 / ADR 0268 / ADR 0269) is on +> `4a0afbf4804d9862bba58869db20ccdfb0a0b37e`; Strix fail-closed and no +> independent APPROVE. Auto-merge squash remains armed on +> #782/#780/#774/#772/#771/#770. Drafts remain dirty against `main`. +> #96 stays closed as a weaker duplicate of #91. GitHub writes through +> `gh`/MCP succeed (comment/create-branch/auto-merge). `git push` HTTPS +> still fails (empty `X-OAuth-Scopes`). Copilot review is not +> independent APPROVE. Do not self-approve. +> +> Next buyer increment on this cycle: leftover-map coordinate ticks +> (ADR 0270 / v2.27.0). Tick leftover-map axes at the origin and at each +> unique finite persisted `ξ` / `ζ` so pair-row `ξ (x, y) ζ (x, y)` +> matches the graphic. UI-only; no new columns. Rank-0 unused axes name +> only `0` and do not invent drawing-scale `−1` / `+1` ticks. Do not +> invent leftover scores. Do not mix into #782; stack onto leftover +> branch `feat/leftover-map-coordinates-v2240`. + > Exact-head loop overlay: 2026-08-28 19:15 KST. Protected `main` is > `fc13acaa20adca11968238e398d4aafcf62b6cee` (v2.23.0 leftover-map > explained leftover share, #775). Open ready PRs still lack independent diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 712509da2..d9f676ce5 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -5,8 +5,8 @@ operator-facing control you can click before changing product CSS. | Story | Operator next action | Token / module | |---|---|---| -| `Reports/LeftoverMapPlot` | Read the leftover-map graphic display of persisted `ξ` (posts) and `ζ` (criteria), then click a post marker to open that post. Leftover-map axes name persisted leftover-map axis share when finite. `ClosestAndFarthest`, `RankZeroOrigin`, `MissingCoordinates`, and `MissingAxisShare` cover two-pair maps, rank-0 origin with 0% share, omitted plots, and missing share that keeps existing leftover-map axis text. The plot does not invent a leftover score. | `LeftoverMapPlot`, `leftoverMapPlotLayout`, `leftoverMapPlotAxisShare`, `--color-primary`, `--color-palette-blue-mid` | -| `Reports/LeftoverPairList` | Read closest/farthest leftover pairs with named `R`, `Y`/`E`, rank, `U`, `s`, `e`, `x`, `R̂`, `ξ`/`ζ`, and `d`, then open that post. The leftover-map graphic display sits above the pair buttons when coordinates are finite, and leftover-map axes name persisted leftover-map axis share. | `LeftoverPairList`, `LeftoverMapPlot`, `ticket-list`, `post-badge` | +| `Reports/LeftoverMapPlot` | Read the leftover-map graphic display of persisted `ξ` (posts) and `ζ` (criteria), match axis ticks to those coordinates, then click a post marker to open that post. Leftover-map axes name persisted leftover-map axis share when finite. `ClosestAndFarthest`, `RankZeroOrigin`, `MissingCoordinates`, and `MissingAxisShare` cover two-pair maps, rank-0 origin with 0% share and a `0` tick, omitted plots, and missing share that keeps existing leftover-map axis text. The plot does not invent a leftover score. | `LeftoverMapPlot`, `leftoverMapPlotLayout`, `leftoverMapPlotAxisShare`, `--color-primary`, `--color-palette-blue-mid` | +| `Reports/LeftoverPairList` | Read closest/farthest leftover pairs with named `R`, `Y`/`E`, rank, `U`, `s`, `e`, `x`, `R̂`, `ξ`/`ζ`, and `d`, then open that post. The leftover-map graphic display sits above the pair buttons when coordinates are finite, leftover-map axes name persisted leftover-map axis share, and leftover-map axis ticks name persisted coordinates. | `LeftoverPairList`, `LeftoverMapPlot`, `ticket-list`, `post-badge` | | `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, or repeat-issue fact. `EvidenceReady`, `NarrowViewport`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover populated, mobile, unavailable-evidence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard` | | `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` | | `Post/Recorded perspectives` | Read the imported primary and every evidence-connected additional Voice with its recorded truth state instead of flattening them into one compound category. `CombinedEvidence`, `RejectedEvidence`, and `NarrowViewport` cover desktop, rejected-evidence, and narrow layouts. | `VoicePerspectiveList`, `ticket-list`, `post-meta` | diff --git a/frontend/package.json b/frontend/package.json index 8c35c576e..0de7d33a2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.26.0", + "version": "2.27.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/LeftoverMapPlot.css b/frontend/src/components/LeftoverMapPlot.css index d3dff89d6..21b463496 100644 --- a/frontend/src/components/LeftoverMapPlot.css +++ b/frontend/src/components/LeftoverMapPlot.css @@ -69,6 +69,16 @@ stroke-width: 1; } +.leftover-map-plot-tick line { + stroke: var(--color-text); + stroke-width: 1; +} + +.leftover-map-plot-tick-label { + fill: var(--color-text); + font-size: 0.65rem; +} + .leftover-map-plot-segment { fill: none; stroke: var(--color-palette-gray-400); diff --git a/frontend/src/components/LeftoverMapPlot.test.tsx b/frontend/src/components/LeftoverMapPlot.test.tsx index df6b65576..661cccbff 100644 --- a/frontend/src/components/LeftoverMapPlot.test.tsx +++ b/frontend/src/components/LeftoverMapPlot.test.tsx @@ -45,13 +45,16 @@ describe("LeftoverMapPlot", () => { expect(screen.getByLabelText("Leftover-map graphic display")).toBeInTheDocument(); expect( screen.getByText( - "Leftover map after IRT main effects. Click a post marker to open that post. The plot does not invent a leftover score.", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Click a post marker to open that post. The plot does not invent a leftover score.", ), ).toBeInTheDocument(); expect(screen.getByText("Post ξ")).toBeInTheDocument(); expect(screen.getByText("Criterion ζ")).toBeInTheDocument(); expect(screen.getByText("leftover-map axis 1")).toBeInTheDocument(); expect(screen.getByText("leftover-map axis 2")).toBeInTheDocument(); + expect(screen.getByLabelText("leftover-map axis 1 tick +0.50")).toBeInTheDocument(); + expect(screen.getByLabelText("leftover-map axis 2 tick −0.02")).toBeInTheDocument(); + expect(screen.getByLabelText("leftover-map axis 1 tick 0.00")).toBeInTheDocument(); const postMarker = screen.getByRole("button", { name: "Open leftover-map post Public post at ξ (+0.50, +0.10)", @@ -117,6 +120,8 @@ describe("LeftoverMapPlot", () => { ).toBeInTheDocument(); expect(screen.getByText("leftover-map axis 1 (0%)")).toBeInTheDocument(); expect(screen.getByText("leftover-map axis 2 (0%)")).toBeInTheDocument(); + expect(screen.getAllByLabelText("leftover-map axis 1 tick 0.00").length).toBeGreaterThan(0); + expect(screen.queryByLabelText("leftover-map axis 1 tick +1.00")).not.toBeInTheDocument(); }); it("captions leftover-map axes with persisted leftover-map axis share", () => { diff --git a/frontend/src/components/LeftoverMapPlot.tsx b/frontend/src/components/LeftoverMapPlot.tsx index 46743866a..e747c9fe2 100644 --- a/frontend/src/components/LeftoverMapPlot.tsx +++ b/frontend/src/components/LeftoverMapPlot.tsx @@ -11,6 +11,7 @@ import { layoutLeftoverMapPlot, LEFTOVER_MAP_PLOT_CAPTION, LEFTOVER_MAP_PLOT_POST_ACTION, + LEFTOVER_MAP_PLOT_TICK, } from "../leftoverMapPlotLayout"; import "./LeftoverMapPlot.css"; @@ -44,9 +45,11 @@ function leftoverMapPlotAxisText( * Person markers are posts; item markers are leftover criteria. Click a * post marker to open that post. Caption leftover-map axes with persisted * Gabriel inertia share when finite, including rank-0 zero-share axes. - * Omit that axis badge when share is missing or non-finite and keep the - * existing leftover-map axis text. Omit the plot when no pair has four - * finite leftover-map coordinates. Never invent a leftover score. + * Axis ticks name persisted leftover-map coordinates so ξ / ζ on the + * pair row match the plot. Omit that axis badge when share is missing or + * non-finite and keep the existing leftover-map axis text. Omit the plot + * when no pair has four finite leftover-map coordinates. Never invent a + * leftover score. */ export function LeftoverMapPlot({ pairs, @@ -106,6 +109,23 @@ export function LeftoverMapPlot({ {leftoverMapPlotAxisText(2, leftoverMapAxes)} + {layout.ticks.map((tick) => ( + + + + {tick.label} + + + ))} {layout.segments.map((segment) => ( { "leftover-map axis 1", "leftover-map axis 2", "leftover-map axis {axis} ({share}%)", - "Leftover map after IRT main effects. Click a post marker to open that post. The plot does not invent a leftover score.", + "leftover-map axis {axis} tick {value}", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Click a post marker to open that post. The plot does not invent a leftover score.", "Open leftover-map post {title} at ξ {person}", "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.", "Leftover map has no leftover structure after IRT main effects. Open this post.", @@ -329,26 +330,36 @@ describe("i18n", () => { it.each([ [ "ko", - "IRT 주효과 이후 잔여 지도입니다. 글 표식을 눌러 그 글을 여세요. 이 그림은 잔여 점수를 만들어내지 않습니다.", + "IRT 주효과 이후 잔여 지도입니다. 축 눈금은 저장된 잔여 지도 좌표입니다. 글 표식을 눌러 그 글을 여세요. 이 그림은 잔여 점수를 만들어내지 않습니다.", ], - ["zh", "IRT 主效应后的残差图。点击帖子标记打开该帖子。此图不会虚构残差分数。"], + ["zh", "IRT 主效应后的残差图。轴刻度标出已保存的残差图坐标。点击帖子标记打开该帖子。此图不会虚构残差分数。"], [ "ja", - "IRT主効果後の残差マップです。投稿マーカーをクリックしてその投稿を開いてください。この図は残差スコアを作りません。", + "IRT主効果後の残差マップです。軸目盛は保存済みの残差マップ座標です。投稿マーカーをクリックしてその投稿を開いてください。この図は残差スコアを作りません。", ], [ "vi", - "Bản đồ phần dư sau hiệu ứng chính IRT. Nhấn dấu bài viết để mở bài đó. Hình này không tạo ra điểm phần dư.", + "Bản đồ phần dư sau hiệu ứng chính IRT. Vạch trục ghi tọa độ bản đồ phần dư đã lưu. Nhấn dấu bài viết để mở bài đó. Hình này không tạo ra điểm phần dư.", ], ] as const)("formats leftover-map graphic display caption in %s", (locale, expected) => { setLocale(locale); expect( t( - "Leftover map after IRT main effects. Click a post marker to open that post. The plot does not invent a leftover score.", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Click a post marker to open that post. The plot does not invent a leftover score.", ), ).toBe(expected); }); + it.each([ + ["ko", "잔여 지도 축 1 눈금 +0.50"], + ["zh", "残差图轴 1 刻度 +0.50"], + ["ja", "残差マップ軸 1 目盛 +0.50"], + ["vi", "vạch trục bản đồ phần dư 1 +0.50"], + ] as const)("formats leftover-map coordinate ticks in %s", (locale, expected) => { + setLocale(locale); + expect(tf("leftover-map axis {axis} tick {value}", { axis: 1, value: "+0.50" })).toBe(expected); + }); + it.each([ ["ko", "잔여 지도 축 1 (82%)"], ["zh", "残差图轴 1 (82%)"], diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 32d194ce3..4490ac10c 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -590,8 +590,9 @@ const TRANSLATIONS: Partial>> = { "leftover-map axis 1": "잔여 지도 축 1", "leftover-map axis 2": "잔여 지도 축 2", "leftover-map axis {axis} ({share}%)": "잔여 지도 축 {axis} ({share}%)", - "Leftover map after IRT main effects. Click a post marker to open that post. The plot does not invent a leftover score.": - "IRT 주효과 이후 잔여 지도입니다. 글 표식을 눌러 그 글을 여세요. 이 그림은 잔여 점수를 만들어내지 않습니다.", + "leftover-map axis {axis} tick {value}": "잔여 지도 축 {axis} 눈금 {value}", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Click a post marker to open that post. The plot does not invent a leftover score.": + "IRT 주효과 이후 잔여 지도입니다. 축 눈금은 저장된 잔여 지도 좌표입니다. 글 표식을 눌러 그 글을 여세요. 이 그림은 잔여 점수를 만들어내지 않습니다.", "Open leftover-map post {title} at ξ {person}": "잔여 지도 글 {title} 열기 (ξ {person})", "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.": @@ -1168,8 +1169,9 @@ const TRANSLATIONS: Partial>> = { "leftover-map axis 1": "残差图轴 1", "leftover-map axis 2": "残差图轴 2", "leftover-map axis {axis} ({share}%)": "残差图轴 {axis} ({share}%)", - "Leftover map after IRT main effects. Click a post marker to open that post. The plot does not invent a leftover score.": - "IRT 主效应后的残差图。点击帖子标记打开该帖子。此图不会虚构残差分数。", + "leftover-map axis {axis} tick {value}": "残差图轴 {axis} 刻度 {value}", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Click a post marker to open that post. The plot does not invent a leftover score.": + "IRT 主效应后的残差图。轴刻度标出已保存的残差图坐标。点击帖子标记打开该帖子。此图不会虚构残差分数。", "Open leftover-map post {title} at ξ {person}": "打开残差图帖子 {title}(ξ {person})", "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.": @@ -1750,8 +1752,9 @@ const TRANSLATIONS: Partial>> = { "leftover-map axis 1": "残差マップ軸 1", "leftover-map axis 2": "残差マップ軸 2", "leftover-map axis {axis} ({share}%)": "残差マップ軸 {axis} ({share}%)", - "Leftover map after IRT main effects. Click a post marker to open that post. The plot does not invent a leftover score.": - "IRT主効果後の残差マップです。投稿マーカーをクリックしてその投稿を開いてください。この図は残差スコアを作りません。", + "leftover-map axis {axis} tick {value}": "残差マップ軸 {axis} 目盛 {value}", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Click a post marker to open that post. The plot does not invent a leftover score.": + "IRT主効果後の残差マップです。軸目盛は保存済みの残差マップ座標です。投稿マーカーをクリックしてその投稿を開いてください。この図は残差スコアを作りません。", "Open leftover-map post {title} at ξ {person}": "残差マップの投稿 {title} を開く(ξ {person})", "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.": @@ -2332,8 +2335,9 @@ const TRANSLATIONS: Partial>> = { "leftover-map axis 1": "trục bản đồ phần dư 1", "leftover-map axis 2": "trục bản đồ phần dư 2", "leftover-map axis {axis} ({share}%)": "trục bản đồ phần dư {axis} ({share}%)", - "Leftover map after IRT main effects. Click a post marker to open that post. The plot does not invent a leftover score.": - "Bản đồ phần dư sau hiệu ứng chính IRT. Nhấn dấu bài viết để mở bài đó. Hình này không tạo ra điểm phần dư.", + "leftover-map axis {axis} tick {value}": "vạch trục bản đồ phần dư {axis} {value}", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Click a post marker to open that post. The plot does not invent a leftover score.": + "Bản đồ phần dư sau hiệu ứng chính IRT. Vạch trục ghi tọa độ bản đồ phần dư đã lưu. Nhấn dấu bài viết để mở bài đó. Hình này không tạo ra điểm phần dư.", "Open leftover-map post {title} at ξ {person}": "Mở bài viết bản đồ phần dư {title} tại ξ {person}", "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.": diff --git a/frontend/src/leftoverMapPlotLayout.test.ts b/frontend/src/leftoverMapPlotLayout.test.ts index f3169dc44..bb5ebcf67 100644 --- a/frontend/src/leftoverMapPlotLayout.test.ts +++ b/frontend/src/leftoverMapPlotLayout.test.ts @@ -160,6 +160,51 @@ describe("layoutLeftoverMapPlot", () => { expect(layout?.persons.map((marker) => marker.id)).toEqual(["post-demo-public"]); expect(layout?.segments).toHaveLength(1); }); + + it("names persisted leftover-map coordinates as axis ticks without inventing a leftover score", () => { + const layout = layoutLeftoverMapPlot( + [ + pair(), + pair({ + pair_kind: "farthest", + post_id: "post-demo-spec", + criterion_code: "negative_sentiment", + leftover_map_person_axis_1: 0.9, + leftover_map_person_axis_2: 0.8, + leftover_map_item_axis_1: -0.7, + leftover_map_item_axis_2: -0.4, + }), + ], + criterionLabel, + ); + const axis1 = layout?.ticks.filter((tick) => tick.axis === 1).map((tick) => tick.label); + const axis2 = layout?.ticks.filter((tick) => tick.axis === 2).map((tick) => tick.label); + expect(axis1).toEqual(expect.arrayContaining(["0.00", "+0.50", "+0.90", "\u22120.70"])); + expect(axis2).toEqual(expect.arrayContaining(["0.00", "+0.10", "\u22120.02", "+0.80", "\u22120.40"])); + expect(axis1).toHaveLength(4); + expect(axis2).toHaveLength(5); + const originAxis1 = layout?.ticks.find((tick) => tick.axis === 1 && tick.value === 0); + expect(originAxis1).toMatchObject({ x: layout?.originX, y: layout?.originY, label: "0.00" }); + }); + + it("does not invent drawing-scale leftover-map ticks on a rank-0 origin cell", () => { + const layout = layoutLeftoverMapPlot( + [ + pair({ + leftover_map_person_axis_1: 0, + leftover_map_person_axis_2: 0, + leftover_map_item_axis_1: 0, + leftover_map_item_axis_2: 0, + }), + ], + criterionLabel, + ); + expect(layout?.ticks.map((tick) => tick.label)).toEqual(["0.00", "0.00"]); + expect(layout?.ticks.every((tick) => tick.value === 0)).toBe(true); + expect(layout?.ticks.some((tick) => tick.label === "+1.00" || tick.label === "\u22121.00")).toBe( + false, + ); + }); }); describe("firstPlottablePairForPost", () => { diff --git a/frontend/src/leftoverMapPlotLayout.ts b/frontend/src/leftoverMapPlotLayout.ts index 80095b51e..7af7a30a8 100644 --- a/frontend/src/leftoverMapPlotLayout.ts +++ b/frontend/src/leftoverMapPlotLayout.ts @@ -1,19 +1,25 @@ /** Gabriel leftover-map graphic display of persisted ``ξ_{1:2}`` / ``ζ_{1:2}``. * Leftover-map axis share captions axes 1 and 2 when finite (ADR 0269). + * Axis ticks name persisted leftover-map coordinates (ADR 0270). */ import { formatLeftoverMapCoordinatePair } from "./leftoverMapCoordinates"; +import { formatSignedLeftoverValue } from "./leftoverMapUnexplained"; import type { LeftoverPair } from "./api"; export const LEFTOVER_MAP_PLOT_CAPTION = - "Leftover map after IRT main effects. Click a post marker to open that post. The plot does not invent a leftover score."; + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Click a post marker to open that post. The plot does not invent a leftover score."; export const LEFTOVER_MAP_PLOT_POST_ACTION = "Open leftover-map post {title} at ξ {person}"; +export const LEFTOVER_MAP_PLOT_TICK = + "leftover-map axis {axis} tick {value}"; + export const PLOT_WIDTH = 480; export const PLOT_HEIGHT = 320; export const PLOT_PADDING = 40; +export const PLOT_TICK_LENGTH = 6; const UNIT_DISPLAY_SPAN = 2; const COLLAPSED_SPAN = 1e-12; @@ -48,6 +54,16 @@ export type LeftoverMapPlotSegment = { y2: number; }; +export type LeftoverMapPlotTick = { + axis: 1 | 2; + value: number; + label: string; + x: number; + y: number; + tickX2: number; + tickY2: number; +}; + export type LeftoverMapPlotLayout = { width: number; height: number; @@ -56,6 +72,7 @@ export type LeftoverMapPlotLayout = { persons: LeftoverMapPlotPoint[]; items: LeftoverMapPlotPoint[]; segments: LeftoverMapPlotSegment[]; + ticks: LeftoverMapPlotTick[]; }; export function hasLeftoverMapPlotCoordinates( @@ -95,6 +112,57 @@ function toSvg( }; } +function uniqueCoordinateTicks(values: number[]): { value: number; label: string }[] { + const byLabel = new Map(); + for (const value of values) { + const label = formatSignedLeftoverValue(value); + if (label === null) { + continue; + } + if (!byLabel.has(label)) { + byLabel.set(label, value); + } + } + return [...byLabel.entries()].map(([label, value]) => ({ value, label })); +} + +function leftoverMapCoordinateTicks( + axis1Values: number[], + axis2Values: number[], + minAxis: number, + scaleSpan: number, + width: number, + height: number, + pad: number, +): LeftoverMapPlotTick[] { + const ticks: LeftoverMapPlotTick[] = []; + for (const tick of uniqueCoordinateTicks(axis1Values)) { + const atAxis = toSvg(tick.value, 0, minAxis, scaleSpan, width, height, pad); + ticks.push({ + axis: 1, + value: tick.value, + label: tick.label, + x: atAxis.x, + y: atAxis.y, + tickX2: atAxis.x, + tickY2: atAxis.y + PLOT_TICK_LENGTH, + }); + } + for (const tick of uniqueCoordinateTicks(axis2Values)) { + const atAxis = toSvg(0, tick.value, minAxis, scaleSpan, width, height, pad); + ticks.push({ + axis: 2, + value: tick.value, + label: tick.label, + x: atAxis.x, + y: atAxis.y, + tickX2: atAxis.x - PLOT_TICK_LENGTH, + tickY2: atAxis.y, + }); + } + return ticks; +} + export function layoutLeftoverMapPlot( pairs: LeftoverMapPlottablePair[], criterionLabel: (criterionCode: string) => string, @@ -126,6 +194,8 @@ export function layoutLeftoverMapPlot( const persons = new Map(); const items = new Map(); const segments: LeftoverMapPlotSegment[] = []; + const axis1Values = [0]; + const axis2Values = [0]; for (const pair of plottable) { const personPos = toSvg( @@ -166,6 +236,14 @@ export function layoutLeftoverMapPlot( ...itemPos, }); } + axis1Values.push( + pair.leftover_map_person_axis_1 as number, + pair.leftover_map_item_axis_1 as number, + ); + axis2Values.push( + pair.leftover_map_person_axis_2 as number, + pair.leftover_map_item_axis_2 as number, + ); segments.push({ pairKind: pair.pair_kind === "farthest" ? "farthest" : "closest", postId: pair.post_id, @@ -186,6 +264,15 @@ export function layoutLeftoverMapPlot( persons: [...persons.values()], items: [...items.values()], segments, + ticks: leftoverMapCoordinateTicks( + axis1Values, + axis2Values, + minAxis, + scaleSpan, + width, + height, + PLOT_PADDING, + ), }; } diff --git a/pyproject.toml b/pyproject.toml index 0e37b92aa..747e1dca4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.26.0" +version = "2.27.0" 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/uv.lock b/uv.lock index 73a4f913c..7f53a7895 100644 --- a/uv.lock +++ b/uv.lock @@ -685,7 +685,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.26.0" +version = "2.27.0" source = { editable = "." } dependencies = [ { name = "certifi" }, From 9bdd3e4d6e47a34ac22fca228b8d2a23d8cecca9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 00:14:03 -0700 Subject: [PATCH 7/7] feat(reports): leftover-map distance on pair segments (v2.28.0) (#800) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caption leftover-map graphic-display pair segments with persisted leftover-map distance d (ADR 0271). After make seed, closest and farthest leftover pairs sit above the member list with the Gabriel biplot of already-named coordinates, leftover-map axis share when finite, axis ticks that match ξ (x, y) ζ (x, y) on the pair row, and pair segments that match d on the pair row; click a post marker or a pair button opens that post. A missing or non-finite d omits that segment caption. Do not invent d from plotted coordinates. Never invent a leftover score or a theta. No new columns. --- AGENTS.md | 7 +- ARCHITECTURE.md | 5 +- .../2.28.0-leftover-map-segment-distance.md | 12 ++ CHANGELOG.md | 11 ++ CLAUDE.md | 2 +- docs/adr/0048-persist-lsirm-leftover-pairs.md | 7 +- docs/adr/0049-leftover-pair-report-ui.md | 8 +- docs/adr/0268-leftover-map-graphic-display.md | 7 +- .../adr/0270-leftover-map-coordinate-ticks.md | 5 +- .../adr/0271-leftover-map-segment-distance.md | 108 ++++++++++++++++++ docs/product-technical-gap-baseline.md | 23 ++++ docs/storybook-inventory.md | 4 +- frontend/package.json | 2 +- frontend/src/components/LeftoverMapPlot.css | 5 + .../src/components/LeftoverMapPlot.test.tsx | 30 ++++- frontend/src/components/LeftoverMapPlot.tsx | 41 +++++-- frontend/src/components/LeftoverPairList.tsx | 3 +- frontend/src/i18n.test.ts | 23 +++- frontend/src/i18n.ts | 20 ++-- frontend/src/leftoverMapPlotLayout.test.ts | 69 +++++++++++ frontend/src/leftoverMapPlotLayout.ts | 34 +++++- pyproject.toml | 2 +- uv.lock | 2 +- 23 files changed, 384 insertions(+), 46 deletions(-) create mode 100644 CHANGELOG.d/2.28.0-leftover-map-segment-distance.md create mode 100644 docs/adr/0271-leftover-map-segment-distance.md diff --git a/AGENTS.md b/AGENTS.md index d9e17152c..3af4169bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -273,7 +273,7 @@ stops startup instead of leaving a healthy-looking partial schema, and application code must not compensate for a missing table. Period leftover pairs (ADR 0017 / 0018 / 0048 / 0049 / 0119 / 0158 / 0162 / -0163 / 0164 / 0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269 / 0270) are computed in `lineageweave/leftover_pairs.py` from the +0163 / 0164 / 0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269 / 0270 / 0271) are computed in `lineageweave/leftover_pairs.py` from the residual after a real GRM/GPCM score, never invented. Distances are Euclidean on the two-dimensional Gabriel leftover map; missing cells stay out of the factorization. Closest and farthest post–criterion pairs @@ -298,7 +298,10 @@ that axis badge and keeps the existing leftover-map axis text. ADR 0270 ticks leftover-map axes at the origin and at each unique finite persisted `ξ` / `ζ` coordinate so the pair-row badge matches the plot; rank-0 unused axes name only `0` and do not invent drawing-scale -ticks. When `R`, `R̂`, `U`, `x`, +ticks. ADR 0271 captions leftover-map pair segments with persisted +leftover-map distance `d` so the pair-row badge matches the graphic; +a missing or non-finite `d` omits that segment caption and does not +invent `d` from plotted coordinates. When `R`, `R̂`, `U`, `x`, `s`, and `e` are finite, `e + s + x = 1`. When coordinates, reconstruction, and distance are finite, `R̂ = ξ · ζ` and `d = ‖ξ − ζ‖`. The pairs sit above the member diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0da6e0d22..05f98e69e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -637,7 +637,7 @@ information at the group's mean θ (Lord, 1980 max-info CAT). Rankings persist to `report_item_information`. After those IRT main effects, residual SVD leftover pairs on two Gabriel axes (Jeon et al., 2021; ADR 0017 / 0048 / 0049 / 0119 / 0148 / 0158 / 0162 / 0163 / 0164 / 0168 / -0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269 / 0270) persist to `report_leftover_pair` with signed residual `R`, +0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269 / 0270 / 0271) persist to `report_leftover_pair` with signed residual `R`, observed `Y`, expected `E[Y|θ, item]`, full leftover-map rank, unexplained leftover, ADR 0201 reconstruction evidence, ADR 0185 cross-share evidence, ADR 0233 unexplained leftover share `s`, ADR 0266 explained leftover @@ -646,7 +646,8 @@ ADR 0268 draws those persisted coordinates as the leftover-map graphic display above the pair buttons. ADR 0269 captions leftover-map axes 1 and 2 with persisted leftover-map axis share. ADR 0270 ticks those leftover-map axes at persisted `ξ` / `ζ` coordinates so the pair-row -badge matches the plot. Click a post marker or a pair button +badge matches the plot. ADR 0271 captions leftover-map pair segments +with persisted leftover-map distance `d`. Click a post marker or a pair button opens that post. Those ADRs are the normative mathematical, storage, and display contracts. Leftover-map axis share (Gabriel inertia of residual SVD axes 1 and 2; ADR 0148) persists to diff --git a/CHANGELOG.d/2.28.0-leftover-map-segment-distance.md b/CHANGELOG.d/2.28.0-leftover-map-segment-distance.md new file mode 100644 index 000000000..98d78b251 --- /dev/null +++ b/CHANGELOG.d/2.28.0-leftover-map-segment-distance.md @@ -0,0 +1,12 @@ +## 2.28.0 — Leftover-map distance on graphic-display pair segments + +- Caption leftover-map graphic-display pair segments with persisted + leftover-map distance `d` (ADR 0271). After `make seed`, closest and + farthest leftover pairs sit above the member list with the Gabriel + biplot of already-named coordinates, leftover-map axis share when + finite, axis ticks that match `ξ (x, y) ζ (x, y)` on the pair row, + and pair segments that match `d` on the pair row; click a post + marker or a pair button opens that post. A missing or non-finite + `d` omits that segment caption. Do not invent `d` from plotted + coordinates. Never invent a leftover score. Never invent a theta. + No new columns. diff --git a/CHANGELOG.md b/CHANGELOG.md index d9f305408..7a2724eb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ All notable changes to this project are documented here. Format follows ### Added +- Period leftover pairs now caption leftover-map graphic-display pair + segments with persisted leftover-map distance `d` (ADR 0271 / + v2.28.0). After `make seed`, closest and farthest leftover pairs sit + above the member list with the Gabriel biplot of already-named + coordinates, leftover-map axis share when finite, axis ticks that + match `ξ (x, y) ζ (x, y)` on the pair row, and pair segments that + match `d` on the pair row; click a post marker or a pair button + opens that post. A missing or non-finite `d` omits that segment + caption. Do not invent `d` from plotted coordinates. Never invent a + leftover score or a theta. No new columns. + - Period leftover pairs now caption leftover-map graphic-display axes with persisted leftover-map coordinate ticks (ADR 0270 / v2.27.0). After `make seed`, closest and farthest leftover pairs sit above the diff --git a/CLAUDE.md b/CLAUDE.md index daaf3a583..1a36a87b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ cutoff. Global Ask optional `knowledge_cutoff` uses the same cover Create/start endpoint rules (ADR 0017 / 0021), tie-vs-miss similarity (ADR 0026), R&R catalog ids (ADR 0019 / 0027), leftover pairs -(ADR 0048–0164 / 0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269 / 0270), occupational construct catalog search +(ADR 0048–0164 / 0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269 / 0270 / 0271), occupational construct catalog search (ADR 0257), the text-channel embedding swap and cosine clamp (ADR 0190), per-edge channel-score persistence (ADR 0195), token-backed status notices (ADR 0220), diff --git a/docs/adr/0048-persist-lsirm-leftover-pairs.md b/docs/adr/0048-persist-lsirm-leftover-pairs.md index 75ed90cc0..f3a8c636b 100644 --- a/docs/adr/0048-persist-lsirm-leftover-pairs.md +++ b/docs/adr/0048-persist-lsirm-leftover-pairs.md @@ -13,7 +13,8 @@ [ADR 0267](0267-leftover-map-coordinates.md) (leftover-map coordinates ξ, ζ); [ADR 0268](0268-leftover-map-graphic-display.md) (leftover-map graphic display); [ADR 0269](0269-leftover-map-axis-share-plot.md) (leftover-map axis share on the graphic display); -[ADR 0270](0270-leftover-map-coordinate-ticks.md) (leftover-map coordinate ticks) +[ADR 0270](0270-leftover-map-coordinate-ticks.md) (leftover-map coordinate ticks); +[ADR 0271](0271-leftover-map-segment-distance.md) (leftover-map distance on pair segments) ## Context @@ -71,7 +72,9 @@ from the pair row. ADR 0268 draws those persisted coordinates as the leftover-map graphic display; it adds no columns. ADR 0269 captions those leftover-map axes with persisted leftover-map axis share; it adds no columns. ADR 0270 ticks leftover-map axes at persisted `ξ` / -`ζ` coordinates; it adds no columns. +`ζ` coordinates; it adds no columns. ADR 0271 captions leftover-map +pair segments with persisted leftover-map distance `d`; it adds no +columns. 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 8d8627529..6ff778400 100644 --- a/docs/adr/0049-leftover-pair-report-ui.md +++ b/docs/adr/0049-leftover-pair-report-ui.md @@ -14,7 +14,8 @@ [ADR 0267](0267-leftover-map-coordinates.md) (leftover-map coordinates ξ, ζ); [ADR 0268](0268-leftover-map-graphic-display.md) (leftover-map graphic display); [ADR 0269](0269-leftover-map-axis-share-plot.md) (leftover-map axis share on the graphic display); -[ADR 0270](0270-leftover-map-coordinate-ticks.md) (leftover-map coordinate ticks) +[ADR 0270](0270-leftover-map-coordinate-ticks.md) (leftover-map coordinate ticks); +[ADR 0271](0271-leftover-map-segment-distance.md) (leftover-map distance on pair segments) ## Context @@ -41,7 +42,8 @@ coordinates exist, the leftover-map graphic display of those positions sits above the pair buttons (ADR 0268); click a post marker to open that post. Leftover-map axis share captions those leftover-map axes when finite (ADR 0269). Leftover-map axis ticks name persisted `ξ` / -`ζ` coordinates (ADR 0270). The pair renders every available finite measurement. +`ζ` coordinates (ADR 0270). Pair segments name persisted leftover-map +distance `d` (ADR 0271). The pair renders every available finite measurement. The next action uses the first available value in the priority below; no amendment hides another badge, rank 0 explicitly names no leftover structure, and unexplained leftover names @@ -72,6 +74,8 @@ Leftover-map axis share on the graphic display is [ADR 0269](0269-leftover-map-axis-share-plot.md). Leftover-map coordinate ticks are [ADR 0270](0270-leftover-map-coordinate-ticks.md). +Leftover-map distance on pair segments is +[ADR 0271](0271-leftover-map-segment-distance.md). After `make seed`, closest and farthest leftover pairs sit above the member list. Click a pair to open that post with the leftover diff --git a/docs/adr/0268-leftover-map-graphic-display.md b/docs/adr/0268-leftover-map-graphic-display.md index 3cac93fb6..0b58bb964 100644 --- a/docs/adr/0268-leftover-map-graphic-display.md +++ b/docs/adr/0268-leftover-map-graphic-display.md @@ -6,7 +6,9 @@ **Amended by:** [ADR 0269](0269-leftover-map-axis-share-plot.md) (leftover-map axis share on the graphic display); [ADR 0270](0270-leftover-map-coordinate-ticks.md) -(leftover-map coordinate ticks) +(leftover-map coordinate ticks); +[ADR 0271](0271-leftover-map-segment-distance.md) +(leftover-map distance on pair segments) Amends [ADR 0049](0049-leftover-pair-report-ui.md) and [ADR 0267](0267-leftover-map-coordinates.md). Independent of leftover-map @@ -80,7 +82,8 @@ member list with the leftover-map graphic display of persisted `ξ` and `ζ`; click a post marker or a pair button opens that post. Hidden posts stay hidden. Leftover-map axis share captions those leftover-map axes when finite (ADR 0269). Leftover-map axis ticks name -persisted `ξ` / `ζ` coordinates (ADR 0270). When coordinates, reconstruction, and +persisted `ξ` / `ζ` coordinates (ADR 0270). Pair segments name +persisted leftover-map distance `d` (ADR 0271). When coordinates, reconstruction, and distance are all finite, `R̂ = ξ_{1:2} · ζ_{1:2}` and `d = ‖ξ_{1:2} − ζ_{1:2}‖` remain the same identities already persisted by ADR 0267. diff --git a/docs/adr/0270-leftover-map-coordinate-ticks.md b/docs/adr/0270-leftover-map-coordinate-ticks.md index e40bba33b..55559e891 100644 --- a/docs/adr/0270-leftover-map-coordinate-ticks.md +++ b/docs/adr/0270-leftover-map-coordinate-ticks.md @@ -12,6 +12,8 @@ leftover-map unexplained leftover share ([ADR 0233](0233-leftover-map-unexplained-share.md)), leftover-map reconstruction ([ADR 0201](0201-leftover-map-reconstruction.md)), and leftover-map axis share persistence ([ADR 0148](0148-leftover-map-axis-share.md)). +Independent of leftover-map distance on pair segments +([ADR 0271](0271-leftover-map-segment-distance.md)). ## Context @@ -90,7 +92,8 @@ inner product, leftover-map cosine, leftover-map length, leftover-map reconstruction, leftover-map unexplained leftover, leftover-map cross share, leftover-map unexplained leftover share, leftover-map explained leftover share, leftover-map coordinate persistence, leftover-map -graphic display, and leftover-map axis share on the graphic display. +graphic display, leftover-map axis share on the graphic display, and +leftover-map distance on pair segments. ## References diff --git a/docs/adr/0271-leftover-map-segment-distance.md b/docs/adr/0271-leftover-map-segment-distance.md new file mode 100644 index 000000000..cf458aa5a --- /dev/null +++ b/docs/adr/0271-leftover-map-segment-distance.md @@ -0,0 +1,108 @@ +# ADR 0271 — Name leftover-map distance on graphic-display pair segments + +**Decision status:** Accepted +**Date:** 2026-08-29 + +Amends [ADR 0268](0268-leftover-map-graphic-display.md) and +[ADR 0049](0049-leftover-pair-report-ui.md). Independent of leftover-map +coordinate ticks ([ADR 0270](0270-leftover-map-coordinate-ticks.md)), +leftover-map axis share on the graphic display +([ADR 0269](0269-leftover-map-axis-share-plot.md)), leftover-map +explained leftover share ([ADR 0266](0266-leftover-map-explained-share.md)), +leftover-map unexplained leftover share +([ADR 0233](0233-leftover-map-unexplained-share.md)), leftover-map +reconstruction ([ADR 0201](0201-leftover-map-reconstruction.md)), and +leftover-map axis share persistence ([ADR 0148](0148-leftover-map-axis-share.md)). + +## Context + +ADR 0267 already persists leftover-map distance +`d = ‖ξ_{1:2} − ζ_{1:2}‖` on leftover pair rows next to coordinates +`ξ_{1:2}` / `ζ_{1:2}`. ADR 0268 already draws the connecting segment +between those positions. ADR 0270 already ticks leftover-map axes at +the persisted coordinates. The segment still has no leftover-map +distance label, so a buyer who reads `d 0.12` on the pair row cannot +match that length to the graphic line. Hiding `d` on the segment lets +leftover residual `R`, reconstruction `R̂`, or leftover-map location +be read as leftover-map distance even after the coordinates and ticks +are named. + +This increment names leftover-map graphic-display pair segments with +persisted leftover-map distance. It does not add columns. It does not +recompute `d` from plotted pixel length or from coordinates. It does +not persist leftover-map inner product, cosine, or length. It does not +land Post quality on the leftover criterion. Leftover-map distance +stays two-axis Euclidean. Do not invent a leftover score. Do not +invent a theta. + +The dashboard stack already used neighbouring leftover facts under +other numbers. This protected increment uses **0271** so it does not +collide with leftover-map coordinate ticks (0270), leftover-map axis +share on the graphic display (0269), leftover-map graphic display +(0268), leftover-map coordinates (0267 / migration 0245), leftover-map +explained leftover share (0266 / migration 0244), leftover-map +unexplained leftover share (0233 / migration 0233), leftover-map +reconstruction (0201 / migration 0206), leftover-map cross share +(0185), leftover residual disclosure, leftover observed `Y` / +expected `E`, leftover-map rank, two-axis leftover-map distance +persistence, leftover coverage, leftover-map axis share persistence +(0148), leftover interaction-map persistence, occupational construct +catalog search (0265), or the dashboard stacks. + +## Decision + +On the leftover-map graphic display, caption each closest or farthest +pair segment with the same persisted leftover-map distance formatter +as the pair-row `d` badge. A missing or non-finite `d` omits that +segment caption and keeps the connecting line. Rank-0 origin cells +still name `d 0.00` when that persisted distance is finite. Do not +invent `d` from plotted coordinates or pixel length. Click a post +marker to open that post. Criterion markers are not post buttons. The +grouping comparison strip (ADR 0149) stays on its reduced leftover +payload and does not gain these segment captions. + +Do not add SQL. Do not edit shipped migrations. Do not persist inner +product, cosine, or length as separate columns. Do not invent a +leftover score. Do not invent a theta. + +## Consequences + +After `make seed`, closest and farthest leftover pairs sit above the +member list with the leftover-map graphic display of persisted `ξ` +and `ζ`, leftover-map axes name persisted Gabriel inertia share when +finite, leftover-map axis ticks name the same coordinates shown on +the pair row, and pair segments name persisted leftover-map distance +`d`; click a post marker or a pair button opens that post. Hidden +posts stay hidden. Rank-0 unused axes still plot at the origin, still +name zero leftover-map axis share with a `0` tick, and still name +`d 0.00` when that distance is persisted. When coordinates, +reconstruction, and distance are all finite, `R̂ = ξ_{1:2} · ζ_{1:2}` +and `d = ‖ξ_{1:2} − ζ_{1:2}‖` remain the same identities already +persisted by ADR 0267. + +## 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 persistence, leftover pairs on the grouping comparison +strip, two-axis leftover-map distance persistence, leftover-map rank, +leftover-map inner product, leftover-map cosine, leftover-map length, +leftover-map reconstruction, leftover-map unexplained leftover, +leftover-map cross share, leftover-map unexplained leftover share, +leftover-map explained leftover share, leftover-map coordinate +persistence, leftover-map graphic display, leftover-map axis share on +the graphic display, and leftover-map coordinate ticks. + +## 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 +(LSIRM interaction `−γ‖ξ_j − ζ_i‖` after main effects +`α_j − β_i`; typically `p = 2` for the interaction map.) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 89f07d7cf..b5d31877b 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,28 @@ # Product & Technical Gap Baseline +> Exact-head loop overlay: 2026-08-29 13:20 KST. Protected `main` is +> `fc13acaa20adca11968238e398d4aafcf62b6cee` (v2.23.0 leftover-map +> explained leftover share, #775). Open ready PRs still lack independent +> APPROVE. #782 leftover-map coordinates + graphic + axis share + ticks +> (v2.24.0–v2.27.0 / ADR 0267–0270) is on +> `2a203bf8b75b987ba899a0006a312d81259b9124` after #799 squash-merged +> into the unprotected leftover branch. Auto-merge squash remains armed +> on #782/#780/#774/#772/#771/#770. Independent APPROVE is still +> required for protected main. Drafts remain dirty against `main`. #96 +> stays closed as a weaker duplicate of #91. GitHub writes through +> `gh`/MCP succeed. Copilot review is not independent APPROVE. Do not +> self-approve. Do not `gh pr merge` stacked leftover PRs onto an +> unprotected leftover base. +> +> Next buyer increment on this cycle: leftover-map distance on +> graphic-display pair segments (ADR 0271 / v2.28.0). Caption each +> closest/farthest segment with persisted leftover-map distance `d` so +> the pair-row badge matches the graphic line. UI-only; no new columns. +> Missing/non-finite `d` omits that segment caption. Do not invent `d` +> from plotted coordinates. Do not invent leftover scores. Stack onto +> leftover branch `feat/leftover-map-coordinates-v2240`; leave the PR +> open for independent review. + > Exact-head loop overlay: 2026-08-29 13:15 KST. Protected `main` is > `fc13acaa20adca11968238e398d4aafcf62b6cee` (v2.23.0 leftover-map > explained leftover share, #775). Open ready PRs still lack independent diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index d9f676ce5..f426285a6 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -5,8 +5,8 @@ operator-facing control you can click before changing product CSS. | Story | Operator next action | Token / module | |---|---|---| -| `Reports/LeftoverMapPlot` | Read the leftover-map graphic display of persisted `ξ` (posts) and `ζ` (criteria), match axis ticks to those coordinates, then click a post marker to open that post. Leftover-map axes name persisted leftover-map axis share when finite. `ClosestAndFarthest`, `RankZeroOrigin`, `MissingCoordinates`, and `MissingAxisShare` cover two-pair maps, rank-0 origin with 0% share and a `0` tick, omitted plots, and missing share that keeps existing leftover-map axis text. The plot does not invent a leftover score. | `LeftoverMapPlot`, `leftoverMapPlotLayout`, `leftoverMapPlotAxisShare`, `--color-primary`, `--color-palette-blue-mid` | -| `Reports/LeftoverPairList` | Read closest/farthest leftover pairs with named `R`, `Y`/`E`, rank, `U`, `s`, `e`, `x`, `R̂`, `ξ`/`ζ`, and `d`, then open that post. The leftover-map graphic display sits above the pair buttons when coordinates are finite, leftover-map axes name persisted leftover-map axis share, and leftover-map axis ticks name persisted coordinates. | `LeftoverPairList`, `LeftoverMapPlot`, `ticket-list`, `post-badge` | +| `Reports/LeftoverMapPlot` | Read the leftover-map graphic display of persisted `ξ` (posts) and `ζ` (criteria), match axis ticks to those coordinates and pair-segment `d` to leftover-map distance, then click a post marker to open that post. Leftover-map axes name persisted leftover-map axis share when finite. `ClosestAndFarthest`, `RankZeroOrigin`, `MissingCoordinates`, and `MissingAxisShare` cover two-pair maps, rank-0 origin with 0% share, a `0` tick, and `d 0.00`, omitted plots, and missing share that keeps existing leftover-map axis text. The plot does not invent a leftover score. | `LeftoverMapPlot`, `leftoverMapPlotLayout`, `leftoverMapPlotAxisShare`, `--color-primary`, `--color-palette-blue-mid` | +| `Reports/LeftoverPairList` | Read closest/farthest leftover pairs with named `R`, `Y`/`E`, rank, `U`, `s`, `e`, `x`, `R̂`, `ξ`/`ζ`, and `d`, then open that post. The leftover-map graphic display sits above the pair buttons when coordinates are finite, leftover-map axes name persisted leftover-map axis share, leftover-map axis ticks name persisted coordinates, and pair segments name persisted leftover-map distance. | `LeftoverPairList`, `LeftoverMapPlot`, `ticket-list`, `post-badge` | | `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, or repeat-issue fact. `EvidenceReady`, `NarrowViewport`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover populated, mobile, unavailable-evidence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard` | | `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` | | `Post/Recorded perspectives` | Read the imported primary and every evidence-connected additional Voice with its recorded truth state instead of flattening them into one compound category. `CombinedEvidence`, `RejectedEvidence`, and `NarrowViewport` cover desktop, rejected-evidence, and narrow layouts. | `VoicePerspectiveList`, `ticket-list`, `post-meta` | diff --git a/frontend/package.json b/frontend/package.json index 0de7d33a2..cb6d1347a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.27.0", + "version": "2.28.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/LeftoverMapPlot.css b/frontend/src/components/LeftoverMapPlot.css index 21b463496..c96a691ae 100644 --- a/frontend/src/components/LeftoverMapPlot.css +++ b/frontend/src/components/LeftoverMapPlot.css @@ -94,6 +94,11 @@ stroke: var(--color-text); } +.leftover-map-plot-segment-label { + fill: var(--color-text); + font-size: 0.65rem; +} + .leftover-map-plot-item { fill: var(--color-palette-blue-mid); } diff --git a/frontend/src/components/LeftoverMapPlot.test.tsx b/frontend/src/components/LeftoverMapPlot.test.tsx index 661cccbff..9a7960300 100644 --- a/frontend/src/components/LeftoverMapPlot.test.tsx +++ b/frontend/src/components/LeftoverMapPlot.test.tsx @@ -45,7 +45,7 @@ describe("LeftoverMapPlot", () => { expect(screen.getByLabelText("Leftover-map graphic display")).toBeInTheDocument(); expect( screen.getByText( - "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Click a post marker to open that post. The plot does not invent a leftover score.", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d. Click a post marker to open that post. The plot does not invent a leftover score.", ), ).toBeInTheDocument(); expect(screen.getByText("Post ξ")).toBeInTheDocument(); @@ -55,6 +55,8 @@ describe("LeftoverMapPlot", () => { expect(screen.getByLabelText("leftover-map axis 1 tick +0.50")).toBeInTheDocument(); expect(screen.getByLabelText("leftover-map axis 2 tick −0.02")).toBeInTheDocument(); expect(screen.getByLabelText("leftover-map axis 1 tick 0.00")).toBeInTheDocument(); + expect(screen.getByLabelText("leftover-map distance d 0.12")).toBeInTheDocument(); + expect(screen.getByLabelText("leftover-map distance d 1.84")).toBeInTheDocument(); const postMarker = screen.getByRole("button", { name: "Open leftover-map post Public post at ξ (+0.50, +0.10)", @@ -122,6 +124,7 @@ describe("LeftoverMapPlot", () => { expect(screen.getByText("leftover-map axis 2 (0%)")).toBeInTheDocument(); expect(screen.getAllByLabelText("leftover-map axis 1 tick 0.00").length).toBeGreaterThan(0); expect(screen.queryByLabelText("leftover-map axis 1 tick +1.00")).not.toBeInTheDocument(); + expect(screen.getByLabelText("leftover-map distance d 0.00")).toBeInTheDocument(); }); it("captions leftover-map axes with persisted leftover-map axis share", () => { @@ -156,4 +159,29 @@ describe("LeftoverMapPlot", () => { expect(screen.queryByText(/leftover-map axis 1 \(/)).not.toBeInTheDocument(); expect(screen.queryByText(/leftover-map axis 2 \(/)).not.toBeInTheDocument(); }); + + it("omits leftover-map distance on a pair segment when d is missing", () => { + render( + , + ); + expect(screen.queryByLabelText(/leftover-map distance/)).not.toBeInTheDocument(); + expect(screen.getByLabelText("leftover-map axis 1 tick +0.50")).toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/LeftoverMapPlot.tsx b/frontend/src/components/LeftoverMapPlot.tsx index e747c9fe2..143781a26 100644 --- a/frontend/src/components/LeftoverMapPlot.tsx +++ b/frontend/src/components/LeftoverMapPlot.tsx @@ -11,6 +11,7 @@ import { layoutLeftoverMapPlot, LEFTOVER_MAP_PLOT_CAPTION, LEFTOVER_MAP_PLOT_POST_ACTION, + LEFTOVER_MAP_PLOT_SEGMENT_DISTANCE, LEFTOVER_MAP_PLOT_TICK, } from "../leftoverMapPlotLayout"; import "./LeftoverMapPlot.css"; @@ -46,10 +47,12 @@ function leftoverMapPlotAxisText( * post marker to open that post. Caption leftover-map axes with persisted * Gabriel inertia share when finite, including rank-0 zero-share axes. * Axis ticks name persisted leftover-map coordinates so ξ / ζ on the - * pair row match the plot. Omit that axis badge when share is missing or - * non-finite and keep the existing leftover-map axis text. Omit the plot - * when no pair has four finite leftover-map coordinates. Never invent a - * leftover score. + * pair row match the plot. Pair segments name persisted leftover-map + * distance ``d`` so the pair-row badge matches the graphic. Omit that + * segment caption when distance is missing or non-finite. Omit that axis + * badge when share is missing or non-finite and keep the existing + * leftover-map axis text. Omit the plot when no pair has four finite + * leftover-map coordinates. Never invent a leftover score. */ export function LeftoverMapPlot({ pairs, @@ -127,14 +130,28 @@ export function LeftoverMapPlot({ ))} {layout.segments.map((segment) => ( - + + + {segment.distanceLabel !== null ? ( + + {segment.distanceLabel} + + ) : null} + ))} {layout.items.map((marker) => ( diff --git a/frontend/src/components/LeftoverPairList.tsx b/frontend/src/components/LeftoverPairList.tsx index cc0ae2065..c6a0afb57 100644 --- a/frontend/src/components/LeftoverPairList.tsx +++ b/frontend/src/components/LeftoverPairList.tsx @@ -60,7 +60,8 @@ export type LeftoverPairListProps = { * marker opens that post. ADR 0269 captions those leftover-map axes with * persisted leftover-map axis share when finite. ADR 0270 ticks those * leftover-map axes at persisted ``ξ`` / ``ζ`` so the pair-row badge matches - * the plot. Every badge still + * the plot. ADR 0271 names persisted leftover-map distance ``d`` on those + * pair segments. Every badge still * renders together before opening the named post. */ export function LeftoverPairList({ diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index e67426076..b4b8d2fb6 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -66,7 +66,8 @@ describe("i18n", () => { "leftover-map axis 2", "leftover-map axis {axis} ({share}%)", "leftover-map axis {axis} tick {value}", - "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Click a post marker to open that post. The plot does not invent a leftover score.", + "leftover-map distance {label}", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d. Click a post marker to open that post. The plot does not invent a leftover score.", "Open leftover-map post {title} at ξ {person}", "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.", "Leftover map has no leftover structure after IRT main effects. Open this post.", @@ -330,22 +331,22 @@ describe("i18n", () => { it.each([ [ "ko", - "IRT 주효과 이후 잔여 지도입니다. 축 눈금은 저장된 잔여 지도 좌표입니다. 글 표식을 눌러 그 글을 여세요. 이 그림은 잔여 점수를 만들어내지 않습니다.", + "IRT 주효과 이후 잔여 지도입니다. 축 눈금은 저장된 잔여 지도 좌표입니다. 쌍 선분은 잔여 지도 거리 d입니다. 글 표식을 눌러 그 글을 여세요. 이 그림은 잔여 점수를 만들어내지 않습니다.", ], - ["zh", "IRT 主效应后的残差图。轴刻度标出已保存的残差图坐标。点击帖子标记打开该帖子。此图不会虚构残差分数。"], + ["zh", "IRT 主效应后的残差图。轴刻度标出已保存的残差图坐标。配对线段标出残差图距离 d。点击帖子标记打开该帖子。此图不会虚构残差分数。"], [ "ja", - "IRT主効果後の残差マップです。軸目盛は保存済みの残差マップ座標です。投稿マーカーをクリックしてその投稿を開いてください。この図は残差スコアを作りません。", + "IRT主効果後の残差マップです。軸目盛は保存済みの残差マップ座標です。ペア線分は残差マップ距離 d です。投稿マーカーをクリックしてその投稿を開いてください。この図は残差スコアを作りません。", ], [ "vi", - "Bản đồ phần dư sau hiệu ứng chính IRT. Vạch trục ghi tọa độ bản đồ phần dư đã lưu. Nhấn dấu bài viết để mở bài đó. Hình này không tạo ra điểm phần dư.", + "Bản đồ phần dư sau hiệu ứng chính IRT. Vạch trục ghi tọa độ bản đồ phần dư đã lưu. Đoạn cặp ghi khoảng cách bản đồ phần dư d. Nhấn dấu bài viết để mở bài đó. Hình này không tạo ra điểm phần dư.", ], ] as const)("formats leftover-map graphic display caption in %s", (locale, expected) => { setLocale(locale); expect( t( - "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Click a post marker to open that post. The plot does not invent a leftover score.", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d. Click a post marker to open that post. The plot does not invent a leftover score.", ), ).toBe(expected); }); @@ -360,6 +361,16 @@ describe("i18n", () => { expect(tf("leftover-map axis {axis} tick {value}", { axis: 1, value: "+0.50" })).toBe(expected); }); + it.each([ + ["ko", "잔여 지도 거리 d 0.12"], + ["zh", "残差图距离 d 0.12"], + ["ja", "残差マップ距離 d 0.12"], + ["vi", "khoảng cách bản đồ phần dư d 0.12"], + ] as const)("formats leftover-map segment distance in %s", (locale, expected) => { + setLocale(locale); + expect(tf("leftover-map distance {label}", { label: "d 0.12" })).toBe(expected); + }); + it.each([ ["ko", "잔여 지도 축 1 (82%)"], ["zh", "残差图轴 1 (82%)"], diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 4490ac10c..bfe170ef8 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -591,8 +591,9 @@ const TRANSLATIONS: Partial>> = { "leftover-map axis 2": "잔여 지도 축 2", "leftover-map axis {axis} ({share}%)": "잔여 지도 축 {axis} ({share}%)", "leftover-map axis {axis} tick {value}": "잔여 지도 축 {axis} 눈금 {value}", - "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Click a post marker to open that post. The plot does not invent a leftover score.": - "IRT 주효과 이후 잔여 지도입니다. 축 눈금은 저장된 잔여 지도 좌표입니다. 글 표식을 눌러 그 글을 여세요. 이 그림은 잔여 점수를 만들어내지 않습니다.", + "leftover-map distance {label}": "잔여 지도 거리 {label}", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d. Click a post marker to open that post. The plot does not invent a leftover score.": + "IRT 주효과 이후 잔여 지도입니다. 축 눈금은 저장된 잔여 지도 좌표입니다. 쌍 선분은 잔여 지도 거리 d입니다. 글 표식을 눌러 그 글을 여세요. 이 그림은 잔여 점수를 만들어내지 않습니다.", "Open leftover-map post {title} at ξ {person}": "잔여 지도 글 {title} 열기 (ξ {person})", "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.": @@ -1170,8 +1171,9 @@ const TRANSLATIONS: Partial>> = { "leftover-map axis 2": "残差图轴 2", "leftover-map axis {axis} ({share}%)": "残差图轴 {axis} ({share}%)", "leftover-map axis {axis} tick {value}": "残差图轴 {axis} 刻度 {value}", - "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Click a post marker to open that post. The plot does not invent a leftover score.": - "IRT 主效应后的残差图。轴刻度标出已保存的残差图坐标。点击帖子标记打开该帖子。此图不会虚构残差分数。", + "leftover-map distance {label}": "残差图距离 {label}", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d. Click a post marker to open that post. The plot does not invent a leftover score.": + "IRT 主效应后的残差图。轴刻度标出已保存的残差图坐标。配对线段标出残差图距离 d。点击帖子标记打开该帖子。此图不会虚构残差分数。", "Open leftover-map post {title} at ξ {person}": "打开残差图帖子 {title}(ξ {person})", "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.": @@ -1753,8 +1755,9 @@ const TRANSLATIONS: Partial>> = { "leftover-map axis 2": "残差マップ軸 2", "leftover-map axis {axis} ({share}%)": "残差マップ軸 {axis} ({share}%)", "leftover-map axis {axis} tick {value}": "残差マップ軸 {axis} 目盛 {value}", - "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Click a post marker to open that post. The plot does not invent a leftover score.": - "IRT主効果後の残差マップです。軸目盛は保存済みの残差マップ座標です。投稿マーカーをクリックしてその投稿を開いてください。この図は残差スコアを作りません。", + "leftover-map distance {label}": "残差マップ距離 {label}", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d. Click a post marker to open that post. The plot does not invent a leftover score.": + "IRT主効果後の残差マップです。軸目盛は保存済みの残差マップ座標です。ペア線分は残差マップ距離 d です。投稿マーカーをクリックしてその投稿を開いてください。この図は残差スコアを作りません。", "Open leftover-map post {title} at ξ {person}": "残差マップの投稿 {title} を開く(ξ {person})", "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.": @@ -2336,8 +2339,9 @@ const TRANSLATIONS: Partial>> = { "leftover-map axis 2": "trục bản đồ phần dư 2", "leftover-map axis {axis} ({share}%)": "trục bản đồ phần dư {axis} ({share}%)", "leftover-map axis {axis} tick {value}": "vạch trục bản đồ phần dư {axis} {value}", - "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Click a post marker to open that post. The plot does not invent a leftover score.": - "Bản đồ phần dư sau hiệu ứng chính IRT. Vạch trục ghi tọa độ bản đồ phần dư đã lưu. Nhấn dấu bài viết để mở bài đó. Hình này không tạo ra điểm phần dư.", + "leftover-map distance {label}": "khoảng cách bản đồ phần dư {label}", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d. Click a post marker to open that post. The plot does not invent a leftover score.": + "Bản đồ phần dư sau hiệu ứng chính IRT. Vạch trục ghi tọa độ bản đồ phần dư đã lưu. Đoạn cặp ghi khoảng cách bản đồ phần dư d. Nhấn dấu bài viết để mở bài đó. Hình này không tạo ra điểm phần dư.", "Open leftover-map post {title} at ξ {person}": "Mở bài viết bản đồ phần dư {title} tại ξ {person}", "Read observed Y {observed} and expected E {expected} after IRT main effects, then open this post.": diff --git a/frontend/src/leftoverMapPlotLayout.test.ts b/frontend/src/leftoverMapPlotLayout.test.ts index bb5ebcf67..aa8cebe29 100644 --- a/frontend/src/leftoverMapPlotLayout.test.ts +++ b/frontend/src/leftoverMapPlotLayout.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { LeftoverMapPlottablePair } from "./leftoverMapPlotLayout"; import { firstPlottablePairForPost, + formatLeftoverMapDistance, hasLeftoverMapPlotCoordinates, layoutLeftoverMapPlot, PLOT_HEIGHT, @@ -21,6 +22,7 @@ function pair( leftover_map_person_axis_2: 0.1, leftover_map_item_axis_1: 0.5, leftover_map_item_axis_2: -0.02, + leftover_distance: 0.12, ...overrides, }; } @@ -205,6 +207,73 @@ describe("layoutLeftoverMapPlot", () => { false, ); }); + + it("names persisted leftover-map distance on pair segments without inventing a leftover score", () => { + const layout = layoutLeftoverMapPlot( + [ + pair(), + pair({ + pair_kind: "farthest", + post_id: "post-demo-spec", + criterion_code: "negative_sentiment", + leftover_map_person_axis_1: 0.9, + leftover_map_person_axis_2: 0.8, + leftover_map_item_axis_1: -0.7, + leftover_map_item_axis_2: -0.4, + leftover_distance: 1.84, + }), + ], + criterionLabel, + ); + expect(layout?.segments.map((segment) => segment.distanceLabel)).toEqual(["d 0.12", "d 1.84"]); + expect(layout?.segments[0]?.labelX).toBeCloseTo( + ((layout?.segments[0]?.x1 ?? 0) + (layout?.segments[0]?.x2 ?? 0)) / 2, + 5, + ); + }); + + it("omits a leftover-map distance caption when d is missing or non-finite", () => { + const layout = layoutLeftoverMapPlot( + [ + pair({ leftover_distance: null }), + pair({ + pair_kind: "farthest", + leftover_distance: Number.NaN, + leftover_map_item_axis_1: -0.7, + leftover_map_item_axis_2: -0.4, + criterion_code: "negative_sentiment", + }), + ], + criterionLabel, + ); + expect(layout?.segments.map((segment) => segment.distanceLabel)).toEqual([null, null]); + }); + + it("does not invent leftover-map distance from plotted coordinates", () => { + const layout = layoutLeftoverMapPlot( + [ + pair({ + leftover_map_person_axis_1: 0, + leftover_map_person_axis_2: 0, + leftover_map_item_axis_1: 1, + leftover_map_item_axis_2: 0, + leftover_distance: 0.12, + }), + ], + criterionLabel, + ); + expect(layout?.segments[0]?.distanceLabel).toBe("d 0.12"); + expect(layout?.segments[0]?.distanceLabel).not.toBe("d 1.00"); + }); +}); + +describe("formatLeftoverMapDistance", () => { + it("formats persisted leftover-map distance without inventing a leftover score", () => { + expect(formatLeftoverMapDistance(0.12)).toBe("d 0.12"); + expect(formatLeftoverMapDistance(0)).toBe("d 0.00"); + expect(formatLeftoverMapDistance(null)).toBeNull(); + expect(formatLeftoverMapDistance(Number.NaN)).toBeNull(); + }); }); describe("firstPlottablePairForPost", () => { diff --git a/frontend/src/leftoverMapPlotLayout.ts b/frontend/src/leftoverMapPlotLayout.ts index 7af7a30a8..c0d09473a 100644 --- a/frontend/src/leftoverMapPlotLayout.ts +++ b/frontend/src/leftoverMapPlotLayout.ts @@ -1,6 +1,7 @@ /** Gabriel leftover-map graphic display of persisted ``ξ_{1:2}`` / ``ζ_{1:2}``. * Leftover-map axis share captions axes 1 and 2 when finite (ADR 0269). * Axis ticks name persisted leftover-map coordinates (ADR 0270). + * Pair segments name persisted leftover-map distance ``d`` (ADR 0271). */ import { formatLeftoverMapCoordinatePair } from "./leftoverMapCoordinates"; @@ -8,7 +9,7 @@ import { formatSignedLeftoverValue } from "./leftoverMapUnexplained"; import type { LeftoverPair } from "./api"; export const LEFTOVER_MAP_PLOT_CAPTION = - "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Click a post marker to open that post. The plot does not invent a leftover score."; + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d. Click a post marker to open that post. The plot does not invent a leftover score."; export const LEFTOVER_MAP_PLOT_POST_ACTION = "Open leftover-map post {title} at ξ {person}"; @@ -16,18 +17,23 @@ export const LEFTOVER_MAP_PLOT_POST_ACTION = export const LEFTOVER_MAP_PLOT_TICK = "leftover-map axis {axis} tick {value}"; +export const LEFTOVER_MAP_PLOT_SEGMENT_DISTANCE = + "leftover-map distance {label}"; + export const PLOT_WIDTH = 480; export const PLOT_HEIGHT = 320; export const PLOT_PADDING = 40; export const PLOT_TICK_LENGTH = 6; const UNIT_DISPLAY_SPAN = 2; const COLLAPSED_SPAN = 1e-12; +const COINCIDENT_LABEL_OFFSET = 14; export type LeftoverMapPlottablePair = { pair_kind: LeftoverPair["pair_kind"]; post_id: string; post_title: string; criterion_code: string; + leftover_distance?: number | null; leftover_map_person_axis_1?: number | null; leftover_map_person_axis_2?: number | null; leftover_map_item_axis_1?: number | null; @@ -52,6 +58,9 @@ export type LeftoverMapPlotSegment = { y1: number; x2: number; y2: number; + distanceLabel: string | null; + labelX: number; + labelY: number; }; export type LeftoverMapPlotTick = { @@ -75,6 +84,13 @@ export type LeftoverMapPlotLayout = { ticks: LeftoverMapPlotTick[]; }; +export function formatLeftoverMapDistance(value: number | null | undefined): string | null { + if (value == null || !Number.isFinite(value)) { + return null; + } + return `d ${value.toFixed(2)}`; +} + export function hasLeftoverMapPlotCoordinates( pair: LeftoverMapPlottablePair, ): boolean { @@ -163,6 +179,19 @@ function leftoverMapCoordinateTicks( return ticks; } +function leftoverMapSegmentLabelPosition( + x1: number, + y1: number, + x2: number, + y2: number, +): { labelX: number; labelY: number } { + const coincident = Math.abs(x1 - x2) < 0.01 && Math.abs(y1 - y2) < 0.01; + return { + labelX: (x1 + x2) / 2, + labelY: coincident ? (y1 + y2) / 2 - COINCIDENT_LABEL_OFFSET : (y1 + y2) / 2, + }; +} + export function layoutLeftoverMapPlot( pairs: LeftoverMapPlottablePair[], criterionLabel: (criterionCode: string) => string, @@ -244,6 +273,7 @@ export function layoutLeftoverMapPlot( pair.leftover_map_person_axis_2 as number, pair.leftover_map_item_axis_2 as number, ); + const distanceLabel = formatLeftoverMapDistance(pair.leftover_distance); segments.push({ pairKind: pair.pair_kind === "farthest" ? "farthest" : "closest", postId: pair.post_id, @@ -252,6 +282,8 @@ export function layoutLeftoverMapPlot( y1: personPos.y, x2: itemPos.x, y2: itemPos.y, + distanceLabel, + ...leftoverMapSegmentLabelPosition(personPos.x, personPos.y, itemPos.x, itemPos.y), }); } diff --git a/pyproject.toml b/pyproject.toml index 747e1dca4..7744aef87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.27.0" +version = "2.28.0" 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/uv.lock b/uv.lock index 7f53a7895..f94e79cf4 100644 --- a/uv.lock +++ b/uv.lock @@ -685,7 +685,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.27.0" +version = "2.28.0" source = { editable = "." } dependencies = [ { name = "certifi" },