diff --git a/CHANGELOG.d/2.81.0-leftover-map-plot-axis-singular.md b/CHANGELOG.d/2.81.0-leftover-map-plot-axis-singular.md new file mode 100644 index 000000000..6e39642a5 --- /dev/null +++ b/CHANGELOG.d/2.81.0-leftover-map-plot-axis-singular.md @@ -0,0 +1,3 @@ +### Added + +- Report leftover-map graphic axes now expose persisted Gabriel singular values independently of persisted axis share, preserving finite `σ=0` and omitting invalid evidence without deriving one measure from the other. 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/0324-leftover-map-report-graphic-axis-singular.md b/docs/adr/0324-leftover-map-report-graphic-axis-singular.md new file mode 100644 index 000000000..c40c6d946 --- /dev/null +++ b/docs/adr/0324-leftover-map-report-graphic-axis-singular.md @@ -0,0 +1,31 @@ +# ADR 0324 — Name persisted singular values on report leftover-map graphic axes + +**Decision status:** Proposed +**Date:** 2026-08-31 + +## Context + +LineageWeave persists leftover-map axis share and Gabriel singular value `σ_k`. Comparison graphics and comparison-strip evidence can already expose persisted `σ_k`; the report graphic still shows share only. That hides scale evidence on the report graphic and creates inconsistent semantics for the same persisted axes. + +Missingness is independent. A valid finite, non-negative `σ_k`, including `0`, must remain visible when share is absent; valid share must remain visible when `σ_k` is absent or invalid. Neither value is reconstructed from the other. + +## Decision + +Report graphic axes use distinct copy: + +- singular only: `leftover-map axis {axis} σ {value}`; +- singular plus share: `leftover-map axis {axis} σ {value} ({share}%)`; +- share only: the existing `leftover-map axis {axis} ({share}%)`; +- neither usable: the existing axis name without invented evidence. + +No square root, normalization, SQL, migration, or owner duplication is introduced. The rendering consumes LineageWeave-owned persisted evidence and leaves comparison copy distinct. + +## Consequences + +Report and comparison graphics now preserve the same evidence semantics while keeping surface-specific labels. This ADR remains **Proposed** until fresh exact-head 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/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/components/LeftoverMapPlot.tsx b/frontend/src/components/LeftoverMapPlot.tsx index a09c0ee0e..9e36987ae 100644 --- a/frontend/src/components/LeftoverMapPlot.tsx +++ b/frontend/src/components/LeftoverMapPlot.tsx @@ -33,6 +33,8 @@ import { leftoverSingularForAxis, 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"; import { firstPlottablePairForPost, @@ -106,10 +108,20 @@ function leftoverMapPlotAxisText( share: percent, }); } + if (singular === null) { + if (percent === null) { + return t(axisIndex === 1 ? "leftover-map axis 1" : "leftover-map axis 2"); + } + return tf(LEFTOVER_MAP_PLOT_AXIS_SHARE, { axis: axisIndex, share: percent }); + } if (percent === null) { - return t(axisIndex === 1 ? "leftover-map axis 1" : "leftover-map axis 2"); + return tf(LEFTOVER_MAP_PLOT_AXIS_SINGULAR, { axis: axisIndex, value: singular }); } - return tf(LEFTOVER_MAP_PLOT_AXIS_SHARE, { axis: axisIndex, share: percent }); + return tf(LEFTOVER_MAP_PLOT_AXIS_SINGULAR_SHARE, { + axis: axisIndex, + value: singular, + share: percent, + }); } /** @@ -118,9 +130,9 @@ function leftoverMapPlotAxisText( * Person markers are posts; item markers are leftover criteria. Click a * post marker to open that post. Caption leftover-map axes with persisted * Gabriel inertia share when finite, including rank-0 zero-share axes. - * Comparison graphic axes additionally name finite, non-negative persisted - * Gabriel singular values independently of axis share; never derive one - * measurement from the other. The comparison strip also exposes these two + * Report and comparison graphic axes additionally name finite, non-negative + * persisted Gabriel singular values independently of axis share; never derive + * one measurement from the other. The comparison strip also exposes these two * persisted measures as independent badges so a missing share does not erase * usable σ evidence and a missing σ does not erase usable share evidence. * Axis ticks name persisted leftover-map coordinates so ξ / ζ on the 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/frontend/src/leftoverMapPlotAxisSingular.ts b/frontend/src/leftoverMapPlotAxisSingular.ts index 14fa82bde..aed961ef0 100644 --- a/frontend/src/leftoverMapPlotAxisSingular.ts +++ b/frontend/src/leftoverMapPlotAxisSingular.ts @@ -3,6 +3,12 @@ import type { LeftoverMapAxis } from "./api"; import { formatLeftoverMapPlotAxisShare } from "./leftoverMapPlotAxisShare"; +export const LEFTOVER_MAP_PLOT_AXIS_SINGULAR = + "leftover-map axis {axis} σ {value}"; + +export const LEFTOVER_MAP_PLOT_AXIS_SINGULAR_SHARE = + "leftover-map axis {axis} σ {value} ({share}%)"; + export const LEFTOVER_MAP_COMPARE_PLOT_AXIS_SINGULAR = "leftover map comparison graphic leftover-map axis {axis} σ {value}"; 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 diff --git a/tests/test_leftover_map_report_graphic_axis_singular_contract.py b/tests/test_leftover_map_report_graphic_axis_singular_contract.py new file mode 100644 index 000000000..10ecc77f2 --- /dev/null +++ b/tests/test_leftover_map_report_graphic_axis_singular_contract.py @@ -0,0 +1,33 @@ +"""Executable contract for persisted singular values on report-graphic axes.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SINGULAR_SOURCE = ROOT / "frontend" / "src" / "leftoverMapPlotAxisSingular.ts" +PLOT_SOURCE = ROOT / "frontend" / "src" / "components" / "LeftoverMapPlot.tsx" + + +def test_report_graphic_axis_has_distinct_singular_and_singular_share_copy() -> None: + """Report-graphic σ copy must stay distinct from pair badges and comparison variants.""" + assert SINGULAR_SOURCE.exists(), "persisted singular-value projection helper is missing" + source = SINGULAR_SOURCE.read_text(encoding="utf-8") + plot_source = PLOT_SOURCE.read_text(encoding="utf-8") + + assert '"leftover-map axis {axis} σ {value}"' in source + assert '"leftover-map axis {axis} σ {value} ({share}%)"' in source + assert "LEFTOVER_MAP_PLOT_AXIS_SINGULAR" in plot_source + assert "LEFTOVER_MAP_PLOT_AXIS_SINGULAR_SHARE" in plot_source + + +def test_report_graphic_singular_value_comes_from_persisted_axis_evidence() -> None: + """Invalid σ omits independently of share; finite σ=0 remains explicit.""" + assert SINGULAR_SOURCE.exists(), "persisted singular-value projection helper is missing" + source = SINGULAR_SOURCE.read_text(encoding="utf-8") + + assert "leftover_singular_value" in source + assert "Number.isFinite" in source + assert "< 0" in source + assert ".toFixed(2)" in source + assert "Math.sqrt" not in source + assert "Math.max" not in source