diff --git a/docs/architecture/design-decisions.md b/docs/architecture/design-decisions.md
index bf06fe1..62e3f7d 100644
--- a/docs/architecture/design-decisions.md
+++ b/docs/architecture/design-decisions.md
@@ -146,6 +146,23 @@ slugs, and decoding validates the full shape, so they are not an injection
surface.
→ `web/lib/slug.ts`.
+**The random-access group summary aggregates the whole group, not its first chart.**
+The headline used to be lifted from the first chart in the group with data (in
+practice `feature-vectors/correlated`), which was fine when random access had one
+benchmark and misleading once it had many — the summary reported whatever that one
+dataset did. It now reports two aggregates per format over every dataset in the
+group: the **sum** and the **geomean** of their latest times, both labelled in the
+card. Aggregating means choosing a snapshot and a coverage rule: the aggregate is
+taken at the newest commit with any positive random-access row for the group (all
+of `random-access-bench`'s datasets and formats come from the same run), and a
+format is ranked only if it has a positive value for **every** dataset measured at
+that commit. That follows the compression summaries' "newest complete snapshot"
+precedent rather than the query summary's missing-series penalty, which works for
+ratios but would invent a runtime inside an absolute sum or geomean. The other
+groups' summaries are untouched — `RandomAccessGroup` is a singleton group with
+its own `collect*Summary`, so no other headline number moves.
+→ `web/lib/summary.ts` (`collectRandomAccessSummary`), `web/components/SummaryCard.tsx`.
+
## Performance & client hydration
The v4 stack is serverless and the site is low-traffic, so the costly case is
diff --git a/web/components/GroupSection.test.tsx b/web/components/GroupSection.test.tsx
index cd5c7de..c777ad1 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', total: 3_000_000, geomean: 1_500_000, ratio: 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..5045899 100644
--- a/web/components/SummaryCard.test.tsx
+++ b/web/components/SummaryCard.test.tsx
@@ -16,27 +16,34 @@ describe('SummaryCard', () => {
expect(render(undefined)).toBe('');
});
- it('renders a randomAccess card with ranks, ns times, and ratios', () => {
+ it('renders a randomAccess card with labelled geomean and total aggregates', () => {
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', total: 6_000_000, geomean: 1_500_000, ratio: 1 },
+ { name: 'parquet', total: 12_000_000, geomean: 3_000_000, ratio: 2 },
],
- explanation: 'Random access time | Ratio to fastest (lower is better)',
+ explanation:
+ 'Geomean and total random access time across 4 datasets | ' +
+ 'Ratio of geomean to fastest (lower is better)',
});
expect(html).toContain('class="benchmark-scores-summary"');
expect(html).toContain('
Random Access Performance
');
expect(html).toContain('#1');
expect(html).toContain('vortex');
- expect(html).toContain('1.50 ms');
+ // Both aggregates render, each labelled so the reader is not guessing which
+ // number is which.
+ expect(html).toContain('1.50 ms geomean');
+ expect(html).toContain('6.00 ms total');
expect(html).toContain('1.00x');
expect(html).toContain('#2');
expect(html).toContain('parquet');
- expect(html).toContain('3.00 ms');
+ expect(html).toContain('3.00 ms geomean');
+ expect(html).toContain('12.00 ms total');
expect(html).toContain('2.00x');
- expect(html).toContain('Random access time | Ratio to fastest (lower is better)');
+ expect(html).toContain('Geomean and total random access time across 4 datasets');
+ expect(html).toContain('Ratio of geomean to fastest (lower is better)');
});
it('renders nothing for a randomAccess card with no rankings', () => {
diff --git a/web/components/SummaryCard.tsx b/web/components/SummaryCard.tsx
index 3cd63c9..1fab99c 100644
--- a/web/components/SummaryCard.tsx
+++ b/web/components/SummaryCard.tsx
@@ -37,7 +37,8 @@ export function SummaryCard({ summary }: { summary?: Summary }) {
{displaySeriesLabel(item.name)}
- {formatTimeNs(item.time)}
+ {formatTimeNs(item.geomean)} geomean
+ {formatTimeNs(item.total)} total
{item.ratio.toFixed(2)}x
diff --git a/web/lib/groups.test.ts b/web/lib/groups.test.ts
index 8c958de..a39ec5a 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('aggregates the random-access summary over the group (sum + geomean)', async () => {
const groups = await collectGroups();
const summary = expectDefined(
groups.find((g) => g.name === 'Random Access')?.summary,
@@ -84,10 +84,19 @@ describe.skipIf(!dockerAvailable())(
throw new Error(`expected randomAccess summary, got ${summary.type}`);
}
expect(summary.title).toBe('Random Access Performance');
+ expect(summary.explanation).toContain('across 1 dataset');
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 single `taxi` dataset at the newest commit: both
+ // aggregates collapse to that one latest value (500 / 1000 plus the
+ // third commit's bias), so the pre-aggregation headline is preserved
+ // for a one-chart group.
+ expect(summary.rankings[0].total).toBeCloseTo(100_500, 6);
+ expect(summary.rankings[0].geomean).toBeCloseTo(100_500, 6);
+ expect(summary.rankings[1].total).toBeCloseTo(201_000, 6);
+ expect(summary.rankings[1].geomean).toBeCloseTo(201_000, 6);
});
it('computes compression rankings with Parquet and Lance baselines', async () => {
diff --git a/web/lib/summary.test.ts b/web/lib/summary.test.ts
index 6310188..3545b52 100644
--- a/web/lib/summary.test.ts
+++ b/web/lib/summary.test.ts
@@ -67,3 +67,79 @@ describe('compression summaries', () => {
}
});
});
+
+describe('random-access summary', () => {
+ beforeEach(() => {
+ query.mockReset();
+ });
+
+ const CHARTS = [{ name: 'feature-vectors/correlated' }, { name: 'taxi' }];
+
+ it('aggregates every dataset in the group as a sum and a geomean', async () => {
+ query.mockResolvedValueOnce({
+ rows: [
+ { dataset: 'feature-vectors/correlated', name: 'vortex', value: 1_000_000 },
+ { dataset: 'feature-vectors/correlated', name: 'parquet', value: 4_000_000 },
+ { dataset: 'taxi', name: 'vortex', value: 4_000_000 },
+ { dataset: 'taxi', name: 'parquet', value: 16_000_000 },
+ ],
+ });
+
+ const summary = await collectGroupSummary({ k: 'RandomAccessGroup' }, CHARTS);
+ if (summary === null || summary.type !== 'randomAccess') {
+ throw new Error('expected a randomAccess summary');
+ }
+ const byFormat = new Map(summary.rankings.map((ranking) => [ranking.name, ranking]));
+
+ // Both datasets contribute: sqrt(1e6 * 4e6) = 2e6, sum = 5e6.
+ expect(byFormat.get('vortex')?.geomean).toBeCloseTo(2_000_000, 6);
+ expect(byFormat.get('vortex')?.total).toBeCloseTo(5_000_000, 6);
+ expect(byFormat.get('parquet')?.geomean).toBeCloseTo(8_000_000, 6);
+ expect(byFormat.get('parquet')?.total).toBeCloseTo(20_000_000, 6);
+ // The ratio compares geomeans, not the first chart's raw value.
+ expect(byFormat.get('vortex')?.ratio).toBeCloseTo(1, 6);
+ expect(byFormat.get('parquet')?.ratio).toBeCloseTo(4, 6);
+ expect(summary.rankings.map((ranking) => ranking.name)).toEqual(['vortex', 'parquet']);
+ expect(summary.explanation).toBe(
+ 'Geomean and total random access time across 2 datasets | ' +
+ 'Ratio of geomean to fastest (lower is better)',
+ );
+ });
+
+ it("scopes one query to the group's datasets and its newest snapshot", async () => {
+ query.mockResolvedValue({ rows: [] });
+
+ expect(await collectGroupSummary({ k: 'RandomAccessGroup' }, CHARTS)).toBeNull();
+
+ expect(query).toHaveBeenCalledTimes(1);
+ const [text, params] = query.mock.calls[0] as [string, unknown[]];
+ expect(params).toEqual([['feature-vectors/correlated', 'taxi']]);
+ expect(text).toContain('r.dataset = ANY($1::text[])');
+ expect(text).toContain('SELECT MAX(ts) AS ts FROM scoped');
+ // A same-timestamp commit tie must not double-count into the sum.
+ expect(text).toContain('DISTINCT ON (s.dataset, s.format)');
+ });
+
+ it('drops a format that does not cover every measured dataset', async () => {
+ query.mockResolvedValueOnce({
+ rows: [
+ { dataset: 'feature-vectors/correlated', name: 'lance', value: 1_000 },
+ { dataset: 'feature-vectors/correlated', name: 'vortex', value: 1_000_000 },
+ { dataset: 'taxi', name: 'vortex', value: 4_000_000 },
+ ],
+ });
+
+ const summary = await collectGroupSummary({ k: 'RandomAccessGroup' }, CHARTS);
+ if (summary === null || summary.type !== 'randomAccess') {
+ throw new Error('expected a randomAccess summary');
+ }
+ // Lance is missing `taxi` at the snapshot commit, so summing it over one
+ // dataset would make it look artificially cheap: it is dropped instead.
+ expect(summary.rankings.map((ranking) => ranking.name)).toEqual(['vortex']);
+ });
+
+ it('returns no summary for a group with no chart links', async () => {
+ expect(await collectGroupSummary({ k: 'RandomAccessGroup' }, [])).toBeNull();
+ expect(query).not.toHaveBeenCalled();
+ });
+});
diff --git a/web/lib/summary.ts b/web/lib/summary.ts
index 30579bc..4d9c4aa 100644
--- a/web/lib/summary.ts
+++ b/web/lib/summary.ts
@@ -6,7 +6,8 @@
*
* 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.
+ * compare the configured formats with Parquet. The random-access summary
+ * aggregates every dataset in its group (sum + geomean).
*
* Behaviour-preservation notes (substrate migration, DuckDB -> Postgres):
* - Nullable-dim equality (`dataset_variant` / `scale_factor`) in the
@@ -55,13 +56,15 @@ function compressionSummaryQueryParams(): [string[], string[], string, string] {
];
}
-/** One random-access summary row. */
+/** One random-access summary row, aggregated over the whole group. */
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. */
+ /** Sum of the series' latest per-dataset times, in nanoseconds. */
+ total: number;
+ /** Geometric mean of the same per-dataset times, in nanoseconds. */
+ geomean: number;
+ /** Ratio of this series' geomean to the fastest series' geomean. */
ratio: number;
}
@@ -125,9 +128,9 @@ export type Summary =
/**
* 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[]`.
+ * `charts` is only consulted for the random-access path (whose chart links name
+ * the datasets it aggregates over); the other paths query their fact table
+ * directly. The structural `{ name }[]` accepts a `ChartLink[]`.
*/
export function collectGroupSummary(
key: GroupKey,
@@ -181,62 +184,135 @@ function geoMean(values: readonly number[]): number | null {
return n > 0 ? Math.exp(sumLn / n) : null;
}
+/**
+ * The random-access group summary: one row per format, aggregating **every**
+ * dataset in the group rather than reading a single chart.
+ *
+ * The group has grown from one random-access benchmark to many, so taking the
+ * headline from the first populated chart made the summary a report on whichever
+ * dataset happened to sort first (in practice `feature-vectors/correlated`, the
+ * noisiest one). Both aggregates are reported per format: the `total` (sum) and
+ * the `geomean` of the per-dataset times, the same pairing the query summary
+ * shows (`totalRuntime` + a geomean `score`).
+ *
+ * Two rules keep the two aggregates comparable across formats, following the
+ * compression summaries' "newest complete snapshot" precedent rather than the
+ * query summary's penalty imputation (which only works for ratios -- imputing
+ * into an absolute sum or geomean would invent a runtime):
+ *
+ * - **One snapshot commit.** All of `random-access-bench`'s formats and
+ * datasets are emitted by the same run, so the aggregate is taken at the
+ * newest commit that has any positive random-access row for this group.
+ * - **Complete coverage or nothing.** A format is ranked only if it has a
+ * positive value for every dataset measured at that commit; a format missing
+ * a dataset is dropped instead of being summed over a smaller (and therefore
+ * flatteringly cheaper) set. Non-positive and absent values never enter the
+ * geomean, which is defined over strictly positive values only.
+ */
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).
+ // The group's chart names ARE its dataset names (`collectRandomAccessGroup`
+ // builds one chart link per distinct dataset), so they scope the aggregate to
+ // this group's results in a single query instead of the previous
+ // one-query-per-chart-until-a-hit loop.
+ const datasets = charts.map((chart) => chart.name);
+ if (datasets.length === 0) {
+ return null;
+ }
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
+ WITH scoped AS (
+ SELECT r.dataset AS dataset,
+ r.format AS format,
+ r.commit_sha AS commit_sha,
+ c.timestamp AS ts,
+ r.value_ns::float8 AS value
+ FROM random_access_times r
+ JOIN commits c USING (commit_sha)
+ WHERE r.dataset = ANY($1::text[])
+ AND r.value_ns > 0
+ ), snapshot AS (
+ SELECT MAX(ts) AS ts FROM scoped
+ )
+ SELECT DISTINCT ON (s.dataset, s.format)
+ s.dataset AS dataset, s.format AS name, s.value AS value
+ FROM scoped s
+ JOIN snapshot ON s.ts = snapshot.ts
+ ORDER BY s.dataset, s.format, s.commit_sha
`;
- 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) {
- continue;
+ // `DISTINCT ON` collapses a same-timestamp commit tie (the accepted
+ // same-second tie behaviour elsewhere in the read path) to one row per
+ // (dataset, format), so a tie cannot double-count into the sum.
+ const rows = (
+ await getPool().query<{ dataset: string; name: string; value: number }>(text, [datasets])
+ ).rows;
+ const measured = new Set();
+ const valuesByFormat = new Map>();
+ for (const row of rows) {
+ measured.add(row.dataset);
+ let byDataset = valuesByFormat.get(row.name);
+ if (byDataset === undefined) {
+ byDataset = new Map();
+ valuesByFormat.set(row.name, byDataset);
}
- // 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);
+ byDataset.set(row.dataset, row.value);
+ }
+ if (measured.size === 0) {
+ return null;
+ }
+
+ const rankings: RandomAccessRanking[] = [];
+ for (const name of [...valuesByFormat.keys()].sort(compareCodeUnits)) {
+ const byDataset = valuesByFormat.get(name);
+ if (byDataset === undefined || byDataset.size !== measured.size) {
+ // Incomplete coverage of the snapshot's datasets: drop the format rather
+ // than publish a sum and geomean over a different dataset set.
+ continue;
}
- if (minTime <= 0 || !Number.isFinite(minTime)) {
+ // The size check above means this format's dataset keys are exactly
+ // `measured`, so the `?? 0` is unreachable; a 0 would be excluded from the
+ // geomean anyway.
+ const values = [...measured]
+ .sort(compareCodeUnits)
+ .map((dataset) => byDataset.get(dataset) ?? 0);
+ const geomean = geoMean(values);
+ if (geomean === null) {
continue;
}
- for (const r of rankings) {
- r.ratio = r.time / minTime;
+ let total = 0;
+ for (const value of values) {
+ total += 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)',
- };
+ rankings.push({ name, total, geomean, ratio: 0 });
+ }
+ if (rankings.length === 0) {
+ return null;
}
- return null;
+ // Streaming min (loop, not a `Math.min(...)` spread) for consistency with
+ // `collectCompressionSizeSummary` and to avoid a large-array call-argument
+ // cliff.
+ let fastest = Infinity;
+ for (const ranking of rankings) {
+ fastest = Math.min(fastest, ranking.geomean);
+ }
+ if (fastest <= 0 || !Number.isFinite(fastest)) {
+ return null;
+ }
+ for (const ranking of rankings) {
+ ranking.ratio = ranking.geomean / fastest;
+ }
+ rankings.sort((a, b) =>
+ a.geomean < b.geomean ? -1 : a.geomean > b.geomean ? 1 : compareCodeUnits(a.name, b.name),
+ );
+ const datasetNoun = measured.size === 1 ? 'dataset' : 'datasets';
+ return {
+ type: 'randomAccess',
+ title: 'Random Access Performance',
+ rankings,
+ explanation:
+ `Geomean and total random access time across ${measured.size} ${datasetNoun} | ` +
+ 'Ratio of geomean to fastest (lower is better)',
+ };
}
async function collectCompressionSummary(): Promise {