diff --git a/packages/app/cypress/e2e/compare-index-matrix.cy.ts b/packages/app/cypress/e2e/compare-index-matrix.cy.ts new file mode 100644 index 00000000..d7d96194 --- /dev/null +++ b/packages/app/cypress/e2e/compare-index-matrix.cy.ts @@ -0,0 +1,86 @@ +describe('Compare index matrix', () => { + beforeEach(() => { + cy.window().then((win) => { + win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now())); + }); + }); + + it('renders one matrix per model section with linked + ghost cells on /compare', () => { + cy.visit('/compare'); + cy.get('[data-testid="compare-pair-matrix"]').should('have.length.greaterThan', 0); + + // Every model section renders exactly one matrix. + cy.get('section[id]').then(($sections) => { + const withMatrix = $sections.filter( + (_, s) => s.querySelector('[data-testid="compare-pair-matrix"]') !== null, + ); + cy.get('[data-testid="compare-pair-matrix"]').should('have.length', withMatrix.length); + }); + + // Available pairs are real links under /compare/--vs-. + cy.get('a[data-testid="compare-matrix-cell"]') + .should('have.length.greaterThan', 0) + .first() + .should('have.attr', 'href') + .and('match', /^\/compare\/[a-z0-9-]+-[a-z0-9]+-vs-[a-z0-9]+$/u); + + // Pairs without benchmark data render as non-link ghost cells (the + // fixtures' availability is sparse, so ghosts always exist). + cy.get('[data-testid="compare-matrix-empty-cell"]').should('have.length.greaterThan', 0); + + // Anchor text (sr-only) carries the pair name for crawlers. + cy.get('a[data-testid="compare-matrix-cell"]').first().invoke('text').should('match', /vs/u); + }); + + it('navigates to the pair page when a cell is clicked', () => { + cy.visit('/compare'); + cy.get('a[data-testid="compare-matrix-cell"]') + .first() + .then(($a) => { + const href = $a.attr('href')!; + cy.wrap($a).click(); + cy.location('pathname').should('eq', href); + }); + }); + + it('shows the focused pair in the readout line (same path as hover)', () => { + cy.visit('/compare'); + // React's onMouseEnter is delegated and unreachable via cy.trigger — the + // keyboard focus handler updates the same readout state, so exercise that. + cy.get('a[data-testid="compare-matrix-cell"]').first().focus(); + cy.get('a[data-testid="compare-matrix-cell"]') + .first() + .invoke('attr', 'title') + .then((label) => { + cy.get('[data-testid="compare-matrix-readout"]').first().should('contain.text', label); + }); + }); + + it('renders per-dollar matrices with /compare-per-dollar cell hrefs', () => { + cy.visit('/compare-per-dollar'); + cy.get('[data-testid="compare-pair-matrix"]').should('have.length.greaterThan', 0); + cy.get('a[data-testid="compare-matrix-cell"]') + .first() + .should('have.attr', 'href') + .and('match', /^\/compare-per-dollar\//u); + }); + + it('renders the zh matrices with /zh hrefs and Chinese legend strings', () => { + cy.visit('/zh/compare'); + cy.get('[data-testid="compare-pair-matrix"]').should('have.length.greaterThan', 0); + cy.get('a[data-testid="compare-matrix-cell"]') + .first() + .should('have.attr', 'href') + .and('match', /^\/zh\/compare\//u); + cy.contains('暂无基准测试数据').should('exist'); + cy.contains('点击单元格查看对比').should('exist'); + }); + + it('renders the zh per-dollar matrices with /zh/compare-per-dollar hrefs', () => { + cy.visit('/zh/compare-per-dollar'); + cy.get('a[data-testid="compare-matrix-cell"]') + .first() + .should('have.attr', 'href') + .and('match', /^\/zh\/compare-per-dollar\//u); + }); +}); diff --git a/packages/app/src/app/compare-per-dollar/page.tsx b/packages/app/src/app/compare-per-dollar/page.tsx index c33f57b2..a4038325 100644 --- a/packages/app/src/app/compare-per-dollar/page.tsx +++ b/packages/app/src/app/compare-per-dollar/page.tsx @@ -1,21 +1,17 @@ import type { Metadata } from 'next'; import Link from 'next/link'; -import { - HW_REGISTRY, - SITE_NAME, - SITE_URL, - SUPPORTERS_LINE, -} from '@semianalysisai/inferencex-constants'; +import { SITE_NAME, SITE_URL, SUPPORTERS_LINE } from '@semianalysisai/inferencex-constants'; import { enAlternates } from '@/lib/i18n'; -import { ComparePairCardLink } from '@/components/compare/compare-pair-card-link'; +import { ComparePairMatrix } from '@/components/compare/compare-pair-matrix'; import { JsonLd } from '@/components/json-ld'; import { Card } from '@/components/ui/card'; import { getComparablePairsByModelSlug } from '@/lib/compare-availability'; -import { type ComparePair, COMPARE_MODEL_SLUGS, type CompareModelSlug } from '@/lib/compare-slug'; -import { bucketComparePairsByVendor, formatModelList } from '@/lib/compare-ssr'; +import { buildCompareMatrix } from '@/lib/compare-matrix'; +import { COMPARE_MODEL_SLUGS } from '@/lib/compare-slug'; +import { formatModelList } from '@/lib/compare-ssr'; export const dynamic = 'force-dynamic'; @@ -38,42 +34,6 @@ export const metadata: Metadata = { }, }; -interface VendorGroup { - heading: string; - description: string; - pairs: { a: string; b: string; slug: string; label: string }[]; -} - -function groupPairsByVendorForModel( - model: CompareModelSlug, - comparablePairs: ComparePair[], -): VendorGroup[] { - const { cross, nvidia, amd } = bucketComparePairsByVendor(model.slug, comparablePairs); - const groups: VendorGroup[] = []; - if (cross.length > 0) { - groups.push({ - heading: 'NVIDIA vs AMD', - description: 'Cross-vendor cost-per-token comparisons across architecture generations.', - pairs: cross, - }); - } - if (nvidia.length > 0) { - groups.push({ - heading: 'NVIDIA vs NVIDIA', - description: 'Hopper and Blackwell generation cost-per-token comparisons.', - pairs: nvidia, - }); - } - if (amd.length > 0) { - groups.push({ - heading: 'AMD vs AMD', - description: 'CDNA 3 and CDNA 4 generation cost-per-token comparisons.', - pairs: amd, - }); - } - return groups; -} - const jsonLd = { '@context': 'https://schema.org', '@type': 'CollectionPage', @@ -145,7 +105,6 @@ export default async function ComparePerDollarIndexPage() { {modelsWithPairs.map((model) => { const pairs = comparablePairsByModel.get(model.slug) ?? []; - const groups = groupPairsByVendorForModel(model, pairs); return (
@@ -156,30 +115,11 @@ export default async function ComparePerDollarIndexPage() { benchmark data on {model.label}.

- {groups.map((group) => ( -
-
-

{group.heading}

-

{group.description}

-
-
- {group.pairs.map(({ slug, label, a, b }) => { - const aMeta = HW_REGISTRY[a]; - const bMeta = HW_REGISTRY[b]; - const archLine = `${aMeta?.arch ?? '—'} · ${bMeta?.arch ?? '—'}`; - return ( - - ); - })} -
-
- ))} +
); diff --git a/packages/app/src/app/compare/page.tsx b/packages/app/src/app/compare/page.tsx index 7c44c8c9..e7100ea5 100644 --- a/packages/app/src/app/compare/page.tsx +++ b/packages/app/src/app/compare/page.tsx @@ -1,21 +1,17 @@ import type { Metadata } from 'next'; import Link from 'next/link'; -import { - HW_REGISTRY, - SITE_NAME, - SITE_URL, - SUPPORTERS_LINE, -} from '@semianalysisai/inferencex-constants'; +import { SITE_NAME, SITE_URL, SUPPORTERS_LINE } from '@semianalysisai/inferencex-constants'; import { enAlternates } from '@/lib/i18n'; -import { ComparePairCardLink } from '@/components/compare/compare-pair-card-link'; +import { ComparePairMatrix } from '@/components/compare/compare-pair-matrix'; import { JsonLd } from '@/components/json-ld'; import { Card } from '@/components/ui/card'; import { getComparablePairsByModelSlug } from '@/lib/compare-availability'; -import { type ComparePair, COMPARE_MODEL_SLUGS, type CompareModelSlug } from '@/lib/compare-slug'; -import { bucketComparePairsByVendor, formatModelList } from '@/lib/compare-ssr'; +import { buildCompareMatrix } from '@/lib/compare-matrix'; +import { COMPARE_MODEL_SLUGS } from '@/lib/compare-slug'; +import { formatModelList } from '@/lib/compare-ssr'; export const dynamic = 'force-dynamic'; @@ -38,42 +34,6 @@ export const metadata: Metadata = { }, }; -interface VendorGroup { - heading: string; - description: string; - pairs: { a: string; b: string; slug: string; label: string }[]; -} - -function groupPairsByVendorForModel( - model: CompareModelSlug, - comparablePairs: ComparePair[], -): VendorGroup[] { - const { cross, nvidia, amd } = bucketComparePairsByVendor(model.slug, comparablePairs); - const groups: VendorGroup[] = []; - if (cross.length > 0) { - groups.push({ - heading: 'NVIDIA vs AMD', - description: 'Cross-vendor comparisons across architecture generations.', - pairs: cross, - }); - } - if (nvidia.length > 0) { - groups.push({ - heading: 'NVIDIA vs NVIDIA', - description: 'Hopper and Blackwell generation comparisons.', - pairs: nvidia, - }); - } - if (amd.length > 0) { - groups.push({ - heading: 'AMD vs AMD', - description: 'CDNA 3 and CDNA 4 generation comparisons.', - pairs: amd, - }); - } - return groups; -} - const jsonLd = { '@context': 'https://schema.org', '@type': 'CollectionPage', @@ -142,7 +102,6 @@ export default async function CompareIndexPage() { {modelsWithPairs.map((model) => { const pairs = comparablePairsByModel.get(model.slug) ?? []; - const groups = groupPairsByVendorForModel(model, pairs); return (
@@ -153,30 +112,11 @@ export default async function CompareIndexPage() { {model.label}.

- {groups.map((group) => ( -
-
-

{group.heading}

-

{group.description}

-
-
- {group.pairs.map(({ slug, label, a, b }) => { - const aMeta = HW_REGISTRY[a]; - const bMeta = HW_REGISTRY[b]; - const archLine = `${aMeta?.arch ?? '—'} · ${bMeta?.arch ?? '—'}`; - return ( - - ); - })} -
-
- ))} +
); diff --git a/packages/app/src/app/zh/compare-per-dollar/page.tsx b/packages/app/src/app/zh/compare-per-dollar/page.tsx index a31f6e5b..c4e62887 100644 --- a/packages/app/src/app/zh/compare-per-dollar/page.tsx +++ b/packages/app/src/app/zh/compare-per-dollar/page.tsx @@ -1,19 +1,15 @@ import type { Metadata } from 'next'; import Link from 'next/link'; -import { - HW_REGISTRY, - SITE_NAME, - SITE_URL, - SUPPORTERS_LINE_ZH, -} from '@semianalysisai/inferencex-constants'; +import { SITE_NAME, SITE_URL, SUPPORTERS_LINE_ZH } from '@semianalysisai/inferencex-constants'; -import { ComparePairCardLink } from '@/components/compare/compare-pair-card-link'; +import { ComparePairMatrix } from '@/components/compare/compare-pair-matrix'; import { JsonLd } from '@/components/json-ld'; import { Card } from '@/components/ui/card'; import { getComparablePairsByModelSlug } from '@/lib/compare-availability'; -import { type ComparePair, COMPARE_MODEL_SLUGS, type CompareModelSlug } from '@/lib/compare-slug'; -import { bucketComparePairsByVendor, formatModelList } from '@/lib/compare-ssr'; +import { buildCompareMatrix } from '@/lib/compare-matrix'; +import { COMPARE_MODEL_SLUGS } from '@/lib/compare-slug'; +import { formatModelList } from '@/lib/compare-ssr'; import { ZH_OG_LOCALE, zhAlternates } from '@/lib/i18n'; export const dynamic = 'force-dynamic'; @@ -38,42 +34,6 @@ export const metadata: Metadata = { }, }; -interface VendorGroup { - heading: string; - description: string; - pairs: { a: string; b: string; slug: string; label: string }[]; -} - -function groupPairsByVendorForModel( - model: CompareModelSlug, - comparablePairs: ComparePair[], -): VendorGroup[] { - const { cross, nvidia, amd } = bucketComparePairsByVendor(model.slug, comparablePairs); - const groups: VendorGroup[] = []; - if (cross.length > 0) { - groups.push({ - heading: 'NVIDIA vs AMD', - description: '跨厂商的不同架构代际每 token 成本对比。', - pairs: cross, - }); - } - if (nvidia.length > 0) { - groups.push({ - heading: 'NVIDIA vs NVIDIA', - description: 'Hopper 与 Blackwell 代际每 token 成本对比。', - pairs: nvidia, - }); - } - if (amd.length > 0) { - groups.push({ - heading: 'AMD vs AMD', - description: 'CDNA 3 与 CDNA 4 代际每 token 成本对比。', - pairs: amd, - }); - } - return groups; -} - const jsonLd = { '@context': 'https://schema.org', '@type': 'CollectionPage', @@ -139,7 +99,6 @@ export default async function ComparePerDollarIndexPageZh() { {modelsWithPairs.map((model) => { const pairs = comparablePairsByModel.get(model.slug) ?? []; - const groups = groupPairsByVendorForModel(model, pairs); return (
@@ -149,30 +108,11 @@ export default async function ComparePerDollarIndexPageZh() { {pairs.length} 组 GPU 对比具有 {model.label} 的每 token 成本基准测试数据。

- {groups.map((group) => ( -
-
-

{group.heading}

-

{group.description}

-
-
- {group.pairs.map(({ slug, label, a, b }) => { - const aMeta = HW_REGISTRY[a]; - const bMeta = HW_REGISTRY[b]; - const archLine = `${aMeta?.arch ?? '—'} · ${bMeta?.arch ?? '—'}`; - return ( - - ); - })} -
-
- ))} +
); diff --git a/packages/app/src/app/zh/compare/page.tsx b/packages/app/src/app/zh/compare/page.tsx index 55a4f43f..589c0b90 100644 --- a/packages/app/src/app/zh/compare/page.tsx +++ b/packages/app/src/app/zh/compare/page.tsx @@ -1,19 +1,15 @@ import type { Metadata } from 'next'; import Link from 'next/link'; -import { - HW_REGISTRY, - SITE_NAME, - SITE_URL, - SUPPORTERS_LINE_ZH, -} from '@semianalysisai/inferencex-constants'; +import { SITE_NAME, SITE_URL, SUPPORTERS_LINE_ZH } from '@semianalysisai/inferencex-constants'; -import { ComparePairCardLink } from '@/components/compare/compare-pair-card-link'; +import { ComparePairMatrix } from '@/components/compare/compare-pair-matrix'; import { JsonLd } from '@/components/json-ld'; import { Card } from '@/components/ui/card'; import { getComparablePairsByModelSlug } from '@/lib/compare-availability'; -import { type ComparePair, COMPARE_MODEL_SLUGS, type CompareModelSlug } from '@/lib/compare-slug'; -import { bucketComparePairsByVendor, formatModelList } from '@/lib/compare-ssr'; +import { buildCompareMatrix } from '@/lib/compare-matrix'; +import { COMPARE_MODEL_SLUGS } from '@/lib/compare-slug'; +import { formatModelList } from '@/lib/compare-ssr'; import { ZH_OG_LOCALE, zhAlternates } from '@/lib/i18n'; export const dynamic = 'force-dynamic'; @@ -38,42 +34,6 @@ export const metadata: Metadata = { }, }; -interface VendorGroup { - heading: string; - description: string; - pairs: { a: string; b: string; slug: string; label: string }[]; -} - -function groupPairsByVendorForModel( - model: CompareModelSlug, - comparablePairs: ComparePair[], -): VendorGroup[] { - const { cross, nvidia, amd } = bucketComparePairsByVendor(model.slug, comparablePairs); - const groups: VendorGroup[] = []; - if (cross.length > 0) { - groups.push({ - heading: 'NVIDIA vs AMD', - description: '跨厂商的不同架构代际对比。', - pairs: cross, - }); - } - if (nvidia.length > 0) { - groups.push({ - heading: 'NVIDIA vs NVIDIA', - description: 'Hopper 与 Blackwell 代际对比。', - pairs: nvidia, - }); - } - if (amd.length > 0) { - groups.push({ - heading: 'AMD vs AMD', - description: 'CDNA 3 与 CDNA 4 代际对比。', - pairs: amd, - }); - } - return groups; -} - const jsonLd = { '@context': 'https://schema.org', '@type': 'CollectionPage', @@ -138,7 +98,6 @@ export default async function CompareIndexPageZh() { {modelsWithPairs.map((model) => { const pairs = comparablePairsByModel.get(model.slug) ?? []; - const groups = groupPairsByVendorForModel(model, pairs); return (
@@ -148,30 +107,11 @@ export default async function CompareIndexPageZh() { {pairs.length} 组 GPU 对比具有 {model.label} 的基准测试数据。

- {groups.map((group) => ( -
-
-

{group.heading}

-

{group.description}

-
-
- {group.pairs.map(({ slug, label, a, b }) => { - const aMeta = HW_REGISTRY[a]; - const bMeta = HW_REGISTRY[b]; - const archLine = `${aMeta?.arch ?? '—'} · ${bMeta?.arch ?? '—'}`; - return ( - - ); - })} -
-
- ))} +
); diff --git a/packages/app/src/components/compare/compare-pair-matrix.tsx b/packages/app/src/components/compare/compare-pair-matrix.tsx new file mode 100644 index 00000000..b7997ae3 --- /dev/null +++ b/packages/app/src/components/compare/compare-pair-matrix.tsx @@ -0,0 +1,260 @@ +'use client'; + +import { useState } from 'react'; + +import { track } from '@/lib/analytics'; +import type { CompareMatrix } from '@/lib/compare-matrix'; +import { useLocale } from '@/lib/use-locale'; +import { cn } from '@/lib/utils'; + +const STRINGS = { + en: { + hint: 'Click a cell to open the comparison.', + crossLegend: 'NVIDIA vs AMD', + sameLegend: 'Same vendor', + emptyLegend: 'No benchmark data yet', + matrixAria: (model: string) => `${model} GPU comparison matrix`, + cellAria: (model: string, pair: string) => `${model}: ${pair} benchmark comparison`, + emptyTitle: (pair: string) => `${pair} — no benchmark data yet`, + }, + zh: { + hint: '点击单元格查看对比。', + crossLegend: 'NVIDIA vs AMD', + sameLegend: '同厂商', + emptyLegend: '暂无基准测试数据', + matrixAria: (model: string) => `${model} GPU 对比矩阵`, + cellAria: (model: string, pair: string) => `${model}:${pair} 基准测试对比`, + emptyTitle: (pair: string) => `${pair} — 暂无基准测试数据`, + }, +} as const; + +/** A row/column cell coordinate plus its display label, tracked for the + * hover/focus highlight and readout. */ +interface ActiveCell { + row: string; + col: string; + label: string; +} + +const VENDOR_HEADER_CLASS: Record = { + NVIDIA: 'border-emerald-500/60 text-emerald-700 dark:text-emerald-400', + AMD: 'border-red-500/60 text-red-700 dark:text-red-400', +}; + +const VENDOR_DOT_CLASS: Record = { + NVIDIA: 'bg-emerald-500/80', + AMD: 'bg-red-500/80', +}; + +interface ComparePairMatrixProps { + matrix: CompareMatrix; + /** Route prefix cells link under, e.g. "/compare" or "/zh/compare-per-dollar". */ + hrefPrefix: string; + /** Model label for accessible names and the analytics payload context. */ + modelLabel: string; +} + +/** + * Lower-triangle GPU×GPU selection matrix for the compare index pages. Each + * available pair is a real server-rendered `
` (SEO: all pair links stay in + * the HTML with the pair name as sr-only anchor text) carrying a visible ↗ + * glyph so linked cells read as clickable next to the empty ghosts; pairs + * without benchmark data render as ghost cells so coverage gaps are visible + * at a glance. The vendor blocks make the cross-vendor region a contiguous + * rectangle, which gets the brand tint — same-vendor triangles stay neutral. + */ +export function ComparePairMatrix({ matrix, hrefPrefix, modelLabel }: ComparePairMatrixProps) { + const locale = useLocale(); + const t = STRINGS[locale]; + // Hover and keyboard focus are tracked separately so a pointer entering and + // leaving a still-focused cell doesn't wipe the focus highlight. Hover takes + // precedence while present; when the pointer leaves, a focused cell persists. + const [hoveredCell, setHoveredCell] = useState(null); + const [focusedCell, setFocusedCell] = useState(null); + const active = hoveredCell ?? focusedCell; + + const { gpus, cells } = matrix; + // Lower triangle: the first GPU never appears as a row (nothing precedes + // it), the last never as a column (nothing follows it). + const rows = gpus.slice(1); + const cols = gpus.slice(0, -1); + + // Consecutive same-vendor column runs → the colSpan group bars on top. + const colGroups: { vendor: string; count: number }[] = []; + for (const gpu of cols) { + const last = colGroups.at(-1); + if (last && last.vendor === gpu.vendor) last.count++; + else colGroups.push({ vendor: gpu.vendor, count: 1 }); + } + + return ( +
+
+ + + + {/* Fixed-layout table: this corner cell sets the row-header + column width; the 9 GPU columns share the remaining space. */} + + ))} + + + + ))} + + + + {rows.map((rowGpu, i) => ( + + + {cols.map((colGpu, jIdx) => { + // rows is gpus[1..], so this row's display index is i+1; + // cols is gpus[0..n-2], so the column's display index is + // jIdx. Cells exist below the diagonal (col before row). + if (jIdx > i) { + return + ); + } + const href = `${hrefPrefix}/${cell.slug}`; + return ( + + ); + })} + + ))} + +
+
+ {group.vendor} +
+
+ + {gpu.shortLabel} + +
+
+ + + + {rowGpu.arch} + +
+
+ + { + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) { + return; + } + e.preventDefault(); + track('compare_index_pair_clicked', { + slug: cell.slug, + label: cell.label, + }); + window.location.href = href; + }} + onMouseEnter={() => + setHoveredCell({ row: rowGpu.key, col: colGpu.key, label: cell.label }) + } + onMouseLeave={() => setHoveredCell(null)} + onFocus={() => + setFocusedCell({ row: rowGpu.key, col: colGpu.key, label: cell.label }) + } + onBlur={() => setFocusedCell(null)} + className={cn( + 'group flex h-8 w-full items-center justify-center rounded-md transition-all duration-150 lg:h-9', + 'hover:scale-105 focus-visible:ring-2 focus-visible:ring-brand focus-visible:outline-none', + cell.cross + ? 'bg-brand/20 ring-1 ring-brand/40 ring-inset hover:bg-brand/45' + : 'bg-foreground/15 hover:bg-foreground/30', + )} + > + {/* Visible link affordance — ghost cells stay empty, + so the glyph doubles as the clickable/not marker. */} + + {cell.label} + +
+
+ +
+ + {active ? {active.label} → : t.hint} + + + + + + + +
+
+ ); +} diff --git a/packages/app/src/lib/compare-matrix.test.ts b/packages/app/src/lib/compare-matrix.test.ts new file mode 100644 index 00000000..5d207f0c --- /dev/null +++ b/packages/app/src/lib/compare-matrix.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; + +import { GPU_KEYS, HW_REGISTRY } from '@semianalysisai/inferencex-constants'; + +import { buildCompareMatrix, compareMatrixGpuOrder } from './compare-matrix'; +import { canonicalCompareSlug } from './compare-slug'; + +describe('compareMatrixGpuOrder', () => { + it('includes every registry GPU exactly once', () => { + const order = compareMatrixGpuOrder(); + expect(order.map((g) => g.key).toSorted()).toEqual([...GPU_KEYS].toSorted()); + }); + + it('keeps vendors contiguous with NVIDIA first', () => { + const vendors = compareMatrixGpuOrder().map((g) => g.vendor); + // Once the vendor changes, the earlier vendor must never reappear — + // contiguous blocks are what make the cross-vendor region a rectangle. + // NVIDIA-first is pinned by VENDOR_BLOCK_ORDER, not derived from registry + // sort, so a future low-sort AMD flagship can't silently flip the axis. + const blocks = vendors.filter((v, i) => i === 0 || v !== vendors[i - 1]); + expect(blocks).toEqual(['NVIDIA', 'AMD']); + }); + + it('orders each vendor block oldest→newest (registry sort descending)', () => { + const order = compareMatrixGpuOrder(); + for (let i = 1; i < order.length; i++) { + if (order[i].vendor !== order[i - 1].vendor) continue; + expect(HW_REGISTRY[order[i].key].sort).toBeLessThan(HW_REGISTRY[order[i - 1].key].sort); + } + // Pin the NVIDIA generational read so a registry sort change is caught. + expect(order.slice(0, 2).map((g) => g.key)).toEqual(['h100', 'h200']); + }); + + it('uses the registry label for rows and the uppercased key for columns', () => { + const gb200 = compareMatrixGpuOrder().find((g) => g.key === 'gb200')!; + expect(gb200.label).toBe('GB200 NVL72'); + expect(gb200.shortLabel).toBe('GB200'); + }); +}); + +describe('buildCompareMatrix', () => { + const PAIRS = [ + { a: 'h100', b: 'h200' }, + { a: 'h100', b: 'mi300x' }, + { a: 'mi300x', b: 'mi325x' }, + ]; + + it('defines cells only for the lower triangle of the display order', () => { + const { gpus, cells } = buildCompareMatrix('deepseek-r1', PAIRS); + const index = new Map(gpus.map((g, i) => [g.key, i])); + for (const [rowKey, row] of Object.entries(cells)) { + for (const colKey of Object.keys(row)) { + expect(index.get(colKey)!).toBeLessThan(index.get(rowKey)!); + } + } + // Every below-diagonal position is present, even when unavailable. + const cellCount = Object.values(cells).reduce((s, r) => s + Object.keys(r).length, 0); + expect(cellCount).toBe((gpus.length * (gpus.length - 1)) / 2); + }); + + it('marks exactly the provided pairs as available', () => { + const { cells, availableCount } = buildCompareMatrix('deepseek-r1', PAIRS); + expect(availableCount).toBe(PAIRS.length); + expect(cells['h200']['h100'].available).toBe(true); + expect(cells['mi300x']['h100'].available).toBe(true); + expect(cells['mi325x']['mi300x'].available).toBe(true); + expect(cells['b200']['h100'].available).toBe(false); + }); + + it('builds canonical alphabetical slugs regardless of display order', () => { + const { cells } = buildCompareMatrix('deepseek-r1', PAIRS); + // Display order puts h100 before b200, but the slug sorts alphabetically. + expect(cells['b200']['h100'].slug).toBe(canonicalCompareSlug('deepseek-r1', 'b200', 'h100')); + expect(cells['b200']['h100'].slug).toBe('deepseek-r1-b200-vs-h100'); + }); + + it('labels cells in canonical alphabetical order to match the destination page', () => { + const { cells } = buildCompareMatrix('deepseek-r1', PAIRS); + // Display puts h100 (col) before b200 (row), and the label also sorts + // alphabetically so it matches the destination page title and the + // analytics payload ("deepseek-r1-b200-vs-h100" → "B200 vs H100"). + expect(cells['b200']['h100'].label).toBe('B200 vs H100'); + // Display and alphabetical order coincide here, so the label is unchanged. + expect(cells['mi355x']['gb200'].label).toBe('GB200 NVL72 vs MI355X'); + }); + + it('flags cross-vendor cells and only those', () => { + const { gpus, cells } = buildCompareMatrix('deepseek-r1', PAIRS); + const vendorOf = new Map(gpus.map((g) => [g.key, g.vendor])); + for (const [rowKey, row] of Object.entries(cells)) { + for (const [colKey, cell] of Object.entries(row)) { + expect(cell.cross).toBe(vendorOf.get(rowKey) !== vendorOf.get(colKey)); + } + } + }); + + it('returns an all-ghost matrix for a model with no pairs', () => { + const { cells, availableCount } = buildCompareMatrix('glm-5-1', []); + expect(availableCount).toBe(0); + expect(Object.values(cells).every((r) => Object.values(r).every((c) => !c.available))).toBe( + true, + ); + }); +}); diff --git a/packages/app/src/lib/compare-matrix.ts b/packages/app/src/lib/compare-matrix.ts new file mode 100644 index 00000000..c6095cb2 --- /dev/null +++ b/packages/app/src/lib/compare-matrix.ts @@ -0,0 +1,145 @@ +/** + * Pure helpers for the GPU-pair selection matrices on the `/compare` and + * `/compare-per-dollar` index pages (and their /zh siblings). + * + * The index used to render one card per comparable pair — ~36 cards per model + * across 9+ model sections stopped scaling as a navigation surface. The matrix + * replaces the card walls with one lower-triangle GPU×GPU grid per model: + * every possible pair is a cell, cells with benchmark data on both sides are + * links, cells without data render as ghosts so coverage gaps are visible. + * + * Axis ordering is derived from HW_REGISTRY rather than hardcoded: + * + * - Vendors form contiguous blocks (NVIDIA, then AMD) so the three vendor + * regions of the old index — NVIDIA×NVIDIA, cross-vendor, AMD×AMD — appear + * as contiguous areas of the triangle instead of separate card groups. + * Block order is pinned by VENDOR_BLOCK_ORDER (NVIDIA first, the site-wide + * convention) rather than derived from registry sort: a future AMD flagship + * with a lower sort than GB300 would otherwise silently flip the whole axis + * to AMD-first. Any unlisted vendor sorts after the pinned blocks, ordered + * by its best (lowest) registry sort value. + * - Within a vendor, GPUs sort by registry `sort` DESCENDING. The registry + * sorts newest-first for chart legends, so descending here reads + * oldest→newest along the axis (H100 → H200 → B200 → B300 → GB200 → GB300), + * the natural left-to-right direction for a generational table. + */ +import { HW_REGISTRY } from '@semianalysisai/inferencex-constants'; + +import { canonicalCompareSlug, compareDisplayLabel, type ComparePair } from '@/lib/compare-slug'; + +export interface CompareMatrixGpu { + key: string; + /** Full display label for row headers, e.g. "GB200 NVL72". */ + label: string; + /** Compact label for the vertical column headers, e.g. "GB200". */ + shortLabel: string; + arch: string; + vendor: string; +} + +export interface CompareMatrixCell { + /** Canonical compare slug (alphabetical GPU order), e.g. "deepseek-r1-b200-vs-h100". */ + slug: string; + /** Canonical alphabetical pair label — matches the destination page title + * and the pre-matrix `compare_index_pair_clicked` analytics payload, e.g. + * "B200 vs H100" (not the display row-vs-column order). */ + label: string; + /** True when both GPUs have benchmark data for the model — cell is a link. */ + available: boolean; + /** True for NVIDIA×AMD pairs — the cross-vendor region gets the brand tint. */ + cross: boolean; +} + +export interface CompareMatrix { + /** Full GPU axis in display order — identical for every model so the nine + * matrices line up and coverage is comparable across sections. */ + gpus: CompareMatrixGpu[]; + /** cells[rowKey][colKey], defined only for column display-index < row + * display-index (lower triangle). */ + cells: Record>; + availableCount: number; +} + +/** Pinned vendor block order for the matrix axes — NVIDIA first per the + * site-wide convention. Vendors not listed here (a future third vendor) + * sort after the pinned blocks, ordered by their best registry sort, so + * new registry entries can never silently reorder the existing axes. */ +const VENDOR_BLOCK_ORDER = ['NVIDIA', 'AMD']; + +/** Shared axis order for every model's matrix. See module docblock. */ +export function compareMatrixGpuOrder(): CompareMatrixGpu[] { + const entries = Object.entries(HW_REGISTRY).map(([key, meta]) => ({ + key, + label: meta.label, + shortLabel: key.toUpperCase(), + arch: meta.arch, + vendor: meta.vendor, + sort: meta.sort, + })); + + const vendorRank = new Map(); + for (const e of entries) { + const best = vendorRank.get(e.vendor); + if (best === undefined || e.sort < best) vendorRank.set(e.vendor, e.sort); + } + + entries.sort((x, y) => { + if (x.vendor !== y.vendor) { + const ix = VENDOR_BLOCK_ORDER.indexOf(x.vendor); + const iy = VENDOR_BLOCK_ORDER.indexOf(y.vendor); + // Both pinned → pinned order. One pinned → it sorts first. Neither pinned + // → best registry sort, so a future third vendor still orders + // deterministically without disturbing the pinned NVIDIA→AMD blocks. + if (ix !== -1 || iy !== -1) { + if (ix === -1) return 1; + if (iy === -1) return -1; + return ix - iy; + } + return (vendorRank.get(x.vendor) ?? 0) - (vendorRank.get(y.vendor) ?? 0); + } + return y.sort - x.sort; + }); + + return entries.map(({ key, label, shortLabel, arch, vendor }) => ({ + key, + label, + shortLabel, + arch, + vendor, + })); +} + +/** Build one model's matrix from the pairs that actually have benchmark data + * on both sides (`getComparablePairsByModelSlug()` output — canonical + * alphabetical a < b). Fully serializable: built in the server page, rendered + * by the ComparePairMatrix client component. */ +export function buildCompareMatrix( + modelSlug: string, + availablePairs: ComparePair[], +): CompareMatrix { + const gpus = compareMatrixGpuOrder(); + const availableKeys = new Set(availablePairs.map((p) => `${p.a}__${p.b}`)); + + const cells: Record> = {}; + let availableCount = 0; + + for (let j = 1; j < gpus.length; j++) { + const row: Record = {}; + for (let i = 0; i < j; i++) { + const col = gpus[i]; + const rowGpu = gpus[j]; + const [first, second] = [col.key, rowGpu.key].toSorted(); + const available = availableKeys.has(`${first}__${second}`); + if (available) availableCount++; + row[col.key] = { + slug: canonicalCompareSlug(modelSlug, col.key, rowGpu.key), + label: compareDisplayLabel(first, second), + available, + cross: col.vendor !== rowGpu.vendor, + }; + } + cells[gpus[j].key] = row; + } + + return { gpus, cells, availableCount }; +} diff --git a/packages/app/src/lib/compare-ssr.ts b/packages/app/src/lib/compare-ssr.ts index 592a8238..1199a526 100644 --- a/packages/app/src/lib/compare-ssr.ts +++ b/packages/app/src/lib/compare-ssr.ts @@ -31,9 +31,7 @@ import { cachedQuery } from '@/lib/api-cache'; import { rowToAggDataEntry } from '@/lib/benchmark-transform'; import { getHardwareKey } from '@/lib/chart-utils'; import { - canonicalCompareSlug, compareDisplayLabel, - type ComparePair, type CompareModelSlug, compareModelDisplayLabel, compareModelSeoName, @@ -887,48 +885,6 @@ export function formatModelList(models: CompareModelSlug[]): string { return `${labels.slice(0, -1).join(', ')}, and ${labels.at(-1)}`; } -export interface VendorBucketEntry { - a: string; - b: string; - slug: string; - label: string; -} - -export interface VendorBuckets { - /** Cross-vendor pairs (NVIDIA × AMD). */ - cross: VendorBucketEntry[]; - /** Both sides NVIDIA. */ - nvidia: VendorBucketEntry[]; - /** Both sides AMD. */ - amd: VendorBucketEntry[]; -} - -/** Split (a, b) GPU pairs into vendor buckets for the index grid. The caller - * wraps these entries with its own group headings / descriptions / route - * prefix — keeps the sorting + bucketing + slug-building in one place so the - * two index pages can't drift on those mechanics. */ -export function bucketComparePairsByVendor(modelSlug: string, pairs: ComparePair[]): VendorBuckets { - const nvidia: VendorBucketEntry[] = []; - const amd: VendorBucketEntry[] = []; - const cross: VendorBucketEntry[] = []; - - for (const { a, b } of pairs) { - const entry: VendorBucketEntry = { - a, - b, - slug: canonicalCompareSlug(modelSlug, a, b), - label: compareDisplayLabel(a, b), - }; - const vA = HW_REGISTRY[a]?.vendor; - const vB = HW_REGISTRY[b]?.vendor; - if (vA === 'NVIDIA' && vB === 'NVIDIA') nvidia.push(entry); - else if (vA === 'AMD' && vB === 'AMD') amd.push(entry); - else cross.push(entry); - } - - return { cross, nvidia, amd }; -} - /** Breadcrumb trail for a compare slug page. Emitted alongside the main * Dataset/ItemList JSON-LD so Google can render the Home → Compare → A vs B * trail in search results. Variant chooses /compare vs /compare-per-dollar. */