From e2d13019004a5d8c019fecf7a39ceeef4093b8dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 04:39:53 +0000 Subject: [PATCH 01/23] 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 02/23] 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 03/23] 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 04/23] 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 05/23] 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 06/23] 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 07/23] 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" }, From 5f21538e7faac4d66e13b3b39f9531ff4cf5b9c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 07:23:56 +0000 Subject: [PATCH 08/23] feat(reports): leftover-map reconstruction on pair segments (v2.29.0) 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 reconstruction R̂ (ADR 0272). 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, pair segments that match d on the pair row, and pair segments that match R̂ on the pair row; click a post marker or a pair button opens that post. A missing or non-finite R̂ omits that reconstruction caption. Do not invent R̂ from plotted coordinates. Never invent a leftover score or a theta. No new columns. --- AGENTS.md | 8 +- ARCHITECTURE.md | 5 +- ...9.0-leftover-map-segment-reconstruction.md | 12 ++ CHANGELOG.md | 12 ++ CLAUDE.md | 2 +- docs/adr/0048-persist-lsirm-leftover-pairs.md | 6 +- docs/adr/0049-leftover-pair-report-ui.md | 8 +- docs/adr/0201-leftover-map-reconstruction.md | 4 +- docs/adr/0268-leftover-map-graphic-display.md | 7 +- .../adr/0271-leftover-map-segment-distance.md | 3 + ...272-leftover-map-segment-reconstruction.md | 118 ++++++++++++++++++ docs/product-technical-gap-baseline.md | 25 ++++ docs/storybook-inventory.md | 4 +- frontend/package.json | 2 +- .../components/LeftoverMapPlot.stories.tsx | 1 + .../src/components/LeftoverMapPlot.test.tsx | 34 ++++- frontend/src/components/LeftoverMapPlot.tsx | 20 ++- frontend/src/components/LeftoverPairList.tsx | 3 +- frontend/src/i18n.test.ts | 23 +++- frontend/src/i18n.ts | 20 +-- frontend/src/leftoverMapPlotLayout.test.ts | 80 ++++++++++++ frontend/src/leftoverMapPlotLayout.ts | 32 ++++- pyproject.toml | 2 +- uv.lock | 2 +- 24 files changed, 395 insertions(+), 38 deletions(-) create mode 100644 CHANGELOG.d/2.29.0-leftover-map-segment-reconstruction.md create mode 100644 docs/adr/0272-leftover-map-segment-reconstruction.md diff --git a/AGENTS.md b/AGENTS.md index 3af4169bc..bb4b7f118 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 / 0271) are computed in `lineageweave/leftover_pairs.py` from the +0163 / 0164 / 0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269 / 0270 / 0271 / 0272) 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 @@ -301,7 +301,11 @@ plot; rank-0 unused axes name only `0` and do not invent drawing-scale 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`, +invent `d` from plotted coordinates. ADR 0272 captions leftover-map +pair segments with persisted leftover-map reconstruction `R̂` so the +pair-row badge matches the graphic; a missing or non-finite `R̂` omits +that reconstruction caption and does not invent `R̂` 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 05f98e69e..d0751f382 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 / 0271) persist to `report_leftover_pair` with signed residual `R`, +0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269 / 0270 / 0271 / 0272) 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 @@ -647,7 +647,8 @@ 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. ADR 0271 captions leftover-map pair segments -with persisted leftover-map distance `d`. Click a post marker or a pair button +with persisted leftover-map distance `d`. ADR 0272 captions leftover-map +pair segments with persisted leftover-map reconstruction `R̂`. 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.29.0-leftover-map-segment-reconstruction.md b/CHANGELOG.d/2.29.0-leftover-map-segment-reconstruction.md new file mode 100644 index 000000000..c6d927390 --- /dev/null +++ b/CHANGELOG.d/2.29.0-leftover-map-segment-reconstruction.md @@ -0,0 +1,12 @@ +## 2.29.0 — Leftover-map reconstruction on graphic-display pair segments + +- Caption leftover-map graphic-display pair segments with persisted + leftover-map reconstruction `R̂` (ADR 0272). 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, pair segments that match `d` on the pair row, and pair + segments that match `R̂` on the pair row; click a post marker or a + pair button opens that post. A missing or non-finite `R̂` omits that + reconstruction caption. Do not invent `R̂` from plotted coordinates. + Never invent a leftover score. Never invent a theta. No new columns. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a2724eb8..f0ac22e3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ 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 reconstruction `R̂` (ADR 0272 / + v2.29.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, pair segments that match + `d` on the pair row, and pair segments that match `R̂` on the pair + row; click a post marker or a pair button opens that post. A missing + or non-finite `R̂` omits that reconstruction caption. Do not invent + `R̂` from plotted coordinates. Never invent a leftover score or a + theta. No new columns. + - 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 diff --git a/CLAUDE.md b/CLAUDE.md index 1a36a87b3..801a79c6f 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 / 0271), occupational construct catalog search +(ADR 0048–0164 / 0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269 / 0270 / 0271 / 0272), 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 f3a8c636b..88c7b67b5 100644 --- a/docs/adr/0048-persist-lsirm-leftover-pairs.md +++ b/docs/adr/0048-persist-lsirm-leftover-pairs.md @@ -14,7 +14,8 @@ [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 0271](0271-leftover-map-segment-distance.md) (leftover-map distance on pair segments) +[ADR 0271](0271-leftover-map-segment-distance.md) (leftover-map distance on pair segments); +[ADR 0272](0272-leftover-map-segment-reconstruction.md) (leftover-map reconstruction on pair segments) ## Context @@ -74,7 +75,8 @@ 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. ADR 0271 captions leftover-map pair segments with persisted leftover-map distance `d`; it adds no -columns. +columns. ADR 0272 captions leftover-map pair segments with persisted +leftover-map reconstruction `R̂`; 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 6ff778400..880dd15d2 100644 --- a/docs/adr/0049-leftover-pair-report-ui.md +++ b/docs/adr/0049-leftover-pair-report-ui.md @@ -15,7 +15,8 @@ [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 0271](0271-leftover-map-segment-distance.md) (leftover-map distance on pair segments) +[ADR 0271](0271-leftover-map-segment-distance.md) (leftover-map distance on pair segments); +[ADR 0272](0272-leftover-map-segment-reconstruction.md) (leftover-map reconstruction on pair segments) ## Context @@ -43,7 +44,8 @@ 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). Pair segments name persisted leftover-map -distance `d` (ADR 0271). The pair renders every available finite measurement. +distance `d` (ADR 0271). Pair segments name persisted leftover-map +reconstruction `R̂` (ADR 0272). 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 @@ -76,6 +78,8 @@ 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). +Leftover-map reconstruction on pair segments is +[ADR 0272](0272-leftover-map-segment-reconstruction.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 2071ad646..38619765a 100644 --- a/docs/adr/0201-leftover-map-reconstruction.md +++ b/docs/adr/0201-leftover-map-reconstruction.md @@ -4,7 +4,9 @@ **Date:** 2026-08-25 **Amended by:** [ADR 0267](0267-leftover-map-coordinates.md) -(leftover-map coordinates ξ, ζ) +(leftover-map coordinates ξ, ζ); +[ADR 0272](0272-leftover-map-segment-reconstruction.md) +(leftover-map reconstruction on graphic-display pair segments) Amends [ADR 0048](0048-persist-lsirm-leftover-pairs.md), [ADR 0049](0049-leftover-pair-report-ui.md), and diff --git a/docs/adr/0268-leftover-map-graphic-display.md b/docs/adr/0268-leftover-map-graphic-display.md index 0b58bb964..bc62f6315 100644 --- a/docs/adr/0268-leftover-map-graphic-display.md +++ b/docs/adr/0268-leftover-map-graphic-display.md @@ -8,7 +8,9 @@ [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) +(leftover-map distance on pair segments); +[ADR 0272](0272-leftover-map-segment-reconstruction.md) +(leftover-map reconstruction on pair segments) Amends [ADR 0049](0049-leftover-pair-report-ui.md) and [ADR 0267](0267-leftover-map-coordinates.md). Independent of leftover-map @@ -83,7 +85,8 @@ 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). Pair segments name -persisted leftover-map distance `d` (ADR 0271). When coordinates, reconstruction, and +persisted leftover-map distance `d` (ADR 0271). Pair segments name +persisted leftover-map reconstruction `R̂` (ADR 0272). 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/0271-leftover-map-segment-distance.md b/docs/adr/0271-leftover-map-segment-distance.md index cf458aa5a..786fe2785 100644 --- a/docs/adr/0271-leftover-map-segment-distance.md +++ b/docs/adr/0271-leftover-map-segment-distance.md @@ -3,6 +3,9 @@ **Decision status:** Accepted **Date:** 2026-08-29 +**Amended by:** [ADR 0272](0272-leftover-map-segment-reconstruction.md) +(leftover-map reconstruction on pair segments) + 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)), diff --git a/docs/adr/0272-leftover-map-segment-reconstruction.md b/docs/adr/0272-leftover-map-segment-reconstruction.md new file mode 100644 index 000000000..2406d601e --- /dev/null +++ b/docs/adr/0272-leftover-map-segment-reconstruction.md @@ -0,0 +1,118 @@ +# ADR 0272 — Name leftover-map reconstruction 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 +distance on pair segments ([ADR 0271](0271-leftover-map-segment-distance.md)), +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 persistence ([ADR 0201](0201-leftover-map-reconstruction.md)), +and leftover-map axis share persistence ([ADR 0148](0148-leftover-map-axis-share.md)). + +## Context + +ADR 0201 already persists leftover-map reconstruction +`R̂ = ξ_{1:2} · ζ_{1:2}` on leftover pair rows. ADR 0267 already +persists the two-axis coordinates so that identity stays auditable +next to leftover-map distance `d`. ADR 0268 already draws the +connecting segment. ADR 0271 already names persisted `d` on that +segment. The segment still has no leftover-map reconstruction label, +so a buyer who reads `R̂ +0.35` on the pair row cannot match that +inner product to the graphic pair. Hiding `R̂` on the segment lets +leftover residual `R`, leftover-map distance `d`, or leftover-map +location be read as leftover-map reconstruction even after the +coordinates, ticks, and distance are named. + +This increment names leftover-map graphic-display pair segments with +persisted leftover-map reconstruction. It does not add columns. It +does not recompute `R̂` from plotted coordinates. It does not persist +leftover-map inner product, cosine, or length as separate columns +(`R̂` already is the two-axis inner product). 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 **0272** so it does not +collide with leftover-map distance on pair segments (0271), +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 persistence (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 reconstruction +formatter as the pair-row `R̂` badge. A missing or non-finite `R̂` +omits that reconstruction caption and keeps the connecting line and +any leftover-map distance caption. Rank-0 origin cells still name +`R̂ 0.00` when that persisted reconstruction is finite. Do not invent +`R̂` from plotted coordinates or from a pixel inner product. 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 reconstruction +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, pair segments name persisted leftover-map distance `d` +and persisted leftover-map reconstruction `R̂`; 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, still name `d 0.00` when that distance is +persisted, and still name `R̂ 0.00` when that reconstruction 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 persistence, 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, leftover-map coordinate ticks, and leftover-map +distance on pair segments. + +## 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. Gabriel +reconstruction of the leftover cell is the two-axis inner product +`R̂ = ξ_{1:2} · ζ_{1:2}`.) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index b5d31877b..eaccefe7b 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,30 @@ # Product & Technical Gap Baseline +> Exact-head loop overlay: 2026-08-29 16: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`. #800 leftover-map distance +> on pair segments (v2.28.0 / ADR 0271) is stacked on that leftover +> branch at `1374a830582c22808c7ec02d4ae4cf2f5da55985`. 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 (comment/create-branch/auto-merge). 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 reconstruction `R̂` +> on graphic-display pair segments (ADR 0272 / v2.29.0). Caption each +> closest/farthest segment with persisted leftover-map reconstruction +> so the pair-row `R̂` badge matches the graphic. UI-only; no new +> columns. Missing/non-finite `R̂` omits that reconstruction caption. +> Do not invent `R̂` from plotted coordinates. Do not invent leftover +> scores. Stack onto leftover branch +> `feat/leftover-map-segment-distance-v2280`; leave the PR open for +> independent review. + > 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 diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index f426285a6..473302280 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 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` | +| `Reports/LeftoverMapPlot` | Read the leftover-map graphic display of persisted `ξ` (posts) and `ζ` (criteria), match axis ticks to those coordinates, pair-segment `d` to leftover-map distance, and pair-segment `R̂` to leftover-map reconstruction, 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, `d 0.00`, and `R̂ 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, pair segments name persisted leftover-map distance, and pair segments name persisted leftover-map reconstruction. | `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 cb6d1347a..f62380df7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.28.0", + "version": "2.29.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/LeftoverMapPlot.stories.tsx b/frontend/src/components/LeftoverMapPlot.stories.tsx index 16c869e26..94ec42fdd 100644 --- a/frontend/src/components/LeftoverMapPlot.stories.tsx +++ b/frontend/src/components/LeftoverMapPlot.stories.tsx @@ -80,6 +80,7 @@ export const RankZeroOrigin: Story = { observed_response: 1, expected_response: 1, leftover_map_rank: 0, + leftover_map_reconstruction: 0, leftover_map_person_axis_1: 0, leftover_map_person_axis_2: 0, leftover_map_item_axis_1: 0, diff --git a/frontend/src/components/LeftoverMapPlot.test.tsx b/frontend/src/components/LeftoverMapPlot.test.tsx index 9a7960300..1b211a5c1 100644 --- a/frontend/src/components/LeftoverMapPlot.test.tsx +++ b/frontend/src/components/LeftoverMapPlot.test.tsx @@ -12,6 +12,7 @@ const PAIRS: LeftoverPair[] = [ criterion_code: "sales_lead_quality", leftover_distance: 0.12, leftover_residual: 0.4, + leftover_map_reconstruction: 0.248, leftover_map_person_axis_1: 0.5, leftover_map_person_axis_2: 0.1, leftover_map_item_axis_1: 0.5, @@ -24,6 +25,7 @@ const PAIRS: LeftoverPair[] = [ criterion_code: "negative_sentiment", leftover_distance: 1.84, leftover_residual: -1.1, + leftover_map_reconstruction: -0.95, leftover_map_person_axis_1: 0.9, leftover_map_person_axis_2: 0.8, leftover_map_item_axis_1: -0.7, @@ -45,7 +47,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. Pair segments name leftover-map distance d. 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 and leftover-map reconstruction R̂. Click a post marker to open that post. The plot does not invent a leftover score.", ), ).toBeInTheDocument(); expect(screen.getByText("Post ξ")).toBeInTheDocument(); @@ -57,6 +59,8 @@ describe("LeftoverMapPlot", () => { 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(); + expect(screen.getByLabelText("leftover-map reconstruction R̂ +0.25")).toBeInTheDocument(); + expect(screen.getByLabelText("leftover-map reconstruction R̂ −0.95")).toBeInTheDocument(); const postMarker = screen.getByRole("button", { name: "Open leftover-map post Public post at ξ (+0.50, +0.10)", @@ -103,6 +107,7 @@ describe("LeftoverMapPlot", () => { leftover_distance: 0, leftover_residual: 0, leftover_map_rank: 0, + leftover_map_reconstruction: 0, leftover_map_person_axis_1: 0, leftover_map_person_axis_2: 0, leftover_map_item_axis_1: 0, @@ -125,6 +130,7 @@ describe("LeftoverMapPlot", () => { 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(); + expect(screen.getByLabelText("leftover-map reconstruction R̂ 0.00")).toBeInTheDocument(); }); it("captions leftover-map axes with persisted leftover-map axis share", () => { @@ -184,4 +190,30 @@ describe("LeftoverMapPlot", () => { expect(screen.queryByLabelText(/leftover-map distance/)).not.toBeInTheDocument(); expect(screen.getByLabelText("leftover-map axis 1 tick +0.50")).toBeInTheDocument(); }); + + it("omits leftover-map reconstruction on a pair segment when R̂ is missing", () => { + render( + , + ); + expect(screen.queryByLabelText(/leftover-map reconstruction/)).not.toBeInTheDocument(); + expect(screen.getByLabelText("leftover-map distance d 0.12")).toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/LeftoverMapPlot.tsx b/frontend/src/components/LeftoverMapPlot.tsx index 143781a26..4dbb7d562 100644 --- a/frontend/src/components/LeftoverMapPlot.tsx +++ b/frontend/src/components/LeftoverMapPlot.tsx @@ -12,6 +12,7 @@ import { LEFTOVER_MAP_PLOT_CAPTION, LEFTOVER_MAP_PLOT_POST_ACTION, LEFTOVER_MAP_PLOT_SEGMENT_DISTANCE, + LEFTOVER_MAP_PLOT_SEGMENT_RECONSTRUCTION, LEFTOVER_MAP_PLOT_TICK, } from "../leftoverMapPlotLayout"; import "./LeftoverMapPlot.css"; @@ -48,8 +49,10 @@ function leftoverMapPlotAxisText( * 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. 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 + * distance ``d`` and leftover-map reconstruction ``R̂`` so the pair-row + * badges match the graphic. Omit that distance caption when ``d`` is + * missing or non-finite. Omit that reconstruction caption when ``R̂`` + * 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. @@ -151,6 +154,19 @@ export function LeftoverMapPlot({ {segment.distanceLabel} ) : null} + {segment.reconstructionLabel !== null ? ( + + {segment.reconstructionLabel} + + ) : null} ))} {layout.items.map((marker) => ( diff --git a/frontend/src/components/LeftoverPairList.tsx b/frontend/src/components/LeftoverPairList.tsx index c6a0afb57..21adb84be 100644 --- a/frontend/src/components/LeftoverPairList.tsx +++ b/frontend/src/components/LeftoverPairList.tsx @@ -61,7 +61,8 @@ export type LeftoverPairListProps = { * persisted leftover-map axis share when finite. ADR 0270 ticks those * leftover-map axes at persisted ``ξ`` / ``ζ`` so the pair-row badge matches * the plot. ADR 0271 names persisted leftover-map distance ``d`` on those - * pair segments. Every badge still + * pair segments. ADR 0272 names persisted leftover-map reconstruction + * ``R̂`` 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 b4b8d2fb6..30494e5a3 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -67,7 +67,8 @@ describe("i18n", () => { "leftover-map axis {axis} ({share}%)", "leftover-map axis {axis} tick {value}", "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.", + "leftover-map reconstruction {label}", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d and leftover-map reconstruction R̂. 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.", @@ -331,22 +332,22 @@ describe("i18n", () => { it.each([ [ "ko", - "IRT 주효과 이후 잔여 지도입니다. 축 눈금은 저장된 잔여 지도 좌표입니다. 쌍 선분은 잔여 지도 거리 d입니다. 글 표식을 눌러 그 글을 여세요. 이 그림은 잔여 점수를 만들어내지 않습니다.", + "IRT 주효과 이후 잔여 지도입니다. 축 눈금은 저장된 잔여 지도 좌표입니다. 쌍 선분은 잔여 지도 거리 d와 잔여 지도 재구성 R̂입니다. 글 표식을 눌러 그 글을 여세요. 이 그림은 잔여 점수를 만들어내지 않습니다.", ], - ["zh", "IRT 主效应后的残差图。轴刻度标出已保存的残差图坐标。配对线段标出残差图距离 d。点击帖子标记打开该帖子。此图不会虚构残差分数。"], + ["zh", "IRT 主效应后的残差图。轴刻度标出已保存的残差图坐标。配对线段标出残差图距离 d 与残差图重建 R̂。点击帖子标记打开该帖子。此图不会虚构残差分数。"], [ "ja", - "IRT主効果後の残差マップです。軸目盛は保存済みの残差マップ座標です。ペア線分は残差マップ距離 d です。投稿マーカーをクリックしてその投稿を開いてください。この図は残差スコアを作りません。", + "IRT主効果後の残差マップです。軸目盛は保存済みの残差マップ座標です。ペア線分は残差マップ距離 d と残差マップ再構成 R̂ です。投稿マーカーをクリックしてその投稿を開いてください。この図は残差スコアを作りません。", ], [ "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. Đ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ư.", + "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 và tái dựng bản đồ phần dư R̂. 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. Pair segments name leftover-map distance d. 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 and leftover-map reconstruction R̂. Click a post marker to open that post. The plot does not invent a leftover score.", ), ).toBe(expected); }); @@ -371,6 +372,16 @@ describe("i18n", () => { expect(tf("leftover-map distance {label}", { label: "d 0.12" })).toBe(expected); }); + it.each([ + ["ko", "잔여 지도 재구성 R̂ +0.35"], + ["zh", "残差图重建 R̂ +0.35"], + ["ja", "残差マップ再構成 R̂ +0.35"], + ["vi", "tái dựng bản đồ phần dư R̂ +0.35"], + ] as const)("formats leftover-map segment reconstruction in %s", (locale, expected) => { + setLocale(locale); + expect(tf("leftover-map reconstruction {label}", { label: "R̂ +0.35" })).toBe(expected); + }); + it.each([ ["ko", "잔여 지도 축 1 (82%)"], ["zh", "残差图轴 1 (82%)"], diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index bfe170ef8..3ef421547 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -592,8 +592,9 @@ const TRANSLATIONS: Partial>> = { "leftover-map axis {axis} ({share}%)": "잔여 지도 축 {axis} ({share}%)", "leftover-map axis {axis} tick {value}": "잔여 지도 축 {axis} 눈금 {value}", "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입니다. 글 표식을 눌러 그 글을 여세요. 이 그림은 잔여 점수를 만들어내지 않습니다.", + "leftover-map reconstruction {label}": "잔여 지도 재구성 {label}", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d and leftover-map reconstruction R̂. Click a post marker to open that post. The plot does not invent a leftover score.": + "IRT 주효과 이후 잔여 지도입니다. 축 눈금은 저장된 잔여 지도 좌표입니다. 쌍 선분은 잔여 지도 거리 d와 잔여 지도 재구성 R̂입니다. 글 표식을 눌러 그 글을 여세요. 이 그림은 잔여 점수를 만들어내지 않습니다.", "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.": @@ -1172,8 +1173,9 @@ const TRANSLATIONS: Partial>> = { "leftover-map axis {axis} ({share}%)": "残差图轴 {axis} ({share}%)", "leftover-map axis {axis} tick {value}": "残差图轴 {axis} 刻度 {value}", "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。点击帖子标记打开该帖子。此图不会虚构残差分数。", + "leftover-map reconstruction {label}": "残差图重建 {label}", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d and leftover-map reconstruction R̂. Click a post marker to open that post. The plot does not invent a leftover score.": + "IRT 主效应后的残差图。轴刻度标出已保存的残差图坐标。配对线段标出残差图距离 d 与残差图重建 R̂。点击帖子标记打开该帖子。此图不会虚构残差分数。", "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.": @@ -1756,8 +1758,9 @@ const TRANSLATIONS: Partial>> = { "leftover-map axis {axis} ({share}%)": "残差マップ軸 {axis} ({share}%)", "leftover-map axis {axis} tick {value}": "残差マップ軸 {axis} 目盛 {value}", "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 です。投稿マーカーをクリックしてその投稿を開いてください。この図は残差スコアを作りません。", + "leftover-map reconstruction {label}": "残差マップ再構成 {label}", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d and leftover-map reconstruction R̂. Click a post marker to open that post. The plot does not invent a leftover score.": + "IRT主効果後の残差マップです。軸目盛は保存済みの残差マップ座標です。ペア線分は残差マップ距離 d と残差マップ再構成 R̂ です。投稿マーカーをクリックしてその投稿を開いてください。この図は残差スコアを作りません。", "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.": @@ -2340,8 +2343,9 @@ const TRANSLATIONS: Partial>> = { "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 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ư.", + "leftover-map reconstruction {label}": "tái dựng 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 and leftover-map reconstruction R̂. 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 và tái dựng bản đồ phần dư R̂. 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 aa8cebe29..aeef24827 100644 --- a/frontend/src/leftoverMapPlotLayout.test.ts +++ b/frontend/src/leftoverMapPlotLayout.test.ts @@ -23,6 +23,7 @@ function pair( leftover_map_item_axis_1: 0.5, leftover_map_item_axis_2: -0.02, leftover_distance: 0.12, + leftover_map_reconstruction: 0.248, ...overrides, }; } @@ -265,6 +266,85 @@ describe("layoutLeftoverMapPlot", () => { expect(layout?.segments[0]?.distanceLabel).toBe("d 0.12"); expect(layout?.segments[0]?.distanceLabel).not.toBe("d 1.00"); }); + + it("names persisted leftover-map reconstruction 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, + leftover_map_reconstruction: -0.95, + }), + ], + criterionLabel, + ); + expect(layout?.segments.map((segment) => segment.reconstructionLabel)).toEqual([ + "R\u0302 +0.25", + "R\u0302 \u22120.95", + ]); + expect(layout?.segments[0]?.reconstructionX).toBeCloseTo(layout?.segments[0]?.labelX ?? 0, 5); + expect(layout?.segments[0]?.reconstructionY).toBeGreaterThan(layout?.segments[0]?.labelY ?? 0); + }); + + it("omits a leftover-map reconstruction caption when R̂ is missing or non-finite", () => { + const layout = layoutLeftoverMapPlot( + [ + pair({ leftover_map_reconstruction: null }), + pair({ + pair_kind: "farthest", + leftover_map_reconstruction: 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.reconstructionLabel)).toEqual([null, null]); + expect(layout?.segments.map((segment) => segment.distanceLabel)).toEqual(["d 0.12", "d 0.12"]); + }); + + it("does not invent leftover-map reconstruction from plotted coordinates", () => { + const layout = layoutLeftoverMapPlot( + [ + pair({ + leftover_map_person_axis_1: 1, + leftover_map_person_axis_2: 0, + leftover_map_item_axis_1: 1, + leftover_map_item_axis_2: 0, + leftover_map_reconstruction: 0.35, + }), + ], + criterionLabel, + ); + expect(layout?.segments[0]?.reconstructionLabel).toBe("R\u0302 +0.35"); + expect(layout?.segments[0]?.reconstructionLabel).not.toBe("R\u0302 +1.00"); + }); + + it("names rank-0 origin reconstruction R̂ 0.00 when that persisted value is finite", () => { + 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, + leftover_distance: 0, + leftover_map_reconstruction: 0, + }), + ], + criterionLabel, + ); + expect(layout?.segments[0]?.reconstructionLabel).toBe("R\u0302 0.00"); + expect(layout?.segments[0]?.distanceLabel).toBe("d 0.00"); + }); }); describe("formatLeftoverMapDistance", () => { diff --git a/frontend/src/leftoverMapPlotLayout.ts b/frontend/src/leftoverMapPlotLayout.ts index c0d09473a..5475ed298 100644 --- a/frontend/src/leftoverMapPlotLayout.ts +++ b/frontend/src/leftoverMapPlotLayout.ts @@ -1,15 +1,17 @@ /** 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). + * Pair segments name persisted leftover-map distance ``d`` (ADR 0271) + * and persisted leftover-map reconstruction ``R̂`` (ADR 0272). */ import { formatLeftoverMapCoordinatePair } from "./leftoverMapCoordinates"; +import { formatLeftoverMapReconstruction } from "./leftoverMapReconstruction"; 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. Pair segments name leftover-map distance d. 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 and leftover-map reconstruction R̂. 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}"; @@ -20,6 +22,9 @@ export const LEFTOVER_MAP_PLOT_TICK = export const LEFTOVER_MAP_PLOT_SEGMENT_DISTANCE = "leftover-map distance {label}"; +export const LEFTOVER_MAP_PLOT_SEGMENT_RECONSTRUCTION = + "leftover-map reconstruction {label}"; + export const PLOT_WIDTH = 480; export const PLOT_HEIGHT = 320; export const PLOT_PADDING = 40; @@ -27,6 +32,7 @@ export const PLOT_TICK_LENGTH = 6; const UNIT_DISPLAY_SPAN = 2; const COLLAPSED_SPAN = 1e-12; const COINCIDENT_LABEL_OFFSET = 14; +const RECONSTRUCTION_LABEL_OFFSET = 12; export type LeftoverMapPlottablePair = { pair_kind: LeftoverPair["pair_kind"]; @@ -34,6 +40,7 @@ export type LeftoverMapPlottablePair = { post_title: string; criterion_code: string; leftover_distance?: number | null; + leftover_map_reconstruction?: number | null; leftover_map_person_axis_1?: number | null; leftover_map_person_axis_2?: number | null; leftover_map_item_axis_1?: number | null; @@ -59,8 +66,11 @@ export type LeftoverMapPlotSegment = { x2: number; y2: number; distanceLabel: string | null; + reconstructionLabel: string | null; labelX: number; labelY: number; + reconstructionX: number; + reconstructionY: number; }; export type LeftoverMapPlotTick = { @@ -274,6 +284,19 @@ export function layoutLeftoverMapPlot( pair.leftover_map_item_axis_2 as number, ); const distanceLabel = formatLeftoverMapDistance(pair.leftover_distance); + const reconstructionLabel = formatLeftoverMapReconstruction( + pair.leftover_map_reconstruction, + ); + const labelPosition = leftoverMapSegmentLabelPosition( + personPos.x, + personPos.y, + itemPos.x, + itemPos.y, + ); + const reconstructionY = + reconstructionLabel !== null && distanceLabel !== null + ? labelPosition.labelY + RECONSTRUCTION_LABEL_OFFSET + : labelPosition.labelY; segments.push({ pairKind: pair.pair_kind === "farthest" ? "farthest" : "closest", postId: pair.post_id, @@ -283,7 +306,10 @@ export function layoutLeftoverMapPlot( x2: itemPos.x, y2: itemPos.y, distanceLabel, - ...leftoverMapSegmentLabelPosition(personPos.x, personPos.y, itemPos.x, itemPos.y), + reconstructionLabel, + reconstructionX: labelPosition.labelX, + reconstructionY, + ...labelPosition, }); } diff --git a/pyproject.toml b/pyproject.toml index 7744aef87..6c49d361e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.28.0" +version = "2.29.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 f94e79cf4..5b5f91f43 100644 --- a/uv.lock +++ b/uv.lock @@ -685,7 +685,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.28.0" +version = "2.29.0" source = { editable = "." } dependencies = [ { name = "certifi" }, From 79ec22f0a4b7236ab06500c7bf8f392da6a9419b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 11:14:53 +0000 Subject: [PATCH 09/23] feat(reports): leftover-map explained leftover share on pair segments (v2.30.0) 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 explained leftover share e = R̂²/R² (ADR 0273). 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, pair segments that match d on the pair row, pair segments that match R̂ on the pair row, and pair segments that match R̂²/R² on the pair row; click a post marker or a pair button opens that post. A missing or non-finite e omits that explained leftover share caption. A share greater than 1 is shown, never clamped. Do not invent e from R̂ and R or from plotted coordinates. Never invent a leftover score or a theta. No new columns. --- AGENTS.md | 8 +- ARCHITECTURE.md | 6 +- ....0-leftover-map-segment-explained-share.md | 14 ++ CHANGELOG.md | 14 ++ CLAUDE.md | 2 +- docs/adr/0048-persist-lsirm-leftover-pairs.md | 7 +- docs/adr/0049-leftover-pair-report-ui.md | 8 +- docs/adr/0201-leftover-map-reconstruction.md | 4 +- docs/adr/0266-leftover-map-explained-share.md | 4 +- docs/adr/0268-leftover-map-graphic-display.md | 7 +- .../adr/0271-leftover-map-segment-distance.md | 4 +- ...272-leftover-map-segment-reconstruction.md | 6 +- ...73-leftover-map-segment-explained-share.md | 126 ++++++++++++++++++ docs/product-technical-gap-baseline.md | 33 +++++ docs/storybook-inventory.md | 4 +- frontend/package.json | 2 +- .../components/LeftoverMapPlot.stories.tsx | 1 + .../src/components/LeftoverMapPlot.test.tsx | 36 ++++- frontend/src/components/LeftoverMapPlot.tsx | 30 ++++- frontend/src/components/LeftoverPairList.tsx | 3 +- frontend/src/i18n.test.ts | 25 +++- frontend/src/i18n.ts | 20 +-- frontend/src/leftoverMapPlotLayout.test.ts | 89 +++++++++++++ frontend/src/leftoverMapPlotLayout.ts | 37 ++++- pyproject.toml | 2 +- uv.lock | 2 +- 26 files changed, 444 insertions(+), 50 deletions(-) create mode 100644 CHANGELOG.d/2.30.0-leftover-map-segment-explained-share.md create mode 100644 docs/adr/0273-leftover-map-segment-explained-share.md diff --git a/AGENTS.md b/AGENTS.md index bb4b7f118..266618122 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 / 0271 / 0272) are computed in `lineageweave/leftover_pairs.py` from the +0163 / 0164 / 0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269 / 0270 / 0271 / 0272 / 0273) 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 @@ -305,7 +305,11 @@ invent `d` from plotted coordinates. ADR 0272 captions leftover-map pair segments with persisted leftover-map reconstruction `R̂` so the pair-row badge matches the graphic; a missing or non-finite `R̂` omits that reconstruction caption and does not invent `R̂` from plotted -coordinates. When `R`, `R̂`, `U`, `x`, +coordinates. ADR 0273 captions leftover-map pair segments with persisted +leftover-map explained leftover share `e` so the pair-row `R̂²/R²` +badge matches the graphic; a missing or non-finite `e` omits that +explained leftover share caption and does not invent `e` from `R̂` and +`R` or 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 d0751f382..a071f7a8b 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 / 0271 / 0272) persist to `report_leftover_pair` with signed residual `R`, +0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269 / 0270 / 0271 / 0272 / 0273) 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 @@ -648,7 +648,9 @@ 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. ADR 0271 captions leftover-map pair segments with persisted leftover-map distance `d`. ADR 0272 captions leftover-map -pair segments with persisted leftover-map reconstruction `R̂`. Click a post marker or a pair button +pair segments with persisted leftover-map reconstruction `R̂`. ADR 0273 +captions leftover-map pair segments with persisted leftover-map +explained leftover share `e`. 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.30.0-leftover-map-segment-explained-share.md b/CHANGELOG.d/2.30.0-leftover-map-segment-explained-share.md new file mode 100644 index 000000000..e80d477a1 --- /dev/null +++ b/CHANGELOG.d/2.30.0-leftover-map-segment-explained-share.md @@ -0,0 +1,14 @@ +## 2.30.0 — Leftover-map explained leftover share on graphic-display pair segments + +- Caption leftover-map graphic-display pair segments with persisted + leftover-map explained leftover share `e = R̂² / R²` (ADR 0273). 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, pair segments that match `d` on the pair row, pair + segments that match `R̂` on the pair row, and pair segments that match + `R̂²/R²` on the pair row; click a post marker or a pair button opens + that post. A missing or non-finite `e` omits that explained leftover + share caption. A share greater than 1 is shown, never clamped. Do not + invent `e` from `R̂` and `R` or from plotted coordinates. Never invent + a leftover score. Never invent a theta. No new columns. diff --git a/CHANGELOG.md b/CHANGELOG.md index f0ac22e3e..271c89353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ 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 explained leftover share + `e = R̂² / R²` (ADR 0273 / v2.30.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, + pair segments that match `d` on the pair row, pair segments that + match `R̂` on the pair row, and pair segments that match `R̂²/R²` + on the pair row; click a post marker or a pair button opens that + post. A missing or non-finite `e` omits that explained leftover + share caption. A share greater than 1 is shown, never clamped. Do + not invent `e` from `R̂` and `R` or from plotted coordinates. Never + invent a leftover score or a theta. No new columns. + - Period leftover pairs now caption leftover-map graphic-display pair segments with persisted leftover-map reconstruction `R̂` (ADR 0272 / v2.29.0). After `make seed`, closest and farthest leftover pairs sit diff --git a/CLAUDE.md b/CLAUDE.md index 801a79c6f..c4dad11ee 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 / 0271 / 0272), occupational construct catalog search +(ADR 0048–0164 / 0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269 / 0270 / 0271 / 0272 / 0273), 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 88c7b67b5..3c0adc8a5 100644 --- a/docs/adr/0048-persist-lsirm-leftover-pairs.md +++ b/docs/adr/0048-persist-lsirm-leftover-pairs.md @@ -15,7 +15,8 @@ [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 0271](0271-leftover-map-segment-distance.md) (leftover-map distance on pair segments); -[ADR 0272](0272-leftover-map-segment-reconstruction.md) (leftover-map reconstruction on pair segments) +[ADR 0272](0272-leftover-map-segment-reconstruction.md) (leftover-map reconstruction on pair segments); +[ADR 0273](0273-leftover-map-segment-explained-share.md) (leftover-map explained leftover share on pair segments) ## Context @@ -76,7 +77,9 @@ adds no columns. ADR 0270 ticks leftover-map axes at persisted `ξ` / `ζ` coordinates; it adds no columns. ADR 0271 captions leftover-map pair segments with persisted leftover-map distance `d`; it adds no columns. ADR 0272 captions leftover-map pair segments with persisted -leftover-map reconstruction `R̂`; it adds no columns. +leftover-map reconstruction `R̂`; it adds no columns. ADR 0273 captions +leftover-map pair segments with persisted leftover-map explained leftover +share `e`; 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 880dd15d2..39cca48bd 100644 --- a/docs/adr/0049-leftover-pair-report-ui.md +++ b/docs/adr/0049-leftover-pair-report-ui.md @@ -16,7 +16,8 @@ [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 0271](0271-leftover-map-segment-distance.md) (leftover-map distance on pair segments); -[ADR 0272](0272-leftover-map-segment-reconstruction.md) (leftover-map reconstruction on pair segments) +[ADR 0272](0272-leftover-map-segment-reconstruction.md) (leftover-map reconstruction on pair segments); +[ADR 0273](0273-leftover-map-segment-explained-share.md) (leftover-map explained leftover share on pair segments) ## Context @@ -45,7 +46,8 @@ that post. Leftover-map axis share captions those leftover-map axes when finite (ADR 0269). Leftover-map axis ticks name persisted `ξ` / `ζ` coordinates (ADR 0270). Pair segments name persisted leftover-map distance `d` (ADR 0271). Pair segments name persisted leftover-map -reconstruction `R̂` (ADR 0272). The pair renders every available finite measurement. +reconstruction `R̂` (ADR 0272). Pair segments name persisted leftover-map +explained leftover share `e` (ADR 0273). 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 @@ -80,6 +82,8 @@ Leftover-map distance on pair segments is [ADR 0271](0271-leftover-map-segment-distance.md). Leftover-map reconstruction on pair segments is [ADR 0272](0272-leftover-map-segment-reconstruction.md). +Leftover-map explained leftover share on pair segments is +[ADR 0273](0273-leftover-map-segment-explained-share.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 38619765a..fa1677bb6 100644 --- a/docs/adr/0201-leftover-map-reconstruction.md +++ b/docs/adr/0201-leftover-map-reconstruction.md @@ -6,7 +6,9 @@ **Amended by:** [ADR 0267](0267-leftover-map-coordinates.md) (leftover-map coordinates ξ, ζ); [ADR 0272](0272-leftover-map-segment-reconstruction.md) -(leftover-map reconstruction on graphic-display pair segments) +(leftover-map reconstruction on graphic-display pair segments); +[ADR 0273](0273-leftover-map-segment-explained-share.md) +(leftover-map explained leftover share on graphic-display pair segments) Amends [ADR 0048](0048-persist-lsirm-leftover-pairs.md), [ADR 0049](0049-leftover-pair-report-ui.md), and diff --git a/docs/adr/0266-leftover-map-explained-share.md b/docs/adr/0266-leftover-map-explained-share.md index 81a66105e..f05454772 100644 --- a/docs/adr/0266-leftover-map-explained-share.md +++ b/docs/adr/0266-leftover-map-explained-share.md @@ -4,7 +4,9 @@ **Date:** 2026-08-28 **Amended by:** [ADR 0267](0267-leftover-map-coordinates.md) -(leftover-map coordinates ξ, ζ) +(leftover-map coordinates ξ, ζ); +[ADR 0273](0273-leftover-map-segment-explained-share.md) +(leftover-map explained leftover share on graphic-display pair segments) Amends [ADR 0048](0048-persist-lsirm-leftover-pairs.md) and [ADR 0049](0049-leftover-pair-report-ui.md). Independent of leftover-map diff --git a/docs/adr/0268-leftover-map-graphic-display.md b/docs/adr/0268-leftover-map-graphic-display.md index bc62f6315..9182aab35 100644 --- a/docs/adr/0268-leftover-map-graphic-display.md +++ b/docs/adr/0268-leftover-map-graphic-display.md @@ -10,7 +10,9 @@ [ADR 0271](0271-leftover-map-segment-distance.md) (leftover-map distance on pair segments); [ADR 0272](0272-leftover-map-segment-reconstruction.md) -(leftover-map reconstruction on pair segments) +(leftover-map reconstruction on pair segments); +[ADR 0273](0273-leftover-map-segment-explained-share.md) +(leftover-map explained leftover share on pair segments) Amends [ADR 0049](0049-leftover-pair-report-ui.md) and [ADR 0267](0267-leftover-map-coordinates.md). Independent of leftover-map @@ -86,7 +88,8 @@ 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). Pair segments name persisted leftover-map distance `d` (ADR 0271). Pair segments name -persisted leftover-map reconstruction `R̂` (ADR 0272). When coordinates, reconstruction, and +persisted leftover-map reconstruction `R̂` (ADR 0272). Pair segments name +persisted leftover-map explained leftover share `e` (ADR 0273). 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/0271-leftover-map-segment-distance.md b/docs/adr/0271-leftover-map-segment-distance.md index 786fe2785..f4ed9a3d7 100644 --- a/docs/adr/0271-leftover-map-segment-distance.md +++ b/docs/adr/0271-leftover-map-segment-distance.md @@ -4,7 +4,9 @@ **Date:** 2026-08-29 **Amended by:** [ADR 0272](0272-leftover-map-segment-reconstruction.md) -(leftover-map reconstruction on pair segments) +(leftover-map reconstruction on pair segments); +[ADR 0273](0273-leftover-map-segment-explained-share.md) +(leftover-map explained leftover share on pair segments) Amends [ADR 0268](0268-leftover-map-graphic-display.md) and [ADR 0049](0049-leftover-pair-report-ui.md). Independent of leftover-map diff --git a/docs/adr/0272-leftover-map-segment-reconstruction.md b/docs/adr/0272-leftover-map-segment-reconstruction.md index 2406d601e..2dcede993 100644 --- a/docs/adr/0272-leftover-map-segment-reconstruction.md +++ b/docs/adr/0272-leftover-map-segment-reconstruction.md @@ -3,6 +3,9 @@ **Decision status:** Accepted **Date:** 2026-08-29 +**Amended by:** [ADR 0273](0273-leftover-map-segment-explained-share.md) +(leftover-map explained leftover share on pair segments) + Amends [ADR 0268](0268-leftover-map-graphic-display.md) and [ADR 0049](0049-leftover-pair-report-ui.md). Independent of leftover-map distance on pair segments ([ADR 0271](0271-leftover-map-segment-distance.md)), @@ -78,7 +81,8 @@ 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, pair segments name persisted leftover-map distance `d` -and persisted leftover-map reconstruction `R̂`; click a post marker +and persisted leftover-map reconstruction `R̂`, and pair segments name +persisted leftover-map explained leftover share `e` (ADR 0273); 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, still name `d 0.00` when that distance is diff --git a/docs/adr/0273-leftover-map-segment-explained-share.md b/docs/adr/0273-leftover-map-segment-explained-share.md new file mode 100644 index 000000000..3330e3104 --- /dev/null +++ b/docs/adr/0273-leftover-map-segment-explained-share.md @@ -0,0 +1,126 @@ +# ADR 0273 — Name leftover-map explained leftover share 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 +reconstruction on pair segments +([ADR 0272](0272-leftover-map-segment-reconstruction.md)), leftover-map +distance on pair segments ([ADR 0271](0271-leftover-map-segment-distance.md)), +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 persistence +([ADR 0266](0266-leftover-map-explained-share.md)), leftover-map +unexplained leftover share +([ADR 0233](0233-leftover-map-unexplained-share.md)), leftover-map +reconstruction persistence ([ADR 0201](0201-leftover-map-reconstruction.md)), +and leftover-map axis share persistence ([ADR 0148](0148-leftover-map-axis-share.md)). + +## Context + +ADR 0266 already persists leftover-map explained leftover share +`e = R̂² / R²` of raw residual after two-axis Gabriel reconstruction. +ADR 0201 already persists leftover-map reconstruction `R̂`. ADR 0272 +already names persisted `R̂` on the leftover-map graphic-display pair +segment. The segment still has no explained leftover share label, so a +buyer who reads `R̂²/R² 0.76` on the pair row cannot match that square +share to the graphic pair. Hiding `e` on the segment lets leftover-map +reconstruction `R̂`, leftover residual `R`, or leftover-map axis share +`x` be read as the leftover the truncated map explains even after +`R̂` is named on the segment. When `R`, `R̂`, `U`, `x`, `s`, and `e` +are finite, `e + s + x = 1`; the graphic must name the same persisted +`e` the pair row already shows. + +This increment names leftover-map graphic-display pair segments with +persisted leftover-map explained leftover share. It does not add +columns. It does not recompute `e` from `R̂` and `R` or from plotted +coordinates. It does not persist leftover-map inner product, cosine, or +length as separate columns. 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 **0273** so it does not +collide with leftover-map reconstruction on pair segments (0272), +leftover-map distance on pair segments (0271), 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 persistence +(0266 / migration 0244), leftover-map unexplained leftover share +(0233 / migration 0233), leftover-map reconstruction persistence +(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 explained leftover +share formatter as the pair-row `R̂²/R²` badge. A missing or +non-finite `e` omits that explained leftover share caption and keeps +the connecting line and any leftover-map distance or reconstruction +caption. Rank-0 origin cells still name `R̂²/R² 0.00` when that +persisted share is finite. A finite share greater than 1 is shown, +never clamped. Do not invent `e` from `R̂` and `R` or from plotted +coordinates. 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 explained leftover +share 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, pair segments name persisted leftover-map distance `d`, +persisted leftover-map reconstruction `R̂`, and persisted leftover-map +explained leftover share `e`; 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, still name `d 0.00` when that distance is persisted, still name +`R̂ 0.00` when that reconstruction is persisted, and still name +`R̂²/R² 0.00` when that explained leftover share is persisted. When +`R`, `R̂`, `U`, `x`, `s`, and `e` are all finite, `e + s + x = 1` +remains the same identity already persisted by ADR 0266. + +## 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 persistence, leftover-map unexplained +leftover, leftover-map cross share, leftover-map unexplained leftover +share, leftover-map explained leftover share persistence, leftover-map +coordinate persistence, leftover-map graphic display, leftover-map axis +share on the graphic display, leftover-map coordinate ticks, +leftover-map distance on pair segments, and leftover-map reconstruction +on pair segments. + +## 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. Gabriel +reconstruction of the leftover cell is the two-axis inner product +`R̂ = ξ_{1:2} · ζ_{1:2}`. Explained leftover share is the square share +`e = R̂² / R²` of that leftover.) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index eaccefe7b..ae32121c1 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,38 @@ # Product & Technical Gap Baseline +> Exact-head loop overlay: 2026-08-29 20:00 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 +> + distance (v2.24.0–v2.28.0 / ADR 0267–0271) is on +> `9bdd3e4d6e47a34ac22fca228b8d2a23d8cecca9` with auto-merge SQUASH +> armed. Independent APPROVE is still required for protected main. +> #801 leftover-map reconstruction on pair segments (v2.29.0 / ADR 0272) +> squash-merged onto `feat/leftover-map-segment-distance-v2280` (not +> onto #782); v2.29 is replayed onto the leftover-coordinates branch as +> cherry-pick `5f21538e`. Do not squash-merge stacked leftover PRs onto +> an unprotected leftover base. 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 (`state_reason` still +> 403). GitHub writes through `gh`/MCP succeed (comment/create-PR/ +> auto-merge). Copilot review is not independent APPROVE. Do not +> self-approve. +> +> Next buyer increment on this cycle: leftover-map explained leftover +> share `e` on graphic-display pair segments (ADR 0273 / v2.30.0) +> delivered locally on +> `feat/leftover-map-segment-explained-share-v2300`. Caption each +> closest/farthest segment with persisted leftover-map explained leftover +> share so the pair-row `R̂²/R²` badge matches the graphic. UI-only; no +> new columns. Missing/non-finite `e` omits that explained leftover +> share caption. A share greater than 1 is shown, never clamped. Do not +> invent `e` from `R̂` and `R` or from plotted coordinates. Do not +> invent leftover scores. Stack onto leftover branch +> `feat/leftover-map-coordinates-v2240`; leave the PR open for +> independent review. The following buyer increment is leftover-map +> unexplained leftover share `s` on pair segments so `e + s + x = 1` +> is graphic-auditable. + > Exact-head loop overlay: 2026-08-29 16:20 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 473302280..e26bd5c3b 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, pair-segment `d` to leftover-map distance, and pair-segment `R̂` to leftover-map reconstruction, 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, `d 0.00`, and `R̂ 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, pair segments name persisted leftover-map distance, and pair segments name persisted leftover-map reconstruction. | `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, pair-segment `d` to leftover-map distance, pair-segment `R̂` to leftover-map reconstruction, and pair-segment `R̂²/R²` to leftover-map explained leftover share, 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, `d 0.00`, `R̂ 0.00`, and `R̂²/R² 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, pair segments name persisted leftover-map distance, pair segments name persisted leftover-map reconstruction, and pair segments name persisted leftover-map explained leftover 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 f62380df7..8a01b19f8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.29.0", + "version": "2.30.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/LeftoverMapPlot.stories.tsx b/frontend/src/components/LeftoverMapPlot.stories.tsx index 94ec42fdd..aad15812e 100644 --- a/frontend/src/components/LeftoverMapPlot.stories.tsx +++ b/frontend/src/components/LeftoverMapPlot.stories.tsx @@ -81,6 +81,7 @@ export const RankZeroOrigin: Story = { expected_response: 1, leftover_map_rank: 0, leftover_map_reconstruction: 0, + leftover_map_explained_share: 0, leftover_map_person_axis_1: 0, leftover_map_person_axis_2: 0, leftover_map_item_axis_1: 0, diff --git a/frontend/src/components/LeftoverMapPlot.test.tsx b/frontend/src/components/LeftoverMapPlot.test.tsx index 1b211a5c1..8b2067652 100644 --- a/frontend/src/components/LeftoverMapPlot.test.tsx +++ b/frontend/src/components/LeftoverMapPlot.test.tsx @@ -13,6 +13,7 @@ const PAIRS: LeftoverPair[] = [ leftover_distance: 0.12, leftover_residual: 0.4, leftover_map_reconstruction: 0.248, + 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, @@ -26,6 +27,7 @@ const PAIRS: LeftoverPair[] = [ leftover_distance: 1.84, leftover_residual: -1.1, leftover_map_reconstruction: -0.95, + 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, @@ -47,7 +49,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. Pair segments name leftover-map distance d and leftover-map reconstruction R̂. 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, leftover-map reconstruction R̂, and leftover-map explained leftover share e. Click a post marker to open that post. The plot does not invent a leftover score.", ), ).toBeInTheDocument(); expect(screen.getByText("Post ξ")).toBeInTheDocument(); @@ -61,6 +63,8 @@ describe("LeftoverMapPlot", () => { expect(screen.getByLabelText("leftover-map distance d 1.84")).toBeInTheDocument(); expect(screen.getByLabelText("leftover-map reconstruction R̂ +0.25")).toBeInTheDocument(); expect(screen.getByLabelText("leftover-map reconstruction R̂ −0.95")).toBeInTheDocument(); + expect(screen.getByLabelText("leftover-map explained leftover share R̂²/R² 0.76")).toBeInTheDocument(); + expect(screen.getByLabelText("leftover-map explained leftover share R̂²/R² 0.60")).toBeInTheDocument(); const postMarker = screen.getByRole("button", { name: "Open leftover-map post Public post at ξ (+0.50, +0.10)", @@ -108,6 +112,7 @@ describe("LeftoverMapPlot", () => { leftover_residual: 0, leftover_map_rank: 0, leftover_map_reconstruction: 0, + leftover_map_explained_share: 0, leftover_map_person_axis_1: 0, leftover_map_person_axis_2: 0, leftover_map_item_axis_1: 0, @@ -131,6 +136,7 @@ describe("LeftoverMapPlot", () => { expect(screen.queryByLabelText("leftover-map axis 1 tick +1.00")).not.toBeInTheDocument(); expect(screen.getByLabelText("leftover-map distance d 0.00")).toBeInTheDocument(); expect(screen.getByLabelText("leftover-map reconstruction R̂ 0.00")).toBeInTheDocument(); + expect(screen.getByLabelText("leftover-map explained leftover share R̂²/R² 0.00")).toBeInTheDocument(); }); it("captions leftover-map axes with persisted leftover-map axis share", () => { @@ -216,4 +222,32 @@ describe("LeftoverMapPlot", () => { expect(screen.queryByLabelText(/leftover-map reconstruction/)).not.toBeInTheDocument(); expect(screen.getByLabelText("leftover-map distance d 0.12")).toBeInTheDocument(); }); + + it("omits leftover-map explained leftover share on a pair segment when e is missing", () => { + render( + , + ); + expect(screen.queryByLabelText(/leftover-map explained leftover share/)).not.toBeInTheDocument(); + expect(screen.getByLabelText("leftover-map reconstruction R̂ +0.25")).toBeInTheDocument(); + expect(screen.getByLabelText("leftover-map distance d 0.12")).toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/LeftoverMapPlot.tsx b/frontend/src/components/LeftoverMapPlot.tsx index 4dbb7d562..29ffed490 100644 --- a/frontend/src/components/LeftoverMapPlot.tsx +++ b/frontend/src/components/LeftoverMapPlot.tsx @@ -12,6 +12,7 @@ import { LEFTOVER_MAP_PLOT_CAPTION, LEFTOVER_MAP_PLOT_POST_ACTION, LEFTOVER_MAP_PLOT_SEGMENT_DISTANCE, + LEFTOVER_MAP_PLOT_SEGMENT_EXPLAINED_SHARE, LEFTOVER_MAP_PLOT_SEGMENT_RECONSTRUCTION, LEFTOVER_MAP_PLOT_TICK, } from "../leftoverMapPlotLayout"; @@ -49,13 +50,15 @@ function leftoverMapPlotAxisText( * 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. Pair segments name persisted leftover-map - * distance ``d`` and leftover-map reconstruction ``R̂`` so the pair-row - * badges match the graphic. Omit that distance caption when ``d`` is - * missing or non-finite. Omit that reconstruction caption when ``R̂`` - * 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. + * distance ``d``, leftover-map reconstruction ``R̂``, and leftover-map + * explained leftover share ``e`` so the pair-row badges match the graphic. + * Omit that distance caption when ``d`` is missing or non-finite. Omit + * that reconstruction caption when ``R̂`` is missing or non-finite. Omit + * that explained leftover share caption when ``e`` 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, @@ -167,6 +170,19 @@ export function LeftoverMapPlot({ {segment.reconstructionLabel} ) : null} + {segment.explainedShareLabel !== null ? ( + + {segment.explainedShareLabel} + + ) : null} ))} {layout.items.map((marker) => ( diff --git a/frontend/src/components/LeftoverPairList.tsx b/frontend/src/components/LeftoverPairList.tsx index 21adb84be..75e500821 100644 --- a/frontend/src/components/LeftoverPairList.tsx +++ b/frontend/src/components/LeftoverPairList.tsx @@ -62,7 +62,8 @@ export type LeftoverPairListProps = { * leftover-map axes at persisted ``ξ`` / ``ζ`` so the pair-row badge matches * the plot. ADR 0271 names persisted leftover-map distance ``d`` on those * pair segments. ADR 0272 names persisted leftover-map reconstruction - * ``R̂`` on those pair segments. Every badge still + * ``R̂`` on those pair segments. ADR 0273 names persisted leftover-map + * explained leftover share ``e`` 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 30494e5a3..6feba1e48 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -68,7 +68,8 @@ describe("i18n", () => { "leftover-map axis {axis} tick {value}", "leftover-map distance {label}", "leftover-map reconstruction {label}", - "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d and leftover-map reconstruction R̂. Click a post marker to open that post. The plot does not invent a leftover score.", + "leftover-map explained leftover share {label}", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d, leftover-map reconstruction R̂, and leftover-map explained leftover share e. 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.", @@ -332,22 +333,22 @@ describe("i18n", () => { it.each([ [ "ko", - "IRT 주효과 이후 잔여 지도입니다. 축 눈금은 저장된 잔여 지도 좌표입니다. 쌍 선분은 잔여 지도 거리 d와 잔여 지도 재구성 R̂입니다. 글 표식을 눌러 그 글을 여세요. 이 그림은 잔여 점수를 만들어내지 않습니다.", + "IRT 주효과 이후 잔여 지도입니다. 축 눈금은 저장된 잔여 지도 좌표입니다. 쌍 선분은 잔여 지도 거리 d, 잔여 지도 재구성 R̂, 잔여 지도 설명 잔여 점유율 e입니다. 글 표식을 눌러 그 글을 여세요. 이 그림은 잔여 점수를 만들어내지 않습니다.", ], - ["zh", "IRT 主效应后的残差图。轴刻度标出已保存的残差图坐标。配对线段标出残差图距离 d 与残差图重建 R̂。点击帖子标记打开该帖子。此图不会虚构残差分数。"], + ["zh", "IRT 主效应后的残差图。轴刻度标出已保存的残差图坐标。配对线段标出残差图距离 d、残差图重建 R̂ 与残差图已解释残差份额 e。点击帖子标记打开该帖子。此图不会虚构残差分数。"], [ "ja", - "IRT主効果後の残差マップです。軸目盛は保存済みの残差マップ座標です。ペア線分は残差マップ距離 d と残差マップ再構成 R̂ です。投稿マーカーをクリックしてその投稿を開いてください。この図は残差スコアを作りません。", + "IRT主効果後の残差マップです。軸目盛は保存済みの残差マップ座標です。ペア線分は残差マップ距離 d、残差マップ再構成 R̂、残差マップ説明済み残差割合 e です。投稿マーカーをクリックしてその投稿を開いてください。この図は残差スコアを作りません。", ], [ "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. Đoạn cặp ghi khoảng cách bản đồ phần dư d và tái dựng bản đồ phần dư R̂. 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, tái dựng bản đồ phần dư R̂ và phần dư giải thích e. 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. Pair segments name leftover-map distance d and leftover-map reconstruction R̂. 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, leftover-map reconstruction R̂, and leftover-map explained leftover share e. Click a post marker to open that post. The plot does not invent a leftover score.", ), ).toBe(expected); }); @@ -382,6 +383,18 @@ describe("i18n", () => { expect(tf("leftover-map reconstruction {label}", { label: "R̂ +0.35" })).toBe(expected); }); + it.each([ + ["ko", "잔여 지도 설명 잔여 점유율 R̂²/R² 0.76"], + ["zh", "残差图已解释残差份额 R̂²/R² 0.76"], + ["ja", "残差マップ説明済み残差割合 R̂²/R² 0.76"], + ["vi", "phần dư giải thích bản đồ phần dư R̂²/R² 0.76"], + ] as const)("formats leftover-map segment explained leftover share in %s", (locale, expected) => { + setLocale(locale); + expect(tf("leftover-map explained leftover share {label}", { label: "R̂²/R² 0.76" })).toBe( + expected, + ); + }); + it.each([ ["ko", "잔여 지도 축 1 (82%)"], ["zh", "残差图轴 1 (82%)"], diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 3ef421547..52f64363c 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -593,8 +593,9 @@ const TRANSLATIONS: Partial>> = { "leftover-map axis {axis} tick {value}": "잔여 지도 축 {axis} 눈금 {value}", "leftover-map distance {label}": "잔여 지도 거리 {label}", "leftover-map reconstruction {label}": "잔여 지도 재구성 {label}", - "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d and leftover-map reconstruction R̂. Click a post marker to open that post. The plot does not invent a leftover score.": - "IRT 주효과 이후 잔여 지도입니다. 축 눈금은 저장된 잔여 지도 좌표입니다. 쌍 선분은 잔여 지도 거리 d와 잔여 지도 재구성 R̂입니다. 글 표식을 눌러 그 글을 여세요. 이 그림은 잔여 점수를 만들어내지 않습니다.", + "leftover-map explained leftover share {label}": "잔여 지도 설명 잔여 점유율 {label}", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d, leftover-map reconstruction R̂, and leftover-map explained leftover share e. Click a post marker to open that post. The plot does not invent a leftover score.": + "IRT 주효과 이후 잔여 지도입니다. 축 눈금은 저장된 잔여 지도 좌표입니다. 쌍 선분은 잔여 지도 거리 d, 잔여 지도 재구성 R̂, 잔여 지도 설명 잔여 점유율 e입니다. 글 표식을 눌러 그 글을 여세요. 이 그림은 잔여 점수를 만들어내지 않습니다.", "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.": @@ -1174,8 +1175,9 @@ const TRANSLATIONS: Partial>> = { "leftover-map axis {axis} tick {value}": "残差图轴 {axis} 刻度 {value}", "leftover-map distance {label}": "残差图距离 {label}", "leftover-map reconstruction {label}": "残差图重建 {label}", - "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d and leftover-map reconstruction R̂. Click a post marker to open that post. The plot does not invent a leftover score.": - "IRT 主效应后的残差图。轴刻度标出已保存的残差图坐标。配对线段标出残差图距离 d 与残差图重建 R̂。点击帖子标记打开该帖子。此图不会虚构残差分数。", + "leftover-map explained leftover share {label}": "残差图已解释残差份额 {label}", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d, leftover-map reconstruction R̂, and leftover-map explained leftover share e. Click a post marker to open that post. The plot does not invent a leftover score.": + "IRT 主效应后的残差图。轴刻度标出已保存的残差图坐标。配对线段标出残差图距离 d、残差图重建 R̂ 与残差图已解释残差份额 e。点击帖子标记打开该帖子。此图不会虚构残差分数。", "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.": @@ -1759,8 +1761,9 @@ const TRANSLATIONS: Partial>> = { "leftover-map axis {axis} tick {value}": "残差マップ軸 {axis} 目盛 {value}", "leftover-map distance {label}": "残差マップ距離 {label}", "leftover-map reconstruction {label}": "残差マップ再構成 {label}", - "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d and leftover-map reconstruction R̂. Click a post marker to open that post. The plot does not invent a leftover score.": - "IRT主効果後の残差マップです。軸目盛は保存済みの残差マップ座標です。ペア線分は残差マップ距離 d と残差マップ再構成 R̂ です。投稿マーカーをクリックしてその投稿を開いてください。この図は残差スコアを作りません。", + "leftover-map explained leftover share {label}": "残差マップ説明済み残差割合 {label}", + "Leftover map after IRT main effects. Axis ticks name persisted leftover-map coordinates. Pair segments name leftover-map distance d, leftover-map reconstruction R̂, and leftover-map explained leftover share e. Click a post marker to open that post. The plot does not invent a leftover score.": + "IRT主効果後の残差マップです。軸目盛は保存済みの残差マップ座標です。ペア線分は残差マップ距離 d、残差マップ再構成 R̂、残差マップ説明済み残差割合 e です。投稿マーカーをクリックしてその投稿を開いてください。この図は残差スコアを作りません。", "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.": @@ -2344,8 +2347,9 @@ const TRANSLATIONS: Partial>> = { "leftover-map axis {axis} tick {value}": "vạch trục bản đồ phần dư {axis} {value}", "leftover-map distance {label}": "khoảng cách bản đồ phần dư {label}", "leftover-map reconstruction {label}": "tái dựng 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 and leftover-map reconstruction R̂. 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 và tái dựng bản đồ phần dư R̂. 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 explained leftover share {label}": "phần dư giải thí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, leftover-map reconstruction R̂, and leftover-map explained leftover share e. 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, tái dựng bản đồ phần dư R̂ và phần dư giải thích e. 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 aeef24827..ac6fe5ed8 100644 --- a/frontend/src/leftoverMapPlotLayout.test.ts +++ b/frontend/src/leftoverMapPlotLayout.test.ts @@ -24,6 +24,7 @@ function pair( leftover_map_item_axis_2: -0.02, leftover_distance: 0.12, leftover_map_reconstruction: 0.248, + leftover_map_explained_share: 0.76, ...overrides, }; } @@ -345,6 +346,94 @@ describe("layoutLeftoverMapPlot", () => { expect(layout?.segments[0]?.reconstructionLabel).toBe("R\u0302 0.00"); expect(layout?.segments[0]?.distanceLabel).toBe("d 0.00"); }); + + it("names persisted leftover-map explained leftover share 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, + leftover_map_reconstruction: -0.95, + leftover_map_explained_share: 0.6, + }), + ], + criterionLabel, + ); + expect(layout?.segments.map((segment) => segment.explainedShareLabel)).toEqual([ + "R\u0302\u00b2/R\u00b2 0.76", + "R\u0302\u00b2/R\u00b2 0.60", + ]); + expect(layout?.segments[0]?.explainedShareX).toBeCloseTo(layout?.segments[0]?.labelX ?? 0, 5); + expect(layout?.segments[0]?.explainedShareY).toBeGreaterThan( + layout?.segments[0]?.reconstructionY ?? 0, + ); + }); + + it("omits a leftover-map explained leftover share caption when e is missing or non-finite", () => { + const layout = layoutLeftoverMapPlot( + [ + pair({ leftover_map_explained_share: null }), + pair({ + pair_kind: "farthest", + leftover_map_explained_share: 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.explainedShareLabel)).toEqual([null, null]); + expect(layout?.segments.map((segment) => segment.reconstructionLabel)).toEqual([ + "R\u0302 +0.25", + "R\u0302 +0.25", + ]); + }); + + it("does not invent leftover-map explained leftover share from reconstruction or residual", () => { + const layout = layoutLeftoverMapPlot( + [ + pair({ + leftover_map_person_axis_1: 1, + leftover_map_person_axis_2: 0, + leftover_map_item_axis_1: 1, + leftover_map_item_axis_2: 0, + leftover_map_reconstruction: 1, + leftover_map_explained_share: 0.76, + }), + ], + criterionLabel, + ); + expect(layout?.segments[0]?.explainedShareLabel).toBe("R\u0302\u00b2/R\u00b2 0.76"); + expect(layout?.segments[0]?.explainedShareLabel).not.toBe("R\u0302\u00b2/R\u00b2 1.00"); + }); + + it("names rank-0 origin explained leftover share e 0.00 when that persisted value is finite", () => { + 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, + leftover_distance: 0, + leftover_map_reconstruction: 0, + leftover_map_explained_share: 0, + }), + ], + criterionLabel, + ); + expect(layout?.segments[0]?.explainedShareLabel).toBe("R\u0302\u00b2/R\u00b2 0.00"); + expect(layout?.segments[0]?.reconstructionLabel).toBe("R\u0302 0.00"); + expect(layout?.segments[0]?.distanceLabel).toBe("d 0.00"); + }); }); describe("formatLeftoverMapDistance", () => { diff --git a/frontend/src/leftoverMapPlotLayout.ts b/frontend/src/leftoverMapPlotLayout.ts index 5475ed298..7cc26c807 100644 --- a/frontend/src/leftoverMapPlotLayout.ts +++ b/frontend/src/leftoverMapPlotLayout.ts @@ -1,17 +1,19 @@ /** 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) - * and persisted leftover-map reconstruction ``R̂`` (ADR 0272). + * Pair segments name persisted leftover-map distance ``d`` (ADR 0271), + * persisted leftover-map reconstruction ``R̂`` (ADR 0272), and persisted + * leftover-map explained leftover share ``e`` (ADR 0273). */ import { formatLeftoverMapCoordinatePair } from "./leftoverMapCoordinates"; +import { formatLeftoverMapExplainedShare } from "./leftoverMapExplainedShare"; import { formatLeftoverMapReconstruction } from "./leftoverMapReconstruction"; 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. Pair segments name leftover-map distance d and leftover-map reconstruction R̂. 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, leftover-map reconstruction R̂, and leftover-map explained leftover share e. 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}"; @@ -25,6 +27,9 @@ export const LEFTOVER_MAP_PLOT_SEGMENT_DISTANCE = export const LEFTOVER_MAP_PLOT_SEGMENT_RECONSTRUCTION = "leftover-map reconstruction {label}"; +export const LEFTOVER_MAP_PLOT_SEGMENT_EXPLAINED_SHARE = + "leftover-map explained leftover share {label}"; + export const PLOT_WIDTH = 480; export const PLOT_HEIGHT = 320; export const PLOT_PADDING = 40; @@ -41,6 +46,7 @@ export type LeftoverMapPlottablePair = { criterion_code: string; leftover_distance?: number | null; leftover_map_reconstruction?: 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; @@ -67,10 +73,13 @@ export type LeftoverMapPlotSegment = { y2: number; distanceLabel: string | null; reconstructionLabel: string | null; + explainedShareLabel: string | null; labelX: number; labelY: number; reconstructionX: number; reconstructionY: number; + explainedShareX: number; + explainedShareY: number; }; export type LeftoverMapPlotTick = { @@ -202,6 +211,10 @@ function leftoverMapSegmentLabelPosition( }; } +function leftoverMapStackedCaptionY(labelY: number, stackedAbove: number): number { + return stackedAbove > 0 ? labelY + stackedAbove * RECONSTRUCTION_LABEL_OFFSET : labelY; +} + export function layoutLeftoverMapPlot( pairs: LeftoverMapPlottablePair[], criterionLabel: (criterionCode: string) => string, @@ -287,16 +300,23 @@ export function layoutLeftoverMapPlot( const reconstructionLabel = formatLeftoverMapReconstruction( pair.leftover_map_reconstruction, ); + const explainedShareLabel = formatLeftoverMapExplainedShare( + pair.leftover_map_explained_share, + ); const labelPosition = leftoverMapSegmentLabelPosition( personPos.x, personPos.y, itemPos.x, itemPos.y, ); - const reconstructionY = - reconstructionLabel !== null && distanceLabel !== null - ? labelPosition.labelY + RECONSTRUCTION_LABEL_OFFSET - : labelPosition.labelY; + const reconstructionY = leftoverMapStackedCaptionY( + labelPosition.labelY, + distanceLabel !== null && reconstructionLabel !== null ? 1 : 0, + ); + const explainedShareY = leftoverMapStackedCaptionY( + labelPosition.labelY, + (distanceLabel !== null ? 1 : 0) + (reconstructionLabel !== null ? 1 : 0), + ); segments.push({ pairKind: pair.pair_kind === "farthest" ? "farthest" : "closest", postId: pair.post_id, @@ -307,8 +327,11 @@ export function layoutLeftoverMapPlot( y2: itemPos.y, distanceLabel, reconstructionLabel, + explainedShareLabel, reconstructionX: labelPosition.labelX, reconstructionY, + explainedShareX: labelPosition.labelX, + explainedShareY, ...labelPosition, }); } diff --git a/pyproject.toml b/pyproject.toml index 6c49d361e..839a61c8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.29.0" +version = "2.30.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 5b5f91f43..b21381b50 100644 --- a/uv.lock +++ b/uv.lock @@ -685,7 +685,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.29.0" +version = "2.30.0" source = { editable = "." } dependencies = [ { name = "certifi" }, From 68cf70fc612dae5d6564fc7817db8bc123678f99 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 31 Aug 2026 06:31:26 +0900 Subject: [PATCH 10/23] docs(gaps): refresh protected stack evidence --- docs/product-technical-gap-baseline.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index ae32121c1..13f1f2679 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,31 @@ # Product & Technical Gap Baseline +> Exact-head loop overlay: 2026-08-31 07:00 KST. Protected `main` is +> `cb187cadee5fb6c46d8a944815ccc154a1e028d1` (leftover-map coordinates, +> graphic, axis share, ticks, and segment distance through v2.28.0, #782). +> The live inventory contains 55 open PRs and 10 open issues. Parent #782 is +> protected-merged; its first child #802 was therefore retargeted from the +> merged feature branch to `main`, and its squash-history conflict is repaired +> without force-push while preserving ADR 0272/0273 and v2.29.0/v2.30.0. +> #771, #772, #774, and #780 remain exact-head blocked: normal squash +> auto-merge is armed, no independent APPROVE exists, and failed hosted gates +> remain fail-closed rather than being treated as product failures. #780's +> focused Voice authority tests, frontend suite, lint, and build pass locally; +> its desktop and mobile evidence distinguish the carrying Post action from +> the separately authorized derivation-evidence action. This is candidate +> evidence only: authenticated PostgreSQL API and rendered runtime proof are +> still required before marking the Voice acceptance boundary complete. +> +> Highest buyer-visible active gap in this slice: finish the protected parent- +> first delivery of persisted leftover-map reconstruction `R̂` and explained +> share `e` on graphic pair segments (#802), without recomputing either value in +> LineageWeave. The implementation consumes the already persisted owner result, +> omits missing/non-finite values, and has focused regression coverage. Parallel +> stacked branches currently reuse release numbers (v2.46.0, v2.47.0, v2.50.0, +> and v2.61.0); those branches are not release-ready and must be serialized and +> renumbered after their common parent merges. No stacked child may be retargeted +> or inherit Checks before its own parent is protected-merged. + > Exact-head loop overlay: 2026-08-29 20:00 KST. Protected `main` is > `fc13acaa20adca11968238e398d4aafcf62b6cee` (v2.23.0 leftover-map > explained leftover share, #775). Open ready PRs still lack independent From 6c5a2267b182a603f598035580fc07d87b0818f9 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 31 Aug 2026 10:16:18 +0900 Subject: [PATCH 11/23] fix(reports): preserve distinct leftover-map ticks Signed-off-by: Codex --- docs/product-technical-gap-baseline.md | 2 +- .../src/components/LeftoverMapPlot.test.tsx | 19 +++++++++++++++++++ frontend/src/components/LeftoverMapPlot.tsx | 2 +- frontend/src/i18n.test.ts | 4 ++-- frontend/src/i18n.ts | 4 ++-- frontend/src/leftoverMapPlotLayout.test.ts | 16 ++++++++++++++++ frontend/src/leftoverMapPlotLayout.ts | 12 ++++++------ 7 files changed, 47 insertions(+), 12 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 13f1f2679..bdbb3439c 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,6 +1,6 @@ # Product & Technical Gap Baseline -> Exact-head loop overlay: 2026-08-31 07:00 KST. Protected `main` is +> Exact-head loop overlay: 2026-08-31 06:31 KST. Protected `main` is > `cb187cadee5fb6c46d8a944815ccc154a1e028d1` (leftover-map coordinates, > graphic, axis share, ticks, and segment distance through v2.28.0, #782). > The live inventory contains 55 open PRs and 10 open issues. Parent #782 is diff --git a/frontend/src/components/LeftoverMapPlot.test.tsx b/frontend/src/components/LeftoverMapPlot.test.tsx index 8b2067652..4e830bc8e 100644 --- a/frontend/src/components/LeftoverMapPlot.test.tsx +++ b/frontend/src/components/LeftoverMapPlot.test.tsx @@ -139,6 +139,25 @@ describe("LeftoverMapPlot", () => { expect(screen.getByLabelText("leftover-map explained leftover share R̂²/R² 0.00")).toBeInTheDocument(); }); + it("renders every distinct persisted tick when rounded labels match", () => { + render( + , + ); + + expect(screen.getByLabelText("leftover-map axis 1 tick 0.00")).toBeInTheDocument(); + expect(screen.getAllByLabelText("leftover-map axis 1 tick +0.00")).toHaveLength(2); + }); + it("captions leftover-map axes with persisted leftover-map axis share", () => { render( {layout.ticks.map((tick) => ( diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index 6feba1e48..4ef3a02a2 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -342,7 +342,7 @@ describe("i18n", () => { ], [ "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. Đoạn cặp ghi khoảng cách bản đồ phần dư d, tái dựng bản đồ phần dư R̂ và phần dư giải thích e. 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, tái dựng bản đồ phần dư R̂ và tỷ phần phần dư được giải thích e. 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); @@ -387,7 +387,7 @@ describe("i18n", () => { ["ko", "잔여 지도 설명 잔여 점유율 R̂²/R² 0.76"], ["zh", "残差图已解释残差份额 R̂²/R² 0.76"], ["ja", "残差マップ説明済み残差割合 R̂²/R² 0.76"], - ["vi", "phần dư giải thích bản đồ phần dư R̂²/R² 0.76"], + ["vi", "tỷ phần phần dư được giải thích trên bản đồ phần dư R̂²/R² 0.76"], ] as const)("formats leftover-map segment explained leftover share in %s", (locale, expected) => { setLocale(locale); expect(tf("leftover-map explained leftover share {label}", { label: "R̂²/R² 0.76" })).toBe( diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 52f64363c..b4816f24c 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -2347,9 +2347,9 @@ const TRANSLATIONS: Partial>> = { "leftover-map axis {axis} tick {value}": "vạch trục bản đồ phần dư {axis} {value}", "leftover-map distance {label}": "khoảng cách bản đồ phần dư {label}", "leftover-map reconstruction {label}": "tái dựng bản đồ phần dư {label}", - "leftover-map explained leftover share {label}": "phần dư giải thích bản đồ phần dư {label}", + "leftover-map explained leftover share {label}": "tỷ phần phần dư được giải thích trên 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, leftover-map reconstruction R̂, and leftover-map explained leftover share e. 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, tái dựng bản đồ phần dư R̂ và phần dư giải thích e. 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, tái dựng bản đồ phần dư R̂ và tỷ phần phần dư được giải thích e. 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 ac6fe5ed8..c6285adcf 100644 --- a/frontend/src/leftoverMapPlotLayout.test.ts +++ b/frontend/src/leftoverMapPlotLayout.test.ts @@ -210,6 +210,22 @@ describe("layoutLeftoverMapPlot", () => { ); }); + it("keeps distinct persisted coordinates that share a rounded tick label", () => { + const layout = layoutLeftoverMapPlot( + [ + pair({ + leftover_map_person_axis_1: 0.001, + leftover_map_item_axis_1: 0.004, + }), + ], + criterionLabel, + ); + const axis1 = layout?.ticks.filter((tick) => tick.axis === 1); + expect(axis1?.map((tick) => tick.value)).toEqual([0, 0.001, 0.004]); + expect(axis1?.map((tick) => tick.label)).toEqual(["0.00", "+0.00", "+0.00"]); + expect(new Set(axis1?.map((tick) => tick.x))).toHaveProperty("size", 3); + }); + it("names persisted leftover-map distance on pair segments without inventing a leftover score", () => { const layout = layoutLeftoverMapPlot( [ diff --git a/frontend/src/leftoverMapPlotLayout.ts b/frontend/src/leftoverMapPlotLayout.ts index 7cc26c807..faf2f7013 100644 --- a/frontend/src/leftoverMapPlotLayout.ts +++ b/frontend/src/leftoverMapPlotLayout.ts @@ -148,17 +148,17 @@ function toSvg( } function uniqueCoordinateTicks(values: number[]): { value: number; label: string }[] { - const byLabel = new Map(); + const seen = new Set(); + const ticks: { value: number; label: string }[] = []; for (const value of values) { const label = formatSignedLeftoverValue(value); - if (label === null) { + if (label === null || seen.has(value)) { continue; } - if (!byLabel.has(label)) { - byLabel.set(label, value); - } + seen.add(value); + ticks.push({ value, label }); } - return [...byLabel.entries()].map(([label, value]) => ({ value, label })); + return ticks; } function leftoverMapCoordinateTicks( From 88270da43cdb4a75349d10ab612b99690df59f45 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 31 Aug 2026 11:46:12 +0900 Subject: [PATCH 12/23] fix(release): synchronize runtime provenance version Signed-off-by: Codex --- lineageweave/__init__.py | 2 +- tests/test_package_version.py | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 tests/test_package_version.py diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 45c371fa7..ff0c6867e 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -130,4 +130,4 @@ "serialize_lineage_analysis_result", ] -__version__ = "2.20.0" +__version__ = "2.30.0" diff --git a/tests/test_package_version.py b/tests/test_package_version.py new file mode 100644 index 000000000..cb85f136e --- /dev/null +++ b/tests/test_package_version.py @@ -0,0 +1,20 @@ +"""Release identity stays consistent across Python and frontend artifacts.""" + +from __future__ import annotations + +import json +import tomllib +from pathlib import Path + +from lineageweave import __version__ + + +def test_release_versions_are_synchronized() -> None: + """Runtime provenance must use the same version as shipped package metadata.""" + repository_root = Path(__file__).resolve().parents[1] + project = tomllib.loads((repository_root / "pyproject.toml").read_text(encoding="utf-8")) + frontend = json.loads( + (repository_root / "frontend" / "package.json").read_text(encoding="utf-8") + ) + + assert __version__ == project["project"]["version"] == frontend["version"] From 1840378b8bdffe5b53c69589490da83d29df1184 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 31 Aug 2026 11:48:02 +0900 Subject: [PATCH 13/23] docs(gaps): record release identity repair Signed-off-by: Codex --- docs/product-technical-gap-baseline.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index bdbb3439c..659e10952 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 release-contract overlay: 2026-08-31 11:46 KST. +> Protected `main` remains `cb187cadee5fb6c46d8a944815ccc154a1e028d1`; +> sixty-nine open PRs and ten open issues were enumerated in a fresh snapshot. +> PR #780 remains on normal squash auto-merge with its exact-head product and +> frontend checks successful, resolved review threads, and no independent +> approval; its central required-workflow failures remain external protected +> delivery blockers, so no merge SHA or protected-main Voice acceptance is +> claimed. The next direct-to-`main` candidate, PR #802, exposed a release +> identity conflict: Python package metadata and the frontend named v2.30.0 +> while `lineageweave.__version__` still named v2.20.0. Because that runtime +> value is persisted as Event Lineage reconstruction provenance, the mismatch +> was not documentation-only. Implementation revision +> `88270da43cdb4a75349d10ab612b99690df59f45` synchronizes the runtime identity +> and adds a repository contract test that binds Python metadata, runtime +> provenance, and frontend release identity. Fourteen focused persistence, +> channel-evidence, and release-contract tests pass. This later evidence-only +> overlay does not transfer those results to its own head; current-head hosted +> Checks and independent approval remain pending, and normal squash auto-merge +> is retained. No mathematical value is recomputed in LineageWeave: the +> leftover-map UI continues to render only persisted owner-produced values and +> leaves missing or non-finite evidence unavailable. No self-approval, bypass, +> force push, arbitrary weighting, or hidden-evidence substitution is used. +> > Exact-head loop overlay: 2026-08-31 06:31 KST. Protected `main` is > `cb187cadee5fb6c46d8a944815ccc154a1e028d1` (leftover-map coordinates, > graphic, axis share, ticks, and segment distance through v2.28.0, #782). From 1e972d7f65b31e3c73b29b3d8c996aec20600971 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 31 Aug 2026 12:15:50 +0900 Subject: [PATCH 14/23] fix(reports): separate dense segment captions Signed-off-by: Codex --- frontend/src/components/LeftoverMapPlot.css | 4 ++++ frontend/src/leftoverMapPlotLayout.test.ts | 9 +++++---- frontend/src/leftoverMapPlotLayout.ts | 16 ++++++++++++++-- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/LeftoverMapPlot.css b/frontend/src/components/LeftoverMapPlot.css index c96a691ae..619fddaee 100644 --- a/frontend/src/components/LeftoverMapPlot.css +++ b/frontend/src/components/LeftoverMapPlot.css @@ -97,6 +97,10 @@ .leftover-map-plot-segment-label { fill: var(--color-text); font-size: 0.65rem; + paint-order: stroke; + stroke: var(--color-background); + stroke-width: 3px; + stroke-linejoin: round; } .leftover-map-plot-item { diff --git a/frontend/src/leftoverMapPlotLayout.test.ts b/frontend/src/leftoverMapPlotLayout.test.ts index c6285adcf..4edb69055 100644 --- a/frontend/src/leftoverMapPlotLayout.test.ts +++ b/frontend/src/leftoverMapPlotLayout.test.ts @@ -244,10 +244,11 @@ describe("layoutLeftoverMapPlot", () => { 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, - ); + const segment = layout?.segments[0]; + const midpointX = ((segment?.x1 ?? 0) + (segment?.x2 ?? 0)) / 2; + const midpointY = ((segment?.y1 ?? 0) + (segment?.y2 ?? 0)) / 2; + expect(Math.hypot((segment?.labelX ?? 0) - midpointX, (segment?.labelY ?? 0) - midpointY)) + .toBeCloseTo(24, 5); }); it("omits a leftover-map distance caption when d is missing or non-finite", () => { diff --git a/frontend/src/leftoverMapPlotLayout.ts b/frontend/src/leftoverMapPlotLayout.ts index faf2f7013..65371362c 100644 --- a/frontend/src/leftoverMapPlotLayout.ts +++ b/frontend/src/leftoverMapPlotLayout.ts @@ -37,6 +37,7 @@ export const PLOT_TICK_LENGTH = 6; const UNIT_DISPLAY_SPAN = 2; const COLLAPSED_SPAN = 1e-12; const COINCIDENT_LABEL_OFFSET = 14; +const SEGMENT_LABEL_OFFSET = 24; const RECONSTRUCTION_LABEL_OFFSET = 12; export type LeftoverMapPlottablePair = { @@ -205,9 +206,20 @@ function leftoverMapSegmentLabelPosition( y2: number, ): { labelX: number; labelY: number } { const coincident = Math.abs(x1 - x2) < 0.01 && Math.abs(y1 - y2) < 0.01; + const midpointX = (x1 + x2) / 2; + const midpointY = (y1 + y2) / 2; + if (coincident) { + return { + labelX: midpointX, + labelY: midpointY - COINCIDENT_LABEL_OFFSET, + }; + } + const deltaX = x2 - x1; + const deltaY = y2 - y1; + const length = Math.hypot(deltaX, deltaY); return { - labelX: (x1 + x2) / 2, - labelY: coincident ? (y1 + y2) / 2 - COINCIDENT_LABEL_OFFSET : (y1 + y2) / 2, + labelX: midpointX - (deltaY / length) * SEGMENT_LABEL_OFFSET, + labelY: midpointY + (deltaX / length) * SEGMENT_LABEL_OFFSET, }; } From 4e79c2801a8530d54075d6e8445eb7a2ff2ef719 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 31 Aug 2026 12:16:47 +0900 Subject: [PATCH 15/23] docs(gaps): record rendered segment audit Signed-off-by: Codex --- docs/product-technical-gap-baseline.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 659e10952..5db914a52 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,18 @@ # Product & Technical Gap Baseline +> Exact-head rendered-UX overlay: 2026-08-31 12:16 KST. PR #802 +> implementation revision `1e972d7f6` retains persisted-only `d`, `R̂`, and +> `e` projection while moving dense segment captions off their plot segments +> and painting them with the existing background token. The focused 32-test +> plot selection, frontend lint, production build, and Storybook build pass. +> Fresh synthetic `Reports/LeftoverMapPlot/ClosestAndFarthest` renders were +> inspected at 1440-by-1000 and 390-by-844: the three captions are separated +> from each segment and remain legible over axes; the fixed-width plot remains +> intentionally horizontally scrollable at mobile width. Screenshots remain +> temporary audit evidence and are not committed. This later documentation +> overlay does not inherit those results; exact-head GitHub Checks and an +> independent approval remain required before protected merge. +> > Exact-head release-contract overlay: 2026-08-31 11:46 KST. > Protected `main` remains `cb187cadee5fb6c46d8a944815ccc154a1e028d1`; > sixty-nine open PRs and ten open issues were enumerated in a fresh snapshot. From be91cf843c047917d5c7aba776985e8017139761 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 31 Aug 2026 12:20:32 +0900 Subject: [PATCH 16/23] fix(reports): bound segment caption stacks Signed-off-by: Codex --- frontend/src/leftoverMapPlotLayout.test.ts | 28 ++++++++++++++++++++++ frontend/src/leftoverMapPlotLayout.ts | 18 ++++++++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/frontend/src/leftoverMapPlotLayout.test.ts b/frontend/src/leftoverMapPlotLayout.test.ts index 4edb69055..51f266522 100644 --- a/frontend/src/leftoverMapPlotLayout.test.ts +++ b/frontend/src/leftoverMapPlotLayout.test.ts @@ -393,6 +393,34 @@ describe("layoutLeftoverMapPlot", () => { ); }); + it("keeps complete caption stacks inside top and bottom plot edges", () => { + const layout = layoutLeftoverMapPlot( + [ + pair({ + leftover_map_person_axis_1: -1, + leftover_map_person_axis_2: -1, + leftover_map_item_axis_1: 1, + leftover_map_item_axis_2: -1, + }), + pair({ + pair_kind: "farthest", + post_id: "post-demo-spec", + criterion_code: "negative_sentiment", + leftover_map_person_axis_1: 1, + leftover_map_person_axis_2: 1, + leftover_map_item_axis_1: -1, + leftover_map_item_axis_2: 1, + }), + ], + criterionLabel, + ); + + for (const segment of layout?.segments ?? []) { + expect(segment.labelY).toBeGreaterThanOrEqual(12); + expect(segment.explainedShareY).toBeLessThanOrEqual((layout?.height ?? 0) - 4); + } + }); + it("omits a leftover-map explained leftover share caption when e is missing or non-finite", () => { const layout = layoutLeftoverMapPlot( [ diff --git a/frontend/src/leftoverMapPlotLayout.ts b/frontend/src/leftoverMapPlotLayout.ts index 65371362c..bf5a06955 100644 --- a/frontend/src/leftoverMapPlotLayout.ts +++ b/frontend/src/leftoverMapPlotLayout.ts @@ -39,6 +39,8 @@ const COLLAPSED_SPAN = 1e-12; const COINCIDENT_LABEL_OFFSET = 14; const SEGMENT_LABEL_OFFSET = 24; const RECONSTRUCTION_LABEL_OFFSET = 12; +const SEGMENT_LABEL_TOP_INSET = 12; +const SEGMENT_LABEL_BOTTOM_INSET = 4; export type LeftoverMapPlottablePair = { pair_kind: LeftoverPair["pair_kind"]; @@ -321,12 +323,23 @@ export function layoutLeftoverMapPlot( itemPos.x, itemPos.y, ); + const captionCount = [distanceLabel, reconstructionLabel, explainedShareLabel].filter( + (label) => label !== null, + ).length; + const maximumLabelY = + height - + SEGMENT_LABEL_BOTTOM_INSET - + Math.max(0, captionCount - 1) * RECONSTRUCTION_LABEL_OFFSET; + const labelY = Math.min( + Math.max(labelPosition.labelY, SEGMENT_LABEL_TOP_INSET), + maximumLabelY, + ); const reconstructionY = leftoverMapStackedCaptionY( - labelPosition.labelY, + labelY, distanceLabel !== null && reconstructionLabel !== null ? 1 : 0, ); const explainedShareY = leftoverMapStackedCaptionY( - labelPosition.labelY, + labelY, (distanceLabel !== null ? 1 : 0) + (reconstructionLabel !== null ? 1 : 0), ); segments.push({ @@ -345,6 +358,7 @@ export function layoutLeftoverMapPlot( explainedShareX: labelPosition.labelX, explainedShareY, ...labelPosition, + labelY, }); } From 35aa0ab86cb25ee80c6e6a94be3cd917967d6178 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 16:26:40 +0900 Subject: [PATCH 17/23] test(reports): reproduce short-canvas caption clipping --- .../src/leftoverMapPlotSmallCanvas.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 frontend/src/leftoverMapPlotSmallCanvas.test.ts diff --git a/frontend/src/leftoverMapPlotSmallCanvas.test.ts b/frontend/src/leftoverMapPlotSmallCanvas.test.ts new file mode 100644 index 000000000..75d18ab13 --- /dev/null +++ b/frontend/src/leftoverMapPlotSmallCanvas.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { layoutLeftoverMapPlot, type LeftoverMapPlottablePair } from "./leftoverMapPlotLayout"; + +const completeCaptionPair: LeftoverMapPlottablePair = { + pair_kind: "closest", + post_id: "post-small-canvas", + post_title: "Small canvas post", + criterion_code: "sales_lead_quality", + leftover_distance: 0.12, + leftover_map_reconstruction: 0.25, + leftover_map_explained_share: 0.76, + leftover_map_person_axis_1: -1, + leftover_map_person_axis_2: -1, + leftover_map_item_axis_1: 1, + leftover_map_item_axis_2: -1, +}; + +describe("layoutLeftoverMapPlot small-canvas caption bounds", () => { + it("keeps a complete three-caption stack inside the requested 20px height", () => { + const layout = layoutLeftoverMapPlot( + [completeCaptionPair], + (criterionCode) => criterionCode, + { height: 20 }, + ); + + expect(layout).not.toBeNull(); + expect(layout?.height).toBe(20); + const segment = layout?.segments[0]; + expect(segment).toBeDefined(); + expect(segment?.labelY).toBeGreaterThanOrEqual(12); + expect(segment?.reconstructionY).toBeGreaterThanOrEqual(segment?.labelY ?? 0); + expect(segment?.explainedShareY).toBeGreaterThanOrEqual(segment?.reconstructionY ?? 0); + expect(segment?.explainedShareY).toBeLessThanOrEqual(16); + }); +}); From 85b3233e203eb4f45460a0856a8f02fb02fdc4cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 16:27:21 +0900 Subject: [PATCH 18/23] fix(reports): bound caption stacks on short canvases --- frontend/src/leftoverMapPlotLayout.ts | 33 +++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/frontend/src/leftoverMapPlotLayout.ts b/frontend/src/leftoverMapPlotLayout.ts index bf5a06955..638ad9513 100644 --- a/frontend/src/leftoverMapPlotLayout.ts +++ b/frontend/src/leftoverMapPlotLayout.ts @@ -225,8 +225,26 @@ function leftoverMapSegmentLabelPosition( }; } -function leftoverMapStackedCaptionY(labelY: number, stackedAbove: number): number { - return stackedAbove > 0 ? labelY + stackedAbove * RECONSTRUCTION_LABEL_OFFSET : labelY; +function leftoverMapStackedCaptionY( + labelY: number, + stackedAbove: number, + captionSpacing = RECONSTRUCTION_LABEL_OFFSET, +): number { + return stackedAbove > 0 ? labelY + stackedAbove * captionSpacing : labelY; +} + +function leftoverMapCaptionSpacing(height: number, captionCount: number): number { + if (captionCount <= 1) { + return 0; + } + const availableStackHeight = Math.max( + 0, + height - SEGMENT_LABEL_TOP_INSET - SEGMENT_LABEL_BOTTOM_INSET, + ); + return Math.min( + RECONSTRUCTION_LABEL_OFFSET, + availableStackHeight / (captionCount - 1), + ); } export function layoutLeftoverMapPlot( @@ -326,10 +344,13 @@ export function layoutLeftoverMapPlot( const captionCount = [distanceLabel, reconstructionLabel, explainedShareLabel].filter( (label) => label !== null, ).length; - const maximumLabelY = + const captionSpacing = leftoverMapCaptionSpacing(height, captionCount); + const maximumLabelY = Math.max( + SEGMENT_LABEL_TOP_INSET, height - - SEGMENT_LABEL_BOTTOM_INSET - - Math.max(0, captionCount - 1) * RECONSTRUCTION_LABEL_OFFSET; + SEGMENT_LABEL_BOTTOM_INSET - + Math.max(0, captionCount - 1) * captionSpacing, + ); const labelY = Math.min( Math.max(labelPosition.labelY, SEGMENT_LABEL_TOP_INSET), maximumLabelY, @@ -337,10 +358,12 @@ export function layoutLeftoverMapPlot( const reconstructionY = leftoverMapStackedCaptionY( labelY, distanceLabel !== null && reconstructionLabel !== null ? 1 : 0, + captionSpacing, ); const explainedShareY = leftoverMapStackedCaptionY( labelY, (distanceLabel !== null ? 1 : 0) + (reconstructionLabel !== null ? 1 : 0), + captionSpacing, ); segments.push({ pairKind: pair.pair_kind === "farthest" ? "farthest" : "closest", From 7cebe5e8c84ddf3553dc4871e8f0db194a6e09fa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 16:35:10 +0900 Subject: [PATCH 19/23] fix(stack): adopt protected Draft admission workflow --- .github/workflows/tests.yml | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5e58c7412..c26de5fc6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -3,14 +3,8 @@ name: Tests on: push: branches: [main] - paths-ignore: - - "docs/**" - - "*.md" pull_request: - types: [opened, synchronize, reopened, closed] - paths-ignore: - - "docs/**" - - "*.md" + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] permissions: contents: read @@ -19,17 +13,17 @@ concurrency: # A merged pull request can report the base ref on ``closed``. Keying PR # events by number lets that close run cancel an older queued synchronize # run for the same PR instead of consuming runners after the PR is closed. - group: tests-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: pytest: name: Full test suite - if: github.event_name != 'pull_request' || github.event.action != 'closed' + if: github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false) runs-on: ubuntu-latest services: postgres: - image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 + image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc4999588649806c4678c61a1 env: POSTGRES_PASSWORD: postgres ports: @@ -71,7 +65,7 @@ jobs: frontend: name: Frontend lint, test, build - if: github.event_name != 'pull_request' || github.event.action != 'closed' + if: github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false) runs-on: ubuntu-latest steps: - name: Checkout repository From 17402c49f34fdf5df373872904c7ddca5529c40d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 16:35:30 +0900 Subject: [PATCH 20/23] fix(stack): restore protected PostgreSQL image pin --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c26de5fc6..9a73249f6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest services: postgres: - image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc4999588649806c4678c61a1 + image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 env: POSTGRES_PASSWORD: postgres ports: From 0b61e372529275dedbef63c7bc7cad4018327229 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 16:35:46 +0900 Subject: [PATCH 21/23] fix(stack): adopt protected ontology admission contract --- .github/workflows/ontology-pages.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ontology-pages.yml b/.github/workflows/ontology-pages.yml index 05e7000bb..8cc734e27 100644 --- a/.github/workflows/ontology-pages.yml +++ b/.github/workflows/ontology-pages.yml @@ -2,6 +2,7 @@ name: Ontology Pages on: pull_request: + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] branches: [main] paths: - "docs/ontology/**" @@ -32,13 +33,14 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: validate: name: Validate ontology publication - if: github.event_name == 'pull_request' - concurrency: - group: ontology-pages-validation-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + if: github.event_name == 'pull_request' && github.event.action != 'closed' && github.event.pull_request.draft == false runs-on: ubuntu-latest steps: - name: Checkout repository @@ -82,9 +84,6 @@ jobs: publish: name: Publish ontology to GitHub Pages if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' - concurrency: - group: ontology-pages-publication - cancel-in-progress: false runs-on: ubuntu-latest permissions: contents: read From 3e19fa9e147ace5c7d3ccc124e6af31b2e19571d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 16:36:03 +0900 Subject: [PATCH 22/23] fix(stack): adopt protected PROV-O admission contract --- .github/workflows/prov-o-contract.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/prov-o-contract.yml b/.github/workflows/prov-o-contract.yml index eae453153..a289748c9 100644 --- a/.github/workflows/prov-o-contract.yml +++ b/.github/workflows/prov-o-contract.yml @@ -2,6 +2,7 @@ name: PROV-O contract on: pull_request: + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] branches: [main] paths: - "lineageweave/prov_o.py" @@ -28,12 +29,13 @@ permissions: contents: read concurrency: - group: prov-o-contract-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: standards-contract: name: Registry, inference, coverage, PostgreSQL + if: github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false) runs-on: ubuntu-latest services: postgres: From 0ead4cac77abd7ee667428c23441066d2d8d98e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 16:36:16 +0900 Subject: [PATCH 23/23] test(stack): adopt protected workflow contract --- tests/test_tests_workflow_contract.py | 58 ++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/tests/test_tests_workflow_contract.py b/tests/test_tests_workflow_contract.py index e6b48461a..b0953f6f9 100644 --- a/tests/test_tests_workflow_contract.py +++ b/tests/test_tests_workflow_contract.py @@ -1,17 +1,63 @@ -"""Regression contracts for the repository-local test workflow.""" +"""Regression contracts for repository-local workflow concurrency.""" from pathlib import Path -_WORKFLOW = Path(__file__).parents[1] / ".github" / "workflows" / "tests.yml" +_WORKFLOW_DIRECTORY = Path(__file__).parents[1] / ".github" / "workflows" +_PULL_REQUEST_GROUP = ( + "group: ${{ github.workflow }}-${{ github.repository }}-" + "${{ github.event_name == 'pull_request' && " + "github.event.pull_request.number || github.run_id }}" +) +_PULL_REQUEST_CANCELLATION = ( + "cancel-in-progress: ${{ github.event_name == 'pull_request' }}" +) +_PULL_REQUEST_TYPES = ( + "types: [opened, synchronize, reopened, ready_for_review, " + "converted_to_draft, closed]" +) +_DRAFT_ADMISSION = "github.event.pull_request.draft == false" def test_pull_request_concurrency_survives_closed_ref_change() -> None: """Key synchronize and closed events by PR number so close cancels stale work.""" - workflow = _WORKFLOW.read_text(encoding="utf-8") + workflow = (_WORKFLOW_DIRECTORY / "tests.yml").read_text(encoding="utf-8") - assert "types: [opened, synchronize, reopened, closed]" in workflow - assert "group: tests-${{ github.event.pull_request.number || github.ref }}" in workflow - assert "cancel-in-progress: true" in workflow + assert _PULL_REQUEST_TYPES in workflow assert workflow.count("github.event.action != 'closed'") == 2 + + +def test_pull_request_workflows_cancel_only_superseded_same_pr_runs() -> None: + """Scope PR cancellation by workflow, repository, and pull request number.""" + + for workflow_path in sorted(_WORKFLOW_DIRECTORY.glob("*.yml")): + workflow = workflow_path.read_text(encoding="utf-8") + if "pull_request:" not in workflow: + continue + assert _PULL_REQUEST_GROUP in workflow, workflow_path.name + assert _PULL_REQUEST_CANCELLATION in workflow, workflow_path.name + + +def test_draft_pull_requests_do_not_consume_repository_local_runners() -> None: + """Cancel stale draft runs while deferring expensive jobs until review readiness.""" + + expected_draft_guards = { + "tests.yml": 2, + "prov-o-contract.yml": 1, + "ontology-pages.yml": 1, + } + for workflow_name, expected_guard_count in expected_draft_guards.items(): + workflow = (_WORKFLOW_DIRECTORY / workflow_name).read_text(encoding="utf-8") + assert _PULL_REQUEST_TYPES in workflow, workflow_name + assert workflow.count(_DRAFT_ADMISSION) == expected_guard_count, workflow_name + + +def test_ontology_publication_runs_are_not_cancelled() -> None: + """Keep publication runs isolated and non-cancelling outside pull requests.""" + + workflow = (_WORKFLOW_DIRECTORY / "ontology-pages.yml").read_text( + encoding="utf-8" + ) + assert "github.run_id" in workflow + assert _PULL_REQUEST_CANCELLATION in workflow