diff --git a/AGENTS.md b/AGENTS.md index c790995c1..1560ff2e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f8a83ceb1..335af6b81 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -516,7 +516,9 @@ 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 @@ -524,8 +526,9 @@ 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 diff --git a/CHANGELOG.d/0.86.0-leftover-comparison-strip.md b/CHANGELOG.d/0.86.0-leftover-comparison-strip.md new file mode 100644 index 000000000..46b70b503 --- /dev/null +++ b/CHANGELOG.d/0.86.0-leftover-comparison-strip.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bfcaa28f..e2d4deb22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 27f679119..6c37c2c06 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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} diff --git a/backend/app/report_ingestion.py b/backend/app/report_ingestion.py index eff621d03..4c8f5cfa0 100644 --- a/backend/app/report_ingestion.py +++ b/backend/app/report_ingestion.py @@ -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"]) @@ -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 diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 1db483ee0..8e6f9b62a 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -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( @@ -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( diff --git a/docs/adr/0025-leftover-pairs-on-comparison-strip.md b/docs/adr/0025-leftover-pairs-on-comparison-strip.md new file mode 100644 index 000000000..4f42681c8 --- /dev/null +++ b/docs/adr/0025-leftover-pairs-on-comparison-strip.md @@ -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 diff --git a/frontend/package.json b/frontend/package.json index 575b7c586..dac241738 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.75.0", + "version": "0.86.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index a32a26403..b7d33ca95 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -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", @@ -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, + }, + ], }, ], }), @@ -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( @@ -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(); + + 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(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6056e5eb4..131aea900 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1522,6 +1522,37 @@ function ReportsPanel({ mean θ {row.mean_theta.toFixed(2)} {row.post_count} posts + {row.leftover_pairs && row.leftover_pairs.length > 0 && ( + + )} ))} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index e6dfcbad2..2a3c74e28 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -394,6 +394,7 @@ export interface GroupingComparisonRow { mean_theta: number; post_count: number; link_method: string; + leftover_pairs?: LeftoverPair[]; } export interface PeriodComparison { diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 1710c009e..55efae4e2 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -35,4 +35,4 @@ "sentence_excerpts", ] -__version__ = "0.75.0" +__version__ = "0.86.0" diff --git a/pyproject.toml b/pyproject.toml index 764ebad72..7b18a4515 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/uv.lock b/uv.lock index 08eab7768..977de5086 100644 --- a/uv.lock +++ b/uv.lock @@ -355,7 +355,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.75.0" +version = "0.86.0" source = { virtual = "." } dependencies = [ { name = "certifi" },