Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
ef5fcf6
feat(reports): leftover-map singular values on grouping comparison st…
seonghobae Aug 30, 2026
c6f9654
feat(reports): reconstruct grouping comparison axis singular value on…
Sep 7, 2026
aa54b5e
test(reports): require current axis-singular release authority
seonghobae Sep 7, 2026
8f6e17c
docs(reports): allocate ADR 0370 and v2.56.0
seonghobae Sep 7, 2026
5e7756b
test(reports): bind axis-singular release to universal lock
seonghobae Sep 7, 2026
704bd36
ci(release): regenerate v2.56 universal lock once
seonghobae Sep 7, 2026
9b06dce
chore(release): regenerate v2.56 universal lock [lock-regenerated]
github-actions[bot] Sep 7, 2026
fe48037
ci(release): remove completed v2.56 lock regeneration workflow
seonghobae Sep 7, 2026
63a7f95
test(reports): cover large finite axis singular formatting
seonghobae Sep 7, 2026
09a3a8f
fix(reports): keep large axis singular values fixed-decimal
seonghobae Sep 7, 2026
d4aa190
ci(reports): repair finite-axis assertion once
seonghobae Sep 7, 2026
b36b253
test(reports): require finite persisted axis singular values
github-actions[bot] Sep 7, 2026
4c71c06
chore(reports): converge #830 on exact #829 transport repair
seonghobae Sep 7, 2026
2b09fbc
test(reports): RED stale #830 predecessor authority
seonghobae Sep 7, 2026
a87e881
fix(reports): bind ADR 0370 to exact #829 predecessor
seonghobae Sep 7, 2026
921dc73
test(a11y): RED generic singular badge must not carry aria-label
seonghobae Sep 7, 2026
4c93e91
fix(a11y): keep leftover-map singular text without a generic name
Sep 7, 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.56.0-leftover-map-compare-axis-singular.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# v2.56.0 — grouping-comparison leftover-map axis singular values

Grouping-comparison rows now surface persisted finite, non-negative leftover-map singular values `σ_k` for Gabriel axes under ADR 0370. Persisted zero remains visible as `0.00`; missing, non-finite, or negative values omit only the singular caption. Axis share and singular availability are independent, and LineageWeave never derives `σ_k` from any UI-visible or psychometric proxy.
14 changes: 13 additions & 1 deletion backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5778,7 +5778,11 @@ def test_seed_period_report_surfaces_on_get_reports(client, demo_analyst_token,
assert explained_share + unexplained_share + share == pytest.approx(1.0)
leftover_axes = high_report.get("leftover_map_axes", [])
assert [axis["axis_index"] for axis in leftover_axes] == [1, 2]
assert all(axis["leftover_singular_value"] >= 0 for axis in leftover_axes)
assert all(
math.isfinite(axis["leftover_singular_value"])
and axis["leftover_singular_value"] >= 0
for axis in leftover_axes
)
assert all(0.0 <= axis["leftover_share"] <= 1.0 for axis in leftover_axes)
leftover_coverage = high_report.get("leftover_map_coverage")
assert leftover_coverage is not None
Expand Down Expand Up @@ -5845,6 +5849,14 @@ def test_seed_period_report_surfaces_on_get_reports(client, demo_analyst_token,
assert leftover_compare_axes[0]["leftover_share"] != leftover_compare_axes[0][
"leftover_singular_value"
] or leftover_compare_axes[0]["leftover_share"] in {0, 1}
assert all(
isinstance(axis["leftover_singular_value"], (int, float)) for axis in leftover_compare_axes
)
assert all(
math.isfinite(axis["leftover_singular_value"])
and axis["leftover_singular_value"] >= 0
for axis in leftover_compare_axes
)
assert leftover_compare_coverage["map_item_count"] <= leftover_compare_coverage["scored_item_count"]
assert leftover_compare_coverage["map_post_count"] != len(
leftover_thread.get("leftover_pairs", [])
Expand Down
27 changes: 27 additions & 0 deletions docs/adr/0370-leftover-map-compare-axis-singular.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# ADR 0370 — Persisted leftover-map singular values in grouping comparison axes

**Decision status:** Proposed

## Problem
The grouping-comparison read model persists `leftover_singular_value` for each Gabriel leftover-map axis, but exact predecessor #829 (`07301271f813b8bb0e40f57aa22373baf5efbf01`, ADR 0369 / v2.55.0) does not expose that scale on the comparison strip. Historical #830 demonstrated the buyer-visible delta on stale ancestry and historical ADR 0294 / v2.51.0, which are evidence only because those identities now belong to different serialized decisions.

## Constraints
- Consume only persisted `leftover_singular_value`; LineageWeave must not derive `σ_k` from axis share, pair or marker counts, coordinates, distance, rank, coverage, incomplete counts, reconstruction, or any other psychometric value.
- Accept only finite, non-negative persisted values. Persisted zero is meaningful and must remain visible as `0.00`; missing, non-finite, or negative values omit only the singular-value caption.
- Singular-value availability and axis-share availability are independent. An invalid or missing value on one axis metric must not suppress a valid value on the other.
- Preserve the existing full-visible-grouping authorization boundary for persisted grouping aggregates; do not reconstruct a hidden population from caller-visible members.
- The comparison caption remains a LineageWeave read-model/UI concern. Psychometric estimation and true-parameter validation remain owned by the released `fast-mlsirm` boundary.
- The canonical eight-locale translation resource remains the versioned database ledger serialized through #922/#929/#932. This decision must not create a competing inline ES/DE/FR store.

## Alternatives and decision
Omitting `σ_k` hides persisted Gabriel scale that is already available in the authorized read model. Recomputing it in the client would duplicate psychometric authority and can diverge from the persisted factorization. Coupling singular display to axis-share validity would also discard valid evidence. Therefore the grouping-comparison strip consumes the persisted scalar directly, formats finite non-negative values independently from axis share, preserves zero, and fail-closes only the invalid singular caption.

This is the serialized successor of exact #829 `07301271f813b8bb0e40f57aa22373baf5efbf01` and allocates v2.56.0. Historical #830 ADR 0294 / v2.51.0 remains provenance only; current ADR 0294 continues to govern grouping-comparison incomplete-item coverage.

## Verification
A current-parent test must first RED because exact #829 lacks the grouping-comparison singular formatter. The causal implementation then requires focused formatter/render/API regressions, the full PostgreSQL-backed backend suite, frontend lint/test/build/Storybook, release-version parity, and a clean exact-head tree. Material UI completion additionally requires current-head responsive, keyboard, focus, and accessibility evidence. Hosted security gates and qualifying independent review remain separate merge gates; this ADR stays Proposed until the protected-main release is actually accepted.

## Evidence
Gabriel, K. R. (1971). The biplot graphic display of matrices with application to principal component analysis. *Biometrika, 58*(3), 453–467.

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.
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "2.55.0",
"version": "2.56.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
25 changes: 24 additions & 1 deletion frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4360,7 +4360,30 @@ describe("App, authenticated", () => {
within(screen.getByLabelText("Grouping comparison")).queryByText(/leftover-map axis 1/),
).not.toBeInTheDocument();
expect(
within(screen.getByLabelText("Grouping comparison")).queryByText(/σ 1\.84/),
within(screen.getByLabelText("Grouping comparison")).getAllByText(
/leftover map comparison axis \d σ /,
),
).toHaveLength(4);
expect(
within(screen.getByLabelText("Grouping comparison")).getByText(
"leftover map comparison axis 1 σ 0.00",
),
).toBeInTheDocument();
expect(
within(screen.getByLabelText("Grouping comparison")).getByText(
"leftover map comparison axis 1 σ 1.84",
),
).toBeInTheDocument();
expect(
within(screen.getByLabelText("Grouping comparison")).getByText(
"leftover map comparison axis 2 σ 0.86",
),
).toBeInTheDocument();
expect(
within(screen.getByLabelText("Grouping comparison")).queryByText("leftover axis 1 σ 1.84 82%"),
).not.toBeInTheDocument();
expect(
within(screen.getByLabelText("Grouping comparison")).queryByText("leftover-map axis 1 σ 1.84"),
).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Compare Business unit (PU): Demo Report High, mean θ 0.81" }),
Expand Down
29 changes: 20 additions & 9 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { focusedGraphMustReset } from "./focusedGraphSelection";
import { canAuthorVoice, postPrimaryVoiceLabel } from "./voicePerspective";

import { Component, lazy, Suspense, useCallback, useEffect, useEffectEvent, useRef, useState, type ReactNode } from "react";
import { Component, Fragment, lazy, Suspense, useCallback, useEffect, useEffectEvent, useRef, useState, type ReactNode } from "react";
import { useAuth } from "react-oidc-context";
import {
askPostChat,
Expand Down Expand Up @@ -157,8 +157,10 @@ import {
} from "./leftoverMapUnexplainedShare";
import {
leftoverMapCompareAxisShare,
leftoverMapCompareAxisSingular,
LEFTOVER_MAP_COMPARE_AXIS_SHARE,
LEFTOVER_MAP_COMPARE_AXIS_SHARE_LABEL,
LEFTOVER_MAP_COMPARE_AXIS_SINGULAR,
} from "./leftoverMapCompareAxis";
import "./App.css";

Expand Down Expand Up @@ -4075,17 +4077,26 @@ function ReportsPanel({
) : null}
{row.leftover_map_axes?.map((axis) => {
const comparisonAxisShare = leftoverMapCompareAxisShare(axis);
if (comparisonAxisShare === null) {
const comparisonAxisSingular = leftoverMapCompareAxisSingular(axis);
if (comparisonAxisShare === null && comparisonAxisSingular === null) {
return null;
}
return (
<span
key={axis.axis_index}
className="post-badge"
aria-label={t(LEFTOVER_MAP_COMPARE_AXIS_SHARE_LABEL)}
>
{tf(LEFTOVER_MAP_COMPARE_AXIS_SHARE, comparisonAxisShare)}
</span>
<Fragment key={axis.axis_index}>
{comparisonAxisShare !== null ? (
<span
className="post-badge"
aria-label={t(LEFTOVER_MAP_COMPARE_AXIS_SHARE_LABEL)}
>
{tf(LEFTOVER_MAP_COMPARE_AXIS_SHARE, comparisonAxisShare)}
</span>
) : null}
{comparisonAxisSingular !== null ? (
<span className="post-badge">
{tf(LEFTOVER_MAP_COMPARE_AXIS_SINGULAR, comparisonAxisSingular)}
</span>
) : null}
</Fragment>
);
})}
{row.leftover_pairs && row.leftover_pairs.length > 0 && (
Expand Down
72 changes: 72 additions & 0 deletions frontend/src/leftoverMapCompareAxis.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { describe, expect, it } from "vitest";
import {
leftoverMapCompareAxisShare,
leftoverMapCompareAxisSingular,
LEFTOVER_MAP_COMPARE_AXIS_SHARE,
LEFTOVER_MAP_COMPARE_AXIS_SHARE_LABEL,
LEFTOVER_MAP_COMPARE_AXIS_SINGULAR,
LEFTOVER_MAP_COMPARE_AXIS_SINGULAR_LABEL,
LEFTOVER_MAP_LIST_AXIS_SHARE,
LEFTOVER_MAP_LIST_AXIS_SINGULAR,
LEFTOVER_MAP_PLOT_AXIS_SHARE,
LEFTOVER_MAP_PLOT_AXIS_SINGULAR,
} from "./leftoverMapCompareAxis";

describe("leftoverMapCompareAxisShare", () => {
Expand Down Expand Up @@ -63,3 +68,70 @@ describe("leftoverMapCompareAxisShare", () => {
expect(LEFTOVER_MAP_COMPARE_AXIS_SHARE).not.toBe(LEFTOVER_MAP_PLOT_AXIS_SHARE);
});
});

describe("leftoverMapCompareAxisSingular", () => {
it("names persisted leftover-map singular values without inventing a leftover score", () => {
expect(
leftoverMapCompareAxisSingular({ axis_index: 1, leftover_singular_value: 1.84 }),
).toEqual({ axis: 1, value: "1.84" });
expect(
leftoverMapCompareAxisSingular({ axis_index: 2, leftover_singular_value: 0.86 }),
).toEqual({ axis: 2, value: "0.86" });
});

it("names a rank-0 zero leftover-map singular value", () => {
expect(
leftoverMapCompareAxisSingular({ axis_index: 1, leftover_singular_value: 0 }),
).toEqual({ axis: 1, value: "0.00" });
});

it("formats a finite 1e21 singular value to two decimals without exponential notation", () => {
expect(
leftoverMapCompareAxisSingular({ axis_index: 1, leftover_singular_value: 1e21 }),
).toEqual({ axis: 1, value: "1000000000000000000000.00" });
});

it("omits leftover-map singular values that are missing, non-finite, or negative", () => {
expect(leftoverMapCompareAxisSingular(null)).toBeNull();
expect(leftoverMapCompareAxisSingular(undefined)).toBeNull();
expect(leftoverMapCompareAxisSingular({ axis_index: 1 })).toBeNull();
expect(
leftoverMapCompareAxisSingular({ axis_index: 1, leftover_singular_value: Number.NaN }),
).toBeNull();
expect(
leftoverMapCompareAxisSingular({
axis_index: 1,
leftover_singular_value: Number.POSITIVE_INFINITY,
}),
).toBeNull();
expect(
leftoverMapCompareAxisSingular({ axis_index: 1, leftover_singular_value: -0.01 }),
).toBeNull();
});

it("does not invent leftover-map singular value from leftover-map axis share", () => {
expect(
leftoverMapCompareAxisSingular({
axis_index: 1,
leftover_singular_value: Number.NaN,
}),
).toBeNull();
expect(
leftoverMapCompareAxisSingular({ axis_index: 1, leftover_singular_value: 1.84 }),
).not.toEqual({ axis: 1, value: "82" });
});

it("keeps the grouping comparison leftover-map singular caption distinct from leftover-axis badges and graphic axes", () => {
expect(LEFTOVER_MAP_COMPARE_AXIS_SINGULAR_LABEL).toBe(
"Leftover map comparison axis singular",
);
expect(LEFTOVER_MAP_COMPARE_AXIS_SINGULAR_LABEL).not.toBe(
LEFTOVER_MAP_COMPARE_AXIS_SHARE_LABEL,
);
expect(LEFTOVER_MAP_COMPARE_AXIS_SINGULAR).toBe(
"leftover map comparison axis {axis} σ {value}",
);
expect(LEFTOVER_MAP_COMPARE_AXIS_SINGULAR).not.toBe(LEFTOVER_MAP_LIST_AXIS_SINGULAR);
expect(LEFTOVER_MAP_COMPARE_AXIS_SINGULAR).not.toBe(LEFTOVER_MAP_PLOT_AXIS_SINGULAR);
});
});
57 changes: 56 additions & 1 deletion frontend/src/leftoverMapCompareAxis.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** Caption leftover-map axis share on the grouping comparison strip (ADR 0367). */
/** Caption persisted leftover-map axis share and singular values on the grouping comparison strip. */

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

Expand All @@ -10,11 +10,34 @@ export const LEFTOVER_MAP_LIST_AXIS_SHARE = "leftover axis {axis} {share}%";

export const LEFTOVER_MAP_PLOT_AXIS_SHARE = "leftover-map axis {axis} ({share}%)";

export const LEFTOVER_MAP_COMPARE_AXIS_SINGULAR_LABEL = "Leftover map comparison axis singular";

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

export const LEFTOVER_MAP_LIST_AXIS_SINGULAR = "leftover axis {axis} σ {value} {share}%";

export const LEFTOVER_MAP_PLOT_AXIS_SINGULAR = "leftover-map axis {axis} σ {value}";

export type LeftoverMapCompareAxisShare = {
axis: number;
share: string;
};

export type LeftoverMapCompareAxisSingular = {
axis: number;
value: string;
};

type LeftoverMapCompareAxisSingularInput =
Pick<LeftoverMapAxis, "axis_index"> &
Partial<Pick<LeftoverMapAxis, "leftover_singular_value">>;

const LARGE_FIXED_TWO_DECIMAL = new Intl.NumberFormat("en-US", {
useGrouping: false,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});

export function leftoverMapCompareAxisShare(
axis: Pick<LeftoverMapAxis, "axis_index" | "leftover_share"> | null | undefined,
): LeftoverMapCompareAxisShare | null {
Expand All @@ -32,3 +55,35 @@ export function leftoverMapCompareAxisShare(
share: (axis.leftover_share * 100).toFixed(0),
};
}

/**
* Format the canonical persisted singular value for one comparison axis.
*
* The persisted singular field is optional at the read boundary. This
* deliberately does not derive a singular value from share, pair count,
* marker count, rank, distance, or coverage. Missing/invalid persisted
* values are omitted so the UI cannot invent psychometric output.
*/
export function leftoverMapCompareAxisSingular(
axis: LeftoverMapCompareAxisSingularInput | null | undefined,
): LeftoverMapCompareAxisSingular | null {
if (axis == null) {
return null;
}
if (!Number.isInteger(axis.axis_index) || axis.axis_index < 1) {
return null;
}
if (
axis.leftover_singular_value == null ||
!Number.isFinite(axis.leftover_singular_value) ||
axis.leftover_singular_value < 0
) {
return null;
}
const value = axis.leftover_singular_value;
return {
axis: axis.axis_index,
// ECMAScript toFixed switches to exponential notation at 1e21.
value: value >= 1e21 ? LARGE_FIXED_TWO_DECIMAL.format(value) : value.toFixed(2),
};
}
2 changes: 1 addition & 1 deletion lineageweave/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,4 @@
"serialize_lineage_analysis_result",
]

__version__ = "2.55.0"
__version__ = "2.56.0"
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "lineageweave"
version = "2.55.0"
version = "2.56.0"
description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication."
readme = "README.md"
license = { text = "MIT" }
Expand Down
16 changes: 16 additions & 0 deletions tests/test_leftover_map_compare_axis_singular_accessibility.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from pathlib import Path


APP_SOURCE = Path(__file__).parents[1] / "frontend" / "src" / "App.tsx"


def test_grouping_comparison_singular_badge_does_not_name_generic_span() -> None:
"""Keep persisted sigma visible without an ARIA name on a generic span."""
source = APP_SOURCE.read_text(encoding="utf-8")
marker = "{comparisonAxisSingular !== null ? ("
assert marker in source
singular_block = source.split(marker, 1)[1].split(") : null}", 1)[0]

assert 'className="post-badge"' in singular_block
assert "LEFTOVER_MAP_COMPARE_AXIS_SINGULAR, comparisonAxisSingular" in singular_block
assert "aria-label=" not in singular_block
Loading