Skip to content
Closed
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
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.d/0.91.0-leftover-lineage-node.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions docs/adr/0032-leftover-pair-on-lineage-node.md
Original file line number Diff line number Diff line change
@@ -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
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": "0.75.0",
"version": "0.91.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
34 changes: 34 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ describe("App, authenticated", () => {
function stubBackend(options?: {
admin?: boolean;
calendarCommitments?: unknown[];
reportsUnavailable?: boolean;
rankings?: {
status?: "accepted" | "unavailable";
status_reason?: string | null;
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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(<App />);

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(<App />);

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();

Expand Down
37 changes: 32 additions & 5 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
type PostSummary,
type RankingList,
type RelatedNode,
type LeftoverPair,
type VocEvidence,
} from "./api";
import { LineageDag } from "./LineageDag";
Expand Down Expand Up @@ -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 <p>Loading lineage...</p>;
const scoped = graph ? subgraphForPost(graph, postId) : { nodes: [], edges: [] };
Expand All @@ -299,7 +302,7 @@ function EventLineageSection({
return (
<>
{scoped.nodes.length > 0 && onSelectPost && (
<LineageDag graph={scoped} onSelectPost={onSelectPost} />
<LineageDag graph={scoped} onSelectPost={onSelectPost} leftoverPairs={leftoverPairs} />
)}
{hasLinks && (
<ul className="lineage-list">
Expand Down Expand Up @@ -1087,13 +1090,15 @@ function PostDetailPopup({
accessToken,
canExtract,
graph,
leftoverPairs,
onClose,
onSelectPost,
}: {
postId: string;
accessToken: string;
canExtract: boolean;
graph: LineageGraph | null;
leftoverPairs?: LeftoverPair[];
onClose: () => void;
onSelectPost?: (postId: string) => void;
}) {
Expand Down Expand Up @@ -1247,6 +1252,7 @@ function PostDetailPopup({
graph={graph}
postId={postId}
onSelectPost={onSelectPost}
leftoverPairs={leftoverPairs}
/>
</section>

Expand Down Expand Up @@ -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");
Expand All @@ -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);
Expand All @@ -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);
}
Expand Down Expand Up @@ -1658,6 +1672,7 @@ function PostList({ accessToken }: { accessToken: string }) {
const [graph, setGraph] = useState<LineageGraph | null>(null);
const [error, setError] = useState<string | null>(null);
const [selectedPostId, setSelectedPostId] = useState<string | null>(null);
const [leftoverPairs, setLeftoverPairs] = useState<LeftoverPair[]>([]);
const [canRebuild, setCanRebuild] = useState(false);
const [rebuilding, setRebuilding] = useState(false);
const [rebuildError, setRebuildError] = useState<string | null>(null);
Expand Down Expand Up @@ -1691,7 +1706,12 @@ function PostList({ accessToken }: { accessToken: string }) {
<>
<RankingsPanel accessToken={accessToken} onSelectPost={setSelectedPostId} />
<CalendarPanel accessToken={accessToken} onSelectPost={setSelectedPostId} />
<ReportsPanel accessToken={accessToken} canRebuild={canRebuild} onSelectPost={setSelectedPostId} />
<ReportsPanel
accessToken={accessToken}
canRebuild={canRebuild}
onSelectPost={setSelectedPostId}
onLeftoverPairsChange={setLeftoverPairs}
/>
<section className="popup-section lineage-home">
<div className="lineage-home-header">
<h2>Event Lineage</h2>
Expand All @@ -1703,7 +1723,13 @@ function PostList({ accessToken }: { accessToken: string }) {
</div>
{rebuildError && <p className="error">{rebuildError}</p>}
{!graph && <p>Loading lineage graph...</p>}
{graph && <LineageDag graph={graph} onSelectPost={setSelectedPostId} />}
{graph && (
<LineageDag
graph={graph}
onSelectPost={setSelectedPostId}
leftoverPairs={leftoverPairs}
/>
)}
</section>
<ul className="post-list">
{posts.map((post) => (
Expand All @@ -1726,6 +1752,7 @@ function PostList({ accessToken }: { accessToken: string }) {
accessToken={accessToken}
canExtract={canRebuild}
graph={graph}
leftoverPairs={leftoverPairs}
onClose={() => setSelectedPostId(null)}
onSelectPost={setSelectedPostId}
/>
Expand Down
23 changes: 21 additions & 2 deletions frontend/src/LineageDag.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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 (
<g
key={node.id}
Expand All @@ -70,7 +75,21 @@ export function LineageDag({
<text x={12} y={4}>
{truncateLabel(node.label)}
</text>
<title>{`${node.label} — ${node.occurred_at.slice(0, 10)}`}</title>
{leftoverForNode.map((pair, index) => (
<text
key={`${pair.pair_kind}:${pair.criterion_code}`}
className="lineage-dag-leftover"
x={12}
y={16 + index * 12}
>
{leftoverBadgeText(pair)}
</text>
))}
<title>
{leftoverTitles.length > 0
? `${node.label} — ${node.occurred_at.slice(0, 10)} — ${leftoverTitles.join(" · ")}`
: `${node.label} — ${node.occurred_at.slice(0, 10)}`}
</title>
</g>
);
})}
Expand Down
Loading
Loading