diff --git a/AGENTS.md b/AGENTS.md index 8f0db60..da5c878 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,6 +49,20 @@ rules below are the parts that were worth keeping. enforce a path convention, so update the explicit mapping when a suite is added or its doc moves. Groups without a matching explainer render no docs link. Do not guess a path or link to an unrelated general guide. +- **Every group kind has a summary, by default.** [`web/lib/summary.ts`](web/lib/summary.ts) + has no allowlist gate: a suite that lands in one of the five fact tables gets a rollup card + from its first ingest. The three timing families (query, random access, vector search) rank + through one `rankSeries` model, so a new suite in any of them needs no summary code at all. + `collectGroupSummary`'s exhaustive switch makes a missing arm for a sixth fact table a compile + error rather than a silently blank card. Do NOT reintroduce a per-dataset allowlist; the v2-era + one is what left `spatialbench`, `fineweb`, `gharchive`, `appian`, `public-bi`, + `clickbench-sorted`, and every vector-search group with no card. +- **A summary ranks the whole group, never one chart.** Random access is the cautionary case: + the producer emits `dataset` as `{dataset}/{pattern}`, so the group holds ~nine charts, and the + old summary published the alphabetically first chart's raw times under the group-wide title + "Random Access Performance" — reporting `lance` at 352us when it is over 1ms on most of the + other charts. Rank across every bucket in the group, impute the missing-series penalty where a + series skipped one, and report `measured`/`total` so a partially covered series is legible. - **Don't write a server-side classifier for live ingest.** The emitter produces structured records directly. Classifying loose name strings at read time was the v2-era weakness every later generation existed to escape; it belongs nowhere in the live pipeline. diff --git a/web/components/GroupSection.test.tsx b/web/components/GroupSection.test.tsx index cd5c7de..6bacd8c 100644 --- a/web/components/GroupSection.test.tsx +++ b/web/components/GroupSection.test.tsx @@ -23,7 +23,7 @@ const RANDOM_ACCESS: Group = { summary: { type: 'randomAccess', title: 'Random Access Performance', - rankings: [{ name: 'vortex', time: 1_500_000, ratio: 1 }], + rankings: [{ name: 'vortex', score: 1, totalRuntime: 1_500_000, measured: 1, total: 1 }], explanation: 'lower is better', }, description: 'Tests selecting arbitrary row indices on NVMe', diff --git a/web/components/SummaryCard.test.tsx b/web/components/SummaryCard.test.tsx index a91e1fd..f0b4d82 100644 --- a/web/components/SummaryCard.test.tsx +++ b/web/components/SummaryCard.test.tsx @@ -16,15 +16,15 @@ describe('SummaryCard', () => { expect(render(undefined)).toBe(''); }); - it('renders a randomAccess card with ranks, ns times, and ratios', () => { + it('renders a randomAccess card with ranks, scores, and total runtimes', () => { const html = render({ type: 'randomAccess', title: 'Random Access Performance', rankings: [ - { name: 'vortex', time: 1_500_000, ratio: 1 }, - { name: 'parquet', time: 3_000_000, ratio: 2 }, + { name: 'vortex', score: 1, totalRuntime: 1_500_000, measured: 2, total: 2 }, + { name: 'parquet', score: 2, totalRuntime: 3_000_000, measured: 2, total: 2 }, ], - explanation: 'Random access time | Ratio to fastest (lower is better)', + explanation: 'Geomean of take time ratio to fastest across every chart (lower is better)', }); expect(html).toContain('class="benchmark-scores-summary"'); expect(html).toContain('

Random Access Performance

'); @@ -36,7 +36,25 @@ describe('SummaryCard', () => { expect(html).toContain('parquet'); expect(html).toContain('3.00 ms'); expect(html).toContain('2.00x'); - expect(html).toContain('Random access time | Ratio to fastest (lower is better)'); + expect(html).toContain( + 'Geomean of take time ratio to fastest across every chart (lower is better)', + ); + }); + + it('flags a partially measured series in its hover text', () => { + const html = render({ + type: 'randomAccess', + title: 'Random Access Performance', + rankings: [ + { name: 'vortex', score: 1, totalRuntime: 1_500_000, measured: 9, total: 9 }, + { name: 'lance', score: 3, totalRuntime: 3_000_000, measured: 4, total: 9 }, + ], + explanation: 'e', + }); + // The full-coverage series keeps a bare label; the partial one says so, so + // a penalty-inflated score is never presented as a like-for-like number. + expect(html).toContain('title="vortex"'); + expect(html).toContain('measured in 4 of 9 charts'); }); it('renders nothing for a randomAccess card with no rankings', () => { @@ -96,7 +114,15 @@ describe('SummaryCard', () => { const html = render({ type: 'queryBenchmark', title: 'Performance Summary', - rankings: [{ name: 'vortex:vortex-file', score: 1.0, totalRuntime: 5_000_000_000 }], + rankings: [ + { + name: 'vortex:vortex-file', + score: 1.0, + totalRuntime: 5_000_000_000, + measured: 1, + total: 1, + }, + ], explanation: 'lower is better', }); expect(html).toContain('#1'); @@ -104,4 +130,18 @@ describe('SummaryCard', () => { expect(html).toContain('1.00x'); expect(html).toContain('5.00 s'); }); + + it('renders a vectorSearch card through the shared timing arm', () => { + const html = render({ + type: 'vectorSearch', + title: 'Vector Search Performance', + rankings: [ + { name: 'vortex-turboquant', score: 1.0, totalRuntime: 7_000, measured: 2, total: 2 }, + ], + explanation: 'lower is better', + }); + expect(html).toContain('

Vector Search Performance

'); + expect(html).toContain('vortex-turboquant'); + expect(html).toContain('1.00x'); + }); }); diff --git a/web/components/SummaryCard.tsx b/web/components/SummaryCard.tsx index 3cd63c9..6d57dc3 100644 --- a/web/components/SummaryCard.tsx +++ b/web/components/SummaryCard.tsx @@ -3,7 +3,20 @@ import { displayFormat, displaySeriesLabel } from '@/lib/chart-format'; import { formatTimeNs } from '@/lib/format'; -import type { Summary } from '@/lib/summary'; +import type { SeriesRanking, Summary } from '@/lib/summary'; + +/** + * Hover text for a ranked series: its full label plus, when the series was not + * measured everywhere, how much of its score came from the missing-bucket + * penalty. A partially measured series is ranked, not hidden, so the coverage + * has to be legible somewhere. + */ +function seriesTitle(item: SeriesRanking): string { + const label = displaySeriesLabel(item.name); + return item.measured >= item.total + ? label + : `${label} - measured in ${item.measured} of ${item.total} charts; the rest scored by the missing-series penalty`; +} /** * The per-group summary card. @@ -11,7 +24,8 @@ import type { Summary } from '@/lib/summary'; * Every [`Summary`] variant renders the same `.benchmark-scores-summary` shape * (a `.scores-title`, a `.scores-list` of `.score-item` rows, and a * `.scores-explanation` footer); only the rank label, value, and optional - * runtime change. The card stays visible whether or not the enclosing group is + * runtime change. The three timing families (query, random access, vector + * search) share one arm because they share one [`SeriesRanking`] shape. The card stays visible whether or not the enclosing group is * expanded (the CSS only hides `.chart-grid` when the disclosure is closed), so * the at-a-glance rankings show without expanding the group. * @@ -22,30 +36,6 @@ export function SummaryCard({ summary }: { summary?: Summary }) { return null; } switch (summary.type) { - case 'randomAccess': - if (summary.rankings.length === 0) { - return null; - } - return ( -
-

{summary.title}

-
- {summary.rankings.map((item, idx) => ( -
- #{idx + 1} - - {displaySeriesLabel(item.name)} - - - {formatTimeNs(item.time)} - {item.ratio.toFixed(2)}x - -
- ))} -
-
{summary.explanation}
-
- ); case 'compression': { if (summary.rankings.length === 0) { return null; @@ -118,6 +108,8 @@ export function SummaryCard({ summary }: { summary?: Summary }) { ); case 'queryBenchmark': + case 'randomAccess': + case 'vectorSearch': if (summary.rankings.length === 0) { return null; } @@ -128,7 +120,7 @@ export function SummaryCard({ summary }: { summary?: Summary }) { {summary.rankings.map((item, idx) => (
#{idx + 1} - + {displaySeriesLabel(item.name)} diff --git a/web/lib/data-cache.test.ts b/web/lib/data-cache.test.ts index 4cf46f0..4de1c1c 100644 --- a/web/lib/data-cache.test.ts +++ b/web/lib/data-cache.test.ts @@ -63,9 +63,9 @@ describe('data-cache wrappers', () => { expect(BENCH_DATA_TAG).toBe('bench-data'); expect(DATA_CACHE_BACKSTOP_SECONDS).toBe(86400); expect(cacheCalls.map((call) => call.keyParts)).toContainEqual([ - 'data-cache:group-charts:v4:n100', + 'data-cache:group-charts:v5:n100', ]); - expect(cacheCalls.map((call) => call.keyParts)).toContainEqual(['data-cache:groups:v4']); + expect(cacheCalls.map((call) => call.keyParts)).toContainEqual(['data-cache:groups:v5']); for (const call of cacheCalls) { expect(call.options.tags).toEqual([BENCH_DATA_TAG]); expect(call.options.revalidate).toBe(DATA_CACHE_BACKSTOP_SECONDS); diff --git a/web/lib/data-cache.ts b/web/lib/data-cache.ts index fda1b92..f5a83fb 100644 --- a/web/lib/data-cache.ts +++ b/web/lib/data-cache.ts @@ -46,7 +46,7 @@ const CACHE_OPTIONS = { tags: [BENCH_DATA_TAG], revalidate: DATA_CACHE_BACKSTOP_ * This value must change when a deployment cannot read the preceding shape. * It is independent of the producer-facing benchmark schema version. */ -const GROUP_PAYLOAD_CACHE_VERSION = 'v4'; +const GROUP_PAYLOAD_CACHE_VERSION = 'v5'; // The default last-100 group bundle, keyed by group slug. The slug is the cache // key (an `unstable_cache` argument), so one wrapper covers every group. A diff --git a/web/lib/groups.test.ts b/web/lib/groups.test.ts index 8c958de..f561481 100644 --- a/web/lib/groups.test.ts +++ b/web/lib/groups.test.ts @@ -74,7 +74,7 @@ describe.skipIf(!dockerAvailable())( ]); }); - it('computes the random-access summary (ratio to fastest)', async () => { + it('computes the random-access summary (geomean ratio to fastest)', async () => { const groups = await collectGroups(); const summary = expectDefined( groups.find((g) => g.name === 'Random Access')?.summary, @@ -86,8 +86,33 @@ describe.skipIf(!dockerAvailable())( expect(summary.title).toBe('Random Access Performance'); expect(summary.rankings[0].name).toBe('vortex-file-compressed'); expect(summary.rankings[1].name).toBe('parquet'); - expect(summary.rankings[0].ratio).toBeCloseTo(1.0, 6); - expect(summary.rankings[1].ratio).toBeCloseTo(2.0, 6); + // The fixture's newest commit has vortex=100_500 and parquet=201_000 on + // the one `taxi` chart, so the scores are the damped + // `(10 + value) / (10 + best)` ratios and both series cover every chart. + expect(summary.rankings[0].score).toBeCloseTo(1.0, 6); + expect(summary.rankings[1].score).toBeCloseTo(1.9999005, 6); + expect(summary.rankings[0].totalRuntime).toBeCloseTo(100_500, 6); + expect(summary.rankings[1].totalRuntime).toBeCloseTo(201_000, 6); + expect(summary.rankings.map((r) => [r.measured, r.total])).toEqual([ + [1, 1], + [1, 1], + ]); + }); + + it('summarizes the vector-search group instead of leaving it blank', async () => { + const groups = await collectGroups(); + const summary = expectDefined( + groups.find((g) => g.name === 'cohere-large-10m / partitioned')?.summary, + 'vector search summary', + ); + if (summary.type !== 'vectorSearch') { + throw new Error(`expected vectorSearch summary, got ${summary.type}`); + } + expect(summary.title).toBe('Vector Search Performance'); + expect(summary.rankings.map((r) => r.name)).toEqual(['vortex-turboquant']); + expect(summary.rankings[0].score).toBeCloseTo(1.0, 6); + // The newest commit's value for the group's single threshold. + expect(summary.rankings[0].totalRuntime).toBeCloseTo(107_000, 6); }); it('computes compression rankings with Parquet and Lance baselines', async () => { @@ -170,8 +195,9 @@ describe.skipIf(!dockerAvailable())( groups.find((g) => g.name === 'cohere-large-10m / partitioned'), 'vector-search group', ); - // Vector-search groups carry neither a summary nor a description. - expect(vector.summary).toBeUndefined(); + // Vector-search groups now carry a summary (every group kind does), but + // still have no editorial description. + expect(vector.summary?.type).toBe('vectorSearch'); expect(vector.description).toBeUndefined(); }); @@ -343,7 +369,8 @@ describe.skipIf(!dockerAvailable())('summary math fidelity (testcontainers Postg beforeEach(async () => { await getPool().query( - 'TRUNCATE compression_times, compression_sizes, query_measurements, commits', + `TRUNCATE compression_times, compression_sizes, query_measurements, + random_access_times, vector_search_runs, commits`, ); }); @@ -366,6 +393,156 @@ describe.skipIf(!dockerAvailable())('summary math fidelity (testcontainers Postg ); } + // One random-access measurement for a `{dataset}/{pattern}` chart. The + // producer (`benchmarks/random-access-bench`) writes `dataset` in exactly + // this shape, plus a legacy bare `taxi`. + async function insertRandomAccess( + sha: string, + dataset: string, + format: string, + valueNs: number, + ): Promise { + await getPool().query( + `INSERT INTO random_access_times + (measurement_id, commit_sha, dataset, format, value_ns, all_runtimes_ns) + VALUES ($1, $2, $3, $4, $5, '{1}'::bigint[])`, + [nextId(), sha, dataset, format, valueNs], + ); + } + + it('ranks random access over every chart, not the alphabetically first one', async () => { + // The bug this pins: the old summary walked the group's chart links and + // published the first populated chart's raw times under the group-wide + // title, so `lance` winning `feature-vectors/correlated` was reported as + // `lance` leading random access outright -- even though it is 3x slower on + // the two other charts. Ranking over all three charts reverses that. + const sha = 'a'.repeat(40); + await insertCommit(sha, '2026-04-23T12:00:00Z'); + await insertRandomAccess(sha, 'feature-vectors/correlated', 'lance', 350_000); + await insertRandomAccess( + sha, + 'feature-vectors/correlated', + 'vortex-file-compressed', + 1_100_000, + ); + await insertRandomAccess(sha, 'nested-structs/uniform', 'lance', 3_000_000); + await insertRandomAccess(sha, 'nested-structs/uniform', 'vortex-file-compressed', 1_000_000); + await insertRandomAccess(sha, 'taxi', 'lance', 3_000_000); + await insertRandomAccess(sha, 'taxi', 'vortex-file-compressed', 1_000_000); + + const summary = expectDefined( + await collectGroupSummary({ k: 'RandomAccessGroup' }), + 'random-access summary', + ); + if (summary.type !== 'randomAccess') { + throw new Error(`expected randomAccess summary, got ${summary.type}`); + } + expect(summary.rankings.map((r) => r.name)).toEqual(['vortex-file-compressed', 'lance']); + // vortex: cbrt(1100000/350000 * 1 * 1); lance: cbrt(1 * 3 * 3). + expect(summary.rankings[0].score).toBeCloseTo(Math.cbrt(1_100_010 / 350_010), 5); + expect(summary.rankings[1].score).toBeCloseTo(Math.cbrt((3_000_010 / 1_000_010) ** 2), 5); + expect(summary.rankings[0].totalRuntime).toBeCloseTo(3_100_000, 6); + }); + + it('keeps an intermittently benchmarked format at its own latest run', async () => { + // `lance` runs less often than Vortex. Pinning every format to one global + // latest commit dropped it from the card entirely on any commit it skipped; + // each format is instead read at its own newest run per chart, the same + // freshness policy the compression summaries use. + const older = 'b'.repeat(40); + const newer = 'c'.repeat(40); + await insertCommit(older, '2026-04-22T12:00:00Z'); + await insertCommit(newer, '2026-04-23T12:00:00Z'); + await insertRandomAccess(older, 'taxi', 'lance', 4_000_000); + await insertRandomAccess(older, 'taxi', 'vortex-file-compressed', 2_000_000); + // The newer commit has no `lance` row. + await insertRandomAccess(newer, 'taxi', 'vortex-file-compressed', 1_000_000); + + const summary = expectDefined( + await collectGroupSummary({ k: 'RandomAccessGroup' }), + 'random-access summary', + ); + if (summary.type !== 'randomAccess') { + throw new Error(`expected randomAccess summary, got ${summary.type}`); + } + const byName = new Map(summary.rankings.map((r) => [r.name, r])); + // Vortex is read at the newer commit, lance at its own older one. + expect(byName.get('vortex-file-compressed')?.totalRuntime).toBeCloseTo(1_000_000, 6); + expect(byName.get('lance')?.totalRuntime).toBeCloseTo(4_000_000, 6); + }); + + it('penalizes a format that skipped a chart instead of scoring it on a subset', async () => { + // A format measured only where it wins would otherwise take #1 on a + // one-chart geomean. `lance` is fastest on the chart it ran and absent from + // the other; the missing chart is imputed `max(itsWorstChart, 0) * 2`, which + // is what drops it behind the format measured everywhere. + const sha = 'd'.repeat(40); + await insertCommit(sha, '2026-04-23T12:00:00Z'); + await insertRandomAccess(sha, 'feature-vectors/correlated', 'lance', 100_000); + await insertRandomAccess(sha, 'feature-vectors/correlated', 'vortex-file-compressed', 200_000); + await insertRandomAccess(sha, 'taxi', 'vortex-file-compressed', 50_000); + + const summary = expectDefined( + await collectGroupSummary({ k: 'RandomAccessGroup' }), + 'random-access summary', + ); + if (summary.type !== 'randomAccess') { + throw new Error(`expected randomAccess summary, got ${summary.type}`); + } + expect(summary.rankings.map((r) => r.name)).toEqual(['vortex-file-compressed', 'lance']); + const byName = new Map(summary.rankings.map((r) => [r.name, r])); + // lance: sqrt(1 * (10 + 200_000) / (10 + 50_000)) with penalty + // max(100_000, 0) * 2 = 200_000 on the chart it skipped. + expect(byName.get('lance')?.score).toBeCloseTo(Math.sqrt(200_010 / 50_010), 6); + expect(byName.get('lance')?.measured).toBe(1); + expect(byName.get('lance')?.total).toBe(2); + // `totalRuntime` sums only the charts it actually ran; the penalty is a + // scoring device, not a reported measurement. + expect(byName.get('lance')?.totalRuntime).toBeCloseTo(100_000, 6); + expect(byName.get('vortex-file-compressed')?.score).toBeCloseTo( + Math.sqrt(200_010 / 100_010), + 6, + ); + expect(byName.get('vortex-file-compressed')?.measured).toBe(2); + }); + + it('summarizes a vector-search group across its thresholds', async () => { + const sha = 'e'.repeat(40); + await insertCommit(sha, '2026-04-23T12:00:00Z'); + const rows: ReadonlyArray = [ + ['vortex-turboquant', 0.5, 1_000], + ['vortex-turboquant', 0.75, 2_000], + ['vortex-flat', 0.5, 2_000], + ['vortex-flat', 0.75, 8_000], + ]; + for (const [flavor, threshold, valueNs] of rows) { + await getPool().query( + `INSERT INTO vector_search_runs + (measurement_id, commit_sha, dataset, layout, flavor, threshold, value_ns, + all_runtimes_ns, matches, rows_scanned, bytes_scanned, iterations) + VALUES ($1, $2, 'cohere-large-10m', 'partitioned', $3, $4, $5, + '{1}'::bigint[], 42, 1000000, 5000000, 1)`, + [nextId(), sha, flavor, threshold, valueNs], + ); + } + + const summary = expectDefined( + await collectGroupSummary({ + k: 'VectorSearchGroup', + dataset: 'cohere-large-10m', + layout: 'partitioned', + }), + 'vector-search summary', + ); + if (summary.type !== 'vectorSearch') { + throw new Error(`expected vectorSearch summary, got ${summary.type}`); + } + expect(summary.rankings.map((r) => r.name)).toEqual(['vortex-turboquant', 'vortex-flat']); + expect(summary.rankings[0].score).toBeCloseTo(1.0, 6); + // sqrt((2010/1010) * (8010/2010)). + expect(summary.rankings[1].score).toBeCloseTo(Math.sqrt((2010 / 1010) * (8010 / 2010)), 6); + }); + it('keeps a sub-second latest commit timestamp (no whole-second truncation)', async () => { // A single commit whose timestamp carries microseconds. The pre-fix code // rendered MAX(ts) to whole-second text and rebound it with exact @@ -377,7 +554,7 @@ describe.skipIf(!dockerAvailable())('summary math fidelity (testcontainers Postg await insertCompSizePair(sha, 1_000, 4_000); const time = expectDefined( - await collectGroupSummary({ k: 'CompressionTimeGroup' }, []), + await collectGroupSummary({ k: 'CompressionTimeGroup' }), 'compression-time summary', ); if (time.type !== 'compression') { @@ -392,7 +569,7 @@ describe.skipIf(!dockerAvailable())('summary math fidelity (testcontainers Postg expect(timeByKey.get('decode:parquet')?.ratio).toBeCloseTo(1.0, 6); const size = expectDefined( - await collectGroupSummary({ k: 'CompressionSizeGroup' }, []), + await collectGroupSummary({ k: 'CompressionSizeGroup' }), 'compression-size summary', ); if (size.type !== 'compressionSize') { @@ -418,7 +595,7 @@ describe.skipIf(!dockerAvailable())('summary math fidelity (testcontainers Postg await insertCompSizePair(higherSha, 2_000, 8_000); const time = expectDefined( - await collectGroupSummary({ k: 'CompressionTimeGroup' }, []), + await collectGroupSummary({ k: 'CompressionTimeGroup' }), 'compression-time summary', ); if (time.type !== 'compression') { @@ -433,7 +610,7 @@ describe.skipIf(!dockerAvailable())('summary math fidelity (testcontainers Postg expect(vortexTime.ratio).toBeCloseTo(4.0, 6); const size = expectDefined( - await collectGroupSummary({ k: 'CompressionSizeGroup' }, []), + await collectGroupSummary({ k: 'CompressionSizeGroup' }), 'compression-size summary', ); if (size.type !== 'compressionSize') { @@ -459,7 +636,7 @@ describe.skipIf(!dockerAvailable())('summary math fidelity (testcontainers Postg await insertCompTimePair(newer, 'decode', 1_000, 2_000); const summary = expectDefined( - await collectGroupSummary({ k: 'CompressionTimeGroup' }, []), + await collectGroupSummary({ k: 'CompressionTimeGroup' }), 'compression-time summary', ); if (summary.type !== 'compression') { @@ -478,7 +655,7 @@ describe.skipIf(!dockerAvailable())('summary math fidelity (testcontainers Postg await insertCompTimePair(sha, 'decode', 1_000, 2_000); const summary = expectDefined( - await collectGroupSummary({ k: 'CompressionTimeGroup' }, []), + await collectGroupSummary({ k: 'CompressionTimeGroup' }), 'compression-time summary', ); if (summary.type !== 'compression') { @@ -504,16 +681,13 @@ describe.skipIf(!dockerAvailable())('summary math fidelity (testcontainers Postg await insertQuery(sha, 1, 'duckdb', 'parquet', 50_000); const summary = expectDefined( - await collectGroupSummary( - { - k: 'QueryGroup', - dataset: 'tpch', - dataset_variant: null, - scale_factor: '1', - storage: 'nvme', - }, - [], - ), + await collectGroupSummary({ + k: 'QueryGroup', + dataset: 'tpch', + dataset_variant: null, + scale_factor: '1', + storage: 'nvme', + }), 'query summary', ); if (summary.type !== 'queryBenchmark') { @@ -627,16 +801,13 @@ describe.skipIf(!dockerAvailable())( }); it('prefers a stamped row over a newer NULL-stamped row and keeps all-NULL series', async () => { - const summary = await collectGroupSummary( - { - k: 'QueryGroup', - dataset: 'tpch', - dataset_variant: null, - scale_factor: '1', - storage: 'nvme', - }, - [], - ); + const summary = await collectGroupSummary({ + k: 'QueryGroup', + dataset: 'tpch', + dataset_variant: null, + scale_factor: '1', + storage: 'nvme', + }); if (summary === null || summary.type !== 'queryBenchmark') { throw new Error(`expected queryBenchmark summary, got ${summary?.type}`); } @@ -653,16 +824,13 @@ describe.skipIf(!dockerAvailable())( }); it('enumerates the format, engine, and query_idx successor branches', async () => { - const summary = await collectGroupSummary( - { - k: 'QueryGroup', - dataset: 'tpch', - dataset_variant: null, - scale_factor: '1', - storage: 'nvme', - }, - [], - ); + const summary = await collectGroupSummary({ + k: 'QueryGroup', + dataset: 'tpch', + dataset_variant: null, + scale_factor: '1', + storage: 'nvme', + }); if (summary === null || summary.type !== 'queryBenchmark') { throw new Error(`expected queryBenchmark summary, got ${summary?.type}`); } diff --git a/web/lib/queries.ts b/web/lib/queries.ts index a84035b..f789196 100644 --- a/web/lib/queries.ts +++ b/web/lib/queries.ts @@ -642,10 +642,11 @@ export interface ChartLink { /** * One group: a display name, a permalink slug, the chart links inside it, and - * an optional v2-compatible rollup [`Summary`] plus editorial description. - * `summary` and `description` are left `undefined` (so `JSON.stringify` drops - * them) when absent, the TS analogue of serde `skip_serializing_if = - * "Option::is_none"`. + * a rollup [`Summary`] plus an optional editorial description. Every group + * kind has a summary (see `summary.ts`), so `summary` is absent only for a + * group whose fact rows are not yet usable. `summary` and `description` are + * left `undefined` (so `JSON.stringify` drops them) when absent, the TS + * analogue of serde `skip_serializing_if = "Option::is_none"`. */ export interface Group { name: string; @@ -1135,7 +1136,7 @@ export async function collectGroups(): Promise { // ~64 groups (PR-5.1.5 fix e). await mapWithConcurrency(groups, SUMMARY_CONCURRENCY, async (group) => { const key = groupKeyFromSlug(group.slug); - const summary = await collectGroupSummary(key, group.charts); + const summary = await collectGroupSummary(key); if (summary !== null) { group.summary = summary; } diff --git a/web/lib/summary.test.ts b/web/lib/summary.test.ts index 6310188..e1b95d9 100644 --- a/web/lib/summary.test.ts +++ b/web/lib/summary.test.ts @@ -9,8 +9,13 @@ vi.mock('./db', () => ({ getPool: () => ({ query }), })); +import { FAMILIES } from './families'; +import type { GroupKey } from './slug'; import { collectGroupSummary } from './summary'; +/** Every group discriminant, from the fact-table registry. */ +const GROUP_KINDS = FAMILIES.map((family) => family.groupKind); + describe('compression summaries', () => { beforeEach(() => { query.mockReset(); @@ -26,7 +31,7 @@ describe('compression summaries', () => { ], }); - const summary = await collectGroupSummary({ k: 'CompressionSizeGroup' }, []); + const summary = await collectGroupSummary({ k: 'CompressionSizeGroup' }); if (summary === null || summary.type !== 'compressionSize') { throw new Error('expected a compressionSize summary'); } @@ -40,8 +45,8 @@ describe('compression summaries', () => { it('applies one extensible snapshot policy to timings and sizes', async () => { query.mockResolvedValue({ rows: [] }); - await collectGroupSummary({ k: 'CompressionTimeGroup' }, []); - await collectGroupSummary({ k: 'CompressionSizeGroup' }, []); + await collectGroupSummary({ k: 'CompressionTimeGroup' }); + await collectGroupSummary({ k: 'CompressionSizeGroup' }); expect(query).toHaveBeenCalledTimes(2); const expectedParams = [ @@ -67,3 +72,167 @@ describe('compression summaries', () => { } }); }); + +describe('timing summaries (shared ranking model)', () => { + beforeEach(() => { + query.mockReset(); + }); + + it('ranks random access across every chart, not the first one', async () => { + // The regression: the old summary published one chart's raw times under the + // group-wide title. `lance` wins `feature-vectors/correlated` outright and + // loses the other two charts 3x; the group ranking must reflect all three. + query.mockResolvedValueOnce({ + rows: [ + { bucket: 'feature-vectors/correlated', series: 'lance', value: 350_000 }, + { bucket: 'feature-vectors/correlated', series: 'vortex', value: 1_100_000 }, + { bucket: 'nested-structs/uniform', series: 'lance', value: 3_000_000 }, + { bucket: 'nested-structs/uniform', series: 'vortex', value: 1_000_000 }, + { bucket: 'taxi', series: 'lance', value: 3_000_000 }, + { bucket: 'taxi', series: 'vortex', value: 1_000_000 }, + ], + }); + + const summary = await collectGroupSummary({ k: 'RandomAccessGroup' }); + if (summary === null || summary.type !== 'randomAccess') { + throw new Error('expected a randomAccess summary'); + } + expect(summary.rankings.map((r) => r.name)).toEqual(['vortex', 'lance']); + expect(summary.rankings[0].score).toBeCloseTo(Math.cbrt(1_100_010 / 350_010), 6); + expect(summary.rankings[1].score).toBeCloseTo(Math.cbrt((3_000_010 / 1_000_010) ** 2), 6); + expect(summary.rankings[0].totalRuntime).toBeCloseTo(3_100_000, 6); + expect(summary.rankings.map((r) => r.measured)).toEqual([3, 3]); + expect(summary.rankings.map((r) => r.total)).toEqual([3, 3]); + }); + + it('reads each random-access format at its own newest run', async () => { + query.mockResolvedValue({ rows: [] }); + await collectGroupSummary({ k: 'RandomAccessGroup' }); + const [text] = query.mock.calls[0] as [string, unknown[] | undefined]; + // Per-series freshness, not one global latest commit: a format that skipped + // the newest commit stays on the card at its own last run. + expect(text).toContain('DISTINCT ON (r.dataset, r.format)'); + expect(text).toContain('ORDER BY r.dataset, r.format, c.timestamp DESC'); + expect(text).not.toContain('MAX(c2.timestamp)'); + }); + + it('penalizes a series that skipped a bucket and reports its coverage', async () => { + query.mockResolvedValueOnce({ + rows: [ + { bucket: 'feature-vectors/correlated', series: 'lance', value: 100_000 }, + { bucket: 'feature-vectors/correlated', series: 'vortex', value: 200_000 }, + { bucket: 'taxi', series: 'vortex', value: 50_000 }, + ], + }); + + const summary = await collectGroupSummary({ k: 'RandomAccessGroup' }); + if (summary === null || summary.type !== 'randomAccess') { + throw new Error('expected a randomAccess summary'); + } + const byName = new Map(summary.rankings.map((r) => [r.name, r])); + // lance skipped `taxi`, so that bucket scores max(100_000, 0) * 2 against + // taxi's best of 50_000 -- which is what keeps it behind `vortex`. + expect(summary.rankings.map((r) => r.name)).toEqual(['vortex', 'lance']); + expect(byName.get('lance')?.score).toBeCloseTo(Math.sqrt(200_010 / 50_010), 6); + expect(byName.get('lance')?.measured).toBe(1); + expect(byName.get('lance')?.total).toBe(2); + // `totalRuntime` reports measured time only; the penalty never enters it. + expect(byName.get('lance')?.totalRuntime).toBeCloseTo(100_000, 6); + expect(byName.get('vortex')?.measured).toBe(2); + }); + + it('does not reward a series for skipping a slow bucket', async () => { + query.mockResolvedValueOnce({ + rows: [ + { bucket: 'easy', series: 'partial', value: 100_000 }, + { bucket: 'easy', series: 'complete', value: 110_000 }, + { bucket: 'slow', series: 'complete', value: 100_000_000 }, + ], + }); + + const summary = await collectGroupSummary({ k: 'RandomAccessGroup' }); + if (summary === null || summary.type !== 'randomAccess') { + throw new Error('expected a randomAccess summary'); + } + const byName = new Map(summary.rankings.map((r) => [r.name, r])); + // The absolute penalty for `partial` is 200_000ns. That value is faster + // than the measured 100_000_000ns best on `slow`, so the 2x ratio floor + // must prevent the absent bucket from improving the partial series' score. + expect(summary.rankings.map((r) => r.name)).toEqual(['complete', 'partial']); + expect(byName.get('partial')?.score).toBeCloseTo(Math.sqrt(2), 6); + expect(byName.get('partial')?.measured).toBe(1); + expect(byName.get('partial')?.total).toBe(2); + }); + + it('summarizes a vector-search group across its thresholds', async () => { + query.mockResolvedValueOnce({ + rows: [ + { bucket: 0.5, series: 'vortex-turboquant', value: 1_000 }, + { bucket: 0.75, series: 'vortex-turboquant', value: 2_000 }, + { bucket: 0.5, series: 'vortex-flat', value: 2_000 }, + { bucket: 0.75, series: 'vortex-flat', value: 8_000 }, + ], + }); + + const summary = await collectGroupSummary({ + k: 'VectorSearchGroup', + dataset: 'cohere-large-10m', + layout: 'partitioned', + }); + if (summary === null || summary.type !== 'vectorSearch') { + throw new Error('expected a vectorSearch summary'); + } + expect(summary.title).toBe('Vector Search Performance'); + expect(summary.rankings.map((r) => r.name)).toEqual(['vortex-turboquant', 'vortex-flat']); + expect(summary.rankings[0].score).toBeCloseTo(1.0, 6); + expect(summary.rankings[1].score).toBeCloseTo(Math.sqrt((2010 / 1010) * (8010 / 2010)), 6); + expect(summary.rankings[1].totalRuntime).toBeCloseTo(10_000, 6); + }); + + it('summarizes every query group, with no dataset allowlist', async () => { + // `spatialbench` (and every other suite outside the retired v2 five) used to + // fall through to `null` and render no card at all. + for (const dataset of ['spatialbench', 'fineweb', 'gharchive', 'appian', 'tpch']) { + query.mockResolvedValueOnce({ + rows: [ + { query_idx: 1, series: 'datafusion:vortex', value_ns: 1_000 }, + { query_idx: 1, series: 'duckdb:parquet', value_ns: 2_000 }, + ], + }); + const summary = await collectGroupSummary({ + k: 'QueryGroup', + dataset, + dataset_variant: null, + scale_factor: null, + storage: 'nvme', + }); + expect(summary?.type).toBe('queryBenchmark'); + } + }); + + it('returns a summary for every group kind', async () => { + // The default-on contract: a new suite landing in any fact table gets a + // card without a summary-side change. Only an empty result yields `null`. + const keys: GroupKey[] = [ + { + k: 'QueryGroup', + dataset: 'newsuite', + dataset_variant: null, + scale_factor: null, + storage: 'nvme', + }, + { k: 'CompressionTimeGroup' }, + { k: 'CompressionSizeGroup' }, + { k: 'RandomAccessGroup' }, + { k: 'VectorSearchGroup', dataset: 'd', layout: 'l' }, + ]; + expect(keys.map((key) => key.k).sort()).toEqual([...GROUP_KINDS].sort()); + for (const key of keys) { + query.mockResolvedValue({ rows: [] }); + // Every kind reaches SQL; none short-circuits to `null` on its key alone. + query.mockClear(); + await collectGroupSummary(key); + expect(query).toHaveBeenCalled(); + } + }); +}); diff --git a/web/lib/summary.ts b/web/lib/summary.ts index 30579bc..029bb3f 100644 --- a/web/lib/summary.ts +++ b/web/lib/summary.ts @@ -5,8 +5,16 @@ * Per-group summary rollups. * * Each `collect*Summary` runs focused SQL queries and returns one [`Summary`] - * variant. Query summaries use a v2 dataset allowlist. Compression summaries - * compare the configured formats with Parquet. + * variant. Compression summaries compare the configured formats with Parquet. + * + * **Every group kind has a summary.** There is no allowlist gate: a benchmark + * suite that lands in one of the five fact tables gets a rollup card the moment + * its first rows are ingested. The timing-shaped families (query, random + * access, vector search) all rank through the one [`rankSeries`] model below, + * so a new suite in any of them needs no summary code at all. Adding a sixth + * fact table is the only case that needs a new arm here, and + * [`collectGroupSummary`]'s exhaustive switch makes omitting it a compile + * error rather than a silently missing card. * * Behaviour-preservation notes (substrate migration, DuckDB -> Postgres): * - Nullable-dim equality (`dataset_variant` / `scale_factor`) in the @@ -55,26 +63,33 @@ function compressionSummaryQueryParams(): [string[], string[], string, string] { ]; } -/** One random-access summary row. */ -export interface RandomAccessRanking { - /** Series name, normally the physical format. */ - name: string; - /** Latest measured time in nanoseconds. */ - time: number; - /** Ratio to the fastest series in the same chart. */ - ratio: number; -} - -/** One query-benchmark summary row. */ -export interface QueryRanking { - /** Series name, normally `engine:format`. */ +/** + * One row of a timing-benchmark ranking (query, random access, vector search). + * + * `score` is the headline number: the geomean of this series' time ratio to + * the fastest series, taken over every bucket in the group (queries for a + * query suite, `dataset/pattern` charts for random access, thresholds for + * vector search). A single bucket's absolute time is deliberately NOT the + * headline -- a summary that quoted one arbitrary chart's numbers reads as a + * claim about the whole group and is wrong whenever the group's charts + * disagree. + */ +export interface SeriesRanking { + /** Series name: `engine:format` for queries, the format or flavor otherwise. */ name: string; - /** Geomean ratio to the fastest observed value per query. */ + /** Geomean ratio to the fastest series per bucket. Lower is better. */ score: number; - /** Sum of latest runtimes for the queries this series has. */ + /** Sum of the latest times over the buckets this series was measured in. */ totalRuntime: number; + /** Buckets this series has a measurement for. */ + measured: number; + /** Buckets in the group, i.e. the denominator `measured` is out of. */ + total: number; } +/** @deprecated Prefer [`SeriesRanking`]; kept as the historical spelling. */ +export type QueryRanking = SeriesRanking; + /** One format and operation in the compression throughput summary. */ export interface CompressionRanking { /** On-disk format. */ @@ -101,7 +116,7 @@ export type Summary = | { type: 'randomAccess'; title: string; - rankings: RandomAccessRanking[]; + rankings: SeriesRanking[]; explanation: string; } | { @@ -119,48 +134,33 @@ export type Summary = | { type: 'queryBenchmark'; title: string; - rankings: QueryRanking[]; + rankings: SeriesRanking[]; + explanation: string; + } + | { + type: 'vectorSearch'; + title: string; + rankings: SeriesRanking[]; explanation: string; }; /** - * Compute the summary for one group, if its kind has one. - * `charts` is only consulted for the random-access path (which scans its chart - * links for the latest populated dataset); the other paths query their fact - * table directly. The structural `{ name }[]` accepts a `ChartLink[]`. + * Compute the summary for one group. Every group kind has one, so this returns + * `null` only when the group has no usable rows yet (a brand-new suite between + * its first ingest and its first complete measurement). */ -export function collectGroupSummary( - key: GroupKey, - charts: readonly { readonly name: string }[], -): Promise { +export function collectGroupSummary(key: GroupKey): Promise { switch (key.k) { case 'QueryGroup': - if (queryGroupHasV2Summary(key.dataset)) { - return collectQuerySummary(key.dataset, key.dataset_variant, key.scale_factor, key.storage); - } - return Promise.resolve(null); + return collectQuerySummary(key.dataset, key.dataset_variant, key.scale_factor, key.storage); case 'CompressionTimeGroup': return collectCompressionSummary(); case 'CompressionSizeGroup': return collectCompressionSizeSummary(); case 'RandomAccessGroup': - return collectRandomAccessSummary(charts); + return collectRandomAccessSummary(); case 'VectorSearchGroup': - return Promise.resolve(null); - } -} - -/** The v2 dataset allowlist for which a query group carries a summary. */ -function queryGroupHasV2Summary(dataset: string): boolean { - switch (dataset) { - case 'clickbench': - case 'statpopgen': - case 'polarsignals': - case 'tpch': - case 'tpcds': - return true; - default: - return false; + return collectVectorSearchSummary(key.dataset, key.layout); } } @@ -181,62 +181,225 @@ function geoMean(values: readonly number[]): number | null { return n > 0 ? Math.exp(sumLn / n) : null; } -async function collectRandomAccessSummary( - charts: readonly { readonly name: string }[], -): Promise { - // Scan the group's chart links in order; the first chart with valid rows at - // its latest commit wins (matching the Rust early-return loop). - const text = ` - SELECT r.format AS name, r.value_ns::float8 AS value - FROM random_access_times r - JOIN commits c USING (commit_sha) - WHERE r.dataset = $1 - AND r.value_ns > 0 - AND c.timestamp = ( - SELECT MAX(c2.timestamp) - FROM random_access_times r2 - JOIN commits c2 USING (commit_sha) - WHERE r2.dataset = $2 - AND r2.value_ns > 0 - ) - ORDER BY r.value_ns, r.format - `; - for (const chart of charts) { - const rows = ( - await getPool().query<{ name: string; value: number }>(text, [chart.name, chart.name]) - ).rows; - const rankings: RandomAccessRanking[] = rows.map((row) => ({ - name: row.name, - time: row.value, - ratio: 0, - })); - if (rankings.length === 0) { +/** + * The v2 penalty floor for query suites, in nanoseconds. A series missing a + * query is imputed `max(itsWorstQuery, 300us) * 2`; the floor stops a suite of + * uniformly fast queries from making a missing result nearly free. + */ +const QUERY_PENALTY_FLOOR_NS = 300_000; + +/** One measured point feeding [`rankSeries`]. */ +interface SeriesSample { + /** Series being ranked (`engine:format`, a format, or a flavor). */ + series: string; + /** The thing being measured across series: a query, a chart, a threshold. */ + bucket: K; + /** Latest value for `(series, bucket)`, in nanoseconds. */ + value: number; +} + +/** + * Rank timing series by the geomean of their ratio to the fastest series in + * each bucket, with v2's missing-series penalty. + * + * This is the one ranking model behind the query, random-access, and + * vector-search summaries. Two properties matter for a summary card: + * + * - **Every bucket counts.** Ranking on one bucket's absolute times (which is + * what the random-access summary used to do -- it took whichever chart + * sorted first and quoted its raw numbers) states a group-wide conclusion + * from a single chart. `lance` leading `feature-vectors/correlated` says + * nothing about `nested-structs/uniform`. + * - **A missing bucket is not a free win.** A series measured on only the + * buckets it happens to win would otherwise outrank one measured + * everywhere. Where a series has no value the bucket contributes + * `max(itsWorstBucket, penaltyFloorNs) * 2` instead. `measured`/`total` on + * each row reports how much of the score was imputed. + * + * The `(10 + value) / (10 + best)` ratio (rather than `value / best`) is v2's, + * damping sub-10ns noise; it is preserved because the shipped query scores are + * pinned to it. Random-access and vector-search summaries also set a 2x floor + * for a missing bucket. The floor prevents a penalty derived from a fast bucket + * from beating a real measurement on a slower bucket. Query summaries keep a + * zero floor to preserve the shipped v2 scores. + */ +function rankSeries( + samples: readonly SeriesSample[], + compareBuckets: (a: K, b: K) => number, + penaltyFloorNs: number, + missingRatioFloor: number, +): SeriesRanking[] { + const buckets = new Map(); + const valuesBySeries = new Map>(); + for (const sample of samples) { + if (!(sample.value > 0) || !Number.isFinite(sample.value)) { continue; } - // Streaming min (loop, not a `Math.min(...)` spread) for consistency with - // `collectCompressionSizeSummary` and to avoid a large-array call-argument - // cliff; the Rust source uses `reduce(f64::min)` here. - let minTime = Infinity; - for (const r of rankings) { - minTime = Math.min(minTime, r.time); + const bucketKey = String(sample.bucket); + buckets.set(bucketKey, sample.bucket); + let series = valuesBySeries.get(sample.series); + if (series === undefined) { + series = new Map(); + valuesBySeries.set(sample.series, series); } - if (minTime <= 0 || !Number.isFinite(minTime)) { + series.set(bucketKey, sample.value); + } + if (valuesBySeries.size === 0) { + return []; + } + + // Sorted buckets match the Rust `BTreeSet` iteration order for query suites. + const sortedBuckets = [...buckets.entries()].sort((a, b) => compareBuckets(a[1], b[1])); + const bestByBucket = new Map(); + for (const [bucketKey] of sortedBuckets) { + let best = Infinity; + for (const series of valuesBySeries.values()) { + const value = series.get(bucketKey); + if (value !== undefined && value < best) { + best = value; + } + } + if (Number.isFinite(best)) { + bestByBucket.set(bucketKey, best); + } + } + + const rankings: SeriesRanking[] = []; + // Sorted series keys match the Rust `BTreeMap` iteration order. + for (const name of [...valuesBySeries.keys()].sort(compareCodeUnits)) { + const bucketValues = valuesBySeries.get(name); + if (bucketValues === undefined) { continue; } - for (const r of rankings) { - r.ratio = r.time / minTime; + let totalRuntime = 0; + let maxRuntime = -Infinity; + for (const [bucketKey] of sortedBuckets) { + const value = bucketValues.get(bucketKey); + if (value === undefined) { + continue; + } + totalRuntime += value; + if (value > maxRuntime) { + maxRuntime = value; + } } - rankings.sort((a, b) => - a.time < b.time ? -1 : a.time > b.time ? 1 : compareCodeUnits(a.name, b.name), - ); - return { - type: 'randomAccess', - title: 'Random Access Performance', - rankings, - explanation: 'Random access time | Ratio to fastest (lower is better)', - }; + if (!Number.isFinite(maxRuntime)) { + continue; + } + const penalty = Math.max(maxRuntime, penaltyFloorNs) * 2; + const ratios: number[] = []; + for (const [bucketKey] of sortedBuckets) { + const base = bestByBucket.get(bucketKey); + if (base === undefined) { + continue; + } + const measuredValue = bucketValues.get(bucketKey); + const ratio = (10 + (measuredValue ?? penalty)) / (10 + base); + ratios.push(measuredValue === undefined ? Math.max(ratio, missingRatioFloor) : ratio); + } + const score = geoMean(ratios); + if (score === null) { + continue; + } + rankings.push({ + name, + score, + totalRuntime, + measured: bucketValues.size, + total: sortedBuckets.length, + }); + } + rankings.sort((a, b) => + a.score < b.score ? -1 : a.score > b.score ? 1 : compareCodeUnits(a.name, b.name), + ); + return rankings; +} + +/** + * The random-access rollup, over every `(dataset, pattern)` chart in the group. + * + * Two things this deliberately does NOT do, both of which the previous + * implementation did: + * + * - It does not summarize one chart. The old query walked the group's chart + * links and returned the first that had rows -- in practice always the + * alphabetically first `dataset/pattern` -- then published its raw times + * under the group-wide title "Random Access Performance". The producer + * (`benchmarks/random-access-bench`) emits `dataset` as `{dataset}/{pattern}` + * plus the legacy bare `taxi`, so that was one of nine charts speaking for + * all nine. + * - It does not pin every format to one global latest commit. `format` is + * ranked from its own newest run per chart, the same freshness policy the + * compression summaries apply to intermittently benchmarked formats such as + * `lance`: a format that skipped the newest commit is compared as of when it + * last ran instead of vanishing from the card. + * + * `random_access_times` holds one row per `(commit_sha, dataset, format)` and + * is the smallest fact table, so the per-series `DISTINCT ON` descent is cheap. + */ +async function collectRandomAccessSummary(): Promise { + const text = ` + SELECT DISTINCT ON (r.dataset, r.format) + r.dataset AS bucket, + r.format AS series, + r.value_ns::float8 AS value + FROM random_access_times r + JOIN commits c USING (commit_sha) + WHERE r.value_ns > 0 + ORDER BY r.dataset, r.format, c.timestamp DESC, r.commit_sha DESC + `; + const rows = (await getPool().query<{ bucket: string; series: string; value: number }>(text)) + .rows; + const rankings = rankSeries(rows, compareCodeUnits, 0, 2); + if (rankings.length === 0) { + return null; } - return null; + return { + type: 'randomAccess', + title: 'Random Access Performance', + rankings, + explanation: 'Geomean of take time ratio to fastest across every chart (lower is better)', + }; +} + +/** + * The vector-search rollup for one `(dataset, layout)` group, ranking flavors + * across the group's thresholds. Vector-search groups previously carried no + * summary at all; they rank through the same model as every other timing + * family, with the threshold as the bucket. + */ +async function collectVectorSearchSummary( + dataset: string, + layout: string, +): Promise { + const text = ` + SELECT DISTINCT ON (v.threshold, v.flavor) + v.threshold::float8 AS bucket, + v.flavor AS series, + v.value_ns::float8 AS value + FROM vector_search_runs v + JOIN commits c USING (commit_sha) + WHERE v.dataset = $1 + AND v.layout = $2 + AND v.value_ns > 0 + ORDER BY v.threshold, v.flavor, c.timestamp DESC, v.commit_sha DESC + `; + const rows = ( + await getPool().query<{ bucket: number; series: string; value: number }>(text, [ + dataset, + layout, + ]) + ).rows; + const rankings = rankSeries(rows, (a, b) => a - b, 0, 2); + if (rankings.length === 0) { + return null; + } + return { + type: 'vectorSearch', + title: 'Vector Search Performance', + rankings, + explanation: 'Geomean of scan time ratio to fastest across thresholds (lower is better)', + }; } async function collectCompressionSummary(): Promise { @@ -470,9 +633,9 @@ async function collectQuerySummary( scaleFactor: string | null, storage: string, ): Promise { - // Latest value per (query_idx, engine, format), then v2's missing-series - // penalty model: each series scores the geomean of `(10 + value) / (10 + - // best)` over every query, imputing a penalty where the series has no value. + // Latest value per (query_idx, engine, format), then the shared + // [`rankSeries`] model with the query bucket = `query_idx` and v2's + // `QUERY_PENALTY_FLOOR_NS` floor. // // "Latest per series" is a recursive-CTE skip scan (loose index scan) over the // covering index `idx_query_measurements_summary` (dataset, dataset_variant, @@ -636,77 +799,12 @@ async function collectQuerySummary( await getPool().query<{ query_idx: number; series: string; value_ns: number }>(text, params) ).rows; - const queries = new Set(); - const valuesBySeries = new Map>(); - for (const row of rows) { - queries.add(row.query_idx); - let series = valuesBySeries.get(row.series); - if (series === undefined) { - series = new Map(); - valuesBySeries.set(row.series, series); - } - series.set(row.query_idx, row.value_ns); - } - if (valuesBySeries.size === 0) { - return null; - } - - // Sorted query indices match the Rust `BTreeSet` iteration order. - const sortedQueries = [...queries].sort((a, b) => a - b); - const bestByQuery = new Map(); - for (const queryIdx of sortedQueries) { - let best = Infinity; - for (const series of valuesBySeries.values()) { - const value = series.get(queryIdx); - if (value !== undefined && value < best) { - best = value; - } - } - if (Number.isFinite(best)) { - bestByQuery.set(queryIdx, best); - } - } - - const rankings: QueryRanking[] = []; - // Sorted series keys match the Rust `BTreeMap` iteration order. - for (const name of [...valuesBySeries.keys()].sort(compareCodeUnits)) { - const queryValues = valuesBySeries.get(name); - if (queryValues === undefined) { - continue; - } - let totalRuntime = 0; - for (const queryIdx of [...queryValues.keys()].sort((a, b) => a - b)) { - totalRuntime += queryValues.get(queryIdx) ?? 0; - } - let maxRuntime = -Infinity; - for (const value of queryValues.values()) { - if (value > maxRuntime) { - maxRuntime = value; - } - } - if (!Number.isFinite(maxRuntime)) { - continue; - } - const penalty = Math.max(maxRuntime, 300_000) * 2; - const ratios: number[] = []; - for (const queryIdx of sortedQueries) { - const base = bestByQuery.get(queryIdx); - if (base === undefined) { - continue; - } - const value = queryValues.get(queryIdx) ?? penalty; - ratios.push((10 + value) / (10 + base)); - } - const score = geoMean(ratios); - if (score === null) { - continue; - } - rankings.push({ name, score, totalRuntime }); - } - rankings.sort((a, b) => - a.score < b.score ? -1 : a.score > b.score ? 1 : compareCodeUnits(a.name, b.name), + const rankings = rankSeries( + rows.map((row) => ({ series: row.series, bucket: row.query_idx, value: row.value_ns })), + (a, b) => a - b, + QUERY_PENALTY_FLOOR_NS, + 0, ); - if (rankings.length === 0) { return null; }