diff --git a/AGENTS.md b/AGENTS.md index c790995c1..9a528dccc 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 / 0033) 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 Keyman 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..bd93d0312 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -123,6 +123,8 @@ 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 Keyman chip (ADR 0033). ## Standards and citations diff --git a/CHANGELOG.d/0.92.0-leftover-keyman-chip.md b/CHANGELOG.d/0.92.0-leftover-keyman-chip.md new file mode 100644 index 000000000..d8b175648 --- /dev/null +++ b/CHANGELOG.d/0.92.0-leftover-keyman-chip.md @@ -0,0 +1,10 @@ +# 0.92.0 — Leftover caption on the matching Keyman chip + +## Added + +- Keyman chips on a leftover post name the leftover criterion + (ADR 0033). After `make seed`, Ada West on the leftover Public + post reads **Closest leftover · sales-lead**; click still opens + related nodes. A related leftover post chip reads **Farthest + leftover · negative**. A chip that is not a leftover pair stays + unmarked. Never invent a fused score or a theta. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bfcaa28f..e10b50f4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ 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.92.0] - 2026-08-18 + +### Added + +- Keyman chips on a leftover post name the leftover criterion + (ADR 0033). After `make seed`, Ada West on the leftover Public + post reads **Closest leftover · sales-lead**; click still opens + related nodes. A related leftover post chip reads **Farthest + leftover · negative**. A chip 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/0033-leftover-pair-on-keyman-chip.md b/docs/adr/0033-leftover-pair-on-keyman-chip.md new file mode 100644 index 000000000..4a9d7f7d3 --- /dev/null +++ b/docs/adr/0033-leftover-pair-on-keyman-chip.md @@ -0,0 +1,60 @@ +# ADR 0033 — Name leftover on the matching Keyman chip + +**Decision status:** Accepted +**Date:** 2026-08-18 + +## Context + +ADR 0018 puts leftover pairs above the period-report member list. +The member list is already the click-through to Event Lineage, +Keyman, and evaluation. After a leftover pair opens that post, the +Keyman chips still do not name the leftover criterion. + +Do not invent a second leftover store. Do not invent a fused score +or a theta. Do not invent leftover on a person. Leftover is a +post–criterion pair (Jeon leftover map). The Keyman extracted from +that post, and a related-post chip that *is* a leftover pair, only +show the authorized caption. Do not change existing accessible +names (`Related nodes for {name}`, `Open related post: {title}`). + +## Decision + +When an authorized leftover pair names the opened post, each Keyman +person chip on that post shows `Closest leftover · {criterion}` or +`Farthest leftover · {criterion}` next to the name. + +When a related-node walk returns a post that is itself a leftover +pair, that related-post chip shows the same leftover caption. + +A Keyman that is not on a leftover post stays unmarked. A related +person or organization chip stays unmarked. A leftover pair for a +hidden post never reaches Keyman (ADR 0017 ABAC). A report fetch +error clears leftover captions and leaves the Keyman list intact — +never an invented pair. + +After `make seed`, open the leftover Public post: Ada West reads +**Closest leftover · sales-lead**; click still opens related nodes. +A related leftover post chip reads **Farthest leftover · negative**; +click still opens that post. + +Leftover evidence is the same authorized `leftover_pairs` already +on the period-report payload. + +## Consequences + +Leftover buttons above the member list stay (ADR 0018). Event +Lineage leftover stays on #255 / ADR 0032. Home-row leftover stays +on #254 / ADR 0031. This slice only labels the already-visible +Keyman 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..4344ef713 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.75.0", + "version": "0.92.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 76cf3665c..64a1a7b01 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -234,6 +234,14 @@ padding: 0.3rem 0; } +.keyman-leftover { + display: block; + margin-top: 0.15rem; + font-size: 0.75rem; + letter-spacing: 0.02em; + color: color-mix(in srgb, canvastext 72%, transparent); +} + .keyman-affiliations { opacity: 0.7; font-size: 0.85rem; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index a32a26403..74d34e2d2 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,39 @@ describe("App, authenticated", () => { ); }); + it("names leftover criterion on the matching Keyman chip", async () => { + stubBackend(); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + const leftoverChip = await screen.findByRole("button", { name: "Related nodes for Ada West" }); + await waitFor(() => + expect(leftoverChip.closest("li")).toHaveTextContent("Closest leftover · sales-lead"), + ); + expect(leftoverChip).toHaveAccessibleName("Related nodes for Ada West"); + + await userEvent.click(leftoverChip); + const leftoverRelated = await screen.findByRole("button", { name: "Open related post: Linked post" }); + await waitFor(() => expect(leftoverRelated).toHaveTextContent("Farthest leftover · negative")); + expect(leftoverRelated).toHaveAccessibleName("Open related post: Linked post"); + expect(screen.getByText("Priya Nair (Person)")).not.toHaveTextContent("leftover"); + + await userEvent.click(leftoverRelated); + await waitFor(() => + expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), + ); + }); + + it("clears leftover badges on Keyman chips 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: "Related nodes for Ada West" }); + expect(leftoverChip.closest("li")).not.toHaveTextContent("leftover"); + expect(leftoverChip).toHaveAccessibleName("Related nodes for Ada West"); + }); + 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..4d315c543 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"; @@ -523,6 +525,7 @@ function KeymanPanel({ canExtract, onExtracted, onSelectPost, + leftoverPairs, focusPerson, focusEntity, }: { @@ -532,6 +535,7 @@ function KeymanPanel({ canExtract: boolean; onExtracted: () => void; onSelectPost?: (postId: string) => void; + leftoverPairs?: LeftoverPair[]; focusPerson?: { personId: string; personName: string } | null; focusEntity?: { entityId: string; entityName: string } | null; }) { @@ -628,7 +632,9 @@ function KeymanPanel({ {error &&

{error}

} {keymen && keymen.length > 0 ? (
    - {keymen.map((person) => ( + {keymen.map((person) => { + const leftoverForOpenedPost = leftoverPairsForPost(leftoverPairs, postId); + return (
  • + {leftoverForOpenedPost.map((pair) => ( + + {leftoverBadgeText(pair)} + + ))} {person.affiliations.length > 0 && ( {" -- "} @@ -664,7 +675,8 @@ function KeymanPanel({ )}
  • - ))} + ); + })}
) : (

No Keyman extracted yet.

@@ -681,6 +693,7 @@ function KeymanPanel({ {related.map((node) => { const caption = `${node.label ?? node.node_id} (${node.ontology_label ?? node.node_type_code})`; if (node.node_type_code === NODE_POST && onSelectPost) { + const leftoverForRelated = leftoverPairsForPost(leftoverPairs, node.node_id); return (
  • ); @@ -1087,6 +1108,7 @@ function PostDetailPopup({ accessToken, canExtract, graph, + leftoverPairs, onClose, onSelectPost, }: { @@ -1094,6 +1116,7 @@ function PostDetailPopup({ accessToken: string; canExtract: boolean; graph: LineageGraph | null; + leftoverPairs?: LeftoverPair[]; onClose: () => void; onSelectPost?: (postId: string) => void; }) { @@ -1283,6 +1306,7 @@ function PostDetailPopup({ canExtract={canExtract} onExtracted={reloadKeymen} onSelectPost={onSelectPost} + leftoverPairs={leftoverPairs} focusPerson={focusPerson} focusEntity={focusEntity} /> @@ -1425,10 +1449,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 +1481,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 +1502,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 +1690,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 +1724,12 @@ function PostList({ accessToken }: { accessToken: string }) { <> - +

    Event Lineage

    @@ -1726,6 +1764,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..c7b10c6ca 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -35,4 +35,4 @@ "sentence_excerpts", ] -__version__ = "0.75.0" +__version__ = "0.92.0" diff --git a/pyproject.toml b/pyproject.toml index 764ebad72..d7cf88ded 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.75.0" +version = "0.92.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..26a7debf4 100644 --- a/uv.lock +++ b/uv.lock @@ -355,7 +355,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.75.0" +version = "0.92.0" source = { virtual = "." } dependencies = [ { name = "certifi" },