diff --git a/CHANGELOG.d/2.56.0-leftover-map-compare-axis-singular.md b/CHANGELOG.d/2.56.0-leftover-map-compare-axis-singular.md
new file mode 100644
index 000000000..82b7f1d12
--- /dev/null
+++ b/CHANGELOG.d/2.56.0-leftover-map-compare-axis-singular.md
@@ -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.
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index 95431c425..3fe3ee935 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -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
@@ -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", [])
diff --git a/docs/adr/0370-leftover-map-compare-axis-singular.md b/docs/adr/0370-leftover-map-compare-axis-singular.md
new file mode 100644
index 000000000..8e7c9799f
--- /dev/null
+++ b/docs/adr/0370-leftover-map-compare-axis-singular.md
@@ -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 (`0e4fd5815686120ec66203cb6848834b56bdf289`, 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 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.
diff --git a/frontend/package.json b/frontend/package.json
index 228735341..4f0158ad8 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "2.55.0",
+ "version": "2.56.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 3230225df..07e7609b3 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -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")).getAllByLabelText(
+ "Leftover map comparison axis singular",
+ ),
+ ).toHaveLength(4);
+ expect(
+ within(screen.getByLabelText("Grouping comparison")).getAllByLabelText(
+ "Leftover map comparison axis singular",
+ )[0],
+ ).toHaveTextContent("leftover map comparison axis 1 σ 0.00");
+ 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" }),
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 863999640..e10f18b12 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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,
@@ -157,8 +157,11 @@ import {
} from "./leftoverMapUnexplainedShare";
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,
} from "./leftoverMapCompareAxis";
import "./App.css";
@@ -4075,17 +4078,29 @@ 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 (
-
- {tf(LEFTOVER_MAP_COMPARE_AXIS_SHARE, comparisonAxisShare)}
-
+
+ {comparisonAxisShare !== null ? (
+
+ {tf(LEFTOVER_MAP_COMPARE_AXIS_SHARE, comparisonAxisShare)}
+
+ ) : null}
+ {comparisonAxisSingular !== null ? (
+
+ {tf(LEFTOVER_MAP_COMPARE_AXIS_SINGULAR, comparisonAxisSingular)}
+
+ ) : null}
+
);
})}
{row.leftover_pairs && row.leftover_pairs.length > 0 && (
diff --git a/frontend/src/leftoverMapCompareAxis.test.ts b/frontend/src/leftoverMapCompareAxis.test.ts
index 1a9ac333d..933f0cb23 100644
--- a/frontend/src/leftoverMapCompareAxis.test.ts
+++ b/frontend/src/leftoverMapCompareAxis.test.ts
@@ -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", () => {
@@ -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);
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/leftoverMapCompareAxis.ts b/frontend/src/leftoverMapCompareAxis.ts
index 32353ab78..065653805 100644
--- a/frontend/src/leftoverMapCompareAxis.ts
+++ b/frontend/src/leftoverMapCompareAxis.ts
@@ -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";
@@ -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 &
+ Partial>;
+
+const LARGE_FIXED_TWO_DECIMAL = new Intl.NumberFormat("en-US", {
+ useGrouping: false,
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+});
+
export function leftoverMapCompareAxisShare(
axis: Pick | null | undefined,
): LeftoverMapCompareAxisShare | null {
@@ -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),
+ };
+}
\ No newline at end of file
diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py
index 15a8d1d08..de9e32170 100644
--- a/lineageweave/__init__.py
+++ b/lineageweave/__init__.py
@@ -130,4 +130,4 @@
"serialize_lineage_analysis_result",
]
-__version__ = "2.55.0"
+__version__ = "2.56.0"
diff --git a/pyproject.toml b/pyproject.toml
index 61be353ce..09a2849c2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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" }
diff --git a/tests/test_leftover_map_compare_axis_singular_release_authority.py b/tests/test_leftover_map_compare_axis_singular_release_authority.py
new file mode 100644
index 000000000..b77eec7ec
--- /dev/null
+++ b/tests/test_leftover_map_compare_axis_singular_release_authority.py
@@ -0,0 +1,42 @@
+"""ADR and release-identity regression for grouping-comparison axis singular values."""
+
+from __future__ import annotations
+
+import json
+import re
+import tomllib
+from pathlib import Path
+
+_ROOT = Path(__file__).resolve().parents[1]
+_ADR_DIRECTORY = _ROOT / "docs" / "adr"
+
+
+def test_axis_singular_decision_uses_unclaimed_proposed_adr_0370() -> None:
+ """Keep the reconstructed axis-singular decision off historical ADR 0294."""
+ canonical = _ADR_DIRECTORY / "0370-leftover-map-compare-axis-singular.md"
+
+ assert canonical.exists(), "axis-singular decision must use unclaimed ADR 0370"
+ content = canonical.read_text(encoding="utf-8")
+ assert content.startswith("# ADR 0370 —")
+ assert "**Decision status:** Proposed" in content
+ assert "**Decision status:** Accepted" not in content
+ assert "v2.56.0" in content
+
+
+def test_axis_singular_release_identity_is_2560_everywhere() -> None:
+ """Keep the reconstructed feature on one unreleased product identity."""
+ project = tomllib.loads((_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
+ frontend = json.loads((_ROOT / "frontend" / "package.json").read_text(encoding="utf-8"))
+ runtime = (_ROOT / "lineageweave" / "__init__.py").read_text(encoding="utf-8")
+ lock = tomllib.loads((_ROOT / "uv.lock").read_text(encoding="utf-8"))
+ locked_lineageweave = [
+ package for package in lock["package"] if package.get("name") == "lineageweave"
+ ]
+ match = re.search(r'^__version__ = "([^"]+)"$', runtime, flags=re.MULTILINE)
+
+ assert match is not None
+ assert project["project"]["version"] == "2.56.0"
+ assert frontend["version"] == "2.56.0"
+ assert match.group(1) == "2.56.0"
+ assert len(locked_lineageweave) == 1
+ assert locked_lineageweave[0]["version"] == "2.56.0"
diff --git a/uv.lock b/uv.lock
index 0e7d2ce9d..879d38ff5 100644
--- a/uv.lock
+++ b/uv.lock
@@ -685,7 +685,7 @@ wheels = [
[[package]]
name = "lineageweave"
-version = "2.37.0"
+version = "2.56.0"
source = { editable = "." }
dependencies = [
{ name = "certifi" },