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 1ce282c2f..f499a3a45 100644 --- a/packages/app/cypress/e2e/ttft-x-axis-toggle.cy.ts +++ b/packages/app/cypress/e2e/ttft-x-axis-toggle.cy.ts @@ -13,6 +13,8 @@ const interceptDerivedMetrics = () => { p90_prefill_tps_per_user: 100 + index, p75_normalized_e2e_400_s: 8 + index, p90_normalized_e2e_400_s: 12 + index, + p75_osl_per_e2el: 40 + index, + p90_osl_per_e2el: 25 + index, }, ]), ), @@ -144,6 +146,10 @@ const interceptFixedSequenceData = () => { describe('X-Axis Mode Toggle (inference chart)', () => { before(() => { interceptAgenticData(); + // Stub derived metrics before the visit: if the agentic DEFAULT x-axis mode + // is (or becomes) a derived mode that fetches /derived-agentic-metrics on + // mount, the chart must not sit on its loading skeleton. + interceptDerivedMetrics(); cy.visit('/inference?i_seq=agentic-traces', { onBeforeLoad(win) { win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now())); @@ -165,6 +171,21 @@ describe('X-Axis Mode Toggle (inference chart)', () => { cy.get('[data-testid="chart-figure"] h2').should('contain.text', 'Interactivity'); }); + it('shows the selected percentile in the Interactivity axis label', () => { + // Explicitly select the mode — do not rely on the agentic default mode. + cy.get('[data-testid="x-axis-mode-interactivity"]').click(); + cy.get('[data-testid="x-axis-mode-interactivity"]').should( + 'have.attr', + 'aria-selected', + 'true', + ); + // Agentic plots percentile fields (p90_intvty), so the axis label carries it. + cy.get('[data-testid="chart-figure"] svg').should( + 'contain.text', + 'P90 Interactivity (tok/s/user)', + ); + }); + it('defaults to parallelism labels without line labels for the agentic view', () => { cy.get('#scatter-parallelism-labels').should('have.attr', 'data-state', 'checked'); cy.get('#scatter-point-labels').should('have.attr', 'data-state', 'checked'); @@ -195,6 +216,7 @@ describe('X-Axis Mode Toggle (inference chart)', () => { cy.get('[data-testid="x-axis-mode-e2e"]').click(); cy.get('[data-testid="x-axis-mode-e2e"]').should('have.attr', 'aria-selected', 'true'); cy.get('[data-testid="chart-figure"] h2').should('contain.text', 'End-to-end Latency'); + cy.get('[data-testid="chart-figure"] svg').should('contain.text', 'P90 End-to-end Latency (s)'); }); it('switches to request-level normalized E2E at 400 output tokens', () => { @@ -231,6 +253,11 @@ describe('X-Axis Mode Toggle (inference chart)', () => { 'true', ); cy.get('[data-testid="chart-figure"] h2').should('contain.text', 'Interactivity'); + // Percentile was switched to p75 in the previous test — the axis label follows. + cy.get('[data-testid="chart-figure"] svg').should( + 'contain.text', + 'P75 Interactivity (tok/s/user)', + ); }); }); @@ -246,6 +273,9 @@ describe('Default scenario', () => { }); cy.get('[data-testid="scenario-selector"]').should('contain.text', '8K / 1K'); cy.get('[data-testid="chart-figure"]').should('have.length.at.least', 1); + // Fixed-seq plots the mean field — no percentile prefix on the axis label. + cy.get('[data-testid="chart-figure"] svg').should('contain.text', 'Interactivity (tok/s/user)'); + cy.get('[data-testid="chart-figure"] svg').should('not.contain.text', 'P90 Interactivity'); }); }); @@ -340,6 +370,9 @@ const interceptAgenticDataWithOverlay = () => { describe('X-Axis Mode Toggle — overlay path (finding #8 regression guard)', () => { before(() => { interceptAgenticDataWithOverlay(); + // Same as the main suite: keep the visit independent of the agentic default + // x-axis mode (a derived default fetches /derived-agentic-metrics on mount). + interceptDerivedMetrics(); cy.visit(`/inference?unofficialrun=${OVERLAY_RUN_ID}&i_seq=agentic-traces`, { onBeforeLoad(win) { win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now())); @@ -352,10 +385,22 @@ describe('X-Axis Mode Toggle — overlay path (finding #8 regression guard)', () }); it('shows overlay (unofficial-run) watermark SVG when an overlay is loaded', () => { + // Explicitly select Interactivity — do not rely on the agentic default mode. + cy.get('[data-testid="x-axis-mode-interactivity"]').click(); + cy.get('[data-testid="x-axis-mode-interactivity"]').should( + 'have.attr', + 'aria-selected', + 'true', + ); // The unofficial-run pattern watermark appears when isUnofficialRun is true. cy.get('[data-testid="inference-chart-display"] svg pattern[id^="unofficial-pattern-"]').should( 'exist', ); + // Overlay shares the chartDefinition label — the percentile prefix applies here too. + cy.get('[data-testid="chart-figure"] svg').should( + 'contain.text', + 'P90 Interactivity (tok/s/user)', + ); }); it('switches to ttft x-axis mode and renders SVG with overlay points', () => { diff --git a/packages/app/src/components/inference/hooks/useChartData.test.ts b/packages/app/src/components/inference/hooks/useChartData.test.ts index c4998add0..fe0452129 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 { + applyAgenticPercentileToXLabel, buildComparisonDates, dedupeRowsToLatestPerConfig, filterByGPU, @@ -139,6 +140,54 @@ describe('filterByGPU', () => { }); }); +describe('applyAgenticPercentileToXLabel', () => { + it('prefixes the percentile when the interactivity label has no statistic word', () => { + expect(applyAgenticPercentileToXLabel('Interactivity (tok/s/user)', 'P90')).toBe( + 'P90 Interactivity (tok/s/user)', + ); + }); + + it('prefixes the percentile when the e2e latency label has no statistic word', () => { + expect(applyAgenticPercentileToXLabel('End-to-end Latency (s)', 'P90')).toBe( + 'P90 End-to-end Latency (s)', + ); + }); + + it('follows the selected percentile (p75)', () => { + expect(applyAgenticPercentileToXLabel('Interactivity (tok/s/user)', 'P75')).toBe( + 'P75 Interactivity (tok/s/user)', + ); + }); + + it('replaces an existing percentile prefix instead of doubling it', () => { + expect(applyAgenticPercentileToXLabel('P90 Time To First Token (s)', 'P75')).toBe( + 'P75 Time To First Token (s)', + ); + }); + + it('replaces Median/Mean statistic prefixes', () => { + expect(applyAgenticPercentileToXLabel('Median Time To First Token (s)', 'P90')).toBe( + 'P90 Time To First Token (s)', + ); + expect(applyAgenticPercentileToXLabel('Mean Interactivity (tok/s/user)', 'P75')).toBe( + 'P75 Interactivity (tok/s/user)', + ); + }); + + it('is a no-op when the label already carries the selected percentile', () => { + expect(applyAgenticPercentileToXLabel('P90 Interactivity (tok/s/user)', 'P90')).toBe( + 'P90 Interactivity (tok/s/user)', + ); + }); + + it('does not touch mid-label statistic words', () => { + // Only a LEADING statistic word is a prefix; words later in the label are content. + expect(applyAgenticPercentileToXLabel('Normalized E2E @ 400 output tokens (s)', 'P90')).toBe( + 'P90 Normalized E2E @ 400 output tokens (s)', + ); + }); +}); + describe('flipRooflineDirection', () => { it('flips left/right while preserving upper/lower', () => { expect(flipRooflineDirection('upper_left')).toBe('upper_right'); diff --git a/packages/app/src/components/inference/hooks/useChartData.ts b/packages/app/src/components/inference/hooks/useChartData.ts index 197fcd6b2..c125e9e9e 100644 --- a/packages/app/src/components/inference/hooks/useChartData.ts +++ b/packages/app/src/components/inference/hooks/useChartData.ts @@ -139,6 +139,25 @@ export function flipRooflineDirection(dir: RooflineDirection): RooflineDirection return FLIP_MAP[dir]; } +// Statistic words that may already prefix an x-axis label (from chart config +// or the TTFT override label). Trailing whitespace is consumed so a replace +// never doubles the separator space. +const X_LABEL_STAT_PREFIX_RE = /^(?:Median|Mean|P75|P90|P95|P99(?:\.9)?)\b\s*/iu; + +/** + * Agentic sequences plot percentile fields (e.g. `p90_intvty`, `p75_e2el`), + * so the x-axis label must carry the selected percentile. Replaces an + * existing leading statistic word (e.g. the TTFT override's "P90 Time To + * First Token (s)") or prefixes the percentile when the configured label has + * none (e.g. "Interactivity (tok/s/user)" → "P90 Interactivity (tok/s/user)"). + * Only call for agentic sequences — fixed-seq labels must stay untouched. + */ +export function applyAgenticPercentileToXLabel(label: string, pctlWord: string): string { + return X_LABEL_STAT_PREFIX_RE.test(label) + ? label.replace(X_LABEL_STAT_PREFIX_RE, `${pctlWord} `) + : `${pctlWord} ${label}`; +} + /** The dedup key fields a chart series is identified by. */ interface DedupeRow { hardware: string; @@ -409,13 +428,15 @@ export function useChartData( // Agentic: relabel to the chosen percentile (the resolver already // rewrote the field) — xAxisLabel still carries the raw chartDef - // prefix. The chart heading ("vs. ") is also rewritten so the - // title above the plot reflects what's drawn. + // prefix (or none: the base Interactivity / E2E Latency config labels + // have no statistic word, so the percentile is prefixed). The chart + // heading ("vs. ") is also rewritten so the title above the + // plot reflects what's drawn. const headingKey = `${selectedYAxisMetric}_heading` as keyof ChartDefinition; let chartHeading = (chartDef[headingKey] as string) || chartDef.heading; if (isAgentic) { const pctlWord = selectedPercentile.toUpperCase(); - xAxisLabel = xAxisLabel.replace(/^(?:Median|Mean|P75|P90|P95|P99(?:\.9)?)\b/iu, pctlWord); + xAxisLabel = applyAgenticPercentileToXLabel(xAxisLabel, pctlWord); chartHeading = chartHeading.replace( /^(?vs\.\s+)(?:(?:Median|Mean|P75|P90|P95|P99(?:\.9)?)\s+)?/iu, `$1${pctlWord} `,