Skip to content
Merged
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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ reimplementing them:
- [ThreadWeave](https://github.com/ContextualWisdomLab/ThreadWeave) for
tree assembly (`reconstruct.py`'s `_walk`/`thread_messages` calls).
- [RankWeave](https://github.com/ContextualWisdomLab/RankWeave) for
multi-channel score fusion (`weighted_convex_fuse`).
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.
- [TEPP](https://github.com/ContextualWisdomLab/TEPP)'s published wire
contract for calibrated measurement (`tepp_client.py`) -- never
reimplement TEPP's model here.
Expand Down
6 changes: 6 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ flowchart LR
| `adjudication_client.py` | Pluggable LLM-judgment channel (`Null` default, `ContextualOrchestrator` real impl) |
| `image_content.py` | Pluggable vision channel: OCR + object recognition/tagging for embedded images (`Null` default, `OpenAiCompatibleVisionClient` real impl) |
| `tepp_client.py` | TEPP's published `AnalysisRunRequest` wire contract, pluggable transport |
| `rankweave_client.py` | Fail-closed RankWeave ranking port (`weighted_reciprocal_rank_fuse` in-process; never invent a fused score or a theta) |
| `reconstruct.py` | The pipeline: group → candidate window → score → fuse → thread |
| `lineage_persistence.py` | Flattens reconstruct trees into `post_lineage_edge` row specs (parent, child, fused_score) |
| `knowledge_graph.py` | Random-walk-with-restart relevance + per-node adaptive related-node cutoff (Tong et al., 2006) -- pure graph math, no Postgres |
Expand Down Expand Up @@ -117,6 +118,11 @@ flowchart LR
(`AnalysisRunRequest.to_json()` mirrors TEPP's published JSON Schema
exactly, `additionalProperties: false` and all) so wiring in a real
transport is additive, not a rewrite.
- **RankWeave is an in-process library, not an HTTP host.**
`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.

## Standards and citations

Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.d/0.75.0-rankweave-fusion-fail-closed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# 0.75.0 — Fail-closed RankWeave rankings

## Added

- Home Rankings panel fuses visible posts through `RankWeaveClient`.
After login with the port disabled or the library missing, Demo
Analyst sees **Rankings · RankWeave not available**. An accepted hit
lists the title; click opens that post. A hidden post is omitted.
Never invent a fused score or a theta.
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.75.0] - 2026-08-17

### Added

- Home Rankings panel fuses visible posts through `RankWeaveClient`
(ADR 0024). After login with the port disabled or the library
missing, Demo Analyst sees **Rankings · RankWeave not available**.
An accepted hit lists the title; click opens that post. A hidden
post is omitted. Never invent a fused score or a theta.

## [0.71.2] - 2026-08-17

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ The optional LLM-adjudication channel calls
[ThreadWeave](https://github.com/ContextualWisdomLab/ThreadWeave) (JWZ
message threading) and channel fusion reuses
[RankWeave](https://github.com/ContextualWisdomLab/RankWeave) (weighted
score fusion) -- both real dependencies, not reimplemented here.
score fusion for reconstruction and the fail-closed Rankings port) -- both real dependencies, not reimplemented here.

## Run it

Expand Down
8 changes: 8 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ class Settings:
# means the verification channel is unavailable, same "no fake
# channel" discipline as every other pluggable client.
searxng_base_url: str
# RankWeave ranking port (ADR 0024). True = fail-closed
# RankWeaveNotAvailable -- never invent a fused score. Default false
# uses the in-process library already required by reconstruct.py.
rankweave_disabled: bool

@property
def keycloak_jwks_uri(self) -> str:
Expand Down Expand Up @@ -80,4 +84,8 @@ def load_settings() -> Settings:
vision_model=os.environ.get("VISION_MODEL", ""),
valkey_url=os.environ.get("VALKEY_URL", "redis://localhost:16379/0"),
searxng_base_url=os.environ.get("SEARXNG_BASE_URL", ""),
rankweave_disabled=os.environ.get("RANKWEAVE_DISABLED", "")
.strip()
.lower()
in {"1", "true", "yes", "on"},
)
28 changes: 28 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
)
from lineageweave.post_summary import ContextualOrchestratorPostSummaryClient, NullPostSummaryClient
from lineageweave.relation_verification import NullRelationVerificationClient, SearxngRelationVerificationClient
from lineageweave.rankweave_client import build_rankweave_client

from backend.app.activity_stream import (
create_valkey_client,
Expand All @@ -72,6 +73,7 @@
ingest_post_entity_relationships,
)
from backend.app.post_evaluation_ingestion import fetch_post_evaluation, ingest_post_evaluation
from backend.app.ranking_ingestion import load_visible_ranking_posts
from backend.app.report_ingestion import (
GROUPING_KINDS,
fetch_period_comparison,
Expand Down Expand Up @@ -235,6 +237,11 @@ def _post_evaluation_client():
)


def _rankweave_client():
"""In-process RankWeave unless RANKWEAVE_DISABLED=1 (ADR 0024)."""
return build_rankweave_client(disabled=load_settings().rankweave_disabled)


def _can_see_post(account: CurrentAccount, post: asyncpg.Record) -> bool:
"""ABAC: public rows are visible; private rows require same-corp affiliation."""
if post["visibility_code"] == "public":
Expand Down Expand Up @@ -1122,3 +1129,24 @@ async def read_calendar(
for c in visible:
del c["visibility_code"], c["corporate_entity_id"]
return {"commitments": visible}


@app.get("/api/rankings")
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).

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.
"""
_require_post_read(account)
async with pool.acquire() as conn:
posts = await load_visible_ranking_posts(
conn, lambda row: _can_see_post(account, row)
)
return _rankweave_client().as_api_payload(
posts, can_see_post=lambda _row: True
)
26 changes: 26 additions & 0 deletions backend/app/ranking_ingestion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Load ABAC-visible posts for the RankWeave ranking port.

A hidden post is omitted from every channel. This module never invents
a fused score or a theta.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any, Callable, Mapping

if TYPE_CHECKING:
import asyncpg

__all__ = ["load_visible_ranking_posts"]


async def load_visible_ranking_posts(
conn: "asyncpg.Connection",
can_see_post: Callable[[Mapping[str, Any]], bool],
) -> list[dict[str, Any]]:
"""Read ``source_post`` rows the buyer may rank."""
posts = await conn.fetch(
"select post_id, post_title, created_at, visibility_code, "
"corporate_entity_id from source_post"
)
return [dict(row) for row in posts if can_see_post(row)]
10 changes: 10 additions & 0 deletions backend/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,13 @@ def test_frontend_origins_are_parsed_from_comma_separated_env(monkeypatch) -> No
def test_frontend_origins_drop_blank_entries(monkeypatch) -> None:
monkeypatch.setenv("FRONTEND_ORIGINS", "http://localhost:5173,,")
assert load_settings().frontend_origins == ["http://localhost:5173"]


def test_rankweave_disabled_defaults_off(monkeypatch) -> None:
monkeypatch.delenv("RANKWEAVE_DISABLED", raising=False)
assert load_settings().rankweave_disabled is False


def test_rankweave_disabled_flag_is_opt_in(monkeypatch) -> None:
monkeypatch.setenv("RANKWEAVE_DISABLED", "1")
assert load_settings().rankweave_disabled is True
60 changes: 60 additions & 0 deletions docs/adr/0024-rankweave-fusion-fail-closed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# ADR 0024 — Fail-closed RankWeave ranking port

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

## Context

LineageWeave already calls RankWeave inside `reconstruct.py` to fuse
per-candidate channel scores into a parent choice. Demo Analyst had no
buyer-facing Rankings surface over the same visible `source_post` rows.
RankWeave is an in-process library
([README](https://github.com/ContextualWisdomLab/RankWeave)): it does
not define HTTP, a mailbox host, or authentication. A missing package
or a disabled port must not become an invented fused score or a
calibrated theta (TEPP owns theta; see ADR 0022 on #214).

This ADR does not replace `reconstruct.py`, does not read naruon
tables, and does not bind the demo IdP to production Keyverse.

## Decision

1. Consume RankWeave only through `RankWeaveClient`. The default
transport raises `RankWeaveNotAvailable`. `build_rankweave_client
(disabled=False)` uses `LibraryRankWeaveTransport`, which imports
`weighted_reciprocal_rank_fuse` inside the call so a missing
package fail-closes.
2. `GET /api/rankings` (`post_read`) loads ABAC-visible posts as two
rank-only channels: temporal (newest first) and lexical (token
overlap with the synthetic demo query `pricing quote delivery`).
Hidden posts are omitted from every channel. Never invent a score.
3. Fusion is weighted RRF with Cormack et al. (2009) η = 60 and
Samuel et al. (2025) unequal-channel weights (`temporal` 0.25,
`lexical` 0.75). The buyer sees 1-based `fused_rank` and the post
title — not a TEPP theta.
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`.

## Consequences

`RANKWEAVE_DISABLED=1` keeps the fail-closed transport. The default
seeded stack uses the in-process library already required by
`reconstruct.py`. Mailbox stays on ADR 0020 / #217. Conversations stay
on ADR 0021 / #219. Leftover pairs stay on #211. TEPP stays on #214.
Keyverse IdP remains a later slice.

## 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].

Contextual Wisdom Lab. (2026). *RankWeave* [Software documentation].
https://github.com/ContextualWisdomLab/RankWeave
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.71.2",
"version": "0.75.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
66 changes: 66 additions & 0 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[];
rankings?: {
status?: "accepted" | "unavailable";
status_reason?: string | null;
rankings?: {
post_id: string;
post_title: string;
fused_rank: number;
}[];
};
chatUnavailable?: boolean;
searchUnavailable?: boolean;
verificationEvidenceUrl?: string | null;
Expand Down Expand Up @@ -204,6 +213,21 @@ describe("App, authenticated", () => {
}),
);
}
if (url.endsWith("/api/rankings")) {
const rankings = options?.rankings ?? {
status: "unavailable" as const,
status_reason: "rankweave_not_available",
rankings: [],
};
return Promise.resolve(
jsonResponse({
port: "rankweave",
status: rankings.status,
status_reason: rankings.status_reason,
rankings: rankings.rankings ?? [],
}),
);
}
if (url.includes("/api/reports/compare/") && method === "GET") {
return Promise.resolve(
jsonResponse({
Expand Down Expand Up @@ -1241,6 +1265,48 @@ describe("App, authenticated", () => {
);
});

it("names RankWeave unavailability on home rankings instead of inventing a fused score", async () => {
stubBackend();
render(<App />);

expect(await screen.findByText("Rankings · RankWeave not available")).toBeInTheDocument();
expect(screen.queryByText("Pricing renegotiation: revised quote sent")).not.toBeInTheDocument();
});

it("opens an accepted ranking hit without inventing a fused score", async () => {
stubBackend({
rankings: {
status: "accepted",
status_reason: null,
rankings: [
{
post_id: "post-1",
post_title: "Public post",
fused_rank: 1,
},
{
post_id: "post-2",
post_title: "Pricing renegotiation: revised quote sent",
fused_rank: 2,
},
],
},
});
render(<App />);

const rankingButton = await screen.findByRole("button", {
name: /open ranking: public post/i,
});
expect(rankingButton).toHaveTextContent("Public post");
expect(rankingButton).toHaveTextContent("Rankings · rankweave");
expect(rankingButton).toHaveTextContent("rank 1");
expect(screen.queryByRole("button", { name: /open ranking: private parent/i })).not.toBeInTheDocument();

await userEvent.click(rankingButton);

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

it("shows upcoming commitments on the home page calendar and opens the post on click", async () => {
stubBackend();
render(<App />);
Expand Down
Loading
Loading