From 0b5f72e83c5a6473271774fa8f8364b4419247c1 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 18 Aug 2026 11:05:48 +0000 Subject: [PATCH] feat: name leftover criterion on the matching Event Lineage node (v0.91.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After make seed, leftover DAG nodes read Closest leftover · sales-lead under the title. Click still opens that post. A node that is not a leftover pair stays unmarked. A report fetch error clears leftover captions and leaves the reconstructed graph intact. Do not invent a fused score or a theta. Do not mix into #74 or #92. --- AGENTS.md | 5 +- ARCHITECTURE.md | 3 +- CHANGELOG.d/0.91.0-leftover-lineage-node.md | 8 +++ CHANGELOG.md | 10 ++++ .../adr/0032-leftover-pair-on-lineage-node.md | 53 +++++++++++++++++++ frontend/package.json | 2 +- frontend/src/App.css | 6 +++ frontend/src/App.test.tsx | 34 ++++++++++++ frontend/src/App.tsx | 37 +++++++++++-- frontend/src/LineageDag.tsx | 23 +++++++- frontend/src/leftoverCaption.test.ts | 46 ++++++++++++++++ frontend/src/leftoverCaption.ts | 26 +++++++++ lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 15 files changed, 245 insertions(+), 14 deletions(-) create mode 100644 CHANGELOG.d/0.91.0-leftover-lineage-node.md create mode 100644 docs/adr/0032-leftover-pair-on-lineage-node.md create mode 100644 frontend/src/leftoverCaption.test.ts create mode 100644 frontend/src/leftoverCaption.ts 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" },