From 2c21b7579c9e6e1497c00e94e948649b482b9ce0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 07:23:03 +0000 Subject: [PATCH 1/4] feat(reports): leftover-map graphic display (v2.25.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- 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 | 122 +++++++++++ .../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 | 139 +++++++++++++ frontend/src/leftoverMapPlotLayout.ts | 189 ++++++++++++++++++ pyproject.toml | 2 +- 24 files changed, 1100 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..fad004dbd --- /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 any pair lacks 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..8d2f5a3f9 --- /dev/null +++ b/frontend/src/components/LeftoverMapPlot.css @@ -0,0 +1,122 @@ +.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: 100%; + 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..389405955 --- /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..12883edef --- /dev/null +++ b/frontend/src/leftoverMapPlotLayout.test.ts @@ -0,0 +1,139 @@ +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("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..7a992e42c --- /dev/null +++ b/frontend/src/leftoverMapPlotLayout.ts @@ -0,0 +1,189 @@ +/** Gabriel leftover-map graphic display of persisted ``ξ_{1:2}`` / ``ζ_{1:2}``. */ + +import { formatLeftoverMapCoordinatePair } from "./leftoverMapCoordinates"; + +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: "closest" | "farthest"; + 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; + return { + x: pad + ((axis1 - minAxis) / scaleSpan) * innerW, + y: pad + ((minAxis + scaleSpan - axis2) / scaleSpan) * innerH, + }; +} + +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, + 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 e4bb4cf67c02154f51a542a0de6de00ae692808b Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 28 Aug 2026 17:58:41 +0900 Subject: [PATCH 2/4] fix(reports): preserve leftover-map distances Signed-off-by: Codex --- frontend/src/leftoverMapPlotLayout.test.ts | 33 ++++++++++++++++++++++ frontend/src/leftoverMapPlotLayout.ts | 14 ++++++--- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/frontend/src/leftoverMapPlotLayout.test.ts b/frontend/src/leftoverMapPlotLayout.test.ts index 12883edef..f3169dc44 100644 --- a/frontend/src/leftoverMapPlotLayout.test.ts +++ b/frontend/src/leftoverMapPlotLayout.test.ts @@ -82,6 +82,39 @@ describe("layoutLeftoverMapPlot", () => { 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( [ diff --git a/frontend/src/leftoverMapPlotLayout.ts b/frontend/src/leftoverMapPlotLayout.ts index 7a992e42c..eef687d5e 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}``. */ 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."; @@ -15,7 +16,7 @@ const UNIT_DISPLAY_SPAN = 2; const COLLAPSED_SPAN = 1e-12; export type LeftoverMapPlottablePair = { - pair_kind: "closest" | "farthest"; + pair_kind: LeftoverPair["pair_kind"]; post_id: string; post_title: string; criterion_code: string; @@ -81,9 +82,14 @@ function toSvg( ): { 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: pad + ((axis1 - minAxis) / scaleSpan) * innerW, - y: pad + ((minAxis + scaleSpan - axis2) / scaleSpan) * innerH, + x: offsetX + (axis1 - minAxis) * pixelsPerUnit, + y: offsetY + (minAxis + scaleSpan - axis2) * pixelsPerUnit, }; } @@ -159,7 +165,7 @@ export function layoutLeftoverMapPlot( }); } segments.push({ - pairKind: pair.pair_kind, + pairKind: pair.pair_kind === "farthest" ? "farthest" : "closest", postId: pair.post_id, criterionCode: pair.criterion_code, x1: personPos.x, From 93e96f9dad5f9650f6377d9e8271e330438b0342 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 28 Aug 2026 18:03:58 +0900 Subject: [PATCH 3/4] fix(reports): keep plot labels readable Signed-off-by: Codex --- frontend/src/components/LeftoverMapPlot.css | 3 ++- frontend/src/components/LeftoverMapPlot.tsx | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/LeftoverMapPlot.css b/frontend/src/components/LeftoverMapPlot.css index 8d2f5a3f9..d3dff89d6 100644 --- a/frontend/src/components/LeftoverMapPlot.css +++ b/frontend/src/components/LeftoverMapPlot.css @@ -59,7 +59,8 @@ .leftover-map-plot-svg { display: block; - width: 100%; + width: 480px; + max-width: none; height: auto; } diff --git a/frontend/src/components/LeftoverMapPlot.tsx b/frontend/src/components/LeftoverMapPlot.tsx index 389405955..998e96ac1 100644 --- a/frontend/src/components/LeftoverMapPlot.tsx +++ b/frontend/src/components/LeftoverMapPlot.tsx @@ -96,7 +96,7 @@ export function LeftoverMapPlot({ {layout.items.map((marker) => ( - + {marker.label} @@ -123,7 +123,7 @@ export function LeftoverMapPlot({ > - + {marker.label} From 96fc61b3bf83a38027fbc685ff6d73d900c08342 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 28 Aug 2026 18:38:17 +0900 Subject: [PATCH 4/4] docs(reports): correct leftover plot omission rule --- CHANGELOG.d/2.25.0-leftover-map-graphic-display.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.d/2.25.0-leftover-map-graphic-display.md b/CHANGELOG.d/2.25.0-leftover-map-graphic-display.md index fad004dbd..cf5a83c5e 100644 --- a/CHANGELOG.d/2.25.0-leftover-map-graphic-display.md +++ b/CHANGELOG.d/2.25.0-leftover-map-graphic-display.md @@ -5,7 +5,7 @@ `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 any pair lacks four finite leftover-map coordinates. Rank-0 + 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.