From 3c01067191c8391aa2ee6136de1b6c49c1abb9f0 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 18 Aug 2026 11:15:25 +0000 Subject: [PATCH] feat: name leftover criterion on the matching affiliate-tree Keyman (v0.93.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After make seed, Priya Nair on the leftover Public post reads Closest leftover · sales-lead. Click still opens related nodes. An organization chip stays unmarked. Never invent a fused score or a theta. --- AGENTS.md | 5 +- ARCHITECTURE.md | 3 ++ CHANGELOG.d/0.93.0-leftover-affiliate-tree.md | 9 ++++ CHANGELOG.md | 10 ++++ .../0034-leftover-pair-on-affiliate-tree.md | 54 +++++++++++++++++++ frontend/package.json | 2 +- frontend/src/App.css | 8 +++ frontend/src/App.test.tsx | 39 ++++++++++++++ frontend/src/App.tsx | 41 ++++++++++++-- frontend/src/leftoverCaption.test.ts | 46 ++++++++++++++++ frontend/src/leftoverCaption.ts | 26 +++++++++ lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 14 files changed, 240 insertions(+), 9 deletions(-) create mode 100644 CHANGELOG.d/0.93.0-leftover-affiliate-tree.md create mode 100644 docs/adr/0034-leftover-pair-on-affiliate-tree.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 c790995c1..b77cc4601 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 / 0034) 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. Authorized leftover pairs also name the +matching affiliate-tree person chip on that post. `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 f8a83ceb1..f4e15ea26 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -123,6 +123,9 @@ flowchart LR `RankWeaveNotAvailable`. `GET /api/rankings` then returns `rankweave_not_available` and an empty ranking list. Hidden posts are omitted from every channel. See ADR 0024. + A leftover post–criterion pair that already sits on the authorized + report payload names the matching affiliate-tree person chip + (ADR 0034). ## Standards and citations diff --git a/CHANGELOG.d/0.93.0-leftover-affiliate-tree.md b/CHANGELOG.d/0.93.0-leftover-affiliate-tree.md new file mode 100644 index 000000000..f990bfe11 --- /dev/null +++ b/CHANGELOG.d/0.93.0-leftover-affiliate-tree.md @@ -0,0 +1,9 @@ +# 0.93.0 — Leftover caption on the matching affiliate-tree Keyman + +## Added + +- Affiliate-tree person chips on a leftover post name the leftover + criterion (ADR 0034). After `make seed`, Priya Nair on the leftover + Public post reads **Closest leftover · sales-lead**; click still + opens related nodes. An organization chip stays unmarked. Never + invent a fused score or a theta. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bfcaa28f..f6e15e1a9 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.93.0] - 2026-08-18 + +### Added + +- Affiliate-tree person chips on a leftover post name the leftover + criterion (ADR 0034). After `make seed`, Priya Nair on the leftover + Public post reads **Closest leftover · sales-lead**; click still + opens related nodes. An organization chip stays unmarked. Never + invent a fused score or a theta. + ## [0.75.0] - 2026-08-17 ### Added diff --git a/docs/adr/0034-leftover-pair-on-affiliate-tree.md b/docs/adr/0034-leftover-pair-on-affiliate-tree.md new file mode 100644 index 000000000..5ab9afabb --- /dev/null +++ b/docs/adr/0034-leftover-pair-on-affiliate-tree.md @@ -0,0 +1,54 @@ +# ADR 0034 — Name leftover on the matching affiliate-tree Keyman + +**Decision status:** Accepted +**Date:** 2026-08-18 + +## Context + +ADR 0018 puts leftover pairs above the period-report member list. +After a leftover pair opens that post, the affiliate tree still does +not name the leftover criterion. The leftover post already lists +affiliate people; those chips just do not name leftover. + +Do not invent a second leftover store. Do not invent a fused score +or a theta. Do not invent leftover on an organization. Leftover is +a post–criterion pair (Jeon leftover map). Do not change the existing +`Affiliate Keyman: {name}` or `Affiliate org: {name}` accessible names. + +## Decision + +When an authorized leftover pair names the opened post, each +affiliate-tree person chip on that post shows +`Closest leftover · {criterion}` or `Farthest leftover · {criterion}` +next to the name. + +An organization chip stays unmarked. A leftover pair for a hidden +post never reaches the tree (ADR 0017 ABAC). A report fetch error +clears leftover captions and leaves the tree intact — never an +invented pair. + +After `make seed`, open the leftover Public post: Priya Nair on the +affiliate tree reads **Closest leftover · sales-lead**; click still +opens related nodes. + +Leftover evidence is the same authorized `leftover_pairs` already +on the period-report payload. + +## Consequences + +Leftover buttons above the member list stay (ADR 0018). Keyman-chip +leftover stays on #256 / ADR 0033. Event Lineage leftover stays on +#255 / ADR 0032. This slice only labels the already-visible +affiliate-tree person chips. + +## 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 575b7c586..592021f70 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.75.0", + "version": "0.93.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 76cf3665c..30299f931 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -307,6 +307,14 @@ font-size: 0.8rem; } +.affiliate-leftover { + display: block; + margin-top: 0.15rem; + font-size: 0.75rem; + letter-spacing: 0.02em; + color: color-mix(in srgb, canvastext 72%, transparent); +} + .voc-excerpt-list { list-style: none; padding: 0; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index a32a26403..b8f37e92b 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -67,6 +67,7 @@ describe("App, authenticated", () => { }; chatUnavailable?: boolean; searchUnavailable?: boolean; + reportsUnavailable?: boolean; verificationEvidenceUrl?: string | null; }) { const statusLabel: Record = { @@ -294,6 +295,16 @@ describe("App, authenticated", () => { }), ); } + if (options?.reportsUnavailable && url.includes("/api/reports/") && method === "GET") { + return Promise.resolve( + new Response( + JSON.stringify({ + detail: "Period report is unavailable", + }), + { status: 503, headers: { "Content-Type": "application/json" } }, + ), + ); + } if (url.includes("/api/reports/") && method === "GET") { return Promise.resolve( jsonResponse({ @@ -1018,6 +1029,34 @@ describe("App, authenticated", () => { ); }); + it("names leftover criterion on the matching affiliate-tree Keyman", async () => { + stubBackend(); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + const leftoverChip = await screen.findByRole("button", { name: "Affiliate Keyman: Priya Nair" }); + await waitFor(() => + expect(leftoverChip.parentElement).toHaveTextContent("Closest leftover · sales-lead"), + ); + expect(leftoverChip).toHaveAccessibleName("Affiliate Keyman: Priya Nair"); + expect(screen.getByRole("button", { name: "Affiliate org: Demo Corp" })).not.toHaveTextContent( + "leftover", + ); + + await userEvent.click(leftoverChip); + await waitFor(() => expect(screen.getByText("Related to Priya Nair")).toBeInTheDocument()); + }); + + it("clears leftover badges on the affiliate tree when the report fetch fails", async () => { + stubBackend({ reportsUnavailable: true }); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + const leftoverChip = await screen.findByRole("button", { name: "Affiliate Keyman: Priya Nair" }); + expect(leftoverChip.parentElement).not.toHaveTextContent("leftover"); + expect(leftoverChip).toHaveAccessibleName("Affiliate Keyman: Priya Nair"); + }); + it("opens related Keyman nodes from an R&R person", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6056e5eb4..064828313 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -52,8 +52,10 @@ import { type PostSummary, type RankingList, type RelatedNode, + type LeftoverPair, type VocEvidence, } from "./api"; +import { leftoverBadgeText, leftoverPairsForPost } from "./leftoverCaption"; import { LineageDag } from "./LineageDag"; import { subgraphForPost } from "./lineageLayout"; import "./App.css"; @@ -313,13 +315,20 @@ function EventLineageSection({ function AffiliateTreeNode({ node, + leftoverPairs, + leftoverPostId, onSelectPerson, onSelectEntity, }: { node: AffiliateNode; + leftoverPairs?: LeftoverPair[]; + leftoverPostId?: string; onSelectPerson?: (personId: string, personName: string) => void; onSelectEntity?: (entityId: string, entityName: string) => void; }) { + const leftoverForOpenedPost = leftoverPostId + ? leftoverPairsForPost(leftoverPairs, leftoverPostId) + : []; return (
  • @@ -358,6 +367,11 @@ function AffiliateTreeNode({ ) : ( `${person.person_name} (${person.person_side_label ?? person.person_side_code})` )} + {leftoverForOpenedPost.map((pair) => ( + + {leftoverBadgeText(pair)} + + ))} ))} @@ -368,6 +382,8 @@ function AffiliateTreeNode({ @@ -1087,6 +1103,7 @@ function PostDetailPopup({ accessToken, canExtract, graph, + leftoverPairs, onClose, onSelectPost, }: { @@ -1094,6 +1111,7 @@ function PostDetailPopup({ accessToken: string; canExtract: boolean; graph: LineageGraph | null; + leftoverPairs?: LeftoverPair[]; onClose: () => void; onSelectPost?: (postId: string) => void; }) { @@ -1262,6 +1280,8 @@ function PostDetailPopup({ { setFocusEntity(null); setFocusPerson({ personId, personName }); @@ -1425,10 +1445,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 +1477,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 +1498,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 +1686,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 +1720,12 @@ function PostList({ accessToken }: { accessToken: string }) { <> - +

    Event Lineage

    @@ -1726,6 +1760,7 @@ function PostList({ accessToken }: { accessToken: string }) { accessToken={accessToken} canExtract={canRebuild} graph={graph} + leftoverPairs={leftoverPairs} onClose={() => setSelectedPostId(null)} onSelectPost={setSelectedPostId} /> diff --git a/frontend/src/leftoverCaption.test.ts b/frontend/src/leftoverCaption.test.ts new file mode 100644 index 000000000..d1e29e966 --- /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 000000000..5107f06e3 --- /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 1710c009e..0123f9a6f 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -35,4 +35,4 @@ "sentence_excerpts", ] -__version__ = "0.75.0" +__version__ = "0.93.0" diff --git a/pyproject.toml b/pyproject.toml index 764ebad72..3dd44c94c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.75.0" +version = "0.93.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 08eab7768..ca69271b7 100644 --- a/uv.lock +++ b/uv.lock @@ -355,7 +355,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.75.0" +version = "0.93.0" source = { virtual = "." } dependencies = [ { name = "certifi" },