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
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 (`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.
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")).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" }),
Expand Down
33 changes: 24 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,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";

Expand Down Expand Up @@ -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;
Comment thread
seonghobae marked this conversation as resolved.
}
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"
aria-label={t(LEFTOVER_MAP_COMPARE_AXIS_SINGULAR_LABEL)}
>
{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
42 changes: 42 additions & 0 deletions tests/test_leftover_map_compare_axis_singular_release_authority.py
Original file line number Diff line number Diff line change
@@ -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"
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.