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
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,12 @@ 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 / 0025) 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.
persist to `report_leftover_pair`, sit above the member list, and
appear on the grouping comparison strip so a click opens 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
9 changes: 6 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -516,16 +516,19 @@ residual SVD leftover pairs (Jeon et al., 2021; ADR 0017) persist to
`report_period_score` / `report_member_score`.
`GET /api/reports/{grouping}` lists the trend;
`GET /api/reports/{grouping}/{period}` is ABAC-filtered;
`GET /api/reports/compare/{period}` is the home-page grouping strip;
`GET /api/reports/compare/{period}` is the home-page grouping strip
and now carries authorized leftover pairs so a comparison-row click
opens that post;
`POST .../rebuild` scores every grouping kind (post_admin). `make seed`
folds A-100/B-200 Event Lineage fixtures (and the Riverbend calendar
post) that already have constructed IRT cells into the same shared
bank as the dummy high/low band rows, so comparison-strip click
through opens those DAG posts. Report members include the earliest
open ticket title, status lookup label, and due date when one exists. The home page renders
the actual mean θ, the FIPC delta, the CAT-selected item, leftover
closest/farthest pairs above the member list, and the
PU / corp / thread comparison -- never a placeholder. TEPP is unchanged.
closest/farthest pairs above the member list and on the grouping
comparison strip, and the PU / corp / thread comparison -- never a
placeholder. TEPP is unchanged.

## Phase 6b: Knowledge Graph as a real Ontology + Semantic Layer

Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.d/0.86.0-leftover-comparison-strip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# 0.86.0 — Leftover pairs on the comparison strip

## Added

- Grouping comparison rows carry authorized leftover pairs (ADR 0025).
After `make seed`, the A-100 comparison row names the closest leftover
pair; click opens that post. A leftover pair for a hidden post is
omitted the same way a hidden member is.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ 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.86.0] - 2026-08-17

### Added

- Grouping comparison rows carry authorized leftover pairs (ADR 0025).
After `make seed`, the A-100 comparison row names the closest leftover
pair; click opens that post. A leftover pair for a hidden post is
omitted the same way a hidden member is.

## [0.75.0] - 2026-08-17

### Added
Expand Down
14 changes: 13 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -718,7 +718,19 @@ async def compare_period_groupings(
members = [member for member in row["members"] if _can_see_post(account, member)]
if not members:
continue
visible.append({**row, "members": [], "post_count": len(members)})
leftover_pairs = [
pair
for pair in row.get("leftover_pairs", [])
if _can_see_post(account, pair)
]
visible.append(
{
**row,
"members": [],
"leftover_pairs": leftover_pairs,
"post_count": len(members),
}
)
return {"period_code": period_code, "groupings": visible}


Expand Down
33 changes: 33 additions & 0 deletions backend/app/report_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,26 @@ async def fetch_period_comparison(
members_by_key: dict[tuple[str, str], list[asyncpg.Record]] = defaultdict(list)
for row in members:
members_by_key[(row["grouping_kind"], row["grouping_key"])].append(row)
leftover = await conn.fetch(
"""
select lp.grouping_kind, lp.grouping_key, lp.pair_kind, lp.post_id,
lp.criterion_code, lp.leftover_distance, lp.leftover_residual,
p.post_title, p.visibility_code, p.corporate_entity_id
from report_leftover_pair lp
join source_post p on p.post_id = lp.post_id
where lp.period_code = $1 and lp.rubric_version = $2
and lp.grouping_kind = any($3::text[])
order by lp.grouping_kind, lp.grouping_key,
case lp.pair_kind when 'closest' then 0 else 1 end,
p.post_title
""",
period_code,
RUBRIC_VERSION,
list(GROUPING_KINDS),
)
leftover_by_key: dict[tuple[str, str], list[asyncpg.Record]] = defaultdict(list)
for row in leftover:
leftover_by_key[(row["grouping_kind"], row["grouping_key"])].append(row)
payload: list[dict[str, Any]] = []
for row in rows:
label = await resolve_grouping_label(conn, row["grouping_kind"], row["grouping_key"])
Expand All @@ -750,6 +770,19 @@ async def fetch_period_comparison(
}
for member in members_by_key.get((row["grouping_kind"], row["grouping_key"]), [])
],
"leftover_pairs": [
{
"pair_kind": str(pair["pair_kind"]),
"post_id": str(pair["post_id"]),
"post_title": pair["post_title"],
"criterion_code": str(pair["criterion_code"]),
"leftover_distance": float(pair["leftover_distance"]),
"leftover_residual": float(pair["leftover_residual"]),
"visibility_code": pair["visibility_code"],
"corporate_entity_id": str(pair["corporate_entity_id"]),
}
for pair in leftover_by_key.get((row["grouping_kind"], row["grouping_key"]), [])
],
}
)
return payload
Expand Down
24 changes: 24 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2402,6 +2402,22 @@ def test_seed_period_report_surfaces_on_get_reports(client, demo_analyst_token,
if row["grouping_kind"] == "thread_group"
}
assert threads["A-100"] > threads["B-200"]
leftover_titles = {
pair["post_title"]
for row in compare.json()["groupings"]
for pair in row.get("leftover_pairs", [])
}
assert leftover_titles
assert all(
pair["leftover_distance"] >= 0
for row in compare.json()["groupings"]
for pair in row.get("leftover_pairs", [])
)
assert all(
pair["pair_kind"] in {"closest", "farthest"}
for row in compare.json()["groupings"]
for pair in row.get("leftover_pairs", [])
)


def test_seed_period_report_includes_fixture_event_lineage_posts(
Expand Down Expand Up @@ -2478,6 +2494,14 @@ def test_seed_period_report_includes_fixture_event_lineage_posts(
}
assert thread_counts["A-100"] > 4
assert thread_counts["B-200"] > 4
a100_compare = next(
row
for row in compare.json()["groupings"]
if row["grouping_kind"] == "thread_group" and row["grouping_label"] == "A-100"
)
assert a100_compare.get("leftover_pairs")
assert all(pair["post_title"] for pair in a100_compare["leftover_pairs"])
assert all(pair["leftover_distance"] >= 0 for pair in a100_compare["leftover_pairs"])


def test_seed_period_report_member_click_lands_on_decorated_fixture(
Expand Down
58 changes: 58 additions & 0 deletions docs/adr/0025-leftover-pairs-on-comparison-strip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# ADR 0025 — Leftover pairs on the grouping comparison strip

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

## Context

ADR 0018 puts closest and farthest leftover pairs above each period-report
member list. The home grouping comparison strip already shows mean θ and
post count for every process unit, corporate entity, and thread group on
the shared metric, but it does not name leftover pairs. A buyer comparing
A-100 and B-200 has to switch grouping and scan the report before they
can open the unexpectedly aligned or opposed post–criterion pair.

`GET /api/reports/compare/{period}` already ABAC-filters members. Leftover
pairs must use the same gate. Do not invent a second leftover store or a
theta.

## Decision

Carry authorized leftover pairs on each comparison-strip row.

1. `fetch_period_comparison` reads `report_leftover_pair` for the period
and attaches `leftover_pairs` next to the existing grouping label and
mean θ. The payload includes post title, criterion, leftover-map
distance, and the same visibility columns members already use.
2. The compare endpoint drops leftover pairs the account cannot see,
the same way it drops hidden members. A grouping with no visible
leftover pair still appears when it has visible members.
3. The comparison strip renders leftover pair buttons under the grouping
row. Clicking a pair opens that post. Clicking the grouping row still
switches the Period reports grouping.
4. Missing leftover rows render nothing — never a placeholder pair.

After `make seed`, the A-100 comparison row names its closest leftover
pair above the member list path; click opens that post.

## Consequences

The comparison strip and the report panel share one leftover store
(ADR 0017). Mean θ stays on the strip. Rankings stay on ADR 0024.
Do not mix this into #74 or #92.

## Related

Depends on [ADR 0017](0017-persist-lsirm-leftover-pairs.md) and
[ADR 0018](0018-leftover-pair-report-ui.md).

## References

Gabriel, K. R. (1971). The biplot graphic display of matrices with
application to principal component analysis. *Biometrika, 58*(3),
453–467. https://doi.org/10.1093/biomet/58.3.453

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.86.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
43 changes: 43 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,16 @@ describe("App, authenticated", () => {
mean_theta: 0.81,
post_count: 4,
link_method: "fipc",
leftover_pairs: [
{
pair_kind: "closest",
post_id: "post-1",
post_title: "Public post",
criterion_code: "sales_lead_specificity",
leftover_distance: 0.12,
leftover_residual: 0.4,
},
],
},
{
grouping_kind: "corporate_entity",
Expand All @@ -256,6 +266,16 @@ describe("App, authenticated", () => {
mean_theta: 0.81,
post_count: 4,
link_method: "fipc",
leftover_pairs: [
{
pair_kind: "closest",
post_id: "post-1",
post_title: "Public post",
criterion_code: "sales_lead_specificity",
leftover_distance: 0.12,
leftover_residual: 0.4,
},
],
},
],
}),
Expand Down Expand Up @@ -1391,6 +1411,17 @@ describe("App, authenticated", () => {
expect(screen.getByRole("button", { name: /compare process_unit: demo report high/i })).toHaveTextContent(
"mean θ 0.81",
);
expect(screen.getByLabelText("Leftover pairs for Demo Report High")).toBeInTheDocument();
expect(
screen.getByRole("button", {
name: /open comparison leftover closest pair on demo report high: public post/i,
}),
).toHaveTextContent("Closest leftover: Public post · sales-lead");
expect(
screen.getByRole("button", {
name: /open comparison leftover closest pair on demo report high: public post/i,
}),
).toHaveTextContent("Open this post to read the criterion it sat closest to after main effects.");
await userEvent.click(screen.getByRole("button", { name: /compare thread_group: a-100/i }));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
Expand All @@ -1409,6 +1440,18 @@ describe("App, authenticated", () => {
expect(periodInput).toHaveValue("2026-W03");
});

it("opens a leftover pair post from the comparison strip", async () => {
stubBackend();
render(<App />);

await userEvent.click(
await screen.findByRole("button", {
name: /open comparison leftover closest pair on demo report high: public post/i,
}),
);
await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument());
});

it("opens a leftover pair post from the report panel", async () => {
stubBackend();
render(<App />);
Expand Down
31 changes: 31 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1522,6 +1522,37 @@ function ReportsPanel({
<span className="post-badge">mean θ {row.mean_theta.toFixed(2)}</span>
<span className="post-badge">{row.post_count} posts</span>
</button>
{row.leftover_pairs && row.leftover_pairs.length > 0 && (
<ul className="ticket-list" aria-label={`Leftover pairs for ${row.grouping_label}`}>
{row.leftover_pairs.map((pair) => {
const kindLabel =
pair.pair_kind === "farthest" ? "Farthest leftover" : "Closest leftover";
const nextAction =
pair.pair_kind === "farthest"
? "Open this post to read the criterion it sat farthest from after main effects."
: "Open this post to read the criterion it sat closest to after main effects.";
const criterion = criterionShortLabel(pair.criterion_code);
return (
<li
key={`${row.grouping_kind}:${row.grouping_key}:${pair.pair_kind}:${pair.post_id}:${pair.criterion_code}`}
className="ticket-list-item"
>
<button
className="post-list-item"
aria-label={`Open comparison leftover ${pair.pair_kind} pair on ${row.grouping_label}: ${pair.post_title} · ${criterion}`}
onClick={() => onSelectPost(pair.post_id)}
>
<span className="ticket-title">
{kindLabel}: {pair.post_title} · {criterion}
</span>
<span className="post-badge">{nextAction}</span>
<span className="post-badge">d {pair.leftover_distance.toFixed(2)}</span>
</button>
</li>
);
})}
</ul>
)}
</li>
))}
</ul>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ export interface GroupingComparisonRow {
mean_theta: number;
post_count: number;
link_method: string;
leftover_pairs?: LeftoverPair[];
}

export interface PeriodComparison {
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.86.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.86.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