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 / 0031) 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. A home post row that is a leftover pair
also names the leftover criterion next to visibility.

`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. A home post row
that is also a leftover pair names that criterion (ADR 0031).

## Standards and citations

Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.d/0.90.0-leftover-home-post.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# 0.90.0 — Leftover badge on the matching home post row

## Added

- Home post rows that are leftover pairs name the leftover
criterion (ADR 0031). After `make seed`, the leftover row reads
**Closest leftover · sales-lead** next to visibility; click
opens that post. A row 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.90.0] - 2026-08-18

### Added

- Home post rows that are leftover pairs name the leftover
criterion (ADR 0031). After `make seed`, the leftover row reads
**Closest leftover · sales-lead** next to visibility; click
opens that post. A row 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
52 changes: 52 additions & 0 deletions docs/adr/0031-leftover-pair-on-home-post.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# ADR 0031 — Name leftover on the matching home post row

**Decision status:** Accepted
**Date:** 2026-08-18

## Context

ADR 0018 puts leftover pairs above the period-report member list.
The home post list is the last surface a buyer scans after Rankings,
Calendar, Period reports, and Event Lineage. The leftover post is
already in that list; the row 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 `View post: {title}`
accessible name — leftover is a visible badge on the already-named
control.

## Decision

When an authorized leftover pair names a home post row, that button
shows `Closest leftover · {criterion}` or
`Farthest leftover · {criterion}` next to the visibility badge.

A row that is not a leftover pair stays unmarked. A leftover pair
for a hidden post never reaches the home list (ADR 0017 ABAC). A
report fetch error clears leftover badges and leaves the post list
intact — never an invented pair.

After `make seed`, the leftover home row reads **Closest leftover ·
sales-lead** next to visibility; 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). Calendar
leftover stays on #253 / ADR 0030. Rankings leftover stays on #252
/ ADR 0029. This slice only labels the already-visible home row.

## 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.90.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
70 changes: 59 additions & 11 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ describe("App, authenticated", () => {
function stubBackend(options?: {
admin?: boolean;
calendarCommitments?: unknown[];
homePosts?: {
post_id: string;
post_title: string;
voc_type_code: string;
voc_type_label?: string;
visibility_code: string;
visibility_label?: string;
created_at: string;
}[];
rankings?: {
status?: "accepted" | "unavailable";
status_reason?: string | null;
Expand Down Expand Up @@ -427,17 +436,19 @@ describe("App, authenticated", () => {
}
if (url.endsWith("/api/posts")) {
return Promise.resolve(
jsonResponse([
{
post_id: "post-1",
post_title: "Public post",
voc_type_code: "voc",
voc_type_label: "Voice of Customer",
visibility_code: "public",
visibility_label: "Public",
created_at: "2026-01-01T00:00:00Z",
},
]),
jsonResponse(
options?.homePosts ?? [
{
post_id: "post-1",
post_title: "Public post",
voc_type_code: "voc",
voc_type_label: "Voice of Customer",
visibility_code: "public",
visibility_label: "Public",
created_at: "2026-01-01T00:00:00Z",
},
],
),
);
}
if (url.endsWith("/api/posts/post-1")) {
Expand Down Expand Up @@ -830,6 +841,43 @@ describe("App, authenticated", () => {
expect(screen.queryByText("Not yet evaluated.")).not.toBeInTheDocument();
});

it("names leftover criterion on the matching home post row", async () => {
stubBackend({
homePosts: [
{
post_id: "post-1",
post_title: "Public post",
voc_type_code: "voc",
voc_type_label: "Voice of Customer",
visibility_code: "public",
visibility_label: "Public",
created_at: "2026-01-01T00:00:00Z",
},
{
post_id: "post-9",
post_title: "Riverbend calendar commitment",
voc_type_code: "voc",
voc_type_label: "Voice of Customer",
visibility_code: "public",
visibility_label: "Public",
created_at: "2026-01-03T00:00:00Z",
},
],
});
render(<App />);

const leftoverRow = await screen.findByRole("button", { name: "View post: Public post" });
await waitFor(() => expect(leftoverRow).toHaveTextContent("Closest leftover · sales-lead"));
expect(leftoverRow).toHaveAccessibleName("View post: Public post");
const unmarked = screen.getByRole("button", { name: "View post: Riverbend calendar commitment" });
expect(unmarked).toHaveTextContent("Voice of Customer");
expect(unmarked).not.toHaveTextContent("leftover");

await userEvent.click(leftoverRow);

await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument());
});

it("rebuilds lineage when the account has post_admin", async () => {
const fetchMock = stubBackend({ admin: true });
render(<App />);
Expand Down
40 changes: 35 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 @@ -82,6 +83,10 @@ function criterionShortLabel(itemCode: string): string {
return CRITERION_SHORT_LABEL[itemCode] ?? itemCode;
}

function leftoverRowLabel(pairKind: string): string {
return pairKind === "farthest" ? "Farthest leftover" : "Closest leftover";
}

// This popup's layout follows the textual product brief (Korean summary,
// key events, R&R, Event Lineage, Keyman, in-popup chat with a sliding
// evidence panel) rather than the referenced Figma frame's actual pixel
Expand Down Expand Up @@ -1425,10 +1430,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 +1462,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 +1483,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 +1671,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 +1705,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 @@ -1706,7 +1725,9 @@ function PostList({ accessToken }: { accessToken: string }) {
{graph && <LineageDag graph={graph} onSelectPost={setSelectedPostId} />}
</section>
<ul className="post-list">
{posts.map((post) => (
{posts.map((post) => {
const leftoverForPost = leftoverPairs.filter((pair) => pair.post_id === post.post_id);
return (
<li key={post.post_id}>
<button
className="post-list-item"
Expand All @@ -1716,9 +1737,18 @@ function PostList({ accessToken }: { accessToken: string }) {
<span className="post-title">{post.post_title}</span>
<span className="post-badge">{post.voc_type_label ?? post.voc_type_code}</span>
<span className="post-badge">{post.visibility_label ?? post.visibility_code}</span>
{leftoverForPost.map((pair) => (
<span
key={`${pair.pair_kind}:${pair.criterion_code}`}
className="post-badge"
>
{leftoverRowLabel(pair.pair_kind)} · {criterionShortLabel(pair.criterion_code)}
</span>
))}
</button>
</li>
))}
);
})}
</ul>
{selectedPostId && (
<PostDetailPopup
Expand Down
2 changes: 1 addition & 1 deletion lineageweave/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,4 @@
"sentence_excerpts",
]

__version__ = "0.75.0"
__version__ = "0.90.0"
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "lineageweave"
version = "0.75.0"
version = "0.90.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" }
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading