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; +}; + +/** + * 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( @@ -25,3 +39,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 = leftoverMapAxisBadgeShare(axis.leftover_share); + + if (singular === null && share === "") { + return null; + } + if (singular === null) { + return { + template: LEFTOVER_MAP_AXIS_BADGE_SHARE, + values: { axis: axis.axis_index, share }, + }; + } + if (share === "") { + 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, share }, + }; +} 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 diff --git a/tests/test_leftover_axis_report_singular_only_contract.py b/tests/test_leftover_axis_report_singular_only_contract.py new file mode 100644 index 000000000..2232a8d8c --- /dev/null +++ b/tests/test_leftover_axis_report_singular_only_contract.py @@ -0,0 +1,50 @@ +"""Executable contract for the report-axis singular-only state.""" + +from pathlib import Path + + +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: + """A valid persisted σ remains visible when axis share is absent.""" + assert BADGE_SOURCE.exists(), "report-axis singular-value badge helper is missing" + source = BADGE_SOURCE.read_text(encoding="utf-8") + + assert ( + 'LEFTOVER_MAP_AXIS_BADGE_SINGULAR_ONLY = "leftover axis {axis} σ {value}"' + 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: + """σ-only, share-only, combined, and empty states must remain data-driven.""" + assert BADGE_SOURCE.exists(), "report-axis singular-value badge helper is missing" + source = BADGE_SOURCE.read_text(encoding="utf-8") + + assert "formatLeftoverMapPlotAxisSingular" in source + assert "leftoverMapAxisBadgeShare" in source + 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_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 "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