diff --git a/CHANGELOG.d/2.79.0-leftover-map-axis-singular.md b/CHANGELOG.d/2.79.0-leftover-map-axis-singular.md new file mode 100644 index 000000000..d83609121 --- /dev/null +++ b/CHANGELOG.d/2.79.0-leftover-map-axis-singular.md @@ -0,0 +1,17 @@ +## 2.79.0 — Leftover-map singular values on leftover-axis report badges + +- Caption leftover-axis report badges with persisted leftover-map singular + values `σ_k` (ADR 0322). After `make seed`, leftover-axis badges name + persisted leftover-map singular values when finite together with persisted + leftover-map axis share; click a leftover pair opens that post. 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`. + Pair-list leftover-axis badges stay `leftover axis {k} σ {value} {share}%` + so they stay distinct from hyphen `leftover-map axis {k} σ {value} ({share}%)` + and from comparison copy + `leftover map comparison graphic leftover-map axis {k} σ {value} ({share}%)`. + Do not invent `σ_k` from leftover-map axis share. This increment does not + caption leftover-map singular values on leftover-axis badges on the grouping + comparison strip with a distinct name. A finite negative leftover is shown, + never clamped. Never invent a leftover score. Never invent a theta. No new + columns. diff --git a/docs/adr/0322-leftover-map-axis-singular.md b/docs/adr/0322-leftover-map-axis-singular.md new file mode 100644 index 000000000..f1c259954 --- /dev/null +++ b/docs/adr/0322-leftover-map-axis-singular.md @@ -0,0 +1,98 @@ +# ADR 0322 — Name leftover-map singular values on leftover-axis report badges + +**Decision status:** Proposed +**Date:** 2026-08-31 + +Amends leftover-map axis share persistence +([ADR 0148](0148-leftover-map-axis-share.md)). Independent of leftover-map +singular values on the grouping comparison leftover-map graphic +([ADR 0321](0321-leftover-map-compare-plot-singular.md)), leftover-map +coordinate ticks on that comparison graphic +([ADR 0320](0320-leftover-map-compare-plot-ticks.md)), leftover-map +axis share on that comparison graphic +([ADR 0305](0305-leftover-map-compare-plot-axis-share.md)), leftover-map +graphic display on the grouping comparison strip +([ADR 0304](0304-leftover-map-compare-graphic.md)), leftover-map +coordinates ([ADR 0267](0267-leftover-map-coordinates.md)), leftover-map +graphic display ([ADR 0268](0268-leftover-map-graphic-display.md)), and +leftover-map axis share on the graphic display +([ADR 0269](0269-leftover-map-axis-share-plot.md)). + +## Context + +ADR 0148 already persists leftover-map singular values `σ_k` and leftover-map +axis share `σ_k² / Σ_j σ_j²` and captions leftover-axis report badges with +share only. ADR 0321 already captions the grouping comparison leftover-map +graphic with persisted `σ_k`. Those leftover-axis badges still hide `σ_k`, so +Gabriel (1971) singular values appear on the comparison graphic and never on +the report-level leftover-axis badges that name the same axes. Hiding `σ_k` on +the badges lets leftover-map axis share be read as leftover-map structure even +when the buyer cannot tell whether axis 1 is large because `σ_1` is large or +because axis 2 collapsed. 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. + +This increment captions leftover-axis report badges with already persisted +leftover-map singular values. Pair-list leftover-axis badges stay +`leftover axis {k} σ {value} {share}%` so they stay distinct from hyphen +`leftover-map axis {k} σ {value} ({share}%)` and from comparison copy +`leftover map comparison graphic leftover-map axis {k} σ {value} ({share}%)`. +It does not add columns. Do not invent a leftover score. Do not invent a +theta. + +This protected increment uses **0322** so it does not collide with leftover-map +singular values on the grouping comparison leftover-map graphic (0321), +leftover-map coordinate ticks on that graphic (0320), leftover-map axis share +on that graphic (0305), leftover-map graphic display on the grouping +comparison strip (0304), 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. Pair-list labels stay +distinct from graphic labels: leftover-axis badges stay +`leftover axis {k} σ {value} {share}%`; leftover-map graphic axes stay +`leftover-map axis {k} ({share}%)` until a later increment captions `σ_k` +there; grouping comparison leftover-map graphic axes stay +`leftover map comparison graphic leftover-map axis {k} σ {value} ({share}%)`. +A missing, non-finite, or negative singular value omits that `σ` badge and +keeps the existing `leftover axis {k} {share}%` text. Do not invent `σ_k` +from leftover-map axis share. Axis 1 and axis 2 stay independently named. +Click a leftover pair to open that post. The grouping comparison strip +(ADR 0149) stays on its reduced leftover payload and does not gain leftover-axis +report badges with a distinct comparison-strip name. + +Do not add SQL. Do not edit shipped migrations. Do not persist inner +product, cosine, or length as separate columns. Do not invent a leftover +score. Do not invent a theta. + +## Consequences + +After `make seed`, leftover-axis report badges name persisted leftover-map +singular values when finite together with persisted leftover-map axis share; +click a leftover pair opens that post. Hidden posts stay hidden. Rank-0 unused +axes still name `σ 0.00`. Grouping comparison leftover-map graphic leftover-map +singular values (ADR 0321) remain. + +## Related + +Independent of leftover-map singular values on the grouping comparison +leftover-map graphic. This increment does not caption leftover-map singular +values on leftover-axis badges on the grouping comparison strip with a +distinct name. + +## 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/frontend/src/App.tsx b/frontend/src/App.tsx index dfa30ed1b..ecd927785 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -137,6 +137,12 @@ 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 { formatLeftoverMapReconstruction, LEFTOVER_MAP_COMPARE_RECONSTRUCTION_LABEL, @@ -3923,18 +3929,25 @@ function ReportsPanel({ {tf(LEFTOVER_MAP_PLOT_INCOMPLETE_ITEM, incompleteItemCount)}

) : null} - {report.leftover_map_axes?.map((axis) => ( - - {tf("leftover axis {axis} {share}%", { - axis: axis.axis_index, - share: (axis.leftover_share * 100).toFixed(0), - })} - - ))} + {report.leftover_map_axes?.map((axis) => { + const singular = leftoverMapAxisBadgeSingular(axis); + const share = leftoverMapAxisBadgeShare(axis.leftover_share); + 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, + })} + + ); + })} {report.leftover_map_axes && report.leftover_map_axes.length > 0 && (

{t( - "Leftover-map axis share is Gabriel inertia of residual SVD axes 1 and 2. Open a leftover pair to read the post–criterion cell. The shares do not invent a leftover score.", + "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 new file mode 100644 index 000000000..84ec57a39 --- /dev/null +++ b/frontend/src/leftoverMapAxisBadge.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { + leftoverMapAxisBadgeShare, + leftoverMapAxisBadgeSingular, + LEFTOVER_MAP_AXIS_BADGE_SHARE, + LEFTOVER_MAP_AXIS_BADGE_SINGULAR, +} from "./leftoverMapAxisBadge"; +import { + LEFTOVER_MAP_COMPARE_PLOT_AXIS_SINGULAR, + LEFTOVER_MAP_COMPARE_PLOT_AXIS_SINGULAR_SHARE, + LEFTOVER_MAP_PLOT_AXIS_SINGULAR, + LEFTOVER_MAP_PLOT_AXIS_SINGULAR_SHARE, +} 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"); + }); +}); + +describe("leftoverMapAxisBadgeSingular", () => { + it("reads persisted leftover-map singular values without inventing a leftover score", () => { + expect( + leftoverMapAxisBadgeSingular({ axis_index: 1, leftover_singular_value: 1.84 }), + ).toBe("1.84"); + expect( + leftoverMapAxisBadgeSingular({ axis_index: 2, leftover_singular_value: 0.86 }), + ).toBe("0.86"); + }); + + it("names a rank-0 zero leftover-map singular value", () => { + expect(leftoverMapAxisBadgeSingular({ axis_index: 1, leftover_singular_value: 0 })).toBe("0.00"); + }); + + it("omits leftover-map singular values that are missing, non-finite, or negative", () => { + expect(leftoverMapAxisBadgeSingular({ axis_index: 1 })).toBeNull(); + expect( + leftoverMapAxisBadgeSingular({ axis_index: 1, leftover_singular_value: Number.NaN }), + ).toBeNull(); + expect( + leftoverMapAxisBadgeSingular({ axis_index: 1, leftover_singular_value: Number.POSITIVE_INFINITY }), + ).toBeNull(); + expect( + leftoverMapAxisBadgeSingular({ axis_index: 1, leftover_singular_value: -0.01 }), + ).toBeNull(); + }); + + it("does not invent leftover-map singular values from leftover-map axis share", () => { + 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}%"); + 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}"); + }); +}); diff --git a/frontend/src/leftoverMapAxisBadge.ts b/frontend/src/leftoverMapAxisBadge.ts new file mode 100644 index 000000000..7d2b377df --- /dev/null +++ b/frontend/src/leftoverMapAxisBadge.ts @@ -0,0 +1,27 @@ +/** Caption leftover-axis report badges with persisted Gabriel singular values. */ + +import type { LeftoverMapAxis } from "./api"; +import { + formatLeftoverMapPlotAxisSingular, + leftoverSingularForAxis, +} from "./leftoverMapPlotAxisSingular"; + +export const LEFTOVER_MAP_AXIS_BADGE_SHARE = "leftover axis {axis} {share}%"; + +export const LEFTOVER_MAP_AXIS_BADGE_SINGULAR = "leftover axis {axis} σ {value} {share}%"; + +export function leftoverMapAxisBadgeShare( + leftoverShare: LeftoverMapAxis["leftover_share"] | null | undefined, +): string { + return ((leftoverShare ?? Number.NaN) * 100).toFixed(0); +} + +export function leftoverMapAxisBadgeSingular( + axis: Pick & { + leftover_singular_value?: LeftoverMapAxis["leftover_singular_value"] | null; + }, +): string | null { + return formatLeftoverMapPlotAxisSingular( + leftoverSingularForAxis([axis], axis.axis_index), + ); +} diff --git a/tests/test_leftover_axis_report_singular_contract.py b/tests/test_leftover_axis_report_singular_contract.py new file mode 100644 index 000000000..5319a7113 --- /dev/null +++ b/tests/test_leftover_axis_report_singular_contract.py @@ -0,0 +1,38 @@ +"""Executable contract for persisted singular values on report-axis badges.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +BADGE_SOURCE = ROOT / "frontend" / "src" / "leftoverMapAxisBadge.ts" +SINGULAR_SOURCE = ROOT / "frontend" / "src" / "leftoverMapPlotAxisSingular.ts" + + +def test_report_axis_badge_keeps_singular_value_and_share_semantically_distinct() -> None: + """Report badges must expose persisted σ without borrowing comparison-graphic copy.""" + 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 "leftoverSingularForAxis" in badge_source + assert "formatLeftoverMapPlotAxisSingular" in badge_source + assert "leftover map comparison graphic" not in badge_source + + +def test_report_axis_badge_never_infers_singular_value_from_share() -> None: + """Missing or invalid σ omits independently while rank-0 σ=0 stays representable.""" + assert BADGE_SOURCE.exists(), "report-axis singular-value badge helper is missing" + assert SINGULAR_SOURCE.exists(), "persisted singular-value helper is missing" + badge_source = BADGE_SOURCE.read_text(encoding="utf-8") + singular_source = SINGULAR_SOURCE.read_text(encoding="utf-8") + + assert "leftover_singular_value" in singular_source + assert "Number.isFinite" in singular_source + assert "< 0" in singular_source + assert ".toFixed(2)" in singular_source + assert "Math.sqrt" not in badge_source + assert "Math.sqrt" not in singular_source