Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions docs/assets/benchmarks/assets/index-SG8QC8nm.js

Large diffs are not rendered by default.

62 changes: 0 additions & 62 deletions docs/assets/benchmarks/assets/index-WHX9O2yE.js

This file was deleted.

2 changes: 1 addition & 1 deletion docs/assets/benchmarks/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; }
</style>
<script type="module" crossorigin src="./assets/index-WHX9O2yE.js"></script>
<script type="module" crossorigin src="./assets/index-SG8QC8nm.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-y_ksZDKc.css">
</head>
<body>
Expand Down
95 changes: 95 additions & 0 deletions frontend/e2e-production/echarts-runtime.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { test, expect, type Locator } from '@playwright/test';

async function announcedRowCount(details: Locator): Promise<number> {
const text = (await details.locator('summary').textContent()) ?? '';
const match = text.match(/\((\d+) rows\)$/);
expect(match, `expected row count in disclosure summary: ${text}`).not.toBeNull();
return Number(match![1]);
}

test.describe('Deployed benchmark chart runtime', () => {
test('completes tree-shaken ECharts initialization without async runtime errors', async ({ page }) => {
const consoleErrors: string[] = [];
const pageErrors: string[] = [];
page.on('console', message => {
if (message.type() === 'error') consoleErrors.push(message.text());
});
page.on('pageerror', error => pageErrors.push(error.message));

await page.goto('./');
await expect(page.locator('.header')).toBeVisible({ timeout: 15000 });

// Chart rendering is intentionally deferred with requestAnimationFrame in
// main.ts. Wait for both renderers to enter the real setOption path and
// produce their CanvasRenderer surfaces before checking async errors.
await page.waitForFunction(() => {
const timing = document.querySelector<HTMLElement>('#timing-chart');
const speedup = document.querySelector<HTMLElement>('#speedup-chart');
return Boolean(
timing?.dataset.timingDisplayed !== undefined &&
speedup?.dataset.speedupDisplayed !== undefined &&
timing.querySelector('canvas') &&
speedup.querySelector('canvas'),
);
});
await page.evaluate(
() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))),
);

const runtime = await page.evaluate(() => {
const timing = document.querySelector<HTMLElement>('#timing-chart')!;
const speedup = document.querySelector<HTMLElement>('#speedup-chart')!;
const timingCanvas = timing.querySelector<HTMLCanvasElement>('canvas')!;
const speedupCanvas = speedup.querySelector<HTMLCanvasElement>('canvas')!;
return {
timingDisplayed: Number(timing.dataset.timingDisplayed ?? 0),
speedupDisplayed: Number(speedup.dataset.speedupDisplayed ?? 0),
timingCanvas: [timingCanvas.width, timingCanvas.height],
speedupCanvas: [speedupCanvas.width, speedupCanvas.height],
};
});

expect(runtime.timingDisplayed).toBeGreaterThan(0);
expect(runtime.speedupDisplayed).toBeGreaterThan(0);
expect(runtime.timingCanvas[0]).toBeGreaterThan(0);
expect(runtime.timingCanvas[1]).toBeGreaterThan(0);
expect(runtime.speedupCanvas[0]).toBeGreaterThan(0);
expect(runtime.speedupCanvas[1]).toBeGreaterThan(0);
expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});

test('materializes exactly the announced chart rows and resets them on rerender', async ({ page }) => {
await page.goto('./');
await expect(page.locator('.header')).toBeVisible({ timeout: 15000 });

const timing = page.locator('#timing-chart-data');
const speedup = page.locator('#speedup-chart-data');
const focusedTimingRows = await announcedRowCount(timing);
const focusedSpeedupRows = await announcedRowCount(speedup);

await expect(timing.locator('table')).toHaveCount(0);
await expect(speedup.locator('table')).toHaveCount(0);
await timing.locator('summary').click();
await speedup.locator('summary').click();
await expect(timing.locator('tbody tr')).toHaveCount(focusedTimingRows);
await expect(speedup.locator('tbody tr')).toHaveCount(focusedSpeedupRows);

// A filter/view update replaces the dashboard main content. The newly
// selected disclosure must start unmaterialized and derive rows from the
// new selection rather than retaining the previous table DOM.
await page.locator('[data-chart-view="full"]').click();
await expect(timing.locator('table')).toHaveCount(0);
await expect(speedup.locator('table')).toHaveCount(0);

const fullTimingRows = await announcedRowCount(timing);
const fullSpeedupRows = await announcedRowCount(speedup);
expect(fullTimingRows).toBeGreaterThanOrEqual(focusedTimingRows);
expect(fullSpeedupRows).toBeGreaterThanOrEqual(focusedSpeedupRows);

await timing.locator('summary').click();
await speedup.locator('summary').click();
await expect(timing.locator('tbody tr')).toHaveCount(fullTimingRows);
await expect(speedup.locator('tbody tr')).toHaveCount(fullSpeedupRows);
});
});
13 changes: 11 additions & 2 deletions frontend/e2e-production/production.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ test.describe('Deployed benchmark dashboard', () => {
await expect(page.locator('input[name="backend"][value="all"]')).toBeChecked();
});

test('exposes keyboard sorting, disclosure controls, skip link, and chart tables', async ({ page }) => {
test('exposes keyboard sorting, disclosure controls, skip link, and lazy chart tables', async ({ page }) => {
await openProduction(page);
const skip = page.getByRole('link', { name: 'Skip to benchmark results' });
await skip.focus();
Expand All @@ -97,10 +97,19 @@ test.describe('Deployed benchmark dashboard', () => {

await expect(page.locator('#timing-chart-data')).toBeVisible();
await expect(page.locator('#speedup-chart-data')).toBeVisible();
await expect(page.locator('#timing-chart-data table')).toHaveCount(0);
await expect(page.locator('#speedup-chart-data table')).toHaveCount(0);

await page.locator('#timing-chart-data summary').click();
await expect(page.locator('#timing-chart-data table')).toBeVisible();
await expect(page.locator('#timing-chart-data tbody tr').first()).toBeVisible();
await expect(page.locator('#timing-chart-data caption')).toContainText('Full labels');

await page.locator('#speedup-chart-data summary').click();
await expect(page.locator('#speedup-chart-data table')).toBeVisible();
await expect(page.locator('#speedup-chart-data tbody tr').first()).toBeVisible();
await expect(page.locator('#speedup-chart-data caption')).toContainText('Full labels');

const selects = page.locator('select');
for (let i = 0; i < await selects.count(); i += 1) {
const select = selects.nth(i);
Expand Down Expand Up @@ -174,4 +183,4 @@ test.describe('Deployed benchmark dashboard', () => {
expect(ratios.header).toBeGreaterThanOrEqual(4.5);
expect(ratios.muted).toBeGreaterThanOrEqual(4.5);
});
});
});
4 changes: 2 additions & 2 deletions frontend/src/charts/SpeedupChart.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as echarts from 'echarts';
import { echarts, type ECharts } from '../echarts';
import type { Run } from '../schema';
import type { AppState } from '../state';
import { formatModelName } from '../utils/format';
Expand Down Expand Up @@ -196,7 +196,7 @@ export function renderSpeedupChart(
el: HTMLElement,
runs: Run[],
state: AppState,
chartInstances: echarts.ECharts[],
chartInstances: ECharts[],
): void {
let chart = echarts.getInstanceByDom(el);
if (!chart) {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/charts/TimingChart.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as echarts from 'echarts';
import { echarts, type ECharts } from '../echarts';
import type { Run } from '../schema';
import type { AppState } from '../state';
import { CHART_STYLE, COLORS } from '../utils/theme';
Expand Down Expand Up @@ -153,7 +153,7 @@ export function renderTimingChart(
el: HTMLElement,
runs: Run[],
state: AppState,
chartInstances: echarts.ECharts[],
chartInstances: ECharts[],
): void {
let chart = echarts.getInstanceByDom(el);
if (!chart) {
Expand Down
130 changes: 80 additions & 50 deletions frontend/src/components/ChartDataFallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,52 +20,70 @@ function identityCells(run: Run): string[] {
];
}

interface TableValue {
value: string;
reference: string;
}

function renderTable(
id: string,
caption: string,
valueLabel: string,
rows: Array<{ run: Run; value: string; reference: string }>,
runs: Run[],
valueForRun: (run: Run) => TableValue,
): HTMLElement {
const details = h('details', { class: 'chart-data-details', id });
details.appendChild(h('summary', {}, `${caption} (${rows.length} rows)`));
const wrapper = h('div', { class: 'chart-data-table-wrap' });
const table = h('table', { class: 'chart-data-table' });
table.appendChild(
h(
'caption',
{},
`${caption}. Full labels are shown here when chart labels are truncated.`,
),
);
const details = h('details', { class: 'chart-data-details', id }) as HTMLDetailsElement;
details.appendChild(h('summary', {}, `${caption} (${runs.length} rows)`));

let materialized = false;
const materialize = (): void => {
if (materialized) return;
materialized = true;

const wrapper = h('div', { class: 'chart-data-table-wrap' });
const table = h('table', { class: 'chart-data-table' });
table.appendChild(
h(
'caption',
{},
`${caption}. Full labels are shown here when chart labels are truncated.`,
),
);

const header = h('tr');
for (const label of [
'Model',
'Variant',
'Penalty',
'Solver',
'Backend / reference',
'Scale',
valueLabel,
'Reference',
]) {
header.appendChild(h('th', { scope: 'col' }, label));
}
const thead = h('thead');
thead.appendChild(header);
table.appendChild(thead);
const header = h('tr');
for (const label of [
'Model',
'Variant',
'Penalty',
'Solver',
'Backend / reference',
'Scale',
valueLabel,
'Reference',
]) {
header.appendChild(h('th', { scope: 'col' }, label));
}
const thead = h('thead');
thead.appendChild(header);
table.appendChild(thead);

const tbody = h('tbody');
for (const { run, value, reference } of rows) {
const tr = h('tr');
for (const cell of [...identityCells(run), value, reference]) {
tr.appendChild(h('td', {}, cell));
const tbody = h('tbody');
for (const run of runs) {
const { value, reference } = valueForRun(run);
const tr = h('tr');
for (const cell of [...identityCells(run), value, reference]) {
tr.appendChild(h('td', {}, cell));
}
tbody.appendChild(tr);
}
tbody.appendChild(tr);
}
table.appendChild(tbody);
wrapper.appendChild(table);
details.appendChild(wrapper);
table.appendChild(tbody);
wrapper.appendChild(table);
details.appendChild(wrapper);
};

details.addEventListener('toggle', () => {
if (details.open) materialize();
});
return details;
}

Expand All @@ -87,20 +105,32 @@ export function renderChartDataFallback(
),
);

const timing = selectTimingRuns(timingSourceRuns, state).runs.map((run) => ({
run,
value: `${run.metrics.timing!.fit_time_ms.toFixed(3)} ms`,
reference: run.metrics.timing!.quality,
}));
const speedup = selectSpeedupRuns(speedupSourceRuns, state).runs.map((run) => ({
run,
value: `${run.metrics.speedup!.value.toFixed(3)}×`,
reference: `${run.metrics.speedup!.reference_framework} (${run.metrics.speedup!.reported_semantics})`,
}));
const timingRuns = selectTimingRuns(timingSourceRuns, state).runs;
const speedupRuns = selectSpeedupRuns(speedupSourceRuns, state).runs;

section.appendChild(renderTable('timing-chart-data', 'Fit Time chart data', 'Time', timing));
section.appendChild(
renderTable('speedup-chart-data', 'Speedup chart data', 'Speedup', speedup),
renderTable(
'timing-chart-data',
'Fit Time chart data',
'Time',
timingRuns,
run => ({
value: `${run.metrics.timing!.fit_time_ms.toFixed(3)} ms`,
reference: run.metrics.timing!.quality,
}),
),
);
section.appendChild(
renderTable(
'speedup-chart-data',
'Speedup chart data',
'Speedup',
speedupRuns,
run => ({
value: `${run.metrics.speedup!.value.toFixed(3)}×`,
reference: `${run.metrics.speedup!.reference_framework} (${run.metrics.speedup!.reported_semantics})`,
}),
),
);
return section;
}
26 changes: 26 additions & 0 deletions frontend/src/echarts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/** Tree-shakeable ECharts surface used by the benchmark dashboard. */
import * as echarts from 'echarts/core';
import { BarChart } from 'echarts/charts';
import {
DataZoomComponent,
GridComponent,
LegendComponent,
MarkLineComponent,
TitleComponent,
TooltipComponent,
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';

echarts.use([
BarChart,
DataZoomComponent,
GridComponent,
LegendComponent,
MarkLineComponent,
TitleComponent,
TooltipComponent,
CanvasRenderer,
]);

export { echarts };
export type { ECharts } from 'echarts/core';
16 changes: 10 additions & 6 deletions frontend/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import './style.css';
import './metric-scope.css';

import * as echarts from 'echarts';
import { echarts, type ECharts } from './echarts';
import type { BenchmarkData, ParseReport, Run, SourceInventory } from './schema';
import { fetchBenchmarkData, fetchParseReport, fetchSourceInventory, filterRuns } from './data';
import { createDefaultState } from './state';
Expand All @@ -28,7 +28,7 @@ let sourceInventory: SourceInventory | null = null;
let state: AppState | null = null;

/** Track ECharts instances for cleanup before re-render */
const chartInstances: echarts.ECharts[] = [];
const chartInstances: ECharts[] = [];

// ---------------------------------------------------------------------------
// Layout
Expand Down Expand Up @@ -219,13 +219,17 @@ async function init(): Promise<void> {
root.appendChild(emptyStateMessage('Loading benchmark data...'));

try {
data = await fetchBenchmarkData();
state = createDefaultState(data.environments, data.runs);
// Non-critical metadata — fetch in parallel, failure doesn't block dashboard
[parseReport, sourceInventory] = await Promise.all([
// Start all production assets together. Data is required; metadata remains
// non-critical, but it should not add a serial network round trip.
const [loadedData, loadedReport, loadedInventory] = await Promise.all([
fetchBenchmarkData(),
fetchParseReport().catch(() => null),
fetchSourceInventory().catch(() => null),
]);
data = loadedData;
parseReport = loadedReport;
sourceInventory = loadedInventory;
state = createDefaultState(data.environments, data.runs);

// Cross-validate generation_id: discard metadata that doesn't match data
if (parseReport && parseReport.generation_id !== data.meta.generation_id) {
Expand Down
Loading