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 / 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
Expand Down
3 changes: 3 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.d/0.93.0-leftover-affiliate-tree.md
Original file line number Diff line number Diff line change
@@ -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.
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.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
Expand Down
54 changes: 54 additions & 0 deletions docs/adr/0034-leftover-pair-on-affiliate-tree.md
Original file line number Diff line number Diff line change
@@ -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
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.93.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
39 changes: 39 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ describe("App, authenticated", () => {
};
chatUnavailable?: boolean;
searchUnavailable?: boolean;
reportsUnavailable?: boolean;
verificationEvidenceUrl?: string | null;
}) {
const statusLabel: Record<string, string> = {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -1018,6 +1029,34 @@ describe("App, authenticated", () => {
);
});

it("names leftover criterion on the matching affiliate-tree Keyman", async () => {
stubBackend();
render(<App />);

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

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(<App />);
Expand Down
41 changes: 38 additions & 3 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<li>
<span className={node.resolved ? "affiliate-resolved" : "affiliate-unresolved"}>
Expand Down Expand Up @@ -358,6 +367,11 @@ function AffiliateTreeNode({
) : (
`${person.person_name} (${person.person_side_label ?? person.person_side_code})`
)}
{leftoverForOpenedPost.map((pair) => (
<span key={`${pair.pair_kind}:${pair.criterion_code}`} className="affiliate-leftover">
{leftoverBadgeText(pair)}
</span>
))}
</span>
))}
</span>
Expand All @@ -368,6 +382,8 @@ function AffiliateTreeNode({
<AffiliateTreeNode
key={child.entity_id ?? child.entity_name}
node={child}
leftoverPairs={leftoverPairs}
leftoverPostId={leftoverPostId}
onSelectPerson={onSelectPerson}
onSelectEntity={onSelectEntity}
/>
Expand Down Expand Up @@ -1087,13 +1103,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 @@ -1262,6 +1280,8 @@ function PostDetailPopup({
<AffiliateTreeNode
key={node.entity_id ?? node.entity_name}
node={node}
leftoverPairs={leftoverPairs}
leftoverPostId={postId}
onSelectPerson={(personId, personName) => {
setFocusEntity(null);
setFocusPerson({ personId, personName });
Expand Down Expand Up @@ -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");
Expand All @@ -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);
Expand All @@ -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);
}
Expand Down Expand Up @@ -1658,6 +1686,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 +1720,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 Down Expand Up @@ -1726,6 +1760,7 @@ function PostList({ accessToken }: { accessToken: string }) {
accessToken={accessToken}
canExtract={canRebuild}
graph={graph}
leftoverPairs={leftoverPairs}
onClose={() => setSelectedPostId(null)}
onSelectPost={setSelectedPostId}
/>
Expand Down
46 changes: 46 additions & 0 deletions frontend/src/leftoverCaption.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading
Loading