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: 4 additions & 0 deletions src/app/methodology/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ const CONVENTIONS = [
term: "Success rate",
body: "Share of requests returning a usable result within the published timeout. The only metric that includes failures.",
},
{
term: "Ranking on multi-chain benchmarks",
body: "When a benchmark measures several chains, providers are ranked first by the number of chains they lead, and only then by their cross-chain figure. A chain counts toward that total only when at least two providers reported data on it. A cross-chain average is a mix rather than a comparison, so ranking on it alone let a provider measured on one uncontested chain finish above a provider that led several contested ones.",
},
{
term: "Region normalisation",
body: "Where a benchmark is multi-region, the headline figure is the cross-region median. Per-region figures appear on every benchmark page.",
Expand Down
70 changes: 69 additions & 1 deletion src/lib/citation.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { leader, fieldValue, rankedCandidates } from "./citation";
import { chainWins, leader, fieldValue, rankedCandidates } from "./citation";
import type { Benchmark, ProviderResult } from "@/types/benchmark";

function r(
Expand Down Expand Up @@ -111,3 +111,71 @@ describe("citation reliability threshold", () => {
expect(top?.value).toBe(ranks[0].ms.p50);
});
});

describe("contested-chain wins drive the ranking", () => {
// Bench 008 as it actually shipped: XRPScan and StellarExpert sat 1st
// and 2nd on the cross-chain average, each measured on a single chain
// nobody else reported, while Serialized led four contested ones.
const b008 = (): Benchmark => ({
...bench([
r("stellarexpert", "StellarExpert", 80.08),
r("xrpscan", "XRPScan", 79.84),
r("serialized", "Serialized", 76.98),
r("mobula", "Mobula", 46.75),
]),
higherIsBetter: true,
bestPerChain: {
ethereum: r("serialized", "Serialized", 96.84),
base: r("serialized", "Serialized", 76.36),
solana: r("serialized", "Serialized", 57.79),
arbitrum: r("serialized", "Serialized", 70.0),
bnb: r("mobula", "Mobula", 79.78),
xrp: r("xrpscan", "XRPScan", 79.84),
stellar: r("stellarexpert", "StellarExpert", 80.08),
},
providersPerChain: {
ethereum: ["serialized", "mobula", "oli", "blockscout"],
base: ["serialized", "mobula", "oli", "blockscout"],
solana: ["serialized", "mobula"],
arbitrum: ["serialized", "mobula", "oli"],
bnb: ["mobula", "serialized", "oli"],
xrp: ["xrpscan"],
stellar: ["stellarexpert"],
},
});

test("the provider leading the most contested chains ranks first", () => {
expect(rankedCandidates(b008()).map((r) => r.slug)).toEqual([
"serialized",
"mobula",
"stellarexpert",
"xrpscan",
]);
expect(leader(b008())?.slug).toBe("serialized");
});

test("a chain with one measured provider awards no win", () => {
const wins = chainWins(b008());
expect(wins?.get("xrpscan")).toBeUndefined();
expect(wins?.get("stellarexpert")).toBeUndefined();
expect(wins?.get("serialized")).toBe(4);
expect(wins?.get("mobula")).toBe(1);
});

test("providers with equal wins fall back to the aggregate value", () => {
const b = b008();
// Strip every contested win so the whole field ties at zero.
b.providersPerChain = { ethereum: ["serialized"], bnb: ["mobula"] };
expect(rankedCandidates(b).map((r) => r.slug)).toEqual([
"stellarexpert",
"xrpscan",
"serialized",
"mobula",
]);
});

test("a bench without per-chain stashes ranks by value alone", () => {
const b = { ...b008(), bestPerChain: undefined, providersPerChain: undefined };
expect(rankedCandidates(b).map((r) => r.slug)[0]).toBe("stellarexpert");
});
});
54 changes: 49 additions & 5 deletions src/lib/citation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,60 @@ export function citationCandidates(b: Benchmark): ProviderResult[] {
return pool.filter((r) => r.dataConfidence !== "insufficient");
}

/**
* Chains each provider leads, counting **contested** chains only: a chain
* where at least two providers reported data.
*
* The exclusion is the whole point. On a chain-dimensioned bench the
* cross-chain aggregate is a mix, not a comparison, and a provider
* measured on exactly one easy chain with no competitor on it can top the
* board without ever beating anyone. Bench 008 shipped that way:
* StellarExpert and XRPScan sat 1st and 2nd, each measured on a single
* uncontested chain, above Serialized which led four contested ones. Four
* other live benches had the same shape, `rpc-capabilities` worst of all
* (Binance 1st on one chain while PublicNode led six).
*
* Returns null when the bench cannot support the count — no chain
* dimensions, or the per-chain stashes absent. Those stashes are only
* populated on the unfiltered view (see materialize/load.ts), which is
* also the guard that keeps a chain-filtered variant from being ranked by
* cross-chain wins: on `?chain=bnb` there is nothing to count.
*/
export function chainWins(b: Benchmark): Map<string, number> | null {
const best = b.bestPerChain;
const present = b.providersPerChain;
if (!best || !present) return null;
const wins = new Map<string, number>();
for (const [chain, chainLeader] of Object.entries(best)) {
if ((present[chain]?.length ?? 0) < 2) continue;
const slug = chainLeader.slug.toLowerCase();
wins.set(slug, (wins.get(slug) ?? 0) + 1);
}
return wins.size > 0 ? wins : null;
}

/** Sorted candidate pool for the machine-readable `rankings` array on
* `/api/stat`, MCP, llm-context and any downstream that ranks the
* full field. Applies the same reliability + insufficient-sample
* filters as `leader()` so a document that names X as leader ranks X
* first in its own list. Sort direction honors the bench's
* `higherIsBetter` flag. */
* first in its own list.
*
* On a bench that can count contested-chain wins, those wins are the
* primary key and the aggregate value only breaks ties: head-to-head
* record first, chain-mix average second. Everywhere else (no chain
* dimensions, filtered variants) it is the aggregate value alone, sorted
* in the direction the bench's `higherIsBetter` flag asks for. */
export function rankedCandidates(b: Benchmark): ProviderResult[] {
return [...citationCandidates(b)].sort((a, c) =>
b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50,
);
const byValue = (a: ProviderResult, c: ProviderResult) =>
b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50;
const pool = [...citationCandidates(b)];
const wins = chainWins(b);
if (!wins) return pool.sort(byValue);
return pool.sort((a, c) => {
const delta =
(wins.get(c.slug.toLowerCase()) ?? 0) - (wins.get(a.slug.toLowerCase()) ?? 0);
return delta !== 0 ? delta : byValue(a, c);
});
}

/** Timestamp of the last real measurement, or null when the bench has
Expand Down
18 changes: 14 additions & 4 deletions src/lib/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { cache } from "react";
import { unstable_cache } from "next/cache";
import { getBenchmarksSafe } from "@/data/benchmarks";
import { liveResults } from "@/lib/provider-filters";
import { citationCandidates } from "@/lib/citation";
import { chainWins, citationCandidates } from "@/lib/citation";
import { readBestPerChain } from "@/lib/per-chain-contract";
import type { Benchmark, ProviderResult } from "@/types/benchmark";

Expand Down Expand Up @@ -240,9 +240,19 @@ function rankProviders(b: Benchmark): ProviderResult[] {
// a best-of-bad-options ranking.
const pool = citationCandidates(b);
const live = pool.length > 0 ? pool : liveResults(b.results);
return [...live].sort((a, c) =>
b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50,
);
// Same ordering as the bench page: contested-chain wins first, aggregate
// value as the tiebreak (see rankedCandidates). Sorting these two
// surfaces differently is what let /products show "#3 of 8" beside five
// chain-leadership chips on the same bench.
const byValue = (a: ProviderResult, c: ProviderResult) =>
b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50;
const wins = chainWins(b);
if (!wins) return [...live].sort(byValue);
return [...live].sort((a, c) => {
const delta =
(wins.get(c.slug.toLowerCase()) ?? 0) - (wins.get(a.slug.toLowerCase()) ?? 0);
return delta !== 0 ? delta : byValue(a, c);
});
}

/**
Expand Down
Loading