diff --git a/AGENTS.md b/AGENTS.md index c790995c..5be17dc8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 / 0029) 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. An accepted Rankings hit that is a leftover +pair also names the leftover criterion next to rank. `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 f8a83ceb..fe78cf1a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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. An accepted hit that is also a + leftover pair names that criterion (ADR 0029). See ADR 0024. ## Standards and citations @@ -524,7 +525,8 @@ 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 +closest/farthest pairs above the member list, leftover criterion +badges on matching Rankings hits, 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.88.0-leftover-ranking-hit.md b/CHANGELOG.d/0.88.0-leftover-ranking-hit.md new file mode 100644 index 00000000..506f329d --- /dev/null +++ b/CHANGELOG.d/0.88.0-leftover-ranking-hit.md @@ -0,0 +1,8 @@ +# 0.88.0 — Leftover badge on an accepted Rankings hit + +## Added + +- Accepted Rankings hits that are leftover pairs name the leftover + criterion (ADR 0029). After `make seed` with RankWeave accepted, + the leftover hit reads **Closest leftover · sales-lead** next to + rank; click opens that post. Unavailable Rankings stay fail-closed. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bfcaa28..3780788e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.88.0] - 2026-08-18 + +### Added + +- Accepted Rankings hits that are leftover pairs name the leftover + criterion (ADR 0029). After `make seed` with RankWeave accepted, + the leftover hit reads **Closest leftover · sales-lead** next to + rank; click opens that post. Unavailable Rankings stay fail-closed. + Never invent a fused score or a theta. + ## [0.75.0] - 2026-08-17 ### Added diff --git a/docs/adr/0029-leftover-pair-on-ranking-hit.md b/docs/adr/0029-leftover-pair-on-ranking-hit.md new file mode 100644 index 00000000..0acefe5f --- /dev/null +++ b/docs/adr/0029-leftover-pair-on-ranking-hit.md @@ -0,0 +1,62 @@ +# ADR 0029 — Name leftover on an accepted Rankings hit + +**Decision status:** Accepted +**Date:** 2026-08-18 + +## Context + +ADR 0024 lists accepted RankWeave hits above Calendar. ADR 0018 puts +leftover pairs above the period-report member list. A buyer who starts +from Rankings still sees only title and fused rank. The leftover post +is already in that list when RankWeave accepts it; the hit just does +not name the leftover criterion. + +Do not invent a second leftover store. Do not invent a fused score or +a theta. Unavailable Rankings stay **Rankings · RankWeave not +available**. + +## Decision + +When an authorized leftover pair names an accepted ranking hit, that +button shows `Closest leftover · {criterion}` or +`Farthest leftover · {criterion}` and includes the same caption in +its accessible name. + +A hit that is not a leftover pair stays unmarked. A leftover pair +for a hidden post never reaches Rankings (ADR 0017 ABAC plus ADR +0024 hidden-post omit). RankWeave unavailability still renders no +hits. + +After `make seed` with RankWeave accepted, the leftover ranking hit +reads **Closest leftover · sales-lead** next to rank; click still +opens that post. + +Leftover evidence is the same authorized `leftover_pairs` already +on the period-report payload. A report fetch error clears the list +— never an invented pair. + +## Consequences + +Leftover buttons above the member list stay (ADR 0018). Member +badges stay on #234 / ADR 0028. Comparison-strip leftover is #233. +Opened-post leftover copy is #224. This slice only labels the +already-visible ranking hit. + +## Related + +Depends on [ADR 0017](0017-persist-lsirm-leftover-pairs.md), +[ADR 0018](0018-leftover-pair-report-ui.md), and +[ADR 0024](0024-rankweave-fusion-fail-closed.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 + +Cormack, G. V., Clarke, C. L. A., & Buettcher, S. (2009). Reciprocal +rank fusion outperforms condorcet and individual rank learning +methods. In *Proceedings of the 32nd international ACM SIGIR +conference on Research and development in information retrieval* +(pp. 758–759). ACM. https://doi.org/10.1145/1571941.1572114 diff --git a/frontend/package.json b/frontend/package.json index 575b7c58..a9cf709c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.75.0", + "version": "0.88.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index a32a2640..d481d580 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1289,17 +1289,31 @@ describe("App, authenticated", () => { post_title: "Pricing renegotiation: revised quote sent", fused_rank: 2, }, + { + post_id: "post-9", + post_title: "Riverbend calendar commitment", + fused_rank: 3, + }, ], }, }); render(); const rankingButton = await screen.findByRole("button", { - name: /open ranking: public post/i, + name: /open ranking: public post \(closest leftover · sales-lead\)/i, }); expect(rankingButton).toHaveTextContent("Public post"); expect(rankingButton).toHaveTextContent("Rankings · rankweave"); expect(rankingButton).toHaveTextContent("rank 1"); + expect(rankingButton).toHaveTextContent("Closest leftover · sales-lead"); + expect(rankingButton).toHaveAccessibleName( + "Open ranking: Public post (Closest leftover · sales-lead)", + ); + const unmarked = await screen.findByRole("button", { + name: /^open ranking: riverbend calendar commitment$/i, + }); + expect(unmarked).toHaveTextContent("rank 3"); + expect(unmarked).not.toHaveTextContent("leftover"); expect(screen.queryByRole("button", { name: /open ranking: private parent/i })).not.toBeInTheDocument(); await userEvent.click(rankingButton); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6056e5eb..f6d94898 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -52,6 +52,7 @@ import { type PostSummary, type RankingList, type RelatedNode, + type LeftoverPair, type VocEvidence, } from "./api"; import { LineageDag } from "./LineageDag"; @@ -82,6 +83,16 @@ function criterionShortLabel(itemCode: string): string { return CRITERION_SHORT_LABEL[itemCode] ?? itemCode; } +function leftoverRowLabel(pairKind: string): string { + return pairKind === "farthest" ? "Farthest leftover" : "Closest leftover"; +} + +function leftoverCaptionForPairs(pairs: LeftoverPair[]): string { + return pairs + .map((pair) => `${leftoverRowLabel(pair.pair_kind)} · ${criterionShortLabel(pair.criterion_code)}`) + .join("; "); +} + // 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 @@ -1315,9 +1326,11 @@ function PostDetailPopup({ function RankingsPanel({ accessToken, + leftoverPairs, onSelectPost, }: { accessToken: string; + leftoverPairs: LeftoverPair[]; onSelectPost: (postId: string) => void; }) { const [ranking, setRanking] = useState(null); @@ -1352,19 +1365,35 @@ function RankingsPanel({ )} {ranking && ranking.rankings.length > 0 && ( )} @@ -1425,10 +1454,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"); @@ -1455,9 +1486,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); @@ -1472,8 +1507,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); } @@ -1658,6 +1695,7 @@ function PostList({ accessToken }: { accessToken: string }) { const [graph, setGraph] = useState(null); const [error, setError] = useState(null); const [selectedPostId, setSelectedPostId] = useState(null); + const [leftoverPairs, setLeftoverPairs] = useState([]); const [canRebuild, setCanRebuild] = useState(false); const [rebuilding, setRebuilding] = useState(false); const [rebuildError, setRebuildError] = useState(null); @@ -1689,9 +1727,18 @@ function PostList({ accessToken }: { accessToken: string }) { return ( <> - + - +

Event Lineage

diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 1710c009..736bc3f5 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -35,4 +35,4 @@ "sentence_excerpts", ] -__version__ = "0.75.0" +__version__ = "0.88.0" diff --git a/pyproject.toml b/pyproject.toml index 764ebad7..af141fcd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.75.0" +version = "0.88.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 08eab776..3f145669 100644 --- a/uv.lock +++ b/uv.lock @@ -355,7 +355,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.75.0" +version = "0.88.0" source = { virtual = "." } dependencies = [ { name = "certifi" },