From e28bb0d985ba04a2843f7a403cf28af73c239360 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:28:53 -0500 Subject: [PATCH] refactor(inference): data-driven config exclusion; group ATOM+SGLang MTP on dsv4 --- .../favorites/favorite-presets.test.ts | 8 +- .../components/favorites/favorite-presets.ts | 19 +- .../components/inference/InferenceContext.tsx | 76 +++-- packages/app/src/lib/data-mappings.ts | 37 ++- packages/app/src/lib/exclusion.test.ts | 293 ++++++++++++++++++ packages/app/src/lib/exclusion.ts | 258 +++++++++++++++ packages/app/src/lib/mtp-exclusion.test.ts | 192 ------------ packages/app/src/lib/mtp-exclusion.ts | 168 ---------- 8 files changed, 632 insertions(+), 419 deletions(-) create mode 100644 packages/app/src/lib/exclusion.test.ts create mode 100644 packages/app/src/lib/exclusion.ts delete mode 100644 packages/app/src/lib/mtp-exclusion.test.ts delete mode 100644 packages/app/src/lib/mtp-exclusion.ts diff --git a/packages/app/src/components/favorites/favorite-presets.test.ts b/packages/app/src/components/favorites/favorite-presets.test.ts index cf46867d..055969ad 100644 --- a/packages/app/src/components/favorites/favorite-presets.test.ts +++ b/packages/app/src/components/favorites/favorite-presets.test.ts @@ -13,8 +13,8 @@ import { // ── matchesPresetHwFilter ──────────────────────────────────────────── describe('matchesPresetHwFilter', () => { - const dsv4 = Model.DeepSeek_V4_Pro; // mtpEngineExclusion = true - const dsr1 = Model.DeepSeek_R1; // no MTP exclusion + const dsv4 = Model.DeepSeek_V4_Pro; // has an MTP exclusion rule + const dsr1 = Model.DeepSeek_R1; // no exclusion rule it('matches a bare GPU prefix against any framework variant on that GPU', () => { expect(matchesPresetHwFilter('b300_sglang', ['b300'], dsv4)).toBe(true); @@ -22,8 +22,8 @@ describe('matchesPresetHwFilter', () => { expect(matchesPresetHwFilter('b300_dynamo-vllm', ['b300'], dsv4)).toBe(true); }); - it('skips _mtp keys via a bare GPU prefix only for mtpEngineExclusion models', () => { - // dsv4 has mtpEngineExclusion → MTP keys filtered out under bare prefix + it('skips _mtp keys via a bare GPU prefix only for models with an exclusion rule', () => { + // dsv4 has an MTP exclusion rule → MTP keys filtered out under bare prefix expect(matchesPresetHwFilter('b300_sglang_mtp', ['b300'], dsv4)).toBe(false); expect(matchesPresetHwFilter('b300_vllm_mtp', ['b300'], dsv4)).toBe(false); // dsr1 (and other models) → bare prefix still pulls MTP variants through diff --git a/packages/app/src/components/favorites/favorite-presets.ts b/packages/app/src/components/favorites/favorite-presets.ts index 27d13029..9b17848f 100644 --- a/packages/app/src/components/favorites/favorite-presets.ts +++ b/packages/app/src/components/favorites/favorite-presets.ts @@ -1,5 +1,5 @@ import type { BenchmarkRow } from '@/lib/api'; -import { hasMtpEngineExclusion, Model, Sequence } from '@/lib/data-mappings'; +import { getModelExclusion, Model, Sequence } from '@/lib/data-mappings'; export interface FavoritePreset { id: string; @@ -26,23 +26,20 @@ export interface FavoritePreset { * Match an hwKey against a preset's hwFilter. Exact entries always match * exactly (so MTP keys like `h100_dynamo-trt_mtp` can be explicitly opted in). * Bare GPU prefixes (no underscore) match any framework variant on that GPU, - * but for models with `mtpEngineExclusion` (currently dsv4) they also skip - * `_mtp` keys — otherwise the preset would surface two engine families' - * forced-acceptance MTP numbers on the same chart, which the legend toggle - * guard already blocks for explicit user actions. + * but for models with an exclusion rule (currently dsv4 MTP) they also skip + * keys matching the rule's suffix — otherwise the preset would surface two + * comparability groups on the same chart, which the legend toggle guard already + * blocks for explicit user actions. */ export function matchesPresetHwFilter( hwKey: string, filter: string[], model: Model | string | null | undefined, ): boolean { - const skipMtpOnPrefix = hasMtpEngineExclusion(model); + const excludedSuffixes = getModelExclusion(model).map((spec) => spec.suffix); + const isExcludedVariant = excludedSuffixes.some((suffix) => hwKey.endsWith(suffix)); return filter.some( - (f) => - hwKey === f || - (!f.includes('_') && - hwKey.startsWith(`${f}_`) && - !(skipMtpOnPrefix && hwKey.endsWith('_mtp'))), + (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 3c929af0..fc992ee4 100644 --- a/packages/app/src/components/inference/InferenceContext.tsx +++ b/packages/app/src/components/inference/InferenceContext.tsx @@ -43,12 +43,17 @@ import { import { useUrlState } from '@/hooks/useUrlState'; import { buildAvailabilityHwKey } from '@/lib/chart-utils'; import { getHardwareConfig, getModelSortIndex, isKnownGpu, TABLEAU_10 } from '@/lib/constants'; -import { hasMtpEngineExclusion, MODEL_PREFIX_MAPPING } from '@/lib/data-mappings'; +import { getModelExclusion, MODEL_PREFIX_MAPPING } from '@/lib/data-mappings'; import { MtpEngineConflictToast, type MtpEngineConflictDetail, } from '@/components/mtp-engine-conflict-toast'; -import { clearAllMtpFamilies, effectiveLegendItems, resolveMtpToggle } from '@/lib/mtp-exclusion'; +import { + buildExclusion, + clearAllExclusionGroups, + effectiveLegendItems, + resolveExclusionToggle, +} from '@/lib/exclusion'; import { filterRunsByModel, getDisplayLabel } from '@/lib/utils'; import { useChartData } from './hooks/useChartData'; @@ -422,8 +427,8 @@ export function InferenceProvider({ const pendingHwFilterRef = useRef(pendingHwFilter); pendingHwFilterRef.current = pendingHwFilter; // Read selectedModel via a ref so the callback identity below stays stable — - // matchesPresetHwFilter only consults the model to gate the bare-prefix MTP - // skip (mtpEngineExclusion models), and we want the current value at call time. + // matchesPresetHwFilter only consults the model to gate the bare-prefix + // exclusion-suffix skip, and we want the current value at call time. const selectedModelRef = useRef(selectedModel); selectedModelRef.current = selectedModel; // Note: setActiveHwTypes is a useState dispatcher that accepts functional updaters, @@ -474,18 +479,21 @@ export function InferenceProvider({ } }, [pendingHwFilter, hwTypesWithData, setActiveHwTypes]); - const mtpExclusion = hasMtpEngineExclusion(selectedModel); + const exclusion = useMemo(() => { + const specs = getModelExclusion(selectedModel); + return specs.length > 0 ? buildExclusion(specs) : null; + }, [selectedModel]); const toggleHwType = useCallback( (hw: string) => { - // Under MTP exclusion, hide MTP keys from inactive families when + // Under exclusion, hide participating keys from inactive groups when // computing the toggle "universe". This makes the default-deselected - // state (DSv4 on first load) count as "all selected", so clicking a + // state (DSv4 MTP on first load) count as "all selected", so clicking a // legend entry solos it instead of just removing it. - const toggleUniverse = mtpExclusion - ? effectiveLegendItems(hwTypesWithData, activeHwTypes) + const toggleUniverse = exclusion + ? effectiveLegendItems(hwTypesWithData, activeHwTypes, exclusion) : hwTypesWithData; - if (mtpExclusion) { - const decision = resolveMtpToggle(activeHwTypes, hw, toggleUniverse); + if (exclusion) { + const decision = resolveExclusionToggle(activeHwTypes, hw, toggleUniverse, exclusion); if (decision.kind === 'block') { setMtpConflict({ kind: 'blocked', @@ -505,7 +513,7 @@ export function InferenceProvider({ setActivePresetId(null); presetHwFilterRef.current = null; }, - [toggleHwRaw, hwTypesWithData, mtpExclusion, activeHwTypes, setActiveHwTypes], + [toggleHwRaw, hwTypesWithData, exclusion, activeHwTypes, setActiveHwTypes], ); const removeHwType = useCallback( @@ -536,16 +544,16 @@ export function InferenceProvider({ ); const removeActiveDate = useCallback((id: string) => removeDateRaw(id), [removeDateRaw]); const selectAllHwTypes = useCallback(() => { - if (mtpExclusion) { - const { result, droppedFamilies } = clearAllMtpFamilies(hwTypesWithData); + if (exclusion) { + const { result, droppedGroups } = clearAllExclusionGroups(hwTypesWithData, exclusion); setActiveHwTypes(result); - if (droppedFamilies.length > 0) { - setMtpConflict({ kind: 'cleared', families: droppedFamilies }); + if (droppedGroups.length > 0) { + setMtpConflict({ kind: 'cleared', families: droppedGroups }); } return; } selectAllHwRaw(hwTypesWithData); - }, [selectAllHwRaw, hwTypesWithData, mtpExclusion, setActiveHwTypes]); + }, [selectAllHwRaw, hwTypesWithData, exclusion, setActiveHwTypes]); const selectAllActiveDates = useCallback( () => selectAllDatesRaw(allDateIds), [selectAllDatesRaw, allDateIds], @@ -588,11 +596,11 @@ export function InferenceProvider({ // → fall back to the default "all available" set. MTP sanitization is then // applied below so the fallback itself is engine-exclusion safe. if (restored.size === 0) restored = hwTypesWithData; - if (mtpExclusion) { - const cleared = clearAllMtpFamilies(restored); + if (exclusion) { + const cleared = clearAllExclusionGroups(restored, exclusion); restored = cleared.result; - if (cleared.droppedFamilies.length > 0) { - setMtpConflict({ kind: 'cleared', families: cleared.droppedFamilies }); + if (cleared.droppedGroups.length > 0) { + setMtpConflict({ kind: 'cleared', families: cleared.droppedGroups }); } } setActiveHwTypes(restored); @@ -601,7 +609,7 @@ export function InferenceProvider({ }, [ pendingActiveHwTypes, hwTypesWithData, - mtpExclusion, + exclusion, selectedModel, effectiveSequence, precisionsKey, @@ -622,22 +630,22 @@ export function InferenceProvider({ ); if (filtered.size > 0) { // Presets explicitly chose hw configs — respect their picks. The - // matcher already excludes _mtp under bare prefixes for - // mtpEngineExclusion models, so we don't fall through to - // clearAllMtpFamilies (which would fire the toast). The legend - // toggle guard still blocks adding a second engine family later. + // 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); return; } } - if (mtpExclusion) { - // When multiple engine families' MTP have data, disable them all by - // default and surface a toast. The user has to opt in to one engine's - // MTP explicitly — never multiple at once. - const { result, droppedFamilies } = clearAllMtpFamilies(hwTypesWithData); + 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); setActiveHwTypes(result); - if (droppedFamilies.length > 0) { - setMtpConflict({ kind: 'cleared', families: droppedFamilies }); + if (droppedGroups.length > 0) { + setMtpConflict({ kind: 'cleared', families: droppedGroups }); } return; } @@ -647,7 +655,7 @@ export function InferenceProvider({ effectiveSequence, precisionsKey, hwTypesWithData, - mtpExclusion, + exclusion, pendingActiveHwTypes, ]); diff --git a/packages/app/src/lib/data-mappings.ts b/packages/app/src/lib/data-mappings.ts index 62d16ce1..0cbf4072 100644 --- a/packages/app/src/lib/data-mappings.ts +++ b/packages/app/src/lib/data-mappings.ts @@ -1,3 +1,5 @@ +import type { ExclusionSpec } from './exclusion'; + export enum Model { Llama3_3_70B = 'Llama-3.3-70B-Instruct-FP8', Llama3_1_70B = 'Llama-3.1-70B-Instruct-FP8-KV', @@ -41,13 +43,23 @@ interface ModelConfig { prefix: string; category: CategoryTag; /** - * If true, MTP configs from different engine families (e.g. vLLM and SGLang) - * cannot be active simultaneously, since their acceptance-rate forcing - * implementations differ and aren't directly comparable on the same graph. + * Data-driven exclusion rules for this model (see `exclusion.ts`). Each spec + * partitions matching config keys into comparability groups that can't share + * a graph with each other. Absent/empty = no exclusion. */ - mtpEngineExclusion?: boolean; + exclusion?: ExclusionSpec[]; } +/** + * dsv4 MTP exclusion: MTP configs (`*_mtp`) from different engine families can't + * be active together because their acceptance-rate forcing implementations + * differ. ATOM and SGLang share the upstream ROCm MTP path, so they form one + * comparability group; vLLM is its own group. + */ +const MTP_ENGINE_EXCLUSION: ExclusionSpec[] = [ + { suffix: '_mtp', stripPrefixes: ['dynamo-', 'mori-'], groupAliases: { atom: 'sglang' } }, +]; + // Total parameter counts appended to each label so users can compare model // scale at a glance in the dropdown. For Llama and gpt-oss the count is // already part of the canonical name (Llama 3.3 70B, gpt-oss 120B) so no @@ -58,7 +70,7 @@ const MODEL_CONFIG: Record = { label: 'DeepSeek V4 Pro 1.6T', prefix: 'dsv4', category: 'default', - mtpEngineExclusion: true, + exclusion: MTP_ENGINE_EXCLUSION, }, [Model.Kimi_K2_5]: { // K2.5 and K2.6 share an architecture, so the dropdown surfaces both @@ -117,12 +129,17 @@ export function getModelLabel(model: Model): string { } /** - * True if the model enforces the rule that MTP configs from different engine - * families can't be shown on the same graph. + * Exclusion specs configured for a model (see `exclusion.ts`). Empty when the + * model has no exclusion rules. */ -export function hasMtpEngineExclusion(model: Model | string | null | undefined): boolean { - if (!model) return false; - return MODEL_CONFIG[model as Model]?.mtpEngineExclusion === true; +export function getModelExclusion(model: Model | string | null | undefined): ExclusionSpec[] { + if (!model) return []; + return MODEL_CONFIG[model as Model]?.exclusion ?? []; +} + +/** True if the model has any config-exclusion rule. */ +export function hasExclusion(model: Model | string | null | undefined): boolean { + return getModelExclusion(model).length > 0; } /** diff --git a/packages/app/src/lib/exclusion.test.ts b/packages/app/src/lib/exclusion.test.ts new file mode 100644 index 00000000..71c79d6b --- /dev/null +++ b/packages/app/src/lib/exclusion.test.ts @@ -0,0 +1,293 @@ +import { describe, it, expect } from 'vitest'; + +import { computeToggle } from '@/hooks/useTogglableSet'; + +import { + buildExclusion, + clearAllExclusionGroups, + effectiveLegendItems, + pickStickyGroup, + resolveExclusionToggle, + type ExclusionSpec, +} from './exclusion'; + +// 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' } }, +]; +const ex = buildExclusion(MTP_SPEC); + +describe('buildExclusion — familyOf', () => { + it('returns null for non-participating keys', () => { + expect(ex.familyOf('h100_vllm')).toBeNull(); + expect(ex.familyOf('gb300_sglang')).toBeNull(); + expect(ex.familyOf('h100')).toBeNull(); + expect(ex.familyOf('')).toBeNull(); + }); + + it('extracts the literal engine family for participating keys', () => { + expect(ex.familyOf('h100_vllm_mtp')).toBe('vllm'); + expect(ex.familyOf('gb300_sglang_mtp')).toBe('sglang'); + expect(ex.familyOf('h100_trt_mtp')).toBe('trt'); + expect(ex.familyOf('mi355x_atom_mtp')).toBe('atom'); + }); + + it('strips configured engine-family prefixes', () => { + expect(ex.familyOf('h100_dynamo-vllm_mtp')).toBe('vllm'); + 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'); + }); +}); + +describe('buildExclusion — groupOf', () => { + it('returns null for non-participating keys', () => { + expect(ex.groupOf('mi355x_atom')).toBeNull(); + expect(ex.groupOf('mi355x_sglang')).toBeNull(); + expect(ex.groupOf('')).toBeNull(); + }); + + it('collapses aliased families into a shared comparability group', () => { + expect(ex.groupOf('mi355x_atom_mtp')).toBe('sglang'); + expect(ex.groupOf('mi355x_sglang_mtp')).toBe('sglang'); + }); + + it('leaves other engines as their own group', () => { + expect(ex.groupOf('mi355x_vllm_mtp')).toBe('vllm'); + expect(ex.groupOf('h100_dynamo-trt_mtp')).toBe('trt'); + }); + + it('honors a custom suffix in the spec', () => { + const eagle = buildExclusion([{ suffix: '_eagle' }]); + expect(eagle.groupOf('h100_vllm_eagle')).toBe('vllm'); + expect(eagle.groupOf('h100_vllm_mtp')).toBeNull(); + }); +}); + +// 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. +describe('ATOM/SGLang comparability group', () => { + it('treats ATOM + SGLang MTP as a single group (coexist, no drop)', () => { + const set = new Set(['mi355x_atom_mtp', 'mi355x_sglang_mtp', 'mi355x_vllm']); + const sticky = pickStickyGroup(set, new Set(), ex); + expect(sticky.result).toBe(set); + expect(sticky.droppedGroups).toEqual([]); + expect(sticky.keptGroup).toBe('sglang'); + + const cleared = clearAllExclusionGroups(set, ex); + expect(cleared.result).toBe(set); + expect(cleared.droppedGroups).toEqual([]); + }); + + it('keeps ATOM + SGLang exclusive from vLLM MTP', () => { + const proposed = new Set(['mi355x_atom_mtp', 'mi355x_sglang_mtp', 'mi355x_vllm_mtp']); + const cleared = clearAllExclusionGroups(proposed, ex); + expect([...cleared.result]).toEqual([]); + expect(cleared.droppedGroups.toSorted()).toEqual(['sglang', 'vllm']); + }); + + it('lets ATOM MTP coexist with SGLang MTP on toggle', () => { + const prev = new Set(['mi355x_sglang_mtp', 'mi355x_sglang']); + const all = new Set(['mi355x_sglang_mtp', 'mi355x_sglang', 'mi355x_atom_mtp', 'mi355x_atom']); + expect(resolveExclusionToggle(prev, 'mi355x_atom_mtp', all, ex)).toEqual({ + kind: 'fallthrough', + }); + }); + + it('blocks vLLM MTP while ATOM MTP is active, naming ATOM as the conflict', () => { + const prev = new Set(['mi355x_atom_mtp']); + const all = new Set(['mi355x_atom_mtp', 'mi355x_vllm_mtp']); + expect(resolveExclusionToggle(prev, 'mi355x_vllm_mtp', all, ex)).toEqual({ + kind: 'block', + attempted: 'vllm', + existing: 'atom', + }); + }); + + it('blocks ATOM MTP while vLLM MTP is active, naming vLLM as the conflict', () => { + const prev = new Set(['mi355x_vllm_mtp']); + const all = new Set(['mi355x_vllm_mtp', 'mi355x_atom_mtp']); + expect(resolveExclusionToggle(prev, 'mi355x_atom_mtp', all, ex)).toEqual({ + kind: 'block', + attempted: 'atom', + existing: 'vllm', + }); + }); + + it('surfaces ATOM MTP in the legend universe when SGLang MTP is active', () => { + const all = new Set([ + 'mi355x_sglang', + 'mi355x_sglang_mtp', + 'mi355x_atom_mtp', + 'mi355x_vllm_mtp', + ]); + const active = new Set(['mi355x_sglang', 'mi355x_sglang_mtp']); + const out = effectiveLegendItems(all, active, ex); + // sglang_mtp active → sglang group active → atom_mtp (same group) kept, + // vllm_mtp (different group) dropped. + expect([...out].toSorted()).toEqual(['mi355x_atom_mtp', 'mi355x_sglang', 'mi355x_sglang_mtp']); + }); +}); + +describe('pickStickyGroup', () => { + it('passes through when no participating keys present', () => { + const set = new Set(['h100_vllm', 'gb300_sglang']); + const out = pickStickyGroup(set, new Set(), ex); + expect(out.result).toBe(set); + expect(out.droppedGroups).toEqual([]); + expect(out.keptGroup).toBeNull(); + }); + + it('passes through when only one group present', () => { + const set = new Set(['h100_vllm_mtp', 'h100_dynamo-vllm_mtp', 'gb300_sglang']); + const out = pickStickyGroup(set, new Set(), ex); + expect(out.result).toBe(set); + expect(out.droppedGroups).toEqual([]); + expect(out.keptGroup).toBe('vllm'); + }); + + it('drops the non-sticky group when prev had one', () => { + const proposed = new Set(['h100_vllm_mtp', 'gb300_sglang_mtp', 'h100_vllm']); + const prev = new Set(['h100_vllm_mtp']); + const out = pickStickyGroup(proposed, prev, ex); + expect(out.keptGroup).toBe('vllm'); + expect([...out.result].toSorted()).toEqual(['h100_vllm', 'h100_vllm_mtp']); + expect(out.droppedGroups).toEqual(['sglang']); + }); + + it('falls back to alphabetical when neither group was in prev', () => { + const proposed = new Set(['h100_vllm_mtp', 'gb300_sglang_mtp']); + const out = pickStickyGroup(proposed, new Set(), ex); + expect(out.keptGroup).toBe('sglang'); + expect([...out.result]).toEqual(['gb300_sglang_mtp']); + expect(out.droppedGroups).toEqual(['vllm']); + }); + + it('treats dynamo/mori variants as the same group', () => { + const proposed = new Set(['h100_vllm_mtp', 'h100_dynamo-vllm_mtp', 'gb300_dynamo-sglang_mtp']); + const out = pickStickyGroup(proposed, new Set(['h100_vllm_mtp']), ex); + expect(out.keptGroup).toBe('vllm'); + expect([...out.result].toSorted()).toEqual(['h100_dynamo-vllm_mtp', 'h100_vllm_mtp']); + expect(out.droppedGroups).toEqual(['sglang']); + }); +}); + +describe('clearAllExclusionGroups', () => { + it('passes through when no participating keys present', () => { + const set = new Set(['h100_vllm', 'gb300_sglang']); + const out = clearAllExclusionGroups(set, ex); + expect(out.result).toBe(set); + expect(out.droppedGroups).toEqual([]); + }); + + it('passes through when only one group present', () => { + const set = new Set(['h100_vllm_mtp', 'h100_dynamo-vllm_mtp', 'h100_vllm']); + const out = clearAllExclusionGroups(set, ex); + expect(out.result).toBe(set); + expect(out.droppedGroups).toEqual([]); + }); + + it('drops every group when multiple are present', () => { + const proposed = new Set(['h100_vllm_mtp', 'gb300_sglang_mtp', 'h100_vllm', 'gb300_sglang']); + const out = clearAllExclusionGroups(proposed, ex); + expect([...out.result].toSorted()).toEqual(['gb300_sglang', 'h100_vllm']); + expect(out.droppedGroups.toSorted()).toEqual(['sglang', 'vllm']); + }); +}); + +describe('effectiveLegendItems', () => { + it('returns the input set unchanged when no participating keys present', () => { + const all = new Set(['h100_vllm', 'gb300_sglang']); + const active = new Set(['h100_vllm']); + const out = effectiveLegendItems(all, active, ex); + expect([...out].toSorted()).toEqual(['gb300_sglang', 'h100_vllm']); + }); + + it('drops participating keys whose group is not active (default DSv4 state)', () => { + const all = new Set(['h100_vllm', 'gb300_sglang', 'h100_vllm_mtp', 'gb300_sglang_mtp']); + const active = new Set(['h100_vllm', 'gb300_sglang']); // no MTP active + const out = effectiveLegendItems(all, active, ex); + expect([...out].toSorted()).toEqual(['gb300_sglang', 'h100_vllm']); + }); + + it('keeps participating keys for active groups and drops the rest', () => { + const all = new Set([ + 'h100_vllm', + 'gb300_sglang', + 'h100_vllm_mtp', + 'h200_vllm_mtp', + 'gb300_sglang_mtp', + ]); + const active = new Set(['h100_vllm', 'gb300_sglang', 'h100_vllm_mtp']); + const out = effectiveLegendItems(all, active, ex); + expect([...out].toSorted()).toEqual([ + 'gb300_sglang', + 'h100_vllm', + 'h100_vllm_mtp', + 'h200_vllm_mtp', + ]); + }); + + it('treats dynamo-/mori- variants as the same group', () => { + const all = new Set(['h100_vllm', 'h100_dynamo-vllm_mtp', 'h100_vllm_mtp', 'h100_sglang_mtp']); + const active = new Set(['h100_vllm', 'h100_vllm_mtp']); + const out = effectiveLegendItems(all, active, ex); + expect([...out].toSorted()).toEqual(['h100_dynamo-vllm_mtp', 'h100_vllm', 'h100_vllm_mtp']); + }); + + it('makes computeToggle solo on click in the default-deselected state', () => { + // Default DSv4 state: all non-MTP active, MTP keys exist in data but + // are deselected. The effective universe matches active → computeToggle + // soloes the clicked item. + const all = new Set(['h100_vllm', 'gb300_sglang', 'h100_vllm_mtp', 'gb300_sglang_mtp']); + const active = new Set(['h100_vllm', 'gb300_sglang']); + const effective = effectiveLegendItems(all, active, ex); + const out = computeToggle(active, 'h100_vllm', effective); + expect(out).toEqual(new Set(['h100_vllm'])); + }); +}); + +describe('resolveExclusionToggle', () => { + it('falls through for non-participating toggles', () => { + const prev = new Set(['h100_vllm']); + const all = new Set(['h100_vllm', 'gb300_sglang']); + expect(resolveExclusionToggle(prev, 'gb300_sglang', all, ex)).toEqual({ kind: 'fallthrough' }); + }); + + it('falls through when adding the only group already active', () => { + const prev = new Set(['h100_vllm_mtp']); + const all = new Set(['h100_vllm_mtp', 'h100_dynamo-vllm_mtp', 'h100_vllm']); + expect(resolveExclusionToggle(prev, 'h100_dynamo-vllm_mtp', all, ex)).toEqual({ + kind: 'fallthrough', + }); + }); + + it('blocks adding a key whose group conflicts with the existing one', () => { + const prev = new Set(['h100_vllm_mtp']); + const all = new Set(['h100_vllm_mtp', 'gb300_sglang_mtp']); + expect(resolveExclusionToggle(prev, 'gb300_sglang_mtp', all, ex)).toEqual({ + kind: 'block', + attempted: 'sglang', + existing: 'vllm', + }); + }); + + it('silent-disable-all 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.result].toSorted()).toEqual(['h100_vllm']); + }); + + it('falls through when removing a participating key (no add, no surfaced groups)', () => { + const prev = new Set(['h100_vllm_mtp', 'h100_vllm']); + const all = new Set(['h100_vllm_mtp', 'h100_vllm']); + expect(resolveExclusionToggle(prev, 'h100_vllm_mtp', all, ex)).toEqual({ kind: 'fallthrough' }); + }); +}); diff --git a/packages/app/src/lib/exclusion.ts b/packages/app/src/lib/exclusion.ts new file mode 100644 index 00000000..3a9b3656 --- /dev/null +++ b/packages/app/src/lib/exclusion.ts @@ -0,0 +1,258 @@ +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. + * + * 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. + * + * 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.) + */ + +/** Data params defining one exclusion rule. */ +export interface ExclusionSpec { + /** Only hwKeys ending in this suffix participate in the rule (e.g. `_mtp`). */ + suffix: string; + /** + * Engine-family prefixes stripped from the framework segment before grouping + * (e.g. `dynamo-`, `mori-`), so `h100_dynamo-vllm_mtp` resolves to `vllm`. + */ + stripPrefixes?: string[]; + /** + * Raw family → shared comparability-group id. Two families can co-exist on a + * graph iff they resolve to the same group; families omitted here are their + * own group. (e.g. `{ atom: 'sglang' }` — ATOM and SGLang are comparable.) + */ + groupAliases?: Record; +} + +/** Compiled resolvers for a model's exclusion specs. */ +export interface Exclusion { + /** Literal engine family of a participating key (for display), else null. */ + familyOf(hwKey: string): string | null; + /** Comparability-group id of a participating key (for exclusion), else null. */ + groupOf(hwKey: string): string | null; +} + +/** + * 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. + */ +function familyForSpec(hwKey: string, spec: ExclusionSpec): string | null { + if (!hwKey.endsWith(spec.suffix)) return null; + const head = hwKey.slice(0, -spec.suffix.length); + const firstUnderscore = head.indexOf('_'); + if (firstUnderscore === -1) return null; + let framework = head.slice(firstUnderscore + 1); + for (const prefix of spec.stripPrefixes ?? []) { + if (framework.startsWith(prefix)) { + framework = framework.slice(prefix.length); + break; + } + } + return framework || 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). + */ +export function buildExclusion(specs: ExclusionSpec[]): Exclusion { + return { + familyOf(hwKey: string): string | null { + for (const spec of specs) { + const fam = familyForSpec(hwKey, spec); + if (fam) return fam; + } + return null; + }, + groupOf(hwKey: string): string | null { + for (const spec of specs) { + const fam = familyForSpec(hwKey, spec); + if (fam) return spec.groupAliases?.[fam] ?? fam; + } + return null; + }, + }; +} + +function groupKeysByGroup(keys: Iterable, ex: Exclusion): Map { + const byGroup = new Map(); + for (const key of keys) { + const group = ex.groupOf(key); + if (!group) continue; + const existing = byGroup.get(group); + if (existing) existing.push(key); + else byGroup.set(group, [key]); + } + return byGroup; +} + +/** + * Find the literal engine family of an active participating key in `keys` that + * belongs to comparability group `group`. Used to label conflict messaging with + * the real active engine (e.g. "ATOM") rather than the group id (e.g. "sglang"). + * When several comparable engines are active, returns the alphabetically-first + * for a stable message. Returns null if none match. + */ +function activeFamilyInGroup( + keys: Iterable, + group: string | null, + ex: Exclusion, +): string | null { + if (!group) return null; + const families: string[] = []; + for (const key of keys) { + const fam = ex.familyOf(key); + if (fam && ex.groupOf(key) === group) families.push(fam); + } + return families.length > 0 ? families.toSorted()[0] : null; +} + +/** + * Pick a single comparability group to keep when `proposed` contains keys from + * multiple groups. Sticks to a group already present in `prev`; otherwise falls + * back to the alphabetically-first group. Drops other groups' participating keys. + * + * If `proposed` has 0 or 1 groups, the input set is returned unchanged. + */ +export function pickStickyGroup( + proposed: Set, + prev: Set, + ex: Exclusion, +): { result: Set; keptGroup: string | null; droppedGroups: string[] } { + const byGroup = groupKeysByGroup(proposed, ex); + if (byGroup.size <= 1) { + return { + result: proposed, + keptGroup: byGroup.size === 1 ? [...byGroup.keys()][0] : null, + droppedGroups: [], + }; + } + const prevGroups = new Set(); + for (const key of prev) { + const group = ex.groupOf(key); + if (group) prevGroups.add(group); + } + const groups = [...byGroup.keys()]; + const sticky = groups.find((g) => prevGroups.has(g)); + const winner = sticky ?? [...groups].toSorted()[0]; + const result = new Set(proposed); + const dropped: string[] = []; + for (const [group, keys] of byGroup) { + if (group === winner) continue; + for (const k of keys) result.delete(k); + dropped.push(group); + } + return { result, keptGroup: winner, droppedGroups: dropped }; +} + +/** + * Compute the effective legend universe for solo/restore-all toggle semantics + * under exclusion. Participating keys whose group is not currently active are + * dropped, so the default-deselected state (e.g. DSv4 MTP on first load) counts + * as "all selected" — clicking an entry then solos it instead of just removing + * it. + */ +export function effectiveLegendItems( + allItems: Set, + active: Set, + ex: Exclusion, +): Set { + const activeGroups = new Set(); + for (const k of active) { + const group = ex.groupOf(k); + if (group) activeGroups.add(group); + } + const result = new Set(); + for (const k of allItems) { + const group = ex.groupOf(k); + if (!group || activeGroups.has(group)) result.add(k); + } + return result; +} + +/** + * Drop participating keys for ALL groups when `proposed` contains keys from more + * than one group. Used for auto-reset / select-all paths so the user has to opt + * into one group explicitly (and only one at a time). + * + * If `proposed` has 0 or 1 groups, the input set is returned unchanged. + */ +export function clearAllExclusionGroups( + proposed: Set, + ex: Exclusion, +): { result: Set; droppedGroups: string[] } { + const byGroup = groupKeysByGroup(proposed, ex); + if (byGroup.size <= 1) { + return { result: proposed, droppedGroups: [] }; + } + const result = new Set(proposed); + for (const keys of byGroup.values()) { + for (const k of keys) result.delete(k); + } + return { result, droppedGroups: [...byGroup.keys()] }; +} + +/** + * Decision for a single hw-toggle action under an exclusion rule. + * + * - `block`: the user explicitly tried to add a key whose group conflicts with + * 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. + * - `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: 'fallthrough' }; + +export function resolveExclusionToggle( + prev: Set, + hw: string, + allItems: Set, + ex: Exclusion, +): ExclusionToggleDecision { + const proposed = computeToggle(prev, hw, allItems); + const wasActive = prev.has(hw); + const willBeActive = proposed.has(hw); + const newFamily = ex.familyOf(hw); + const newGroup = ex.groupOf(hw); + + // Hard-block the explicit ADD that introduces a cross-group conflict. Compare + // on the comparability group (so adding an engine in the already-active group, + // e.g. ATOM alongside SGLang, is allowed), but surface the literal engine + // label in the toast. + if (!wasActive && willBeActive && newGroup) { + const sticky = pickStickyGroup(proposed, prev, ex); + if (sticky.droppedGroups.length > 0 && sticky.keptGroup !== newGroup) { + const existing = activeFamilyInGroup(prev, sticky.keptGroup, ex) ?? sticky.keptGroup; + return { kind: 'block', attempted: newFamily ?? newGroup, existing }; + } + } + + // 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 }; + } + + return { kind: 'fallthrough' }; +} diff --git a/packages/app/src/lib/mtp-exclusion.test.ts b/packages/app/src/lib/mtp-exclusion.test.ts deleted file mode 100644 index 2bf41caf..00000000 --- a/packages/app/src/lib/mtp-exclusion.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { computeToggle } from '@/hooks/useTogglableSet'; - -import { - clearAllMtpFamilies, - effectiveLegendItems, - getMtpEngineFamily, - pickStickyMtpFamily, - resolveMtpToggle, -} from './mtp-exclusion'; - -describe('getMtpEngineFamily', () => { - it('returns null for non-MTP keys', () => { - expect(getMtpEngineFamily('h100_vllm')).toBeNull(); - expect(getMtpEngineFamily('gb300_sglang')).toBeNull(); - expect(getMtpEngineFamily('h100')).toBeNull(); - expect(getMtpEngineFamily('')).toBeNull(); - }); - - it('extracts the framework segment for MTP keys', () => { - expect(getMtpEngineFamily('h100_vllm_mtp')).toBe('vllm'); - expect(getMtpEngineFamily('gb300_sglang_mtp')).toBe('sglang'); - expect(getMtpEngineFamily('h100_trt_mtp')).toBe('trt'); - }); - - it('strips dynamo- and mori- engine-family prefixes', () => { - expect(getMtpEngineFamily('h100_dynamo-vllm_mtp')).toBe('vllm'); - expect(getMtpEngineFamily('gb300_dynamo-sglang_mtp')).toBe('sglang'); - expect(getMtpEngineFamily('h100_dynamo-trt_mtp')).toBe('trt'); - expect(getMtpEngineFamily('mi355x_mori-sglang_mtp')).toBe('sglang'); - }); -}); - -describe('pickStickyMtpFamily', () => { - it('passes through when no MTP keys present', () => { - const set = new Set(['h100_vllm', 'gb300_sglang']); - const out = pickStickyMtpFamily(set, new Set()); - expect(out.result).toBe(set); - expect(out.droppedFamilies).toEqual([]); - expect(out.keptFamily).toBeNull(); - }); - - it('passes through when only one MTP family present', () => { - const set = new Set(['h100_vllm_mtp', 'h100_dynamo-vllm_mtp', 'gb300_sglang']); - const out = pickStickyMtpFamily(set, new Set()); - expect(out.result).toBe(set); - expect(out.droppedFamilies).toEqual([]); - expect(out.keptFamily).toBe('vllm'); - }); - - it('drops the non-sticky family when prev had one', () => { - const proposed = new Set(['h100_vllm_mtp', 'gb300_sglang_mtp', 'h100_vllm']); - const prev = new Set(['h100_vllm_mtp']); - const out = pickStickyMtpFamily(proposed, prev); - expect(out.keptFamily).toBe('vllm'); - expect([...out.result].toSorted()).toEqual(['h100_vllm', 'h100_vllm_mtp']); - expect(out.droppedFamilies).toEqual(['sglang']); - }); - - it('falls back to alphabetical when neither family was in prev', () => { - const proposed = new Set(['h100_vllm_mtp', 'gb300_sglang_mtp']); - const out = pickStickyMtpFamily(proposed, new Set()); - expect(out.keptFamily).toBe('sglang'); - expect([...out.result]).toEqual(['gb300_sglang_mtp']); - expect(out.droppedFamilies).toEqual(['vllm']); - }); - - it('treats dynamo/mori variants as the same family', () => { - const proposed = new Set(['h100_vllm_mtp', 'h100_dynamo-vllm_mtp', 'gb300_dynamo-sglang_mtp']); - const out = pickStickyMtpFamily(proposed, new Set(['h100_vllm_mtp'])); - expect(out.keptFamily).toBe('vllm'); - expect([...out.result].toSorted()).toEqual(['h100_dynamo-vllm_mtp', 'h100_vllm_mtp']); - expect(out.droppedFamilies).toEqual(['sglang']); - }); -}); - -describe('clearAllMtpFamilies', () => { - it('passes through when no MTP keys present', () => { - const set = new Set(['h100_vllm', 'gb300_sglang']); - const out = clearAllMtpFamilies(set); - expect(out.result).toBe(set); - expect(out.droppedFamilies).toEqual([]); - }); - - it('passes through when only one MTP family present', () => { - const set = new Set(['h100_vllm_mtp', 'h100_dynamo-vllm_mtp', 'h100_vllm']); - const out = clearAllMtpFamilies(set); - expect(out.result).toBe(set); - expect(out.droppedFamilies).toEqual([]); - }); - - it('drops every MTP family when multiple are present', () => { - const proposed = new Set(['h100_vllm_mtp', 'gb300_sglang_mtp', 'h100_vllm', 'gb300_sglang']); - const out = clearAllMtpFamilies(proposed); - expect([...out.result].toSorted()).toEqual(['gb300_sglang', 'h100_vllm']); - expect(out.droppedFamilies.toSorted()).toEqual(['sglang', 'vllm']); - }); -}); - -describe('effectiveLegendItems', () => { - it('returns the input set unchanged when no MTP keys present', () => { - const all = new Set(['h100_vllm', 'gb300_sglang']); - const active = new Set(['h100_vllm']); - const out = effectiveLegendItems(all, active); - expect([...out].toSorted()).toEqual(['gb300_sglang', 'h100_vllm']); - }); - - it('drops MTP keys whose family is not active (default DSv4 state)', () => { - const all = new Set(['h100_vllm', 'gb300_sglang', 'h100_vllm_mtp', 'gb300_sglang_mtp']); - const active = new Set(['h100_vllm', 'gb300_sglang']); // no MTP active - const out = effectiveLegendItems(all, active); - expect([...out].toSorted()).toEqual(['gb300_sglang', 'h100_vllm']); - }); - - it('keeps MTP keys for active families and drops the rest', () => { - const all = new Set([ - 'h100_vllm', - 'gb300_sglang', - 'h100_vllm_mtp', - 'h200_vllm_mtp', - 'gb300_sglang_mtp', - ]); - const active = new Set(['h100_vllm', 'gb300_sglang', 'h100_vllm_mtp']); - const out = effectiveLegendItems(all, active); - expect([...out].toSorted()).toEqual([ - 'gb300_sglang', - 'h100_vllm', - 'h100_vllm_mtp', - 'h200_vllm_mtp', - ]); - }); - - it('treats dynamo-/mori- variants as the same family', () => { - const all = new Set(['h100_vllm', 'h100_dynamo-vllm_mtp', 'h100_vllm_mtp', 'h100_sglang_mtp']); - const active = new Set(['h100_vllm', 'h100_vllm_mtp']); - const out = effectiveLegendItems(all, active); - expect([...out].toSorted()).toEqual(['h100_dynamo-vllm_mtp', 'h100_vllm', 'h100_vllm_mtp']); - }); - - it('makes computeToggle solo on click in the default-deselected state', () => { - // Default DSv4 state: all non-MTP active, MTP keys exist in data but - // are deselected. The effective universe matches active → computeToggle - // soloes the clicked item. - const all = new Set(['h100_vllm', 'gb300_sglang', 'h100_vllm_mtp', 'gb300_sglang_mtp']); - const active = new Set(['h100_vllm', 'gb300_sglang']); - const effective = effectiveLegendItems(all, active); - const out = computeToggle(active, 'h100_vllm', effective); - expect(out).toEqual(new Set(['h100_vllm'])); - }); -}); - -describe('resolveMtpToggle', () => { - it('falls through for non-MTP toggles', () => { - const prev = new Set(['h100_vllm']); - const all = new Set(['h100_vllm', 'gb300_sglang']); - expect(resolveMtpToggle(prev, 'gb300_sglang', all)).toEqual({ kind: 'fallthrough' }); - }); - - it('falls through when adding the only MTP family already active', () => { - const prev = new Set(['h100_vllm_mtp']); - const all = new Set(['h100_vllm_mtp', 'h100_dynamo-vllm_mtp', 'h100_vllm']); - expect(resolveMtpToggle(prev, 'h100_dynamo-vllm_mtp', all)).toEqual({ kind: 'fallthrough' }); - }); - - it('blocks adding an MTP key whose family conflicts with the existing one', () => { - const prev = new Set(['h100_vllm_mtp']); - const all = new Set(['h100_vllm_mtp', 'gb300_sglang_mtp']); - expect(resolveMtpToggle(prev, 'gb300_sglang_mtp', all)).toEqual({ - kind: 'block', - attempted: 'sglang', - existing: 'vllm', - }); - }); - - it('silent-disable-all when solo→restore would surface multiple MTP families', () => { - // prev is a single non-MTP item; toggling it triggers "restore all", which - // would surface all items including two MTP families. - const prev = new Set(['h100_vllm']); - const all = new Set(['h100_vllm', 'h100_vllm_mtp', 'gb300_sglang_mtp']); - const decision = resolveMtpToggle(prev, 'h100_vllm', all); - expect(decision.kind).toBe('silent-disable-all'); - if (decision.kind !== 'silent-disable-all') return; - expect([...decision.result].toSorted()).toEqual(['h100_vllm']); - }); - - it('falls through when removing an MTP key (no add, no surfaced families)', () => { - const prev = new Set(['h100_vllm_mtp', 'h100_vllm']); - const all = new Set(['h100_vllm_mtp', 'h100_vllm']); - expect(resolveMtpToggle(prev, 'h100_vllm_mtp', all)).toEqual({ kind: 'fallthrough' }); - }); -}); diff --git a/packages/app/src/lib/mtp-exclusion.ts b/packages/app/src/lib/mtp-exclusion.ts deleted file mode 100644 index 2472387c..00000000 --- a/packages/app/src/lib/mtp-exclusion.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { computeToggle } from '@/hooks/useTogglableSet'; - -/** - * MTP engine-family exclusion rules. - * - * Some models (currently dsv4) cannot show MTP configs from different engine - * families simultaneously, since their acceptance-rate forcing implementations - * differ and the numbers aren't directly comparable. This module contains the - * pure helpers that identify families and resolve exclusion decisions; the - * provider/UI wires them up. - */ - -/** - * If `hwKey` is an MTP config (ends in `_mtp`), return its base engine family - * (`vllm`, `sglang`, `trt`, etc.) by stripping the GPU prefix and any - * `dynamo-` / `mori-` engine-family prefix from the framework segment. - * Returns null for non-MTP keys. - */ -export function getMtpEngineFamily(hwKey: string): string | null { - if (!hwKey.endsWith('_mtp')) return null; - const withoutMtp = hwKey.slice(0, -'_mtp'.length); - const firstUnderscore = withoutMtp.indexOf('_'); - if (firstUnderscore === -1) return null; - let framework = withoutMtp.slice(firstUnderscore + 1); - for (const prefix of ['dynamo-', 'mori-']) { - if (framework.startsWith(prefix)) { - framework = framework.slice(prefix.length); - break; - } - } - return framework || null; -} - -function groupMtpKeysByFamily(keys: Iterable): Map { - const byFamily = new Map(); - for (const key of keys) { - const fam = getMtpEngineFamily(key); - if (!fam) continue; - const existing = byFamily.get(fam); - if (existing) existing.push(key); - else byFamily.set(fam, [key]); - } - return byFamily; -} - -/** - * Pick a single MTP engine family to keep when `proposed` contains keys from - * multiple families. Sticks to the family already present in `prev`; otherwise - * falls back to the alphabetically-first family. Drops other families' MTP keys. - * - * If `proposed` has 0 or 1 MTP families, the input set is returned unchanged. - */ -export function pickStickyMtpFamily( - proposed: Set, - prev: Set, -): { result: Set; keptFamily: string | null; droppedFamilies: string[] } { - const mtpByFamily = groupMtpKeysByFamily(proposed); - if (mtpByFamily.size <= 1) { - return { - result: proposed, - keptFamily: mtpByFamily.size === 1 ? [...mtpByFamily.keys()][0] : null, - droppedFamilies: [], - }; - } - const prevFamilies = new Set(); - for (const key of prev) { - const fam = getMtpEngineFamily(key); - if (fam) prevFamilies.add(fam); - } - const families = [...mtpByFamily.keys()]; - const sticky = families.find((f) => prevFamilies.has(f)); - const winner = sticky ?? [...families].toSorted()[0]; - const result = new Set(proposed); - const dropped: string[] = []; - for (const [fam, keys] of mtpByFamily) { - if (fam === winner) continue; - for (const k of keys) result.delete(k); - dropped.push(fam); - } - return { result, keptFamily: winner, droppedFamilies: dropped }; -} - -/** - * Compute the effective legend universe for solo/restore-all toggle semantics - * under MTP engine-family exclusion. MTP keys whose family is not currently - * active are dropped, so the default-deselected MTP state (e.g. DSv4 on first - * load) counts as "all selected" — clicking an entry then solos it instead of - * just removing it. - */ -export function effectiveLegendItems(allItems: Set, active: Set): Set { - const activeFamilies = new Set(); - for (const k of active) { - const fam = getMtpEngineFamily(k); - if (fam) activeFamilies.add(fam); - } - const result = new Set(); - for (const k of allItems) { - const fam = getMtpEngineFamily(k); - if (!fam || activeFamilies.has(fam)) result.add(k); - } - return result; -} - -/** - * Drop MTP keys for ALL families when `proposed` contains keys from more than - * one family. Used for auto-reset / select-all paths so the user has to opt - * into MTP explicitly (and only one engine at a time). - * - * If `proposed` has 0 or 1 MTP families, the input set is returned unchanged. - */ -export function clearAllMtpFamilies(proposed: Set): { - result: Set; - droppedFamilies: string[]; -} { - const mtpByFamily = groupMtpKeysByFamily(proposed); - if (mtpByFamily.size <= 1) { - return { result: proposed, droppedFamilies: [] }; - } - const result = new Set(proposed); - for (const keys of mtpByFamily.values()) { - for (const k of keys) result.delete(k); - } - return { result, droppedFamilies: [...mtpByFamily.keys()] }; -} - -/** - * Decision for a single hw-toggle action under the MTP engine-exclusion rule. - * - * - `block`: the user explicitly tried to add a key whose family conflicts - * with the family already active. The provider should refuse the toggle - * (no state change) and surface a toast. - * - `silent-disable-all`: the toggle would surface multiple MTP families - * (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. - * - `fallthrough`: the toggle is fine, run the normal toggle path. - */ -export type MtpToggleDecision = - | { kind: 'block'; attempted: string; existing: string | null } - | { kind: 'silent-disable-all'; result: Set } - | { kind: 'fallthrough' }; - -export function resolveMtpToggle( - prev: Set, - hw: string, - allItems: Set, -): MtpToggleDecision { - const proposed = computeToggle(prev, hw, allItems); - const wasActive = prev.has(hw); - const willBeActive = proposed.has(hw); - const newFamily = getMtpEngineFamily(hw); - - // Hard-block the explicit ADD that introduces a cross-family MTP conflict. - if (!wasActive && willBeActive && newFamily) { - const sticky = pickStickyMtpFamily(proposed, prev); - if (sticky.droppedFamilies.length > 0 && sticky.keptFamily !== newFamily) { - return { kind: 'block', attempted: newFamily, existing: sticky.keptFamily }; - } - } - - // Other paths (e.g. solo→restore-all surfacing a hidden second family) — - // disable both MTP families silently. - const cleared = clearAllMtpFamilies(proposed); - if (cleared.droppedFamilies.length > 0) { - return { kind: 'silent-disable-all', result: cleared.result }; - } - - return { kind: 'fallthrough' }; -}