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%
25.4 KB · 500 lines tsx
Raw Blame History
1import { ExternalLink } from 'lucide-react';2import { ScrollX } from '@/components/models/scroll-x';3import Link from 'next/link';4import { ChangeRow } from '@/components/changes/change-row';5import { Legend, LineChart, type Series, Sparkline, stepPoints } from '@/components/charts/charts';6import { Chip, ConfidenceBadge, Estimated, TierBadge } from '@/components/ui/badges';7import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';8import { EntityInline, EntityLink, EntityRow, QualityMark } from '@/components/ui/entity';9import { KeyValue, type KVRow } from '@/components/ui/key-value';10import { SourceCell } from '@/components/ui/provenance';11import { Note } from '@/components/ui/section';12import { EmptyState } from '@/components/ui/unavailable';13import { fmtAgo, fmtDate, fmtGb, fmtInt, fmtParams, fmtScore, fmtTokens, fmtUsdPerM, fmtValue, num } from '@/lib/format';14import { predicateLabel, PROSE_KEYS, propertyLabel, routes, typeLabel } from '@/lib/site';15import type { BenchmarkResult, ChangeEvent, EntityDetail, EntitySummary, HardwareFitRow, Price, Provenance, RelationGroup, SourceRef } from '@/lib/types';1617/* ------------------------------------------------------------------------------------------------------ spec table */1819const MODEL_ORDER = ['family', 'version', 'release_date', 'status', 'openness', 'license', 'architecture', 'parameter_count', 'active_parameter_count', 'is_moe', 'context_length', 'max_output_tokens', 'knowledge_cutoff', 'training_data_cutoff', 'modalities', 'modalities_input', 'modalities_output', 'languages', 'tokenizer', 'api_model_id', 'api_alias', 'base_model', 'quantization', 'quant_format', 'file_size_gb', 'deprecation_date', 'retirement_date', 'retirement_tentative', 'hf_repo', 'pipeline_tag', 'official_url', 'model_card_url', 'paper_url', 'repository_url', 'metric.downloads', 'metric.likes'];20const CAPABILITY_KEYS = ['tool_calling', 'structured_output', 'reasoning', 'vision', 'audio', 'fine_tuning_available'];21const HIDDEN = new Set(['name', 'slug', 'id', 'entity_type']);2223/** Every non-prose attribute as a KeyValue list, model keys in canonical order, others alphabetical. */24export function SpecTable({ d, exclude = [] }: { d: EntityDetail; exclude?: string[] }) {25  const attrs = d.attributes ?? {};26  const skip = new Set([...exclude, ...HIDDEN, ...PROSE_KEYS]);27  const keys = Object.keys(attrs).filter((k) => !skip.has(k) && attrs[k] !== null && attrs[k] !== undefined && attrs[k] !== '' && !(Array.isArray(attrs[k]) && (attrs[k] as unknown[]).length === 0));28  const order = new Map(MODEL_ORDER.map((k, i) => [k, i]));29  keys.sort((a, b) => (order.get(a) ?? 999) - (order.get(b) ?? 999) || a.localeCompare(b));30  const rows: KVRow[] = keys.map((k) => ({ key: k, raw: attrs[k] }));31  return <KeyValue rows={rows} provenance={d.provenance} slug={d.slug} entity={{ name: d.name, entity_type: d.entity_type }} />;32}3334/** Identity block: aliases + identifiers (scheme: value). */35export function Identity({ d }: { d: EntityDetail }) {36  if (!d.aliases?.length && !d.identifiers?.length) return null;37  return (38    <div className="space-y-3 text-sm">39      {d.identifiers?.length > 0 && (40        <dl className="kv">41          {d.identifiers.map((i) => (42            <div key={`${i.scheme}:${i.value}`}>43              <dt className="mono">{i.scheme}</dt>44              <dd className="mono text-ink">{i.value}</dd>45            </div>46          ))}47        </dl>48      )}49      {d.aliases?.length > 0 && (50        <p className="text-ink-3">51          Also known as: <span className="text-ink-2">{d.aliases.join(', ')}</span>52        </p>53      )}54    </div>55  );56}5758/* ------------------------------------------------------------------------------------------------------ capabilities */5960export function Capabilities({ d }: { d: EntityDetail }) {61  const a = d.attributes ?? {};62  const mods = (Array.isArray(a.modalities) ? a.modalities : []) as string[];63  const inMods = (Array.isArray(a.modalities_input) ? a.modalities_input : []) as string[];64  const outMods = (Array.isArray(a.modalities_output) ? a.modalities_output : []) as string[];65  const flags = CAPABILITY_KEYS.map((k) => ({ key: k, value: a[k] }));66  const known = flags.filter((f) => typeof f.value === 'boolean');67  return (68    <div className="space-y-6">69      <div>70        <p className="eyebrow mb-2">Modalities</p>71        {mods.length || inMods.length || outMods.length ? (72          <dl className="kv">73            {mods.length > 0 && (74              <div>75                <dt>Modalities</dt>76                <dd className="flex flex-wrap gap-1.5">{mods.map((m) => <Chip key={m}>{m}</Chip>)}</dd>77              </div>78            )}79            {inMods.length > 0 && (80              <div>81                <dt>Input</dt>82                <dd className="flex flex-wrap gap-1.5">{inMods.map((m) => <Chip key={m}>{m}</Chip>)}</dd>83              </div>84            )}85            {outMods.length > 0 && (86              <div>87                <dt>Output</dt>88                <dd className="flex flex-wrap gap-1.5">{outMods.map((m) => <Chip key={m}>{m}</Chip>)}</dd>89              </div>90            )}91          </dl>92        ) : (93          <p className="text-sm text-ink-3">Modalities unavailable.</p>94        )}95      </div>96      <div>97        <p className="eyebrow mb-2">Capabilities</p>98        <ul className="grid grid-cols-2 gap-px border border-rule bg-rule sm:grid-cols-3">99          {flags.map((f) => {100            const v = f.value;101            const p = d.provenance?.[f.key];102            return (103              <li key={f.key} className="bg-canvas px-3 py-3">104                <p className="text-xs text-ink-3">{propertyLabel(f.key)}</p>105                <p className={typeof v === 'boolean' ? (v ? 'mt-0.5 font-medium text-positive' : 'mt-0.5 font-medium text-ink-2') : 'mt-0.5 text-ink-3'}>{typeof v === 'boolean' ? (v ? 'Yes' : 'No') : 'Unavailable'}</p>106                {p && <p className="mt-0.5 truncate text-[11px] text-ink-3">{p.source_name ?? 'source'} · T{p.tier}</p>}107              </li>108            );109          })}110        </ul>111        {known.length === 0 && <Note className="mt-2">No capability flags have been observed from a source yet — we do not infer them.</Note>}112      </div>113      <KeyValue rows={[{ key: 'context_length', raw: a.context_length }, { key: 'max_output_tokens', raw: a.max_output_tokens }, { key: 'knowledge_cutoff', raw: a.knowledge_cutoff }, { key: 'languages', raw: a.languages }, { key: 'tokenizer', raw: a.tokenizer }]} provenance={d.provenance} slug={d.slug} entity={{ name: d.name, entity_type: d.entity_type }} />114    </div>115  );116}117118/* ------------------------------------------------------------------------------------------------------ benchmarks */119120function configSummary(c: Record<string, unknown>): string {121  const parts = Object.entries(c ?? {})122    .filter(([, v]) => v !== null && v !== undefined && v !== '')123    .slice(0, 4)124    .map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : String(v)}`);125  return parts.join(' · ');126}127128/** Results table. `perspective="model"` shows the benchmark column; `"benchmark"` shows rank + model. */129export function ResultsTable({ results, perspective }: { results: BenchmarkResult[]; perspective: 'model' | 'benchmark' }) {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>;131  return (132    <>133      <ScrollX><DataTable caption="Benchmark results">134        <thead>135          <tr>136            {perspective === 'benchmark' && <Th className="w-10">#</Th>}137            <Th>{perspective === 'model' ? 'Benchmark' : 'Model'}</Th>138            <Th num>Score</Th>139            <Th>Metric</Th>140            <Th>Config</Th>141            <Th>Evaluated</Th>142            <Th>Source</Th>143          </tr>144        </thead>145        <tbody>146          {results.map((r, i) => {147            const target = perspective === 'model' ? r.benchmark : r.model;148            return (149              <tr key={r.id}>150                {perspective === 'benchmark' && <Td className="tnum text-ink-3" hideStack>{i + 1}</Td>}151                <Td primary>152                  <EntityLink e={target} />153                  {perspective === 'benchmark' && r.model.organization && <span className="ml-2 text-xs text-ink-3">{r.model.organization.name}</span>}154                </Td>155                <Td num label="Score" className="tnum font-medium">156                  {fmtScore(r.score)}157                  {r.unit && r.unit !== '%' ? <span className="text-ink-3"> {r.unit}</span> : r.unit === '%' ? '%' : ''}158                </Td>159                <Td label="Metric" className="text-ink-2">{r.metric ?? '—'}{r.higher_is_better === false && <span className="text-ink-3"> (lower is better)</span>}</Td>160                <Td label="Config" className="mono max-w-[18rem] truncate text-xs text-ink-3" title={JSON.stringify(r.config)}>{configSummary(r.config) || '—'}</Td>161                <Td label="Evaluated" className="text-ink-2">{fmtDate(r.evaluated_at)}</Td>162                <Td label="Source"><SourceCell url={r.source_url} tier={r.tier} /> <ConfidenceBadge confidence={r.confidence !== 'high' && r.confidence !== 'medium' ? r.confidence : null} /></Td>163              </tr>164            );165          })}166        </tbody>167      </DataTable></ScrollX>168      <Note className="mt-3">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>.170      </Note>171    </>172  );173}174175/* ------------------------------------------------------------------------------------------------------ prices */176177export function PricesTable({ prices, perspective }: { prices: Price[]; perspective: 'model' | 'provider' }) {178  if (!prices.length) return <EmptyState title="No current prices recorded">Prices appear when a provider publishes a public pricing page we crawl.</EmptyState>;179  const sorted = [...prices].sort((a, b) => (num(a.input_per_mtok) ?? Infinity) - (num(b.input_per_mtok) ?? Infinity));180  return (181    <>182      <ScrollX><DataTable caption="Current prices per 1M tokens">183        <thead>184          <tr>185            <Th>{perspective === 'model' ? 'Provider' : 'Model'}</Th>186            <Th num>Input / 1M</Th>187            <Th num>Output / 1M</Th>188            <Th num>Cached in</Th>189            <Th num>Batch in / out</Th>190            <Th num>Context</Th>191            <Th>Observed</Th>192            <Th>Source</Th>193          </tr>194        </thead>195        <tbody>196          {sorted.map((p) => {197            const target = perspective === 'model' ? p.provider : p.model;198            return (199              <tr key={p.id}>200                <Td primary>201                  <EntityLink e={target} />202                  {p.provider_model_id && <span className="mono ml-2 text-[11px] text-ink-3">{p.provider_model_id}</span>}203                  {perspective === 'provider' && p.model.organization && <span className="ml-2 text-xs text-ink-3">{p.model.organization.name}</span>}204                </Td>205                <Td num label="Input / 1M" className="tnum font-medium text-accent-2">{fmtUsdPerM(p.input_per_mtok)}</Td>206                <Td num label="Output / 1M" className="tnum font-medium text-accent-2">{fmtUsdPerM(p.output_per_mtok)}</Td>207                <Td num label="Cached input" className="tnum text-ink-2">{fmtUsdPerM(p.cached_input_per_mtok)}</Td>208                <Td num label="Batch in / out" className="tnum text-ink-2">{num(p.batch_input_per_mtok) === null && num(p.batch_output_per_mtok) === null ? '—' : `${fmtUsdPerM(p.batch_input_per_mtok)} / ${fmtUsdPerM(p.batch_output_per_mtok)}`}</Td>209                <Td num label="Context" className="tnum text-ink-2">{num(p.context_length) === null ? '—' : fmtTokens(p.context_length)}</Td>210                <Td label="Observed" className="text-ink-2" title={p.observed_at}>{fmtAgo(p.observed_at)}</Td>211                <Td label="Source"><SourceCell url={p.source_url} tier={p.tier} /></Td>212              </tr>213            );214          })}215        </tbody>216      </DataTable></ScrollX>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>218    </>219  );220}221222/** Price history: step lines per provider (input price) + output as second chart when > 1 point. */223export function PriceHistory({ history, perspective = 'model' }: { history: Price[]; perspective?: 'model' | 'provider' }) {224  if (!history.length) return null;225  const byKey = new Map<string, Price[]>();226  for (const p of history) {227    const k = perspective === 'model' ? p.provider.name : p.model.name;228    const arr = byKey.get(k);229    if (arr) arr.push(p);230    else byKey.set(k, [p]);231  }232  const build = (field: 'input_per_mtok' | 'output_per_mtok'): Series[] =>233    [...byKey.entries()].slice(0, 8).map(([name, rows]) => ({234      name,235      points: stepPoints([...rows.sort((a, b) => a.valid_from.localeCompare(b.valid_from)).map((r) => ({ at: r.valid_from, value: num(r[field]) })), ...(rows.every((r) => r.valid_to) ? [] : [{ at: new Date().toISOString(), value: num([...rows].sort((a, b) => b.valid_from.localeCompare(a.valid_from))[0]?.[field]) }])]),236    }));237  const inSeries = build('input_per_mtok').filter((s) => s.points.length > 0);238  const totalPoints = inSeries.reduce((n, s) => n + s.points.length, 0);239  if (totalPoints < 2) return <Note>Price history starts with the first observation — no changes recorded yet ({history.length} row{history.length === 1 ? '' : 's'}).</Note>;240  const outSeries = build('output_per_mtok').filter((s) => s.points.length > 0);241  return (242    <div className="grid gap-6 md:grid-cols-2">243      <div>244        <p className="eyebrow mb-2">Input price · USD / 1M tokens</p>245        <LineChart series={inSeries} height={200} yFormat={(v) => fmtUsdPerM(v)} yDomain={[0, Math.max(...inSeries.flatMap((s) => s.points.map((p) => p.y))) * 1.15 || 1]} />246      </div>247      <div>248        <p className="eyebrow mb-2">Output price · USD / 1M tokens</p>249        <LineChart series={outSeries} height={200} yFormat={(v) => fmtUsdPerM(v)} yDomain={[0, Math.max(...outSeries.flatMap((s) => s.points.map((p) => p.y))) * 1.15 || 1]} />250      </div>251      <Legend series={inSeries} className="md:col-span-2" />252    </div>253  );254}255256/** Tiny per-row sparkline of input price for a model across its history. */257export function PriceSpark({ history, provider }: { history: Price[]; provider: string }) {258  const vals = history.filter((p) => p.provider.slug === provider).sort((a, b) => a.valid_from.localeCompare(b.valid_from)).map((p) => num(p.input_per_mtok)).filter((v): v is number => v !== null);259  return <Sparkline values={vals} width={80} height={20} stroke="var(--accent-2)" />;260}261262/* ------------------------------------------------------------------------------------------------------ hardware fit */263264export function HardwareFitTable({ rows }: { rows: HardwareFitRow[] }) {265  if (!rows.length) return <EmptyState title="No hardware estimate available">Estimates need a parameter count; this model has none recorded from a source.</EmptyState>;266  return (267    <>268      <div className="mb-3 flex flex-wrap items-center gap-2">269        <Estimated />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>271      </div>272      <ScrollX><DataTable caption="Estimated hardware fit">273        <thead>274          <tr>275            <Th>Hardware</Th>276            <Th>Quantization</Th>277            <Th num>Memory</Th>278            <Th num>Est. need</Th>279            <Th>Fits</Th>280          </tr>281        </thead>282        <tbody>283          {rows.map((r, i) => (284            <tr key={`${r.hardware.id}-${r.quantization}-${i}`}>285              <Td primary><EntityLink e={r.hardware} /></Td>286              <Td label="Quantization" className="mono text-xs text-ink-2">{r.quantization}</Td>287              <Td num label="Memory" className="tnum text-ink-2">{fmtGb(r.hardware.attributes?.memory_gb as never)}</Td>288              <Td num label="Est. need" className="tnum">{fmtGb(r.estimated_memory_gb, 1)}</Td>289              <Td label="Fits" className={r.fits ? 'font-medium text-positive' : 'text-ink-3'}>{r.fits ? 'Yes' : 'No'}</Td>290            </tr>291          ))}292        </tbody>293      </DataTable></ScrollX>294    </>295  );296}297298/* ------------------------------------------------------------------------------------------------------ lineage & relations */299300export function LineageBlock({ d }: { d: EntityDetail }) {301  const l = d.lineage;302  if (!l || (!l.ancestors.length && !l.descendants.length && !l.quantizations.length)) return <EmptyState title="No lineage recorded">Lineage comes from explicit `derived_from`, `fine_tuned_from`, `distilled_from` and `quantized_from` relations stated by sources.</EmptyState>;303  const Col = ({ title, items, hint }: { title: string; items: EntitySummary[]; hint: string }) => (304    <div>305      <p className="eyebrow">{title} <span className="tnum text-ink-3">{items.length}</span></p>306      {items.length ? (307        <ul className="mt-2 divide-y divide-rule border-y border-rule">308          {items.map((e) => (309            <li key={e.id} className="flex items-baseline justify-between gap-3 py-2 text-sm">310              <EntityLink e={e} className="font-medium" />311              <span className="tnum shrink-0 text-xs text-ink-3">{num(e.attributes?.parameter_count) !== null ? fmtParams(e.attributes.parameter_count) : e.organization?.name ?? ''}</span>312            </li>313          ))}314        </ul>315      ) : (316        <p className="mt-2 text-sm text-ink-3">{hint}</p>317      )}318    </div>319  );320  return (321    <div className="grid gap-6 md:grid-cols-3">322      <Col title="Ancestors" items={l.ancestors} hint="None recorded." />323      <div className="md:border-x md:border-rule md:px-6">324        <p className="eyebrow">This model</p>325        <p className="mt-2 text-sm font-medium">{d.name}</p>326        <p className="tnum text-xs text-ink-3">{num(d.attributes?.parameter_count) !== null ? `${fmtParams(d.attributes.parameter_count)} params` : ''}</p>327        <Col title="Quantizations" items={l.quantizations} hint="None recorded." />328      </div>329      <Col title="Descendants" items={l.descendants} hint="None recorded." />330    </div>331  );332}333334export function RelationsBlock({ relations, exclude = [] }: { relations: RelationGroup[]; exclude?: string[] }) {335  const groups = relations.filter((g) => g.items.length && !exclude.includes(g.predicate));336  if (!groups.length) return <p className="text-sm text-ink-3">No relations recorded.</p>;337  return (338    <dl className="kv">339      {groups.map((g) => (340        <div key={`${g.predicate}-${g.direction}`}>341          <dt>{predicateLabel(g.predicate, g.direction)}</dt>342          <dd>343            <EntityInline items={g.items} max={8} total={g.total} />344            {g.total > 8 && <span className="ml-1 text-xs text-ink-3">({fmtInt(g.total)} total)</span>}345          </dd>346        </div>347      ))}348    </dl>349  );350}351352/* ------------------------------------------------------------------------------------------------------ lists */353354export function EntityList({ items, empty = 'Nothing recorded yet.', showType = false }: { items: EntitySummary[]; empty?: string; showType?: boolean }) {355  if (!items.length) return <p className="text-sm text-ink-3">{empty}</p>;356  return (357    <ul className="border-t border-rule">358      {items.map((e) => (359        <EntityRow key={e.id} e={e} showType={showType} />360      ))}361    </ul>362  );363}364365/** Dense models table for company / provider / hardware pages. */366export function ModelsTable({ items, total, moreHref }: { items: EntitySummary[]; total?: number; moreHref?: string }) {367  if (!items.length) return <EmptyState title="No models recorded" />;368  return (369    <>370      <ScrollX><DataTable caption="Models">371        <thead>372          <tr>373            <Th>Model</Th>374            <Th num>Params</Th>375            <Th num>Context</Th>376            <Th>Openness</Th>377            <Th>Released</Th>378            <Th>Status</Th>379            <Th num>Quality</Th>380          </tr>381        </thead>382        <tbody>383          {items.map((m) => {384            const a = m.attributes ?? {};385            return (386              <tr key={m.id}>387                <Td primary><EntityLink e={m} /></Td>388                <Td num label="Params" className="tnum">{num(a.parameter_count) === null ? '—' : fmtParams(a.parameter_count)}</Td>389                <Td num label="Context" className="tnum">{num(a.context_length) === null ? '—' : fmtTokens(a.context_length)}</Td>390                <Td label="Openness" className="text-ink-2">{typeof a.openness === 'string' ? fmtValue(a.openness) : '—'}</Td>391                <Td label="Released" className="text-ink-2">{typeof a.release_date === 'string' ? fmtDate(a.release_date) : '—'}</Td>392                <Td label="Status" className="text-ink-2">{m.status && m.status !== 'unknown' ? m.status : '—'}</Td>393                <Td num label="Quality"><QualityMark q={m.quality?.score} /></Td>394              </tr>395            );396          })}397        </tbody>398      </DataTable></ScrollX>399      {total !== undefined && total > items.length && moreHref && (400        <p className="mt-3 text-sm">401          <Link href={moreHref} className="link">All {fmtInt(total)} models →</Link>402        </p>403      )}404    </>405  );406}407408/* ------------------------------------------------------------------------------------------------------ timeline & sources */409410export function TimelineList({ events, slug }: { events: ChangeEvent[]; slug?: string }) {411  if (!events.length) return <EmptyState title="No events yet">Events are generated by the change engine when a material property, price or result changes.</EmptyState>;412  return (413    <>414      <ul className="border-t border-rule">415        {events.map((e) => (416          <ChangeRow key={e.id} e={e} showDate live={false} />417        ))}418      </ul>419      {slug && (420        <p className="mt-3 text-sm">421          <Link href={`/timeline?entity=${encodeURIComponent(slug)}`} className="link">Full timeline →</Link>422        </p>423      )}424    </>425  );426}427428export function SourcesTable({ sources }: { sources: SourceRef[] }) {429  if (!sources.length) return <EmptyState title="No documents recorded" />;430  const sorted = [...sources].sort((a, b) => (a.tier ?? 9) - (b.tier ?? 9) || (b.last_observed_at ?? '').localeCompare(a.last_observed_at ?? ''));431  return (432    <>433      <ScrollX><DataTable caption="Source documents">434        <thead>435          <tr>436            <Th>Source</Th>437            <Th>Document</Th>438            <Th>Type</Th>439            <Th>Tier</Th>440            <Th>Last observed</Th>441            <Th num>Snapshots</Th>442          </tr>443        </thead>444        <tbody>445          {sorted.map((s) => (446            <tr key={s.url}>447              <Td primary>{s.source_name ?? s.domain ?? '—'}</Td>448              <Td label="Document" wide>449                <a href={s.url} target="_blank" rel="noopener noreferrer" className="inline-flex max-w-full items-center gap-1 break-all text-ink-2 hover:text-accent">450                  <span className="truncate">{s.url.replace(/^https?:\/\/(www\.)?/, '')}</span> <ExternalLink className="size-3 shrink-0" aria-hidden />451                </a>452              </Td>453              <Td label="Type" className="mono text-xs text-ink-2">{s.doc_type}</Td>454              <Td label="Tier"><TierBadge tier={s.tier} withLabel /></Td>455              <Td label="Last observed" className="text-ink-2" title={s.last_observed_at ?? undefined}>{fmtAgo(s.last_observed_at)}</Td>456              <Td num label="Snapshots" className="tnum">{fmtInt(s.snapshots)}</Td>457            </tr>458          ))}459        </tbody>460      </DataTable></ScrollX>461      <Note className="mt-3">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>.463      </Note>464    </>465  );466}467468/** Summary of provenance across all attributes: sources count, tiers distribution, freshest observation. */469export function ProvenanceSummary({ provenance, quality }: { provenance: Provenance; quality: EntityDetail['quality'] }) {470  const entries = Object.values(provenance ?? {});471  const tiers = [1, 2, 3, 4].map((t) => ({ t, n: entries.filter((e) => e.tier === t).length })).filter((x) => x.n);472  const newest = entries.map((e) => e.observed_at).sort().at(-1);473  const conflicts = num(quality?.conflicts) ?? 0;474  return (475    <div className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm sm:grid-cols-4">476      <div>477        <p className="eyebrow">Attributed facts</p>478        <p className="tnum mt-0.5 text-lg font-semibold">{fmtInt(entries.length)}</p>479      </div>480      <div>481        <p className="eyebrow">Source tiers</p>482        <p className="mt-1 flex flex-wrap gap-1">{tiers.length ? tiers.map((x) => <TierBadge key={x.t} tier={x.t} withLabel={false} />) : <span className="text-ink-3">—</span>}{tiers.length > 0 && <span className="tnum text-xs text-ink-3">{tiers.map((x) => x.n).join(' / ')}</span>}</p>483      </div>484      <div>485        <p className="eyebrow">Freshest observation</p>486        <p className="mt-0.5 text-ink-2">{newest ? fmtAgo(newest) : '—'}</p>487      </div>488      <div>489        <p className="eyebrow">Conflicts</p>490        <p className={conflicts ? 'mt-0.5 font-medium text-danger' : 'mt-0.5 text-ink-2'}>{conflicts ? `${conflicts} flagged` : 'None'}</p>491      </div>492    </div>493  );494}495496export function typeTitle(e: { entity_type: string }): string {497  return typeLabel(e.entity_type);498}499export { routes };500