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

Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.d/0.92.0-leftover-keyman-chip.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions docs/adr/0033-leftover-pair-on-keyman-chip.md
Original file line number Diff line number Diff line change
@@ -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
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.92.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 @@ -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;
Expand Down
44 changes: 44 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,39 @@ describe("App, authenticated", () => {
);
});

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

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

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(<App />);
Expand Down
49 changes: 44 additions & 5 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 @@ -523,6 +525,7 @@ function KeymanPanel({
canExtract,
onExtracted,
onSelectPost,
leftoverPairs,
focusPerson,
focusEntity,
}: {
Expand All @@ -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;
}) {
Expand Down Expand Up @@ -628,7 +632,9 @@ function KeymanPanel({
{error && <p className="error">{error}</p>}
{keymen && keymen.length > 0 ? (
<ul className="keyman-list">
{keymen.map((person) => (
{keymen.map((person) => {
const leftoverForOpenedPost = leftoverPairsForPost(leftoverPairs, postId);
return (
<li key={person.person_id}>
<button
className="keyman-select"
Expand All @@ -637,6 +643,11 @@ function KeymanPanel({
>
<strong>{person.person_name}</strong> ({person.person_side_label ?? person.person_side_code})
</button>
{leftoverForOpenedPost.map((pair) => (
<span key={`${pair.pair_kind}:${pair.criterion_code}`} className="keyman-leftover">
{leftoverBadgeText(pair)}
</span>
))}
{person.affiliations.length > 0 && (
<span className="keyman-affiliations">
{" -- "}
Expand Down Expand Up @@ -664,7 +675,8 @@ function KeymanPanel({
</span>
)}
</li>
))}
);
})}
</ul>
) : (
<p className="popup-placeholder">No Keyman extracted yet.</p>
Expand All @@ -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 (
<li key={`${node.node_type_code}:${node.node_id}`}>
<button
Expand All @@ -689,6 +702,14 @@ function KeymanPanel({
onClick={() => onSelectPost(node.node_id)}
>
{caption}
{leftoverForRelated.map((pair) => (
<span
key={`${pair.pair_kind}:${pair.criterion_code}`}
className="keyman-leftover"
>
{leftoverBadgeText(pair)}
</span>
))}
</button>
</li>
);
Expand Down Expand Up @@ -1087,13 +1108,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 @@ -1283,6 +1306,7 @@ function PostDetailPopup({
canExtract={canExtract}
onExtracted={reloadKeymen}
onSelectPost={onSelectPost}
leftoverPairs={leftoverPairs}
focusPerson={focusPerson}
focusEntity={focusEntity}
/>
Expand Down Expand Up @@ -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");
Expand All @@ -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);
Expand All @@ -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);
}
Expand Down Expand Up @@ -1658,6 +1690,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 +1724,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 +1764,7 @@ function PostList({ accessToken }: { accessToken: string }) {
accessToken={accessToken}
canExtract={canRebuild}
graph={graph}
leftoverPairs={leftoverPairs}
onClose={() => setSelectedPostId(null)}
onSelectPost={setSelectedPostId}
/>
Expand Down
Loading
Loading