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%`. 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 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)} ); })} 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(); }); }); diff --git a/frontend/src/leftoverMapAxisBadge.ts b/frontend/src/leftoverMapAxisBadge.ts index 7d2b377df..4a00d425f 100644 --- a/frontend/src/leftoverMapAxisBadge.ts +++ b/frontend/src/leftoverMapAxisBadge.ts @@ -1,19 +1,33 @@ /** Caption leftover-axis report badges with persisted Gabriel singular values. */ import type { LeftoverMapAxis } from "./api"; +import { formatLeftoverMapPlotAxisShare } from "./leftoverMapPlotAxisShare"; import { formatLeftoverMapPlotAxisSingular, 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}"; + +export type LeftoverMapAxisBadge = { + template: string; + values: Record