diff --git a/AGENTS.md b/AGENTS.md
index c790995c..fc4b8a03 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -75,12 +75,13 @@ in the same spirit) -- never against real data, per the hard rule above.
against a live local stack (`make up`) and self-skip without one -- see
[README.md](README.md#local-product-stack-docker-compose).
-Period leftover pairs (ADR 0017 / 0018) are computed in
+Period leftover pairs (ADR 0017 / 0018 / 0032) are computed in
`lineageweave/leftover_pairs.py` from the residual after a real
GRM/GPCM score, never invented. Missing cells stay out of the
Gabriel factorization. Closest and farthest post–criterion pairs
persist to `report_leftover_pair` and sit above the member list so
-a click opens that post.
+a click opens that post. An Event Lineage node that is a leftover
+pair also names the leftover criterion under the title.
`frontend/` has its own toolchain (Node pinned via `frontend/mise.toml`,
pnpm via Corepack -- do not add a second Node package manager or a
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index f8a83ceb..f1a236b1 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -122,7 +122,8 @@ flowchart LR
`rankweave_client.py`'s default transport raises
`RankWeaveNotAvailable`. `GET /api/rankings` then returns
`rankweave_not_available` and an empty ranking list. Hidden posts
- are omitted from every channel. See ADR 0024.
+ are omitted from every channel. See ADR 0024. An Event Lineage
+ node that is also a leftover pair names that criterion (ADR 0032).
## Standards and citations
diff --git a/CHANGELOG.d/0.91.0-leftover-lineage-node.md b/CHANGELOG.d/0.91.0-leftover-lineage-node.md
new file mode 100644
index 00000000..dbfa26fe
--- /dev/null
+++ b/CHANGELOG.d/0.91.0-leftover-lineage-node.md
@@ -0,0 +1,8 @@
+# 0.91.0 — Leftover caption on the matching Event Lineage node
+
+## Added
+
+- Event Lineage nodes that are leftover pairs name the leftover
+ criterion (ADR 0032). After `make seed`, the leftover node reads
+ **Closest leftover · sales-lead** under the title; click opens
+ that post. A node that is not a leftover pair stays unmarked.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6bfcaa28..2c6982dd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,16 @@ All notable changes to this project are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.91.0] - 2026-08-18
+
+### Added
+
+- Event Lineage nodes that are leftover pairs name the leftover
+ criterion (ADR 0032). After `make seed`, the leftover node reads
+ **Closest leftover · sales-lead** under the title; click opens
+ that post. A node that is not a leftover pair stays unmarked.
+ Never invent a fused score or a theta.
+
## [0.75.0] - 2026-08-17
### Added
diff --git a/docs/adr/0032-leftover-pair-on-lineage-node.md b/docs/adr/0032-leftover-pair-on-lineage-node.md
new file mode 100644
index 00000000..ede197ef
--- /dev/null
+++ b/docs/adr/0032-leftover-pair-on-lineage-node.md
@@ -0,0 +1,53 @@
+# ADR 0032 — Name leftover on the matching Event Lineage node
+
+**Decision status:** Accepted
+**Date:** 2026-08-18
+
+## Context
+
+ADR 0018 puts leftover pairs above the period-report member list.
+Event Lineage is the surface a buyer scans to reconstruct the thread
+before they reach the home post list. The leftover post is already a
+DAG node; the node just does not name the leftover criterion.
+
+Do not invent a second leftover store. Do not invent a fused score
+or a theta. Do not change the existing `Open post: {title}`
+accessible name — leftover is a visible caption on the already-named
+control.
+
+## Decision
+
+When an authorized leftover pair names an Event Lineage node, that
+node shows `Closest leftover · {criterion}` or
+`Farthest leftover · {criterion}` under the post title.
+
+A node that is not a leftover pair stays unmarked. A leftover pair
+for a hidden post never reaches the DAG (ADR 0017 ABAC). A report
+fetch error clears leftover captions and leaves the reconstructed
+graph intact — never an invented pair.
+
+After `make seed`, the leftover Event Lineage node reads **Closest
+leftover · sales-lead** under the title; click still opens that post.
+
+Leftover evidence is the same authorized `leftover_pairs` already
+on the period-report payload. The home DAG and the popup DAG share
+that list.
+
+## Consequences
+
+Leftover buttons above the member list stay (ADR 0018). Home-row
+leftover stays on #254 / ADR 0031. Calendar leftover stays on #253 /
+ADR 0030. Rankings leftover stays on #252 / ADR 0029. This slice
+only labels the already-visible Event Lineage node.
+
+## Related
+
+Depends on [ADR 0017](0017-persist-lsirm-leftover-pairs.md) and
+[ADR 0018](0018-leftover-pair-report-ui.md).
+
+## References
+
+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
diff --git a/frontend/package.json b/frontend/package.json
index 575b7c58..e3e0f6f8 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "0.75.0",
+ "version": "0.91.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/App.css b/frontend/src/App.css
index 76cf3665..04b36759 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -215,6 +215,12 @@
fill: canvastext;
}
+.lineage-dag-leftover {
+ font-size: 9px;
+ letter-spacing: 0.02em;
+ fill: color-mix(in srgb, canvastext 72%, transparent);
+}
+
.lineage-dag-node:focus {
outline: none;
}
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index a32a2640..6dada340 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -56,6 +56,7 @@ describe("App, authenticated", () => {
function stubBackend(options?: {
admin?: boolean;
calendarCommitments?: unknown[];
+ reportsUnavailable?: boolean;
rankings?: {
status?: "accepted" | "unavailable";
status_reason?: string | null;
@@ -228,6 +229,14 @@ describe("App, authenticated", () => {
}),
);
}
+ if (options?.reportsUnavailable && url.includes("/api/reports/")) {
+ return Promise.resolve(
+ new Response(JSON.stringify({ detail: "Period report unavailable" }), {
+ status: 503,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ }
if (url.includes("/api/reports/compare/") && method === "GET") {
return Promise.resolve(
jsonResponse({
@@ -808,6 +817,31 @@ describe("App, authenticated", () => {
await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument());
});
+ it("names leftover criterion on the matching Event Lineage node", async () => {
+ stubBackend();
+ render();
+
+ const leftoverNode = await screen.findByLabelText("Open post: Public post");
+ await waitFor(() => expect(leftoverNode).toHaveTextContent("Closest leftover · sales-lead"));
+ expect(leftoverNode).toHaveAccessibleName("Open post: Public post");
+ const unmarked = screen.getByLabelText("Open post: Unrelated: annual account review");
+ expect(unmarked).not.toHaveTextContent("leftover");
+
+ await userEvent.click(leftoverNode);
+
+ await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument());
+ });
+
+ it("clears leftover badges on the DAG when the report fetch fails", async () => {
+ stubBackend({ reportsUnavailable: true });
+ render();
+
+ const leftoverNode = await screen.findByLabelText("Open post: Public post");
+ await waitFor(() => expect(screen.getByLabelText("A-100 lineage")).toBeInTheDocument());
+ expect(leftoverNode).not.toHaveTextContent("leftover");
+ expect(leftoverNode).toHaveAccessibleName("Open post: Public post");
+ });
+
it("fetches and renders the post list, then opens a detail popup on click", async () => {
const fetchMock = stubBackend();
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 6056e5eb..0ffc6a65 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -52,6 +52,7 @@ import {
type PostSummary,
type RankingList,
type RelatedNode,
+ type LeftoverPair,
type VocEvidence,
} from "./api";
import { LineageDag } from "./LineageDag";
@@ -272,11 +273,13 @@ function EventLineageSection({
graph,
postId,
onSelectPost,
+ leftoverPairs,
}: {
lineage: PostLineage | null;
graph: LineageGraph | null;
postId: string;
onSelectPost?: (postId: string) => void;
+ leftoverPairs?: LeftoverPair[];
}) {
if (!lineage) return
Loading lineage...
;
const scoped = graph ? subgraphForPost(graph, postId) : { nodes: [], edges: [] };
@@ -299,7 +302,7 @@ function EventLineageSection({
return (
<>
{scoped.nodes.length > 0 && onSelectPost && (
-
+
)}
{hasLinks && (
@@ -1087,6 +1090,7 @@ function PostDetailPopup({
accessToken,
canExtract,
graph,
+ leftoverPairs,
onClose,
onSelectPost,
}: {
@@ -1094,6 +1098,7 @@ function PostDetailPopup({
accessToken: string;
canExtract: boolean;
graph: LineageGraph | null;
+ leftoverPairs?: LeftoverPair[];
onClose: () => void;
onSelectPost?: (postId: string) => void;
}) {
@@ -1247,6 +1252,7 @@ function PostDetailPopup({
graph={graph}
postId={postId}
onSelectPost={onSelectPost}
+ leftoverPairs={leftoverPairs}
/>
@@ -1425,10 +1431,12 @@ function ReportsPanel({
accessToken,
canRebuild,
onSelectPost,
+ onLeftoverPairsChange,
}: {
accessToken: string;
canRebuild: boolean;
onSelectPost: (postId: string) => void;
+ onLeftoverPairsChange: (pairs: LeftoverPair[]) => void;
}) {
const [grouping, setGrouping] = useState("process_unit");
const [period, setPeriod] = useState("2026-W02");
@@ -1455,9 +1463,13 @@ function ReportsPanel({
setPayload(reports);
setIndex(periods);
setComparison(compared);
+ onLeftoverPairsChange(reports.reports.flatMap((row) => row.leftover_pairs ?? []));
})
- .catch((err) => setError(String(err)));
- }, [accessToken, grouping, period]);
+ .catch((err) => {
+ setError(String(err));
+ onLeftoverPairsChange([]);
+ });
+ }, [accessToken, grouping, period, onLeftoverPairsChange]);
async function handleRebuild() {
setRebuilding(true);
@@ -1472,8 +1484,10 @@ function ReportsPanel({
setPayload(reports);
setIndex(periods);
setComparison(compared);
+ onLeftoverPairsChange(reports.reports.flatMap((row) => row.leftover_pairs ?? []));
} catch (err) {
setError(String(err));
+ onLeftoverPairsChange([]);
} finally {
setRebuilding(false);
}
@@ -1658,6 +1672,7 @@ function PostList({ accessToken }: { accessToken: string }) {
const [graph, setGraph] = useState(null);
const [error, setError] = useState(null);
const [selectedPostId, setSelectedPostId] = useState(null);
+ const [leftoverPairs, setLeftoverPairs] = useState([]);
const [canRebuild, setCanRebuild] = useState(false);
const [rebuilding, setRebuilding] = useState(false);
const [rebuildError, setRebuildError] = useState(null);
@@ -1691,7 +1706,12 @@ function PostList({ accessToken }: { accessToken: string }) {
<>
-
+
Event Lineage
@@ -1703,7 +1723,13 @@ function PostList({ accessToken }: { accessToken: string }) {
{rebuildError && {rebuildError}
}
{!graph && Loading lineage graph...
}
- {graph && }
+ {graph && (
+
+ )}
{posts.map((post) => (
@@ -1726,6 +1752,7 @@ function PostList({ accessToken }: { accessToken: string }) {
accessToken={accessToken}
canExtract={canRebuild}
graph={graph}
+ leftoverPairs={leftoverPairs}
onClose={() => setSelectedPostId(null)}
onSelectPost={setSelectedPostId}
/>
diff --git a/frontend/src/LineageDag.tsx b/frontend/src/LineageDag.tsx
index a4b296a9..ac589e7a 100644
--- a/frontend/src/LineageDag.tsx
+++ b/frontend/src/LineageDag.tsx
@@ -1,4 +1,5 @@
-import type { LineageGraph } from "./api";
+import type { LeftoverPair, LineageGraph } from "./api";
+import { leftoverBadgeText, leftoverPairsForPost } from "./leftoverCaption";
import { layoutLineageDag } from "./lineageLayout";
function truncateLabel(label: string): string {
@@ -8,9 +9,11 @@ function truncateLabel(label: string): string {
export function LineageDag({
graph,
onSelectPost,
+ leftoverPairs,
}: {
graph: LineageGraph;
onSelectPost: (postId: string) => void;
+ leftoverPairs?: LeftoverPair[];
}) {
const groups = layoutLineageDag(graph);
if (graph.nodes.length === 0) {
@@ -50,6 +53,8 @@ export function LineageDag({
})}
{group.nodes.map((node) => {
const kind = node.is_branch_point ? "branch" : node.is_root ? "root" : "node";
+ const leftoverForNode = leftoverPairsForPost(leftoverPairs, node.id);
+ const leftoverTitles = leftoverForNode.map(leftoverBadgeText);
return (
{truncateLabel(node.label)}
- {`${node.label} — ${node.occurred_at.slice(0, 10)}`}
+ {leftoverForNode.map((pair, index) => (
+
+ {leftoverBadgeText(pair)}
+
+ ))}
+
+ {leftoverTitles.length > 0
+ ? `${node.label} — ${node.occurred_at.slice(0, 10)} — ${leftoverTitles.join(" · ")}`
+ : `${node.label} — ${node.occurred_at.slice(0, 10)}`}
+
);
})}
diff --git a/frontend/src/leftoverCaption.test.ts b/frontend/src/leftoverCaption.test.ts
new file mode 100644
index 00000000..d1e29e96
--- /dev/null
+++ b/frontend/src/leftoverCaption.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from "vitest";
+import {
+ leftoverBadgeText,
+ leftoverPairsForPost,
+ leftoverRowLabel,
+} from "./leftoverCaption";
+
+describe("leftoverCaption", () => {
+ it("names closest and farthest leftover badges without inventing a theta", () => {
+ expect(leftoverRowLabel("closest")).toBe("Closest leftover");
+ expect(leftoverRowLabel("farthest")).toBe("Farthest leftover");
+ expect(
+ leftoverBadgeText({
+ pair_kind: "closest",
+ post_id: "post-1",
+ post_title: "Public post",
+ criterion_code: "sales_lead_specificity",
+ leftover_distance: 0.12,
+ leftover_residual: 0.4,
+ }),
+ ).toBe("Closest leftover · sales-lead");
+ });
+
+ it("keeps leftover pairs bound to the named post only", () => {
+ const pairs = [
+ {
+ pair_kind: "closest" as const,
+ post_id: "post-1",
+ post_title: "Public post",
+ criterion_code: "sales_lead_specificity",
+ leftover_distance: 0.12,
+ leftover_residual: 0.4,
+ },
+ {
+ pair_kind: "farthest" as const,
+ post_id: "post-2",
+ post_title: "Linked post",
+ criterion_code: "general_sentiment_negative",
+ leftover_distance: 1.84,
+ leftover_residual: -1.1,
+ },
+ ];
+ expect(leftoverPairsForPost(pairs, "post-1")).toEqual([pairs[0]]);
+ expect(leftoverPairsForPost(undefined, "post-1")).toEqual([]);
+ });
+});
diff --git a/frontend/src/leftoverCaption.ts b/frontend/src/leftoverCaption.ts
new file mode 100644
index 00000000..5107f06e
--- /dev/null
+++ b/frontend/src/leftoverCaption.ts
@@ -0,0 +1,26 @@
+import type { LeftoverPair } from "./api";
+
+const CRITERION_SHORT_LABEL: Record = {
+ general_sentiment_positive: "constructive",
+ general_sentiment_negative: "negative",
+ sales_lead_specificity: "sales-lead",
+};
+
+export function criterionShortLabel(itemCode: string): string {
+ return CRITERION_SHORT_LABEL[itemCode] ?? itemCode;
+}
+
+export function leftoverRowLabel(pairKind: string): string {
+ return pairKind === "farthest" ? "Farthest leftover" : "Closest leftover";
+}
+
+export function leftoverBadgeText(pair: LeftoverPair): string {
+ return `${leftoverRowLabel(pair.pair_kind)} · ${criterionShortLabel(pair.criterion_code)}`;
+}
+
+export function leftoverPairsForPost(
+ pairs: LeftoverPair[] | undefined,
+ postId: string,
+): LeftoverPair[] {
+ return (pairs ?? []).filter((pair) => pair.post_id === postId);
+}
diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py
index 1710c009..4d55cb97 100644
--- a/lineageweave/__init__.py
+++ b/lineageweave/__init__.py
@@ -35,4 +35,4 @@
"sentence_excerpts",
]
-__version__ = "0.75.0"
+__version__ = "0.91.0"
diff --git a/pyproject.toml b/pyproject.toml
index 764ebad7..84601130 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "lineageweave"
-version = "0.75.0"
+version = "0.91.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/uv.lock b/uv.lock
index 08eab776..d98d2a5a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -355,7 +355,7 @@ wheels = [
[[package]]
name = "lineageweave"
-version = "0.75.0"
+version = "0.91.0"
source = { virtual = "." }
dependencies = [
{ name = "certifi" },