Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.d/2.79.0-leftover-map-axis-singular.md
Original file line number Diff line number Diff line change
@@ -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.
98 changes: 98 additions & 0 deletions docs/adr/0322-leftover-map-axis-singular.md
Original file line number Diff line number Diff line change
@@ -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.)
31 changes: 22 additions & 9 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -3923,18 +3929,25 @@ function ReportsPanel({
{tf(LEFTOVER_MAP_PLOT_INCOMPLETE_ITEM, incompleteItemCount)}
</p>
) : null}
{report.leftover_map_axes?.map((axis) => (
<span key={axis.axis_index} className="post-badge">
{tf("leftover axis {axis} {share}%", {
axis: axis.axis_index,
share: (axis.leftover_share * 100).toFixed(0),
})}
</span>
))}
{report.leftover_map_axes?.map((axis) => {
const singular = leftoverMapAxisBadgeSingular(axis);
const share = leftoverMapAxisBadgeShare(axis.leftover_share);
return (
<span key={axis.axis_index} className="post-badge">
{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,
})}
</span>
);
})}
{report.leftover_map_axes && report.leftover_map_axes.length > 0 && (
<p aria-label={t("Leftover-map axis share")}>
{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.",
)}
</p>
)}
Expand Down
65 changes: 65 additions & 0 deletions frontend/src/leftoverMapAxisBadge.test.ts
Original file line number Diff line number Diff line change
@@ -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}");
});
});
27 changes: 27 additions & 0 deletions frontend/src/leftoverMapAxisBadge.ts
Original file line number Diff line number Diff line change
@@ -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<LeftoverMapAxis, "axis_index"> & {
leftover_singular_value?: LeftoverMapAxis["leftover_singular_value"] | null;
},
): string | null {
return formatLeftoverMapPlotAxisSingular(
leftoverSingularForAxis([axis], axis.axis_index),
);
}
38 changes: 38 additions & 0 deletions tests/test_leftover_axis_report_singular_contract.py
Original file line number Diff line number Diff line change
@@ -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
Loading