Spaces:
Running
Running
| import { afterEach, describe, expect, it, vi } from 'vitest'; | |
| import { | |
| assertBackendPolicy, | |
| evaluateModelContextPolicy, | |
| evaluateModelGate, | |
| inspectWebGpuAdapter, | |
| supportsWllamaWebGpuRuntime, | |
| } from './device-gate'; | |
| import type { ManifestModelV2, ModelTierId } from './manifest'; | |
| import type { StorageEstimate, WebGpuAdapterSnapshot } from './protocol'; | |
| function model(id: ModelTierId, cpuFallback: boolean): ManifestModelV2 { | |
| return { | |
| id, | |
| displayName: `Bonsai ${id}`, | |
| architecture: id === '27b' ? 'qwen35' : 'qwen3', | |
| source: { | |
| repo: 'prism-ml/Bonsai', | |
| revision: '0123456789abcdef0123456789abcdef01234567', | |
| file: 'Bonsai.gguf', | |
| bytes: 1000, | |
| sha256: 'a'.repeat(64), | |
| }, | |
| files: [{ path: 'Bonsai.gguf', bytes: 1000, sha256: 'a'.repeat(64) }], | |
| downloadBytes: 1000, | |
| contextLength: 32768, | |
| defaultContext: 4096, | |
| cpuFallback, | |
| largestTensorBytes: 256, | |
| requiredLimits: { maxStorageBufferBindingSize: 256 }, | |
| chatTemplate: { | |
| bytes: 10, | |
| sha256: 'a'.repeat(64), | |
| markers: { think: true, toolCall: true, toolResponse: true }, | |
| }, | |
| hybridDimensions: {}, | |
| nextNTensorCount: 0, | |
| runtimePolicy: { | |
| flashAttention: false, | |
| tokenEmbeddingOnWebGPU: true, | |
| requireSingleWebGPUGraph: id === '27b', | |
| }, | |
| }; | |
| } | |
| function adapter(limit = 1024): WebGpuAdapterSnapshot { | |
| return { | |
| available: true, | |
| name: 'Test GPU', | |
| vendor: null, | |
| architecture: null, | |
| device: null, | |
| description: null, | |
| limits: { maxStorageBufferBindingSize: limit }, | |
| features: ['shader-f16'], | |
| }; | |
| } | |
| const storage: StorageEstimate = { | |
| usageBytes: 100, | |
| quotaBytes: 10_000, | |
| persisted: true, | |
| }; | |
| afterEach(() => { | |
| vi.unstubAllGlobals(); | |
| }); | |
| describe('inspectWebGpuAdapter', () => { | |
| it('inspects the same default adapter selection used by the pinned runtime', async () => { | |
| const requestAdapter = vi.fn().mockResolvedValue({ | |
| limits: { maxStorageBufferBindingSize: 1024 }, | |
| features: new Set(['shader-f16']), | |
| info: { description: 'Default test GPU' }, | |
| }); | |
| vi.stubGlobal('navigator', { gpu: { requestAdapter } }); | |
| const snapshot = await inspectWebGpuAdapter(); | |
| expect(requestAdapter).toHaveBeenCalledOnce(); | |
| expect(requestAdapter).toHaveBeenCalledWith(); | |
| expect(snapshot.name).toBe('Default test GPU'); | |
| }); | |
| it('normalizes empty adapter-info strings instead of exporting an empty device name', async () => { | |
| vi.stubGlobal('navigator', { | |
| gpu: { | |
| requestAdapter: vi.fn().mockResolvedValue({ | |
| limits: {}, | |
| features: new Set(), | |
| info: { vendor: ' Apple ', architecture: '', device: '', description: ' ' }, | |
| }), | |
| }, | |
| }); | |
| const snapshot = await inspectWebGpuAdapter(); | |
| expect(snapshot).toMatchObject({ | |
| name: 'Apple', | |
| vendor: 'Apple', | |
| architecture: null, | |
| device: null, | |
| description: null, | |
| }); | |
| }); | |
| }); | |
| describe('supportsWllamaWebGpuRuntime', () => { | |
| const firefox = 'Mozilla/5.0 Gecko/20100101 Firefox/128.0'; | |
| const chromium = 'Mozilla/5.0 Chrome/128.0.0.0 Safari/537.36'; | |
| it('masks WebGPU only on Firefox without JSPI', () => { | |
| expect(supportsWllamaWebGpuRuntime(firefox, false)).toBe(false); | |
| expect(supportsWllamaWebGpuRuntime(firefox, true)).toBe(true); | |
| expect(supportsWllamaWebGpuRuntime(chromium, false)).toBe(true); | |
| }); | |
| }); | |
| describe('evaluateModelGate', () => { | |
| it('fails 27B loudly instead of selecting CPU-WASM when a WebGPU limit is short', () => { | |
| const decision = evaluateModelGate(model('27b', false), 'auto', adapter(128), storage); | |
| expect(decision.allowed).toBe(false); | |
| expect(decision.selectedBackend).toBeNull(); | |
| expect(decision.reasons.join(' ')).toMatch(/forbids CPU-WASM fallback/); | |
| }); | |
| it('allows an explicit smaller-tier CPU-WASM fallback', () => { | |
| const decision = evaluateModelGate(model('8b', true), 'auto', adapter(128), storage); | |
| expect(decision.allowed).toBe(true); | |
| expect(decision.selectedBackend).toBe('wasm'); | |
| expect(decision.warnings.join(' ')).toMatch(/Falling back to CPU-WASM/); | |
| }); | |
| it('accounts for already cached bytes when checking quota', () => { | |
| const constrained = { usageBytes: 9500, quotaBytes: 10_000, persisted: true }; | |
| expect(evaluateModelGate(model('8b', true), 'webgpu', adapter(), constrained, 1000).allowed).toBe(true); | |
| expect(evaluateModelGate(model('8b', true), 'webgpu', adapter(), constrained, 0).allowed).toBe(false); | |
| }); | |
| it('falls back safely when the token-embedding path lacks shader-f16', () => { | |
| const noF16 = { ...adapter(), features: [] }; | |
| const dense = evaluateModelGate(model('8b', true), 'auto', noF16, storage); | |
| const hybrid = evaluateModelGate(model('27b', false), 'auto', noF16, storage); | |
| expect(dense.selectedBackend).toBe('wasm'); | |
| expect(dense.warnings.join(' ')).toMatch(/shader-f16/); | |
| expect(hybrid.allowed).toBe(false); | |
| }); | |
| it('routes a masked Firefox runtime to CPU only when the model permits it', () => { | |
| const masked = { ...adapter(), available: false }; | |
| const denseAuto = evaluateModelGate(model('8b', true), 'auto', masked, storage); | |
| const denseExplicit = evaluateModelGate(model('8b', true), 'webgpu', masked, storage); | |
| const hybridAuto = evaluateModelGate(model('27b', false), 'auto', masked, storage); | |
| expect(denseAuto.allowed).toBe(true); | |
| expect(denseAuto.selectedBackend).toBe('wasm'); | |
| expect(denseExplicit.allowed).toBe(false); | |
| expect(denseExplicit.selectedBackend).toBeNull(); | |
| expect(hybridAuto.allowed).toBe(false); | |
| expect(hybridAuto.selectedBackend).toBeNull(); | |
| }); | |
| }); | |
| describe('evaluateModelContextPolicy', () => { | |
| const model27b = model('27b', false); | |
| const longContextExperiment = { | |
| tuningScope: 'benchmark', | |
| requestedBackend: 'webgpu', | |
| flashMode: 'auto', | |
| kvCacheType: 'q4_0', | |
| } as const; | |
| it('allows the bounded 8,448-token 27B benchmark experiment', () => { | |
| expect(evaluateModelContextPolicy(model27b, 8_448, longContextExperiment)).toEqual({ | |
| allowed: true, | |
| limit: 8_448, | |
| }); | |
| }); | |
| it('rejects 8,449 tokens even for the exact long-context experiment', () => { | |
| expect(evaluateModelContextPolicy(model27b, 8_449, longContextExperiment)).toEqual({ | |
| allowed: false, | |
| limit: 8_448, | |
| }); | |
| }); | |
| it.each([ | |
| ['q8 KV', { ...longContextExperiment, kvCacheType: 'q8_0' as const }], | |
| ['f16 KV', { ...longContextExperiment, kvCacheType: 'f16' as const }], | |
| ['auto backend', { ...longContextExperiment, requestedBackend: 'auto' as const }], | |
| ['release defaults', { ...longContextExperiment, tuningScope: 'release-defaults' as const }], | |
| ])('keeps 27B at the 4,096-token release limit for %s', (_label, input) => { | |
| expect(evaluateModelContextPolicy(model27b, 8_448, input)).toEqual({ | |
| allowed: false, | |
| limit: 4_096, | |
| }); | |
| expect(evaluateModelContextPolicy(model27b, 4_096, input).allowed).toBe(true); | |
| expect(evaluateModelContextPolicy(model27b, 4_097, input).allowed).toBe(false); | |
| }); | |
| it('leaves dense-tier manifest limits unchanged', () => { | |
| expect(evaluateModelContextPolicy(model('8b', true), 32_768, { | |
| tuningScope: 'release-defaults', | |
| requestedBackend: 'auto', | |
| flashMode: 'off', | |
| kvCacheType: 'f16', | |
| })).toEqual({ allowed: true, limit: 32_768 }); | |
| }); | |
| }); | |
| describe('assertBackendPolicy', () => { | |
| const strictModel = model('27b', false); | |
| const baseReport = { | |
| backends: ['WebGPU'], | |
| nGraphSplits: 1, | |
| opsOnCpu: 0, | |
| layersGpu: { offloaded: 65, total: 65 }, | |
| flashAttention: false, | |
| cacheTypeK: 'f16', | |
| cacheTypeV: 'f16', | |
| webgpuKvBufferBytes: 56 * 1024 ** 2, | |
| webgpuTrace: [], | |
| }; | |
| it('accepts a proven single WebGPU graph', () => { | |
| expect(() => assertBackendPolicy(strictModel, 'webgpu', baseReport)).not.toThrow(); | |
| }); | |
| it('rejects both graph splitting and explicit CPU work', () => { | |
| expect(() => assertBackendPolicy(strictModel, 'webgpu', { | |
| ...baseReport, | |
| nGraphSplits: 2, | |
| })).toThrow(/requires one graph split/); | |
| expect(() => assertBackendPolicy(strictModel, 'webgpu', { | |
| ...baseReport, | |
| opsOnCpu: 1, | |
| })).toThrow(/CPU op/); | |
| }); | |
| it('enforces the token-embedding single-graph policy on dense tiers too', () => { | |
| expect(() => assertBackendPolicy(model('1_7b', true), 'webgpu', { | |
| ...baseReport, | |
| layersGpu: { offloaded: 29, total: 29 }, | |
| nGraphSplits: 2, | |
| })).toThrow(/requires one graph split/); | |
| }); | |
| }); | |