SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%

web D1: models terminal 3.0, model page 3.0, artifacts, model diff, compare 3.0, benchmarks 2.0 (directory · leaderboard · matrix · cost-vs-performance), families, licences, per-type OG images, qa/d1.mjs

- /models: TerminalLayout with API facets (orgs, families, openness, canonical licences, modalities, status, years, identity trust), 7B/128K range inputs, include-artifacts toggle, understood-as chips, dense sortable table with column chooser (localStorage), keyboard row focus, inspector pane (mobile: sheet), Compare + Watch
- /models/[slug]: identity strip + SectionNav (Overview … Provenance), identity panel, openness block from ontology dimensions, grouped benchmarks with trust/comparability + vs-leader delta, deployments table, price-history step charts with evidence, version-history transitions, hardware fit (estimated), SVG lineage tree, artifacts by kind, change history + as-of; artifact and folded-variant slugs 308 to their canonical URL
- /artifacts/[slug], /models/[a]/diff/[b], /compare (modes, row groups, hide-identical, diff_only, use-case emphasis, per-cell evidence, stacked mobile layout)
- /benchmarks (family-grouped directory, honesty line), /benchmarks/[slug] (comparability-group picker, trust/org/comparable-only filters, one-row-per-model leaderboard, frontier over time, definition with evidence), /benchmarks/matrix (Heatmap by within-column rank), /benchmarks/[slug]/cost-vs-performance (Pareto, client-only scatter)
- /families, /families/[slug] (release lanes, members, lineage mini-graph, licence mix, benchmark progress), /licenses, /licenses/[key]
- lib: D1 blocks in types.ts / api.ts (apiD1)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 12 days ago (Sep 12, 2026) parent f48531f

41 changed files +6,726 −487

added apps/web/qa/d1.mjs +196 −0
@@ -0,0 +1,196 @@
1 +/**
2 + * Stream D1 QA sweep — models · artifacts · families · benchmarks · compare · licences.
3 + * Widths 320 · 360 · 375 · 390 · 430 · 768 · 1366 · 1440 · 1920, dark + light. Slugs are discovered live from the API.
4 + * Asserts: HTTP status (308 for merged / artifact slugs under /models), zero console errors, no horizontal overflow
5 + * (scrollWidth ≤ clientWidth), the evidence drawer opens from a model-page value, the matrix renders ≥ 1 heatmap cell,
6 + * the leaderboard has one row per model (no duplicate model slug in the first 50 rows).
7 + * Run: node qa/d1.mjs [BASE=http://localhost:8341] [API=http://127.0.0.1:8332] (FAST=1 → 390 + 1440 only)
8 + */
9 +import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';
10 +import { mkdirSync } from 'node:fs';
11 +
12 +const BASE = process.argv[2] ?? 'http://localhost:8341';
13 +const API = (process.argv[3] ?? 'http://127.0.0.1:8332') + '/api/v1';
14 +const OUT = new URL('./screens/d1/', import.meta.url).pathname;
15 +mkdirSync(OUT, { recursive: true });
16 +const WIDTHS = process.env.FAST === '1' ? [390, 1440] : [320, 360, 375, 390, 430, 768, 1366, 1440, 1920];
17 +const THEMES = ['dark', 'light'];
18 +const j = async (u) => (await fetch(API + u)).json();
19 +
20 +// ---------------------------------------------------------------------------------------------------------- discover slugs
21 +const top = await j('/models?limit=3&sort=quality');
22 +const [a, b, c] = top.items.map((m) => m.slug);
23 +const artRes = await j('/models?include=artifacts&limit=80');
24 +// prefer an artifact whose canonical model is resolved (the page shows a prominent link + note); fall back to any artifact
25 +const artifactRow = artRes.items.find((m) => m.entity_type === 'artifact' && m.canonical) ?? artRes.items.find((m) => m.entity_type === 'artifact');
26 +const artifact = artifactRow?.slug;
27 +const artifactHasCanonical = !!artifactRow?.canonical;
28 +const detail = await j(`/models/${encodeURIComponent(a)}`);
29 +let merged = null;
30 +for (const alias of detail.identity?.api_aliases ?? []) {
31 + if (alias.includes('/') || alias === a) continue;
32 + const d = await j(`/models/${encodeURIComponent(alias)}`);
33 + if (d.redirected_from && d.slug === a) {
34 + merged = alias;
35 + break;
36 + }
37 +}
38 +const fam = (await j('/families?limit=1&sort=models')).items[0]?.slug;
39 +const benches = (await j('/benchmarks')).items.filter((x) => Number(x.result_count) > 0);
40 +const bench = benches.find((x) => x.slug === 'gpqa-diamond')?.slug ?? benches[0]?.slug;
41 +const lic = (await j('/licenses')).items[0]?.key ?? 'Apache-2.0';
42 +console.log(`slugs: model=${a} artifact=${artifact} merged=${merged} family=${fam} benchmark=${bench} licence=${lic}`);
43 +
44 +const PAGES = [
45 + '/models',
46 + '/models?openness=open-weights&min_params=7B&sort=params',
47 + `/models/${a}`,
48 + `/models/${a}/diff/${b}`,
49 + `/artifacts/${artifact}`,
50 + `/compare?ids=${a},${b},${c}`,
51 + '/benchmarks',
52 + `/benchmarks/${bench}`,
53 + '/benchmarks/matrix',
54 + `/benchmarks/${bench}/cost-vs-performance`,
55 + '/families',
56 + `/families/${fam}`,
57 + '/licenses',
58 + `/licenses/${encodeURIComponent(lic)}`,
59 +];
60 +// 404 favicon noise and 429s from the API's per-IP rate limiter (the sweep loads a page every second) are not page bugs.
61 +const filterErrors = (errors) => errors.filter((e) => !/favicon|Failed to load resource: the server responded with a status of (404|429)/.test(e));
62 +
63 +let failures = 0;
64 +const ok = (cond, msg) => {
65 + if (!cond) failures++;
66 + console.log(`${cond ? 'OK ' : 'FAIL'} ${msg}`);
67 +};
68 +
69 +// ---------------------------------------------------------------------------------------------------------- redirects
70 +for (const [path, expect] of [
71 + [`/models/${artifact}`, `/artifacts/${artifact}`],
72 + [`/models/${merged}`, `/models/${a}`],
73 +]) {
74 + if (!path.includes('null')) {
75 + const r = await fetch(BASE + path, { redirect: 'manual' });
76 + const loc = r.headers.get('location') ?? '';
77 + ok((r.status === 308 || r.status === 301) && loc.endsWith(expect), `redirect ${path} → ${r.status} ${loc}`);
78 + } else console.log(`SKIP redirect (no slug discovered) ${path}`);
79 +}
80 +for (const path of ['/models/does-not-exist-xyz', '/artifacts/does-not-exist-xyz', '/families/does-not-exist-xyz', '/licenses/does-not-exist-xyz', '/benchmarks/does-not-exist-xyz']) {
81 + const r = await fetch(BASE + path);
82 + ok(r.status === 404, `404 ${path} → ${r.status}`);
83 +}
84 +for (const path of [`/models/${a}/opengraph-image`, `/artifacts/${artifact}/opengraph-image`, `/benchmarks/${bench}/opengraph-image`, `/families/${fam}/opengraph-image`, `/licenses/${encodeURIComponent(lic)}/opengraph-image`]) {
85 + const r = await fetch(BASE + path);
86 + ok(r.status === 200 && (r.headers.get('content-type') ?? '').startsWith('image/png'), `og ${path} → ${r.status} ${r.headers.get('content-type')}`);
87 +}
88 +
89 +// ---------------------------------------------------------------------------------------------------------- sweep
90 +const browser = await chromium.launch();
91 +for (const theme of THEMES) {
92 + for (const width of WIDTHS) {
93 + const mobile = width < 768;
94 + const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: theme });
95 + await ctx.addInitScript((t) => localStorage.setItem('aia-theme', t), theme);
96 + const page = await ctx.newPage();
97 + for (const path of PAGES) {
98 + const errors = [];
99 + const onErr = (e) => errors.push(String(e));
100 + const onCon = (m) => {
101 + if (m.type() === 'error') errors.push(m.text());
102 + };
103 + page.on('pageerror', onErr);
104 + page.on('console', onCon);
105 + const t0 = Date.now();
106 + const res = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 90000 }).catch((e) => ({ status: () => `ERR ${e.message.slice(0, 40)}` }));
107 + await page.waitForTimeout(400);
108 + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth).catch(() => -1);
109 + const filtered = filterErrors(errors);
110 + const status = res.status();
111 + const good = status === 200 && overflow <= 0 && filtered.length === 0;
112 + if (!good) failures++;
113 + console.log(`${good ? 'OK ' : 'FAIL'} ${theme.padEnd(5)} ${String(width).padStart(4)} ${status} ${String(Date.now() - t0).padStart(5)}ms overflow=${overflow} errors=${filtered.length} ${path}${filtered.length ? ' :: ' + filtered[0].slice(0, 160) : ''}`);
114 + if ([390, 1440].includes(width) || !good) await page.screenshot({ path: `${OUT}${theme}-${width}-${path.replace(/[^a-z0-9]+/gi, '_').replace(/^_|_$/g, '')}.png`, fullPage: false }).catch(() => undefined);
115 + page.off('pageerror', onErr);
116 + page.off('console', onCon);
117 + }
118 + await ctx.close();
119 + }
120 +}
121 +
122 +// ---------------------------------------------------------------------------------------------------------- flows (1440 dark)
123 +{
124 + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, colorScheme: 'dark' });
125 + await ctx.addInitScript(() => localStorage.setItem('aia-theme', 'dark'));
126 + const page = await ctx.newPage();
127 +
128 + // evidence drawer from a model page value
129 + await page.goto(`${BASE}/models/${a}`, { waitUntil: 'networkidle' });
130 + const trigger = page.locator('[data-identity-strip] [data-evidence]').first();
131 + ok((await trigger.count()) > 0, 'model page: identity strip has evidence triggers');
132 + await trigger.click();
133 + const drawer = await page.waitForSelector('[data-evidence-drawer]', { timeout: 8000 }).catch(() => null);
134 + ok(!!drawer, 'model page: evidence drawer opens from an identity-strip value');
135 + const drawerText = drawer ? await drawer.innerText() : '';
136 + ok(/Source|Tier|Observed/i.test(drawerText), 'evidence drawer shows source · tier · observed');
137 + await page.screenshot({ path: `${OUT}flow-evidence-drawer.png` });
138 + await page.keyboard.press('Escape');
139 + // section nav present with sections in order
140 + const navItems = await page.$$eval('[data-section-nav] a', (as) => as.map((x) => x.textContent.trim()));
141 + ok(navItems[0] === 'Overview' && navItems.at(-1) === 'Provenance', `section nav order: ${navItems.join(' · ')}`);
142 + // every rendered section id has a nav item
143 + const sectionIds = await page.$$eval('section[data-section]', (ss) => ss.map((s) => s.id));
144 + const navIds = await page.$$eval('[data-section-nav] a', (as) => as.map((x) => x.getAttribute('href').slice(1)));
145 + ok(sectionIds.every((id) => navIds.includes(id)), `all ${sectionIds.length} sections are in the nav`);
146 +
147 + // models list: inspector reflects the inspected row; column chooser
148 + await page.goto(`${BASE}/models`, { waitUntil: 'networkidle' });
149 + const second = page.locator('[data-models-table] tr[data-row]').nth(1);
150 + const secondSlug = await second.getAttribute('data-row');
151 + await second.locator('[data-inspect]').click();
152 + await page.waitForTimeout(200);
153 + const inspected = await page.getAttribute('aside[aria-label="Inspector"] [data-models-inspector]', 'data-inspected');
154 + ok(inspected === secondSlug, `models inspector shows the inspected row (${inspected})`);
155 + await page.click('[data-column-chooser]');
156 + const chooserLabels = await page.$$eval('[role="group"][aria-label="Visible columns"] label', (ls) => ls.length);
157 + ok(chooserLabels >= 6, `column chooser lists ${chooserLabels} columns`);
158 + const understood = await page.goto(`${BASE}/models?openness=open-weights&min_params=7B`, { waitUntil: 'networkidle' }).then(() => page.$$eval('[data-understood] a', (as) => as.map((x) => x.textContent.trim())));
159 + ok(understood.some((t) => /parameters ≥ 7B/.test(t)) && understood.some((t) => /Open weights/.test(t)), `understood-as chips: ${understood.join(' | ')}`);
160 + const countText = await page.textContent('[data-models-count]');
161 + ok(/canonical models/.test(countText ?? ''), `count label: ${countText?.trim().slice(0, 60)}`);
162 +
163 + // matrix renders cells
164 + await page.goto(`${BASE}/benchmarks/matrix`, { waitUntil: 'networkidle' });
165 + const cells = await page.$$eval('table.heatmap td:not(.empty)', (tds) => tds.length);
166 + ok(cells >= 1, `matrix renders ${cells} heatmap cells`);
167 +
168 + // leaderboard: one row per model
169 + await page.goto(`${BASE}/benchmarks/${bench}`, { waitUntil: 'networkidle' });
170 + const slugs = await page.$$eval('[data-leaderboard] tr[data-model]', (trs) => trs.slice(0, 50).map((t) => t.getAttribute('data-model')));
171 + ok(slugs.length > 0 && new Set(slugs).size === slugs.length, `leaderboard: ${slugs.length} rows, ${new Set(slugs).size} distinct models in the first 50`);
172 + const lbApi = await j(`/benchmarks/${bench}/leaderboard?limit=50`);
173 + ok(slugs[0] === lbApi.items[0]?.model.slug, `leaderboard first row matches the API leader (${slugs[0]})`);
174 +
175 + // compare: hide-identical toggle + differences-only link
176 + await page.goto(`${BASE}/compare?ids=${a},${b},${c}`, { waitUntil: 'networkidle' });
177 + const before = await page.$$eval('[data-compare-table] tr[data-dim]', (r) => r.length);
178 + await page.click('[data-hide-identical]');
179 + await page.waitForTimeout(150);
180 + const after = await page.$$eval('[data-compare-table] tr[data-dim]', (r) => r.length);
181 + ok(after >= before, `compare: ${before} rows with identical hidden → ${after} rows shown when unhidden`);
182 + const diffOnlyHref = await page.getAttribute('[data-diff-only]', 'href');
183 + ok((diffOnlyHref ?? '').includes('diff_only=1'), `compare: differences-only link → ${diffOnlyHref}`);
184 + const evidenceCells = await page.$$eval('[data-compare-table] [data-evidence]', (x) => x.length);
185 + ok(evidenceCells > 0, `compare: ${evidenceCells} cells open evidence`);
186 +
187 + // artifact page note + canonical link
188 + await page.goto(`${BASE}/artifacts/${artifact}`, { waitUntil: 'networkidle' });
189 + if (artifactHasCanonical) ok((await page.locator('[data-artifact-note]').count()) === 1, 'artifact page shows the "not an independent model" note with a canonical link');
190 + else ok((await page.locator('[data-artifact-header]').count()) === 1, `artifact page renders (canonical model unresolved for ${artifact} — honest fallback note)`);
191 + await ctx.close();
192 +}
193 +
194 +await browser.close();
195 +console.log(failures ? `\n${failures} failure(s)` : '\nall D1 checks OK');
196 +process.exit(failures ? 1 : 0);
added apps/web/src/app/artifacts/[slug]/opengraph-image.tsx +24 −0
@@ -0,0 +1,24 @@
1 +import { ImageResponse } from 'next/og';
2 +import { Fallback, Wallpaper } from '@/components/brand/og';
3 +import { apiD1, safe } from '@/lib/api';
4 +import { fmtGb, fmtInt, num } from '@/lib/format';
5 +import { routes, SITE_NAME } from '@/lib/site';
6 +
7 +export const runtime = 'nodejs';
8 +export const alt = `Artifact on ${SITE_NAME}`;
9 +export const size = { width: 1200, height: 630 };
10 +export const contentType = 'image/png';
11 +
12 +export default async function ArtifactOgImage({ params }: { params: Promise<{ slug: string }> }) {
13 + const { slug } = await params;
14 + const d = await safe(apiD1.model(slug));
15 + if (!d || d.entity_type !== 'artifact') return new ImageResponse(<Fallback label="Artifact" />, { ...size });
16 + const a = d.attributes ?? {};
17 + const counters: [string, string][] = [];
18 + if (typeof a.quant_format === 'string') counters.push(['Format', String(a.quant_format).toUpperCase()]);
19 + if (num(a.file_size_gb) !== null) counters.push(['File size', fmtGb(a.file_size_gb, 1)]);
20 + if (num(a['metric.downloads']) !== null) counters.push(['Downloads', fmtInt(a['metric.downloads'])]);
21 + if (d.organization) counters.push(['Publisher', d.organization.name.slice(0, 18)]);
22 + const kind = d.artifact_kind ?? 'artifact';
23 + return new ImageResponse(<Wallpaper eyebrow={`Artifact · ${kind}`} title={d.name} subtitle={d.canonical ? `${kind.charAt(0).toUpperCase()}${kind.slice(1)} of ${d.canonical.name} — not an independent model` : 'Packaging of a canonical model'} counters={counters.slice(0, 4)} footer={`www.ai-atlas.co${routes.artifact(d.slug)}`} markPx={220} />, { ...size });
24 +}
added apps/web/src/app/artifacts/[slug]/page.tsx +37 −0
@@ -0,0 +1,37 @@
1 +import type { Metadata } from 'next';
2 +import { notFound, permanentRedirect } from 'next/navigation';
3 +import { ArtifactPage, describeArtifact } from '@/components/entity/artifact-page';
4 +import { ApiError, apiD1, safe } from '@/lib/api';
5 +import { routes, SITE_NAME, SITE_URL } from '@/lib/site';
6 +import type { ModelDetail } from '@/lib/types';
7 +
8 +type Params = { params: Promise<{ slug: string }> };
9 +
10 +async function load(slug: string): Promise<ModelDetail> {
11 + try {
12 + return await apiD1.model(slug);
13 + } catch (e) {
14 + if (e instanceof ApiError && e.notFound) notFound();
15 + throw e;
16 + }
17 +}
18 +
19 +export async function generateMetadata({ params }: Params): Promise<Metadata> {
20 + const { slug } = await params;
21 + const d = await safe(apiD1.model(slug));
22 + if (!d || d.entity_type !== 'artifact' || d.slug !== slug) return { title: 'Artifact', robots: { index: false } };
23 + const kind = d.artifact_kind ?? 'artifact';
24 + const title = `${d.name} — ${kind.charAt(0).toUpperCase()}${kind.slice(1)} of ${d.canonical?.name ?? 'a model'}`;
25 + const description = describeArtifact(d);
26 + const canonical = routes.artifact(d.slug);
27 + return { title, description, alternates: { canonical }, openGraph: { title: `${title} | ${SITE_NAME}`, description, url: `${SITE_URL}${canonical}`, type: 'article', siteName: SITE_NAME }, twitter: { card: 'summary_large_image', title, description } };
28 +}
29 +
30 +export default async function ArtifactRoute({ params }: Params) {
31 + const { slug } = await params;
32 + const d = await load(slug);
33 + // A model slug under /artifacts → its canonical URL; unknown types → 404.
34 + if (d.entity_type !== 'artifact') permanentRedirect(routes.entity(d));
35 + if (d.slug !== slug) permanentRedirect(routes.artifact(d.slug));
36 + return <ArtifactPage d={d} canonical={routes.artifact(d.slug)} />;
37 +}
added apps/web/src/app/benchmarks/[slug]/cost-vs-performance/page.tsx +257 −0
@@ -0,0 +1,257 @@
1 +import type { Metadata } from 'next';
2 +import { ScrollX } from '@/components/models/scroll-x';
3 +import Link from 'next/link';
4 +import { notFound } from 'next/navigation';
5 +import type { ScatterPoint } from '@/components/charts';
6 +import { ParetoChart } from '@/components/benchmarks/client-charts';
7 +import { TrustBadge } from '@/components/models/badges';
8 +import { configChipsOf, fmtScoreUnit, opennessLabel, xFormatter } from '@/components/models/shared';
9 +import { Estimated } from '@/components/ui/badges';
10 +import { DataTable, Td, Th } from '@/components/ui/data-table';
11 +import { Container, Note, PageHeader } from '@/components/ui/section';
12 +import { EmptyState, Unavailable } from '@/components/ui/unavailable';
13 +import { ApiError, apiD1, safe } from '@/lib/api';
14 +import { cn } from '@/lib/cn';
15 +import { fmtInt } from '@/lib/format';
16 +import { routes, SITE_NAME, SITE_URL } from '@/lib/site';
17 +import type { ParetoPayload } from '@/lib/types';
18 +
19 +/*
20 + Cost vs performance for one benchmark (Pareto view from `/pareto`): X = cheapest current output price (log toggle) or another axis,
21 + Y = score in the comparability group, bubble = context or parameters (joined from a second /pareto call), frontier from the API.
22 +*/
23 +
24 +type SP = Record<string, string | undefined>;
25 +type Params = { params: Promise<{ slug: string }>; searchParams: Promise<SP> };
26 +export const revalidate = 600;
27 +const X_AXES = [
28 + { key: 'output_price', label: 'Output price' },
29 + { key: 'input_price', label: 'Input price' },
30 + { key: 'parameter_count', label: 'Parameters' },
31 + { key: 'context_length', label: 'Context' },
32 + { key: 'memory_estimate', label: 'Memory (est.)' },
33 +];
34 +
35 +export async function generateMetadata({ params }: Params): Promise<Metadata> {
36 + const { slug } = await params;
37 + const d = await safe(apiD1.benchmark(slug));
38 + if (!d || d.entity_type !== 'benchmark') return { title: 'Cost vs performance', robots: { index: false } };
39 + const title = `${d.name} — Cost vs Performance (Pareto)`;
40 + const description = `Every canonical model with a current ${d.name} result plotted against its cheapest current output price (USD per 1M tokens), with the Pareto frontier — same comparability group only, trust level on every point. ${SITE_NAME}.`;
41 + const canonical = `${routes.benchmark(d.slug)}/cost-vs-performance`;
42 + return { title, description, alternates: { canonical }, openGraph: { title: `${title} | ${SITE_NAME}`, description, url: `${SITE_URL}${canonical}`, type: 'article' } };
43 +}
44 +
45 +async function loadPareto(q: Record<string, string | number | undefined>): Promise<{ res: ParetoPayload | null; error: string | null }> {
46 + try {
47 + return { res: await apiD1.pareto(q), error: null };
48 + } catch (e) {
49 + if (e instanceof ApiError && e.notFound) notFound();
50 + if (e instanceof ApiError && (e.status === 400 || e.status === 422)) return { res: null, error: e.detail ?? 'Unsupported axis.' };
51 + return { res: null, error: null };
52 + }
53 +}
54 +
55 +export default async function CostVsPerformancePage({ params, searchParams }: Params) {
56 + const { slug } = await params;
57 + const sp = await searchParams;
58 + const x = X_AXES.find((a) => a.key === sp.x) ?? X_AXES[0]!;
59 + const log = sp.log !== '0';
60 + const bubble = sp.bubble === 'params' ? 'params' : sp.bubble === 'none' ? 'none' : 'context';
61 + const metric = sp.metric?.trim() || undefined;
62 + const configKey = sp.config_key?.trim() || undefined;
63 + const org = sp.org?.trim() || undefined;
64 + const openness = sp.openness?.trim() || undefined;
65 + const base = { benchmark: slug, metric, config_key: configKey, org, openness };
66 + const [detail, { res, error }, bubbleRes] = await Promise.all([safe(apiD1.benchmark(slug)), loadPareto({ ...base, x: x.key }), bubble === 'none' ? Promise.resolve(null) : safe(apiD1.pareto({ ...base, x: bubble === 'params' ? 'parameter_count' : 'context_length' }))]);
67 + if (!detail || detail.entity_type !== 'benchmark') notFound();
68 + const canonical = `${routes.benchmark(detail.slug)}/cost-vs-performance`;
69 + const href = (patch: SP) => {
70 + const p = new URLSearchParams();
71 + for (const [k, v] of Object.entries({ x: x.key === 'output_price' ? undefined : x.key, log: log ? undefined : '0', bubble: bubble === 'context' ? undefined : bubble, metric, config_key: configKey, org, openness, ...patch })) if (v) p.set(k, v);
72 + const s = p.toString();
73 + return `${canonical}${s ? `?${s}` : ''}`;
74 + };
75 + const unit = typeof detail.attributes?.unit === 'string' ? (detail.attributes.unit as string) : null;
76 + const bubbleById = new Map<string, number>();
77 + for (const p of bubbleRes?.points ?? []) bubbleById.set(p.model.id, p.x);
78 + const frontierSet = new Set(res?.frontier ?? []);
79 + const points: ScatterPoint[] = (res?.points ?? []).map((p) => {
80 + const chips = configChipsOf(p.config, res?.group?.config ?? null, 3);
81 + return {
82 + id: p.id,
83 + x: p.x,
84 + y: p.y,
85 + r: bubble === 'none' ? 1 : bubbleById.get(p.model.id) ?? 1,
86 + label: p.model.name,
87 + sub: [p.model.organization, p.provider?.name ? `via ${p.provider.name}` : null, `rank ${p.rank}`, p.trust_level, chips.map((c) => `${c.key}=${c.value}`).join(' ') || null, p.estimated ? 'estimated' : null].filter(Boolean).join(' · '),
88 + href: `/models/${encodeURIComponent(p.model.slug)}`,
89 + color: p.model.openness && /open|restricted/.test(p.model.openness) ? 'var(--positive)' : 'var(--type-model)',
90 + group: p.model.openness ?? undefined,
91 + };
92 + });
93 + const frontierPts = points.filter((p) => frontierSet.has(p.id)).sort((a, b) => a.x - b.x);
94 + const hib = res?.group?.higher_is_better !== false;
95 + const yFmt = (v: number) => fmtScoreUnit(v, unit);
96 + const xFmt = xFormatter(x.key);
97 + const chip = (on: boolean) => cn('inline-flex h-8 items-center border px-2.5 text-xs whitespace-nowrap', on ? 'border-ink bg-ink text-canvas' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink');
98 + const ld = { '@context': 'https://schema.org', '@type': 'Dataset', name: `${detail.name} — cost vs performance`, url: `${SITE_URL}${canonical}`, description: res?.methodology };
99 +
100 + return (
101 + <Container wide>
102 + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} />
103 + <PageHeader
104 + eyebrow={
105 + <>
106 + <Link href={routes.benchmarks()} className="hover:text-ink">
107 + Benchmarks
108 + </Link>
109 + <span aria-hidden>/</span>
110 + <Link href={routes.benchmark(detail.slug)} className="hover:text-ink">
111 + {detail.name}
112 + </Link>
113 + </>
114 + }
115 + title={`${detail.name} — cost vs performance`}
116 + lede={res?.group ? `Best current row per canonical model in the group “${res.group.label}” (${fmtInt(res.group.model_count)} models) against ${res.x.label}. The dashed line is the Pareto frontier: no model is both better and cheaper than a point on it.` : 'Best current row per canonical model against its cheapest current price.'}
117 + aside={
118 + <Link href={routes.benchmark(detail.slug)} className="inline-flex h-9 items-center border border-rule px-2.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink">
119 + ← Leaderboard
120 + </Link>
121 + }
122 + >
123 + <div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-2 text-xs" data-pareto-controls>
124 + <span className="flex flex-wrap items-center gap-1.5">
125 + <span className="eyebrow mr-1">X</span>
126 + {X_AXES.map((a) => (
127 + <Link key={a.key} href={href({ x: a.key === 'output_price' ? undefined : a.key })} className={chip(a.key === x.key)} aria-current={a.key === x.key ? 'true' : undefined}>
128 + {a.label}
129 + </Link>
130 + ))}
131 + </span>
132 + <span className="flex items-center gap-1.5">
133 + <span className="eyebrow mr-1">Scale</span>
134 + <Link href={href({ log: undefined })} className={chip(log)} aria-pressed={log}>
135 + log
136 + </Link>
137 + <Link href={href({ log: '0' })} className={chip(!log)} aria-pressed={!log}>
138 + linear
139 + </Link>
140 + </span>
141 + <span className="flex items-center gap-1.5">
142 + <span className="eyebrow mr-1">Bubble</span>
143 + <Link href={href({ bubble: undefined })} className={chip(bubble === 'context')}>
144 + context
145 + </Link>
146 + <Link href={href({ bubble: 'params' })} className={chip(bubble === 'params')}>
147 + params
148 + </Link>
149 + <Link href={href({ bubble: 'none' })} className={chip(bubble === 'none')}>
150 + none
151 + </Link>
152 + </span>
153 + {res && res.groups.length > 1 && (
154 + <form action={canonical} method="get" className="flex items-center gap-1.5">
155 + {x.key !== 'output_price' && <input type="hidden" name="x" value={x.key} />}
156 + {!log && <input type="hidden" name="log" value="0" />}
157 + <span className="eyebrow mr-1">Group</span>
158 + <select name="group" defaultValue={res.group ? `${res.group.metric}|${res.group.config_key}` : ''} className="h-8 max-w-[18rem] border border-rule bg-surface px-2 text-xs text-ink" aria-label="Comparability group">
159 + {res.groups.map((g) => (
160 + <option key={g.config_key} value={`${g.metric}|${g.config_key}`}>
161 + {g.label} ({g.model_count})
162 + </option>
163 + ))}
164 + </select>
165 + <button type="submit" className="inline-flex h-8 items-center bg-ink px-2.5 text-xs font-medium text-canvas">
166 + Go
167 + </button>
168 + </form>
169 + )}
170 + </div>
171 + </PageHeader>
172 +
173 + <div className="space-y-8 pb-16">
174 + {error ? (
175 + <EmptyState title="This axis is not available">{error}</EmptyState>
176 + ) : !res ? (
177 + <Unavailable what="Pareto view" />
178 + ) : points.length === 0 ? (
179 + <EmptyState title="No model has both a score in this group and a value on this axis">Try another axis or comparability group.</EmptyState>
180 + ) : (
181 + <>
182 + <section data-pareto-chart>
183 + <ParetoChart points={points} xKey={x.key} unit={unit} log={log} xLabel={res.x.label} yLabel={res.y.label} frontier={frontierPts.map((p) => ({ x: p.x, y: p.y }))} highlight={[...frontierSet]} />
184 + <p className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-ink-3">
185 + <span className="inline-flex items-center gap-1">
186 + <span className="inline-block size-2 rounded-full" style={{ background: 'var(--positive)' }} /> open / restricted weights
187 + </span>
188 + <span className="inline-flex items-center gap-1">
189 + <span className="inline-block size-2 rounded-full" style={{ background: 'var(--type-model)' }} /> closed
190 + </span>
191 + <span className="inline-flex items-center gap-1">
192 + <span className="inline-block h-[2px] w-4 border-t border-dashed border-accent-2" /> Pareto frontier ({fmtInt(frontierPts.length)} models)
193 + </span>
194 + {bubble !== 'none' && <span>bubble = {bubble === 'params' ? 'total parameters' : 'context window'}{bubbleRes ? '' : ' (unavailable — uniform)'}</span>}
195 + {x.key === 'memory_estimate' && <Estimated />}
196 + </p>
197 + </section>
198 + <section>
199 + <p className="eyebrow mb-2">
200 + Frontier models <span className="tnum text-ink-3">{fmtInt(frontierPts.length)}</span>
201 + </p>
202 + <ScrollX><DataTable caption="Pareto frontier" compact>
203 + <thead>
204 + <tr>
205 + <Th>Model</Th>
206 + <Th num>{res.y.label}</Th>
207 + <Th num>Rank</Th>
208 + <Th num>{res.x.label}</Th>
209 + <Th>Provider</Th>
210 + <Th>Trust</Th>
211 + </tr>
212 + </thead>
213 + <tbody>
214 + {(hib ? [...frontierPts].sort((a, b) => b.y - a.y) : [...frontierPts].sort((a, b) => a.y - b.y)).map((p) => {
215 + const raw = res.points.find((q) => q.id === p.id)!;
216 + return (
217 + <tr key={p.id}>
218 + <Td primary>
219 + <Link href={p.href ?? '#'} className="text-ink hover:text-accent hover:underline">
220 + {p.label}
221 + </Link>
222 + <span className="block text-[11px] text-ink-3">
223 + {raw.model.organization ?? ''}
224 + {raw.model.openness ? ` · ${opennessLabel(raw.model.openness)}` : ''}
225 + </span>
226 + </Td>
227 + <Td num label={res.y.label} className="tnum font-medium">
228 + {yFmt(p.y)}
229 + </Td>
230 + <Td num label="Rank" className="tnum text-ink-2">
231 + {fmtInt(raw.rank)}
232 + </Td>
233 + <Td num label={res.x.label} className="tnum text-accent-2">
234 + {xFmt(p.x)}
235 + {raw.estimated && <span className="ml-1 text-[10px] uppercase text-warning">est.</span>}
236 + </Td>
237 + <Td label="Provider" className="text-ink-2">
238 + {raw.provider ? <Link href={routes.entity(raw.provider)} className="hover:text-accent">{raw.provider.name}</Link> : '—'}
239 + </Td>
240 + <Td label="Trust">
241 + <TrustBadge level={raw.trust_level} />
242 + </Td>
243 + </tr>
244 + );
245 + })}
246 + </tbody>
247 + </DataTable></ScrollX>
248 + </section>
249 + <Note>
250 + <strong className="font-medium text-ink-2">Methodology.</strong> {res.methodology} Only the selected comparability group is plotted; points under other configurations are not mixed in. Price = cheapest current offer across providers at the time of the last crawl. Nothing is estimated except where marked.
251 + </Note>
252 + </>
253 + )}
254 + </div>
255 + </Container>
256 + );
257 +}
added apps/web/src/app/benchmarks/[slug]/opengraph-image.tsx +27 −0
@@ -0,0 +1,27 @@
1 +import { ImageResponse } from 'next/og';
2 +import { Fallback, Wallpaper } from '@/components/brand/og';
3 +import { fmtScoreUnit } from '@/components/models/shared';
4 +import { apiD1, safe } from '@/lib/api';
5 +import { fmtInt } from '@/lib/format';
6 +import { routes, SITE_NAME } from '@/lib/site';
7 +
8 +export const runtime = 'nodejs';
9 +export const alt = `Benchmark leaderboard on ${SITE_NAME}`;
10 +export const size = { width: 1200, height: 630 };
11 +export const contentType = 'image/png';
12 +
13 +/** Benchmark OG: "Current recorded leader: X (score)" + results · models · metric · trust. */
14 +export default async function BenchmarkOgImage({ params }: { params: Promise<{ slug: string }> }) {
15 + const { slug } = await params;
16 + const [d, list] = await Promise.all([safe(apiD1.benchmark(slug)), safe(apiD1.benchmarks())]);
17 + if (!d) return new ImageResponse(<Fallback label="Benchmark" />, { ...size });
18 + const item = list?.items.find((b) => b.slug === d.slug || b.id === d.id) ?? null;
19 + const leader = item?.leader ?? d.leaderboard?.[0] ?? null;
20 + const counters: [string, string][] = [];
21 + counters.push(['Results', fmtInt(item?.result_count ?? d.result_count ?? 0)]);
22 + counters.push(['Models', fmtInt(item?.model_count ?? d.model_count ?? 0)]);
23 + if (d.metric ?? item?.metric) counters.push(['Metric', `${d.metric ?? item?.metric} ${(d.direction ?? item?.direction) === 'lower' ? '↓' : '↑'}`]);
24 + if (leader) counters.push(['Leader score', fmtScoreUnit(leader.score, leader.unit)]);
25 + const subtitle = leader ? `Current recorded leader: ${leader.model.name}${leader.model.organization ? ` (${leader.model.organization.name})` : ''} — ${leader.trust_label ?? leader.trust_level}` : 'No results recorded yet — sources being connected.';
26 + return new ImageResponse(<Wallpaper eyebrow={`Benchmark${d.category ? ` · ${d.category}` : ''}${d.variant && d.variant !== 'main' ? ` · ${d.variant}` : ''}`} title={d.name} subtitle={subtitle} counters={counters.slice(0, 4)} footer={`www.ai-atlas.co${routes.benchmark(d.slug)}`} markPx={220} />, { ...size });
27 +}
modified apps/web/src/app/benchmarks/[slug]/page.tsx +321 −168
@@ -1,213 +1,366 @@
1 1 import { ExternalLink } from 'lucide-react';
2 2 import type { Metadata } from 'next';
3 3 import Link from 'next/link';
4 −import { permanentRedirect } from 'next/navigation';
5 −import { configChips, ConfigChips, HistoryChart, Leaderboard } from '@/components/benchmarks/leaderboard';
4 +import { notFound, permanentRedirect } from 'next/navigation';
5 +import { HistoryChart } from '@/components/benchmarks/leaderboard';
6 +import { FrontierChart, GroupPicker, Leaderboard2 } from '@/components/benchmarks/leaderboard2';
6 7 import { CompareTrayBar } from '@/components/compare/compare-tray-bar';
7 −import { RelationsBlock, SourcesTable, TimelineList } from '@/components/entity/blocks';
8 −import { HistoryPanel } from '@/components/entity/history';
9 −import { entityMetadata, loadEntity } from '@/components/entity/load';
8 +import { ComparabilityLegend } from '@/components/entity/model-blocks';
9 +import { TerminalLayout } from '@/components/layout/terminal';
10 10 import { ViewBeacon } from '@/components/layout/view-beacon';
11 +import { TrustBadge } from '@/components/models/badges';
12 +import { fmtScoreUnit, TRUST_LONG } from '@/components/models/shared';
11 13 import { EntityBadge, StatusBadge } from '@/components/ui/badges';
12 −import { QualityMark } from '@/components/ui/entity';
14 +import { EntityLink, QualityMark } from '@/components/ui/entity';
13 15 import { KeyValue, type KVRow } from '@/components/ui/key-value';
14 16 import { Container, Note } from '@/components/ui/section';
15 −import { TabPanel, Tabs, type TabDef } from '@/components/ui/tabs';
16 17 import { Unavailable } from '@/components/ui/unavailable';
17 −import { api, safe } from '@/lib/api';
18 −import { fmtAgo, fmtDate, fmtInt, fmtScore } from '@/lib/format';
18 +import { api, ApiError, apiD1, safe } from '@/lib/api';
19 +import { cn } from '@/lib/cn';
20 +import { fmtAgo, fmtDate, fmtInt, num } from '@/lib/format';
19 21 import { routes, SITE_NAME, SITE_URL } from '@/lib/site';
22 +import type { BenchmarkDetail, Group } from '@/lib/types';
20 23
21 −type Params = { params: Promise<{ slug: string }>; searchParams: Promise<Record<string, string | undefined>> };
24 +/*
25 + Benchmark leaderboard page 2.0 — TerminalLayout: filters (comparability group · trust · organization · comparable-only) ·
26 + main (variant navigation, frontier over time, one-row-per-model leaderboard) · inspector (definition with evidence, trust mix, links).
27 +*/
28 +
29 +type SP = Record<string, string | undefined>;
30 +type Params = { params: Promise<{ slug: string }>; searchParams: Promise<SP> };
22 31 const LIMIT = 100;
32 +export const revalidate = 300;
33 +
34 +async function loadBenchmark(slug: string): Promise<BenchmarkDetail> {
35 + try {
36 + const d = await apiD1.benchmark(slug);
37 + if (d.entity_type !== 'benchmark') notFound();
38 + return d;
39 + } catch (e) {
40 + if (e instanceof ApiError && e.notFound) notFound();
41 + throw e;
42 + }
43 +}
23 44
24 45 export async function generateMetadata({ params }: Params): Promise<Metadata> {
25 46 const { slug } = await params;
26 − const m = await entityMetadata('benchmarks', slug);
27 − if (!m.title || (m.robots as { index?: boolean } | undefined)?.index === false) return m;
28 − const name = String(m.title).split(' — ')[0];
29 − const title = `${name} leaderboard — results, configs and history`;
30 − return { ...m, title, openGraph: { ...(m.openGraph ?? {}), title: `${title} | ${SITE_NAME}` }, twitter: { ...(m.twitter ?? {}), title } };
47 + const d = await safe(apiD1.benchmark(slug));
48 + if (!d || d.entity_type !== 'benchmark' || d.slug !== slug) return { title: 'Benchmark', robots: { index: false } };
49 + const title = `${d.name} Leaderboard & Results`;
50 + const leader = d.leaderboard?.[0];
51 + const description = `${d.name}${d.category ? ` (${d.category})` : ''}: ${fmtInt(d.result_count ?? 0)} current results across ${fmtInt(d.model_count ?? 0)} canonical models, metric ${d.metric ?? '—'}${leader ? `; current recorded leader ${leader.model.name} at ${fmtScoreUnit(leader.score, leader.unit)} (${leader.trust_label})` : ''}. Comparability groups, trust levels, frontier over time and per-model history on ${SITE_NAME}.`.slice(0, 300);
52 + const canonical = routes.benchmark(d.slug);
53 + return { title, description, alternates: { canonical }, openGraph: { title: `${title} | ${SITE_NAME}`, description, url: `${SITE_URL}${canonical}`, type: 'article', siteName: SITE_NAME }, twitter: { card: 'summary_large_image', title, description } };
31 54 }
32 55
33 −/**
34 − * Dedicated benchmark page: definition, leaderboard (paged, config-filterable), per-model history chart,
35 − * timeline and sources. Static segment → shadows /[type]/[slug] for benchmarks only.
36 − */
37 56 export default async function BenchmarkPage({ params, searchParams }: Params) {
38 57 const { slug } = await params;
39 58 const sp = await searchParams;
40 − const d = await loadEntity('benchmarks', slug);
41 − if (d.slug !== slug) permanentRedirect(routes.benchmark(d.slug, { config: sp.config, model: sp.model }));
42 − const canonical = routes.entity(d);
59 + const d = await loadBenchmark(slug);
60 + if (d.slug !== slug) permanentRedirect(routes.benchmark(d.slug));
61 + const canonical = routes.benchmark(d.slug);
62 + // Group from `?group=metric|config_key` (select form) or `?metric=&config_key=` (links).
63 + let metric = sp.metric?.trim() || undefined;
64 + let configKey = sp.config_key?.trim() || undefined;
65 + if (sp.group?.includes('|')) [metric, configKey] = sp.group.split('|') as [string, string];
66 + const trust = sp.trust?.trim() || undefined;
67 + const org = sp.org?.trim() || undefined;
68 + const comparableOnly = sp.comparable_only === '1';
43 69 const offset = Math.max(0, Number(sp.offset) || 0);
44 − const config = sp.config?.trim() || undefined;
45 70 const model = sp.model?.trim() || undefined;
46 − // History tab (same mode as EntityPage): full claim history always, "as of" state only when requested.
47 − const asofRaw = sp.asof?.trim() || undefined;
48 − const asofValid = asofRaw && /^\d{4}-\d{2}-\d{2}$/.test(asofRaw) ? asofRaw : undefined;
49 − const property = sp.property?.trim() || undefined;
50 − const [page, history, claimHistory, asofPayload] = await Promise.all([
51 − safe(api.benchmarkResults(d.slug, { limit: LIMIT, offset, config })),
71 + const [lb, frontier, list, history] = await Promise.all([
72 + safe(apiD1.leaderboard(d.slug, { metric, config_key: configKey, trust, org, comparable_only: comparableOnly ? 1 : undefined, limit: LIMIT, offset })),
73 + safe(apiD1.frontier(d.slug, { metric, config_key: configKey })),
74 + safe(apiD1.benchmarks()),
52 75 model ? safe(api.benchmarkHistory(d.slug, model)) : Promise.resolve(null),
53 − safe(api.entityHistory(d.slug, property)),
54 − asofValid ? safe(api.entityAsOf(d.slug, asofValid)) : Promise.resolve(null),
55 76 ]);
56 − const claims = claimHistory?.items ?? null;
57 − // Chips come from an unfiltered sample so the filter can be changed while one is active.
58 − const sample = config || offset ? await safe(api.benchmarkResults(d.slug, { limit: LIMIT })) : page;
59 − const chips = configChips(sample?.items ?? []);
77 + const group: Group | null = lb?.group ?? d.primary_group ?? null;
78 + const groups = lb?.groups ?? d.groups ?? [];
60 79 const a = d.attributes ?? {};
61 − const unit = typeof a.unit === 'string' ? a.unit : (page?.items[0]?.unit ?? null);
62 − const direction = page?.items[0] ? (page.items[0].higher_is_better === false ? 'Lower is better' : 'Higher is better') : null;
80 + const unit = typeof a.unit === 'string' ? a.unit : (lb?.items[0]?.unit ?? null);
81 + const item = list?.items.find((b) => b.slug === d.slug) ?? null;
82 + const siblings = list?.items.filter((b) => (b.family ?? b.slug) === (d.family ?? item?.family ?? d.slug)).sort((x, y) => Number(!!y.attributes?.family_head) - Number(!!x.attributes?.family_head) || (num(y.result_count) ?? 0) - (num(x.result_count) ?? 0)) ?? [];
83 + const cur: SP = { metric, config_key: configKey, trust, org, comparable_only: comparableOnly ? '1' : undefined, model };
84 + const href = (patch: SP) => {
85 + const p = new URLSearchParams();
86 + for (const [k, v] of Object.entries({ ...cur, ...patch })) if (v) p.set(k, v);
87 + const s = p.toString();
88 + return `${canonical}${s ? `?${s}` : ''}`;
89 + };
63 90 const link = (k: string) => (typeof a[k] === 'string' && /^https?:\/\//.test(a[k] as string) ? (a[k] as string) : null);
64 91 const site = link('website') ?? link('official_url');
65 92 const defRows: KVRow[] = [
66 − { key: 'category', raw: a.category },
67 93 { key: 'task', raw: a.task },
68 − { key: 'metric', raw: a.metric, value: typeof a.metric === 'string' ? <span>{a.metric}{unit && <span className="text-ink-3"> · {unit}</span>}</span> : undefined },
69 − ...(direction ? [{ key: 'direction', label: 'Direction', value: <span>{direction}</span> }] : []),
70 − { key: 'creator', raw: a.creator },
94 + { key: 'metric', raw: a.metric_label ?? a.metric, value: typeof (a.metric_label ?? a.metric) === 'string' ? <span>{String(a.metric_label ?? a.metric)}{unit && <span className="text-ink-3"> · {unit}</span>}{a.higher_is_better === false ? <span className="text-ink-3"> · lower is better</span> : ''}</span> : undefined },
95 + { key: 'metric_min', label: 'Metric range', raw: num(a.metric_min) !== null && num(a.metric_max) !== null ? `${a.metric_min} – ${a.metric_max}` : undefined },
96 + { key: 'variant', raw: a.variant },
97 + { key: 'version', raw: a.version },
98 + { key: 'harness', raw: a.harness },
99 + { key: 'comparability_note', label: 'Comparability note', raw: a.comparability_note },
100 + { key: 'known_limitations', raw: a.known_limitations },
71 101 { key: 'paper', raw: a.paper },
72 102 { key: 'paper_url', raw: a.paper_url },
73 − { key: 'methodology', raw: a.methodology, value: link('methodology') ? <a href={link('methodology') as string} className="link break-all" target="_blank" rel="noopener noreferrer">{(link('methodology') as string).replace(/^https?:\/\/(www\.)?/, '')}</a> : undefined },
74 − { key: 'known_limitations', raw: a.known_limitations },
75 − { key: 'website', raw: a.website, value: site ? <a href={site} className="link break-all" target="_blank" rel="noopener noreferrer">{site.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '')}</a> : undefined },
76 − { key: 'official_url', raw: link('website') ? undefined : a.official_url },
77 − ];
78 − const tabs: TabDef[] = [
79 − { id: 'leaderboard', label: 'Leaderboard', count: page?.total ?? d.results?.length ?? undefined },
80 − { id: 'definition', label: 'Definition' },
81 − { id: 'relations', label: 'Relations', count: d.relations?.reduce((n, g) => n + g.items.length, 0) || undefined },
82 − { id: 'history', label: 'History', count: property ? undefined : claims?.length || undefined },
83 − { id: 'timeline', label: 'Timeline', count: d.timeline?.length || undefined },
84 − { id: 'sources', label: 'Sources', count: d.sources?.length || undefined },
103 + { key: 'website', raw: a.website },
104 + { key: 'creator', raw: a.creator },
85 105 ];
86 − const ld = { '@context': 'https://schema.org', '@type': 'Dataset', name: d.name, url: `${SITE_URL}${canonical}`, description: d.description ?? (typeof a.task === 'string' ? a.task : undefined), alternateName: d.aliases?.length ? d.aliases : undefined, creator: typeof a.creator === 'string' ? { '@type': 'Organization', name: a.creator } : d.organization ? { '@type': 'Organization', name: d.organization.name } : undefined, sameAs: site ? [site] : undefined, measurementTechnique: typeof a.metric === 'string' ? a.metric : undefined };
87 − const href = (o: number) => routes.benchmark(d.slug, { config, model }) + (o ? `${config || model ? '&' : '?'}offset=${o}` : '');
106 + const trustMix = lb?.group?.trust_mix ?? d.trust_mix ?? item?.trust_mix ?? {};
107 + const filterCount = [trust, org, comparableOnly ? '1' : undefined, configKey].filter(Boolean).length;
108 + const ld = { '@context': 'https://schema.org', '@type': 'Dataset', name: `${d.name} leaderboard`, url: `${SITE_URL}${canonical}`, description: d.description ?? (typeof a.task === 'string' ? a.task : undefined), alternateName: d.aliases?.length ? d.aliases : undefined, measurementTechnique: typeof a.metric === 'string' ? a.metric : undefined, sameAs: [site, link('paper')].filter(Boolean), variableMeasured: typeof a.metric === 'string' ? a.metric : undefined };
109 + const empty = (num(d.result_count) ?? 0) === 0;
110 +
111 + const filters = (
112 + <div className="space-y-5 pb-6 text-sm" data-leaderboard-filters>
113 + <div>
114 + <p className="eyebrow mb-1">Comparability group</p>
115 + <GroupPicker groups={groups} active={group} makeHref={(g) => href({ metric: g.metric, config_key: g.config_key, offset: undefined })} slug={d.slug} hidden={{ trust, org, comparable_only: cur.comparable_only }} />
116 + </div>
117 + {Object.keys(trustMix).length > 0 && (
118 + <div>
119 + <p className="eyebrow mb-1">Trust level</p>
120 + <ul className="space-y-px">
121 + <li>
122 + <Link href={href({ trust: undefined, offset: undefined })} className={cn('flex min-h-8 items-center justify-between px-1 text-[13px] hover:bg-surface-2', !trust ? 'bg-surface-2 font-medium text-ink' : 'text-ink-2')} aria-current={!trust ? 'true' : undefined}>
123 + <span>All</span>
124 + <span className="tnum text-xs text-ink-3">{fmtInt(Object.values(trustMix).reduce((n, v) => n + v, 0))}</span>
125 + </Link>
126 + </li>
127 + {Object.entries(trustMix)
128 + .sort((x, y) => y[1] - x[1])
129 + .map(([k, n]) => (
130 + <li key={k}>
131 + <Link href={href({ trust: trust === k ? undefined : k, offset: undefined })} className={cn('flex min-h-8 items-center justify-between gap-2 px-1 text-[13px] hover:bg-surface-2', trust === k ? 'bg-surface-2 font-medium text-ink' : 'text-ink-2')} aria-current={trust === k ? 'true' : undefined} title={TRUST_LONG[k] ?? k}>
132 + <TrustBadge level={k} />
133 + <span className="tnum text-xs text-ink-3">{fmtInt(n)}</span>
134 + </Link>
135 + </li>
136 + ))}
137 + </ul>
138 + </div>
139 + )}
140 + <form action={canonical} method="get" className="space-y-3">
141 + {metric && <input type="hidden" name="metric" value={metric} />}
142 + {configKey && <input type="hidden" name="config_key" value={configKey} />}
143 + {trust && <input type="hidden" name="trust" value={trust} />}
144 + <label className="block">
145 + <span className="eyebrow block pb-1">Organization slug</span>
146 + <input name="org" defaultValue={org ?? ''} placeholder="anthropic, openai…" className="h-9 w-full border border-rule bg-surface px-2 text-[13px] text-ink focus:border-accent focus:outline-none" />
147 + </label>
148 + <label className="flex min-h-9 items-center gap-2 text-[13px] text-ink-2">
149 + <input type="checkbox" name="comparable_only" value="1" defaultChecked={comparableOnly} className="size-4 accent-[var(--accent)]" /> Comparable rows only
150 + </label>
151 + <div className="flex gap-2">
152 + <button type="submit" className="inline-flex h-9 flex-1 items-center justify-center bg-ink px-3 text-[13px] font-medium text-canvas hover:opacity-90">
153 + Apply
154 + </button>
155 + <Link href={canonical} className="inline-flex h-9 items-center border border-rule px-3 text-[13px] text-ink-2 hover:text-ink">
156 + Reset
157 + </Link>
158 + </div>
159 + </form>
160 + <div>
161 + <p className="eyebrow mb-1">Views</p>
162 + <ul className="space-y-px text-[13px]">
163 + <li>
164 + <Link href={`${canonical}/cost-vs-performance${group ? `?metric=${encodeURIComponent(group.metric)}&config_key=${encodeURIComponent(group.config_key)}` : ''}`} className="flex min-h-8 items-center px-1 text-ink-2 hover:bg-surface-2 hover:text-ink">
165 + Cost vs performance →
166 + </Link>
167 + </li>
168 + <li>
169 + <Link href={`/benchmarks/matrix?benchmarks=${encodeURIComponent(d.slug)}`} className="flex min-h-8 items-center px-1 text-ink-2 hover:bg-surface-2 hover:text-ink">
170 + In the matrix →
171 + </Link>
172 + </li>
173 + <li>
174 + <Link href={`${routes.timeline({ entity: d.slug })}`} className="flex min-h-8 items-center px-1 text-ink-2 hover:bg-surface-2 hover:text-ink">
175 + Timeline →
176 + </Link>
177 + </li>
178 + </ul>
179 + </div>
180 + </div>
181 + );
182 +
183 + const inspector = (
184 + <div className="space-y-5 text-sm" data-benchmark-definition>
185 + <div>
186 + <KeyValue rows={defRows} provenance={d.provenance} slug={d.slug} entity={{ name: d.name, entity_type: 'benchmark' }} dense />
187 + </div>
188 + {group && (
189 + <div>
190 + <p className="eyebrow mb-1">Active group</p>
191 + <p className="text-[13px] text-ink">{group.label}</p>
192 + <p className="tnum text-xs text-ink-3">
193 + {fmtInt(group.model_count)} models · {fmtInt(group.n)} current rows
194 + </p>
195 + </div>
196 + )}
197 + <div>
198 + <p className="eyebrow mb-1">Comparability</p>
199 + <ComparabilityLegend />
200 + </div>
201 + {d.aliases?.length > 0 && (
202 + <div>
203 + <p className="eyebrow mb-1">Also known as</p>
204 + <p className="text-xs text-ink-2">{d.aliases.join(', ')}</p>
205 + </div>
206 + )}
207 + <p className="mono break-all text-[11px] text-ink-3">
208 + slug {d.slug} · {d.id}
209 + </p>
210 + {lb?.methodology && <Note>{lb.methodology}</Note>}
211 + </div>
212 + );
88 213
89 214 return (
90 − <Container wide>
215 + <>
91 216 <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} />
92 217 <ViewBeacon path={canonical} />
93 − <nav aria-label="Breadcrumb" className="pt-5 text-xs text-ink-3">
94 − <ol className="flex flex-wrap items-center gap-1.5">
95 − <li><Link href="/" className="hover:text-ink">AI Atlas</Link></li>
96 − <li aria-hidden>/</li>
97 − <li><Link href={routes.benchmarks()} className="hover:text-ink">Benchmarks</Link></li>
98 − {typeof a.category === 'string' && (
99 − <>
100 − <li aria-hidden>/</li>
101 − <li><Link href={`/benchmarks?category=${encodeURIComponent(a.category)}`} className="hover:text-ink">{a.category}</Link></li>
102 − </>
103 − )}
104 − <li aria-hidden>/</li>
105 − <li className="text-ink-2">{d.name}</li>
106 − </ol>
107 − </nav>
108 −
109 − <header className="pb-6 pt-4 md:pb-8 md:pt-5">
110 − <div className="flex flex-wrap items-center gap-2">
111 − <EntityBadge type={d.entity_type} />
112 − <StatusBadge status={d.status} />
113 − {typeof a.category === 'string' && <span className="text-xs text-ink-3">category · {a.category}</span>}
114 − </div>
115 − <div className="mt-3 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
116 − <div className="min-w-0">
117 − <h1 className="display text-[30px] md:text-[44px]">{d.name}</h1>
118 − <p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[15px] text-ink-2">
119 − {typeof a.creator === 'string' && <span className="font-medium text-ink">{a.creator}</span>}
120 − {d.organization && (
121 − <Link href={routes.entity({ entity_type: 'company', slug: d.organization.slug })} className="font-medium text-ink hover:text-accent">
122 − {d.organization.name}
123 − </Link>
124 − )}
125 − {site && (
126 − <a href={site} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-ink-3 hover:text-accent">
127 − {site.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '').slice(0, 48)} <ExternalLink className="size-3.5" aria-hidden />
128 − </a>
129 − )}
130 − </p>
131 − {(d.description || typeof a.task === 'string') && <p className="mt-3 max-w-3xl text-[15px] leading-relaxed text-ink-2">{d.description ?? (a.task as string)}</p>}
218 + <Container wide>
219 + <nav aria-label="Breadcrumb" className="pt-5 text-xs text-ink-3">
220 + <ol className="flex flex-wrap items-center gap-1.5">
221 + <li>
222 + <Link href="/" className="hover:text-ink">
223 + AI Atlas
224 + </Link>
225 + </li>
226 + <li aria-hidden>/</li>
227 + <li>
228 + <Link href={routes.benchmarks()} className="hover:text-ink">
229 + Benchmarks
230 + </Link>
231 + </li>
232 + {d.category && (
233 + <>
234 + <li aria-hidden>/</li>
235 + <li>
236 + <Link href={`/benchmarks?category=${encodeURIComponent(d.category)}`} className="hover:text-ink">
237 + {d.category}
238 + </Link>
239 + </li>
240 + </>
241 + )}
242 + <li aria-hidden>/</li>
243 + <li className="text-ink-2">{d.name}</li>
244 + </ol>
245 + </nav>
246 + <header className="pb-4 pt-4 md:pt-5">
247 + <div className="flex flex-wrap items-center gap-2">
248 + <EntityBadge type="benchmark" />
249 + <StatusBadge status={d.status} />
250 + {d.category && <span className="text-xs text-ink-3">category · {d.category}</span>}
251 + {d.family && d.variant && (
252 + <span className="text-xs text-ink-3">
253 + family · {d.family} · variant {d.variant}
254 + </span>
255 + )}
132 256 </div>
133 − <div className="shrink-0 text-xs text-ink-3 lg:text-right">
134 − <QualityMark q={d.quality?.score} label />
135 − <p className="mt-1" title={d.updated_at}>Updated {fmtAgo(d.updated_at)} · first seen {fmtDate(d.first_seen_at)}</p>
136 − <p className="mono mt-0.5 text-[11px]">{d.id}</p>
257 + <div className="mt-3 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
258 + <div className="min-w-0">
259 + <h1 className="display text-[30px] md:text-[44px]">{d.name}</h1>
260 + <p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[15px] text-ink-2">
261 + {typeof a.creator === 'string' && <span className="font-medium text-ink">{a.creator}</span>}
262 + {site && (
263 + <a href={site} target="_blank" rel="noopener noreferrer" className="inline-flex max-w-full min-w-0 items-center gap-1 text-ink-3 hover:text-accent">
264 + <span className="truncate">{site.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '').slice(0, 48)}</span> <ExternalLink className="size-3.5 shrink-0" aria-hidden />
265 + </a>
266 + )}
267 + </p>
268 + {(d.description || typeof a.task === 'string') && <p className="mt-3 max-w-3xl text-[15px] leading-relaxed text-ink-2">{d.description ?? (a.task as string)}</p>}
269 + </div>
270 + <div className="shrink-0 text-xs text-ink-3 lg:text-right">
271 + <QualityMark q={d.quality?.score} label />
272 + <p className="mt-1" title={d.updated_at}>
273 + Updated {fmtAgo(d.updated_at)} · first seen {fmtDate(d.first_seen_at)}
274 + </p>
275 + </div>
137 276 </div>
138 − </div>
139 − <dl className="mt-5 grid grid-cols-2 gap-x-6 gap-y-3 border-y border-rule py-3 sm:grid-cols-4">
140 − <div><dt className="eyebrow">Metric</dt><dd className="mt-0.5 truncate text-[15px] font-medium text-ink">{typeof a.metric === 'string' ? a.metric : '—'}{unit && <span className="text-ink-3"> · {unit}</span>}</dd></div>
141 − <div><dt className="eyebrow">Direction</dt><dd className="mt-0.5 truncate text-[15px] font-medium text-ink">{direction ?? '—'}</dd></div>
142 − <div><dt className="eyebrow">Results</dt><dd className="tnum mt-0.5 text-[15px] font-medium text-ink">{sample ? fmtInt(sample.total) : '—'}{config && page && <span className="text-xs text-ink-3"> · {fmtInt(page.total)} filtered</span>}</dd></div>
143 − <div><dt className="eyebrow">Leader</dt><dd className="mt-0.5 truncate text-[15px] font-medium text-ink">{sample?.items[0] ? <Link href={routes.entity(sample.items[0].model)} className="hover:text-accent">{sample.items[0].model.name} <span className="tnum text-ink-2">{fmtScore(sample.items[0].score)}{unit === '%' ? '%' : ''}</span></Link> : '—'}</dd></div>
144 − </dl>
145 − </header>
277 + <dl className="mt-4 grid grid-cols-2 gap-x-6 gap-y-3 border-y border-rule py-3 sm:grid-cols-4" data-benchmark-strip>
278 + <div>
279 + <dt className="eyebrow">Metric</dt>
280 + <dd className="mt-0.5 truncate text-[15px] font-medium text-ink">
281 + {d.metric ?? '—'}
282 + {unit && <span className="text-ink-3"> · {unit}</span>} <span className="text-ink-3">{d.direction === 'lower' ? '↓' : '↑'}</span>
283 + </dd>
284 + </div>
285 + <div>
286 + <dt className="eyebrow">Current results</dt>
287 + <dd className="tnum mt-0.5 text-[15px] font-medium text-ink">{fmtInt(d.result_count ?? 0)}</dd>
288 + </div>
289 + <div>
290 + <dt className="eyebrow">Models</dt>
291 + <dd className="tnum mt-0.5 text-[15px] font-medium text-ink">{fmtInt(d.model_count ?? 0)}</dd>
292 + </div>
293 + <div>
294 + <dt className="eyebrow">Current leader</dt>
295 + <dd className="mt-0.5 truncate text-[15px] font-medium text-ink">
296 + {lb?.items[0] && offset === 0 ? (
297 + <>
298 + <EntityLink e={{ ...lb.items[0].model, entity_type: 'model' }} /> <span className="tnum text-ink-2">{fmtScoreUnit(lb.items[0].score, unit)}</span>
299 + </>
300 + ) : item?.leader ? (
301 + <>
302 + <EntityLink e={{ ...item.leader.model, entity_type: 'model' }} /> <span className="tnum text-ink-2">{fmtScoreUnit(item.leader.score, unit)}</span>
303 + </>
304 + ) : (
305 + <span className="text-ink-3">—</span>
306 + )}
307 + </dd>
308 + </div>
309 + </dl>
310 + {siblings.length > 1 && (
311 + <nav aria-label="Variants" className="no-scrollbar -mx-4 mt-3 flex gap-1.5 overflow-x-auto px-4 md:mx-0 md:flex-wrap md:px-0" data-variant-nav>
312 + <span className="eyebrow mr-1 flex items-center">Variants</span>
313 + {siblings.map((s) => (
314 + <Link key={s.slug} href={routes.benchmark(s.slug)} aria-current={s.slug === d.slug ? 'page' : undefined} className={cn('inline-flex h-8 items-center gap-1.5 border px-2.5 text-xs whitespace-nowrap', s.slug === d.slug ? 'border-ink bg-ink text-canvas' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}>
315 + {s.variant ?? s.name}
316 + {s.attributes?.family_head ? <span className="opacity-70">· head</span> : null}
317 + <span className="tnum opacity-70">{fmtInt(s.result_count)}</span>
318 + </Link>
319 + ))}
320 + </nav>
321 + )}
322 + </header>
323 + </Container>
146 324
147 − <Tabs tabs={tabs} className="pb-10">
148 − <TabPanel id="leaderboard">
149 − <div className="space-y-6">
150 − {model && (
151 − <section>
152 − {history ? <HistoryChart items={history.items} model={model} unit={unit} /> : <Unavailable what="Result history" compact />}
153 − <p className="mt-2 text-xs">
154 − <Link href={routes.benchmark(d.slug, { config })} className="link">Back to the full leaderboard</Link>
155 − </p>
156 − </section>
157 − )}
158 − <section className="space-y-3">
159 − <div className="flex flex-wrap items-center justify-between gap-3">
160 − <p className="eyebrow">
161 − Leaderboard {page && <span className="tnum text-ink-3">{fmtInt(page.total)} current {page.total === 1 ? 'result' : 'results'}{config ? ` · config contains “${config}”` : ''}</span>}
162 − </p>
163 − <p className="text-xs text-ink-3">
164 − Select models with <span className="mono">+</span>, then open <Link href={routes.compare()} className="link">Compare</Link>.
165 − </p>
166 − </div>
167 − <ConfigChips slug={d.slug} chips={chips} active={config} model={model} />
168 − {!page ? <Unavailable what="Leaderboard" /> : <Leaderboard slug={d.slug} results={page.items} total={page.total} limit={LIMIT} offset={offset} config={config} model={model} unit={unit} makeHref={href} />}
169 − {chips.length > 0 && <Note>The config filter matches a value inside each result's configuration (server-side, `config=` on the API). Chips are the values shared by several rows on the first page; per-model identifiers are not offered.</Note>}
325 + <TerminalLayout filters={filters} filterCount={filterCount} inspector={inspector} inspectorTitle="Definition" storageKey="aia-leaderboard-inspector">
326 + <div className="space-y-8 pb-16">
327 + {empty && (
328 + <p className="border-l-2 border-warning bg-warning-soft/40 px-3 py-2 text-xs text-ink-2" data-honesty>
329 + No results recorded for this benchmark yet — its sources are being connected. The definition, aliases and variants are kept so links resolve; nothing is fabricated.
330 + </p>
331 + )}
332 + {model && (
333 + <section>
334 + {history ? <HistoryChart items={history.items} model={model} unit={unit} /> : <Unavailable what="Result history" compact />}
335 + <p className="mt-2 text-xs">
336 + <Link href={href({ model: undefined })} className="link">
337 + Back to the leaderboard
338 + </Link>
339 + </p>
170 340 </section>
171 − </div>
172 − </TabPanel>
173 − <TabPanel id="definition">
174 − <div className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_22rem]">
341 + )}
342 + {!empty && (
175 343 <section>
176 − <p className="eyebrow mb-2">Definition</p>
177 − <KeyValue rows={defRows} provenance={d.provenance} />
178 − <Note className="mt-3">
179 − Each value shows its source, tier and observation time. Missing rows mean no source stated them. <Link href="/methodology#benchmarks" className="link">How results are recorded →</Link>
180 − </Note>
344 + <p className="eyebrow mb-2">
345 + Frontier over time {group && <span className="text-ink-3">· {group.label}</span>}
346 + </p>
347 + <FrontierChart frontier={frontier} group={group} unit={unit} />
181 348 </section>
182 − <aside className="space-y-8">
183 − {d.aliases?.length > 0 && (
184 − <section>
185 − <p className="eyebrow mb-2">Also known as</p>
186 − <p className="text-sm text-ink-2">{d.aliases.join(', ')}</p>
187 − </section>
188 − )}
189 − <section>
190 − <p className="eyebrow mb-2">Identity</p>
191 − <p className="mono break-all text-[11px] text-ink-3">slug {d.slug}</p>
192 − <p className="mono break-all text-[11px] text-ink-3">{d.id}</p>
193 − </section>
194 − </aside>
195 − </div>
196 − </TabPanel>
197 − <TabPanel id="relations">
198 − <RelationsBlock relations={d.relations ?? []} />
199 − </TabPanel>
200 − <TabPanel id="history">
201 − <HistoryPanel d={d} asof={asofRaw} asofPayload={asofPayload} claims={claims} property={property} />
202 − </TabPanel>
203 − <TabPanel id="timeline">
204 − <TimelineList events={d.timeline ?? []} slug={d.slug} />
205 − </TabPanel>
206 − <TabPanel id="sources">
207 − <SourcesTable sources={d.sources ?? []} />
208 − </TabPanel>
209 − </Tabs>
349 + )}
350 + <section>
351 + <div className="mb-2 flex flex-wrap items-center justify-between gap-2">
352 + <p className="eyebrow">
353 + Leaderboard {lb && <span className="tnum text-ink-3">{fmtInt(lb.total)} models{trust ? ` · trust ${trust}` : ''}{org ? ` · org ${org}` : ''}{comparableOnly ? ' · comparable only' : ''}</span>}
354 + </p>
355 + <p className="text-xs text-ink-3">
356 + Select models with <span className="mono">+</span>, then <Link href={routes.compare()} className="link">Compare</Link>.
357 + </p>
358 + </div>
359 + {!lb ? <Unavailable what="Leaderboard" /> : <Leaderboard2 rows={lb.items} group={group} total={lb.total} limit={LIMIT} offset={offset} unit={unit} activeModel={model} makeHref={(o) => href({ offset: o ? String(o) : undefined })} historyHref={(s) => href({ model: model === s ? undefined : s })} />}
360 + </section>
361 + </div>
362 + </TerminalLayout>
210 363 <CompareTrayBar />
211 − </Container>
364 + </>
212 365 );
213 366 }
added apps/web/src/app/benchmarks/matrix/page.tsx +179 −0
@@ -0,0 +1,179 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { Heatmap } from '@/components/charts';
4 +import { ComparabilityLegend } from '@/components/entity/model-blocks';
5 +import { MatrixFilterRail } from '@/components/benchmarks/filter-rails';
6 +import { TerminalLayout } from '@/components/layout/terminal';
7 +import { TrustBadge } from '@/components/models/badges';
8 +import { fmtScoreUnit, trustShort } from '@/components/models/shared';
9 +import { Container, Note } from '@/components/ui/section';
10 +import { EmptyState, Unavailable } from '@/components/ui/unavailable';
11 +import { apiD1, safe } from '@/lib/api';
12 +import { cn } from '@/lib/cn';
13 +import { fmtInt, num } from '@/lib/format';
14 +import { routes, SITE_NAME, SITE_URL } from '@/lib/site';
15 +
16 +export const metadata: Metadata = {
17 + title: 'Benchmark matrix — models × benchmarks, best comparable current score',
18 + description: 'Heatmap of canonical models against benchmarks: each cell is the best current score inside the comparability group, coloured by rank within the column. Pick benchmarks, filter by organization or family and release year, restrict to comparable rows.',
19 + alternates: { canonical: '/benchmarks/matrix' },
20 + openGraph: { title: `Benchmark matrix | ${SITE_NAME}`, url: `${SITE_URL}/benchmarks/matrix`, type: 'website' },
21 +};
22 +export const revalidate = 600;
23 +
24 +type SP = Record<string, string | undefined>;
25 +const LIMITS = [30, 60, 100];
26 +
27 +export default async function MatrixPage({ searchParams }: { searchParams: Promise<SP> }) {
28 + const sp = await searchParams;
29 + const benchmarks = (sp.benchmarks ?? '').split(',').map((s) => s.trim()).filter(Boolean);
30 + const org = sp.org?.trim() || undefined;
31 + const family = sp.family?.trim() || undefined;
32 + const comparableOnly = sp.comparable_only === '1';
33 + const minCells = Math.min(12, Math.max(1, Number(sp.min_cells) || 3));
34 + const limit = LIMITS.includes(Number(sp.limit)) ? Number(sp.limit) : 60;
35 + const yearFrom = /^\d{4}$/.test(sp.year_from ?? '') ? Number(sp.year_from) : undefined;
36 + const yearTo = /^\d{4}$/.test(sp.year_to ?? '') ? Number(sp.year_to) : undefined;
37 + const [matrix, list] = await Promise.all([safe(apiD1.matrix({ benchmarks: benchmarks.length ? benchmarks.join(',') : undefined, org, family, comparable_only: comparableOnly ? 1 : undefined, min_cells: minCells, limit })), safe(apiD1.benchmarks())]);
38 + const withResults = (list?.items ?? []).filter((b) => (num(b.result_count) ?? 0) > 0).sort((x, y) => (num(y.result_count) ?? 0) - (num(x.result_count) ?? 0));
39 + // Release-year range filters rows in the page (the API has no date filter on the matrix; cells carry no dates).
40 + const rows = (matrix?.rows ?? []).filter((r) => {
41 + const y = r.model.release_date ? Number(r.model.release_date.slice(0, 4)) : null;
42 + if (yearFrom !== undefined && (y === null || y < yearFrom)) return false;
43 + if (yearTo !== undefined && (y === null || y > yearTo)) return false;
44 + return true;
45 + });
46 + const cols = matrix?.columns ?? [];
47 + const colById = new Map(cols.map((c) => [c.id, c]));
48 + const colIds = cols.map((c) => c.id);
49 + // Colour by within-column rank: 1 = best rank in the column, 0 = last among the models that have a cell.
50 + const colRanks = new Map<string, number[]>();
51 + for (const c of cols) colRanks.set(c.id, rows.map((r) => r.cells[c.id]?.rank).filter((x): x is number => typeof x === 'number').sort((a, b) => a - b));
52 + const cellFn = (rowKey: string, colKey: string) => {
53 + const r = rows.find((x) => x.model.id === rowKey);
54 + const c = r?.cells[colKey];
55 + const col = colById.get(colKey);
56 + if (!r || !c || !col) return { value: null, title: r && col ? `${r.model.name} · ${col.name}: no current result in this group` : undefined };
57 + const ranks = colRanks.get(colKey) ?? [];
58 + const pos = ranks.indexOf(c.rank);
59 + const value = ranks.length > 1 ? 1 - pos / (ranks.length - 1) : 1;
60 + const unit = typeof list?.items.find((b) => b.id === colKey)?.unit === 'string' ? (list?.items.find((b) => b.id === colKey)?.unit as string) : null;
61 + return {
62 + value,
63 + label: fmtScoreUnit(c.score, unit),
64 + href: `${routes.benchmark(col.slug)}?metric=${encodeURIComponent(col.metric)}&config_key=${encodeURIComponent(c.config_key)}`,
65 + title: `${r.model.name} · ${col.name}: ${fmtScoreUnit(c.score, unit)} · rank ${c.rank} of ${col.n_models} · ${trustShort(c.trust_level)} · ${c.comparability}`,
66 + };
67 + };
68 + const filterCount = [benchmarks.length ? '1' : undefined, org, family, comparableOnly ? '1' : undefined, yearFrom, yearTo].filter(Boolean).length;
69 + const trustLevels = [...new Set(rows.flatMap((r) => Object.values(r.cells).map((c) => c?.trust_level).filter(Boolean)))] as string[];
70 + const partially = rows.reduce((n, r) => n + Object.values(r.cells).filter((c) => c && c.comparability !== 'comparable').length, 0);
71 + const cellsN = rows.reduce((n, r) => n + r.n_cells, 0);
72 + const ld = { '@context': 'https://schema.org', '@type': 'Dataset', name: 'Benchmark matrix', url: `${SITE_URL}/benchmarks/matrix`, description: metadata.description };
73 +
74 + const filters = <MatrixFilterRail benchmarks={withResults.map((b) => ({ slug: b.slug, name: b.name, models: num(b.model_count) ?? 0 }))} picked={benchmarks} org={org} family={family} yearFrom={sp.year_from} yearTo={sp.year_to} comparableOnly={comparableOnly} minCells={minCells} limit={limit} limits={LIMITS} />;
75 + const inspector = (
76 + <div className="space-y-5 text-sm" data-matrix-legend>
77 + <dl className="kv [&>div]:grid-cols-[7rem_minmax(0,1fr)] [&>div]:py-1">
78 + <div>
79 + <dt>Rows</dt>
80 + <dd className="tnum text-ink">
81 + {fmtInt(rows.length)} <span className="text-ink-3">of {fmtInt(matrix?.total_rows ?? 0)} eligible</span>
82 + </dd>
83 + </div>
84 + <div>
85 + <dt>Columns</dt>
86 + <dd className="tnum text-ink">{fmtInt(cols.length)}</dd>
87 + </div>
88 + <div>
89 + <dt>Cells</dt>
90 + <dd className="tnum text-ink">
91 + {fmtInt(cellsN)} {partially ? <span className="text-ink-3">· {fmtInt(partially)} partially comparable</span> : null}
92 + </dd>
93 + </div>
94 + </dl>
95 + <div>
96 + <p className="eyebrow mb-1">Colour</p>
97 + <p className="text-xs text-ink-2">Within-column rank of the model's best comparable current score: darkest = best rank in that column. Colours never compare across columns; the number in the cell is the score itself.</p>
98 + <div className="mt-2 flex h-2 overflow-hidden rounded-sm" aria-hidden>
99 + {[0.1, 0.3, 0.5, 0.7, 0.9].map((k) => (
100 + <span key={k} className="flex-1" style={{ background: `color-mix(in srgb, var(--type-benchmark) ${Math.round(8 + k * 72)}%, var(--surface))` }} />
101 + ))}
102 + </div>
103 + <p className="mt-0.5 flex justify-between text-[10px] text-ink-3">
104 + <span>last</span>
105 + <span>best</span>
106 + </p>
107 + </div>
108 + {trustLevels.length > 0 && (
109 + <div>
110 + <p className="eyebrow mb-1">Trust levels present</p>
111 + <p className="flex flex-wrap gap-1">
112 + {trustLevels.map((t) => (
113 + <TrustBadge key={t} level={t} />
114 + ))}
115 + </p>
116 + </div>
117 + )}
118 + <div>
119 + <p className="eyebrow mb-1">Comparability</p>
120 + <ComparabilityLegend />
121 + </div>
122 + {matrix?.methodology && <Note>{matrix.methodology}</Note>}
123 + <Note>
124 + `mean_rank` orders the rows; it is a sort key, not a score. <Link href="/methodology#benchmarks" className="link">Methodology →</Link>
125 + </Note>
126 + </div>
127 + );
128 +
129 + return (
130 + <>
131 + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} />
132 + <Container wide>
133 + <header className="flex flex-col gap-3 pb-4 pt-6 md:flex-row md:items-end md:justify-between md:pt-8">
134 + <div className="min-w-0">
135 + <p className="eyebrow">
136 + <Link href={routes.benchmarks()} className="hover:text-ink">
137 + Benchmarks
138 + </Link>{' '}
139 + / Matrix
140 + </p>
141 + <h1 className="display mt-1 text-[26px] md:text-[34px]">Benchmark matrix</h1>
142 + <p className="mt-2 max-w-2xl text-sm text-ink-2">Canonical models × benchmarks. Each cell is the best current score in the benchmark's comparability group; hover for score, rank, trust and comparability, click to open the leaderboard.</p>
143 + </div>
144 + {matrix && (
145 + <p className="tnum text-sm text-ink-2">
146 + <span className="font-semibold text-ink">{fmtInt(rows.length)}</span> models × <span className="font-semibold text-ink">{fmtInt(cols.length)}</span> benchmarks
147 + </p>
148 + )}
149 + </header>
150 + </Container>
151 + <TerminalLayout filters={filters} filterCount={filterCount} inspector={inspector} inspectorTitle="Legend" storageKey="aia-matrix-inspector">
152 + <div className="pb-16">
153 + {!matrix ? (
154 + <Unavailable what="Benchmark matrix" />
155 + ) : rows.length === 0 || cols.length === 0 ? (
156 + <EmptyState title="No model has enough comparable cells for this selection">Lower “min cells”, widen the release range or pick other benchmarks.</EmptyState>
157 + ) : (
158 + <>
159 + <Heatmap
160 + rows={rows.map((r) => ({ key: r.model.id, label: r.model.name, href: `/models/${encodeURIComponent(r.model.slug)}`, sub: [r.model.organization, r.model.release_date ? r.model.release_date.slice(0, 4) : null].filter(Boolean).join(' · ') }))}
161 + cols={cols.map((c) => ({ key: c.id, label: c.name.length > 22 ? `${c.name.slice(0, 21)}…` : c.name, href: routes.benchmark(c.slug), sub: `${c.metric} · ${fmtInt(c.n_models)} models` }))}
162 + cell={cellFn}
163 + min={0}
164 + max={1}
165 + color="var(--type-benchmark)"
166 + caption="Benchmark matrix: models × benchmarks, best comparable current score, coloured by within-column rank"
167 + rowHeader="Model"
168 + className="max-h-[75dvh] overflow-auto scrollbar-thin"
169 + />
170 + <Note className={cn('mt-3')}>
171 + Rows are the models with at least {fmtInt(matrix.min_cells)} cells{comparableOnly ? ' (comparable rows only)' : ''}, ordered by mean rank; {yearFrom || yearTo ? `release-year filter applied in the page (${yearFrom ?? '…'} – ${yearTo ?? '…'}); ` : ''}columns are {benchmarks.length ? 'the picked benchmarks' : 'the 12 benchmarks with the most current results'}. Empty = no current result in the group, never interpolated.
172 + </Note>
173 + </>
174 + )}
175 + </div>
176 + </TerminalLayout>
177 + </>
178 + );
179 +}
modified apps/web/src/app/benchmarks/page.tsx +200 −75
@@ -1,101 +1,226 @@
1 1 import type { Metadata } from 'next';
2 2 import Link from 'next/link';
3 −import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
3 +import { redirect } from 'next/navigation';
4 +import { BenchmarksFilterRail } from '@/components/benchmarks/filter-rails';
5 +import { TerminalLayout } from '@/components/layout/terminal';
6 +import { ScrollX } from '@/components/models/scroll-x';
7 +import { TrustBadge } from '@/components/models/badges';
8 +import { fmtScoreUnit } from '@/components/models/shared';
9 +import { ComparabilityLegend } from '@/components/entity/model-blocks';
4 10 import { EntityLink } from '@/components/ui/entity';
5 −import { Container, Note, PageHeader } from '@/components/ui/section';
11 +import { Hint } from '@/components/ui/hint';
12 +import { Container, Note } from '@/components/ui/section';
6 13 import { EmptyState, Unavailable } from '@/components/ui/unavailable';
7 −import { api, safe } from '@/lib/api';
14 +import { apiD1, safe } from '@/lib/api';
8 15 import { cn } from '@/lib/cn';
9 −import { fmtAgo, fmtInt, fmtScore, num } from '@/lib/format';
10 −import { routes } from '@/lib/site';
16 +import { fmtAgo, fmtInt, num } from '@/lib/format';
17 +import { routes, SITE_NAME, SITE_URL } from '@/lib/site';
18 +import type { BenchmarkListItem } from '@/lib/types';
11 19
12 −export const metadata: Metadata = { title: 'AI benchmarks & leaderboards', description: 'Benchmarks with their published results, evaluation configs and current leaders — never compared blindly across configs.', alternates: { canonical: '/benchmarks' } };
20 +export const metadata: Metadata = {
21 + title: 'AI benchmarks — leaderboards, comparability groups & current leaders',
22 + description: 'Every benchmark in the atlas grouped by family and variant: metric and direction, current results, distinct models, the current recorded leader with its trust level — one row per canonical model, never compared across configurations.',
23 + alternates: { canonical: '/benchmarks' },
24 + openGraph: { title: `AI benchmarks — leaderboards, comparability groups & current leaders | ${SITE_NAME}`, url: `${SITE_URL}/benchmarks`, type: 'website' },
25 +};
13 26 export const revalidate = 300;
14 27
15 −export default async function BenchmarksPage({ searchParams }: { searchParams: Promise<{ category?: string }> }) {
16 − const { category } = await searchParams;
17 − const res = await safe(api.benchmarks());
18 − const all = (res?.items ?? []).slice().sort((a, b) => (num(b.result_count) ?? 0) - (num(a.result_count) ?? 0));
28 +type SP = { category?: string; view?: string; q?: string; with_results?: string };
29 +
30 +function familyOf(b: BenchmarkListItem): string {
31 + return b.family ?? b.slug;
32 +}
33 +
34 +export default async function BenchmarksPage({ searchParams }: { searchParams: Promise<SP> }) {
35 + const sp = await searchParams;
36 + if (sp.view === 'matrix') redirect('/benchmarks/matrix');
37 + const category = sp.category?.trim() || undefined;
38 + const q = sp.q?.trim().toLowerCase() || undefined;
39 + const withResults = sp.with_results === '1';
40 + const [res, meth] = await Promise.all([safe(apiD1.benchmarks()), safe(apiD1.methodology())]);
41 + const all = res?.items ?? [];
19 42 const cats = new Map<string, number>();
20 − for (const b of all) {
21 − const c = typeof b.attributes?.category === 'string' ? b.attributes.category : null;
22 − if (c) cats.set(c, (cats.get(c) ?? 0) + 1);
23 − }
43 + for (const b of all) if (b.category) cats.set(b.category, (cats.get(b.category) ?? 0) + 1);
24 44 const catList = [...cats.entries()].sort((x, y) => y[1] - x[1] || x[0].localeCompare(y[0]));
25 − const items = category ? all.filter((b) => b.attributes?.category === category) : all;
26 − const chip = (active: boolean) => cn('inline-flex h-8 items-center gap-1.5 border px-2.5 text-xs whitespace-nowrap', active ? 'border-ink bg-ink text-canvas' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink');
45 + let items = category ? all.filter((b) => b.category === category) : all;
46 + if (q) items = items.filter((b) => `${b.name} ${b.family ?? ''} ${b.variant ?? ''}`.toLowerCase().includes(q));
47 + if (withResults) items = items.filter((b) => (num(b.result_count) ?? 0) > 0);
48 + const empty = all.filter((b) => (num(b.result_count) ?? 0) === 0).length;
49 + const totalResults = all.reduce((n, b) => n + (num(b.result_count) ?? 0), 0);
50 + // Family grouping: families by total results, head first, then variants by results.
51 + const fams = new Map<string, BenchmarkListItem[]>();
52 + for (const b of items) fams.set(familyOf(b), [...(fams.get(familyOf(b)) ?? []), b]);
53 + const famList = [...fams.entries()]
54 + .map(([f, list]) => ({ f, list: list.sort((a, b) => Number(!!b.attributes?.family_head) - Number(!!a.attributes?.family_head) || (num(b.result_count) ?? 0) - (num(a.result_count) ?? 0) || a.name.localeCompare(b.name)), total: list.reduce((n, b) => n + (num(b.result_count) ?? 0), 0) }))
55 + .sort((a, b) => b.total - a.total || a.f.localeCompare(b.f));
56 + const href = (patch: Record<string, string | undefined>) => {
57 + const p = new URLSearchParams();
58 + const cur = { category, q: sp.q, with_results: withResults ? '1' : undefined, ...patch };
59 + for (const [k, v] of Object.entries(cur)) if (v) p.set(k, v);
60 + const s = p.toString();
61 + return s ? `/benchmarks?${s}` : '/benchmarks';
62 + };
63 + const trustLevels = meth?.trust_levels ?? [];
64 + const filterCount = [category, q, withResults ? '1' : undefined].filter(Boolean).length;
65 + const ld = { '@context': 'https://schema.org', '@type': 'CollectionPage', name: 'AI benchmarks', url: `${SITE_URL}/benchmarks`, description: metadata.description, mainEntity: { '@type': 'ItemList', numberOfItems: all.length, itemListElement: all.slice(0, 12).map((b, i) => ({ '@type': 'ListItem', position: i + 1, name: b.name, url: `${SITE_URL}${routes.benchmark(b.slug)}` })) } };
66 +
67 + const filters = <BenchmarksFilterRail q={sp.q} category={category} withResults={withResults} total={all.length} categories={catList.map(([c, n]) => ({ c, n }))} hrefAll={href({ category: undefined })} categoryHrefs={Object.fromEntries(catList.map(([c]) => [c, href({ category: category === c ? undefined : c })]))} />;
68 + const inspector = (
69 + <div className="space-y-5 text-sm" data-benchmark-inspector>
70 + <dl className="kv [&>div]:grid-cols-[8rem_minmax(0,1fr)] [&>div]:py-1">
71 + <div>
72 + <dt>Benchmarks</dt>
73 + <dd className="tnum text-ink">{fmtInt(all.length)}</dd>
74 + </div>
75 + <div>
76 + <dt>Current results</dt>
77 + <dd className="tnum text-ink">{fmtInt(totalResults)}</dd>
78 + </div>
79 + <div>
80 + <dt>Without results</dt>
81 + <dd className="tnum text-ink">{fmtInt(empty)}</dd>
82 + </div>
83 + </dl>
84 + <div>
85 + <p className="eyebrow mb-1">Trust levels</p>
86 + <ul className="space-y-1">
87 + {(trustLevels.length ? trustLevels : Object.entries({ 'official-benchmark': 'Official benchmark leaderboard', 'independent-evaluator': 'Independent third-party evaluator', community: 'Community-run leaderboard or submission' }).map(([key, label]) => ({ key, label }))).map((t) => (
88 + <li key={t.key} className="flex items-start gap-2 text-xs text-ink-2">
89 + <TrustBadge level={t.key} /> <span>{t.label}</span>
90 + </li>
91 + ))}
92 + </ul>
93 + </div>
94 + <div>
95 + <p className="eyebrow mb-1">Comparability</p>
96 + <ComparabilityLegend />
97 + </div>
98 + <Note>{res?.note ?? 'Leaderboards show one row per canonical model: its best current row inside the comparability group (metric × task-defining configuration).'}</Note>
99 + </div>
100 + );
101 +
27 102 return (
28 − <Container>
29 − <PageHeader eyebrow="Benchmarks" title="Benchmarks" lede="Evaluation suites and the results published for them. Each result carries its configuration; leaders are shown per benchmark, not as a composite." aside={res ? <p className="tnum text-sm text-ink-3">{fmtInt(items.length)}{category ? ` of ${fmtInt(all.length)}` : ''} benchmarks</p> : undefined}>
30 − {catList.length > 0 && (
31 − <nav aria-label="Category" className="no-scrollbar -mx-4 mt-6 flex gap-1.5 overflow-x-auto px-4 md:mx-0 md:flex-wrap md:px-0">
32 − <Link href={routes.benchmarks()} className={chip(!category)} aria-current={!category ? 'true' : undefined}>
33 − All <span className="tnum opacity-70">{fmtInt(all.length)}</span>
34 − </Link>
35 − {catList.map(([c, n]) => (
36 − <Link key={c} href={`/benchmarks?category=${encodeURIComponent(c)}`} className={chip(category === c)} aria-current={category === c ? 'true' : undefined}>
37 − {c} <span className="tnum opacity-70">{fmtInt(n)}</span>
38 − </Link>
39 − ))}
40 − </nav>
41 − )}
42 − </PageHeader>
43 − <div className="pb-16">
103 + <>
104 + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} />
105 + <Container wide>
106 + <header className="flex flex-col gap-3 pb-4 pt-6 md:flex-row md:items-end md:justify-between md:pt-8">
107 + <div className="min-w-0">
108 + <p className="eyebrow">Benchmarks</p>
109 + <h1 className="display mt-1 text-[26px] md:text-[34px]">Benchmarks</h1>
110 + <p className="mt-2 max-w-2xl text-sm text-ink-2">Evaluation suites grouped by family and variant. Each leaderboard is one row per canonical model inside a comparability group; leaders are per group, never a composite.</p>
111 + </div>
112 + {res && (
113 + <p className="tnum text-sm text-ink-2">
114 + <span className="font-semibold text-ink">{fmtInt(items.length)}</span> {category ? `of ${fmtInt(all.length)} ` : ''}benchmarks · <span className="font-semibold text-ink">{fmtInt(totalResults)}</span> current results
115 + <Hint text="Current results = one row per model × benchmark × metric × configuration run; closed (superseded) rows are kept in history." align="right" />
116 + </p>
117 + )}
118 + </header>
119 + </Container>
120 + <TerminalLayout filters={filters} filterCount={filterCount} inspector={inspector} inspectorTitle="Legend" storageKey="aia-benchmarks-inspector">
44 121 {!res ? (
45 122 <Unavailable what="Benchmarks" />
46 123 ) : items.length === 0 ? (
47 − <EmptyState title={category ? `No benchmark in “${category}”` : 'No benchmarks recorded yet'}>{category && <Link href={routes.benchmarks()} className="link">Show all benchmarks</Link>}</EmptyState>
124 + <EmptyState title={category ? `No benchmark in “${category}”` : 'No benchmark matches'}>
125 + <Link href="/benchmarks" className="link">
126 + Show all benchmarks
127 + </Link>
128 + </EmptyState>
48 129 ) : (
49 130 <>
50 − <DataTable caption="Benchmarks">
131 + {empty > 0 && !withResults && (
132 + <p className="mb-2 border-l-2 border-warning bg-warning-soft/40 px-3 py-2 text-xs text-ink-2" data-honesty>
133 + {fmtInt(empty)} benchmark{empty === 1 ? '' : 's'} ha{empty === 1 ? 's' : 've'} no results yet — sources being connected. They are listed so their definitions and aliases resolve; nothing is fabricated.
134 + </p>
135 + )}
136 + <ScrollX>
137 + <table className="data-table stack compact" data-benchmarks-table>
138 + <caption className="sr-only">Benchmarks by family</caption>
51 139 <thead>
52 140 <tr>
53 − <Th>Benchmark</Th>
54 − <Th>Category</Th>
55 − <Th>Metric</Th>
56 − <Th num>Results</Th>
57 − <Th num>Models</Th>
58 − <Th>Current leader</Th>
59 − <Th>Updated</Th>
141 + <th scope="col">Family · variant</th>
142 + <th scope="col">Metric</th>
143 + <th scope="col" className="num">
144 + Results
145 + </th>
146 + <th scope="col" className="num">
147 + Models
148 + </th>
149 + <th scope="col">Current leader</th>
150 + <th scope="col">Updated</th>
60 151 </tr>
61 152 </thead>
62 153 <tbody>
63 − {items.length === 0 && <EmptyRow cols={7}>No benchmarks recorded yet.</EmptyRow>}
64 − {items.map((b) => {
65 − const a = b.attributes ?? {};
66 − return (
67 − <tr key={b.id}>
68 − <Td primary>
69 − <Link href={routes.benchmark(b.slug)} className="text-ink hover:text-accent hover:underline">{b.name}</Link>
70 − {(b.description || typeof a.task === 'string') && <span className="block max-w-md truncate text-xs text-ink-3">{b.description ?? (a.task as string)}</span>}
71 − </Td>
72 − <Td label="Category" className="text-ink-2">{typeof a.category === 'string' ? <Link href={`/benchmarks?category=${encodeURIComponent(a.category)}`} className="hover:text-accent">{a.category}</Link> : <span className="text-ink-3">—</span>}</Td>
73 − <Td label="Metric" className="text-ink-2">
74 − {typeof a.metric === 'string' ? a.metric : <span className="text-ink-3">—</span>}
75 − {typeof a.unit === 'string' && a.unit && <span className="text-ink-3"> ({a.unit})</span>}
76 − </Td>
77 − <Td num label="Results" className="tnum">{fmtInt(b.result_count)}</Td>
78 − <Td num label="Models" className="tnum text-ink-2">{fmtInt(b.model_count)}</Td>
79 − <Td label="Current leader">
80 − {b.top ? (
81 − <span>
82 − <EntityLink e={b.top.model} className="font-medium" /> <span className="tnum text-ink-2">{fmtScore(b.top.score)}{a.unit === '%' ? '%' : ''}</span>
83 − {b.top.model.organization && <span className="block text-xs text-ink-3">{b.top.model.organization.name}</span>}
84 − </span>
85 − ) : (
86 − <span className="text-ink-3">—</span>
87 − )}
88 − </Td>
89 − <Td label="Updated" className="text-ink-2" title={b.updated_at}>{fmtAgo(b.updated_at)}</Td>
90 − </tr>
91 − );
92 − })}
154 + {famList.map((fam) => (
155 + <FamilyRows key={fam.f} fam={fam.f} list={fam.list} />
156 + ))}
93 157 </tbody>
94 − </DataTable>
95 − <Note className="mt-3">Leader = best current result under the benchmark's default direction (higher or lower is better). Configs differ; open a benchmark for its leaderboard, config filter and per-model history.</Note>
158 + </table>
159 + </ScrollX>
160 + <Note className="mt-3">Leader = best current row of the benchmark's primary comparability group (most-populated task configuration of the canonical metric), with the trust level of that row. Open a benchmark for the group picker, trust filter, frontier over time and per-model history.</Note>
96 161 </>
97 162 )}
98 − </div>
99 − </Container>
163 + </TerminalLayout>
164 + </>
165 + );
166 +}
167 +
168 +function FamilyRows({ fam, list }: { fam: string; list: BenchmarkListItem[] }) {
169 + const grouped = list.length > 1;
170 + const head = list.find((b) => b.attributes?.family_head) ?? null;
171 + return (
172 + <>
173 + {grouped && (
174 + <tr className="bg-surface-2/40">
175 + <td colSpan={6} className="!py-1.5 text-[11px] font-semibold uppercase tracking-[0.08em] text-ink-3">
176 + {head?.family ?? fam} <span className="tnum font-normal normal-case tracking-normal">· {list.length} variants · {fmtInt(list.reduce((n, b) => n + (num(b.result_count) ?? 0), 0))} results</span>
177 + </td>
178 + </tr>
179 + )}
180 + {list.map((b) => {
181 + const empty = (num(b.result_count) ?? 0) === 0;
182 + const leader = b.leader;
183 + return (
184 + <tr key={b.id} className={cn(empty && 'text-ink-3')}>
185 + <td className={cn('primary', grouped && 'md:pl-5')}>
186 + <Link href={routes.benchmark(b.slug)} className="text-ink hover:text-accent hover:underline">
187 + {b.name}
188 + </Link>
189 + <span className="block text-[11px] text-ink-3">
190 + {grouped && b.variant ? `variant ${b.variant}` : b.category ?? ''}
191 + {grouped && b.variant && b.category ? ` · ${b.category}` : ''}
192 + {b.attributes?.family_head ? ' · family head' : ''}
193 + </span>
194 + </td>
195 + <td data-label="Metric" className="text-ink-2">
196 + {b.metric ?? '—'}
197 + <span className="text-ink-3"> {b.direction === 'lower' ? '↓ lower is better' : '↑'}</span>
198 + {b.groups.length > 1 && <span className="block text-[11px] text-ink-3">{fmtInt(b.groups.length)} comparability groups</span>}
199 + </td>
200 + <td data-label="Results" className="num tnum">
201 + {empty ? <span className="text-ink-3">0</span> : fmtInt(b.result_count)}
202 + </td>
203 + <td data-label="Models" className="num tnum text-ink-2">
204 + {fmtInt(b.model_count)}
205 + </td>
206 + <td data-label="Current leader">
207 + {leader ? (
208 + <span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
209 + <EntityLink e={{ ...leader.model, entity_type: 'model' }} className="font-medium" />
210 + <span className="tnum text-ink-2">{fmtScoreUnit(leader.score, leader.unit)}</span>
211 + <TrustBadge level={leader.trust_level} label={leader.trust_label} />
212 + {leader.model.organization && <span className="block w-full text-[11px] text-ink-3">{leader.model.organization.name}</span>}
213 + </span>
214 + ) : (
215 + <span className="text-xs text-ink-3">{empty ? 'no results yet' : '—'}</span>
216 + )}
217 + </td>
218 + <td data-label="Updated" className="text-xs text-ink-2" title={leader?.observed_at}>
219 + {leader ? fmtAgo(leader.observed_at) : '—'}
220 + </td>
221 + </tr>
222 + );
223 + })}
224 + </>
100 225 );
101 226 }
modified apps/web/src/app/compare/page.tsx +58 −38
@@ -1,28 +1,37 @@
1 1 import type { Metadata } from 'next';
2 −import { CompareMatrix, ComparePrices, SharedBenchmarks } from '@/components/compare/compare-matrix';
2 +import Link from 'next/link';
3 3 import { ComparePicker } from '@/components/compare/compare-picker';
4 4 import type { TrayItem } from '@/components/compare/compare-store';
5 +import { CompareTerminal } from '@/components/compare/compare-terminal';
5 6 import { EntityBadge } from '@/components/ui/badges';
6 7 import { Container, Note, PageHeader } from '@/components/ui/section';
7 8 import { EmptyState, Unavailable } from '@/components/ui/unavailable';
8 −import { api, ApiError, safe } from '@/lib/api';
9 +import { api, ApiError, apiD1, safe } from '@/lib/api';
10 +import { cn } from '@/lib/cn';
9 11 import { fmtInt } from '@/lib/format';
10 −import { routes, SITE_NAME, typeLabel } from '@/lib/site';
11 −import type { ComparePayload } from '@/lib/types';
12 +import { routes, SITE_NAME, SITE_URL, typeLabel } from '@/lib/site';
13 +import type { ComparePayload11 } from '@/lib/types';
12 14
13 15 export const revalidate = 300;
14 16
15 −type SP = { ids?: string };
17 +type SP = { ids?: string; mode?: string; diff_only?: string };
18 +const MODES = [
19 + { id: 'models', label: 'Models', type: 'model' },
20 + { id: 'providers', label: 'Providers', type: 'provider' },
21 + { id: 'hardware', label: 'Hardware', type: 'hardware' },
22 + { id: 'companies', label: 'Companies', type: 'company' },
23 + { id: 'frameworks', label: 'Frameworks', type: 'framework' },
24 +];
16 25
17 26 function parseIds(raw: string | undefined): string[] {
18 27 return [...new Set((raw ?? '').split(',').map((s) => s.trim()).filter(Boolean))].slice(0, 6);
19 28 }
20 29
21 30 /** Resolve the comparison, distinguishing a semantic 400 (mixed types / unknown slug) from an outage. */
22 −async function load(ids: string[]): Promise<{ res: ComparePayload | null; error: string | null }> {
31 +async function load(ids: string[], mode: string | undefined, diffOnly: boolean): Promise<{ res: ComparePayload11 | null; error: string | null }> {
23 32 if (ids.length < 2) return { res: null, error: null };
24 33 try {
25 − return { res: await api.compare(ids), error: null };
34 + return { res: await apiD1.compare(ids, { diff_only: diffOnly, mode }), error: null };
26 35 } catch (e) {
27 36 if (e instanceof ApiError && (e.status === 400 || e.status === 404 || e.status === 422)) return { res: null, error: e.detail ?? 'These entities cannot be compared.' };
28 37 return { res: null, error: null };
@@ -30,62 +39,73 @@ async function load(ids: string[]): Promise<{ res: ComparePayload | null; error:
30 39 }
31 40
32 41 export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {
33 − const ids = parseIds((await searchParams).ids);
34 − const res = ids.length >= 2 ? await safe(api.compare(ids)) : null;
42 + const sp = await searchParams;
43 + const ids = parseIds(sp.ids);
44 + const res = ids.length >= 2 ? await safe(apiD1.compare(ids, { mode: sp.mode })) : null;
35 45 const canonical = routes.compare(ids.length >= 2 ? ids : undefined);
36 − if (!res) return { title: 'Compare — models, providers, hardware side by side', description: 'Compare 2–6 entities of the same type on every recorded dimension, with the source of each value.', alternates: { canonical } };
46 + if (!res) return { title: 'Compare — models, providers, hardware side by side', description: 'Compare 2–6 entities of one type on every recorded dimension — architecture, context, capabilities, comparable benchmarks, cheapest deployments, licences — with the source of each value.', alternates: { canonical } };
37 47 const names = res.items.map((i) => i.entity.name);
38 48 const title = `${names.join(' vs ')} — ${typeLabel(res.entity_type).toLowerCase()} comparison`;
39 − const description = `${names.join(', ')} compared on ${res.dimensions.length} dimensions with the source of every value. ${SITE_NAME}.`.slice(0, 300);
40 − return { title, description, alternates: { canonical }, openGraph: { title, description } };
49 + const description = `${names.join(', ')} compared on ${res.dimensions.length} recorded dimensions (comparable benchmark groups only) with the source of every value. ${SITE_NAME}.`.slice(0, 300);
50 + return { title, description, alternates: { canonical }, openGraph: { title: `${title} | ${SITE_NAME}`, description, url: `${SITE_URL}${canonical}`, type: 'website' } };
41 51 }
42 52
43 53 export default async function ComparePage({ searchParams }: { searchParams: Promise<SP> }) {
44 − const ids = parseIds((await searchParams).ids);
45 − const [{ res, error }, examples] = await Promise.all([load(ids), ids.length < 2 ? safe(api.models({ limit: 2, sort: 'quality' })) : Promise.resolve(null)]);
54 + const sp = await searchParams;
55 + const ids = parseIds(sp.ids);
56 + const diffOnly = sp.diff_only === '1';
57 + const mode = MODES.some((m) => m.id === sp.mode) ? sp.mode : undefined;
58 + const [{ res, error }, examples] = await Promise.all([load(ids, mode, diffOnly), ids.length < 2 ? safe(api.models({ limit: 3, sort: 'quality' })) : Promise.resolve(null)]);
46 59 const initial: TrayItem[] = res ? res.items.map((it) => ({ slug: it.entity.slug, name: it.entity.name, entity_type: it.entity.entity_type, organization: it.entity.organization?.name ?? null })) : [];
47 − const exampleHref = examples && examples.items.length >= 2 ? routes.compare(examples.items.slice(0, 2).map((m) => m.slug)) : null;
48 − const isModel = res?.entity_type === 'model';
60 + const exampleHref = examples && examples.items.length >= 2 ? routes.compare(examples.items.slice(0, 3).map((m) => m.slug)) : null;
61 + const activeMode = res ? MODES.find((m) => m.type === res.entity_type || (m.type === 'company' && ['organization', 'lab', 'university'].includes(res.entity_type)) || (m.type === 'framework' && ['library', 'runtime'].includes(res.entity_type)))?.id ?? 'models' : mode ?? 'models';
62 + const ld = res ? { '@context': 'https://schema.org', '@type': 'Dataset', name: `${res.items.map((i) => i.entity.name).join(' vs ')} — comparison`, url: `${SITE_URL}${routes.compare(ids)}`, about: res.items.map((i) => ({ '@type': 'Thing', name: i.entity.name, url: `${SITE_URL}${routes.entity(i.entity)}` })) } : null;
49 63
50 64 return (
51 65 <Container wide>
66 + {ld && <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} />}
52 67 <PageHeader
53 − eyebrow={res ? <><span>Compare</span><EntityBadge type={res.entity_type} small /></> : 'Compare'}
68 + eyebrow={
69 + res ? (
70 + <>
71 + <span>Compare</span>
72 + <EntityBadge type={res.entity_type} small />
73 + </>
74 + ) : (
75 + 'Compare'
76 + )
77 + }
54 78 title={res ? res.items.map((i) => i.entity.name).join(' vs ') : 'Compare side by side'}
55 − lede={res ? `${fmtInt(res.items.length)} ${typeLabel(res.entity_type, true).toLowerCase()} on ${fmtInt(res.dimensions.length)} recorded dimensions. Each cell shows the value as stated by its source.` : 'Two to six entities of one type — models, providers, hardware, frameworks or companies. Each cell shows the recorded value and where it came from.'}
79 + lede={res ? `${fmtInt(res.items.length)} ${typeLabel(res.entity_type, true).toLowerCase()} · ${fmtInt(res.dimensions.length)} recorded dimensions${diffOnly ? ' (differences only)' : ''}. Each cell is the value as stated by its source; benchmarks only where every entity sits in the same comparability group.` : 'Two to six entities of one type — models, providers, hardware, companies or frameworks. Each cell shows the recorded value, its source and tier; benchmarks are only compared inside the same comparability group.'}
56 80 >
81 + <nav aria-label="Compare mode" className="no-scrollbar -mx-4 mt-5 flex gap-1 overflow-x-auto border-b border-rule px-4 md:mx-0 md:px-0" data-compare-modes>
82 + {MODES.map((m) => {
83 + const on = m.id === activeMode;
84 + const keep = res && m.id === activeMode ? `?ids=${ids.map(encodeURIComponent).join(',')}&mode=${m.id}` : `?mode=${m.id}`;
85 + return (
86 + <Link key={m.id} href={`/compare${keep}`} aria-current={on ? 'page' : undefined} className={cn('-mb-px flex h-10 shrink-0 items-center gap-1.5 border-b-2 px-3 text-sm whitespace-nowrap', on ? 'border-ink font-medium text-ink' : 'border-transparent text-ink-2 hover:text-ink')}>
87 + <EntityBadge type={m.type} small className={on ? '' : 'opacity-60'} /> {m.label}
88 + </Link>
89 + );
90 + })}
91 + </nav>
57 92 <ComparePicker initial={initial} exampleHref={exampleHref} />
58 93 </PageHeader>
59 94
60 − <div className="space-y-12 pb-16">
95 + <div className="space-y-8 pb-16">
61 96 {ids.length < 2 ? (
62 − <EmptyState title="Pick at least two entities of the same type">Search above, or press “Compare” on any model, provider or hardware listing — your selection is kept in this browser and in the URL.</EmptyState>
97 + <EmptyState title={`Pick at least two ${MODES.find((m) => m.id === activeMode)?.label.toLowerCase() ?? 'entities'}`}>Search above, or press “Compare” on any listing or entity page — the selection is kept in this browser and in the URL, so it can be shared.</EmptyState>
63 98 ) : error ? (
64 99 <EmptyState title="These entities cannot be compared together">
65 − {error}. Comparisons work across entities of one type (all models, all providers…). Remove the odd one out in the tray above.
100 + {error}. Comparisons work across entities of one type (all models, all providers…){mode ? ` — the “${MODES.find((m) => m.id === mode)?.label}” mode asserts that type` : ''}. Remove the odd one out in the tray above{mode ? ' or switch mode' : ''}.
66 101 </EmptyState>
67 102 ) : !res ? (
68 103 <Unavailable what="Comparison" reason="The API did not answer. Try again in a moment." />
69 104 ) : (
70 105 <>
71 − <section>
72 − <p className="eyebrow mb-2">Dimensions <span className="tnum text-ink-3">{res.dimensions.length}</span></p>
73 − <CompareMatrix res={res} />
74 − </section>
75 − {isModel && (
76 − <section>
77 − <p className="eyebrow mb-2">Shared benchmarks</p>
78 − <SharedBenchmarks res={res} />
79 − </section>
80 − )}
81 − {isModel && (
82 − <section>
83 − <p className="eyebrow mb-2">Prices · USD per 1M tokens</p>
84 − <ComparePrices res={res} />
85 − </section>
86 − )}
106 + <CompareTerminal res={res} diffOnly={diffOnly} />
87 107 <Note>
88 − Share this comparison with its URL. Values come from the atlas as of now; use each entity's History tab for how a value changed. <a href={routes.methodology()} className="link">How AI Atlas records facts →</a>
108 + Share this comparison with its URL. Values are the atlas as of now; use each entity's change history for how a value moved{res.entity_type === 'model' && res.items.length === 2 ? <>, or the <Link href={`/models/${encodeURIComponent(res.items[0]!.entity.slug)}/diff/${encodeURIComponent(res.items[1]!.entity.slug)}`} className="link">pairwise diff</Link> with deltas</> : null}. <Link href={routes.methodology()} className="link">How AI Atlas records facts →</Link>
89 109 </Note>
90 110 </>
91 111 )}
added apps/web/src/app/families/[slug]/opengraph-image.tsx +22 −0
@@ -0,0 +1,22 @@
1 +import { ImageResponse } from 'next/og';
2 +import { Fallback, Wallpaper } from '@/components/brand/og';
3 +import { apiD1, safe } from '@/lib/api';
4 +import { fmtDate, fmtInt, fmtParams, num } from '@/lib/format';
5 +import { routes, SITE_NAME } from '@/lib/site';
6 +
7 +export const runtime = 'nodejs';
8 +export const alt = `Model family on ${SITE_NAME}`;
9 +export const size = { width: 1200, height: 630 };
10 +export const contentType = 'image/png';
11 +
12 +export default async function FamilyOgImage({ params }: { params: Promise<{ slug: string }> }) {
13 + const { slug } = await params;
14 + const f = await safe(apiD1.family(slug, 5));
15 + if (!f) return new ImageResponse(<Fallback label="Family" />, { ...size });
16 + const counters: [string, string][] = [['Models', fmtInt(f.model_count)]];
17 + if (f.first_release) counters.push(['First release', fmtDate(f.first_release)]);
18 + if (f.last_release) counters.push(['Latest release', fmtDate(f.last_release)]);
19 + if (f.param_range && num(f.param_range.min) !== null && num(f.param_range.max) !== null) counters.push(['Parameters', num(f.param_range.min) === num(f.param_range.max) ? fmtParams(f.param_range.min) : `${fmtParams(f.param_range.min)} – ${fmtParams(f.param_range.max)}`]);
20 + const subtitle = [f.modalities?.length ? f.modalities.join(', ') : null, f.licenses?.length ? `licences: ${f.licenses.slice(0, 3).map((l) => l.key).join(', ')}` : null, num(f.artifacts_count) ? `${fmtInt(f.artifacts_count)} artifacts` : null].filter(Boolean).join(' · ') || 'Releases, members, lineage and benchmark progress.';
21 + return new ImageResponse(<Wallpaper eyebrow={f.organization ? `Family · ${f.organization.name}` : 'Model family'} title={f.name} subtitle={subtitle} counters={counters.slice(0, 4)} footer={`www.ai-atlas.co${routes.family(f.slug)}`} markPx={220} />, { ...size });
22 +}
added apps/web/src/app/families/[slug]/page.tsx +313 −0
@@ -0,0 +1,313 @@
1 +import type { Metadata } from 'next';
2 +import { ScrollX } from '@/components/models/scroll-x';
3 +import Link from 'next/link';
4 +import { notFound, permanentRedirect } from 'next/navigation';
5 +import { CompareTrayBar } from '@/components/compare/compare-tray-bar';
6 +import { CompareButton } from '@/components/compare/compare-button';
7 +import { ViewBeacon } from '@/components/layout/view-beacon';
8 +import { OpennessChip } from '@/components/models/badges';
9 +import { FamilyBenchmarkProgress, FamilyReleaseLanes } from '@/components/models/family-blocks';
10 +import { MiniGraph } from '@/components/models/mini-graph';
11 +import { Chip, EntityBadge, StatusBadge } from '@/components/ui/badges';
12 +import { DataTable, Td, Th } from '@/components/ui/data-table';
13 +import { EntityLink, QualityMark } from '@/components/ui/entity';
14 +import { Hint } from '@/components/ui/hint';
15 +import { Container, Note } from '@/components/ui/section';
16 +import { EmptyState } from '@/components/ui/unavailable';
17 +import { ApiError, apiD1, safe } from '@/lib/api';
18 +import { fmtDate, fmtInt, fmtParams, fmtTokens, num } from '@/lib/format';
19 +import { routes, SITE_NAME, SITE_URL } from '@/lib/site';
20 +import type { FamilyDetail } from '@/lib/types';
21 +
22 +type Params = { params: Promise<{ slug: string }> };
23 +export const revalidate = 600;
24 +
25 +async function load(slug: string): Promise<FamilyDetail> {
26 + try {
27 + return await apiD1.family(slug, 300);
28 + } catch (e) {
29 + if (e instanceof ApiError && e.notFound) notFound();
30 + throw e;
31 + }
32 +}
33 +
34 +function describe(f: FamilyDetail): string {
35 + const pmin = num(f.param_range?.min);
36 + const pmax = num(f.param_range?.max);
37 + const bits = [`${fmtInt(f.model_count)} canonical models`, f.first_release ? `released ${fmtDate(f.first_release)} → ${f.last_release ? fmtDate(f.last_release) : 'today'}` : null, pmin !== null ? `${fmtParams(pmin)}${pmax !== null && pmax !== pmin ? ` – ${fmtParams(pmax)}` : ''} parameters` : null, f.licenses?.length ? `licences ${f.licenses.map((l) => l.key).slice(0, 3).join(', ')}` : null, num(f.artifacts_count) ? `${fmtInt(f.artifacts_count)} artifacts` : null].filter(Boolean);
38 + return `${f.name}${f.organization ? ` by ${f.organization.name}` : ''} — ${bits.join('; ')}. Release timeline, members, lineage, licence mix and benchmark progress on ${SITE_NAME}.`.slice(0, 300);
39 +}
40 +
41 +export async function generateMetadata({ params }: Params): Promise<Metadata> {
42 + const { slug } = await params;
43 + const f = await safe(apiD1.family(slug, 5));
44 + if (!f || f.slug !== slug) return { title: 'Family', robots: { index: false } };
45 + const title = `${f.name} family — Releases, Members, Lineage & Benchmarks`;
46 + const description = describe(f);
47 + const canonical = routes.family(f.slug);
48 + return { title, description, alternates: { canonical }, openGraph: { title: `${title} | ${SITE_NAME}`, description, url: `${SITE_URL}${canonical}`, type: 'article', siteName: SITE_NAME }, twitter: { card: 'summary_large_image', title, description } };
49 +}
50 +
51 +export default async function FamilyPage({ params }: Params) {
52 + const { slug } = await params;
53 + const f = await load(slug);
54 + if (f.slug && f.slug !== slug) permanentRedirect(routes.family(f.slug));
55 + const bench = await safe(apiD1.benchmarks());
56 + const benchNames: Record<string, string> = {};
57 + for (const b of bench?.items ?? []) benchNames[b.slug] = b.name;
58 + const canonical = routes.family(f.slug);
59 + const members = [...f.members].sort((a, b) => String(b.key_facts?.release_date ?? '').localeCompare(String(a.key_facts?.release_date ?? '')) || a.model.name.localeCompare(b.model.name));
60 + const pmin = num(f.param_range?.min);
61 + const pmax = num(f.param_range?.max);
62 + const nodes = f.members.map((m) => ({ id: m.model.id, label: m.model.name, href: routes.entity(m.model), sub: num(m.model.attributes?.parameter_count) !== null ? fmtParams(m.model.attributes.parameter_count) : undefined }));
63 + const licTotal = f.licenses.reduce((n, l) => n + (num(l.models) ?? 0), 0);
64 + const ranks = Object.entries(f.benchmark_best ?? {}).sort((a, b) => a[1].rank - b[1].rank);
65 + const root = typeof f.summary?.attributes?.family_root === 'string' ? (f.summary.attributes.family_root as string) : null;
66 + const ld = { '@context': 'https://schema.org', '@type': 'CreativeWorkSeries', name: f.name, url: `${SITE_URL}${canonical}`, description: describe(f), creator: f.organization ? { '@type': 'Organization', name: f.organization.name } : undefined, hasPart: members.slice(0, 20).map((m) => ({ '@type': 'SoftwareApplication', name: m.model.name, url: `${SITE_URL}${routes.entity(m.model)}` })) };
67 +
68 + return (
69 + <Container wide>
70 + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} />
71 + <ViewBeacon path={canonical} />
72 + <nav aria-label="Breadcrumb" className="pt-5 text-xs text-ink-3">
73 + <ol className="flex flex-wrap items-center gap-1.5">
74 + <li>
75 + <Link href="/" className="hover:text-ink">
76 + AI Atlas
77 + </Link>
78 + </li>
79 + <li aria-hidden>/</li>
80 + <li>
81 + <Link href={routes.families()} className="hover:text-ink">
82 + Families
83 + </Link>
84 + </li>
85 + {f.organization && (
86 + <>
87 + <li aria-hidden>/</li>
88 + <li>
89 + <Link href={routes.entity({ entity_type: 'company', slug: f.organization.slug })} className="hover:text-ink">
90 + {f.organization.name}
91 + </Link>
92 + </li>
93 + </>
94 + )}
95 + <li aria-hidden>/</li>
96 + <li className="text-ink-2">{f.name}</li>
97 + </ol>
98 + </nav>
99 + <header className="pb-6 pt-4 md:pt-5" data-family-header>
100 + <div className="flex flex-wrap items-center gap-2">
101 + <EntityBadge type="model_family" />
102 + {!f.canonical && (
103 + <span className="inline-flex items-center gap-0.5 rounded-[3px] bg-warning-soft px-1.5 text-[11px] font-medium text-warning">
104 + label-based family
105 + <Hint text={f.note ?? 'Grouped by the family label stated by sources; not yet a canonical model_family entity. Counts may change when canonicalised.'} />
106 + </span>
107 + )}
108 + {root && root !== f.name && <span className="text-xs text-ink-3">root · {root}</span>}
109 + </div>
110 + <div className="mt-3 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
111 + <div className="min-w-0">
112 + <h1 className="display text-[30px] md:text-[44px]">{f.name}</h1>
113 + <p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[15px] text-ink-2">
114 + {f.organization && (
115 + <Link href={routes.entity({ entity_type: 'company', slug: f.organization.slug })} className="font-medium text-ink hover:text-accent">
116 + {f.organization.name}
117 + </Link>
118 + )}
119 + <Link href={`/models?family=${encodeURIComponent(f.slug)}`} className="link">
120 + All {fmtInt(f.model_count)} models in the terminal →
121 + </Link>
122 + </p>
123 + {f.summary?.description && <p className="mt-3 max-w-3xl text-[15px] leading-relaxed text-ink-2">{f.summary.description}</p>}
124 + </div>
125 + {f.summary && (
126 + <div className="shrink-0 text-xs text-ink-3 lg:text-right">
127 + <QualityMark q={f.summary.quality?.score} label />
128 + <p className="mt-1">first seen {fmtDate(f.summary.first_seen_at)}</p>
129 + <p className="mono mt-0.5 text-[11px]">{f.id}</p>
130 + </div>
131 + )}
132 + </div>
133 + <dl className="tnum mt-4 grid grid-cols-2 gap-x-6 gap-y-3 border-y border-rule py-3 sm:grid-cols-3 lg:grid-cols-6" data-family-strip>
134 + {[
135 + ['Models', fmtInt(f.model_count)],
136 + ['First release', f.first_release ? fmtDate(f.first_release) : '—'],
137 + ['Latest release', f.last_release ? fmtDate(f.last_release) : '—'],
138 + ['Parameters', pmin === null ? '—' : pmin === pmax ? fmtParams(pmin) : `${fmtParams(pmin)} – ${fmtParams(pmax)}`],
139 + ['Artifacts', fmtInt(f.artifacts_count)],
140 + ['Providers', fmtInt(f.providers?.length ?? 0)],
141 + ].map(([k, v]) => (
142 + <div key={k} className="min-w-0">
143 + <dt className="eyebrow">{k}</dt>
144 + <dd className="mt-0.5 truncate text-[15px] font-medium text-ink">{v}</dd>
145 + </div>
146 + ))}
147 + </dl>
148 + </header>
149 +
150 + <div className="space-y-10 pb-16">
151 + <section id="releases">
152 + <p className="eyebrow mb-2">Releases</p>
153 + <FamilyReleaseLanes members={f.members} />
154 + </section>
155 +
156 + <section id="members">
157 + <p className="eyebrow mb-2">
158 + Members <span className="tnum text-ink-3">{fmtInt(members.length)}</span>
159 + </p>
160 + {members.length === 0 ? (
161 + <EmptyState title="No member recorded" />
162 + ) : (
163 + <ScrollX><DataTable caption="Family members" compact>
164 + <thead>
165 + <tr>
166 + <Th>Model</Th>
167 + <Th num>Params</Th>
168 + <Th num>Context</Th>
169 + <Th>Openness</Th>
170 + <Th>Licence</Th>
171 + <Th>Released</Th>
172 + <Th>Status</Th>
173 + <Th>Best rank</Th>
174 + <Th className="text-right">
175 + <span className="sr-only">Compare</span>
176 + </Th>
177 + </tr>
178 + </thead>
179 + <tbody>
180 + {members.map((m) => {
181 + const a = m.model.attributes ?? {};
182 + const kf = m.key_facts ?? {};
183 + const best = Object.entries(m.benchmark_ranks ?? {}).sort((x, y) => x[1] - y[1])[0];
184 + const status = String(kf.status ?? m.model.status ?? '');
185 + return (
186 + <tr key={m.model.id}>
187 + <Td primary>
188 + <EntityLink e={m.model} />
189 + {m.model.organization && m.model.organization.slug !== f.organization?.slug && <span className="block text-[11px] text-ink-3">{m.model.organization.name}</span>}
190 + </Td>
191 + <Td num label="Params" className="tnum">
192 + {num(a.parameter_count) === null ? <span className="text-ink-3">—</span> : fmtParams(a.parameter_count)}
193 + {num(a.active_parameter_count) !== null && num(a.active_parameter_count) !== num(a.parameter_count) && <span className="block text-[11px] text-ink-3">{fmtParams(a.active_parameter_count)} active</span>}
194 + </Td>
195 + <Td num label="Context" className="tnum">
196 + {num(kf.context_length ?? a.context_length) === null ? <span className="text-ink-3">—</span> : fmtTokens(kf.context_length ?? a.context_length)}
197 + </Td>
198 + <Td label="Openness">{typeof (kf.openness ?? a.openness) === 'string' ? <OpennessChip openness={String(kf.openness ?? a.openness)} /> : <span className="text-ink-3">—</span>}</Td>
199 + <Td label="Licence" className="max-w-[10rem] truncate text-xs text-ink-2">
200 + {typeof (a.license_key ?? a.license) === 'string' ? <Link href={`/licenses/${encodeURIComponent(String(a.license_key ?? a.license))}`} className="hover:text-accent">{String(a.license_key ?? a.license)}</Link> : <span className="text-ink-3">—</span>}
201 + </Td>
202 + <Td label="Released" className="tnum whitespace-nowrap text-ink-2">
203 + {typeof kf.release_date === 'string' ? fmtDate(kf.release_date) : <span className="text-ink-3">—</span>}
204 + </Td>
205 + <Td label="Status">
206 + <StatusBadge status={status} />
207 + </Td>
208 + <Td label="Best rank" className="text-xs text-ink-2">
209 + {best ? (
210 + <Link href={routes.benchmark(best[0])} className="whitespace-nowrap hover:text-accent" title={benchNames[best[0]] ?? best[0]}>
211 + <span className="tnum font-medium text-ink">#{fmtInt(best[1])}</span> {(benchNames[best[0]] ?? best[0]).replace('Artificial Analysis ', 'AA ').slice(0, 22)}
212 + </Link>
213 + ) : (
214 + <span className="text-ink-3">—</span>
215 + )}
216 + </Td>
217 + <Td className="text-right">
218 + <CompareButton e={m.model} size="sm" label="" />
219 + </Td>
220 + </tr>
221 + );
222 + })}
223 + </tbody>
224 + </DataTable></ScrollX>
225 + )}
226 + </section>
227 +
228 + <div className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_22rem]">
229 + <section id="lineage" className="min-w-0">
230 + <div className="mb-2 flex items-baseline justify-between gap-2">
231 + <p className="eyebrow">
232 + Lineage among members <span className="tnum text-ink-3">{fmtInt(f.lineage.length)} relations</span>
233 + </p>
234 + {f.summary && (
235 + <Link href={`${routes.graph(f.summary.slug)}?mode=lineage`} className="link text-xs">
236 + Open in Graph →
237 + </Link>
238 + )}
239 + </div>
240 + <MiniGraph nodes={nodes} edges={f.lineage} title={`Lineage of the ${f.name} family`} />
241 + </section>
242 + <aside className="min-w-0 space-y-8">
243 + <section id="licences">
244 + <p className="eyebrow mb-2">Licence mix</p>
245 + {f.licenses.length ? (
246 + <ul className="space-y-1.5 text-sm">
247 + {f.licenses.map((l) => (
248 + <li key={l.key} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3">
249 + <Link href={`/licenses/${encodeURIComponent(l.key)}`} className="truncate text-ink hover:text-accent" title={l.label}>
250 + {l.label}
251 + </Link>
252 + <span className="tnum text-xs text-ink-2">{fmtInt(l.models)}</span>
253 + <span className="col-span-2 h-1 rounded-sm bg-surface-2">
254 + <span className="block h-full rounded-sm bg-accent" style={{ width: `${licTotal ? (100 * (num(l.models) ?? 0)) / licTotal : 0}%` }} />
255 + </span>
256 + </li>
257 + ))}
258 + </ul>
259 + ) : (
260 + <p className="text-sm text-ink-3">No licence recorded on the members.</p>
261 + )}
262 + </section>
263 + <section id="providers">
264 + <p className="eyebrow mb-2">
265 + Providers <span className="tnum text-ink-3">{fmtInt(f.providers?.length ?? 0)}</span>
266 + </p>
267 + {f.providers?.length ? (
268 + <p className="flex flex-wrap gap-x-3 gap-y-1 text-sm">
269 + {f.providers.map((p) => (
270 + <EntityLink key={p.id} e={p} />
271 + ))}
272 + </p>
273 + ) : (
274 + <p className="text-sm text-ink-3">No provider deployment recorded.</p>
275 + )}
276 + </section>
277 + <section id="modalities">
278 + <p className="eyebrow mb-2">Modalities</p>
279 + <p className="flex flex-wrap gap-1">{f.modalities?.length ? f.modalities.map((m) => <Chip key={m}>{m}</Chip>) : <span className="text-sm text-ink-3">—</span>}</p>
280 + </section>
281 + {ranks.length > 0 && (
282 + <section id="best-ranks">
283 + <p className="eyebrow mb-2">Best benchmark ranks</p>
284 + <ul className="divide-y divide-rule border-y border-rule text-sm">
285 + {ranks.slice(0, 8).map(([slug, r]) => (
286 + <li key={slug} className="grid grid-cols-[auto_minmax(0,1fr)] items-baseline gap-x-3 py-1.5">
287 + <span className="tnum font-medium">#{fmtInt(r.rank)}</span>
288 + <span className="min-w-0 truncate">
289 + <Link href={routes.benchmark(slug)} className="text-ink hover:text-accent">
290 + {benchNames[slug] ?? slug}
291 + </Link>
292 + <span className="block truncate text-[11px] text-ink-3">{r.model}</span>
293 + </span>
294 + </li>
295 + ))}
296 + </ul>
297 + </section>
298 + )}
299 + </aside>
300 + </div>
301 +
302 + <section id="progress">
303 + <p className="eyebrow mb-2">Benchmark progress</p>
304 + <FamilyBenchmarkProgress members={f.members} benchNames={benchNames} />
305 + </section>
306 + <Note>
307 + Aggregates are computed from the members' sourced claims; members without a release date or parameter count are left out of the range and timeline rather than guessed. <Link href="/methodology" className="link">Methodology →</Link>
308 + </Note>
309 + </div>
310 + <CompareTrayBar />
311 + </Container>
312 + );
313 +}
added apps/web/src/app/families/page.tsx +159 −0
@@ -0,0 +1,159 @@
1 +import type { Metadata } from 'next';
2 +import { ScrollX } from '@/components/models/scroll-x';
3 +import Link from 'next/link';
4 +import { CompareTrayBar } from '@/components/compare/compare-tray-bar';
5 +import { FilterBar } from '@/components/listing/filters';
6 +import { Chip, EntityBadge } from '@/components/ui/badges';
7 +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
8 +import { Hint } from '@/components/ui/hint';
9 +import { Pagination, withParams } from '@/components/ui/pagination';
10 +import { Container, Note, PageHeader } from '@/components/ui/section';
11 +import { Unavailable } from '@/components/ui/unavailable';
12 +import { apiD1, safe } from '@/lib/api';
13 +import { fmtDate, fmtInt, fmtParams, num } from '@/lib/format';
14 +import { routes, SITE_NAME, SITE_URL } from '@/lib/site';
15 +
16 +export const metadata: Metadata = {
17 + title: 'Model families — releases, parameter ranges, licences & benchmark ranks',
18 + description: 'Model families (Llama, Qwen, Claude, Gemma…) grouping canonical releases: member count, first and latest release, parameter range, modalities, licence mix and best benchmark ranks — every number from sourced claims.',
19 + alternates: { canonical: '/families' },
20 + openGraph: { title: `Model families | ${SITE_NAME}`, url: `${SITE_URL}/families`, type: 'website' },
21 +};
22 +export const revalidate = 600;
23 +
24 +type SP = Record<string, string | undefined>;
25 +const LIMIT = 50;
26 +const SORTS = [
27 + { value: 'models', label: 'Most models' },
28 + { value: 'last_release', label: 'Latest release' },
29 + { value: 'name', label: 'Name' },
30 +];
31 +
32 +export default async function FamiliesPage({ searchParams }: { searchParams: Promise<SP> }) {
33 + const sp = await searchParams;
34 + const current: SP = {};
35 + for (const k of ['q', 'org', 'sort', 'offset']) if (sp[k]) current[k] = sp[k];
36 + const offset = Math.max(0, Number(current.offset) || 0);
37 + const sort = current.sort ?? 'models';
38 + const [page, bench] = await Promise.all([safe(apiD1.families({ q: current.q, org: current.org, sort, limit: LIMIT, offset })), safe(apiD1.benchmarks())]);
39 + const benchName = new Map((bench?.items ?? []).map((b) => [b.slug, b.name]));
40 + const href = (patch: Record<string, string | number | undefined | null>) => withParams('/families', current, patch);
41 + const nonCanonical = (page?.items ?? []).filter((f) => !f.canonical).length;
42 + const ld = { '@context': 'https://schema.org', '@type': 'CollectionPage', name: 'Model families', url: `${SITE_URL}/families`, description: metadata.description, ...(page ? { mainEntity: { '@type': 'ItemList', numberOfItems: page.total, itemListElement: page.items.slice(0, 10).map((f, i) => ({ '@type': 'ListItem', position: offset + i + 1, name: f.name, url: `${SITE_URL}${routes.family(f.slug)}` })) } } : {}) };
43 +
44 + return (
45 + <Container wide>
46 + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} />
47 + <PageHeader eyebrow="Families" title="Model families" lede="Families group canonical releases (Llama 3.1, Qwen3, Claude…). Counts, releases and parameter ranges are aggregated from the members' sourced claims; benchmark ranks are the best rank of any member in the benchmark's primary group." aside={page ? <p className="tnum text-sm text-ink-3">{fmtInt(page.total)} families</p> : undefined}>
48 + <FilterBar action="/families" className="mt-6" resetHref="/families" fields={[{ kind: 'text', name: 'q', label: 'Name', value: current.q, placeholder: 'llama, qwen…' }, { kind: 'text', name: 'org', label: 'Organization slug', value: current.org, placeholder: 'meta-ai' }]} sort={{ value: sort, options: SORTS }} />
49 + </PageHeader>
50 + <div className="pb-16">
51 + {!page ? (
52 + <Unavailable what="Families" />
53 + ) : (
54 + <>
55 + {nonCanonical > 0 && (
56 + <p className="mb-2 flex items-center gap-1 text-xs text-ink-3">
57 + {fmtInt(nonCanonical)} row{nonCanonical === 1 ? '' : 's'} on this page are label-based families (not yet canonical entities)
58 + <Hint text={page.note ?? 'canonical: true = model_family entity; false = legacy attributes.family label awaiting canonicalisation.'} />
59 + </p>
60 + )}
61 + <ScrollX><DataTable caption="Model families" compact>
62 + <thead>
63 + <tr>
64 + <Th>Family</Th>
65 + <Th>Organization</Th>
66 + <Th num>Models</Th>
67 + <Th>First · latest release</Th>
68 + <Th num>Parameters</Th>
69 + <Th>Modalities</Th>
70 + <Th>Licences</Th>
71 + <Th>Best benchmark ranks</Th>
72 + </tr>
73 + </thead>
74 + <tbody>
75 + {page.items.length === 0 && <EmptyRow cols={8}>No family matches.</EmptyRow>}
76 + {page.items.map((f) => {
77 + const ranks = Object.entries(f.benchmark_best ?? {})
78 + .sort((a, b) => a[1].rank - b[1].rank)
79 + .slice(0, 3);
80 + const pmin = num(f.param_range?.min);
81 + const pmax = num(f.param_range?.max);
82 + return (
83 + <tr key={f.slug}>
84 + <Td primary>
85 + <span className="flex flex-wrap items-center gap-x-2">
86 + <Link href={routes.family(f.slug)} className="text-ink hover:text-accent hover:underline">
87 + {f.name}
88 + </Link>
89 + {!f.canonical && (
90 + <span className="inline-flex items-center gap-0.5 text-[10px] uppercase tracking-wide text-warning" title="Label-based family: grouped by the source's family label, not yet a canonical entity">
91 + label
92 + </span>
93 + )}
94 + </span>
95 + </Td>
96 + <Td label="Organization" className="text-ink-2">
97 + {f.organization ? (
98 + <Link href={routes.entity({ entity_type: 'company', slug: f.organization.slug })} className="hover:text-accent">
99 + {f.organization.name}
100 + </Link>
101 + ) : (
102 + '—'
103 + )}
104 + </Td>
105 + <Td num label="Models" className="tnum">
106 + {fmtInt(f.model_count)}
107 + </Td>
108 + <Td label="First · latest release" className="tnum text-ink-2 whitespace-nowrap">
109 + {f.first_release ? fmtDate(f.first_release) : '—'} <span className="text-ink-3">·</span> {f.last_release ? fmtDate(f.last_release) : '—'}
110 + </Td>
111 + <Td num label="Parameters" className="tnum text-ink-2 whitespace-nowrap">
112 + {pmin === null ? '—' : pmin === pmax ? fmtParams(pmin) : `${fmtParams(pmin)} – ${fmtParams(pmax)}`}
113 + </Td>
114 + <Td label="Modalities">
115 + <span className="flex flex-wrap gap-1">{f.modalities?.length ? f.modalities.map((m) => <Chip key={m}>{m}</Chip>) : <span className="text-ink-3">—</span>}</span>
116 + </Td>
117 + <Td label="Licences" className="text-xs text-ink-2">
118 + {f.licenses?.length ? (
119 + <span className="flex flex-wrap gap-x-2 gap-y-0.5">
120 + {f.licenses.slice(0, 3).map((l) => (
121 + <Link key={l.key} href={`/licenses/${encodeURIComponent(l.key)}`} className="hover:text-accent" title={l.label}>
122 + {l.key} <span className="tnum text-ink-3">{fmtInt(l.models)}</span>
123 + </Link>
124 + ))}
125 + {f.licenses.length > 3 && <span className="text-ink-3">+{f.licenses.length - 3}</span>}
126 + </span>
127 + ) : (
128 + <span className="text-ink-3">—</span>
129 + )}
130 + </Td>
131 + <Td label="Best benchmark ranks" className="text-xs text-ink-2">
132 + {ranks.length ? (
133 + <span className="flex flex-wrap gap-x-2 gap-y-0.5">
134 + {ranks.map(([slug, r]) => (
135 + <Link key={slug} href={routes.benchmark(slug)} className="whitespace-nowrap hover:text-accent" title={`${benchName.get(slug) ?? slug}: rank ${r.rank} (${r.model})`}>
136 + {(benchName.get(slug) ?? slug).replace('Artificial Analysis ', 'AA ')} <span className="tnum font-medium text-ink">#{fmtInt(r.rank)}</span>
137 + </Link>
138 + ))}
139 + </span>
140 + ) : (
141 + <span className="text-ink-3">no results</span>
142 + )}
143 + </Td>
144 + </tr>
145 + );
146 + })}
147 + </tbody>
148 + </DataTable></ScrollX>
149 + <Pagination total={page.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" />
150 + <Note className="mt-3">
151 + <EntityBadge type="model_family" small /> {page.note ?? 'Families are canonical model_family entities; legacy labels appear as label-based rows until canonicalised.'}
152 + </Note>
153 + </>
154 + )}
155 + </div>
156 + <CompareTrayBar />
157 + </Container>
158 + );
159 +}
added apps/web/src/app/licenses/[key]/opengraph-image.tsx +26 −0
@@ -0,0 +1,26 @@
1 +import { ImageResponse } from 'next/og';
2 +import { Fallback, Wallpaper } from '@/components/brand/og';
3 +import { apiD1, safe } from '@/lib/api';
4 +import { fmtInt } from '@/lib/format';
5 +import { SITE_NAME } from '@/lib/site';
6 +
7 +export const runtime = 'nodejs';
8 +export const alt = `Licence on ${SITE_NAME}`;
9 +export const size = { width: 1200, height: 630 };
10 +export const contentType = 'image/png';
11 +
12 +const tri = (v: boolean | null) => (v === true ? 'Yes' : v === false ? 'No' : 'Unknown');
13 +
14 +export default async function LicenseOgImage({ params }: { params: Promise<{ key: string }> }) {
15 + const { key } = await params;
16 + const l = await safe(apiD1.license(key, 1));
17 + if (!l) return new ImageResponse(<Fallback label="Licence" />, { ...size });
18 + const counters: [string, string][] = [
19 + ['Commercial use', tri(l.commercial_use)],
20 + ['Redistribution', tri(l.redistribution)],
21 + ['Derivatives', tri(l.derivatives)],
22 + ['Models', fmtInt(l.models?.total ?? 0)],
23 + ];
24 + const subtitle = [l.category, l.spdx ? `SPDX ${l.spdx}` : null, l.osi_approved ? 'OSI approved' : null, l.weights_downloadable ? 'weights downloadable' : 'weights not downloadable'].filter(Boolean).join(' · ');
25 + return new ImageResponse(<Wallpaper eyebrow={`Licence · ${l.category}`} title={l.label} subtitle={subtitle} counters={counters} footer={`www.ai-atlas.co/licenses/${encodeURIComponent(l.key)}`} markPx={220} />, { ...size });
26 +}
added apps/web/src/app/licenses/[key]/page.tsx +197 −0
@@ -0,0 +1,197 @@
1 +import { ExternalLink } from 'lucide-react';
2 +import type { Metadata } from 'next';
3 +import Link from 'next/link';
4 +import { notFound } from 'next/navigation';
5 +import { CompareTrayBar } from '@/components/compare/compare-tray-bar';
6 +import { ModelsTable } from '@/components/entity/blocks';
7 +import { Tri } from '@/components/models/badges';
8 +import { LICENCE_DIMENSIONS, OpennessBlock } from '@/components/models/openness-block';
9 +import { EntityBadge } from '@/components/ui/badges';
10 +import { Hint } from '@/components/ui/hint';
11 +import { Pagination } from '@/components/ui/pagination';
12 +import { Container, Note } from '@/components/ui/section';
13 +import { ApiError, apiD1, safe } from '@/lib/api';
14 +import { fmtInt } from '@/lib/format';
15 +import { SITE_NAME, SITE_URL } from '@/lib/site';
16 +import type { LicenseDetail, ModelOpenness } from '@/lib/types';
17 +
18 +type Params = { params: Promise<{ key: string }>; searchParams: Promise<{ offset?: string }> };
19 +const LIMIT = 50;
20 +export const revalidate = 3600;
21 +
22 +async function load(key: string, offset: number): Promise<LicenseDetail> {
23 + try {
24 + return await apiD1.license(key, LIMIT, offset);
25 + } catch (e) {
26 + if (e instanceof ApiError && e.notFound) notFound();
27 + throw e;
28 + }
29 +}
30 +
31 +const tri = (v: boolean | null) => (v === true ? 'yes' : v === false ? 'no' : 'unknown');
32 +
33 +export async function generateMetadata({ params }: Params): Promise<Metadata> {
34 + const { key } = await params;
35 + const l = await safe(apiD1.license(key, 1));
36 + if (!l) return { title: 'Licence', robots: { index: false } };
37 + const title = `${l.label} — Model licence terms & models using it`;
38 + const description = `${l.label} (${l.category}${l.spdx ? `, SPDX ${l.spdx}` : ''}): commercial use ${tri(l.commercial_use)}, redistribution ${tri(l.redistribution)}, derivatives ${tri(l.derivatives)}, hosting restrictions ${tri(l.hosting_restrictions)}, acceptable-use policy ${tri(l.acceptable_use)}. ${fmtInt(l.models.total)} canonical models use it. ${SITE_NAME}.`.slice(0, 300);
39 + const canonical = `/licenses/${encodeURIComponent(l.key)}`;
40 + return { title, description, alternates: { canonical }, openGraph: { title: `${title} | ${SITE_NAME}`, description, url: `${SITE_URL}${canonical}`, type: 'article' } };
41 +}
42 +
43 +/** Openness view derived from the licence permissions alone (weights downloadable · commercial · redistribution · derivatives). */
44 +function opennessFromLicence(l: LicenseDetail): ModelOpenness {
45 + const category = !l.weights_downloadable ? 'proprietary' : l.commercial_use === false || l.hosting_restrictions === true || l.derivatives === false || l.category === 'community' || l.category === 'research-only' || l.category === 'responsible-ai' ? 'restricted-weights' : 'open-weights';
46 + const labels: Record<string, string> = { proprietary: 'Closed / proprietary', 'restricted-weights': 'Restricted weights', 'open-weights': 'Open weights' };
47 + const defs: Record<string, string> = {
48 + proprietary: 'Weights are not redistributable under this licence.',
49 + 'restricted-weights': 'Weights downloadable, but the licence restricts commercial use, hosting, derivatives or field of use.',
50 + 'open-weights': 'Weights downloadable under a permissive licence allowing commercial use; code or data may be missing.',
51 + };
52 + return {
53 + category,
54 + raw: l.key,
55 + label: labels[category] ?? category,
56 + definition: defs[category] ?? '',
57 + dimensions: { weights_available: l.weights_downloadable, source_code_available: null, training_code_available: null, training_data_disclosed: null, dataset_available: null, commercial_use_allowed: l.commercial_use, redistribution_allowed: l.redistribution, derivatives_allowed: l.derivatives },
58 + note: 'Derived from the licence text alone — a model under this licence may still publish code or data (see its page).',
59 + };
60 +}
61 +
62 +export default async function LicensePage({ params, searchParams }: Params) {
63 + const { key } = await params;
64 + const { offset: off } = await searchParams;
65 + const offset = Math.max(0, Number(off) || 0);
66 + const l = await load(key, offset);
67 + const canonical = `/licenses/${encodeURIComponent(l.key)}`;
68 + const ld = { '@context': 'https://schema.org', '@type': 'CreativeWork', name: l.label, url: `${SITE_URL}${canonical}`, alternateName: l.aliases, sameAs: l.url ?? undefined, identifier: l.spdx ?? l.key, description: `${l.category} licence · commercial use ${tri(l.commercial_use)} · redistribution ${tri(l.redistribution)} · derivatives ${tri(l.derivatives)}` };
69 +
70 + return (
71 + <Container wide>
72 + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} />
73 + <nav aria-label="Breadcrumb" className="pt-5 text-xs text-ink-3">
74 + <ol className="flex flex-wrap items-center gap-1.5">
75 + <li>
76 + <Link href="/" className="hover:text-ink">
77 + AI Atlas
78 + </Link>
79 + </li>
80 + <li aria-hidden>/</li>
81 + <li>
82 + <Link href="/licenses" className="hover:text-ink">
83 + Licences
84 + </Link>
85 + </li>
86 + <li aria-hidden>/</li>
87 + <li>
88 + <Link href={`/licenses?category=${encodeURIComponent(l.category)}`} className="hover:text-ink">
89 + {l.category}
90 + </Link>
91 + </li>
92 + <li aria-hidden>/</li>
93 + <li className="text-ink-2">{l.label}</li>
94 + </ol>
95 + </nav>
96 + <header className="pb-6 pt-4 md:pt-5" data-license-header>
97 + <div className="flex flex-wrap items-center gap-2">
98 + <EntityBadge type="license" />
99 + <span className="rounded-[3px] bg-surface-2 px-1.5 text-[11px] font-medium text-ink-2">{l.category}</span>
100 + {l.osi_approved && <span className="rounded-[3px] bg-positive-soft px-1.5 text-[11px] font-medium text-positive">OSI approved</span>}
101 + {l.spdx && <span className="mono text-xs text-ink-3">SPDX {l.spdx}</span>}
102 + </div>
103 + <div className="mt-3 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
104 + <div className="min-w-0">
105 + <h1 className="display text-[30px] md:text-[44px]">{l.label}</h1>
106 + <p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[15px] text-ink-2">
107 + <span className="mono text-ink">{l.key}</span>
108 + {l.url && (
109 + <a href={l.url} target="_blank" rel="noopener noreferrer" className="inline-flex max-w-full min-w-0 items-center gap-1 text-ink-3 hover:text-accent">
110 + <span className="truncate">{l.url.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '').slice(0, 56)}</span> <ExternalLink className="size-3.5 shrink-0" aria-hidden />
111 + </a>
112 + )}
113 + </p>
114 + </div>
115 + <p className="tnum text-sm text-ink-2">
116 + <span className="font-semibold text-ink">{fmtInt(l.models.total)}</span> canonical models{' '}
117 + <Link href={`/models?license=${encodeURIComponent(l.key)}`} className="link">
118 + open in the terminal →
119 + </Link>
120 + </p>
121 + </div>
122 + </header>
123 +
124 + <div className="grid gap-10 pb-16 lg:grid-cols-[minmax(0,1fr)_24rem]">
125 + <div className="min-w-0 space-y-10">
126 + <section>
127 + <p className="eyebrow mb-2">Dimensions</p>
128 + <dl className="kv" data-license-dimensions>
129 + {LICENCE_DIMENSIONS.map((d) => (
130 + <div key={d.key}>
131 + <dt>{d.label}</dt>
132 + <dd className="flex items-start justify-between gap-2 text-ink">
133 + <span>
134 + <Tri v={l[d.key] as boolean | null} yes={d.invert ? 'Yes — restrictions apply' : 'Yes'} no={d.invert ? 'None' : 'No'} />
135 + </span>
136 + <Hint text={d.hint} align="right" className="-my-1 shrink-0" />
137 + </dd>
138 + </div>
139 + ))}
140 + <div>
141 + <dt>Weights downloadable</dt>
142 + <dd className="text-ink">
143 + <Tri v={l.weights_downloadable} />
144 + </dd>
145 + </div>
146 + <div>
147 + <dt>OSI approved</dt>
148 + <dd className="text-ink">
149 + <Tri v={l.osi_approved} />
150 + </dd>
151 + </div>
152 + </dl>
153 + <Note className="mt-2">Read from the licence text by the ontology; a dash means the text is ambiguous and nothing is assumed. Not legal advice.</Note>
154 + </section>
155 + <section>
156 + <p className="eyebrow mb-2">What this means for openness</p>
157 + <OpennessBlock openness={opennessFromLicence(l)} compact />
158 + </section>
159 + <section id="models">
160 + <p className="eyebrow mb-2">
161 + Models under this licence <span className="tnum text-ink-3">{fmtInt(l.models.total)}</span>
162 + </p>
163 + <ModelsTable items={l.models.items} />
164 + <Pagination total={l.models.total} limit={LIMIT} offset={offset} makeHref={(o) => `${canonical}${o ? `?offset=${o}#models` : '#models'}`} className="mt-4" />
165 + </section>
166 + </div>
167 + <aside className="min-w-0 space-y-8">
168 + {l.aliases?.length > 0 && (
169 + <section>
170 + <p className="eyebrow mb-2">
171 + Aliases mapped to this licence <span className="tnum text-ink-3">{fmtInt(l.aliases.length)}</span>
172 + </p>
173 + <ul className="flex flex-wrap gap-1.5 text-xs">
174 + {l.aliases.map((a) => (
175 + <li key={a} className="mono border border-rule px-1.5 py-0.5 text-ink-2">
176 + {a}
177 + </li>
178 + ))}
179 + </ul>
180 + <Note className="mt-2">Raw labels stated by sources that the ontology maps to this canonical key.</Note>
181 + </section>
182 + )}
183 + <section>
184 + <p className="eyebrow mb-2">Category</p>
185 + <p className="text-sm text-ink-2">
186 + <Link href={`/licenses?category=${encodeURIComponent(l.category)}`} className="link">
187 + {l.category}
188 + </Link>{' '}
189 + — see the other licences of this category and the <Link href="/methodology" className="link">openness methodology</Link>.
190 + </p>
191 + </section>
192 + </aside>
193 + </div>
194 + <CompareTrayBar />
195 + </Container>
196 + );
197 +}
added apps/web/src/app/licenses/page.tsx +147 −0
@@ -0,0 +1,147 @@
1 +import type { Metadata } from 'next';
2 +import { ScrollX } from '@/components/models/scroll-x';
3 +import Link from 'next/link';
4 +import { Tri } from '@/components/models/badges';
5 +import { LICENCE_DIMENSIONS } from '@/components/models/openness-block';
6 +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
7 +import { Hint } from '@/components/ui/hint';
8 +import { Container, Note, PageHeader } from '@/components/ui/section';
9 +import { Unavailable } from '@/components/ui/unavailable';
10 +import { apiD1, safe } from '@/lib/api';
11 +import { cn } from '@/lib/cn';
12 +import { fmtInt, num } from '@/lib/format';
13 +import { SITE_NAME, SITE_URL } from '@/lib/site';
14 +
15 +export const metadata: Metadata = {
16 + title: 'Model licences — commercial use, redistribution, derivatives, hosting restrictions',
17 + description: 'Canonical licences of AI models (Apache-2.0, MIT, Llama Community, Gemma Terms, RAIL…): category, commercial use, redistribution, derivatives, hosting restrictions, acceptable-use policy and the number of canonical models under each — permissions read from the licence text, unknown when ambiguous.',
18 + alternates: { canonical: '/licenses' },
19 + openGraph: { title: `Model licences | ${SITE_NAME}`, url: `${SITE_URL}/licenses`, type: 'website' },
20 +};
21 +export const revalidate = 3600;
22 +
23 +const CATEGORY_HINT: Record<string, string> = {
24 + permissive: 'OSI-style licences with no field-of-use restriction (Apache, MIT, BSD).',
25 + copyleft: 'Derivatives must be shared under the same terms (GPL, AGPL, LGPL, MPL).',
26 + 'creative-commons': 'Creative Commons terms; NC variants forbid commercial use, ND forbids derivatives.',
27 + 'responsible-ai': 'RAIL / OpenRAIL licences: permissive with an attached acceptable-use policy.',
28 + community: 'Vendor community licences (Llama, Gemma, Qwen, NVIDIA…): downloadable weights with user caps, hosting or field-of-use clauses.',
29 + 'research-only': 'Non-commercial / research-only terms.',
30 + proprietary: 'No weights redistribution; access through an API or product.',
31 + unknown: 'Stated licence text could not be classified.',
32 +};
33 +
34 +export default async function LicensesPage({ searchParams }: { searchParams: Promise<{ category?: string }> }) {
35 + const { category } = await searchParams;
36 + const res = await safe(apiD1.licenses());
37 + const all = (res?.items ?? []).slice().sort((a, b) => (num(b.models) ?? 0) - (num(a.models) ?? 0) || a.label.localeCompare(b.label));
38 + const items = category ? all.filter((l) => l.category === category) : all;
39 + const cats = (res?.categories ?? []).map((c) => ({ c, n: all.filter((l) => l.category === c).length, models: all.filter((l) => l.category === c).reduce((s, l) => s + (num(l.models) ?? 0), 0) })).filter((x) => x.n > 0);
40 + const chip = (active: boolean) => cn('inline-flex h-8 items-center gap-1.5 border px-2.5 text-xs whitespace-nowrap', active ? 'border-ink bg-ink text-canvas' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink');
41 + const ld = { '@context': 'https://schema.org', '@type': 'CollectionPage', name: 'Model licences', url: `${SITE_URL}/licenses`, description: metadata.description, mainEntity: { '@type': 'ItemList', numberOfItems: all.length, itemListElement: all.slice(0, 12).map((l, i) => ({ '@type': 'ListItem', position: i + 1, name: l.label, url: `${SITE_URL}/licenses/${encodeURIComponent(l.key)}` })) } };
42 +
43 + return (
44 + <Container wide>
45 + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} />
46 + <PageHeader eyebrow="Licences" title="Model licences" lede="The canonical licence ontology behind the openness classification. Permissions are read from each licence text; a dash means the text is ambiguous — never assumed." aside={res ? <p className="tnum text-sm text-ink-3">{fmtInt(items.length)}{category ? ` of ${fmtInt(all.length)}` : ''} licences</p> : undefined}>
47 + {cats.length > 0 && (
48 + <nav aria-label="Category" className="no-scrollbar -mx-4 mt-6 flex gap-1.5 overflow-x-auto px-4 md:mx-0 md:flex-wrap md:px-0">
49 + <Link href="/licenses" className={chip(!category)} aria-current={!category ? 'true' : undefined}>
50 + All <span className="tnum opacity-70">{fmtInt(all.length)}</span>
51 + </Link>
52 + {cats.map(({ c, n, models }) => (
53 + <Link key={c} href={`/licenses?category=${encodeURIComponent(c)}`} className={chip(category === c)} aria-current={category === c ? 'true' : undefined} title={`${CATEGORY_HINT[c] ?? c} · ${fmtInt(models)} models`}>
54 + {c} <span className="tnum opacity-70">{fmtInt(n)}</span>
55 + </Link>
56 + ))}
57 + </nav>
58 + )}
59 + </PageHeader>
60 + <div className="pb-16">
61 + {!res ? (
62 + <Unavailable what="Licences" />
63 + ) : (
64 + <>
65 + {category && CATEGORY_HINT[category] && <p className="mb-3 text-sm text-ink-2">{CATEGORY_HINT[category]}</p>}
66 + <ScrollX><DataTable caption="Licences" compact>
67 + <thead>
68 + <tr>
69 + <Th>Licence</Th>
70 + <Th>Category</Th>
71 + {LICENCE_DIMENSIONS.slice(0, 5).map((d) => (
72 + <Th key={d.key} title={d.hint} className="cursor-help underline decoration-dotted decoration-rule-strong underline-offset-2">
73 + {d.label}
74 + </Th>
75 + ))}
76 + <Th num>Models</Th>
77 + </tr>
78 + </thead>
79 + <tbody>
80 + {items.length === 0 && <EmptyRow cols={8}>No licence in this category.</EmptyRow>}
81 + {items.map((l) => (
82 + <tr key={l.key}>
83 + <Td primary>
84 + <Link href={`/licenses/${encodeURIComponent(l.key)}`} className="text-ink hover:text-accent hover:underline">
85 + {l.label}
86 + </Link>
87 + <span className="mono block text-[11px] text-ink-3">
88 + {l.key}
89 + {l.spdx && l.spdx !== l.key ? ` · SPDX ${l.spdx}` : ''}
90 + {l.osi_approved ? ' · OSI' : ''}
91 + </span>
92 + </Td>
93 + <Td label="Category" className="text-ink-2">
94 + <Link href={`/licenses?category=${encodeURIComponent(l.category)}`} className="hover:text-accent">
95 + {l.category}
96 + </Link>
97 + </Td>
98 + {LICENCE_DIMENSIONS.slice(0, 5).map((d) => (
99 + <Td key={d.key} label={d.label}>
100 + <Tri v={l[d.key] as boolean | null} yes={d.invert ? 'Yes — restricted' : 'Yes'} no={d.invert ? 'None' : 'No'} />
101 + </Td>
102 + ))}
103 + <Td num label="Models" className="tnum">
104 + {num(l.models) ? (
105 + <Link href={`/models?license=${encodeURIComponent(l.key)}`} className="hover:text-accent">
106 + {fmtInt(l.models)}
107 + </Link>
108 + ) : (
109 + <span className="text-ink-3">0</span>
110 + )}
111 + </Td>
112 + </tr>
113 + ))}
114 + </tbody>
115 + </DataTable></ScrollX>
116 + {res.unclassified?.length > 0 && (
117 + <section className="mt-8">
118 + <p className="eyebrow mb-2">
119 + Unclassified licence labels <span className="tnum text-ink-3">{fmtInt(res.unclassified.length)}</span>
120 + </p>
121 + <ul className="flex flex-wrap gap-1.5 text-xs">
122 + {res.unclassified.map((u) => (
123 + <li key={u.raw} className="border border-dashed border-rule-strong px-2 py-1 text-ink-2">
124 + “{u.raw}” <span className="tnum text-ink-3">{fmtInt(u.models)}</span>
125 + </li>
126 + ))}
127 + </ul>
128 + </section>
129 + )}
130 + <Note className="mt-3">{res.note ?? 'Permissions are read from the licence text (null = the text is ambiguous). Counts cover canonical models only.'} Hosting restrictions / acceptable-use: “Yes — restricted” means the clause exists.</Note>
131 + <dl className="mt-3 grid gap-x-6 gap-y-1 text-[11px] text-ink-3 sm:grid-cols-2 lg:grid-cols-3" data-licence-definitions>
132 + {LICENCE_DIMENSIONS.map((d) => (
133 + <div key={d.key} className="flex gap-1.5">
134 + <dt className="shrink-0 font-medium text-ink-2">{d.label}</dt>
135 + <dd className="flex items-center gap-1">
136 + {d.hint}
137 + <Hint text={d.hint} align="right" className="hidden" />
138 + </dd>
139 + </div>
140 + ))}
141 + </dl>
142 + </>
143 + )}
144 + </div>
145 + </Container>
146 + );
147 +}
modified apps/web/src/app/models/(list)/page.tsx +98 −146
@@ -1,169 +1,121 @@
1 1 import type { Metadata } from 'next';
2 2 import Link from 'next/link';
3 −import { CompareButton } from '@/components/compare/compare-button';
4 3 import { CompareTrayBar } from '@/components/compare/compare-tray-bar';
5 −import { ActiveFilters, type FacetGroup, Facets, FilterBar, ListingLayout } from '@/components/listing/filters';
6 −import { OpennessBadge, StatusBadge } from '@/components/ui/badges';
7 −import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
8 −import { EntityLink, QualityMark } from '@/components/ui/entity';
9 −import { Pagination, withParams } from '@/components/ui/pagination';
10 −import { Container, PageHeader } from '@/components/ui/section';
4 +import { type Current, MODEL_PARAM_KEYS, MODEL_SORTS, ModelsFilterRail, modelsHref, UnderstoodChips } from '@/components/models/models-filters';
5 +import { ModelsTable, ModelsTerminal } from '@/components/models/models-terminal';
6 +import { parseScale } from '@/components/models/shared';
7 +import { Hint } from '@/components/ui/hint';
8 +import { Pagination } from '@/components/ui/pagination';
9 +import { Container, Note } from '@/components/ui/section';
11 10 import { Unavailable } from '@/components/ui/unavailable';
12 −import { api, safe } from '@/lib/api';
13 −import { fmtDate, fmtInt, fmtParams, fmtTokens, num } from '@/lib/format';
14 −import { OPENNESS_LABELS, routes, STATUS_LABELS } from '@/lib/site';
11 +import { api, apiD1, safe } from '@/lib/api';
12 +import { fmtInt } from '@/lib/format';
13 +import { routes, SITE_NAME, SITE_URL } from '@/lib/site';
15 14
16 15 export const metadata: Metadata = {
17 − title: 'AI models — parameters, context, openness, pricing',
18 − description: 'Every AI model in the atlas: parameters, context window, openness, license, release date and data quality — filterable and sortable, with provenance on every value.',
16 + title: 'AI models — parameters, context, openness, licences & pricing',
17 + description: 'Every canonical AI model release in the atlas: parameters, context window, openness, canonical licence, release date and data quality — filterable, sortable and shareable, with provenance on every value.',
19 18 alternates: { canonical: '/models' },
19 + openGraph: { title: `AI models — parameters, context, openness, licences & pricing | ${SITE_NAME}`, url: `${SITE_URL}/models`, type: 'website' },
20 20 };
21 21 export const revalidate = 120;
22 22
23 23 type SP = Record<string, string | undefined>;
24 24 const LIMIT = 50;
25 −const KEYS = ['q', 'org', 'family', 'openness', 'modality', 'status', 'min_params', 'max_params', 'min_context', 'year_from', 'year_to', 'license', 'provider', 'sort', 'order', 'offset'] as const;
26 −const LABELS: Record<string, string> = { q: 'query', org: 'organization', family: 'family', openness: 'openness', modality: 'modality', status: 'status', min_params: 'min params', max_params: 'max params', min_context: 'min context', year_from: 'from', year_to: 'to', license: 'license', provider: 'provider' };
27 −const SORTS = [
28 − { value: 'updated', label: 'Recently updated' },
29 − { value: 'release', label: 'Release date' },
30 − { value: 'name', label: 'Name' },
31 − { value: 'params', label: 'Parameters' },
32 − { value: 'context', label: 'Context window' },
33 − { value: 'quality', label: 'Data quality' },
34 − { value: 'downloads', label: 'Downloads' },
35 −];
36 −
37 −/** Accept "70B" / "7b" / "70000000000" for parameter inputs; "128k" for context. */
38 −function parseScale(v: string | undefined): number | undefined {
39 − if (!v) return undefined;
40 − const m = /^\s*([\d.]+)\s*([kmbt])?\s*$/i.exec(v);
41 − if (!m) return undefined;
42 − const n = Number(m[1]);
43 − const mult = { k: 1e3, m: 1e6, b: 1e9, t: 1e12 }[(m[2] ?? '').toLowerCase() as 'k' | 'm' | 'b' | 't'] ?? 1;
44 − return Number.isFinite(n) ? Math.round(n * mult) : undefined;
45 −}
46 25
47 26 export default async function ModelsPage({ searchParams }: { searchParams: Promise<SP> }) {
48 27 const sp = await searchParams;
49 − const current: Record<string, string | undefined> = {};
50 − for (const k of KEYS) if (sp[k]) current[k] = sp[k];
28 + const current: Current = {};
29 + for (const k of MODEL_PARAM_KEYS) if (sp[k]) current[k] = sp[k];
51 30 const offset = Math.max(0, Number(current.offset) || 0);
52 31 const sort = current.sort ?? 'updated';
53 − const page = await safe(api.models({ ...current, min_params: parseScale(current.min_params), max_params: parseScale(current.max_params), min_context: parseScale(current.min_context), sort, order: current.order, limit: LIMIT, offset, facets: 1 }));
54 − const href = (patch: Record<string, string | number | undefined | null>) => withParams('/models', current, patch);
55 − const f = page?.facets;
56 − const facetGroups: FacetGroup[] = [
57 − { key: 'org', label: 'Organization', items: (f?.organizations ?? []).map((o) => ({ value: o.slug, label: o.name, count: o.count })) },
58 − { key: 'openness', label: 'Openness', items: (f?.openness ?? []).map((x) => ({ value: x.value, label: OPENNESS_LABELS[x.value] ?? x.value, count: x.count })) },
59 − { key: 'modality', label: 'Modality', items: (f?.modalities ?? []).map((x) => ({ value: x.value, count: x.count })) },
60 − { key: 'status', label: 'Status', items: (f?.status ?? []).map((x) => ({ value: x.value, label: STATUS_LABELS[x.value] ?? x.value, count: x.count })) },
61 − { key: 'family', label: 'Family', items: (f?.families ?? []).map((x) => ({ value: x.value, count: x.count })) },
62 − { key: 'year_from', label: 'Release year', items: (f?.years ?? []).map((x) => ({ value: String(x.value), count: x.count })) },
63 − { key: 'license', label: 'License', items: (f?.licenses ?? []).map((x) => ({ value: x.value, count: x.count })) },
64 − ];
65 − const sortHref = (s: string) => href({ sort: s, order: sort === s && current.order !== 'asc' ? 'asc' : undefined, offset: undefined });
66 − const SortTh = ({ s, children, num: n }: { s: string; children: React.ReactNode; num?: boolean }) => (
67 − <Th num={n} aria-sort={sort === s ? (current.order === 'asc' ? 'ascending' : 'descending') : undefined}>
68 − <Link href={sortHref(s)} className={sort === s ? 'text-ink' : 'hover:text-ink'}>
69 − {children}
70 − {sort === s && <span aria-hidden> {current.order === 'asc' ? '↑' : '↓'}</span>}
71 − </Link>
72 − </Th>
73 − );
32 + const order = current.order;
33 + const [page, stats] = await Promise.all([
34 + safe(
35 + apiD1.models({
36 + q: current.q,
37 + org: current.org,
38 + family: current.family,
39 + openness: current.openness,
40 + modality: current.modality,
41 + status: current.status,
42 + license: current.license,
43 + trust: current.trust,
44 + reasoning: current.reasoning,
45 + include: current.include,
46 + min_params: parseScale(current.min_params),
47 + max_params: parseScale(current.max_params),
48 + min_context: parseScale(current.min_context),
49 + year_from: current.year_from,
50 + year_to: current.year_to,
51 + sort,
52 + order,
53 + limit: LIMIT,
54 + offset,
55 + facets: 1,
56 + }),
57 + ),
58 + safe(api.stats()),
59 + ]);
60 + const href = (patch: Record<string, string | number | undefined | null>) => modelsHref(current, patch);
61 + const sortHref: Record<string, string> = {};
62 + for (const s of [...MODEL_SORTS.map((x) => x.value)]) sortHref[s] = href({ sort: s, order: sort === s && order !== 'asc' ? 'asc' : undefined, offset: undefined });
63 + const filterCount = Object.keys(current).filter((k) => !['sort', 'order', 'offset'].includes(k)).length;
64 + const artifacts = current.include === 'artifacts';
65 + const definition = stats?.definitions?.models ?? page?.facets?.definitions?.models ?? 'Canonical model releases; artifacts, quantisations, conversions and folded evaluation variants are excluded.';
66 + const artifactsDef = stats?.definitions?.artifacts;
67 + const ld = { '@context': 'https://schema.org', '@type': 'CollectionPage', name: 'AI models', url: `${SITE_URL}/models`, description: metadata.description, isPartOf: { '@type': 'WebSite', name: SITE_NAME, url: SITE_URL }, ...(page ? { mainEntity: { '@type': 'ItemList', numberOfItems: page.total, itemListElement: page.items.slice(0, 10).map((m, i) => ({ '@type': 'ListItem', position: offset + i + 1, url: `${SITE_URL}${routes.entity(m)}`, name: m.name })) } } : {}) };
74 68
75 69 return (
76 − <Container wide>
77 − <PageHeader eyebrow="Models" title="AI models" lede="Foundation and fine-tuned models across every lab and modality. Parameters, context and prices are shown exactly as stated by their sources." aside={page ? <p className="tnum text-sm text-ink-3">{fmtInt(page.total)} models</p> : undefined}>
78 − <FilterBar
79 − action="/models"
80 − className="mt-6"
81 − resetHref={routes.models()}
82 − fields={[
83 − { kind: 'text', name: 'q', label: 'Name', value: current.q, placeholder: 'e.g. claude, llama' },
84 − { kind: 'select', name: 'openness', label: 'Openness', value: current.openness, options: Object.entries(OPENNESS_LABELS).map(([value, label]) => ({ value, label })) },
85 − { kind: 'select', name: 'status', label: 'Status', value: current.status, options: Object.entries(STATUS_LABELS).filter(([v]) => v !== 'unknown').map(([value, label]) => ({ value, label })) },
86 − { kind: 'text', name: 'min_params', label: 'Min params', value: current.min_params, placeholder: 'e.g. 70B' },
87 − { kind: 'text', name: 'min_context', label: 'Min context', value: current.min_context, placeholder: 'e.g. 128k' },
88 − ...(current.org ? [{ kind: 'hidden' as const, name: 'org', value: current.org }] : []),
89 − ...(current.modality ? [{ kind: 'hidden' as const, name: 'modality', value: current.modality }] : []),
90 − ...(current.family ? [{ kind: 'hidden' as const, name: 'family', value: current.family }] : []),
91 − ...(current.license ? [{ kind: 'hidden' as const, name: 'license', value: current.license }] : []),
92 − ...(current.year_from ? [{ kind: 'hidden' as const, name: 'year_from', value: current.year_from }] : []),
93 − ]}
94 − sort={{ value: sort, options: SORTS }}
95 − />
96 − <ActiveFilters current={current} labels={LABELS} makeHref={(p) => href(p)} className="mt-3" />
97 − </PageHeader>
98 −
99 − <div className="pb-16">
100 − <ListingLayout facets={<Facets groups={facetGroups} current={current} makeHref={(p) => href(p)} />}>
101 − {!page ? (
102 − <Unavailable what="Models" />
103 − ) : (
104 − <>
105 − <DataTable caption="Models">
106 − <thead>
107 − <tr>
108 − <SortTh s="name">Model</SortTh>
109 − <Th>Organization</Th>
110 − <SortTh s="params" num>Params</SortTh>
111 − <SortTh s="context" num>Context</SortTh>
112 − <Th>Openness</Th>
113 − <Th>License</Th>
114 − <SortTh s="release">Released</SortTh>
115 − <SortTh s="quality" num>Quality</SortTh>
116 − </tr>
117 − </thead>
118 − <tbody>
119 − {page.items.length === 0 && <EmptyRow cols={8}>No models match these filters.</EmptyRow>}
120 − {page.items.map((m) => {
121 − const a = m.attributes ?? {};
122 − const p = num(a.parameter_count);
123 − const ap = num(a.active_parameter_count);
124 − return (
125 − <tr key={m.id}>
126 − <Td primary>
127 − <div className="flex items-start justify-between gap-2">
128 − <div className="min-w-0">
129 − <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
130 − <EntityLink e={m} />
131 − <StatusBadge status={m.status !== 'active' ? m.status : null} />
132 − </div>
133 − {typeof a.family === 'string' && <span className="block text-xs text-ink-3">{a.family}</span>}
134 − </div>
135 − <CompareButton e={m} size="sm" className="mt-0.5" />
136 − </div>
137 − </Td>
138 − <Td label="Organization" className="text-ink-2">
139 − {m.organization ? (
140 − <Link href={href({ org: m.organization.slug, offset: undefined })} className="hover:text-accent">
141 − {m.organization.name}
142 − </Link>
143 − ) : (
144 − '—'
145 − )}
146 − </Td>
147 − <Td num label="Params" className="tnum">
148 − {p === null ? <span className="text-ink-3">—</span> : fmtParams(p)}
149 − {ap !== null && ap !== p && <span className="text-xs text-ink-3"> · {fmtParams(ap)} active</span>}
150 − </Td>
151 − <Td num label="Context" className="tnum">{num(a.context_length) === null ? <span className="text-ink-3">—</span> : fmtTokens(a.context_length)}</Td>
152 − <Td label="Openness">{typeof a.openness === 'string' ? <OpennessBadge openness={a.openness} /> : <span className="text-ink-3">—</span>}</Td>
153 − <Td label="License" className="max-w-[10rem] truncate text-ink-2" title={typeof a.license === 'string' ? a.license : undefined}>{typeof a.license === 'string' ? a.license : <span className="text-ink-3">—</span>}</Td>
154 − <Td label="Released" className="tnum text-ink-2">{typeof a.release_date === 'string' ? fmtDate(a.release_date) : <span className="text-ink-3">—</span>}</Td>
155 − <Td num label="Quality"><QualityMark q={m.quality?.score} /></Td>
156 − </tr>
157 − );
158 − })}
159 − </tbody>
160 − </DataTable>
161 − <Pagination total={page.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" />
162 − </>
70 + <>
71 + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} />
72 + <Container wide>
73 + <header className="flex flex-col gap-3 pb-4 pt-6 md:flex-row md:items-end md:justify-between md:pt-8">
74 + <div className="min-w-0">
75 + <p className="eyebrow">Models</p>
76 + <h1 className="display mt-1 text-[26px] md:text-[34px]">AI models</h1>
77 + <p className="mt-2 max-w-2xl text-sm text-ink-2">Canonical model releases across every lab and modality. Parameters, context, openness and licences exactly as their sources state them — one row per real model, artifacts folded under their canonical release.</p>
78 + </div>
79 + {page && (
80 + <p className="tnum flex items-center gap-1 text-sm text-ink-2" data-models-count>
81 + <span className="font-semibold text-ink">{fmtInt(page.total)}</span> {artifacts ? 'models + artifacts' : 'canonical models'} <span className="text-ink-3">{artifacts ? '(artifacts included)' : '(artifacts excluded)'}</span>
82 + <Hint text={artifacts ? (artifactsDef ? `${definition} Artifacts: ${artifactsDef}` : definition) : definition} align="right" />
83 + </p>
163 84 )}
164 − </ListingLayout>
165 − </div>
85 + </header>
86 + <UnderstoodChips current={current} facets={page?.facets} className="pb-3" />
87 + </Container>
88 +
89 + <ModelsTerminal filters={<ModelsFilterRail current={current} facets={page?.facets} universeNote={artifactsDef} />} filterCount={filterCount} items={page?.items ?? []}>
90 + {!page ? (
91 + <Unavailable what="Models" reason="The API did not answer. Try again in a moment." />
92 + ) : (
93 + <>
94 + <div className="flex flex-wrap items-center justify-between gap-2 pb-1 text-xs text-ink-3">
95 + <p>
96 + Sort:{' '}
97 + {MODEL_SORTS.map((s, i) => (
98 + <span key={s.value}>
99 + {i > 0 && ' · '}
100 + <Link href={sortHref[s.value] ?? '#'} className={sort === s.value ? 'font-medium text-ink' : 'hover:text-ink'} aria-current={sort === s.value ? 'true' : undefined}>
101 + {s.label}
102 + </Link>
103 + </span>
104 + ))}
105 + </p>
106 + <p className="tnum">
107 + {page.items.length ? `${fmtInt(offset + 1)}–${fmtInt(offset + page.items.length)} of ${fmtInt(page.total)}` : '0 rows'} · {page.universe ?? 'canonical models'}
108 + </p>
109 + </div>
110 + <ModelsTable items={page.items} sort={sort} order={order} sortHref={sortHref} orgHrefTemplate={href({ org: '__ORG__', offset: undefined })} offset={offset} />
111 + <Pagination total={page.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" />
112 + <Note className="mt-3">
113 + Best price = cheapest current offer across providers when the listing carries one (USD per 1M tokens); otherwise open the model for its provider deployments. Data quality is how well AI Atlas knows the entity, not how good the model is. Rows focus with the keyboard: ↑ ↓ move, Enter inspects. <Link href="/methodology" className="link">Methodology →</Link>
114 + </Note>
115 + </>
116 + )}
117 + </ModelsTerminal>
166 118 <CompareTrayBar />
167 − </Container>
119 + </>
168 120 );
169 121 }
added apps/web/src/app/models/[slug]/diff/[b]/page.tsx +252 −0
@@ -0,0 +1,252 @@
1 +import { ArrowLeftRight } from 'lucide-react';
2 +import { ScrollX } from '@/components/models/scroll-x';
3 +import type { Metadata } from 'next';
4 +import Link from 'next/link';
5 +import { notFound } from 'next/navigation';
6 +import { ComparabilityBadge, TrustBadge } from '@/components/models/badges';
7 +import { fmtScoreUnit } from '@/components/models/shared';
8 +import { Chip, EntityBadge } from '@/components/ui/badges';
9 +import { DataTable, Td, Th } from '@/components/ui/data-table';
10 +import { EntityLink } from '@/components/ui/entity';
11 +import { Container, Note, PageHeader } from '@/components/ui/section';
12 +import { EmptyState, Unavailable } from '@/components/ui/unavailable';
13 +import { ApiError, apiD1, safe } from '@/lib/api';
14 +import { cn } from '@/lib/cn';
15 +import { fmtDate, fmtInt, fmtTokens, fmtUsdPerM, fmtValue, num } from '@/lib/format';
16 +import { routes, SITE_NAME, SITE_URL } from '@/lib/site';
17 +import type { DiffDelta, ModelDiffPayload } from '@/lib/types';
18 +
19 +/*
20 + /models/[a]/diff/[b] — only the dimensions whose observed values differ, with deltas: context 200K → 1M (+400%), price −34%,
21 + benchmark ± points (comparable groups only), capability lists as added / removed. Shareable; linked from /compare.
22 +*/
23 +
24 +type Params = { params: Promise<{ slug: string; b: string }> };
25 +export const revalidate = 300;
26 +
27 +async function load(a: string, b: string): Promise<{ res: ModelDiffPayload | null; error: string | null }> {
28 + try {
29 + return { res: await apiD1.modelDiff(a, b), error: null };
30 + } catch (e) {
31 + if (e instanceof ApiError && e.notFound) notFound();
32 + if (e instanceof ApiError && (e.status === 400 || e.status === 422)) return { res: null, error: e.detail ?? 'These two entities cannot be diffed.' };
33 + return { res: null, error: null };
34 + }
35 +}
36 +
37 +export async function generateMetadata({ params }: Params): Promise<Metadata> {
38 + const { slug, b } = await params;
39 + const res = await safe(apiD1.modelDiff(slug, b));
40 + const canonical = `/models/${encodeURIComponent(slug)}/diff/${encodeURIComponent(b)}`;
41 + if (!res) return { title: 'Model diff', robots: { index: false }, alternates: { canonical } };
42 + const title = `${res.a.name} vs ${res.b.name} — what differs`;
43 + const description = `${res.dimensions.length} differing dimensions between ${res.a.name} and ${res.b.name}: context, prices, benchmarks (comparable groups only), capabilities — each value with its source. ${SITE_NAME}.`;
44 + return { title, description, alternates: { canonical }, openGraph: { title: `${title} | ${SITE_NAME}`, description, url: `${SITE_URL}${canonical}`, type: 'article' } };
45 +}
46 +
47 +function isNumDelta(d: DiffDelta): d is { absolute: number; percent: number | null } {
48 + return !!d && 'absolute' in d;
49 +}
50 +function isListDelta(d: DiffDelta): d is { added: unknown[]; removed: unknown[] } {
51 + return !!d && 'added' in d;
52 +}
53 +
54 +function cell(kind: string, key: string, v: unknown, unit?: string): string {
55 + if (v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0)) return 'Unavailable';
56 + if (kind === 'date') return fmtDate(String(v));
57 + if (kind === 'bool') return v ? 'Yes' : 'No';
58 + if (kind === 'list') return (Array.isArray(v) ? v : [v]).map(String).join(', ');
59 + if (kind === 'number') {
60 + if (/per_mtok/.test(key)) return fmtUsdPerM(v);
61 + if (key.startsWith('bench:')) return fmtScoreUnit(num(v), unit ?? null);
62 + if (/context_length|max_output_tokens/.test(key)) return fmtTokens(v);
63 + return fmtValue(v, key);
64 + }
65 + return typeof v === 'string' ? v : fmtValue(v, key);
66 +}
67 +
68 +/** "+39 days" / "−2 months" between two ISO dates (month precision when either date is YYYY-MM). */
69 +function dayDelta(a: string, b: string): string {
70 + const monthly = a.length <= 7 || b.length <= 7;
71 + const ta = new Date(a.length === 7 ? `${a}-01` : a.length === 4 ? `${a}-01-01` : a).getTime();
72 + const tb = new Date(b.length === 7 ? `${b}-01` : b.length === 4 ? `${b}-01-01` : b).getTime();
73 + if (!Number.isFinite(ta) || !Number.isFinite(tb)) return 'differs';
74 + const days = Math.round((tb - ta) / 86400000);
75 + const sign = days > 0 ? '+' : '−';
76 + const abs = Math.abs(days);
77 + if (monthly || abs >= 90) {
78 + const months = Math.round(abs / 30.44);
79 + return `${sign}${months} month${months === 1 ? '' : 's'}`;
80 + }
81 + return `${sign}${abs} day${abs === 1 ? '' : 's'}`;
82 +}
83 +
84 +function fmtPct(p: number | null): string | null {
85 + if (p === null || !Number.isFinite(p)) return null;
86 + const sign = p > 0 ? '+' : p < 0 ? '−' : '';
87 + const abs = Math.abs(p);
88 + return `${sign}${abs >= 100 ? abs.toFixed(0) : abs.toFixed(abs < 10 ? 1 : 0)}%`;
89 +}
90 +
91 +export default async function ModelDiffPage({ params }: Params) {
92 + const { slug, b } = await params;
93 + const { res, error } = await load(slug, b);
94 + const canonical = `/models/${encodeURIComponent(slug)}/diff/${encodeURIComponent(b)}`;
95 + if (!res)
96 + return (
97 + <Container>
98 + <PageHeader eyebrow="Model diff" title="What differs" />
99 + <div className="pb-16">{error ? <EmptyState title="These two entities cannot be diffed">{error}</EmptyState> : <Unavailable what="Model diff" reason="The API did not answer. Try again in a moment." />}</div>
100 + </Container>
101 + );
102 + const attr = res.dimensions.filter((d) => d.source !== 'results' && d.source !== 'prices');
103 + const prices = res.dimensions.filter((d) => d.source === 'prices');
104 + const bench = res.dimensions.filter((d) => d.source === 'results');
105 + const lists = res.dimensions.filter((d) => isListDelta(d.delta));
106 + const ld = { '@context': 'https://schema.org', '@type': 'Dataset', name: `${res.a.name} vs ${res.b.name} — differences`, url: `${SITE_URL}${canonical}`, description: res.note ?? undefined, about: [res.a, res.b].map((e) => ({ '@type': 'SoftwareApplication', name: e.name, url: `${SITE_URL}${routes.entity(e)}` })) };
107 +
108 + const Row = ({ d }: { d: ModelDiffPayload['dimensions'][number] }) => {
109 + const comp = res.comparability?.[d.key];
110 + const nd = isNumDelta(d.delta) ? d.delta : null;
111 + const better = nd && d.kind === 'number' ? (d.source === 'prices' ? nd.absolute < 0 : (d.higher_is_better ?? true) ? nd.absolute > 0 : nd.absolute < 0) : null;
112 + return (
113 + <tr>
114 + <Td primary className="align-top">
115 + {d.key.startsWith('bench:') && d.benchmark ? (
116 + <Link href={routes.benchmark(d.benchmark) + (d.metric && d.config_key ? `?metric=${encodeURIComponent(d.metric)}&config_key=${encodeURIComponent(d.config_key)}` : '')} className="text-ink hover:text-accent hover:underline">
117 + {d.label.split(' · ')[0]}
118 + </Link>
119 + ) : (
120 + d.label
121 + )}
122 + <span className="block text-[11px] text-ink-3">
123 + {d.key.startsWith('bench:') ? d.label.split(' · ').slice(1).join(' · ') : d.unit}
124 + </span>
125 + {comp && (
126 + <span className="mt-0.5 flex flex-wrap items-center gap-1">
127 + <ComparabilityBadge level={comp.level} reasons={comp.reasons} />
128 + {comp.trust?.[res.a.id] && <TrustBadge level={comp.trust[res.a.id]?.level} label={comp.trust[res.a.id]?.label} />}
129 + </span>
130 + )}
131 + </Td>
132 + <Td label={res.a.name} className="tnum align-top">
133 + {cell(d.kind, d.key, d.a, d.unit)}
134 + </Td>
135 + <Td label={res.b.name} className="tnum align-top">
136 + {cell(d.kind, d.key, d.b, d.unit)}
137 + </Td>
138 + <Td label="Delta (b − a)" className="tnum align-top">
139 + {nd ? (
140 + <span className={cn('font-medium', better === true && 'text-positive', better === false && 'text-danger')}>
141 + {d.source === 'prices' ? `${nd.absolute > 0 ? '+' : '−'}${fmtUsdPerM(Math.abs(nd.absolute))}` : d.key.startsWith('bench:') ? `${nd.absolute > 0 ? '+' : '−'}${Math.abs(nd.absolute).toFixed(Math.abs(nd.absolute) < 10 ? 2 : 1)}${d.unit === '%' ? ' pt' : ''}` : `${nd.absolute > 0 ? '+' : '−'}${fmtValue(Math.abs(nd.absolute), d.key)}`}
142 + {fmtPct(nd.percent) && <span className="ml-1.5 text-xs font-normal text-ink-3">{fmtPct(nd.percent)}</span>}
143 + </span>
144 + ) : isListDelta(d.delta) ? (
145 + <span className="flex flex-wrap gap-1">
146 + {d.delta.added.map((x) => (
147 + <Chip key={`+${String(x)}`} className="text-positive bg-positive-soft">
148 + + {String(x)}
149 + </Chip>
150 + ))}
151 + {d.delta.removed.map((x) => (
152 + <Chip key={`-${String(x)}`} className="text-danger bg-danger-soft">
153 + − {String(x)}
154 + </Chip>
155 + ))}
156 + </span>
157 + ) : d.kind === 'date' && typeof d.a === 'string' && typeof d.b === 'string' ? (
158 + <span className="tnum text-ink-2">{dayDelta(d.a, d.b)}</span>
159 + ) : (
160 + <span className="text-ink-3">differs</span>
161 + )}
162 + </Td>
163 + </tr>
164 + );
165 + };
166 + const Table = ({ rows, caption }: { rows: ModelDiffPayload['dimensions']; caption: string }) => (
167 + <ScrollX><DataTable caption={caption} compact>
168 + <thead>
169 + <tr>
170 + <Th>Dimension</Th>
171 + <Th>{res.a.name}</Th>
172 + <Th>{res.b.name}</Th>
173 + <Th>Delta (b − a)</Th>
174 + </tr>
175 + </thead>
176 + <tbody>
177 + {rows.map((d) => (
178 + <Row key={d.key} d={d} />
179 + ))}
180 + </tbody>
181 + </DataTable></ScrollX>
182 + );
183 +
184 + return (
185 + <Container wide>
186 + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} />
187 + <PageHeader
188 + eyebrow={
189 + <>
190 + <span>Model diff</span>
191 + <EntityBadge type="model" small />
192 + </>
193 + }
194 + title={
195 + <>
196 + {res.a.name} <span className="text-ink-3">vs</span> {res.b.name}
197 + </>
198 + }
199 + lede={`${fmtInt(res.dimensions.length)} dimensions differ. ${res.note ?? 'Only dimensions with differing observed values are listed; numeric delta = (b − a) / a.'}`}
200 + aside={
201 + <div className="flex flex-wrap gap-2 text-sm">
202 + <Link href={`/models/${encodeURIComponent(res.b.slug)}/diff/${encodeURIComponent(res.a.slug)}`} className="inline-flex h-9 items-center gap-1.5 border border-rule px-2.5 text-ink-2 hover:border-rule-strong hover:text-ink">
203 + <ArrowLeftRight className="size-3.5" aria-hidden /> Swap
204 + </Link>
205 + <Link href={routes.compare([res.a.slug, res.b.slug])} className="inline-flex h-9 items-center bg-ink px-3 font-medium text-canvas hover:opacity-90">
206 + Full comparison →
207 + </Link>
208 + </div>
209 + }
210 + >
211 + <p className="mt-4 flex flex-wrap gap-x-6 gap-y-1 text-sm text-ink-2">
212 + <span>
213 + a · <EntityLink e={res.a} className="font-medium" /> {res.a.organization && <span className="text-ink-3">{res.a.organization.name}</span>}
214 + </span>
215 + <span>
216 + b · <EntityLink e={res.b} className="font-medium" /> {res.b.organization && <span className="text-ink-3">{res.b.organization.name}</span>}
217 + </span>
218 + </p>
219 + </PageHeader>
220 + <div className="space-y-10 pb-16">
221 + {res.dimensions.length === 0 && <EmptyState title="No recorded difference">Every observed dimension has the same value for both models — or neither has a value.</EmptyState>}
222 + {attr.length > 0 && (
223 + <section>
224 + <p className="eyebrow mb-2">
225 + Specification & capabilities <span className="tnum text-ink-3">{attr.length}</span>
226 + </p>
227 + <Table rows={attr} caption="Differing specification dimensions" />
228 + </section>
229 + )}
230 + {prices.length > 0 && (
231 + <section>
232 + <p className="eyebrow mb-2">
233 + Pricing <span className="tnum text-ink-3">{prices.length}</span>
234 + </p>
235 + <Table rows={prices} caption="Differing price dimensions" />
236 + <Note className="mt-2">Best (cheapest) current offer across providers, USD per 1M tokens. A negative delta means b is cheaper.</Note>
237 + </section>
238 + )}
239 + {bench.length > 0 && (
240 + <section>
241 + <p className="eyebrow mb-2">
242 + Benchmarks <span className="tnum text-ink-3">{bench.length}</span>
243 + </p>
244 + <Table rows={bench} caption="Differing benchmark scores" />
245 + <Note className="mt-2">Only comparability groups where both models have a current result. Partially comparable = same task, conditions (effort, temperature, judge) differ. Not a verdict.</Note>
246 + </section>
247 + )}
248 + {lists.length > 0 && <Note>List dimensions ({lists.map((l) => l.label).join(', ')}) show elements added (+) and removed (−) going from a to b.</Note>}
249 + </div>
250 + </Container>
251 + );
252 +}
modified apps/web/src/app/models/[slug]/opengraph-image.tsx +16 −37
@@ -1,47 +1,26 @@
1 1 import { ImageResponse } from 'next/og';
2 −import { api, safe } from '@/lib/api';
3 −import { fmtDate, fmtParams, fmtTokens, fmtUsdPerM, num } from '@/lib/format';
4 −import { OPENNESS_LABELS, routes, SITE_NAME } from '@/lib/site';
5 −import type { EntityDetail } from '@/lib/types';
6 −import { Eyebrow, Facts, Fallback, Frame, INK2, Title } from '@/components/brand/og';
2 +import { Fallback, Wallpaper } from '@/components/brand/og';
3 +import { apiD1, safe } from '@/lib/api';
4 +import { fmtDate, fmtParams, fmtTokens, num } from '@/lib/format';
5 +import { routes, SITE_NAME } from '@/lib/site';
7 6
8 7 export const runtime = 'nodejs';
9 8 export const alt = `Model on ${SITE_NAME}`;
10 9 export const size = { width: 1200, height: 630 };
11 10 export const contentType = 'image/png';
12 11
13 −function modelFacts(d: EntityDetail): [string, string][] {
14 − const a = d.attributes ?? {};
15 − const out: [string, string][] = [];
16 − if (num(a.parameter_count) !== null) out.push(['Parameters', fmtParams(a.parameter_count)]);
17 − if (num(a.context_length) !== null) out.push(['Context', `${fmtTokens(a.context_length)} tokens`]);
18 − const inputs = (d.prices ?? []).map((p) => num(p.input_per_mtok)).filter((v): v is number => v !== null);
19 − if (inputs.length) {
20 − const best = Math.min(...inputs);
21 − out.push(['Best input / 1M', best >= 1 ? `$${best.toFixed(2)}` : fmtUsdPerM(best)]);
22 − }
23 − if (typeof a.release_date === 'string') out.push(['Released', fmtDate(a.release_date)]);
24 − if (typeof a.openness === 'string') out.push(['Openness', OPENNESS_LABELS[a.openness] ?? a.openness]);
25 − if (typeof a.license === 'string') out.push(['License', a.license.slice(0, 18)]);
26 − return out.slice(0, 3);
27 −}
28 −
29 −/** Per-model Open Graph image: brand, "Model" eyebrow, name, organization, three key attributes, canonical URL. */
12 +/** Per-model Open Graph image: wallpaper with "Model · Org", name, identity subtitle and counters params · active · context · released. */
30 13 export default async function ModelOgImage({ params }: { params: Promise<{ slug: string }> }) {
31 14 const { slug } = await params;
32 − const d = await safe(api.entityOfType('models', slug));
33 − if (!d) return new ImageResponse(<Fallback label="Model" />, { ...size });
34 − const facts = modelFacts(d);
35 − return new ImageResponse(
36 − (
37 − <Frame footer={`www.ai-atlas.co${routes.entity(d)}`}>
38 − <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
39 − <Eyebrow>{d.organization ? `Model · ${d.organization.name}` : 'Model'}</Eyebrow>
40 − <Title>{d.name}</Title>
41 − {facts.length > 0 ? <Facts items={facts} /> : <div style={{ display: 'flex', fontSize: 24, color: INK2 }}>{d.description ? d.description.slice(0, 120) : 'Specifications, prices, benchmarks, lineage and sources.'}</div>}
42 − </div>
43 − </Frame>
44 − ),
45 − { ...size },
46 − );
15 + const d = await safe(apiD1.model(slug));
16 + if (!d || d.entity_type === 'artifact') return new ImageResponse(<Fallback label="Model" />, { ...size });
17 + const a = d.attributes ?? {};
18 + const counters: [string, string][] = [];
19 + if (num(a.parameter_count) !== null) counters.push(['Parameters', fmtParams(a.parameter_count)]);
20 + if (num(a.active_parameter_count) !== null && num(a.active_parameter_count) !== num(a.parameter_count)) counters.push(['Active', fmtParams(a.active_parameter_count)]);
21 + if (num(a.context_length) !== null) counters.push(['Context', `${fmtTokens(a.context_length)} tokens`]);
22 + if (typeof a.release_date === 'string') counters.push(['Released', fmtDate(a.release_date)]);
23 + const licence = d.licence && 'key' in d.licence && d.licence.key ? d.licence.key : null;
24 + const subtitle = [d.openness?.label ?? null, licence ? `licence ${licence}` : null, d.deployments?.length ? `${d.deployments.length} provider deployment${d.deployments.length === 1 ? '' : 's'}` : null, d.benchmarks?.items.length ? `${d.benchmarks.items.length} benchmarks` : null].filter(Boolean).join(' · ') || d.description?.slice(0, 110) || 'Specifications, prices, benchmarks, lineage and sources.';
25 + return new ImageResponse(<Wallpaper eyebrow={d.organization ? `Model · ${d.organization.name}` : 'Model'} title={d.name} subtitle={subtitle} counters={counters.slice(0, 4)} footer={`www.ai-atlas.co${routes.entity(d)}`} markPx={220} />, { ...size });
47 26 }
modified apps/web/src/app/models/[slug]/page.tsx +35 −11
@@ -1,22 +1,46 @@
1 1 import type { Metadata } from 'next';
2 −import { permanentRedirect } from 'next/navigation';
3 −import { EntityPage, type EntityPageParams } from '@/components/entity/entity-page';
4 −import { entityMetadata, loadEntity } from '@/components/entity/load';
5 −import { api, safe } from '@/lib/api';
6 −import { routes } from '@/lib/site';
2 +import { notFound, permanentRedirect } from 'next/navigation';
3 +import { describeModel, ModelPage, type ModelPageParams } from '@/components/entity/model-page';
4 +import { api, ApiError, apiD1, safe } from '@/lib/api';
5 +import { routes, SITE_NAME, SITE_URL } from '@/lib/site';
6 +import type { ModelDetail } from '@/lib/types';
7 7
8 −type Params = { params: Promise<{ slug: string }>; searchParams: Promise<EntityPageParams> };
8 +type Params = { params: Promise<{ slug: string }>; searchParams: Promise<ModelPageParams> };
9 +
10 +/** `/models/<slug>` accepts models AND artifacts, and resolves folded variants to their canonical model (`redirected_from`). */
11 +export async function loadModelOrArtifact(slug: string): Promise<ModelDetail> {
12 + try {
13 + return await apiD1.model(slug);
14 + } catch (e) {
15 + if (e instanceof ApiError && e.notFound) notFound();
16 + throw e;
17 + }
18 +}
9 19
10 20 export async function generateMetadata({ params }: Params): Promise<Metadata> {
11 21 const { slug } = await params;
12 − return entityMetadata('models', slug);
22 + const d = await safe(apiD1.model(slug));
23 + if (!d || d.entity_type === 'artifact' || d.slug !== slug) return { title: 'Model', robots: { index: false } };
24 + const title = `${d.name} — Parameters, Context, Benchmarks & Pricing`;
25 + const description = describeModel(d);
26 + const canonical = routes.entity(d);
27 + return {
28 + title,
29 + description,
30 + alternates: { canonical },
31 + openGraph: { title: `${title} | ${SITE_NAME}`, description, url: `${SITE_URL}${canonical}`, type: 'article', siteName: SITE_NAME },
32 + twitter: { card: 'summary_large_image', title, description },
33 + };
13 34 }
14 35
15 −export default async function ModelPage({ params, searchParams }: Params) {
36 +export default async function ModelRoute({ params, searchParams }: Params) {
16 37 const { slug } = await params;
17 38 const sp = await searchParams;
18 − const d = await loadEntity('models', slug);
19 − if (d.slug !== slug) permanentRedirect(routes.entity(d));
39 + const d = await loadModelOrArtifact(slug);
40 + // Old URLs keep resolving: artifacts live at /artifacts/<slug>; folded evaluation variants and aliases 308 to the canonical model.
41 + if (d.entity_type === 'artifact') permanentRedirect(routes.artifact(d.slug));
42 + if (d.redirected_from || d.slug !== slug) permanentRedirect(routes.entity(d));
43 + if (d.entity_type !== 'model' && d.entity_type !== 'quantization') notFound();
20 44 const related = await safe(api.entityRelated(d.slug, 10));
21 − return <EntityPage d={d} canonical={routes.entity(d)} related={related?.items} asof={sp.asof} historyProperty={sp.property} />;
45 + return <ModelPage d={d} canonical={routes.entity(d)} related={related?.items} params={sp} />;
22 46 }
added apps/web/src/components/benchmarks/client-charts.tsx +23 −0
@@ -0,0 +1,23 @@
1 +'use client';
2 +import dynamic from 'next/dynamic';
3 +import { InteractiveLineChart, type Point, type ScatterPoint } from '@/components/charts';
4 +
5 +// d3's log scale yields last-digit float differences between Node and Chromium (cx=400.02442488279183 vs …917), which React
6 +// reports as a hydration mismatch — so the scatter is rendered on the client only, with a fixed-height placeholder.
7 +const ScatterChart = dynamic(() => import('@/components/charts/scatter-chart').then((m) => m.ScatterChart), { ssr: false, loading: () => <div className="animate-pulse bg-surface-2/60" style={{ height: 420 }} aria-hidden /> });
8 +import { fmtScoreUnit, xFormatter } from '@/components/models/shared';
9 +
10 +/*
11 + Client wrappers for the benchmark pages: server components cannot pass formatter functions across the boundary, so these take
12 + a `unit` / axis key and build the formatters on the client.
13 +*/
14 +
15 +export function FrontierLineChart({ points, unit, label, height = 220 }: { points: { x: string; y: number }[]; unit: string | null; label: string; height?: number }) {
16 + const pts: Point[] = points.map((p) => ({ x: new Date(p.x), y: p.y }));
17 + return <InteractiveLineChart series={[{ name: 'Best score', color: 'var(--type-benchmark)', points: pts }]} height={height} step showDots yFormat={(v) => fmtScoreUnit(v, unit)} yLabel={label} />;
18 +}
19 +
20 +
21 +export function ParetoChart({ points, xKey, unit, log, xLabel, yLabel, frontier, highlight, height = 420 }: { points: ScatterPoint[]; xKey: string; unit: string | null; log: boolean; xLabel: string; yLabel: string; frontier: { x: number; y: number }[]; highlight: string[]; height?: number }) {
22 + return <ScatterChart points={points} xScale={log && points.every((p) => p.x > 0) ? 'log' : 'linear'} yScale="linear" xLabel={xLabel} yLabel={yLabel} xFormat={xFormatter(xKey)} yFormat={(v) => fmtScoreUnit(v, unit)} frontier={frontier} highlight={highlight} height={height} labelTop={8} color="var(--type-model)" />;
23 +}
added apps/web/src/components/benchmarks/filter-rails.tsx +142 −0
@@ -0,0 +1,142 @@
1 +'use client';
2 +import Link from 'next/link';
3 +import { cn } from '@/lib/cn';
4 +import { fmtInt } from '@/lib/format';
5 +
6 +/*
7 + Filter rails of /benchmarks and /benchmarks/matrix as client components fed with plain data.
8 + Why client: these GET forms need no interactivity, but when the whole rail is server-rendered JSX, React Flight outlines the long
9 + element arrays and React dev reports a false "unique key" warning under TerminalLayout. Building the elements on the client avoids it.
10 + Everything still works without JavaScript being needed for the forms (plain GET submits) once hydrated.
11 +*/
12 +
13 +const input = 'h-9 w-full border border-rule bg-surface px-2 text-[13px] text-ink focus:border-accent focus:outline-none';
14 +const chip = (active: boolean) => cn('flex min-h-8 items-center justify-between gap-2 px-1 text-[13px] hover:bg-surface-2', active ? 'bg-surface-2 font-medium text-ink' : 'text-ink-2');
15 +
16 +export function BenchmarksFilterRail({ q, category, withResults, total, categories, hrefAll, categoryHrefs }: { q?: string; category?: string; withResults: boolean; total: number; categories: { c: string; n: number }[]; hrefAll: string; categoryHrefs: Record<string, string> }) {
17 + return (
18 + <div className="space-y-5 pb-6 text-sm" data-benchmark-filters>
19 + <form action="/benchmarks" method="get" className="space-y-3">
20 + {category && <input type="hidden" name="category" value={category} />}
21 + <label className="block">
22 + <span className="eyebrow block pb-1">Name</span>
23 + <input name="q" defaultValue={q ?? ''} placeholder="swe-bench, gpqa…" className={input} />
24 + </label>
25 + <label className="flex min-h-9 items-center gap-2 text-[13px] text-ink-2">
26 + <input type="checkbox" name="with_results" value="1" defaultChecked={withResults} className="size-4 accent-[var(--accent)]" /> Only benchmarks with results
27 + </label>
28 + <div className="flex gap-2">
29 + <button type="submit" className="inline-flex h-9 flex-1 items-center justify-center bg-ink px-3 text-[13px] font-medium text-canvas hover:opacity-90">
30 + Apply
31 + </button>
32 + <Link href="/benchmarks" className="inline-flex h-9 items-center border border-rule px-3 text-[13px] text-ink-2 hover:text-ink">
33 + Reset
34 + </Link>
35 + </div>
36 + </form>
37 + <div>
38 + <p className="eyebrow mb-1">Category</p>
39 + <ul className="space-y-px">
40 + <li>
41 + <Link href={hrefAll} className={chip(!category)} aria-current={!category ? 'true' : undefined}>
42 + <span>All</span> <span className="tnum text-xs text-ink-3">{fmtInt(total)}</span>
43 + </Link>
44 + </li>
45 + {categories.map(({ c, n }) => (
46 + <li key={c}>
47 + <Link href={categoryHrefs[c] ?? hrefAll} className={chip(category === c)} aria-current={category === c ? 'true' : undefined}>
48 + <span className="truncate">{c}</span> <span className="tnum text-xs text-ink-3">{fmtInt(n)}</span>
49 + </Link>
50 + </li>
51 + ))}
52 + </ul>
53 + </div>
54 + <div>
55 + <p className="eyebrow mb-1">Views</p>
56 + <ul className="space-y-px text-[13px]">
57 + <li>
58 + <Link href="/benchmarks/matrix" className="flex min-h-8 items-center px-1 text-ink-2 hover:bg-surface-2 hover:text-ink">
59 + Benchmark matrix →
60 + </Link>
61 + </li>
62 + <li>
63 + <Link href="/frontier" className="flex min-h-8 items-center px-1 text-ink-2 hover:bg-surface-2 hover:text-ink">
64 + Frontier →
65 + </Link>
66 + </li>
67 + </ul>
68 + </div>
69 + </div>
70 + );
71 +}
72 +
73 +export function MatrixFilterRail({ benchmarks, picked, org, family, yearFrom, yearTo, comparableOnly, minCells, limit, limits }: { benchmarks: { slug: string; name: string; models: number }[]; picked: string[]; org?: string; family?: string; yearFrom?: string; yearTo?: string; comparableOnly: boolean; minCells: number; limit: number; limits: number[] }) {
74 + return (
75 + <form action="/benchmarks/matrix" method="get" className="space-y-4 pb-6 text-sm" data-matrix-filters>
76 + <div>
77 + <p className="eyebrow mb-1">
78 + Benchmarks {picked.length ? <span className="tnum text-ink-3">{picked.length} picked</span> : <span className="text-ink-3">(default: 12 most populated)</span>}
79 + </p>
80 + <ul className="scrollbar-thin max-h-72 space-y-px overflow-y-auto pr-1">
81 + {benchmarks.map((b) => (
82 + <li key={b.slug}>
83 + <label className="flex min-h-8 items-center gap-2 px-1 text-[13px] text-ink-2 hover:bg-surface-2">
84 + <input type="checkbox" name="benchmarks" value={b.slug} defaultChecked={picked.includes(b.slug)} className="size-4 shrink-0 accent-[var(--accent)]" />
85 + <span className="min-w-0 flex-1 truncate">{b.name}</span>
86 + <span className="tnum text-xs text-ink-3">{fmtInt(b.models)}</span>
87 + </label>
88 + </li>
89 + ))}
90 + </ul>
91 + <p className="mt-1 text-[10.5px] text-ink-3">Checked benchmarks become columns (`benchmarks=` in the URL).</p>
92 + </div>
93 + <div className="grid grid-cols-2 gap-2">
94 + <label className="block">
95 + <span className="eyebrow block pb-1">Organization</span>
96 + <input name="org" defaultValue={org ?? ''} placeholder="openai" className={input} />
97 + </label>
98 + <label className="block">
99 + <span className="eyebrow block pb-1">Family</span>
100 + <input name="family" defaultValue={family ?? ''} placeholder="qwen3-family" className={input} />
101 + </label>
102 + </div>
103 + <div className="grid grid-cols-2 gap-2">
104 + <label className="block">
105 + <span className="eyebrow block pb-1">Released ≥</span>
106 + <input name="year_from" defaultValue={yearFrom ?? ''} placeholder="2025" inputMode="numeric" className={input} />
107 + </label>
108 + <label className="block">
109 + <span className="eyebrow block pb-1">Released ≤</span>
110 + <input name="year_to" defaultValue={yearTo ?? ''} placeholder="2026" inputMode="numeric" className={input} />
111 + </label>
112 + </div>
113 + <label className="flex min-h-9 items-center gap-2 text-[13px] text-ink-2">
114 + <input type="checkbox" name="comparable_only" value="1" defaultChecked={comparableOnly} className="size-4 accent-[var(--accent)]" /> Comparable cells only
115 + </label>
116 + <div className="grid grid-cols-2 gap-2">
117 + <label className="block">
118 + <span className="eyebrow block pb-1">Min cells / row</span>
119 + <input name="min_cells" type="number" min={1} max={12} defaultValue={minCells} className={input} />
120 + </label>
121 + <label className="block">
122 + <span className="eyebrow block pb-1">Rows</span>
123 + <select name="limit" defaultValue={String(limit)} className={input}>
124 + {limits.map((l) => (
125 + <option key={l} value={l}>
126 + {l}
127 + </option>
128 + ))}
129 + </select>
130 + </label>
131 + </div>
132 + <div className="flex gap-2">
133 + <button type="submit" className="inline-flex h-9 flex-1 items-center justify-center bg-ink px-3 text-[13px] font-medium text-canvas hover:opacity-90">
134 + Apply
135 + </button>
136 + <Link href="/benchmarks/matrix" className="inline-flex h-9 items-center border border-rule px-3 text-[13px] text-ink-2 hover:text-ink">
137 + Reset
138 + </Link>
139 + </div>
140 + </form>
141 + );
142 +}
added apps/web/src/components/benchmarks/leaderboard2.tsx +204 −0
@@ -0,0 +1,204 @@
1 +import Link from 'next/link';
2 +import { CompareButton } from '@/components/compare/compare-button';
3 +import { ComparabilityBadge, ConfigChipEl, OpennessChip, TrustBadge } from '@/components/models/badges';
4 +import { configChipsOf, fmtScoreUnit, refToSummary } from '@/components/models/shared';
5 +import { FrontierLineChart } from './client-charts';
6 +import { ScrollX } from '@/components/models/scroll-x';
7 +import { EntityLink } from '@/components/ui/entity';
8 +import { Pagination } from '@/components/ui/pagination';
9 +import { SourceCell } from '@/components/ui/provenance';
10 +import { Note } from '@/components/ui/section';
11 +import { EmptyState } from '@/components/ui/unavailable';
12 +import { cn } from '@/lib/cn';
13 +import { fmtDate, fmtInt, fmtSigned } from '@/lib/format';
14 +import { routes } from '@/lib/site';
15 +import type { BenchmarkFrontierPayload, Group, LeaderboardRow } from '@/lib/types';
16 +
17 +/*
18 + Leaderboard 2.0 (server): one row per canonical model — rank (+Δ vs closed rows) · model · score bar · trust · config chips ·
19 + comparability vs leader · evaluated/observed · source · History · Compare. Mobile stacks (rank + model + score first).
20 +*/
21 +
22 +export function Leaderboard2({ rows, group, total, limit, offset, makeHref, historyHref, activeModel, unit }: { rows: LeaderboardRow[]; group: Group | null; total: number; limit: number; offset: number; makeHref: (offset: number) => string; historyHref: (slug: string) => string; activeModel?: string; unit?: string | null }) {
23 + if (!rows.length) return <EmptyState title="No result in this group with these filters">Relax the trust / organization filters or pick another comparability group.</EmptyState>;
24 + const hib = rows[0]?.higher_is_better !== false;
25 + const scores = rows.map((r) => r.score).filter(Number.isFinite);
26 + const max = Math.max(...scores);
27 + const min = Math.min(...scores);
28 + const width = (s: number) => {
29 + if (!Number.isFinite(s) || max <= 0) return 0;
30 + const v = hib ? s / max : min > 0 ? min / s : 0;
31 + return Math.max(2, Math.min(100, v * 100));
32 + };
33 + const u = unit ?? rows[0]?.unit ?? null;
34 + const leader = rows.find((r) => r.rank === 1) ?? rows[0];
35 + return (
36 + <>
37 + <ScrollX>
38 + <table className="data-table stack compact" data-leaderboard>
39 + <caption className="sr-only">Leaderboard</caption>
40 + <thead>
41 + <tr>
42 + <th scope="col" className="w-12">
43 + #
44 + </th>
45 + <th scope="col">Model</th>
46 + <th scope="col" className="num">
47 + Score
48 + </th>
49 + <th scope="col">Trust</th>
50 + <th scope="col">Configuration</th>
51 + <th scope="col">vs leader</th>
52 + <th scope="col">Evaluated</th>
53 + <th scope="col">Source</th>
54 + <th scope="col" className="text-right">
55 + <span className="sr-only">Actions</span>
56 + </th>
57 + </tr>
58 + </thead>
59 + <tbody>
60 + {rows.map((r) => {
61 + const on = activeModel === r.model.slug;
62 + const chips = configChipsOf(r.config, group?.config ?? null, 4);
63 + const openness = typeof r.model.attributes?.openness === 'string' ? r.model.attributes.openness : null;
64 + return (
65 + <tr key={r.result_id} className={cn(on && 'bg-accent-soft/40')} data-model={r.model.slug}>
66 + <td className="tnum text-ink-3" data-label="Rank">
67 + <span className="font-medium text-ink">{fmtInt(r.rank)}</span>
68 + {r.delta_rank !== null && r.delta_rank !== 0 && (
69 + <span className={cn('ml-1 text-[11px]', r.delta_rank > 0 ? 'text-positive' : 'text-danger')} title={`Rank moved ${fmtSigned(r.delta_rank)} vs the closed rows of this group${r.previous_rank ? ` (was ${r.previous_rank})` : ''}`}>
70 + {r.delta_rank > 0 ? '▲' : '▼'}
71 + {Math.abs(r.delta_rank)}
72 + </span>
73 + )}
74 + </td>
75 + <td className="primary">
76 + <span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
77 + <EntityLink e={{ ...r.model, entity_type: 'model' }} />
78 + {openness && <OpennessChip openness={openness} />}
79 + </span>
80 + <span className="block text-[11px] text-ink-3">
81 + {r.model.organization?.name ?? ''}
82 + {typeof r.model.attributes?.family === 'string' ? ` · ${r.model.attributes.family}` : ''}
83 + {r.n_rows > 1 ? ` · best of ${r.n_rows} rows` : ''}
84 + </span>
85 + </td>
86 + <td className="num tnum font-medium" data-label="Score">
87 + <span className="inline-flex flex-col items-end gap-1">
88 + <span>{fmtScoreUnit(r.score, u)}</span>
89 + <span className="block h-1 w-24 overflow-hidden rounded-sm bg-surface-2" aria-hidden>
90 + <span className="block h-full" style={{ width: `${width(r.score)}%`, background: 'var(--type-benchmark)' }} />
91 + </span>
92 + </span>
93 + </td>
94 + <td data-label="Trust">
95 + <TrustBadge level={r.trust_level} label={r.trust_label} />
96 + </td>
97 + <td data-label="Configuration">
98 + <span className="flex flex-wrap gap-1">{chips.length ? chips.map((c) => <ConfigChipEl key={c.key} k={c.key} v={c.value} kind={c.kind} />) : <span className="text-xs text-ink-3">group defaults</span>}</span>
99 + </td>
100 + <td data-label="vs leader">
101 + {r.rank === 1 ? <span className="text-xs font-medium text-positive">leader</span> : <ComparabilityBadge level={r.comparability} reasons={r.comparability_reasons} />}
102 + {leader && r.rank !== 1 && <span className="tnum block text-[11px] text-ink-3">{(hib ? r.score - leader.score : leader.score - r.score).toFixed(Math.abs(r.score - leader.score) < 10 ? 2 : 1)}{u === '%' ? ' pt' : ''}</span>}
103 + </td>
104 + <td data-label="Evaluated" className="tnum whitespace-nowrap text-xs text-ink-2" title={r.evaluated_at ? undefined : `Observed ${fmtDate(r.observed_at)}; the source gave no evaluation date`}>
105 + {r.evaluated_at ? fmtDate(r.evaluated_at) : <span className="text-ink-3">obs. {fmtDate(r.observed_at)}</span>}
106 + </td>
107 + <td data-label="Source">
108 + <SourceCell url={r.source_url} tier={r.tier} />
109 + </td>
110 + <td className="text-right">
111 + <span className="inline-flex flex-wrap items-center justify-end gap-1">
112 + <Link href={historyHref(r.model.slug)} className={cn('inline-flex h-7 items-center border px-1.5 text-xs whitespace-nowrap', on ? 'border-accent bg-accent-soft text-accent' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')} aria-pressed={on}>
113 + History
114 + </Link>
115 + <CompareButton e={refToSummary(r.model)} size="sm" label="" />
116 + </span>
117 + </td>
118 + </tr>
119 + );
120 + })}
121 + </tbody>
122 + </table>
123 + </ScrollX>
124 + <Pagination total={total} limit={limit} offset={offset} makeHref={makeHref} className="mt-4" />
125 + <Note className="mt-3">
126 + One row per canonical model — its best current row inside this comparability group (effort variants are folded into the model). Bars are relative to the page's best score{hib ? '' : ' (lower is better)'}. “vs leader” reads comparability: partially comparable = same task, conditions differ (reasoning effort, temperature, judge). <Link href="/methodology#benchmarks" className="link">Rules →</Link>
127 + </Note>
128 + </>
129 + );
130 +}
131 +
132 +/** Frontier over time: a point each time a new best appeared in the group; markers = leader changes. */
133 +export function FrontierChart({ frontier, group, unit }: { frontier: BenchmarkFrontierPayload | null; group: Group | null; unit?: string | null }) {
134 + if (!frontier) return <Note>Frontier history unavailable.</Note>;
135 + const series = frontier.series.find((s) => (group ? s.group.config_key === group.config_key && s.group.metric === group.metric : s.primary)) ?? frontier.series.find((s) => s.primary) ?? frontier.series[0];
136 + if (!series) return <Note>No frontier history for this group.</Note>;
137 + const pts = series.points.map((p) => ({ x: new Date(p.date), y: p.score, p })).filter((x) => !Number.isNaN(x.x.getTime())).sort((a, b) => a.x.getTime() - b.x.getTime());
138 + const days = new Set(pts.map((p) => p.x.toISOString().slice(0, 10)));
139 + return (
140 + <div data-frontier-chart>
141 + {pts.length < 2 || days.size < 2 ? (
142 + <Note>
143 + {pts.length === 0 ? 'No leader recorded yet.' : `${fmtInt(pts.length)} leader change${pts.length === 1 ? '' : 's'} recorded, all dated ${fmtDate(pts[0]?.p.date)} — the frontier line needs at least two distinct dates.`} The corpus is young: every result was first observed on the same day, so leader changes will separate in time as sources are re-crawled.
144 + </Note>
145 + ) : (
146 + <FrontierLineChart points={pts.map((p) => ({ x: p.p.date, y: p.y }))} unit={unit ?? null} label={`Frontier of ${frontier.benchmark.name}`} />
147 + )}
148 + {pts.length > 0 && (
149 + <ol className="mt-3 divide-y divide-rule border-y border-rule text-sm">
150 + {[...pts].reverse().slice(0, 8).map((p) => (
151 + <li key={p.p.result_id} className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-baseline gap-x-3 py-1.5">
152 + <span className="tnum font-medium">{fmtScoreUnit(p.y, unit)}</span>
153 + <span className="min-w-0 truncate">
154 + <EntityLink e={{ ...p.p.model, entity_type: 'model' }} /> <span className="text-xs text-ink-3">{p.p.model.organization?.name ?? ''}</span> <TrustBadge level={p.p.trust_level} className="ml-1" />
155 + </span>
156 + <span className="tnum text-xs text-ink-2">{fmtDate(p.p.date)}</span>
157 + </li>
158 + ))}
159 + </ol>
160 + )}
161 + <Note className="mt-2">{frontier.methodology}</Note>
162 + </div>
163 + );
164 +}
165 +
166 +/** Group picker: links when ≤ 8 groups, else a GET <select> — always server-rendered. */
167 +export function GroupPicker({ groups, active, makeHref, slug, hidden }: { groups: Group[]; active: Group | null; makeHref: (g: Group) => string; slug: string; hidden: Record<string, string | undefined> }) {
168 + if (groups.length <= 1) return active ? <p className="text-xs text-ink-2">{active.label}</p> : null;
169 + const sorted = [...groups].sort((a, b) => b.model_count - a.model_count);
170 + if (sorted.length <= 8)
171 + return (
172 + <ul className="space-y-px" data-group-picker>
173 + {sorted.map((g) => {
174 + const on = active?.config_key === g.config_key && active?.metric === g.metric;
175 + return (
176 + <li key={`${g.metric}:${g.config_key}`}>
177 + <Link href={makeHref(g)} className={cn('flex min-h-8 items-start justify-between gap-2 px-1 py-1 text-[12px] leading-4 hover:bg-surface-2', on ? 'bg-surface-2 font-medium text-ink' : 'text-ink-2')} aria-current={on ? 'true' : undefined} title={g.label}>
178 + <span className="min-w-0">
179 + <span className="block">{g.metric}</span>
180 + <span className="mono block truncate text-[10.5px] font-normal text-ink-3">{Object.entries(g.config).map(([k, v]) => `${k}=${String(v)}`).join(' · ') || 'default'}</span>
181 + </span>
182 + <span className="tnum shrink-0 text-[11px] text-ink-3">{fmtInt(g.model_count)}</span>
183 + </Link>
184 + </li>
185 + );
186 + })}
187 + </ul>
188 + );
189 + return (
190 + <form action={routes.benchmark(slug)} method="get" className="space-y-2" data-group-picker>
191 + {Object.entries(hidden).map(([k, v]) => (v ? <input key={k} type="hidden" name={k} value={v} /> : null))}
192 + <select name="group" defaultValue={active ? `${active.metric}|${active.config_key}` : ''} className="h-9 w-full border border-rule bg-surface px-2 text-[12px] text-ink focus:border-accent focus:outline-none" aria-label="Comparability group">
193 + {sorted.map((g) => (
194 + <option key={`${g.metric}:${g.config_key}`} value={`${g.metric}|${g.config_key}`}>
195 + {g.label} ({g.model_count})
196 + </option>
197 + ))}
198 + </select>
199 + <button type="submit" className="inline-flex h-8 w-full items-center justify-center bg-ink px-3 text-[12px] font-medium text-canvas hover:opacity-90">
200 + Show group
201 + </button>
202 + </form>
203 + );
204 +}
added apps/web/src/components/compare/compare-terminal.tsx +386 −0
@@ -0,0 +1,386 @@
1 +'use client';
2 +import { LayoutList, Table2 } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { useMemo, useState } from 'react';
5 +import { Evidence } from '@/components/evidence/evidence';
6 +import { ComparabilityBadge, OpennessChip, TrustBadge } from '@/components/models/badges';
7 +import { fmtScoreUnit, opennessLabel } from '@/components/models/shared';
8 +import { Chip, TierBadge } from '@/components/ui/badges';
9 +import { EntityLink } from '@/components/ui/entity';
10 +import { Hint } from '@/components/ui/hint';
11 +import { Note } from '@/components/ui/section';
12 +import { cn } from '@/lib/cn';
13 +import { DASH, fmtAgo, fmtDate, fmtTokens, fmtUsdPerM, fmtValue, hostOf, num } from '@/lib/format';
14 +import { routes } from '@/lib/site';
15 +import type { CompareDimension11, ComparePayload11, ProvenanceEntry } from '@/lib/types';
16 +import { CompareButton } from './compare-button';
17 +
18 +/*
19 + Compare 3.0 (client): row groups × sticky entity headers, "hide identical rows" (default on when ≥ 3 entities), use-case chips that
20 + reorder and emphasise relevant rows (never a winner), per-cell evidence, mobile horizontal scroll with sticky first column or stacked cards.
21 + Data is fetched server-side (`/compare`, `diff_only` is a URL parameter); this component only arranges it.
22 +*/
23 +
24 +type Group = { id: string; label: string; test: (d: CompareDimension11) => boolean };
25 +const GROUPS: Group[] = [
26 + { id: 'overview', label: 'Overview', test: (d) => ['release_date', 'status', 'family', 'version', 'openness', 'organization', 'kind', 'website', 'pricing_url', 'model_count', 'category', 'manufacturer', 'latest_version', 'language'].includes(d.key) },
27 + { id: 'architecture', label: 'Architecture', test: (d) => /parameter_count|architecture|is_moe|num_experts|tokenizer|memory_gb|memory_type|bandwidth|tdp|tflops|compute/.test(d.key) },
28 + { id: 'context', label: 'Context', test: (d) => /context_length|max_output_tokens|knowledge_cutoff|training_data_cutoff/.test(d.key) },
29 + { id: 'capabilities', label: 'Capabilities', test: (d) => /modalit|reasoning|tool_calling|structured_output|vision|audio|fine_tuning|features|languages|runtimes/.test(d.key) },
30 + { id: 'benchmarks', label: 'Benchmarks', test: (d) => d.key.startsWith('bench:') || d.source === 'results' },
31 + { id: 'pricing', label: 'Pricing', test: (d) => d.source === 'prices' || /per_mtok|provider_count|price/.test(d.key) },
32 + { id: 'license', label: 'License', test: (d) => /license|licence/.test(d.key) },
33 + { id: 'history', label: 'History', test: () => false },
34 +];
35 +const USE_CASES: { id: string; label: string; match: RegExp; groups: string[] }[] = [
36 + { id: 'coding', label: 'Coding', match: /swe-bench|aider|livebench-coding|livebench-agentic|scicode|humaneval|livecodebench|terminal-bench|tool_calling|structured_output/i, groups: ['benchmarks', 'capabilities'] },
37 + { id: 'reasoning', label: 'Reasoning', match: /gpqa|humanitys-last-exam|aime|math|arc-agi|livebench-reasoning|livebench-math|reasoning|intelligence-index/i, groups: ['benchmarks', 'capabilities'] },
38 + { id: 'agentic', label: 'Agentic', match: /tau|terminal-bench|agentic|tool_calling|max_output_tokens/i, groups: ['benchmarks', 'capabilities', 'context'] },
39 + { id: 'long_context', label: 'Long context', match: /context_length|max_output_tokens|knowledge_cutoff/i, groups: ['context'] },
40 + { id: 'vision', label: 'Vision', match: /mmmu|vision|modalit/i, groups: ['capabilities', 'benchmarks'] },
41 + { id: 'low_cost', label: 'Low-cost', match: /per_mtok|provider_count|active_parameter/i, groups: ['pricing', 'architecture'] },
42 + { id: 'local', label: 'Local', match: /parameter_count|openness|license|is_moe|memory/i, groups: ['architecture', 'license', 'overview'] },
43 + { id: 'embeddings', label: 'Embeddings', match: /mteb|embedding|dimension/i, groups: ['benchmarks', 'capabilities'] },
44 +];
45 +
46 +function groupOf(d: CompareDimension11): string {
47 + return GROUPS.find((g) => g.test(d))?.id ?? 'overview';
48 +}
49 +const LOWER_BETTER = /per_mtok|tdp|price|latency/;
50 +
51 +function fmtCell(v: unknown, dim: CompareDimension11): string {
52 + if (v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0)) return 'Unavailable';
53 + switch (dim.kind) {
54 + case 'date':
55 + return fmtDate(String(v));
56 + case 'bool':
57 + return v ? 'Yes' : 'No';
58 + case 'list':
59 + return (Array.isArray(v) ? v : [v]).map((x) => (typeof x === 'object' && x ? JSON.stringify(x) : String(x))).join(', ');
60 + case 'number':
61 + if (/per_mtok/.test(dim.key)) return fmtUsdPerM(v);
62 + if (dim.key.startsWith('bench:')) return fmtScoreUnit(num(v), dim.unit ?? null);
63 + if (/context_length|max_output_tokens/.test(dim.key)) return `${fmtTokens(v)} tokens`;
64 + return fmtValue(v, dim.key);
65 + default:
66 + if (dim.key === 'openness') return opennessLabel(v);
67 + return typeof v === 'string' ? v : fmtValue(v, dim.key);
68 + }
69 +}
70 +const sameValue = (vals: unknown[]) => {
71 + const norm = vals.map((v) => JSON.stringify(v ?? null));
72 + return norm.every((n) => n === norm[0]);
73 +};
74 +
75 +export function CompareTerminal({ res, diffOnly }: { res: ComparePayload11; diffOnly: boolean }) {
76 + const n = res.items.length;
77 + const [hideIdentical, setHideIdentical] = useState(n >= 3);
78 + const [useCase, setUseCase] = useState<string | null>(null);
79 + const [layout, setLayout] = useState<'table' | 'stack'>('table');
80 + const uc = USE_CASES.find((u) => u.id === useCase) ?? null;
81 + const isModel = res.entity_type === 'model';
82 +
83 + const rows = useMemo(() => {
84 + const byGroup = new Map<string, CompareDimension11[]>();
85 + for (const d of res.dimensions) {
86 + const g = groupOf(d);
87 + byGroup.set(g, [...(byGroup.get(g) ?? []), d]);
88 + }
89 + // History rows synthesised from the entity summaries (first seen · release · last change).
90 + const historyDims: CompareDimension11[] = [
91 + { key: '_first_seen', label: 'First seen in AI Atlas', kind: 'date', source: 'entity' },
92 + { key: '_last_change', label: 'Last change', kind: 'date', source: 'entity' },
93 + ];
94 + byGroup.set('history', historyDims);
95 + let order = GROUPS.map((g) => g.id);
96 + if (uc) order = [...uc.groups, ...order.filter((g) => !uc.groups.includes(g))];
97 + return order.map((id) => ({ id, label: GROUPS.find((g) => g.id === id)?.label ?? id, dims: (byGroup.get(id) ?? []).slice().sort((a, b) => (uc ? Number(uc.match.test(b.key) || uc.match.test(b.label)) - Number(uc.match.test(a.key) || uc.match.test(a.label)) : 0)) })).filter((g) => g.dims.length);
98 + }, [res.dimensions, uc]);
99 +
100 + const valueOf = (d: CompareDimension11, it: ComparePayload11['items'][number]) => {
101 + if (d.key === '_first_seen') return it.entity.first_seen_at || null;
102 + if (d.key === '_last_change') return it.entity.updated_at || null;
103 + return it.values[d.key];
104 + };
105 + const provenanceOf = (d: CompareDimension11, it: ComparePayload11['items'][number]): ProvenanceEntry | null => {
106 + const p = it.provenance?.[d.key];
107 + if (p) return p;
108 + if (d.key.startsWith('bench:') && d.benchmark) {
109 + const r = (it.results ?? []).find((x) => x.benchmark.slug === d.benchmark && (!d.metric || x.metric === d.metric));
110 + if (r) return { source_id: null, source_name: hostOf(r.source_url) ?? undefined, url: r.source_url, observed_at: r.observed_at, tier: r.tier, confidence: r.confidence, extractor: 'deterministic', unit: r.unit ?? undefined };
111 + }
112 + if (d.source === 'prices') {
113 + const best = [...(it.prices ?? [])].sort((a, b) => (num(a.output_per_mtok) ?? Infinity) - (num(b.output_per_mtok) ?? Infinity))[0];
114 + if (best) return { source_id: null, source_name: hostOf(best.source_url) ?? best.provider.name, url: best.source_url, observed_at: best.observed_at, tier: best.tier, confidence: 'high', extractor: 'deterministic', unit: 'USD / 1M tokens' };
115 + }
116 + return null;
117 + };
118 + const propertyOf = (d: CompareDimension11) => (d.key.startsWith('bench:') && d.benchmark ? `benchmark.${d.benchmark}.${d.metric ?? ''}` : d.key.replace(/^_/, ''));
119 +
120 + let shownRows = 0;
121 + let hiddenRows = 0;
122 + const body = rows.map((g) => {
123 + const dims = g.dims.filter((d) => {
124 + const vals = res.items.map((it) => valueOf(d, it));
125 + const identical = sameValue(vals);
126 + if (hideIdentical && identical && g.id !== 'history') {
127 + hiddenRows++;
128 + return false;
129 + }
130 + return true;
131 + });
132 + shownRows += dims.length;
133 + return { ...g, dims };
134 + }).filter((g) => g.dims.length);
135 +
136 + const cellFor = (d: CompareDimension11, it: ComparePayload11['items'][number], best: number | null, emph: boolean) => {
137 + const v = valueOf(d, it);
138 + const text = fmtCell(v, d);
139 + const p = provenanceOf(d, it);
140 + const isBest = best !== null && num(v) === best;
141 + const canOpen = v !== null && v !== undefined && v !== '' && d.source !== 'entity';
142 + return (
143 + <div className={cn('min-w-0', d.kind === 'number' && 'tnum')}>
144 + {canOpen ? (
145 + <Evidence slug={it.entity.slug} property={propertyOf(d)} value={v} display={text} unit={d.unit} fallback={p} entity={{ name: it.entity.name, entity_type: it.entity.entity_type }} className={cn('block text-left whitespace-normal', isBest && 'font-semibold text-ink', emph && 'text-accent')}>
146 + {d.key === 'openness' && typeof v === 'string' ? <OpennessChip openness={v} /> : d.kind === 'list' ? <span className="flex flex-wrap gap-1">{(Array.isArray(v) ? v : [v]).map((x, i) => <Chip key={i}>{String(x)}</Chip>)}</span> : text}
147 + </Evidence>
148 + ) : (
149 + <span className={cn(text === 'Unavailable' && 'text-ink-3')}>{text}</span>
150 + )}
151 + {p && (
152 + <span className="mt-0.5 hidden items-center gap-1 text-[10.5px] leading-4 text-ink-3 md:flex">
153 + <TierBadge tier={p.tier} /> <span className="max-w-[9rem] truncate">{p.source_name ?? hostOf(p.url) ?? 'source'}</span> · {fmtAgo(p.observed_at)}
154 + </span>
155 + )}
156 + </div>
157 + );
158 + };
159 + const bestOf = (d: CompareDimension11) => {
160 + if (d.kind !== 'number' || d.source === 'entity') return null;
161 + const nums = res.items.map((it) => num(valueOf(d, it))).filter((x): x is number => x !== null);
162 + if (nums.length < 2) return null;
163 + const lower = d.higher_is_better === false || (d.higher_is_better === undefined && LOWER_BETTER.test(d.key));
164 + return lower ? Math.min(...nums) : Math.max(...nums);
165 + };
166 + const rowLabel = (d: CompareDimension11) => {
167 + const comp = res.comparability?.[d.key];
168 + return (
169 + <>
170 + {d.key.startsWith('bench:') && d.benchmark ? (
171 + <Link href={routes.benchmark(d.benchmark) + (d.metric && d.config_key ? `?metric=${encodeURIComponent(d.metric)}&config_key=${encodeURIComponent(d.config_key)}` : '')} className="text-ink hover:text-accent hover:underline">
172 + {d.label.split(' · ')[0]}
173 + </Link>
174 + ) : (
175 + <span className="text-ink">{d.label}</span>
176 + )}
177 + {(d.unit || d.key.startsWith('bench:')) && <span className="block text-[10.5px] text-ink-3">{d.key.startsWith('bench:') ? d.label.split(' · ').slice(1).join(' · ') : d.unit}</span>}
178 + {comp && (
179 + <span className="mt-0.5 flex flex-wrap items-center gap-1">
180 + <ComparabilityBadge level={comp.level} reasons={comp.reasons} short />
181 + {comp.reasons?.length ? <Hint text={comp.reasons.join('; ')} /> : null}
182 + {[...new Set(Object.values(comp.trust ?? {}).map((t) => t.level))].map((lvl) => (
183 + <TrustBadge key={lvl} level={lvl} />
184 + ))}
185 + </span>
186 + )}
187 + </>
188 + );
189 + };
190 + const emphasised = (d: CompareDimension11) => !!uc && (uc.match.test(d.key) || uc.match.test(d.label));
191 + const ROW_TH = { whiteSpace: 'normal', textTransform: 'none', letterSpacing: 0, fontSize: '0.8125rem', fontWeight: 400 } as const;
192 + const wide = n <= 3 ? 'md:overflow-visible' : 'xl:overflow-visible';
193 + const stickyTop = n <= 3 ? 'md:sticky md:top-[var(--header-h)] md:z-20' : 'xl:sticky xl:top-[var(--header-h)] xl:z-20';
194 +
195 + return (
196 + <div className="space-y-4" data-compare-terminal>
197 + {/* controls */}
198 + <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
199 + <label className="inline-flex min-h-9 items-center gap-2 text-ink-2">
200 + <input type="checkbox" checked={hideIdentical} onChange={(e) => setHideIdentical(e.target.checked)} className="size-4 accent-[var(--accent)]" data-hide-identical /> Hide identical rows
201 + </label>
202 + <Link href={`?ids=${res.items.map((i) => encodeURIComponent(i.entity.slug)).join(',')}${diffOnly ? '' : '&diff_only=1'}${res.entity_type !== 'model' ? `&mode=${res.entity_type}s` : ''}`} className={cn('inline-flex h-8 items-center border px-2.5 text-xs', diffOnly ? 'border-ink bg-ink text-canvas' : 'border-rule text-ink-2 hover:text-ink')} aria-pressed={diffOnly} data-diff-only>
203 + Differences only {diffOnly ? '· on' : ''}
204 + </Link>
205 + <span className="ml-auto inline-flex items-center gap-1 lg:hidden">
206 + <button type="button" onClick={() => setLayout('table')} aria-pressed={layout === 'table'} className={cn('inline-flex size-9 items-center justify-center border', layout === 'table' ? 'border-ink text-ink' : 'border-rule text-ink-3')} aria-label="Table layout">
207 + <Table2 className="size-4" aria-hidden />
208 + </button>
209 + <button type="button" onClick={() => setLayout('stack')} aria-pressed={layout === 'stack'} className={cn('inline-flex size-9 items-center justify-center border', layout === 'stack' ? 'border-ink text-ink' : 'border-rule text-ink-3')} aria-label="Stacked cards layout">
210 + <LayoutList className="size-4" aria-hidden />
211 + </button>
212 + </span>
213 + </div>
214 + {isModel && (
215 + <div className="flex flex-wrap items-center gap-1.5" role="group" aria-label="Use case emphasis" data-use-cases>
216 + <span className="eyebrow mr-1">Emphasise</span>
217 + {USE_CASES.map((u) => (
218 + <button key={u.id} type="button" onClick={() => setUseCase(useCase === u.id ? null : u.id)} aria-pressed={useCase === u.id} className={cn('inline-flex h-8 items-center border px-2.5 text-xs whitespace-nowrap', useCase === u.id ? 'border-accent bg-accent-soft text-accent' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}>
219 + {u.label}
220 + </button>
221 + ))}
222 + {uc && <span className="text-[11px] text-ink-3">Relevant rows first and highlighted — no score is combined, no winner is declared.</span>}
223 + </div>
224 + )}
225 +
226 + {/* ------------------------------------------------------------------------------------------ table layout */}
227 + <div className={cn(layout === 'stack' && 'hidden lg:block')}>
228 + <div className={cn('table-scroll scrollbar-thin relative', wide)}>
229 + <table className="data-table compare-matrix compact" data-compare-table>
230 + <caption className="sr-only">Comparison of {n} entities</caption>
231 + <thead>
232 + <tr>
233 + <th scope="col" className={cn('sticky left-0 z-30 w-[9rem] min-w-[9rem] bg-canvas md:w-[13rem] md:min-w-[13rem]', stickyTop)} style={{ whiteSpace: 'normal' }}>
234 + Dimension
235 + </th>
236 + {res.items.map((it) => (
237 + <th key={it.entity.id} scope="col" className={cn('min-w-[10rem] bg-canvas align-top', stickyTop)} style={{ whiteSpace: 'normal' }}>
238 + <span className="flex flex-col items-start gap-1 normal-case tracking-normal">
239 + <EntityLink e={it.entity} className="text-sm font-semibold" />
240 + {it.entity.organization && <span className="text-[11px] font-normal text-ink-3">{it.entity.organization.name}</span>}
241 + <span className="flex items-center gap-1">
242 + <CompareButton e={it.entity} size="sm" label="Add" />
243 + {isModel && n === 2 && <Link href={`/models/${encodeURIComponent(res.items[0]!.entity.slug)}/diff/${encodeURIComponent(res.items[1]!.entity.slug)}`} className="inline-flex h-7 items-center border border-rule px-1.5 text-xs font-normal text-ink-2 hover:text-ink">Diff</Link>}
244 + </span>
245 + </span>
246 + </th>
247 + ))}
248 + </tr>
249 + </thead>
250 + <tbody>
251 + {body.map((g) => (
252 + <GroupRows key={g.id} label={g.label} cols={n + 1}>
253 + {g.dims.map((d) => {
254 + const best = bestOf(d);
255 + const emph = emphasised(d);
256 + return (
257 + <tr key={d.key} className={cn(emph && 'bg-accent-soft/25')} data-dim={d.key}>
258 + <th scope="row" className="sticky left-0 z-10 bg-canvas text-left" style={ROW_TH}>
259 + {rowLabel(d)}
260 + </th>
261 + {res.items.map((it) => (
262 + <td key={it.entity.id} className="align-top" style={{ whiteSpace: 'normal' }}>
263 + {cellFor(d, it, best, emph)}
264 + </td>
265 + ))}
266 + </tr>
267 + );
268 + })}
269 + </GroupRows>
270 + ))}
271 + {isModel && <PricingRows res={res} n={n} />}
272 + {isModel && (
273 + <GroupRows label="Hardware fit" cols={n + 1}>
274 + <tr>
275 + <th scope="row" className="sticky left-0 z-10 bg-canvas text-left" style={ROW_TH}>
276 + Estimated memory
277 + <span className="block text-[10.5px] text-warning">estimated</span>
278 + </th>
279 + {res.items.map((it) => (
280 + <td key={it.entity.id} className="align-top text-xs text-ink-3" style={{ whiteSpace: 'normal' }}>
281 + {num(it.entity.attributes?.parameter_count) === null ? 'No parameter count — not estimable' : <Link href={`${routes.entity(it.entity)}#hardware-fit`} className="link">Per-device estimates →</Link>}
282 + </td>
283 + ))}
284 + </tr>
285 + </GroupRows>
286 + )}
287 + </tbody>
288 + </table>
289 + </div>
290 + </div>
291 +
292 + {/* ------------------------------------------------------------------------------------------ stacked layout (mobile) */}
293 + {layout === 'stack' && (
294 + <div className="space-y-6 lg:hidden" data-compare-stack>
295 + {res.items.map((it) => (
296 + <section key={it.entity.id} className="border-t border-rule pt-3">
297 + <h3 className="flex flex-wrap items-center gap-2 text-[15px] font-semibold">
298 + <EntityLink e={it.entity} /> {it.entity.organization && <span className="text-xs font-normal text-ink-3">{it.entity.organization.name}</span>}
299 + </h3>
300 + {body.map((g) => (
301 + <div key={g.id} className="mt-3">
302 + <p className="eyebrow mb-1">{g.label}</p>
303 + <dl className="kv [&>div]:grid-cols-[7.5rem_minmax(0,1fr)] [&>div]:py-1">
304 + {g.dims.map((d) => (
305 + <div key={d.key} className={cn(emphasised(d) && 'bg-accent-soft/25')}>
306 + <dt className="text-[12px]">{d.label.split(' · ')[0]}</dt>
307 + <dd className="text-[13px]">{cellFor(d, it, bestOf(d), emphasised(d))}</dd>
308 + </div>
309 + ))}
310 + </dl>
311 + </div>
312 + ))}
313 + </section>
314 + ))}
315 + </div>
316 + )}
317 +
318 + <Note>
319 + {shownRows} rows shown{hiddenRows ? `, ${hiddenRows} identical hidden` : ''}. Bold = best number in a row (highest; lowest for prices) — a reading aid, not a verdict. Benchmarks appear only when every entity has a current result in the same comparability group. {res.note ?? ''} Click any value for its evidence.
320 + </Note>
321 + </div>
322 + );
323 +}
324 +
325 +function GroupRows({ label, cols, children }: { label: string; cols: number; children: React.ReactNode }) {
326 + return (
327 + <>
328 + <tr className="group-row">
329 + <th colSpan={cols} scope="colgroup" className="sticky left-0 !border-b-0 bg-canvas pt-4 text-left text-[11px] tracking-[0.1em] text-ink-3">
330 + {label}
331 + </th>
332 + </tr>
333 + {children}
334 + </>
335 + );
336 +}
337 +
338 +/** Cheapest current deployment per provider × entity (from the compare payload's prices). */
339 +function PricingRows({ res, n }: { res: ComparePayload11; n: number }) {
340 + const providers = new Map<string, { slug: string; name: string; entity_type: string }>();
341 + const per = res.items.map((it) => {
342 + const m = new Map<string, { input: number | null; output: number | null; observed: string; url: string | null; tier: number }>();
343 + for (const p of it.prices ?? []) {
344 + providers.set(p.provider.slug, p.provider);
345 + const cur = m.get(p.provider.slug) ?? { input: null, output: null, observed: p.observed_at, url: p.source_url, tier: p.tier };
346 + const i = num(p.input_per_mtok);
347 + const o = num(p.output_per_mtok);
348 + if (i !== null && (cur.input === null || i < cur.input)) cur.input = i;
349 + if (o !== null && (cur.output === null || o < cur.output)) cur.output = o;
350 + m.set(p.provider.slug, cur);
351 + }
352 + return m;
353 + });
354 + if (!providers.size) return null;
355 + const rows = [...providers.values()].sort((a, b) => a.name.localeCompare(b.name));
356 + return (
357 + <GroupRows label="Pricing · cheapest deployment per provider (USD / 1M in / out)" cols={n + 1}>
358 + {rows.map((prov) => (
359 + <tr key={prov.slug}>
360 + <th scope="row" className="sticky left-0 z-10 bg-canvas text-left" style={{ whiteSpace: 'normal', textTransform: 'none', letterSpacing: 0, fontSize: '0.8125rem', fontWeight: 400 }}>
361 + <EntityLink e={prov} />
362 + </th>
363 + {per.map((m, i) => {
364 + const b = m.get(prov.slug);
365 + return (
366 + <td key={res.items[i]!.entity.id} className="tnum align-top" style={{ whiteSpace: 'normal' }}>
367 + {b ? (
368 + <>
369 + <span className="text-accent-2">
370 + {fmtUsdPerM(b.input)} <span className="text-ink-3">/</span> {fmtUsdPerM(b.output)}
371 + </span>
372 + <span className="mt-0.5 hidden items-center gap-1 text-[10.5px] text-ink-3 md:flex">
373 + <TierBadge tier={b.tier} /> {b.url ? <a href={b.url} target="_blank" rel="noopener noreferrer" className="max-w-[9rem] truncate hover:text-accent">{hostOf(b.url)}</a> : null} · {fmtAgo(b.observed)}
374 + </span>
375 + </>
376 + ) : (
377 + <span className="text-ink-3">{DASH}</span>
378 + )}
379 + </td>
380 + );
381 + })}
382 + </tr>
383 + ))}
384 + </GroupRows>
385 + );
386 +}
added apps/web/src/components/entity/artifact-page.tsx +224 −0
@@ -0,0 +1,224 @@
1 +import { ExternalLink } from 'lucide-react';
2 +import Link from 'next/link';
3 +import { Evidence } from '@/components/evidence/evidence';
4 +import { ViewBeacon } from '@/components/layout/view-beacon';
5 +import { ArtifactKindChip, OpennessChip } from '@/components/models/badges';
6 +import { EntityBadge, StatusBadge } from '@/components/ui/badges';
7 +import { EntityLink, QualityMark } from '@/components/ui/entity';
8 +import { KeyValue, type KVRow } from '@/components/ui/key-value';
9 +import { Container, Note } from '@/components/ui/section';
10 +import { fmtAgo, fmtBytes, fmtDate, fmtGb, fmtInt, num } from '@/lib/format';
11 +import { routes, SITE_NAME, SITE_URL } from '@/lib/site';
12 +import type { ModelDetail } from '@/lib/types';
13 +import { ProvenanceSummary, RelationsBlock, SourcesTable, TimelineList } from './blocks';
14 +import { HardwareFitBlock } from './model-blocks';
15 +
16 +/*
17 + Artifact page: a checkpoint / quantisation / conversion / packaging of a canonical model. Header points prominently to the
18 + canonical model; the artifact's own facts (file size, quant format, dtype, downloads, publisher) are shown with evidence.
19 +*/
20 +
21 +const FACT_KEYS = ['quant_format', 'quantization', 'weights_dtype', 'file_size_gb', 'metric.downloads', 'metric.likes', 'hf_repo', 'base_model', 'quantized_by', 'pipeline_tag', 'library_name', 'gated', 'access', 'license', 'release_date', 'last_modified', 'model_card_url', 'tags'];
22 +
23 +export function describeArtifact(d: ModelDetail): string {
24 + const a = d.attributes ?? {};
25 + const bits: string[] = [];
26 + if (typeof a.quant_format === 'string') bits.push(String(a.quant_format).toUpperCase());
27 + if (num(a.file_size_gb) !== null) bits.push(fmtGb(a.file_size_gb, 1));
28 + if (num(a['metric.downloads']) !== null) bits.push(`${fmtInt(a['metric.downloads'])} downloads`);
29 + return `${d.name} is a ${d.artifact_kind ?? 'packaging'} of ${d.canonical?.name ?? 'a canonical model'}${d.organization ? ` published by ${d.organization.name}` : ''}${bits.length ? ` — ${bits.join(', ')}` : ''}. Not an independent model: parameters, benchmarks and prices live on the canonical model page. ${SITE_NAME}.`.slice(0, 300);
30 +}
31 +
32 +export function ArtifactPage({ d, canonical }: { d: ModelDetail; canonical: string }) {
33 + const a = d.attributes ?? {};
34 + const entity = { name: d.name, entity_type: d.entity_type };
35 + const format = typeof a.quant_format === 'string' ? String(a.quant_format).toUpperCase() : Array.isArray(a.weights_dtype) && a.weights_dtype.length ? (a.weights_dtype as string[]).join('/') : null;
36 + const rows: KVRow[] = FACT_KEYS.filter((k) => a[k] !== undefined && a[k] !== null && a[k] !== '' && !(Array.isArray(a[k]) && (a[k] as unknown[]).length === 0)).map((k) => ({ key: k, raw: a[k] }));
37 + const hf = typeof a.hf_repo === 'string' ? `https://huggingface.co/${a.hf_repo}` : typeof a.model_card_url === 'string' ? a.model_card_url : null;
38 + const ld = {
39 + '@context': 'https://schema.org',
40 + '@type': 'SoftwareSourceCode',
41 + name: d.name,
42 + url: `${SITE_URL}${canonical}`,
43 + description: d.description ?? describeArtifact(d),
44 + isBasedOn: d.canonical ? `${SITE_URL}${routes.entity(d.canonical)}` : undefined,
45 + publisher: d.organization ? { '@type': 'Organization', name: d.organization.name } : undefined,
46 + fileFormat: typeof a.quant_format === 'string' ? a.quant_format : undefined,
47 + contentSize: num(a.file_size_gb) !== null ? fmtBytes((num(a.file_size_gb) as number) * 1e9) : undefined,
48 + codeRepository: hf ?? undefined,
49 + };
50 + const quants = Array.isArray(a.quantization) ? (a.quantization as string[]) : [];
51 + return (
52 + <Container wide>
53 + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} />
54 + <ViewBeacon path={canonical} />
55 + <nav aria-label="Breadcrumb" className="pt-5 text-xs text-ink-3">
56 + <ol className="flex flex-wrap items-center gap-1.5">
57 + <li>
58 + <Link href="/" className="hover:text-ink">
59 + AI Atlas
60 + </Link>
61 + </li>
62 + <li aria-hidden>/</li>
63 + <li>
64 + <Link href={routes.models()} className="hover:text-ink">
65 + Models
66 + </Link>
67 + </li>
68 + {d.canonical && (
69 + <>
70 + <li aria-hidden>/</li>
71 + <li>
72 + <Link href={routes.entity(d.canonical)} className="hover:text-ink">
73 + {d.canonical.name}
74 + </Link>
75 + </li>
76 + </>
77 + )}
78 + <li aria-hidden>/</li>
79 + <li className="text-ink-2">{d.name}</li>
80 + </ol>
81 + </nav>
82 +
83 + <header className="pb-6 pt-4 md:pt-5" data-artifact-header>
84 + <div className="flex flex-wrap items-center gap-2">
85 + <EntityBadge type="artifact" />
86 + <ArtifactKindChip kind={d.artifact_kind} />
87 + {format && <span className="mono text-xs text-ink-2">{format}</span>}
88 + {typeof a.openness === 'string' && <OpennessChip openness={a.openness} />}
89 + <StatusBadge status={d.status !== 'active' ? d.status : null} />
90 + {d.canonical && (
91 + <span className="text-xs text-ink-3">
92 + of <EntityLink e={d.canonical} className="font-medium text-ink-2" />
93 + </span>
94 + )}
95 + </div>
96 + <div className="mt-3 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
97 + <div className="min-w-0">
98 + <h1 className="display text-[28px] md:text-[40px]">{d.name}</h1>
99 + <p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[15px] text-ink-2">
100 + {d.organization && (
101 + <span>
102 + published by{' '}
103 + <Link href={routes.entity({ entity_type: 'company', slug: d.organization.slug })} className="font-medium text-ink hover:text-accent">
104 + {d.organization.name}
105 + </Link>
106 + </span>
107 + )}
108 + {hf && (
109 + <a href={hf} target="_blank" rel="noopener noreferrer" className="inline-flex max-w-full min-w-0 items-center gap-1 text-ink-3 hover:text-accent">
110 + <span className="truncate">{hf.replace(/^https?:\/\/(www\.)?/, '').slice(0, 56)}</span> <ExternalLink className="size-3.5 shrink-0" aria-hidden />
111 + </a>
112 + )}
113 + </p>
114 + {d.canonical ? (
115 + <p className="mt-4 border-l-2 border-accent bg-accent-soft/40 px-3 py-2 text-sm text-ink-2" data-artifact-note>
116 + This is a {d.artifact_kind ?? 'packaging'} of{' '}
117 + <Link href={routes.entity(d.canonical)} className="font-semibold text-ink hover:text-accent">
118 + {d.canonical.name}
119 + </Link>
120 + , not an independent model. Parameters, benchmarks, prices and lineage are recorded on the canonical model.{' '}
121 + <Link href={routes.entity(d.canonical)} className="link">
122 + Open {d.canonical.name} →
123 + </Link>
124 + </p>
125 + ) : (
126 + <Note className="mt-4">The canonical model of this artifact is not resolved yet — it is listed as an artifact because its name, repo or metadata identify it as a re-packaging.</Note>
127 + )}
128 + {d.description && <p className="mt-3 max-w-3xl text-[15px] leading-relaxed text-ink-2">{d.description}</p>}
129 + </div>
130 + <div className="shrink-0 text-xs text-ink-3 lg:text-right">
131 + <QualityMark q={d.quality?.score} label />
132 + <p className="mt-1" title={d.updated_at}>
133 + Updated {fmtAgo(d.updated_at)} · first seen {fmtDate(d.first_seen_at)}
134 + </p>
135 + <p className="mono mt-0.5 text-[11px]">{d.id}</p>
136 + </div>
137 + </div>
138 + <dl className="mt-5 grid grid-cols-2 gap-x-6 gap-y-3 border-y border-rule py-3 sm:grid-cols-4" data-artifact-strip>
139 + {[
140 + { key: 'file_size_gb', label: 'File size', value: num(a.file_size_gb) === null ? null : fmtGb(a.file_size_gb, 1) },
141 + { key: 'quant_format', label: 'Format', value: format },
142 + { key: 'metric.downloads', label: 'Downloads', value: num(a['metric.downloads']) === null ? null : fmtInt(a['metric.downloads']) },
143 + { key: 'release_date', label: 'Published', value: typeof a.release_date === 'string' ? fmtDate(a.release_date) : typeof a.last_modified === 'string' ? fmtDate(a.last_modified) : null },
144 + ].map((c) => (
145 + <div key={c.key} className="min-w-0">
146 + <dt className="eyebrow">{c.label}</dt>
147 + <dd className="tnum mt-0.5 truncate text-[15px] font-medium text-ink">
148 + {c.value === null ? (
149 + <span className="text-ink-3">—</span>
150 + ) : (
151 + <Evidence slug={d.slug} property={c.key} value={a[c.key]} display={c.value} fallback={d.provenance?.[c.key]} entity={entity}>
152 + {c.value}
153 + </Evidence>
154 + )}
155 + </dd>
156 + </div>
157 + ))}
158 + </dl>
159 + </header>
160 +
161 + <div className="grid gap-10 pb-16 lg:grid-cols-[minmax(0,1fr)_22rem]">
162 + <div className="min-w-0 space-y-10">
163 + <section>
164 + <p className="eyebrow mb-2">Artifact facts</p>
165 + <KeyValue rows={rows} provenance={d.provenance} slug={d.slug} entity={entity} />
166 + {quants.length > 0 && <Note className="mt-2">{fmtInt(quants.length)} quantisation levels in this repository ({quants.slice(0, 8).join(', ')}{quants.length > 8 ? '…' : ''}).</Note>}
167 + </section>
168 + {d.hardware_fit && d.hardware_fit.length > 0 && (
169 + <section>
170 + <p className="eyebrow mb-2">Hardware fit (this packaging)</p>
171 + <HardwareFitBlock rows={d.hardware_fit} assumptions={d.hardware_fit_assumptions} modelSlug={d.canonical?.slug ?? d.slug} />
172 + </section>
173 + )}
174 + {d.timeline?.length > 0 && (
175 + <section>
176 + <p className="eyebrow mb-2">Timeline</p>
177 + <TimelineList events={d.timeline} slug={d.slug} />
178 + </section>
179 + )}
180 + <section>
181 + <p className="eyebrow mb-2">Provenance</p>
182 + <ProvenanceSummary provenance={d.provenance} quality={d.quality} />
183 + <div className="mt-4">
184 + <SourcesTable sources={d.sources ?? []} />
185 + </div>
186 + </section>
187 + </div>
188 + <aside className="min-w-0 space-y-8">
189 + <section>
190 + <p className="eyebrow mb-2">Relations</p>
191 + <RelationsBlock relations={d.relations ?? []} />
192 + </section>
193 + {d.canonical && (
194 + <section>
195 + <p className="eyebrow mb-2">Canonical model</p>
196 + <p className="text-sm">
197 + <EntityLink e={d.canonical} className="font-medium" />
198 + {d.canonical.organization && <span className="block text-xs text-ink-3">{d.canonical.organization.name}</span>}
199 + </p>
200 + <p className="mt-2 text-xs text-ink-3">
201 + Compare packagings, prices and benchmarks there. <Link href={`${routes.graph(d.canonical.slug)}?mode=lineage`} className="link">Lineage graph →</Link>
202 + </p>
203 + </section>
204 + )}
205 + {(d.aliases?.length > 0 || d.identifiers?.length > 0) && (
206 + <section>
207 + <p className="eyebrow mb-2">Identifiers</p>
208 + <dl className="kv [&>div]:grid-cols-[8rem_minmax(0,1fr)]">
209 + {d.identifiers.map((i) => (
210 + <div key={`${i.scheme}:${i.value}`}>
211 + <dt className="mono text-[11px]">{i.scheme}</dt>
212 + <dd className="mono break-all text-[12px] text-ink">{i.value}</dd>
213 + </div>
214 + ))}
215 + </dl>
216 + {d.aliases?.length > 0 && <p className="mt-2 text-xs text-ink-3">Also known as: {d.aliases.join(', ')}</p>}
217 + <p className="mono mt-2 break-all text-[11px] text-ink-3">slug {d.slug}</p>
218 + </section>
219 + )}
220 + </aside>
221 + </div>
222 + </Container>
223 + );
224 +}
modified apps/web/src/components/entity/blocks.tsx +11 −10
@@ -1,4 +1,5 @@
1 1 import { ExternalLink } from 'lucide-react';
2 +import { ScrollX } from '@/components/models/scroll-x';
2 3 import Link from 'next/link';
3 4 import { ChangeRow } from '@/components/changes/change-row';
4 5 import { Legend, LineChart, type Series, Sparkline, stepPoints } from '@/components/charts/charts';
@@ -129,7 +130,7 @@ export function ResultsTable({ results, perspective }: { results: BenchmarkResul
129 130 if (!results.length) return <EmptyState title="No benchmark results recorded">Results appear when a tier 1–3 source publishes them; we never copy scores without a source.</EmptyState>;
130 131 return (
131 132 <>
132 − <DataTable caption="Benchmark results">
133 + <ScrollX><DataTable caption="Benchmark results">
133 134 <thead>
134 135 <tr>
135 136 {perspective === 'benchmark' && <Th className="w-10">#</Th>}
@@ -163,7 +164,7 @@ export function ResultsTable({ results, perspective }: { results: BenchmarkResul
163 164 );
164 165 })}
165 166 </tbody>
166 − </DataTable>
167 + </DataTable></ScrollX>
167 168 <Note className="mt-3">
168 169 Scores are reported as published, with their evaluation configuration (harness, prompting, judge). Results with different configs are not directly comparable — see <Link href="/methodology#benchmarks" className="link">methodology</Link>.
169 170 </Note>
@@ -178,7 +179,7 @@ export function PricesTable({ prices, perspective }: { prices: Price[]; perspect
178 179 const sorted = [...prices].sort((a, b) => (num(a.input_per_mtok) ?? Infinity) - (num(b.input_per_mtok) ?? Infinity));
179 180 return (
180 181 <>
181 − <DataTable caption="Current prices per 1M tokens">
182 + <ScrollX><DataTable caption="Current prices per 1M tokens">
182 183 <thead>
183 184 <tr>
184 185 <Th>{perspective === 'model' ? 'Provider' : 'Model'}</Th>
@@ -212,7 +213,7 @@ export function PricesTable({ prices, perspective }: { prices: Price[]; perspect
212 213 );
213 214 })}
214 215 </tbody>
215 − </DataTable>
216 + </DataTable></ScrollX>
216 217 <Note className="mt-3">USD per 1M tokens as published by each provider ({sorted[0]?.currency ?? 'USD'}). Rows are append-only: every change is kept in the history below.</Note>
217 218 </>
218 219 );
@@ -268,7 +269,7 @@ export function HardwareFitTable({ rows }: { rows: HardwareFitRow[] }) {
268 269 <Estimated />
269 270 <Note>Memory need = bytes per parameter (4-bit ≈ 0.5 × 1.15 overhead, 8-bit 1.0, fp16 2.0) + a KV-cache allowance. Not a measurement.</Note>
270 271 </div>
271 − <DataTable caption="Estimated hardware fit">
272 + <ScrollX><DataTable caption="Estimated hardware fit">
272 273 <thead>
273 274 <tr>
274 275 <Th>Hardware</Th>
@@ -289,7 +290,7 @@ export function HardwareFitTable({ rows }: { rows: HardwareFitRow[] }) {
289 290 </tr>
290 291 ))}
291 292 </tbody>
292 − </DataTable>
293 + </DataTable></ScrollX>
293 294 </>
294 295 );
295 296 }
@@ -366,7 +367,7 @@ export function ModelsTable({ items, total, moreHref }: { items: EntitySummary[]
366 367 if (!items.length) return <EmptyState title="No models recorded" />;
367 368 return (
368 369 <>
369 − <DataTable caption="Models">
370 + <ScrollX><DataTable caption="Models">
370 371 <thead>
371 372 <tr>
372 373 <Th>Model</Th>
@@ -394,7 +395,7 @@ export function ModelsTable({ items, total, moreHref }: { items: EntitySummary[]
394 395 );
395 396 })}
396 397 </tbody>
397 − </DataTable>
398 + </DataTable></ScrollX>
398 399 {total !== undefined && total > items.length && moreHref && (
399 400 <p className="mt-3 text-sm">
400 401 <Link href={moreHref} className="link">All {fmtInt(total)} models →</Link>
@@ -429,7 +430,7 @@ export function SourcesTable({ sources }: { sources: SourceRef[] }) {
429 430 const sorted = [...sources].sort((a, b) => (a.tier ?? 9) - (b.tier ?? 9) || (b.last_observed_at ?? '').localeCompare(a.last_observed_at ?? ''));
430 431 return (
431 432 <>
432 − <DataTable caption="Source documents">
433 + <ScrollX><DataTable caption="Source documents">
433 434 <thead>
434 435 <tr>
435 436 <Th>Source</Th>
@@ -456,7 +457,7 @@ export function SourcesTable({ sources }: { sources: SourceRef[] }) {
456 457 </tr>
457 458 ))}
458 459 </tbody>
459 − </DataTable>
460 + </DataTable></ScrollX>
460 461 <Note className="mt-3">
461 462 Tier 1 = official/primary, 2 = quality secondary, 3 = community, 4 = unverified. Every snapshot is archived; see <Link href="/sources" className="link">all sources</Link> and the <Link href="/methodology" className="link">methodology</Link>.
462 463 </Note>
modified apps/web/src/components/entity/history.tsx +3 −2
@@ -1,4 +1,5 @@
1 1 import Link from 'next/link';
2 +import { ScrollX } from '@/components/models/scroll-x';
2 3 import { DataTable, Td, Th } from '@/components/ui/data-table';
3 4 import { KeyValue, type KVRow } from '@/components/ui/key-value';
4 5 import { SourceCell } from '@/components/ui/provenance';
@@ -109,7 +110,7 @@ export function ClaimHistory({ d, claims, property }: { d: EntityDetail; claims:
109 110 <span className="tnum text-xs text-ink-3">{fmtInt(rows.length)}</span>
110 111 {rows.some((c) => c.status === 'conflicting') && <span className="text-xs font-medium text-danger">conflicting claims</span>}
111 112 </h3>
112 − <DataTable compact caption={`Claim history for ${propertyLabel(k)}`}>
113 + <ScrollX><DataTable compact caption={`Claim history for ${propertyLabel(k)}`}>
113 114 <thead>
114 115 <tr>
115 116 <Th>Value</Th>
@@ -139,7 +140,7 @@ export function ClaimHistory({ d, claims, property }: { d: EntityDetail; claims:
139 140 </tr>
140 141 ))}
141 142 </tbody>
142 − </DataTable>
143 + </DataTable></ScrollX>
143 144 </section>
144 145 );
145 146 })}
added apps/web/src/components/entity/model-blocks.tsx +479 −0
@@ -0,0 +1,479 @@
1 +import { ExternalLink } from 'lucide-react';
2 +import { ScrollX } from '@/components/models/scroll-x';
3 +import Link from 'next/link';
4 +import { Evidence } from '@/components/evidence/evidence';
5 +import { ComparabilityBadge, ConfigChipEl, TrustBadge } from '@/components/models/badges';
6 +import { configChipsOf, fmtScoreUnit, scoreDelta } from '@/components/models/shared';
7 +import { Estimated } from '@/components/ui/badges';
8 +import { DataTable, Td, Th } from '@/components/ui/data-table';
9 +import { EntityLink } from '@/components/ui/entity';
10 +import { Hint } from '@/components/ui/hint';
11 +import { SourceCell } from '@/components/ui/provenance';
12 +import { Note } from '@/components/ui/section';
13 +import { EmptyState } from '@/components/ui/unavailable';
14 +import { cn } from '@/lib/cn';
15 +import { DASH, fmtAgo, fmtDate, fmtGb, fmtInt, fmtTokens, fmtUsdPerM, fmtValue, hostOf, num } from '@/lib/format';
16 +import { propertyLabel, routes } from '@/lib/site';
17 +import type { BenchmarkListItem, Deployment, HardwareFitRow, ModelDetail, ProvenanceEntry, VersionHistoryItem } from '@/lib/types';
18 +
19 +/* ------------------------------------------------------------------------------------------------------ identity panel */
20 +
21 +/** Canonical model · official checkpoints · third-party artifacts (by kind) · provider deployments · API aliases · folded variants. */
22 +export function IdentityPanel({ d }: { d: ModelDetail }) {
23 + const id = d.identity;
24 + const kinds = d.artifacts?.items ?? [];
25 + if (!id) return <p className="text-sm text-ink-3">Identity block not returned by the API for this entity.</p>;
26 + // The definition hint sits at the far right of the value cell (right-aligned bubble): in the narrow label column the 18rem bubble would widen the page.
27 + const Row = ({ k, hint, children }: { k: string; hint?: string; children: React.ReactNode }) => (
28 + <div>
29 + <dt>{k}</dt>
30 + <dd className="flex items-start justify-between gap-2 text-ink">
31 + <span className="min-w-0 flex-1">{children}</span>
32 + {hint && <Hint text={hint} align="right" className="-my-1 shrink-0" />}
33 + </dd>
34 + </div>
35 + );
36 + return (
37 + <dl className="kv" data-identity-panel>
38 + <Row k="Canonical model" hint="One row per real model release. Artifacts (checkpoints, quantisations, conversions) and folded evaluation variants point here.">
39 + <span className="font-medium">{id.canonical_model ? 'Yes' : 'No'}</span>
40 + {d.identity_confidence && <span className="ml-2 text-xs text-ink-3">identity confidence: {d.identity_confidence}</span>}
41 + </Row>
42 + <Row k="Official checkpoints" hint={id.note ?? 'hf_repo identifiers carried by the model itself.'}>
43 + {id.official_checkpoints.length ? (
44 + <ul className="space-y-0.5">
45 + {id.official_checkpoints.map((c) => (
46 + <li key={c}>
47 + <a href={`https://huggingface.co/${c}`} target="_blank" rel="noopener noreferrer" className="mono inline-flex items-center gap-1 text-[13px] text-ink hover:text-accent">
48 + {c} <ExternalLink className="size-3" aria-hidden />
49 + </a>
50 + </li>
51 + ))}
52 + </ul>
53 + ) : (
54 + <span className="text-ink-3">None recorded{d.openness?.category === 'proprietary' ? ' — closed weights' : ''}</span>
55 + )}
56 + </Row>
57 + <Row k="Artifacts" hint="Separate entities (checkpoint · quantization · conversion · packaging) pointing to this model through canonical_id.">
58 + {kinds.length ? (
59 + <span className="flex flex-wrap gap-x-3 gap-y-0.5">
60 + {kinds.map((k) => (
61 + <a key={k.kind} href="#versions-artifacts" className="tnum text-ink-2 hover:text-accent">
62 + {fmtInt(k.count)} {k.kind}
63 + {k.count === 1 ? '' : 's'}
64 + </a>
65 + ))}
66 + <span className="text-xs text-ink-3">
67 + {fmtInt(id.official_artifacts)} official · {fmtInt(id.third_party_artifacts)} third-party
68 + </span>
69 + </span>
70 + ) : (
71 + <span className="text-ink-3">None recorded</span>
72 + )}
73 + </Row>
74 + <Row k="Provider deployments">
75 + {id.provider_deployments ? (
76 + <a href="#providers-pricing" className="tnum hover:text-accent">
77 + {fmtInt(id.provider_deployments)}
78 + </a>
79 + ) : (
80 + <span className="text-ink-3">None recorded</span>
81 + )}
82 + </Row>
83 + <Row k="API aliases" hint="Identifiers under which providers and evaluators refer to this model.">
84 + {id.api_aliases.length ? <span className="mono flex flex-wrap gap-x-2 gap-y-0.5 text-[12px] text-ink-2">{id.api_aliases.map((a) => <span key={a}>{a}</span>)}</span> : <span className="text-ink-3">None</span>}
85 + </Row>
86 + <Row k="Folded evaluation variants" hint="Effort / thinking variants (…-high, …-non-reasoning) are result configurations of this model, not separate models. Their old URLs redirect here.">
87 + <span className="tnum">{fmtInt(id.folded_variants)}</span>
88 + </Row>
89 + </dl>
90 + );
91 +}
92 +
93 +/* ------------------------------------------------------------------------------------------------------ deployments */
94 +
95 +export function DeploymentsTable({ deployments, modelSlug }: { deployments: Deployment[]; modelSlug: string }) {
96 + if (!deployments.length) return <EmptyState title="No provider deployment recorded">Deployments appear when a provider's public pricing or model listing is crawled.</EmptyState>;
97 + const rows = [...deployments].sort((a, b) => (num(a.prices.output) ?? Infinity) - (num(b.prices.output) ?? Infinity) || (num(a.prices.input) ?? Infinity) - (num(b.prices.input) ?? Infinity));
98 + const has = (k: keyof Deployment['prices']) => rows.some((r) => num(r.prices[k]) !== null);
99 + const showCached = has('cached_input');
100 + const showWrite = has('cache_write');
101 + const showBatch = has('batch_input') || has('batch_output');
102 + const showNative = rows.some((r) => Object.keys(r.prices.native_units ?? {}).length > 0);
103 + const showImage = has('per_image') || has('per_request');
104 + const delisted = rows.filter((r) => r.status === 'delisted').length;
105 + return (
106 + <>
107 + <ScrollX><DataTable caption="Provider deployments, cheapest output first" compact>
108 + <thead>
109 + <tr>
110 + <Th>Provider</Th>
111 + <Th num>Context</Th>
112 + <Th num>Input / 1M</Th>
113 + {showCached && <Th num>Cached in</Th>}
114 + {showWrite && <Th num>Cache write</Th>}
115 + <Th num>Output / 1M</Th>
116 + {showBatch && <Th num>Batch in / out</Th>}
117 + {showImage && <Th num>Per image / request</Th>}
118 + {showNative && <Th>Native units</Th>}
119 + <Th>Status</Th>
120 + <Th>Observed</Th>
121 + <Th>Source</Th>
122 + </tr>
123 + </thead>
124 + <tbody>
125 + {rows.map((r, i) => {
126 + const native = Object.entries(r.prices.native_units ?? {});
127 + const evidence = (k: string, v: unknown, display: string) => (
128 + <Evidence slug={modelSlug} property={`price.${k}.${r.provider.slug}`} value={v} display={display} unit="USD / 1M tokens" label={`${propertyLabel(k)} · ${r.provider.name}`} fallback={{ source_id: null, source_name: hostOf(r.source_url) ?? r.provider.name, url: r.source_url, observed_at: r.observed_at, tier: r.tier, confidence: 'high', extractor: 'deterministic', unit: 'USD / 1M tokens' }} entity={{ name: r.model.name, entity_type: 'model' }}>
129 + {display}
130 + </Evidence>
131 + );
132 + return (
133 + <tr key={r.id} className={r.status === 'delisted' ? 'opacity-60' : undefined}>
134 + <Td primary>
135 + <span className="flex flex-wrap items-center gap-x-2">
136 + <EntityLink e={r.provider} />
137 + {i === 0 && r.status === 'active' && num(r.prices.output) !== null && <span className="text-[10px] font-medium uppercase tracking-wide text-accent-2">cheapest output</span>}
138 + </span>
139 + {r.provider_model_id && <span className="mono block text-[11px] text-ink-3">{r.provider_model_id}</span>}
140 + </Td>
141 + <Td num label="Context" className="tnum text-ink-2">
142 + {num(r.context_length) === null ? DASH : fmtTokens(r.context_length)}
143 + {num(r.max_output_tokens) !== null && <span className="block text-[11px] text-ink-3">out {fmtTokens(r.max_output_tokens)}</span>}
144 + </Td>
145 + <Td num label="Input / 1M" className="tnum font-medium text-accent-2">
146 + {num(r.prices.input) === null ? DASH : evidence('input_per_mtok', r.prices.input, fmtUsdPerM(r.prices.input))}
147 + </Td>
148 + {showCached && (
149 + <Td num label="Cached in" className="tnum text-ink-2">
150 + {fmtUsdPerM(r.prices.cached_input)}
151 + </Td>
152 + )}
153 + {showWrite && (
154 + <Td num label="Cache write" className="tnum text-ink-2">
155 + {fmtUsdPerM(r.prices.cache_write)}
156 + </Td>
157 + )}
158 + <Td num label="Output / 1M" className="tnum font-medium text-accent-2">
159 + {num(r.prices.output) === null ? DASH : evidence('output_per_mtok', r.prices.output, fmtUsdPerM(r.prices.output))}
160 + </Td>
161 + {showBatch && (
162 + <Td num label="Batch in / out" className="tnum text-ink-2">
163 + {num(r.prices.batch_input) === null && num(r.prices.batch_output) === null ? DASH : `${fmtUsdPerM(r.prices.batch_input)} / ${fmtUsdPerM(r.prices.batch_output)}`}
164 + </Td>
165 + )}
166 + {showImage && (
167 + <Td num label="Per image / request" className="tnum text-ink-2">
168 + {num(r.prices.per_image) === null && num(r.prices.per_request) === null ? DASH : `${num(r.prices.per_image) === null ? DASH : `$${fmtValue(num(r.prices.per_image))}`} / ${num(r.prices.per_request) === null ? DASH : `$${fmtValue(num(r.prices.per_request))}`}`}
169 + </Td>
170 + )}
171 + {showNative && (
172 + <Td label="Native units" className="text-xs text-ink-2">
173 + {native.length ? (
174 + <span className="flex flex-wrap gap-1" title={native.map(([k, v]) => `${k}=${String(v)}`).join('\n')}>
175 + {native.slice(0, 3).map(([k, v]) => (
176 + <span key={k} className="mono rounded-[3px] bg-surface-2 px-1 text-[10.5px]">
177 + {k}={typeof v === 'object' ? JSON.stringify(v) : String(v)}
178 + </span>
179 + ))}
180 + {native.length > 3 && <span className="text-ink-3">+{native.length - 3}</span>}
181 + </span>
182 + ) : (
183 + DASH
184 + )}
185 + </Td>
186 + )}
187 + <Td label="Status">
188 + <span className={cn('text-xs font-medium', r.status === 'active' ? 'text-positive' : 'text-warning')}>{r.status}</span>
189 + {r.valid_to && <span className="block text-[11px] text-ink-3">until {fmtDate(r.valid_to)}</span>}
190 + </Td>
191 + <Td label="Observed" className="text-xs text-ink-2" title={r.observed_at}>
192 + {fmtAgo(r.observed_at)}
193 + <span className="block text-[11px] text-ink-3">since {fmtDate(r.valid_from)}</span>
194 + </Td>
195 + <Td label="Source">
196 + <SourceCell url={r.source_url} tier={r.tier} />
197 + </Td>
198 + </tr>
199 + );
200 + })}
201 + </tbody>
202 + </DataTable></ScrollX>
203 + <Note className="mt-3">
204 + {rows[0]?.prices.unit ?? 'USD per 1M tokens'} as published by each provider; native units (per-request fees, flex/priority tiers) are kept verbatim. Rows are append-only — every price change is kept in the history below.
205 + {delisted > 0 && ` ${fmtInt(delisted)} delisted deployment${delisted === 1 ? '' : 's'} shown greyed.`} <Link href={routes.calculator()} className="link">Cost of a workload →</Link>
206 + </Note>
207 + </>
208 + );
209 +}
210 +
211 +/* ------------------------------------------------------------------------------------------------------ version history */
212 +
213 +function fmtVersionValue(property: string, v: unknown): string {
214 + if (v === null || v === undefined) return 'unknown';
215 + if (/context_length|max_output_tokens/.test(property)) return `${fmtTokens(v)}`;
216 + return fmtValue(v, property);
217 +}
218 +
219 +/** property → "128K → 200K → 1M" with dates; every hop is an evidence trigger (claim id, source, tier). */
220 +export function VersionHistoryBlock({ items, d }: { items: VersionHistoryItem[]; d: ModelDetail }) {
221 + const shown = items.filter((v) => v.transitions.length > 0);
222 + if (!shown.length) return <p className="text-sm text-ink-3">No versioned property recorded yet.</p>;
223 + const sorted = [...shown].sort((a, b) => b.transitions.length - a.transitions.length || a.property.localeCompare(b.property));
224 + return (
225 + <div className="space-y-3" data-version-history>
226 + {sorted.map((v) => {
227 + const first = v.transitions[0];
228 + const chain = [first?.from ?? null, ...v.transitions.map((t) => t.to)];
229 + const changes = v.transitions.filter((t) => t.from !== null && t.from !== undefined).length;
230 + return (
231 + <div key={v.property} className="grid gap-1 border-b border-rule py-2 sm:grid-cols-[10rem_minmax(0,1fr)]">
232 + <p className="text-[13px] text-ink-3">
233 + {propertyLabel(v.property)}
234 + <span className="block text-[11px]">
235 + {changes ? `${fmtInt(changes)} change${changes === 1 ? '' : 's'}` : 'first observation only'}
236 + </span>
237 + </p>
238 + <div className="min-w-0">
239 + <p className="tnum flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm">
240 + {chain.map((val, i) => {
241 + const t = i === 0 ? null : v.transitions[i - 1];
242 + const isLast = i === chain.length - 1;
243 + const text = fmtVersionValue(v.property, val);
244 + if (!t) return val === null || val === undefined ? null : <span key={i} className="text-ink-3">{text}</span>;
245 + const fallback: ProvenanceEntry = { source_id: null, source_name: hostOf(t.source_url) ?? undefined, url: t.source_url, observed_at: t.valid_from, tier: t.tier, confidence: t.status === 'conflicting' ? 'conflicted' : 'high', extractor: 'deterministic' };
246 + return (
247 + <span key={t.claim_id} className="inline-flex items-center gap-1.5">
248 + {(i > 1 || (chain[0] !== null && chain[0] !== undefined)) && <span className="text-ink-3">→</span>}
249 + <Evidence slug={d.slug} property={v.property} value={t.to} display={text} fallback={fallback} entity={{ name: d.name, entity_type: d.entity_type }} className={cn(isLast ? 'font-semibold text-ink' : 'text-ink-2')}>
250 + {text}
251 + </Evidence>
252 + <span className="text-[11px] text-ink-3" title={t.valid_from}>
253 + {fmtDate(t.effective_at ?? t.valid_from)}
254 + </span>
255 + </span>
256 + );
257 + })}
258 + {v.transitions[v.transitions.length - 1]?.valid_to === null && <span className="text-[10px] font-medium uppercase tracking-wide text-positive">current</span>}
259 + </p>
260 + </div>
261 + </div>
262 + );
263 + })}
264 + <Note>Each hop is a claim: click a value for its source, tier and observation time. Nothing is overwritten — a new observation closes the previous claim.</Note>
265 + </div>
266 + );
267 +}
268 +
269 +/* ------------------------------------------------------------------------------------------------------ benchmarks grouped */
270 +
271 +/** Benchmark → metric / comparability group → best current row (trust, comparability note, n results, vs leader, leaderboard link). */
272 +export function ModelBenchmarksBlock({ d, leaders }: { d: ModelDetail; leaders: BenchmarkListItem[] | null }) {
273 + const b = d.benchmarks;
274 + if (!b || !b.items.length) return <EmptyState title="No benchmark results recorded">Results appear when a tier 1–3 source publishes them; we never copy scores without a source.</EmptyState>;
275 + const leaderMap = new Map<string, BenchmarkListItem>();
276 + for (const l of leaders ?? []) leaderMap.set(l.slug, l);
277 + const items = [...b.items].sort((x, y) => (x.category ?? '').localeCompare(y.category ?? '') || x.name.localeCompare(y.name));
278 + const rows: React.ReactNode[] = [];
279 + for (const bm of items) {
280 + const li = leaderMap.get(bm.slug);
281 + for (const m of bm.metrics) {
282 + for (const g of m.groups) {
283 + const isPrimary = li?.primary_group?.config_key === g.config_key && li?.primary_group?.metric === m.metric;
284 + const leader = isPrimary ? li?.leader : null;
285 + const leaderIsSelf = leader?.model.slug === d.slug;
286 + const delta = leader && !leaderIsSelf ? scoreDelta(g.best.score, leader.score, g.best.unit) : null;
287 + const chips = configChipsOf(g.best.config, null, 4);
288 + const conditions = chips.filter((c) => c.kind === 'condition');
289 + rows.push(
290 + <tr key={`${bm.slug}:${m.metric}:${g.config_key}`}>
291 + <Td primary>
292 + <Link href={routes.benchmark(bm.slug) + `?metric=${encodeURIComponent(m.metric)}&config_key=${encodeURIComponent(g.config_key)}`} className="text-ink hover:text-accent hover:underline">
293 + {bm.name}
294 + </Link>
295 + <span className="block text-[11px] text-ink-3">
296 + {bm.category ?? ''}
297 + {bm.category ? ' · ' : ''}
298 + {g.comparability_group}
299 + </span>
300 + </Td>
301 + <Td num label="Best score" className="tnum">
302 + <Evidence slug={d.slug} property={`benchmark.${bm.slug}.${m.metric}`} value={g.best.score} display={fmtScoreUnit(g.best.score, g.best.unit)} label={`${bm.name} · ${m.metric}`} fallback={{ source_id: null, source_name: hostOf(g.best.source_url) ?? undefined, url: g.best.source_url, observed_at: g.best.observed_at, tier: g.best.tier, confidence: 'high', extractor: 'deterministic', unit: g.best.unit ?? undefined }} entity={{ name: d.name, entity_type: d.entity_type }} className="font-semibold">
303 + {fmtScoreUnit(g.best.score, g.best.unit)}
304 + </Evidence>
305 + {!g.higher_is_better && <span className="block text-[10px] text-ink-3">lower is better</span>}
306 + </Td>
307 + <Td label="Trust">
308 + <TrustBadge level={g.best.trust_level} label={g.best.trust_label} />
309 + </Td>
310 + <Td label="Configuration">
311 + <span className="flex flex-wrap gap-1">
312 + {chips.length ? chips.map((c) => <ConfigChipEl key={c.key} k={c.key} v={c.value} kind={c.kind} />) : <span className="text-ink-3">{DASH}</span>}
313 + </span>
314 + {conditions.length > 0 && <span className="block text-[10px] text-ink-3">conditions differ across rows → partially comparable</span>}
315 + </Td>
316 + <Td num label="Results" className="tnum text-ink-2">
317 + {fmtInt(g.n_rows)}
318 + </Td>
319 + <Td label="vs leader" className="tnum whitespace-nowrap">
320 + {leaderIsSelf ? (
321 + <span className="text-xs font-medium text-positive">current leader</span>
322 + ) : leader ? (
323 + <span title={`Leader: ${leader.model.name} ${fmtScoreUnit(leader.score, leader.unit)}`}>
324 + <span className={cn('font-medium', delta?.startsWith('+') ? 'text-positive' : 'text-ink-2')}>{delta}</span>
325 + <span className="block text-[11px] text-ink-3">vs {leader.model.name}</span>
326 + </span>
327 + ) : (
328 + <span className="text-xs text-ink-3" title="Leader delta is shown only for the benchmark's primary comparability group">
329 + {isPrimary ? DASH : 'non-primary group'}
330 + </span>
331 + )}
332 + </Td>
333 + <Td label="Evaluated" className="tnum text-xs text-ink-2" title={g.best.evaluated_at ? undefined : `Observed ${fmtDate(g.best.observed_at)}; the source gave no evaluation date`}>
334 + {g.best.evaluated_at ? fmtDate(g.best.evaluated_at) : <span className="text-ink-3">obs. {fmtDate(g.best.observed_at)}</span>}
335 + </Td>
336 + <Td label="Source">
337 + <SourceCell url={g.best.source_url} tier={g.best.tier} />
338 + </Td>
339 + </tr>,
340 + );
341 + }
342 + }
343 + }
344 + return (
345 + <>
346 + <ScrollX><DataTable caption="Benchmark results grouped by comparability group" compact>
347 + <thead>
348 + <tr>
349 + <Th>Benchmark · group</Th>
350 + <Th num>Best score</Th>
351 + <Th>Trust</Th>
352 + <Th>Configuration</Th>
353 + <Th num>Results</Th>
354 + <Th>vs leader</Th>
355 + <Th>Evaluated</Th>
356 + <Th>Source</Th>
357 + </tr>
358 + </thead>
359 + <tbody>{rows}</tbody>
360 + </DataTable></ScrollX>
361 + <Note className="mt-3">
362 + {b.note ?? 'Current rows only, grouped by benchmark → canonical metric → comparability group.'} {fmtInt(b.total_rows)} current rows in total. “vs leader” compares with the current leader of the benchmark's primary group only; other groups are not directly comparable. <Link href="/methodology#benchmarks" className="link">Comparability rules →</Link>
363 + </Note>
364 + </>
365 + );
366 +}
367 +
368 +/* ------------------------------------------------------------------------------------------------------ hardware fit */
369 +
370 +export function HardwareFitBlock({ rows, assumptions, modelSlug }: { rows: HardwareFitRow[]; assumptions?: string[]; modelSlug: string }) {
371 + if (!rows.length) return <EmptyState title="No hardware estimate available">Estimates need a parameter count; this model has none recorded from a source.</EmptyState>;
372 + const sorted = [...rows].sort((a, b) => Number(b.fits) - Number(a.fits) || (num(a.hardware.attributes?.memory_gb) ?? 0) - (num(b.hardware.attributes?.memory_gb) ?? 0));
373 + const fits = sorted.filter((r) => r.fits).length;
374 + return (
375 + <>
376 + <div className="mb-3 flex flex-wrap items-center gap-2">
377 + <Estimated />
378 + <p className="tnum text-sm text-ink-2">
379 + {fmtInt(fits)} of {fmtInt(sorted.length)} device × quantization combinations fit.
380 + </p>
381 + <Link href={`${routes.runLocally()}?model=${encodeURIComponent(modelSlug)}`} className="link text-sm">
382 + Run locally: your machine →
383 + </Link>
384 + </div>
385 + <ScrollX><DataTable caption="Estimated hardware fit" compact>
386 + <thead>
387 + <tr>
388 + <Th>Hardware</Th>
389 + <Th>Quantization</Th>
390 + <Th num>Device memory</Th>
391 + <Th num>Est. memory</Th>
392 + <Th>Fits</Th>
393 + </tr>
394 + </thead>
395 + <tbody>
396 + {sorted.map((r, i) => (
397 + <tr key={`${r.hardware.id}-${r.quantization}-${i}`}>
398 + <Td primary>
399 + <EntityLink e={r.hardware} />
400 + </Td>
401 + <Td label="Quantization" className="mono text-xs text-ink-2">
402 + {r.quantization}
403 + </Td>
404 + <Td num label="Device memory" className="tnum text-ink-2">
405 + {fmtGb(r.hardware.attributes?.memory_gb as never)}
406 + </Td>
407 + <Td num label="Est. memory" className="tnum">
408 + {fmtGb(r.estimated_memory_gb, 1)} <span className="text-[10px] uppercase tracking-wide text-warning">est.</span>
409 + </Td>
410 + <Td label="Fits" className={r.fits ? 'font-medium text-positive' : 'text-ink-3'}>
411 + {r.fits ? 'Yes' : 'No'}
412 + </Td>
413 + </tr>
414 + ))}
415 + </tbody>
416 + </DataTable></ScrollX>
417 + {assumptions && assumptions.length > 0 && (
418 + <details className="mt-3 text-xs text-ink-3">
419 + <summary className="cursor-pointer text-ink-2">Assumptions ({assumptions.length})</summary>
420 + <ul className="mt-1 list-disc space-y-0.5 pl-4">
421 + {assumptions.map((a) => (
422 + <li key={a}>{a}</li>
423 + ))}
424 + </ul>
425 + </details>
426 + )}
427 + </>
428 + );
429 +}
430 +
431 +/* ------------------------------------------------------------------------------------------------------ artifacts */
432 +
433 +export function ArtifactsBlock({ d }: { d: ModelDetail }) {
434 + const groups = d.artifacts?.items ?? [];
435 + if (!groups.length) return <p className="text-sm text-ink-3">No artifact (checkpoint, quantisation, conversion or packaging) points to this model yet.</p>;
436 + return (
437 + <div className="space-y-4" data-artifacts-block>
438 + {groups.map((g) => (
439 + <div key={g.kind}>
440 + <p className="eyebrow mb-1">
441 + {g.kind}
442 + {g.count === 1 ? '' : 's'} <span className="tnum text-ink-3">{fmtInt(g.count)}</span>
443 + </p>
444 + <ul className="divide-y divide-rule border-y border-rule">
445 + {g.items.map((a) => {
446 + const at = a.attributes ?? {};
447 + return (
448 + <li key={a.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-x-3 py-1.5 text-sm">
449 + <span className="min-w-0">
450 + <Link href={routes.artifact(a.slug)} className="text-ink hover:text-accent hover:underline">
451 + {a.name}
452 + </Link>
453 + <span className="block truncate text-[11px] text-ink-3">
454 + {a.organization?.name ?? ''}
455 + {typeof at.quant_format === 'string' ? ` · ${String(at.quant_format).toUpperCase()}` : ''}
456 + {Array.isArray(at.weights_dtype) && at.weights_dtype.length ? ` · ${(at.weights_dtype as string[]).join('/')}` : ''}
457 + {num(at['metric.downloads']) !== null ? ` · ${fmtInt(at['metric.downloads'])} downloads` : ''}
458 + </span>
459 + </span>
460 + <span className="tnum text-xs text-ink-2">{num(at.file_size_gb) !== null ? fmtGb(at.file_size_gb, 1) : DASH}</span>
461 + </li>
462 + );
463 + })}
464 + {g.count > g.items.length && <li className="py-1.5 text-xs text-ink-3">+{fmtInt(g.count - g.items.length)} more — see the graph.</li>}
465 + </ul>
466 + </div>
467 + ))}
468 + </div>
469 + );
470 +}
471 +
472 +/* ------------------------------------------------------------------------------------------------------ comparability legend (reused) */
473 +export function ComparabilityLegend() {
474 + return (
475 + <p className="flex flex-wrap items-center gap-2 text-[11px] text-ink-3">
476 + <ComparabilityBadge level="comparable" /> same task and conditions · <ComparabilityBadge level="partially-comparable" /> same task, conditions differ (effort, temperature, judge) · <ComparabilityBadge level="not-comparable" /> different variant or metric
477 + </p>
478 + );
479 +}
added apps/web/src/components/entity/model-page.tsx +449 −0
@@ -0,0 +1,449 @@
1 +import { ExternalLink, GitFork } from 'lucide-react';
2 +import Link from 'next/link';
3 +import { CompareButton } from '@/components/compare/compare-button';
4 +import { CompareTrayBar } from '@/components/compare/compare-tray-bar';
5 +import { Evidence } from '@/components/evidence/evidence';
6 +import { SectionNav } from '@/components/layout/terminal';
7 +import { ViewBeacon } from '@/components/layout/view-beacon';
8 +import { IdentityBadge, OpennessChip } from '@/components/models/badges';
9 +import { LineageTree } from '@/components/models/lineage-tree';
10 +import { OpennessBlock } from '@/components/models/openness-block';
11 +import { PriceHistoryChart } from '@/components/models/price-history';
12 +import { ScrollToSection } from '@/components/models/scroll-to-section';
13 +import { identityStrip } from '@/components/models/shared';
14 +import { EntityBadge, StatusBadge } from '@/components/ui/badges';
15 +import { EntityLink, QualityMark } from '@/components/ui/entity';
16 +import { KeyValue, type KVRow } from '@/components/ui/key-value';
17 +import { Container, Note } from '@/components/ui/section';
18 +import { WatchButton } from '@/components/watchlist/watch-button';
19 +import { api, apiD1, safe } from '@/lib/api';
20 +import { fmtAgo, fmtDate, fmtInt, fmtParams, fmtTokens, num } from '@/lib/format';
21 +import { routes, SITE_NAME, SITE_URL } from '@/lib/site';
22 +import type { EntitySummary, ModelDetail } from '@/lib/types';
23 +import { AsOfPicker } from './asof-picker';
24 +import { Capabilities, EntityList, PricesTable, ProvenanceSummary, RelationsBlock, SourcesTable, TimelineList } from './blocks';
25 +import { AsOfBlock, ClaimHistory } from './history';
26 +import { ArtifactsBlock, ComparabilityLegend, DeploymentsTable, HardwareFitBlock, IdentityPanel, ModelBenchmarksBlock, VersionHistoryBlock } from './model-blocks';
27 +
28 +/*
29 + Model page 3.0: sticky header with identity strip → SectionNav → sections in a fixed order (only those with data render):
30 + Overview · Architecture · Capabilities · Benchmarks · Providers & Pricing · Price history · Hardware fit · Lineage ·
31 + Versions & Artifacts · Repositories · Papers · Datasets · Timeline · Change history · Provenance. Every value opens the evidence drawer.
32 +*/
33 +
34 +export type ModelPageParams = { asof?: string; property?: string; tab?: string };
35 +const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
36 +const ARCH_KEYS = ['architecture', 'model_type', 'parameter_count', 'active_parameter_count', 'is_moe', 'num_experts', 'num_layers', 'hidden_size', 'tokenizer', 'vocab_size', 'weights_dtype', 'file_size_gb', 'library_name', 'pipeline_tag', 'hf_repo', 'training_tokens', 'training_data_cutoff'];
37 +const OVERVIEW_KEYS = ['release_date', 'status', 'version', 'knowledge_cutoff', 'deprecation_date', 'retirement_date', 'official_url', 'model_card_url', 'paper_url', 'repository_url', 'api_model_id', 'openrouter_id'];
38 +
39 +function sectionTitle(id: string): string {
40 + return SECTIONS.find((s) => s.id === id)?.label ?? id;
41 +}
42 +const SECTIONS = [
43 + { id: 'overview', label: 'Overview' },
44 + { id: 'architecture', label: 'Architecture' },
45 + { id: 'capabilities', label: 'Capabilities' },
46 + { id: 'benchmarks', label: 'Benchmarks' },
47 + { id: 'providers-pricing', label: 'Providers & Pricing' },
48 + { id: 'price-history', label: 'Price history' },
49 + { id: 'hardware-fit', label: 'Hardware fit' },
50 + { id: 'lineage', label: 'Lineage' },
51 + { id: 'versions-artifacts', label: 'Versions & Artifacts' },
52 + { id: 'repositories', label: 'Repositories' },
53 + { id: 'papers', label: 'Papers' },
54 + { id: 'datasets', label: 'Datasets' },
55 + { id: 'timeline', label: 'Timeline' },
56 + { id: 'change-history', label: 'Change history' },
57 + { id: 'provenance', label: 'Provenance' },
58 +];
59 +
60 +function Sec({ id, children, count, lede, action }: { id: string; children: React.ReactNode; count?: number | null; lede?: React.ReactNode; action?: { href: string; label: string } }) {
61 + return (
62 + <section id={id} className="scroll-mt-[calc(var(--header-h)+3rem)] border-t border-rule py-6 md:py-8" data-section={id}>
63 + <div className="mb-3 flex flex-wrap items-end justify-between gap-2">
64 + <h2 className="text-base font-semibold tracking-tight text-ink md:text-lg">
65 + {sectionTitle(id)}
66 + {count !== undefined && count !== null && <span className="tnum ml-2 text-sm font-normal text-ink-3">{fmtInt(count)}</span>}
67 + </h2>
68 + {action && (
69 + <Link href={action.href} className="link text-sm">
70 + {action.label} →
71 + </Link>
72 + )}
73 + </div>
74 + {lede && <div className="mb-3 max-w-3xl text-sm text-ink-2">{lede}</div>}
75 + {children}
76 + </section>
77 + );
78 +}
79 +
80 +function jsonLd(d: ModelDetail, canonical: string) {
81 + const a = d.attributes ?? {};
82 + const org = d.organization ? { '@type': 'Organization', name: d.organization.name, url: `${SITE_URL}${routes.entity({ entity_type: 'company', slug: d.organization.slug })}` } : undefined;
83 + const deployments = d.deployments ?? [];
84 + return {
85 + '@context': 'https://schema.org',
86 + '@type': ['SoftwareApplication', 'Product'],
87 + name: d.name,
88 + url: `${SITE_URL}${canonical}`,
89 + description: d.description ?? undefined,
90 + applicationCategory: 'AI model',
91 + alternateName: d.aliases?.length ? d.aliases : undefined,
92 + identifier: d.identifiers?.map((i) => ({ '@type': 'PropertyValue', propertyID: i.scheme, value: i.value })),
93 + creator: org,
94 + manufacturer: org,
95 + datePublished: typeof a.release_date === 'string' ? a.release_date : undefined,
96 + license: d.licence && 'key' in d.licence && d.licence.key ? (d.licence.url ?? d.licence.key) : typeof a.license === 'string' ? a.license : undefined,
97 + isPartOf: d.family && 'slug' in d.family && d.family.slug ? { '@type': 'CreativeWorkSeries', name: d.family.name, url: `${SITE_URL}${routes.family(d.family.slug)}` } : undefined,
98 + additionalProperty: [
99 + num(a.parameter_count) !== null ? { '@type': 'PropertyValue', name: 'parameter_count', value: num(a.parameter_count) } : null,
100 + num(a.context_length) !== null ? { '@type': 'PropertyValue', name: 'context_length', value: num(a.context_length), unitText: 'tokens' } : null,
101 + typeof a.openness === 'string' ? { '@type': 'PropertyValue', name: 'openness', value: a.openness } : null,
102 + ].filter(Boolean),
103 + offers: deployments.length
104 + ? deployments.slice(0, 8).map((p) => ({ '@type': 'Offer', seller: { '@type': 'Organization', name: p.provider.name }, price: num(p.prices.output) ?? undefined, priceCurrency: p.prices.currency || 'USD', description: 'Output price per 1M tokens', availability: p.status === 'active' ? 'https://schema.org/InStock' : 'https://schema.org/Discontinued' }))
105 + : undefined,
106 + };
107 +}
108 +
109 +/** SEO description: "Qwen3.6 35B A3B by Qwen: 35B parameters (3B active), 262K context, open weights (Apache-2.0), released 14 May 2026. …" */
110 +export function describeModel(d: ModelDetail): string {
111 + const a = d.attributes ?? {};
112 + const bits: string[] = [];
113 + const p = num(a.parameter_count);
114 + const ap = num(a.active_parameter_count);
115 + if (p !== null) bits.push(`${fmtParams(p)} parameters${ap !== null && ap !== p ? ` (${fmtParams(ap)} active)` : ''}`);
116 + if (num(a.context_length) !== null) bits.push(`${fmtTokens(a.context_length)} context`);
117 + if (d.openness?.label) bits.push(`${d.openness.label.toLowerCase()}${d.licence && 'key' in d.licence && d.licence.key ? ` (${d.licence.key})` : ''}`);
118 + if (typeof a.release_date === 'string') bits.push(`released ${fmtDate(a.release_date)}`);
119 + const n = d.deployments?.length ?? 0;
120 + const b = d.benchmarks?.items.length ?? 0;
121 + const tail = [n ? `${n} provider deployment${n === 1 ? '' : 's'}` : null, b ? `${b} benchmark${b === 1 ? '' : 's'}` : null].filter(Boolean).join(', ');
122 + let s = `${d.name}${d.organization ? ` by ${d.organization.name}` : ''}${bits.length ? `: ${bits.join(', ')}` : ''}.`;
123 + if (tail) s += ` ${tail} with sourced prices and scores.`;
124 + s += ` Every value carries its source, tier and observation time on ${SITE_NAME}.`;
125 + return s.slice(0, 300);
126 +}
127 +
128 +export async function ModelPage({ d, canonical, related, params }: { d: ModelDetail; canonical: string; related?: EntitySummary[] | null; params: ModelPageParams }) {
129 + const a = d.attributes ?? {};
130 + const asofRaw = params.asof?.trim() || undefined;
131 + const asof = asofRaw && ISO_DAY.test(asofRaw) ? asofRaw : undefined;
132 + const property = params.property?.trim() || undefined;
133 + const [history, asofPayload, benchList] = await Promise.all([safe(api.entityHistory(d.slug, property)), asof ? safe(api.entityAsOf(d.slug, asof)) : Promise.resolve(null), d.benchmarks?.items.length ? safe(apiD1.benchmarks()) : Promise.resolve(null)]);
134 + const claims = history?.items ?? null;
135 + const entity = { name: d.name, entity_type: d.entity_type };
136 + const licenceKey = d.licence && 'key' in d.licence && d.licence.key ? d.licence.key : typeof a.license === 'string' ? a.license : null;
137 + const strip = identityStrip(a, { opennessLabel: d.openness?.label ?? null, licence: licenceKey });
138 + const family = d.family && 'slug' in d.family && d.family.slug ? d.family : null;
139 + const familyLabel = d.family && !('slug' in d.family && d.family.slug) ? d.family.name : typeof a.family === 'string' ? a.family : null;
140 + const link = ['official_url', 'model_card_url', 'website'].map((k) => a[k]).find((v): v is string => typeof v === 'string' && /^https?:\/\//.test(v)) ?? null;
141 + const deployments = d.deployments ?? [];
142 + const priceHistory = d.price_history ?? d.prices ?? [];
143 + const datasets = (d.relations ?? []).flatMap((g) => g.items.filter((i) => i.entity_type === 'dataset'));
144 + const lineage = d.lineage ?? { ancestors: [], descendants: [], quantizations: [] };
145 + const artifactKinds = (d.artifacts?.items ?? []).map((g) => ({ kind: g.kind, count: g.count }));
146 + const hasLineage = lineage.ancestors.length + lineage.descendants.length + lineage.quantizations.length + artifactKinds.length > 0;
147 + const archRows: KVRow[] = ARCH_KEYS.filter((k) => a[k] !== undefined && a[k] !== null && a[k] !== '' && !(Array.isArray(a[k]) && (a[k] as unknown[]).length === 0)).map((k) => ({ key: k, raw: a[k] }));
148 + const overviewRows: KVRow[] = OVERVIEW_KEYS.filter((k) => a[k] !== undefined && a[k] !== null && a[k] !== '').map((k) => ({ key: k, raw: a[k] }));
149 + const hasCaps = ['tool_calling', 'structured_output', 'reasoning', 'vision', 'audio', 'fine_tuning_available', 'modalities', 'modalities_input', 'modalities_output', 'languages'].some((k) => a[k] !== undefined && a[k] !== null);
150 +
151 + const present = new Set<string>(['overview', 'change-history', 'provenance']);
152 + if (archRows.length) present.add('architecture');
153 + if (hasCaps) present.add('capabilities');
154 + if (d.benchmarks?.items.length || d.results?.length) present.add('benchmarks');
155 + if (deployments.length || d.prices?.length) present.add('providers-pricing');
156 + if (priceHistory.length) present.add('price-history');
157 + if (d.hardware_fit?.length) present.add('hardware-fit');
158 + if (hasLineage) present.add('lineage');
159 + if (d.version_history?.length || d.artifacts?.total || d.identity) present.add('versions-artifacts');
160 + if (d.repositories?.length) present.add('repositories');
161 + if (d.papers?.length) present.add('papers');
162 + if (datasets.length) present.add('datasets');
163 + if (d.timeline?.length) present.add('timeline');
164 + const nav = SECTIONS.filter((s) => present.has(s.id));
165 + const ld = jsonLd(d, canonical);
166 +
167 + return (
168 + <Container wide>
169 + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} />
170 + <ViewBeacon path={canonical} />
171 + <ScrollToSection />
172 +
173 + <nav aria-label="Breadcrumb" className="pt-5 text-xs text-ink-3">
174 + <ol className="flex flex-wrap items-center gap-1.5">
175 + <li>
176 + <Link href="/" className="hover:text-ink">
177 + AI Atlas
178 + </Link>
179 + </li>
180 + <li aria-hidden>/</li>
181 + <li>
182 + <Link href={routes.models()} className="hover:text-ink">
183 + Models
184 + </Link>
185 + </li>
186 + {d.organization && (
187 + <>
188 + <li aria-hidden>/</li>
189 + <li>
190 + <Link href={routes.entity({ entity_type: 'company', slug: d.organization.slug })} className="hover:text-ink">
191 + {d.organization.name}
192 + </Link>
193 + </li>
194 + </>
195 + )}
196 + {family && (
197 + <>
198 + <li aria-hidden>/</li>
199 + <li>
200 + <Link href={routes.family(family.slug)} className="hover:text-ink">
201 + {family.name}
202 + </Link>
203 + </li>
204 + </>
205 + )}
206 + <li aria-hidden>/</li>
207 + <li className="text-ink-2">{d.name}</li>
208 + </ol>
209 + </nav>
210 +
211 + {/* ------------------------------------------------------------------------------------------------ header */}
212 + <header className="pb-4 pt-4 md:pt-5" data-model-header>
213 + <div className="flex flex-wrap items-center gap-2">
214 + <EntityBadge type="model" />
215 + <StatusBadge status={d.status} />
216 + {d.openness && <OpennessChip openness={d.openness.category} label={d.openness.label} />}
217 + <IdentityBadge level={d.identity_confidence} />
218 + {d.redirected_from && <span className="text-xs text-ink-3">redirected from {d.redirected_from.slug}</span>}
219 + </div>
220 + <div className="mt-3 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
221 + <div className="min-w-0">
222 + <h1 className="display text-[30px] md:text-[44px]">{d.name}</h1>
223 + <p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[15px] text-ink-2">
224 + {d.organization && (
225 + <Link href={routes.entity({ entity_type: 'company', slug: d.organization.slug })} className="font-medium text-ink hover:text-accent">
226 + {d.organization.name}
227 + </Link>
228 + )}
229 + {family ? (
230 + <Link href={routes.family(family.slug)} className="hover:text-accent">
231 + family · {family.name}
232 + </Link>
233 + ) : familyLabel ? (
234 + <span className="text-ink-3" title="Family label from the source, not yet a canonical family">
235 + family · {familyLabel}
236 + </span>
237 + ) : null}
238 + {typeof a.release_date === 'string' && (
239 + <Evidence slug={d.slug} property="release_date" value={a.release_date} display={fmtDate(a.release_date)} fallback={d.provenance?.release_date} entity={entity}>
240 + released {fmtDate(a.release_date)}
241 + </Evidence>
242 + )}
243 + {link && (
244 + <a href={link} target="_blank" rel="noopener noreferrer" className="inline-flex max-w-full min-w-0 items-center gap-1 text-ink-3 hover:text-accent">
245 + <span className="truncate">{link.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '').slice(0, 48)}</span> <ExternalLink className="size-3.5 shrink-0" aria-hidden />
246 + </a>
247 + )}
248 + </p>
249 + {d.description && <p className="mt-3 max-w-3xl text-[15px] leading-relaxed text-ink-2">{d.description}</p>}
250 + <div className="mt-4 flex flex-wrap items-center gap-2" aria-label="Actions">
251 + <CompareButton e={d} />
252 + <WatchButton e={d} />
253 + <Link href={`${routes.graph(d.slug)}?mode=lineage`} className="inline-flex h-9 items-center gap-1.5 border border-rule px-2.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink">
254 + <GitFork className="size-3.5" aria-hidden /> Open in Graph
255 + </Link>
256 + </div>
257 + </div>
258 + <div className="shrink-0 text-xs text-ink-3 lg:text-right">
259 + <QualityMark q={d.quality?.score} label />
260 + <p className="mt-1" title={d.updated_at}>
261 + Updated {fmtAgo(d.updated_at)} · first seen {fmtDate(d.first_seen_at)}
262 + </p>
263 + <p className="mono mt-0.5 text-[11px]">{d.id}</p>
264 + </div>
265 + </div>
266 + {strip.length > 0 && (
267 + <p className="tnum mt-4 flex flex-wrap items-center gap-x-2 gap-y-1 border-y border-rule py-2.5 text-[15px] font-medium text-ink" data-identity-strip>
268 + {strip.map((s, i) => (
269 + <span key={s.key} className="inline-flex items-center gap-2">
270 + {i > 0 && (
271 + <span aria-hidden className="text-ink-3">
272 + ·
273 + </span>
274 + )}
275 + <Evidence slug={d.slug} property={s.key} value={a[s.key]} display={s.text} fallback={d.provenance?.[s.key]} entity={entity}>
276 + {s.text}
277 + </Evidence>
278 + </span>
279 + ))}
280 + </p>
281 + )}
282 + </header>
283 +
284 + <SectionNav items={nav} />
285 +
286 + <div className="pb-16">
287 + {/* -------------------------------------------------------------------------------------------- Overview */}
288 + <Sec id="overview">
289 + <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_24rem]">
290 + <div className="min-w-0 space-y-6">
291 + <div>
292 + <p className="eyebrow mb-2">Identity</p>
293 + <IdentityPanel d={d} />
294 + </div>
295 + <div>
296 + <p className="eyebrow mb-2">Openness</p>
297 + <OpennessBlock openness={d.openness} licence={d.licence} />
298 + </div>
299 + {overviewRows.length > 0 && (
300 + <div>
301 + <p className="eyebrow mb-2">Key facts</p>
302 + <KeyValue rows={overviewRows} provenance={d.provenance} slug={d.slug} entity={entity} />
303 + </div>
304 + )}
305 + </div>
306 + <aside className="min-w-0 space-y-6">
307 + <div>
308 + <p className="eyebrow mb-2">Relations</p>
309 + <RelationsBlock relations={d.relations ?? []} exclude={['evaluated_on', 'artifact_of', 'quantized_from']} />
310 + </div>
311 + {(d.aliases?.length > 0 || d.identifiers?.length > 0) && (
312 + <div>
313 + <p className="eyebrow mb-2">Identifiers</p>
314 + {d.identifiers?.length > 0 && (
315 + <dl className="kv [&>div]:grid-cols-[8rem_minmax(0,1fr)]">
316 + {d.identifiers.slice(0, 12).map((i) => (
317 + <div key={`${i.scheme}:${i.value}`}>
318 + <dt className="mono text-[11px]">{i.scheme}</dt>
319 + <dd className="mono break-all text-[12px] text-ink">{i.value}</dd>
320 + </div>
321 + ))}
322 + </dl>
323 + )}
324 + {d.aliases?.length > 0 && (
325 + <p className="mt-2 text-xs text-ink-3">
326 + Also known as: <span className="text-ink-2">{d.aliases.join(', ')}</span>
327 + </p>
328 + )}
329 + <p className="mono mt-2 break-all text-[11px] text-ink-3">slug {d.slug}</p>
330 + </div>
331 + )}
332 + {related && related.length > 0 && (
333 + <div>
334 + <p className="eyebrow mb-2">Related</p>
335 + <ul className="divide-y divide-rule border-y border-rule">
336 + {related.slice(0, 8).map((r) => (
337 + <li key={r.id} className="flex items-center gap-2 py-1.5 text-sm">
338 + <EntityBadge type={r.entity_type} small />
339 + <EntityLink e={r} className="truncate" />
340 + {r.organization && <span className="ml-auto shrink-0 text-xs text-ink-3">{r.organization.name}</span>}
341 + </li>
342 + ))}
343 + </ul>
344 + </div>
345 + )}
346 + </aside>
347 + </div>
348 + </Sec>
349 +
350 + {present.has('architecture') && (
351 + <Sec id="architecture">
352 + <KeyValue rows={archRows} provenance={d.provenance} slug={d.slug} entity={entity} />
353 + </Sec>
354 + )}
355 + {present.has('capabilities') && (
356 + <Sec id="capabilities">
357 + <Capabilities d={d} />
358 + </Sec>
359 + )}
360 + {present.has('benchmarks') && (
361 + <Sec id="benchmarks" count={d.benchmarks?.total_rows ?? d.results?.length} lede={<ComparabilityLegend />} action={{ href: `${routes.compare([d.slug])}`, label: 'Compare with another model' }}>
362 + <ModelBenchmarksBlock d={d} leaders={benchList?.items ?? null} />
363 + </Sec>
364 + )}
365 + {present.has('providers-pricing') && (
366 + <Sec id="providers-pricing" count={deployments.length || d.prices?.length} action={{ href: `${routes.prices()}?model=${encodeURIComponent(d.slug)}`, label: 'All offers in the price terminal' }}>
367 + {deployments.length ? <DeploymentsTable deployments={deployments} modelSlug={d.slug} /> : <PricesTable prices={d.prices ?? []} perspective="model" />}
368 + </Sec>
369 + )}
370 + {present.has('price-history') && (
371 + <Sec id="price-history" lede="Step lines per provider; amber markers are recorded changes. Click a marker or a row for the evidence behind that price.">
372 + <div className="grid gap-8 lg:grid-cols-2">
373 + <PriceHistoryChart history={priceHistory} field="output_per_mtok" modelSlug={d.slug} modelName={d.name} />
374 + <PriceHistoryChart history={priceHistory} field="input_per_mtok" modelSlug={d.slug} modelName={d.name} />
375 + </div>
376 + </Sec>
377 + )}
378 + {present.has('hardware-fit') && (
379 + <Sec id="hardware-fit" count={d.hardware_fit?.length}>
380 + <HardwareFitBlock rows={d.hardware_fit ?? []} assumptions={d.hardware_fit_assumptions} modelSlug={d.slug} />
381 + </Sec>
382 + )}
383 + {present.has('lineage') && (
384 + <Sec id="lineage" action={{ href: `${routes.graph(d.slug)}?mode=lineage`, label: 'Open in Graph' }} lede="Explicit derived_from / fine_tuned_from / distilled_from relations stated by sources; artifacts collapsed by kind.">
385 + <LineageTree self={d} ancestors={lineage.ancestors} descendants={lineage.descendants} artifactKinds={artifactKinds.length ? artifactKinds : lineage.quantizations.length ? [{ kind: 'quantization', count: lineage.quantizations.length }] : []} />
386 + </Sec>
387 + )}
388 + {present.has('versions-artifacts') && (
389 + <Sec id="versions-artifacts" count={d.artifacts?.total}>
390 + <div className="grid gap-8 lg:grid-cols-2">
391 + <div>
392 + <p className="eyebrow mb-2">Version history</p>
393 + <VersionHistoryBlock items={d.version_history ?? []} d={d} />
394 + </div>
395 + <div>
396 + <p className="eyebrow mb-2">
397 + Artifacts <span className="tnum text-ink-3">{fmtInt(d.artifacts?.total ?? 0)}</span>
398 + </p>
399 + <ArtifactsBlock d={d} />
400 + </div>
401 + </div>
402 + </Sec>
403 + )}
404 + {present.has('repositories') && (
405 + <Sec id="repositories" count={d.repositories?.length}>
406 + <EntityList items={d.repositories ?? []} />
407 + </Sec>
408 + )}
409 + {present.has('papers') && (
410 + <Sec id="papers" count={d.papers?.length}>
411 + <EntityList items={d.papers ?? []} />
412 + </Sec>
413 + )}
414 + {present.has('datasets') && (
415 + <Sec id="datasets" count={datasets.length}>
416 + <EntityList items={datasets} />
417 + </Sec>
418 + )}
419 + {present.has('timeline') && (
420 + <Sec id="timeline" count={d.timeline?.length} action={{ href: routes.timeline({ entity: d.slug }), label: 'Full timeline' }}>
421 + <TimelineList events={d.timeline ?? []} />
422 + </Sec>
423 + )}
424 + <Sec id="change-history" count={property ? undefined : claims?.length} lede="Temporal, append-only claims: a new observation closes the previous claim instead of overwriting it. Rewind the record with the as-of picker.">
425 + <div className="space-y-6">
426 + <AsOfPicker value={asofRaw} />
427 + {asofRaw && <AsOfBlock d={d} asof={asofRaw} payload={asof ? asofPayload : null} />}
428 + <ClaimHistory d={d} claims={claims} property={property} />
429 + </div>
430 + </Sec>
431 + <Sec id="provenance">
432 + <div className="space-y-6">
433 + <ProvenanceSummary provenance={d.provenance} quality={d.quality} />
434 + <div>
435 + <p className="eyebrow mb-2">
436 + Source documents <span className="tnum text-ink-3">{fmtInt(d.sources?.length ?? 0)}</span>
437 + </p>
438 + <SourcesTable sources={d.sources ?? []} />
439 + </div>
440 + <Note>
441 + Data quality ({num(d.quality?.score) === null ? 'not computed' : `${Math.round(num(d.quality?.score) as number)}/100`}) measures how well AI Atlas knows this entity — completeness, primary-source ratio, freshness, conflicts — never how good the model is. <Link href="/methodology" className="link">Methodology →</Link>
442 + </Note>
443 + </div>
444 + </Sec>
445 + </div>
446 + <CompareTrayBar />
447 + </Container>
448 + );
449 +}
added apps/web/src/components/models/badges.tsx +82 −0
@@ -0,0 +1,82 @@
1 +import { cn } from '@/lib/cn';
2 +import type { Comparability, IdentityConfidence } from '@/lib/types';
3 +import { COMPARABILITY_LABEL, IDENTITY_LABEL, opennessLabel, TRUST_LONG, trustShort } from './shared';
4 +
5 +const base = 'inline-flex items-center gap-1 whitespace-nowrap rounded-[3px] px-1.5 py-[1px] text-[11px] font-medium leading-4 tracking-wide';
6 +
7 +const TRUST_CLS: Record<string, string> = {
8 + 'official-benchmark': 'text-tier-1 bg-positive-soft',
9 + 'peer-reviewed': 'text-tier-1 bg-positive-soft',
10 + 'independent-evaluator': 'text-tier-2 bg-accent-soft',
11 + 'official-model-card': 'text-tier-3 bg-warning-soft',
12 + community: 'text-tier-3 bg-warning-soft',
13 + unverified: 'text-tier-4 bg-surface-2',
14 +};
15 +/** Trust level of a benchmark row (who produced the score). */
16 +export function TrustBadge({ level, label, className }: { level: string | null | undefined; label?: string | null; className?: string }) {
17 + if (!level) return null;
18 + return (
19 + <span className={cn(base, TRUST_CLS[level] ?? 'text-ink-2 bg-surface-2', className)} title={label ?? TRUST_LONG[level] ?? level}>
20 + {trustShort(level, label)}
21 + </span>
22 + );
23 +}
24 +
25 +const COMP_CLS: Record<Comparability, string> = { comparable: 'text-positive bg-positive-soft', 'partially-comparable': 'text-warning bg-warning-soft', 'not-comparable': 'text-danger bg-danger-soft' };
26 +/** Comparability of a row vs the group leader (or of a compare dimension). `reasons` go in the title. */
27 +export function ComparabilityBadge({ level, reasons, className, short = false }: { level: Comparability | string | null | undefined; reasons?: string[]; className?: string; short?: boolean }) {
28 + if (!level) return null;
29 + const l = level as Comparability;
30 + const text = COMPARABILITY_LABEL[l] ?? level;
31 + return (
32 + <span className={cn(base, COMP_CLS[l] ?? 'text-ink-2 bg-surface-2', className)} title={reasons?.length ? reasons.join('; ') : text}>
33 + {short ? text.replace('Partially comparable', 'Partial').replace('Not comparable', 'Not comp.') : text}
34 + </span>
35 + );
36 +}
37 +
38 +const ID_CLS: Record<IdentityConfidence, string> = { high: 'text-positive bg-positive-soft', medium: 'text-warning bg-warning-soft', low: 'text-danger bg-danger-soft' };
39 +/** identity_confidence of a model row: how sure AI Atlas is that this entry is one real model release. */
40 +export function IdentityBadge({ level, className, hideHigh = true }: { level: IdentityConfidence | string | null | undefined; className?: string; hideHigh?: boolean }) {
41 + if (!level || (hideHigh && level === 'high')) return null;
42 + const l = level as IdentityConfidence;
43 + return (
44 + <span className={cn(base, ID_CLS[l] ?? 'text-ink-2 bg-surface-2', className)} title="identity_confidence: how sure AI Atlas is that this entry is one real model release">
45 + {IDENTITY_LABEL[l] ?? level}
46 + </span>
47 + );
48 +}
49 +
50 +/** Artifact kind chip (checkpoint · quantization · conversion · packaging). */
51 +export function ArtifactKindChip({ kind, className }: { kind: string | null | undefined; className?: string }) {
52 + if (!kind) return null;
53 + return <span className={cn(base, 'bg-surface-2 uppercase text-ink-2', className)}>{kind}</span>;
54 +}
55 +
56 +/** Small config chip; task keys read stronger than condition keys. */
57 +export function ConfigChipEl({ k, v, kind, className }: { k: string; v: string; kind: 'task' | 'condition' | 'other'; className?: string }) {
58 + return (
59 + <span className={cn('mono inline-flex max-w-[14rem] items-center gap-1 truncate rounded-[3px] px-1 text-[10.5px] leading-4', kind === 'task' ? 'bg-surface-3 text-ink-2' : 'bg-surface-2 text-ink-3', className)} title={`${k}=${v}${kind === 'task' ? ' (task-defining)' : kind === 'condition' ? ' (condition — partially comparable)' : ''}`}>
60 + <span className="opacity-70">{k}</span>
61 + <span className="truncate text-ink-2">{v}</span>
62 + </span>
63 + );
64 +}
65 +
66 +/** Yes / No / — (unknown) for licence and openness dimensions. */
67 +export function Tri({ v, yes = 'Yes', no = 'No' }: { v: boolean | null | undefined; yes?: string; no?: string }) {
68 + if (v === true) return <span className="font-medium text-positive">{yes}</span>;
69 + if (v === false) return <span className="text-ink-2">{no}</span>;
70 + return (
71 + <span className="text-ink-3" title="Unknown — the source or licence text is ambiguous, not false">
72 + —
73 + </span>
74 + );
75 +}
76 +
77 +const OPEN_CLS: Record<string, string> = { 'open-source': 'text-positive bg-positive-soft', 'open-weights': 'text-positive bg-positive-soft', 'restricted-weights': 'text-warning bg-warning-soft', restricted: 'text-warning bg-warning-soft', proprietary: 'text-ink-2 bg-surface-2', unknown: 'text-ink-3 bg-surface-2' };
78 +/** Openness chip with the 1.1 ontology labels (Open source · Open weights · Restricted weights · Closed). */
79 +export function OpennessChip({ openness, label, className }: { openness: string | null | undefined; label?: string | null; className?: string }) {
80 + if (!openness) return null;
81 + return <span className={cn(base, OPEN_CLS[openness] ?? 'text-ink-2 bg-surface-2', className)}>{label ?? opennessLabel(openness)}</span>;
82 +}
added apps/web/src/components/models/family-blocks.tsx +84 −0
@@ -0,0 +1,84 @@
1 +'use client';
2 +import { InteractiveLineChart, TimelineLanes } from '@/components/charts';
3 +import { Note } from '@/components/ui/section';
4 +import { fmtDate, fmtInt } from '@/lib/format';
5 +import { routes } from '@/lib/site';
6 +import type { FamilyMember } from '@/lib/types';
7 +
8 +/*
9 + Client blocks of the family page: release timeline (TimelineLanes by status) and benchmark progress
10 + (best rank of any member by member release date; ranks go down = better, so the axis is inverted).
11 +*/
12 +
13 +const STATUS_LANES: { key: string; label: string; color: string }[] = [
14 + { key: 'active', label: 'Active', color: 'var(--positive)' },
15 + { key: 'preview', label: 'Preview', color: 'var(--accent)' },
16 + { key: 'announced', label: 'Announced', color: 'var(--accent)' },
17 + { key: 'deprecated', label: 'Deprecated', color: 'var(--warning)' },
18 + { key: 'retired', label: 'Retired', color: 'var(--danger)' },
19 + { key: 'other', label: 'Other', color: 'var(--ink-3)' },
20 +];
21 +
22 +export function FamilyReleaseLanes({ members }: { members: FamilyMember[] }) {
23 + const dated = members.filter((m) => typeof m.key_facts?.release_date === 'string' || typeof m.model.attributes?.release_date === 'string');
24 + const undated = members.length - dated.length;
25 + if (dated.length === 0) return <Note>No member has a sourced release date yet — the timeline needs at least one.</Note>;
26 + const laneOf = (m: FamilyMember) => {
27 + const s = String(m.key_facts?.status ?? m.model.status ?? 'other');
28 + return STATUS_LANES.some((l) => l.key === s) ? s : 'other';
29 + };
30 + const used = new Set(dated.map(laneOf));
31 + const lanes = STATUS_LANES.filter((l) => used.has(l.key));
32 + const events = dated.map((m) => {
33 + const at = String(m.key_facts?.release_date ?? m.model.attributes?.release_date);
34 + const ranks = Object.values(m.benchmark_ranks ?? {});
35 + return { id: m.model.id, lane: laneOf(m), at, importance: ranks.length ? (Math.min(...ranks) <= 10 ? 3 : Math.min(...ranks) <= 50 ? 2 : 1) : 1, label: m.model.name, href: routes.entity(m.model), sub: `${fmtDate(at)}${ranks.length ? ` · best rank #${Math.min(...ranks)}` : ''}` };
36 + });
37 + return (
38 + <div data-family-timeline>
39 + <TimelineLanes lanes={lanes} events={events} title="Family releases by status" laneWidth={88} />
40 + <p className="mt-1 text-[11px] text-ink-3">
41 + {fmtInt(dated.length)} dated releases · dot size = best benchmark rank of the member
42 + {undated > 0 ? ` · ${fmtInt(undated)} member${undated === 1 ? '' : 's'} without a sourced release date not shown` : ''}
43 + </p>
44 + </div>
45 + );
46 +}
47 +
48 +export function FamilyBenchmarkProgress({ members, benchNames }: { members: FamilyMember[]; benchNames: Record<string, string> }) {
49 + // per benchmark: (release date, best rank so far among members released up to that date)
50 + const perBench = new Map<string, { x: Date; y: number; model: string }[]>();
51 + const dated = members
52 + .map((m) => ({ m, at: typeof m.key_facts?.release_date === 'string' ? new Date(m.key_facts.release_date) : null }))
53 + .filter((x): x is { m: FamilyMember; at: Date } => !!x.at && !Number.isNaN(x.at.getTime()))
54 + .sort((a, b) => a.at.getTime() - b.at.getTime());
55 + for (const { m, at } of dated) {
56 + for (const [slug, rank] of Object.entries(m.benchmark_ranks ?? {})) {
57 + const arr = perBench.get(slug) ?? [];
58 + const best = arr.length ? Math.min(arr[arr.length - 1]!.y, rank) : rank;
59 + arr.push({ x: at, y: best, model: m.model.name });
60 + perBench.set(slug, arr);
61 + }
62 + }
63 + const charts = [...perBench.entries()].map(([slug, pts]) => ({ slug, pts: pts.filter((p, i, a) => i === 0 || p.x.getTime() !== a[i - 1]!.x.getTime() || p.y !== a[i - 1]!.y) })).filter((c) => new Set(c.pts.map((p) => p.x.toISOString().slice(0, 10))).size >= 2).sort((a, b) => a.pts[a.pts.length - 1]!.y - b.pts[b.pts.length - 1]!.y).slice(0, 6);
64 + if (!charts.length) return <Note>Benchmark progress needs at least two dated members with a rank on the same benchmark. Ranks (not scores) are what the family endpoint provides.</Note>;
65 + return (
66 + <div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3" data-family-progress>
67 + {charts.map((c) => {
68 + const maxRank = Math.max(...c.pts.map((p) => p.y));
69 + return (
70 + <div key={c.slug}>
71 + <p className="mb-1 flex items-baseline justify-between gap-2 text-xs">
72 + <a href={routes.benchmark(c.slug)} className="truncate font-medium text-ink hover:text-accent">
73 + {benchNames[c.slug] ?? c.slug}
74 + </a>
75 + <span className="tnum text-ink-3">best #{c.pts[c.pts.length - 1]!.y}</span>
76 + </p>
77 + <InteractiveLineChart series={[{ name: 'Best rank so far', color: 'var(--type-benchmark)', points: c.pts.map((p) => ({ x: p.x, y: p.y })) }]} height={150} step showDots yDomain={[Math.max(2, Math.ceil(maxRank * 1.15)), 1]} yFormat={(v) => `#${Math.round(v)}`} yLabel={`Best rank of the family on ${benchNames[c.slug] ?? c.slug}`} />
78 + </div>
79 + );
80 + })}
81 + <p className="text-[11px] text-ink-3 md:col-span-2 xl:col-span-3">Best rank (primary comparability group, current leaderboard) reached by any member released up to each date — lower is better, axis inverted. Ranks are today's ranks, not the ranks at release time.</p>
82 + </div>
83 + );
84 +}
added apps/web/src/components/models/lineage-tree.tsx +116 −0
@@ -0,0 +1,116 @@
1 +import Link from 'next/link';
2 +import { fmtInt, fmtParams, num } from '@/lib/format';
3 +import { routes } from '@/lib/site';
4 +import type { EntitySummary } from '@/lib/types';
5 +
6 +/*
7 + Compact server-rendered SVG lineage tree: ancestors → this model → descendants, artifacts collapsed by kind with counts.
8 + Not the force graph (components/graph is not ours); "Open in Graph" links to the interactive explorer in lineage mode.
9 +*/
10 +
11 +type Node = { id: string; label: string; sub?: string; href?: string; kind: 'model' | 'this' | 'artifact' };
12 +
13 +const W = 720;
14 +const ROW = 34;
15 +const BOX_W = 200;
16 +const BOX_H = 28;
17 +const PAD = 8;
18 +
19 +function trunc(s: string, n: number): string {
20 + return s.length > n ? `${s.slice(0, n - 1)}…` : s;
21 +}
22 +
23 +function toNode(e: EntitySummary): Node {
24 + const p = num(e.attributes?.parameter_count);
25 + return { id: e.id, label: e.name, sub: p !== null ? fmtParams(p) : e.organization?.name ?? undefined, href: routes.entity(e), kind: 'model' };
26 +}
27 +
28 +export function LineageTree({ self, ancestors, descendants, artifactKinds, className }: { self: EntitySummary; ancestors: EntitySummary[]; descendants: EntitySummary[]; artifactKinds: { kind: string; count: number }[]; className?: string }) {
29 + const left = ancestors.slice(0, 6).map(toNode);
30 + const rightModels = descendants.slice(0, 6).map(toNode);
31 + const rightArtifacts: Node[] = artifactKinds.filter((k) => k.count > 0).map((k) => ({ id: `kind-${k.kind}`, label: `${fmtInt(k.count)} ${k.kind}${k.count === 1 ? '' : 's'}`, sub: 'artifacts · collapsed', href: `${routes.entity(self)}#versions-artifacts`, kind: 'artifact' }));
32 + const right = [...rightModels, ...rightArtifacts];
33 + const moreL = ancestors.length - left.length;
34 + const moreR = descendants.length - rightModels.length;
35 + const rows = Math.max(1, left.length + (moreL > 0 ? 1 : 0), right.length + (moreR > 0 ? 1 : 0));
36 + const H = PAD * 2 + rows * ROW;
37 + const colX = [PAD, W / 2 - BOX_W / 2, W - PAD - BOX_W];
38 + const yOf = (i: number, n: number) => PAD + ((H - PAD * 2) / Math.max(1, n)) * (i + 0.5) - BOX_H / 2;
39 + const selfY = H / 2 - BOX_H / 2;
40 + const p = num(self.attributes?.parameter_count);
41 +
42 + const Box = ({ n, x, y }: { n: Node; x: number; y: number }) => {
43 + const fill = n.kind === 'this' ? 'var(--accent-soft)' : n.kind === 'artifact' ? 'var(--surface-2)' : 'var(--surface)';
44 + const stroke = n.kind === 'this' ? 'var(--accent)' : 'var(--rule-strong)';
45 + const body = (
46 + <g>
47 + <rect x={x} y={y} width={BOX_W} height={BOX_H} rx={3} fill={fill} stroke={stroke} strokeDasharray={n.kind === 'artifact' ? '3 2' : undefined} />
48 + <text x={x + 8} y={y + 12} fontSize={11} fontWeight={n.kind === 'this' ? 600 : 500} fill="var(--ink)">
49 + {trunc(n.label, 30)}
50 + </text>
51 + {n.sub && (
52 + <text x={x + 8} y={y + 23} fontSize={9.5} fill="var(--ink-3)">
53 + {trunc(n.sub, 34)}
54 + </text>
55 + )}
56 + <title>{`${n.label}${n.sub ? ` — ${n.sub}` : ''}`}</title>
57 + </g>
58 + );
59 + return n.href && n.kind !== 'this' ? (
60 + <a href={n.href} className="hover:opacity-80">
61 + {body}
62 + </a>
63 + ) : (
64 + body
65 + );
66 + };
67 + const edge = (x1: number, y1: number, x2: number, y2: number, dashed = false) => <path d={`M${x1},${y1} C${(x1 + x2) / 2},${y1} ${(x1 + x2) / 2},${y2} ${x2},${y2}`} fill="none" stroke="var(--rule-strong)" strokeWidth={1.2} strokeDasharray={dashed ? '3 3' : undefined} />;
68 +
69 + return (
70 + <div className={className} data-lineage-tree>
71 + <svg viewBox={`0 0 ${W} ${H}`} className="block w-full" role="img" aria-label={`Lineage of ${self.name}: ${ancestors.length} ancestors, ${descendants.length} descendants, ${artifactKinds.reduce((n, k) => n + k.count, 0)} artifacts`}>
72 + <text x={colX[0]} y={PAD - 1} fontSize={9} fill="var(--ink-3)" className="eyebrow">
73 + {left.length ? `ANCESTORS ${ancestors.length}` : ''}
74 + </text>
75 + <text x={W - PAD} y={PAD - 1} fontSize={9} fill="var(--ink-3)" textAnchor="end">
76 + {right.length ? `DESCENDANTS ${descendants.length}${rightArtifacts.length ? ' · ARTIFACTS' : ''}` : ''}
77 + </text>
78 + {left.map((n, i) => (
79 + <g key={n.id}>
80 + {edge((colX[0] as number) + BOX_W, yOf(i, rows) + BOX_H / 2, colX[1] as number, selfY + BOX_H / 2)}
81 + <Box n={n} x={colX[0] as number} y={yOf(i, rows)} />
82 + </g>
83 + ))}
84 + {moreL > 0 && (
85 + <text x={(colX[0] as number) + 8} y={yOf(left.length, rows) + 16} fontSize={10} fill="var(--ink-3)">
86 + +{moreL} more ancestors
87 + </text>
88 + )}
89 + {right.map((n, i) => (
90 + <g key={n.id}>
91 + {edge((colX[1] as number) + BOX_W, selfY + BOX_H / 2, colX[2] as number, yOf(i, rows) + BOX_H / 2, n.kind === 'artifact')}
92 + <Box n={n} x={colX[2] as number} y={yOf(i, rows)} />
93 + </g>
94 + ))}
95 + {moreR > 0 && (
96 + <text x={(colX[2] as number) + 8} y={yOf(right.length, rows) + 16} fontSize={10} fill="var(--ink-3)">
97 + +{moreR} more descendants
98 + </text>
99 + )}
100 + <Box n={{ id: self.id, label: self.name, sub: p !== null ? `${fmtParams(p)} params · this model` : 'this model', kind: 'this' }} x={colX[1] as number} y={selfY} />
101 + </svg>
102 + <ul className="sr-only">
103 + {ancestors.map((e) => (
104 + <li key={e.id}>
105 + ancestor: <Link href={routes.entity(e)}>{e.name}</Link>
106 + </li>
107 + ))}
108 + {descendants.map((e) => (
109 + <li key={e.id}>
110 + descendant: <Link href={routes.entity(e)}>{e.name}</Link>
111 + </li>
112 + ))}
113 + </ul>
114 + </div>
115 + );
116 +}
added apps/web/src/components/models/mini-graph.tsx +102 −0
@@ -0,0 +1,102 @@
1 +import Link from 'next/link';
2 +import { predicateLabel } from '@/lib/site';
3 +
4 +/*
5 + Compact layered SVG graph for lineage edges among a known set of nodes (family members): roots left, derived models to the right.
6 + Server-rendered, links on nodes, accessible edge list. For the interactive explorer use /graph.
7 +*/
8 +
9 +export type MiniNode = { id: string; label: string; href?: string; sub?: string };
10 +export type MiniEdge = { source: string; target: string; predicate: string };
11 +
12 +const W = 720;
13 +const BOX_W = 168;
14 +const BOX_H = 26;
15 +const ROW = 32;
16 +const PAD = 8;
17 +const trunc = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
18 +
19 +export function MiniGraph({ nodes, edges, className, title = 'Lineage' }: { nodes: MiniNode[]; edges: MiniEdge[]; className?: string; title?: string }) {
20 + const ids = new Set(nodes.map((n) => n.id));
21 + const E = edges.filter((e) => ids.has(e.source) && ids.has(e.target));
22 + if (!E.length) return <p className="text-sm text-ink-3">No lineage relation recorded among these members.</p>;
23 + const involved = new Set(E.flatMap((e) => [e.source, e.target]));
24 + const N = nodes.filter((n) => involved.has(n.id));
25 + // depth = longest chain from a root; edges point source (derived) → target (base): the base is upstream, so depth(source) = depth(target) + 1
26 + const depth = new Map<string, number>();
27 + const upstream = new Map<string, string[]>();
28 + for (const e of E) upstream.set(e.source, [...(upstream.get(e.source) ?? []), e.target]);
29 + const visit = (id: string, seen: Set<string>): number => {
30 + if (depth.has(id)) return depth.get(id) as number;
31 + if (seen.has(id)) return 0;
32 + seen.add(id);
33 + const ups = upstream.get(id) ?? [];
34 + const d = ups.length ? 1 + Math.max(...ups.map((u) => visit(u, seen))) : 0;
35 + depth.set(id, d);
36 + return d;
37 + };
38 + for (const n of N) visit(n.id, new Set());
39 + const maxD = Math.max(...N.map((n) => depth.get(n.id) ?? 0));
40 + const cols = Array.from({ length: maxD + 1 }, () => [] as MiniNode[]);
41 + for (const n of N) cols[depth.get(n.id) ?? 0]!.push(n);
42 + const rows = Math.max(...cols.map((c) => c.length));
43 + const H = PAD * 2 + rows * ROW;
44 + const colX = (d: number) => (maxD === 0 ? W / 2 - BOX_W / 2 : PAD + (d * (W - PAD * 2 - BOX_W)) / maxD);
45 + const pos = new Map<string, { x: number; y: number }>();
46 + cols.forEach((c, d) => c.forEach((n, i) => pos.set(n.id, { x: colX(d), y: PAD + ((H - PAD * 2) / Math.max(1, c.length)) * (i + 0.5) - BOX_H / 2 })));
47 + const tooMany = N.length > 40;
48 + return (
49 + <div className={className} data-mini-graph>
50 + <svg viewBox={`0 0 ${W} ${Math.min(H, 640)}`} className="block w-full" role="img" aria-label={`${title}: ${N.length} models, ${E.length} relations`}>
51 + <title>{title}</title>
52 + {E.map((e, i) => {
53 + const a = pos.get(e.target);
54 + const b = pos.get(e.source);
55 + if (!a || !b) return null;
56 + const x1 = a.x + BOX_W;
57 + const y1 = a.y + BOX_H / 2;
58 + const x2 = b.x;
59 + const y2 = b.y + BOX_H / 2;
60 + return (
61 + <g key={i}>
62 + <path d={`M${x1},${y1} C${(x1 + x2) / 2},${y1} ${(x1 + x2) / 2},${y2} ${x2},${y2}`} fill="none" stroke="var(--rule-strong)" strokeWidth={1.1} strokeDasharray={e.predicate === 'quantized_from' ? '3 2' : undefined} />
63 + <title>{`${nodes.find((n) => n.id === e.source)?.label} ${predicateLabel(e.predicate, 'out').toLowerCase()} ${nodes.find((n) => n.id === e.target)?.label}`}</title>
64 + </g>
65 + );
66 + })}
67 + {N.map((n) => {
68 + const p = pos.get(n.id)!;
69 + const body = (
70 + <g>
71 + <rect x={p.x} y={p.y} width={BOX_W} height={BOX_H} rx={3} fill="var(--surface)" stroke="var(--rule-strong)" />
72 + <text x={p.x + 7} y={p.y + 11} fontSize={10.5} fontWeight={500} fill="var(--ink)">
73 + {trunc(n.label, 26)}
74 + </text>
75 + {n.sub && (
76 + <text x={p.x + 7} y={p.y + 21} fontSize={8.5} fill="var(--ink-3)">
77 + {trunc(n.sub, 30)}
78 + </text>
79 + )}
80 + <title>{n.label}</title>
81 + </g>
82 + );
83 + return n.href ? (
84 + <a key={n.id} href={n.href} className="hover:opacity-80">
85 + {body}
86 + </a>
87 + ) : (
88 + <g key={n.id}>{body}</g>
89 + );
90 + })}
91 + </svg>
92 + {tooMany && <p className="text-[11px] text-ink-3">Large family — open the interactive graph for the full picture.</p>}
93 + <ul className="sr-only">
94 + {E.map((e, i) => (
95 + <li key={i}>
96 + <Link href={nodes.find((n) => n.id === e.source)?.href ?? '#'}>{nodes.find((n) => n.id === e.source)?.label}</Link> {predicateLabel(e.predicate, 'out').toLowerCase()} <Link href={nodes.find((n) => n.id === e.target)?.href ?? '#'}>{nodes.find((n) => n.id === e.target)?.label}</Link>
97 + </li>
98 + ))}
99 + </ul>
100 + </div>
101 + );
102 +}
added apps/web/src/components/models/models-filters.tsx +173 −0
@@ -0,0 +1,173 @@
1 +import Link from 'next/link';
2 +import { Hint } from '@/components/ui/hint';
3 +import { withParams } from '@/components/ui/pagination';
4 +import { cn } from '@/lib/cn';
5 +import { fmtInt, fmtParams, fmtTokens } from '@/lib/format';
6 +import { STATUS_LABELS } from '@/lib/site';
7 +import type { ModelFacets11 } from '@/lib/types';
8 +import { IDENTITY_LABEL, opennessLabel, parseScale } from './shared';
9 +
10 +/*
11 + /models filter rail (server component, GET form + facet links — works without JavaScript).
12 + Every control maps 1:1 to a URL parameter the API understands, so the state is shareable.
13 +*/
14 +
15 +export const MODEL_PARAM_KEYS = ['q', 'org', 'family', 'openness', 'modality', 'status', 'min_params', 'max_params', 'min_context', 'year_from', 'year_to', 'license', 'trust', 'reasoning', 'include', 'sort', 'order', 'offset'] as const;
16 +export type Current = Record<string, string | undefined>;
17 +
18 +export const MODEL_SORTS = [
19 + { value: 'updated', label: 'Recently updated' },
20 + { value: 'release', label: 'Release date' },
21 + { value: 'name', label: 'Name' },
22 + { value: 'params', label: 'Parameters' },
23 + { value: 'context', label: 'Context window' },
24 + { value: 'cheapest', label: 'Cheapest output' },
25 + { value: 'quality', label: 'Data quality' },
26 + { value: 'downloads', label: 'Downloads' },
27 +];
28 +
29 +export function modelsHref(current: Current, patch: Record<string, string | number | undefined | null>): string {
30 + return withParams('/models', current, patch);
31 +}
32 +
33 +const input = 'h-9 w-full border border-rule bg-surface px-2 text-[13px] text-ink focus:border-accent focus:outline-none';
34 +
35 +type FacetGroupDef = { key: string; label: string; hint?: string; items: { value: string; label?: string; count: unknown; sub?: string }[] };
36 +
37 +export function ModelsFilterRail({ current, facets, universeNote }: { current: Current; facets: ModelFacets11 | undefined; universeNote?: string }) {
38 + const defs = facets?.definitions ?? {};
39 + const groups: FacetGroupDef[] = [
40 + { key: 'org', label: 'Organization', items: (facets?.organizations ?? []).map((o) => ({ value: o.slug, label: o.name, count: o.count })) },
41 + { key: 'family', label: 'Family', hint: defs.families, items: (facets?.families ?? []).map((f) => ({ value: f.value, label: f.label ?? f.value, count: f.count, sub: f.canonical === false ? 'label' : undefined })) },
42 + { key: 'openness', label: 'Openness', hint: defs.openness, items: (facets?.openness ?? []).map((x) => ({ value: x.value, label: opennessLabel(x.value), count: x.count })) },
43 + { key: 'license', label: 'Licence', hint: defs.licenses, items: (facets?.licenses ?? []).map((x) => ({ value: x.value, label: x.label ?? x.value, count: x.count, sub: x.category })) },
44 + { key: 'modality', label: 'Modality', items: (facets?.modalities ?? []).map((x) => ({ value: x.value, count: x.count })) },
45 + { key: 'status', label: 'Status', items: (facets?.status ?? []).map((x) => ({ value: x.value, label: STATUS_LABELS[x.value] ?? x.value, count: x.count })) },
46 + { key: 'year_from', label: 'Release year', items: (facets?.years ?? []).map((x) => ({ value: String(x.value), count: x.count })) },
47 + { key: 'trust', label: 'Identity trust', hint: defs.trust, items: (facets?.trust ?? []).map((x) => ({ value: x.value, label: x.label ?? IDENTITY_LABEL[x.value as 'high'] ?? x.value, count: x.count })) },
48 + ];
49 + const hidden = ['org', 'family', 'openness', 'modality', 'status', 'license', 'trust', 'sort', 'order'].filter((k) => current[k]);
50 + const artifacts = current.include === 'artifacts';
51 + return (
52 + <div className="space-y-5 pb-6 text-sm" data-models-filters>
53 + <form action="/models" method="get" className="space-y-3">
54 + {hidden.map((k) => (
55 + <input key={k} type="hidden" name={k} value={current[k] as string} />
56 + ))}
57 + <label className="block">
58 + <span className="eyebrow block pb-1">Name</span>
59 + <input name="q" defaultValue={current.q ?? ''} placeholder="claude, llama, qwen…" className={input} />
60 + </label>
61 + <div className="grid grid-cols-2 gap-2">
62 + <label className="block">
63 + <span className="eyebrow block pb-1">Params ≥</span>
64 + <input name="min_params" defaultValue={current.min_params ?? ''} placeholder="7B" inputMode="text" className={input} />
65 + </label>
66 + <label className="block">
67 + <span className="eyebrow block pb-1">Params ≤</span>
68 + <input name="max_params" defaultValue={current.max_params ?? ''} placeholder="70B" className={input} />
69 + </label>
70 + </div>
71 + <label className="block">
72 + <span className="eyebrow block pb-1">Context ≥</span>
73 + <input name="min_context" defaultValue={current.min_context ?? ''} placeholder="128K" className={input} />
74 + </label>
75 + <div className="grid grid-cols-2 gap-2">
76 + <label className="block">
77 + <span className="eyebrow block pb-1">Released from</span>
78 + <input name="year_from" defaultValue={current.year_from ?? ''} placeholder="2024" inputMode="numeric" className={input} />
79 + </label>
80 + <label className="block">
81 + <span className="eyebrow block pb-1">to</span>
82 + <input name="year_to" defaultValue={current.year_to ?? ''} placeholder="2026" inputMode="numeric" className={input} />
83 + </label>
84 + </div>
85 + <label className="flex min-h-9 items-center gap-2 text-[13px] text-ink-2">
86 + <input type="checkbox" name="reasoning" value="1" defaultChecked={current.reasoning === '1'} className="size-4 accent-[var(--accent)]" /> Reasoning models only
87 + </label>
88 + <label className="flex min-h-9 items-center gap-2 text-[13px] text-ink-2">
89 + <input type="checkbox" name="include" value="artifacts" defaultChecked={artifacts} className="size-4 accent-[var(--accent)]" />
90 + <span className="flex items-center gap-1">
91 + Include artifacts
92 + <Hint text={universeNote ?? 'Artifacts = checkpoints, quantisations, conversions and packagings of a canonical model. Excluded by default so a model is counted once.'} />
93 + </span>
94 + </label>
95 + <div className="flex items-center gap-2">
96 + <button type="submit" className="inline-flex h-9 flex-1 items-center justify-center bg-ink px-3 text-[13px] font-medium text-canvas hover:opacity-90">
97 + Apply
98 + </button>
99 + <Link href="/models" className="inline-flex h-9 items-center border border-rule px-3 text-[13px] text-ink-2 hover:text-ink">
100 + Reset
101 + </Link>
102 + </div>
103 + </form>
104 + {groups
105 + .filter((g) => g.items.length)
106 + .map((g) => (
107 + <div key={g.key}>
108 + <p className="eyebrow mb-1 flex items-center gap-0.5">
109 + {g.label}
110 + {g.hint && <Hint text={g.hint} />}
111 + </p>
112 + <ul className="space-y-px">
113 + {g.items.slice(0, 10).map((it) => {
114 + const on = current[g.key] === it.value;
115 + return (
116 + <li key={it.value}>
117 + <Link href={modelsHref(current, { [g.key]: on ? undefined : it.value, offset: undefined })} className={cn('flex min-h-8 items-center justify-between gap-2 px-1 text-[13px] hover:bg-surface-2', on ? 'bg-surface-2 font-medium text-ink' : 'text-ink-2')} aria-current={on ? 'true' : undefined} title={it.sub}>
118 + <span className="truncate">
119 + {it.label ?? it.value}
120 + {it.sub && <span className="ml-1 text-[10px] uppercase tracking-wide text-ink-3">{it.sub}</span>}
121 + </span>
122 + <span className="tnum shrink-0 text-xs text-ink-3">{fmtInt(it.count as never)}</span>
123 + </Link>
124 + </li>
125 + );
126 + })}
127 + </ul>
128 + </div>
129 + ))}
130 + </div>
131 + );
132 +}
133 +
134 +/** "Understood as" chips: how the API read each active filter (with the parsed number where relevant). Each chip removes its filter. */
135 +export function UnderstoodChips({ current, facets, className }: { current: Current; facets?: ModelFacets11; className?: string }) {
136 + const chips: { key: string; text: string }[] = [];
137 + const orgName = facets?.organizations?.find((o) => o.slug === current.org)?.name;
138 + const famLabel = facets?.families?.find((f) => f.value === current.family)?.label;
139 + const licLabel = facets?.licenses?.find((f) => f.value === current.license)?.label;
140 + const add = (key: string, text: string | null | undefined) => text && chips.push({ key, text });
141 + add('q', current.q ? `name contains “${current.q}”` : null);
142 + add('org', current.org ? `organization = ${orgName ?? current.org}` : null);
143 + add('family', current.family ? `family = ${famLabel ?? current.family}` : null);
144 + add('openness', current.openness ? `openness = ${opennessLabel(current.openness)}` : null);
145 + add('license', current.license ? `licence = ${licLabel ?? current.license}` : null);
146 + add('modality', current.modality ? `modality includes ${current.modality}` : null);
147 + add('status', current.status ? `status = ${STATUS_LABELS[current.status] ?? current.status}` : null);
148 + const minP = parseScale(current.min_params);
149 + const maxP = parseScale(current.max_params);
150 + add('min_params', current.min_params ? (minP !== undefined ? `parameters ≥ ${fmtParams(minP)}` : `min params “${current.min_params}” not understood`) : null);
151 + add('max_params', current.max_params ? (maxP !== undefined ? `parameters ≤ ${fmtParams(maxP)}` : `max params “${current.max_params}” not understood`) : null);
152 + const minC = parseScale(current.min_context);
153 + add('min_context', current.min_context ? (minC !== undefined ? `context ≥ ${fmtTokens(minC)} tokens` : `min context “${current.min_context}” not understood`) : null);
154 + add('year_from', current.year_from ? `released ≥ ${current.year_from}` : null);
155 + add('year_to', current.year_to ? `released ≤ ${current.year_to}` : null);
156 + add('trust', current.trust ? `identity trust = ${IDENTITY_LABEL[current.trust as 'high'] ?? current.trust}` : null);
157 + add('reasoning', current.reasoning === '1' ? 'reasoning = yes' : null);
158 + add('include', current.include === 'artifacts' ? 'universe = models + artifacts' : null);
159 + if (!chips.length) return null;
160 + return (
161 + <div className={cn('flex flex-wrap items-center gap-1.5', className)} data-understood>
162 + <span className="eyebrow mr-1">Understood as</span>
163 + {chips.map((c) => (
164 + <Link key={c.key} href={modelsHref(current, { [c.key]: undefined, offset: undefined })} className="inline-flex h-7 items-center gap-1 border border-rule bg-surface px-2 text-xs text-ink-2 hover:border-rule-strong hover:text-ink" title="Remove this filter">
165 + {c.text} <span aria-hidden className="text-ink-3">×</span>
166 + </Link>
167 + ))}
168 + <Link href="/models" className="text-xs text-ink-3 hover:text-ink">
169 + clear all
170 + </Link>
171 + </div>
172 + );
173 +}
added apps/web/src/components/models/models-terminal.tsx +370 −0
@@ -0,0 +1,370 @@
1 +'use client';
2 +import { Columns3, ScanSearch } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
5 +import { CompareButton } from '@/components/compare/compare-button';
6 +import { TerminalLayout } from '@/components/layout/terminal';
7 +import { StatusBadge } from '@/components/ui/badges';
8 +import { EntityLink, QualityMark } from '@/components/ui/entity';
9 +import { Sheet } from '@/components/ui/sheet';
10 +import { WatchButton } from '@/components/watchlist/watch-button';
11 +import { cn } from '@/lib/cn';
12 +import { DASH, fmtDate, fmtInt, fmtParams, fmtTokens, fmtUsdPerM, num } from '@/lib/format';
13 +import { routes, STATUS_LABELS } from '@/lib/site';
14 +import type { ModelRow } from '@/lib/types';
15 +import { ArtifactKindChip, IdentityBadge, OpennessChip } from './badges';
16 +import { IDENTITY_LABEL, opennessLabel, rowPrice } from './shared';
17 +
18 +/*
19 + /models terminal (client): shares the "inspected row" between the table (main pane) and the inspector (right pane / mobile sheet).
20 + The filter rail is a server-rendered node passed through untouched. Column visibility lives in localStorage['aia-models-cols'].
21 +*/
22 +
23 +type Ctx = { selected: ModelRow | null; select: (m: ModelRow | null, opts?: { sheet?: boolean }) => void };
24 +const InspectCtx = createContext<Ctx>({ selected: null, select: () => undefined });
25 +
26 +export const COLUMNS = [
27 + { key: 'params', label: 'Params', num: true },
28 + { key: 'context', label: 'Context', num: true },
29 + { key: 'openness', label: 'Openness' },
30 + { key: 'license', label: 'Licence' },
31 + { key: 'release', label: 'Released' },
32 + { key: 'price', label: 'Best price in / out', num: true },
33 + { key: 'quality', label: 'Data quality', num: true },
34 +] as const;
35 +type ColKey = (typeof COLUMNS)[number]['key'];
36 +const COLS_KEY = 'aia-models-cols';
37 +const DEFAULT_COLS: ColKey[] = ['params', 'context', 'openness', 'license', 'release', 'quality'];
38 +
39 +export function ModelsTerminal({ filters, filterCount, children, items }: { filters: ReactNode; filterCount: number; children: ReactNode; items: ModelRow[] }) {
40 + const [selected, setSelected] = useState<ModelRow | null>(null);
41 + const [sheet, setSheet] = useState(false);
42 + const select = useCallback((m: ModelRow | null, opts?: { sheet?: boolean }) => {
43 + setSelected(m);
44 + if (m && opts?.sheet && typeof window !== 'undefined' && window.matchMedia('(max-width: 1023px)').matches) setSheet(true);
45 + }, []);
46 + // Default inspector target: the first row (so the pane is never empty when rows exist).
47 + const target = selected ?? items[0] ?? null;
48 + const value = useMemo(() => ({ selected, select }), [selected, select]);
49 + return (
50 + <InspectCtx.Provider value={value}>
51 + <TerminalLayout filters={filters} filtersTitle="Filters" filterCount={filterCount} inspector={<Inspector m={target} placeholder={!selected} />} inspectorTitle="Inspector" storageKey="aia-models-inspector">
52 + {children}
53 + </TerminalLayout>
54 + <Sheet open={sheet} onClose={() => setSheet(false)} side="bottom" eyebrow="Inspector" title={target?.name}>
55 + <Inspector m={target} placeholder={false} />
56 + </Sheet>
57 + </InspectCtx.Provider>
58 + );
59 +}
60 +
61 +/* ---------------------------------------------------------------------------------------------------------- inspector */
62 +
63 +function Inspector({ m, placeholder }: { m: ModelRow | null; placeholder: boolean }) {
64 + if (!m) return <p className="text-sm text-ink-3">No row to inspect — adjust the filters.</p>;
65 + const a = m.attributes ?? {};
66 + const p = num(a.parameter_count);
67 + const ap = num(a.active_parameter_count);
68 + const facts: { k: string; v: ReactNode }[] = [
69 + { k: 'Parameters', v: p === null ? DASH : `${fmtParams(p)}${ap !== null && ap !== p ? ` · ${fmtParams(ap)} active` : ''}` },
70 + { k: 'Context', v: num(a.context_length) === null ? DASH : `${fmtTokens(a.context_length)} tokens` },
71 + { k: 'Max output', v: num(a.max_output_tokens) === null ? DASH : `${fmtTokens(a.max_output_tokens)} tokens` },
72 + { k: 'Openness', v: opennessLabel(a.openness) },
73 + { k: 'Licence', v: typeof a.license_key === 'string' ? a.license_key : typeof a.license === 'string' ? a.license : DASH },
74 + { k: 'Released', v: typeof a.release_date === 'string' ? fmtDate(a.release_date) : DASH },
75 + { k: 'Status', v: STATUS_LABELS[m.status] ?? m.status ?? DASH },
76 + { k: 'Knowledge cutoff', v: typeof a.knowledge_cutoff === 'string' ? fmtDate(a.knowledge_cutoff) : DASH },
77 + { k: 'Modalities', v: Array.isArray(a.modalities) && a.modalities.length ? (a.modalities as string[]).join(', ') : DASH },
78 + { k: 'Family', v: m.family ? <Link href={routes.family(m.family.slug)} className="link">{m.family.name}</Link> : typeof a.family === 'string' ? a.family : DASH },
79 + { k: 'Identity', v: m.identity_confidence ? IDENTITY_LABEL[m.identity_confidence] : DASH },
80 + ];
81 + const isArtifact = m.entity_type === 'artifact';
82 + return (
83 + <div className="space-y-4 text-sm" data-models-inspector data-inspected={m.slug}>
84 + {placeholder && <p className="text-[11px] text-ink-3">Showing the first row — press “Inspect” on any row (or focus it and press Enter).</p>}
85 + <div>
86 + <p className="flex flex-wrap items-center gap-1.5">
87 + <EntityLink e={isArtifact ? { ...m, entity_type: 'artifact' } : m} className="text-[15px] font-semibold" />
88 + <StatusBadge status={m.status !== 'active' ? m.status : null} />
89 + {isArtifact && <ArtifactKindChip kind={m.artifact_kind} />}
90 + </p>
91 + {m.organization && (
92 + <Link href={routes.entity({ entity_type: 'company', slug: m.organization.slug })} className="text-xs text-ink-3 hover:text-accent">
93 + {m.organization.name}
94 + </Link>
95 + )}
96 + {isArtifact && m.canonical && (
97 + <p className="mt-1 text-xs text-ink-2">
98 + Packaging of <EntityLink e={m.canonical} className="font-medium" /> — not an independent model.
99 + </p>
100 + )}
101 + {m.description && <p className="mt-2 line-clamp-4 text-[13px] leading-relaxed text-ink-2">{m.description}</p>}
102 + </div>
103 + <dl className="kv [&>div]:grid-cols-[7rem_minmax(0,1fr)] [&>div]:py-1">
104 + {facts.map((f) => (
105 + <div key={f.k}>
106 + <dt>{f.k}</dt>
107 + <dd className="tnum text-ink">{f.v}</dd>
108 + </div>
109 + ))}
110 + </dl>
111 + <div className="text-[11px] leading-5 text-ink-3">
112 + <p className="flex items-center gap-2">
113 + <QualityMark q={m.quality?.score} label /> {m.quality?.score === undefined && 'Data quality not computed yet'}
114 + </p>
115 + <p title={m.updated_at}>Updated {fmtDate(m.updated_at)} · first seen {fmtDate(m.first_seen_at)}</p>
116 + <p>
117 + {fmtInt(m.counts?.claims)} claims · {fmtInt(m.counts?.relations)} relations · {fmtInt(m.counts?.events)} events
118 + </p>
119 + <p>Field-level provenance (source, tier, observed time) is on the model page — every value opens the evidence drawer there.</p>
120 + </div>
121 + <div className="flex flex-wrap items-center gap-1.5">
122 + <CompareButton e={m} size="sm" />
123 + <WatchButton e={m} size="sm" />
124 + <Link href={routes.entity(isArtifact ? { ...m, entity_type: 'artifact' } : m)} className="inline-flex h-7 items-center border border-rule px-1.5 text-xs text-ink-2 hover:border-rule-strong hover:text-ink">
125 + Open page →
126 + </Link>
127 + <Link href={routes.graph(m.slug)} className="inline-flex h-7 items-center border border-rule px-1.5 text-xs text-ink-2 hover:border-rule-strong hover:text-ink">
128 + Graph
129 + </Link>
130 + </div>
131 + </div>
132 + );
133 +}
134 +
135 +/* ---------------------------------------------------------------------------------------------------------- table */
136 +
137 +export function ModelsTable({ items, sort, order, sortHref, orgHrefTemplate, offset }: { items: ModelRow[]; sort: string; order?: string; sortHref: Record<string, string>; /** URL with `__ORG__` where the organization slug goes (functions cannot cross the server → client boundary). */ orgHrefTemplate: string; offset: number }) {
138 + const orgHref = (slug: string) => orgHrefTemplate.replace('__ORG__', encodeURIComponent(slug));
139 + const { selected, select } = useContext(InspectCtx);
140 + const [cols, setCols] = useState<ColKey[]>(DEFAULT_COLS);
141 + const [ready, setReady] = useState(false);
142 + const [chooser, setChooser] = useState(false);
143 + const bodyRef = useRef<HTMLTableSectionElement>(null);
144 + useEffect(() => {
145 + try {
146 + const raw = localStorage.getItem(COLS_KEY);
147 + if (raw) {
148 + const arr = JSON.parse(raw) as unknown;
149 + if (Array.isArray(arr)) setCols(COLUMNS.map((c) => c.key).filter((k) => (arr as string[]).includes(k)));
150 + }
151 + } catch {
152 + /* ignore */
153 + }
154 + setReady(true);
155 + }, []);
156 + const toggleCol = (k: ColKey) => {
157 + setCols((cur) => {
158 + const next = cur.includes(k) ? cur.filter((x) => x !== k) : COLUMNS.map((c) => c.key).filter((x) => x === k || cur.includes(x));
159 + try {
160 + localStorage.setItem(COLS_KEY, JSON.stringify(next));
161 + } catch {
162 + /* ignore */
163 + }
164 + return next;
165 + });
166 + };
167 + const show = (k: ColKey) => !ready || cols.includes(k);
168 +
169 + const onRowKey = (e: React.KeyboardEvent<HTMLTableRowElement>, m: ModelRow, i: number) => {
170 + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
171 + e.preventDefault();
172 + const rows = bodyRef.current?.querySelectorAll<HTMLTableRowElement>('tr[data-row]');
173 + rows?.[i + (e.key === 'ArrowDown' ? 1 : -1)]?.focus();
174 + } else if (e.key === 'Enter' || e.key === ' ' || e.key.toLowerCase() === 'i') {
175 + if ((e.target as HTMLElement).closest('a, button, input')) return;
176 + e.preventDefault();
177 + select(m, { sheet: e.key !== 'i' });
178 + }
179 + };
180 +
181 + const SortTh = ({ s, children, num: n, hide }: { s: string; children: ReactNode; num?: boolean; hide?: boolean }) =>
182 + hide ? null : (
183 + <th scope="col" className={cn(n && 'num')} aria-sort={sort === s ? (order === 'asc' ? 'ascending' : 'descending') : undefined}>
184 + <Link href={sortHref[s] ?? '#'} className={cn('inline-flex items-center gap-0.5', sort === s ? 'text-ink' : 'hover:text-ink')}>
185 + {children}
186 + {sort === s && <span aria-hidden>{order === 'asc' ? '↑' : '↓'}</span>}
187 + </Link>
188 + </th>
189 + );
190 +
191 + return (
192 + <div data-models-table>
193 + <div className="flex items-center justify-end gap-2 pb-1">
194 + <div className="relative">
195 + <button type="button" onClick={() => setChooser((c) => !c)} aria-expanded={chooser} aria-haspopup="true" className="inline-flex h-8 items-center gap-1.5 border border-rule px-2 text-xs text-ink-2 hover:border-rule-strong hover:text-ink" data-column-chooser>
196 + <Columns3 className="size-3.5" aria-hidden /> Columns <span className="tnum text-ink-3">{ready ? cols.length : DEFAULT_COLS.length}/{COLUMNS.length}</span>
197 + </button>
198 + {chooser && (
199 + <div className="panel absolute right-0 top-9 z-40 w-56 p-2 shadow-lg" role="group" aria-label="Visible columns">
200 + {COLUMNS.map((c) => (
201 + <label key={c.key} className="flex min-h-9 items-center gap-2 px-1 text-[13px] text-ink-2 hover:bg-surface-2">
202 + <input type="checkbox" checked={show(c.key)} onChange={() => toggleCol(c.key)} className="size-4 accent-[var(--accent)]" /> {c.label}
203 + </label>
204 + ))}
205 + <p className="px-1 pt-1 text-[10px] text-ink-3">Saved in this browser.</p>
206 + </div>
207 + )}
208 + </div>
209 + </div>
210 + {/* ≥ md: the dense table scrolls horizontally inside its pane when needed (a scroll container offsets a sticky header, so the header stays static); < md rows stack. */}
211 + <div className="relative scrollbar-thin md:overflow-x-auto">
212 + <table className="data-table stack compact md:[&_td]:align-top md:[&_td]:px-2 md:[&_th]:px-2">
213 + <caption className="sr-only">Models</caption>
214 + <thead>
215 + <tr>
216 + <th scope="col" className="tnum hidden w-8 text-ink-3 xl:table-cell">
217 + #
218 + </th>
219 + <SortTh s="name">Model</SortTh>
220 + <SortTh s="params" num hide={!show('params')}>
221 + Params
222 + </SortTh>
223 + <SortTh s="context" num hide={!show('context')}>
224 + Context
225 + </SortTh>
226 + {show('openness') && <th scope="col">Openness</th>}
227 + {show('license') && <th scope="col">Licence</th>}
228 + <SortTh s="release" hide={!show('release')}>
229 + Released
230 + </SortTh>
231 + <SortTh s="cheapest" num hide={!show('price')}>
232 + Best price in / out
233 + </SortTh>
234 + <SortTh s="quality" num hide={!show('quality')}>
235 + Data quality
236 + </SortTh>
237 + <th scope="col" className="text-right">
238 + <span className="sr-only">Actions</span>
239 + </th>
240 + </tr>
241 + </thead>
242 + <tbody ref={bodyRef}>
243 + {items.length === 0 && (
244 + <tr>
245 + <td colSpan={10} className="py-10 text-center text-sm text-ink-3">
246 + No models match these filters.
247 + </td>
248 + </tr>
249 + )}
250 + {items.map((m, i) => {
251 + const a = m.attributes ?? {};
252 + const p = num(a.parameter_count);
253 + const ap = num(a.active_parameter_count);
254 + const on = selected?.slug === m.slug;
255 + const isArtifact = m.entity_type === 'artifact';
256 + const licence = typeof a.license_key === 'string' ? a.license_key : typeof a.license === 'string' ? a.license : null;
257 + const pin = rowPrice(a, 'input');
258 + const pout = rowPrice(a, 'output');
259 + return (
260 + <tr
261 + key={m.id}
262 + data-row={m.slug}
263 + tabIndex={0}
264 + aria-selected={on}
265 + onKeyDown={(e) => onRowKey(e, m, i)}
266 + onClick={(e) => {
267 + if ((e.target as HTMLElement).closest('a, button, input')) return;
268 + select(m);
269 + }}
270 + className={cn('cursor-default focus-visible:outline-2 focus-visible:outline-accent', on && 'bg-accent-soft/40')}
271 + >
272 + <td className="tnum hide-stack hidden text-ink-3 xl:table-cell">{fmtInt(offset + i + 1)}</td>
273 + <td className="primary min-w-[11rem] md:max-w-[20rem]">
274 + <div className="min-w-0">
275 + <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
276 + <EntityLink e={isArtifact ? { ...m, entity_type: 'artifact' } : m} />
277 + <StatusBadge status={m.status !== 'active' ? m.status : null} />
278 + <IdentityBadge level={m.identity_confidence} />
279 + {isArtifact && <ArtifactKindChip kind={m.artifact_kind} />}
280 + </div>
281 + <div className="flex flex-wrap items-center gap-x-1.5 text-xs text-ink-3">
282 + {m.organization && (
283 + <Link href={orgHref(m.organization.slug)} className="hover:text-accent">
284 + {m.organization.name}
285 + </Link>
286 + )}
287 + {m.family ? (
288 + <>
289 + <span aria-hidden>·</span>
290 + <Link href={routes.family(m.family.slug)} className="hover:text-accent">
291 + {m.family.name}
292 + </Link>
293 + </>
294 + ) : typeof a.family === 'string' ? (
295 + <>
296 + <span aria-hidden>·</span>
297 + <span>{a.family}</span>
298 + </>
299 + ) : null}
300 + {isArtifact && m.canonical && (
301 + <>
302 + <span aria-hidden>·</span>
303 + <span>
304 + of <EntityLink e={m.canonical} className="text-ink-2" />
305 + </span>
306 + </>
307 + )}
308 + </div>
309 + </div>
310 + </td>
311 + {show('params') && (
312 + <td className="num tnum" data-label="Params">
313 + {p === null ? <span className="text-ink-3">{DASH}</span> : fmtParams(p)}
314 + {ap !== null && ap !== p && <span className="block text-[11px] text-ink-3">{fmtParams(ap)} active</span>}
315 + </td>
316 + )}
317 + {show('context') && (
318 + <td className="num tnum" data-label="Context">
319 + {num(a.context_length) === null ? <span className="text-ink-3">{DASH}</span> : fmtTokens(a.context_length)}
320 + </td>
321 + )}
322 + {show('openness') && (
323 + <td data-label="Openness">
324 + {typeof a.openness === 'string' ? <OpennessChip openness={a.openness} /> : <span className="text-ink-3">{DASH}</span>}
325 + </td>
326 + )}
327 + {show('license') && (
328 + <td data-label="Licence" className="max-w-[11rem] truncate text-ink-2" title={licence ?? undefined}>
329 + {licence ? <Link href={`/licenses/${encodeURIComponent(licence)}`} className="hover:text-accent">{licence}</Link> : <span className="text-ink-3">{DASH}</span>}
330 + </td>
331 + )}
332 + {show('release') && (
333 + <td data-label="Released" className="tnum whitespace-nowrap text-ink-2">
334 + {typeof a.release_date === 'string' ? fmtDate(a.release_date) : <span className="text-ink-3">{DASH}</span>}
335 + </td>
336 + )}
337 + {show('price') && (
338 + <td className="num tnum whitespace-nowrap" data-label="Best price in / out" title={pin === null && pout === null ? 'No price on this row — see the model page for provider deployments' : 'Cheapest current offer across providers, USD per 1M tokens'}>
339 + {pin === null && pout === null ? (
340 + <span className="text-ink-3">{DASH}</span>
341 + ) : (
342 + <span className="text-accent-2">
343 + {fmtUsdPerM(pin)} <span className="text-ink-3">/</span> {fmtUsdPerM(pout)}
344 + </span>
345 + )}
346 + </td>
347 + )}
348 + {show('quality') && (
349 + <td className="num" data-label="Data quality">
350 + <QualityMark q={m.quality?.score} />
351 + </td>
352 + )}
353 + <td className="text-right whitespace-nowrap">
354 + <span className="inline-flex items-center justify-end gap-1">
355 + <button type="button" onClick={() => select(m, { sheet: true })} aria-pressed={on} className={cn('inline-flex h-7 items-center gap-1 border px-1.5 text-xs whitespace-nowrap', on ? 'border-accent bg-accent-soft text-accent' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')} data-inspect={m.slug} title="Inspect: key facts in the side pane" aria-label={`Inspect ${m.name}`}>
356 + <ScanSearch className="size-3" aria-hidden /> <span className="md:sr-only xl:not-sr-only">Inspect</span>
357 + </button>
358 + <CompareButton e={m} size="sm" label="" />
359 + <WatchButton e={m} size="sm" label="" />
360 + </span>
361 + </td>
362 + </tr>
363 + );
364 + })}
365 + </tbody>
366 + </table>
367 + </div>
368 + </div>
369 + );
370 +}
added apps/web/src/components/models/openness-block.tsx +96 −0
@@ -0,0 +1,96 @@
1 +import Link from 'next/link';
2 +import { cn } from '@/lib/cn';
3 +import type { LicenseInfo, ModelLicence, ModelOpenness } from '@/lib/types';
4 +import { OpennessChip, Tri } from './badges';
5 +
6 +/*
7 + Openness explanation, reused by model pages and licence pages:
8 + "Open weights — weights downloadable under Apache-2.0; code available; training data not disclosed" built from `openness.dimensions`.
9 + Unknown dimensions are said to be unknown (null ≠ false).
10 +*/
11 +
12 +export const OPENNESS_DIMENSIONS: { key: string; label: string; yes: string; no: string; hint: string }[] = [
13 + { key: 'weights_available', label: 'Weights', yes: 'weights downloadable', no: 'weights not available', hint: 'Can the model weights be downloaded (Hugging Face or an official mirror)?' },
14 + { key: 'source_code_available', label: 'Inference code', yes: 'inference code available', no: 'inference code not published', hint: 'Is the inference / modelling code published?' },
15 + { key: 'training_code_available', label: 'Training code', yes: 'training code available', no: 'training code not published', hint: 'Is the training code published?' },
16 + { key: 'training_data_disclosed', label: 'Training data', yes: 'training data disclosed', no: 'training data not disclosed', hint: 'Is the composition of the training data documented?' },
17 + { key: 'dataset_available', label: 'Dataset', yes: 'training dataset downloadable', no: 'training dataset not released', hint: 'Can the training dataset itself be downloaded?' },
18 + { key: 'commercial_use_allowed', label: 'Commercial use', yes: 'commercial use allowed', no: 'commercial use restricted', hint: 'Does the licence allow commercial use without a separate agreement?' },
19 + { key: 'redistribution_allowed', label: 'Redistribution', yes: 'redistribution allowed', no: 'redistribution restricted', hint: 'May the weights be redistributed (mirrors, bundles)?' },
20 + { key: 'derivatives_allowed', label: 'Derivatives', yes: 'derivatives allowed', no: 'derivatives restricted', hint: 'May fine-tunes, merges and quantisations be published?' },
21 +];
22 +
23 +export const LICENCE_DIMENSIONS: { key: keyof LicenseInfo; label: string; hint: string; invert?: boolean }[] = [
24 + { key: 'commercial_use', label: 'Commercial use', hint: 'Use in a commercial product or service without a separate agreement.' },
25 + { key: 'redistribution', label: 'Redistribution', hint: 'Sharing the licensed weights or code with third parties.' },
26 + { key: 'derivatives', label: 'Derivatives', hint: 'Publishing fine-tunes, merges, quantisations or other modified versions.' },
27 + { key: 'hosting_restrictions', label: 'Hosting restrictions', hint: 'Restrictions on serving the model as a hosted API (user caps, field-of-use, competitor clauses). Yes = restrictions exist.', invert: true },
28 + { key: 'acceptable_use', label: 'Acceptable-use policy', hint: 'The licence attaches an acceptable-use policy / behavioural restrictions. Yes = a policy applies.', invert: true },
29 + { key: 'attribution', label: 'Attribution', hint: 'Attribution or notice requirements.' },
30 +];
31 +
32 +/** One-line sentence from the dimensions: "weights downloadable under Apache-2.0; inference code available; training data not disclosed". */
33 +export function opennessSentence(o: ModelOpenness, licenceLabel?: string | null): string {
34 + const parts: string[] = [];
35 + for (const dim of OPENNESS_DIMENSIONS) {
36 + const v = o.dimensions?.[dim.key];
37 + if (v === true) parts.push(dim.key === 'weights_available' && licenceLabel ? `${dim.yes} under ${licenceLabel}` : dim.yes);
38 + else if (v === false) parts.push(dim.no);
39 + }
40 + const unknown = OPENNESS_DIMENSIONS.filter((d) => o.dimensions?.[d.key] === null || o.dimensions?.[d.key] === undefined).length;
41 + let s = parts.join('; ');
42 + if (unknown) s += `${s ? '; ' : ''}${unknown} dimension${unknown === 1 ? '' : 's'} unknown`;
43 + return s;
44 +}
45 +
46 +export function OpennessBlock({ openness, licence, className, compact = false }: { openness: ModelOpenness | null | undefined; licence?: ModelLicence | null; className?: string; compact?: boolean }) {
47 + if (!openness) return <p className={cn('text-sm text-ink-3', className)}>Openness not classified yet — no sourced evidence to place this model in the ontology.</p>;
48 + const lic = licence && 'key' in licence && licence.key ? (licence as LicenseInfo & { raw?: string | null }) : null;
49 + const licLabel = lic ? lic.key : licence && licence.raw ? licence.raw : null;
50 + return (
51 + <div className={cn('text-sm', className)} data-openness-block>
52 + <p className="flex flex-wrap items-center gap-2">
53 + <OpennessChip openness={openness.category} label={openness.label} />
54 + <span className="text-ink-2">— {opennessSentence(openness, licLabel)}.</span>
55 + </p>
56 + <p className="mt-1 text-xs text-ink-3">{openness.definition}</p>
57 + {!compact && (
58 + <ul className="mt-3 grid grid-cols-2 gap-px border border-rule bg-rule sm:grid-cols-4">
59 + {OPENNESS_DIMENSIONS.map((dim) => (
60 + <li key={dim.key} className="bg-canvas px-2.5 py-2">
61 + {/* native title tooltip: the absolute Hint bubble (18rem) widens narrow viewports from a 2-column grid */}
62 + <p className="cursor-help text-[11px] text-ink-3 underline decoration-dotted decoration-rule-strong underline-offset-2" title={dim.hint}>
63 + {dim.label}
64 + </p>
65 + <p className="mt-0.5 text-[13px]">
66 + <Tri v={openness.dimensions?.[dim.key]} yes="Yes" no="No" />
67 + </p>
68 + </li>
69 + ))}
70 + </ul>
71 + )}
72 + {(lic || (licence && licence.raw)) && (
73 + <p className="mt-3 text-xs text-ink-2">
74 + Licence:{' '}
75 + {lic ? (
76 + <>
77 + <Link href={`/licenses/${encodeURIComponent(lic.key)}`} className="link font-medium">
78 + {lic.label}
79 + </Link>{' '}
80 + <span className="text-ink-3">
81 + ({lic.category}
82 + {lic.spdx ? ` · SPDX ${lic.spdx}` : ''}
83 + {lic.raw && lic.raw !== lic.key ? ` · stated as “${lic.raw}”` : ''})
84 + </span>
85 + </>
86 + ) : (
87 + <span>
88 + stated as “{licence?.raw}” — <span className="text-ink-3">not mapped to a canonical licence yet{licence && 'note' in licence && licence.note ? ` (${licence.note})` : ''}</span>
89 + </span>
90 + )}
91 + </p>
92 + )}
93 + {openness.note && <p className="mt-1 text-[11px] text-ink-3">{openness.note}</p>}
94 + </div>
95 + );
96 +}
added apps/web/src/components/models/price-history.tsx +134 −0
@@ -0,0 +1,134 @@
1 +'use client';
2 +import { useMemo, useRef } from 'react';
3 +import { Legend, LineChart, lineLayout, type Series, stepPoints } from '@/components/charts/charts';
4 +import { useEvidence } from '@/components/evidence';
5 +import { Note } from '@/components/ui/section';
6 +import { cn } from '@/lib/cn';
7 +import { fmtDate, fmtUsdPerM, hostOf, num } from '@/lib/format';
8 +import type { Price } from '@/lib/types';
9 +
10 +/*
11 + Price history 3.0 (client): one step line per provider (output price by default, input on toggle by the parent), a legend, and a
12 + transitions list. Clicking the chart or a transition opens the evidence drawer for that price row (source, tier, observed time).
13 +*/
14 +
15 +type Transition = { id: string; provider: string; providerSlug: string; at: string; from: number | null; to: number | null; row: Price };
16 +
17 +export function buildTransitions(history: Price[], field: 'input_per_mtok' | 'output_per_mtok'): Transition[] {
18 + const byProv = new Map<string, Price[]>();
19 + for (const p of history) {
20 + const arr = byProv.get(p.provider.slug) ?? [];
21 + arr.push(p);
22 + byProv.set(p.provider.slug, arr);
23 + }
24 + const out: Transition[] = [];
25 + for (const rows of byProv.values()) {
26 + const sorted = [...rows].sort((a, b) => a.valid_from.localeCompare(b.valid_from));
27 + let prev: number | null = null;
28 + for (const r of sorted) {
29 + const v = num(r[field]);
30 + if (v === null) continue;
31 + if (prev === null || prev !== v) out.push({ id: r.id, provider: r.provider.name, providerSlug: r.provider.slug, at: r.valid_from, from: prev, to: v, row: r });
32 + prev = v;
33 + }
34 + }
35 + return out.sort((a, b) => a.at.localeCompare(b.at));
36 +}
37 +
38 +export function PriceHistoryChart({ history, field, modelSlug, modelName, className }: { history: Price[]; field: 'input_per_mtok' | 'output_per_mtok'; modelSlug: string; modelName: string; className?: string }) {
39 + const { open } = useEvidence();
40 + const wrap = useRef<HTMLDivElement>(null);
41 + const byProv = useMemo(() => {
42 + const m = new Map<string, Price[]>();
43 + for (const p of history) {
44 + const arr = m.get(p.provider.name) ?? [];
45 + arr.push(p);
46 + m.set(p.provider.name, arr);
47 + }
48 + return m;
49 + }, [history]);
50 + // "Now" anchor for open-ended steps = the latest observation in the data (deterministic → identical server and client SVG).
51 + const nowAnchor = useMemo(() => history.reduce((m, p) => (p.observed_at > m ? p.observed_at : m), history[0]?.observed_at ?? ''), [history]);
52 + const series: Series[] = useMemo(
53 + () =>
54 + [...byProv.entries()].slice(0, 8).map(([name, rows]) => {
55 + const sorted = [...rows].sort((a, b) => a.valid_from.localeCompare(b.valid_from));
56 + const last = sorted[sorted.length - 1];
57 + const pts = stepPoints(sorted.map((r) => ({ at: r.valid_from, value: num(r[field]) })));
58 + if (last && !last.valid_to && num(last[field]) !== null && nowAnchor && nowAnchor > last.valid_from) pts.push({ x: new Date(nowAnchor), y: num(last[field]) as number });
59 + return { name, points: pts };
60 + }).filter((s) => s.points.length > 0),
61 + [byProv, field, nowAnchor],
62 + );
63 + const transitions = useMemo(() => buildTransitions(history, field), [history, field]);
64 + const L = useMemo(() => lineLayout({ series, height: 220, xTime: true, yDomain: [0, Math.max(...series.flatMap((s) => s.points.map((p) => p.y)), 0) * 1.15 || 1] }), [series]);
65 + const label = field === 'input_per_mtok' ? 'Input price' : 'Output price';
66 +
67 + const openFor = (t: Transition) => {
68 + open({
69 + slug: modelSlug,
70 + property: `price.${field === 'input_per_mtok' ? 'input' : 'output'}.${t.providerSlug}`,
71 + value: t.to,
72 + display: `${fmtUsdPerM(t.to)} / 1M tokens`,
73 + label: `${label} · ${t.provider}`,
74 + fallback: { source_id: null, source_name: hostOf(t.row.source_url) ?? t.provider, url: t.row.source_url, observed_at: t.row.observed_at, tier: t.row.tier, confidence: 'high', extractor: 'deterministic', unit: 'USD / 1M tokens' },
75 + entity: { name: modelName, entity_type: 'model' },
76 + });
77 + };
78 + const onClick = (clientX: number) => {
79 + if (!L || !wrap.current || !transitions.length) return;
80 + const rect = wrap.current.getBoundingClientRect();
81 + const vx = ((clientX - rect.left) / rect.width) * L.w;
82 + const t = L.xInvert(vx);
83 + let best = transitions[0] as Transition;
84 + for (const tr of transitions) if (Math.abs(new Date(tr.at).getTime() - t) < Math.abs(new Date(best.at).getTime() - t)) best = tr;
85 + openFor(best);
86 + };
87 +
88 + const points = series.reduce((n, s) => n + s.points.length, 0);
89 + if (!history.length) return null;
90 + return (
91 + <div className={cn('space-y-3', className)} data-price-history={field}>
92 + <p className="eyebrow">
93 + {label} · USD / 1M tokens <span className="tnum text-ink-3">{byProv.size} provider{byProv.size === 1 ? '' : 's'}</span>
94 + </p>
95 + {points < 2 || !L ? (
96 + <Note>Price history starts with the first observation — no change recorded yet for this side ({history.length} row{history.length === 1 ? '' : 's'}).</Note>
97 + ) : (
98 + <>
99 + <div ref={wrap} className="cursor-pointer" onClick={(e) => onClick(e.clientX)} title="Click to open the evidence of the nearest price change" data-price-chart>
100 + <LineChart series={series} height={220} step yFormat={(v) => fmtUsdPerM(v)} yDomain={[0, Math.max(...series.flatMap((s) => s.points.map((p) => p.y))) * 1.15 || 1]} yLabel={`${label} history of ${modelName}`}>
101 + {transitions.map((t) => (
102 + <circle key={t.id} cx={L.x(new Date(t.at).getTime())} cy={L.y(t.to ?? 0)} r={3.5} fill="var(--accent-2)" stroke="var(--canvas)" strokeWidth={1.2}>
103 + <title>{`${t.provider}: ${t.from === null ? 'first observed' : fmtUsdPerM(t.from)} → ${fmtUsdPerM(t.to)} · ${fmtDate(t.at)}`}</title>
104 + </circle>
105 + ))}
106 + </LineChart>
107 + </div>
108 + <Legend series={series} />
109 + </>
110 + )}
111 + <ul className="divide-y divide-rule border-y border-rule text-sm">
112 + {transitions
113 + .slice()
114 + .reverse()
115 + .slice(0, 12)
116 + .map((t) => (
117 + <li key={t.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-x-3 py-1.5 sm:grid-cols-[10rem_minmax(0,1fr)_auto]">
118 + <span className="truncate text-ink-2">{t.provider}</span>
119 + <span className="tnum col-span-2 sm:col-span-1">
120 + {t.from === null ? <span className="text-ink-3">first observed </span> : <span className="text-ink-3">{fmtUsdPerM(t.from)} → </span>}
121 + <span className={cn('font-medium', t.from !== null && t.to !== null && t.to < t.from ? 'text-positive' : t.from !== null && t.to !== null && t.to > t.from ? 'text-danger' : 'text-accent-2')}>{fmtUsdPerM(t.to)}</span>
122 + </span>
123 + <span className="tnum flex items-center gap-2 text-xs text-ink-3">
124 + {fmtDate(t.at)}
125 + <button type="button" className="evidence text-accent" onClick={() => openFor(t)} data-evidence={`${modelSlug}:price.${t.providerSlug}`}>
126 + Evidence
127 + </button>
128 + </span>
129 + </li>
130 + ))}
131 + </ul>
132 + </div>
133 + );
134 +}
added apps/web/src/components/models/scroll-to-section.tsx +21 −0
@@ -0,0 +1,21 @@
1 +'use client';
2 +import { useSearchParams } from 'next/navigation';
3 +import { useEffect } from 'react';
4 +
5 +/**
6 + * Legacy `?tab=<id>` deep links (evidence drawer "View history" → `?tab=history&property=`) land on the matching section of the
7 + * section-nav layout. Maps old tab ids to section anchors; does nothing when the parameter is absent.
8 + */
9 +const MAP: Record<string, string> = { history: 'change-history', benchmarks: 'benchmarks', providers: 'providers-pricing', hardware: 'hardware-fit', lineage: 'lineage', capabilities: 'capabilities', research: 'papers', timeline: 'timeline', sources: 'provenance' };
10 +
11 +export function ScrollToSection() {
12 + const sp = useSearchParams();
13 + const tab = sp.get('tab');
14 + useEffect(() => {
15 + if (!tab) return;
16 + const id = MAP[tab] ?? tab;
17 + const el = document.getElementById(id);
18 + if (el) el.scrollIntoView({ block: 'start' });
19 + }, [tab]);
20 + return null;
21 +}
added apps/web/src/components/models/scroll-x.tsx +12 −0
@@ -0,0 +1,12 @@
1 +import type { ReactNode } from 'react';
2 +import { cn } from '@/lib/cn';
3 +
4 +/**
5 + * Tablet-safe wrapper for dense `.data-table.stack` tables: rows stack under 768 px (the table's own behaviour) and the table
6 + * scrolls horizontally from 768 px up when its columns are wider than the pane, instead of widening the page.
7 + * (A scroll container offsets `position: sticky` headers, so tables inside it keep a static header.)
8 + */
9 +export function ScrollX({ children, className }: { children: ReactNode; className?: string }) {
10 + // `relative`: absolutely positioned descendants (sr-only captions, quality bars) must take the wrapper as containing block, or they escape its clip and widen the page.
11 + return <div className={cn('relative md:overflow-x-auto md:scrollbar-thin', className)}>{children}</div>;
12 +}
added apps/web/src/components/models/shared.ts +141 −0
@@ -0,0 +1,141 @@
1 +/**
2 + * Pure helpers shared by the D1 pages (models · artifacts · families · benchmarks · compare · licences).
3 + * No React here — safe to import from server and client components.
4 + */
5 +import { DASH, fmtCompact, fmtParams, fmtTokens, fmtUsdPerM, num } from '@/lib/format';
6 +import type { Comparability, EntitySummary, Group, IdentityConfidence, ModelRef } from '@/lib/types';
7 +
8 +/** Ontology openness labels (mirrors `/methodology.openness.labels`; the API's own label wins when present). */
9 +export const OPENNESS_11: Record<string, string> = {
10 + 'open-source': 'Open source',
11 + 'open-weights': 'Open weights',
12 + 'restricted-weights': 'Restricted weights',
13 + restricted: 'Restricted weights',
14 + proprietary: 'Closed',
15 + unknown: 'Unknown',
16 +};
17 +export function opennessLabel(v: unknown, labels?: Record<string, string>): string {
18 + if (typeof v !== 'string' || !v) return DASH;
19 + return labels?.[v] ?? OPENNESS_11[v] ?? v;
20 +}
21 +
22 +/** Trust levels of benchmark rows (`/methodology.trust_levels`); short labels for badges, long ones in `title`. */
23 +export const TRUST_SHORT: Record<string, string> = {
24 + 'official-model-card': 'Self-reported',
25 + 'official-benchmark': 'Official board',
26 + 'peer-reviewed': 'Peer-reviewed',
27 + 'independent-evaluator': 'Independent',
28 + community: 'Community',
29 + unverified: 'Unverified',
30 +};
31 +export const TRUST_LONG: Record<string, string> = {
32 + 'official-model-card': 'Official model card / technical report (self-reported)',
33 + 'official-benchmark': 'Official benchmark leaderboard (submissions checked by the benchmark owner)',
34 + 'peer-reviewed': 'Peer-reviewed paper',
35 + 'independent-evaluator': 'Independent third-party evaluator',
36 + community: 'Community-run leaderboard or submission',
37 + unverified: 'Unverified / unknown provenance',
38 +};
39 +export function trustShort(level: string | null | undefined, label?: string | null): string {
40 + if (!level) return label ?? DASH;
41 + return TRUST_SHORT[level] ?? label ?? level;
42 +}
43 +
44 +export const COMPARABILITY_LABEL: Record<Comparability, string> = { comparable: 'Comparable', 'partially-comparable': 'Partially comparable', 'not-comparable': 'Not comparable' };
45 +
46 +export const IDENTITY_LABEL: Record<IdentityConfidence, string> = { high: 'Identity confirmed', medium: 'Identity probable', low: 'Identity uncertain' };
47 +
48 +/** Task-defining config keys (define the comparability group) vs condition keys (same group, partially comparable). */
49 +export const TASK_KEYS = new Set(['variant', 'board', 'harness', 'evaluator', 'subset', 'split', 'shots', 'pass_count', 'attempts', 'language', 'scaffold', 'agent', 'system']);
50 +export const CONDITION_KEYS = new Set(['reasoning_effort', 'reasoning', 'thinking_budget', 'temperature', 'judge', 'tools', 'tool_use', 'max_tokens', 'context_length', 'sampling', 'aggregation', 'edit_format', 'model_tag']);
51 +/** Per-row identifiers — never useful as chips. */
52 +const NOISE_KEYS = new Set(['aa_slug', 'model_tag', 'model_id', 'run_id', 'submission_id', 'date', 'submitted_at', 'url', 'index_version']);
53 +
54 +export type ConfigChip = { key: string; value: string; kind: 'task' | 'condition' | 'other' };
55 +/** Config → compact chips (task keys first, then conditions, then the rest); `omit` drops keys already implied by the group. */
56 +export function configChipsOf(config: Record<string, unknown> | null | undefined, omit?: Record<string, unknown> | null, max = 6): ConfigChip[] {
57 + const out: ConfigChip[] = [];
58 + for (const [k, v] of Object.entries(config ?? {})) {
59 + if (v === null || v === undefined || v === '' || NOISE_KEYS.has(k)) continue;
60 + if (omit && k in omit && String(omit[k]) === String(v)) continue;
61 + const s = typeof v === 'object' ? JSON.stringify(v) : String(v);
62 + if (s.length > 40) continue;
63 + out.push({ key: k, value: s, kind: TASK_KEYS.has(k) ? 'task' : CONDITION_KEYS.has(k) ? 'condition' : 'other' });
64 + }
65 + const order = { task: 0, condition: 1, other: 2 };
66 + return out.sort((a, b) => order[a.kind] - order[b.kind] || a.key.localeCompare(b.key)).slice(0, max);
67 +}
68 +
69 +/** "753B total · 42B active · 1.31M context · Open weights (Apache-2.0)" — every fragment only when sourced. */
70 +export function identityStrip(attrs: Record<string, unknown>, opts: { opennessLabel?: string | null; licence?: string | null } = {}): { key: string; text: string }[] {
71 + const out: { key: string; text: string }[] = [];
72 + const p = num(attrs.parameter_count);
73 + const ap = num(attrs.active_parameter_count);
74 + if (p !== null) out.push({ key: 'parameter_count', text: `${fmtParams(p)} total` });
75 + if (ap !== null && ap !== p) out.push({ key: 'active_parameter_count', text: `${fmtParams(ap)} active` });
76 + if (num(attrs.context_length) !== null) out.push({ key: 'context_length', text: `${fmtTokens(attrs.context_length)} context` });
77 + const open = opts.opennessLabel ?? (typeof attrs.openness === 'string' ? opennessLabel(attrs.openness) : null);
78 + if (open) out.push({ key: 'openness', text: opts.licence ? `${open} (${opts.licence})` : open });
79 + return out;
80 +}
81 +
82 +/** Accept "70B" / "7b" / "1.5T" / "128k" / raw integers for parameter and token inputs. */
83 +export function parseScale(v: string | undefined | null): number | undefined {
84 + if (!v) return undefined;
85 + const m = /^\s*([\d.]+)\s*([kmbt])?\s*$/i.exec(v);
86 + if (!m) return undefined;
87 + const n = Number(m[1]);
88 + const mult = { k: 1e3, m: 1e6, b: 1e9, t: 1e12 }[(m[2] ?? '').toLowerCase() as 'k' | 'm' | 'b' | 't'] ?? 1;
89 + return Number.isFinite(n) ? Math.round(n * mult) : undefined;
90 +}
91 +
92 +/** Group label without the metric prefix ("variant=hard · evaluator=Artificial Analysis"). */
93 +export function groupConditions(g: Group): string {
94 + const parts = Object.entries(g.config ?? {}).map(([k, v]) => `${k}=${String(v)}`);
95 + return parts.join(' · ');
96 +}
97 +
98 +export function refToSummary(m: ModelRef): EntitySummary {
99 + return { id: m.id, entity_type: m.entity_type || 'model', slug: m.slug, name: m.name, description: null, status: 'active', organization: m.organization, attributes: m.attributes ?? {}, quality: {}, counts: {}, first_seen_at: '', last_seen_at: '', updated_at: '' };
100 +}
101 +
102 +/** Score formatter aware of the unit: 93.7% · 65.9 · 1 234 elo. */
103 +export function fmtScoreUnit(score: number | null | undefined, unit: string | null | undefined): string {
104 + const n = num(score);
105 + if (n === null) return DASH;
106 + const s = Number.isInteger(n) ? String(n) : n >= 100 ? n.toFixed(0) : n.toFixed(n < 10 ? 2 : 1);
107 + if (unit === '%') return `${s}%`;
108 + return unit ? `${s} ${unit}` : s;
109 +}
110 +
111 +/** Signed delta between two scores with the right sign for the metric direction. */
112 +export function scoreDelta(a: number, b: number, unit: string | null | undefined): string {
113 + const d = a - b;
114 + if (!Number.isFinite(d) || d === 0) return '±0';
115 + const sign = d > 0 ? '+' : '−';
116 + const abs = Math.abs(d);
117 + const s = abs >= 100 ? abs.toFixed(0) : abs.toFixed(abs < 10 ? 2 : 1);
118 + return `${sign}${s}${unit === '%' ? ' pt' : ''}`;
119 +}
120 +
121 +/** A model's cheapest output price when the API put one on the row (attributes only — nothing fetched). */
122 +export function rowPrice(attrs: Record<string, unknown>, side: 'input' | 'output'): number | null {
123 + const keys = side === 'input' ? ['cheapest_input_per_mtok', 'min_input_per_mtok', 'best_input_per_mtok'] : ['cheapest_output_per_mtok', 'min_output_per_mtok', 'best_output_per_mtok'];
124 + for (const k of keys) {
125 + const v = num(attrs[k]);
126 + if (v !== null) return v;
127 + }
128 + return null;
129 +}
130 +
131 +/** Formatter for a Pareto x axis key (`/pareto?x=`). Pure — usable on the server and the client. */
132 +const X_FMT: Record<string, (v: number) => string> = {
133 + output_price: (v) => fmtUsdPerM(v),
134 + input_price: (v) => fmtUsdPerM(v),
135 + parameter_count: (v) => fmtParams(v),
136 + context_length: (v) => fmtTokens(v),
137 + memory_estimate: (v) => `${fmtCompact(v)} GB`,
138 +};
139 +export function xFormatter(key: string): (v: number) => string {
140 + return X_FMT[key] ?? fmtCompact;
141 +}
modified apps/web/src/lib/api.ts +100 −0
@@ -183,3 +183,103 @@ export async function countOfType(type: string): Promise<number | null> {
183 183 const n = Number(v);
184 184 return Number.isFinite(n) ? n : null;
185 185 }
186 +
187 +// ---- D1 (models/benchmarks/compare) ----
188 +import type {
189 + BenchmarkDetail,
190 + BenchmarkFrontierPayload,
191 + BenchmarksPayload,
192 + ComparePayload11,
193 + FamiliesPage,
194 + FamilyDetail,
195 + LeaderboardPayload,
196 + LicenseDetail,
197 + LicensesPayload,
198 + MatrixPayload,
199 + Methodology11,
200 + ModelDetail,
201 + ModelDiffPayload,
202 + ModelsPage11,
203 + ParetoPayload,
204 +} from './types';
205 +
206 +/** 1.1 endpoints used by the models · benchmarks · compare · families · licences pages (docs/API.md §1.1). */
207 +export const apiD1 = {
208 + /** `/models` with the 1.1 facets (families, canonical licences, trust) and `include=artifacts`. */
209 + models: (query: Query) => request<ModelsPage11>('/models', query, { revalidate: 120 }),
210 + /** `/models/<slug>` — accepts models AND artifacts; folded variants come back as the canonical model with `redirected_from`. */
211 + model: (slug: string) => request<ModelDetail>(`/models/${enc(slug)}`, undefined, { revalidate: 120 }),
212 + modelDiff: (a: string, b: string) => request<ModelDiffPayload>(`/models/${enc(a)}/diff/${enc(b)}`, undefined, { revalidate: 300 }),
213 + compare: (ids: string[], opts: { diff_only?: boolean; mode?: string } = {}) => request<ComparePayload11>('/compare', { ids: ids.join(','), diff_only: opts.diff_only ? 1 : undefined, mode: opts.mode }, { revalidate: 300 }),
214 + benchmarks: (category?: string) => request<BenchmarksPayload>('/benchmarks', { category }, { revalidate: 300 }),
215 + benchmark: (slug: string) => request<BenchmarkDetail>(`/benchmarks/${enc(slug)}`, undefined, { revalidate: 120 }),
216 + leaderboard: (slug: string, query: Query = {}) => request<LeaderboardPayload>(`/benchmarks/${enc(slug)}/leaderboard`, query, { revalidate: 300 }),
217 + frontier: (slug: string, query: Query = {}) => request<BenchmarkFrontierPayload>(`/benchmarks/${enc(slug)}/frontier`, query, { revalidate: 600 }),
218 + matrix: (query: Query = {}) => request<MatrixPayload>('/benchmarks/matrix', query, { revalidate: 600 }),
219 + pareto: (query: Query) => request<ParetoPayload>('/pareto', query, { revalidate: 600 }),
220 + families: (query: Query = {}) => request<FamiliesPage>('/families', query, { revalidate: 600 }),
221 + family: (slug: string, limit = 200) => request<FamilyDetail>(`/families/${enc(slug)}`, { limit }, { revalidate: 600 }),
222 + licenses: () => request<LicensesPayload>('/licenses', undefined, { revalidate: 3600 }),
223 + license: (key: string, limit = 100, offset = 0) => request<LicenseDetail>(`/licenses/${enc(key)}`, { limit, offset }, { revalidate: 3600 }),
224 + methodology: () => request<Methodology11>('/methodology', undefined, { revalidate: 3600 }),
225 +};
226 +// ---- /D1 ----
227 +
228 +// ---- D2 (intelligence) ----
229 +import type {
230 + CostContextPayload,
231 + CostPayload,
232 + DeploymentsPage,
233 + FinderPayload,
234 + FrontierIntel,
235 + HardwarePage,
236 + HardwareSlugFit,
237 + MethodologyIntel,
238 + OpenPayload,
239 + PriceIndexIntel,
240 + ProviderIntelRow,
241 + PulseIntel,
242 + RunLocallyPayload,
243 +} from './types';
244 +
245 +/** 1.1 intelligence routes, typed for the D2 pages (frontier · prices · calculator · run-locally · find-a-model · open · pulse · providers · hardware). */
246 +export const intel = {
247 + frontier: (limit = 12) => request<FrontierIntel>('/frontier', { limit }, { revalidate: 300 }),
248 + /** `/pareto` is typed in the D1 block: `apiD1.pareto(query)`. */
249 + priceIndex: (days = 180) => request<PriceIndexIntel>('/prices/index', { days }, { revalidate: 900 }),
250 + prices: (query: Query) => request<Page<Price> & { methodology?: string }>('/prices', { current: 1, ...query }, { revalidate: 300 }),
251 + providers: () => request<{ items: ProviderIntelRow[]; note?: string }>('/providers', undefined, { revalidate: 300 }),
252 + deployments: (query: Query) => request<DeploymentsPage>('/deployments', query, { revalidate: 300 }),
253 + cost: (query: Query) => request<CostPayload>('/cost', query, { revalidate: 300 }),
254 + costContext: (query: Query) => request<CostContextPayload>('/cost/context', query, { revalidate: 300 }),
255 + runLocally: (query: Query) => request<RunLocallyPayload>('/run-locally', query, { revalidate: 600 }),
256 + hardware: (query: Query) => request<HardwarePage>('/hardware', { facets: 1, ...query }, { revalidate: 600 }),
257 + hardwareSlugFit: (slug: string, query: Query = {}) => request<HardwareSlugFit>(`/hardware/${enc(slug)}/fit`, query, { revalidate: 600 }),
258 + findAModel: (query: Query) => request<FinderPayload>('/find-a-model', query, { revalidate: 300 }),
259 + open: (query: Query) => request<OpenPayload>('/open', query, { revalidate: 300 }),
260 + pulse: (days = 7) => request<PulseIntel>('/pulse', { days }, { revalidate: 120 }),
261 + methodology: () => request<MethodologyIntel>('/methodology', undefined, { revalidate: 3600 }),
262 +};
263 +// ---- /D2 ----
264 +
265 +// ---- D3 (temporal/graph/admin) ----
266 +import type { ChangesPage, ClaimDetail, DailyDigest2, DiffPayload11, EntityClaimsPayload, GraphExploreMode, GraphExplorePayload, MethodologyD3, SourcesPayload, TimeMachinePayload, TimelinePayload11, TrendingPayload, SearchPayload2 } from './types';
267 +
268 +export const apiD3 = {
269 + /** Typed neighbourhood explorer (7 modes). Never more than `limit` nodes; `truncated` says when the API cut. */
270 + graphExplore: (node: string, mode: GraphExploreMode, depth: 1 | 2 = 1, limit = 150) => request<GraphExplorePayload>('/graph/explore', { node, mode, depth, limit }, { revalidate: 600 }),
271 + timeMachine: (date: string, scope: string, limit = 50) => request<TimeMachinePayload>('/time-machine', { date, scope, limit }, { revalidate: 3600 }),
272 + diff: (a: string, b: string, scope = 'all', limit = 200, includeBackfill = false) => request<DiffPayload11>('/diff', { a, b, scope, limit, include_backfill: includeBackfill ? 1 : undefined }, { revalidate: 1800 }),
273 + changesDaily: (date?: string, perSection = 30, includeBackfill = false) => request<DailyDigest2>('/changes/daily', { date, per_section: perSection, include_backfill: includeBackfill ? 1 : undefined }, { revalidate: 300 }),
274 + changes: (query: Query) => request<ChangesPage>('/changes', query, { revalidate: 60 }),
275 + timeline: (query: Query) => request<TimelinePayload11>('/timeline', query, { revalidate: 600 }),
276 + search: (q: string, query: Query = {}) => request<SearchPayload2>('/search', { q, ...query }, { revalidate: false }),
277 + claim: (id: string) => request<ClaimDetail>(`/claims/${enc(id)}`, undefined, { revalidate: 600 }),
278 + entityClaims: (slug: string, query: Query = {}) => request<EntityClaimsPayload>(`/entities/${enc(slug)}/claims`, query, { revalidate: 300 }),
279 + methodology: () => request<MethodologyD3>('/methodology', undefined, { revalidate: 3600 }),
280 + sources: () => request<SourcesPayload>('/sources', undefined, { revalidate: 300 }),
281 + trending: (kind: string, query: Query = {}) => request<TrendingPayload>('/trending', { kind, ...query }, { revalidate: 300 }),
282 + /** Generic entity timeline with the 1.1 flags (`include_backfill`, `date_field`). */
283 + entityTimeline: (slug: string, query: Query = {}) => request<ChangesPage>(`/entities/${enc(slug)}/timeline`, query, { revalidate: 120 }),
284 +};
285 +// ---- /D3 ----
modified apps/web/src/lib/types.ts +810 −0
@@ -491,3 +491,813 @@ export interface AsOfPayload {
491 491 attributes: Record<string, unknown>;
492 492 claims: Claim[];
493 493 }
494 +
495 +// ---- D1 (models/benchmarks/compare) ----
496 +/** 1.1 shapes consumed by the models · artifacts · families · benchmarks · compare · licences pages. Mirrors docs/API.md §1.1. */
497 +export type IdentityConfidence = 'high' | 'medium' | 'low';
498 +export interface FamilyRef {
499 + id: string | null;
500 + slug: string;
501 + name: string;
502 +}
503 +/** `/models` row (1.1): canonical model, or an artifact when `include=artifacts`. */
504 +export type ModelRow = EntitySummary & { identity_confidence?: IdentityConfidence; family?: FamilyRef | null; canonical?: EntitySummary | null; artifact_kind?: string | null };
505 +export interface FacetValue11 {
506 + value: string;
507 + label?: string;
508 + count: Num;
509 + canonical?: boolean;
510 + category?: string;
511 + raw_labels?: string[];
512 + raw?: boolean;
513 +}
514 +export interface ModelFacets11 {
515 + organizations?: OrgFacet[];
516 + openness?: FacetValue11[];
517 + modalities?: FacetValue11[];
518 + families?: FacetValue11[];
519 + years?: FacetValue11[];
520 + licenses?: FacetValue11[];
521 + status?: FacetValue11[];
522 + trust?: FacetValue11[];
523 + definitions?: Record<string, string>;
524 +}
525 +export type ModelsPage11 = Page<ModelRow> & { facets?: ModelFacets11; universe?: string };
526 +
527 +export interface DeploymentPrices {
528 + input: Num;
529 + cached_input: Num;
530 + cache_write: Num;
531 + output: Num;
532 + batch_input: Num;
533 + batch_output: Num;
534 + per_image: Num;
535 + per_request: Num;
536 + currency: string;
537 + unit: string;
538 + native_units: Record<string, unknown>;
539 +}
540 +export interface Deployment {
541 + id: string;
542 + model: EntitySummary;
543 + provider: EntitySummary;
544 + provider_model_id: string | null;
545 + context_length: Num;
546 + max_output_tokens: Num;
547 + prices: DeploymentPrices;
548 + features: Record<string, unknown>;
549 + status: 'active' | 'delisted' | string;
550 + observed_at: string;
551 + valid_from: string;
552 + valid_to: string | null;
553 + source_url: string | null;
554 + tier: number;
555 +}
556 +export interface Group {
557 + metric: string;
558 + config_key: string;
559 + label: string;
560 + n: number;
561 + model_count: number;
562 + config: Record<string, unknown>;
563 + higher_is_better: boolean;
564 + trust_mix: Record<string, number>;
565 +}
566 +export interface ModelRef {
567 + id: string;
568 + slug: string;
569 + name: string;
570 + entity_type: string;
571 + organization: Org;
572 + attributes: Record<string, unknown>;
573 +}
574 +export type Comparability = 'comparable' | 'partially-comparable' | 'not-comparable';
575 +export interface LeaderboardRow {
576 + rank: number;
577 + model: ModelRef;
578 + score: number;
579 + metric: string;
580 + unit: string | null;
581 + higher_is_better: boolean;
582 + delta_rank: number | null;
583 + previous_rank: number | null;
584 + trust_level: string;
585 + trust_label: string;
586 + config: Record<string, unknown>;
587 + config_key: string;
588 + comparability: Comparability;
589 + comparability_reasons: string[];
590 + evaluated_at: string | null;
591 + observed_at: string;
592 + source_url: string | null;
593 + tier: number;
594 + result_id: string;
595 + n_rows: number;
596 +}
597 +export interface LicenseInfo {
598 + key: string;
599 + label: string;
600 + category: string;
601 + spdx: string | null;
602 + url: string | null;
603 + commercial_use: boolean | null;
604 + redistribution: boolean | null;
605 + derivatives: boolean | null;
606 + hosting_restrictions: boolean | null;
607 + attribution: boolean | null;
608 + acceptable_use: boolean | null;
609 + osi_approved: boolean;
610 + weights_downloadable: boolean;
611 +}
612 +export interface BenchmarkListItem {
613 + id: string;
614 + entity_type: string;
615 + slug: string;
616 + name: string;
617 + description?: string | null;
618 + category: string | null;
619 + family: string | null;
620 + variant: string | null;
621 + metric: string | null;
622 + unit: string | null;
623 + direction: 'higher' | 'lower' | string | null;
624 + attributes: Record<string, unknown>;
625 + result_count: Num;
626 + model_count: Num;
627 + leader: LeaderboardRow | null;
628 + second: LeaderboardRow | null;
629 + top: { model: EntitySummary; score: number } | null;
630 + primary_group: Group | null;
631 + groups: Group[];
632 + trust_mix: Record<string, number>;
633 + trust_labels: Record<string, string>;
634 + updated_at?: string;
635 +}
636 +export interface BenchmarksPayload {
637 + items: BenchmarkListItem[];
638 + total: Num;
639 + note?: string;
640 +}
641 +export interface BenchmarkDetail extends Omit<EntityDetail, 'model_count'> {
642 + family?: string | null;
643 + variant?: string | null;
644 + metric?: string | null;
645 + direction?: string | null;
646 + category?: string | null;
647 + groups?: Group[];
648 + primary_group?: Group | null;
649 + result_count?: Num;
650 + model_count?: Num;
651 + leaderboard?: LeaderboardRow[];
652 + trust_mix?: Record<string, number>;
653 +}
654 +export interface LeaderboardPayload {
655 + benchmark: EntitySummary;
656 + group: Group | null;
657 + groups: Group[];
658 + items: LeaderboardRow[];
659 + total: number;
660 + limit: number;
661 + offset: number;
662 + comparable_only: boolean;
663 + filters: Record<string, unknown>;
664 + history_available: boolean;
665 + methodology: string;
666 +}
667 +export interface FrontierPoint {
668 + date: string;
669 + model: ModelRef;
670 + score: number;
671 + trust_level: string;
672 + config: Record<string, unknown>;
673 + result_id: string;
674 +}
675 +export interface BenchmarkFrontierPayload {
676 + benchmark: EntitySummary;
677 + series: { group: Group; primary: boolean; points: FrontierPoint[]; current_leader: FrontierPoint | null }[];
678 + generated_at: string;
679 + methodology: string;
680 +}
681 +export interface MatrixColumn {
682 + id: string;
683 + slug: string;
684 + name: string;
685 + category: string | null;
686 + metric: string;
687 + config_key: string;
688 + group_label: string;
689 + higher_is_better: boolean;
690 + n_models: number;
691 +}
692 +export interface MatrixCell {
693 + score: number;
694 + rank: number;
695 + trust_level: string;
696 + config_key: string;
697 + comparability: Comparability;
698 + result_id: string;
699 +}
700 +export interface MatrixRow {
701 + model: { id: string; slug: string; name: string; organization: string | null; organization_slug: string | null; openness: string | null; release_date: string | null };
702 + cells: Record<string, MatrixCell | null>;
703 + n_cells: number;
704 + mean_rank: number | null;
705 +}
706 +export interface MatrixPayload {
707 + columns: MatrixColumn[];
708 + rows: MatrixRow[];
709 + total_rows: number;
710 + comparable_only: boolean;
711 + min_cells: number;
712 + methodology: string;
713 +}
714 +export interface ParetoPoint {
715 + id: string;
716 + model: { id: string; slug: string; name: string; organization: string | null; openness: string | null };
717 + x: number;
718 + y: number;
719 + rank: number;
720 + trust_level: string;
721 + config: Record<string, unknown>;
722 + provider?: EntitySummary | null;
723 + estimated?: boolean;
724 + pareto: boolean;
725 +}
726 +export interface ParetoPayload {
727 + benchmark: EntitySummary;
728 + group: Group | null;
729 + groups: Group[];
730 + x: { key: string; label: string };
731 + y: { key: string; label: string };
732 + points: ParetoPoint[];
733 + frontier: string[];
734 + methodology: string;
735 +}
736 +export interface FamilyRow {
737 + id: string | null;
738 + slug: string;
739 + name: string;
740 + canonical: boolean;
741 + entity_type: 'model_family' | string;
742 + organization: Org;
743 + model_count: Num;
744 + first_release: string | null;
745 + last_release: string | null;
746 + param_range: { min: Num; max: Num } | null;
747 + modalities: string[];
748 + licenses: { key: string; label: string; models: Num }[];
749 + benchmark_best: Record<string, { rank: number; model: string }>;
750 +}
751 +export interface FamilyMember {
752 + model: EntitySummary;
753 + key_facts: Record<string, unknown>;
754 + benchmark_ranks: Record<string, number>;
755 +}
756 +export interface FamilyDetail extends FamilyRow {
757 + summary: EntitySummary | null;
758 + members: FamilyMember[];
759 + artifacts_count: Num;
760 + providers: EntitySummary[];
761 + lineage: { source: string; target: string; predicate: string }[];
762 + timeline: { date: string | null; kind: string; model: { id: string; slug: string; name: string } }[];
763 + note: string | null;
764 +}
765 +export type FamiliesPage = Page<FamilyRow> & { note?: string };
766 +export type LicenseRow = LicenseInfo & { aliases: string[]; models: Num };
767 +export interface LicensesPayload {
768 + items: LicenseRow[];
769 + total: Num;
770 + categories: string[];
771 + unclassified: { raw: string; models: Num }[];
772 + note?: string;
773 +}
774 +export type LicenseDetail = LicenseInfo & { aliases: string[]; models: Page<EntitySummary> };
775 +
776 +export interface ModelIdentity {
777 + canonical_model: boolean;
778 + official_checkpoints: string[];
779 + official_artifacts: number;
780 + third_party_artifacts: number;
781 + provider_deployments: number;
782 + folded_variants: number;
783 + api_aliases: string[];
784 + note?: string;
785 +}
786 +export interface ModelOpenness {
787 + category: string;
788 + raw: string | null;
789 + label: string;
790 + definition: string;
791 + dimensions: Record<string, boolean | null>;
792 + note?: string;
793 +}
794 +export type ModelLicence = (LicenseInfo & { raw: string | null; url_observed: string | null }) | { key: null; raw: string | null; note?: string };
795 +export interface VersionTransition {
796 + from: unknown;
797 + to: unknown;
798 + valid_from: string;
799 + valid_to: string | null;
800 + effective_at: string | null;
801 + source_url: string | null;
802 + tier: number;
803 + claim_id: string;
804 + status: string;
805 +}
806 +export interface VersionHistoryItem {
807 + property: string;
808 + transitions: VersionTransition[];
809 + current: unknown;
810 +}
811 +export interface ModelBenchmarkBest {
812 + score: number;
813 + unit: string | null;
814 + trust_level: string;
815 + trust_label: string;
816 + config: Record<string, unknown>;
817 + evaluated_at: string | null;
818 + observed_at: string;
819 + source_url: string | null;
820 + tier: number;
821 + result_id: string;
822 +}
823 +export interface ModelBenchmarkGroup {
824 + config_key: string;
825 + comparability_group: string;
826 + n_rows: number;
827 + higher_is_better: boolean;
828 + best: ModelBenchmarkBest;
829 + trust_levels: string[];
830 +}
831 +export interface ModelBenchmarks {
832 + items: { id: string; slug: string; name: string; category: string | null; metrics: { metric: string; groups: ModelBenchmarkGroup[] }[] }[];
833 + total_rows: number;
834 + note?: string;
835 +}
836 +export type ArtifactKind = 'checkpoint' | 'quantization' | 'conversion' | 'packaging';
837 +export type ArtifactSummary = EntitySummary & { artifact_kind?: ArtifactKind | string | null };
838 +export type FamilyBlock = EntitySummary | { id: null; name: string; canonical: false; note?: string } | null;
839 +/** Model (or artifact) detail with the 1.1 blocks. */
840 +export interface ModelDetail extends EntityDetail {
841 + redirected_from?: { slug: string; id: string; entity_type: string } | null;
842 + family?: FamilyBlock;
843 + artifacts?: { items: { kind: string; items: ArtifactSummary[]; count: number }[]; total: number };
844 + deployments?: Deployment[];
845 + identity?: ModelIdentity;
846 + licence?: ModelLicence | null;
847 + openness?: ModelOpenness | null;
848 + version_history?: VersionHistoryItem[];
849 + benchmarks?: ModelBenchmarks;
850 + family_id?: string | null;
851 + identity_confidence?: IdentityConfidence;
852 + hardware_fit_assumptions?: string[];
853 + canonical?: EntitySummary | null;
854 + artifact_kind?: ArtifactKind | string | null;
855 +}
856 +export interface CompareDimension11 extends CompareDimension {
857 + higher_is_better?: boolean;
858 + benchmark?: string;
859 + metric?: string;
860 + config_key?: string;
861 + comparability?: Comparability;
862 + trust_levels?: string[];
863 +}
864 +export interface ComparabilityInfo {
865 + level: Comparability;
866 + reasons: string[];
867 + trust: Record<string, { level: string; label: string }>;
868 +}
869 +export interface ComparePayload11 {
870 + entity_type: string;
871 + dimensions: CompareDimension11[];
872 + items: { entity: EntitySummary; values: Record<string, unknown>; provenance: Provenance; prices?: Price[]; results?: BenchmarkResult[]; deployments?: Deployment[] }[];
873 + comparability: Record<string, ComparabilityInfo>;
874 + diff_only: boolean;
875 + note?: string;
876 +}
877 +export type DiffDelta = { absolute: number; percent: number | null } | { added: unknown[]; removed: unknown[] } | null;
878 +export interface ModelDiffPayload {
879 + a: EntitySummary;
880 + b: EntitySummary;
881 + dimensions: (CompareDimension11 & { a: unknown; b: unknown; delta: DiffDelta })[];
882 + comparability: Record<string, ComparabilityInfo>;
883 + note?: string;
884 +}
885 +export interface Methodology11 extends Methodology {
886 + openness?: { categories: string[]; labels: Record<string, string>; definitions: Record<string, string>; dimensions: string[]; note?: string };
887 + trust_levels?: { key: string; label: string }[];
888 + comparability?: Record<string, unknown> & { comparable?: string; 'partially-comparable'?: string; 'not-comparable'?: string };
889 + licence_categories?: string[];
890 + counters?: Record<string, string>;
891 + hardware_fit?: unknown;
892 +}
893 +// ---- /D1 ----
894 +
895 +// ---- D2 (intelligence) ----
896 +// Reuses the D1 shapes above: Deployment · DeploymentPrices · Group · ModelRef · LeaderboardRow · LicenseInfo · ModelLicence · ParetoPoint · ParetoPayload.
897 +export type DeploymentsPage = Page<Deployment> & { next_before?: string | null; current?: boolean };
898 +/** 1.1 feed fields on events (`occurred_at = coalesce(effective_at, observed_at)`, backfill flag, semantic group). */
899 +export type ChangeEventIntel = ChangeEvent & { occurred_at?: string; is_backfill?: boolean; group_key?: string | null; percent_change?: Num };
900 +/** Event date to display: 1.1 `occurred_at`, else effective, else observed. */
901 +export function eventDate(e: ChangeEventIntel): string {
902 + return e.occurred_at ?? e.effective_at ?? e.observed_at;
903 +}
904 +
905 +/** 1.1 `Fit` — every hardware-fit figure is an estimate (`estimated: true`). */
906 +export interface FitBreakdown {
907 + weights_gb: number;
908 + weights_source: 'observed' | 'estimated' | string;
909 + overhead_gb: number;
910 + kv_cache_gb: number;
911 + kv_cache_method: 'architecture' | 'heuristic' | string;
912 + reserved_gb: number;
913 + context: number;
914 + batch: number;
915 +}
916 +export interface Fit {
917 + quantization: string;
918 + estimated: true | boolean;
919 + fits: boolean | null;
920 + estimated_memory_gb: Num;
921 + headroom_gb: Num;
922 + parameter_count?: Num;
923 + breakdown?: FitBreakdown;
924 + device?: { memory_gb: number; gpu_count: number; total_memory_gb: number };
925 + note?: string | null;
926 + multi_gpu_note?: string | null;
927 +}
928 +
929 +
930 +export type BenchmarkRef = { id: string; slug: string; name: string; category?: string | null; entity_type?: string };
931 +
932 +/** `GET /frontier` (1.1) — every section is optional so partial payloads still render. */
933 +export interface FrontierIntel {
934 + latest_major_models?: ChangeEventIntel[];
935 + benchmark_frontier?: { benchmark: BenchmarkRef; group: Group | null; leader: LeaderboardRow | null; second: LeaderboardRow | null; gap: Num }[];
936 + price_frontier?: {
937 + cheapest_output: Deployment | null;
938 + cheapest_output_1m_context: Deployment | null;
939 + frontier_models: Num;
940 + composition?: { recent_by_active_orgs?: Num; top10_on_a_benchmark?: Num; total?: Num; since?: string } | null;
941 + };
942 + context_frontier?: { model: EntitySummary; context_length: Num }[];
943 + open_weight_frontier?: { items: { model: EntitySummary; best_rank: Num; best_rank_on: string | null; parameter_count: Num; context_length: Num; ranks: Record<string, number> }[]; dimensions: string[]; note?: string };
944 + efficiency_frontier?: { quality: { benchmark: string; group: Group | null }; x: string; points: ParetoPoint[]; frontier: string[] };
945 + agentic_frontier?: { benchmark: BenchmarkRef; group: Group | null; leaders: LeaderboardRow[] }[];
946 + multimodal_frontier?: { model: ModelRef | EntitySummary; modalities: string[]; top10_on: string[] }[];
947 + recent_frontier_movements?: ChangeEventIntel[];
948 + generated_at?: string;
949 + methodology?: string;
950 +}
951 +
952 +/** AI Price Index (1.1) — v1 keys kept, new medians and sample sizes per day. */
953 +export interface PriceIndexPointIntel extends PriceIndexPoint {
954 + median_frontier_output?: Num;
955 + median_frontier_input?: Num;
956 + median_open_output?: Num;
957 + median_embedding_input?: Num;
958 + min_frontier_output?: Num;
959 + sample?: { models?: Num; offers?: Num; frontier_models?: Num; frontier_offers?: Num; open_models?: Num; embedding_models?: Num };
960 +}
961 +export interface PriceDistribution {
962 + metric: string;
963 + unit: string;
964 + buckets: { from: Num; to: Num; label: string; offers: Num }[];
965 + offers: Num;
966 +}
967 +export interface CheapestFrontier {
968 + model: EntitySummary;
969 + provider: EntitySummary;
970 + output: Num;
971 + input: Num;
972 + context_length: Num;
973 + price_id?: string;
974 +}
975 +export interface PriceIndexIntel {
976 + days?: number;
977 + series: PriceIndexPointIntel[];
978 + movers: ChangeEventIntel[];
979 + cheapest_frontier?: CheapestFrontier | null;
980 + cheapest_frontier_1m_context?: CheapestFrontier | null;
981 + distribution?: PriceDistribution | null;
982 + new_listings_30d?: Num | ChangeEvent[];
983 + delistings_30d?: Num | ChangeEvent[];
984 + price_changes_30d?: Num | ChangeEvent[];
985 + frontier?: { composition?: Record<string, unknown>; methodology?: string };
986 + methodology?: string;
987 + note?: string;
988 +}
989 +
990 +/** `/providers` 1.1 aggregates. */
991 +export interface PriceDistributionStats {
992 + min: Num;
993 + p25: Num;
994 + median: Num;
995 + p75: Num;
996 + max: Num;
997 + n: Num;
998 +}
999 +export type ProviderIntelRow = ProviderRow & {
1000 + input_price_distribution?: PriceDistributionStats | null;
1001 + output_price_distribution?: PriceDistributionStats | null;
1002 + models_added_30d?: Num;
1003 + models_removed_30d?: Num;
1004 + price_changes_30d?: Num;
1005 + organizations_covered?: Num;
1006 + features_supported?: string[];
1007 + feature_keys?: string[];
1008 +};
1009 +
1010 +/** `GET /cost` and `GET /cost/context`. */
1011 +export interface CostItem {
1012 + deployment: Deployment;
1013 + cost: {
1014 + per_request: Num;
1015 + daily: Num;
1016 + monthly: Num;
1017 + annual: Num;
1018 + effective_input_per_mtok: Num;
1019 + effective_output_per_mtok: Num;
1020 + per_request_fee: Num;
1021 + inputs?: Record<string, unknown>;
1022 + notes: string[];
1023 + };
1024 +}
1025 +export interface CostPayload {
1026 + model: EntitySummary | null;
1027 + inputs: { input_tokens: Num; output_tokens: Num; requests_per_day: Num; cached_share: Num; batch: boolean };
1028 + items: CostItem[];
1029 + total: number;
1030 + currency: string;
1031 + methodology?: string;
1032 + note?: string | null;
1033 +}
1034 +export interface CostContextPayload {
1035 + tokens: number;
1036 + items: { deployment: Deployment; context_length: Num; context_source: 'offer' | 'model attribute' | string; cost_usd: Num }[];
1037 + total: number;
1038 + currency: string;
1039 + methodology?: string;
1040 + note?: string | null;
1041 +}
1042 +
1043 +/** `GET /run-locally`. */
1044 +export interface RunLocallyArtifact {
1045 + artifact: EntitySummary;
1046 + quant_format: string | null;
1047 + file_size_gb: Num;
1048 + weights_source: 'observed' | 'estimated' | string;
1049 + fit: Fit;
1050 +}
1051 +export interface RunLocallyItem {
1052 + model: EntitySummary;
1053 + fit: Fit;
1054 + artifacts: RunLocallyArtifact[];
1055 + artifact_count: Num;
1056 +}
1057 +export interface RunLocallyPayload {
1058 + inputs: Record<string, unknown>;
1059 + estimated: boolean;
1060 + assumptions: string[];
1061 + counts: { fits?: Num; evaluated?: Num };
1062 + items: RunLocallyItem[];
1063 + note?: string;
1064 +}
1065 +/** `GET /hardware/{slug}/fit`. */
1066 +export interface HardwareSlugFit {
1067 + hardware: EntitySummary;
1068 + memory_options_gb: number[];
1069 + inputs: Record<string, unknown>;
1070 + estimated: boolean;
1071 + assumptions: string[];
1072 + runtimes: string[];
1073 + counts: { fits?: Num; evaluated?: Num };
1074 + items: ({ model: EntitySummary } & Fit)[];
1075 + note?: string;
1076 +}
1077 +
1078 +/** `GET /find-a-model`. */
1079 +export interface FinderMatch {
1080 + model: EntitySummary;
1081 + why: string[];
1082 + observed: Record<string, unknown> & { benchmark_ranks?: Record<string, number>; best_rank?: Num; providers?: Num; cheapest_input_per_mtok?: Num; cheapest_output_per_mtok?: Num };
1083 + estimated_fit?: Partial<Fit> | null;
1084 + deployments?: Deployment[] | null;
1085 +}
1086 +export interface FinderPayload {
1087 + matches: FinderMatch[];
1088 + total: number;
1089 + filters_applied: Record<string, unknown>;
1090 + rules: Record<string, string>;
1091 + note?: string;
1092 +}
1093 +
1094 +/** `GET /open` (1.1). */
1095 +export interface OpenItem {
1096 + model: EntitySummary;
1097 + licence: ModelLicence;
1098 + dimensions: Record<string, unknown>;
1099 + best_results: { benchmark: string; rank: number }[];
1100 + best_rank: Num;
1101 + hardware_fit: { '4bit_64gb'?: Partial<Fit> | null; '8bit_128gb'?: Partial<Fit> | null; estimated?: boolean } | null;
1102 + providers: Num;
1103 + cheapest_output_per_mtok: Num;
1104 +}
1105 +export interface OpenPayload extends Page<OpenItem> {
1106 + summary?: { by_category?: Record<string, Num>; by_license_top?: { key: string; label: string; models: Num }[]; new_30d?: Num };
1107 + note?: string;
1108 +}
1109 +
1110 +/** `GET /pulse` (1.1). */
1111 +export interface PulseCounter {
1112 + value: Num;
1113 + definition: string;
1114 + median_percent?: Num | null;
1115 + items?: unknown[] | null;
1116 +}
1117 +export interface PulseIntel {
1118 + days: number;
1119 + since: string;
1120 + until: string;
1121 + counters: Record<string, PulseCounter>;
1122 + note?: string;
1123 +}
1124 +export interface PulseLeaderItem {
1125 + benchmark: BenchmarkRef;
1126 + previous: { model: ModelRef; score: Num } | null;
1127 + current: { model: ModelRef; score: Num; metric?: string; group_label?: string; trust_level?: string; n_models?: Num; as_of?: string } | null;
1128 +}
1129 +
1130 +/** `/methodology` 1.1 additions read by the intelligence pages. */
1131 +export interface MethodologyIntel {
1132 + openness?: { categories: string[]; labels: Record<string, string>; definitions: Record<string, string>; dimensions: string[]; note?: string };
1133 + hardware_fit?: { assumptions: string[]; bytes_per_param?: Record<string, number>; reserved_gb?: number };
1134 + frontier?: string;
1135 + find_a_model?: Record<string, string> | string;
1136 + licence_categories?: string[];
1137 + trust_levels?: { key: string; label: string }[];
1138 + counters?: Record<string, string> | string[];
1139 + [k: string]: unknown;
1140 +}
1141 +
1142 +/** `/hardware` facets (1.1). */
1143 +export type HardwarePage = Page<EntitySummary> & { facets?: { kinds?: FacetValue[]; manufacturers?: FacetValue[] } };
1144 +// ---- /D2 ----
1145 +
1146 +// ---- D3 (temporal/graph/admin) ----
1147 +/** 1.1 feed fields merged into the base event (declaration merging: additive, optional). */
1148 +export interface ChangeEvent {
1149 + occurred_at?: string;
1150 + is_backfill?: boolean;
1151 + group_key?: string | null;
1152 +}
1153 +/** `GET /graph/explore` (1.1): typed neighbourhood explorer, one of seven modes. */
1154 +export type GraphExploreMode = 'lineage' | 'research' | 'company' | 'benchmark' | 'dataset' | 'provider' | 'hardware';
1155 +export interface ExploreNode {
1156 + id: string;
1157 + slug: string;
1158 + name: string;
1159 + entity_type: string;
1160 + org: string | null;
1161 + org_slug: string | null;
1162 + level: number;
1163 + artifact_kind: string | null;
1164 + attributes: Record<string, unknown>;
1165 +}
1166 +export interface ExploreEdge {
1167 + source: string;
1168 + target: string;
1169 + predicate: string;
1170 + attributes?: Record<string, unknown>;
1171 + tier?: number | null;
1172 +}
1173 +export interface GraphExplorePayload {
1174 + root: string;
1175 + mode: GraphExploreMode;
1176 + depth: number;
1177 + predicates: string[];
1178 + nodes: ExploreNode[];
1179 + edges: ExploreEdge[];
1180 + truncated: boolean;
1181 + counts: { nodes: Num; edges: Num; by_type: Record<string, Num> };
1182 +}
1183 +
1184 +/** `GET /time-machine` (1.1). */
1185 +export interface TimeMachineModelRow {
1186 + model: EntitySummary;
1187 + attributes_as_of: Record<string, unknown>;
1188 + observed_then: boolean;
1189 + reconstructed: boolean;
1190 +}
1191 +export interface TimeMachineLeader {
1192 + benchmark: { id: string; slug: string; name: string; category?: string | null };
1193 + leader: { model: EntitySummary; score: number; metric: string | null; config_key?: string | null; group_label?: string | null; trust_level?: string | null; n_models?: Num; as_of?: string | null } | null;
1194 +}
1195 +export interface TimeMachinePayload {
1196 + date: string;
1197 + scope: string;
1198 + first_entity_at: string | null;
1199 + reconstructed: boolean;
1200 + note: string | null;
1201 + models?: { items: TimeMachineModelRow[]; total: Num; limit?: Num; note?: string };
1202 + prices?: { items: Price[]; total: Num; note?: string };
1203 + benchmarks?: { leaders: TimeMachineLeader[]; note?: string };
1204 + hardware?: { items: { hardware: EntitySummary; reconstructed?: boolean }[]; total?: Num; note?: string };
1205 +}
1206 +
1207 +/** `GET /diff` (1.1): v1 keys + the new sections. */
1208 +export interface DiffPayload11 extends DiffPayload {
1209 + new_benchmark_leaders?: { benchmark: { id: string; slug: string; name: string; category?: string | null }; at_a: TimeMachineLeader['leader']; at_b: TimeMachineLeader['leader'] }[];
1210 + provider_changes?: ChangeEvent[];
1211 + hardware_changes?: ChangeEvent[];
1212 + context_changes?: ChangeEvent[];
1213 + retired_models?: (EntitySummary | ChangeEvent)[];
1214 + include_artifacts?: boolean;
1215 + include_backfill?: boolean;
1216 + note?: string;
1217 +}
1218 +
1219 +/** Today in AI 2.0 (`/changes/daily.today`). */
1220 +export type TodayItem = ChangeEvent & { sources?: Num; documents?: string[]; grouped_events?: Num; event_ids?: string[] };
1221 +export interface TodaySection {
1222 + key: string;
1223 + label: string;
1224 + items: TodayItem[];
1225 + total: Num;
1226 +}
1227 +export interface DailyDigest2 extends DailyDigest {
1228 + today?: TodaySection[];
1229 + date_field?: string;
1230 + note?: string;
1231 +}
1232 +export type ChangesPage = Page<ChangeEvent> & { next_before?: string | null; date_field?: string; include_backfill?: boolean };
1233 +export interface TimelinePayload11 extends TimelinePayload {
1234 + date_field?: string;
1235 + include_backfill?: boolean;
1236 +}
1237 +
1238 +/** Search compiler v2. */
1239 +export interface CompiledFilter {
1240 + filter: string;
1241 + label: string;
1242 + value: unknown;
1243 + source_span?: string | null;
1244 +}
1245 +export interface CompiledQuery2 extends CompiledQuery {
1246 + compiled?: CompiledFilter[];
1247 + sort?: string | null;
1248 + residual?: string | null;
1249 + unrecognised?: string[];
1250 + semantic?: boolean;
1251 + version?: number;
1252 + note?: string | null;
1253 +}
1254 +export type SearchPayload2 = Omit<SearchPayload, 'query'> & { query: CompiledQuery2 };
1255 +
1256 +/** `GET /claims/{id}` and `GET /entities/{slug}/claims`. */
1257 +export type ClaimRow = Claim & { snapshot_id?: string | null; run_id?: string | null; value_raw?: unknown; extractor_version?: string | null };
1258 +export interface ClaimDetail {
1259 + claim: ClaimRow;
1260 + entity: EntitySummary | null;
1261 + property: string;
1262 + chain: { previous: ClaimRow[]; superseding: ClaimRow[]; conflicting: ClaimRow[]; history_count: Num };
1263 + source: { id: string | null; name: string | null; domain: string | null; tier: number | null; url: string | null; snapshot_id: string | null; observed_at: string | null } | null;
1264 + extractor: { name: string | null; version: string | null; confidence: string | null } | null;
1265 + run_id: string | null;
1266 + evidence: { snapshot_id: string | null; document_url: string | null; archived: boolean; snapshot_observed_at: string | null; document_title: string | null; doc_type: string | null } | null;
1267 + note?: string | null;
1268 +}
1269 +export interface EntityClaimsPayload {
1270 + entity: EntitySummary;
1271 + items: ClaimRow[];
1272 + total: number;
1273 + limit: number;
1274 + offset: number;
1275 + status: string;
1276 +}
1277 +
1278 +/** `GET /methodology` fields D3 renders beyond D1's `Methodology11` (loose: rendered as-is). */
1279 +export interface MethodologyD3 extends Methodology11 {
1280 + quality_version?: string;
1281 + expected_fields?: Record<string, string[]>;
1282 + status_vocabulary?: string[];
1283 + anomaly_checks?: { check: string; severity: string; description: string }[];
1284 + event_semantics?: Record<string, string>;
1285 + frontier?: string | Record<string, unknown>;
1286 + find_a_model?: Record<string, string>;
1287 + principles?: string[];
1288 +}
1289 +
1290 +export interface SourcesPayload {
1291 + items: SourceRow[];
1292 + total?: Num;
1293 + tiers?: Record<string, string>;
1294 +}
1295 +export type SourceRow11 = SourceRow & { snapshots?: Num; claims?: Num; base_url?: string | null; robots_policy?: string | null; priority?: Num; notes?: string | null };
1296 +
1297 +export interface TrendingPayload {
1298 + days: number;
1299 + kind: string;
1300 + items: (EntitySummary & { views?: Num; events?: Num; last_event_at?: string | null })[];
1301 + definition?: string;
1302 +}
1303 +// ---- /D3 ----
494 1304