diff --git a/src/app/api/citable/route.ts b/src/app/api/citable/route.ts index f5e8646e..972ceb22 100644 --- a/src/app/api/citable/route.ts +++ b/src/app/api/citable/route.ts @@ -2,7 +2,12 @@ import { NextResponse } from "next/server"; import { getBenchmarks } from "@/data/benchmarks"; import { SITE } from "@/data/site"; import { AllBenchmarksDraftError } from "@/lib/spec"; -import { fieldValue, leader, headlineSentence } from "@/lib/citation"; +import { + fieldValue, + headlineSentence, + isInsufficient, + leader, +} from "@/lib/citation"; import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; export const runtime = "nodejs"; @@ -47,17 +52,21 @@ export async function GET(req: Request) { throw err; } const data = benches.map((b) => { - const top = leader(b); + const insufficient = isInsufficient(b); + const top = insufficient ? null : leader(b); + const status: "live" | "draft" | "insufficient" = insufficient + ? "insufficient" + : b.status; return { slug: b.slug, title: b.title, category: b.category, metric: b.metric, unit: b.unit, - status: b.status, - value: fieldValue(b), + status, + value: insufficient ? null : fieldValue(b), leader: top ? { name: top.name, slug: top.slug, value: top.value } : null, - sampleSize: b.sampleSize, + sampleSize: insufficient ? 0 : b.sampleSize, asOf: b.lastRunAt, headline: headlineSentence(b), url: `${SITE.url}/benchmarks/${b.slug}`, diff --git a/src/app/api/llm-context/route.ts b/src/app/api/llm-context/route.ts index 57b01e5c..b520e5f4 100644 --- a/src/app/api/llm-context/route.ts +++ b/src/app/api/llm-context/route.ts @@ -2,7 +2,12 @@ import { getBenchmarks } from "@/data/benchmarks"; import { SITE } from "@/data/site"; import { AllBenchmarksDraftError } from "@/lib/spec"; import { fmtUnit } from "@/lib/format"; -import { fieldValue, headlineSentence, leader } from "@/lib/citation"; +import { + fieldValue, + headlineSentence, + isInsufficient, + leader, +} from "@/lib/citation"; import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; export const runtime = "nodejs"; @@ -67,11 +72,15 @@ export async function GET(req: Request) { lines.push(`- Metric: ${b.metric} (${b.unit})`); lines.push(`- Page: ${SITE.url}/benchmarks/${b.slug}`); lines.push(`- JSON: ${SITE.url}/api/stat/${b.slug}`); - lines.push(`- Status: ${b.status}`); + const insufficient = isInsufficient(b); + const reportedStatus: "live" | "draft" | "insufficient" = insufficient + ? "insufficient" + : b.status; + lines.push(`- Status: ${reportedStatus}`); const v = fieldValue(b); const lead = leader(b); - if (v != null && lead) { + if (!insufficient && v != null && lead) { lines.push(`- Headline: ${headlineSentence(b)}`); lines.push(""); lines.push(`**Rankings (p50, 24h):**`); @@ -89,6 +98,12 @@ export async function GET(req: Request) { )}, success ${r.successRate.toFixed(1)}%, sample ${r.sampleSize ?? "n/a"})`, ); } + } else if (insufficient) { + // Surface the same insufficient sentence the other citable surfaces + // emit, so an LLM that pastes this Markdown into context never sees + // a fabricated winner for a bench whose harness lacks data. + lines.push(`- Headline: ${headlineSentence(b)}`); + lines.push(`- Insufficient samples to rank providers yet.`); } else { lines.push(`- ${b.status === "draft" ? "Draft (no live data yet)" : "Awaiting samples"}.`); } diff --git a/src/app/api/mcp/[transport]/route.ts b/src/app/api/mcp/[transport]/route.ts index d9cdea5c..809e4978 100644 --- a/src/app/api/mcp/[transport]/route.ts +++ b/src/app/api/mcp/[transport]/route.ts @@ -7,6 +7,7 @@ import { citationQuote, fieldValue, headlineSentence, + isInsufficient, leader, sparklineFor, } from "@/lib/citation"; @@ -208,15 +209,19 @@ const mcpHandler = createMcpHandler( async () => { const benches = (await getBenchmarks()).filter((b) => b.editorialStatus === "live"); const rows = benches.map((b) => { - const top = leader(b); + const insufficient = isInsufficient(b); + const top = insufficient ? null : leader(b); + const status: "live" | "draft" | "insufficient" = insufficient + ? "insufficient" + : b.status; return { slug: b.slug, title: b.title, category: b.category, metric: b.metric, unit: b.unit, - status: b.status, - value: fieldValue(b), + status, + value: insufficient ? null : fieldValue(b), leader: top, headline: headlineSentence(b), url: `${SITE.url}/benchmarks/${b.slug}`, @@ -279,25 +284,39 @@ const mcpHandler = createMcpHandler( isError: true, }; } - const top = leader(b); + const insufficient = isInsufficient(b); + const top = insufficient ? null : leader(b); + const status: "live" | "draft" | "insufficient" = insufficient + ? "insufficient" + : b.status; + const rankings = insufficient + ? b.results.map((r) => ({ + name: r.name, + slug: r.slug, + ms: { p50: null, p90: null, p99: null, mean: null }, + successRate: r.successRate, + })) + : b.results + .filter((r) => r.ms.p50 > 0) + .sort((a, c) => + b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50, + ) + .map((r) => ({ + name: r.name, + slug: r.slug, + ms: r.ms, + successRate: r.successRate, + })); const payload = { slug: b.slug, title: b.title, metric: b.metric, unit: b.unit, - status: b.status, - value: fieldValue(b), + status, + value: insufficient ? null : fieldValue(b), leader: top, - rankings: b.results - .filter((r) => r.ms.p50 > 0) - .sort((a, c) => (b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50)) - .map((r) => ({ - name: r.name, - slug: r.slug, - ms: r.ms, - successRate: r.successRate, - })), - sparkline: sparklineFor(b, top?.slug), + rankings, + sparkline: insufficient ? [] : sparklineFor(b, top?.slug), headline: headlineSentence(b), quote: citationQuote(b, SITE.url), pageUrl: `${SITE.url}/benchmarks/${b.slug}`, @@ -458,10 +477,15 @@ const mcpHandler = createMcpHandler( ], }; } - const top = leader(b); - const ranked = b.results - .filter((r) => r.ms.p50 > 0) - .sort((a, c) => (b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50)); + const insufficient = isInsufficient(b); + const top = insufficient ? null : leader(b); + const ranked = insufficient + ? [] + : b.results + .filter((r) => r.ms.p50 > 0) + .sort((a, c) => + b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50, + ); const md: string[] = []; md.push(`# ${b.title}`); @@ -501,21 +525,33 @@ const mcpHandler = createMcpHandler( // We attach both Markdown (default rendering) and JSON (structured // access) so clients can pick whichever matches their context. + const status: "live" | "draft" | "insufficient" = insufficient + ? "insufficient" + : b.status; const payload = { slug: b.slug, title: b.title, metric: b.metric, unit: b.unit, - value: fieldValue(b), + status, + value: insufficient ? null : fieldValue(b), leader: top, - rankings: ranked.map((r) => ({ - name: r.name, - slug: r.slug, - ms: r.ms, - successRate: r.successRate, - sampleSize: r.sampleSize, - })), - sparkline: sparklineFor(b, top?.slug), + rankings: insufficient + ? b.results.map((r) => ({ + name: r.name, + slug: r.slug, + ms: { p50: null, p90: null, p99: null, mean: null }, + successRate: r.successRate, + sampleSize: r.sampleSize ?? null, + })) + : ranked.map((r) => ({ + name: r.name, + slug: r.slug, + ms: r.ms, + successRate: r.successRate, + sampleSize: r.sampleSize, + })), + sparkline: insufficient ? [] : sparklineFor(b, top?.slug), headline: headlineSentence(b), quote: citationQuote(b, SITE.url), pageUrl: `${SITE.url}/benchmarks/${b.slug}`, diff --git a/src/app/api/stat/[slug]/route.ts b/src/app/api/stat/[slug]/route.ts index dde8da87..47bcfe87 100644 --- a/src/app/api/stat/[slug]/route.ts +++ b/src/app/api/stat/[slug]/route.ts @@ -5,6 +5,7 @@ import { citationQuote, fieldValue, headlineSentence, + isInsufficient, leader, sparklineFor, } from "@/lib/citation"; @@ -19,6 +20,16 @@ export const revalidate = 60; * Single benchmark as a citable atomic unit. Designed to fit into one * agent tool call: ranked providers, sparkline, methodology link, * pre-formatted attribution string, and stable citation URL. + * + * Status field semantics: + * "live" - usable measurement, leader / value populated. + * "draft" - spec author has not published (editorialStatus draft). + * "insufficient" - editorially live but the harness has no usable + * sample yet (every provider p50 = 0, or runtime + * status flipped to draft mid-cycle). value, leader + * and rankings p50 are nulled so a consumer cannot + * accidentally cite a fabricated winner. The shape + * of the response is preserved. */ export async function GET( req: Request, @@ -42,7 +53,40 @@ export async function GET( ); } - const top = leader(b); + const insufficient = isInsufficient(b); + const top = insufficient ? null : leader(b); + const value = insufficient ? null : fieldValue(b); + // Status surfaced to consumers: "insufficient" wins over the raw + // runtime "live" flag when the predicate fires, so /api/stat stops + // claiming live data for a bench whose harness has nothing to show. + const status: "live" | "draft" | "insufficient" = insufficient + ? "insufficient" + : b.status; + + // Rankings: when insufficient we still return one entry per provider + // (shape preserved for any consumer that diff-tracks the provider set) + // but every p50 is nulled to drive home that no comparison is possible. + const rankings = insufficient + ? b.results.map((r) => ({ + name: r.name, + slug: r.slug, + ms: { p50: null, p90: null, p99: null, mean: null }, + successRate: r.successRate, + sampleSize: r.sampleSize ?? null, + })) + : b.results + .filter((r) => r.ms.p50 > 0) + .sort((a, c) => + b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50, + ) + .map((r) => ({ + name: r.name, + slug: r.slug, + ms: r.ms, + successRate: r.successRate, + sampleSize: r.sampleSize, + })); + const payload = { slug: b.slug, title: b.title, @@ -50,22 +94,13 @@ export async function GET( category: b.category, metric: b.metric, unit: b.unit, - status: b.status, + status, higherIsBetter: b.higherIsBetter, - value: fieldValue(b), + value, leader: top, - rankings: b.results - .filter((r) => r.ms.p50 > 0) - .sort((a, c) => (b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50)) - .map((r) => ({ - name: r.name, - slug: r.slug, - ms: r.ms, - successRate: r.successRate, - sampleSize: r.sampleSize, - })), - sparkline: sparklineFor(b, top?.slug), - sampleSize: b.sampleSize, + rankings, + sparkline: insufficient ? [] : sparklineFor(b, top?.slug), + sampleSize: insufficient ? 0 : b.sampleSize, asOf: b.lastRunAt, headline: headlineSentence(b), quote: citationQuote(b, SITE.url), diff --git a/src/app/benchmarks/[slug]/page.tsx b/src/app/benchmarks/[slug]/page.tsx index 9f73a146..56afc33d 100644 --- a/src/app/benchmarks/[slug]/page.tsx +++ b/src/app/benchmarks/[slug]/page.tsx @@ -16,7 +16,7 @@ import { ShareSection } from "@/components/share-section"; import { ExportVideoSection } from "@/components/export-video-section"; import { ReportSection } from "@/components/report-section"; import { CATEGORY_COLOR } from "@/lib/category-colors"; -import { headlineSentence } from "@/lib/citation"; +import { headlineSentence, isInsufficient } from "@/lib/citation"; import { capDescription } from "@/lib/seo-text"; import { getBenchCreatedAt } from "@/lib/seo/bench-dates"; import { SITE } from "@/data/site"; @@ -172,6 +172,10 @@ export default async function BenchmarkPage({ const isDraft = benchmark.status === "draft"; const isAwaiting = isDraft && benchmark.editorialStatus === "live"; + // Insufficient: editorially live, runtime might say "live" too, but the + // shared predicate decided no provider has a usable p50. Drives the + // pill above the H1 and the headline degradation downstream. + const insufficient = isInsufficient(benchmark); // Cap the "more benchmarks" rail at 6 items so it doesn't turn into // an endless single-column scroll on mobile (with 18 benches the old // unlimited list rendered 17 full cards stacked). Prefer same-category @@ -321,7 +325,10 @@ export default async function BenchmarkPage({ {isAwaiting ? "awaiting samples" : "draft"} )} - {!isDraft && ( + {!isDraft && insufficient && ( + insufficient samples + )} + {!isDraft && !insufficient && ( diff --git a/src/components/benchmark-card.tsx b/src/components/benchmark-card.tsx index 8848f0bc..77f079cb 100644 --- a/src/components/benchmark-card.tsx +++ b/src/components/benchmark-card.tsx @@ -3,18 +3,24 @@ import type { Benchmark } from "@/types/benchmark"; import { Hint } from "@/components/hint"; import { MiniChart } from "@/components/mini-chart"; import { CATEGORY_COLOR } from "@/lib/category-colors"; +import { isInsufficient } from "@/lib/citation"; import { fmtValue, unitSuffix } from "@/lib/format"; /** * Card tile for the All Benchmarks grid. Self-contained - renders the * category badge, title, headline KPI (field-composite p50), a compact * MiniChart preview, and a 3-column footer (providers / samples / updated). - * Drafts render the same skeleton with an "Awaiting first run" placeholder - * in place of the chart and an em-dash where the headline number would be. + * + * Three visual states: + * - Draft: spec not published. "Draft" pill + chart placeholder. + * - Insufficient samples: published but the harness has no usable p50 + * yet. "Insufficient samples" pill, greyed-out value, chart skipped. + * - Live: standard rendering, leader p50 in display style. */ export function BenchmarkCard({ benchmark }: { benchmark: Benchmark }) { const b = benchmark; const isDraft = b.status === "draft"; + const insufficient = !isDraft && isInsufficient(b); const catColor = CATEGORY_COLOR[b.category] ?? "var(--color-ink-muted)"; // Field composite p50: best-of-class (or worst if higher-is-better is @@ -23,7 +29,8 @@ export function BenchmarkCard({ benchmark }: { benchmark: Benchmark }) { b.higherIsBetter ? (a, x) => x.ms.p50 - a.ms.p50 : (a, x) => a.ms.p50 - x.ms.p50, ); const leader = sorted[0]; - const headlineValue = !isDraft && leader ? fmtValue(leader.ms.p50, b.unit) : "n/a"; + const headlineValue = + !isDraft && !insufficient && leader ? fmtValue(leader.ms.p50, b.unit) : "n/a"; // Pass the leader's p50 so unitSuffix mirrors fmtUnit's auto-conversion // (e.g. Ethereum finality renders as "15.9 min" not "15.9 s"). const headlineUnit = unitSuffix(b.unit, leader?.ms.p50).trim(); @@ -50,6 +57,11 @@ export function BenchmarkCard({ benchmark }: { benchmark: Benchmark }) { Draft )} + {insufficient && ( + + Insufficient samples + + )}
{chips.map((r) => ( @@ -71,20 +83,26 @@ export function BenchmarkCard({ benchmark }: { benchmark: Benchmark }) { {/* Big number row */}
- + {headlineValue} - {headlineUnit && ( + {headlineUnit && !isDraft && !insufficient && ( {headlineUnit} )}
24H
- {/* Chart preview - or draft placeholder */} + {/* Chart preview - or draft / insufficient placeholder */}
{isDraft ? (

Awaiting first run

+ ) : insufficient ? ( +

Insufficient samples to rank

) : ( )} diff --git a/src/components/home-bench-table.tsx b/src/components/home-bench-table.tsx index 040269a9..bd475b12 100644 --- a/src/components/home-bench-table.tsx +++ b/src/components/home-bench-table.tsx @@ -4,7 +4,7 @@ import type { Benchmark } from "@/types/benchmark"; import { Hint } from "@/components/hint"; import { MiniChart } from "@/components/mini-chart"; import { CATEGORY_COLOR } from "@/lib/category-colors"; -import { leader, fieldValue } from "@/lib/citation"; +import { fieldValue, isInsufficient, leader } from "@/lib/citation"; import { fmtValue, unitSuffix } from "@/lib/format"; /** @@ -47,10 +47,12 @@ export function HomeBenchTable({ benchmarks }: { benchmarks: Benchmark[] }) { >
- {b.status === "live" ? ( - + {isInsufficient(b) ? ( + + {b.status === "live" ? "Insufficient samples" : "Awaiting samples"} + ) : ( - Awaiting samples + )}
diff --git a/src/lib/citation.ts b/src/lib/citation.ts index fc22fd75..197c9199 100644 --- a/src/lib/citation.ts +++ b/src/lib/citation.ts @@ -8,9 +8,48 @@ import type { Benchmark } from "@/types/benchmark"; import { liveResults } from "@/lib/provider-filters"; import { fmtUnit } from "@/lib/format"; +/** + * Canonical "this bench cannot be ranked right now" predicate, shared by + * every citable surface (api/stat, api/citable, api/llm-context, llms.txt, + * /answers, bench page hero, OG image, MCP). + * + * A benchmark is insufficient when ANY of: + * - editorialStatus is draft (the spec author has not published) + * - runtime status flipped to draft (the materialize layer fell back to + * draftPlaceholderForSpec because every Prom query came back empty) + * - aggregate sampleSize is exactly 0 (harness ran but emitted no + * samples; the headline number is then a rolling aggregate over an + * empty window and must not be cited) + * - no live provider has a usable, finite, positive p50 + * + * Why both bench.sampleSize and the per-provider p50 check matter: + * /api/stat goes through the per-slug cache and routinely returns + * status=live with a non-zero p50 even when the aggregator collapsed + * the same bench to draft on /api/citable (per-bench throw, fallback + * to draftPlaceholderForSpec). Aligning on bench.sampleSize === 0 + * closes that gap for the network-fees / token-deployment-cost class + * of bench, where the harness emits a value but no samples. + * + * Notes: + * - per-provider `sampleSize` is intentionally NOT used. Several + * harnesses do not emit a per-provider count even when the rolling + * headline is real, so making it a hard condition would mass-flag + * healthy benches. + * - Use this predicate BEFORE deriving leader / fieldValue / headline + * for any externally-visible surface. + */ +export function isInsufficient(b: Benchmark): boolean { + if (b.editorialStatus !== "live") return true; + if (b.status !== "live") return true; + if (b.sampleSize === 0) return true; + const live = liveResults(b.results); + if (live.length === 0) return true; + return live.every((r) => !Number.isFinite(r.ms.p50) || r.ms.p50 <= 0); +} + /** Median value of the benchmark (the field shown in the headline). */ export function fieldValue(b: Benchmark): number | null { - if (b.status !== "live") return null; + if (isInsufficient(b)) return null; const live = liveResults(b.results); if (live.length === 0) return null; const sorted = [...live].sort((a, c) => @@ -21,7 +60,7 @@ export function fieldValue(b: Benchmark): number | null { /** Who is currently #1 on this benchmark, if any. */ export function leader(b: Benchmark): { name: string; slug: string; value: number } | null { - if (b.status !== "live") return null; + if (isInsufficient(b)) return null; const live = liveResults(b.results); if (live.length === 0) return null; const sorted = [...live].sort((a, c) => @@ -39,8 +78,22 @@ export function windowSuffix(unit: string): string { return "(p50, 24h)"; } -/** Short factual sentence ready to paste into an article. Templated, no LLM. */ +/** Short factual sentence ready to paste into an article. Templated, no LLM. + * + * Three-state output, in priority order: + * 1. Insufficient data: harness has no usable sample (zero p50 across + * every provider, draft status, etc). Refuse to assert a winner. + * 2. No provider but bench is live (transient edge case): generic + * "awaiting first run" sentence. + * 3. Live with a leader: the standard headline assertion. + * + * The insufficient branch is critical for /answers, /llms.txt, + * /api/llm-context and /api/citable so an LLM consumer never reads a + * fabricated leader for a bench whose harness has not produced data. */ export function headlineSentence(b: Benchmark): string { + if (isInsufficient(b)) { + return `Insufficient data to rank providers. The harness for ${b.title} is awaiting sufficient samples.`; + } const top = leader(b); if (!top) return `${b.title}. Awaiting first run.`; const value = fmtUnit(top.value, b.unit);