Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
a3613ac
feat(reports): leftover-map singular values on leftover-map graphic-d…
seonghobae Aug 31, 2026
593b8d8
feat(reports): leftover-map singular values on leftover-axis report b…
seonghobae Aug 31, 2026
13f94ca
test(red): reconstruct report-graphic axis singular contract
seonghobae Sep 10, 2026
bae8103
test(red): reconstruct report-axis singular-only contract
seonghobae Sep 10, 2026
88e88b2
chore(stack): converge report-graphic singular lane on repaired parent
seonghobae Sep 11, 2026
265e91e
chore(stack): converge singular-only report badge lane on repaired pa…
seonghobae Sep 11, 2026
1705a96
chore(stack): converge report-graphic singular lane on documented parent
seonghobae Sep 11, 2026
e26f523
chore(stack): converge singular-only report badge lane on documented …
seonghobae Sep 11, 2026
4b9f9e9
chore(stack): converge #865 onto repaired #863 head
seonghobae Sep 11, 2026
39dd775
chore(stack): converge #866 onto repaired #865 head
seonghobae Sep 11, 2026
866e277
chore(stack): converge #865 onto repaired #863
seonghobae Sep 12, 2026
8cef532
chore(stack): converge #866 onto repaired #865
seonghobae Sep 12, 2026
dec9705
chore(stack): converge #865 onto repaired ancestry
seonghobae Sep 13, 2026
198d682
chore(stack): converge #866 onto repaired ancestry
seonghobae Sep 13, 2026
a046c2b
chore(stack): converge #865 onto repaired #863
seonghobae Sep 15, 2026
e20200c
chore(stack): converge #866 onto repaired #865
seonghobae Sep 15, 2026
b212048
chore(stack): converge #865 onto repaired #863
seonghobae Sep 15, 2026
4b53924
chore(stack): converge #866 onto repaired #865
seonghobae Sep 15, 2026
7024f3c
merge: converge v2.81.0 onto repaired comparison axis badges
seonghobae Sep 15, 2026
d421c86
merge: converge v2.82.0 onto repaired comparison axis badges
seonghobae Sep 15, 2026
7458b7f
fix(ui): expose report graphic singular axis copy
seonghobae Sep 15, 2026
75f0bf6
fix(ui): render report graphic singular axis evidence
seonghobae Sep 15, 2026
9ae19d8
docs(adr): record report graphic singular axes
seonghobae Sep 15, 2026
eeb8102
docs(changelog): record report graphic singular axes
seonghobae Sep 15, 2026
31e3c03
fix(ui): type axis evidence template values safely
seonghobae Sep 15, 2026
bb1c8c8
merge: converge v2.82.0 onto report graphic singular repair
seonghobae Sep 15, 2026
611636d
fix(ui): preserve report axis singular-only state
seonghobae Sep 15, 2026
f9461c9
merge: converge v2.81.0 onto exact v2.80.0 head
seonghobae Sep 15, 2026
46c2c56
merge: converge v2.82.0 onto exact v2.81.0 head
seonghobae Sep 15, 2026
a87efee
docs(adr): record independent report axis evidence states
seonghobae Sep 15, 2026
e3cce38
docs(changelog): record singular-only report axis evidence
seonghobae Sep 15, 2026
7c570b4
test(reports): expose unwired report-axis singular-only RED
seonghobae Sep 15, 2026
3422a23
fix(reports): keep report-axis sigma visible without share
seonghobae Sep 15, 2026
2efcf06
test(reports): cover report-axis missingness states
seonghobae Sep 15, 2026
c267657
test(reports): pin report-axis sigma/share rendering contract
seonghobae Sep 15, 2026
e98abe2
test(reports): require empty report-axis evidence omission
seonghobae Sep 15, 2026
ee04d46
chore(stack): adopt #863 localized comparison tick contract
seonghobae Sep 15, 2026
7e9e83b
chore(stack): adopt #863 localized comparison tick repair
seonghobae Sep 15, 2026
0728f56
chore(stack): converge current #863 into #865
seonghobae Sep 15, 2026
35f4b07
chore(stack): converge current #865 into #866
seonghobae Sep 15, 2026
047cefc
test(reports): assert four-state badge semantics, not import punctuation
seonghobae Sep 16, 2026
6935ac4
fix(reports): consume report-axis badge projection
github-actions[bot] Sep 16, 2026
0c13fd8
test(reporting): verify report-axis copy semantically
seonghobae Sep 19, 2026
6f75308
test(reporting): align report badge contract with independent share s…
seonghobae Sep 19, 2026
3178713
merge: converge #865 on singular contract repair
seonghobae Sep 19, 2026
8d63271
merge: converge #866 on report contract repairs
seonghobae Sep 19, 2026
1c5cfc8
chore(stack): converge #865 onto #863 owner RED
seonghobae Sep 20, 2026
a51b0ec
chore(stack): converge #866 onto #865 owner stack
seonghobae Sep 20, 2026
688643c
chore(stack): converge #865 onto repaired #863 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.81.0-leftover-map-plot-axis-singular.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions CHANGELOG.d/2.82.0-leftover-map-axis-singular-only.md
Original file line number Diff line number Diff line change
@@ -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%`.
31 changes: 31 additions & 0 deletions docs/adr/0324-leftover-map-report-graphic-axis-singular.md
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions docs/adr/0325-leftover-map-report-axis-singular-only.md
Original file line number Diff line number Diff line change
@@ -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
20 changes: 4 additions & 16 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -3930,17 +3925,10 @@ function ReportsPanel({
</p>
) : 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 : (
<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,
})}
{tf(badge.template, badge.values)}
</span>
);
})}
Expand Down
22 changes: 17 additions & 5 deletions frontend/src/components/LeftoverMapPlot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
}

/**
Expand All @@ -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
Expand Down
63 changes: 55 additions & 8 deletions frontend/src/leftoverMapAxisBadge.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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("");
});
});

Expand Down Expand Up @@ -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();
});
});

Expand Down
51 changes: 48 additions & 3 deletions frontend/src/leftoverMapAxisBadge.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | number>;
};

/**
* 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(
Expand All @@ -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<LeftoverMapAxis, "axis_index"> & {
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 },
};
}
6 changes: 6 additions & 0 deletions frontend/src/leftoverMapPlotAxisSingular.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}";

Expand Down
9 changes: 4 additions & 5 deletions tests/test_leftover_axis_report_singular_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
Loading
Loading