diff --git a/docs/data-transforms.md b/docs/data-transforms.md index 1dfaf33cd..b04ae973e 100644 --- a/docs/data-transforms.md +++ b/docs/data-transforms.md @@ -90,7 +90,7 @@ This is the most complex and bug-prone part of the pipeline. A bad hardware key 1. Base GPU: `entry.hw.split('-')[0]` strips any `-DP` / `-MN` variant suffix from the hardware field (e.g. `"h100-8"` → `"h100"`). 2. Framework suffix: appends `_${entry.framework}`. The direct key (`h100_trt`) is tested via `isKnownGpu()` (checks whether the base GPU exists in `HW_REGISTRY`). If the direct key's base is unknown and `entry.disagg` is true, a `-disagg` variant is tried. -3. Spec decoding suffix: if `entry.mtp === 'on'` or `entry.spec_decoding === 'mtp'`, appends `_mtp`. Otherwise, any non-`'none'` `spec_decoding` value is appended as-is (e.g. `_eagle`). +3. Spec decoding suffix: for fixed-sequence rows, if `entry.mtp === 'on'` or `entry.spec_decoding === 'mtp'`, appends `_mtp`. Otherwise, any non-`'none'` `spec_decoding` value is appended as-is (e.g. `_eagle`). Agentic rows deliberately omit this suffix because one production curve may combine speculative and standard-decoding points; `spec_decoding` remains on each point for filtering, tooltip metadata, and point-level identity. The resulting key's base GPU must exist in `HW_REGISTRY`. Display fields (label, suffix, gpu tooltip) are derived dynamically by `getHardwareConfig()`. Unrecognised base GPUs fall back to the `unknown` hardware config. diff --git a/packages/app/cypress/component/chart-legend.cy.tsx b/packages/app/cypress/component/chart-legend.cy.tsx index 8aa4e358e..15b9ab100 100644 --- a/packages/app/cypress/component/chart-legend.cy.tsx +++ b/packages/app/cypress/component/chart-legend.cy.tsx @@ -141,8 +141,8 @@ describe('ChartLegend (sidebar variant)', () => { cy.get('[data-testid="offload-halo-key"]') .should('be.visible') - .and('contain.text', 'Dashed halo:') .and('contain.text', 'KV offload ON') + .and('not.contain.text', 'Dashed halo:') .and('not.have.class', 'no-export'); cy.get('[data-testid="offload-halo-key"] circle[stroke-dasharray="3 2"]').should('exist'); cy.get('[data-testid="chart-legend"]').then(($legend) => { diff --git a/packages/app/cypress/component/gpu-graph.cy.tsx b/packages/app/cypress/component/gpu-graph.cy.tsx index 82d214077..0634ffff6 100644 --- a/packages/app/cypress/component/gpu-graph.cy.tsx +++ b/packages/app/cypress/component/gpu-graph.cy.tsx @@ -5,7 +5,7 @@ import { createMockChartDefinition, createMockHardwareConfig, } from '../support/mock-data'; -import { Precision } from '@/lib/data-mappings'; +import { Precision, Sequence } from '@/lib/data-mappings'; const defaultChartDef = createMockChartDefinition(); const hwConfig = createMockHardwareConfig(); @@ -132,6 +132,70 @@ describe('GPUGraph', () => { cy.get('[data-testid="gpu-graph"] svg .visible-shape').should('have.length.greaterThan', 0); }); + it('shows spec decoding only on hover while retaining the offload halo', () => { + const data = [ + createMockInferenceData({ + hwKey: 'h100', + x: 32, + y: 180, + date: '2025-03-01', + precision: Precision.FP4, + benchmark_type: 'agentic_traces', + spec_decoding: 'mtp', + offload_mode: 'on', + }), + createMockInferenceData({ + hwKey: 'h100', + x: 64, + y: 210, + date: '2025-03-01', + precision: Precision.FP4, + benchmark_type: 'agentic_traces', + spec_decoding: 'none', + offload_mode: 'off', + }), + ]; + + mountWithProviders( +
+ +
, + { + inference: { + hardwareConfig: hwConfig, + selectedGPUs: ['h100'], + selectedDates: ['2025-03-01'], + selectedDateRange: { startDate: '', endDate: '' }, + activeDates: new Set(['2025-03-01_h100']), + selectedPrecisions: [Precision.FP4], + selectedSequence: Sequence.AgenticTraces, + }, + }, + ); + + cy.get('#test-gpu-agentic-decorations svg .spec-decode-marker').should('not.exist'); + cy.get('#test-gpu-agentic-decorations svg .offload-halo').should('have.length', 1); + cy.get('#test-gpu-agentic-decorations [data-testid="spec-decode-marker-key"]').should( + 'not.exist', + ); + cy.get('#test-gpu-agentic-decorations [data-testid="offload-halo-key"]').should('exist'); + cy.get('#test-gpu-agentic-decorations [data-testid="agentic-optimization-note"]') + .should('contain.text', 'Inference optimizations enabled') + .find('button') + .focus(); + cy.contains('Each configuration may use inference optimizations').should('be.visible'); + cy.get('#test-gpu-agentic-decorations svg .dot-group').first().trigger('mouseenter'); + cy.get('[data-chart-tooltip]').should('contain.text', 'Speculative Decoding'); + cy.get('[data-chart-tooltip]').should('contain.text', 'MTP'); + }); + it('renders date line labels along each roofline when showLineLabels is on', () => { // Two GPUs × two dates with enough points each to form rooflines. const data = [ diff --git a/packages/app/cypress/e2e/ttft-x-axis-toggle.cy.ts b/packages/app/cypress/e2e/ttft-x-axis-toggle.cy.ts index 9fe0f70ce..63453aa3e 100644 --- a/packages/app/cypress/e2e/ttft-x-axis-toggle.cy.ts +++ b/packages/app/cypress/e2e/ttft-x-axis-toggle.cy.ts @@ -174,7 +174,6 @@ describe('X-Axis Mode Toggle (inference chart)', () => { it('explains the offload halo in the legend and distinguishes it from plain points', () => { cy.get('#chart-0 [data-testid="offload-halo-key"]') .should('be.visible') - .and('contain.text', 'Dashed halo:') .and('contain.text', 'KV offload ON'); cy.get('#chart-0 .offload-halo').should('have.length.at.least', 1); cy.get('#chart-0 .dot-group').then(($points) => { @@ -513,7 +512,6 @@ describe('X-Axis Mode Toggle — overlay path (finding #8 regression guard)', () ); cy.get('#chart-0 [data-testid="offload-halo-key"]') .should('be.visible') - .and('contain.text', 'Dashed halo:') .and('contain.text', 'KV offload ON'); }); diff --git a/packages/app/src/app/api/v1/benchmarks/history/route.test.ts b/packages/app/src/app/api/v1/benchmarks/history/route.test.ts index e16549347..cbb8affe5 100644 --- a/packages/app/src/app/api/v1/benchmarks/history/route.test.ts +++ b/packages/app/src/app/api/v1/benchmarks/history/route.test.ts @@ -81,6 +81,35 @@ describe('GET /api/v1/benchmarks/history', () => { expect(mockGetAllBenchmarksForHistory).toHaveBeenCalledWith('mock-sql', ['dsr1'], 1024, 1024); }); + it('returns agentic history without numeric sequence lengths', async () => { + const mockRows = [{ date: '2026-03-01', benchmark_type: 'agentic_traces' }]; + mockGetAllBenchmarksForHistory.mockResolvedValueOnce(mockRows); + + const res = await GET( + req('/api/v1/benchmarks/history?model=DeepSeek-R1-0528&benchmarkType=agentic_traces'), + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual(mockRows); + expect(mockGetAllBenchmarksForHistory).toHaveBeenCalledWith( + 'mock-sql', + ['dsr1'], + null, + null, + 'agentic_traces', + ); + }); + + it('preserves fixed-sequence behavior when benchmarkType=single_turn is supplied', async () => { + mockGetAllBenchmarksForHistory.mockResolvedValueOnce([]); + const res = await GET( + req( + '/api/v1/benchmarks/history?model=DeepSeek-R1-0528&isl=1024&osl=1024&benchmarkType=single_turn', + ), + ); + expect(res.status).toBe(200); + expect(mockGetAllBenchmarksForHistory).toHaveBeenCalledWith('mock-sql', ['dsr1'], 1024, 1024); + }); + it('returns 500 when query throws', async () => { mockGetAllBenchmarksForHistory.mockRejectedValueOnce(new Error('DB error')); diff --git a/packages/app/src/app/api/v1/benchmarks/history/route.ts b/packages/app/src/app/api/v1/benchmarks/history/route.ts index 29f2442b3..113a6ed65 100644 --- a/packages/app/src/app/api/v1/benchmarks/history/route.ts +++ b/packages/app/src/app/api/v1/benchmarks/history/route.ts @@ -7,6 +7,7 @@ import { getAllBenchmarksForHistory } from '@semianalysisai/inferencex-db/querie import { cachedJson, cachedQuery } from '@/lib/api-cache'; import { loadFixture } from '@/lib/test-fixtures'; +import { agenticWorkflowMetadataOnly } from '@/lib/agentic-workflow-metadata'; export const dynamic = 'force-dynamic'; @@ -16,13 +17,26 @@ const getCachedBenchmarkHistory = cachedQuery( 'benchmark-history', { blobOnly: true }, ); +const getCachedAgenticBenchmarkHistory = cachedQuery( + (modelKeys: string[]) => + getAllBenchmarksForHistory(getDb(), modelKeys, null, null, 'agentic_traces'), + 'benchmark-history-agentic', + { blobOnly: true }, +); export async function GET(request: NextRequest) { const model = request.nextUrl.searchParams.get('model') ?? ''; - const isl = Number(request.nextUrl.searchParams.get('isl')); - const osl = Number(request.nextUrl.searchParams.get('osl')); - - if (!model || !isl || !osl) { + const rawIsl = request.nextUrl.searchParams.get('isl'); + const rawOsl = request.nextUrl.searchParams.get('osl'); + const benchmarkType = request.nextUrl.searchParams.get('benchmarkType') ?? undefined; + const isl = rawIsl === null ? null : Number(rawIsl); + const osl = rawOsl === null ? null : Number(rawOsl); + const isAgentic = benchmarkType === 'agentic_traces'; + + if (!model) { + return NextResponse.json({ error: 'model, isl, and osl are required' }, { status: 400 }); + } + if (!isAgentic && (!isl || !osl)) { return NextResponse.json({ error: 'model, isl, and osl are required' }, { status: 400 }); } if (FIXTURES_MODE) return cachedJson(loadFixture('benchmarks-history')); @@ -32,8 +46,10 @@ export async function GET(request: NextRequest) { if (!modelKeys || modelKeys.length === 0) { return NextResponse.json({ error: 'Unknown model' }, { status: 400 }); } - const rows = await getCachedBenchmarkHistory(modelKeys, isl, osl); - return cachedJson(rows); + const rows = isAgentic + ? await getCachedAgenticBenchmarkHistory(modelKeys) + : await getCachedBenchmarkHistory(modelKeys, isl!, osl!); + return cachedJson(agenticWorkflowMetadataOnly(rows)); } catch (error) { console.error('Error fetching benchmark history:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); diff --git a/packages/app/src/app/api/v1/benchmarks/route.test.ts b/packages/app/src/app/api/v1/benchmarks/route.test.ts index a1809efda..629339016 100644 --- a/packages/app/src/app/api/v1/benchmarks/route.test.ts +++ b/packages/app/src/app/api/v1/benchmarks/route.test.ts @@ -64,6 +64,41 @@ describe('GET /api/v1/benchmarks', () => { ); }); + it('does not expose agentic run-selection metadata on fixed-sequence rows', async () => { + mockGetLatestBenchmarks.mockResolvedValueOnce([ + { + id: 1, + benchmark_type: 'single_turn', + workflow_run_id: 42, + run_started_at: '2026-08-12T10:00:00Z', + }, + ]); + + const res = await GET(req('/api/v1/benchmarks?model=DeepSeek-R1-0528')); + expect(await res.json()).toEqual([{ id: 1, benchmark_type: 'single_turn' }]); + }); + + it('keeps run-selection metadata on agentic rows', async () => { + mockGetLatestBenchmarks.mockResolvedValueOnce([ + { + id: 1, + benchmark_type: 'agentic_traces', + workflow_run_id: 42, + run_started_at: '2026-08-12T10:00:00Z', + }, + ]); + + const res = await GET(req('/api/v1/benchmarks?model=DeepSeek-R1-0528')); + expect(await res.json()).toEqual([ + { + id: 1, + benchmark_type: 'agentic_traces', + workflow_run_id: 42, + run_started_at: '2026-08-12T10:00:00Z', + }, + ]); + }); + it('passes date param to query when provided', async () => { mockGetLatestBenchmarks.mockResolvedValueOnce([]); diff --git a/packages/app/src/app/api/v1/benchmarks/route.ts b/packages/app/src/app/api/v1/benchmarks/route.ts index ce151eccf..84ef529f8 100644 --- a/packages/app/src/app/api/v1/benchmarks/route.ts +++ b/packages/app/src/app/api/v1/benchmarks/route.ts @@ -11,6 +11,7 @@ import { import { cachedJson, cachedQuery } from '@/lib/api-cache'; import { toCalculatorBenchmarkRows } from '@/lib/benchmark-api-view'; +import { agenticWorkflowMetadataOnly } from '@/lib/agentic-workflow-metadata'; import { loadFixture } from '@/lib/test-fixtures'; export const dynamic = 'force-dynamic'; @@ -18,7 +19,7 @@ export const dynamic = 'force-dynamic'; const getCachedBenchmarks = cachedQuery( (dbModelKeys: string[], date?: string, exact?: boolean, runId?: string) => getLatestBenchmarks(getDb(), dbModelKeys, date, exact, runId), - 'benchmarks', + 'benchmarks-agentic-run-metadata', { blobOnly: true }, ); @@ -26,14 +27,14 @@ const getCachedBenchmarks = cachedQuery( // under a distinct key prefix so it never collides with the latest/as-of query. const getCachedBenchmarksForRun = cachedQuery( (dbModelKeys: string[], runId: string) => getBenchmarksForRun(getDb(), dbModelKeys, runId), - 'benchmarks-run', + 'benchmarks-run-agentic-run-metadata', { blobOnly: true }, ); const getCachedCalculatorBenchmarks = cachedQuery( async (dbModelKeys: string[], sequence: string, date?: string) => toCalculatorBenchmarkRows(await getLatestBenchmarks(getDb(), dbModelKeys, date), sequence), - 'benchmarks-calculator', + 'benchmarks-calculator-agentic-run-metadata', { blobOnly: true }, ); @@ -70,7 +71,7 @@ export async function GET(request: NextRequest) { : exactRun && runId ? await getCachedBenchmarksForRun(dbModelKeys, runId) : await getCachedBenchmarks(dbModelKeys, date, exact || undefined, runId); - return cachedJson(rows); + return cachedJson(agenticWorkflowMetadataOnly(rows)); } catch (error) { console.error('Error fetching benchmarks:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); diff --git a/packages/app/src/app/api/v1/workflow-info/route.test.ts b/packages/app/src/app/api/v1/workflow-info/route.test.ts index 32652b01b..e9e07faf0 100644 --- a/packages/app/src/app/api/v1/workflow-info/route.test.ts +++ b/packages/app/src/app/api/v1/workflow-info/route.test.ts @@ -89,6 +89,23 @@ describe('GET /api/v1/workflow-info', () => { expect(mockGetRunConfigsByDate).toHaveBeenCalledWith('mock-sql', '2026-03-01'); }); + it('scopes run coverage only when agentic is explicitly requested', async () => { + mockGetWorkflowRunsByDate.mockResolvedValueOnce([]); + mockGetChangelogByDate.mockResolvedValueOnce([]); + mockGetDateConfigs.mockResolvedValueOnce([]); + mockGetRunConfigsByDate.mockResolvedValueOnce([]); + + const res = await GET( + req('/api/v1/workflow-info?date=2026-03-01&benchmarkType=agentic_traces'), + ); + expect(res.status).toBe(200); + expect(mockGetRunConfigsByDate).toHaveBeenCalledWith( + 'mock-sql', + '2026-03-01', + 'agentic_traces', + ); + }); + it('accepts empty date param (returns all)', async () => { mockGetWorkflowRunsByDate.mockResolvedValueOnce([]); mockGetChangelogByDate.mockResolvedValueOnce([]); diff --git a/packages/app/src/app/api/v1/workflow-info/route.ts b/packages/app/src/app/api/v1/workflow-info/route.ts index 4c5c12bc2..d8d646e97 100644 --- a/packages/app/src/app/api/v1/workflow-info/route.ts +++ b/packages/app/src/app/api/v1/workflow-info/route.ts @@ -14,19 +14,34 @@ import { loadFixture } from '@/lib/test-fixtures'; export const dynamic = 'force-dynamic'; -const getCachedWorkflowInfo = cachedQuery(async (date: string) => { +async function loadWorkflowInfo(date: string, benchmarkType?: 'agentic_traces') { const sql = getDb(); const [runs, changelogs, configs, runConfigs] = await Promise.all([ getWorkflowRunsByDate(sql, date), getChangelogByDate(sql, date), getDateConfigs(sql, date), - getRunConfigsByDate(sql, date), + benchmarkType ? getRunConfigsByDate(sql, date, benchmarkType) : getRunConfigsByDate(sql, date), ]); return { runs, changelogs, configs, runConfigs }; -}, 'workflow-info'); +} + +// Preserve the established public cache and response for calls without a +// scenario. Scenario-scoped calls use a separate cache namespace. +const getCachedWorkflowInfo = cachedQuery( + (date: string) => loadWorkflowInfo(date), + 'workflow-info', +); +const getCachedScenarioWorkflowInfo = cachedQuery( + (date: string) => loadWorkflowInfo(date, 'agentic_traces'), + 'workflow-info-scenario', +); export async function GET(request: NextRequest) { const date = request.nextUrl.searchParams.get('date') ?? ''; + const benchmarkType = + request.nextUrl.searchParams.get('benchmarkType') === 'agentic_traces' + ? 'agentic_traces' + : undefined; if (date && !/^\d{4}-\d{2}-\d{2}$/u.test(date)) { return NextResponse.json( { error: 'Invalid date format (YYYY-MM-DD required)' }, @@ -36,7 +51,9 @@ export async function GET(request: NextRequest) { if (FIXTURES_MODE) return cachedJson(loadFixture('workflow-info')); try { - const data = await getCachedWorkflowInfo(date); + const data = benchmarkType + ? await getCachedScenarioWorkflowInfo(date) + : await getCachedWorkflowInfo(date); return cachedJson(data); } catch (error) { console.error('Error fetching workflow info:', error); diff --git a/packages/app/src/components/inference/InferenceContext.tsx b/packages/app/src/components/inference/InferenceContext.tsx index 152bc58b2..991c583dc 100644 --- a/packages/app/src/components/inference/InferenceContext.tsx +++ b/packages/app/src/components/inference/InferenceContext.tsx @@ -523,7 +523,13 @@ export function InferenceProvider({ if (rowToSequence(r) !== effectiveSequence) return false; if (!effectivePrecisions.includes(r.precision)) return false; if (!r.hardware) return false; - const hwKey = buildAvailabilityHwKey(r.hardware, r.framework, r.spec_method, r.disagg); + const hwKey = buildAvailabilityHwKey( + r.hardware, + r.framework, + r.spec_method, + r.disagg, + r.benchmark_type, + ); return selectedGPUs.includes(hwKey); }); const dates = [...new Set(rows.map((r) => r.date))].toSorted(); @@ -548,7 +554,13 @@ export function InferenceProvider({ if (rowToSequence(r) !== effectiveSequence) continue; if (!effectivePrecisions.includes(r.precision)) continue; if (!r.hardware) continue; - const hwKey = buildAvailabilityHwKey(r.hardware, r.framework, r.spec_method, r.disagg); + const hwKey = buildAvailabilityHwKey( + r.hardware, + r.framework, + r.spec_method, + r.disagg, + r.benchmark_type, + ); if (isKnownGpu(hwKey)) hwKeys.add(hwKey); } return [...hwKeys] diff --git a/packages/app/src/components/inference/hooks/useChartData.test.ts b/packages/app/src/components/inference/hooks/useChartData.test.ts index b61cfb17b..94c9075d3 100644 --- a/packages/app/src/components/inference/hooks/useChartData.test.ts +++ b/packages/app/src/components/inference/hooks/useChartData.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import chartDefinitions from '@/components/inference/inference-chart-config.json'; +import { dedupeAgenticHistoryRuns } from '@/lib/benchmark-run-selection'; import type { InferenceData } from '@/components/inference/types'; import { EMPTY_QUICK_FILTERS } from '@/components/inference/utils/quickFilters'; @@ -24,7 +25,10 @@ interface DedupeInput { disagg: boolean; precision: string; offload_mode?: string | null; + benchmark_type?: string; date: string; + workflow_run_id?: number; + run_started_at?: string | null; } const drow = (over: Partial = {}): DedupeInput => ({ @@ -86,6 +90,160 @@ describe('dedupeRowsToLatestPerConfig', () => { ]; expect(dedupeRowsToLatestPerConfig(rows).map((r) => r.id)).toEqual([2]); }); + + it('dedupes mixed agentic spec methods as one curve', () => { + const rows = [ + drow({ id: 1, benchmark_type: 'agentic_traces', spec_method: 'none', date: '2026-06-01' }), + drow({ id: 2, benchmark_type: 'agentic_traces', spec_method: 'mtp', date: '2026-06-03' }), + drow({ id: 3, benchmark_type: 'agentic_traces', spec_method: 'eagle', date: '2026-06-03' }), + ]; + + expect(dedupeRowsToLatestPerConfig(rows).map((r) => r.id)).toEqual([2, 3]); + }); + + it('continues deduping fixed-sequence spec methods independently', () => { + const rows = [ + drow({ id: 1, benchmark_type: 'single_turn', spec_method: 'none', date: '2026-06-01' }), + drow({ id: 2, benchmark_type: 'single_turn', spec_method: 'mtp', date: '2026-06-03' }), + ]; + + expect(dedupeRowsToLatestPerConfig(rows).map((r) => r.id)).toEqual([1, 2]); + }); + + it('keeps mixed agentic points from only the newest same-day workflow run', () => { + const rows = [ + drow({ + id: 1, + benchmark_type: 'agentic_traces', + spec_method: 'none', + workflow_run_id: 10, + run_started_at: '2026-06-03T10:00:00Z', + }), + drow({ + id: 2, + benchmark_type: 'agentic_traces', + spec_method: 'mtp', + workflow_run_id: 10, + run_started_at: '2026-06-03T10:00:00Z', + }), + drow({ + id: 3, + benchmark_type: 'agentic_traces', + spec_method: 'mtp', + workflow_run_id: 11, + run_started_at: '2026-06-03T12:00:00Z', + }), + ]; + + expect(dedupeRowsToLatestPerConfig(rows).map((r) => r.id)).toEqual([3]); + }); + + it('keeps every spec method produced by the winning agentic workflow run', () => { + const rows = [ + drow({ + id: 1, + benchmark_type: 'agentic_traces', + spec_method: 'none', + workflow_run_id: 12, + run_started_at: '2026-06-03T12:00:00Z', + }), + drow({ + id: 2, + benchmark_type: 'agentic_traces', + spec_method: 'mtp', + workflow_run_id: 12, + run_started_at: '2026-06-03T12:00:00Z', + }), + drow({ + id: 3, + benchmark_type: 'agentic_traces', + spec_method: 'eagle', + workflow_run_id: 12, + run_started_at: '2026-06-03T12:00:00Z', + }), + ]; + + expect(dedupeRowsToLatestPerConfig(rows).map((r) => r.spec_method)).toEqual([ + 'none', + 'mtp', + 'eagle', + ]); + }); +}); + +describe('dedupeAgenticHistoryRuns', () => { + it('keeps the newest agentic workflow per series on each date', () => { + const rows = [ + drow({ + id: 1, + benchmark_type: 'agentic_traces', + spec_method: 'none', + workflow_run_id: 20, + run_started_at: '2026-06-01T10:00:00Z', + }), + drow({ + id: 2, + benchmark_type: 'agentic_traces', + spec_method: 'mtp', + workflow_run_id: 21, + run_started_at: '2026-06-01T12:00:00Z', + }), + drow({ + id: 3, + benchmark_type: 'agentic_traces', + spec_method: 'none', + date: '2026-06-02', + workflow_run_id: 22, + run_started_at: '2026-06-02T10:00:00Z', + }), + ]; + + expect(dedupeAgenticHistoryRuns(rows).map((row) => row.id)).toEqual([2, 3]); + }); + + it('keeps mixed spec points together in the winning workflow on every date', () => { + const rows = [ + drow({ + id: 1, + benchmark_type: 'agentic_traces', + spec_method: 'none', + workflow_run_id: 30, + run_started_at: '2026-06-01T10:00:00Z', + }), + drow({ + id: 2, + benchmark_type: 'agentic_traces', + spec_method: 'mtp', + workflow_run_id: 30, + run_started_at: '2026-06-01T10:00:00Z', + }), + drow({ + id: 3, + benchmark_type: 'agentic_traces', + spec_method: 'eagle', + date: '2026-06-02', + workflow_run_id: 31, + run_started_at: '2026-06-02T10:00:00Z', + }), + drow({ + id: 4, + benchmark_type: 'agentic_traces', + spec_method: 'none', + date: '2026-06-02', + workflow_run_id: 31, + run_started_at: '2026-06-02T10:00:00Z', + }), + ]; + + expect( + dedupeAgenticHistoryRuns(rows).map((row) => [row.date, row.workflow_run_id, row.spec_method]), + ).toEqual([ + ['2026-06-01', 30, 'none'], + ['2026-06-01', 30, 'mtp'], + ['2026-06-02', 31, 'eagle'], + ['2026-06-02', 31, 'none'], + ]); + }); }); describe('buildComparisonDates', () => { diff --git a/packages/app/src/components/inference/hooks/useChartData.ts b/packages/app/src/components/inference/hooks/useChartData.ts index 2a9f7904f..1f76ebbb1 100644 --- a/packages/app/src/components/inference/hooks/useChartData.ts +++ b/packages/app/src/components/inference/hooks/useChartData.ts @@ -24,6 +24,10 @@ import { hardwareKeyMatchesAnyBase, } from '@/lib/constants'; import { mergeRunScopedRows, transformBenchmarkRows } from '@/lib/benchmark-transform'; +import { + dedupeAgenticHistoryRuns, + dedupeRowsToLatestPerConfig as dedupeLatestBenchmarkSeries, +} from '@/lib/benchmark-run-selection'; import { Sequence, type Model } from '@/lib/data-mappings'; import { calculateCostsForGpus, calculatePowerForGpus } from '@/lib/utils'; import { overviewServingSeriesKey, type OverviewServingSeriesRow } from '@/lib/overview-data'; @@ -159,32 +163,20 @@ interface DedupeRow { disagg: boolean; precision: string; offload_mode?: string | null; + benchmark_type?: string; date: string; + workflow_run_id?: number; + run_started_at?: string | null; } // offload_mode normalized `?? 'off'` to match the SQL layer's getBenchmarksForRun // lineKey — agentic offload=on and offload=off are distinct series. -const dedupeSeriesKey = (r: DedupeRow): string => - `${r.hardware}|${r.framework}|${r.spec_method}|${r.disagg}|${r.precision}|${r.offload_mode ?? 'off'}`; - /** - * For each series — (hardware, framework, spec_method, disagg, precision, - * offload_mode) — keep only the rows from that series' most recent date. When - * parallelism settings change between runs, old config_ids create stale points - * under the same legend line; dropping all-but-latest removes them. - * - * Without `offload_mode` in the key, an offload=on sweep ingested on a LATER date - * than the offload=off sweep would win the shared group and silently drop the - * (earlier-dated) offload=off variant — a data-loss regression. + * Keep only the newest workflow run for each chart series. Agentic series omit + * point-level spec decoding from their curve identity; fixed-sequence series do not. */ export function dedupeRowsToLatestPerConfig(rows: T[]): T[] { - const maxDatePerGroup = new Map(); - for (const r of rows) { - const k = dedupeSeriesKey(r); - const cur = maxDatePerGroup.get(k); - if (!cur || r.date > cur) maxDatePerGroup.set(k, r.date); - } - return rows.filter((r) => r.date === maxDatePerGroup.get(dedupeSeriesKey(r))); + return dedupeLatestBenchmarkSeries(rows); } /** @@ -353,12 +345,15 @@ export function useChartData( selectedRunDate ? { ...r, date: selectedRunDate, actualDate: r.date } : r, ); if (comparisonDates.length === 0) return mainRows; - const extraRows = comparisonQueries.flatMap((q, i) => - filterOverviewHistoryRows( + const extraRows = comparisonQueries.flatMap((q, i) => { + const filtered = filterOverviewHistoryRows( (q.data ?? []).filter(seqFilter), overviewHistoryPair?.baselineConfigKey, - ).map((r) => ({ ...r, date: comparisonDates[i], actualDate: r.date })), - ); + ); + const selected = + selectedSequence === Sequence.AgenticTraces ? dedupeAgenticHistoryRuns(filtered) : filtered; + return selected.map((r) => ({ ...r, date: comparisonDates[i], actualDate: r.date })); + }); return [...mainRows, ...extraRows]; }, [ allRows, diff --git a/packages/app/src/components/inference/hooks/useInterpolatedTrendData.ts b/packages/app/src/components/inference/hooks/useInterpolatedTrendData.ts index 90e7c5af3..bfef739d3 100644 --- a/packages/app/src/components/inference/hooks/useInterpolatedTrendData.ts +++ b/packages/app/src/components/inference/hooks/useInterpolatedTrendData.ts @@ -15,7 +15,8 @@ import { getHardwareKey } from '@/lib/chart-utils'; import { getGpuSpecs, isKnownGpu } from '@/lib/constants'; import { rowToAggDataEntry } from '@/lib/benchmark-transform'; import type { BenchmarkRow } from '@/lib/api'; -import type { Model, Sequence } from '@/lib/data-mappings'; +import { dedupeAgenticHistoryRuns } from '@/lib/benchmark-run-selection'; +import { Sequence, type Model } from '@/lib/data-mappings'; // Trend points never sit on a roofline — they're synthetic per-(date, config) // aggregates, not the per-load Pareto-frontier points the chart marks. Hardcode @@ -246,6 +247,7 @@ export function useInterpolatedTrendData({ enabled ? selectedModel : '', seqIslOsl?.isl ?? 0, seqIslOsl?.osl ?? 0, + selectedSequence === Sequence.AgenticTraces ? 'agentic_traces' : undefined, ); // Build lightweight InferenceData points grouped by date and hwKey. @@ -255,7 +257,7 @@ export function useInterpolatedTrendData({ const result = new Map>(); - for (const row of allRows) { + for (const row of dedupeAgenticHistoryRuns(allRows)) { if (!selectedPrecisions.includes(row.precision)) continue; const point = rowToLightweightPoint(row); diff --git a/packages/app/src/components/inference/replay/ReplayPanel.tsx b/packages/app/src/components/inference/replay/ReplayPanel.tsx index 7273ed24c..3d36bca0f 100644 --- a/packages/app/src/components/inference/replay/ReplayPanel.tsx +++ b/packages/app/src/components/inference/replay/ReplayPanel.tsx @@ -21,6 +21,7 @@ import { } from '@/components/ui/select'; import { useBenchmarkHistory } from '@/hooks/api/use-benchmark-history'; import { track } from '@/lib/analytics'; +import { Sequence } from '@/lib/data-mappings'; import { cn } from '@/lib/utils'; import { buildReplayTimeline, computeFullRunDomain } from './buildReplayTimeline'; @@ -68,7 +69,12 @@ export default function ReplayPanel({ const { selectedModel, selectedSequence, activeHwTypes } = inference; const { isl = 0, osl = 0 } = sequenceToIslOsl(selectedSequence) ?? {}; - const history = useBenchmarkHistory(selectedModel, isl, osl); + const history = useBenchmarkHistory( + selectedModel, + isl, + osl, + selectedSequence === Sequence.AgenticTraces ? 'agentic_traces' : undefined, + ); const effectiveX = chartDefinition.chartType === 'e2e' diff --git a/packages/app/src/components/inference/replay/__tests__/buildReplayTimeline.test.ts b/packages/app/src/components/inference/replay/__tests__/buildReplayTimeline.test.ts index 6e8025e66..6dd9aac94 100644 --- a/packages/app/src/components/inference/replay/__tests__/buildReplayTimeline.test.ts +++ b/packages/app/src/components/inference/replay/__tests__/buildReplayTimeline.test.ts @@ -216,6 +216,45 @@ describe('buildReplayTimeline', () => { expect(t.configs.length).toBeGreaterThanOrEqual(2); }); + it('keeps overlapping agentic MTP and standard-decoding replay points distinct', () => { + const rows = [ + baseRow({ + benchmark_type: 'agentic_traces', + spec_method: 'none', + isl: null, + osl: null, + metrics: { tput_per_gpu: 1000, median_itl: 0.02 }, + }), + baseRow({ + benchmark_type: 'agentic_traces', + spec_method: 'mtp', + isl: null, + osl: null, + metrics: { tput_per_gpu: 1200, median_itl: 1 / 55 }, + }), + ]; + + const t = buildReplayTimeline(rows, interactivityChartDef, 'y_tpPerGpu', null, ['fp4']); + expect(t.configs).toHaveLength(2); + expect(new Set(t.configs.map((config) => config.hwKey))).toEqual(new Set(['h100_trt'])); + expect(t.configs.map((config) => config.configId).toSorted()).toEqual([ + 'h100_trt|fp4|0|32|0|0|0|spec-mtp', + 'h100_trt|fp4|0|32|0|0|0|spec-none', + ]); + }); + + it('preserves fixed-sequence replay identity when offload metadata differs', () => { + const rows = [ + baseRow({ benchmark_type: 'single_turn', offload_mode: 'off' }), + baseRow({ benchmark_type: 'single_turn', offload_mode: 'on' }), + ]; + + const t = buildReplayTimeline(rows, interactivityChartDef, 'y_tpPerGpu', null, ['fp4']); + expect(t.configs).toHaveLength(1); + expect(t.configs[0].configId).not.toContain('offload'); + expect(t.configs[0].configId).not.toContain('spec-'); + }); + it('computes a global x/y domain spanning all observations', () => { const rows = [ baseRow({ date: '2025-01-01', metrics: { tput_per_gpu: 100, median_intvty: 10 } }), diff --git a/packages/app/src/components/inference/replay/buildReplayTimeline.ts b/packages/app/src/components/inference/replay/buildReplayTimeline.ts index 917616046..717b12840 100644 --- a/packages/app/src/components/inference/replay/buildReplayTimeline.ts +++ b/packages/app/src/components/inference/replay/buildReplayTimeline.ts @@ -8,6 +8,8 @@ import type { InferenceData, YAxisMetricKey, } from '@/components/inference/types'; +import { agenticSpecDecodingKeySuffix } from '@/components/inference/utils/point-identity'; +import { dedupeAgenticHistoryRuns } from '@/lib/benchmark-run-selection'; import type { PerStepValue } from './interpolateAtTime'; @@ -81,10 +83,12 @@ export function computeFullRunDomain( return { x: safeDomain(xMin, xMax), y: safeDomain(yMin, yMax) }; } -const buildPointConfigId = (point: InferenceData): string => { +const buildReplayPointConfigId = (point: InferenceData): string => { let key = `${point.hwKey}|${point.precision}|${point.tp}|${point.conc}|${point.decode_ep ?? 0}|${point.prefill_tp ?? 0}|${point.prefill_ep ?? 0}`; if (point.disagg) key += `|disagg|${point.num_prefill_gpu ?? 0}|${point.num_decode_gpu ?? 0}`; - return key; + // Preserve the pre-existing replay identity for fixed-sequence points. Agentic + // curves need only the decode-method suffix because their hwKey now merges it. + return key + agenticSpecDecodingKeySuffix(point); }; const safeDomain = (lo: number, hi: number): [number, number] => { @@ -155,7 +159,7 @@ export function buildReplayTimeline( let yMax = -Infinity; const dateSet = new Set(); - for (const row of rows) { + for (const row of dedupeAgenticHistoryRuns(rows)) { if (!selectedPrecisions.includes(row.precision)) continue; const entry = rowToAggDataEntry(row); @@ -181,7 +185,7 @@ export function buildReplayTimeline( if (xVal <= 0 || yMetric <= 0) continue; const finalPoint: InferenceData = { ...point, x: xVal, y: yMetric }; - const configId = buildPointConfigId(finalPoint); + const configId = buildReplayPointConfigId(finalPoint); const dateMs = Date.parse(`${row.date}T00:00:00Z`); if (Number.isNaN(dateMs)) continue; diff --git a/packages/app/src/components/inference/ui/AgenticOptimizationNote.tsx b/packages/app/src/components/inference/ui/AgenticOptimizationNote.tsx new file mode 100644 index 000000000..020d0c38b --- /dev/null +++ b/packages/app/src/components/inference/ui/AgenticOptimizationNote.tsx @@ -0,0 +1,53 @@ +import { Info } from 'lucide-react'; + +import { + TooltipContent, + TooltipProvider, + TooltipRoot, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { useLocale } from '@/lib/use-locale'; + +const STRINGS = { + en: { + label: 'Inference optimizations enabled', + aria: 'About inference optimizations', + details: + 'Each configuration may use inference optimizations such as speculative decoding. Hover over a point to see its exact settings.', + }, + zh: { + label: '已启用推理优化', + aria: '关于推理优化', + details: '每项配置可能使用推测解码等推理优化。将鼠标悬停在数据点上可查看其具体设置。', + }, +} as const; + +/** Agentic-only legend note; point tooltips carry the exact optimization method. */ +export function AgenticOptimizationNote() { + const t = STRINGS[useLocale()]; + + return ( +
+ *{t.label} + + + + + + + {t.details} + + + +
+ ); +} diff --git a/packages/app/src/components/inference/ui/ChartDisplay.tsx b/packages/app/src/components/inference/ui/ChartDisplay.tsx index f80f823bb..85160daf2 100644 --- a/packages/app/src/components/inference/ui/ChartDisplay.tsx +++ b/packages/app/src/components/inference/ui/ChartDisplay.tsx @@ -43,7 +43,7 @@ import { useUnofficialRun } from '@/components/unofficial-run-provider'; import { type Model, type Precision, - type Sequence, + Sequence, getModelLabel, getPrecisionLabel, getSequenceLabel, @@ -218,12 +218,21 @@ export default function ChartDisplay() { setSelectedXAxisMode, quickFilters, } = useInference(); + const selectedBenchmarkType: 'single_turn' | 'agentic_traces' = + selectedSequence === Sequence.AgenticTraces ? 'agentic_traces' : 'single_turn'; + const workflowInfoBenchmarkType = + selectedSequence === Sequence.AgenticTraces ? 'agentic_traces' : undefined; const { changelogs, loading: changelogsLoading, totalDatesQueried, - } = useComparisonChangelogs(selectedGPUs, selectedDateRange, dateRangeAvailableDates); + } = useComparisonChangelogs( + selectedGPUs, + selectedDateRange, + dateRangeAvailableDates, + workflowInfoBenchmarkType, + ); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); @@ -232,7 +241,6 @@ export default function ChartDisplay() { () => DISPLAY_MODEL_TO_DB[selectedModel] ?? [selectedModel], [selectedModel], ); - // Stable run numbering shared by the changelog and the chart legend: each of a // date's runs gets a fixed 1-based number (by start time) regardless of which // are on the chart, so the two surfaces always show the same #N for a run and a @@ -241,14 +249,17 @@ export default function ChartDisplay() { const runNumbering = useMemo(() => { const map = new Map(); for (const c of changelogs) { - dataRunsForDate(c.runConfigs, { modelDbKeys, selectedGPUs, selectedPrecisions }).forEach( - (run, idx) => { - map.set(makeRunComparisonEntry(c.date, run.runId), idx + 1); - }, - ); + dataRunsForDate(c.runConfigs, { + modelDbKeys, + selectedGPUs, + selectedPrecisions, + benchmarkType: selectedBenchmarkType, + }).forEach((run, idx) => { + map.set(makeRunComparisonEntry(c.date, run.runId), idx + 1); + }); } return map; - }, [changelogs, modelDbKeys, selectedGPUs, selectedPrecisions]); + }, [changelogs, modelDbKeys, selectedGPUs, selectedPrecisions, selectedBenchmarkType]); // Expand a plain-date selection into one entry per run once that date's runs are // known. Picking a date that has multiple runs shows each run as its own series @@ -257,7 +268,12 @@ export default function ChartDisplay() { // in sync. Idempotent: after expansion no expandable plain date remains. useEffect(() => { const runConfigsByDate = new Map(changelogs.map((c) => [c.date, c.runConfigs])); - const scope = { modelDbKeys, selectedGPUs, selectedPrecisions }; + const scope = { + modelDbKeys, + selectedGPUs, + selectedPrecisions, + benchmarkType: selectedBenchmarkType, + }; setSelectedDatesFromRunExpansion((prev) => { let changed = false; const out: string[] = []; @@ -283,6 +299,7 @@ export default function ChartDisplay() { modelDbKeys, selectedGPUs, selectedPrecisions, + selectedBenchmarkType, selectedDates, setSelectedDatesFromRunExpansion, ]); @@ -998,6 +1015,7 @@ export default function ChartDisplay() { selectedGPUs={selectedGPUs} selectedPrecisions={selectedPrecisions} modelDbKeys={modelDbKeys} + selectedSequence={selectedSequence} loading={changelogsLoading} totalDatesQueried={totalDatesQueried} selectedDates={selectedDates} diff --git a/packages/app/src/components/inference/ui/ComparisonChangelog.tsx b/packages/app/src/components/inference/ui/ComparisonChangelog.tsx index ae906b750..f87a8084c 100644 --- a/packages/app/src/components/inference/ui/ComparisonChangelog.tsx +++ b/packages/app/src/components/inference/ui/ComparisonChangelog.tsx @@ -16,6 +16,7 @@ import { import { makeRunComparisonEntry } from '@/components/inference/utils/comparisonEntry'; import { dataRunsForDate } from '@/components/inference/utils/runEnumeration'; import { getHardwareConfig } from '@/lib/constants'; +import { Sequence, type Sequence as SequenceType } from '@/lib/data-mappings'; import { getDisplayLabel, updateRepoUrl } from '@/lib/utils'; /** Git Commit and Workflow Run external links for a run, each shown when known. */ @@ -72,6 +73,7 @@ interface ComparisonChangelogProps { * model-scoped). */ modelDbKeys: string[]; + selectedSequence: SequenceType; loading?: boolean; totalDatesQueried: number; selectedDates: string[]; @@ -88,6 +90,7 @@ export default function ComparisonChangelog({ selectedGPUs, selectedPrecisions, modelDbKeys, + selectedSequence, loading, totalDatesQueried, selectedDates, @@ -98,6 +101,10 @@ export default function ComparisonChangelog({ firstAvailableDate, }: ComparisonChangelogProps) { const [isExpanded, setIsExpanded] = useState(true); + const benchmarkType = + selectedSequence === Sequence.AgenticTraces ? 'agentic_traces' : 'single_turn'; + const changelogBenchmarkType = + selectedSequence === Sequence.AgenticTraces ? 'agentic_traces' : undefined; // Filter changelog entries to only show those matching selected GPUs and precisions. // Always keep range endpoints and first appearance date visible. @@ -120,7 +127,7 @@ export default function ComparisonChangelog({ return ( modelDbKeys.some((m) => key.startsWith(`${m}-`)) && precSet.has(precision) && - selectedGPUs.some((gpu) => configKeyMatchesHwKey(key, gpu)) + selectedGPUs.some((gpu) => configKeyMatchesHwKey(key, gpu, changelogBenchmarkType)) ); }), ), @@ -136,7 +143,14 @@ export default function ComparisonChangelog({ return mapped .filter((item) => item.entries.length > 0 || pinnedDates.has(item.date)) .toSorted((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); - }, [changelogs, modelDbKeys, selectedGPUs, selectedPrecisions, pinnedDates]); + }, [ + changelogs, + modelDbKeys, + selectedGPUs, + selectedPrecisions, + pinnedDates, + changelogBenchmarkType, + ]); const datesOnChart = useMemo(() => { const set = new Set(selectedDates); @@ -156,10 +170,10 @@ export default function ComparisonChangelog({ return ( modelDbKeys.some((m) => key.startsWith(`${m}-`)) && precSet.has(precision) && - selectedGPUs.some((gpu) => configKeyMatchesHwKey(key, gpu)) + selectedGPUs.some((gpu) => configKeyMatchesHwKey(key, gpu, changelogBenchmarkType)) ); }); - }, [modelDbKeys, selectedPrecisions, selectedGPUs]); + }, [modelDbKeys, selectedPrecisions, selectedGPUs, changelogBenchmarkType]); /** * Every run that produced data for the selected config on a date, earliest @@ -173,6 +187,7 @@ export default function ComparisonChangelog({ modelDbKeys, selectedGPUs, selectedPrecisions, + benchmarkType, }).map((run) => { const cl = clByRun.get(run.runId); return { @@ -183,7 +198,7 @@ export default function ComparisonChangelog({ }; }); }, - [modelDbKeys, selectedGPUs, selectedPrecisions, entryMatchesSelection], + [modelDbKeys, selectedGPUs, selectedPrecisions, benchmarkType, entryMatchesSelection], ); // Entries the "Add all to chart" button would add: every run not yet on the @@ -219,7 +234,9 @@ export default function ComparisonChangelog({ if (selectedGPUs.length <= 1) return ''; return selectedGPUs .filter((gpu) => - entries.some((e) => e.config_keys.some((k) => configKeyMatchesHwKey(k, gpu))), + entries.some((e) => + e.config_keys.some((k) => configKeyMatchesHwKey(k, gpu, changelogBenchmarkType)), + ), ) .map((gpu) => getDisplayLabel(getHardwareConfig(gpu, displayModel))) .join(', '); diff --git a/packages/app/src/components/inference/ui/GPUGraph.tsx b/packages/app/src/components/inference/ui/GPUGraph.tsx index ea820653f..1171ab66b 100644 --- a/packages/app/src/components/inference/ui/GPUGraph.tsx +++ b/packages/app/src/components/inference/ui/GPUGraph.tsx @@ -8,7 +8,7 @@ import { useTheme } from 'next-themes'; import { useInference } from '@/components/inference/InferenceContext'; import ChartLegend from '@/components/ui/chart-legend'; import { getHardwareConfig, getModelSortIndex } from '@/lib/constants'; -import { getChartWatermark } from '@/lib/data-mappings'; +import { getChartWatermark, Sequence } from '@/lib/data-mappings'; import { generateGpuDateColors } from '@/lib/dynamic-colors'; import { useLocale } from '@/lib/use-locale'; import { formatNumber, getDisplayLabel, updateRepoUrl } from '@/lib/utils'; @@ -62,6 +62,7 @@ import { OFFLOAD_HALO_STROKE_WIDTH, OffloadHaloLegendKey, } from '@/components/inference/ui/OffloadHaloLegendKey'; +import { AgenticOptimizationNote } from '@/components/inference/ui/AgenticOptimizationNote'; const CHART_MARGIN = { top: 24, right: 10, bottom: 60, left: 60 }; @@ -115,6 +116,7 @@ const GPUGraph = React.memo( selectedGPUs, selectedDateRange, selectedDates, + selectedSequence, setSelectedDates, toggleActiveDate, removeActiveDate, @@ -1030,7 +1032,14 @@ const GPUGraph = React.memo( }, ]} precisionIndicators={selectedPrecisions} - keyIndicators={hasOffloadHalo ? : undefined} + keyIndicators={ + hasOffloadHalo || selectedSequence === Sequence.AgenticTraces ? ( + <> + {hasOffloadHalo && } + {selectedSequence === Sequence.AgenticTraces && } + + ) : undefined + } /> } /> diff --git a/packages/app/src/components/inference/ui/OffloadHaloLegendKey.tsx b/packages/app/src/components/inference/ui/OffloadHaloLegendKey.tsx index 82c9f20fb..c7e38e10b 100644 --- a/packages/app/src/components/inference/ui/OffloadHaloLegendKey.tsx +++ b/packages/app/src/components/inference/ui/OffloadHaloLegendKey.tsx @@ -34,10 +34,7 @@ export function OffloadHaloLegendKey() { strokeDasharray={OFFLOAD_HALO_DASHARRAY} /> - - Dashed halo: - KV offload ON - + KV offload ON ); } diff --git a/packages/app/src/components/inference/ui/ScatterGraph.decoration.test.tsx b/packages/app/src/components/inference/ui/ScatterGraph.decoration.test.tsx index 7b7d57da9..57320c91c 100644 --- a/packages/app/src/components/inference/ui/ScatterGraph.decoration.test.tsx +++ b/packages/app/src/components/inference/ui/ScatterGraph.decoration.test.tsx @@ -22,7 +22,9 @@ vi.mock('@/lib/d3-chart/chart-setup', { spy: true }); vi.mock('@/lib/analytics', () => ({ track: vi.fn() })); vi.mock('next-themes', () => ({ useTheme: () => ({ resolvedTheme: 'dark' }) })); // The legend is React-rendered (covered elsewhere) — keep the tree light. -vi.mock('@/components/ui/chart-legend', () => ({ default: () => null })); +vi.mock('@/components/ui/chart-legend', () => ({ + default: ({ keyIndicators }: { keyIndicators?: React.ReactNode }) => keyIndicators ?? null, +})); const inferenceState = vi.hoisted(() => ({ current: {} as Record })); vi.mock('@/components/inference/InferenceContext', () => ({ @@ -41,7 +43,7 @@ vi.mock('@/hooks/api/use-trace-availability', () => ({ useTraceAvailability: () => ({ data: undefined }), })); -import ScatterGraph from './ScatterGraph'; +import ScatterGraph, { pointLabelText } from './ScatterGraph'; // ── Environment stubs ──────────────────────────────────────────────────────── class MockResizeObserver { @@ -73,6 +75,28 @@ const CHART_DEFINITION = { chartType: 'interactivity' } as unknown as ChartDefin const noop = () => {}; +describe('pointLabelText', () => { + it('keeps decode mode out of mixed agentic point labels', () => { + const standard = point('h100', 'fp8', 1, 1, 8); + standard.benchmark_type = 'agentic_traces'; + standard.spec_decoding = 'none'; + const mtp = { ...standard, spec_decoding: 'mtp' }; + const eagle = { ...standard, spec_decoding: 'eagle' }; + + expect(pointLabelText(standard, false)).toBe('8\nC=16'); + expect(pointLabelText(mtp, false)).toBe('8\nC=16'); + expect(pointLabelText(eagle, false)).toBe('8\nC=16'); + }); + + it('keeps fixed-sequence labels unchanged', () => { + const fixed = point('h100', 'fp8', 1, 1, 8); + fixed.benchmark_type = 'single_turn'; + fixed.spec_decoding = 'mtp'; + + expect(pointLabelText(fixed, false)).toBe('8\nC=16'); + }); +}); + function baseInferenceState() { return { activeHwTypes: new Set(['h100', 'b200']), @@ -212,6 +236,50 @@ describe('ScatterGraph toggle decoration', () => { unmount(); }); + it('keeps speculative decoding out of point decorations and shows only KV offload', () => { + const standard = { + ...point('h100', 'fp8', 1, 1, 1), + benchmark_type: 'agentic_traces', + spec_decoding: 'none', + offload_mode: 'off', + } as InferenceData; + const mtp = { + ...point('h100', 'fp8', 20, 200, 2), + benchmark_type: 'agentic_traces', + spec_decoding: 'mtp', + offload_mode: 'off', + } as InferenceData; + const mtpWithOffload = { + ...point('h100', 'fp8', 40, 400, 4), + benchmark_type: 'agentic_traces', + spec_decoding: 'mtp', + offload_mode: 'on', + } as InferenceData; + const fixedMtp = { + ...point('h100', 'fp8', 100, 1000, 8), + benchmark_type: 'single_turn', + spec_decoding: 'mtp', + offload_mode: 'off', + } as InferenceData; + + inferenceState.current = { + ...baseInferenceState(), + selectedSequence: 'agentic-traces', + }; + const { container, unmount } = mountChart({ + data: [standard, mtp, mtpWithOffload, fixedMtp], + }); + const groups = dotGroups(container); + + expect(container.querySelector('.spec-decode-marker')).toBeNull(); + expect(groups[1].querySelector('.offload-halo')).toBeNull(); + expect(groups[2].querySelector('.offload-halo')).not.toBeNull(); + expect(container.querySelector('[data-testid="spec-decode-marker-key"]')).toBeNull(); + expect(container.querySelector('[data-testid="offload-halo-key"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="agentic-optimization-note"]')).not.toBeNull(); + unmount(); + }); + it('hides a toggled-off hw via opacity without rebuilding the chart', () => { const { container, rerender, unmount } = mountChart(); const buildsAfterMount = rebuildCount(); @@ -374,6 +442,48 @@ describe('ScatterGraph toggle decoration', () => { unmount(); }); + it('keeps speculative decoding out of unofficial-run point decorations', () => { + const runUrl = 'https://github.com/o/r/actions/runs/123'; + const overlayPoints = [ + { + ...point('h100', 'fp8', 30, 300, 2), + benchmark_type: 'agentic_traces', + spec_decoding: 'mtp', + offload_mode: 'on', + run_url: runUrl, + } as InferenceData, + { + ...point('h100', 'fp8', 35, 350, 4), + benchmark_type: 'agentic_traces', + spec_decoding: 'none', + offload_mode: 'off', + run_url: runUrl, + } as InferenceData, + ]; + overlayState.current = { + ...baseOverlayState(), + isUnofficialRun: true, + activeOverlayHwTypes: new Set(['h100']), + allOverlayHwTypes: new Set(['h100']), + runIndexByUrl: { [runUrl]: 0 }, + unofficialRunInfos: [{ id: '123', branch: 'test-branch', url: runUrl }], + }; + + const { container, unmount } = mountChart({ + overlayData: { + data: overlayPoints, + hardwareConfig: HARDWARE_CONFIG, + } as unknown as Parameters[0]['overlayData'], + }); + const groups = [...container.querySelectorAll('.unofficial-overlay-pt')]; + + expect(groups[0].querySelector('.spec-decode-marker')).toBeNull(); + expect(groups[0].querySelector('.offload-halo')).not.toBeNull(); + expect(groups[1].querySelector('.spec-decode-marker')).toBeNull(); + expect(groups[1].querySelector('.offload-halo')).toBeNull(); + unmount(); + }); + it('applies quick filters to unofficial-run overlay markers', () => { const overlayPoints = [point('h100', 'fp8', 30, 300, 2), point('h100', 'fp8', 35, 350, 4)].map( (p) => ({ ...p, run_url: 'https://github.com/o/r/actions/runs/123' }), diff --git a/packages/app/src/components/inference/ui/ScatterGraph.tsx b/packages/app/src/components/inference/ui/ScatterGraph.tsx index 921bda54b..063d007c2 100644 --- a/packages/app/src/components/inference/ui/ScatterGraph.tsx +++ b/packages/app/src/components/inference/ui/ScatterGraph.tsx @@ -69,6 +69,7 @@ import { generateTooltipContent, getPointLabel, } from '@/components/inference/utils/tooltipUtils'; +import { scatterPointConfigId } from '@/components/inference/utils/point-identity'; import LegendPointsDialog from '@/components/inference/ui/LegendPointsDialog'; import { OFFLOAD_HALO_DASHARRAY, @@ -76,6 +77,7 @@ import { OFFLOAD_HALO_STROKE_WIDTH, OffloadHaloLegendKey, } from '@/components/inference/ui/OffloadHaloLegendKey'; +import { AgenticOptimizationNote } from '@/components/inference/ui/AgenticOptimizationNote'; import { buildLegendPointsRows } from '@/components/inference/utils/legend-points-table'; import { type ParetoPointLabel, @@ -199,6 +201,26 @@ const getXPath = (size: number) => { return `M ${-s} ${-s} L ${s} ${s} M ${s} ${-s} L ${-s} ${s}`; }; +/** Render the KV-offload halo around a point when applicable. */ +function renderOffloadHalo( + group: d3.Selection, + point: InferenceData, + stroke: string, +): void { + group + .selectAll('.offload-halo') + .data(point.offload_mode === 'on' ? [true] : []) + .join('circle') + .attr('class', 'offload-halo') + .attr('r', OFFLOAD_HALO_RADIUS) + .attr('fill', 'none') + .attr('stroke', stroke) + .attr('stroke-width', OFFLOAD_HALO_STROKE_WIDTH) + .attr('stroke-dasharray', OFFLOAD_HALO_DASHARRAY) + .attr('opacity', 0.9) + .attr('pointer-events', 'none'); +} + const formatChangelogDescription = (desc: string | string[]): React.JSX.Element => { if (typeof desc === 'string') { return ( @@ -245,8 +267,8 @@ function groupPointsByDate(points: InferenceData[]): Map `${d.hwKey}_${d.precision}_${d.date}-${d.x}-${d.y}`; -/** Point label lines: TP (or full parallelism label) plus the C= concurrency. */ -const pointLabelText = (d: InferenceData, advanced: boolean): string => +/** Point label lines. Decode mode is shown by a point marker instead of text. */ +export const pointLabelText = (d: InferenceData, advanced: boolean): string => advanced ? `${getPointLabel(d)}\nC=${d.conc}` : `${d.tp}\nC=${d.conc}`; // Referentially stable "no overlay data" result (see processedOverlayData). @@ -745,14 +767,19 @@ const ScatterGraph = React.memo( const hwKeys = cl.entries.flatMap((entry: any) => (entry.config_keys ?? entry['config-keys'] ?? []) .filter((key: string) => selectedPrecisions.includes(key.split('-')[1])) - .map(changelogConfigToHwKey) + .map((key: string) => + changelogConfigToHwKey( + key, + selectedSequence === Sequence.AgenticTraces ? 'agentic_traces' : undefined, + ), + ) .filter((key: string | null): key is string => key !== null), ); return new Set(hwKeys); } } return new Set(); - }, [availableRuns, selectedRunId, selectedPrecisions]); + }, [availableRuns, selectedRunId, selectedPrecisions, selectedSequence]); // --- Data Processing --- const groupedData = useMemo( @@ -814,15 +841,7 @@ const ScatterGraph = React.memo( return effectiveOfficialHwTypes; }, [showAllHardwareTypes, groupedData, effectiveOfficialHwTypes]); - const buildPointConfigId = useCallback((point: InferenceData): string => { - let key = `${point.hwKey}|${point.precision}|${point.tp}|${point.conc}|${point.decode_ep ?? 0}|${point.prefill_tp ?? 0}|${point.prefill_ep ?? 0}`; - if (point.disagg) key += `|disagg|${point.num_prefill_gpu ?? 0}|${point.num_decode_gpu ?? 0}`; - // Agentic runs emit two rows per (config, conc) — one offload=on, one off. - // Without this suffix, d3's data join treats them as the same point and - // drops one variant (along with its halo). - if (point.offload_mode) key += `|offload-${point.offload_mode}`; - return key; - }, []); + const buildPointConfigId = useCallback(scatterPointConfigId, []); // filteredData: visible points only (for scale domain calculation) const filteredData = useMemo( @@ -1056,7 +1075,6 @@ const ScatterGraph = React.memo( processedOverlayData.some((point) => point.offload_mode === 'on'), [pointsData, processedOverlayData], ); - // Bulk presence lookup for agentic points: which ids have a stored // trace_replay blob → controls the "View charts" button in the pinned // tooltip. We deliberately don't fetch the histograms themselves here; @@ -2387,6 +2405,16 @@ const ScatterGraph = React.memo( overlayRunColor(overlayRunIndex(d.run_url ?? null, runIndexByUrl)), ); + // Match official points: KV offload is the only persistent + // point decoration. Decode method remains in the tooltip. + overlayPoints.each(function (d) { + renderOffloadHalo( + d3.select(this), + d, + overlayRunColor(overlayRunIndex(d.run_url ?? null, runIndexByUrl)), + ); + }); + // Labels const showLabels = showPointLabels && !showGradientLabels; overlayPoints.each(function (d) { @@ -2874,19 +2902,7 @@ const ScatterGraph = React.memo( // Offload halo: dashed ring on every point that used KV offload (Pareto or not) zoomGroup.selectAll('.dot-group').each(function (d) { - const showHalo = d.offload_mode === 'on'; - d3.select(this) - .selectAll('.offload-halo') - .data(showHalo ? [true] : []) - .join('circle') - .attr('class', 'offload-halo') - .attr('r', OFFLOAD_HALO_RADIUS) - .attr('fill', 'none') - .attr('stroke', 'var(--foreground)') - .attr('stroke-width', OFFLOAD_HALO_STROKE_WIDTH) - .attr('stroke-dasharray', OFFLOAD_HALO_DASHARRAY) - .attr('opacity', 0.9) - .attr('pointer-events', 'none'); + renderOffloadHalo(d3.select(this), d, 'var(--foreground)'); }); avoidLabelCollisions(zoomGroup); @@ -2953,6 +2969,9 @@ const ScatterGraph = React.memo( getShapeKeyForPrecision(d.precision, ir.selectedPrecisions), color, ); + // A precision toggle may replace and append the visible SVG shape. + // Keep the offload halo above that shape after the swap. + sel.selectAll('.offload-halo').raise(); }); // Overlay X markers: Optimal Only visibility (mirrors the official dot @@ -3427,7 +3446,14 @@ const ScatterGraph = React.memo( : [] } precisionIndicators={selectedPrecisions} - keyIndicators={hasOffloadHalo ? : undefined} + keyIndicators={ + hasOffloadHalo || selectedSequence === Sequence.AgenticTraces ? ( + <> + {hasOffloadHalo && } + {selectedSequence === Sequence.AgenticTraces && } + + ) : undefined + } enableTooltips={true} /> } diff --git a/packages/app/src/components/inference/utils/changelogFormatters.test.ts b/packages/app/src/components/inference/utils/changelogFormatters.test.ts index b657e97e2..a1e80866c 100644 --- a/packages/app/src/components/inference/utils/changelogFormatters.test.ts +++ b/packages/app/src/components/inference/utils/changelogFormatters.test.ts @@ -62,10 +62,10 @@ describe('changelogConfigToHwKey', () => { ); }); - it('keeps a trailing MTP spec method while dropping agentic metadata', () => { - expect(changelogConfigToHwKey('dsv4-fp4-mi355x-sglang-agentic-hicache-mtp')).toBe( - 'mi355x_sglang_mtp', - ); + it('drops a trailing MTP spec method from agentic legend identity', () => { + expect( + changelogConfigToHwKey('dsv4-fp4-mi355x-sglang-agentic-hicache-mtp', 'agentic_traces'), + ).toBe('mi355x_sglang'); }); }); @@ -98,6 +98,22 @@ describe('configKeyMatchesHwKey', () => { ).toBe(true); }); + it('matches an agentic MTP changelog key to the mixed-spec legend key', () => { + expect( + configKeyMatchesHwKey( + 'dsv4-fp4-mi355x-mori-sglang-agentic-hicache-mtp', + 'mi355x_mori-sglang', + 'agentic_traces', + ), + ).toBe(true); + }); + + it('keeps fixed-sequence changelogs out of agentic views', () => { + expect( + configKeyMatchesHwKey('dsv4-fp4-mi355x-mori-sglang', 'mi355x_mori-sglang', 'agentic_traces'), + ).toBe(false); + }); + it('matches sglang framework', () => { expect(configKeyMatchesHwKey('gptoss-fp8-mi300x-sglang', 'mi300x_sglang')).toBe(true); }); diff --git a/packages/app/src/components/inference/utils/changelogFormatters.tsx b/packages/app/src/components/inference/utils/changelogFormatters.tsx index 476ed2211..17a0f2038 100644 --- a/packages/app/src/components/inference/utils/changelogFormatters.tsx +++ b/packages/app/src/components/inference/utils/changelogFormatters.tsx @@ -20,7 +20,10 @@ const CHANGELOG_FRAMEWORK_KEYS = [ * `agentic`, `hicache`, and `pcp` after the serving framework; those are not * framework labels and must not become part of the legend identity. */ -export function changelogConfigToHwKey(configKey: string): string | null { +export function changelogConfigToHwKey( + configKey: string, + benchmarkType?: 'single_turn' | 'agentic_traces', +): string | null { const parts = configKey.toLowerCase().split('-'); const gpu = parts[2]; const remainder = parts.slice(3).join('-'); @@ -32,7 +35,14 @@ export function changelogConfigToHwKey(configKey: string): string | null { if (!framework) return null; const trailingParts = remainder.slice(framework.length).split('-').filter(Boolean); - const specSuffix = trailingParts.includes('mtp') ? '_mtp' : ''; + const isAgentic = trailingParts.includes('agentic'); + if (benchmarkType === 'agentic_traces' && !isAgentic) return null; + const specSuffix = + benchmarkType === 'agentic_traces' && isAgentic + ? '' + : trailingParts.includes('mtp') + ? '_mtp' + : ''; return `${gpu}_${resolveFrameworkAlias(framework)}${specSuffix}`; } @@ -62,8 +72,12 @@ export function formatChangelogDescription(desc: string | string[]) { * Check if a changelog config key matches a hwKey. * Normalizes both to hyphen-separated form for comparison. */ -export function configKeyMatchesHwKey(configKey: string, hwKey: string): boolean { - return changelogConfigToHwKey(configKey) === hwKey; +export function configKeyMatchesHwKey( + configKey: string, + hwKey: string, + benchmarkType?: 'single_turn' | 'agentic_traces', +): boolean { + return changelogConfigToHwKey(configKey, benchmarkType) === hwKey; } export function formatConfigKeys(key: string) { diff --git a/packages/app/src/components/inference/utils/point-identity.test.ts b/packages/app/src/components/inference/utils/point-identity.test.ts new file mode 100644 index 000000000..5f3ee14ae --- /dev/null +++ b/packages/app/src/components/inference/utils/point-identity.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import type { InferenceData } from '@/components/inference/types'; + +import { scatterPointConfigId } from './point-identity'; + +const point = (overrides: Partial): InferenceData => + ({ + hwKey: 'h200_vllm', + precision: 'fp8', + tp: 8, + conc: 32, + ...overrides, + }) as InferenceData; + +describe('scatterPointConfigId', () => { + it('keeps overlapping agentic MTP and standard-decoding points distinct', () => { + const standard = scatterPointConfigId( + point({ benchmark_type: 'agentic_traces', spec_decoding: 'none' }), + ); + const mtp = scatterPointConfigId( + point({ benchmark_type: 'agentic_traces', spec_decoding: 'mtp' }), + ); + + expect(standard).not.toBe(mtp); + expect(standard).toContain('|spec-none'); + expect(mtp).toContain('|spec-mtp'); + }); + + it('keeps agentic offload variants distinct alongside spec methods', () => { + const off = scatterPointConfigId( + point({ + benchmark_type: 'agentic_traces', + spec_decoding: 'mtp', + offload_mode: 'off', + }), + ); + const on = scatterPointConfigId( + point({ + benchmark_type: 'agentic_traces', + spec_decoding: 'mtp', + offload_mode: 'on', + }), + ); + + expect(off).not.toBe(on); + }); +}); diff --git a/packages/app/src/components/inference/utils/point-identity.ts b/packages/app/src/components/inference/utils/point-identity.ts new file mode 100644 index 000000000..f578c8e69 --- /dev/null +++ b/packages/app/src/components/inference/utils/point-identity.ts @@ -0,0 +1,24 @@ +import type { InferenceData } from '@/components/inference/types'; + +interface AgenticSpecIdentity { + benchmark_type?: string; + spec_decoding?: string; +} + +/** Point-level identity suffix for decode methods merged into one agentic curve. */ +export function agenticSpecDecodingKeySuffix(point: AgenticSpecIdentity): string { + return point.benchmark_type === 'agentic_traces' ? `|spec-${point.spec_decoding || 'none'}` : ''; +} + +/** Stable D3 join key for one scatter point within a chart series. */ +export function scatterPointConfigId(point: InferenceData): string { + let key = `${point.hwKey}|${point.precision}|${point.tp}|${point.conc}|${point.decode_ep ?? 0}|${point.prefill_tp ?? 0}|${point.prefill_ep ?? 0}`; + if (point.disagg) { + key += `|disagg|${point.num_prefill_gpu ?? 0}|${point.num_decode_gpu ?? 0}`; + } + if (point.offload_mode) key += `|offload-${point.offload_mode}`; + // Agentic series omit spec decoding from hwKey so one curve can mix methods. + // It remains point identity to avoid collapsing overlapping MTP/STP results. + key += agenticSpecDecodingKeySuffix(point); + return key; +} diff --git a/packages/app/src/components/inference/utils/runEnumeration.test.ts b/packages/app/src/components/inference/utils/runEnumeration.test.ts index 155fae66a..3d92f637c 100644 --- a/packages/app/src/components/inference/utils/runEnumeration.test.ts +++ b/packages/app/src/components/inference/utils/runEnumeration.test.ts @@ -24,6 +24,7 @@ const SCOPE = { modelDbKeys: ['minimaxm3'], selectedGPUs: ['mi300x_vllm'], selectedPrecisions: ['fp8'], + benchmarkType: 'single_turn' as const, }; describe('dataRunsForDate', () => { @@ -66,6 +67,15 @@ describe('dataRunsForDate', () => { expect(runs.map((r) => r.runId)).toEqual(['2']); }); + it('maps agentic MTP and non-MTP run coverage to one GPU series', () => { + const rows = [ + rc({ github_run_id: 1, spec_method: 'none' }), + rc({ github_run_id: 2, spec_method: 'mtp' }), + ]; + const runs = dataRunsForDate(rows, { ...SCOPE, benchmarkType: 'agentic_traces' }); + expect(runs.map((r) => r.runId)).toEqual(['1', '2']); + }); + it('excludes runs for other models, precisions, and GPUs', () => { const rows = [ rc({ github_run_id: 1 }), // matches diff --git a/packages/app/src/components/inference/utils/runEnumeration.ts b/packages/app/src/components/inference/utils/runEnumeration.ts index fda1ae30f..1c84b6f25 100644 --- a/packages/app/src/components/inference/utils/runEnumeration.ts +++ b/packages/app/src/components/inference/utils/runEnumeration.ts @@ -10,9 +10,9 @@ * changelog entry, and that newest run is exactly the one the plain-date "latest" * view shows — so enumerating from changelog entries alone would silently drop it. * - * Runs are scoped to the selected GPUs using the canonical {@link getHardwareKey} - * so MTP and disagg variants (separate hw keys) are kept distinct, exactly as the - * chart keys them. + * Runs are scoped to the selected GPUs using the canonical {@link getHardwareKey}. + * Fixed-sequence MTP variants stay distinct; agentic MTP and non-MTP points map + * to the same series, exactly as the chart keys them. */ import type { AggDataEntry } from '@/components/inference/types'; @@ -37,15 +37,18 @@ export interface RunScope { selectedGPUs: string[]; /** Selected DB precisions, e.g. ['fp8']. */ selectedPrecisions: string[]; + /** Scenario currently rendered by the chart. */ + benchmarkType: 'single_turn' | 'agentic_traces'; } /** The hw key a runConfig maps to, built the same way the chart builds series keys. */ -function runConfigHwKey(rc: RunConfigRow): string { +function runConfigHwKey(rc: RunConfigRow, benchmarkType: 'single_turn' | 'agentic_traces'): string { return getHardwareKey({ hw: rc.hardware, framework: rc.framework, disagg: rc.disagg, spec_decoding: rc.spec_method, + benchmark_type: benchmarkType, } as unknown as AggDataEntry); } @@ -55,7 +58,7 @@ function runConfigHwKey(rc: RunConfigRow): string { * assigns read in the order the runs actually happened. */ export function dataRunsForDate(runConfigs: RunConfigRow[], scope: RunScope): DataRun[] { - const { modelDbKeys, selectedGPUs, selectedPrecisions } = scope; + const { modelDbKeys, selectedGPUs, selectedPrecisions, benchmarkType } = scope; const precSet = new Set(selectedPrecisions); const gpuSet = new Set(selectedGPUs); const byRun = new Map(); @@ -63,7 +66,7 @@ export function dataRunsForDate(runConfigs: RunConfigRow[], scope: RunScope): Da for (const rc of runConfigs) { if (!modelDbKeys.includes(rc.model)) continue; if (!precSet.has(rc.precision)) continue; - if (!gpuSet.has(runConfigHwKey(rc))) continue; + if (!gpuSet.has(runConfigHwKey(rc, benchmarkType))) continue; const id = String(rc.github_run_id); if (!byRun.has(id)) { diff --git a/packages/app/src/components/inference/utils/tooltip-utils.test.ts b/packages/app/src/components/inference/utils/tooltip-utils.test.ts index bc254e02b..7edc1268f 100644 --- a/packages/app/src/components/inference/utils/tooltip-utils.test.ts +++ b/packages/app/src/components/inference/utils/tooltip-utils.test.ts @@ -473,6 +473,23 @@ describe('generateOverlayTooltipContent', () => { expect(html).toContain('CPU Cache Hit Rate: 42.0%'); }); + it('shows point-level speculative decoding for mixed agentic overlays', () => { + const mtp = generateOverlayTooltipContent( + overlayConfig({ + data: pt({ benchmark_type: 'agentic_traces', spec_decoding: 'mtp' }), + }), + ); + const standardZh = generateOverlayTooltipContent( + overlayConfig({ + data: pt({ benchmark_type: 'agentic_traces', spec_decoding: 'none' }), + locale: 'zh', + }), + ); + + expect(mtp).toContain('Speculative Decoding: MTP'); + expect(standardZh).toContain('投机解码: 关闭'); + }); + it('hides stale CPU cache hits for unofficial overlays without offload', () => { const html = generateOverlayTooltipContent( overlayConfig({ diff --git a/packages/app/src/components/inference/utils/tooltipUtils.ts b/packages/app/src/components/inference/utils/tooltipUtils.ts index ae5d89267..eff4f0e29 100644 --- a/packages/app/src/components/inference/utils/tooltipUtils.ts +++ b/packages/app/src/components/inference/utils/tooltipUtils.ts @@ -1,4 +1,5 @@ import { formatNumber, getDisplayLabel } from '@/lib/utils'; +import { specMethodDisplayLabel } from '@/lib/compare-variant-slug'; import { isPersistedBenchmarkId } from '@/lib/benchmark-id'; import type { Locale } from '@/lib/i18n'; import { isKvOffloadEnabled } from '@/lib/kv-offload'; @@ -170,10 +171,25 @@ const generateCacheMetadataHTML = (d: InferenceData, locale: Locale): string => * Agentic-only request success and token totals. Cache metadata is rendered * separately because fixed-sequence rows can carry it too. */ -const generateAgenticHTML = (d: InferenceData): string => { +const AGENTIC_STRINGS = { + en: { speculativeDecoding: 'Speculative Decoding', off: 'Off' }, + zh: { speculativeDecoding: '投机解码', off: '关闭' }, +} as const; + +const generateAgenticHTML = (d: InferenceData, locale: Locale): string => { if (d.benchmark_type !== 'agentic_traces') return ''; + const t = AGENTIC_STRINGS[locale]; const parts: string[] = []; + const specMethod = d.spec_decoding ?? 'none'; + parts.push( + tooltipLine( + t.speculativeDecoding, + specMethod === 'none' || specMethod === '' + ? t.off + : specMethodDisplayLabel(d.model, specMethod), + ), + ); if (d.num_requests_total !== undefined && d.num_requests_successful !== undefined) { const successPct = @@ -368,7 +384,7 @@ export const generateTooltipContent = (config: TooltipConfig): string => { Precision: ${d.precision.toUpperCase()} ${generateCacheMetadataHTML(d, locale)} - ${generateAgenticHTML(d)} + ${generateAgenticHTML(d, locale)} ${runLinkHTML(runUrl)} ${viewChartsButtonHTML(isPinned, Boolean(hasTrace), d.id)} @@ -419,7 +435,7 @@ export const generateOverlayTooltipContent = (config: OverlayTooltipConfig): str Precision: ${d.precision.toUpperCase()} ${generateCacheMetadataHTML(d, locale)} - ${generateAgenticHTML(d)} + ${generateAgenticHTML(d, locale)} `; }; @@ -490,7 +506,7 @@ export const generateGPUGraphTooltipContent = (config: TooltipConfig): string => Precision: ${d.precision.toUpperCase()} ${generateCacheMetadataHTML(d, locale)} - ${generateAgenticHTML(d)} + ${generateAgenticHTML(d, locale)} ${runLinkHTML(runUrl)} ${viewChartsButtonHTML(isPinned, Boolean(hasTrace), d.id)} diff --git a/packages/app/src/hooks/api/use-ai-chart.ts b/packages/app/src/hooks/api/use-ai-chart.ts index 55d2597ce..92783463a 100644 --- a/packages/app/src/hooks/api/use-ai-chart.ts +++ b/packages/app/src/hooks/api/use-ai-chart.ts @@ -22,6 +22,10 @@ import { type ReliabilityRow, } from '@/lib/api'; import { transformBenchmarkRows } from '@/lib/benchmark-transform'; +import { + dedupeAgenticHistoryRuns, + dedupeRowsToLatestPerConfig, +} from '@/lib/benchmark-run-selection'; import { getNestedYValue, normalizeEvalHardwareKey, @@ -391,11 +395,24 @@ async function resolveSpec(spec: AiChartSpec): Promise { } // Benchmarks or History - const { isl, osl } = sequenceToIslOsl(spec.sequence); - const rows = + const isAgentic = spec.sequence === 'agentic-traces'; + const { isl, osl } = isAgentic ? { isl: 0, osl: 0 } : sequenceToIslOsl(spec.sequence); + const fetchedRows = spec.dataSource === 'history' - ? await fetchBenchmarkHistory(spec.model, isl, osl) + ? await fetchBenchmarkHistory( + spec.model, + isl, + osl, + undefined, + isAgentic ? 'agentic_traces' : undefined, + ) : await fetchBenchmarks(spec.model); + const rows = + spec.dataSource === 'history' && isAgentic + ? dedupeAgenticHistoryRuns(fetchedRows) + : isAgentic + ? dedupeRowsToLatestPerConfig(fetchedRows) + : fetchedRows; const { chartData } = transformBenchmarkRows(rows); let points = chartData[0] ?? []; diff --git a/packages/app/src/hooks/api/use-benchmark-history.ts b/packages/app/src/hooks/api/use-benchmark-history.ts index 4d4378172..971919d19 100644 --- a/packages/app/src/hooks/api/use-benchmark-history.ts +++ b/packages/app/src/hooks/api/use-benchmark-history.ts @@ -2,10 +2,17 @@ import { useQuery } from '@tanstack/react-query'; import { fetchBenchmarkHistory } from '@/lib/api'; -export function useBenchmarkHistory(model: string, isl: number, osl: number) { +export function useBenchmarkHistory( + model: string, + isl: number, + osl: number, + benchmarkType?: 'agentic_traces', +) { return useQuery({ - queryKey: ['benchmark-history', model, isl, osl], - queryFn: ({ signal }) => fetchBenchmarkHistory(model, isl, osl, signal), - enabled: Boolean(model && isl && osl), + queryKey: benchmarkType + ? ['benchmark-history', model, isl, osl, benchmarkType] + : ['benchmark-history', model, isl, osl], + queryFn: ({ signal }) => fetchBenchmarkHistory(model, isl, osl, signal, benchmarkType), + enabled: Boolean(model && (benchmarkType === 'agentic_traces' || (isl && osl))), }); } diff --git a/packages/app/src/hooks/api/use-comparison-changelogs.ts b/packages/app/src/hooks/api/use-comparison-changelogs.ts index 01d60ad3e..b56976140 100644 --- a/packages/app/src/hooks/api/use-comparison-changelogs.ts +++ b/packages/app/src/hooks/api/use-comparison-changelogs.ts @@ -43,6 +43,7 @@ export function useComparisonChangelogs( selectedGPUs: string[], selectedDateRange: { startDate: string; endDate: string }, availableDates: string[], + benchmarkType?: 'agentic_traces', ) { const hasGPUs = selectedGPUs.length > 0; const hasDateRange = Boolean(selectedDateRange.startDate) && Boolean(selectedDateRange.endDate); @@ -64,8 +65,9 @@ export function useComparisonChangelogs( const queries = useQueries({ queries: datesToQuery.map((date) => ({ - queryKey: ['workflow-info', date], - queryFn: ({ signal }: { signal: AbortSignal }) => fetchWorkflowInfo(date, signal), + queryKey: benchmarkType ? ['workflow-info', date, benchmarkType] : ['workflow-info', date], + queryFn: ({ signal }: { signal: AbortSignal }) => + fetchWorkflowInfo(date, signal, benchmarkType), enabled: hasGPUs, })), }); diff --git a/packages/app/src/lib/agentic-workflow-metadata.test.ts b/packages/app/src/lib/agentic-workflow-metadata.test.ts new file mode 100644 index 000000000..d0d677872 --- /dev/null +++ b/packages/app/src/lib/agentic-workflow-metadata.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { agenticWorkflowMetadataOnly } from './agentic-workflow-metadata'; + +describe('agenticWorkflowMetadataOnly', () => { + it('preserves workflow metadata for agentic rows', () => { + const [row] = agenticWorkflowMetadataOnly([ + { + benchmark_type: 'agentic_traces', + workflow_run_id: 42, + run_started_at: '2026-08-12T10:00:00Z', + }, + ]); + expect(row.workflow_run_id).toBe(42); + expect(row.run_started_at).toBe('2026-08-12T10:00:00Z'); + }); + + it('preserves the prior fixed-sequence response shape', () => { + const [row] = agenticWorkflowMetadataOnly([ + { + benchmark_type: 'single_turn', + workflow_run_id: 42, + run_started_at: '2026-08-12T10:00:00Z', + }, + ]); + expect(row).toEqual({ benchmark_type: 'single_turn' }); + expect('workflow_run_id' in row).toBe(false); + expect('run_started_at' in row).toBe(false); + }); +}); diff --git a/packages/app/src/lib/agentic-workflow-metadata.ts b/packages/app/src/lib/agentic-workflow-metadata.ts new file mode 100644 index 000000000..9c605fcbd --- /dev/null +++ b/packages/app/src/lib/agentic-workflow-metadata.ts @@ -0,0 +1,14 @@ +interface WorkflowMetadataRow { + benchmark_type: string; + workflow_run_id?: number; + run_started_at?: string | null; +} + +/** Keep workflow identity internal to agentic rows; fixed API rows retain their prior shape. */ +export function agenticWorkflowMetadataOnly(rows: T[]): T[] { + return rows.map((row) => { + if (row.benchmark_type === 'agentic_traces') return row; + const { workflow_run_id: _workflowRunId, run_started_at: _runStartedAt, ...fixedRow } = row; + return fixedRow as T; + }); +} diff --git a/packages/app/src/lib/api-documentation.ts b/packages/app/src/lib/api-documentation.ts index 793a42d2d..5167a85da 100644 --- a/packages/app/src/lib/api-documentation.ts +++ b/packages/app/src/lib/api-documentation.ts @@ -234,9 +234,11 @@ const benchmarkRowSchema = objectSchemaWithOptional( metrics: metricMapSchema, workers: arraySchema(workerPowerSchema), date: { type: 'string', format: 'date' }, + workflow_run_id: integerSchema, + run_started_at: { type: ['string', 'null'], format: 'date-time' }, run_url: nullableStringSchema, }, - ['workers'], + ['workers', 'workflow_run_id', 'run_started_at'], ); const benchmarkRowsSchema = arraySchema(benchmarkRowSchema); const benchmarkExample = [ @@ -590,6 +592,16 @@ export const apiOperations: readonly ApiOperation[] = [ { type: 'string', format: 'date' }, '2026-08-08', ), + parameter( + 'benchmarkType', + 'query', + false, + 'string', + 'Set to agentic_traces to scope per-run configuration coverage to Agentic Traces.', + '设为 agentic_traces 可将每次运行的配置覆盖限定为 Agentic Traces。', + { type: 'string', enum: ['agentic_traces'] }, + 'agentic_traces', + ), parameter( 'exact', 'query', @@ -651,8 +663,8 @@ export const apiOperations: readonly ApiOperation[] = [ path: '/api/v1/benchmarks/history', summary: text('Read benchmark history', '读取基准历史'), description: text( - 'Returns every dated benchmark row for one model and fixed input/output token pair.', - '返回某个模型和固定输入/输出 token 组合的全部历史基准行。', + 'Returns every dated benchmark row for one model and either a fixed input/output token pair or Agentic Traces.', + '返回某个模型以及固定输入/输出 token 组合或 Agentic Traces 的全部历史基准行。', ), audience: 'public', stability: 'stable', @@ -670,23 +682,33 @@ export const apiOperations: readonly ApiOperation[] = [ parameter( 'isl', 'query', - true, + false, 'integer', - 'Positive input sequence length in tokens.', - '正整数输入序列长度,单位为 token。', + 'Positive input sequence length in tokens. Required unless benchmarkType=agentic_traces.', + '正整数输入序列长度,单位为 token;除非 benchmarkType=agentic_traces,否则必填。', positiveIdSchema, 1024, ), parameter( 'osl', 'query', - true, + false, 'integer', - 'Positive output sequence length in tokens.', - '正整数输出序列长度,单位为 token。', + 'Positive output sequence length in tokens. Required unless benchmarkType=agentic_traces.', + '正整数输出序列长度,单位为 token;除非 benchmarkType=agentic_traces,否则必填。', positiveIdSchema, 1024, ), + parameter( + 'benchmarkType', + 'query', + false, + 'string', + 'Set to agentic_traces to read Agentic Traces history without ISL/OSL.', + '设为 agentic_traces 可在不提供 ISL/OSL 的情况下读取 Agentic Traces 历史。', + { type: 'string', enum: ['agentic_traces'] }, + 'agentic_traces', + ), ], responses: [ success('Historical benchmark rows.', '历史基准行。', benchmarkRowsSchema, benchmarkExample), diff --git a/packages/app/src/lib/api-route-catalog.ts b/packages/app/src/lib/api-route-catalog.ts index a557dd7f7..07dadbf19 100644 --- a/packages/app/src/lib/api-route-catalog.ts +++ b/packages/app/src/lib/api-route-catalog.ts @@ -108,7 +108,7 @@ export const apiRouteCatalog = [ method: 'GET', classification: 'published-read', operationId: 'list-benchmarks', - sourceSha256: 'daf24b2a08ab021782084fc6e5e145013fca518e58d18837355a088e040f257b', + sourceSha256: '37b5a31613a9c5a2e1de35758551dfdbbb8b920fcd6ae6baeedf973c8802bc2c', }, { source: 'src/app/api/v1/benchmarks/history/route.ts', @@ -116,7 +116,7 @@ export const apiRouteCatalog = [ method: 'GET', classification: 'published-read', operationId: 'list-benchmark-history', - sourceSha256: 'f99940aabe91d315e33e0e645342d83ace227fc014de029d16868284e158be40', + sourceSha256: 'd4b3d2ad8ed6e35df70c6b651f71c9d86b6e1dad9c3eaf6eadc2a8591318d1fb', }, { source: 'src/app/api/v1/collectivex/latest/route.ts', @@ -353,7 +353,7 @@ export const apiRouteCatalog = [ method: 'GET', classification: 'published-read', operationId: 'get-workflow-info', - sourceSha256: 'ec497811abc666e4bf970b6e722fde3a7fefc25a87d6b4e5897a605e22c84ee8', + sourceSha256: 'b7b0f215e9bf2c766ce2d4c9a13090504c1704a67f697af3a2fd1f318de1e6d6', }, ] as const satisfies readonly ApiRouteCatalogEntry[]; @@ -380,7 +380,7 @@ export const apiContractSourceDigests = [ }, { source: 'src/lib/api.ts', - sourceSha256: '7824444f73bd1331ad06cc62060de08446f9d20661fa77030a254d6125b3636a', + sourceSha256: '03809377af6c2ee938169a065e06dccb25d983d7171a54b998ffa08dd970d306', reviewArea: { en: 'Public API client parameter serialization and TypeScript response contracts.', zh: '公开 API 客户端的参数序列化和 TypeScript 响应契约。', @@ -436,7 +436,7 @@ export const apiContractSourceDigests = [ }, { source: '../db/src/queries/benchmarks.ts', - sourceSha256: '05e97742b831d1afdcb8dec1d764fd9b3d4a54564e93da4ceea778f8533c5c49', + sourceSha256: 'bb2f2cd28d8e4cea7b556561235da43d6a33363dc2c6d1f0b13408193d04298e', reviewArea: { en: 'Benchmark row fields and latest, exact-run, history, and TCO query semantics.', zh: '基准行字段以及最新、精确运行、历史和 TCO 查询语义。', @@ -532,7 +532,7 @@ export const apiContractSourceDigests = [ }, { source: '../db/src/queries/workflow-info.ts', - sourceSha256: 'a588811952d152ecb5ca3725e565f3f829c7bbdfd14a795b261a11bfa82fe00a', + sourceSha256: 'b6611604d41ab69c00cb804ad255d4cbc9f70e41e84ad43330538558956f9eb6', reviewArea: { en: 'Availability rows plus workflow runs, changelogs, configurations, and run coverage responses.', zh: '可用配置行以及工作流运行、变更记录、配置和运行覆盖响应。', diff --git a/packages/app/src/lib/api.ts b/packages/app/src/lib/api.ts index 00fea7219..0b2a03f71 100644 --- a/packages/app/src/lib/api.ts +++ b/packages/app/src/lib/api.ts @@ -52,6 +52,9 @@ export interface BenchmarkRow { */ workers?: WorkerPower[]; date: string; + /** Internal workflow identity used to keep merged agentic curves within one run. */ + workflow_run_id?: number; + run_started_at?: string | null; run_url: string | null; } @@ -179,16 +182,21 @@ export function fetchBenchmarkHistory( isl: number, osl: number, signal?: AbortSignal, + benchmarkType?: 'agentic_traces', ) { const params = new URLSearchParams({ model, isl: String(isl), osl: String(osl) }); + if (benchmarkType) params.set('benchmarkType', benchmarkType); return fetchJson(`/api/v1/benchmarks/history?${params}`, signal); } -export function fetchWorkflowInfo(date: string, signal?: AbortSignal) { - return fetchJson( - `/api/v1/workflow-info?date=${encodeURIComponent(date)}`, - signal, - ); +export function fetchWorkflowInfo( + date: string, + signal?: AbortSignal, + benchmarkType?: 'agentic_traces', +) { + const params = new URLSearchParams({ date }); + if (benchmarkType) params.set('benchmarkType', benchmarkType); + return fetchJson(`/api/v1/workflow-info?${params}`, signal); } export interface AvailabilityRow { diff --git a/packages/app/src/lib/benchmark-data.server.ts b/packages/app/src/lib/benchmark-data.server.ts index e288596c2..c3dc23ca5 100644 --- a/packages/app/src/lib/benchmark-data.server.ts +++ b/packages/app/src/lib/benchmark-data.server.ts @@ -5,6 +5,7 @@ import { } from '@semianalysisai/inferencex-db/queries/benchmarks'; import { cachedQuery } from '@/lib/api-cache'; +import { agenticWorkflowMetadataOnly } from '@/lib/agentic-workflow-metadata'; import { loadFixture } from '@/lib/test-fixtures'; /** Cache slot is keyed on the dbKeys array. Both `/compare/` and @@ -14,9 +15,9 @@ export const getCachedBenchmarks = cachedQuery( (dbModelKeys: string[]) => { if (FIXTURES_MODE) return Promise.resolve(loadFixture('benchmarks')); - return getLatestBenchmarks(getDb(), dbModelKeys); + return getLatestBenchmarks(getDb(), dbModelKeys).then(agenticWorkflowMetadataOnly); }, - 'benchmarks', + 'benchmarks-agentic-run-metadata', { blobOnly: true }, ); @@ -26,8 +27,8 @@ export const getCachedBenchmarksAsOf = cachedQuery( (dbModelKeys: string[], date: string) => { if (FIXTURES_MODE) return Promise.resolve(loadFixture('benchmarks')); - return getLatestBenchmarks(getDb(), dbModelKeys, date); + return getLatestBenchmarks(getDb(), dbModelKeys, date).then(agenticWorkflowMetadataOnly); }, - 'benchmarks-as-of', + 'benchmarks-as-of-agentic-run-metadata', { blobOnly: true }, ); diff --git a/packages/app/src/lib/benchmark-run-selection.ts b/packages/app/src/lib/benchmark-run-selection.ts new file mode 100644 index 000000000..f7332b96c --- /dev/null +++ b/packages/app/src/lib/benchmark-run-selection.ts @@ -0,0 +1,76 @@ +/** Fields needed to select one workflow run for a rendered benchmark series. */ +export interface BenchmarkSeriesRow { + hardware: string; + framework: string; + spec_method: string; + disagg: boolean; + precision: string; + offload_mode?: string | null; + benchmark_type?: string; + date: string; + workflow_run_id?: number; + run_started_at?: string | null; +} + +const seriesKey = (row: BenchmarkSeriesRow): string => { + const specMethod = row.benchmark_type === 'agentic_traces' ? '' : row.spec_method; + return `${row.hardware}|${row.framework}|${specMethod}|${row.disagg}|${row.precision}|${row.offload_mode ?? 'off'}`; +}; + +function isLaterRun(candidate: BenchmarkSeriesRow, current: BenchmarkSeriesRow): boolean { + const startedAt = candidate.run_started_at ?? ''; + const currentStartedAt = current.run_started_at ?? ''; + return ( + startedAt > currentStartedAt || + (startedAt === currentStartedAt && + (candidate.workflow_run_id ?? Number.NEGATIVE_INFINITY) > + (current.workflow_run_id ?? Number.NEGATIVE_INFINITY)) + ); +} + +function isWinningRun(row: BenchmarkSeriesRow, winner: BenchmarkSeriesRow): boolean { + return ( + row.run_started_at === winner.run_started_at && row.workflow_run_id === winner.workflow_run_id + ); +} + +/** Keep only the newest date for each chart series and, for agentic, one workflow run. */ +export function dedupeRowsToLatestPerConfig(rows: T[]): T[] { + const winnerPerGroup = new Map(); + for (const row of rows) { + const key = seriesKey(row); + const current = winnerPerGroup.get(key); + if (!current || row.date > current.date) { + winnerPerGroup.set(key, row); + continue; + } + if ( + row.date === current.date && + row.benchmark_type === 'agentic_traces' && + isLaterRun(row, current) + ) { + winnerPerGroup.set(key, row); + } + } + return rows.filter((row) => { + const winner = winnerPerGroup.get(seriesKey(row)); + if (!winner || row.date !== winner.date) return false; + return row.benchmark_type !== 'agentic_traces' || isWinningRun(row, winner); + }); +} + +/** For historical views, keep one agentic workflow run per series on each calendar date. */ +export function dedupeAgenticHistoryRuns(rows: T[]): T[] { + const winnerPerDateAndSeries = new Map(); + for (const row of rows) { + if (row.benchmark_type !== 'agentic_traces') continue; + const key = `${row.date}|${seriesKey(row)}`; + const current = winnerPerDateAndSeries.get(key); + if (!current || isLaterRun(row, current)) winnerPerDateAndSeries.set(key, row); + } + return rows.filter((row) => { + if (row.benchmark_type !== 'agentic_traces') return true; + const winner = winnerPerDateAndSeries.get(`${row.date}|${seriesKey(row)}`); + return winner !== undefined && isWinningRun(row, winner); + }); +} diff --git a/packages/app/src/lib/benchmark-transform.test.ts b/packages/app/src/lib/benchmark-transform.test.ts index 95ff1958e..35f1eb7ef 100644 --- a/packages/app/src/lib/benchmark-transform.test.ts +++ b/packages/app/src/lib/benchmark-transform.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { getPointLabel } from '@/components/inference/utils/tooltipUtils'; import type { BenchmarkRow } from '@/lib/api'; +import { dedupeAgenticHistoryRuns } from '@/lib/benchmark-run-selection'; import { mergeRunScopedRows, @@ -739,6 +740,89 @@ describe('transformBenchmarkRows — hardware key resolution', () => { expect(hardwareConfig).toHaveProperty('h200_trt_mtp'); }); + it('groups none, MTP, and EAGLE agentic points into one hardware series', () => { + const rows = [ + makeRow({ + benchmark_type: 'agentic_traces', + hardware: 'h200', + framework: 'trt', + spec_method: 'none', + }), + makeRow({ + benchmark_type: 'agentic_traces', + hardware: 'h200', + framework: 'trt', + spec_method: 'mtp', + }), + makeRow({ + benchmark_type: 'agentic_traces', + hardware: 'h200', + framework: 'trt', + spec_method: 'eagle', + }), + ]; + + const { chartData, hardwareConfig } = transformBenchmarkRows(rows); + expect(Object.keys(hardwareConfig)).toEqual(['h200_trt']); + expect(chartData[0].map((point) => point.hwKey)).toEqual(['h200_trt', 'h200_trt', 'h200_trt']); + expect(chartData[0].map((point) => point.spec_decoding)).toEqual(['none', 'mtp', 'eagle']); + }); + + it('keeps one mixed-spec agentic series identity across comparison dates', () => { + const rows = dedupeAgenticHistoryRuns([ + makeRow({ + id: 1, + benchmark_type: 'agentic_traces', + spec_method: 'none', + conc: 8, + date: '2026-06-01', + workflow_run_id: 30, + run_started_at: '2026-06-01T10:00:00Z', + }), + makeRow({ + id: 2, + benchmark_type: 'agentic_traces', + spec_method: 'mtp', + conc: 16, + date: '2026-06-01', + workflow_run_id: 30, + run_started_at: '2026-06-01T10:00:00Z', + }), + makeRow({ + id: 3, + benchmark_type: 'agentic_traces', + spec_method: 'eagle', + conc: 32, + date: '2026-06-02', + workflow_run_id: 31, + run_started_at: '2026-06-02T10:00:00Z', + }), + makeRow({ + id: 4, + benchmark_type: 'agentic_traces', + spec_method: 'none', + conc: 64, + date: '2026-06-02', + workflow_run_id: 31, + run_started_at: '2026-06-02T10:00:00Z', + }), + ]); + + const { chartData, hardwareConfig } = transformBenchmarkRows(rows); + const pointsByDate = Object.groupBy(chartData[0], (point) => point.date); + + expect(Object.keys(hardwareConfig)).toEqual(['h200_trt']); + expect(pointsByDate['2026-06-01']?.map((point) => point.spec_decoding)).toEqual([ + 'none', + 'mtp', + ]); + expect(pointsByDate['2026-06-02']?.map((point) => point.spec_decoding)).toEqual([ + 'eagle', + 'none', + ]); + expect(new Set(chartData[0].map((point) => point.hwKey))).toEqual(new Set(['h200_trt'])); + }); + it('handles AMD hardware with vllm framework', () => { const rows = [makeRow({ hardware: 'mi300x', framework: 'vllm' })]; const { chartData, hardwareConfig } = transformBenchmarkRows(rows); diff --git a/packages/app/src/lib/chart-utils.test.ts b/packages/app/src/lib/chart-utils.test.ts index cfde6deda..b5d667cba 100644 --- a/packages/app/src/lib/chart-utils.test.ts +++ b/packages/app/src/lib/chart-utils.test.ts @@ -256,6 +256,15 @@ describe('buildAvailabilityHwKey', () => { expect(buildAvailabilityHwKey('h200', undefined, 'mtp')).toBe('h200_mtp'); }); + it('omits speculative decoding from agentic availability series keys', () => { + expect(buildAvailabilityHwKey('h200', 'sglang', 'mtp', false, 'agentic_traces')).toBe( + 'h200_sglang', + ); + expect(buildAvailabilityHwKey('h200', 'sglang', 'eagle', false, 'agentic_traces')).toBe( + 'h200_sglang', + ); + }); + it('handles undefined framework and spec method', () => { expect(buildAvailabilityHwKey('b200', undefined, undefined)).toBe('b200'); }); @@ -921,6 +930,13 @@ describe('getHardwareKey', () => { ); }); + it('omits speculative decoding from agentic series identity', () => { + const base = { hw: 'h100-sxm', framework: 'vllm', benchmark_type: 'agentic_traces' }; + expect(getHardwareKey(entry({ ...base, spec_decoding: 'none' }))).toBe('h100_vllm'); + expect(getHardwareKey(entry({ ...base, spec_decoding: 'mtp' }))).toBe('h100_vllm'); + expect(getHardwareKey(entry({ ...base, spec_decoding: 'eagle' }))).toBe('h100_vllm'); + }); + it('appends spec_decoding suffix when not "none" and not "mtp"', () => { expect(getHardwareKey(entry({ hw: 'b200-sxm', framework: '', spec_decoding: 'eagle' }))).toBe( 'b200_eagle', diff --git a/packages/app/src/lib/chart-utils.ts b/packages/app/src/lib/chart-utils.ts index e9b792348..087b3a114 100644 --- a/packages/app/src/lib/chart-utils.ts +++ b/packages/app/src/lib/chart-utils.ts @@ -196,7 +196,12 @@ export const Y_AXIS_METRICS = [ export type YAxisMetric = (typeof Y_AXIS_METRICS)[number]; /** - * Determines the correct hardware key based on the hardware name and MTP status. + * Determines the chart-series hardware key. + * + * Fixed-sequence curves keep speculative decoding in their identity. Agentic + * curves deliberately do not: one production curve may choose a speculative + * method for some load points and standard decoding for others. The point + * still carries `spec_decoding` for filters, tooltips, and point-level keys. */ export const getHardwareKey = (entry: AggDataEntry): string => { let normalizedHwName = entry.hw.split('-')[0]; @@ -215,10 +220,12 @@ export const getHardwareKey = (entry: AggDataEntry): string => { normalizedHwName = candidateDirect; } } - if (entry.mtp === 'on' || entry['spec_decoding'] === 'mtp') { - normalizedHwName = `${normalizedHwName}_mtp`; - } else if (entry['spec_decoding'] && entry['spec_decoding'] !== 'none') { - normalizedHwName = `${normalizedHwName}_${entry['spec_decoding']}`; + if (entry.benchmark_type !== 'agentic_traces') { + if (entry.mtp === 'on' || entry['spec_decoding'] === 'mtp') { + normalizedHwName = `${normalizedHwName}_mtp`; + } else if (entry['spec_decoding'] && entry['spec_decoding'] !== 'none') { + normalizedHwName = `${normalizedHwName}_${entry['spec_decoding']}`; + } } return normalizedHwName; }; @@ -270,6 +277,7 @@ export function buildAvailabilityHwKey( framework?: string, specMethod?: string, disagg?: boolean, + benchmarkType?: string, ): string { let hwKey = hardware.split('-')[0]; const fw = framework ? resolveFrameworkAlias(framework) : undefined; @@ -285,8 +293,10 @@ export function buildAvailabilityHwKey( hwKey = candidateDirect; } } - if (specMethod === 'mtp') hwKey = `${hwKey}_mtp`; - else if (specMethod && specMethod !== 'none') hwKey = `${hwKey}_${specMethod}`; + if (benchmarkType !== 'agentic_traces') { + if (specMethod === 'mtp') hwKey = `${hwKey}_mtp`; + else if (specMethod && specMethod !== 'none') hwKey = `${hwKey}_${specMethod}`; + } return hwKey; } diff --git a/packages/app/src/lib/default-precisions.test.ts b/packages/app/src/lib/default-precisions.test.ts index 9d8a72cac..0f1140a41 100644 --- a/packages/app/src/lib/default-precisions.test.ts +++ b/packages/app/src/lib/default-precisions.test.ts @@ -33,6 +33,24 @@ describe('countCurvesByPrecision', () => { it('returns {} for no rows', () => { expect(countCurvesByPrecision([])).toEqual({}); }); + + it('counts mixed agentic spec methods as one rendered curve', () => { + const base = { ...row('fp8', 'b200'), benchmark_type: 'agentic_traces' }; + const counts = countCurvesByPrecision([ + base, + { ...base, spec_method: 'mtp' }, + { ...base, spec_method: 'eagle' }, + ]); + + expect(counts).toEqual({ fp8: 1 }); + }); + + it('continues counting fixed-sequence spec methods as separate curves', () => { + const base = { ...row('fp8', 'b200'), benchmark_type: 'single_turn' }; + const counts = countCurvesByPrecision([base, { ...base, spec_method: 'mtp' }]); + + expect(counts).toEqual({ fp8: 2 }); + }); }); describe('pickDefaultPrecisions', () => { diff --git a/packages/app/src/lib/default-precisions.ts b/packages/app/src/lib/default-precisions.ts index 71c493868..85fd3d2fe 100644 --- a/packages/app/src/lib/default-precisions.ts +++ b/packages/app/src/lib/default-precisions.ts @@ -8,8 +8,9 @@ import { Precision, PRECISION_OPTIONS } from './data-mappings'; * showing a near-empty chart on first load. Instead we default to whichever * precision has the most data, with a guard against committing to a sparse one. * - * "Curves" = distinct (hardware, framework, spec_method, disagg) series that - * would render for a precision — i.e. how many lines the chart draws. + * "Curves" = distinct chart series that would render for a precision. Fixed- + * sequence series include `spec_method`; agentic series do not because one line + * can contain points from multiple speculative-decoding methods. */ /** @@ -26,6 +27,7 @@ interface CurveRow { framework: string; spec_method: string; disagg: boolean; + benchmark_type?: string; } /** Count distinct curves per precision from already-filtered rows (model + sequence). */ @@ -37,7 +39,8 @@ export function countCurvesByPrecision(rows: CurveRow[]): Record curves = new Set(); seen.set(r.precision, curves); } - curves.add(`${r.hardware}|${r.framework}|${r.spec_method}|${r.disagg}`); + const specMethod = r.benchmark_type === 'agentic_traces' ? '' : r.spec_method; + curves.add(`${r.hardware}|${r.framework}|${specMethod}|${r.disagg}`); } const counts: Record = {}; for (const [precision, curves] of seen) counts[precision] = curves.size; diff --git a/packages/app/src/lib/overview-data.test.ts b/packages/app/src/lib/overview-data.test.ts index 43d3ea64b..98fd146db 100644 --- a/packages/app/src/lib/overview-data.test.ts +++ b/packages/app/src/lib/overview-data.test.ts @@ -1119,6 +1119,33 @@ describe('overview platform selection', () => { ); }); + it('builds one AgentX frontier from mixed standard and MTP points', () => { + const summary = buildOverviewModelSummary(Model.GLM_5_2, [ + agenticRow(40, 30, 12600, 1200, { + hardware: 'b200', + conc: 8, + spec_method: 'none', + }), + agenticRow(50, 25, 10800, 850, { + hardware: 'b200', + conc: 12, + spec_method: 'mtp', + }), + agenticRow(60, 20, 9000, 800, { + hardware: 'b200', + conc: 16, + spec_method: 'none', + }), + ]); + + const config = summary.platforms.find(({ hardware }) => hardware === 'b200')?.read.config; + expect(config).toMatchObject({ + specMethod: 'mixed', + specLabel: 'STP + MTP', + hwKey: 'b200_sglang', + }); + }); + it('restricts AgentX points to the E2E frontier on total throughput', () => { // The slower-E2E point wins on output tokens but loses on total tokens, so // the total-token frontier drops it and the tier read becomes unreachable. diff --git a/packages/app/src/lib/overview-data.ts b/packages/app/src/lib/overview-data.ts index 1bedd5597..51af391d1 100644 --- a/packages/app/src/lib/overview-data.ts +++ b/packages/app/src/lib/overview-data.ts @@ -330,15 +330,16 @@ function overviewScenarioRows( export type OverviewServingSeriesRow = Pick< BenchmarkRow, 'model' | 'hardware' | 'framework' | 'spec_method' | 'precision' | 'disagg' | 'is_multinode' -> & { offload_mode?: string | null }; +> & { offload_mode?: string | null; benchmark_type?: string }; /** Stable identity for one Overview serving envelope across topology points. */ export function overviewServingSeriesKey(row: OverviewServingSeriesRow): string { + const specMethod = row.benchmark_type === 'agentic_traces' ? '' : row.spec_method; return JSON.stringify([ row.model, row.hardware, row.framework, - row.spec_method, + specMethod, row.precision, row.disagg, row.is_multinode, @@ -367,7 +368,19 @@ function buildConfigs( (latest, row) => (row.date > latest ? row.date : latest), configRows[0].date, ); - const latestRows = configRows.filter((row) => row.date === latestDate); + let latestRows = configRows.filter((row) => row.date === latestDate); + if (scenario === 'agentx' && latestRows.some((row) => row.workflow_run_id !== undefined)) { + const winningRow = latestRows.reduce((winner, row) => { + const startedAt = row.run_started_at ?? ''; + const winnerStartedAt = winner.run_started_at ?? ''; + if (startedAt !== winnerStartedAt) return startedAt > winnerStartedAt ? row : winner; + return (row.workflow_run_id ?? Number.NEGATIVE_INFINITY) > + (winner.workflow_run_id ?? Number.NEGATIVE_INFINITY) + ? row + : winner; + }); + latestRows = latestRows.filter((row) => row.workflow_run_id === winningRow.workflow_run_id); + } const config = buildConfigResult(model, scenario, latestRows[0].precision, key, latestRows); if (config) configs.push(config); } @@ -638,7 +651,17 @@ function buildConfigResult( if (feed.length === 0) return null; const first = rows[0]; - const { hardware, framework, spec_method: specMethod, disagg, is_multinode: isMultinode } = first; + const { hardware, framework, disagg, is_multinode: isMultinode } = first; + const specMethods = [...new Set(rows.map((row) => row.spec_method))]; + const specMethod = specMethods.length === 1 ? specMethods[0] : 'mixed'; + const specLabel = + specMethod === 'mixed' + ? specMethods + .map((method) => + method === 'none' || method === '' ? 'STP' : resolveFrameworkPartLabel(model, method), + ) + .join(' + ') + : resolveFrameworkPartLabel(model, specMethod); const sourceRunUrls = [ ...new Set(rows.flatMap((row) => (row.run_url === null ? [] : [row.run_url]))), ].toSorted(); @@ -646,11 +669,17 @@ function buildConfigResult( key, dbModel: first.model, hardware, - hwKey: buildAvailabilityHwKey(hardware, framework, specMethod, disagg), + hwKey: buildAvailabilityHwKey( + hardware, + framework, + specMethod, + disagg, + scenario === 'agentx' ? 'agentic_traces' : 'single_turn', + ), framework, frameworkLabel: resolveFrameworkPartLabel(model, framework), specMethod, - specLabel: resolveFrameworkPartLabel(model, specMethod), + specLabel, disagg, isMultinode, precision, diff --git a/packages/app/src/lib/overview-links.test.ts b/packages/app/src/lib/overview-links.test.ts index 63ef4a085..11bf02dc1 100644 --- a/packages/app/src/lib/overview-links.test.ts +++ b/packages/app/src/lib/overview-links.test.ts @@ -135,6 +135,17 @@ describe('buildOverviewDashboardHref', () => { 'i_spec=mtp', ); }); + + it('does not filter an AgentX mixed-spec curve to only one decode method', () => { + const href = buildOverviewDashboardHref( + 'en', + summary({ scenario: 'agentx' }), + config({ specMethod: 'mixed', hwKey: 'b200_sglang' }), + ); + + expect(href).toContain('i_seq=agentic-traces'); + expect(href).not.toContain('i_spec='); + }); }); describe('buildOverviewHistoryDashboardHref', () => { diff --git a/packages/app/src/lib/overview-links.ts b/packages/app/src/lib/overview-links.ts index d8fa4eae8..223fe468c 100644 --- a/packages/app/src/lib/overview-links.ts +++ b/packages/app/src/lib/overview-links.ts @@ -78,7 +78,8 @@ function inferenceRoute(locale: 'en' | 'zh'): string { * (mirrors `pointSpecMode` in quickFilters.ts, minus its hwKey suffix check — * overview `specMethod` comes straight from `spec_method`). */ -function dashboardSpecMode(specMethod: string): 'mtp' | 'stp' { +function dashboardSpecMode(specMethod: string): 'mtp' | 'stp' | undefined { + if (specMethod === 'mixed') return undefined; return specMethod === 'none' || specMethod === '' ? 'stp' : 'mtp'; } diff --git a/packages/db/src/queries/benchmarks.ts b/packages/db/src/queries/benchmarks.ts index 87c5efbd2..d7989e4cc 100644 --- a/packages/db/src/queries/benchmarks.ts +++ b/packages/db/src/queries/benchmarks.ts @@ -45,6 +45,9 @@ export interface BenchmarkRow { */ workers?: BenchmarkWorkerRow[]; date: string; + /** Internal workflow identity used to keep merged agentic curves within one run. */ + workflow_run_id?: number; + run_started_at?: string | null; run_url: string | null; } @@ -147,6 +150,8 @@ export async function getLatestBenchmarks( br.metrics, br.workers, br.date::text, + br.workflow_run_id, + wr.run_started_at::text, CASE WHEN wr.html_url IS NOT NULL THEN wr.html_url || '/attempts/' || wr.run_attempt ELSE NULL END AS run_url FROM benchmark_results br JOIN configs c ON c.id = br.config_id @@ -194,6 +199,8 @@ export async function getLatestBenchmarks( lb.metrics, lb.workers, lb.date::text, + lb.workflow_run_id, + wr.run_started_at::text, CASE WHEN wr.html_url IS NOT NULL THEN wr.html_url || '/attempts/' || wr.run_attempt ELSE NULL END AS run_url FROM latest_benchmarks lb JOIN configs c ON c.id = lb.config_id @@ -246,6 +253,8 @@ export async function getBenchmarksForRun( br.metrics, br.workers, br.date::text, + br.workflow_run_id, + wr.run_started_at::text, CASE WHEN wr.html_url IS NOT NULL THEN wr.html_url || '/attempts/' || wr.run_attempt ELSE NULL END AS run_url FROM benchmark_results br JOIN configs c ON c.id = br.config_id @@ -266,10 +275,15 @@ export async function getBenchmarksForRun( export async function getAllBenchmarksForHistory( sql: DbClient, modelKey: string | string[], - isl: number, - osl: number, + isl: number | null, + osl: number | null, + benchmarkType?: string, ): Promise { const modelKeys = Array.isArray(modelKey) ? modelKey : [modelKey]; + const sequenceFilter = + benchmarkType === 'agentic_traces' + ? sql`br.benchmark_type = 'agentic_traces'` + : sql`br.isl = ${isl} AND br.osl = ${osl}`; const rows = await sql` SELECT br.id, @@ -299,11 +313,12 @@ export async function getAllBenchmarksForHistory( br.metrics - '{std_ttft,std_tpot,std_e2el,std_intvty,std_itl,mean_ttft,mean_tpot,mean_e2el,mean_intvty,mean_itl}'::text[] as metrics, br.workers, br.date::text, + br.workflow_run_id, + wr.run_started_at::text, CASE WHEN wr.html_url IS NOT NULL THEN wr.html_url || '/attempts/' || wr.run_attempt ELSE NULL END AS run_url FROM configs c JOIN benchmark_results br ON br.config_id = c.id - AND br.isl = ${isl} - AND br.osl = ${osl} + AND ${sequenceFilter} AND br.error IS NULL JOIN latest_workflow_runs wr ON wr.id = br.workflow_run_id WHERE c.model = ANY(${modelKeys}) diff --git a/packages/db/src/queries/workflow-info.ts b/packages/db/src/queries/workflow-info.ts index 01e13dd88..e32401395 100644 --- a/packages/db/src/queries/workflow-info.ts +++ b/packages/db/src/queries/workflow-info.ts @@ -84,7 +84,12 @@ export interface RunConfigRow { * shipped data without a changelog entry still surfaces — the comparison UI uses * this to enumerate every run on a date, not just runs with changelog notes. */ -export async function getRunConfigsByDate(sql: DbClient, date: string): Promise { +export async function getRunConfigsByDate( + sql: DbClient, + date: string, + benchmarkType?: 'agentic_traces', +): Promise { + const benchmarkTypeFilter = benchmarkType ? sql`AND br.benchmark_type = ${benchmarkType}` : sql``; const rows = await sql` SELECT DISTINCT wr.github_run_id, @@ -102,6 +107,7 @@ export async function getRunConfigsByDate(sql: DbClient, date: string): Promise< JOIN latest_workflow_runs wr ON wr.id = br.workflow_run_id WHERE br.date = ${date}::date AND br.error IS NULL + ${benchmarkTypeFilter} `; return rows as unknown as RunConfigRow[]; }