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} - + diff --git a/packages/app/src/components/inference/types.ts b/packages/app/src/components/inference/types.ts index becc946f..a1a98b4b 100644 --- a/packages/app/src/components/inference/types.ts +++ b/packages/app/src/components/inference/types.ts @@ -704,6 +704,17 @@ export interface InferenceChartContextType { toggleHwType: (hw: string) => void; removeHwType: (hw: string) => void; selectAllHwTypes: () => void; + /** Resolve automatic official + `overlay:` hardware selections under the active scope rule. */ + resolveComparisonSelection: ( + proposed: Set, + prev?: Set, + ) => { result: Set; keptGroup: string | null; droppedGroups: string[] }; + /** Apply one official/overlay toggle; returns null when a cross-engine add is blocked. */ + toggleComparisonSelection: ( + prev: Set, + item: string, + allItems: Set, + ) => Set | null; hardwareConfig: HardwareConfig; graphs: RenderableGraph[]; selectedModel: Model; diff --git a/packages/app/src/components/inference/ui/ChartDisplay.tsx b/packages/app/src/components/inference/ui/ChartDisplay.tsx index 1754b15e..b5327aaa 100644 --- a/packages/app/src/components/inference/ui/ChartDisplay.tsx +++ b/packages/app/src/components/inference/ui/ChartDisplay.tsx @@ -5,7 +5,7 @@ import { } from '@semianalysisai/inferencex-constants'; import { track } from '@/lib/analytics'; import dynamic from 'next/dynamic'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { BarChart3, Table2, X } from 'lucide-react'; import chartDefinitions from '@/components/inference/inference-chart-config.json'; @@ -26,6 +26,7 @@ import { makeRunComparisonEntry, } from '@/components/inference/utils/comparisonEntry'; import { dataRunsForDate } from '@/components/inference/utils/runEnumeration'; +import { matchesQuickFilters } from '@/components/inference/utils/quickFilters'; import InferenceTable from '@/components/inference/ui/InferenceTable'; import ScatterGraph from '@/components/inference/ui/ScatterGraph'; import { Card } from '@/components/ui/card'; @@ -253,6 +254,8 @@ export default function ChartDisplay() { compareGpuPair, selectedXAxisMode, setSelectedXAxisMode, + quickFilters, + resolveComparisonSelection, } = useInference(); const { @@ -340,6 +343,9 @@ export default function ChartDisplay() { getOverlayData, isUnofficialRun, activeOverlayHwTypes, + setActiveOverlayHwTypes, + localOfficialOverride, + setLocalOfficialOverride, } = useUnofficialRun(); // Compute overlay data for each chart type — must match useChartData processing @@ -423,6 +429,148 @@ export default function ChartDisplay() { compareGpuPair, ]); + const overlayScope = useMemo(() => { + const eligibleKeys = new Set(); + for (const overlay of [overlayDataByChartType.e2e, overlayDataByChartType.interactivity]) { + for (const point of overlay?.data ?? []) { + const key = String(point.hwKey); + if ( + selectedPrecisions.includes(point.precision) && + matchesQuickFilters(point, quickFilters) + ) { + eligibleKeys.add(key); + } + } + } + return eligibleKeys; + }, [overlayDataByChartType, selectedPrecisions, quickFilters]); + const officialScope = useMemo(() => { + const eligibleKeys = new Set(); + for (const graph of graphs) { + for (const point of graph.data) { + const key = String(point.hwKey); + if ( + selectedPrecisions.includes(point.precision) && + matchesQuickFilters(point, quickFilters) + ) { + eligibleKeys.add(key); + } + } + } + return eligibleKeys; + }, [graphs, selectedPrecisions, quickFilters]); + const overlayRowsScopeKey = `${selectedModel}|${selectedSequence}|${selectedPrecisions.join( + ',', + )}|${unofficialRunInfos.map((run) => run.url).join(',')}`; + const [appliedOverlayRowsScopeKey, setAppliedOverlayRowsScopeKey] = useState(overlayRowsScopeKey); + const overlayRowsScopeChanged = appliedOverlayRowsScopeKey !== overlayRowsScopeKey; + const selectedOfficialHwTypes = + overlayRowsScopeChanged || localOfficialOverride === null + ? activeHwTypes + : localOfficialOverride; + const resolvedScopedOverlayHwTypes = useMemo(() => { + const activeOfficialKeys = new Set( + [...selectedOfficialHwTypes].filter((key) => officialScope.has(key)), + ); + const officialKeys = activeOfficialKeys; + const activeScopedOverlayKeys = new Set( + [...activeOverlayHwTypes].filter((key) => overlayScope.has(key)), + ); + const overlayKeys = overlayRowsScopeChanged ? overlayScope : activeScopedOverlayKeys; + const proposed = new Set(officialKeys); + overlayKeys.forEach((key) => proposed.add(`overlay:${key}`)); + const previous = new Set(activeOfficialKeys); + activeScopedOverlayKeys.forEach((key) => previous.add(`overlay:${key}`)); + const resolved = resolveComparisonSelection(proposed, previous).result; + const overlay = new Set(); + for (const key of resolved) { + if (key.startsWith('overlay:')) overlay.add(key.slice('overlay:'.length)); + } + return overlay; + }, [ + selectedOfficialHwTypes, + activeOverlayHwTypes, + officialScope, + overlayScope, + overlayRowsScopeChanged, + resolveComparisonSelection, + ]); + useEffect(() => { + const merged = new Set(activeOverlayHwTypes); + overlayScope.forEach((key) => merged.delete(key)); + resolvedScopedOverlayHwTypes.forEach((key) => merged.add(key)); + let selectionChanged = merged.size !== activeOverlayHwTypes.size; + if (!selectionChanged) { + for (const key of merged) { + if (!activeOverlayHwTypes.has(key)) { + selectionChanged = true; + break; + } + } + } + if (selectionChanged) setActiveOverlayHwTypes(merged); + if (overlayRowsScopeChanged && localOfficialOverride !== null) { + setLocalOfficialOverride(null); + } + if (overlayRowsScopeChanged) setAppliedOverlayRowsScopeKey(overlayRowsScopeKey); + }, [ + overlayRowsScopeChanged, + overlayRowsScopeKey, + activeOverlayHwTypes, + overlayScope, + resolvedScopedOverlayHwTypes, + setActiveOverlayHwTypes, + localOfficialOverride, + setLocalOfficialOverride, + ]); + + const visibleComparisonRows = useCallback( + (officialRows: InferenceData[], overlay: OverlayData | null | undefined) => { + const eligibleOfficialRows = officialRows.filter( + (point) => + selectedPrecisions.includes(point.precision) && matchesQuickFilters(point, quickFilters), + ); + const eligibleOverlayRows = (overlay?.data ?? []).filter( + (point) => + selectedPrecisions.includes(point.precision) && matchesQuickFilters(point, quickFilters), + ); + const availableOfficialKeys = new Set( + eligibleOfficialRows.map((point) => String(point.hwKey)), + ); + const availableOverlayKeys = new Set(eligibleOverlayRows.map((point) => String(point.hwKey))); + const activeOfficialKeys = new Set( + [...selectedOfficialHwTypes].filter((key) => availableOfficialKeys.has(key)), + ); + const activeScopedOverlayKeys = new Set( + [...activeOverlayHwTypes].filter((key) => availableOverlayKeys.has(key)), + ); + const officialKeys = activeOfficialKeys; + const overlayKeys = new Set( + [...resolvedScopedOverlayHwTypes].filter((key) => availableOverlayKeys.has(key)), + ); + const proposed = new Set(officialKeys); + overlayKeys.forEach((key) => proposed.add(`overlay:${key}`)); + const previous = new Set(activeOfficialKeys); + activeScopedOverlayKeys.forEach((key) => previous.add(`overlay:${key}`)); + const resolved = resolveComparisonSelection(proposed, previous).result; + + return { + officialRows: eligibleOfficialRows.filter((point) => resolved.has(String(point.hwKey))), + overlayRows: eligibleOverlayRows.filter((point) => + resolved.has(`overlay:${String(point.hwKey)}`), + ), + }; + }, + [ + selectedPrecisions, + quickFilters, + selectedOfficialHwTypes, + activeOverlayHwTypes, + resolvedScopedOverlayHwTypes, + resolveComparisonSelection, + ], + ); + // Resolve x-axis field per chart type for trend data const xAxisFieldByChartType = useMemo(() => { const result: Record = {}; @@ -603,12 +751,20 @@ export default function ChartDisplay() { : undefined } onExportCsv={() => { - const visibleData = graph.data.filter((d) => - isTimelineMode - ? activeDates.has(`${d.date}_${d.hwKey}`) - : activeHwTypes.has(d.hwKey as string) && - selectedPrecisions.includes(d.precision), + const candidateVisibleData = isTimelineMode + ? graph.data.filter((d) => activeDates.has(`${d.date}_${d.hwKey}`)) + : graph.data; + const overlay = selectUnofficialOverlayForMode( + selectedXAxisMode, + graph.chartDefinition.chartType, + overlayDataByChartType, ); + const { + officialRows: visibleData, + overlayRows: visibleOverlayRowsForExport, + } = isTimelineMode + ? { officialRows: candidateVisibleData, overlayRows: [] } + : visibleComparisonRows(candidateVisibleData, overlay); const { headers, rows } = inferenceChartToCsv( visibleData, graph.model, @@ -616,21 +772,9 @@ export default function ChartDisplay() { ); // Match warnings against the same series the chart annotates, // including visible unofficial-run overlay series. - const overlay = selectUnofficialOverlayForMode( - selectedXAxisMode, - graph.chartDefinition.chartType, - overlayDataByChartType, - ); - const visibleOverlayRows = isTimelineMode - ? [] - : (overlay?.data ?? []).filter( - (p) => - activeOverlayHwTypes.has(p.hwKey as string) && - selectedPrecisions.includes(p.precision), - ); const issueNotes = matchKnownConfigIssues(graph.model, [ ...visibleData, - ...visibleOverlayRows, + ...visibleOverlayRowsForExport, ]).map((issue) => knownIssueCsvNote(issue, getDisplayLabel(getHardwareConfig(issue.hwKey))), ); @@ -740,18 +884,15 @@ export default function ChartDisplay() { graph.chartDefinition.chartType, overlayDataByChartType, ); - const overlayRows = (overlay?.data ?? []).filter((p) => - selectedPrecisions.includes(p.precision), + const { officialRows, overlayRows } = visibleComparisonRows( + graph.data, + overlay, ); return ( <> {chartCaption} 0 - ? [...graph.data, ...overlayRows] - : graph.data - } + data={[...officialRows, ...overlayRows]} chartDefinition={graph.chartDefinition} selectedYAxisMetric={selectedYAxisMetric} /> 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 fac038e3..0a7eff20 100644 --- a/packages/app/src/components/inference/ui/ScatterGraph.decoration.test.tsx +++ b/packages/app/src/components/inference/ui/ScatterGraph.decoration.test.tsx @@ -16,6 +16,7 @@ import * as d3 from 'd3'; import { setupChartStructure } from '@/lib/d3-chart/chart-setup'; import type { ChartDefinition, InferenceData } from '@/components/inference/types'; +import { computeToggle } from '@/hooks/useTogglableSet'; // ── Module mocks ─────────────────────────────────────────────────────────── vi.mock('@/lib/d3-chart/chart-setup', { spy: true }); vi.mock('@/lib/analytics', () => ({ track: vi.fn() })); @@ -79,6 +80,13 @@ function baseInferenceState() { toggleHwType: noop, removeHwType: noop, hwTypesWithData: new Set(['h100', 'b200']), + resolveComparisonSelection: (proposed: Set) => ({ + result: proposed, + keptGroup: null, + droppedGroups: [], + }), + toggleComparisonSelection: (prev: Set, item: string, allItems: Set) => + computeToggle(prev, item, allItems), selectedPrecisions: ['fp8'], selectedYAxisMetric: 'y', quickFilters: { vendors: [], frameworks: [], disagg: [], spec: [] }, diff --git a/packages/app/src/components/inference/ui/ScatterGraph.tsx b/packages/app/src/components/inference/ui/ScatterGraph.tsx index 53d5ce17..2b3bb2b8 100644 --- a/packages/app/src/components/inference/ui/ScatterGraph.tsx +++ b/packages/app/src/components/inference/ui/ScatterGraph.tsx @@ -14,7 +14,6 @@ import { } from '@/components/inference/ui/line-label-visibility'; import ChartLegend from '@/components/ui/chart-legend'; import { useUnofficialRun } from '@/components/unofficial-run-provider'; -import { computeToggle } from '@/hooks/useTogglableSet'; import { getHardwareConfig, getModelSortIndex } from '@/lib/constants'; import { getChartWatermark, @@ -239,6 +238,14 @@ const pointLabelText = (d: InferenceData, advanced: boolean): string => // Referentially stable "no overlay data" result (see processedOverlayData). const EMPTY_OVERLAY_DATA: InferenceData[] = []; +function setsEqual(a: Set, b: Set): boolean { + if (a.size !== b.size) return false; + for (const value of a) { + if (!b.has(value)) return false; + } + return true; +} + /** Which legend series' points table is open (per-series drill-down dialog). */ type LegendPointsTarget = | { kind: 'official'; hwKey: string } @@ -341,6 +348,8 @@ const ScatterGraph = React.memo( toggleHwType, removeHwType, hwTypesWithData, + resolveComparisonSelection, + toggleComparisonSelection, selectedPrecisions, selectedYAxisMetric, availableRuns, @@ -372,6 +381,7 @@ const ScatterGraph = React.memo( removeTrackedConfig, selectedXAxisMode, selectedSequence, + selectedModel, quickFilters, } = useInference(); const locale = useLocale(); @@ -379,11 +389,8 @@ const ScatterGraph = React.memo( const { isUnofficialRun, - activeOverlayHwTypes, + activeOverlayHwTypes: providerActiveOverlayHwTypes, setActiveOverlayHwTypes, - allOverlayHwTypes, - toggleOverlayHwType: _toggleOverlayHwType, - resetOverlayHwTypes, localOfficialOverride, setLocalOfficialOverride, runIndexByUrl, @@ -396,41 +403,166 @@ const ScatterGraph = React.memo( // replay animation. Only read/written when `pinLineLabels` is true. const lineLabelAnchorRef = useRef>(new Map()); - // Effective active hw types for rendering — shared override when present, else global - const effectiveOfficialHwTypes = localOfficialOverride ?? activeHwTypes; - - // Unified toggle across official + overlay items (shared via context) + const scopedOverlayHwTypes = useMemo(() => { + const keys = new Set(); + for (const point of overlayData?.data ?? []) { + if ( + selectedPrecisions.includes(point.precision) && + matchesQuickFilters(point, quickFilters) + ) { + keys.add(String(point.hwKey)); + } + } + return keys; + }, [overlayData, selectedPrecisions, quickFilters]); + const overlayScopeKey = `${selectedModel}|${selectedSequence}|${selectedPrecisions.join(',')}`; + const previousOverlayScopeRef = useRef(overlayScopeKey); + const overlayScopeChanged = previousOverlayScopeRef.current !== overlayScopeKey; + + const localOfficialOverrideIsStale = useMemo(() => { + if (localOfficialOverride === null) return false; + if (overlayScopeChanged) return true; + if (localOfficialOverride.size === 0) return false; + for (const key of localOfficialOverride) { + if (hwTypesWithData.has(key)) return false; + } + return true; + }, [localOfficialOverride, overlayScopeChanged, hwTypesWithData]); + const rawOfficialHwTypes = useMemo(() => { + const source = localOfficialOverrideIsStale + ? activeHwTypes + : (localOfficialOverride ?? activeHwTypes); + return new Set([...source].filter((key) => hwTypesWithData.has(key))); + }, [activeHwTypes, hwTypesWithData, localOfficialOverride, localOfficialOverrideIsStale]); + const rawOverlayHwTypes = useMemo( + () => + overlayScopeChanged + ? scopedOverlayHwTypes + : new Set( + [...providerActiveOverlayHwTypes].filter((key) => scopedOverlayHwTypes.has(key)), + ), + [overlayScopeChanged, providerActiveOverlayHwTypes, scopedOverlayHwTypes], + ); const allUnifiedHwTypes = useMemo(() => { - const all = new Set(); - hwTypesWithData.forEach((k) => all.add(k)); - allOverlayHwTypes.forEach((k) => all.add(`overlay:${k}`)); + const all = new Set(hwTypesWithData); + scopedOverlayHwTypes.forEach((key) => all.add(`overlay:${key}`)); return all; - }, [hwTypesWithData, allOverlayHwTypes]); + }, [hwTypesWithData, scopedOverlayHwTypes]); + const rawUnifiedSelection = useMemo(() => { + const combined = new Set(rawOfficialHwTypes); + rawOverlayHwTypes.forEach((key) => combined.add(`overlay:${key}`)); + return combined; + }, [rawOfficialHwTypes, rawOverlayHwTypes]); + const resolvedUnifiedSelection = useMemo( + () => + resolveComparisonSelection( + rawUnifiedSelection, + rawOfficialHwTypes.size > 0 ? rawOfficialHwTypes : rawUnifiedSelection, + ).result, + [rawUnifiedSelection, rawOfficialHwTypes, resolveComparisonSelection], + ); + const resolvedHwTypes = useMemo(() => { + const official = new Set(); + const overlay = new Set(); + for (const key of resolvedUnifiedSelection) { + if (key.startsWith('overlay:')) overlay.add(key.slice('overlay:'.length)); + else official.add(key); + } + return { official, overlay }; + }, [resolvedUnifiedSelection]); + const effectiveOfficialHwTypes = resolvedHwTypes.official; + // Official-only toggles must not rebuild the D3 overlay layer. Preserve the + // overlay Set identity when its contents did not change. + const activeOverlayHwTypesRef = useRef(resolvedHwTypes.overlay); + if (!setsEqual(activeOverlayHwTypesRef.current, resolvedHwTypes.overlay)) { + activeOverlayHwTypesRef.current = resolvedHwTypes.overlay; + } + const activeOverlayHwTypes = activeOverlayHwTypesRef.current; + const mergeScopedOverlaySelection = useCallback( + (scopedSelection: Set) => { + const merged = new Set(providerActiveOverlayHwTypes); + scopedOverlayHwTypes.forEach((key) => merged.delete(key)); + scopedSelection.forEach((key) => merged.add(key)); + return merged; + }, + [providerActiveOverlayHwTypes, scopedOverlayHwTypes], + ); + useEffect(() => { + if (!overlayData) return; + previousOverlayScopeRef.current = overlayScopeKey; + if (localOfficialOverrideIsStale) { + setLocalOfficialOverride(null); + } else if ( + localOfficialOverride !== null && + !setsEqual(localOfficialOverride, effectiveOfficialHwTypes) + ) { + setLocalOfficialOverride(effectiveOfficialHwTypes); + } + const mergedOverlaySelection = mergeScopedOverlaySelection(activeOverlayHwTypes); + if (!setsEqual(providerActiveOverlayHwTypes, mergedOverlaySelection)) { + setActiveOverlayHwTypes(mergedOverlaySelection); + } + }, [ + overlayData, + overlayScopeKey, + localOfficialOverride, + localOfficialOverrideIsStale, + effectiveOfficialHwTypes, + activeOverlayHwTypes, + providerActiveOverlayHwTypes, + setLocalOfficialOverride, + setActiveOverlayHwTypes, + mergeScopedOverlaySelection, + ]); + + const commitUnifiedSelection = useCallback( + (selection: Set) => { + const official = new Set(); + const overlay = new Set(); + for (const key of selection) { + if (key.startsWith('overlay:')) overlay.add(key.slice('overlay:'.length)); + else official.add(key); + } + setLocalOfficialOverride(official); + setActiveOverlayHwTypes(mergeScopedOverlaySelection(overlay)); + }, + [setLocalOfficialOverride, setActiveOverlayHwTypes, mergeScopedOverlaySelection], + ); const unifiedToggle = useCallback( (key: string, isOverlay: boolean) => { const prefixedKey = isOverlay ? `overlay:${key}` : key; - const prev = new Set(); - effectiveOfficialHwTypes.forEach((k) => prev.add(k)); - activeOverlayHwTypes.forEach((k) => prev.add(`overlay:${k}`)); - const next = computeToggle(prev, prefixedKey, allUnifiedHwTypes); - const nextOfficial = new Set(); - const nextOverlay = new Set(); - for (const k of next) { - if (k.startsWith('overlay:')) nextOverlay.add(k.slice(8)); - else nextOfficial.add(k); - } - setLocalOfficialOverride(nextOfficial); - setActiveOverlayHwTypes(nextOverlay); + const next = toggleComparisonSelection( + resolvedUnifiedSelection, + prefixedKey, + allUnifiedHwTypes, + ); + if (next) commitUnifiedSelection(next); }, [ - effectiveOfficialHwTypes, - activeOverlayHwTypes, + resolvedUnifiedSelection, allUnifiedHwTypes, - setLocalOfficialOverride, - setActiveOverlayHwTypes, + toggleComparisonSelection, + commitUnifiedSelection, ], ); + const resetUnifiedSelection = useCallback(() => { + selectAllHwTypes(); + if (!overlayData) { + setLocalOfficialOverride(null); + return; + } + const resolved = resolveComparisonSelection(allUnifiedHwTypes, effectiveOfficialHwTypes); + commitUnifiedSelection(resolved.result); + }, [ + selectAllHwTypes, + overlayData, + setLocalOfficialOverride, + allUnifiedHwTypes, + effectiveOfficialHwTypes, + resolveComparisonSelection, + commitUnifiedSelection, + ]); // When no overlay data, delegate to context's toggleHwType (preserves setActivePresetId) const handleToggleHwType = useCallback( @@ -442,7 +574,7 @@ const ScatterGraph = React.memo( const hardwareConfig = hardwareConfigOverride || contextHardwareConfig; const activeHwKeys = useMemo(() => { const keys = [...effectiveOfficialHwTypes]; - activeOverlayHwTypes.forEach((k) => keys.push(`overlay:${k}`)); + activeOverlayHwTypes.forEach((key) => keys.push(`overlay:${key}`)); return keys; }, [effectiveOfficialHwTypes, activeOverlayHwTypes]); const activeOfficialKeys = useMemo( @@ -580,12 +712,15 @@ const ScatterGraph = React.memo( // precision changes — it feeds the `layers` memo, and a new identity // there forces a full chart rebuild. if (!overlayData?.data) return EMPTY_OVERLAY_DATA; - // Mirror the official path's quick filters (vendor / agg-disagg / mtp-stp) - // so overlay points obey the same coarse filters the user picked. + // Mirror the official path's precision/quick filters and remove inactive + // overlay hardware before any points or rooflines are constructed. return overlayData.data.filter( - (p) => selectedPrecisions.includes(p.precision) && matchesQuickFilters(p, quickFilters), + (point) => + selectedPrecisions.includes(point.precision) && + matchesQuickFilters(point, quickFilters) && + activeOverlayHwTypes.has(String(point.hwKey)), ); - }, [overlayData, selectedPrecisions, quickFilters]); + }, [overlayData, selectedPrecisions, quickFilters, activeOverlayHwTypes]); // Warning annotations for visible series (official + unofficial overlay) // with known upstream issues. Drawn as an SVG layer (box + arrow to the @@ -2826,15 +2961,13 @@ const ScatterGraph = React.memo( }} actions={ effectiveOfficialHwTypes.size < hwTypesWithData.size || - activeOverlayHwTypes.size < allOverlayHwTypes.size + activeOverlayHwTypes.size < scopedOverlayHwTypes.size ? [ { id: 'scatter-reset-filter', label: legendT.resetFilter, onClick: () => { - selectAllHwTypes(); - setLocalOfficialOverride(null); - resetOverlayHwTypes(); + resetUnifiedSelection(); track('latency_legend_filter_reset'); }, }, diff --git a/packages/app/src/components/mtp-engine-conflict-toast.tsx b/packages/app/src/components/mtp-engine-conflict-toast.tsx deleted file mode 100644 index d15f977e..00000000 --- a/packages/app/src/components/mtp-engine-conflict-toast.tsx +++ /dev/null @@ -1,113 +0,0 @@ -'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'; - -/** - * Discriminated detail for the MTP-engine-conflict toast. - * - * - `blocked`: the user explicitly tried to add a second engine family's MTP - * config; the action was refused. Names the attempted and existing families. - * - `cleared`: a non-toggle path (model reset, select-all) saw multiple - * families simultaneously and disabled them all. Names the dropped families. - */ -export type MtpEngineConflictDetail = - | { kind: 'blocked'; attempted: string; existing: string | null } - | { kind: 'cleared'; families: 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: MtpEngineConflictDetail, 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 different MTP acceptance-rate implementations, so their numbers aren't directly comparable. Remove the ${existing} MTP config first to switch.`; - } - return `${attempted} MTP can't be enabled while another engine's MTP is active. Remove the existing MTP config first.`; - } - const labels = [...detail.families].toSorted().map(familyLabel); - if (labels.length === 0) { - return `MTP configs from different engines use different acceptance-rate implementations and can't be shown on the same graph. All MTP configs are disabled by default. Enable one from the legend to view it.`; - } - return `${joinList(labels)} use different MTP acceptance-rate implementations and can't be shown on the same graph. All MTP configs are disabled by default. Enable one from the legend to view it.`; -} - -function describeZh(detail: MtpEngineConflictDetail): string { - if (detail.kind === 'blocked') { - const attempted = familyLabel(detail.attempted); - if (detail.existing) { - const existing = familyLabel(detail.existing); - return `${attempted} 和 ${existing} 使用不同的 MTP 接受率实现,数值不可直接比较。请先移除 ${existing} MTP 配置再切换。`; - } - return `另一个引擎的 MTP 处于启用状态时,无法启用 ${attempted} MTP。请先移除现有 MTP 配置。`; - } - const labels = [...detail.families].toSorted().map(familyLabel); - if (labels.length === 0) { - return '不同引擎的 MTP 配置使用不同的接受率实现,无法在同一图表上显示。所有 MTP 配置默认禁用,请从图例中启用一项来查看。'; - } - return `${joinListZh(labels)} 使用不同的 MTP 接受率实现,无法在同一图表上显示。所有 MTP 配置默认禁用,请从图例中启用一项来查看。`; -} - -const TITLES = { - en: "MTP configs from different engines can't share a graph", - zh: '不同引擎的 MTP 配置无法共享同一图表', -} as const; - -interface Props { - detail: MtpEngineConflictDetail | null; - onDismiss?: () => void; -} - -export function MtpEngineConflictToast({ detail, onDismiss }: Props) { - const locale = useLocale(); - const [seq, setSeq] = useState(0); - - useEffect(() => { - if (!detail) return; - setSeq((n) => n + 1); - track('inference_mtp_engine_conflict_blocked', { - kind: detail.kind, - attempted: detail.kind === 'blocked' ? detail.attempted : null, - existing: detail.kind === 'blocked' ? detail.existing : null, - families: detail.kind === 'cleared' ? detail.families : null, - }); - }, [detail]); - - if (!detail) return null; - - return ( - } - title={TITLES[locale]} - description={describe(detail, locale)} - onDismiss={onDismiss} - /> - ); -} diff --git a/packages/app/src/lib/data-mappings.test.ts b/packages/app/src/lib/data-mappings.test.ts index d05bf502..01cb3173 100644 --- a/packages/app/src/lib/data-mappings.test.ts +++ b/packages/app/src/lib/data-mappings.test.ts @@ -4,6 +4,8 @@ import { getModelAndSequence, getModelAndSequenceFromArtifact, getModelLabel, + getModelExclusion, + getSequenceExclusion, getSequenceLabel, getPrecisionLabel, getEvalBenchmarkLabel, @@ -197,6 +199,19 @@ describe('isSequenceDeprecated', () => { }); }); +describe('comparison exclusions', () => { + it('keeps the existing DeepSeek V4 MTP rule model-scoped', () => { + expect(getModelExclusion(Model.DeepSeek_V4_Pro).map((spec) => spec.suffix)).toEqual(['_mtp']); + expect(getModelExclusion(Model.DeepSeek_R1)).toEqual([]); + }); + + it('applies the unsuffixed STP rule only to Agentic Traces', () => { + expect(getSequenceExclusion(Sequence.AgenticTraces).map((spec) => spec.suffix)).toEqual([null]); + expect(getSequenceExclusion(Sequence.EightK_OneK)).toEqual([]); + expect(getSequenceExclusion(Sequence.OneK_OneK)).toEqual([]); + }); +}); + // =========================================================================== // getModelLabel // =========================================================================== diff --git a/packages/app/src/lib/data-mappings.ts b/packages/app/src/lib/data-mappings.ts index 3113a866..2b8fbde5 100644 --- a/packages/app/src/lib/data-mappings.ts +++ b/packages/app/src/lib/data-mappings.ts @@ -59,7 +59,24 @@ interface ModelConfig { * comparability group; vLLM is its own group. */ const MTP_ENGINE_EXCLUSION: ExclusionSpec[] = [ - { suffix: '_mtp', stripPrefixes: ['dynamo-', 'mori-'], groupAliases: { atom: 'sglang' } }, + { + suffix: '_mtp', + stripPrefixes: ['dynamo-', 'mori-', 'llmd-', 'mooncake-'], + groupAliases: { atom: 'sglang' }, + }, +]; + +/** + * AgentX STP exclusion: unsuffixed standard-token configs from different engine + * families can't be active together. Fixed-sequence STP comparisons remain + * available; this rule is attached only to the Agentic Traces sequence. + */ +const AGENTIC_STP_ENGINE_EXCLUSION: ExclusionSpec[] = [ + { + suffix: null, + stripPrefixes: ['dynamo-', 'mori-', 'llmd-', 'mooncake-'], + groupAliases: { atom: 'sglang' }, + }, ]; // Total parameter counts appended to each label so users can compare model @@ -193,10 +210,15 @@ export function sequenceKind(seq: Sequence): ScenarioKind { return seq === Sequence.AgenticTraces ? 'agentic' : 'fixed-seq'; } -const SEQUENCE_CONFIG: Record< - Sequence, - { label: string; compact: string; category: CategoryTag; kind: ScenarioKind } -> = { +interface SequenceConfig { + label: string; + compact: string; + category: CategoryTag; + kind: ScenarioKind; + exclusion?: ExclusionSpec[]; +} + +const SEQUENCE_CONFIG: Record = { [Sequence.OneK_OneK]: { label: '1K / 1K', compact: '1k1k', @@ -220,9 +242,18 @@ const SEQUENCE_CONFIG: Record< compact: 'agentic', category: 'default', kind: 'agentic', + exclusion: AGENTIC_STP_ENGINE_EXCLUSION, }, }; +/** Exclusion specs configured for a sequence. Empty when no rule applies. */ +export function getSequenceExclusion( + sequence: Sequence | string | null | undefined, +): ExclusionSpec[] { + if (!sequence) return []; + return SEQUENCE_CONFIG[sequence as Sequence]?.exclusion ?? []; +} + export const SEQUENCE_OPTIONS = Object.keys(SEQUENCE_CONFIG) as Sequence[]; /** diff --git a/packages/app/src/lib/exclusion.test.ts b/packages/app/src/lib/exclusion.test.ts index 71c79d6b..944d0bdf 100644 --- a/packages/app/src/lib/exclusion.test.ts +++ b/packages/app/src/lib/exclusion.test.ts @@ -6,7 +6,9 @@ import { buildExclusion, clearAllExclusionGroups, effectiveLegendItems, + exclusionResolutionFamilies, pickStickyGroup, + resolveExclusionGroups, resolveExclusionToggle, type ExclusionSpec, } from './exclusion'; @@ -14,9 +16,27 @@ import { // The dsv4 MTP rule: `*_mtp` keys participate, dynamo-/mori- prefixes are // stripped, and ATOM shares SGLang's comparability group. const MTP_SPEC: ExclusionSpec[] = [ - { suffix: '_mtp', stripPrefixes: ['dynamo-', 'mori-'], groupAliases: { atom: 'sglang' } }, + { + suffix: '_mtp', + stripPrefixes: ['dynamo-', 'mori-', 'llmd-', 'mooncake-'], + groupAliases: { atom: 'sglang' }, + }, ]; const ex = buildExclusion(MTP_SPEC); +const STP_SPEC: ExclusionSpec[] = [ + { + suffix: null, + stripPrefixes: ['dynamo-', 'mori-', 'llmd-', 'mooncake-'], + groupAliases: { atom: 'sglang' }, + }, +]; +const agenticEx = buildExclusion([...MTP_SPEC, ...STP_SPEC]); +const namespacedAgenticEx = { + familyOf: (key: string) => + agenticEx.familyOf(key.startsWith('overlay:') ? key.slice('overlay:'.length) : key), + groupOf: (key: string) => + agenticEx.groupOf(key.startsWith('overlay:') ? key.slice('overlay:'.length) : key), +}; describe('buildExclusion — familyOf', () => { it('returns null for non-participating keys', () => { @@ -38,6 +58,8 @@ describe('buildExclusion — familyOf', () => { expect(ex.familyOf('gb300_dynamo-sglang_mtp')).toBe('sglang'); expect(ex.familyOf('h100_dynamo-trt_mtp')).toBe('trt'); expect(ex.familyOf('mi355x_mori-sglang_mtp')).toBe('sglang'); + expect(ex.familyOf('b300_llmd-vllm_mtp')).toBe('vllm'); + expect(ex.familyOf('mi355x_mooncake-atom_mtp')).toBe('atom'); }); }); @@ -65,6 +87,86 @@ describe('buildExclusion — groupOf', () => { }); }); +describe('exclusionResolutionFamilies', () => { + it('reports literal aliased families rather than comparability group ids', () => { + const proposed = new Set(['mi355x_atom_mtp', 'gb300_sglang_mtp', 'h100_vllm_mtp', 'h100_vllm']); + const result = new Set(['mi355x_atom_mtp', 'gb300_sglang_mtp', 'h100_vllm']); + + expect(exclusionResolutionFamilies(proposed, result, ex)).toEqual({ + kept: ['atom', 'sglang'], + dropped: ['vllm'], + }); + }); +}); + +describe('AgentX STP engine exclusion', () => { + it('classifies unsuffixed STP keys without capturing speculative variants', () => { + const stpEx = buildExclusion(STP_SPEC); + expect(stpEx.familyOf('b300_vllm')).toBe('vllm'); + expect(stpEx.familyOf('gb300_dynamo-sglang')).toBe('sglang'); + expect(stpEx.groupOf('mi355x_atom')).toBe('sglang'); + expect(stpEx.familyOf('b300_vllm_mtp')).toBeNull(); + expect(stpEx.familyOf('b300_llmd-vllm')).toBe('vllm'); + expect(stpEx.groupOf('mi355x_mooncake-atom')).toBe('sglang'); + }); + + it('blocks adding vLLM STP while SGLang STP is active', () => { + const prev = new Set(['b300_sglang']); + const all = new Set(['b300_sglang', 'b300_vllm']); + expect(resolveExclusionToggle(prev, 'b300_vllm', all, agenticEx, 'keep-sticky')).toEqual({ + kind: 'block', + attempted: 'vllm', + existing: 'sglang', + }); + }); + + it('allows STP and MTP configs from the same engine family', () => { + const prev = new Set(['b300_vllm']); + const all = new Set(['b300_vllm', 'b300_vllm_mtp']); + expect(resolveExclusionToggle(prev, 'b300_vllm_mtp', all, agenticEx, 'keep-sticky')).toEqual({ + kind: 'fallthrough', + }); + }); + + it('keeps the active engine during automatic AgentX selection resolution', () => { + const proposed = new Set(['b300_sglang', 'b300_vllm', 'b300_vllm_mtp']); + const resolved = resolveExclusionGroups( + proposed, + new Set(['b300_vllm']), + agenticEx, + 'keep-sticky', + ); + expect([...resolved.result].toSorted()).toEqual(['b300_vllm', 'b300_vllm_mtp']); + expect(resolved.keptGroup).toBe('vllm'); + expect(resolved.droppedGroups).toEqual(['sglang']); + }); + + it('blocks cross-engine adds across official and overlay namespaces', () => { + const prev = new Set(['overlay:b300_sglang']); + const all = new Set(['overlay:b300_sglang', 'b300_vllm']); + expect( + resolveExclusionToggle(prev, 'b300_vllm', all, namespacedAgenticEx, 'keep-sticky'), + ).toEqual({ + kind: 'block', + attempted: 'vllm', + existing: 'sglang', + }); + }); + + it('keeps the official engine when an automatic overlay load conflicts', () => { + const proposed = new Set(['b300_sglang', 'overlay:b300_vllm']); + const resolved = resolveExclusionGroups( + proposed, + new Set(['b300_sglang']), + namespacedAgenticEx, + 'keep-sticky', + ); + expect([...resolved.result]).toEqual(['b300_sglang']); + expect(resolved.keptGroup).toBe('sglang'); + expect(resolved.droppedGroups).toEqual(['vllm']); + }); +}); + // ATOM and SGLang share the upstream ROCm MTP path, so they belong to one // comparability group: they may co-exist on a graph and are jointly exclusive // with other engines (vLLM). Only enforced where the model has an exclusion rule. @@ -274,14 +376,14 @@ describe('resolveExclusionToggle', () => { }); }); - it('silent-disable-all when solo→restore would surface multiple groups', () => { + it('silently resolves when solo→restore would surface multiple groups', () => { // prev is a single non-MTP item; toggling it triggers "restore all", which // would surface all items including two groups. const prev = new Set(['h100_vllm']); const all = new Set(['h100_vllm', 'h100_vllm_mtp', 'gb300_sglang_mtp']); const decision = resolveExclusionToggle(prev, 'h100_vllm', all, ex); - expect(decision.kind).toBe('silent-disable-all'); - if (decision.kind !== 'silent-disable-all') return; + expect(decision.kind).toBe('silent-resolve'); + if (decision.kind !== 'silent-resolve') return; expect([...decision.result].toSorted()).toEqual(['h100_vllm']); }); diff --git a/packages/app/src/lib/exclusion.ts b/packages/app/src/lib/exclusion.ts index fa2a3035..86bb5445 100644 --- a/packages/app/src/lib/exclusion.ts +++ b/packages/app/src/lib/exclusion.ts @@ -1,28 +1,33 @@ +import { SPEC_METHOD_KEYS } from '@semianalysisai/inferencex-constants'; + import { computeToggle } from '@/hooks/useTogglableSet'; /** * Data-driven config exclusion. * - * Some models can't show certain config variants on the same graph because the - * numbers aren't directly comparable. The first case is dsv4 MTP: different - * engines force speculative acceptance differently, so two engines' MTP curves - * mean different things side by side. + * Some models or scenarios can't show certain config variants on the same graph + * because the numbers aren't directly comparable. DeepSeek V4 MTP configs use + * engine-specific acceptance forcing; AgentX also keeps standard-token (STP) + * results from different engines separate while the benchmark is new. * - * The rule is expressed as DATA — an `ExclusionSpec[]` declared on the model - * (see `data-mappings.ts`) — then compiled into resolvers by `buildExclusion`. - * Every helper here operates on a compiled `Exclusion`, so adding a new - * exclusivity rule means adding data, not code. + * Rules are expressed as DATA — `ExclusionSpec[]` values declared by model or + * sequence in `data-mappings.ts` — then compiled into resolvers by + * `buildExclusion`. Every helper here operates on a compiled `Exclusion`, so + * adding an exclusivity rule means adding data, not branching UI code. * - * Model: participating keys are partitioned into comparability GROUPS. Keys in - * the same group may be active together; keys in different groups are mutually - * exclusive — at most one group active at a time. (For dsv4 MTP, ATOM and SGLang - * share the upstream ROCm path → one group; vLLM is its own group.) + * Participating keys are partitioned into comparability GROUPS. Keys in the + * same group may be active together; keys in different groups are mutually + * exclusive — at most one group active at a time. ATOM and SGLang may share a + * group while vLLM remains separate. */ /** Data params defining one exclusion rule. */ export interface ExclusionSpec { - /** Only hwKeys ending in this suffix participate in the rule (e.g. `_mtp`). */ - suffix: string; + /** + * Non-empty hwKey suffix for a variant (for example `_mtp`), or `null` for + * standard-token configs whose hwKeys have no speculative-method suffix. + */ + suffix: string | null; /** * Engine-family prefixes stripped from the framework segment before grouping * (e.g. `dynamo-`, `mori-`), so `h100_dynamo-vllm_mtp` resolves to `vllm`. @@ -36,7 +41,7 @@ export interface ExclusionSpec { groupAliases?: Record; } -/** Compiled resolvers for a model's exclusion specs. */ +/** Compiled resolvers for model- or sequence-scoped exclusion specs. */ export interface Exclusion { /** Literal engine family of a participating key (for display), else null. */ familyOf: (hwKey: string) => string | null; @@ -44,14 +49,25 @@ export interface Exclusion { groupOf: (hwKey: string) => string | null; } +const ACTIVE_SPEC_SUFFIXES = [...SPEC_METHOD_KEYS] + .filter((method) => method !== 'none') + .map((method) => `_${method}`); + /** * Extract the literal engine family for `hwKey` under a single spec: strip the - * trailing suffix, drop the leading GPU segment, then strip any configured - * engine-family prefix. Returns null if the key doesn't participate. + * configured variant suffix (or require an unsuffixed STP key), drop the leading + * GPU segment, then strip any configured engine-family prefix. Returns null if + * the key doesn't participate. */ function familyForSpec(hwKey: string, spec: ExclusionSpec): string | null { - if (!hwKey.endsWith(spec.suffix)) return null; - const head = hwKey.slice(0, -spec.suffix.length); + let head: string; + if (spec.suffix === null) { + if (ACTIVE_SPEC_SUFFIXES.some((suffix) => hwKey.endsWith(suffix))) return null; + head = hwKey; + } else { + if (spec.suffix.length === 0 || !hwKey.endsWith(spec.suffix)) return null; + head = hwKey.slice(0, -spec.suffix.length); + } const firstUnderscore = head.indexOf('_'); if (firstUnderscore === -1) return null; let framework = head.slice(firstUnderscore + 1); @@ -66,10 +82,10 @@ function familyForSpec(hwKey: string, spec: ExclusionSpec): string | null { /** * Compile a list of `ExclusionSpec`s into `familyOf` / `groupOf` resolvers. - * The first spec that matches a key wins (specs are expected to be disjoint by - * suffix in practice). + * The first spec that matches a key wins; variant-specific suffixes and the + * unsuffixed STP matcher are disjoint. */ -export function buildExclusion(specs: ExclusionSpec[]): Exclusion { +export function buildExclusion(specs: readonly ExclusionSpec[]): Exclusion { return { familyOf(hwKey: string): string | null { for (const spec of specs) { @@ -206,6 +222,42 @@ export function clearAllExclusionGroups( return { result, droppedGroups: [...byGroup.keys()] }; } +export type ExclusionConflictPolicy = 'clear-all' | 'keep-sticky'; + +export interface ExclusionResolution { + result: Set; + keptGroup: string | null; + droppedGroups: string[]; +} + +/** Resolve a multi-group set according to the view's default-selection policy. */ +export function resolveExclusionGroups( + proposed: Set, + prev: Set, + ex: Exclusion, + policy: ExclusionConflictPolicy = 'clear-all', +): ExclusionResolution { + if (policy === 'keep-sticky') return pickStickyGroup(proposed, prev, ex); + const cleared = clearAllExclusionGroups(proposed, ex); + return { ...cleared, keptGroup: null }; +} + +/** Literal engine families retained and removed by an exclusion resolution. */ +export function exclusionResolutionFamilies( + proposed: Iterable, + result: ReadonlySet, + ex: Exclusion, +): { kept: string[]; dropped: string[] } { + const kept = new Set(); + const dropped = new Set(); + for (const key of proposed) { + const family = ex.familyOf(key); + if (!family) continue; + (result.has(key) ? kept : dropped).add(family); + } + return { kept: [...kept].toSorted(), dropped: [...dropped].toSorted() }; +} + /** * Decision for a single hw-toggle action under an exclusion rule. * @@ -213,14 +265,14 @@ export function clearAllExclusionGroups( * the group already active. The provider should refuse the toggle (no state * change) and surface a toast. `attempted` / `existing` name the literal * engine families for display. - * - `silent-disable-all`: the toggle would surface multiple groups (e.g. via - * solo→restore-all). Replace the active set with `result` and don't show a - * toast — the user didn't explicitly try to add anything. + * - `silent-resolve`: the toggle would surface multiple groups (e.g. via + * solo→restore-all). Replace the active set with the policy-resolved `result` + * and don't show a toast — the user didn't explicitly try to add anything. * - `fallthrough`: the toggle is fine, run the normal toggle path. */ export type ExclusionToggleDecision = | { kind: 'block'; attempted: string; existing: string | null } - | { kind: 'silent-disable-all'; result: Set } + | { kind: 'silent-resolve'; result: Set } | { kind: 'fallthrough' }; export function resolveExclusionToggle( @@ -228,6 +280,7 @@ export function resolveExclusionToggle( hw: string, allItems: Set, ex: Exclusion, + policy: ExclusionConflictPolicy = 'clear-all', ): ExclusionToggleDecision { const proposed = computeToggle(prev, hw, allItems); const wasActive = prev.has(hw); @@ -247,11 +300,11 @@ export function resolveExclusionToggle( } } - // Other paths (e.g. solo→restore-all surfacing a hidden second group) — - // disable every group silently. - const cleared = clearAllExclusionGroups(proposed, ex); - if (cleared.droppedGroups.length > 0) { - return { kind: 'silent-disable-all', result: cleared.result }; + // Other paths (e.g. solo→restore-all surfacing a hidden second group) are + // normalized silently because the user didn't explicitly add a conflict. + const resolved = resolveExclusionGroups(proposed, prev, ex, policy); + if (resolved.droppedGroups.length > 0) { + return { kind: 'silent-resolve', result: resolved.result }; } return { kind: 'fallthrough' };