diff --git a/packages/app/cypress/component/scatter-graph.cy.tsx b/packages/app/cypress/component/scatter-graph.cy.tsx
index 0d935a3b..fa41d51c 100644
--- a/packages/app/cypress/component/scatter-graph.cy.tsx
+++ b/packages/app/cypress/component/scatter-graph.cy.tsx
@@ -1,11 +1,20 @@
+import { useState } from 'react';
+import { GlobalFilterContext } from '@/components/GlobalFilterContext';
+import { InferenceContext } from '@/components/inference/InferenceContext';
+import { UnofficialRunContext } from '@/components/unofficial-run-provider';
import ScatterGraph from '@/components/inference/ui/ScatterGraph';
+import ChartDisplay from '@/components/inference/ui/ChartDisplay';
import { mountWithProviders } from '../support/test-utils';
import {
createMockInferenceData,
createMockChartDefinition,
createMockHardwareConfig,
+ createMockGlobalFilterContext,
+ createMockInferenceContext,
+ createMockUnofficialRunContext,
} from '../support/mock-data';
-import { Precision } from '@/lib/data-mappings';
+import { Model, Precision, Sequence } from '@/lib/data-mappings';
+import { buildExclusion, resolveExclusionGroups } from '@/lib/exclusion';
const defaultChartDef = createMockChartDefinition();
const hwConfig = createMockHardwareConfig();
@@ -331,4 +340,298 @@ describe('ScatterGraph', () => {
// No label should show the generic MTP token for M3.
cy.get('#test-scatter-m3-eagle svg .line-label text').should('not.contain.text', 'MTP');
});
+
+ it('removes a cross-engine AgentX STP overlay before rendering', () => {
+ const chartDefinition = createMockChartDefinition({
+ chartType: 'interactivity',
+ y_tpPerGpu_roofline: 'upper_left',
+ });
+ const officialData = [8, 16, 32].map((x, index) =>
+ createMockInferenceData({
+ hwKey: 'b200_sglang',
+ x,
+ y: 320 - index * 40,
+ precision: Precision.FP4,
+ }),
+ );
+ const runUrl = 'https://github.com/x/y/actions/runs/agentx-vllm';
+ const overlayData = {
+ data: [8, 16, 32].map((x, index) =>
+ createMockInferenceData({
+ hwKey: 'h100_vllm',
+ x,
+ y: 260 - index * 40,
+ precision: Precision.FP4,
+ run_url: runUrl,
+ }),
+ ),
+ hardwareConfig: hwConfig,
+ label: 'agentx-vllm',
+ runUrl,
+ };
+ const exclusion = buildExclusion([
+ { suffix: null, stripPrefixes: ['dynamo-', 'mori-', 'llmd-', 'mooncake-'] },
+ ]);
+ const namespacedExclusion = {
+ familyOf: (key: string) =>
+ exclusion.familyOf(key.startsWith('overlay:') ? key.slice('overlay:'.length) : key),
+ groupOf: (key: string) =>
+ exclusion.groupOf(key.startsWith('overlay:') ? key.slice('overlay:'.length) : key),
+ };
+
+ mountWithProviders(
+
+
+
,
+ {
+ inference: {
+ hardwareConfig: hwConfig,
+ activeHwTypes: new Set(['b200_sglang']),
+ hwTypesWithData: new Set(['b200_sglang']),
+ selectedModel: Model.DeepSeek_V4_Pro,
+ selectedSequence: Sequence.AgenticTraces,
+ selectedPrecisions: [Precision.FP4],
+ showLineLabels: true,
+ resolveComparisonSelection: (proposed, prev = new Set()) =>
+ resolveExclusionGroups(proposed, prev, namespacedExclusion, 'keep-sticky'),
+ },
+ unofficial: {
+ activeOverlayHwTypes: new Set(['h100_vllm']),
+ allOverlayHwTypes: new Set(['h100_vllm']),
+ },
+ },
+ );
+
+ cy.get('#test-scatter-agentx-engine-guard svg .roofline-path').should('exist');
+ cy.get('#test-scatter-agentx-engine-guard svg .overlay-roofline-path').should('not.exist');
+ cy.get('@setActiveOverlayHwTypes').should('have.been.called');
+ });
+});
+
+describe('ChartDisplay engine comparison guard', () => {
+ it('keeps cross-engine AgentX STP rows out of table mode', () => {
+ const chartDefinition = createMockChartDefinition({ chartType: 'interactivity' });
+ const sglangRow = createMockInferenceData({
+ hwKey: 'b200_sglang',
+ hw: 'Official SGLang',
+ model: Model.DeepSeek_V4_Pro,
+ precision: Precision.FP4,
+ });
+ const vllmRow = createMockInferenceData({
+ hwKey: 'h100_vllm',
+ hw: 'Official vLLM',
+ model: Model.DeepSeek_V4_Pro,
+ precision: Precision.FP4,
+ });
+ const exclusion = buildExclusion([
+ { suffix: null, stripPrefixes: ['dynamo-', 'mori-', 'llmd-', 'mooncake-'] },
+ ]);
+ const resolveSelection = (proposed: Set, prev = new Set()) =>
+ resolveExclusionGroups(proposed, prev, exclusion, 'keep-sticky');
+
+ mountWithProviders(, {
+ inference: {
+ graphs: [
+ {
+ model: Model.DeepSeek_V4_Pro,
+ sequence: Sequence.AgenticTraces,
+ chartDefinition,
+ data: [sglangRow, vllmRow],
+ },
+ ],
+ selectedModel: Model.DeepSeek_V4_Pro,
+ selectedSequence: Sequence.AgenticTraces,
+ selectedXAxisMode: 'interactivity',
+ activeHwTypes: new Set(['b200_sglang']),
+ hwTypesWithData: new Set(['b200_sglang', 'h100_vllm']),
+ resolveComparisonSelection: resolveSelection,
+ },
+ globalFilters: {
+ selectedModel: Model.DeepSeek_V4_Pro,
+ selectedSequence: Sequence.AgenticTraces,
+ effectiveSequence: Sequence.AgenticTraces,
+ },
+ unofficial: {},
+ });
+
+ cy.get('[data-testid="inference-table-view-btn"]').click();
+ cy.get('[data-testid="inference-results-table"] tbody tr').should('have.length', 1);
+ });
+
+ it('keeps an explicitly empty official legend out of table mode', () => {
+ const chartDefinition = createMockChartDefinition({ chartType: 'interactivity' });
+ const row = createMockInferenceData({
+ hwKey: 'b200_sglang',
+ model: Model.DeepSeek_V4_Pro,
+ precision: Precision.FP4,
+ });
+
+ mountWithProviders(, {
+ inference: {
+ graphs: [
+ {
+ model: Model.DeepSeek_V4_Pro,
+ sequence: Sequence.AgenticTraces,
+ chartDefinition,
+ data: [row],
+ },
+ ],
+ selectedModel: Model.DeepSeek_V4_Pro,
+ selectedSequence: Sequence.AgenticTraces,
+ selectedXAxisMode: 'interactivity',
+ activeHwTypes: new Set(['b200_sglang']),
+ hwTypesWithData: new Set(['b200_sglang']),
+ },
+ globalFilters: {
+ selectedModel: Model.DeepSeek_V4_Pro,
+ selectedSequence: Sequence.AgenticTraces,
+ effectiveSequence: Sequence.AgenticTraces,
+ },
+ unofficial: { localOfficialOverride: new Set() },
+ });
+
+ cy.get('[data-testid="inference-table-view-btn"]').click();
+ cy.contains('No data available for the current filters.').should('be.visible');
+ cy.get('[data-testid="inference-results-table"]').should('not.exist');
+ });
+
+ it('commits a new table overlay scope and preserves an explicit empty selection', () => {
+ const chartDefinition = createMockChartDefinition({ chartType: 'interactivity' });
+ const exclusion = buildExclusion([
+ { suffix: null, stripPrefixes: ['dynamo-', 'mori-', 'llmd-', 'mooncake-'] },
+ ]);
+ const namespacedExclusion = {
+ familyOf: (key: string) =>
+ exclusion.familyOf(key.startsWith('overlay:') ? key.slice('overlay:'.length) : key),
+ groupOf: (key: string) =>
+ exclusion.groupOf(key.startsWith('overlay:') ? key.slice('overlay:'.length) : key),
+ };
+ const resolveSelection = (proposed: Set, prev = new Set()) =>
+ resolveExclusionGroups(proposed, prev, namespacedExclusion, 'keep-sticky');
+ const runInfo = {
+ id: 123,
+ name: 'agentx-scope-test',
+ branch: 'agentx-scope-test',
+ sha: 'abc123',
+ createdAt: '2026-07-10T00:00:00Z',
+ url: 'https://github.com/x/y/actions/runs/123',
+ conclusion: 'success',
+ status: 'completed',
+ isNonMainBranch: true,
+ };
+ const baseInference = createMockInferenceContext();
+ const baseGlobalFilters = createMockGlobalFilterContext();
+ const baseUnofficial = createMockUnofficialRunContext();
+
+ function OverlayScopeHarness() {
+ const [secondScope, setSecondScope] = useState(false);
+ const [activeOverlayKeys, setActiveOverlayKeys] = useState(new Set(['h100_sglang']));
+ const [, setRenderVersion] = useState(0);
+ const model = secondScope ? Model.DeepSeek_R1 : Model.DeepSeek_V4_Pro;
+ const officialKey = secondScope ? 'h100_vllm' : 'b200_sglang';
+ const overlayKeys = secondScope
+ ? ['b200_vllm', 'h200_sglang']
+ : ['h100_sglang', 'h200_sglang'];
+ const officialRows = [
+ createMockInferenceData({
+ hwKey: officialKey,
+ model,
+ precision: Precision.FP4,
+ }),
+ ];
+ const overlayRows = overlayKeys.map((hwKey, index) =>
+ createMockInferenceData({
+ hwKey,
+ model,
+ precision: Precision.FP4,
+ x: 8 + index * 8,
+ run_url: runInfo.url,
+ }),
+ );
+ const inference = {
+ ...baseInference,
+ graphs: [
+ {
+ model,
+ sequence: Sequence.AgenticTraces,
+ chartDefinition,
+ data: officialRows,
+ },
+ ],
+ selectedModel: model,
+ selectedSequence: Sequence.AgenticTraces,
+ selectedXAxisMode: 'interactivity' as const,
+ selectedXAxisMetric: 'p90_ttft',
+ activeHwTypes: new Set([officialKey]),
+ hwTypesWithData: new Set([officialKey]),
+ resolveComparisonSelection: resolveSelection,
+ };
+ const globalFilters = {
+ ...baseGlobalFilters,
+ selectedModel: model,
+ selectedSequence: Sequence.AgenticTraces,
+ effectiveSequence: Sequence.AgenticTraces,
+ };
+ const unofficial = {
+ ...baseUnofficial,
+ isUnofficialRun: true,
+ unofficialRunInfo: runInfo,
+ unofficialRunInfos: [runInfo],
+ runIndexByUrl: { [runInfo.url]: 0, [String(runInfo.id)]: 0 },
+ getOverlayData: () => ({ data: overlayRows, hardwareConfig: hwConfig }),
+ activeOverlayHwTypes: activeOverlayKeys,
+ setActiveOverlayHwTypes: setActiveOverlayKeys,
+ allOverlayHwTypes: new Set(['h100_sglang', 'h200_sglang', 'b200_vllm']),
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ mountWithProviders();
+ cy.get('[data-testid="inference-table-view-btn"]').click();
+ cy.get('[data-testid="inference-results-table"] tbody tr').should('have.length', 2);
+
+ cy.get('[data-testid="change-overlay-scope"]').click();
+ cy.get('[data-testid="inference-results-table"] tbody tr').should('have.length', 2);
+ cy.get('[data-testid="rerender-overlay-scope"]').click();
+ cy.get('[data-testid="inference-results-table"] tbody tr').should('have.length', 2);
+
+ cy.get('[data-testid="clear-overlay-scope"]').click();
+ cy.get('[data-testid="inference-results-table"] tbody tr').should('have.length', 1);
+ cy.get('[data-testid="rerender-overlay-scope"]').click();
+ cy.get('[data-testid="inference-results-table"] tbody tr').should('have.length', 1);
+ cy.get('[data-testid="inference-chart-view-btn"]').click();
+ cy.get('#chart-0 svg .unofficial-overlay-pt').should('not.exist');
+ });
});
diff --git a/packages/app/cypress/e2e/gpu-compare-agentic-detail.cy.ts b/packages/app/cypress/e2e/gpu-compare-agentic-detail.cy.ts
index 6c832e08..a6eab9b1 100644
--- a/packages/app/cypress/e2e/gpu-compare-agentic-detail.cy.ts
+++ b/packages/app/cypress/e2e/gpu-compare-agentic-detail.cy.ts
@@ -13,10 +13,11 @@ import { unlockAgenticGate } from '../support/e2e';
const DEFAULT_MODEL_DB_KEY = 'dsv4'; // DeepSeek-V4-Pro
const AGENTIC_DATE = '2026-06-12';
-// Two GPUs with agentic + single_turn entries so the scenario selector resolves
+// Three configs with agentic + single_turn entries so the scenario selector resolves
// to agentic (agentic preferred when both types exist for the same model).
const AGENTIC_HARDWARE = [
{ hardware: 'b200', framework: 'vllm', disagg: false },
+ { hardware: 'b200', framework: 'sglang', disagg: false },
{ hardware: 'b300', framework: 'vllm', disagg: false },
];
@@ -185,4 +186,29 @@ describe('GPU comparison agentic point detail', () => {
});
cy.location('pathname').should('eq', '/inference');
});
+
+ it('surfaces automatic resolution of conflicting GPU URL state', () => {
+ cy.intercept('GET', '/api/v1/availability', { body: agenticAvailability }).as(
+ 'agenticAvailability',
+ );
+ cy.intercept('GET', '/api/v1/benchmarks*', { body: agenticBenchmarks }).as('agenticBenchmarks');
+
+ cy.visit(
+ '/inference?g_model=DeepSeek-V4-Pro&i_seq=agentic-traces&i_prec=fp4&i_gpus=b200_sglang,b200_vllm&i_dates=2026-06-12&i_dstart=2026-06-12&i_dend=2026-06-12',
+ {
+ onBeforeLoad(win) {
+ win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ unlockAgenticGate(win);
+ },
+ },
+ );
+
+ cy.get('[data-testid="engine-comparison-conflict-toast"]')
+ .should('be.visible')
+ .and('contain.text', 'Kept SGLang and removed vLLM configs');
+ cy.get('[data-testid="gpu-multiselect"] [data-slot="select-trigger"]')
+ .should('contain.text', 'SGLang')
+ .and('not.contain.text', 'vLLM');
+ cy.contains('button', 'Jun 12, 2026').should('be.visible');
+ });
});
diff --git a/packages/app/cypress/support/mock-data.ts b/packages/app/cypress/support/mock-data.ts
index 490fca87..bcad303f 100644
--- a/packages/app/cypress/support/mock-data.ts
+++ b/packages/app/cypress/support/mock-data.ts
@@ -15,6 +15,7 @@ import type {
} from '@/components/reliability/types';
import type { GlobalFilterContextType } from '@/components/GlobalFilterContext';
import type { UnofficialRunContextType } from '@/components/unofficial-run-provider';
+import { computeToggle } from '@/hooks/useTogglableSet';
import { Model, Sequence, Precision } from '@/lib/data-mappings';
import React from 'react';
@@ -165,6 +166,12 @@ export function createMockInferenceContext(
toggleHwType: namedStub('toggleHwType'),
removeHwType: namedStub('removeHwType'),
selectAllHwTypes: namedStub('selectAllHwTypes'),
+ resolveComparisonSelection: (proposed) => ({
+ result: proposed,
+ keptGroup: null,
+ droppedGroups: [],
+ }),
+ toggleComparisonSelection: (prev, item, allItems) => computeToggle(prev, item, allItems),
toggleActiveDate: namedStub('toggleActiveDate'),
removeActiveDate: namedStub('removeActiveDate'),
selectAllActiveDates: namedStub('selectAllActiveDates'),
diff --git a/packages/app/src/components/engine-comparison-conflict-toast.tsx b/packages/app/src/components/engine-comparison-conflict-toast.tsx
new file mode 100644
index 00000000..1fd8d651
--- /dev/null
+++ b/packages/app/src/components/engine-comparison-conflict-toast.tsx
@@ -0,0 +1,119 @@
+'use client';
+
+import { AlertTriangle } from 'lucide-react';
+import { useEffect, useState } from 'react';
+
+import { FRAMEWORK_LABELS } from '@semianalysisai/inferencex-constants';
+
+import { track } from '@/lib/analytics';
+import { useLocale } from '@/lib/use-locale';
+import type { Locale } from '@/lib/i18n';
+import { BottomToast } from '@/components/ui/bottom-toast';
+
+/**
+ * Detail for a rejected or automatically resolved cross-engine selection.
+ * Explicit conflicting adds are blocked; reset/select-all paths report which
+ * comparability groups were kept or removed.
+ */
+export type EngineComparisonConflictDetail =
+ | { kind: 'blocked'; attempted: string; existing: string | null }
+ | { kind: 'resolved'; kept: string[]; dropped: string[] };
+
+function familyLabel(family: string): string {
+ return FRAMEWORK_LABELS[family] ?? family;
+}
+
+function joinList(parts: string[]): string {
+ if (parts.length === 0) return '';
+ if (parts.length === 1) return parts[0];
+ if (parts.length === 2) return `${parts[0]} and ${parts[1]}`;
+ return `${parts.slice(0, -1).join(', ')}, and ${parts.at(-1)}`;
+}
+
+function joinListZh(parts: string[]): string {
+ if (parts.length === 0) return '';
+ if (parts.length === 1) return parts[0];
+ if (parts.length === 2) return `${parts[0]} 和 ${parts[1]}`;
+ return `${parts.slice(0, -1).join('、')}和 ${parts.at(-1)}`;
+}
+
+function describe(detail: EngineComparisonConflictDetail, locale: Locale): string {
+ if (locale === 'zh') return describeZh(detail);
+ if (detail.kind === 'blocked') {
+ const attempted = familyLabel(detail.attempted);
+ if (detail.existing) {
+ const existing = familyLabel(detail.existing);
+ return `${attempted} and ${existing} use engine-specific benchmark implementations, so their numbers aren't directly comparable in this view. Remove the ${existing} configs first to switch.`;
+ }
+ return `${attempted} can't be enabled while another engine family is active. Remove the existing configs first.`;
+ }
+ const kept = [...detail.kept].toSorted().map(familyLabel);
+ const dropped = [...detail.dropped].toSorted().map(familyLabel);
+ if (kept.length > 0) {
+ return `Only compatible engine families can be shown together in this view. Kept ${joinList(kept)} and removed ${joinList(dropped)} configs.`;
+ }
+ if (dropped.length === 0) {
+ return `Configs from different engine families can't be shown on the same graph in this view.`;
+ }
+ return `${joinList(dropped)} use engine-specific benchmark implementations and can't be shown on the same graph in this view. Conflicting configs were disabled; enable one engine family from the legend.`;
+}
+
+function describeZh(detail: EngineComparisonConflictDetail): string {
+ if (detail.kind === 'blocked') {
+ const attempted = familyLabel(detail.attempted);
+ if (detail.existing) {
+ const existing = familyLabel(detail.existing);
+ return `${attempted} 和 ${existing} 使用各自引擎的基准测试实现,因此在此视图中无法直接比较。请先移除 ${existing} 配置再切换。`;
+ }
+ return `另一个引擎系列处于启用状态时,无法启用 ${attempted}。请先移除现有配置。`;
+ }
+ const kept = [...detail.kept].toSorted().map(familyLabel);
+ const dropped = [...detail.dropped].toSorted().map(familyLabel);
+ if (kept.length > 0) {
+ return `此视图只能同时显示相互兼容的引擎系列。已保留 ${joinListZh(kept)},并移除 ${joinListZh(dropped)} 配置。`;
+ }
+ if (dropped.length === 0) {
+ return '此视图无法在同一图表上显示不同引擎系列的配置。';
+ }
+ return `${joinListZh(dropped)} 使用各自引擎的基准测试实现,无法在此视图的同一图表上显示。冲突配置已禁用;请从图例中启用一个引擎系列。`;
+}
+
+const TITLES = {
+ en: "Configs from different engines can't share a graph",
+ zh: '不同引擎的配置无法共享同一图表',
+} as const;
+
+interface Props {
+ detail: EngineComparisonConflictDetail | null;
+ onDismiss?: () => void;
+}
+
+export function EngineComparisonConflictToast({ detail, onDismiss }: Props) {
+ const locale = useLocale();
+ const [seq, setSeq] = useState(0);
+
+ useEffect(() => {
+ if (!detail) return;
+ setSeq((n) => n + 1);
+ track('inference_engine_comparison_conflict_blocked', {
+ kind: detail.kind,
+ attempted: detail.kind === 'blocked' ? detail.attempted : null,
+ existing: detail.kind === 'blocked' ? detail.existing : null,
+ kept: detail.kind === 'resolved' ? detail.kept : null,
+ dropped: detail.kind === 'resolved' ? detail.dropped : null,
+ });
+ }, [detail]);
+
+ if (!detail) return null;
+
+ return (
+ }
+ title={TITLES[locale]}
+ description={describe(detail, locale)}
+ onDismiss={onDismiss}
+ />
+ );
+}
diff --git a/packages/app/src/components/favorites/favorite-presets.ts b/packages/app/src/components/favorites/favorite-presets.ts
index 8efc594b..bb93803b 100644
--- a/packages/app/src/components/favorites/favorite-presets.ts
+++ b/packages/app/src/components/favorites/favorite-presets.ts
@@ -38,7 +38,9 @@ export function matchesPresetHwFilter(
filter: string[],
model: Model | string | null | undefined,
): boolean {
- const excludedSuffixes = getModelExclusion(model).map((spec) => spec.suffix);
+ const excludedSuffixes = getModelExclusion(model)
+ .map((spec) => spec.suffix)
+ .filter((suffix): suffix is string => suffix !== null);
const isExcludedVariant = excludedSuffixes.some((suffix) => hwKey.endsWith(suffix));
return filter.some(
(f) => hwKey === f || (!f.includes('_') && hwKey.startsWith(`${f}_`) && !isExcludedVariant),
diff --git a/packages/app/src/components/inference/InferenceContext.tsx b/packages/app/src/components/inference/InferenceContext.tsx
index 259a25e0..e3738b56 100644
--- a/packages/app/src/components/inference/InferenceContext.tsx
+++ b/packages/app/src/components/inference/InferenceContext.tsx
@@ -42,18 +42,26 @@ import {
useUrlStateSync,
} from '@/hooks/useChartContext';
import { useUrlState } from '@/hooks/useUrlState';
+import { computeToggle } from '@/hooks/useTogglableSet';
import { buildAvailabilityHwKey } from '@/lib/chart-utils';
import { getHardwareConfig, getModelSortIndex, isKnownGpu, TABLEAU_10 } from '@/lib/constants';
-import { getModelExclusion, MODEL_PREFIX_MAPPING, sequenceKind } from '@/lib/data-mappings';
import {
- MtpEngineConflictToast,
- type MtpEngineConflictDetail,
-} from '@/components/mtp-engine-conflict-toast';
+ getModelExclusion,
+ getSequenceExclusion,
+ MODEL_PREFIX_MAPPING,
+ sequenceKind,
+} from '@/lib/data-mappings';
+import {
+ EngineComparisonConflictToast,
+ type EngineComparisonConflictDetail,
+} from '@/components/engine-comparison-conflict-toast';
import {
buildExclusion,
- clearAllExclusionGroups,
effectiveLegendItems,
+ exclusionResolutionFamilies,
+ resolveExclusionGroups,
resolveExclusionToggle,
+ type ExclusionConflictPolicy,
} from '@/lib/exclusion';
import { filterRunsByModel, getDisplayLabel } from '@/lib/utils';
@@ -130,7 +138,18 @@ export function InferenceProvider({
workflowError,
} = useGlobalFilters();
- const { getUrlParam } = useUrlState();
+ const { getUrlParam, setUrlParam } = useUrlState();
+
+ const exclusion = useMemo(() => {
+ const modelSpecs = getModelExclusion(selectedModel);
+ const sequenceSpecs = getSequenceExclusion(effectiveSequence);
+ if (modelSpecs.length === 0 && sequenceSpecs.length === 0) return null;
+ if (modelSpecs.length === 0) return buildExclusion(sequenceSpecs);
+ if (sequenceSpecs.length === 0) return buildExclusion(modelSpecs);
+ return buildExclusion([...modelSpecs, ...sequenceSpecs]);
+ }, [selectedModel, effectiveSequence]);
+ const exclusionPolicy: ExclusionConflictPolicy =
+ sequenceKind(effectiveSequence) === 'agentic' ? 'keep-sticky' : 'clear-all';
// ── GPU comparison state (owned by inference, not global) ─────────────────
const [selectedDates, setSelectedDates] = useState(() => {
@@ -148,11 +167,52 @@ export function InferenceProvider({
const [isCheckingAvailableDates] = useState(false);
const [showDateRangeDialog, setShowDateRangeDialog] = useState(false);
+ // --- Cross-engine comparison conflict toast state ---
+ const [engineConflict, setEngineConflict] = useState(null);
+ const dismissEngineConflict = useCallback(() => setEngineConflict(null), []);
+
// ── Inference-specific filter state ─────────────────────────────────────────
- const [selectedGPUs, setSelectedGPUs] = useState(() => {
+ // Defer URL restoration until after mount so the first client render matches SSR.
+ const [selectedGpuState, setSelectedGpuState] = useState([]);
+ const [gpuUrlHydrated, setGpuUrlHydrated] = useState(false);
+ useEffect(() => {
const urlGpus = getUrlParam('i_gpus');
- return urlGpus ? urlGpus.split(',').filter(Boolean) : [];
- });
+ if (urlGpus) setSelectedGpuState(urlGpus.split(',').filter(Boolean));
+ setGpuUrlHydrated(true);
+ }, [getUrlParam]);
+ const selectedGpuResolution = useMemo(() => {
+ if (!sequenceResolved || !exclusion || selectedGpuState.length < 2) return null;
+ const resolution = resolveExclusionGroups(
+ new Set(selectedGpuState),
+ new Set(),
+ exclusion,
+ exclusionPolicy,
+ );
+ const selection = [...resolution.result];
+ if (
+ selection.length === selectedGpuState.length &&
+ selection.every((gpu, index) => gpu === selectedGpuState[index])
+ ) {
+ return null;
+ }
+ return {
+ selection,
+ ...exclusionResolutionFamilies(selectedGpuState, resolution.result, exclusion),
+ };
+ }, [selectedGpuState, sequenceResolved, exclusion, exclusionPolicy]);
+ const selectedGPUs = selectedGpuResolution?.selection ?? selectedGpuState;
+ useEffect(() => {
+ if (!selectedGpuResolution) return;
+ setSelectedGpuState(selectedGpuResolution.selection);
+ setUrlParam('i_gpus', selectedGpuResolution.selection.join(','));
+ if (selectedGpuResolution.dropped.length > 0) {
+ setEngineConflict({
+ kind: 'resolved',
+ kept: selectedGpuResolution.kept,
+ dropped: selectedGpuResolution.dropped,
+ });
+ }
+ }, [selectedGpuResolution, setUrlParam]);
const [selectedYAxisMetric, setSelectedYAxisMetric] = useState(
() => getUrlParam('i_metric') || initialYAxisMetric || 'y_tpPerGpu',
);
@@ -294,10 +354,6 @@ export function InferenceProvider({
return null;
});
- // --- MTP cross-engine conflict toast state ---
- const [mtpConflict, setMtpConflict] = useState(null);
- const dismissMtpConflict = useCallback(() => setMtpConflict(null), []);
-
// ── Data fetching (gated by isActive) ──────────────────────────────────────
const latestDate = availableDates.length > 0 ? availableDates.at(-1) : undefined;
@@ -640,11 +696,60 @@ export function InferenceProvider({
[setSelectedYAxisMetric, clearPresetOnChange],
);
const setSelectedGPUsAndClear = useCallback(
- (v: string[]) => {
- setSelectedGPUs(v);
+ (next: string[]) => {
+ if (!exclusion) {
+ setSelectedGpuState(next);
+ clearPresetOnChange();
+ return;
+ }
+
+ const previous = new Set(selectedGPUs);
+ const proposed = new Set(next);
+ const added = [...proposed].filter((gpu) => !previous.has(gpu));
+ if (added.length === 1) {
+ const available = new Set([...availableGPUs.map((gpu) => gpu.value), ...proposed]);
+ const decision = resolveExclusionToggle(
+ previous,
+ added[0],
+ available,
+ exclusion,
+ exclusionPolicy,
+ );
+ if (decision.kind === 'block') {
+ setEngineConflict({
+ kind: 'blocked',
+ attempted: decision.attempted,
+ existing: decision.existing,
+ });
+ clearPresetOnChange();
+ return;
+ }
+ if (decision.kind === 'silent-resolve') {
+ setSelectedGpuState([...decision.result]);
+ clearPresetOnChange();
+ return;
+ }
+ setSelectedGpuState(next);
+ clearPresetOnChange();
+ return;
+ }
+
+ const { result, droppedGroups } = resolveExclusionGroups(
+ proposed,
+ previous,
+ exclusion,
+ exclusionPolicy,
+ );
+ setSelectedGpuState([...result]);
+ if (droppedGroups.length > 0) {
+ setEngineConflict({
+ kind: 'resolved',
+ ...exclusionResolutionFamilies(proposed, result, exclusion),
+ });
+ }
clearPresetOnChange();
},
- [setSelectedGPUs, clearPresetOnChange],
+ [selectedGPUs, availableGPUs, exclusion, exclusionPolicy, clearPresetOnChange],
);
const setSelectedDatesAndClear = useCallback(
// Accept a React state updater (value OR function) so callers adding several
@@ -672,7 +777,6 @@ export function InferenceProvider({
const {
activeSet: activeHwTypes,
setActiveSet: setActiveHwTypes,
- toggle: toggleHwRaw,
selectAll: selectAllHwRaw,
remove: removeHwRaw,
} = useChartToggleSet();
@@ -693,6 +797,65 @@ export function InferenceProvider({
);
const extractHwKey = useCallback((point: InferenceData) => point.hwKey as string, []);
+ const comparisonExclusion = useMemo(
+ () =>
+ exclusion
+ ? {
+ familyOf: (key: string) =>
+ exclusion.familyOf(key.startsWith('overlay:') ? key.slice('overlay:'.length) : key),
+ groupOf: (key: string) =>
+ exclusion.groupOf(key.startsWith('overlay:') ? key.slice('overlay:'.length) : key),
+ }
+ : null,
+ [exclusion],
+ );
+ const activeHwTypesRef = useRef(activeHwTypes);
+ activeHwTypesRef.current = activeHwTypes;
+ const exclusionRef = useRef(comparisonExclusion);
+ exclusionRef.current = comparisonExclusion;
+ const exclusionPolicyRef = useRef(exclusionPolicy);
+ exclusionPolicyRef.current = exclusionPolicy;
+ const resolveHwSelection = useCallback((proposed: Set, prev?: Set) => {
+ const currentExclusion = exclusionRef.current;
+ if (!currentExclusion) {
+ return { result: proposed, keptGroup: null, droppedGroups: [] };
+ }
+ return resolveExclusionGroups(
+ proposed,
+ prev ?? activeHwTypesRef.current,
+ currentExclusion,
+ exclusionPolicyRef.current,
+ );
+ }, []);
+ const toggleComparisonSelection = useCallback(
+ (prev: Set, item: string, allItems: Set): Set | null => {
+ const currentExclusion = exclusionRef.current;
+ const toggleUniverse = currentExclusion
+ ? effectiveLegendItems(allItems, prev, currentExclusion)
+ : allItems;
+ if (currentExclusion) {
+ const decision = resolveExclusionToggle(
+ prev,
+ item,
+ toggleUniverse,
+ currentExclusion,
+ exclusionPolicyRef.current,
+ );
+ if (decision.kind === 'block') {
+ setEngineConflict({
+ kind: 'blocked',
+ attempted: decision.attempted,
+ existing: decision.existing,
+ });
+ return null;
+ }
+ if (decision.kind === 'silent-resolve') return decision.result;
+ }
+ return computeToggle(prev, item, toggleUniverse);
+ },
+ [],
+ );
+
// Wrap setActiveHwTypes to intercept resets and apply pendingHwFilter atomically.
// Without this, useChartDataFilter resets to "all GPUs" in one render and the
// pendingHwFilter effect filters it down in the next — causing a flash/race.
@@ -713,7 +876,10 @@ export function InferenceProvider({
(update: Set | ((prev: Set) => Set)) => {
const filter = pendingHwFilterRef.current;
if (!filter) {
- setActiveHwTypesDispatch(update);
+ setActiveHwTypesDispatch((prev) => {
+ const proposed = typeof update === 'function' ? update(prev) : update;
+ return resolveHwSelection(proposed, prev).result;
+ });
return;
}
// Preset filter is active: evaluate updater to get all available items, then filter.
@@ -723,13 +889,13 @@ export function InferenceProvider({
[...base].filter((k) => matchesPresetHwFilter(k, filter, selectedModelRef.current)),
);
if (filtered.size > 0) {
- setActiveHwTypes(filtered);
+ setActiveHwTypes(resolveHwSelection(filtered).result);
setPendingHwFilter(null);
} else {
- setActiveHwTypes(base);
+ setActiveHwTypes(resolveHwSelection(base).result);
}
},
- [setActiveHwTypes, setActiveHwTypesDispatch],
+ [resolveHwSelection, setActiveHwTypes, setActiveHwTypesDispatch],
);
const hwTypesWithData = useChartDataFilter(
@@ -746,46 +912,20 @@ export function InferenceProvider({
[...hwTypesWithData].filter((k) => matchesPresetHwFilter(k, pendingHwFilter, selectedModel)),
);
if (filtered.size > 0) {
- setActiveHwTypes(filtered);
+ setActiveHwTypes(resolveHwSelection(filtered).result);
setPendingHwFilter(null);
}
- }, [pendingHwFilter, hwTypesWithData, setActiveHwTypes]);
+ }, [pendingHwFilter, hwTypesWithData, selectedModel, resolveHwSelection, setActiveHwTypes]);
- const exclusion = useMemo(() => {
- const specs = getModelExclusion(selectedModel);
- return specs.length > 0 ? buildExclusion(specs) : null;
- }, [selectedModel]);
const toggleHwType = useCallback(
(hw: string) => {
- // Under exclusion, hide participating keys from inactive groups when
- // computing the toggle "universe". This makes the default-deselected
- // state (DSv4 MTP on first load) count as "all selected", so clicking a
- // legend entry solos it instead of just removing it.
- const toggleUniverse = exclusion
- ? effectiveLegendItems(hwTypesWithData, activeHwTypes, exclusion)
- : hwTypesWithData;
- if (exclusion) {
- const decision = resolveExclusionToggle(activeHwTypes, hw, toggleUniverse, exclusion);
- if (decision.kind === 'block') {
- setMtpConflict({
- kind: 'blocked',
- attempted: decision.attempted,
- existing: decision.existing,
- });
- return;
- }
- if (decision.kind === 'silent-disable-all') {
- setActiveHwTypes(decision.result);
- setActivePresetId(null);
- presetHwFilterRef.current = null;
- return;
- }
- }
- toggleHwRaw(hw, toggleUniverse);
+ const next = toggleComparisonSelection(activeHwTypes, hw, hwTypesWithData);
+ if (!next) return;
+ setActiveHwTypes(next);
setActivePresetId(null);
presetHwFilterRef.current = null;
},
- [toggleHwRaw, hwTypesWithData, exclusion, activeHwTypes, setActiveHwTypes],
+ [activeHwTypes, hwTypesWithData, setActiveHwTypes, toggleComparisonSelection],
);
const removeHwType = useCallback(
@@ -813,15 +953,25 @@ export function InferenceProvider({
const removeActiveDate = useCallback((id: string) => removeDateRaw(id), [removeDateRaw]);
const selectAllHwTypes = useCallback(() => {
if (exclusion) {
- const { result, droppedGroups } = clearAllExclusionGroups(hwTypesWithData, exclusion);
+ const { result, droppedGroups } = resolveHwSelection(hwTypesWithData, activeHwTypes);
setActiveHwTypes(result);
if (droppedGroups.length > 0) {
- setMtpConflict({ kind: 'cleared', families: droppedGroups });
+ setEngineConflict({
+ kind: 'resolved',
+ ...exclusionResolutionFamilies(hwTypesWithData, result, exclusion),
+ });
}
return;
}
selectAllHwRaw(hwTypesWithData);
- }, [selectAllHwRaw, hwTypesWithData, exclusion, setActiveHwTypes]);
+ }, [
+ selectAllHwRaw,
+ hwTypesWithData,
+ activeHwTypes,
+ exclusion,
+ resolveHwSelection,
+ setActiveHwTypes,
+ ]);
const selectAllActiveDates = useCallback(
() => selectAllDatesRaw(allDateIds),
[selectAllDatesRaw, allDateIds],
@@ -842,9 +992,8 @@ export function InferenceProvider({
// Restore legend-active selection from URL on first availability of
// hwTypesWithData. Sets lastHwResetKeyRef so the reset effect below treats
- // the current key as already-applied and bails. Empty intersection (e.g.
- // shared GPUs no longer in availability) falls back to "all available".
- // Multi-family MTP keys are cleared the same way as the auto-reset path.
+ // the current key as already-applied and bails. Empty intersections fall back
+ // to all available configs before the active exclusion policy is applied.
useEffect(() => {
if (!pendingActiveHwTypes) return;
if (pendingHwFilterRef.current) return;
@@ -860,15 +1009,17 @@ export function InferenceProvider({
),
);
// Empty intersection (e.g. URL referenced GPUs no longer in availability,
- // or the URL only contained multi-family MTP keys that get sanitized away)
- // → fall back to the default "all available" set. MTP sanitization is then
- // applied below so the fallback itself is engine-exclusion safe.
+ // or every referenced key disappeared) falls back to all available configs.
if (restored.size === 0) restored = hwTypesWithData;
if (exclusion) {
- const cleared = clearAllExclusionGroups(restored, exclusion);
- restored = cleared.result;
- if (cleared.droppedGroups.length > 0) {
- setMtpConflict({ kind: 'cleared', families: cleared.droppedGroups });
+ const proposed = restored;
+ const resolved = resolveHwSelection(restored, new Set());
+ restored = resolved.result;
+ if (resolved.droppedGroups.length > 0) {
+ setEngineConflict({
+ kind: 'resolved',
+ ...exclusionResolutionFamilies(proposed, resolved.result, exclusion),
+ });
}
}
setActiveHwTypes(restored);
@@ -881,6 +1032,7 @@ export function InferenceProvider({
selectedModel,
effectiveSequence,
precisionsKey,
+ resolveHwSelection,
setActiveHwTypes,
]);
@@ -897,23 +1049,23 @@ export function InferenceProvider({
[...hwTypesWithData].filter((k) => matchesPresetHwFilter(k, presetFilter, selectedModel)),
);
if (filtered.size > 0) {
- // Presets explicitly chose hw configs — respect their picks. The
- // matcher already excludes rule-suffix keys under bare prefixes for
- // models with an exclusion rule, so we don't fall through to
- // clearAllExclusionGroups (which would fire the toast). The legend
- // toggle guard still blocks adding a second comparability group later.
- setActiveHwTypes(filtered);
+ // Presets explicitly choose configs. Resolve any engine conflict
+ // silently so loading a preset never flashes an invalid comparison.
+ setActiveHwTypes(resolveHwSelection(filtered).result);
return;
}
}
if (exclusion) {
- // When multiple comparability groups have data, disable them all by
- // default and surface a toast. The user has to opt into one group
- // explicitly — never multiple at once.
- const { result, droppedGroups } = clearAllExclusionGroups(hwTypesWithData, exclusion);
+ // Automatic resets must never surface multiple incomparable engine groups.
+ // AgentX keeps one sticky group so its chart remains useful; variant-only
+ // rules retain the existing clear-all behavior.
+ const { result, droppedGroups } = resolveHwSelection(hwTypesWithData);
setActiveHwTypes(result);
if (droppedGroups.length > 0) {
- setMtpConflict({ kind: 'cleared', families: droppedGroups });
+ setEngineConflict({
+ kind: 'resolved',
+ ...exclusionResolutionFamilies(hwTypesWithData, result, exclusion),
+ });
}
return;
}
@@ -925,6 +1077,7 @@ export function InferenceProvider({
hwTypesWithData,
exclusion,
pendingActiveHwTypes,
+ resolveHwSelection,
]);
// Remove selected GPUs that no longer have data for current filters
@@ -932,16 +1085,17 @@ export function InferenceProvider({
if (selectedGPUs.length === 0 || availableGPUs.length === 0) return;
const validKeys = new Set(availableGPUs.map((g) => g.value));
const valid = selectedGPUs.filter((g) => validKeys.has(g));
- if (valid.length !== selectedGPUs.length) setSelectedGPUs(valid);
+ if (valid.length !== selectedGPUs.length) setSelectedGpuState(valid);
}, [availableGPUs]);
useEffect(() => {
+ if (!gpuUrlHydrated) return;
if (selectedGPUs.length === 0) {
setSelectedDateRange({ startDate: '', endDate: '' });
setSelectedDates([]);
setUserCosts(null);
}
- }, [selectedGPUs]);
+ }, [gpuUrlHydrated, selectedGPUs]);
// Reset date range when selected dates are no longer available (e.g. precision change)
useEffect(() => {
@@ -1144,7 +1298,7 @@ export function InferenceProvider({
setActivePresetId(preset.id);
setHighContrast(true);
if (config.gpus && config.gpus.length > 0) {
- setSelectedGPUs(config.gpus);
+ setSelectedGpuState(config.gpus);
if (config.useDateRange) {
setSelectedDateRange({ startDate: '', endDate: '' });
setSelectedDates([]);
@@ -1155,7 +1309,7 @@ export function InferenceProvider({
setSelectedDates([]);
}
} else {
- setSelectedGPUs([]);
+ setSelectedGpuState([]);
setSelectedDateRange({ startDate: '', endDate: '' });
setSelectedDates([]);
}
@@ -1171,7 +1325,7 @@ export function InferenceProvider({
setSelectedSequence,
setSelectedPrecisions,
setSelectedYAxisMetric,
- setSelectedGPUs,
+ setSelectedGpuState,
setSelectedDates,
setSelectedDateRange,
setActivePresetId,
@@ -1223,6 +1377,8 @@ export function InferenceProvider({
toggleHwType,
removeHwType,
selectAllHwTypes,
+ resolveComparisonSelection: resolveHwSelection,
+ toggleComparisonSelection,
hardwareConfig,
graphs,
selectedModel,
@@ -1315,6 +1471,9 @@ export function InferenceProvider({
toggleHwType,
removeHwType,
selectAllHwTypes,
+ resolveHwSelection,
+ toggleComparisonSelection,
+
hardwareConfig,
graphs,
loading,
@@ -1371,7 +1530,7 @@ export function InferenceProvider({
return (
{children}
-
+