diff --git a/src/app/api/cite/[slug]/[format]/route.ts b/src/app/api/cite/[slug]/[format]/route.ts
new file mode 100644
index 00000000..f09a44a9
--- /dev/null
+++ b/src/app/api/cite/[slug]/[format]/route.ts
@@ -0,0 +1,106 @@
+import { NextResponse } from "next/server";
+import { getBenchmark } from "@/data/benchmarks";
+import { SITE } from "@/data/site";
+import { citeBundle } from "@/lib/citation";
+import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit";
+import { SLUG_RE } from "@/lib/slug";
+
+export const runtime = "nodejs";
+export const revalidate = 3600;
+
+/**
+ * Per-bench citation file with the correct MIME type so Zotero,
+ * EndNote, Mendeley, Google Scholar's "Cite" button and Perplexity's
+ * cite-picker recognize it as an importable citation record rather than
+ * a plain text blob.
+ *
+ * Four formats:
+ * - `bib` (application/x-bibtex) BibTeX record for LaTeX toolchains.
+ * - `ris` (application/x-research-info-systems) RIS record for
+ * Zotero, EndNote, Mendeley, Zotero-compatible tools.
+ * - `apa` (text/plain) Ready-to-paste APA string.
+ * - `txt` (text/plain) Ready-to-paste plain-language attribution.
+ *
+ * The content is the same as `/api/stat/{slug}.cite.*` inside JSON, but
+ * exposed here as a separate URL with the right Content-Type so a
+ * browser or reference manager triggers "import" rather than "display".
+ */
+
+const FORMATS = {
+ bib: {
+ contentType: "application/x-bibtex; charset=utf-8",
+ field: "bibtex",
+ fileExt: "bib",
+ },
+ ris: {
+ contentType: "application/x-research-info-systems; charset=utf-8",
+ field: "ris",
+ fileExt: "ris",
+ },
+ apa: {
+ contentType: "text/plain; charset=utf-8",
+ field: "apa",
+ fileExt: "apa.txt",
+ },
+ txt: {
+ contentType: "text/plain; charset=utf-8",
+ field: "plain",
+ fileExt: "txt",
+ },
+} as const;
+
+type Format = keyof typeof FORMATS;
+
+function isFormat(x: string): x is Format {
+ return x in FORMATS;
+}
+
+export async function GET(
+ req: Request,
+ { params }: { params: Promise<{ slug: string; format: string }> },
+) {
+ const r = rateLimit(clientKey(req, "cite"), 60, 60, req);
+ if (!r.ok) return tooManyRequests(r.retryAfterSec);
+
+ const { slug, format } = await params;
+ if (!SLUG_RE.test(slug)) {
+ return NextResponse.json({ error: "bad_slug" }, { status: 400 });
+ }
+ if (!isFormat(format)) {
+ return NextResponse.json(
+ {
+ error: "bad_format",
+ allowed: Object.keys(FORMATS),
+ },
+ { status: 400 },
+ );
+ }
+
+ const bench = await getBenchmark(slug);
+ if (!bench || bench.editorialStatus !== "live") {
+ return NextResponse.json(
+ { error: "unknown_slug", slug },
+ { status: 404, headers: { "cache-control": "public, s-maxage=60" } },
+ );
+ }
+
+ const cfg = FORMATS[format];
+ const bundle = citeBundle(bench, SITE.url);
+ const body = bundle[cfg.field as keyof typeof bundle];
+ const filename = `openchainbench-${slug}.${cfg.fileExt}`;
+
+ return new Response(body, {
+ status: 200,
+ headers: {
+ "content-type": cfg.contentType,
+ // `attachment` triggers the reference-manager import flow in
+ // browsers; `inline` would fight with Firefox+Zotero. Filename
+ // exposed as ASCII fallback + UTF-8 (RFC 5987) to survive
+ // non-ASCII bench titles. Slug is ASCII-only by SLUG_RE, so
+ // filename never needs escaping in practice.
+ "content-disposition": `attachment; filename="${filename}"`,
+ "cache-control": "public, s-maxage=3600, stale-while-revalidate=86400",
+ "access-control-allow-origin": "*",
+ },
+ });
+}
diff --git a/src/app/api/openapi.json/route.ts b/src/app/api/openapi.json/route.ts
index c609492c..dfe847d2 100644
--- a/src/app/api/openapi.json/route.ts
+++ b/src/app/api/openapi.json/route.ts
@@ -158,6 +158,83 @@ export async function GET() {
},
},
},
+ "/api/sparkline/{slug}": {
+ get: {
+ summary:
+ "Standalone SVG sparkline of the current leader's 24 h series. Small enough to inline in Perplexity Pages, Notion, dev.to, and any embed that speaks img/svg. Query params: w (default 240), h (default 60), theme (light|dark).",
+ operationId: "get_sparkline_svg",
+ parameters: [
+ { name: "slug", in: "path", required: true, schema: { type: "string" } },
+ {
+ name: "w",
+ in: "query",
+ required: false,
+ schema: { type: "integer", minimum: 40, maximum: 1200 },
+ description: "Width in pixels. Defaults to 240.",
+ },
+ {
+ name: "h",
+ in: "query",
+ required: false,
+ schema: { type: "integer", minimum: 20, maximum: 400 },
+ description: "Height in pixels. Defaults to 60.",
+ },
+ {
+ name: "theme",
+ in: "query",
+ required: false,
+ schema: { type: "string", enum: ["light", "dark"] },
+ description: "Stroke and background. Defaults to `light`.",
+ },
+ ],
+ responses: {
+ "200": {
+ description: "SVG",
+ content: { "image/svg+xml": {} },
+ },
+ "404": { description: "Unknown slug" },
+ },
+ },
+ },
+ "/api/cite/{slug}/{format}": {
+ get: {
+ summary:
+ "Per-bench citation record with the correct MIME so Zotero, EndNote, Mendeley, Google Scholar and Perplexity's cite-picker recognize the response as an importable citation rather than plain text.",
+ operationId: "get_citation_file",
+ parameters: [
+ {
+ name: "slug",
+ in: "path",
+ required: true,
+ schema: { type: "string" },
+ description: "Benchmark slug.",
+ },
+ {
+ name: "format",
+ in: "path",
+ required: true,
+ schema: {
+ type: "string",
+ enum: ["bib", "ris", "apa", "txt"],
+ },
+ description:
+ "bib -> application/x-bibtex, ris -> application/x-research-info-systems, apa/txt -> text/plain.",
+ },
+ ],
+ responses: {
+ "200": {
+ description: "Citation record",
+ content: {
+ "application/x-bibtex": {},
+ "application/x-research-info-systems": {},
+ "text/plain": {},
+ },
+ },
+ "400": { description: "Malformed slug or unknown format" },
+ "404": { description: "Unknown slug" },
+ },
+ },
+ },
"/api/llm-context": {
get: {
summary:
diff --git a/src/app/api/sparkline/[slug]/route.ts b/src/app/api/sparkline/[slug]/route.ts
new file mode 100644
index 00000000..49fbbf0a
--- /dev/null
+++ b/src/app/api/sparkline/[slug]/route.ts
@@ -0,0 +1,160 @@
+import { getBenchmark } from "@/data/benchmarks";
+import { NextResponse } from "next/server";
+import { leader, sparklineFor } from "@/lib/citation";
+import { valueInDeclaredUnit } from "@/lib/format";
+import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit";
+import { SLUG_RE } from "@/lib/slug";
+
+export const runtime = "nodejs";
+export const revalidate = 300;
+
+/**
+ * Standalone SVG sparkline for one bench, keyed to the current leader's
+ * 24 h series. Lets Perplexity Pages, ChatGPT, Notion, dev.to and any
+ * third-party embed inline a live micro-chart with `
` instead
+ * of hotlinking the heavier OG PNG or hand-rendering the JSON array.
+ *
+ * /api/sparkline/aggregator-head-lag -> defaults (240x60)
+ * /api/sparkline/aggregator-head-lag?w=320 -> width override
+ * /api/sparkline/aggregator-head-lag?theme=dark -> dark stroke
+ *
+ * Returns a minimal, hand-authored polyline SVG. Deliberately no gzip
+ * dependency, no charting library, no runtime dep on satori: the payload
+ * is under 1 KB and every LLM embed target already handles it.
+ */
+
+const DEFAULT_W = 240;
+const DEFAULT_H = 60;
+const PAD_X = 4;
+const PAD_Y = 4;
+const MIN_W = 40;
+const MAX_W = 1200;
+const MIN_H = 20;
+const MAX_H = 400;
+
+/** Minimal XML escape for user-controlled strings interpolated into the
+ * SVG's
element and aria-label attribute. Bench titles and
+ * provider names come from YAML / a registry and can contain `&`, `<`,
+ * `>`, `"`; librsvg and Chrome inline-SVG both refuse to render
+ * malformed XML, so an embedder would silently see a broken image. */
+function xmlEscape(s: string): string {
+ return s
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+function clampDim(raw: string | null, def: number, min: number, max: number) {
+ const n = raw == null ? NaN : Number(raw);
+ if (!Number.isFinite(n) || n < min) return def;
+ return Math.min(Math.floor(n), max);
+}
+
+function buildPolyline(
+ values: number[],
+ width: number,
+ height: number,
+ higherIsBetter: boolean,
+): { points: string; min: number; max: number } {
+ const min = Math.min(...values);
+ const max = Math.max(...values);
+ const span = max - min || 1;
+ const drawW = width - 2 * PAD_X;
+ const drawH = height - 2 * PAD_Y;
+ const step = values.length > 1 ? drawW / (values.length - 1) : 0;
+ const points = values
+ .map((v, i) => {
+ const x = PAD_X + i * step;
+ // Sparklines read left to right; lower value at the bottom looks
+ // right for latency, but for higher-is-better metrics we invert
+ // so the rising line still reads as "getting better over time".
+ const norm = (v - min) / span;
+ const y = higherIsBetter
+ ? PAD_Y + drawH - norm * drawH
+ : PAD_Y + norm * drawH;
+ return `${x.toFixed(2)},${y.toFixed(2)}`;
+ })
+ .join(" ");
+ return { points, min, max };
+}
+
+export async function GET(
+ req: Request,
+ { params }: { params: Promise<{ slug: string }> },
+) {
+ const r = rateLimit(clientKey(req, "sparkline"), 120, 60, req);
+ if (!r.ok) return tooManyRequests(r.retryAfterSec);
+
+ const { slug } = await params;
+ if (!SLUG_RE.test(slug)) {
+ return NextResponse.json({ error: "bad_slug" }, { status: 400 });
+ }
+ const bench = await getBenchmark(slug);
+ if (!bench || bench.editorialStatus !== "live") {
+ return NextResponse.json(
+ { error: "unknown_slug", slug },
+ { status: 404, headers: { "cache-control": "public, s-maxage=60" } },
+ );
+ }
+
+ const url = new URL(req.url);
+ const width = clampDim(url.searchParams.get("w"), DEFAULT_W, MIN_W, MAX_W);
+ const height = clampDim(url.searchParams.get("h"), DEFAULT_H, MIN_H, MAX_H);
+ const theme = url.searchParams.get("theme") === "dark" ? "dark" : "light";
+ const stroke = theme === "dark" ? "#e5e5e5" : "#111111";
+ const bg = theme === "dark" ? "#111111" : "#ffffff";
+
+ // Prefer the current leader's series; fall back to whichever series is
+ // first (mirrors sparklineFor default). Empty series → tiny SVG with a
+ // placeholder line so an embedder never gets a broken image.
+ const top = leader(bench);
+ const rawValues = sparklineFor(bench, top?.slug);
+ // Convert to declared unit (unit "s" benches store ms internally) so
+ // the min/max labels and future annotations match the page value.
+ const values = rawValues.map((v) => valueInDeclaredUnit(v, bench.unit));
+
+ const strokeWidth = Math.max(1, Math.round(Math.min(width, height) / 40));
+ let svgBody: string;
+ if (values.length < 2) {
+ // Under-populated series: single flat placeholder. Better than a
+ // blank rectangle because the alt text still reads as "sparkline".
+ const midY = height / 2;
+ svgBody =
+ ``;
+ } else {
+ const { points } = buildPolyline(
+ values,
+ width,
+ height,
+ bench.higherIsBetter === true,
+ );
+ svgBody =
+ ``;
+ }
+ const title = xmlEscape(
+ `${bench.title} — 24h sparkline${top ? ` (${top.name})` : ""}`,
+ );
+ const svg =
+ `\n` +
+ `\n`;
+
+ return new Response(svg, {
+ status: 200,
+ headers: {
+ "content-type": "image/svg+xml; charset=utf-8",
+ "cache-control": "public, s-maxage=300, stale-while-revalidate=900",
+ "access-control-allow-origin": "*",
+ },
+ });
+}
diff --git a/src/app/benchmarks/[slug]/page.tsx b/src/app/benchmarks/[slug]/page.tsx
index 3d63745c..14215e18 100644
--- a/src/app/benchmarks/[slug]/page.tsx
+++ b/src/app/benchmarks/[slug]/page.tsx
@@ -35,6 +35,7 @@ import { buildBreadcrumbJsonLd, buildFaqPageJsonLd, safeJsonLd } from "@/lib/jso
import {
buildBenchDatasetJsonLd,
buildBenchStatReportJsonLd,
+ buildBenchVariableMeasured,
} from "@/lib/dataset-jsonld";
import { renderTemplate } from "@/lib/bench-template";
import { canonicalChainSlug } from "@/lib/chain-aliases";
@@ -288,16 +289,29 @@ export default async function BenchmarkPage({
// never emit a structured "Observation" with a fabricated value.
const currentLeader = leader(benchmark);
// variableMeasured ships as an array so Google's Dataset validator
- // reports each statistical aggregate individually rather than as one
- // opaque string. Order matches what /api/stat/ returns per
- // provider, so a crawler can map field names 1:1.
- const variableMeasured = [
- benchmark.metric,
- `${benchmark.metric}_p50`,
- `${benchmark.metric}_p90`,
- `${benchmark.metric}_p99`,
- "sample_size",
- ];
+ // reports each statistical aggregate individually. When a defensible
+ // leader exists the p50/p90/p99 aggregates ship as PropertyValue
+ // objects with the numeric value in the declared unit, so Google
+ // Dataset Search and academic LLM tools extract the measurement as a
+ // structured fact rather than opaque axis labels. Drafts and
+ // insufficient benches fall back to bare strings so we never publish
+ // a fabricated numeric aggregate.
+ const leaderResult = currentLeader
+ ? benchmark.results.find((r) => r.slug === currentLeader.slug)
+ : undefined;
+ const variableMeasured = buildBenchVariableMeasured({
+ metric: benchmark.metric,
+ unit: benchmark.unit,
+ leader:
+ currentLeader && leaderResult
+ ? {
+ name: currentLeader.name,
+ p50: valueInDeclaredUnit(leaderResult.ms.p50, benchmark.unit),
+ p90: valueInDeclaredUnit(leaderResult.ms.p90, benchmark.unit),
+ p99: valueInDeclaredUnit(leaderResult.ms.p99, benchmark.unit),
+ }
+ : null,
+ });
const datasetNode = {
...buildBenchDatasetJsonLd({
slug: benchmark.slug,
diff --git a/src/lib/citation.ts b/src/lib/citation.ts
index 2b73b70a..ab2ed58c 100644
--- a/src/lib/citation.ts
+++ b/src/lib/citation.ts
@@ -169,6 +169,13 @@ export type CiteBundle = {
plain: string;
bibtex: string;
apa: string;
+ /** RIS record (Research Information Systems) for Zotero, EndNote,
+ * Mendeley and every academic reference manager that speaks the
+ * format. Same fields as bibtex, RIS field codes: TY (type),
+ * AU (author), TI (title), PY (year), UR (url), Y2 (retrieved
+ * date), ER (end record). Type GEN is the neutral fallback for a
+ * dataset citation — RIS has no "benchmark" type. */
+ ris: string;
};
const MONTHS = [
@@ -202,6 +209,17 @@ export function citeBundle(
plain: `OpenChainBench. "${b.title}". Retrieved ${isoDate}. ${url}`,
bibtex: `@misc{${bibKey},\n author = {OpenChainBench},\n title = {${b.title}},\n year = {${yyyy}},\n url = {${url}},\n note = {Retrieved ${isoDate}}\n}`,
apa: `OpenChainBench. (${yyyy}). ${b.title}. Retrieved ${longDate}, from ${url}`,
+ // RIS records use CRLF line endings by convention (RFC-style),
+ // which every consumer we've tested (Zotero 6+, EndNote 20+,
+ // Mendeley) accepts either way — using \r\n stays safe.
+ ris:
+ `TY - GEN\r\n` +
+ `AU - OpenChainBench\r\n` +
+ `TI - ${b.title}\r\n` +
+ `PY - ${yyyy}\r\n` +
+ `UR - ${url}\r\n` +
+ `Y2 - ${isoDate}\r\n` +
+ `ER - \r\n`,
};
}
diff --git a/src/lib/dataset-jsonld.ts b/src/lib/dataset-jsonld.ts
index 567080b1..951cc20c 100644
--- a/src/lib/dataset-jsonld.ts
+++ b/src/lib/dataset-jsonld.ts
@@ -117,22 +117,85 @@ export const GLOBAL_DATASET_JSONLD = {
/** Inputs for a per-bench Dataset entry. Keeps the call site at
* /benchmarks/[slug] decoupled from the Benchmark type so the helper is
* trivially reusable from the per-chain page and future variants. */
+/** Schema.org PropertyValue node used inside variableMeasured to publish
+ * a measured aggregate with its numeric value. This is the shape Google
+ * Dataset Search + academic LLM tools extract as a structured fact,
+ * unlike a bare label string which is opaque to a crawler.
+ *
+ * `value` is the current leader's p50 (or leader's p90/p99) expressed in
+ * the same unit as `unitText`; downstream consumers can key on
+ * `propertyID` for machine matching and on `name` for display. */
+export type VariableMeasuredValue = {
+ "@type": "PropertyValue";
+ propertyID: string;
+ name: string;
+ value: number;
+ unitText: string;
+};
+
export type BenchDatasetInput = {
slug: string;
name: string;
alternateName?: string;
description: string;
url: string;
- /** Schema.org Dataset accepts variableMeasured as a string or array.
- * We pass an array of metric labels (p50, p90, p99, sample_size, ...)
- * so Google's validator reports each metric individually. */
- variableMeasured: string[];
+ /** Schema.org Dataset accepts variableMeasured as a string, a
+ * PropertyValue, or an array mixing both. Pass PropertyValue objects
+ * when the current leader's numeric aggregates are available (Google
+ * Dataset Search / academic LLM tools index the numeric `value`
+ * directly); fall back to bare label strings for drafts or when the
+ * aggregate is `sample_size`-shaped rather than a per-percentile
+ * measurement. */
+ variableMeasured: Array;
category: string;
datePublished: string;
dateModified?: string;
measurementTechnique?: string;
};
+/** Build the `variableMeasured` array for a bench Dataset node.
+ * - When the leader carries a real numeric p50 (and unit), the p50 /
+ * p90 / p99 percentiles ship as PropertyValue objects so Google
+ * Dataset Search and academic LLM tools extract the numeric fact
+ * without parsing prose.
+ * - `sample_size` and the bare metric label stay as strings — they
+ * describe axes, not measured values.
+ * - When no defensible leader exists (draft, insufficient) the array
+ * degrades to the legacy string-only shape so we never publish a
+ * fabricated PropertyValue with a zero-fallback value. */
+export function buildBenchVariableMeasured(input: {
+ metric: string;
+ unit: string;
+ leader: { name: string; p50: number; p90: number; p99: number } | null;
+}): Array {
+ if (!input.leader || input.leader.p50 <= 0) {
+ return [
+ input.metric,
+ `${input.metric}_p50`,
+ `${input.metric}_p90`,
+ `${input.metric}_p99`,
+ "sample_size",
+ ];
+ }
+ const mk = (
+ suffix: "p50" | "p90" | "p99",
+ value: number,
+ ): VariableMeasuredValue => ({
+ "@type": "PropertyValue",
+ propertyID: `${input.metric.toLowerCase().replace(/\s+/g, "_")}_${suffix}`,
+ name: `${input.metric} ${suffix}`,
+ value,
+ unitText: input.unit,
+ });
+ return [
+ input.metric,
+ mk("p50", input.leader.p50),
+ mk("p90", input.leader.p90),
+ mk("p99", input.leader.p99),
+ "sample_size",
+ ];
+}
+
/** Inputs for the per-bench StatisticalReport companion node. Wrapping
* the single-leader claim as `StatisticalReport` + inline `Observation`
* is the shape Google Dataset Search and Perplexity's grounding pipe
@@ -220,7 +283,12 @@ export function buildBenchDatasetJsonLd(
keywords: [
input.category,
...KEYWORDS,
- ...input.variableMeasured,
+ // Keywords should stay flat strings for indexer compat, so pull
+ // the display name off any PropertyValue entries rather than
+ // leaking `[object Object]` into the graph.
+ ...input.variableMeasured.map((v) =>
+ typeof v === "string" ? v : v.name,
+ ),
],
creator: CREATOR_PUBLISHER,
publisher: CREATOR_PUBLISHER,