diff --git a/AGENTS.md b/AGENTS.md index 5ed21a371..bf3c7e98b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,8 @@ reimplementing them: multi-channel score fusion (`weighted_convex_fuse` in `reconstruct.py`) and the buyer-facing Rankings port (`rankweave_client.py`) -- never invent a fused score or a theta. + Rankings disclose Cormack RRF channel contributions from owned + rank lists (ADR 0167). - [TEPP](https://github.com/ContextualWisdomLab/TEPP)'s published wire contract for calibrated measurement (`tepp_client.py`) -- never reimplement TEPP's model here. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 44664df5d..fe6a94941 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -122,7 +122,10 @@ 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. Accepted hits include + `channel_evidence` computed from owned temporal/lexical ranks + (Cormack weighted RRF contribution); RankWeave extra fields are + ignored and no theta is invented. See ADR 0024 and ADR 0167. ## Standards and citations diff --git a/CHANGELOG.d/2.12.17-rankweave-ranking-channel-evidence.md b/CHANGELOG.d/2.12.17-rankweave-ranking-channel-evidence.md new file mode 100644 index 000000000..13231f708 --- /dev/null +++ b/CHANGELOG.d/2.12.17-rankweave-ranking-channel-evidence.md @@ -0,0 +1,11 @@ +# 2.12.17 — RankWeave ranking channel evidence + +Rankings now shows why a fused hit landed. + +- `GET /api/rankings` attaches owned-channel evidence: 1-based + newest-first and title-overlap ranks plus Cormack RRF contribution + `weight / (η + rank)` with η = 60. +- A channel the post is missing from is omitted. RankWeave extra + fields are ignored. No fused score or theta is invented. +- The Rankings list discloses that evidence under each hit. Copy + states this is not a calibrated score. Click still opens the post. diff --git a/CHANGELOG.md b/CHANGELOG.md index e077e6387..2f94a2c23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,15 @@ All notable changes to this project are documented here. Format follows environment, so local OIDC and synthetic-data workflows resolve the same pinned dependencies as CI. +## [2.12.17] - 2026-08-24 + +### Added + +- Rankings now names the newest-first and title-overlap ranks that + RankWeave fused, with each channel's Cormack RRF contribution. + Missing channels stay omitted. This is ranking evidence, not a + calibrated score. Click still opens that post. + ## [2.12.16] - 2026-08-24 ### Added diff --git a/backend/app/main.py b/backend/app/main.py index 207f69a4b..b8c569ec2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3123,11 +3123,12 @@ async def read_rankings( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: - """RankWeave fusion of ABAC-visible posts (ADR 0024). + """RankWeave fusion of ABAC-visible posts (ADR 0024 / ADR 0167). Hidden posts are omitted from every channel. Never invents a fused - score or a theta. Fail-closed when RankWeave is disabled or the - library is missing. + score or a theta. Channel evidence is computed from owned rank + lists. Fail-closed when RankWeave is disabled or the library is + missing. """ _require_post_read(account) async with pool.acquire() as conn: diff --git a/docs/adr/0024-rankweave-fusion-fail-closed.md b/docs/adr/0024-rankweave-fusion-fail-closed.md index af1de902e..c03f73b45 100644 --- a/docs/adr/0024-rankweave-fusion-fail-closed.md +++ b/docs/adr/0024-rankweave-fusion-fail-closed.md @@ -35,6 +35,11 @@ tables, and does not bind the demo IdP to production Keyverse. 4. After login, Rankings sits above Calendar. Unavailable copy is **Rankings · RankWeave not available**. An accepted hit lists the title; click opens that `source_post`. +5. Accepted hits also disclose owned-channel evidence (ADR 0167): + 1-based `channel_rank` and Cormack contribution + `weight / (η + rank)` for each channel the post actually appears + in. Missing channels are omitted. RankWeave extra fields are + ignored. Copy states this is not a calibrated score. ## Consequences diff --git a/docs/adr/0167-rankweave-ranking-channel-evidence.md b/docs/adr/0167-rankweave-ranking-channel-evidence.md new file mode 100644 index 000000000..d7fe2e30b --- /dev/null +++ b/docs/adr/0167-rankweave-ranking-channel-evidence.md @@ -0,0 +1,67 @@ +# ADR 0167 — Disclose RankWeave ranking channel evidence + +**Decision status:** Accepted +**Date:** 2026-08-23 + +## Context + +ADR 0024 already fuses ABAC-visible posts through RankWeave weighted +reciprocal-rank fusion (Cormack et al., 2009, η = 60; Samuel et al., +2025, unequal weights). `GET /api/rankings` returned only `post_id`, +`post_title`, and 1-based `fused_rank`. A reader could open the hit +but could not see which owned channel ranked it, or how much that +rank contributed. + +Event Lineage channel evidence (ADR 0124 on #387) explains +reconstructed parent→child edges from persisted convex-fusion +scores. Rankings is a different surface: two rank-only channels +(`temporal`, `lexical`) fused in-process at GET time. There is no +persisted ranking table and no TEPP theta. RankWeave extra fields +must not be trusted; a missing channel stays missing. + +This ADR does not replace reconstruction fusion, leftover pairs, +TEPP receipts, Allen interval labels, or Event Lineage isolation +reasons. + +## Decision + +1. Compute ranking channel evidence from LineageWeave-owned rank + lists, never from RankWeave payload extras. For each fused hit + and each channel with a positive weight, take the 1-based rank + of that `post_id` in the ordered id list. Skip a channel the + post is absent from. Do not invent a rank. +2. Contribution is Cormack weighted RRF: + `weight / (η + rank)` with η = 60. Sort by contribution + descending, then `signal_code`. `rank` on the evidence row is + that 1-based evidence order. `channel_rank` is the 1-based + position in that channel. +3. Labels: `temporal` = **Newest first**, `lexical` = **Title + overlap**. The payload never includes a fused score or a theta. +4. Rankings lists the evidence under each accepted hit as an + accessible sibling list, not hover-only. Copy states that + RankWeave fused newest-first and title-overlap ranks, and that + this is not a calibrated score. Click still opens that post. +5. Unavailable RankWeave stays empty (`rankweave_not_available`). + Hidden posts remain omitted from every channel. + +## Consequences + +Buyers can see why a Rankings hit landed without treating RRF as +measurement. A later channel or weight change recomputes evidence +on the next GET; nothing is persisted. Event Lineage evidence +remains the reconstruct/convex path. + +## References + +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 + +Samuel, D., MacAvaney, S., Yates, A., Zhang, E., Zhang, S., +Macdonald, C., & Ounis, I. (2025). *Weighted reciprocal rank fusion +for multi-channel retrieval* [Preprint]. + +ADR 0024 (RankWeave fusion fail-closed) +ADR 0124 (Event Lineage channel evidence; separate surface) diff --git a/docs/adr/README.md b/docs/adr/README.md index 85ea44129..9890fa083 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -10,7 +10,7 @@ decision from them. | Supporting document | Normative ADR | |---|---| | [`product-technical-gap-baseline.md`](../product-technical-gap-baseline.md) | Product/technical traceability projection across the ADR set; ADRs remain normative | -| [`lineage-bi-research-notes.md`](../lineage-bi-research-notes.md) | [0084](0084-lineage-research-grounding.md), [0062](0062-semantic-unit-embedding.md), [0064](0064-lineage-evidence-and-tree-assembly.md) | +| [`lineage-bi-research-notes.md`](../lineage-bi-research-notes.md) | [0084](0084-lineage-research-grounding.md), [0062](0062-semantic-unit-embedding.md), [0064](0064-lineage-evidence-and-tree-assembly.md), [0024](0024-rankweave-fusion-fail-closed.md), [0167](0167-rankweave-ranking-channel-evidence.md) | | [`PROV_O_IMPLEMENTATION.md`](../PROV_O_IMPLEMENTATION.md) | [0065](0065-prov-o-provenance-boundary.md) | | [`PROV_O_IMPLEMENTATION_MATRIX.md`](../PROV_O_IMPLEMENTATION_MATRIX.md) | [0065](0065-prov-o-provenance-boundary.md) | | [`ONTOLOGY_NAMESPACE_INVENTORY.md`](../doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md) | [0157](0157-public-ontology-namespace-identity.md) | diff --git a/frontend/package.json b/frontend/package.json index 02f50d38d..46a418cc9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.12.16", + "version": "2.12.17", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index c72aab078..9c0fce272 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -706,6 +706,27 @@ gap: 0.5rem; } +.ranking-hit { + flex-direction: column; + align-items: stretch; + justify-content: flex-start; +} + +.ranking-channel-evidence-copy { + margin: 0 0 0.75rem; +} + +.ranking-channel-evidence { + list-style: none; + margin: 0; + padding: 0 0 0 0.5rem; + font-size: 0.85rem; +} + +.ranking-channel-evidence li { + margin: 0.15rem 0; +} + .ticket-title { flex: 1; } diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 9ff79a27e..9488a014a 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -78,6 +78,14 @@ describe("App, authenticated", () => { post_id: string; post_title: string; fused_rank: number; + channel_evidence?: { + signal_code: string; + signal_label: string; + channel_rank: number; + weight: number; + contribution: number; + rank: number; + }[]; }[]; }; chatUnavailable?: boolean; @@ -2592,11 +2600,47 @@ describe("App, authenticated", () => { post_id: "post-1", post_title: "Public post", fused_rank: 1, + channel_evidence: [ + { + signal_code: "lexical", + signal_label: "Title overlap", + channel_rank: 2, + weight: 0.75, + contribution: 0.75 / 62, + rank: 1, + }, + { + signal_code: "temporal", + signal_label: "Newest first", + channel_rank: 2, + weight: 0.25, + contribution: 0.25 / 62, + rank: 2, + }, + ], }, { post_id: "post-2", post_title: "Pricing renegotiation: revised quote sent", fused_rank: 2, + channel_evidence: [ + { + signal_code: "lexical", + signal_label: "Title overlap", + channel_rank: 1, + weight: 0.75, + contribution: 0.75 / 61, + rank: 1, + }, + { + signal_code: "temporal", + signal_label: "Newest first", + channel_rank: 1, + weight: 0.25, + contribution: 0.25 / 61, + rank: 2, + }, + ], }, ], }, @@ -2609,6 +2653,17 @@ describe("App, authenticated", () => { expect(rankingButton).toHaveTextContent("Public post"); expect(rankingButton).toHaveTextContent("Rankings · rankweave"); expect(rankingButton).toHaveTextContent("rank 1"); + expect( + screen.getByText( + "RankWeave fused newest-first and title-overlap ranks. This is not a calibrated score.", + ), + ).toBeInTheDocument(); + expect( + screen.getByRole("list", { name: "Ranking evidence for Public post" }), + ).toHaveTextContent("Title overlap rank 2, contribution 0.012097"); + expect( + screen.getByRole("list", { name: "Ranking evidence for Public post" }), + ).toHaveTextContent("Newest first rank 2, contribution 0.004032"); 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 39446abf8..3053837d5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3058,6 +3058,10 @@ function AnalysisRunsPanel({ ); } +function formatRankingContribution(value: number): string { + return value.toFixed(6); +} + function RankingsPanel({ accessToken, onSelectPost, @@ -3076,9 +3080,9 @@ function RankingsPanel({ }, [accessToken]); return ( -
+
-

Rankings

+

{t("Rankings")}

{ranking && ( {ranking.status === "accepted" @@ -3088,30 +3092,53 @@ function RankingsPanel({ )}
{error &&

{error}

} - {ranking === null && !error &&

Loading rankings...

} + {ranking === null && !error &&

{t("Loading rankings...")}

} {ranking && ranking.status === "unavailable" && ( -

Rankings · RankWeave not available

+

{t("Rankings · RankWeave not available")}

)} {ranking && ranking.status === "accepted" && ranking.rankings.length === 0 && ( -

No fused rankings from RankWeave.

+

{t("No fused rankings from RankWeave.")}

)} {ranking && ranking.rankings.length > 0 && ( - - )} + <> +

+ {t( + "RankWeave fused newest-first and title-overlap ranks. This is not a calibrated score.", + )} +

+ + + )}
); } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 485f3ac04..8a2be84af 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1037,10 +1037,20 @@ export function startAnalysisRun( }); } +export interface RankingChannelEvidence { + signal_code: string; + signal_label: string; + channel_rank: number; + weight: number; + contribution: number; + rank: number; +} + export interface RankedPost { post_id: string; post_title: string; fused_rank: number; + channel_evidence?: RankingChannelEvidence[]; } export interface RankingList { diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index 553f3f148..608f95858 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -48,6 +48,9 @@ describe("i18n", () => { "Read leftover map rank {rank}, observed Y {observed}, and expected E {expected} after IRT main effects, then open this post.", "Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y {observed} and expected E {expected}, then open this post.", "Showing the first {shown} of {total} posts known at this cutoff.", + "Rankings", + "Title overlap", + "RankWeave fused newest-first and title-overlap ranks. This is not a calibrated score.", ] as const; it("supports the five product locales", () => { diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index f448aee62..880a4b98a 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -36,6 +36,20 @@ const TRANSLATIONS: Partial>> = { "Log in": "로그인", "Log out": "로그아웃", Calendar: "캘린더", + Rankings: "순위", + "Rankings · RankWeave not available": "순위 · RankWeave를 사용할 수 없음", + "Rankings · rankweave": "순위 · rankweave", + "Loading rankings...": "순위를 불러오는 중...", + "No fused rankings from RankWeave.": "RankWeave가 융합한 순위가 없습니다.", + "Fused rankings": "융합 순위", + "Open ranking: {title}": "순위 열기: {title}", + "rank {rank}": "순위 {rank}", + "Title overlap": "제목 겹침", + "RankWeave fused newest-first and title-overlap ranks. This is not a calibrated score.": + "RankWeave가 최신순과 제목 겹침 순위를 융합했습니다. 보정된 점수가 아닙니다.", + "Ranking evidence for {title}": "{title}의 순위 근거", + "{label} rank {rank}, contribution {contribution}": + "{label} 순위 {rank}, 기여 {contribution}", "CalDAV events": "CalDAV 이벤트", "No CalDAV events are available.": "사용할 수 있는 CalDAV 이벤트가 없습니다.", "CalDAV is not connected.": "CalDAV가 연결되지 않았습니다.", @@ -396,6 +410,20 @@ const TRANSLATIONS: Partial>> = { "Log in": "登录", "Log out": "退出登录", Calendar: "日历", + Rankings: "排名", + "Rankings · RankWeave not available": "排名 · RankWeave 不可用", + "Rankings · rankweave": "排名 · rankweave", + "Loading rankings...": "正在加载排名...", + "No fused rankings from RankWeave.": "RankWeave 未返回融合排名。", + "Fused rankings": "融合排名", + "Open ranking: {title}": "打开排名:{title}", + "rank {rank}": "排名 {rank}", + "Title overlap": "标题重叠", + "RankWeave fused newest-first and title-overlap ranks. This is not a calibrated score.": + "RankWeave 融合了最新优先和标题重叠排名。这不是校准分数。", + "Ranking evidence for {title}": "{title} 的排名证据", + "{label} rank {rank}, contribution {contribution}": + "{label} 排名 {rank},贡献 {contribution}", "CalDAV events": "CalDAV 事件", "No CalDAV events are available.": "没有可用的 CalDAV 事件。", "CalDAV is not connected.": "CalDAV 尚未连接。", @@ -779,6 +807,20 @@ const TRANSLATIONS: Partial>> = { "Log in": "ログイン", "Log out": "ログアウト", Calendar: "カレンダー", + Rankings: "ランキング", + "Rankings · RankWeave not available": "ランキング · RankWeave を利用できません", + "Rankings · rankweave": "ランキング · rankweave", + "Loading rankings...": "ランキングを読み込み中...", + "No fused rankings from RankWeave.": "RankWeave の融合ランキングはありません。", + "Fused rankings": "融合ランキング", + "Open ranking: {title}": "ランキングを開く: {title}", + "rank {rank}": "順位 {rank}", + "Title overlap": "タイトル一致", + "RankWeave fused newest-first and title-overlap ranks. This is not a calibrated score.": + "RankWeave が新しい順とタイトル一致の順位を融合しました。校正されたスコアではありません。", + "Ranking evidence for {title}": "{title} の順位根拠", + "{label} rank {rank}, contribution {contribution}": + "{label} 順位 {rank}、寄与 {contribution}", "CalDAV events": "CalDAV イベント", "No CalDAV events are available.": "利用できる CalDAV イベントはありません。", "CalDAV is not connected.": "CalDAV が接続されていません。", @@ -1138,6 +1180,20 @@ const TRANSLATIONS: Partial>> = { "Log in": "Đăng nhập", "Log out": "Đăng xuất", Calendar: "Lịch", + Rankings: "Xếp hạng", + "Rankings · RankWeave not available": "Xếp hạng · RankWeave không khả dụng", + "Rankings · rankweave": "Xếp hạng · rankweave", + "Loading rankings...": "Đang tải xếp hạng...", + "No fused rankings from RankWeave.": "Không có xếp hạng hợp nhất từ RankWeave.", + "Fused rankings": "Xếp hạng hợp nhất", + "Open ranking: {title}": "Mở xếp hạng: {title}", + "rank {rank}": "hạng {rank}", + "Title overlap": "Trùng tiêu đề", + "RankWeave fused newest-first and title-overlap ranks. This is not a calibrated score.": + "RankWeave đã hợp nhất hạng mới nhất trước và trùng tiêu đề. Đây không phải điểm đã hiệu chỉnh.", + "Ranking evidence for {title}": "Bằng chứng xếp hạng cho {title}", + "{label} rank {rank}, contribution {contribution}": + "{label} hạng {rank}, đóng góp {contribution}", "CalDAV events": "Sự kiện CalDAV", "No CalDAV events are available.": "Không có sự kiện CalDAV nào khả dụng.", "CalDAV is not connected.": "CalDAV chưa được kết nối.", diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 95330cb50..72e8bbcab 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "2.12.6" +__version__ = "2.12.17" diff --git a/lineageweave/rankweave_client.py b/lineageweave/rankweave_client.py index eb0b3358b..cc5902b66 100644 --- a/lineageweave/rankweave_client.py +++ b/lineageweave/rankweave_client.py @@ -12,6 +12,10 @@ :class:`lineageweave.threadweave_client.ThreadWeaveNotAvailable`. Wiring the in-process library is additive (``LibraryRankWeaveTransport``), not a redesign. + +Rankings channel evidence is computed from LineageWeave-owned rank +lists (ADR 0167). The transport is trusted only for fused item order. +A missing channel is omitted, never fabricated. """ from __future__ import annotations @@ -27,6 +31,10 @@ # Seeded A-100 titles mention pricing, quote, and delivery. This is a # synthetic demo query, not a customer string. DEFAULT_RANKING_QUERY = "pricing quote delivery" +RANKING_SIGNAL_LABELS = { + "temporal": "Newest first", + "lexical": "Title overlap", +} class RankWeaveNotAvailable(RuntimeError): @@ -115,6 +123,70 @@ def ranking_channels_from_rows( } +def ranking_channel_evidence( + post_id: str, + channels: Mapping[str, Sequence[str]], + weights: Mapping[str, float], + eta: int = DEFAULT_RANK_CONSTANT_ETA, +) -> tuple["RankingChannelEvidence", ...]: + """Explain one fused hit from owned channel ranks. + + Contribution is Cormack et al. (2009) weighted RRF: + ``weight / (η + rank)`` with 1-based rank. A channel the post is + missing from, or a non-positive weight, is omitted. RankWeave extra + fields are ignored so a missing signal cannot be invented. + """ + collected: list[tuple[str, int, float, float]] = [] + for signal_code, ordered_ids in channels.items(): + weight = float(weights.get(signal_code) or 0.0) + if weight <= 0: + continue + try: + channel_rank = [str(item_id) for item_id in ordered_ids].index(post_id) + 1 + except ValueError: + continue + contribution = weight / (eta + channel_rank) + collected.append((signal_code, channel_rank, weight, contribution)) + collected.sort(key=lambda item: (-item[3], item[0])) + return tuple( + RankingChannelEvidence( + signal_code=signal_code, + signal_label=RANKING_SIGNAL_LABELS.get(signal_code, signal_code), + channel_rank=channel_rank, + weight=weight, + contribution=contribution, + rank=index, + ) + for index, (signal_code, channel_rank, weight, contribution) in enumerate( + collected, start=1 + ) + ) + + +@dataclass(frozen=True) +class RankingChannelEvidence: + """One owned-channel contribution to a fused ranking hit.""" + + signal_code: str + signal_label: str + channel_rank: int + weight: float + contribution: float + rank: int + + def to_json(self) -> dict[str, Any]: + """Return the reader-safe owned-channel evidence payload.""" + + return { + "signal_code": self.signal_code, + "signal_label": self.signal_label, + "channel_rank": self.channel_rank, + "weight": self.weight, + "contribution": self.contribution, + "rank": self.rank, + } + + @dataclass(frozen=True) class RankedPost: """One visible fused hit. Rank is 1-based position, never a theta.""" @@ -122,12 +194,14 @@ class RankedPost: post_id: str post_title: str fused_rank: int + channel_evidence: tuple[RankingChannelEvidence, ...] = () def to_json(self) -> dict[str, Any]: return { "post_id": self.post_id, "post_title": self.post_title, "fused_rank": self.fused_rank, + "channel_evidence": [item.to_json() for item in self.channel_evidence], } @@ -155,14 +229,23 @@ def _item_id_from_hit(hit: object) -> str: def project_ranking_list( raw: object, titles_by_id: Mapping[str, str], + channels: Mapping[str, Sequence[str]] | None = None, + weights: Mapping[str, float] | None = None, ) -> RankingList: - """Accept transport output. Unknown shapes fail closed. Hidden ids drop.""" + """Accept transport output. Unknown shapes fail closed. Hidden ids drop. + + Channel evidence is attached from ``channels`` LineageWeave already + owns. Transport extra fields are ignored so RankWeave cannot invent + a missing signal. + """ if not isinstance(raw, list): raise RankWeaveNotAvailable( "rankweave_not_available: ranking envelope is not a hit list" ) items: list[RankedPost] = [] seen: set[str] = set() + owned_channels = channels or {} + owned_weights = weights or DEFAULT_CHANNEL_WEIGHTS for hit in raw: post_id = _item_id_from_hit(hit) title = str(titles_by_id.get(post_id) or "").strip() @@ -174,6 +257,9 @@ def project_ranking_list( post_id=post_id, post_title=title, fused_rank=len(items) + 1, + channel_evidence=ranking_channel_evidence( + post_id, owned_channels, owned_weights + ), ) ) return RankingList(items=tuple(items)) @@ -262,15 +348,18 @@ def fuse_rankings( titles_by_id: Mapping[str, str], weights: dict[str, float] | None = None, ) -> RankingList: + active_weights = weights or DEFAULT_CHANNEL_WEIGHTS try: - raw = self._transport(channels, weights or DEFAULT_CHANNEL_WEIGHTS) + raw = self._transport(channels, active_weights) except RankWeaveNotAvailable: raise except Exception as exc: raise RankWeaveNotAvailable( f"rankweave_not_available: ranking transport failed ({exc})" ) from exc - return project_ranking_list(raw, titles_by_id) + return project_ranking_list( + raw, titles_by_id, channels=channels, weights=active_weights + ) def as_api_payload( self, diff --git a/pyproject.toml b/pyproject.toml index 0f3f0dc79..06e27ac93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.12.16" +version = "2.12.17" 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/tests/test_rankweave_client.py b/tests/test_rankweave_client.py index 64c5e70b1..c6f23af01 100644 --- a/tests/test_rankweave_client.py +++ b/tests/test_rankweave_client.py @@ -2,21 +2,25 @@ RankWeave is an in-process weighted-RRF library. LineageWeave fuses only visible posts. A hidden post is omitted from every channel. The -client never invents a fused score or a theta. +client never invents a fused score or a theta. Channel evidence is +computed from owned rank lists (Cormack 2009), not RankWeave extras. """ from __future__ import annotations +import json from types import SimpleNamespace import pytest from lineageweave.rankweave_client import ( + DEFAULT_CHANNEL_WEIGHTS, LibraryRankWeaveTransport, RankWeaveClient, RankWeaveNotAvailable, build_rankweave_client, project_ranking_list, + ranking_channel_evidence, ranking_channels_from_rows, ) @@ -38,6 +42,29 @@ } +def _lexical_then_temporal(post_id: str, channel_rank: int) -> list[dict[str, object]]: + lexical = 0.75 / (60 + channel_rank) + temporal = 0.25 / (60 + channel_rank) + return [ + { + "signal_code": "lexical", + "signal_label": "Title overlap", + "channel_rank": channel_rank, + "weight": 0.75, + "contribution": lexical, + "rank": 1, + }, + { + "signal_code": "temporal", + "signal_label": "Newest first", + "channel_rank": channel_rank, + "weight": 0.25, + "contribution": temporal, + "rank": 2, + }, + ] + + def test_default_transport_fails_closed() -> None: client = RankWeaveClient() with pytest.raises(RankWeaveNotAvailable, match="rankweave_not_available"): @@ -59,6 +86,7 @@ def test_default_payload_never_invents_a_fused_score() -> None: "status_reason": "rankweave_not_available", "rankings": [], } + assert "theta" not in json.dumps(payload) def test_disabled_factory_fails_closed() -> None: @@ -125,13 +153,18 @@ def fake_transport( "post_id": "post-2", "post_title": "Pricing renegotiation: revised quote sent", "fused_rank": 1, + "channel_evidence": _lexical_then_temporal("post-2", 1), }, { "post_id": "post-1", "post_title": "Public post", "fused_rank": 2, + "channel_evidence": _lexical_then_temporal("post-1", 2), }, ] + serialized = json.dumps(payload) + assert "theta" not in serialized + assert "fused_score" not in serialized def test_library_transport_projects_monkeypatched_rrf( @@ -152,7 +185,7 @@ def weighted_reciprocal_rank_fuse( captured["limit"] = limit captured["eta"] = rank_constant_eta return [ - SimpleNamespace(item_id="post-2"), + SimpleNamespace(item_id="post-2", fused_score=0.99, theta=1.2), SimpleNamespace(item_id="post-1"), ] @@ -170,6 +203,12 @@ def weighted_reciprocal_rank_fuse( "Pricing renegotiation: revised quote sent" ) assert payload["rankings"][0]["fused_rank"] == 1 + assert payload["rankings"][0]["channel_evidence"] == _lexical_then_temporal( + "post-2", 1 + ) + serialized = json.dumps(payload) + assert "theta" not in serialized + assert "fused_score" not in serialized def test_hidden_post_is_omitted_from_every_channel() -> None: @@ -206,3 +245,71 @@ def test_unknown_hit_id_is_dropped_not_repaired() -> None: assert [item.post_id for item in ranking.items] == ["post-2"] assert all(item.post_id != "invented" for item in ranking.items) assert ranking.items[0].fused_rank == 1 + assert ranking.items[0].channel_evidence == () + + +def test_ranking_channel_evidence_uses_cormack_weighted_rrf() -> None: + evidence = ranking_channel_evidence( + "post-1", + {"temporal": ["post-1"], "lexical": ["post-1"]}, + DEFAULT_CHANNEL_WEIGHTS, + eta=60, + ) + by_code = {item.signal_code: item for item in evidence} + assert by_code["lexical"].contribution == 0.75 / 61 + assert by_code["temporal"].contribution == 0.25 / 61 + assert by_code["lexical"].channel_rank == 1 + assert by_code["temporal"].channel_rank == 1 + assert by_code["lexical"].rank == 1 + assert by_code["temporal"].rank == 2 + assert [item.signal_code for item in evidence] == ["lexical", "temporal"] + + +def test_ranking_channel_evidence_skips_missing_and_zero_weight_channels() -> None: + evidence = ranking_channel_evidence( + "post-1", + {"temporal": ["post-1"], "lexical": ["post-2"], "unused": ["post-1"]}, + {"temporal": 0.25, "lexical": 0.75, "unused": 0.0}, + ) + assert [item.signal_code for item in evidence] == ["temporal"] + assert evidence[0].contribution == 0.25 / 61 + assert evidence[0].channel_rank == 1 + + +def test_ranking_channel_evidence_tie_breaks_by_signal_code() -> None: + evidence = ranking_channel_evidence( + "post-1", + {"temporal": ["post-1"], "lexical": ["post-1"]}, + {"temporal": 0.5, "lexical": 0.5}, + ) + assert [item.signal_code for item in evidence] == ["lexical", "temporal"] + assert evidence[0].contribution == evidence[1].contribution == 0.5 / 61 + + +def test_project_ranking_list_ignores_transport_extra_fields() -> None: + ranking = project_ranking_list( + [ + { + "item_id": "post-1", + "theta": 1.7, + "channel_evidence": [{"signal_code": "invented", "rank": 1}], + } + ], + {"post-1": "Public post"}, + channels={"temporal": ["post-1"], "lexical": ["post-2"]}, + weights=DEFAULT_CHANNEL_WEIGHTS, + ) + payload = ranking.to_json() + assert payload[0]["channel_evidence"] == [ + { + "signal_code": "temporal", + "signal_label": "Newest first", + "channel_rank": 1, + "weight": 0.25, + "contribution": 0.25 / 61, + "rank": 1, + } + ] + serialized = json.dumps(payload) + assert "theta" not in serialized + assert "invented" not in serialized diff --git a/uv.lock b/uv.lock index cf5db11aa..651a179f7 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.12.16" +version = "2.12.17" source = { editable = "." } dependencies = [ { name = "certifi" },