Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
52b3597
feat(reports): leftover-map singular values on comparison leftover-ax…
seonghobae Aug 31, 2026
83f2753
test(red): reconstruct comparison-strip axis singular contract
seonghobae Sep 10, 2026
86fa267
chore(stack): converge comparison-axis badge lane on repaired parent
seonghobae Sep 11, 2026
8fbd8ee
chore(stack): converge comparison-axis badge lane on documented parent
seonghobae Sep 11, 2026
b55fddd
chore(stack): converge #863 onto repaired #862 head
seonghobae Sep 11, 2026
394b00f
chore(stack): converge #863 onto repaired #862
seonghobae Sep 12, 2026
4e8720e
chore(stack): converge #863 onto repaired ancestry
seonghobae Sep 13, 2026
be6e911
chore(stack): converge #863 onto repaired #862
seonghobae Sep 15, 2026
f1de3f0
chore(stack): converge #863 onto repaired #862
seonghobae Sep 15, 2026
596d251
fix(ui): project comparison strip singular badges
seonghobae Sep 15, 2026
6fa55ff
test(ui): cover comparison strip singular badge states
seonghobae Sep 15, 2026
cbe6c44
fix(ui): render comparison strip axis evidence
seonghobae Sep 15, 2026
8da8c07
docs(adr): record comparison strip singular badges
seonghobae Sep 15, 2026
ea10748
docs(changelog): record comparison axis singular evidence
seonghobae Sep 15, 2026
554e3ad
fix(ui): type comparison axis template values safely
seonghobae Sep 15, 2026
afa9797
chore(stack): adopt #862 localized comparison tick contract
seonghobae Sep 15, 2026
6f43c2d
chore(stack): adopt #862 localized comparison tick repair
seonghobae Sep 15, 2026
bac8d3a
chore(stack): converge current #862 into #863
seonghobae Sep 15, 2026
1bab89a
test(reporting): scope singular independence contract
seonghobae Sep 19, 2026
39d5331
chore(stack): converge #863 onto #862 owner RED
seonghobae Sep 20, 2026
a9e1050
test(reports): preserve owner acceptance RED through #863
seonghobae Sep 20, 2026
7f1b97e
chore(stack): converge #863 onto repaired #862 head
seonghobae Sep 20, 2026
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
3 changes: 3 additions & 0 deletions CHANGELOG.d/2.80.0-leftover-map-compare-axis-singular.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Added

- Grouping comparison leftover-axis evidence now names persisted Gabriel singular values independently of persisted axis share. Combined, singular-only, share-only, and empty states remain distinct; finite `σ=0` is preserved and neither measure is derived from the other.
37 changes: 37 additions & 0 deletions docs/adr/0323-leftover-map-compare-axis-singular.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# ADR 0323 — Name persisted singular values on grouping comparison leftover-axis badges

**Decision status:** Proposed
**Date:** 2026-08-31

Amends leftover-map axis share persistence ([ADR 0148](0148-leftover-map-axis-share.md)) and the grouping comparison strip ([ADR 0149](0149-period-grouping-comparison.md)). It follows comparison-graphic singular evidence ([ADR 0321](0321-leftover-map-compare-plot-singular.md)) and report-axis singular badges ([ADR 0322](0322-leftover-map-axis-singular.md)) without changing either contract.

## Context

LineageWeave already persists leftover-map axis share and singular value `σ_k` for each axis. The report surface and the grouping-comparison graphic can expose that evidence, but the grouping comparison strip still omits it. Buyers therefore cannot read the persisted axis scale from the comparison strip itself.

The two measurements have different missingness. A valid share must remain visible when `σ_k` is absent or invalid; a valid finite, non-negative `σ_k` — including `0` — must remain visible when share is absent or invalid. Neither value may be reconstructed from the other. This increment does not add persistence, SQL, psychometric estimation, or a new canonical owner.

## Decision

For comparison-strip leftover axes, project already persisted values into four independent states:

- combined: `leftover map comparison leftover axis {axis} σ {value} {share}%`;
- singular only: `leftover map comparison leftover axis {axis} σ {value}`;
- share only: `leftover map comparison leftover axis {axis} {share}%`;
- neither usable: omit the badge.

`σ_k` is accepted only when finite and non-negative; `σ=0` remains explicit. Share is accepted only when finite. No `Math.sqrt`, inversion, normalization, or other derivation is allowed. The copy remains distinct from report-axis badges and from comparison-graphic axis captions.

The comparison rendering reuses LineageWeave-owned persisted evidence and existing localization composition. It does not copy logic from fast-mlsirm, TEPP, or any other canonical owner.

## Consequences

The grouping comparison buyer path exposes axis evidence without conflating scale and share. Missing evidence fails closed by omission, while usable evidence survives independently. Existing comparison-graphic interaction, pointer/keyboard behavior, and post navigation remain unchanged.

This ADR stays **Proposed** until the exact-head implementation, executable contract, frontend tests, accessibility/browser evidence, and independent review are 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
20 changes: 19 additions & 1 deletion frontend/src/components/LeftoverMapPlot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
} from "../leftoverMapPlotAxisShare";
import {
formatLeftoverMapPlotAxisSingular,
leftoverMapCompareAxisBadge,
leftoverSingularForAxis,
LEFTOVER_MAP_COMPARE_PLOT_AXIS_SINGULAR,
LEFTOVER_MAP_COMPARE_PLOT_AXIS_SINGULAR_SHARE,
Expand Down Expand Up @@ -119,7 +120,9 @@ function leftoverMapPlotAxisText(
* 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.
* 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
* pair row match the plot. Pair segments name persisted leftover-map
* distance ``d``, leftover-map reconstruction ``R̂``, leftover-map
Expand Down Expand Up @@ -218,6 +221,12 @@ export function LeftoverMapPlot({
const itemCoverageCounts = leftoverMapItemCoverageCounts(leftoverMapCoverage);
const incompletePostCount = leftoverMapIncompletePostCount(leftoverMapCoverage);
const incompleteItemCount = leftoverMapIncompleteItemCount(leftoverMapCoverage);
const comparisonAxisBadges =
variant === "comparison"
? (leftoverMapAxes ?? [])
.map((axis) => leftoverMapCompareAxisBadge(axis))
.filter((badge): badge is NonNullable<typeof badge> => badge !== null)
: [];

const openPost = (postId: string) => {
const pair = firstPlottablePairForPost(pairs, postId);
Expand All @@ -234,6 +243,15 @@ export function LeftoverMapPlot({
<figcaption className="leftover-map-plot-caption">
{t(variant === "comparison" ? LEFTOVER_MAP_COMPARE_PLOT_CAPTION : LEFTOVER_MAP_PLOT_CAPTION)}
</figcaption>
{comparisonAxisBadges.length > 0 ? (
<div className="leftover-map-compare-axis-badges" aria-label={t("Comparison leftover-map axis evidence")}>
{comparisonAxisBadges.map((badge) => (
<span key={badge.values.axis} className="post-badge">
{tf(badge.template, badge.values)}
</span>
))}
</div>
) : null}
{coverageCounts !== null ? (
<p
className="leftover-map-plot-coverage"
Expand Down
49 changes: 49 additions & 0 deletions frontend/src/leftoverMapAxisBadge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import {
LEFTOVER_MAP_AXIS_BADGE_SINGULAR,
} from "./leftoverMapAxisBadge";
import {
leftoverMapCompareAxisBadge,
LEFTOVER_MAP_COMPARE_AXIS_SHARE,
LEFTOVER_MAP_COMPARE_AXIS_SINGULAR,
LEFTOVER_MAP_COMPARE_AXIS_SINGULAR_SHARE,
LEFTOVER_MAP_COMPARE_PLOT_AXIS_SINGULAR,
LEFTOVER_MAP_COMPARE_PLOT_AXIS_SINGULAR_SHARE,
LEFTOVER_MAP_PLOT_AXIS_SINGULAR,
Expand Down Expand Up @@ -63,3 +67,48 @@ describe("leftoverMapAxisBadgeSingular", () => {
expect(LEFTOVER_MAP_AXIS_BADGE_SINGULAR).not.toBe("leftover axis {axis} σ {value}");
});
});

describe("leftoverMapCompareAxisBadge", () => {
it("keeps persisted singular and share evidence independent", () => {
expect(
leftoverMapCompareAxisBadge({
axis_index: 1,
leftover_singular_value: 1.24,
leftover_share: 0.42,
}),
).toEqual({
template: LEFTOVER_MAP_COMPARE_AXIS_SINGULAR_SHARE,
values: { axis: 1, value: "1.24", share: "42" },
});
expect(
leftoverMapCompareAxisBadge({
axis_index: 1,
leftover_singular_value: 0,
leftover_share: null,
}),
).toEqual({
template: LEFTOVER_MAP_COMPARE_AXIS_SINGULAR,
values: { axis: 1, value: "0.00" },
});
expect(
leftoverMapCompareAxisBadge({
axis_index: 2,
leftover_singular_value: null,
leftover_share: 0.18,
}),
).toEqual({
template: LEFTOVER_MAP_COMPARE_AXIS_SHARE,
values: { axis: 2, share: "18" },
});
});

it("omits the comparison-strip badge when neither persisted measure is usable", () => {
expect(
leftoverMapCompareAxisBadge({
axis_index: 1,
leftover_singular_value: Number.NaN,
leftover_share: Number.POSITIVE_INFINITY,
}),
).toBeNull();
});
});
54 changes: 52 additions & 2 deletions frontend/src/leftoverMapPlotAxisSingular.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,33 @@
/** Project persisted leftover-map singular-value evidence into buyer-visible axis captions. */

import type { LeftoverMapAxis } from "./api";
import { formatLeftoverMapPlotAxisShare } from "./leftoverMapPlotAxisShare";

export const LEFTOVER_MAP_COMPARE_PLOT_AXIS_SINGULAR =
"leftover map comparison axis {axis} (σ {value})";
"leftover map comparison graphic leftover-map axis {axis} σ {value}";

export const LEFTOVER_MAP_COMPARE_PLOT_AXIS_SINGULAR_SHARE =
"leftover map comparison axis {axis} (σ {value}, {share}%)";
"leftover map comparison graphic leftover-map axis {axis} σ {value} ({share}%)";

export const LEFTOVER_MAP_COMPARE_AXIS_SINGULAR =
"leftover map comparison leftover axis {axis} σ {value}";

export const LEFTOVER_MAP_COMPARE_AXIS_SINGULAR_SHARE =
"leftover map comparison leftover axis {axis} σ {value} {share}%";

export const LEFTOVER_MAP_COMPARE_AXIS_SHARE =
"leftover map comparison leftover axis {axis} {share}%";

export type LeftoverMapPlotAxisSingular = {
axis_index: LeftoverMapAxis["axis_index"];
leftover_singular_value?: LeftoverMapAxis["leftover_singular_value"] | null;
};

export type LeftoverMapCompareAxisBadge = {
template: string;
values: Record<string, string | number>;
};

export function leftoverSingularForAxis(
axes: ReadonlyArray<LeftoverMapPlotAxisSingular> | null | undefined,
axisIndex: number,
Expand All @@ -37,3 +52,38 @@ export function formatLeftoverMapPlotAxisSingular(
}
return leftoverSingular.toFixed(2);
}

/**
* Compose comparison-strip axis evidence without deriving one persisted
* measurement from the other. Missing or invalid evidence is omitted
* independently; when both are absent, no badge is rendered.
*/
export function leftoverMapCompareAxisBadge(
axis: Pick<LeftoverMapAxis, "axis_index"> & {
leftover_share?: LeftoverMapAxis["leftover_share"] | null;
leftover_singular_value?: LeftoverMapAxis["leftover_singular_value"] | null;
},
): LeftoverMapCompareAxisBadge | null {
const singular = formatLeftoverMapPlotAxisSingular(axis.leftover_singular_value);
const share = formatLeftoverMapPlotAxisShare(axis.leftover_share);

if (singular === null && share === null) {
return null;
}
if (singular === null && share !== null) {
return {
template: LEFTOVER_MAP_COMPARE_AXIS_SHARE,
values: { axis: axis.axis_index, share },
};
}
if (singular !== null && share === null) {
return {
template: LEFTOVER_MAP_COMPARE_AXIS_SINGULAR,
values: { axis: axis.axis_index, value: singular },
};
}
return {
template: LEFTOVER_MAP_COMPARE_AXIS_SINGULAR_SHARE,
values: { axis: axis.axis_index, value: singular as string, share: share as string },
};
}
43 changes: 43 additions & 0 deletions tests/test_grouping_comparison_axis_badge_singular_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Executable contract for comparison-strip leftover-axis singular badges."""

from pathlib import Path


ROOT = Path(__file__).resolve().parents[1]
SINGULAR_SOURCE = ROOT / "frontend" / "src" / "leftoverMapPlotAxisSingular.ts"


def test_comparison_strip_axis_badge_has_distinct_copy_for_share_and_singular() -> None:
"""Comparison-strip badge copy must be distinct from report and comparison-graphic axes."""
assert SINGULAR_SOURCE.exists(), "comparison singular-value helper is missing"
source = SINGULAR_SOURCE.read_text(encoding="utf-8")

assert (
'LEFTOVER_MAP_COMPARE_AXIS_SINGULAR =\n'
' "leftover map comparison leftover axis {axis} σ {value}"'
in source
)
assert (
'LEFTOVER_MAP_COMPARE_AXIS_SINGULAR_SHARE =\n'
' "leftover map comparison leftover axis {axis} σ {value} {share}%"'
in source
)
assert (
'LEFTOVER_MAP_COMPARE_AXIS_SHARE =\n'
' "leftover map comparison leftover axis {axis} {share}%"'
in source
)
assert "leftoverMapCompareAxisBadge" in source


def test_comparison_strip_axis_badge_preserves_independent_missingness() -> None:
"""Share-only and σ-only states must not synthesize the missing counterpart."""
assert SINGULAR_SOURCE.exists(), "comparison singular-value helper is missing"
source = SINGULAR_SOURCE.read_text(encoding="utf-8")

assert "formatLeftoverMapPlotAxisSingular" in source
assert "Number.isFinite" in source
assert "LEFTOVER_MAP_COMPARE_AXIS_SINGULAR_SHARE" in source
assert "LEFTOVER_MAP_COMPARE_AXIS_SINGULAR" in source
assert "LEFTOVER_MAP_COMPARE_AXIS_SHARE" in source
assert "Math.sqrt" not in source
39 changes: 29 additions & 10 deletions tests/test_grouping_comparison_graphic_singular_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@
APP_TEST_SOURCE = ROOT / "frontend" / "src" / "App.test.tsx"


def _exported_function_source(source: str, signature: str) -> str:
"""Return one exported function so independence checks stay scoped to that function."""
start = source.index(signature)
next_export = source.find("\nexport function ", start + len(signature))
if next_export == -1:
return source[start:]
return source[start:next_export]


def test_comparison_graphic_has_distinct_persisted_singular_value_copy() -> None:
"""Comparison-axis σ copy must remain distinct from report, strip, share, and tick copy."""
assert SINGULAR_SOURCE.exists(), (
Expand All @@ -19,12 +28,12 @@ def test_comparison_graphic_has_distinct_persisted_singular_value_copy() -> None

assert (
'export const LEFTOVER_MAP_COMPARE_PLOT_AXIS_SINGULAR =\n'
' "leftover map comparison axis {axis} (σ {value})";'
' "leftover map comparison graphic leftover-map axis {axis} σ {value}";'
in singular_source
)
assert (
'export const LEFTOVER_MAP_COMPARE_PLOT_AXIS_SINGULAR_SHARE =\n'
' "leftover map comparison axis {axis} (σ {value}, {share}%)";'
' "leftover map comparison graphic leftover-map axis {axis} σ {value} ({share}%)";'
in singular_source
)
assert "LEFTOVER_MAP_COMPARE_PLOT_AXIS_SINGULAR" in plot_source
Expand All @@ -35,15 +44,25 @@ def test_singular_value_is_read_from_axis_evidence_and_fails_closed() -> None:
"""Persisted σ=0 stays explicit; missing, non-finite, or negative σ omits independently."""
assert SINGULAR_SOURCE.exists(), "persisted singular-value projection helper is missing"
singular_source = SINGULAR_SOURCE.read_text(encoding="utf-8")
formatter_source = _exported_function_source(
singular_source,
"export function formatLeftoverMapPlotAxisSingular",
)

assert "Number.isFinite" in formatter_source
assert "< 0" in formatter_source
assert "return null" in formatter_source
assert ".toFixed(2)" in formatter_source
assert "leftover_share" not in formatter_source
assert "Math.sqrt" not in formatter_source
assert "Math.max" not in formatter_source
assert "Math.min" not in formatter_source

assert "leftover_singular_value" in singular_source
assert "Number.isFinite" in singular_source
assert "< 0" in singular_source
assert "return null" in singular_source
assert ".toFixed(2)" in singular_source
assert "leftover_share" not in singular_source
assert "Math.max" not in singular_source
assert "Math.min" not in singular_source
assert (
"const singular = formatLeftoverMapPlotAxisSingular(axis.leftover_singular_value);"
in singular_source
)
assert "const share = formatLeftoverMapPlotAxisShare(axis.leftover_share);" in singular_source


def test_app_acceptance_requires_exact_comparison_sigma_and_share_copy() -> None:
Expand Down
Loading