diff --git a/packages/app/cypress/component/gpu-graph.cy.tsx b/packages/app/cypress/component/gpu-graph.cy.tsx
index 0f70e13d..917f2721 100644
--- a/packages/app/cypress/component/gpu-graph.cy.tsx
+++ b/packages/app/cypress/component/gpu-graph.cy.tsx
@@ -76,6 +76,36 @@ describe('GPUGraph', () => {
cy.contains('No data available').should('be.visible');
});
+ it('explains missing role-local energy in GPU comparison mode', () => {
+ mountWithProviders(
+
+
+
,
+ {
+ inference: {
+ hardwareConfig: hwConfig,
+ selectedGPUs: ['mi355x'],
+ selectedDates: ['2026-07-25'],
+ selectedDateRange: { startDate: '', endDate: '' },
+ activeDates: new Set(['2026-07-25_mi355x']),
+ selectedPrecisions: [Precision.FP8],
+ selectedYAxisMetric: 'y_measuredDecodeJPerOutputToken',
+ },
+ },
+ );
+
+ cy.contains('This dataset does not report role-level prefill/decode energy.').should(
+ 'be.visible',
+ );
+ });
+
it('localizes the Chinese comparison empty state', () => {
mountWithProviders(
diff --git a/packages/app/cypress/component/scatter-graph.cy.tsx b/packages/app/cypress/component/scatter-graph.cy.tsx
index 37298864..ab3c3b08 100644
--- a/packages/app/cypress/component/scatter-graph.cy.tsx
+++ b/packages/app/cypress/component/scatter-graph.cy.tsx
@@ -81,6 +81,37 @@ describe('ScatterGraph', () => {
cy.contains('No data available').should('be.visible');
});
+ it('explains when the selected dataset lacks role-local energy', () => {
+ mountWithProviders(
+
+
+
,
+ {
+ inference: {
+ hardwareConfig: hwConfig,
+ activeHwTypes: new Set(['mi355x']),
+ hwTypesWithData: new Set(),
+ selectedYAxisMetric: 'y_measuredPrefillJPerInputToken',
+ },
+ unofficial: {},
+ },
+ );
+
+ cy.contains('This dataset does not report role-level prefill/decode energy.').should(
+ 'be.visible',
+ );
+ cy.contains(
+ 'Please change the model, sequence, precision, date range or chip selection.',
+ ).should('not.exist');
+ });
+
it('localizes the complete Chinese empty state', () => {
mountWithProviders(
@@ -109,6 +140,35 @@ describe('ScatterGraph', () => {
cy.contains('No data available').should('not.exist');
});
+ it('localizes the missing role-energy explanation', () => {
+ mountWithProviders(
+
+
+
+
+ ,
+ {
+ inference: {
+ hardwareConfig: hwConfig,
+ activeHwTypes: new Set(['mi355x']),
+ hwTypesWithData: new Set(),
+ selectedYAxisMetric: 'y_measuredPrefillJPerInputToken',
+ },
+ unofficial: {},
+ },
+ );
+
+ cy.contains('当前数据集未提供 Prefill/Decode 各角色的能耗数据。').should('be.visible');
+ cy.contains('请调整模型、序列长度、精度、日期范围或芯片选项。').should('not.exist');
+ });
+
it('renders scatter points as shapes in SVG with mock data', () => {
const data = [
createMockInferenceData({
diff --git a/packages/app/cypress/e2e/certified-power-filter.cy.ts b/packages/app/cypress/e2e/certified-power-filter.cy.ts
index da77736f..bf57c1f3 100644
--- a/packages/app/cypress/e2e/certified-power-filter.cy.ts
+++ b/packages/app/cypress/e2e/certified-power-filter.cy.ts
@@ -1,10 +1,5 @@
-// Validated-vs-historical measured power (PLAN-02 / gap G2): on the Measured
-// Energy y-axes, points without a producer validation verdict carry a dotted
-// ring and a footer legend key, and Quick Filters gains a "Measured Power"
-// category (Validated/Historical labels, stable `i_power` share-link values).
-// Fixture rows are intercepted so one config is validated (power_valid=1) and
-// one is historical (no verdict), keeping every assertion deterministic regardless of
-// what the production dataset contains.
+// Deterministic intercepted rows exercise the validated/historical power UI:
+// one row has power_valid=1 and one has no validation verdict.
const POWER_MODEL = 'dsv4';
const POWER_DATE = '2026-08-20';
@@ -102,13 +97,9 @@ describe('Validated vs historical measured power', () => {
it('rings legacy points on a measured axis and filters them via Quick Filters', () => {
visitCertifiedPowerChart();
- // No decorations off the Measured Energy axes.
cy.get('.legacy-power-ring').should('not.exist');
cy.get('[data-testid="legacy-power-key"]').should('not.exist');
- // Pick "Measured Average Power per Chip" from the y-axis dropdown. The
- // select list is a scroll container and Measured Energy sits below the
- // fold, so scroll before clicking.
cy.get('[data-testid="yaxis-metric-selector"]').click();
cy.contains('[role="option"]', 'Measured Average Power per Chip')
.scrollIntoView()
@@ -122,14 +113,11 @@ describe('Validated vs historical measured power', () => {
.and('contain.text', '1/3 historical')
.and('contain.text', 'Best per SKU and Optimal Only are enabled');
- // The no-verdict config is ringed; the certified one is not. The footer
- // legend key appears with the ringed points.
cy.get('.dot-group[data-hw-key^="b200"] .legacy-power-ring').should('exist');
cy.get('.dot-group[data-hw-key^="mi300x"] .legacy-power-ring').should('not.exist');
cy.get('[data-testid="legacy-power-key"]').should('be.visible');
cy.screenshot('legacy-power-rings', { capture: 'viewport' });
- // Quick Filters gains the Measured Power category with both pills enabled.
cy.get('[data-testid="scatter-quick-filters"]').click();
cy.get('[data-testid="quick-filters-dialog"]').should('be.visible');
cy.get('[data-testid="quick-filter-power-certified"]').should('be.enabled');
@@ -140,8 +128,6 @@ describe('Validated vs historical measured power', () => {
cy.contains('Both are shown by default.').should('be.visible');
cy.get('body').type('{esc}');
- // Certified-only: legacy points (and with them every ring and the legend
- // key) leave the chart while the certified series stays.
cy.get('[data-testid="quick-filter-power-certified"]').click();
cy.get('[data-testid="quick-filters-selected-count"]').should('contain.text', '1 selected');
cy.get('.dot-group[data-hw-key^="b200"]').should('not.exist');
@@ -151,7 +137,6 @@ describe('Validated vs historical measured power', () => {
cy.get('[data-testid="inference-chart-display"] svg').should('exist');
cy.screenshot('certified-only-filter', { capture: 'viewport' });
- // Clear filters restores the legacy series, rings, and legend key.
cy.contains('button', 'Clear filters').click();
cy.get('[data-testid="quick-filters-selected-count"]').should('not.exist');
cy.get('[data-testid="quick-filter-power-certified"]').should(
diff --git a/packages/app/cypress/support/agentic-fixtures.ts b/packages/app/cypress/support/agentic-fixtures.ts
index 66adff22..eeddd609 100644
--- a/packages/app/cypress/support/agentic-fixtures.ts
+++ b/packages/app/cypress/support/agentic-fixtures.ts
@@ -9,6 +9,61 @@ export function percentileLadder(prefix: string, base: number): Record {
+ const scale = concurrency / 16;
+ return {
+ power_valid: 1,
+ power_metric_schema_version: 2,
+ avg_power_w: 600 + 10 * scale,
+ joules_per_input_token: 0.3 / scale,
+ joules_per_output_token: 8 / scale,
+ joules_per_total_token: 0.9 / scale,
+ joules_per_successful_query: 1500 / scale,
+ avg_temp_c: 65 + scale,
+ avg_util_pct: 80 + scale,
+ ...(opts.disagg
+ ? {
+ prefill_avg_power_w: 612.3,
+ decode_avg_power_w: 701.5,
+ prefill_joules_per_input_token: 0.4 / scale,
+ decode_joules_per_output_token: 5.1 / scale,
+ }
+ : {}),
+ };
+}
+
+/**
+ * WorkerPower-shaped rows for the pinned-tooltip drilldown. Plain object
+ * literals — cypress support files never import types from src.
+ */
+export function syntheticWorkers(disagg: boolean) {
+ if (!disagg) {
+ return [{ role: 'agg', worker_idx: 0, hosts: ['n0'], num_gpus: 8, avg_power_w: 640.2 }];
+ }
+ return [
+ { role: 'frontend', worker_idx: 0, hosts: ['fe0'], num_gpus: 0, avg_power_w: 120 },
+ {
+ role: 'prefill',
+ worker_idx: 0,
+ hosts: ['pn0'],
+ num_gpus: 8,
+ avg_power_w: 612.3,
+ avg_temp_c: 68.4,
+ avg_util_pct: 88.5,
+ },
+ { role: 'decode', worker_idx: 0, hosts: ['dn0'], num_gpus: 8, avg_power_w: 701.5 },
+ ];
+}
+
export function agenticMetrics(concurrency: number): Record {
const scale = concurrency / 16;
const itl = 0.011 * scale;
diff --git a/packages/app/src/components/inference/axis-metric-explanations.ts b/packages/app/src/components/inference/axis-metric-explanations.ts
index e3a2189c..0889c585 100644
--- a/packages/app/src/components/inference/axis-metric-explanations.ts
+++ b/packages/app/src/components/inference/axis-metric-explanations.ts
@@ -271,6 +271,41 @@ function measuredJoulesPerToken(tokenType: TokenType): MetricExplanation {
};
}
+const MEASURED_ROLE_EN: Record<'prefill' | 'decode', { tokens: string; isolates: string }> = {
+ prefill: { tokens: 'input (prompt)', isolates: 'prompt-processing' },
+ decode: { tokens: 'output', isolates: 'token-generation' },
+};
+
+const MEASURED_ROLE_ZH: Record<'prefill' | 'decode', { tokens: string; isolates: string }> = {
+ prefill: { tokens: '输入', isolates: '提示词处理' },
+ decode: { tokens: '输出', isolates: 'token 生成' },
+};
+
+function measuredRoleJoulesPerToken(role: 'prefill' | 'decode'): MetricExplanation {
+ return {
+ description: {
+ en:
+ `Measured accelerator energy consumed by the ${role} workers per ` +
+ `${MEASURED_ROLE_EN[role].tokens} token, from runner power telemetry integrated over ` +
+ `the run. Unlike the whole-deployment J/tok metrics, only that role's energy is ` +
+ `charged, so it isolates ${MEASURED_ROLE_EN[role].isolates} efficiency in ` +
+ `disaggregated deployments.${MEASURED_TIER_NOTE_EN}`,
+ zh:
+ `每个${MEASURED_ROLE_ZH[role].tokens} token 由 ${MEASURED_PHASE_ZH[role]}工作进程消耗的` +
+ `加速器实测能耗,由运行器功耗遥测在整个运行期间积分得到。与全部署 J/tok 指标不同,` +
+ `它只计入该角色的能耗,因此可以在分离式部署中单独衡量${
+ MEASURED_ROLE_ZH[role].isolates
+ }效率。${MEASURED_TIER_NOTE_ZH}`,
+ },
+ formula: {
+ en:
+ `J/tok = measured ${role}-worker energy over the run ÷ ` +
+ `${role === 'prefill' ? 'input' : 'output'} tokens processed`,
+ zh: `J/tok = 运行期间 ${role} 工作进程实测能耗 ÷ 处理的${MEASURED_ROLE_ZH[role].tokens} token 数`,
+ },
+ };
+}
+
/**
* Every `METRIC_REGISTRY` key gets a bilingual explanation and a structural
* formula. Grounded in `buildDerivedChartFields` (src/lib/chart-utils.ts) and
@@ -361,6 +396,8 @@ export const METRIC_EXPLANATIONS: Record = {
measuredJPerOutputToken: measuredJoulesPerToken('output'),
measuredJPerInputToken: measuredJoulesPerToken('input'),
measuredJPerTotalToken: measuredJoulesPerToken('total'),
+ measuredPrefillJPerInputToken: measuredRoleJoulesPerToken('prefill'),
+ measuredDecodeJPerOutputToken: measuredRoleJoulesPerToken('decode'),
measuredJPerSuccessfulQuery: {
description: {
en:
diff --git a/packages/app/src/components/inference/measured-power-direction.test.ts b/packages/app/src/components/inference/measured-power-direction.test.ts
index a8762e11..8e4aaaf1 100644
--- a/packages/app/src/components/inference/measured-power-direction.test.ts
+++ b/packages/app/src/components/inference/measured-power-direction.test.ts
@@ -35,6 +35,11 @@ const QUERY_ENERGY_METRICS = [
'y_measuredWhPerSuccessfulQuery',
] as const;
+const ROLE_ENERGY_METRICS = [
+ 'y_measuredPrefillJPerInputToken',
+ 'y_measuredDecodeJPerOutputToken',
+] as const;
+
const defs = chartDefinitions as unknown as ChartDefinition[];
const interactivityDef = defs.find((d) => d.chartType === 'interactivity')!;
const e2eDef = defs.find((d) => d.chartType === 'e2e')!;
@@ -149,6 +154,16 @@ describe('measured-power Pareto direction', () => {
}
});
+ it.each(ROLE_ENERGY_METRICS)('%s is bilingual and lower-is-better', (metric) => {
+ expect(declaredDirection(interactivityDef, metric)).toBe('lower_right');
+ expect(declaredDirection(e2eDef, metric)).toBe('lower_left');
+ for (const chartDef of [interactivityDef, e2eDef]) {
+ expect(chartDef[metric]).toMatch(/\.y$/u);
+ expect(chartDef[`${metric}_label`]).toBeTruthy();
+ expect(chartDef[`${metric}_labelZh`]).toBeTruthy();
+ }
+ });
+
it('leaves %TDP without a Pareto direction on either block', () => {
// %TDP is a utilization gauge, not an efficiency frontier: a config running
// hotter is not "worse" along an axis the roofline can order, so declaring a
diff --git a/packages/app/src/components/inference/metric-registry.test.ts b/packages/app/src/components/inference/metric-registry.test.ts
index 7fa6a80c..7c784b85 100644
--- a/packages/app/src/components/inference/metric-registry.test.ts
+++ b/packages/app/src/components/inference/metric-registry.test.ts
@@ -5,6 +5,7 @@ import {
DEFAULT_METRIC_CONFIG_KEY,
isBenchmarkMetricKey,
isMeasuredEnergyConfigKey,
+ isRoleLocalMeasuredEnergyConfigKey,
MEASURED_ENERGY_METRIC_CONFIG_KEYS,
METRIC_CONFIG_KEYS,
METRIC_CONTROL_GROUPS,
@@ -63,6 +64,35 @@ describe('metric registry', () => {
expect(controlMetrics.toSorted()).toEqual(METRIC_CONFIG_KEYS.toSorted());
});
+ it('keeps the Measured Energy key list in lockstep with the registry', () => {
+ // Every registry key starting `measured` must be in the exported list, so
+ // a new telemetry metric cannot silently miss the tier decorations.
+ const measuredRegistryKeys = Object.keys(METRIC_REGISTRY)
+ .filter((key) => key.startsWith('measured'))
+ .map((key) => `y_${key}`);
+ expect([...MEASURED_ENERGY_METRIC_CONFIG_KEYS].toSorted()).toEqual(
+ measuredRegistryKeys.toSorted(),
+ );
+
+ const measuredGroup = METRIC_CONTROL_GROUPS.find((group) => group.label === 'Measured Energy');
+ expect(measuredGroup?.metrics).toBe(MEASURED_ENERGY_METRIC_CONFIG_KEYS);
+ });
+
+ it('identifies only the role-local prefill and decode energy axes', () => {
+ expect(isRoleLocalMeasuredEnergyConfigKey('y_measuredPrefillJPerInputToken')).toBe(true);
+ expect(isRoleLocalMeasuredEnergyConfigKey('y_measuredDecodeJPerOutputToken')).toBe(true);
+ expect(isRoleLocalMeasuredEnergyConfigKey('y_measuredJPerOutputToken')).toBe(false);
+ expect(isRoleLocalMeasuredEnergyConfigKey('y_measuredPrefillAvgPower')).toBe(false);
+ });
+
+ it('classifies measured-energy config keys', () => {
+ expect(isMeasuredEnergyConfigKey('y_measuredAvgPower')).toBe(true);
+ expect(isMeasuredEnergyConfigKey('y_measuredWhPerSuccessfulQuery')).toBe(true);
+ expect(isMeasuredEnergyConfigKey('y_tpPerGpu')).toBe(false);
+ expect(isMeasuredEnergyConfigKey('y_jTotal')).toBe(false);
+ expect(isMeasuredEnergyConfigKey('y')).toBe(false);
+ });
+
it('labels every infrastructure purchasing-power metric as TCO', () => {
const metricKeys = [
'tokensPerDollarH',
@@ -155,6 +185,12 @@ describe('metric compatibility', () => {
expect(resolveMetricConfigKey('y_measuredJPerSuccessfulQuery')).toBe(
'y_measuredJPerSuccessfulQuery',
);
+ expect(resolveMetricConfigKey('y_measuredPrefillJPerInputToken')).toBe(
+ 'y_measuredPrefillJPerInputToken',
+ );
+ expect(resolveMetricConfigKey('y_measuredDecodeJPerOutputToken')).toBe(
+ 'y_measuredDecodeJPerOutputToken',
+ );
expect(resolveMetricConfigKey('y_costUser')).toBe('y_costUser');
expect(isBenchmarkMetricKey('tpPerGpu')).toBe(true);
expect(isBenchmarkMetricKey('tokenRevenuePerGpuHour')).toBe(true);
@@ -171,5 +207,7 @@ describe('metric compatibility', () => {
expect(tokenMetricTypeForConfigKey('y_tokenRevenuePerGpuHour')).toBe('total');
expect(tokenMetricTypeForConfigKey('y_tokensPerDollarN')).toBe('total');
expect(tokenMetricTypeForConfigKey('y_measuredAvgPower')).toBe('total');
+ expect(tokenMetricTypeForConfigKey('y_measuredPrefillJPerInputToken')).toBe('input');
+ expect(tokenMetricTypeForConfigKey('y_measuredDecodeJPerOutputToken')).toBe('output');
});
});
diff --git a/packages/app/src/components/inference/metric-registry.ts b/packages/app/src/components/inference/metric-registry.ts
index 2ee697e2..43fa6507 100644
--- a/packages/app/src/components/inference/metric-registry.ts
+++ b/packages/app/src/components/inference/metric-registry.ts
@@ -377,6 +377,14 @@ export const METRIC_REGISTRY = {
titleZh: '每输出 token 实测焦耳能耗',
polarity: 'lower',
},
+ measuredDecodeJPerOutputToken: {
+ field: 'measuredDecodeJPerOutputToken.y',
+ label: 'Measured Decode J per Output Token (J/tok)',
+ labelZh: '每输出 token 实测 Decode 能耗(J/tok)',
+ title: 'Measured Decode Joules per Output Token',
+ titleZh: '每输出 token 实测 Decode 焦耳能耗',
+ polarity: 'lower',
+ },
measuredJPerInputToken: {
field: 'measuredJPerInputToken.y',
label: 'Measured J per Input Token (J/tok)',
@@ -385,6 +393,14 @@ export const METRIC_REGISTRY = {
titleZh: '每输入 token 实测焦耳能耗',
polarity: 'lower',
},
+ measuredPrefillJPerInputToken: {
+ field: 'measuredPrefillJPerInputToken.y',
+ label: 'Measured Prefill J per Input Token (J/tok)',
+ labelZh: '每输入 token 实测 Prefill 能耗(J/tok)',
+ title: 'Measured Prefill Joules per Input Token',
+ titleZh: '每输入 token 实测 Prefill 焦耳能耗',
+ polarity: 'lower',
+ },
measuredJPerTotalToken: {
field: 'measuredJPerTotalToken.y',
label: 'Measured J per Token (J/tok)',
@@ -492,7 +508,7 @@ export interface MetricControlGroup {
}
/**
- * The nine runner-telemetry y-axes in the "Measured Energy" control group.
+ * The runner-telemetry y-axes in the "Measured Energy" control group.
* Exported (and referenced by the group below, so the two cannot drift) for
* consumers that treat measured axes specially — the legacy-power point ring,
* tooltip tier line, and footer legend key.
@@ -502,7 +518,9 @@ export const MEASURED_ENERGY_METRIC_CONFIG_KEYS = [
'y_measuredDecodeAvgPower',
'y_measuredAvgPower',
'y_measuredJPerInputToken',
+ 'y_measuredPrefillJPerInputToken',
'y_measuredJPerOutputToken',
+ 'y_measuredDecodeJPerOutputToken',
'y_measuredJPerTotalToken',
'y_measuredJPerSuccessfulQuery',
'y_measuredWhPerSuccessfulQuery',
@@ -513,11 +531,21 @@ const MEASURED_ENERGY_METRIC_CONFIG_KEY_SET: ReadonlySet = new Set(
MEASURED_ENERGY_METRIC_CONFIG_KEYS,
);
+const ROLE_LOCAL_MEASURED_ENERGY_METRIC_CONFIG_KEY_SET: ReadonlySet = new Set([
+ 'y_measuredPrefillJPerInputToken',
+ 'y_measuredDecodeJPerOutputToken',
+]);
+
/** Whether a y-axis config key plots one of the Measured Energy metrics. */
export function isMeasuredEnergyConfigKey(configKey: string): boolean {
return MEASURED_ENERGY_METRIC_CONFIG_KEY_SET.has(configKey);
}
+/** Whether a y-axis requires the explicit prefill/decode energy breakdown. */
+export function isRoleLocalMeasuredEnergyConfigKey(configKey: string): boolean {
+ return ROLE_LOCAL_MEASURED_ENERGY_METRIC_CONFIG_KEY_SET.has(configKey);
+}
+
export const METRIC_CONTROL_GROUPS: readonly MetricControlGroup[] = [
{
label: 'Throughput',
diff --git a/packages/app/src/components/inference/types.ts b/packages/app/src/components/inference/types.ts
index 4c7875ac..54596de1 100644
--- a/packages/app/src/components/inference/types.ts
+++ b/packages/app/src/components/inference/types.ts
@@ -339,6 +339,9 @@ export interface InferenceData extends Partial (count > 0 ? `Quick Filters (${count})` : 'Quick Filters'),
noData: 'No data available',
noDataHint: 'Please change the model, sequence, precision, date range or chip selection.',
+ noRoleEnergyDataHint:
+ 'This dataset does not report role-level prefill/decode energy. Choose a different model, scenario, precision, date, or measured-energy metric.',
},
zh: {
logScale: '对数缩放',
@@ -127,6 +132,8 @@ const GPU_STRINGS = {
quickFilters: (count: number) => (count > 0 ? `快捷筛选(${count})` : '快捷筛选'),
noData: '暂无数据',
noDataHint: '请调整模型、序列长度、精度、日期范围或芯片选项。',
+ noRoleEnergyDataHint:
+ '当前数据集未提供 Prefill/Decode 各角色的能耗数据。请选择其他模型、场景、精度、日期或实测能耗指标。',
},
} as const;
@@ -184,6 +191,9 @@ const GPUGraph = React.memo(
const locale = useLocale();
const legendT = GPU_STRINGS[locale];
const isMeasuredEnergyAxis = isMeasuredEnergyConfigKey(selectedYAxisMetric);
+ const noDataHint = isRoleLocalMeasuredEnergyConfigKey(selectedYAxisMetric)
+ ? legendT.noRoleEnergyDataHint
+ : legendT.noDataHint;
const ephemeralUrlState = useEphemeralUrlState();
const { resolvedTheme } = useTheme();
const chartRef = useRef(null);
@@ -721,7 +731,7 @@ const GPUGraph = React.memo(
/>
{legendT.noData}
- {legendT.noDataHint}
+ {noDataHint}