diff --git a/src/app/api/citable/route.ts b/src/app/api/citable/route.ts index 03d6b55c..e1283e1e 100644 --- a/src/app/api/citable/route.ts +++ b/src/app/api/citable/route.ts @@ -2,12 +2,7 @@ import { NextResponse } from "next/server"; import { getBenchmarks } from "@/data/benchmarks"; import { SITE } from "@/data/site"; import { AllBenchmarksDraftError } from "@/lib/spec"; -import { - fieldValue, - headlineSentence, - isInsufficient, - leader, -} from "@/lib/citation"; +import { citeBundle, fieldValue, leader, headlineSentence } from "@/lib/citation"; import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; export const runtime = "nodejs"; @@ -52,21 +47,29 @@ export async function GET(req: Request) { throw err; } const data = benches.map((b) => { - const insufficient = isInsufficient(b); - const top = insufficient ? null : leader(b); - const status: "live" | "draft" | "insufficient" = insufficient - ? "insufficient" - : b.status; + const top = leader(b); + // Insufficient aggregate: explicitly null the value + leader so + // downstream LLM agents and journalists do not quote a number drawn + // from an undersized field. The headline sentence is rewritten to + // "insufficient data" by headlineSentence above. + const insufficient = b.dataConfidence === "insufficient"; return { slug: b.slug, title: b.title, category: b.category, metric: b.metric, unit: b.unit, - status, + status: b.status, value: insufficient ? null : fieldValue(b), - leader: top ? { name: top.name, slug: top.slug, value: top.value } : null, - sampleSize: insufficient ? 0 : b.sampleSize, + leader: + insufficient + ? null + : top + ? { name: top.name, slug: top.slug, value: top.value } + : null, + sampleSize: b.sampleSize, + expectedN: b.expectedN, + dataConfidence: b.dataConfidence, asOf: b.lastRunAt, headline: headlineSentence(b), url: `${SITE.url}/benchmarks/${b.slug}`, @@ -74,6 +77,7 @@ export async function GET(req: Request) { ogImage: `${SITE.url}/api/og/${b.slug}`, source: b.source, license: "CC-BY-4.0", + cite: citeBundle(b, SITE.url), }; }); diff --git a/src/app/api/stat/[slug]/route.ts b/src/app/api/stat/[slug]/route.ts index 14d6c7d7..b53c5767 100644 --- a/src/app/api/stat/[slug]/route.ts +++ b/src/app/api/stat/[slug]/route.ts @@ -3,9 +3,9 @@ import { getBenchmark } from "@/data/benchmarks"; import { SITE } from "@/data/site"; import { citationQuote, + citeBundle, fieldValue, headlineSentence, - isInsufficient, leader, sparklineFor, } from "@/lib/citation"; @@ -20,16 +20,6 @@ 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, @@ -53,46 +43,8 @@ export async function GET( ); } - 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, - type: r.type ?? null, - layer: r.layer ?? null, - tag: r.tag ?? null, - 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, - type: r.type ?? null, - layer: r.layer ?? null, - tag: r.tag ?? null, - ms: r.ms, - successRate: r.successRate, - sampleSize: r.sampleSize, - })); - + const top = leader(b); + const insufficient = b.dataConfidence === "insufficient"; const payload = { slug: b.slug, title: b.title, @@ -100,27 +52,44 @@ export async function GET( category: b.category, metric: b.metric, unit: b.unit, - status, + status: b.status, higherIsBetter: b.higherIsBetter, - value, - leader: top, - rankings, - sparkline: insufficient ? [] : sparklineFor(b, top?.slug), - sampleSize: insufficient ? 0 : b.sampleSize, + // Aggregate is "insufficient" (median per-provider sample health + // below 10 percent of expected_n): refuse to publish a value or + // leader; the headline is rewritten by headlineSentence so the + // agent / journalist reads "insufficient data" instead of quoting + // a number drawn from undersized samples. + value: insufficient ? null : fieldValue(b), + leader: insufficient ? null : top, + rankings: b.results + .filter((r) => r.ms.p50 > 0) + // Drop "insufficient" rows from the machine-readable ranking too: + // a row that the page hides from the leaderboard must not surface + // here either. + .filter((r) => r.dataConfidence !== "insufficient") + .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, + sampleHealth: r.sampleHealth, + dataConfidence: r.dataConfidence, + })), + sparkline: sparklineFor(b, top?.slug), + sampleSize: b.sampleSize, + expectedN: b.expectedN, + dataConfidence: b.dataConfidence, asOf: b.lastRunAt, headline: headlineSentence(b), quote: citationQuote(b, SITE.url), + cite: citeBundle(b, SITE.url), pageUrl: `${SITE.url}/benchmarks/${b.slug}`, ogImage: `${SITE.url}/api/og/${b.slug}`, source: b.source, methodology: b.methodology, license: "CC-BY-4.0", - // Per-chain leader / worst. Empty for benches without a chain - // dimension. Used by the HF publisher to populate chain_leaders. - // Insufficient benches null both since the underlying rankings - // are not citable. - bestPerChain: insufficient ? null : (b.bestPerChain ?? null), - worstPerChain: insufficient ? null : (b.worstPerChain ?? null), }; return NextResponse.json(payload, { diff --git a/src/app/benchmarks/category/[cat]/page.tsx b/src/app/benchmarks/category/[cat]/page.tsx new file mode 100644 index 00000000..f001cc93 --- /dev/null +++ b/src/app/benchmarks/category/[cat]/page.tsx @@ -0,0 +1,112 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { getBenchmarksSafe, toBenchmarkCardData } from "@/data/benchmarks"; +import { BenchmarkGrid } from "@/components/benchmark-grid"; +import { Breadcrumb } from "@/components/breadcrumb"; +import { safeJsonLd, buildItemListJsonLd } from "@/lib/jsonld"; +import { SITE } from "@/data/site"; +import { pageMetadata } from "@/lib/page-metadata"; +import { capDescription } from "@/lib/seo-text"; +import { CATEGORIES, CATEGORY_BY_SLUG } from "@/lib/categories"; + +/** + * Per-category hub page. One static route per entry in `CATEGORIES` so + * each architectural slice (RPCs, Bridges, Blockchains, ...) has a + * crawlable, linkable URL. Lets external sites and internal nav deep + * link directly into "all Solana RPC benchmarks" instead of relying on + * the client-only filter that lives on `/benchmarks`. + * + * Prerendered at build time via `generateStaticParams` (closed list, no + * Prom calls needed). ISR refresh aligned with the main hub so a freshly + * added bench surfaces here on the same cadence. + */ + +export const revalidate = 60; + +type Params = { cat: string }; + +export async function generateStaticParams() { + // Closed enum, generated even when a category currently has zero live + // benches (e.g. Wallets at time of writing). The page-level fallback + // below 404s empty categories so crawlers don't index thin pages. + return CATEGORIES.map((c) => ({ cat: c.slug })); +} + +export async function generateMetadata({ + params, +}: { + params: Promise; +}): Promise { + const { cat } = await params; + const entry = CATEGORY_BY_SLUG.get(cat); + if (!entry) return {}; + const description = capDescription(entry.description, 158); + return pageMetadata({ + path: `/benchmarks/category/${entry.slug}`, + title: `${entry.heading}`, + description, + }); +} + +export default async function BenchmarkCategoryPage({ + params, +}: { + params: Promise; +}) { + const { cat } = await params; + const entry = CATEGORY_BY_SLUG.get(cat); + if (!entry) notFound(); + + const all = await getBenchmarksSafe(); + const benchmarks = all.filter((b) => b.category === entry.label); + // Empty-category guard: a category in the enum that has no live bench + // yet returns 404 so the crawler doesn't land on a thin page. The + // category still ships in `generateStaticParams` so adding the first + // bench to it lights up the URL without a redeploy gate. + if (benchmarks.length === 0) notFound(); + + const url = `${SITE.url}/benchmarks/category/${entry.slug}`; + const jsonLd = buildItemListJsonLd({ + name: `${entry.heading} on OpenChainBench`, + url, + description: entry.description, + items: benchmarks.map((b) => ({ + name: b.title, + url: `${SITE.url}/benchmarks/${b.slug}`, + })), + breadcrumb: [ + { name: "Home", url: `${SITE.url}/` }, + { name: "All benchmarks", url: `${SITE.url}/benchmarks` }, + { name: entry.heading, url }, + ], + }); + + return ( +
+