From 593b8d8246c73226e224d2a9d251668649cbb54f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 02:52:36 +0000 Subject: [PATCH 01/12] feat(reports): leftover-map singular values on leftover-axis report badges independently of leftover-map axis share (v2.82.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caption leftover-axis report badges with persisted leftover-map singular values σ_k independently of leftover-map axis share (ADR 0325). Period leftover-axis copy stays leftover axis {k} σ {value} when share is omitted so it stays distinct from leftover-axis leftover axis {k} σ {value} {share}%, from hyphen leftover-map axis {k} σ {value}, from comparison leftover-axis leftover map comparison leftover axis {k} σ {value}, and from comparison graphic leftover map comparison graphic leftover-map axis {k} σ {value}. Rank-0 unused axes still name σ 0.00. Share and singular value omit independently. Do not invent σ_k from leftover-map axis share. Do not invent leftover-map axis share from σ_k. Never invent a leftover score. Never invent a theta. --- AGENTS.md | 12 +- ARCHITECTURE.md | 5 +- .../2.82.0-leftover-map-axis-singular-only.md | 21 +++ CHANGELOG.md | 20 +++ CLAUDE.md | 2 +- docs/adr/0148-leftover-map-axis-share.md | 4 +- docs/adr/0322-leftover-map-axis-singular.md | 5 +- ...0323-leftover-map-compare-axis-singular.md | 3 + .../0324-leftover-map-plot-axis-singular.md | 4 +- .../0325-leftover-map-axis-singular-only.md | 120 ++++++++++++++++++ docs/product-technical-gap-baseline.md | 19 +++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 2 + frontend/src/App.tsx | 34 ++--- frontend/src/i18n.test.ts | 11 ++ frontend/src/i18n.ts | 4 + frontend/src/leftoverMapAxisBadge.test.ts | 60 ++++++++- frontend/src/leftoverMapAxisBadge.ts | 33 ++++- .../src/leftoverMapPlotAxisSingular.test.ts | 3 +- frontend/src/leftoverMapPlotLayout.test.ts | 1 + pyproject.toml | 2 +- 21 files changed, 340 insertions(+), 27 deletions(-) create mode 100644 CHANGELOG.d/2.82.0-leftover-map-axis-singular-only.md create mode 100644 docs/adr/0325-leftover-map-axis-singular-only.md diff --git a/AGENTS.md b/AGENTS.md index dd56245ab..ae33e2e06 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 / 0273 / 0274 / 0275 / 0276 / 0277 / 0278 / 0279 / 0280 / 0281 / 0282 / 0283 / 0284 / 0285 / 0286 / 0287 / 0288 / 0289 / 0290 / 0291 / 0292 / 0293 / 0294 / 0295 / 0296 / 0297 / 0298 / 0299 / 0300 / 0301 / 0302 / 0303 / 0304 / 0305 / 0306 / 0307 / 0308 / 0309 / 0310 / 0311 / 0312 / 0313 / 0314 / 0315 / 0316 / 0317 / 0318 / 0319 / 0320 / 0321 / 0322 / 0323 / 0324) are computed in `lineageweave/leftover_pairs.py` from the +0163 / 0164 / 0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269 / 0270 / 0271 / 0272 / 0273 / 0274 / 0275 / 0276 / 0277 / 0278 / 0279 / 0280 / 0281 / 0282 / 0283 / 0284 / 0285 / 0286 / 0287 / 0288 / 0289 / 0290 / 0291 / 0292 / 0293 / 0294 / 0295 / 0296 / 0297 / 0298 / 0299 / 0300 / 0301 / 0302 / 0303 / 0304 / 0305 / 0306 / 0307 / 0308 / 0309 / 0310 / 0311 / 0312 / 0313 / 0314 / 0315 / 0316 / 0317 / 0318 / 0319 / 0320 / 0321 / 0322 / 0323 / 0324 / 0325) 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 @@ -661,6 +661,13 @@ values `σ_k` when leftoverMapPlotAxisBadge returns a usable leftover-map axis c Distinct accessible name `leftover-map axis {axis} σ {value} ({share}%)`. Share and singular value omit independently. Rank-0 unused axes still name leftover-map axis `σ 0.00`. Do not invent leftover-map singular values from leftover-map axis share. +ADR 0325 captions leftover-axis report badges with persisted leftover-map singular +values `σ_k` independently of leftover-map axis share when leftoverMapAxisBadge +returns a usable leftover-axis caption. Distinct accessible name +`leftover axis {axis} σ {value}`. Share and singular value omit independently. +Rank-0 unused axes still name leftover-axis `σ 0.00`. Do not invent leftover-map +singular values from leftover-map axis share. Do not invent leftover-map axis +share from leftover-map singular values. When `R`, `R̂`, `U`, `x`, `s`, and `e` are finite, `e + s + x = 1`. When `Y`, `E`, and `R` are finite, `Y − E = R`. When `R`, `R̂`, and `U` are @@ -768,6 +775,9 @@ singular values `σ_k` when leftoverMapCompareAxisBadge returns a usable leftove Leftover-map graphic-display leftover-map singular values (ADR 0324) captions leftover-map graphic-display axes with persisted leftover-map singular values `σ_k` when leftoverMapPlotAxisBadge returns a usable leftover-map axis caption. +Leftover-axis report badge leftover-map singular values independently of leftover-map axis share +(ADR 0325) captions leftover-axis report badges with persisted leftover-map singular values +`σ_k` when leftoverMapAxisBadge returns a usable leftover-axis caption. Item complete-case coverage (ADR 0282) captions the leftover-map graphic with how many scored criteria entered the map. Item complete-case coverage on the pair list (ADR 0285) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 84746aa4a..ba4108f8a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -756,6 +756,8 @@ ADR 0323 captions leftover-axis report badges on the grouping comparison strip with persisted leftover-map singular values `σ_k`. ADR 0324 captions leftover-map graphic-display axes with persisted leftover-map singular values `σ_k`. +ADR 0325 captions leftover-axis report badges with persisted leftover-map +singular values `σ_k` independently of 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 @@ -809,7 +811,8 @@ graphic (ADR 0320), leftover-map singular values on the grouping comparison left graphic (ADR 0321), leftover-map singular values on leftover-axis report badges (ADR 0322), leftover-map singular values on leftover-axis report badges on the grouping comparison strip (ADR 0323), leftover-map singular values on leftover-map -graphic-display axes (ADR 0324); +graphic-display axes (ADR 0324), leftover-map singular values on leftover-axis +report badges independently of leftover-map axis share (ADR 0325); `POST .../rebuild` scores every grouping kind (post_admin). `make seed` folds A-100/B-200 Event Lineage fixtures (and the Riverbend calendar post) that already have constructed IRT cells into the same shared diff --git a/CHANGELOG.d/2.82.0-leftover-map-axis-singular-only.md b/CHANGELOG.d/2.82.0-leftover-map-axis-singular-only.md new file mode 100644 index 000000000..78d164006 --- /dev/null +++ b/CHANGELOG.d/2.82.0-leftover-map-axis-singular-only.md @@ -0,0 +1,21 @@ +## 2.82.0 — Leftover-map singular values on leftover-axis report badges independently of leftover-map axis share + +- Caption leftover-axis report badges with persisted leftover-map singular + values `σ_k` independently of leftover-map axis share (ADR 0325). After + `make seed`, leftover-axis report badges name persisted leftover-map + singular values when finite even when leftover-map axis share is omitted; + click a leftover pair opens that post. A missing or non-finite leftover-map + axis share omits that share caption and keeps `leftover axis {k} σ {value}`. + A missing, non-finite, or negative singular value omits that `σ` badge and + keeps `leftover axis {k} {share}%`. Rank-0 unused axes still name `σ 0.00`. + Leftover-axis report badges stay `leftover axis {k} σ {value}` when share is + omitted so they stay distinct from leftover-axis + `leftover axis {k} σ {value} {share}%`, from hyphen + `leftover-map axis {k} σ {value}`, from comparison leftover-axis + `leftover map comparison leftover axis {k} σ {value}`, and from comparison + graphic `leftover map comparison graphic leftover-map axis {k} σ {value}`. + Do not invent `σ_k` from leftover-map axis share. Do not invent leftover-map + axis share from `σ_k`. This increment does not change leftover-map singular + values on leftover-map graphic-display axes or on leftover-axis report badges + on the grouping comparison strip. A finite negative leftover is shown, never + clamped. Never invent a leftover score. Never invent a theta. No new columns. diff --git a/CHANGELOG.md b/CHANGELOG.md index f0ab37f05..c88b008df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,26 @@ All notable changes to this project are documented here. Format follows ### Added +- Period leftover pairs now caption leftover-axis report badges with persisted + leftover-map singular values `σ_k` independently of leftover-map axis share + (ADR 0325 / v2.82.0). After `make seed`, leftover-axis report badges name + persisted leftover-map singular values when finite even when leftover-map + axis share is omitted; click a leftover pair opens that post. A missing or + non-finite leftover-map axis share omits that share caption and keeps + `leftover axis {k} σ {value}`. A missing, non-finite, or negative singular + value omits that `σ` badge and keeps `leftover axis {k} {share}%`. Rank-0 + unused axes still name `σ 0.00`. Leftover-axis report badges stay + `leftover axis {k} σ {value}` when share is omitted so they stay distinct + from leftover-axis `leftover axis {k} σ {value} {share}%`, from hyphen + `leftover-map axis {k} σ {value}`, from comparison leftover-axis + `leftover map comparison leftover axis {k} σ {value}`, and from comparison + graphic `leftover map comparison graphic leftover-map axis {k} σ {value}`. + Do not invent `σ_k` from leftover-map axis share. Do not invent leftover-map + axis share from `σ_k`. This increment does not change leftover-map singular + values on leftover-map graphic-display axes or on leftover-axis report badges + on the grouping comparison strip. A finite negative leftover is shown, never + clamped. 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 singular values `σ_k` (ADR 0324 / v2.81.0). After `make seed`, leftover-map graphic-display axes name persisted leftover-map singular values when finite together diff --git a/CLAUDE.md b/CLAUDE.md index f29361dc8..9de8440ab 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 / 0273 / 0274 / 0275 / 0276 / 0277 / 0278 / 0279 / 0280 / 0281 / 0282 / 0283 / 0284 / 0285 / 0286 / 0287 / 0288 / 0289 / 0290 / 0291 / 0292 / 0293 / 0294 / 0295 / 0296 / 0297 / 0298 / 0299 / 0300 / 0301 / 0302 / 0303 / 0304 / 0305 / 0306 / 0307 / 0308 / 0309 / 0310 / 0311 / 0312 / 0313 / 0314 / 0315 / 0316 / 0317 / 0318 / 0319 / 0320 / 0321 / 0322 / 0323 / 0324), occupational construct catalog search +(ADR 0048–0164 / 0182 / 0185 / 0201 / 0233 / 0266 / 0267 / 0268 / 0269 / 0270 / 0271 / 0272 / 0273 / 0274 / 0275 / 0276 / 0277 / 0278 / 0279 / 0280 / 0281 / 0282 / 0283 / 0284 / 0285 / 0286 / 0287 / 0288 / 0289 / 0290 / 0291 / 0292 / 0293 / 0294 / 0295 / 0296 / 0297 / 0298 / 0299 / 0300 / 0301 / 0302 / 0303 / 0304 / 0305 / 0306 / 0307 / 0308 / 0309 / 0310 / 0311 / 0312 / 0313 / 0314 / 0315 / 0316 / 0317 / 0318 / 0319 / 0320 / 0321 / 0322 / 0323 / 0324 / 0325), 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/0148-leftover-map-axis-share.md b/docs/adr/0148-leftover-map-axis-share.md index 7b5f55843..b2eebef3d 100644 --- a/docs/adr/0148-leftover-map-axis-share.md +++ b/docs/adr/0148-leftover-map-axis-share.md @@ -9,7 +9,9 @@ [ADR 0323](0323-leftover-map-compare-axis-singular.md) (leftover-map singular values on leftover-axis report badges on the grouping comparison strip); [ADR 0324](0324-leftover-map-plot-axis-singular.md) -(leftover-map singular values on leftover-map graphic-display axes) +(leftover-map singular values on leftover-map graphic-display axes); +[ADR 0325](0325-leftover-map-axis-singular-only.md) +(leftover-map singular values on leftover-axis report badges independently of leftover-map axis share) ## Context diff --git a/docs/adr/0322-leftover-map-axis-singular.md b/docs/adr/0322-leftover-map-axis-singular.md index e098a7567..aac40500a 100644 --- a/docs/adr/0322-leftover-map-axis-singular.md +++ b/docs/adr/0322-leftover-map-axis-singular.md @@ -85,7 +85,10 @@ values on leftover-axis badges on the grouping comparison strip with a distinct name ([ADR 0323](0323-leftover-map-compare-axis-singular.md)). This increment does not caption leftover-map singular values on leftover-map graphic-display axes -([ADR 0324](0324-leftover-map-plot-axis-singular.md)). +([ADR 0324](0324-leftover-map-plot-axis-singular.md)). This increment does not +caption leftover-map singular values on leftover-axis report badges independently +of leftover-map axis share +([ADR 0325](0325-leftover-map-axis-singular-only.md)). ## References diff --git a/docs/adr/0323-leftover-map-compare-axis-singular.md b/docs/adr/0323-leftover-map-compare-axis-singular.md index 8c2c28d56..ebe2a5260 100644 --- a/docs/adr/0323-leftover-map-compare-axis-singular.md +++ b/docs/adr/0323-leftover-map-compare-axis-singular.md @@ -105,6 +105,9 @@ Independent of leftover-map singular values on leftover-axis report badges in the period-report panel ([ADR 0322](0322-leftover-map-axis-singular.md)). Independent of leftover-map singular values on leftover-map graphic-display axes ([ADR 0324](0324-leftover-map-plot-axis-singular.md)). +Independent of leftover-map singular values on leftover-axis report badges +independently of leftover-map axis share +([ADR 0325](0325-leftover-map-axis-singular-only.md)). ## References diff --git a/docs/adr/0324-leftover-map-plot-axis-singular.md b/docs/adr/0324-leftover-map-plot-axis-singular.md index 02616597e..bb175246f 100644 --- a/docs/adr/0324-leftover-map-plot-axis-singular.md +++ b/docs/adr/0324-leftover-map-plot-axis-singular.md @@ -113,7 +113,9 @@ Independent of leftover-map singular values on leftover-axis report badges singular values on leftover-axis report badges on the grouping comparison strip ([ADR 0323](0323-leftover-map-compare-axis-singular.md)). Independent of leftover-map singular values on the grouping comparison leftover-map graphic -([ADR 0321](0321-leftover-map-compare-plot-singular.md)). +([ADR 0321](0321-leftover-map-compare-plot-singular.md)). Independent of leftover-map +singular values on leftover-axis report badges independently of leftover-map axis +share ([ADR 0325](0325-leftover-map-axis-singular-only.md)). ## References diff --git a/docs/adr/0325-leftover-map-axis-singular-only.md b/docs/adr/0325-leftover-map-axis-singular-only.md new file mode 100644 index 000000000..b11a4526a --- /dev/null +++ b/docs/adr/0325-leftover-map-axis-singular-only.md @@ -0,0 +1,120 @@ +# ADR 0325 — Name leftover-map singular values on leftover-axis report badges independently of leftover-map axis share + +**Decision status:** Accepted +**Date:** 2026-08-31 + +Amends leftover-map singular values on leftover-axis report badges +([ADR 0322](0322-leftover-map-axis-singular.md)), leftover-map axis share +persistence ([ADR 0148](0148-leftover-map-axis-share.md)). Independent of leftover-map +singular values on leftover-map graphic-display axes +([ADR 0324](0324-leftover-map-plot-axis-singular.md)), leftover-map singular +values on leftover-axis report badges on the grouping comparison strip +([ADR 0323](0323-leftover-map-compare-axis-singular.md)), leftover-map +singular values on the grouping comparison leftover-map graphic +([ADR 0321](0321-leftover-map-compare-plot-singular.md)). + +## Context + +ADR 0148 already persists leftover-map singular values `σ_k` and leftover-map +axis share `σ_k² / Σ_j σ_j²`. ADR 0322 captions leftover-axis report badges +with `leftover axis {axis} σ {value} {share}%` when `σ_k` is finite, and +falls back to `leftover axis {axis} {share}%` when `σ_k` is omitted. Those +badges still couple leftover-map singular values to leftover-map axis share: +a missing, non-finite leftover-map axis share is formatted as `NaN%` beside +a finite `σ_k`, so Gabriel (1971) singular values appear to invent leftover-map +axis share. ADR 0323 already omits share and singular value independently on +grouping comparison leftover-axis badges. ADR 0324 already omits share and +singular value independently on leftover-map graphic-display axes. Period +leftover-axis badges still hide the σ-only copy `leftover axis {k} σ {value}`. +A missing, non-finite, or negative singular value is not a leftover score and +must omit independently of leftover-map axis share. Rank-0 unused axes still +persist `σ_k = 0`. A finite negative leftover is shown, never clamped. Do not +invent `σ_k` from leftover-map axis share. Do not invent leftover-map axis +share from `σ_k`. + +This increment captions leftover-axis report badges with already persisted +leftover-map singular values independently of leftover-map axis share. +Leftover-axis report badges stay `leftover axis {k} σ {value}` when share is +omitted so they stay distinct from leftover-axis `leftover axis {k} σ {value} {share}%` +(ADR 0322), from hyphen `leftover-map axis {k} σ {value}` (ADR 0324), from +comparison leftover-axis `leftover map comparison leftover axis {k} σ {value}` +(ADR 0323), and from comparison graphic +`leftover map comparison graphic leftover-map axis {k} σ {value}`. +It does not add columns. Do not invent a leftover score. Do not invent a +theta. + +This protected increment uses **0325** so it does not collide with leftover-map +singular values on leftover-map graphic-display axes (0324), leftover-map +singular values on leftover-axis report badges on the grouping comparison +strip (0323), leftover-map singular values on leftover-axis report badges +(0322), leftover-map singular values on the grouping comparison leftover-map +graphic (0321), leftover-map axis share persistence (0148), or the dashboard +stacks. + +## Decision + +On leftover-axis report badges, caption leftover-map axis `k` with persisted +leftover-map singular value `σ_k` when that singular value is finite and +non-negative, including rank-0 zero singular values. Share and singular +value omit independently: no share and no `σ_k` omits that leftover-axis +report badge; share only is `leftover axis {k} {share}%`; `σ_k` only is +`leftover axis {k} σ {value}`; both is `leftover axis {k} σ {value} {share}%`. +A missing or non-finite leftover-map axis share omits that share caption and +does not invent leftover-map axis share from `σ_k`. A missing, non-finite, or +negative singular value omits that `σ` badge and keeps a usable leftover-axis +share caption. Rank-0 unused axes still name `σ 0.00`. Click a leftover pair +to open that post. + +Leftover-axis report badge leftover-map singular values omit independently of +leftover-map graphic-display leftover-map axis singular values and leftover-map +comparison leftover-axis singular values. A missing leftover-map axis share +omits that leftover-axis share caption and keeps a usable leftover-axis `σ` +caption, leftover-map graphic leftover-map axis `σ` caption, leftover-map +comparison leftover-axis `σ` caption, leftover-map comparison graphic leftover-map +axis `σ` caption, leftover-map graphic leftover-map axis ticks, leftover-map +distance caption, leftover-map rank caption, leftover expected `E`, leftover +observed `Y`, leftover residual `R`, leftover-map unexplained leftover `U`, +leftover-map reconstruction `R̂`, and leftover-map graphic coverage notes. + +This increment does not change leftover-map singular values on leftover-map +graphic-display axes. Those leftover-map graphic axes stay +`leftover-map axis {axis} σ {value} ({share}%)` (ADR 0324). This increment does +not change leftover-map singular values on leftover-axis report badges on the +grouping comparison strip (ADR 0323). This increment does not persist leftover-map +inner product, cosine, or length. + +Do not add SQL. Do not edit shipped migrations. Do not invent a leftover +score. Do not invent a theta. + +## Consequences + +After `make seed`, leftover-axis report badges name persisted leftover-map +singular values when finite even when leftover-map axis share is omitted; +click a leftover pair opens that post. Hidden posts stay hidden. Rank-0 unused +axes still name leftover-axis `σ 0.00`. Leftover-map graphic-display leftover-map +singular values (ADR 0324), leftover-axis report badge leftover-map singular +values with share (ADR 0322), and grouping comparison leftover-axis report +badge leftover-map singular values (ADR 0323) remain. + +## Related + +Independent of leftover-map singular values on leftover-map graphic-display +axes ([ADR 0324](0324-leftover-map-plot-axis-singular.md)). Independent of +leftover-map singular values on leftover-axis report badges on the grouping +comparison strip ([ADR 0323](0323-leftover-map-compare-axis-singular.md)). +Independent of leftover-map singular values on leftover-axis report badges +that still require leftover-map axis share in the combined caption +([ADR 0322](0322-leftover-map-axis-singular.md)). + +## 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 3c8aa6d5d..5efa4eb84 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-31 11:25 KST. Protected `main` is +> `cb187cadee5fb6c46d8a944815ccc154a1e028d1`. Writes work. Do not +> self-approve. #865 leftover-map singular values on leftover-map graphic-display +> axes (v2.81.0 / ADR 0324) is `a3613ac`, stacked on #863 @ `52b3597`. Independent +> APPROVE is missing. Next buyer increment on this cycle: leftover-map singular +> values on leftover-axis report badges independently of leftover-map axis share +> (ADR 0325 / v2.82.0) delivered locally on +> `feat/leftover-map-axis-singular-only-v2820`. Caption persisted leftover-map +> singular values `σ_k` on leftover-axis report badges when leftover-map axis +> share is omitted. Distinct accessible name `leftover axis {axis} σ {value}`. +> Rank-0 unused axes still name `σ 0.00`. Share and singular value omit +> independently. Do not invent `σ_k` from leftover-map axis share. Do not invent +> leftover-map axis share from `σ_k`. Do not change leftover-map singular values +> on leftover-map graphic-display axes or on leftover-axis report badges on the +> grouping comparison strip this increment. Stack onto #865. Leave open for +> independent review. Do not squash-merge. Do not self-approve. Issues #79 and +> #87 stay open. Do not merge #808 without independent APPROVE. Never +> self-approve. + > Exact-head loop overlay: 2026-08-31 11:05 KST. Protected `main` is > `cb187cadee5fb6c46d8a944815ccc154a1e028d1`. Writes work. Do not > self-approve. #863 leftover-map singular values on leftover-axis report badges diff --git a/frontend/package.json b/frontend/package.json index 9750a7d2e..1f6fe1636 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.81.0", + "version": "2.82.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index bbc95eac7..b1c232ab8 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -4179,6 +4179,8 @@ describe("App, authenticated", () => { expect(screen.getAllByText(/CAT: sales-lead I=0\.70/).length).toBeGreaterThan(0); expect(screen.getAllByText(/leftover axis 1 σ 1\.84 82%/).length).toBeGreaterThan(0); expect(screen.getAllByText(/leftover axis 2 σ 0\.86 18%/).length).toBeGreaterThan(0); + expect(screen.queryByText("leftover axis 1 σ 1.84")).not.toBeInTheDocument(); + expect(screen.queryByText("leftover axis 2 σ 0.86")).not.toBeInTheDocument(); expect(screen.getByText("leftover map comparison leftover axis 1 σ 1.84 82%")).toBeInTheDocument(); expect(screen.getByText("leftover map comparison leftover axis 2 σ 0.86 18%")).toBeInTheDocument(); expect(screen.getByLabelText("Leftover map comparison leftover axis")).toHaveTextContent( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8d36a1b5f..83cb43c13 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -138,10 +138,7 @@ import { LEFTOVER_MAP_PLOT_ITEM_COVERAGE, } from "./leftoverMapCoverage"; import { - leftoverMapAxisBadgeShare, - leftoverMapAxisBadgeSingular, - LEFTOVER_MAP_AXIS_BADGE_SHARE, - LEFTOVER_MAP_AXIS_BADGE_SINGULAR, + leftoverMapAxisBadge, } from "./leftoverMapAxisBadge"; import { leftoverMapCompareAxisBadge, @@ -3937,27 +3934,34 @@ function ReportsPanel({

) : null} {report.leftover_map_axes?.map((axis) => { - const singular = leftoverMapAxisBadgeSingular(axis); - const share = leftoverMapAxisBadgeShare(axis.leftover_share); + const badge = leftoverMapAxisBadge( + axis.axis_index, + leftoverSingularForAxis([axis], axis.axis_index), + leftoverShareForAxis([axis], axis.axis_index), + ); + if (badge === null) { + return null; + } return ( - {singular === null - ? tf(LEFTOVER_MAP_AXIS_BADGE_SHARE, { axis: axis.axis_index, share }) - : tf(LEFTOVER_MAP_AXIS_BADGE_SINGULAR, { - axis: axis.axis_index, - value: singular, - share, - })} + {tf(badge.key, badge.values)} ); })} - {report.leftover_map_axes && report.leftover_map_axes.length > 0 && ( + {report.leftover_map_axes?.some( + (axis) => + leftoverMapAxisBadge( + axis.axis_index, + leftoverSingularForAxis([axis], axis.axis_index), + leftoverShareForAxis([axis], axis.axis_index), + ) !== null, + ) ? (

{t( "Leftover-map axis share is Gabriel inertia of residual SVD axes 1 and 2. Leftover-map singular values are the Gabriel scale of those axes. Open a leftover pair to read the post–criterion cell. The shares and singular values do not invent a leftover score.", )}

- )} + ) : null} {report.leftover_pairs && report.leftover_pairs.length > 0 && ( { "Page", "Answer", "leftover axis {axis} {share}%", + "leftover axis {axis} σ {value}", "leftover axis {axis} σ {value} {share}%", "Leftover-map axis share", "Leftover-map axis share is Gabriel inertia of residual SVD axes 1 and 2. Leftover-map singular values are the Gabriel scale of those axes. Open a leftover pair to read the post–criterion cell. The shares and singular values do not invent a leftover score.", @@ -747,6 +748,16 @@ describe("i18n", () => { ).toBe(expected); }); + it.each([ + ["ko", "잔차 축 1 σ 1.84"], + ["zh", "残差轴 1 σ 1.84"], + ["ja", "残差軸 1 σ 1.84"], + ["vi", "trục phần dư 1 σ 1.84"], + ] as const)("formats leftover-axis badge singular values without share in %s", (locale, expected) => { + setLocale(locale); + expect(tf("leftover axis {axis} σ {value}", { axis: 1, value: "1.84" })).toBe(expected); + }); + it.each([ ["ko", "잔여 지도 비교 잔차 축 1 σ 1.84"], ["zh", "残差地图比较残差轴 1 σ 1.84"], diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index ec4b31d06..453fe3a19 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -613,6 +613,7 @@ const TRANSLATIONS: Partial>> = { "Valid from": "유효 시작", "Valid to": "유효 종료", "leftover axis {axis} {share}%": "잔차 축 {axis} {share}%", + "leftover axis {axis} σ {value}": "잔차 축 {axis} σ {value}", "leftover axis {axis} σ {value} {share}%": "잔차 축 {axis} σ {value} {share}%", "Leftover-map axis share": "잔차 지도 축 비율", "Leftover-map axis share is Gabriel inertia of residual SVD axes 1 and 2. Leftover-map singular values are the Gabriel scale of those axes. Open a leftover pair to read the post–criterion cell. The shares and singular values do not invent a leftover score.": @@ -1265,6 +1266,7 @@ const TRANSLATIONS: Partial>> = { "Valid from": "有效起始", "Valid to": "有效结束", "leftover axis {axis} {share}%": "残差轴 {axis} {share}%", + "leftover axis {axis} σ {value}": "残差轴 {axis} σ {value}", "leftover axis {axis} σ {value} {share}%": "残差轴 {axis} σ {value} {share}%", "Leftover-map axis share": "残差图轴占比", "Leftover-map axis share is Gabriel inertia of residual SVD axes 1 and 2. Leftover-map singular values are the Gabriel scale of those axes. Open a leftover pair to read the post–criterion cell. The shares and singular values do not invent a leftover score.": @@ -1921,6 +1923,7 @@ const TRANSLATIONS: Partial>> = { "Valid from": "有効開始", "Valid to": "有効終了", "leftover axis {axis} {share}%": "残差軸 {axis} {share}%", + "leftover axis {axis} σ {value}": "残差軸 {axis} σ {value}", "leftover axis {axis} σ {value} {share}%": "残差軸 {axis} σ {value} {share}%", "Leftover-map axis share": "残差マップ軸の比率", "Leftover-map axis share is Gabriel inertia of residual SVD axes 1 and 2. Leftover-map singular values are the Gabriel scale of those axes. Open a leftover pair to read the post–criterion cell. The shares and singular values do not invent a leftover score.": @@ -2580,6 +2583,7 @@ const TRANSLATIONS: Partial>> = { "Valid from": "Hiệu lực từ", "Valid to": "Hiệu lực đến", "leftover axis {axis} {share}%": "trục phần dư {axis} {share}%", + "leftover axis {axis} σ {value}": "trục phần dư {axis} σ {value}", "leftover axis {axis} σ {value} {share}%": "trục phần dư {axis} σ {value} {share}%", "Leftover-map axis share": "Tỷ trọng trục bản đồ phần dư", "Leftover-map axis share is Gabriel inertia of residual SVD axes 1 and 2. Leftover-map singular values are the Gabriel scale of those axes. Open a leftover pair to read the post–criterion cell. The shares and singular values do not invent a leftover score.": diff --git a/frontend/src/leftoverMapAxisBadge.test.ts b/frontend/src/leftoverMapAxisBadge.test.ts index 7261489cb..cc0c76001 100644 --- a/frontend/src/leftoverMapAxisBadge.test.ts +++ b/frontend/src/leftoverMapAxisBadge.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "vitest"; import { + leftoverMapAxisBadge, leftoverMapAxisBadgeShare, leftoverMapAxisBadgeSingular, LEFTOVER_MAP_AXIS_BADGE_SHARE, LEFTOVER_MAP_AXIS_BADGE_SINGULAR, + LEFTOVER_MAP_AXIS_BADGE_SINGULAR_ONLY, } from "./leftoverMapAxisBadge"; import { LEFTOVER_MAP_COMPARE_AXIS_SHARE, @@ -21,6 +23,13 @@ describe("leftoverMapAxisBadgeShare", () => { expect(leftoverMapAxisBadgeShare(0.18)).toBe("18"); expect(leftoverMapAxisBadgeShare(0)).toBe("0"); }); + + it("omits leftover-map axis share that is missing or non-finite", () => { + expect(leftoverMapAxisBadgeShare(undefined)).toBeNull(); + expect(leftoverMapAxisBadgeShare(null)).toBeNull(); + expect(leftoverMapAxisBadgeShare(Number.NaN)).toBeNull(); + expect(leftoverMapAxisBadgeShare(Number.POSITIVE_INFINITY)).toBeNull(); + }); }); describe("leftoverMapAxisBadgeSingular", () => { @@ -57,6 +66,7 @@ describe("leftoverMapAxisBadgeSingular", () => { it("stays distinct from leftover-map graphic and comparison leftover-axis σ copy", () => { expect(LEFTOVER_MAP_AXIS_BADGE_SINGULAR).toBe("leftover axis {axis} σ {value} {share}%"); expect(LEFTOVER_MAP_AXIS_BADGE_SHARE).toBe("leftover axis {axis} {share}%"); + expect(LEFTOVER_MAP_AXIS_BADGE_SINGULAR_ONLY).toBe("leftover axis {axis} σ {value}"); expect(LEFTOVER_MAP_AXIS_BADGE_SINGULAR).not.toBe(LEFTOVER_MAP_PLOT_AXIS_SINGULAR); expect(LEFTOVER_MAP_AXIS_BADGE_SINGULAR).not.toBe(LEFTOVER_MAP_PLOT_AXIS_SINGULAR_SHARE); expect(LEFTOVER_MAP_AXIS_BADGE_SINGULAR).not.toBe(LEFTOVER_MAP_COMPARE_PLOT_AXIS_SINGULAR); @@ -66,6 +76,54 @@ describe("leftoverMapAxisBadgeSingular", () => { expect(LEFTOVER_MAP_AXIS_BADGE_SINGULAR).not.toBe(LEFTOVER_MAP_COMPARE_AXIS_SINGULAR); expect(LEFTOVER_MAP_AXIS_BADGE_SINGULAR).not.toBe(LEFTOVER_MAP_COMPARE_AXIS_SINGULAR_SHARE); expect(LEFTOVER_MAP_AXIS_BADGE_SHARE).not.toBe(LEFTOVER_MAP_COMPARE_AXIS_SHARE); - expect(LEFTOVER_MAP_AXIS_BADGE_SINGULAR).not.toBe("leftover axis {axis} σ {value}"); + expect(LEFTOVER_MAP_AXIS_BADGE_SINGULAR).not.toBe(LEFTOVER_MAP_AXIS_BADGE_SINGULAR_ONLY); + expect(LEFTOVER_MAP_AXIS_BADGE_SINGULAR_ONLY).not.toBe(LEFTOVER_MAP_PLOT_AXIS_SINGULAR); + expect(LEFTOVER_MAP_AXIS_BADGE_SINGULAR_ONLY).not.toBe(LEFTOVER_MAP_COMPARE_AXIS_SINGULAR); + }); +}); + +describe("leftover-axis report badge leftover-map singular values", () => { + it("names persisted leftover-map singular values with leftover-map axis share", () => { + expect(leftoverMapAxisBadge(1, 1.84, 0.82)).toEqual({ + key: LEFTOVER_MAP_AXIS_BADGE_SINGULAR, + values: { axis: 1, value: "1.84", share: "82" }, + }); + expect(leftoverMapAxisBadge(2, 0.86, 0.18)).toEqual({ + key: LEFTOVER_MAP_AXIS_BADGE_SINGULAR, + values: { axis: 2, value: "0.86", share: "18" }, + }); + }); + + it("names rank-0 zero leftover-map singular values on leftover-axis report badges", () => { + expect(leftoverMapAxisBadge(1, 0, 0)).toEqual({ + key: LEFTOVER_MAP_AXIS_BADGE_SINGULAR, + values: { axis: 1, value: "0.00", share: "0" }, + }); + }); + + it("omits leftover-map singular values independently of leftover-map axis share", () => { + expect(leftoverMapAxisBadge(1, Number.NaN, 0.82)).toEqual({ + key: LEFTOVER_MAP_AXIS_BADGE_SHARE, + values: { axis: 1, share: "82" }, + }); + expect(leftoverMapAxisBadge(2, -0.01, 0.18)).toEqual({ + key: LEFTOVER_MAP_AXIS_BADGE_SHARE, + values: { axis: 2, share: "18" }, + }); + expect(leftoverMapAxisBadge(1, 1.84, Number.NaN)).toEqual({ + key: LEFTOVER_MAP_AXIS_BADGE_SINGULAR_ONLY, + values: { axis: 1, value: "1.84" }, + }); + expect(leftoverMapAxisBadge(1, null, null)).toBeNull(); + }); + + it("does not invent leftover-map singular values from leftover-map axis share", () => { + expect(leftoverMapAxisBadge(1, undefined, 0.82)?.key).toBe(LEFTOVER_MAP_AXIS_BADGE_SHARE); + expect(leftoverMapAxisBadge(1, undefined, 0.82)?.values.value).toBeUndefined(); + }); + + it("does not invent leftover-map axis share from leftover-map singular values", () => { + expect(leftoverMapAxisBadge(1, 1.84, undefined)?.key).toBe(LEFTOVER_MAP_AXIS_BADGE_SINGULAR_ONLY); + expect(leftoverMapAxisBadge(1, 1.84, undefined)?.values.share).toBeUndefined(); }); }); diff --git a/frontend/src/leftoverMapAxisBadge.ts b/frontend/src/leftoverMapAxisBadge.ts index 71a7d9dc4..a6d767964 100644 --- a/frontend/src/leftoverMapAxisBadge.ts +++ b/frontend/src/leftoverMapAxisBadge.ts @@ -1,9 +1,14 @@ /** Caption leftover-axis report badges with persisted Gabriel singular values. + * ADR 0325 names leftover-map singular values independently of leftover-map + * axis share (`leftover axis {axis} σ {value}` when share is omitted). * ADR 0323 captions leftover-axis report badges on the grouping comparison strip * with a distinct leftover map comparison leftover axis name, not this helper. + * ADR 0324 captions leftover-map graphic-display axes, not this helper. */ import type { LeftoverMapAxis } from "./api"; +import { formatLeftoverMapPlotAxisShare } from "./leftoverMapPlotAxisShare"; +import type { LeftoverMapCompareAxisBadge } from "./leftoverMapPlotAxisSingular"; import { formatLeftoverMapPlotAxisSingular, leftoverSingularForAxis, @@ -13,10 +18,12 @@ export const LEFTOVER_MAP_AXIS_BADGE_SHARE = "leftover axis {axis} {share}%"; export const LEFTOVER_MAP_AXIS_BADGE_SINGULAR = "leftover axis {axis} σ {value} {share}%"; +export const LEFTOVER_MAP_AXIS_BADGE_SINGULAR_ONLY = "leftover axis {axis} σ {value}"; + export function leftoverMapAxisBadgeShare( leftoverShare: LeftoverMapAxis["leftover_share"] | null | undefined, -): string { - return ((leftoverShare ?? Number.NaN) * 100).toFixed(0); +): string | null { + return formatLeftoverMapPlotAxisShare(leftoverShare); } export function leftoverMapAxisBadgeSingular( @@ -28,3 +35,25 @@ export function leftoverMapAxisBadgeSingular( leftoverSingularForAxis([axis], axis.axis_index), ); } + +export function leftoverMapAxisBadge( + axisIndex: number, + leftoverSingular: number | null | undefined, + leftoverShare: number | null | undefined, +): LeftoverMapCompareAxisBadge | null { + const singular = formatLeftoverMapPlotAxisSingular(leftoverSingular); + const percent = leftoverMapAxisBadgeShare(leftoverShare); + if (singular === null && percent === null) { + return null; + } + if (singular === null && percent !== null) { + return { key: LEFTOVER_MAP_AXIS_BADGE_SHARE, values: { axis: axisIndex, share: percent } }; + } + if (singular !== null && percent === null) { + return { key: LEFTOVER_MAP_AXIS_BADGE_SINGULAR_ONLY, values: { axis: axisIndex, value: singular } }; + } + return { + key: LEFTOVER_MAP_AXIS_BADGE_SINGULAR, + values: { axis: axisIndex, value: singular as string, share: percent as string }, + }; +} diff --git a/frontend/src/leftoverMapPlotAxisSingular.test.ts b/frontend/src/leftoverMapPlotAxisSingular.test.ts index 4e47cfb21..3f4056c52 100644 --- a/frontend/src/leftoverMapPlotAxisSingular.test.ts +++ b/frontend/src/leftoverMapPlotAxisSingular.test.ts @@ -19,7 +19,7 @@ import { LEFTOVER_MAP_PLOT_AXIS_SHARE, } from "./leftoverMapPlotAxisShare"; import { LEFTOVER_MAP_COMPARE_PLOT_TICK } from "./leftoverMapPlotLayout"; -import { LEFTOVER_MAP_AXIS_BADGE_SINGULAR } from "./leftoverMapAxisBadge"; +import { LEFTOVER_MAP_AXIS_BADGE_SINGULAR, LEFTOVER_MAP_AXIS_BADGE_SINGULAR_ONLY } from "./leftoverMapAxisBadge"; describe("leftoverSingularForAxis", () => { const axes = [ @@ -223,6 +223,7 @@ describe("leftover-map graphic-display axis singular badges", () => { LEFTOVER_MAP_COMPARE_PLOT_AXIS_SINGULAR_SHARE, ); expect(LEFTOVER_MAP_PLOT_AXIS_SINGULAR).not.toBe(LEFTOVER_MAP_COMPARE_AXIS_SINGULAR); + expect(LEFTOVER_MAP_PLOT_AXIS_SINGULAR).not.toBe(LEFTOVER_MAP_AXIS_BADGE_SINGULAR_ONLY); expect(LEFTOVER_MAP_PLOT_AXIS_SINGULAR_SHARE).not.toBe(LEFTOVER_MAP_AXIS_BADGE_SINGULAR); expect(LEFTOVER_MAP_PLOT_AXIS_SINGULAR_SHARE).not.toBe(LEFTOVER_MAP_PLOT_AXIS_SHARE); }); diff --git a/frontend/src/leftoverMapPlotLayout.test.ts b/frontend/src/leftoverMapPlotLayout.test.ts index 23a3ab3cc..30b8554b0 100644 --- a/frontend/src/leftoverMapPlotLayout.test.ts +++ b/frontend/src/leftoverMapPlotLayout.test.ts @@ -1370,6 +1370,7 @@ describe("leftover map comparison graphic labels", () => { "leftover map comparison graphic leftover-map axis {axis} σ {value}", ); expect(LEFTOVER_MAP_COMPARE_PLOT_TICK).not.toBe("leftover-map axis {axis} σ {value}"); + expect(LEFTOVER_MAP_COMPARE_PLOT_TICK).not.toBe("leftover axis {axis} σ {value}"); expect(LEFTOVER_MAP_COMPARE_PLOT_TICK).not.toBe( "leftover map comparison leftover axis {axis} σ {value}", ); diff --git a/pyproject.toml b/pyproject.toml index d46ab31c7..ab9a1f9af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.81.0" +version = "2.82.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 611636de2dd0989cbc34c8e9dbdc6104fc38785a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 17:24:53 +0900 Subject: [PATCH 02/12] fix(ui): preserve report axis singular-only state --- frontend/src/leftoverMapAxisBadge.ts | 39 ++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/frontend/src/leftoverMapAxisBadge.ts b/frontend/src/leftoverMapAxisBadge.ts index 7d2b377df..1511b115c 100644 --- a/frontend/src/leftoverMapAxisBadge.ts +++ b/frontend/src/leftoverMapAxisBadge.ts @@ -1,6 +1,7 @@ /** Caption leftover-axis report badges with persisted Gabriel singular values. */ import type { LeftoverMapAxis } from "./api"; +import { formatLeftoverMapPlotAxisShare } from "./leftoverMapPlotAxisShare"; import { formatLeftoverMapPlotAxisSingular, leftoverSingularForAxis, @@ -10,6 +11,13 @@ export const LEFTOVER_MAP_AXIS_BADGE_SHARE = "leftover axis {axis} {share}%"; export const LEFTOVER_MAP_AXIS_BADGE_SINGULAR = "leftover axis {axis} σ {value} {share}%"; +export const LEFTOVER_MAP_AXIS_BADGE_SINGULAR_ONLY = "leftover axis {axis} σ {value}"; + +export type LeftoverMapAxisBadge = { + template: string; + values: Record; +}; + export function leftoverMapAxisBadgeShare( leftoverShare: LeftoverMapAxis["leftover_share"] | null | undefined, ): string { @@ -25,3 +33,34 @@ export function leftoverMapAxisBadgeSingular( leftoverSingularForAxis([axis], axis.axis_index), ); } + +/** Keep report-axis singular and share evidence independently observable. */ +export function leftoverMapAxisBadge( + axis: Pick & { + leftover_share?: LeftoverMapAxis["leftover_share"] | null; + leftover_singular_value?: LeftoverMapAxis["leftover_singular_value"] | null; + }, +): LeftoverMapAxisBadge | null { + const singular = leftoverMapAxisBadgeSingular(axis); + const share = formatLeftoverMapPlotAxisShare(axis.leftover_share); + + if (singular === null && share === null) { + return null; + } + if (singular === null && share !== null) { + return { + template: LEFTOVER_MAP_AXIS_BADGE_SHARE, + values: { axis: axis.axis_index, share }, + }; + } + if (singular !== null && share === null) { + return { + template: LEFTOVER_MAP_AXIS_BADGE_SINGULAR_ONLY, + values: { axis: axis.axis_index, value: singular }, + }; + } + return { + template: LEFTOVER_MAP_AXIS_BADGE_SINGULAR, + values: { axis: axis.axis_index, value: singular as string, share: share as string }, + }; +} From a87efee55f51fccff21e6692a687bbcace1bcf77 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 17:29:40 +0900 Subject: [PATCH 03/12] docs(adr): record independent report axis evidence states --- ...-leftover-map-report-axis-singular-only.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 docs/adr/0325-leftover-map-report-axis-singular-only.md diff --git a/docs/adr/0325-leftover-map-report-axis-singular-only.md b/docs/adr/0325-leftover-map-report-axis-singular-only.md new file mode 100644 index 000000000..1ddc3f8ac --- /dev/null +++ b/docs/adr/0325-leftover-map-report-axis-singular-only.md @@ -0,0 +1,35 @@ +# ADR 0325 — Preserve singular-only and share-only report-axis evidence + +**Decision status:** Proposed +**Date:** 2026-08-31 + +## Context + +LineageWeave persists two different measurements for a leftover-map axis: Gabriel singular value `σ_k` and axis share. They have independent missingness. The report badge path previously chose its rendering branch from `σ_k` alone and formatted a missing share through numeric arithmetic, which can surface `NaN%` and erase the distinction between unavailable evidence and a measured value. + +A finite, non-negative persisted `σ_k`, including `0`, must remain buyer-visible when share is unavailable. A finite persisted share must remain visible when `σ_k` is unavailable. Neither measurement may be reconstructed, normalized, clamped, or inferred from the other. + +## Decision + +Define one LineageWeave-owned report-axis badge projection with four states: + +- combined: `leftover axis {axis} σ {value} {share}%`; +- singular only: `leftover axis {axis} σ {value}`; +- share only: `leftover axis {axis} {share}%`; +- neither usable: omit the badge. + +The projection consumes persisted axis values only. `σ_k` is accepted only when finite and non-negative; `σ=0` remains explicit. Share uses the existing finite-value formatter. Missing or invalid evidence fails closed independently. + +This decision changes presentation composition only. It does not add persistence, cross-service SQL, psychometric estimation, or source copies from fast-mlsirm, TEPP, or another canonical owner. + +## Consequences + +The report no longer needs to represent missing share as `NaN%`, and a singular-only axis remains inspectable. Existing combined and share-only buyer copy remains unchanged. The helper is a deterministic projection that can be unit-tested without synthetic domain data. + +This ADR remains **Proposed** until the exact-head buyer path actually consumes the projection and fresh frontend, browser/accessibility, security, and independent-review evidence is current. + +## 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 From e3cce3815ad7c585f114e5b0c1132ad5fa915574 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 17:29:47 +0900 Subject: [PATCH 04/12] docs(changelog): record singular-only report axis evidence --- CHANGELOG.d/2.82.0-leftover-map-axis-singular-only.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 CHANGELOG.d/2.82.0-leftover-map-axis-singular-only.md diff --git a/CHANGELOG.d/2.82.0-leftover-map-axis-singular-only.md b/CHANGELOG.d/2.82.0-leftover-map-axis-singular-only.md new file mode 100644 index 000000000..e8b7973de --- /dev/null +++ b/CHANGELOG.d/2.82.0-leftover-map-axis-singular-only.md @@ -0,0 +1,3 @@ +### Fixed + +- Report leftover-axis evidence now has an explicit singular-only state so a valid persisted Gabriel `σ` remains visible when axis share is unavailable; share-only, combined, and empty states remain independent and missing evidence is not rendered as `NaN%`. From 7c570b484b4298fbd0a699003f05980aee6235e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 23:50:44 +0900 Subject: [PATCH 05/12] test(reports): expose unwired report-axis singular-only RED --- ...est_leftover_axis_report_singular_only_contract.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_leftover_axis_report_singular_only_contract.py b/tests/test_leftover_axis_report_singular_only_contract.py index c02a27f3c..6357a3ca9 100644 --- a/tests/test_leftover_axis_report_singular_only_contract.py +++ b/tests/test_leftover_axis_report_singular_only_contract.py @@ -5,6 +5,7 @@ ROOT = Path(__file__).resolve().parents[1] BADGE_SOURCE = ROOT / "frontend" / "src" / "leftoverMapAxisBadge.ts" +APP_SOURCE = ROOT / "frontend" / "src" / "App.tsx" def test_report_axis_badge_preserves_singular_when_share_is_missing() -> None: @@ -31,3 +32,13 @@ def test_report_axis_badge_keeps_sigma_and_share_missingness_independent() -> No assert "LEFTOVER_MAP_AXIS_BADGE_SHARE" in source assert "LEFTOVER_MAP_AXIS_BADGE_SINGULAR_ONLY" in source assert "Math.sqrt" not in source + + +def test_report_axis_rendering_consumes_the_independent_badge_projection() -> None: + """The report UI must render the four-state projection instead of rebuilding σ/share coupling.""" + app_source = APP_SOURCE.read_text(encoding="utf-8") + + assert "leftoverMapAxisBadge," in app_source + assert "const badge = leftoverMapAxisBadge(axis);" in app_source + assert "badge === null ? null" in app_source + assert "tf(badge.template, badge.values)" in app_source From 3422a234a5bfe885040000492f3a2f901c769c88 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 23:52:18 +0900 Subject: [PATCH 06/12] fix(reports): keep report-axis sigma visible without share --- frontend/src/leftoverMapAxisBadge.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/frontend/src/leftoverMapAxisBadge.ts b/frontend/src/leftoverMapAxisBadge.ts index 1511b115c..4a00d425f 100644 --- a/frontend/src/leftoverMapAxisBadge.ts +++ b/frontend/src/leftoverMapAxisBadge.ts @@ -7,9 +7,9 @@ import { leftoverSingularForAxis, } from "./leftoverMapPlotAxisSingular"; -export const LEFTOVER_MAP_AXIS_BADGE_SHARE = "leftover axis {axis} {share}%"; +export const LEFTOVER_MAP_AXIS_BADGE_SHARE = "leftover axis {axis}{share}"; -export const LEFTOVER_MAP_AXIS_BADGE_SINGULAR = "leftover axis {axis} σ {value} {share}%"; +export const LEFTOVER_MAP_AXIS_BADGE_SINGULAR = "leftover axis {axis} σ {value}{share}"; export const LEFTOVER_MAP_AXIS_BADGE_SINGULAR_ONLY = "leftover axis {axis} σ {value}"; @@ -18,10 +18,16 @@ export type LeftoverMapAxisBadge = { values: Record; }; +/** + * Format report-axis share as an optional suffix consumed directly by the + * report template. Missing or non-finite share stays absent instead of + * leaking a synthetic `NaN%` value into buyer-visible evidence. + */ export function leftoverMapAxisBadgeShare( leftoverShare: LeftoverMapAxis["leftover_share"] | null | undefined, ): string { - return ((leftoverShare ?? Number.NaN) * 100).toFixed(0); + const share = formatLeftoverMapPlotAxisShare(leftoverShare); + return share === null ? "" : ` ${share}%`; } export function leftoverMapAxisBadgeSingular( @@ -42,18 +48,18 @@ export function leftoverMapAxisBadge( }, ): LeftoverMapAxisBadge | null { const singular = leftoverMapAxisBadgeSingular(axis); - const share = formatLeftoverMapPlotAxisShare(axis.leftover_share); + const share = leftoverMapAxisBadgeShare(axis.leftover_share); - if (singular === null && share === null) { + if (singular === null && share === "") { return null; } - if (singular === null && share !== null) { + if (singular === null) { return { template: LEFTOVER_MAP_AXIS_BADGE_SHARE, values: { axis: axis.axis_index, share }, }; } - if (singular !== null && share === null) { + if (share === "") { return { template: LEFTOVER_MAP_AXIS_BADGE_SINGULAR_ONLY, values: { axis: axis.axis_index, value: singular }, @@ -61,6 +67,6 @@ export function leftoverMapAxisBadge( } return { template: LEFTOVER_MAP_AXIS_BADGE_SINGULAR, - values: { axis: axis.axis_index, value: singular as string, share: share as string }, + values: { axis: axis.axis_index, value: singular, share }, }; } From 2efcf06184dcee7815233465fcd9efa32d65aabe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 23:52:48 +0900 Subject: [PATCH 07/12] test(reports): cover report-axis missingness states --- frontend/src/leftoverMapAxisBadge.test.ts | 63 ++++++++++++++++++++--- 1 file changed, 55 insertions(+), 8 deletions(-) diff --git a/frontend/src/leftoverMapAxisBadge.test.ts b/frontend/src/leftoverMapAxisBadge.test.ts index 579273f88..66d5d099c 100644 --- a/frontend/src/leftoverMapAxisBadge.test.ts +++ b/frontend/src/leftoverMapAxisBadge.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "vitest"; import { + leftoverMapAxisBadge, leftoverMapAxisBadgeShare, leftoverMapAxisBadgeSingular, LEFTOVER_MAP_AXIS_BADGE_SHARE, LEFTOVER_MAP_AXIS_BADGE_SINGULAR, + LEFTOVER_MAP_AXIS_BADGE_SINGULAR_ONLY, } from "./leftoverMapAxisBadge"; import { leftoverMapCompareAxisBadge, @@ -17,10 +19,13 @@ import { } from "./leftoverMapPlotAxisSingular"; describe("leftoverMapAxisBadgeShare", () => { - it("formats leftover-axis share percent without inventing a leftover score", () => { - expect(leftoverMapAxisBadgeShare(0.82)).toBe("82"); - expect(leftoverMapAxisBadgeShare(0.18)).toBe("18"); - expect(leftoverMapAxisBadgeShare(0)).toBe("0"); + it("formats report-axis share as an optional suffix", () => { + expect(leftoverMapAxisBadgeShare(0.82)).toBe(" 82%"); + expect(leftoverMapAxisBadgeShare(0.18)).toBe(" 18%"); + expect(leftoverMapAxisBadgeShare(0)).toBe(" 0%"); + expect(leftoverMapAxisBadgeShare(undefined)).toBe(""); + expect(leftoverMapAxisBadgeShare(Number.NaN)).toBe(""); + expect(leftoverMapAxisBadgeShare(Number.POSITIVE_INFINITY)).toBe(""); }); }); @@ -55,16 +60,58 @@ describe("leftoverMapAxisBadgeSingular", () => { expect(leftoverMapAxisBadgeSingular({ axis_index: 1 })).toBeNull(); }); - it("stays distinct from leftover-map graphic and comparison graphic leftover-map axis σ copy", () => { - expect(LEFTOVER_MAP_AXIS_BADGE_SINGULAR).toBe("leftover axis {axis} σ {value} {share}%"); - expect(LEFTOVER_MAP_AXIS_BADGE_SHARE).toBe("leftover axis {axis} {share}%"); + it("keeps report copy distinct from plot and comparison copy", () => { + expect(LEFTOVER_MAP_AXIS_BADGE_SINGULAR).toBe("leftover axis {axis} σ {value}{share}"); + expect(LEFTOVER_MAP_AXIS_BADGE_SHARE).toBe("leftover axis {axis}{share}"); + expect(LEFTOVER_MAP_AXIS_BADGE_SINGULAR_ONLY).toBe("leftover axis {axis} σ {value}"); expect(LEFTOVER_MAP_AXIS_BADGE_SINGULAR).not.toBe(LEFTOVER_MAP_PLOT_AXIS_SINGULAR); expect(LEFTOVER_MAP_AXIS_BADGE_SINGULAR).not.toBe(LEFTOVER_MAP_PLOT_AXIS_SINGULAR_SHARE); expect(LEFTOVER_MAP_AXIS_BADGE_SINGULAR).not.toBe(LEFTOVER_MAP_COMPARE_PLOT_AXIS_SINGULAR); expect(LEFTOVER_MAP_AXIS_BADGE_SINGULAR).not.toBe( LEFTOVER_MAP_COMPARE_PLOT_AXIS_SINGULAR_SHARE, ); - expect(LEFTOVER_MAP_AXIS_BADGE_SINGULAR).not.toBe("leftover axis {axis} σ {value}"); + }); +}); + +describe("leftoverMapAxisBadge", () => { + it("keeps singular-only, share-only, combined, and empty states independent", () => { + expect( + leftoverMapAxisBadge({ + axis_index: 1, + leftover_singular_value: 1.24, + leftover_share: 0.42, + }), + ).toEqual({ + template: LEFTOVER_MAP_AXIS_BADGE_SINGULAR, + values: { axis: 1, value: "1.24", share: " 42%" }, + }); + expect( + leftoverMapAxisBadge({ + axis_index: 1, + leftover_singular_value: 0, + leftover_share: null, + }), + ).toEqual({ + template: LEFTOVER_MAP_AXIS_BADGE_SINGULAR_ONLY, + values: { axis: 1, value: "0.00" }, + }); + expect( + leftoverMapAxisBadge({ + axis_index: 2, + leftover_singular_value: null, + leftover_share: 0.18, + }), + ).toEqual({ + template: LEFTOVER_MAP_AXIS_BADGE_SHARE, + values: { axis: 2, share: " 18%" }, + }); + expect( + leftoverMapAxisBadge({ + axis_index: 2, + leftover_singular_value: Number.NaN, + leftover_share: Number.POSITIVE_INFINITY, + }), + ).toBeNull(); }); }); From c267657779dce655e6a7a604e93a9799d395cc2f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 23:53:02 +0900 Subject: [PATCH 08/12] test(reports): pin report-axis sigma/share rendering contract --- ...over_axis_report_singular_only_contract.py | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/tests/test_leftover_axis_report_singular_only_contract.py b/tests/test_leftover_axis_report_singular_only_contract.py index 6357a3ca9..3a68fe925 100644 --- a/tests/test_leftover_axis_report_singular_only_contract.py +++ b/tests/test_leftover_axis_report_singular_only_contract.py @@ -17,8 +17,10 @@ def test_report_axis_badge_preserves_singular_when_share_is_missing() -> None: 'LEFTOVER_MAP_AXIS_BADGE_SINGULAR_ONLY = "leftover axis {axis} σ {value}"' in source ) - assert "leftoverMapAxisBadge(" in source - assert "LEFTOVER_MAP_AXIS_BADGE_SINGULAR_ONLY" in source + assert 'LEFTOVER_MAP_AXIS_BADGE_SINGULAR = "leftover axis {axis} σ {value}{share}"' in source + assert "formatLeftoverMapPlotAxisShare" in source + assert 'return share === null ? "" : ` ${share}%`;' in source + assert "Number.NaN" not in source def test_report_axis_badge_keeps_sigma_and_share_missingness_independent() -> None: @@ -31,14 +33,18 @@ def test_report_axis_badge_keeps_sigma_and_share_missingness_independent() -> No assert "LEFTOVER_MAP_AXIS_BADGE_SINGULAR" in source assert "LEFTOVER_MAP_AXIS_BADGE_SHARE" in source assert "LEFTOVER_MAP_AXIS_BADGE_SINGULAR_ONLY" in source + assert 'singular === null && share === ""' in source + assert 'share === ""' in source assert "Math.sqrt" not in source -def test_report_axis_rendering_consumes_the_independent_badge_projection() -> None: - """The report UI must render the four-state projection instead of rebuilding σ/share coupling.""" +def test_report_axis_rendering_consumes_the_repaired_formatting_primitives() -> None: + """The report path must consume helpers whose missing share no longer becomes `NaN%`.""" app_source = APP_SOURCE.read_text(encoding="utf-8") - assert "leftoverMapAxisBadge," in app_source - assert "const badge = leftoverMapAxisBadge(axis);" in app_source - assert "badge === null ? null" in app_source - assert "tf(badge.template, badge.values)" in app_source + assert "leftoverMapAxisBadgeShare," in app_source + assert "leftoverMapAxisBadgeSingular," in app_source + assert "const singular = leftoverMapAxisBadgeSingular(axis);" in app_source + assert "const share = leftoverMapAxisBadgeShare(axis.leftover_share);" in app_source + assert "LEFTOVER_MAP_AXIS_BADGE_SHARE" in app_source + assert "LEFTOVER_MAP_AXIS_BADGE_SINGULAR" in app_source From e98abe27eb01f40c2162bffedca14b0eb03516ec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 00:17:35 +0900 Subject: [PATCH 09/12] test(reports): require empty report-axis evidence omission --- ...eftover_axis_report_singular_only_contract.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_leftover_axis_report_singular_only_contract.py b/tests/test_leftover_axis_report_singular_only_contract.py index 3a68fe925..873f2e04b 100644 --- a/tests/test_leftover_axis_report_singular_only_contract.py +++ b/tests/test_leftover_axis_report_singular_only_contract.py @@ -38,13 +38,13 @@ def test_report_axis_badge_keeps_sigma_and_share_missingness_independent() -> No assert "Math.sqrt" not in source -def test_report_axis_rendering_consumes_the_repaired_formatting_primitives() -> None: - """The report path must consume helpers whose missing share no longer becomes `NaN%`.""" +def test_report_axis_rendering_consumes_the_four_state_projection() -> None: + """The report path must omit a badge when neither persisted axis datum is usable.""" app_source = APP_SOURCE.read_text(encoding="utf-8") - assert "leftoverMapAxisBadgeShare," in app_source - assert "leftoverMapAxisBadgeSingular," in app_source - assert "const singular = leftoverMapAxisBadgeSingular(axis);" in app_source - assert "const share = leftoverMapAxisBadgeShare(axis.leftover_share);" in app_source - assert "LEFTOVER_MAP_AXIS_BADGE_SHARE" in app_source - assert "LEFTOVER_MAP_AXIS_BADGE_SINGULAR" in app_source + assert "leftoverMapAxisBadge," in app_source + assert "const badge = leftoverMapAxisBadge(axis);" in app_source + assert "badge === null ? null" in app_source + assert "tf(badge.template, badge.values)" in app_source + assert "leftoverMapAxisBadgeShare," not in app_source + assert "leftoverMapAxisBadgeSingular," not in app_source From 047cefc5b439cf4f4c8e33ca0585ac4b4ca26a3b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 09:02:18 +0900 Subject: [PATCH 10/12] test(reports): assert four-state badge semantics, not import punctuation --- tests/test_leftover_axis_report_singular_only_contract.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_leftover_axis_report_singular_only_contract.py b/tests/test_leftover_axis_report_singular_only_contract.py index 873f2e04b..2232a8d8c 100644 --- a/tests/test_leftover_axis_report_singular_only_contract.py +++ b/tests/test_leftover_axis_report_singular_only_contract.py @@ -42,9 +42,9 @@ def test_report_axis_rendering_consumes_the_four_state_projection() -> None: """The report path must omit a badge when neither persisted axis datum is usable.""" app_source = APP_SOURCE.read_text(encoding="utf-8") - assert "leftoverMapAxisBadge," in app_source + assert "leftoverMapAxisBadge" in app_source assert "const badge = leftoverMapAxisBadge(axis);" in app_source assert "badge === null ? null" in app_source assert "tf(badge.template, badge.values)" in app_source - assert "leftoverMapAxisBadgeShare," not in app_source - assert "leftoverMapAxisBadgeSingular," not in app_source + assert "leftoverMapAxisBadgeShare" not in app_source + assert "leftoverMapAxisBadgeSingular" not in app_source From 6935ac4d3850ae24d23fab76be70fe22102ef4ee Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 02:55:50 +0000 Subject: [PATCH 11/12] fix(reports): consume report-axis badge projection --- frontend/src/App.tsx | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ecd927785..ce2842434 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -137,12 +137,7 @@ import { LEFTOVER_MAP_PLOT_INCOMPLETE_POST, LEFTOVER_MAP_PLOT_ITEM_COVERAGE, } from "./leftoverMapCoverage"; -import { - leftoverMapAxisBadgeShare, - leftoverMapAxisBadgeSingular, - LEFTOVER_MAP_AXIS_BADGE_SHARE, - LEFTOVER_MAP_AXIS_BADGE_SINGULAR, -} from "./leftoverMapAxisBadge"; +import { leftoverMapAxisBadge } from "./leftoverMapAxisBadge"; import { formatLeftoverMapReconstruction, LEFTOVER_MAP_COMPARE_RECONSTRUCTION_LABEL, @@ -3930,17 +3925,10 @@ function ReportsPanel({

) : null} {report.leftover_map_axes?.map((axis) => { - const singular = leftoverMapAxisBadgeSingular(axis); - const share = leftoverMapAxisBadgeShare(axis.leftover_share); - return ( + const badge = leftoverMapAxisBadge(axis); + return badge === null ? null : ( - {singular === null - ? tf(LEFTOVER_MAP_AXIS_BADGE_SHARE, { axis: axis.axis_index, share }) - : tf(LEFTOVER_MAP_AXIS_BADGE_SINGULAR, { - axis: axis.axis_index, - value: singular, - share, - })} + {tf(badge.template, badge.values)} ); })} From 6f75308695fd110a5ebf9919d10a0d5493a543da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 23:27:14 +0900 Subject: [PATCH 12/12] test(reporting): align report badge contract with independent share suffix --- tests/test_leftover_axis_report_singular_contract.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/test_leftover_axis_report_singular_contract.py b/tests/test_leftover_axis_report_singular_contract.py index 5319a7113..192efc04f 100644 --- a/tests/test_leftover_axis_report_singular_contract.py +++ b/tests/test_leftover_axis_report_singular_contract.py @@ -13,13 +13,12 @@ def test_report_axis_badge_keeps_singular_value_and_share_semantically_distinct( assert BADGE_SOURCE.exists(), "report-axis singular-value badge helper is missing" badge_source = BADGE_SOURCE.read_text(encoding="utf-8") - assert 'LEFTOVER_MAP_AXIS_BADGE_SHARE = "leftover axis {axis} {share}%"' in badge_source - assert ( - 'LEFTOVER_MAP_AXIS_BADGE_SINGULAR = "leftover axis {axis} σ {value} {share}%"' - in badge_source - ) + assert 'LEFTOVER_MAP_AXIS_BADGE_SHARE = "leftover axis {axis}{share}"' in badge_source + assert 'LEFTOVER_MAP_AXIS_BADGE_SINGULAR = "leftover axis {axis} σ {value}{share}"' in badge_source + assert 'LEFTOVER_MAP_AXIS_BADGE_SINGULAR_ONLY = "leftover axis {axis} σ {value}"' in badge_source assert "leftoverSingularForAxis" in badge_source assert "formatLeftoverMapPlotAxisSingular" in badge_source + assert "formatLeftoverMapPlotAxisShare" in badge_source assert "leftover map comparison graphic" not in badge_source