diff --git a/.gitignore b/.gitignore index ff67eaaf4..50d1e218b 100644 --- a/.gitignore +++ b/.gitignore @@ -218,3 +218,4 @@ __pycache__/ *.metallib .vite-agent /Model-Optimizer +.vitest/ diff --git a/.yarnrc.yml b/.yarnrc.yml index 41b056bec..99b01d096 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1,11 +1,10 @@ approvedGitRepositories: - - "**" + - '**' catalog: - "@types/node": "npm:@types/node@26.4.0" - vite: "npm:@voidzero-dev/vite-plus-core@0.3.0" - vite-plus: 0.3.0 - vitest: "npm:vitest@4.1.11" + '@types/node': 'npm:@types/node@26.4.0' + vite: npm:@voidzero-dev/vite-plus-core@0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62 + vite-plus: 0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62 enableScripts: true @@ -15,6 +14,10 @@ npmMinimalAgeGate: 0 npmScopes: jsr: - npmRegistryServer: "https://npm.jsr.io" + npmRegistryServer: 'https://npm.jsr.io' yarnPath: .yarn/releases/yarn-4.18.0.cjs +npmPreapprovedPackages: + - vitest + - '@vitest/*' +npmRegistryServer: https://registry-bridge.viteplus.dev/ diff --git a/Cargo.toml b/Cargo.toml index 11386709c..b5f353ecd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,11 +23,7 @@ serde = { version = "1.0.229", features = ["derive"] } thiserror = "2.0.20" tokio = { version = "1.53", features = ["rt-multi-thread", "macros", "sync"] } tracing = "0.1.44" -tracing-subscriber = { version = "0.3.23", default-features = false, features = [ - "std", - "fmt", - "json", -] } +tracing-subscriber = { version = "0.3.23", default-features = false, features = ["std", "fmt", "json"] } tokenizers = "0.23.1" minijinja = { version = "2.24", features = ["json", "preserve_order"] } uuid = { version = "1.26", features = ["v4"] } diff --git a/__test__/cli/serve.test.ts b/__test__/cli/serve.test.ts index 927f042f5..7c67d14c3 100644 --- a/__test__/cli/serve.test.ts +++ b/__test__/cli/serve.test.ts @@ -1,4 +1,5 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { connect } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -164,7 +165,15 @@ describe('mlx serve — end to end', () => { expect(observedUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); expect(exitCodes).toEqual([0]); - await expect(fetch(healthUrl)).rejects.toThrow(); + const connectionError = await new Promise((resolve) => { + const socket = connect(Number(new URL(healthUrl).port), '127.0.0.1'); + socket.once('error', (error) => resolve(error as NodeJS.ErrnoException)); + socket.once('connect', () => { + socket.destroy(); + resolve(null); + }); + }); + expect(connectionError?.code).toBe('ECONNREFUSED'); // Handlers detached on shutdown: a leaked one would keep a closed host // alive in the listener list and swallow the next Ctrl+C. diff --git a/__test__/models/chat-session-mtp-default.test.ts b/__test__/models/chat-session-mtp-default.test.ts index 4ff2c5160..55be53a3c 100644 --- a/__test__/models/chat-session-mtp-default.test.ts +++ b/__test__/models/chat-session-mtp-default.test.ts @@ -45,8 +45,8 @@ function finalChunk(text: string): ChatStreamFinal { /** The session surface every case needs, minus the MTP getters. */ function sessionStubs() { - const chatSessionStart = vi.fn( - async (_messages: ChatMessage[], _config?: ChatConfig | null): Promise => makeChatResult('reply'), + const chatSessionStart = vi.fn(async (_messages: ChatMessage[], _config?: ChatConfig | null): Promise => + makeChatResult('reply'), ); const chatSessionContinue = vi.fn( async (_messages: ChatMessage[], _config?: ChatConfig | null): Promise => makeChatResult('reply'), diff --git a/__test__/models/gemma4-concurrency-e2e.test.ts b/__test__/models/gemma4-concurrency-e2e.test.ts index 4114a82db..7e499ad16 100644 --- a/__test__/models/gemma4-concurrency-e2e.test.ts +++ b/__test__/models/gemma4-concurrency-e2e.test.ts @@ -27,9 +27,9 @@ function isScheduledGemma(model: LoadableModel): model is LoadableModel & Schedu ); } -const gemmaDescribe = MODEL_ENV_PRESENT ? describe.sequential : describe.skip; +const gemmaDescribe = MODEL_ENV_PRESENT ? describe : describe.skip; -gemmaDescribe('Gemma4 grouped hybrid KV continuous batching', () => { +gemmaDescribe('Gemma4 grouped hybrid KV continuous batching', { concurrent: false }, () => { let model: ScheduledGemma; const sessions: ChatSession[] = []; const activeSends = new Set<{ diff --git a/__test__/models/gemma4-unified-detection.test.ts b/__test__/models/gemma4-unified-detection.test.ts index 2f74d9659..62954f401 100644 --- a/__test__/models/gemma4-unified-detection.test.ts +++ b/__test__/models/gemma4-unified-detection.test.ts @@ -13,7 +13,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'; * resolve to the one shared `gemma4` Rust decoder + `Gemma4Model` registry * row — there is no separate unified registry entry. */ -describe.sequential('Gemma4 unified model detection', () => { +describe('Gemma4 unified model detection', { concurrent: false }, () => { let tempDir: string; beforeEach(async () => { diff --git a/__test__/models/harrier-detection.test.ts b/__test__/models/harrier-detection.test.ts index e50a004bc..52a35575d 100644 --- a/__test__/models/harrier-detection.test.ts +++ b/__test__/models/harrier-detection.test.ts @@ -5,7 +5,7 @@ import { join } from 'node:path'; import { detectModelType } from '@mlx-node/lm'; import { describe, it, expect, beforeEach, afterEach } from 'vite-plus/test'; -describe.sequential('Harrier Model Detection', () => { +describe('Harrier Model Detection', { concurrent: false }, () => { let tempDir: string; beforeEach(async () => { diff --git a/__test__/models/harrier.test.ts b/__test__/models/harrier.test.ts index c355f2206..717f16cac 100644 --- a/__test__/models/harrier.test.ts +++ b/__test__/models/harrier.test.ts @@ -33,7 +33,7 @@ const HARRIER_0_6B_CONFIG = { useQkNorm: true, }; -describe.sequential('HarrierModel', () => { +describe('HarrierModel', { concurrent: false }, () => { describe('Model Instantiation', () => { it('should create model from tiny config', () => { const model = new HarrierModel(TINY_CONFIG); diff --git a/__test__/models/lfm2-moe-detection.test.ts b/__test__/models/lfm2-moe-detection.test.ts index 35c9e5757..a5ae3f905 100644 --- a/__test__/models/lfm2-moe-detection.test.ts +++ b/__test__/models/lfm2-moe-detection.test.ts @@ -5,7 +5,7 @@ import { join } from 'node:path'; import { detectModelType } from '@mlx-node/lm'; import { describe, it, expect, beforeEach, afterEach } from 'vite-plus/test'; -describe.sequential('LFM2 MoE Model Detection', () => { +describe('LFM2 MoE Model Detection', { concurrent: false }, () => { let tempDir: string; beforeEach(async () => { diff --git a/__test__/models/model-loader-registry.test.ts b/__test__/models/model-loader-registry.test.ts index 4abe7fa6b..299912bff 100644 --- a/__test__/models/model-loader-registry.test.ts +++ b/__test__/models/model-loader-registry.test.ts @@ -42,7 +42,7 @@ const UNIFIED_GEMMA_COMPATIBILITY_CASES = [ ['competing Harrier probe', { model_type: 'qwen3', architectures: ['Qwen3Model', UNIFIED_GEMMA_ARCHITECTURE] }], ] as const satisfies readonly (readonly [string, Readonly>])[]; -describe.sequential('declarative model loader registry', () => { +describe('declarative model loader registry', { concurrent: false }, () => { let tempDir: string; beforeEach(async () => { diff --git a/__test__/models/nemotron-h-detection.test.ts b/__test__/models/nemotron-h-detection.test.ts index 90c4a0518..8730431ac 100644 --- a/__test__/models/nemotron-h-detection.test.ts +++ b/__test__/models/nemotron-h-detection.test.ts @@ -12,7 +12,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'; * (mirroring the native loader's config validation in * crates/mlx-core/src/models/nemotron_h/config.rs). */ -describe.sequential('Nemotron H model detection', () => { +describe('Nemotron H model detection', { concurrent: false }, () => { let tempDir: string; beforeEach(async () => { diff --git a/__test__/models/qwen3-chat.test.ts b/__test__/models/qwen3-chat.test.ts index c10ec362f..e7b31245c 100644 --- a/__test__/models/qwen3-chat.test.ts +++ b/__test__/models/qwen3-chat.test.ts @@ -19,7 +19,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vite-plus/test'; import { createTempModel, findTokenizerPath, TINY_TEST_CONFIG } from '../test-model-utils'; -describe.sequential('Qwen3 Chat Session API', () => { +describe('Qwen3 Chat Session API', { concurrent: false }, () => { let model: Qwen3Model; let cleanup: () => void; diff --git a/__test__/models/qwen3-generate-batch-parallel.test.ts b/__test__/models/qwen3-generate-batch-parallel.test.ts index cd354f1ac..a03f10897 100644 --- a/__test__/models/qwen3-generate-batch-parallel.test.ts +++ b/__test__/models/qwen3-generate-batch-parallel.test.ts @@ -13,7 +13,7 @@ import { describe, it, expect, beforeAll } from 'vite-plus/test'; * Each test creates its own GrpoTrainingEngine and resets it when done * to release the model thread's training state for the next test. */ -describe.sequential('GRPOTrainer - Parallel Batch Generation', () => { +describe('GRPOTrainer - Parallel Batch Generation', { concurrent: false }, () => { let model: Qwen3Model | null = null; beforeAll(async () => { diff --git a/__test__/models/qwen3.test.ts b/__test__/models/qwen3.test.ts index d26c7b3cf..eab55e9ab 100644 --- a/__test__/models/qwen3.test.ts +++ b/__test__/models/qwen3.test.ts @@ -2,7 +2,7 @@ import { Qwen3Model } from '@mlx-node/core'; import { QWEN3_CONFIGS, getQwen3Config } from '@mlx-node/lm'; import { describe, it, expect } from 'vite-plus/test'; -describe.sequential('Qwen3 Model', () => { +describe('Qwen3 Model', { concurrent: false }, () => { describe('Model Configuration', () => { it('should have correct default configurations', () => { expect(QWEN3_CONFIGS['qwen3-0.6b']).toBeDefined(); diff --git a/__test__/models/qwen35-stream.test.ts b/__test__/models/qwen35-stream.test.ts index e79768e80..da6ddb405 100644 --- a/__test__/models/qwen35-stream.test.ts +++ b/__test__/models/qwen35-stream.test.ts @@ -43,7 +43,7 @@ function fakeNativeMethod(numTokens: number) { }; } -describe.sequential('_runChatStream bridge', () => { +describe('_runChatStream bridge', { concurrent: false }, () => { it('should yield delta chunks followed by a final chunk', async () => { const events: ChatStreamEvent[] = []; const messages: ChatMessage[] = [{ role: 'user', content: 'Hi' }]; diff --git a/__test__/models/qwen3_5-generation.test.ts b/__test__/models/qwen3_5-generation.test.ts index d61d0ea7e..6fbf70b2a 100644 --- a/__test__/models/qwen3_5-generation.test.ts +++ b/__test__/models/qwen3_5-generation.test.ts @@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; import { createTempQwen35Model, type TempModel } from '../test-model-utils'; import { shape } from '../test-utils'; -describe.sequential('Qwen3.5 Generation', () => { +describe('Qwen3.5 Generation', { concurrent: false }, () => { let tempModel: TempModel; beforeAll(async () => { diff --git a/__test__/server/concurrent-stage0-e2e.test.ts b/__test__/server/concurrent-stage0-e2e.test.ts index 3e190b28f..698c8ee54 100644 --- a/__test__/server/concurrent-stage0-e2e.test.ts +++ b/__test__/server/concurrent-stage0-e2e.test.ts @@ -330,9 +330,9 @@ function startTickProbe(intervalMs = 50): { stop: () => number } { }; } -const stage0Describe = MODEL_ENV_PRESENT ? describe.sequential : describe.skip; +const stage0Describe = MODEL_ENV_PRESENT ? describe : describe.skip; -stage0Describe('Stage-0 real-model concurrency hazards', () => { +stage0Describe('Stage-0 real-model concurrency hazards', { concurrent: false }, () => { let instance: ServerInstance; let nativeModel: SessionCapableModel; let instrumented: InstrumentedModel; diff --git a/__test__/server/concurrent-stage1-e2e.test.ts b/__test__/server/concurrent-stage1-e2e.test.ts index 56e2014c1..c54066f03 100644 --- a/__test__/server/concurrent-stage1-e2e.test.ts +++ b/__test__/server/concurrent-stage1-e2e.test.ts @@ -162,9 +162,9 @@ async function postResponse(baseUrl: string, input: string): Promise<{ id?: stri }); } -const stage1Describe = MODEL_ENV_PRESENT ? describe.sequential : describe.skip; +const stage1Describe = MODEL_ENV_PRESENT ? describe : describe.skip; -stage1Describe('Stage-1 real-model server admission', () => { +stage1Describe('Stage-1 real-model server admission', { concurrent: false }, () => { let instance: ServerInstance; let nativeModel: SchedulerStatsModel; let peakActive: () => number; diff --git a/__test__/server/host/inference-host.test.ts b/__test__/server/host/inference-host.test.ts index 66200f04b..63fe92067 100644 --- a/__test__/server/host/inference-host.test.ts +++ b/__test__/server/host/inference-host.test.ts @@ -1,7 +1,7 @@ import { spawnSync } from 'node:child_process'; import { existsSync } from 'node:fs'; import { chmod, mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; -import { createServer as createNetServer } from 'node:net'; +import { connect as connectNet, createServer as createNetServer } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -324,16 +324,28 @@ describe('createInferenceHost — binding and health', () => { const address = host.server.server.address(); expect(address !== null && typeof address === 'object' ? address.address : null).toBe('::1'); expect(host.url).toBe(`http://[::1]:${host.port}`); - expect((await fetch(`${host.url}/health`)).status).toBe(200); + const response = await fetch(`${host.url}/health`); + expect(response.status).toBe(200); + await response.arrayBuffer(); }); it('stops answering once closed', async () => { const modelsDir = await makeModelsDir(['alpha']); const host = await start({ modelsDir }); const url = `${host.url}/health`; - expect((await fetch(url)).status).toBe(200); + const response = await fetch(url); + expect(response.status).toBe(200); + await response.arrayBuffer(); await host.close({ timeoutMs: 0 }); - await expect(fetch(url)).rejects.toThrow(); + const connectionError = await new Promise((resolve) => { + const socket = connectNet(host.port, '127.0.0.1'); + socket.once('error', (error) => resolve(error as NodeJS.ErrnoException)); + socket.once('connect', () => { + socket.destroy(); + resolve(null); + }); + }); + expect(connectionError?.code).toBe('ECONNREFUSED'); }); /** @@ -559,7 +571,13 @@ describe('createInferenceHost — a network-reachable bind must be gated', () => const modelsDir = await makeModelsDir(['alpha']); for (const bind of ['127.0.0.1', 'localhost', undefined]) { const host = await start({ modelsDir, host: bind, port: 0 }); - expect((await fetch(`${host.url}/v1/models`)).status, String(bind)).toBe(200); + expect(host.host).toBe(bind ?? '127.0.0.1'); + const address = host.server.server.address(); + if (address === null || typeof address === 'string') throw new Error('Expected an IP socket'); + const ip = address.family === 'IPv6' ? `[${address.address}]` : address.address; + const response = await fetch(`http://${ip}:${address.port}/v1/models`); + expect(response.status, String(bind)).toBe(200); + await response.arrayBuffer(); } }); }); diff --git a/__test__/server/model-work-coordinator.test.ts b/__test__/server/model-work-coordinator.test.ts index 40f3d0f4b..7de7ecf3b 100644 --- a/__test__/server/model-work-coordinator.test.ts +++ b/__test__/server/model-work-coordinator.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vite-plus/test'; import { ModelWorkCoordinator } from '../../packages/server/src/model-work-coordinator.js'; diff --git a/__test__/server/server-close.test.ts b/__test__/server/server-close.test.ts index de14f035c..c774178e9 100644 --- a/__test__/server/server-close.test.ts +++ b/__test__/server/server-close.test.ts @@ -1,6 +1,6 @@ import { once } from 'node:events'; import { request as httpRequest } from 'node:http'; -import type { AddressInfo } from 'node:net'; +import { connect, type AddressInfo } from 'node:net'; import type { SessionCapableModel } from '@mlx-node/lm'; import { activeSSEStreamCount, createServer, type ServerInstance } from '@mlx-node/server'; @@ -278,7 +278,15 @@ describe('close() on a quiet server', () => { it('stops the server from accepting new requests', async () => { const { instance, base } = await start(); await instance.close({ timeoutMs: 1_000 }); - await expect(fetch(`${base}/health`)).rejects.toThrow(); + const connectionError = await new Promise((resolve) => { + const socket = connect(Number(new URL(base).port), '127.0.0.1'); + socket.once('error', (error) => resolve(error as NodeJS.ErrnoException)); + socket.once('connect', () => { + socket.destroy(); + resolve(null); + }); + }); + expect(connectionError?.code).toBe('ECONNREFUSED'); }); }); diff --git a/__test__/tokenization/qwen3-tokenizer.test.ts b/__test__/tokenization/qwen3-tokenizer.test.ts index ca11f69e2..fc7c41902 100644 --- a/__test__/tokenization/qwen3-tokenizer.test.ts +++ b/__test__/tokenization/qwen3-tokenizer.test.ts @@ -4,25 +4,16 @@ * Comprehensive tests for the Rust-based Qwen3 tokenizer implementation */ -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - import { Qwen3Tokenizer, type ChatMessage } from '@mlx-node/core'; import { describe, it, expect, beforeAll } from 'vite-plus/test'; +import { findTokenizerPath } from '../test-model-utils'; + describe('Qwen3Tokenizer', () => { let tokenizer: Qwen3Tokenizer; - const TOKENIZER_PATH = join( - fileURLToPath(import.meta.url), - '..', - '..', - '..', - '.cache/models/qwen3-0.6b-mlx-bf16/tokenizer.json', - ); - beforeAll(async () => { - tokenizer = await Qwen3Tokenizer.fromPretrained(TOKENIZER_PATH); + tokenizer = await Qwen3Tokenizer.fromPretrained(findTokenizerPath()); }); describe('Initialization', () => { diff --git a/__test__/trainers/grpo-integration.test.ts b/__test__/trainers/grpo-integration.test.ts index fd25ebe34..568af8a8d 100644 --- a/__test__/trainers/grpo-integration.test.ts +++ b/__test__/trainers/grpo-integration.test.ts @@ -16,7 +16,7 @@ const TEST_OUTPUT_DIR = './test-integration-output'; // Shared temp model for all tests let tempModel: { modelPath: string; cleanup: () => void }; -describe.sequential('GRPO Integration Tests', () => { +describe('GRPO Integration Tests', { concurrent: false }, () => { beforeAll(async () => { tempModel = await createTempModel(); }); @@ -39,7 +39,7 @@ describe.sequential('GRPO Integration Tests', () => { } }); - describe.sequential('End-to-End Training', () => { + describe('End-to-End Training', { concurrent: false }, () => { it('should complete a full training run with multiple epochs', async () => { const trainer = await GRPOTrainer.create({ modelPath: tempModel.modelPath, @@ -135,7 +135,7 @@ describe.sequential('GRPO Integration Tests', () => { }); }); - describe.sequential('Generation and Scoring Pipeline', () => { + describe('Generation and Scoring Pipeline', { concurrent: false }, () => { it('should generate, score, and compute loss correctly', async () => { const trainer = await GRPOTrainer.create({ modelPath: tempModel.modelPath, @@ -211,7 +211,7 @@ describe.sequential('GRPO Integration Tests', () => { }); }); - describe.sequential('Loss Computation Variants', () => { + describe('Loss Computation Variants', { concurrent: false }, () => { it('should compute GRPO loss correctly', async () => { const trainer = await GRPOTrainer.create({ modelPath: tempModel.modelPath, @@ -294,7 +294,7 @@ describe.sequential('GRPO Integration Tests', () => { }); }); - describe.sequential('Advantage Computation', () => { + describe('Advantage Computation', { concurrent: false }, () => { it('should normalize advantages per group', async () => { const trainer = await GRPOTrainer.create({ modelPath: tempModel.modelPath, @@ -346,7 +346,7 @@ describe.sequential('GRPO Integration Tests', () => { }); }); - describe.sequential('Batch Processing', () => { + describe('Batch Processing', { concurrent: false }, () => { it('should handle large batches correctly', async () => { const trainer = await GRPOTrainer.create({ modelPath: tempModel.modelPath, @@ -403,7 +403,7 @@ describe.sequential('GRPO Integration Tests', () => { }); }); - describe.sequential('Sampling Configuration', () => { + describe('Sampling Configuration', { concurrent: false }, () => { it('should respect temperature settings', async () => { const temperatures = [0.5, 1.0, 1.5]; @@ -465,7 +465,7 @@ describe.sequential('GRPO Integration Tests', () => { }, 30000); // 30s timeout - creates 3 trainers in sequence }); - describe.sequential('Error Handling', () => { + describe('Error Handling', { concurrent: false }, () => { it('should handle empty dataset gracefully', async () => { const trainer = await GRPOTrainer.create({ modelPath: tempModel.modelPath, @@ -540,7 +540,7 @@ describe.sequential('GRPO Integration Tests', () => { }); }); - describe.sequential('Metrics Tracking', () => { + describe('Metrics Tracking', { concurrent: false }, () => { it('should track all metrics correctly', async () => { const trainer = await GRPOTrainer.create({ modelPath: tempModel.modelPath, diff --git a/__test__/trainers/grpo-optimizer-roundtrip.test.ts b/__test__/trainers/grpo-optimizer-roundtrip.test.ts index 66a9d3b0c..ff033827b 100644 --- a/__test__/trainers/grpo-optimizer-roundtrip.test.ts +++ b/__test__/trainers/grpo-optimizer-roundtrip.test.ts @@ -129,7 +129,7 @@ function expectDeterministicOptimizerState(path: string): void { expect(state.secondMoment).toEqual(Array(MOMENT_LENGTH).fill(SECOND_MOMENT)); } -describe.sequential('GRPOTrainer checkpoint + optimizer state round-trip', () => { +describe('GRPOTrainer checkpoint + optimizer state round-trip', { concurrent: false }, () => { // Track every temp model/checkpoint directory at describe scope so cleanup // still runs when an assertion or checkpoint load fails partway through. const cleanups: Array<() => void> = []; diff --git a/__test__/trainers/grpo-qwen35.test.ts b/__test__/trainers/grpo-qwen35.test.ts index a17a05359..840ce5c5c 100644 --- a/__test__/trainers/grpo-qwen35.test.ts +++ b/__test__/trainers/grpo-qwen35.test.ts @@ -19,7 +19,7 @@ import { createTempQwen35Model, createTempQwen35MoeModel, type TempModel } from const constantReward = (outputs: RewardOutput[]): Float32Array => Float32Array.from(outputs.map(() => 0.5)); -describe.sequential('GRPOTrainer - Qwen3.5 Dense smoke', () => { +describe('GRPOTrainer - Qwen3.5 Dense smoke', { concurrent: false }, () => { let tempModel: TempModel; beforeAll(async () => { @@ -57,7 +57,7 @@ describe.sequential('GRPOTrainer - Qwen3.5 Dense smoke', () => { }); }); -describe.sequential('GRPOTrainer - Qwen3.5 MoE smoke', () => { +describe('GRPOTrainer - Qwen3.5 MoE smoke', { concurrent: false }, () => { let tempModel: TempModel; beforeAll(async () => { diff --git a/__test__/trainers/grpo-resume-validation.test.ts b/__test__/trainers/grpo-resume-validation.test.ts index 1f35da0da..be0f0fe54 100644 --- a/__test__/trainers/grpo-resume-validation.test.ts +++ b/__test__/trainers/grpo-resume-validation.test.ts @@ -107,7 +107,7 @@ describe('computeDatasetHash', () => { }); }); -describe.sequential('GRPOTrainer - Dataset Resume Validation', () => { +describe('GRPOTrainer - Dataset Resume Validation', { concurrent: false }, () => { let tempDir: string; beforeAll(() => { diff --git a/__test__/trainers/grpo-trainer.test.ts b/__test__/trainers/grpo-trainer.test.ts index e012d74fb..07f476f33 100644 --- a/__test__/trainers/grpo-trainer.test.ts +++ b/__test__/trainers/grpo-trainer.test.ts @@ -14,7 +14,7 @@ afterAll(() => { tempModel?.cleanup(); }); -describe.sequential('GRPOTrainer - generateBatch()', () => { +describe('GRPOTrainer - generateBatch()', { concurrent: false }, () => { describe('Basic Functionality', () => { it('should generate completions for single prompt', async () => { const trainer = await GRPOTrainer.create({ @@ -261,7 +261,7 @@ describe.sequential('GRPOTrainer - generateBatch()', () => { }); }); -describe.sequential('GRPOTrainer - Constructor', () => { +describe('GRPOTrainer - Constructor', { concurrent: false }, () => { it('should create trainer with default config', async () => { const trainer = await GRPOTrainer.create({ modelPath: tempModel.modelPath, modelName: 'qwen3-0.6b' }); expect(trainer).toBeDefined(); @@ -297,7 +297,7 @@ describe.sequential('GRPOTrainer - Constructor', () => { }); }); -describe.sequential('GRPOTrainer - scoreGenerations()', () => { +describe('GRPOTrainer - scoreGenerations()', { concurrent: false }, () => { describe('Basic Functionality', () => { it('should score completions with custom reward function', async () => { // Simple reward function: length-based @@ -392,7 +392,7 @@ describe.sequential('GRPOTrainer - scoreGenerations()', () => { }); }); -describe.sequential('GRPOTrainer - trainStep()', () => { +describe('GRPOTrainer - trainStep()', { concurrent: false }, () => { describe('Basic Functionality', () => { it('should execute a training step and return metrics', async () => { const rewardFn = (outputs: RewardOutput[]) => { @@ -726,7 +726,7 @@ describe.sequential('GRPOTrainer - trainStep()', () => { // GRPOTrainer - train() tests // ============================================================================ -describe.sequential('GRPOTrainer - train()', () => { +describe('GRPOTrainer - train()', { concurrent: false }, () => { describe('Basic Training Loop', () => { it('should execute training loop with dataset', async () => { const trainer = await GRPOTrainer.create({ diff --git a/__test__/trainers/sft-dataset.test.ts b/__test__/trainers/sft-dataset.test.ts index bd20568db..2b9d2a601 100644 --- a/__test__/trainers/sft-dataset.test.ts +++ b/__test__/trainers/sft-dataset.test.ts @@ -1,5 +1,3 @@ -import { resolve } from 'node:path'; - import { Qwen3Tokenizer } from '@mlx-node/core'; import { SFTDataset, @@ -10,13 +8,13 @@ import { } from '@mlx-node/trl'; import { describe, expect, it, beforeAll } from 'vite-plus/test'; +import { findTokenizerPath } from '../test-model-utils'; + describe('SFT Dataset', () => { let tokenizer: Qwen3Tokenizer; beforeAll(async () => { - // Load tokenizer from cached model - const tokenizerPath = resolve(process.cwd(), '.cache/models/qwen3-0.6b-mlx-bf16/tokenizer.json'); - tokenizer = await Qwen3Tokenizer.fromPretrained(tokenizerPath); + tokenizer = await Qwen3Tokenizer.fromPretrained(findTokenizerPath()); }); describe('prompt-completion format', () => { diff --git a/__test__/trainers/sft-engine-training-state.test.ts b/__test__/trainers/sft-engine-training-state.test.ts index 69dc79475..9f9af4841 100644 --- a/__test__/trainers/sft-engine-training-state.test.ts +++ b/__test__/trainers/sft-engine-training-state.test.ts @@ -43,7 +43,7 @@ const sftConfig: SftEngineConfig = { gradientCheckpointing: true, }; -describe.sequential('SftTrainingEngine — training state lifecycle', () => { +describe('SftTrainingEngine — training state lifecycle', { concurrent: false }, () => { // Shared thread-backed model. `loadModel` spins up the dedicated OS thread // that `SftTrainingEngine` requires (it pulls `model.thread.cmd_sender()`). let model: Qwen3Model; diff --git a/crates/mlx-metal/Cargo.toml b/crates/mlx-metal/Cargo.toml index 3da3d9c7e..75e6a648c 100644 --- a/crates/mlx-metal/Cargo.toml +++ b/crates/mlx-metal/Cargo.toml @@ -12,11 +12,7 @@ name = "metal" [target.'cfg(target_os = "macos")'.dependencies] foreign-types = "0.5" objc2 = "0.6.4" -objc2-foundation = { version = "0.3.2", default-features = false, features = [ - "std", - "NSError", - "NSString", -] } +objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSError", "NSString"] } objc2-metal = { version = "0.3.2", default-features = false, features = [ "std", "bitflags", diff --git a/docs/cli.md b/docs/cli.md index 5bb985f5b..abc743083 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -48,16 +48,16 @@ quantization variants downloads all remaining variants, and that the marker makes the directory dashboard-managed — a dashboard install into it may replace the contents wholesale. -| Flag | Default | Purpose | -| ---------------- | ---------------------- | ------------------------------------------------------ | -| `-m`, `--model` | `Qwen/Qwen3-0.6B` | HuggingFace model id | -| `-g`, `--glob` | — | Filename pattern filter (download only matching files) | -| `--assets-repo` | — | Base-model repo to fetch tokenizer/config sidecars from | +| Flag | Default | Purpose | +| ---------------- | ---------------------- | ------------------------------------------------------------------------ | +| `-m`, `--model` | `Qwen/Qwen3-0.6B` | HuggingFace model id | +| `-g`, `--glob` | — | Filename pattern filter (download only matching files) | +| `--assets-repo` | — | Base-model repo to fetch tokenizer/config sidecars from | | `--complete` | `false` | Marker records a full-model (catalog) install, not a partial `-g` subset | -| `--force` | `false` | Re-verify every file against upstream by content hash | -| `--cache-dir` | `~/.cache/huggingface` | HuggingFace cache directory | -| `--set-token` | — | Store HuggingFace credentials | -| `-o`, `--output` | — | Output directory | +| `--force` | `false` | Re-verify every file against upstream by content hash | +| `--cache-dir` | `~/.cache/huggingface` | HuggingFace cache directory | +| `--set-token` | — | Store HuggingFace credentials | +| `-o`, `--output` | — | Output directory | `--complete` marks the selection as the COMPLETE prescribed model — the wizard passes it, because a catalog install must read as installed (and update-able) @@ -189,7 +189,7 @@ SwiGLU FFN is rescaled by a power of two and the inverse folded into the norm the MLP reads, moving every block scale into the normal band. The fold is exact, not approximate. RMSNorm computes its reciprocal from the -norm's *input*, so scaling the norm *weight* scales its output by exactly that +norm's _input_, so scaling the norm _weight_ scales its output by exactly that factor; a power of two only shifts an exponent field, so no mantissa and no rounding decision changes anywhere downstream. silu is not homogeneous, which pins `gate_proj`'s factor to the inverse of the norm's; the elementwise product @@ -353,19 +353,19 @@ split). `--q-mtp split` (alias `drafter`) emits a body checkpoint with **no `qwen3_5_mtp` format (bare-keyed, BF16 MTP head); it does not require `--quantize`/`--q-recipe` and the body may be BF16 or already-quantized. -| Flag | Purpose | -| ------------------ | --------------------------------------------------------------------------------------------- | -| `-i`, `--input` | Source model directory (required) | -| `-o`, `--output` | Output directory (required) | -| `-d`, `--dtype` | Target dtype: `float32` / `float16` / `bfloat16` | -| `-q`, `--quantize` | Enable quantization | -| `--q-recipe` | One of `mixed_2_6`, `mixed_3_4`, `mixed_3_6`, `mixed_4_6`, `qwen3_5`, `unsloth`, `nvidia` | +| Flag | Purpose | +| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `-i`, `--input` | Source model directory (required) | +| `-o`, `--output` | Output directory (required) | +| `-d`, `--dtype` | Target dtype: `float32` / `float16` / `bfloat16` | +| `-q`, `--quantize` | Enable quantization | +| `--q-recipe` | One of `mixed_2_6`, `mixed_3_4`, `mixed_3_6`, `mixed_4_6`, `qwen3_5`, `unsloth`, `nvidia` | | `--q-mode` | `affine` (default), `mxfp4`, `mxfp8`, `nvfp4`, or `sym8`. `mxfp4`/`mxfp8` always use the in-tree encoders, never MLX's; `nvfp4` also applies the power-of-two lift to dense FFNs (SafeTensors sources only) | -| `--q-mxfp` | Select Unsloth's fixed MXFP tensor-class map, or upgrade eligible decisions for other recipes; MX block exponents are always encoder-tuned | -| `--q-mtp` | Qwen MTP-quant policy: `off`, `cyankiwi`, `all`, or `split` (alias `drafter`) | -| `--imatrix-path` | Path to imatrix file for AWQ pre-scaling | -| `--mmproj` | Vision-encoder conversion path | -| `-v`, `--verbose` | Verbose logging | +| `--q-mxfp` | Select Unsloth's fixed MXFP tensor-class map, or upgrade eligible decisions for other recipes; MX block exponents are always encoder-tuned | +| `--q-mtp` | Qwen MTP-quant policy: `off`, `cyankiwi`, `all`, or `split` (alias `drafter`) | +| `--imatrix-path` | Path to imatrix file for AWQ pre-scaling | +| `--mmproj` | Vision-encoder conversion path | +| `-v`, `--verbose` | Verbose logging | ### Load Gemma 4 GGUF directly @@ -593,17 +593,17 @@ kl_topk 0.13782 (K=512, teacher tail mass 0.04045) top1_agreement 77.64% ``` -| Flag | Purpose | -| ---------------- | --------------------------------------------------------------------------- | -| `--teacher` | Reference checkpoint, normally bf16 (`cache` mode, required) | -| `--model`, `-m` | Candidate checkpoint to score (`score` mode, required) | -| `--dataset` | Eval JSONL of `{"text": "..."}` rows (`cache` mode, required) | -| `--cache` | Teacher cache directory (required in both modes) | -| `--rows` | Dataset rows to capture (default `64`) | -| `--seq` | Tokens kept per row (default `512`, minimum `2`) | -| `--top-k` | Retained support per position (default `1024`, clamped to the vocabulary) | -| `--logit-chunk` | Positions per head projection (default `64`) | -| `--json` | Emit the report as one JSON object (`score` mode), for A/B scripting | +| Flag | Purpose | +| --------------- | ------------------------------------------------------------------------- | +| `--teacher` | Reference checkpoint, normally bf16 (`cache` mode, required) | +| `--model`, `-m` | Candidate checkpoint to score (`score` mode, required) | +| `--dataset` | Eval JSONL of `{"text": "..."}` rows (`cache` mode, required) | +| `--cache` | Teacher cache directory (required in both modes) | +| `--rows` | Dataset rows to capture (default `64`) | +| `--seq` | Tokens kept per row (default `512`, minimum `2`) | +| `--top-k` | Retained support per position (default `1024`, clamped to the vocabulary) | +| `--logit-chunk` | Positions per head projection (default `64`) | +| `--json` | Emit the report as one JSON object (`score` mode), for A/B scripting | **Reading the numbers.** `nll`, `perplexity` and `top1_agreement` are exact over the full vocabulary. `kl_topk` is a KL over a `K+1`-way partition: one term per @@ -635,7 +635,7 @@ bf16 teacher it came from. The cache is also re-checked after scoring: if a capture replaced it mid-run, the score is refused rather than reported. **What the teacher is.** `--teacher` is normally the bf16 model, and every -number is a divergence *from it*. A quantized checkpoint is accepted rather than +number is a divergence _from it_. A quantized checkpoint is accepted rather than refused — anchoring on a released reference, or A/B-ing two recipes against a shared one, is a real comparison — but the cache records that it was quantized and `score` prints the fact beside the teacher path. Read a report carrying that @@ -805,11 +805,11 @@ On a fresh run (no explicit `--model`/`--provider`/session flag), it injects the When no local model exists, an interactive terminal shows a first-run wizard over a curated catalog and downloads the choice via `mlx download model`. In a non-interactive shell it prints the equivalent `mlx download model` commands instead. The catalog: -| Model | HuggingFace repo | Size | Notes | -| --------------------- | -------------------------------------- | ------- | ---------------------------- | -| Qwen3.8-27B (default) | `unsloth/Qwen3.8-27B-GGUF` | ~18 GB | Best tool use — recommended | -| Qwen-AgentWorld-35B | `unsloth/Qwen-AgentWorld-35B-A3B-GGUF` | ~22 GB | Agent-tuned MoE, fast decode | -| Gemma-4-26B-A4B | `unsloth/gemma-4-26B-A4B-it-GGUF` | ~19 GB | MoE, fast decode | +| Model | HuggingFace repo | Size | Notes | +| --------------------- | -------------------------------------- | ------ | ---------------------------- | +| Qwen3.8-27B (default) | `unsloth/Qwen3.8-27B-GGUF` | ~18 GB | Best tool use — recommended | +| Qwen-AgentWorld-35B | `unsloth/Qwen-AgentWorld-35B-A3B-GGUF` | ~22 GB | Agent-tuned MoE, fast decode | +| Gemma-4-26B-A4B | `unsloth/gemma-4-26B-A4B-it-GGUF` | ~19 GB | MoE, fast decode | Each entry installs its Unsloth **UD-Q4_K_XL** GGUF (`-g "*UD-Q4_K_XL*"`, plus Gemma's `mmproj` projector), one variant out of the dozens diff --git a/docs/concurrent-inference.md b/docs/concurrent-inference.md index 95615d54d..bae5bbd41 100644 --- a/docs/concurrent-inference.md +++ b/docs/concurrent-inference.md @@ -55,15 +55,15 @@ the next plan. Decode-first is a priority inside that one plan, keeping interactive rows moving before a long newly admitted prefill consumes the step's remaining token budget. -| Layer | Current mechanism | -| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Server | `/v1/responses` and `/v1/messages` select `withAdmission` only when a paged model explicitly reports a capacity greater than one. Every other model uses the existing `withExclusive` lane. Both routes retain their admission slot through terminal visibility or disconnect. | -| Admission | `SessionRegistry` is a FIFO counting semaphore for the batched lane. Active, queued, and pre-dispatch requests share the existing bounded budget; overflow still returns 429 plus `Retry-After: 1`. The default is 16 queued requests unless config or `MLX_MAX_QUEUE_DEPTH_PER_MODEL` overrides it or selects `unbounded`. | -| Native thread | One scheduler and executor live beside the model on its dedicated OS thread; `MxArray` never crosses threads. Idle waits block, while busy periods poll commands only between steps. Qwen3, LFM2/2.5, Qwen3.5 Dense/MoE, Gemma4, Muse-Glimmer, and NemotronH instantiate the same engine-owned `HybridSchedulerState`; their backends provide cache/recurrent/prefix/decode hooks rather than duplicating request lifecycle code. | -| Scheduler | One global token ceiling serves decode rows first, then pinned prefill slices. Exclusive commands run only with an empty running set; reset/generate/save/train are barriers. Block growth and hybrid recurrent-state bytes share one unified-memory admission watermark; an actual lazy-allocation squeeze preempts exactly one newest running victim. | -| Cache | `PagedKVCacheAdapter` owns a request table keyed by sequence id over a refcounted block pool. Hybrid families add one manager per compatible KV group. Gemma4 owns distinct full/sliding pools; expired sliding blocks become null sentinels so logical positions remain absolute. A preempted victim releases its live tables and replays safely. Sidecar-free SSD reads can park in `WaitingForSsd`; hybrid exact-boundary restores currently reconcile their K/V and companion state synchronously. | +| Layer | Current mechanism | +| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Server | `/v1/responses` and `/v1/messages` select `withAdmission` only when a paged model explicitly reports a capacity greater than one. Every other model uses the existing `withExclusive` lane. Both routes retain their admission slot through terminal visibility or disconnect. | +| Admission | `SessionRegistry` is a FIFO counting semaphore for the batched lane. Active, queued, and pre-dispatch requests share the existing bounded budget; overflow still returns 429 plus `Retry-After: 1`. The default is 16 queued requests unless config or `MLX_MAX_QUEUE_DEPTH_PER_MODEL` overrides it or selects `unbounded`. | +| Native thread | One scheduler and executor live beside the model on its dedicated OS thread; `MxArray` never crosses threads. Idle waits block, while busy periods poll commands only between steps. Qwen3, LFM2/2.5, Qwen3.5 Dense/MoE, Gemma4, Muse-Glimmer, and NemotronH instantiate the same engine-owned `HybridSchedulerState`; their backends provide cache/recurrent/prefix/decode hooks rather than duplicating request lifecycle code. | +| Scheduler | One global token ceiling serves decode rows first, then pinned prefill slices. Exclusive commands run only with an empty running set; reset/generate/save/train are barriers. Block growth and hybrid recurrent-state bytes share one unified-memory admission watermark; an actual lazy-allocation squeeze preempts exactly one newest running victim. | +| Cache | `PagedKVCacheAdapter` owns a request table keyed by sequence id over a refcounted block pool. Hybrid families add one manager per compatible KV group. Gemma4 owns distinct full/sliding pools; expired sliding blocks become null sentinels so logical positions remain absolute. A preempted victim releases its live tables and replays safely. Sidecar-free SSD reads can park in `WaitingForSsd`; hybrid exact-boundary restores currently reconcile their K/V and companion state synchronously. | | Executor | Dense Qwen3 defaults to uniform `[N,1]` decode plus bounded prefill; `MLX_SCHED_RAGGED_STEP=1` instead packs every planned slice. LFM2 defaults to a row-exact decode wave — one N=1 forward per row inside the shared step, bit-identical to serial — because the fused `[N,1,H]` path's M=N GEMM tiles round differently than M=1 and the carried ShortConv state amplifies a few bf16 ULP into occasional greedy near-tie flips; `MLX_LFM2_FUSED_BATCH_DECODE=1` opts back into one fused forward (stacking/scattering private ShortConv rows) for benchmarking. Text-only Qwen3.5 dense and MoE stack/scatter private GDN rows; MoE expert routing consumes the same `[N,1,H]` tensor rather than falling back to one forward per row. NemotronH has a fused lane — stacking/scattering per-request Mamba-2 rows (conv `[3,6144]` + SSM `[64,64,128]` f32 per SSM layer) around each SSM layer, routing its pure MoE-FFN layers over the shared `[N,1,H]` tensor, and driving its six GQA layers through the batched paged kernels — but that lane is reachable only on a **dense/bf16** checkpoint. `load_inner` (`models/nemotron_h/persistence.rs`) sets `row_exact_decode_projections` for **any** quantized checkpoint (a top-level quant mode or any per-layer override), which the only published NemotronH checkpoint (NVFP4 30B-A3B) always is, so the decode wave instead dispatches to `run_row_exact_decode_wave` (`models/nemotron_h/model.rs`), one single-row decode per row with the logits concatenated — bit-identical to N serial decodes, but N full weight streams. On that checkpoint the batching win is the shared scheduler (prefill/decode interleaving, shared prefix blocks, no whole-turn TTFT queueing), **not** one fused decode forward; the fused branch is currently dead code there. Gemma4 fuses ordinary `[N,1]` rows through full and sliding paged groups, including KV-shared anchors and PLE. Eligible all-greedy rows stay batched through one argmax/eval epilogue; mixed sampling or penalties retain scalar per-row semantics. `schedulerStats().fusedGreedyEpilogueSteps` counts executor-confirmed fused epilogues rather than inferring them from planned occupancy. Every request retains private penalties, sampling, stop, stream, and cancellation state. | -| Transport | SSE writes honor Node backpressure and stop native pulls until drain or close. The 30-second default drain deadline aborts a connected stalled peer and releases admission. The chat model-thread output mailbox, native callback queue, and JS callback queue are independently capped at 64 events. | +| Transport | SSE writes honor Node backpressure and stop native pulls until drain or close. The 30-second default drain deadline aborts a connected stalled peer and releases admission. The chat model-thread output mailbox, native callback queue, and JS callback queue are independently capped at 64 events. | Per-request prefix verification remains all-or-nothing. A miss releases and rebuilds only that sequence's slot; it never resets a peer. Batched admission @@ -84,7 +84,7 @@ same-binary uniform/ragged validation with separate model instances: | `MLX_SCHED_WATERMARK_FRACTION` | `0.05` | Free-block headroom retained while work is already live. | | `MLX_SCHED_RESERVE_FULL_ISL` | `1` | Reserve each admitted request's remaining prompt growth in the must-fit test. | | `MLX_SCHED_RAGGED_STEP` | `0` | Use one packed varlen Qwen3 forward for mixed prefill/decode slices. | -| `MLX_LFM2_FUSED_BATCH_DECODE` | `0` | Opt LFM2 multi-row decode back into one fused `[N,1]` forward (default is a row-exact wave: per-row N=1 forwards, bit-identical to serial). | +| `MLX_LFM2_FUSED_BATCH_DECODE` | `0` | Opt LFM2 multi-row decode back into one fused `[N,1]` forward (default is a row-exact wave: per-row N=1 forwards, bit-identical to serial). | | `MLX_PAGED_PER_SEQ_CTX` | `unset` | Optional per-sequence **admit** cap and Qwen3/LFM2 pool-budget clip. Unset → admit `min(trained, live pool minus recurrent KV-equivalent)`. When set, Muse/Nemotron `contextLimits()` also publish that cap. Default 32768 applies only to the Qwen3/LFM2 **pool request** formula. | | `MLX_CONTINUOUS_BATCHING` | `0` | Opt default-off compatible checkpoints into the shared scheduled lane (currently Qwen3.5 dense and MoE). | | `MLX_SERVE_FORCE_SERIAL` | `0` | Route eligible Qwen3/LFM2/Qwen3.5/Gemma4/Muse-Glimmer/NemotronH turns through the whole-turn path for A/B and rollback. | diff --git a/docs/convert-quantize.md b/docs/convert-quantize.md index 2b78e9c0d..2a2a372de 100644 --- a/docs/convert-quantize.md +++ b/docs/convert-quantize.md @@ -1,6 +1,6 @@ # Convert & quantize internals -`docs/cli.md` covers how to *invoke* `mlx convert` / `mlx calibrate`. This doc covers what those +`docs/cli.md` covers how to _invoke_ `mlx convert` / `mlx calibrate`. This doc covers what those commands actually do: the on-disk formats, the per-tensor decision engine, the `config.json` provenance contract, and the places where a conversion looks right for the wrong reason. @@ -14,11 +14,11 @@ Every quantized tensor is a group of 1–3 arrays sharing a base key: `{base}.we actually is comes from `config.json`, not from the file layout — so three stages own three different things and confusing them is the main source of drift here. -| Stage | Code | Owns | -| ------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | -| **CONVERTER** | `crates/mlx-core/src/convert.rs`, `crates/mlx-core/src/utils/gguf.rs`, `crates/mlx-core/src/convert_gemma_import.rs` | Picks per-key `{bits, group_size, mode}`, packs the bytes, writes the `quantization` block + per-tensor overrides | -| **LOADER** | `crates/mlx-core/src/engine/persistence.rs`, `crates/mlx-core/src/models/quant_dispatch.rs`, per-family `persistence.rs` | Parses that block, **rebuilds arrays that are not on disk**, fail-loud validates every dtype/shape before dispatch | -| **KERNEL** | `crates/mlx-sys/mlx/mlx/ops.cpp` → `backend/{metal,cpu}` | Consumes `(weight, scales, biases, group_size, bits, mode)`; re-validates via `validate_mode_with_type` + `quantization_params_from_mode` | +| Stage | Code | Owns | +| ------------- | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | +| **CONVERTER** | `crates/mlx-core/src/convert.rs`, `crates/mlx-core/src/utils/gguf.rs`, `crates/mlx-core/src/convert_gemma_import.rs` | Picks per-key `{bits, group_size, mode}`, packs the bytes, writes the `quantization` block + per-tensor overrides | +| **LOADER** | `crates/mlx-core/src/engine/persistence.rs`, `crates/mlx-core/src/models/quant_dispatch.rs`, per-family `persistence.rs` | Parses that block, **rebuilds arrays that are not on disk**, fail-loud validates every dtype/shape before dispatch | +| **KERNEL** | `crates/mlx-sys/mlx/mlx/ops.cpp` → `backend/{metal,cpu}` | Consumes `(weight, scales, biases, group_size, bits, mode)`; re-validates via `validate_mode_with_type` + `quantization_params_from_mode` | Three things the loader materializes that are **not bytes on disk**: @@ -112,20 +112,20 @@ three ggml K-quants are **consume-only** — `mx.quantize` throws for them by na Shapes are for a dense `[N, K]` source weight; stacked experts add a leading `[E, …]` to every array. -| mode | `.weight` | `.scales` | `.biases` | bpw | default bits / gs | mlx-lm loadable | direction | -| -------------------- | ----------------------- | ----------------------- | ----------------------------- | ------------------------------------- | --------------------- | ----------------------- | ---------------- | -| **affine** | u32 `[N, K·b/32]` | *wdtype* `[N, K/gs]` | *wdtype* `[N, K/gs]` | `b + 2·16/gs` → 4/64 = **4.500** | **4 / 64** | yes | produce + consume | -| **mxfp4** | u32 `[N, K/8]` | u8 `[N, K/32]` | must be absent | **4.250** | **4 / 32** (pinned) | yes | produce + consume | -| **mxfp8** | u32 `[N, K/4]` | u8 `[N, K/32]` | must be absent | **8.250** | **8 / 32** (pinned) | yes | produce + consume | -| **nvfp4** | u32 `[N, K/8]` | u8 (E4M3) `[N, K/16]` | must be absent | **4.500** | **4 / 16** (pinned) | yes | produce + consume | -| **fp8_e4m3** | u8 `[N, K]` raw E4M3 | bf16 `[N, 1]` | must be absent | `8 + 16/K` = **8.0039** @ K=4096 | 8 / `null` | no — KeyError + null gs | DGX map only | -| **sym8** | **int8** `[N, K]` | **f32 `[N]`** | error if present | `8 + 32/K` = **8.0078** @ K=4096 | 8 / `null` | no — by design | produce + consume, **M5+ only** | -| **q6k** | u32 `[N, K·6/32]` | **int8** `[N, K/16]` | f16 `[N, K/256]` | **6.5625** (= ggml) | 6 / 16 (pinned) | no | **consume only** | -| **q4k** | u32 `[N, K/8]` | u8 `[N, 2K/32]` | f16 `[N, 2K/256]` | **4.6250** (ggml 4.500) | 4 / 32 (pinned) | no | **consume only** | -| **q5k** | u32 `[N, 5K/32]` | u8 `[N, 2K/32]` | f16 `[N, 2K/256]` | **5.6250** (ggml 5.500) | 5 / 32 (pinned) | no | **consume only** | -| **Q4_0 → affine** | u32 `[N, K/8]` | f16 `[N, K/32]` | **omitted, derived `-8·s`** | **4.500** (= ggml) | 4 / 32 | no — missing `.biases` | GGUF source only | -| **Q8_0 → affine** | u32 `[N, K/4]` | f16 `[N, K/32]` | **omitted, derived `-128·s`** | **8.500** (= ggml) | 8 / 32 | no | GGUF source only | -| **Q4_1 → affine** | u32 `[N, K/8]` | f16 `[N, K/32]` | f16 `[N, K/32]` (ggml `m`) | **5.000** (= ggml) | 4 / 32 | yes | GGUF source only | +| mode | `.weight` | `.scales` | `.biases` | bpw | default bits / gs | mlx-lm loadable | direction | +| ----------------- | -------------------- | --------------------- | ----------------------------- | -------------------------------- | ------------------- | ----------------------- | ------------------------------- | +| **affine** | u32 `[N, K·b/32]` | _wdtype_ `[N, K/gs]` | _wdtype_ `[N, K/gs]` | `b + 2·16/gs` → 4/64 = **4.500** | **4 / 64** | yes | produce + consume | +| **mxfp4** | u32 `[N, K/8]` | u8 `[N, K/32]` | must be absent | **4.250** | **4 / 32** (pinned) | yes | produce + consume | +| **mxfp8** | u32 `[N, K/4]` | u8 `[N, K/32]` | must be absent | **8.250** | **8 / 32** (pinned) | yes | produce + consume | +| **nvfp4** | u32 `[N, K/8]` | u8 (E4M3) `[N, K/16]` | must be absent | **4.500** | **4 / 16** (pinned) | yes | produce + consume | +| **fp8_e4m3** | u8 `[N, K]` raw E4M3 | bf16 `[N, 1]` | must be absent | `8 + 16/K` = **8.0039** @ K=4096 | 8 / `null` | no — KeyError + null gs | DGX map only | +| **sym8** | **int8** `[N, K]` | **f32 `[N]`** | error if present | `8 + 32/K` = **8.0078** @ K=4096 | 8 / `null` | no — by design | produce + consume, **M5+ only** | +| **q6k** | u32 `[N, K·6/32]` | **int8** `[N, K/16]` | f16 `[N, K/256]` | **6.5625** (= ggml) | 6 / 16 (pinned) | no | **consume only** | +| **q4k** | u32 `[N, K/8]` | u8 `[N, 2K/32]` | f16 `[N, 2K/256]` | **4.6250** (ggml 4.500) | 4 / 32 (pinned) | no | **consume only** | +| **q5k** | u32 `[N, 5K/32]` | u8 `[N, 2K/32]` | f16 `[N, 2K/256]` | **5.6250** (ggml 5.500) | 5 / 32 (pinned) | no | **consume only** | +| **Q4_0 → affine** | u32 `[N, K/8]` | f16 `[N, K/32]` | **omitted, derived `-8·s`** | **4.500** (= ggml) | 4 / 32 | no — missing `.biases` | GGUF source only | +| **Q8_0 → affine** | u32 `[N, K/4]` | f16 `[N, K/32]` | **omitted, derived `-128·s`** | **8.500** (= ggml) | 8 / 32 | no | GGUF source only | +| **Q4_1 → affine** | u32 `[N, K/8]` | f16 `[N, K/32]` | f16 `[N, K/32]` (ggml `m`) | **5.000** (= ggml) | 4 / 32 | yes | GGUF source only | Defaults are declared in exactly three consistent places plus MLX itself: `packages/cli/src/commands/convert.ts:9` (display only — see gotchas), @@ -149,12 +149,12 @@ Affine is the **only** mode exempt from the `(group_size, bits)` pinning gate at bpw = `bits + 2·(scale_dtype_bits / group_size)`. On `[4096, 4096]` = 16,777,216 weights, bf16 companions: -| config | weight bytes | scales | biases | total | bpw | -| ------ | --------------------------- | --------- | --------- | ---------- | --------- | -| 4 / 64 | 4096·512·4 = 8,388,608 | 524,288 | 524,288 | 9,437,184 | **4.500** | -| 8 / 64 | 4096·1024·4 = 16,777,216 | 524,288 | 524,288 | 17,825,792 | **8.500** | -| 4 / 32 | 8,388,608 | 1,048,576 | 1,048,576 | 10,485,760 | **5.000** | -| 3 / 64 | 4096·384·4 = 6,291,456 | 524,288 | 524,288 | 7,340,032 | **3.500** | +| config | weight bytes | scales | biases | total | bpw | +| ------ | ------------------------ | --------- | --------- | ---------- | --------- | +| 4 / 64 | 4096·512·4 = 8,388,608 | 524,288 | 524,288 | 9,437,184 | **4.500** | +| 8 / 64 | 4096·1024·4 = 16,777,216 | 524,288 | 524,288 | 17,825,792 | **8.500** | +| 4 / 32 | 8,388,608 | 1,048,576 | 1,048,576 | 10,485,760 | **5.000** | +| 3 / 64 | 4096·384·4 = 6,291,456 | 524,288 | 524,288 | 7,340,032 | **3.500** | `--dtype float32` doubles the companion cost: 4/64 becomes `4 + 2·32/64` = **5.000** bpw. @@ -179,18 +179,18 @@ scale codec by GROUP_SIZE, not by mode: fp_quantized.h:30 Output dtypes `{uint32, uint8}` are hard-coded in `fp_quantize` (`crates/mlx-sys/mlx/mlx/ops.cpp:5217`). bpw: -| mode | bits | gs | weight bpw | scale bpw | total | -| ----- | ---- | -- | ---------- | ----------- | --------- | -| mxfp4 | 4 | 32 | 4 | 8/32 = 0.25 | **4.250** | -| mxfp8 | 8 | 32 | 8 | 0.25 | **8.250** | -| nvfp4 | 4 | 16 | 4 | 8/16 = 0.50 | **4.500** | +| mode | bits | gs | weight bpw | scale bpw | total | +| ----- | ---- | --- | ---------- | ----------- | --------- | +| mxfp4 | 4 | 32 | 4 | 8/32 = 0.25 | **4.250** | +| mxfp8 | 8 | 32 | 8 | 0.25 | **8.250** | +| nvfp4 | 4 | 16 | 4 | 8/16 = 0.50 | **4.500** | Checked on `[4096,4096]`: mxfp4 = 8,388,608 + 524,288 = 8,912,896 B ⇒ 4.25 ✓; nvfp4 = 8,388,608 + 1,048,576 = 9,437,184 ⇒ 4.50 ✓. **Convert does not use MLX's encoder for mxfp4 or mxfp8.** The layout above is the format contract — codec, group size, sidecar shapes, dtypes — and it is what convert -writes and what every loader reads. The *block exponent* inside it is chosen in-tree, +writes and what every loader reads. The _block exponent_ inside it is chosen in-tree, on every convert, with no flag: `crates/mlx-core/src/quant/mxfp4_weight.rs` evaluates both candidate E8M0 exponents per block and keeps the lower squared error, and `crates/mlx-core/src/quant/mxfp8_weight.rs` takes the ceiling instead of MLX's @@ -202,7 +202,7 @@ bit-identity tests pin against `mlx_quantize`. nvfp4 keeps MLX's encoder, and ad power-of-two lift on dense FFNs (see `docs/cli.md`, "Data-free encoder tuning"). **The MLX nvfp4 quantizer writes no global scale.** The DGX port does not carry Unsloth's calibrated -global scales. The `nemotron_h` modelopt *ingest* is the one exception — it emits a `.global_scale` +global scales. The `nemotron_h` modelopt _ingest_ is the one exception — it emits a `.global_scale` sidecar rather than folding `weight_scale_2` into the per-group E4M3 scales; see the Nemotron-H NVFP4 section below. @@ -225,7 +225,7 @@ forward plain x.matmul(weight.T) qwen3_5/quantized_linear.rs:763 decoded magnitude > 448, because MLX's fast decoder maps the reserved `0x7f`/`0xff` E4M3 patterns to ±480 while `to_fp8` saturates at ±448 (`crates/mlx-core/src/quant/fp8_weight.rs:170`). -**Emission gate** — `--q-recipe unsloth --q-mode nvfp4` alone is *not* enough. +**Emission gate** — `--q-recipe unsloth --q-mode nvfp4` alone is _not_ enough. `select_official_unsloth_recipe` (`crates/mlx-core/src/convert.rs:4416`) returns `Some(Nvfp4)` only when `recipe == "unsloth"` **and** `is_qwen35_hybrid` **and** `!quant_mxfp` **and** `quant_mode == "nvfp4"`. On any non-Qwen-hybrid input the same CLI line returns `None` and @@ -258,15 +258,15 @@ gotchas. Fallback ladder inside `resolve_legacy_entry` (`crates/mlx-core/src/convert.rs:6233`), in order: -| condition | outcome | -| ------------------------------------------------------------------------------- | -------------------------- | -| `!should_quantize(key)` | dense bf16 | -| gemma4 PLE (`per_layer_*`) / `audio_tower` / `audio_encoder` / `embed_audio` | dense bf16 (sym8-scoped) | -| lfm2 packed embedding | dense bf16 | -| `is_affine_only_key` (lm_head / router.proj / embed_tokens\* / embedding_projection) | 8-bit **affine**, gs 64 | -| `is_router_gate` | 8-bit **affine**, gs 64 | -| 3-D `[E,N,K]` experts, or 2-D with `K % 16 != 0` | 8-bit **affine**, gs 64 | -| everything else | **sym8** | +| condition | outcome | +| ------------------------------------------------------------------------------------ | ------------------------ | +| `!should_quantize(key)` | dense bf16 | +| gemma4 PLE (`per_layer_*`) / `audio_tower` / `audio_encoder` / `embed_audio` | dense bf16 (sym8-scoped) | +| lfm2 packed embedding | dense bf16 | +| `is_affine_only_key` (lm_head / router.proj / embed_tokens\* / embedding_projection) | 8-bit **affine**, gs 64 | +| `is_router_gate` | 8-bit **affine**, gs 64 | +| 3-D `[E,N,K]` experts, or 2-D with `K % 16 != 0` | 8-bit **affine**, gs 64 | +| everything else | **sym8** | `enforce_sym8_group_coherence` (`crates/mlx-core/src/convert.rs:6002`) then re-applies the emission gates to every co-quantized group (five tables at `:5963`) and forces the whole group dense if any @@ -303,7 +303,7 @@ has_min == false (q6k) super_ratio = 16 Why this reuses MLX's affine kernel algebra: -1. A K-quant sub-block is *algebraically* affine — `value = scale*q + bias`, both constant inside a +1. A K-quant sub-block is _algebraically_ affine — `value = scale*q + bias`, both constant inside a group (`kquant.h:5`). `get_pack_factor`, `load_vector`, `qdot`, `qouter`, `dequantize` are byte copies of the affine ones and take `(scale, bias)` by value — they never see a K-quant. 2. The **importer**, not the kernel, absorbs ggml's swizzle: `q6k_code`/`q4k_code`/`q5k_code` @@ -322,11 +322,11 @@ because q6k's group of 16 is narrower than the BK=32 tile (`kquant.h:672`). bpw = `bits + 8/scales_per_value + 16·per_group/256`: -| mode | weight | scales | biases | mlx-node | bytes/super-block | ggml block | ggml bpw | Δ | -| ---- | ------ | ----------- | --------------- | ---------- | ----------------- | ---------- | -------- | ------ | -| q6k | 6 | 8/16 = 0.5 | 16/256 = 0.0625 | **6.5625** | 192+16+2 = **210** | 210 B | 6.5625 | **0** | +| mode | weight | scales | biases | mlx-node | bytes/super-block | ggml block | ggml bpw | Δ | +| ---- | ------ | ------------ | ---------------- | ---------- | ------------------ | ---------- | -------- | ------ | +| q6k | 6 | 8/16 = 0.5 | 16/256 = 0.0625 | **6.5625** | 192+16+2 = **210** | 210 B | 6.5625 | **0** | | q4k | 4 | 2·8/32 = 0.5 | 2·16/256 = 0.125 | **4.6250** | 128+16+4 = **148** | 144 B | 4.5000 | +0.125 | -| q5k | 5 | 0.5 | 0.125 | **5.6250** | 160+16+4 = **180** | 176 B | 5.5000 | +0.125 | +| q5k | 5 | 0.5 | 0.125 | **5.6250** | 160+16+4 = **180** | 176 B | 5.5000 | +0.125 | Exactness vs llama.cpp: decode is float32 on both levels, so **q4k and q5k are bitwise identical**. **Q6_K is identical up to the sign of zero** — ggml subtracts 32 in integer arithmetic, the contract @@ -365,11 +365,11 @@ raw IEEE-754 bit equality with **no tolerance** over an adversarial scale set. bpw at K=4096 rows: -| source | weight | scales | biases | mlx-node | ggml block | ggml bpw | -| ------ | ------ | ----------- | ----------------- | --------- | ---------- | -------- | -| Q4_0 | 4 | 16/32 = 0.5 | **0** (derived) | **4.500** | 18 B / 32 | 4.500 | -| Q8_0 | 8 | 0.5 | **0** (derived) | **8.500** | 34 B / 32 | 8.500 | -| Q4_1 | 4 | 0.5 | 0.5 (real `m`) | **5.000** | 20 B / 32 | 5.000 | +| source | weight | scales | biases | mlx-node | ggml block | ggml bpw | +| ------ | ------ | ----------- | --------------- | --------- | ---------- | -------- | +| Q4_0 | 4 | 16/32 = 0.5 | **0** (derived) | **4.500** | 18 B / 32 | 4.500 | +| Q8_0 | 8 | 0.5 | **0** (derived) | **8.500** | 34 B / 32 | 8.500 | +| Q4_1 | 4 | 0.5 | 0.5 (real `m`) | **5.000** | 20 B / 32 | 5.000 | Before the change, Q4_0 landed at 5.0 and Q8_0 at 9.0 — larger than the GGUF they came from. 0.5 bpw = 0.0625 bytes/weight; the 681 MB saving on Gemma-4-12B-QAT (`crates/mlx-core/src/engine/persistence.rs:206`) therefore implies @@ -389,16 +389,16 @@ weights but **does** write a real `.biases` array (every entry `-2^(bits-1)·s`) Because all nine formats share the triplet shape, dtypes are the only discriminator. Every guard runs **before** dispatching on `plq.mode`. -| guard | trips when | file:line | -| ----------------------------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | -| `ensure_dense_weight_floating` | non-float weight reaches a dense `set_weight` (stripped `.scales`) | `crates/mlx-core/src/models/quant_dispatch.rs:224-234` | -| `ensure_int8_storage_resolves_sym8` | int8 `.weight` but mode ≠ sym8 | `quant_dispatch.rs:243-261` | -| `ensure_plain_fp8_storage_resolves_fp8_e4m3` | u8 weight + **floating** `.scales` but mode ≠ fp8_e4m3 | `quant_dispatch.rs:270-295` | -| `ensure_kquant_storage_resolves_kquant` | int8 `.scales` (q6k) **or** u8 `.scales` + f16 `.biases` (q4k/q5k) but mode is not a K-quant | `quant_dispatch.rs:307-334` | -| `ensure_affine_biases_present` | affine group has `.weight` + `.scales` but no `.biases` | `quant_dispatch.rs:355-378` | -| `resolve_kquant_group` | K-quant with wrong dtype or rank; `Ok(None)` **only** if `.scales` is absent | `quant_dispatch.rs:417-475` | -| `validate_mode_with_type` (C++) | affine w/o biases; K-quant wrong scales dtype / missing f16 biases; float modes with non-null biases | `crates/mlx-sys/mlx/mlx/ops.cpp:4678-4749` | -| `quantization_params_from_mode` (C++) | any non-affine mode whose `(gs, bits)` differ from the pinned pair | `crates/mlx-sys/mlx/mlx/ops.cpp:4661-4671` | +| guard | trips when | file:line | +| -------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | +| `ensure_dense_weight_floating` | non-float weight reaches a dense `set_weight` (stripped `.scales`) | `crates/mlx-core/src/models/quant_dispatch.rs:224-234` | +| `ensure_int8_storage_resolves_sym8` | int8 `.weight` but mode ≠ sym8 | `quant_dispatch.rs:243-261` | +| `ensure_plain_fp8_storage_resolves_fp8_e4m3` | u8 weight + **floating** `.scales` but mode ≠ fp8_e4m3 | `quant_dispatch.rs:270-295` | +| `ensure_kquant_storage_resolves_kquant` | int8 `.scales` (q6k) **or** u8 `.scales` + f16 `.biases` (q4k/q5k) but mode is not a K-quant | `quant_dispatch.rs:307-334` | +| `ensure_affine_biases_present` | affine group has `.weight` + `.scales` but no `.biases` | `quant_dispatch.rs:355-378` | +| `resolve_kquant_group` | K-quant with wrong dtype or rank; `Ok(None)` **only** if `.scales` is absent | `quant_dispatch.rs:417-475` | +| `validate_mode_with_type` (C++) | affine w/o biases; K-quant wrong scales dtype / missing f16 biases; float modes with non-null biases | `crates/mlx-sys/mlx/mlx/ops.cpp:4678-4749` | +| `quantization_params_from_mode` (C++) | any non-affine mode whose `(gs, bits)` differ from the pinned pair | `crates/mlx-sys/mlx/mlx/ops.cpp:4661-4671` | `ensure_affine_biases_present` is live in four families: gemma4 (6 non-test call sites — `crates/mlx-core/src/models/gemma4/persistence.rs:1361`, `:1390`, `:1586`, `:1850`, `:2140`, `:2205`), @@ -410,7 +410,7 @@ The converter mirrors these before writing: `validate_existing_quantized_entry` (`crates/mlx-core/src/convert.rs:5620`). Dtype-cast preservation is content-keyed, not suffix-keyed: `kquant_biases_to_preserve` freezes an f16 `.biases` only when its sibling `.scales` is int8/u8 (`:5455`), and `sym8_scales_cast_action` normalizes an f16/bf16 `[N]` scale next to an int8 weight -*up* to f32 (`:5485`). +_up_ to f32 (`:5485`). ## Recipes & the per-tensor decision engine @@ -422,14 +422,14 @@ enum QuantDecision { Skip, Default, Custom { bits: i32, group_size: i32, mode: S There are **six predicate-builder functions**; `build_predicate_for_recipe` (`crates/mlx-core/src/convert.rs:5207`) is a dispatcher, not a builder: -| builder | line | serves | -| -------------------------------- | ------ | --------------------------------------------------- | -| `build_recipe_predicate` | `3949` | all four `mixed_*` (one dispatcher arm at `:5214`) | -| `build_qwen35_recipe` | `4049` | `qwen3_5` | -| `build_unsloth_recipe` | `4172` | `unsloth` (legacy affine, imatrix-gated) | -| `build_official_unsloth_recipe` | `4470` | the **fixed** Unsloth class map (Mxfp / Nvfp4) | -| `build_nvidia_recipe` | `4637` | `nvidia` | -| `build_privacy_filter_predicate` | `4736` | privacy-filter (never calls `should_quantize`) | +| builder | line | serves | +| -------------------------------- | ------ | -------------------------------------------------- | +| `build_recipe_predicate` | `3949` | all four `mixed_*` (one dispatcher arm at `:5214`) | +| `build_qwen35_recipe` | `4049` | `qwen3_5` | +| `build_unsloth_recipe` | `4172` | `unsloth` (legacy affine, imatrix-gated) | +| `build_official_unsloth_recipe` | `4470` | the **fixed** Unsloth class map (Mxfp / Nvfp4) | +| `build_nvidia_recipe` | `4637` | `nvidia` | +| `build_privacy_filter_predicate` | `4736` | privacy-filter (never calls `should_quantize`) | Seven `--q-recipe` names are accepted (`crates/mlx-core/src/convert.rs:2122`); nine dispatch paths exist once the fixed map and privacy-filter are counted; `resolve_legacy_entry` (`:6233`) is the tenth @@ -441,23 +441,23 @@ path and the only sym8-aware one. `recipe_gs = 64` instead of the top-level 16, because affine only accepts {32,64,128} (`crates/mlx-core/src/convert.rs:2878`). -| Tensor class | `mixed_L_H` | `qwen3_5` | `unsloth` legacy | `unsloth`+`--q-mxfp` (fixed) | `unsloth`+`nvfp4` (fixed) | `nvidia` | no recipe | -| -------------------------------------------- | ------------------------- | ----------------- | ---------------- | ---------------------------- | ------------------------- | ------------ | ----------- | -| `embed_tokens` / `embedding.` | Skip | Skip | `snap(B+2)`/G | **Skip** | **Skip** | Skip | Skip\* | -| `lm_head` | `H`/G affine | **Skip** | `snap(B+3)`/G | mxfp8 8/32 | fp8_e4m3 | mxfp4 4/32 | Skip | -| `self_attn.q/k/v_proj` | `L` (v→`H` in eligible) | `min(B+2,8)` | `snap(B+2)` | mxfp8 8/32 | fp8_e4m3 | mxfp8 8/32 | top-level | -| `self_attn.o_proj` | `L` | **8/64 affine** | **8/64 affine** | mxfp8 8/32 | fp8_e4m3 | mxfp8 8/32 | top-level | -| `linear_attn.in_proj_qkv` / `in_proj_z` | `L` | `min(B+2,8)` | `snap(B+2)` | mxfp8 | fp8_e4m3 | mxfp8 8/32 | top-level | -| `linear_attn.out_proj` | `L` | **8/64 affine** | **8/64 affine** | mxfp8 | fp8_e4m3 | mxfp8 8/32 | top-level | -| `linear_attn.in_proj_a` / `in_proj_b` | `L` | **8/64 affine** | **8/64 affine** | **Skip (bf16)** | **Skip (bf16)** | 8/64 affine | top-level | -| `linear_attn.in_proj_ba` | Skip | Skip | Skip | Skip | Skip | Skip | Skip | -| FFN `gate_proj` / `up_proj` | `L` | `Default` | `Default` | layer < N−8 → mxfp4; else mxfp8 | layer < N−8 → nvfp4; else fp8_e4m3 | mxfp4 4/32 | top-level | -| FFN `down_proj` | `H` in eligible, else `L` | `min(B+1,8)` | `snap(B+1)` | same split | same split | mxfp4 4/32 | top-level | -| Router gates | 8/64 affine | 8/64 affine | 8/64 affine | **Skip (bf16)** | **Skip (bf16)** | 8/64 affine | 8/64 affine | -| norms / `A_log` / `dt_bias` / `conv1d` | Skip | Skip | Skip | Skip | Skip | Skip | Skip | -| vision (`vision_tower`, `visual.`, …) | Skip | Skip | Skip | Skip | Skip | Skip | Skip | -| `mtp.*` | Skip | Skip | Skip | Skip | Skip | Skip | Skip | -| **anything unmatched** | `L` | `Default` | `Default` | **Skip** | **Skip** | **Skip** | top-level | +| Tensor class | `mixed_L_H` | `qwen3_5` | `unsloth` legacy | `unsloth`+`--q-mxfp` (fixed) | `unsloth`+`nvfp4` (fixed) | `nvidia` | no recipe | +| --------------------------------------- | ------------------------- | --------------- | ---------------- | ------------------------------- | ---------------------------------- | ----------- | ----------- | +| `embed_tokens` / `embedding.` | Skip | Skip | `snap(B+2)`/G | **Skip** | **Skip** | Skip | Skip\* | +| `lm_head` | `H`/G affine | **Skip** | `snap(B+3)`/G | mxfp8 8/32 | fp8_e4m3 | mxfp4 4/32 | Skip | +| `self_attn.q/k/v_proj` | `L` (v→`H` in eligible) | `min(B+2,8)` | `snap(B+2)` | mxfp8 8/32 | fp8_e4m3 | mxfp8 8/32 | top-level | +| `self_attn.o_proj` | `L` | **8/64 affine** | **8/64 affine** | mxfp8 8/32 | fp8_e4m3 | mxfp8 8/32 | top-level | +| `linear_attn.in_proj_qkv` / `in_proj_z` | `L` | `min(B+2,8)` | `snap(B+2)` | mxfp8 | fp8_e4m3 | mxfp8 8/32 | top-level | +| `linear_attn.out_proj` | `L` | **8/64 affine** | **8/64 affine** | mxfp8 | fp8_e4m3 | mxfp8 8/32 | top-level | +| `linear_attn.in_proj_a` / `in_proj_b` | `L` | **8/64 affine** | **8/64 affine** | **Skip (bf16)** | **Skip (bf16)** | 8/64 affine | top-level | +| `linear_attn.in_proj_ba` | Skip | Skip | Skip | Skip | Skip | Skip | Skip | +| FFN `gate_proj` / `up_proj` | `L` | `Default` | `Default` | layer < N−8 → mxfp4; else mxfp8 | layer < N−8 → nvfp4; else fp8_e4m3 | mxfp4 4/32 | top-level | +| FFN `down_proj` | `H` in eligible, else `L` | `min(B+1,8)` | `snap(B+1)` | same split | same split | mxfp4 4/32 | top-level | +| Router gates | 8/64 affine | 8/64 affine | 8/64 affine | **Skip (bf16)** | **Skip (bf16)** | 8/64 affine | 8/64 affine | +| norms / `A_log` / `dt_bias` / `conv1d` | Skip | Skip | Skip | Skip | Skip | Skip | Skip | +| vision (`vision_tower`, `visual.`, …) | Skip | Skip | Skip | Skip | Skip | Skip | Skip | +| `mtp.*` | Skip | Skip | Skip | Skip | Skip | Skip | Skip | +| **anything unmatched** | `L` | `Default` | `Default` | **Skip** | **Skip** | **Skip** | top-level | \* unless `embed_quantizable` (lfm2/lfm2_moe) — but see the note below: no recipe ever receives it. @@ -468,14 +468,14 @@ The last row is the sharpest structural split: `mixed_*` / `qwen3_5` / `unsloth` Bits arithmetic for `unsloth` (`snap_bits` maps 7 → 8, `crates/mlx-core/src/convert.rs:4176`): -| class | formula | via CLI (`d=3`) | via direct NAPI (`d=4`) | -| -------------------------------- | ------------ | ---------------- | ----------------------- | -| `gate_proj` / `up_proj` | `Default`→`d` | 3 | 4 | -| `down_proj` | `snap(d+1)` | `snap(4)` = 4 | `snap(5)` = 5 | -| `embed_tokens` | `snap(d+2)` | `snap(5)` = 5 | `snap(6)` = 6 | -| `lm_head` | `snap(d+3)` | `snap(6)` = 6 | `snap(7)` = **8** | -| `q/k/v_proj`, `in_proj_qkv/z` | `snap(d+2)` | 5 + AWQ | 6 + AWQ | -| `o_proj`, `out_proj`, `in_proj_a/b` | pinned | 8-bit affine gs 64 | 8-bit affine gs 64 | +| class | formula | via CLI (`d=3`) | via direct NAPI (`d=4`) | +| ----------------------------------- | ------------- | ------------------ | ----------------------- | +| `gate_proj` / `up_proj` | `Default`→`d` | 3 | 4 | +| `down_proj` | `snap(d+1)` | `snap(4)` = 4 | `snap(5)` = 5 | +| `embed_tokens` | `snap(d+2)` | `snap(5)` = 5 | `snap(6)` = 6 | +| `lm_head` | `snap(d+3)` | `snap(6)` = 6 | `snap(7)` = **8** | +| `q/k/v_proj`, `in_proj_qkv/z` | `snap(d+2)` | 5 + AWQ | 6 + AWQ | +| `o_proj`, `out_proj`, `in_proj_a/b` | pinned | 8-bit affine gs 64 | 8-bit affine gs 64 | The `d=3` column reproduces the documented ladder (`packages/cli/src/commands/convert.ts:87`). `d=3` is a **TS-only injection** at `packages/cli/src/commands/convert.ts:383` — see gotchas. @@ -484,19 +484,19 @@ is a **TS-only injection** at `packages/cli/src/commands/convert.ts:383` — see Returns `false` for (`crates/mlx-core/src/convert.rs:3317-3396`): -| # | test | line | -| -- | ----------------------------------------------------------------- | ------ | -| 1 | key does not end in `.weight` | `3319` | -| 2 | `vision_tower` or `visual.` | `3324` | -| 3 | `vision_embedder` (loader has no quantized branch) | `3331` | -| 4 | `lm_head` — **unconditional** | `3338` | -| 5 | `embed_tokens` / `embedding.` unless `embed_quantizable` | `3345` | -| 6 | `layernorm` / `rms_norm` / `_norm.` | `3350` | -| 7 | `conv1d` | `3355` | -| 8 | ends with `conv.conv.weight` (LFM2 depthwise short conv) | `3365` | -| 9 | `A_log` / `dt_bias` | `3370` | -| 10 | `in_proj_ba.` (fused GDN low-rank; split a/b are NOT excluded) | `3384` | -| 11 | `is_mtp_key` | `3391` | +| # | test | line | +| --- | -------------------------------------------------------------- | ------ | +| 1 | key does not end in `.weight` | `3319` | +| 2 | `vision_tower` or `visual.` | `3324` | +| 3 | `vision_embedder` (loader has no quantized branch) | `3331` | +| 4 | `lm_head` — **unconditional** | `3338` | +| 5 | `embed_tokens` / `embedding.` unless `embed_quantizable` | `3345` | +| 6 | `layernorm` / `rms_norm` / `_norm.` | `3350` | +| 7 | `conv1d` | `3355` | +| 8 | ends with `conv.conv.weight` (LFM2 depthwise short conv) | `3365` | +| 9 | `A_log` / `dt_bias` | `3370` | +| 10 | `in_proj_ba.` (fused GDN low-rank; split a/b are NOT excluded) | `3384` | +| 11 | `is_mtp_key` | `3391` | Recipes that quantize `lm_head` / `embed_tokens` branch **before** calling it: `mixed_*` (`:3988`), `unsloth` (`:4198`, `:4205`), fixed-Unsloth (`:4534`), `nvidia` (`:4648`). `qwen3_5` has no such @@ -542,11 +542,11 @@ original = inner(key) `apply_nvfp4_upgrade` (`crates/mlx-core/src/convert.rs:5139`) is **not** the same shape: -| difference | `apply_mxfp_upgrade` | `apply_nvfp4_upgrade` | -| ------------------------------ | ------------------------------------ | ------------------------------------------------ | -| `is_bitexact_affine_proj` arm | present (`:4836`) | **absent** — no equivalent (`:3890` says none needed) | -| `is_affine_only_key` arm | returns `original` untouched (`:4820`) | **rewrites** `Default` → `Custom{8,64,affine}` (`:5155`) | -| `Default` arm | branches on `default_bits` (`:4863`) | promotes **unconditionally** to nvfp4 4/16 (`:5185`) | +| difference | `apply_mxfp_upgrade` | `apply_nvfp4_upgrade` | +| ----------------------------- | -------------------------------------- | -------------------------------------------------------- | +| `is_bitexact_affine_proj` arm | present (`:4836`) | **absent** — no equivalent (`:3890` says none needed) | +| `is_affine_only_key` arm | returns `original` untouched (`:4820`) | **rewrites** `Default` → `Custom{8,64,affine}` (`:5155`) | +| `Default` arm | branches on `default_bits` (`:4863`) | promotes **unconditionally** to nvfp4 4/16 (`:5185`) | ### The two decision-scoping traps @@ -571,7 +571,7 @@ consulted by the no-recipe ladder (`:6311`), where listing `o_proj` would stop a **Trap B — `sym8_eligible` must not be asked about a packed tensor.** It reads the ARRAY: `ndim == 2 && K % 16 == 0` (`:5546`). A packed affine weight is `Uint32 [N, K/8]` — for `[8, 128]` -that is 2-D with `128 % 16 == 0`, so it answers "eligible" while describing the *packing*. +that is 2-D with `128 % 16 == 0`, so it answers "eligible" while describing the _packing_. `resolve_legacy_entry` therefore takes a `for_existing` flag and swaps the question for a dtype witness (`:6345`): @@ -603,12 +603,12 @@ mtp_quant_decision(key) convert.rs:3790 else → Some(Skip) ``` -| policy | MTP layer linears | `mtp.fc` | norms | storage | -| -------------------- | ------------------------ | --------- | ----- | ------------------------------------------------------------- | -| `off` | inner predicate (Skip) | Skip | Skip | — | -| `cyankiwi` | affine 4/32 | **Skip** | Skip | dense qwen3_5 → `mtp.safetensors`; qwen3_5_moe → inline shards | -| `all` | affine 4/32 | affine 4/32 | Skip | same split | -| `split` (= `drafter`) | **not wrapped** (like `off`, `:2909`) | — | — | `mtp.*` pulled into a bf16 `mtp-drafter/` dir | +| policy | MTP layer linears | `mtp.fc` | norms | storage | +| --------------------- | ------------------------------------- | ----------- | ----- | -------------------------------------------------------------- | +| `off` | inner predicate (Skip) | Skip | Skip | — | +| `cyankiwi` | affine 4/32 | **Skip** | Skip | dense qwen3_5 → `mtp.safetensors`; qwen3_5_moe → inline shards | +| `all` | affine 4/32 | affine 4/32 | Skip | same split | +| `split` (= `drafter`) | **not wrapped** (like `off`, `:2909`) | — | — | `mtp.*` pulled into a bf16 `mtp-drafter/` dir | `MTP_QUANT_BITS = 4`, `MTP_QUANT_GROUP_SIZE = 32` are hard-coded (`crates/mlx-core/src/convert.rs:3303`). Because `apply_mtp_quant_policy` is the **outermost** @@ -635,10 +635,10 @@ use_more_bits = (index < num_layers // 8 `num_layers = 48`: -| | first | last boundary | middle band (6 ≤ i < 42) | count | -| -------- | ----- | -------------- | -------------------------------------- | ----- | -| mlx-node | i<6 | i ≥ 48−6 = 42 | `i%3==0` → {6, 9, …, 39} | 24 | -| mlx-lm | i<6 | i ≥ 7·48//8 = 42 | `(i−6)%3==2` ⇒ i≡2 (mod 3) → {8,11,…,41} | 24 | +| | first | last boundary | middle band (6 ≤ i < 42) | count | +| -------- | ----- | ---------------- | ---------------------------------------- | ----- | +| mlx-node | i<6 | i ≥ 48−6 = 42 | `i%3==0` → {6, 9, …, 39} | 24 | +| mlx-lm | i<6 | i ≥ 7·48//8 = 42 | `(i−6)%3==2` ⇒ i≡2 (mod 3) → {8,11,…,41} | 24 | Same count, **different layers** — the middle-band phase is offset by 2. `num_layers = 36`: mlx-node's last boundary is 32, mlx-lm's is `7*36//8 = 31`, so layer 31 is high-bit there and low-bit @@ -703,18 +703,18 @@ config-write `?` at `:3215` / `:3178`. Nine types are recognized (`GgufTensorType`, `crates/mlx-core/src/utils/gguf.rs:42`). Anything else is a **hard error at header parse** — the whole file is refused even if one tensor uses an unlisted type. -| ggml type (id) | block | `type_size` | route | mlx-node bytes / 4096-col row | ggml bytes | Δ | -| -------------- | ----- | ----------- | -------------------------------------- | ----------------------------- | ---------- | ---- | -| F32 (0) | 1 | 4 | dense (`:597`) | — | — | — | -| F16 (1) | 1 | 2 | dense (`:604`) | — | — | — | -| BF16 (30) | 1 | 2 | dense (`:611`) | — | — | — | -| Q4_0 (2) | 32 | 18 | affine repack (`:808`) | 512·4 + 128·2 = **2304** | 2304 | **0** | -| Q4_1 (3) | 32 | 20 | affine repack (`:831`) | 2048+256+256 = **2560** | 2560 | **0** | -| Q8_0 (8) | 32 | 34 | affine repack (`:854`) | 1024·4 + 256 = **4352** | 4352 | **0** | -| Q4_K (12) | 256 | 144 | K-quant repack, needs `--gguf-kquant` | 2048+256+64 = **2368** | 2304 | +64 | -| Q5_K (13) | 256 | 176 | K-quant repack, needs `--gguf-kquant` | 2560+256+64 = **2880** | 2816 | +64 | -| Q6_K (14) | 256 | 210 | K-quant repack **or** BF16 dequant | 3072+256+32 = **3360** | 3360 | **0** | -| everything else | — | — | **rejected at `:530`** | — | — | — | +| ggml type (id) | block | `type_size` | route | mlx-node bytes / 4096-col row | ggml bytes | Δ | +| --------------- | ----- | ----------- | ------------------------------------- | ----------------------------- | ---------- | ----- | +| F32 (0) | 1 | 4 | dense (`:597`) | — | — | — | +| F16 (1) | 1 | 2 | dense (`:604`) | — | — | — | +| BF16 (30) | 1 | 2 | dense (`:611`) | — | — | — | +| Q4_0 (2) | 32 | 18 | affine repack (`:808`) | 512·4 + 128·2 = **2304** | 2304 | **0** | +| Q4_1 (3) | 32 | 20 | affine repack (`:831`) | 2048+256+256 = **2560** | 2560 | **0** | +| Q8_0 (8) | 32 | 34 | affine repack (`:854`) | 1024·4 + 256 = **4352** | 4352 | **0** | +| Q4_K (12) | 256 | 144 | K-quant repack, needs `--gguf-kquant` | 2048+256+64 = **2368** | 2304 | +64 | +| Q5_K (13) | 256 | 176 | K-quant repack, needs `--gguf-kquant` | 2560+256+64 = **2880** | 2816 | +64 | +| Q6_K (14) | 256 | 210 | K-quant repack **or** BF16 dequant | 3072+256+32 = **3360** | 3360 | **0** | +| everything else | — | — | **rejected at `:530`** | — | — | — | `block_size()` (`:99`), `k_quant_format()` (`:127`) and `SourceQuantProfile::for_gguf_type` (`:1902`) are all `_`-free exhaustive matches, so adding a type forces every routing site to be updated. @@ -728,21 +728,21 @@ block but allocates the destination in full. Dispatch is by metadata only (`gguf_name_to_hf_for_metadata`, `crates/mlx-core/src/utils/gguf.rs:1252`): -| condition | map | can return `None`? | -| ---------------------------------------------------------------------------------- | ------------------------------- | --------------------------- | -| `general.architecture == "gemma4"` | `gemma4_name_to_hf` (`:1178`) | **yes** — `rope_freqs.weight` only | -| `arch == "clip"` and (`clip.vision.projector_type == "gemma4uv"` or `clip.audio.projector_type == "gemma4ua"`) | `gemma4_mmproj_name_to_hf` (`:1219`) | no | -| otherwise | `gguf_name_to_hf` (`:1266`) | no | +| condition | map | can return `None`? | +| -------------------------------------------------------------------------------------------------------------- | ------------------------------------ | ---------------------------------- | +| `general.architecture == "gemma4"` | `gemma4_name_to_hf` (`:1178`) | **yes** — `rope_freqs.weight` only | +| `arch == "clip"` and (`clip.vision.projector_type == "gemma4uv"` or `clip.audio.projector_type == "gemma4ua"`) | `gemma4_mmproj_name_to_hf` (`:1219`) | no | +| otherwise | `gguf_name_to_hf` (`:1266`) | no | Where the two LLM maps disagree — the whole reason the gemma4 map exists: -| GGUF infix | generic (`:1322`) | gemma4 (`:1199`) | -| ----------------------------- | ------------------------------- | ------------------------------------ | -| `.attn_norm.` | `.input_layernorm.` | `.input_layernorm.` | -| `.ffn_norm.` | `.post_attention_layernorm.` | **`.pre_feedforward_layernorm.`** | -| `.post_attention_norm.` | `.post_attention_layernorm.` | `.post_attention_layernorm.` | -| `.post_ffw_norm.` | *(unmapped, passes through)* | `.post_feedforward_layernorm.` | -| `.layer_output_scale.weight` | *(unmapped)* | `.layer_scalar` | +| GGUF infix | generic (`:1322`) | gemma4 (`:1199`) | +| ---------------------------- | ---------------------------- | --------------------------------- | +| `.attn_norm.` | `.input_layernorm.` | `.input_layernorm.` | +| `.ffn_norm.` | `.post_attention_layernorm.` | **`.pre_feedforward_layernorm.`** | +| `.post_attention_norm.` | `.post_attention_layernorm.` | `.post_attention_layernorm.` | +| `.post_ffw_norm.` | _(unmapped, passes through)_ | `.post_feedforward_layernorm.` | +| `.layer_output_scale.weight` | _(unmapped)_ | `.layer_scalar` | Under the generic map `ffn_norm` and `post_attention_norm` both land on `post_attention_layernorm` — a silent one-of-two loss when collected into a `HashMap`. `remap_keys` (`:1365`) now makes that a @@ -765,13 +765,13 @@ any suffix. The three global tensors are whole-string matches and need `rename_global_quant_group` (`:1167`), which requires the remainder to be exactly one of `QUANT_GROUP_SUFFIXES = [".weight", ".scales", ".biases"]` (`:1154`): -| from | to | -| --------------------------------------- | --------------------------------------------------- | -| `token_embd{.weight,.scales,.biases}` | `model.embed_tokens{…}` | -| `output_norm.weight` | `model.norm.weight` (exact — norms are never quantized) | -| `output{.weight,.scales,.biases}` | `lm_head{…}` | -| `mm.input_projection{…}` | `model.embed_vision.embedding_projection{…}` | -| `mm.a.input_projection{…}` | `model.embed_audio.embedding_projection{…}` | +| from | to | +| ------------------------------------- | ------------------------------------------------------- | +| `token_embd{.weight,.scales,.biases}` | `model.embed_tokens{…}` | +| `output_norm.weight` | `model.norm.weight` (exact — norms are never quantized) | +| `output{.weight,.scales,.biases}` | `lm_head{…}` | +| `mm.input_projection{…}` | `model.embed_vision.embedding_projection{…}` | +| `mm.a.input_projection{…}` | `model.embed_audio.embedding_projection{…}` | Moving only `.weight` was the original bug: loaders probe `embed_tokens.scales` as the "this tensor is quantized" sentinel, so a stranded sidecar silently degrades the group to a bare packed weight @@ -792,10 +792,10 @@ returns the string unchanged (`:1299`), so a non-gemma4 GGUF carries the precomp Is the dropped data recoverable? -| family | RoPE source at load | recoverable from GGUF metadata? | -| ------------------- | --------------------------------------------------------------------------- | ------------------------------- | -| qwen3_5 / lfm2 / … | flat top-level `rope_theta` (serde, `crates/mlx-core/src/models/qwen3_5/config.rs:88`) | **yes** — `extract_config` writes it from `{arch}.rope.freq_base` (`gguf.rs:1783`) | -| gemma4 | **nested** `rope_parameters.full_attention.{rope_theta, partial_rotary_factor}` + `rope_parameters.sliding_attention.rope_theta` (`crates/mlx-core/src/models/gemma4/persistence.rs:192`, `:441`) | **no** — nothing writes `rope_parameters` | +| family | RoPE source at load | recoverable from GGUF metadata? | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| qwen3_5 / lfm2 / … | flat top-level `rope_theta` (serde, `crates/mlx-core/src/models/qwen3_5/config.rs:88`) | **yes** — `extract_config` writes it from `{arch}.rope.freq_base` (`gguf.rs:1783`) | +| gemma4 | **nested** `rope_parameters.full_attention.{rope_theta, partial_rotary_factor}` + `rope_parameters.sliding_attention.rope_theta` (`crates/mlx-core/src/models/gemma4/persistence.rs:192`, `:441`) | **no** — nothing writes `rope_parameters` | So for gemma4 with a synthesized config, `parse_rope_parameters(None)` returns the hard-coded triple `(1_000_000.0, 10_000.0, 0.25)` (`crates/mlx-core/src/models/gemma4/persistence.rs:446`), and the @@ -869,22 +869,22 @@ becomes `log(-x)` because GGUF stores `-exp(A_log)` (`:1626`). ### dtype policy `GgufConversionOptions.dtype` accepts `float32|f32`, `float16|f16`, `bfloat16|bf16` -(`crates/mlx-core/src/utils/gguf.rs:3363`). The napi doc says *"default: keep original"* (`:2904`) — +(`crates/mlx-core/src/utils/gguf.rs:3363`). The napi doc says _"default: keep original"_ (`:2904`) — but the CLI never passes `None`: `const dtype = args.dtype || 'bfloat16'` (`packages/cli/src/commands/convert.ts:625`, the GGUF branch). The cast loop (`:3376`) skips a key if any of four tests hit: -| # | test | line | covers | -| - | ----------------------------------- | ------ | -------------------------------------------------------- | -| 1 | `preserve_dtype_keys.contains(key)` | `3377` | every K-quant-sourced output, named explicitly | -| 2 | `key.ends_with(".scales")` | `3381` | affine f16 scales, K-quant int8/uint8 sub-scales | -| 3 | `key.ends_with(".biases")` | `3381` | Q4_1 f16 minima, K-quant f16 `d`/`dmin` | -| 4 | `arr.dtype() == DType::Uint32` | `3386` | every packed weight plane | +| # | test | line | covers | +| --- | ----------------------------------- | ------ | ------------------------------------------------ | +| 1 | `preserve_dtype_keys.contains(key)` | `3377` | every K-quant-sourced output, named explicitly | +| 2 | `key.ends_with(".scales")` | `3381` | affine f16 scales, K-quant int8/uint8 sub-scales | +| 3 | `key.ends_with(".biases")` | `3381` | Q4_1 f16 minima, K-quant f16 `d`/`dmin` | +| 4 | `arr.dtype() == DType::Uint32` | `3386` | every packed weight plane | `preserve_dtype_keys` (`:3319`) is built from the source tensor list mapped through the **same** `gguf_name_to_hf_for_metadata` that `remap_keys` uses, then expanded by `k_quant_output_keys` -(`:947`). Tests 2-4 only *happen* to cover those keys today, which is why the producer and the +(`:947`). Tests 2-4 only _happen_ to cover those keys today, which is why the producer and the do-not-cast set are named through one helper and cannot drift (`:3307`). ## SafeTensors inputs, families, foreign formats @@ -900,7 +900,7 @@ else → Err "No model weights found" : The index is used **only to derive the set of shard filenames** (`let shard_files: HashSet = index.weight_map.values().cloned().collect();`, `:2403`), and -shards merge with `all_tensors.extend(...)` (`:2427`). Tensor *names* in `weight_map` are never +shards merge with `all_tensors.extend(...)` (`:2427`). Tensor _names_ in `weight_map` are never cross-checked in either direction. Output sharding is fixed and does not depend on the input layout @@ -946,30 +946,30 @@ audio-carrying unified QAT into an importer that drops audio. Native registry: 12 `model_type` strings → 9 recipe impls (`recipe_for`, `crates/mlx-core/src/convert.rs:2783`). -| `model_type` | impl | struct line | -| -------------------------- | ------------------------------- | ----------- | -| `qwen3_asr` | `Qwen3AsrRecipe` | `1727` | -| `qwen3_5`, `qwen3_5_moe` | `Qwen35Recipe { is_moe }` | `226` | -| `lfm2`, `lfm2_moe` | `Lfm2Recipe` | `1340` | -| `paddleocr-vl` | `PaddleOcrVlRecipe` | `1671` | -| `qianfan-ocr` | `QianfanOcrRecipe` | `1695` | -| `privacy-filter` | `PrivacyFilterRecipe` | `1800` | -| `muse_glimmer` | `MuseGlimmerRecipe` | `1832` | -| `gemma4`, `gemma4_unified` | `Gemma4Recipe` | `1912` | -| `nemotron_h` | `NemotronHRecipe` | `2720` | +| `model_type` | impl | struct line | +| -------------------------- | ------------------------- | ----------- | +| `qwen3_asr` | `Qwen3AsrRecipe` | `1727` | +| `qwen3_5`, `qwen3_5_moe` | `Qwen35Recipe { is_moe }` | `226` | +| `lfm2`, `lfm2_moe` | `Lfm2Recipe` | `1340` | +| `paddleocr-vl` | `PaddleOcrVlRecipe` | `1671` | +| `qianfan-ocr` | `QianfanOcrRecipe` | `1695` | +| `privacy-filter` | `PrivacyFilterRecipe` | `1800` | +| `muse_glimmer` | `MuseGlimmerRecipe` | `1832` | +| `gemma4`, `gemma4_unified` | `Gemma4Recipe` | `1912` | +| `nemotron_h` | `NemotronHRecipe` | `2720` | ### Recipe asymmetry flags Five behaviour flags on the trait (`crates/mlx-core/src/convert.rs:153`), all defaulting to the conservative value: -| flag (default) | qwen3_asr | qwen3_5 | qwen3_5_moe | lfm2 / lfm2_moe | paddleocr-vl | qianfan-ocr | privacy-filter | muse_glimmer | gemma4\* | nemotron_h | -| ------------------------------------ | --------- | ------- | ----------- | --------------- | ------------ | ----------- | -------------- | ------------ | -------- | ---------- | -| `owns_dtype_cast` (false) | false | **true** (`1317`) | **true** | **true** (`1656`) | false | false | false | false | false | **true** (`2727`) | -| `embed_quantizable` (false) | **true** (`1792`) | false | false | **true** (`1660`) | false | false | false | false | false | false | -| `sym8_supported` (false) | false | **true** (`1321`) | **true** | **true** (`1664`) | false | false | false | false | **true** (`2055`) | false | -| `quant_managed_by_sanitizer` (false) | false | false | false | false | false | false | **true** (`1822`) | false | false | false | -| `has_mtp` (None) | None | **Sidecar** (`1330`) | **Inline** | None | None | None | None | None | None | None | +| flag (default) | qwen3_asr | qwen3_5 | qwen3_5_moe | lfm2 / lfm2_moe | paddleocr-vl | qianfan-ocr | privacy-filter | muse_glimmer | gemma4\* | nemotron_h | +| ------------------------------------ | ----------------- | -------------------- | ----------- | ----------------- | ------------ | ----------- | ----------------- | ------------ | ----------------- | ----------------- | +| `owns_dtype_cast` (false) | false | **true** (`1317`) | **true** | **true** (`1656`) | false | false | false | false | false | **true** (`2727`) | +| `embed_quantizable` (false) | **true** (`1792`) | false | false | **true** (`1660`) | false | false | false | false | false | false | +| `sym8_supported` (false) | false | **true** (`1321`) | **true** | **true** (`1664`) | false | false | false | false | **true** (`2055`) | false | +| `quant_managed_by_sanitizer` (false) | false | false | false | false | false | false | **true** (`1822`) | false | false | false | +| `has_mtp` (None) | None | **Sidecar** (`1330`) | **Inline** | None | None | None | None | None | None | None | `owns_dtype_cast = true` bypasses the generic dtype loop entirely (`:3878`), so the hard `Err("Unsupported target dtype")` at `:3966` is **unreachable** for qwen3_5 / qwen3_5_moe / lfm2 / @@ -1014,7 +1014,7 @@ one Conv2d transpose gated on the heuristic `shape[1] < shape[2]` (`:85`). Unmat through unchanged. **privacy-filter** (`crates/mlx-core/src/convert.rs:1567`). **Identity pass** — `Ok(weights)`. -Quantization is owned by a dedicated predicate block (`:2813`) that re-derives a *complete* per-layer +Quantization is owned by a dedicated predicate block (`:2813`) that re-derives a _complete_ per-layer override map from the resulting `.scales` keys. ### Foreign weight formats @@ -1091,14 +1091,14 @@ under `--q-mode sym8`**, via `sym8_eligible`'s `ndim != 2 → false`. `convert_model` never writes `vision.safetensors`. Vision/audio tensors stay in the main sharded output under their family prefix. -| family | prefix kept | quantized by the generic pass? | -| -------------------------- | ----------------------------------------------- | ------------------------------------------------------------------- | -| qwen3_5 / qwen3_5_moe | `vision_tower.` | no — `should_quantize` skips it (`:3323`) | -| paddleocr-vl | `visual.` | no (`:3324`) | -| gemma4 (SigLIP) | `vision_tower.`, `multi_modal_projector.` | no for `vision_tower`; `multi_modal_projector` is **not** excluded | -| gemma4_unified | `vision_embedder.`, `embed_vision.` | `vision_embedder` skipped (`:3328`); `embed_vision.embedding_projection` forced to 8-bit affine by `is_affine_only_key` (`:3865`) | -| gemma4 audio | `audio_tower.`, `audio_encoder.`, `embed_audio.` | **yes** for `audio_tower`/`audio_encoder` outside sym8 — see gotchas | -| qianfan-ocr | `vision.` | **yes** — no `vision.` arm in `should_quantize` | +| family | prefix kept | quantized by the generic pass? | +| --------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | +| qwen3_5 / qwen3_5_moe | `vision_tower.` | no — `should_quantize` skips it (`:3323`) | +| paddleocr-vl | `visual.` | no (`:3324`) | +| gemma4 (SigLIP) | `vision_tower.`, `multi_modal_projector.` | no for `vision_tower`; `multi_modal_projector` is **not** excluded | +| gemma4_unified | `vision_embedder.`, `embed_vision.` | `vision_embedder` skipped (`:3328`); `embed_vision.embedding_projection` forced to 8-bit affine by `is_affine_only_key` (`:3865`) | +| gemma4 audio | `audio_tower.`, `audio_encoder.`, `embed_audio.` | **yes** for `audio_tower`/`audio_encoder` outside sym8 — see gotchas | +| qianfan-ocr | `vision.` | **yes** — no `vision.` arm in `should_quantize` | The `vision.safetensors` sidecar is a **GGUF-path artifact**, written only by the `--mmproj` second pass (`packages/cli/src/commands/convert.ts:567`). At load it is **never required**: @@ -1138,10 +1138,10 @@ quant_mtp == "split" → mtp-drafter/ dir, EXCLUDED from Sidecar `model_type: "qwen3_5_mtp"`, `block_size = mtp_num_hidden_layers + 2` (`:3624`), `tie_word_embeddings` defaulting to **true** when absent (`:3645`), and copies tokenizer assets from the **source** dir. It first removes stale legacy sidecars (`:3050`), because the dense loader probes -`mtp.safetensors` *before* `mtp-drafter/`. +`mtp.safetensors` _before_ `mtp-drafter/`. MTP sanitize is deliberately split from the body: the main `+1.0` norm loop skips `mtp.*`, but an -**independent** probe (`:1069-1083`) samples the *mean* of `mtp.layers.0.input_layernorm.weight` and +**independent** probe (`:1069-1083`) samples the _mean_ of `mtp.layers.0.input_layernorm.weight` and shifts the seven MTP norms when `mean < 0.5` (`:1146`). The comment at `:1141` records that a previous revision skipped `mtp.*` entirely and produced zero MTP acceptance. @@ -1172,12 +1172,12 @@ the checkpoint, so any other gemma4 QAT variant is rejected rather than mis-repa Routing (`crates/mlx-core/src/convert_gemma_import.rs:483`): -| source class | detector | output | override | -| ----------------------------------------------- | ----------------- | ----------------------------------- | -------------------------- | -| `embed_tokens_per_layer` (PLE) | name (`:585`) | affine triplet, group 128 | `{4, 128, affine}` | -| I8 modules (per-layer gates, vision tower) | `dtype == Int8` (`:633`) | **dequant to target dtype** | none (dense at runtime) | -| 2/4-bit U8 linears / `lm_head` / `embed_tokens` | `dtype == Uint8` (`:554`) | affine triplet, group 128 | `{bits, 128, affine}` | -| floats (norms, projections, conv) | fallthrough (`:567`) | cast + gemma4 conv transposes | none | +| source class | detector | output | override | +| ----------------------------------------------- | ------------------------- | ----------------------------- | ----------------------- | +| `embed_tokens_per_layer` (PLE) | name (`:585`) | affine triplet, group 128 | `{4, 128, affine}` | +| I8 modules (per-layer gates, vision tower) | `dtype == Int8` (`:633`) | **dequant to target dtype** | none (dense at runtime) | +| 2/4-bit U8 linears / `lm_head` / `embed_tokens` | `dtype == Uint8` (`:554`) | affine triplet, group 128 | `{bits, 128, affine}` | +| floats (norms, projections, conv) | fallthrough (`:567`) | cast + gemma4 conv transposes | none | Bit routing: `lm_head`/`embed_tokens` → 2; attention q/k/v/o → 4; MLP → 4 for `layer <= 14`, else 2 (`:292`). Dropped (`:235`): `.input_activation_scale`, `.output_activation_scale`, `.k_cache_scale`, @@ -1196,11 +1196,11 @@ MLX: w[o,c] = q_unsigned[o,c] * scales[o,g] + biases[o,g], g = c/group_siz Size cost at `LINEAR_GROUP_SIZE = 128`, `in = 2048`: -| | 4-bit | 2-bit | -| ---------------------------- | ------------------------- | ------------------------- | -| Google source (per-row f32) | 4 + 32/2048 = **4.0156** | 2 + 32/2048 = **2.0156** | -| MLX affine @ gs 128 | 4 + 64/128 = **4.5** | 2 + 64/128 = **2.5** | -| MLX affine @ gs 64 (rejected) | 4 + 64/64 = **5.0** | 2 + 1.0 = **3.0** | +| | 4-bit | 2-bit | +| ----------------------------- | ------------------------ | ------------------------ | +| Google source (per-row f32) | 4 + 32/2048 = **4.0156** | 2 + 32/2048 = **2.0156** | +| MLX affine @ gs 128 | 4 + 64/128 = **4.5** | 2 + 64/128 = **2.5** | +| MLX affine @ gs 64 (rejected) | 4 + 64/64 = **5.0** | 2 + 1.0 = **3.0** | Lossless but ~+0.48 bpw of pure redundancy, since every group in a row carries the identical source scale. 128 is MLX affine's largest legal group, which halves that overhead versus 64. @@ -1261,6 +1261,7 @@ what v1 cannot use: - **Existing converted checkpoints must be regenerated.** Anything produced by the folding ingest carries pre-multiplied `.scales` and no `.global_scale`; the fail-closed check turns such a checkpoint into a loud load error rather than a silently-8%-wrong model. + - **`lm_head`** — NVFP4 in the source, **dequantized to bf16** at ingest (a single dense matmul; keeping the widest matrix in bf16 avoids a 4-bit gather on it). The dequant uses the **exact** scale: dequantize with the unmodified `weight_scale` into F32, multiply by `weight_scale_2`, @@ -1315,17 +1316,17 @@ output_config["quantization"] = quant_obj; ``` That delete is load-bearing. `quantization_config` is NVIDIA modelopt's and gemma-QAT's name for -the *input* encoding, so leaving it beside the block just written gives the loader two aliases that +the _input_ encoding, so leaving it beside the block just written gives the loader two aliases that disagree — which `select_quantization_block` rejects outright. The **reader** still accepts `quantization_config`; dropping it would make an NVIDIA source un-ingestable. The GGUF converter still writes both keys. -| writer | site | `skip_mtp` | -| -------------------------------------------- | ------------------------------------------- | ---------- | -| SafeTensors convert | `crates/mlx-core/src/convert.rs` | `true` | -| GGUF → SafeTensors with `--quantize` | `crates/mlx-core/src/utils/gguf.rs` | `false` | -| GGUF → SafeTensors, source-preserved | `crates/mlx-core/src/utils/gguf.rs` | n/a | +| writer | site | `skip_mtp` | +| ------------------------------------ | ----------------------------------- | ---------- | +| SafeTensors convert | `crates/mlx-core/src/convert.rs` | `true` | +| GGUF → SafeTensors with `--quantize` | `crates/mlx-core/src/utils/gguf.rs` | `false` | +| GGUF → SafeTensors, source-preserved | `crates/mlx-core/src/utils/gguf.rs` | n/a | Built by one function so the frontends cannot diverge (`build_quantization_object`, `crates/mlx-core/src/convert.rs`): @@ -1352,12 +1353,12 @@ separate `mtplx_mtp_quantization` object pinned to `{4, 32, affine}` (`:3155`). There is **no `mixed` field in any emitted config** — `grep -rn '"mixed"' --include=*.rs crates/` returns nothing. `mixed` is a local `bool` in the GGUF importer. -| emitter | rule | coverage | -| ------------------------ | -------------------------------------------------------------------------------- | ------------------ | -| generic quantize | entry only if `bits != default \|\| group_size != default \|\| mode != default` (`convert.rs:5601`) | **SPARSE by design** | -| privacy-filter | iterate every `*.scales` key, insert unconditionally (`convert.rs:2843`) | 100 % | -| GGUF source-preserved | modal profile wins top level; entry if `mixed \|\| profile.requires_explicit_entry()` (`gguf.rs:2344`) | see below | -| gemma-QAT import | `verify_override_coverage` **fails the conversion** on a gap (`convert_gemma_import.rs:268`) | 100 %, enforced | +| emitter | rule | coverage | +| --------------------- | ------------------------------------------------------------------------------------------------------ | -------------------- | +| generic quantize | entry only if `bits != default \|\| group_size != default \|\| mode != default` (`convert.rs:5601`) | **SPARSE by design** | +| privacy-filter | iterate every `*.scales` key, insert unconditionally (`convert.rs:2843`) | 100 % | +| GGUF source-preserved | modal profile wins top level; entry if `mixed \|\| profile.requires_explicit_entry()` (`gguf.rs:2344`) | see below | +| gemma-QAT import | `verify_override_coverage` **fails the conversion** on a gap (`convert_gemma_import.rs:268`) | 100 %, enforced | GGUF modal derivation (`crates/mlx-core/src/utils/gguf.rs:2344`): @@ -1404,24 +1405,24 @@ turns a loud missing-`.biases` failure into silent corruption." ### Loud vs silent -| condition | behaviour | site | -| ------------------------------------------------------------------ | -------------------------------------------------------------- | --------------------------------- | -| `.biases` present AND config declares `symmetric_zero_point` for it | **Err** "derived or stored, never both" | `crates/mlx-core/src/engine/persistence.rs:257` | -| `.scales` non-floating under a symmetric declaration | **Err** "an affine scale must be floating" | `crates/mlx-core/src/engine/persistence.rs:272` | -| both aliases present but **different** | **Err** "Conflicting quantization aliases … must be identical" | `quant_dispatch.rs:576` | -| either alias present but not an object | **Err** "Invalid {name} alias: expected an object" | `quant_dispatch.rs:567` | -| `symmetric_zero_point != 1 << (bits-1)` | **Err** "a symmetric group subtracts {expected}" | `quant_dispatch.rs:701` | -| `symmetric_zero_point` on a non-affine mode | **Err** | `quant_dispatch.rs:693` | -| K-quant group missing `.weight` / `.biases` / wrong dtype | **Err**, names the layer | `quant_dispatch.rs:432` | -| int8 weight resolving to non-sym8 | **Err** "config drift / stale quantization metadata" | `quant_dispatch.rs:255` | -| u8 weight + float scales resolving to non-fp8_e4m3 | **Err** "config drift / missing fp8_e4m3 override" | `quant_dispatch.rs:288` | -| per-layer object naming mode/group_size but **no** `bits` | **Err** — fails the whole load | `quant_dispatch.rs:833` | -| per-layer object with **none** of bits/group_size/mode/input_amax/symmetric_zero_point | silent `continue` | `quant_dispatch.rs:824` | -| plain-Qwen3 loader given ANY non-empty quantization block | **Err**, names the mode | `crates/mlx-core/src/models/qwen3/persistence.rs:342` | -| **`config.json` unreadable / unparseable** | **silent** `Ok(0)` — no bias rebuild | `crates/mlx-core/src/engine/persistence.rs:227`, `:237` | -| **config text lacks the literal `symmetric_zero_point`** | **silent** `Ok(0)`, block never strictly parsed | `crates/mlx-core/src/engine/persistence.rs:234` | -| **`config.json` missing/unparseable at quant-settings load** | **silent** caller defaults + EMPTY override map | `quant_dispatch.rs:977` | -| `vision.safetensors` absent when `load_vision = true` | silent `Ok(())` | `crates/mlx-core/src/engine/persistence.rs:186` | +| condition | behaviour | site | +| -------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------- | +| `.biases` present AND config declares `symmetric_zero_point` for it | **Err** "derived or stored, never both" | `crates/mlx-core/src/engine/persistence.rs:257` | +| `.scales` non-floating under a symmetric declaration | **Err** "an affine scale must be floating" | `crates/mlx-core/src/engine/persistence.rs:272` | +| both aliases present but **different** | **Err** "Conflicting quantization aliases … must be identical" | `quant_dispatch.rs:576` | +| either alias present but not an object | **Err** "Invalid {name} alias: expected an object" | `quant_dispatch.rs:567` | +| `symmetric_zero_point != 1 << (bits-1)` | **Err** "a symmetric group subtracts {expected}" | `quant_dispatch.rs:701` | +| `symmetric_zero_point` on a non-affine mode | **Err** | `quant_dispatch.rs:693` | +| K-quant group missing `.weight` / `.biases` / wrong dtype | **Err**, names the layer | `quant_dispatch.rs:432` | +| int8 weight resolving to non-sym8 | **Err** "config drift / stale quantization metadata" | `quant_dispatch.rs:255` | +| u8 weight + float scales resolving to non-fp8_e4m3 | **Err** "config drift / missing fp8_e4m3 override" | `quant_dispatch.rs:288` | +| per-layer object naming mode/group_size but **no** `bits` | **Err** — fails the whole load | `quant_dispatch.rs:833` | +| per-layer object with **none** of bits/group_size/mode/input_amax/symmetric_zero_point | silent `continue` | `quant_dispatch.rs:824` | +| plain-Qwen3 loader given ANY non-empty quantization block | **Err**, names the mode | `crates/mlx-core/src/models/qwen3/persistence.rs:342` | +| **`config.json` unreadable / unparseable** | **silent** `Ok(0)` — no bias rebuild | `crates/mlx-core/src/engine/persistence.rs:227`, `:237` | +| **config text lacks the literal `symmetric_zero_point`** | **silent** `Ok(0)`, block never strictly parsed | `crates/mlx-core/src/engine/persistence.rs:234` | +| **`config.json` missing/unparseable at quant-settings load** | **silent** caller defaults + EMPTY override map | `quant_dispatch.rs:977` | +| `vision.safetensors` absent when `load_vision = true` | silent `Ok(())` | `crates/mlx-core/src/engine/persistence.rs:186` | The missing-weight message is deliberately bare and uninterpolated (`crates/mlx-core/src/models/gemma4/persistence.rs:502-807`): `Missing required weight: layers.7.self_attn.q_proj.weight`. Rationale at `:500`: without it "the @@ -1455,7 +1456,7 @@ float scales + **mandatory** float `.biases`; mxfp/nvfp needs u32 + u8 scales an sym8 needs int8 + float scales; fp8_e4m3 needs rank 2/3 and no `.biases`; K-quants get a full ggml geometry check. -The *recording* half is the regression, spelled out at `:6493`: +The _recording_ half is the regression, spelled out at `:6493`: > "Record: a key the ladder moves off the top-level triple (every router gate, pinned to 8-bit > affine) needs its per-layer override re-emitted. Without it the config writer stamps the top-level @@ -1467,7 +1468,7 @@ affine, survive the write and surface at first decode as `null handle returned: naming neither the layer nor the shape. **Second round-trip hazard: the derived-bias claim.** `expand_symmetric_affine_biases` runs at the -converter's *input* boundary (`:2447`), so the output is a *stored*-bias checkpoint. +converter's _input_ boundary (`:2447`), so the output is a _stored_-bias checkpoint. `strip_symmetric_zero_point` (`:1930`) therefore removes `symmetric_zero_point` from both aliases and from every child object before writing. Without it, a plain `mlx convert` of an imported model produces an unloadable model — the loader hits the "derived or stored, never both" contradiction. @@ -1511,12 +1512,12 @@ ratio = 0.5, hardcoded at BOTH entry points: Fold groups (`crates/mlx-core/src/convert.rs:6885`, impl `:6920`): -| group | multiply by `s` (input cols) | divide by `s` | missing target | -| ----- | -------------------------------------------------------------------- | -------------------------------------------------------------------- | -------------- | -| A | `mlp.gate_proj`, `mlp.up_proj` | **`pre_feedforward_layernorm` if present, else `post_attention_layernorm`** | **silent** | -| B | `mlp.down_proj` (cols) | `mlp.up_proj` (rows) | n/a | -| C | `self_attn.{q,k,v}_proj` | `input_layernorm` | warns | -| D | `linear_attn.{in_proj_qkv, in_proj_z, in_proj_a, in_proj_b}` | `input_layernorm` | warns | +| group | multiply by `s` (input cols) | divide by `s` | missing target | +| ----- | ------------------------------------------------------------ | --------------------------------------------------------------------------- | -------------- | +| A | `mlp.gate_proj`, `mlp.up_proj` | **`pre_feedforward_layernorm` if present, else `post_attention_layernorm`** | **silent** | +| B | `mlp.down_proj` (cols) | `mlp.up_proj` (rows) | n/a | +| C | `self_attn.{q,k,v}_proj` | `input_layernorm` | warns | +| D | `linear_attn.{in_proj_qkv, in_proj_z, in_proj_a, in_proj_b}` | `input_layernorm` | warns | `self_attn.o_proj` and `linear_attn.out_proj` are **not covered by design** — their inputs come from attention/GDN compute, not a norm (`:6891`). @@ -1536,14 +1537,14 @@ worse than none" (`:7126`). Recipe acceptance: -| recipe | imatrix | gate | -| --------------------------------------------------------------- | ---------------------- | --------------------------------------------- | -| `unsloth` legacy affine (no `--q-mxfp`, mode ≠ nvfp4) | **required** | `validate_unsloth_imatrix_selector`, `:4374` | -| `unsloth` + `--q-mxfp` or `--q-mode nvfp4` | optional (warns) | `:4365`, `:2803` | -| `unsloth` no-imatrix that failed family/shape validation | **hard error** | `validate_unsloth_imatrix_after_selection`, `:4396` | -| `nvidia` | **rejected outright** | `:5069` — "a data-free port; an imatrix would trigger AWQ pre-scaling that silently alters weights" | -| `mixed_*`, `qwen3_5`, no recipe | accepted, no gate | `:2719` | -| gemma-QAT source / `--gguf-kquant` | **rejected** | `:2267` / `packages/cli/src/commands/convert.ts:503` | +| recipe | imatrix | gate | +| -------------------------------------------------------- | --------------------- | --------------------------------------------------------------------------------------------------- | +| `unsloth` legacy affine (no `--q-mxfp`, mode ≠ nvfp4) | **required** | `validate_unsloth_imatrix_selector`, `:4374` | +| `unsloth` + `--q-mxfp` or `--q-mode nvfp4` | optional (warns) | `:4365`, `:2803` | +| `unsloth` no-imatrix that failed family/shape validation | **hard error** | `validate_unsloth_imatrix_after_selection`, `:4396` | +| `nvidia` | **rejected outright** | `:5069` — "a data-free port; an imatrix would trigger AWQ pre-scaling that silently alters weights" | +| `mixed_*`, `qwen3_5`, no recipe | accepted, no gate | `:2719` | +| gemma-QAT source / `--gguf-kquant` | **rejected** | `:2267` / `packages/cli/src/commands/convert.ts:503` | Pre-quantized bodies are rejected before any mutation (`reject_awq_for_prequantized_body`, `:6861`): "Packed weights cannot be safely AWQ-scaled and this converter has no dequantize/requantize path; @@ -1617,63 +1618,63 @@ The highest-value section. Everything here is confirmed in code. ### Silent defaults that substitute for missing data -| # | Trap | Where | -| - | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| 1 | **An undetectable or unreadable `config.json` silently disables ALL sanitization.** The TS auto-detect wraps the config read in `try { … } catch {}`, leaving `modelType` undefined; the native driver then takes `None => converted_tensors` — pure dtype conversion, no key remap, no norm shift, no expert stacking, no error, no warning. Only an *explicit* unknown `-m` errors. A `model_type: "qwen3_5_text"` root config (accepted by `qwen35_recipe_family` but absent from the CLI's exact-match list) converts to a checkpoint that loads with raw ~0.0 RMSNorm weights. | `packages/cli/src/commands/convert.ts:648`, `crates/mlx-core/src/convert.rs:2699`, `:2688` | -| 2 | **`load_quant_settings_from_disk` returns the caller's defaults + an EMPTY override map** when `config.json` is missing or is not valid JSON. No warning. An affine-4/64 fallback over bytes that were really affine-4/32 passes every storage guard and decodes garbage. | `crates/mlx-core/src/models/quant_dispatch.rs:977` | -| 3 | **`expand_symmetric_affine_biases` returns `Ok(0)` silently** on an unreadable config, an unparseable config, *and* when the raw config TEXT does not literally contain `symmetric_zero_point`. The last is a deliberate compatibility shim, but it means a corrupt config skips the bias rebuild. Caught loudly only in gemma4 / qwen3_5 / qwen3_5_moe / lfm2. | `crates/mlx-core/src/engine/persistence.rs:227`, `:234`, `:237` | -| 4 | **`read_meta_array`'s unsupported-element arm yields an EMPTY `ArrayU32`**, so "unreadable" is indistinguishable from "empty". Only Uint32/Int32/Float32/String arrays decode; Bool, U8, I8, U16, I16, U64, I64, F64 all hit `_ => { … Ok(ArrayU32(Vec::new())) }`. Live victim: `gemma4.attention.sliding_window_pattern` is a bool array, which is why the K=V inference falls back to `head_count_kv`. | `crates/mlx-core/src/utils/gguf.rs:447`, `:2023` | -| 5 | **`extract_config`'s value match has `_ => {}`, dropping every array-valued metadata field.** Gemma4 spells `attention.head_count_kv` as an array, so `num_key_value_heads` is silently dropped — the exact reason `apply_gemma4_attention_geometry` exists. Any other architecture spelling a mapped field as an array gets the same drop, with no rescue helper. | `crates/mlx-core/src/utils/gguf.rs:1790`, `:2242` | -| 6 | **`fixup_qwen35_linear_attn` uses hard-coded geometry fallbacks**: `ssm.state_size` → 128, `ssm.inner_size` → 4096, `qk_dim` → 4096, and silently `Ok(())`s when `n_value_heads < 2`. | `crates/mlx-core/src/utils/gguf.rs:1584`, `:1598`, `:1613` | -| 7 | **Unknown Paddle dtypes silently become f32 and desync the stream parse.** `paddle_dtype_to_str` maps only 2/3/4/5/6; `numpy_dtype_size` also defaults to 4; `parse_tensor_desc` defaults `dtype = 5` when absent. The `.pdiparams` reader is a sequential byte walk, so a wrong `elem_size` lands the offset mid-tensor and every later tensor is garbage. | `crates/mlx-core/src/utils/foreign_weights.rs:1199`, `:776`, `:1095` | -| 8 | **Missing MoE config fields fall back to magic numbers with only a `warn!`** — `num_experts` → 256, `num_hidden_layers` → 40. The failure then surfaces as "Missing expert weight: …" naming an expert index, not "num_experts not found". | `crates/mlx-core/src/convert.rs:518`, `:872` | -| 9 | **The `mlx_dequantize` FFI shim substitutes `"affine"` for a null/empty mode string** (`mlx_advanced_ops.cpp:872`) and treats `group_size <= 0` / `bits <= 0` as "use the mode default" (`:869-870`). The `-1` sentinel convention collides with sym8/fp8_e4m3's own `-1` group-size sentinel, so a `-1` arriving here is silently reinterpreted. | `crates/mlx-sys/src/mlx_advanced_ops.cpp:869`, `:872` | +| # | Trap | Where | +| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| 1 | **An undetectable or unreadable `config.json` silently disables ALL sanitization.** The TS auto-detect wraps the config read in `try { … } catch {}`, leaving `modelType` undefined; the native driver then takes `None => converted_tensors` — pure dtype conversion, no key remap, no norm shift, no expert stacking, no error, no warning. Only an _explicit_ unknown `-m` errors. A `model_type: "qwen3_5_text"` root config (accepted by `qwen35_recipe_family` but absent from the CLI's exact-match list) converts to a checkpoint that loads with raw ~0.0 RMSNorm weights. | `packages/cli/src/commands/convert.ts:648`, `crates/mlx-core/src/convert.rs:2699`, `:2688` | +| 2 | **`load_quant_settings_from_disk` returns the caller's defaults + an EMPTY override map** when `config.json` is missing or is not valid JSON. No warning. An affine-4/64 fallback over bytes that were really affine-4/32 passes every storage guard and decodes garbage. | `crates/mlx-core/src/models/quant_dispatch.rs:977` | +| 3 | **`expand_symmetric_affine_biases` returns `Ok(0)` silently** on an unreadable config, an unparseable config, _and_ when the raw config TEXT does not literally contain `symmetric_zero_point`. The last is a deliberate compatibility shim, but it means a corrupt config skips the bias rebuild. Caught loudly only in gemma4 / qwen3_5 / qwen3_5_moe / lfm2. | `crates/mlx-core/src/engine/persistence.rs:227`, `:234`, `:237` | +| 4 | **`read_meta_array`'s unsupported-element arm yields an EMPTY `ArrayU32`**, so "unreadable" is indistinguishable from "empty". Only Uint32/Int32/Float32/String arrays decode; Bool, U8, I8, U16, I16, U64, I64, F64 all hit `_ => { … Ok(ArrayU32(Vec::new())) }`. Live victim: `gemma4.attention.sliding_window_pattern` is a bool array, which is why the K=V inference falls back to `head_count_kv`. | `crates/mlx-core/src/utils/gguf.rs:447`, `:2023` | +| 5 | **`extract_config`'s value match has `_ => {}`, dropping every array-valued metadata field.** Gemma4 spells `attention.head_count_kv` as an array, so `num_key_value_heads` is silently dropped — the exact reason `apply_gemma4_attention_geometry` exists. Any other architecture spelling a mapped field as an array gets the same drop, with no rescue helper. | `crates/mlx-core/src/utils/gguf.rs:1790`, `:2242` | +| 6 | **`fixup_qwen35_linear_attn` uses hard-coded geometry fallbacks**: `ssm.state_size` → 128, `ssm.inner_size` → 4096, `qk_dim` → 4096, and silently `Ok(())`s when `n_value_heads < 2`. | `crates/mlx-core/src/utils/gguf.rs:1584`, `:1598`, `:1613` | +| 7 | **Unknown Paddle dtypes silently become f32 and desync the stream parse.** `paddle_dtype_to_str` maps only 2/3/4/5/6; `numpy_dtype_size` also defaults to 4; `parse_tensor_desc` defaults `dtype = 5` when absent. The `.pdiparams` reader is a sequential byte walk, so a wrong `elem_size` lands the offset mid-tensor and every later tensor is garbage. | `crates/mlx-core/src/utils/foreign_weights.rs:1199`, `:776`, `:1095` | +| 8 | **Missing MoE config fields fall back to magic numbers with only a `warn!`** — `num_experts` → 256, `num_hidden_layers` → 40. The failure then surfaces as "Missing expert weight: …" naming an expert index, not "num_experts not found". | `crates/mlx-core/src/convert.rs:518`, `:872` | +| 9 | **The `mlx_dequantize` FFI shim substitutes `"affine"` for a null/empty mode string** (`mlx_advanced_ops.cpp:872`) and treats `group_size <= 0` / `bits <= 0` as "use the mode default" (`:869-870`). The `-1` sentinel convention collides with sym8/fp8_e4m3's own `-1` group-size sentinel, so a `-1` arriving here is silently reinterpreted. | `crates/mlx-sys/src/mlx_advanced_ops.cpp:869`, `:872` | ### Silent losses -| # | Trap | Where | -| - | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| 10 | **SafeTensors weight-file discovery is first-match-wins**, so a stray single-file `model.safetensors` beside a shard index silently masks every shard. There is no cross-check that the index's `weight_map` is covered — the map is used only to derive the shard *filename* set — and shards merge with `HashMap::extend` over an unordered `HashSet`, so a name in two shards resolves nondeterministically. | `crates/mlx-core/src/convert.rs:2355`, `:2403`, `:2427` | -| 12 | **`.pdiparams` name↔tensor pairing is positional with only a count check.** Names are sorted by de-`_deepcopy_N`'d name and zipped against binary order; the decoded `dims` are explicitly discarded (`for (name, (_dims, array)) in …`). If Paddle's on-disk order ever diverges from alphabetical, every tensor gets the wrong name and the conversion reports success. | `crates/mlx-core/src/utils/foreign_weights.rs:992`, `:1002`, `:1005` | -| 13 | **No multi-part / split GGUF support, and no error when one is passed.** The parser reads no `split.no` / `split.count` / `split.tensors.count` keys. `model-00001-of-00003.gguf` imports only that shard's tensors and exits cleanly with a plausible `numTensors`. | `crates/mlx-core/src/utils/gguf.rs:459` | -| 14 | **`rope_freqs.weight` is dropped only by the gemma4 map**; the generic map writes it into the output under its raw GGUF name, as dead weight the loader does not expect. It is the only tensor any map ever drops. | `crates/mlx-core/src/utils/gguf.rs:1179`, `:1299`, `:1372` | -| 15 | **For gemma4, the `rope_theta` extracted from GGUF metadata is DEAD**, and `partial_rotary_factor` has no GGUF source at all. The gemma4 loader reads only a nested `rope_parameters` object that nothing in the converter writes, so a gemma4 GGUF converted without `--config-dir` silently gets `(1e6, 1e4, 0.25)` while the extracted flat field sits in config.json looking authoritative. Other families read it flat and are unaffected — which is what makes this easy to miss. | `crates/mlx-core/src/models/gemma4/persistence.rs:441`, `crates/mlx-core/src/utils/gguf.rs:1783` | -| 16 | **The gemma-QAT importer drops the entire activation half of "wNa8o8"** — `.input_activation_scale`, `.output_activation_scale`, `.k_cache_scale`, `.v_cache_scale`. The imported model is weight-only. | `crates/mlx-core/src/convert_gemma_import.rs:235` | -| 18 | **`--config-dir`'s asset copy fails hard, but the implicit alongside-GGUF copy only warns.** Same loop, two branches — without `--config-dir`, a permission error or full disk while copying `tokenizer.json` gives a warning line and a `✓ Converted` banner, leaving an unusable directory. | `crates/mlx-core/src/utils/gguf.rs:3214` | -| 19 | **The `vision.safetensors` media sidecar is silently optional at load.** `append_vision_safetensors` returns `Ok(())` when the file is absent even though `should_load_media_sidecar` returned true. Only a few individual weights have explicit presence checks. | `crates/mlx-core/src/engine/persistence.rs:185`, `crates/mlx-core/src/models/gemma4/persistence.rs:392` | -| 20 | **On the GGUF path an AWQ whole-model no-match is completely silent.** `apply_gguf_awq_prescaling` discards the returned `modified` count, so the SafeTensors path's `warn!("modified == 0")` has no GGUF counterpart. Layer-prefix detection recognizes only `language_model.model.layers.` and `model.layers.`, so a third wrapper spelling gets zero AWQ. | `crates/mlx-core/src/utils/gguf.rs:2466`, `crates/mlx-core/src/convert.rs:2725`, `:6911` | -| 21 | **An imatrix tensor whose `.counts` is ≤ 0 or absent is silently `continue`d** and never enters the importance map. Group A may still fire on the surviving partner, producing a scale derived from half the intended evidence. | `crates/mlx-core/src/utils/imatrix.rs:294`, `crates/mlx-core/src/convert.rs:7103` | +| # | Trap | Where | +| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| 10 | **SafeTensors weight-file discovery is first-match-wins**, so a stray single-file `model.safetensors` beside a shard index silently masks every shard. There is no cross-check that the index's `weight_map` is covered — the map is used only to derive the shard _filename_ set — and shards merge with `HashMap::extend` over an unordered `HashSet`, so a name in two shards resolves nondeterministically. | `crates/mlx-core/src/convert.rs:2355`, `:2403`, `:2427` | +| 12 | **`.pdiparams` name↔tensor pairing is positional with only a count check.** Names are sorted by de-`_deepcopy_N`'d name and zipped against binary order; the decoded `dims` are explicitly discarded (`for (name, (_dims, array)) in …`). If Paddle's on-disk order ever diverges from alphabetical, every tensor gets the wrong name and the conversion reports success. | `crates/mlx-core/src/utils/foreign_weights.rs:992`, `:1002`, `:1005` | +| 13 | **No multi-part / split GGUF support, and no error when one is passed.** The parser reads no `split.no` / `split.count` / `split.tensors.count` keys. `model-00001-of-00003.gguf` imports only that shard's tensors and exits cleanly with a plausible `numTensors`. | `crates/mlx-core/src/utils/gguf.rs:459` | +| 14 | **`rope_freqs.weight` is dropped only by the gemma4 map**; the generic map writes it into the output under its raw GGUF name, as dead weight the loader does not expect. It is the only tensor any map ever drops. | `crates/mlx-core/src/utils/gguf.rs:1179`, `:1299`, `:1372` | +| 15 | **For gemma4, the `rope_theta` extracted from GGUF metadata is DEAD**, and `partial_rotary_factor` has no GGUF source at all. The gemma4 loader reads only a nested `rope_parameters` object that nothing in the converter writes, so a gemma4 GGUF converted without `--config-dir` silently gets `(1e6, 1e4, 0.25)` while the extracted flat field sits in config.json looking authoritative. Other families read it flat and are unaffected — which is what makes this easy to miss. | `crates/mlx-core/src/models/gemma4/persistence.rs:441`, `crates/mlx-core/src/utils/gguf.rs:1783` | +| 16 | **The gemma-QAT importer drops the entire activation half of "wNa8o8"** — `.input_activation_scale`, `.output_activation_scale`, `.k_cache_scale`, `.v_cache_scale`. The imported model is weight-only. | `crates/mlx-core/src/convert_gemma_import.rs:235` | +| 18 | **`--config-dir`'s asset copy fails hard, but the implicit alongside-GGUF copy only warns.** Same loop, two branches — without `--config-dir`, a permission error or full disk while copying `tokenizer.json` gives a warning line and a `✓ Converted` banner, leaving an unusable directory. | `crates/mlx-core/src/utils/gguf.rs:3214` | +| 19 | **The `vision.safetensors` media sidecar is silently optional at load.** `append_vision_safetensors` returns `Ok(())` when the file is absent even though `should_load_media_sidecar` returned true. Only a few individual weights have explicit presence checks. | `crates/mlx-core/src/engine/persistence.rs:185`, `crates/mlx-core/src/models/gemma4/persistence.rs:392` | +| 20 | **On the GGUF path an AWQ whole-model no-match is completely silent.** `apply_gguf_awq_prescaling` discards the returned `modified` count, so the SafeTensors path's `warn!("modified == 0")` has no GGUF counterpart. Layer-prefix detection recognizes only `language_model.model.layers.` and `model.layers.`, so a third wrapper spelling gets zero AWQ. | `crates/mlx-core/src/utils/gguf.rs:2466`, `crates/mlx-core/src/convert.rs:2725`, `:6911` | +| 21 | **An imatrix tensor whose `.counts` is ≤ 0 or absent is silently `continue`d** and never enters the importance map. Group A may still fire on the surviving partner, producing a scale derived from half the intended evidence. | `crates/mlx-core/src/utils/imatrix.rs:294`, `crates/mlx-core/src/convert.rs:7103` | ### Flags that do less than they look like -| # | Trap | Where | -| - | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| 22 | **`--q-mxfp` is a complete no-op on `mixed_2_6` and `mixed_3_6`, and a partial no-op on `mixed_3_4`.** The upgrade arms match only `bits: 8` and `bits: 4`; `mixed_2_6` emits 2- and 6-bit, `mixed_3_6` emits 3- and 6-bit. The CLI prints `--q-mxfp: eligible 8b->mxfp8/4b->mxfp4` with no warning that the eligible set is empty. | `crates/mlx-core/src/convert.rs:4876`, `:3955`, `packages/cli/src/commands/convert.ts:404` | -| 23 | **`--q-bits` is silently ignored by all four `mixed_*` recipes.** `build_predicate_for_recipe` forwards only `default_group_size` to `build_recipe_predicate`, which has no bits parameter — but `--q-bits 8` still changes the recorded top-level `bits`, and therefore which decisions count as overrides. | `crates/mlx-core/src/convert.rs:5214`, `:3949` | -| 24 | **`--model-type` is parsed but never read at all on the GGUF path** — not forwarded, not validated, not auto-detected. The GGUF branch returns before the auto-detect block, and `GgufConversionOptions` has no `model_type` field. This is load-bearing for the nvidia gate: GGUF passes `None` for both `config_family` and `requested_model_type`, which is what makes `--q-recipe nvidia` on GGUF reject wholesale. | `packages/cli/src/commands/convert.ts:591`, `crates/mlx-core/src/utils/gguf.rs:2529` | -| 25 | **`--config-dir`, `--mmproj`, and `--imatrix-path` are fully validated by the CLI and then silently discarded on the paths where they do not apply.** `ConversionOptions` has no `config_source_dir` and no mmproj field at all; the foreign path forwards no imatrix. The user sees a successful conversion with no warning. | `packages/cli/src/commands/convert.ts:440-475`, `:669`, `:706` | -| 26 | **`--q-mtp cyankiwi`/`all` silently no-ops on any non-Qwen model type AND still writes a false `mtplx_mtp_quantization: {prequantized: true}` block.** The "no mtp.* tensors" safety errors are scoped to `MtpPolicy::Sidecar` / `Inline` / `is_split`; for `MtpPolicy::None` nothing rejects it, but the config writer is gated only on `do_quantize && quant_mtp != "off" && !is_split`. | `crates/mlx-core/src/convert.rs:2978`, `:3148` | -| 27 | **`--q-recipe` without `--quantize` is silently ignored on the GGUF path** — the guard exists in `convert.rs:2110` and has no GGUF counterpart. Except `unsloth` and `nvidia`, whose validators run *unconditionally* and still hard-error. Inconsistent coverage of one flag. | `crates/mlx-core/src/convert.rs:2110`, `crates/mlx-core/src/utils/gguf.rs:2503`, `:2941` | -| 28 | **`apply_mxfp_upgrade`'s `Default` arm falls through to `Default` for any `default_bits` outside {4, 8}.** Under `--q-recipe unsloth --q-mxfp` on a checkpoint that FAILS official-map validation, any key the legacy predicate leaves `Default` stays 3-bit affine gs 64 while the request said mxfp. The recorded metadata is *correct* (it matches the top-level triple) but the checkpoint is not what the flag promised. | `crates/mlx-core/src/convert.rs:4863` | -| 29 | **The `--gguf-kquant` + re-quantization reject uses two different predicates.** TS rejects on flags alone, unconditionally; Rust rejects only when the source file actually contains K-quant tensors. `importKQuants: true, quantize: true` on a BF16 GGUF is accepted natively and refused by the CLI. | `packages/cli/src/commands/convert.ts:502`, `crates/mlx-core/src/utils/gguf.rs:2591` | -| 30 | **`--mmproj` silently rewrites the MAIN model's tensor keys to the `language_model.*` VLM namespace**, which `--help` does not mention — it describes `--mmproj` purely as "Converts and merges vision weights". The mmproj sub-conversion also hardcodes `dtype: 'bfloat16'`, ignoring `--dtype`. | `packages/cli/src/commands/convert.ts:42`, `:550`, `:569`, `crates/mlx-core/src/utils/gguf.rs:3061` | -| 31 | **The CLI's `-d` default makes the napi field's documented "keep original" behaviour unreachable.** `const dtype = args.dtype \|\| 'bfloat16'` is forwarded unconditionally, so an F32 GGUF is always downcast to bf16 unless `-d float32` is given. | `packages/cli/src/commands/convert.ts:512`, `crates/mlx-core/src/utils/gguf.rs:2404` | -| 32 | **The `mlx calibrate` per-5% progress logging is dead code.** `calibrate()` runs one blocking native call then calls `onProgress?.(rows.length, rows.length)`, so the handler's throttling can only ever emit a single `calibrated N/N rows` line. A 1024×512 calibration prints nothing for its whole runtime, which reads as a hang. | `packages/cli/src/commands/calibrate.ts:91`, `:193` | +| # | Trap | Where | +| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| 22 | **`--q-mxfp` is a complete no-op on `mixed_2_6` and `mixed_3_6`, and a partial no-op on `mixed_3_4`.** The upgrade arms match only `bits: 8` and `bits: 4`; `mixed_2_6` emits 2- and 6-bit, `mixed_3_6` emits 3- and 6-bit. The CLI prints `--q-mxfp: eligible 8b->mxfp8/4b->mxfp4` with no warning that the eligible set is empty. | `crates/mlx-core/src/convert.rs:4876`, `:3955`, `packages/cli/src/commands/convert.ts:404` | +| 23 | **`--q-bits` is silently ignored by all four `mixed_*` recipes.** `build_predicate_for_recipe` forwards only `default_group_size` to `build_recipe_predicate`, which has no bits parameter — but `--q-bits 8` still changes the recorded top-level `bits`, and therefore which decisions count as overrides. | `crates/mlx-core/src/convert.rs:5214`, `:3949` | +| 24 | **`--model-type` is parsed but never read at all on the GGUF path** — not forwarded, not validated, not auto-detected. The GGUF branch returns before the auto-detect block, and `GgufConversionOptions` has no `model_type` field. This is load-bearing for the nvidia gate: GGUF passes `None` for both `config_family` and `requested_model_type`, which is what makes `--q-recipe nvidia` on GGUF reject wholesale. | `packages/cli/src/commands/convert.ts:591`, `crates/mlx-core/src/utils/gguf.rs:2529` | +| 25 | **`--config-dir`, `--mmproj`, and `--imatrix-path` are fully validated by the CLI and then silently discarded on the paths where they do not apply.** `ConversionOptions` has no `config_source_dir` and no mmproj field at all; the foreign path forwards no imatrix. The user sees a successful conversion with no warning. | `packages/cli/src/commands/convert.ts:440-475`, `:669`, `:706` | +| 26 | **`--q-mtp cyankiwi`/`all` silently no-ops on any non-Qwen model type AND still writes a false `mtplx_mtp_quantization: {prequantized: true}` block.** The "no mtp.* tensors" safety errors are scoped to `MtpPolicy::Sidecar` / `Inline` / `is_split`; for `MtpPolicy::None` nothing rejects it, but the config writer is gated only on `do_quantize && quant_mtp != "off" && !is_split`. | `crates/mlx-core/src/convert.rs:2978`, `:3148` | +| 27 | **`--q-recipe` without `--quantize` is silently ignored on the GGUF path** — the guard exists in `convert.rs:2110` and has no GGUF counterpart. Except `unsloth` and `nvidia`, whose validators run _unconditionally_ and still hard-error. Inconsistent coverage of one flag. | `crates/mlx-core/src/convert.rs:2110`, `crates/mlx-core/src/utils/gguf.rs:2503`, `:2941` | +| 28 | **`apply_mxfp_upgrade`'s `Default` arm falls through to `Default` for any `default_bits` outside {4, 8}.** Under `--q-recipe unsloth --q-mxfp` on a checkpoint that FAILS official-map validation, any key the legacy predicate leaves `Default` stays 3-bit affine gs 64 while the request said mxfp. The recorded metadata is _correct_ (it matches the top-level triple) but the checkpoint is not what the flag promised. | `crates/mlx-core/src/convert.rs:4863` | +| 29 | **The `--gguf-kquant` + re-quantization reject uses two different predicates.** TS rejects on flags alone, unconditionally; Rust rejects only when the source file actually contains K-quant tensors. `importKQuants: true, quantize: true` on a BF16 GGUF is accepted natively and refused by the CLI. | `packages/cli/src/commands/convert.ts:502`, `crates/mlx-core/src/utils/gguf.rs:2591` | +| 30 | **`--mmproj` silently rewrites the MAIN model's tensor keys to the `language_model.*` VLM namespace**, which `--help` does not mention — it describes `--mmproj` purely as "Converts and merges vision weights". The mmproj sub-conversion also hardcodes `dtype: 'bfloat16'`, ignoring `--dtype`. | `packages/cli/src/commands/convert.ts:42`, `:550`, `:569`, `crates/mlx-core/src/utils/gguf.rs:3061` | +| 31 | **The CLI's `-d` default makes the napi field's documented "keep original" behaviour unreachable.** `const dtype = args.dtype \|\| 'bfloat16'` is forwarded unconditionally, so an F32 GGUF is always downcast to bf16 unless `-d float32` is given. | `packages/cli/src/commands/convert.ts:512`, `crates/mlx-core/src/utils/gguf.rs:2404` | +| 32 | **The `mlx calibrate` per-5% progress logging is dead code.** `calibrate()` runs one blocking native call then calls `onProgress?.(rows.length, rows.length)`, so the handler's throttling can only ever emit a single `calibrated N/N rows` line. A 1024×512 calibration prints nothing for its whole runtime, which reads as a hang. | `packages/cli/src/commands/calibrate.ts:91`, `:193` | ### Validation that fires too late, or not at all -| # | Trap | Where | -| - | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| 33 | **Neither layer validates `--q-bits` against MLX's {2,3,4,5,6,8}, nor `--q-group-size` against affine's {32,64,128}.** TS checks `/^[1-9]\d*$/`; Rust checks `> 0` (and even that is missing on the GGUF path). `--q-bits 7 --q-mode affine` or `--q-group-size 48` reaches `mlx_quantize` mid-conversion. `convert.rs:2871` states the affine constraint in a comment while validating nothing. | `packages/cli/src/commands/convert.ts:220`, `crates/mlx-core/src/convert.rs:2019`, `:2871`, `:5320` | -| 34 | **`build_qwen35_recipe` can emit `bits = 7`**, which the repo's own comment says MLX cannot express. `high_bits = (default_bits + 2).min(8)` with NO `snap_bits`. `--q-bits 5` ⇒ attention/GDN at 7; `--q-bits 6` ⇒ `down_proj` at 7. | `crates/mlx-core/src/convert.rs:4053`, `:4176` | -| 35 | **`--q-mode nvfp4` without `--q-recipe` is rejected only in Rust, and only after the entire checkpoint is loaded and sanitized.** On a 100 GB MoE that is many minutes of work before a purely static flag error. Same for `--q-mxfp` without a recipe and bits ∉ {4,8}. | `crates/mlx-core/src/convert.rs:2930`, `:2944`, `crates/mlx-core/src/utils/gguf.rs:3012` | -| 36 | **`--config-dir` pointing at a directory with no `config.json` is not detected until AFTER `model.safetensors` has been written.** The TS check only verifies the directory exists. The output is left with valid weights and no config. | `packages/cli/src/commands/convert.ts:466`, `crates/mlx-core/src/utils/gguf.rs:3096`, `:3109` | -| 37 | **The affine GGUF repack performs no divisibility validation**, unlike the K-quant path which rejects a last dim that is not a positive multiple of `QK_K`. `load_quantized_tensor` uses integer division throughout; a 2-row tensor with last dim 48 indexes `scales[2]` out of bounds — a panic across the napi boundary, not a named error. | `crates/mlx-core/src/utils/gguf.rs:776`, `:978` | -| 38 | **`Vec::with_capacity` on `tensor_count` and `n_dims` is not covered by `MAX_GGUF_ALLOC`.** The 256 MiB cap applies to string lengths and array element *counts* only (256 M `String` headers ≈ 6 GB). A malformed header can abort the process before a single tensor is parsed. | `crates/mlx-core/src/utils/gguf.rs:511`, `:517`, `:362` | -| 39 | **The gemma-QAT "already quantized" reject is narrower than it looks.** It is gated on `is_gemma_qat_family` = `nvidia_recipe_family(model_type) == Some("gemma4") && is_gemma_qat_source`, so a gemma-QAT source converted with a mismatched `-m` (e.g. `-m qwen3_5 -q`) escapes it entirely and falls through to the generic quantizer, which would re-quantize already-quantized weights. Only the *unified* reject hangs off bare `is_gemma_qat_source`. | `crates/mlx-core/src/convert.rs:2264`, `:2267`, `:2284` | -| 40 | **`--dtype` is validated inconsistently by family.** For `owns_dtype_cast` families the generic loop is bypassed, so `Err("Unsupported target dtype")` is unreachable and a `warn!` is the only handler. `mlx convert -m qwen3_5 -d float64` prints `Dtype: float64`, writes bf16, exits 0. The same flag on `-m gemma4` hard-errors. **`-m nemotron_h` is a third behaviour**: its sanitize binds the parameter as `_target_dtype_str` and never reads it, so `--dtype` is a silent no-op with not even a warning — correct for a format-preserving ingest, indistinguishable from a working flag at the CLI. | `crates/mlx-core/src/convert.rs:686`, `:3878`, `:3966`, `:2731` | -| 42 | **`verify_override_coverage` — the set-difference audit — exists ONLY for the gemma-prequant import.** Its doc explains why: the generic paths' override maps are intentionally sparse, so a `.scales` tensor without an override is normal there. No automated guard will catch a coverage hole on any other path. | `crates/mlx-core/src/convert_gemma_import.rs:262` | -| 43 | **A sym8 checkpoint refuses to load on any GPU below Apple gen 17 (M5).** `try_build_sym8_quantized_linear` hard-errors with "sym8 checkpoints require an M5+ GPU". `sym8_eligible` deliberately OMITS this check because it is a runtime property, so conversion succeeds on an M1–M4 box and produces a checkpoint that same box cannot load. Neither `docs/cli.md:144` nor the `--q-mode` help mentions it. | `crates/mlx-core/src/models/qwen3_5/quantized_linear.rs:389`, `crates/mlx-core/src/convert.rs:5546` | +| # | Trap | Where | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| 33 | **Neither layer validates `--q-bits` against MLX's {2,3,4,5,6,8}, nor `--q-group-size` against affine's {32,64,128}.** TS checks `/^[1-9]\d*$/`; Rust checks `> 0` (and even that is missing on the GGUF path). `--q-bits 7 --q-mode affine` or `--q-group-size 48` reaches `mlx_quantize` mid-conversion. `convert.rs:2871` states the affine constraint in a comment while validating nothing. | `packages/cli/src/commands/convert.ts:220`, `crates/mlx-core/src/convert.rs:2019`, `:2871`, `:5320` | +| 34 | **`build_qwen35_recipe` can emit `bits = 7`**, which the repo's own comment says MLX cannot express. `high_bits = (default_bits + 2).min(8)` with NO `snap_bits`. `--q-bits 5` ⇒ attention/GDN at 7; `--q-bits 6` ⇒ `down_proj` at 7. | `crates/mlx-core/src/convert.rs:4053`, `:4176` | +| 35 | **`--q-mode nvfp4` without `--q-recipe` is rejected only in Rust, and only after the entire checkpoint is loaded and sanitized.** On a 100 GB MoE that is many minutes of work before a purely static flag error. Same for `--q-mxfp` without a recipe and bits ∉ {4,8}. | `crates/mlx-core/src/convert.rs:2930`, `:2944`, `crates/mlx-core/src/utils/gguf.rs:3012` | +| 36 | **`--config-dir` pointing at a directory with no `config.json` is not detected until AFTER `model.safetensors` has been written.** The TS check only verifies the directory exists. The output is left with valid weights and no config. | `packages/cli/src/commands/convert.ts:466`, `crates/mlx-core/src/utils/gguf.rs:3096`, `:3109` | +| 37 | **The affine GGUF repack performs no divisibility validation**, unlike the K-quant path which rejects a last dim that is not a positive multiple of `QK_K`. `load_quantized_tensor` uses integer division throughout; a 2-row tensor with last dim 48 indexes `scales[2]` out of bounds — a panic across the napi boundary, not a named error. | `crates/mlx-core/src/utils/gguf.rs:776`, `:978` | +| 38 | **`Vec::with_capacity` on `tensor_count` and `n_dims` is not covered by `MAX_GGUF_ALLOC`.** The 256 MiB cap applies to string lengths and array element _counts_ only (256 M `String` headers ≈ 6 GB). A malformed header can abort the process before a single tensor is parsed. | `crates/mlx-core/src/utils/gguf.rs:511`, `:517`, `:362` | +| 39 | **The gemma-QAT "already quantized" reject is narrower than it looks.** It is gated on `is_gemma_qat_family` = `nvidia_recipe_family(model_type) == Some("gemma4") && is_gemma_qat_source`, so a gemma-QAT source converted with a mismatched `-m` (e.g. `-m qwen3_5 -q`) escapes it entirely and falls through to the generic quantizer, which would re-quantize already-quantized weights. Only the _unified_ reject hangs off bare `is_gemma_qat_source`. | `crates/mlx-core/src/convert.rs:2264`, `:2267`, `:2284` | +| 40 | **`--dtype` is validated inconsistently by family.** For `owns_dtype_cast` families the generic loop is bypassed, so `Err("Unsupported target dtype")` is unreachable and a `warn!` is the only handler. `mlx convert -m qwen3_5 -d float64` prints `Dtype: float64`, writes bf16, exits 0. The same flag on `-m gemma4` hard-errors. **`-m nemotron_h` is a third behaviour**: its sanitize binds the parameter as `_target_dtype_str` and never reads it, so `--dtype` is a silent no-op with not even a warning — correct for a format-preserving ingest, indistinguishable from a working flag at the CLI. | `crates/mlx-core/src/convert.rs:686`, `:3878`, `:3966`, `:2731` | +| 42 | **`verify_override_coverage` — the set-difference audit — exists ONLY for the gemma-prequant import.** Its doc explains why: the generic paths' override maps are intentionally sparse, so a `.scales` tensor without an override is normal there. No automated guard will catch a coverage hole on any other path. | `crates/mlx-core/src/convert_gemma_import.rs:262` | +| 43 | **A sym8 checkpoint refuses to load on any GPU below Apple gen 17 (M5).** `try_build_sym8_quantized_linear` hard-errors with "sym8 checkpoints require an M5+ GPU". `sym8_eligible` deliberately OMITS this check because it is a runtime property, so conversion succeeds on an M1–M4 box and produces a checkpoint that same box cannot load. Neither `docs/cli.md:144` nor the `--q-mode` help mentions it. | `crates/mlx-core/src/models/qwen3_5/quantized_linear.rs:389`, `crates/mlx-core/src/convert.rs:5546` | ### Guard coverage: TS vs Rust @@ -1681,7 +1682,7 @@ The highest-value section. Everything here is confirmed in code. TS-only ones, only two are genuinely reachable by a direct NAPI caller (`--mmproj` and `--imatrix-path` path/extension checks) — the rest are structurally impossible or fail later anyway. Of the Rust-only ones the load-bearing one is the **nvidia family gate** -(`crates/mlx-core/src/convert.rs:5027`, `:5042`, `:5054`): it reads the *input config.json's* own +(`crates/mlx-core/src/convert.rs:5027`, `:5042`, `:5054`): it reads the _input config.json's_ own `model_type`, not `-m`, precisely because the CLI forwards an explicit `-m` verbatim and skips auto-detection. @@ -1697,7 +1698,7 @@ Two consequences worth naming: silently produces the `4/5/6/8/6` ladder instead of the documented `3/4/5/6/5`. (Without `imatrixPath` the call hard-errors at `crates/mlx-core/src/convert.rs:2148` — it does not silently produce anything.) -- The **nvfp4 arm** of `effectiveQuantBits` is *not* redundant. It is a ternary chain: +- The **nvfp4 arm** of `effectiveQuantBits` is _not_ redundant. It is a ternary chain: `quantMode === 'nvfp4' ? 4 : quantRecipe === 'unsloth' ? 3 : undefined`. For the documented `--q-recipe unsloth --q-mode nvfp4` pair the nvfp4 arm **preempts** the unsloth 3-bit arm; delete it and the TS nvfp4 invariant check at `convert.ts:430` rejects the invocation with "nvfp4 requires @@ -1705,62 +1706,62 @@ Two consequences worth naming: ### `config.json` provenance traps -| # | Trap | Where | -| - | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| 44 | **`--q-recipe unsloth --q-mxfp` writes a top-level `{bits:3, group_size:64, mode:"affine"}` block that describes ZERO tensors.** The recipe branch never updates `quant_mode_effective` / `quant_group_size_effective`; only the no-recipe branch does. It is safe *only* because the fixed official map returns `Custom`/`Skip` for every key and never `Default`, so 100 % of quantized tensors carry an explicit override and the fallback is never taken. On this path the map emits only mxfp8 8/32, mxfp4 4/32, and `Skip` (bf16) — the 8-bit-affine router-gate class belongs to the *upgrade wrapper*, which does not run here. | `crates/mlx-core/src/convert.rs:2914` vs `:2967`; `:3132`; `packages/cli/src/commands/convert.ts:383` | -| 45 | **The same bug class was recognized and fixed for `--q-mode nvfp4` and never for `--q-mxfp`.** The CLI comment: "the unsloth 3-bit default would otherwise produce an inconsistent checkpoint: top-level bits=3 but per-layer overrides at bits=4 … with no failure surface." The nvfp4 top-level `nvfp4/4/16` is genuinely load-bearing — the early-FFN class equals it and correctly gets **no** override. | `packages/cli/src/commands/convert.ts:379`, `crates/mlx-core/src/convert.rs:9889` | -| 46 | **The top-level block is misleading for EVERY recipe artifact.** Auditing by reading `quantization.mode` gives the wrong answer; the only correct audit is the set difference of `.scales` keys against the override map. There is one exception in the other direction: the gemma-QAT import (`convert.rs:2750`) is the sole path that rewrites all *four* effective values, deriving them honestly from its own override map. | `crates/mlx-core/src/convert.rs:2740`, `:2750`, `:2967`, `:6726` | -| 47 | **There is no `mixed` field in any emitted config.** Any consumer looking for `"mixed": true` to decide whether the top-level triple is trustworthy finds nothing. `mixed` is a local `bool` in the GGUF importer. | `crates/mlx-core/src/utils/gguf.rs:2349`, `:2371` | -| 48 | **MTP quantization is excluded from the `quantization` block** and described by a non-standard `mtplx_mtp_quantization` key hard-coded to `{4, 32, affine}` regardless of what the MTP tensors actually are. Any standard loader reading only `quantization` treats them as unquantized. The `cyankiwi` description string also says "Load **calibrated** CyanKiwi MTP layer linears" — nothing is calibrated; `cyankiwi` and `all` differ by exactly one tensor (`mtp.fc`). | `crates/mlx-core/src/convert.rs:3138`, `:3155`, `:3156`, `:3798` | -| 49 | **The emitted `config.json` is a verbatim clone of the source with only `_name_or_path` removed and quantization injected.** `torch_dtype` is never read or rewritten — an f32 source converted to bf16 emits `torch_dtype: "float32"` beside bf16 tensors, while mlx-lm uses `config["torch_dtype"]` as its default conversion dtype. The source `model_type` is likewise preserved, so a `gemma4_text` input keeps `gemma4_text` even though it was sanitized as `gemma4`. | `crates/mlx-core/src/convert.rs:3122`, `mlx-lm/mlx_lm/convert.py:131` | +| # | Trap | Where | +| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| 44 | **`--q-recipe unsloth --q-mxfp` writes a top-level `{bits:3, group_size:64, mode:"affine"}` block that describes ZERO tensors.** The recipe branch never updates `quant_mode_effective` / `quant_group_size_effective`; only the no-recipe branch does. It is safe _only_ because the fixed official map returns `Custom`/`Skip` for every key and never `Default`, so 100 % of quantized tensors carry an explicit override and the fallback is never taken. On this path the map emits only mxfp8 8/32, mxfp4 4/32, and `Skip` (bf16) — the 8-bit-affine router-gate class belongs to the _upgrade wrapper_, which does not run here. | `crates/mlx-core/src/convert.rs:2914` vs `:2967`; `:3132`; `packages/cli/src/commands/convert.ts:383` | +| 45 | **The same bug class was recognized and fixed for `--q-mode nvfp4` and never for `--q-mxfp`.** The CLI comment: "the unsloth 3-bit default would otherwise produce an inconsistent checkpoint: top-level bits=3 but per-layer overrides at bits=4 … with no failure surface." The nvfp4 top-level `nvfp4/4/16` is genuinely load-bearing — the early-FFN class equals it and correctly gets **no** override. | `packages/cli/src/commands/convert.ts:379`, `crates/mlx-core/src/convert.rs:9889` | +| 46 | **The top-level block is misleading for EVERY recipe artifact.** Auditing by reading `quantization.mode` gives the wrong answer; the only correct audit is the set difference of `.scales` keys against the override map. There is one exception in the other direction: the gemma-QAT import (`convert.rs:2750`) is the sole path that rewrites all _four_ effective values, deriving them honestly from its own override map. | `crates/mlx-core/src/convert.rs:2740`, `:2750`, `:2967`, `:6726` | +| 47 | **There is no `mixed` field in any emitted config.** Any consumer looking for `"mixed": true` to decide whether the top-level triple is trustworthy finds nothing. `mixed` is a local `bool` in the GGUF importer. | `crates/mlx-core/src/utils/gguf.rs:2349`, `:2371` | +| 48 | **MTP quantization is excluded from the `quantization` block** and described by a non-standard `mtplx_mtp_quantization` key hard-coded to `{4, 32, affine}` regardless of what the MTP tensors actually are. Any standard loader reading only `quantization` treats them as unquantized. The `cyankiwi` description string also says "Load **calibrated** CyanKiwi MTP layer linears" — nothing is calibrated; `cyankiwi` and `all` differ by exactly one tensor (`mtp.fc`). | `crates/mlx-core/src/convert.rs:3138`, `:3155`, `:3156`, `:3798` | +| 49 | **The emitted `config.json` is a verbatim clone of the source with only `_name_or_path` removed and quantization injected.** `torch_dtype` is never read or rewritten — an f32 source converted to bf16 emits `torch_dtype: "float32"` beside bf16 tensors, while mlx-lm uses `config["torch_dtype"]` as its default conversion dtype. The source `model_type` is likewise preserved, so a `gemma4_text` input keeps `gemma4_text` even though it was sanitized as `gemma4`. | `crates/mlx-core/src/convert.rs:3122`, `mlx-lm/mlx_lm/convert.py:131` | ### Naming and layout traps -| # | Trap | Where | -| - | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| 50 | **`.biases` on a K-quant holds ggml's `d` (and `dmin`) — a SCALE, not an additive bias.** Any code assuming it is additive (a bias-folding pass, a dtype-following cast rule, a re-quantizer) silently corrupts K-quant tensors. Its dtype must stay exactly `float16`. This is why the converter needs a dedicated content-keyed exemption rather than the ordinary "float follows `--dtype`" rule. | `crates/mlx-core/src/utils/gguf_kquant.rs:22`, `crates/mlx-core/src/models/quant_dispatch.rs:455`, `crates/mlx-core/src/convert.rs:5455` | -| 51 | **`kquant_biases_to_preserve` is skipped entirely when the family owns its own dtype cast** (`let kquant_biases_keys = if has_custom_sanitizer { HashSet::new() } else { … }`). `owns_dtype_cast()` is true for qwen3_5, qwen3_5_moe, lfm2, lfm2_moe **and nemotron_h** — the flag's membership grows with every family that takes over its own dtype cast, so this list is the thing to re-derive rather than trust. Latent today (the GGUF K-quant import writes its own output, re-quantization is refused, and the nemotron_h ingest reads modelopt NVFP4/FP8 rather than GGUF), but it is a whole-family opt-out, not a per-tensor one. | `crates/mlx-core/src/convert.rs:3833`, `:1317`, `:2727` | -| 52 | **`nn::Linear`'s quantized backend hardcodes `mode = "affine"`** at both the forward (`mlx_quantized_matmul`) and the load-time dequant. There is no mode parameter on `Linear::load_quantized`. This is the concrete reason `is_affine_only_key` exists — emitting mxfp4/mxfp8/nvfp4 at lm_head / router.proj / embed_tokens\* / embedding_projection would be silently mis-dequantized as affine, no error, just wrong numbers. | `crates/mlx-core/src/nn/linear.rs:62`, `:149`, `crates/mlx-core/src/convert.rs:3865` | -| 53 | **`is_affine_only_key` short-circuits `is_router_gate` inside both upgrade wrappers, and the two disagree.** For `.router.proj`, `apply_mxfp_upgrade` returns the inner decision unchanged (including a bare `Default`, which resolves to the global affine default), whereas `apply_nvfp4_upgrade` rewrites a `Default` into an explicit `Custom{8, 64, affine}`. Latent — no shipped recipe returns `Default` there. | `crates/mlx-core/src/convert.rs:4820` vs `:5153` | -| 54 | **PaddleOCR-VL's key transform uses `String::replace`, which rewrites ALL occurrences**: `result.replace("model.", "language_model.model.")` guarded only by `!result.contains("visual")`. Any key containing `model.` more than once is rewritten at every position, and `transform_key` is the identity for unmatched keys, so the damage is silent. | `crates/mlx-core/src/models/paddleocr_vl/persistence.rs:31` | -| 55 | **Three families each guess PyTorch-vs-MLX conv layout from shape alone.** Qianfan-OCR uses `shape[1] < shape[2]`; Qwen uses `dim2 <= 16`; paddleocr uses `t == 3 \|\| (out >= k_h && out >= k_w && k_h == k_w)`. A conv whose in_channels equals its kernel height (or a kernel > 16) is silently left in the wrong layout — both layouts have the same rank and element count, so nothing downstream can fail. | `crates/mlx-core/src/models/qianfan_ocr/persistence.rs:88`, `crates/mlx-core/src/convert.rs:1121`, `crates/mlx-core/src/models/paddleocr_vl/persistence.rs:60` | -| 56 | **The `already_sanitized` probe reads a single f32 scalar and, when true, skips the whole of Step 4** — including both conv transposes, not just the norm shift. It samples element 0 of the first non-MTP `.input_layernorm.weight` and treats `> 0.5` as "already MLX format", then sets `keys = Vec::new()`. Norm-shift state and conv-layout state are independent properties decided by one number. | `crates/mlx-core/src/convert.rs:1019-1032`, `:1084-1089` | -| 57 | **The GGUF path writes ONE unsharded `model.safetensors` with no `index.json`**; the SafeTensors path shards at 5 GiB and always writes an index. A 60 GB GGUF import produces one 60 GB file — a different on-disk shape from every other conversion, with no flag to change it. | `crates/mlx-core/src/utils/gguf.rs:3096`, `crates/mlx-core/src/utils/safetensors.rs:833` | -| 58 | **There is no inverse of llama.cpp's Q/K head permutation anywhere in the importer.** The only head-order fixup is `fixup_qwen35_linear_attn`, and it touches GDN tensors only. `self_attn.q_proj`/`k_proj` are copied through unreordered. mlx-lm has an export-direction `permute_weights` (`mlx-lm/mlx_lm/gguf.py:133`); nothing here undoes it. Shapes are identical, so nothing downstream can detect it. | `crates/mlx-core/src/utils/gguf.rs:1570` | +| # | Trap | Where | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 50 | **`.biases` on a K-quant holds ggml's `d` (and `dmin`) — a SCALE, not an additive bias.** Any code assuming it is additive (a bias-folding pass, a dtype-following cast rule, a re-quantizer) silently corrupts K-quant tensors. Its dtype must stay exactly `float16`. This is why the converter needs a dedicated content-keyed exemption rather than the ordinary "float follows `--dtype`" rule. | `crates/mlx-core/src/utils/gguf_kquant.rs:22`, `crates/mlx-core/src/models/quant_dispatch.rs:455`, `crates/mlx-core/src/convert.rs:5455` | +| 51 | **`kquant_biases_to_preserve` is skipped entirely when the family owns its own dtype cast** (`let kquant_biases_keys = if has_custom_sanitizer { HashSet::new() } else { … }`). `owns_dtype_cast()` is true for qwen3_5, qwen3_5_moe, lfm2, lfm2_moe **and nemotron_h** — the flag's membership grows with every family that takes over its own dtype cast, so this list is the thing to re-derive rather than trust. Latent today (the GGUF K-quant import writes its own output, re-quantization is refused, and the nemotron_h ingest reads modelopt NVFP4/FP8 rather than GGUF), but it is a whole-family opt-out, not a per-tensor one. | `crates/mlx-core/src/convert.rs:3833`, `:1317`, `:2727` | +| 52 | **`nn::Linear`'s quantized backend hardcodes `mode = "affine"`** at both the forward (`mlx_quantized_matmul`) and the load-time dequant. There is no mode parameter on `Linear::load_quantized`. This is the concrete reason `is_affine_only_key` exists — emitting mxfp4/mxfp8/nvfp4 at lm_head / router.proj / embed_tokens\* / embedding_projection would be silently mis-dequantized as affine, no error, just wrong numbers. | `crates/mlx-core/src/nn/linear.rs:62`, `:149`, `crates/mlx-core/src/convert.rs:3865` | +| 53 | **`is_affine_only_key` short-circuits `is_router_gate` inside both upgrade wrappers, and the two disagree.** For `.router.proj`, `apply_mxfp_upgrade` returns the inner decision unchanged (including a bare `Default`, which resolves to the global affine default), whereas `apply_nvfp4_upgrade` rewrites a `Default` into an explicit `Custom{8, 64, affine}`. Latent — no shipped recipe returns `Default` there. | `crates/mlx-core/src/convert.rs:4820` vs `:5153` | +| 54 | **PaddleOCR-VL's key transform uses `String::replace`, which rewrites ALL occurrences**: `result.replace("model.", "language_model.model.")` guarded only by `!result.contains("visual")`. Any key containing `model.` more than once is rewritten at every position, and `transform_key` is the identity for unmatched keys, so the damage is silent. | `crates/mlx-core/src/models/paddleocr_vl/persistence.rs:31` | +| 55 | **Three families each guess PyTorch-vs-MLX conv layout from shape alone.** Qianfan-OCR uses `shape[1] < shape[2]`; Qwen uses `dim2 <= 16`; paddleocr uses `t == 3 \|\| (out >= k_h && out >= k_w && k_h == k_w)`. A conv whose in_channels equals its kernel height (or a kernel > 16) is silently left in the wrong layout — both layouts have the same rank and element count, so nothing downstream can fail. | `crates/mlx-core/src/models/qianfan_ocr/persistence.rs:88`, `crates/mlx-core/src/convert.rs:1121`, `crates/mlx-core/src/models/paddleocr_vl/persistence.rs:60` | +| 56 | **The `already_sanitized` probe reads a single f32 scalar and, when true, skips the whole of Step 4** — including both conv transposes, not just the norm shift. It samples element 0 of the first non-MTP `.input_layernorm.weight` and treats `> 0.5` as "already MLX format", then sets `keys = Vec::new()`. Norm-shift state and conv-layout state are independent properties decided by one number. | `crates/mlx-core/src/convert.rs:1019-1032`, `:1084-1089` | +| 57 | **The GGUF path writes ONE unsharded `model.safetensors` with no `index.json`**; the SafeTensors path shards at 5 GiB and always writes an index. A 60 GB GGUF import produces one 60 GB file — a different on-disk shape from every other conversion, with no flag to change it. | `crates/mlx-core/src/utils/gguf.rs:3096`, `crates/mlx-core/src/utils/safetensors.rs:833` | +| 58 | **There is no inverse of llama.cpp's Q/K head permutation anywhere in the importer.** The only head-order fixup is `fixup_qwen35_linear_attn`, and it touches GDN tensors only. `self_attn.q_proj`/`k_proj` are copied through unreordered. mlx-lm has an export-direction `permute_weights` (`mlx-lm/mlx_lm/gguf.py:133`); nothing here undoes it. Shapes are identical, so nothing downstream can detect it. | `crates/mlx-core/src/utils/gguf.rs:1570` | ### Dead / defensive code you should not read as capability -| # | Fact | Where | -| - | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| 59 | **The K-quant arms in the SafeTensors converter are unreachable from any production path.** `VALID_QUANT_MODES` is `[affine, mxfp4, mxfp8, nvfp4, sym8]`, no recipe constructs a `Custom{mode:"q6k"}`, and the GGUF frontend restricts its modes to affine/mxfp8/mxfp4/nvfp4. They are defensive and unit-tested — but reading them as evidence that convert can emit or round-trip K-quants is wrong. | `crates/mlx-core/src/convert.rs:5910`, `:5711`, `:1967`, `crates/mlx-sys/mlx/mlx/ops.cpp:5245` | -| 60 | **`resolve_legacy_entry`'s `lm_head` clause in arm 4 is dead by construction** — `should_quantize` already excluded it at arm 1. The code says so: "kept for defense-in-depth". | `crates/mlx-core/src/convert.rs:6297` | -| 61 | **`VALID_MTP_QUANT_POLICIES` still lists `"drafter"`**, but the alias is normalized to `"split"` fourteen lines earlier, so that entry can never match. Two independent copies of the list (TS and Rust) can drift. | `crates/mlx-core/src/convert.rs:1960`, `:1977`, `packages/cli/src/commands/convert.ts:265` | -| 62 | **`derived_symmetric_bias_bits` is `pub` production code with zero production callers** — it exists purely as the parity gate's test oracle. It looks like dead code to a linter, and deleting it removes the only check that the load-time reconstruction still reproduces the historical bytes. | `crates/mlx-core/src/utils/gguf.rs:743` | -| 63 | **`fn model_types()` on `ConversionRecipe` is `#[allow(dead_code)]`** — no runtime dispatch role; it exists only for the registry-consistency test. | `crates/mlx-core/src/convert.rs:161` | -| 64 | **`utils/gemma_quant_repack.rs` is NOT on the GGUF path.** Its module doc says it "mirrors the GGUF Q4_0 → MLX affine repack in `super::gguf`", which makes it easy to mistake for shared code. Its only production caller is `convert_gemma_import.rs:52`, and unlike the GGUF path it still **materializes** the derived biases array. Changing one does not change the other. | `crates/mlx-core/src/utils/gemma_quant_repack.rs:31` | -| 65 | **There are TWO independent GGUF header parsers in the crate.** `utils/gguf.rs:34` and `utils/imatrix.rs:23` each declare `GGUF_MAGIC` and their own reader helpers; the imatrix copy has no `MAX_GGUF_ALLOC` equivalent. They *do* share the name mapper (`imatrix.rs:20` imports `gguf_name_to_hf`), so a rename rule fixed in one reaches the other — but a spec/hardening fix does not. | `crates/mlx-core/src/utils/imatrix.rs:20`, `:23` | +| # | Fact | Where | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | +| 59 | **The K-quant arms in the SafeTensors converter are unreachable from any production path.** `VALID_QUANT_MODES` is `[affine, mxfp4, mxfp8, nvfp4, sym8]`, no recipe constructs a `Custom{mode:"q6k"}`, and the GGUF frontend restricts its modes to affine/mxfp8/mxfp4/nvfp4. They are defensive and unit-tested — but reading them as evidence that convert can emit or round-trip K-quants is wrong. | `crates/mlx-core/src/convert.rs:5910`, `:5711`, `:1967`, `crates/mlx-sys/mlx/mlx/ops.cpp:5245` | +| 60 | **`resolve_legacy_entry`'s `lm_head` clause in arm 4 is dead by construction** — `should_quantize` already excluded it at arm 1. The code says so: "kept for defense-in-depth". | `crates/mlx-core/src/convert.rs:6297` | +| 61 | **`VALID_MTP_QUANT_POLICIES` still lists `"drafter"`**, but the alias is normalized to `"split"` fourteen lines earlier, so that entry can never match. Two independent copies of the list (TS and Rust) can drift. | `crates/mlx-core/src/convert.rs:1960`, `:1977`, `packages/cli/src/commands/convert.ts:265` | +| 62 | **`derived_symmetric_bias_bits` is `pub` production code with zero production callers** — it exists purely as the parity gate's test oracle. It looks like dead code to a linter, and deleting it removes the only check that the load-time reconstruction still reproduces the historical bytes. | `crates/mlx-core/src/utils/gguf.rs:743` | +| 63 | **`fn model_types()` on `ConversionRecipe` is `#[allow(dead_code)]`** — no runtime dispatch role; it exists only for the registry-consistency test. | `crates/mlx-core/src/convert.rs:161` | +| 64 | **`utils/gemma_quant_repack.rs` is NOT on the GGUF path.** Its module doc says it "mirrors the GGUF Q4_0 → MLX affine repack in `super::gguf`", which makes it easy to mistake for shared code. Its only production caller is `convert_gemma_import.rs:52`, and unlike the GGUF path it still **materializes** the derived biases array. Changing one does not change the other. | `crates/mlx-core/src/utils/gemma_quant_repack.rs:31` | +| 65 | **There are TWO independent GGUF header parsers in the crate.** `utils/gguf.rs:34` and `utils/imatrix.rs:23` each declare `GGUF_MAGIC` and their own reader helpers; the imatrix copy has no `MAX_GGUF_ALLOC` equivalent. They _do_ share the name mapper (`imatrix.rs:20` imports `gguf_name_to_hf`), so a rename rule fixed in one reaches the other — but a spec/hardening fix does not. | `crates/mlx-core/src/utils/imatrix.rs:20`, `:23` | ### Documentation vs code — code wins -| claim | where | reality | -| --------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--q-recipe nvidia` "is supported **only** for `qwen3_5` / `qwen3_5_moe` … Other families (e.g. `gemma4`) need their own recipe." | `docs/cli.md:97-100` | Stale. `nvidia_recipe_family` accepts `gemma4` / `gemma4_unified` / `gemma4_text` (`crates/mlx-core/src/convert.rs:4952`) and `validate_nvidia_recipe_options` passes them for **dense** gemma4 (`:5027`). Only gemma4 **MoE** is rejected. The CLI's own `--help` documents it correctly (`convert.ts:112`). | -| Auto-detected families list | `docs/cli.md:229-232` | No arm exists for `qianfan-ocr` / `pp-lcnet-ori` / `uvdoc` (all need explicit `-m`), and the list **omits** `lfm2`, `lfm2_moe`, `privacy-filter`, which *are* detected (`convert.ts:641`, `:644`). | -| `--model-type` options in `mlx convert --help` | `packages/cli/src/commands/convert.ts:37` | Omits `gemma4` and `gemma4_unified`, both accepted and auto-detected (`crates/mlx-core/src/convert.rs:1753`). | -| sym8 allowlist is "dense qwen3_5, lfm2/lfm2_moe, gemma4 — **NOT** qwen3_5_moe" | `crates/mlx-core/src/convert.rs:194-201`, `convert.ts:51` | Contradicted by the impl 970 lines below: `Qwen35Recipe::sym8_supported()` returns `true` **unconditionally** for both dense and MoE, and its own inline comment says "Both dense qwen3_5 and qwen3_5_moe dispatch sym8" (`:1170`). `ConversionOptions`'s doc (`:1801`) has it right. `-m qwen3_5_moe -q --q-mode sym8` **is** accepted today — and MoE is exactly where the 3-D `switch_mlp` experts divert to forced 8-bit affine, so the result is a sym8/affine mixture. | -| `--gguf-kquant` "keeps the source … byte size" / "the same bits at ggml's byte size" | `convert.ts:138`, `crates/mlx-core/src/utils/gguf_kquant.rs:323` | True **only** for Q6_K. Q4_K grows 144→148 B/super-block (+2.8 %), Q5_K 176→180 (+2.3 %). The same source file contradicts itself 110 lines later (`gguf_kquant.rs:432`), and `docs/cli.md:184` carries the correct +0.125 bpw table. | -| `fixup_shapes` doc: "Norm weights: GGUF stores delta from 1.0 → add +1.0" | `crates/mlx-core/src/utils/gguf.rs:1471` | The body forty lines later says the exact opposite ("GGUF stores actual trained norm weights … We do NOT apply it here"), and no `+1.0` appears in the function. The NOTE's escape hatch — "handled by persistence.rs sanitize_weights" — cannot fire for GGUF input, because qwen3_5's detector is `conv1d.weight` shape[-1] != 1 and `fixup_shapes` has already reshaped it to `[C,K,1]`. GGUF norms are used verbatim, always. | -| AWQ inline comment `// post_attention_layernorm.weight /= scales` | `crates/mlx-core/src/convert.rs:6952` | Stale — the selection three lines above probes `pre_feedforward_layernorm` first (`:6932`). Any note asserting "fold post_attention_layernorm, NOT pre_feedforward" contradicts both the code and the gemma4 decoder layer. | -| `convert_gemma_import.rs` module doc: "does **NOT** wire into the `mlx convert` CLI driver" | `crates/mlx-core/src/convert_gemma_import.rs:7` | Contradicted 36 lines later in the **same** doc comment (`:43`) and by the call site at `crates/mlx-core/src/convert.rs:2560`. | -| `Gemma4Recipe`'s sanitize "is the real transform (set via [`set_gemma4_sanitize`])" | `crates/mlx-core/src/convert.rs:1589` | `set_gemma4_sanitize` does not exist — the only occurrence in the file is that doc link. Broken intra-doc link; the body is inline at `:1596`. | -| "GDN `a_log` … stays f32 and is cast on-the-fly inside `compute_g`. Casting it would diverge from mlx-lm semantics." | `crates/mlx-core/src/models/qwen3_5_moe/paged_forward.rs:1066` | `set_a_log` unconditionally casts A_log to `dt_bias`'s dtype (`crates/mlx-core/src/models/qwen3_5/gated_delta_net.rs:486`) and is the only such function in the tree. mlx-node genuinely diverges from mlx-lm's `cast_predicate` here (`mlx-lm/mlx_lm/models/qwen3_5.py:386` excludes `A_log`); the divergence is a deliberate perf choice, but this comment denies it. | -| Recipe list | `docs/perf.md:457-475` | Omits `nvidia` entirely, and describes the no-recipe default as "router gates → 8-bit; everything else → 4-bit" without noting the 4 is really the per-mode default (affine=4, mxfp8=8, sym8=8) nor the affine-only-key force. | -| `docs/cli.md` convert flag table | `docs/cli.md:139-149` | Omits `--config-dir`, `-m`/`--model-type`, `--q-bits`, `--q-group-size`, `--gguf-kquant`, `-h`. | -| foreign formats "Paddle `.pdiparams`, PyTorch `.pkl`" / module header lists `.pdparams` only | `docs/cli.md:236`, `crates/mlx-core/src/utils/foreign_weights.rs:6` | Code supports `.pdiparams` (+ mandatory sibling `.json`), `.pdparams`, `.pt`, `.pkl`, `.pth`, and directory auto-detect. Neither doc is complete; the module header omits `.pdiparams`, which is a completely different (non-pickle) code path. | -| GGUF source-type support in `--help` | `packages/cli/src/commands/convert.ts:156` | Says only "BF16, F16, F32, Q4_0, Q4_1, Q8_0". Omits that Q6_K imports **without** `--gguf-kquant` when the tensor is gemma4's `token_embd.weight`, dequantized to BF16 at 16 bpw — a 2.44× size expansion over the 6.5625 bpw source that no doc mentions. | -| `--dtype` default | `convert.ts:32` (bfloat16), `crates/mlx-core/src/convert.rs:1782` (float32), `crates/mlx-core/src/utils/gguf.rs:2404` ("keep original") | All three are accurate **for their own layer** — three different defaults for one option name. `docs/cli.md:141` states none of them. | - -Asymmetries where the code is right but the *quality rationale* does not survive scrutiny: +| claim | where | reality | +| --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--q-recipe nvidia` "is supported **only** for `qwen3_5` / `qwen3_5_moe` … Other families (e.g. `gemma4`) need their own recipe." | `docs/cli.md:97-100` | Stale. `nvidia_recipe_family` accepts `gemma4` / `gemma4_unified` / `gemma4_text` (`crates/mlx-core/src/convert.rs:4952`) and `validate_nvidia_recipe_options` passes them for **dense** gemma4 (`:5027`). Only gemma4 **MoE** is rejected. The CLI's own `--help` documents it correctly (`convert.ts:112`). | +| Auto-detected families list | `docs/cli.md:229-232` | No arm exists for `qianfan-ocr` / `pp-lcnet-ori` / `uvdoc` (all need explicit `-m`), and the list **omits** `lfm2`, `lfm2_moe`, `privacy-filter`, which _are_ detected (`convert.ts:641`, `:644`). | +| `--model-type` options in `mlx convert --help` | `packages/cli/src/commands/convert.ts:37` | Omits `gemma4` and `gemma4_unified`, both accepted and auto-detected (`crates/mlx-core/src/convert.rs:1753`). | +| sym8 allowlist is "dense qwen3_5, lfm2/lfm2_moe, gemma4 — **NOT** qwen3_5_moe" | `crates/mlx-core/src/convert.rs:194-201`, `convert.ts:51` | Contradicted by the impl 970 lines below: `Qwen35Recipe::sym8_supported()` returns `true` **unconditionally** for both dense and MoE, and its own inline comment says "Both dense qwen3_5 and qwen3_5_moe dispatch sym8" (`:1170`). `ConversionOptions`'s doc (`:1801`) has it right. `-m qwen3_5_moe -q --q-mode sym8` **is** accepted today — and MoE is exactly where the 3-D `switch_mlp` experts divert to forced 8-bit affine, so the result is a sym8/affine mixture. | +| `--gguf-kquant` "keeps the source … byte size" / "the same bits at ggml's byte size" | `convert.ts:138`, `crates/mlx-core/src/utils/gguf_kquant.rs:323` | True **only** for Q6_K. Q4_K grows 144→148 B/super-block (+2.8 %), Q5_K 176→180 (+2.3 %). The same source file contradicts itself 110 lines later (`gguf_kquant.rs:432`), and `docs/cli.md:184` carries the correct +0.125 bpw table. | +| `fixup_shapes` doc: "Norm weights: GGUF stores delta from 1.0 → add +1.0" | `crates/mlx-core/src/utils/gguf.rs:1471` | The body forty lines later says the exact opposite ("GGUF stores actual trained norm weights … We do NOT apply it here"), and no `+1.0` appears in the function. The NOTE's escape hatch — "handled by persistence.rs sanitize_weights" — cannot fire for GGUF input, because qwen3_5's detector is `conv1d.weight` shape[-1] != 1 and `fixup_shapes` has already reshaped it to `[C,K,1]`. GGUF norms are used verbatim, always. | +| AWQ inline comment `// post_attention_layernorm.weight /= scales` | `crates/mlx-core/src/convert.rs:6952` | Stale — the selection three lines above probes `pre_feedforward_layernorm` first (`:6932`). Any note asserting "fold post_attention_layernorm, NOT pre_feedforward" contradicts both the code and the gemma4 decoder layer. | +| `convert_gemma_import.rs` module doc: "does **NOT** wire into the `mlx convert` CLI driver" | `crates/mlx-core/src/convert_gemma_import.rs:7` | Contradicted 36 lines later in the **same** doc comment (`:43`) and by the call site at `crates/mlx-core/src/convert.rs:2560`. | +| `Gemma4Recipe`'s sanitize "is the real transform (set via [`set_gemma4_sanitize`])" | `crates/mlx-core/src/convert.rs:1589` | `set_gemma4_sanitize` does not exist — the only occurrence in the file is that doc link. Broken intra-doc link; the body is inline at `:1596`. | +| "GDN `a_log` … stays f32 and is cast on-the-fly inside `compute_g`. Casting it would diverge from mlx-lm semantics." | `crates/mlx-core/src/models/qwen3_5_moe/paged_forward.rs:1066` | `set_a_log` unconditionally casts A_log to `dt_bias`'s dtype (`crates/mlx-core/src/models/qwen3_5/gated_delta_net.rs:486`) and is the only such function in the tree. mlx-node genuinely diverges from mlx-lm's `cast_predicate` here (`mlx-lm/mlx_lm/models/qwen3_5.py:386` excludes `A_log`); the divergence is a deliberate perf choice, but this comment denies it. | +| Recipe list | `docs/perf.md:457-475` | Omits `nvidia` entirely, and describes the no-recipe default as "router gates → 8-bit; everything else → 4-bit" without noting the 4 is really the per-mode default (affine=4, mxfp8=8, sym8=8) nor the affine-only-key force. | +| `docs/cli.md` convert flag table | `docs/cli.md:139-149` | Omits `--config-dir`, `-m`/`--model-type`, `--q-bits`, `--q-group-size`, `--gguf-kquant`, `-h`. | +| foreign formats "Paddle `.pdiparams`, PyTorch `.pkl`" / module header lists `.pdparams` only | `docs/cli.md:236`, `crates/mlx-core/src/utils/foreign_weights.rs:6` | Code supports `.pdiparams` (+ mandatory sibling `.json`), `.pdparams`, `.pt`, `.pkl`, `.pth`, and directory auto-detect. Neither doc is complete; the module header omits `.pdiparams`, which is a completely different (non-pickle) code path. | +| GGUF source-type support in `--help` | `packages/cli/src/commands/convert.ts:156` | Says only "BF16, F16, F32, Q4_0, Q4_1, Q8_0". Omits that Q6_K imports **without** `--gguf-kquant` when the tensor is gemma4's `token_embd.weight`, dequantized to BF16 at 16 bpw — a 2.44× size expansion over the 6.5625 bpw source that no doc mentions. | +| `--dtype` default | `convert.ts:32` (bfloat16), `crates/mlx-core/src/convert.rs:1782` (float32), `crates/mlx-core/src/utils/gguf.rs:2404` ("keep original") | All three are accurate **for their own layer** — three different defaults for one option name. `docs/cli.md:141` states none of them. | + +Asymmetries where the code is right but the _quality rationale_ does not survive scrutiny: - **`linear_attn.out_proj` is protected asymmetrically.** `validate_nvfp4_recipe` (`crates/mlx-core/src/convert.rs:4930`) rejects `--q-mode nvfp4 --q-recipe mixed_*` precisely diff --git a/docs/perf.md b/docs/perf.md index 680d97a35..7c88b30ee 100644 --- a/docs/perf.md +++ b/docs/perf.md @@ -10,9 +10,9 @@ oxnode scripts/benchmark-fixture.ts fetch --fixture qwen38 oxnode scripts/benchmark-fixture.ts verify --fixture qwen38 --tokenizer /path/to/prepared-model/tokenizer.json ``` -| Fixture | Measured input tokens | Recorded protocol | -| --- | --- | --- | -| [Gemma 4 QAT Q4_0](../scripts/fixtures/gemma4-oxc-review-v1.json) | 7,733 / 40,528 / 66,904 | 256 output tokens; BF16 KV in both runtimes; 512-token prefill chunks; fresh process per sample. | +| Fixture | Measured input tokens | Recorded protocol | +| ------------------------------------------------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------- | +| [Gemma 4 QAT Q4_0](../scripts/fixtures/gemma4-oxc-review-v1.json) | 7,733 / 40,528 / 66,904 | 256 output tokens; BF16 KV in both runtimes; 512-token prefill chunks; fresh process per sample. | | [Qwen3.8 UD-Q4_K_XL](../scripts/fixtures/qwen38-oxc-review-v1.json) | 6,219 / 18,754 / 32,488 | 512 output tokens; BF16 MLX / F16 llama.cpp KV; 2,048-token chunks; three requests per runtime/context process. | The default selection is `gemma4`. Downloads and existing files must match the manifest before use. `fetch` and offline `verify` need no model or native addon; only the optional tokenizer check uses the addon to compare current rendering against every pinned token ID and prompt. Qwen verification sets `preserve_thinking=true` in a temporary tokenizer copy to match its native session; the model files are untouched. Its package contains only measured cases, with the original messages and saved rendered prompts; warmup used each same context for 16 output tokens. The unused synthetic warmup in the historical preparation file is excluded. @@ -56,59 +56,59 @@ The per-generation profiler (`crates/mlx-core/src/decode_profiler.rs`) records: ### Profiling and tracing -| Var | Effect | -| -------------------------- | --------------------------------- | -| `MLX_PROFILE_DECODE=1` | Auto-enables profiling at startup | -| `MLX_NODE_LOG` | Tracing-level filter | -| `MLX_INFERENCE_TRACE_FILE` | Path for inference trace dump | -| `MLX_DEBUG_GEMMA4_DUMP` | Diagnostic dumps for Gemma4 | -| `MLX_DFLASH2_PHASE_TIME` | Per-cycle stderr timing of the DFlash2 draft-build / draft+selector / verify-build / verify phases. Forces extra evals to isolate the phases — diagnostic only, never set in production. | -| `MLX_DFLASH2_VERIFY_LAYERS` | stderr timing per 15-layer block inside the DFlash2 verify forward (forces mid-graph evals — diagnostic only). | +| Var | Effect | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MLX_PROFILE_DECODE=1` | Auto-enables profiling at startup | +| `MLX_NODE_LOG` | Tracing-level filter | +| `MLX_INFERENCE_TRACE_FILE` | Path for inference trace dump | +| `MLX_DEBUG_GEMMA4_DUMP` | Diagnostic dumps for Gemma4 | +| `MLX_DFLASH2_PHASE_TIME` | Per-cycle stderr timing of the DFlash2 draft-build / draft+selector / verify-build / verify phases. Forces extra evals to isolate the phases — diagnostic only, never set in production. | +| `MLX_DFLASH2_VERIFY_LAYERS` | stderr timing per 15-layer block inside the DFlash2 verify forward (forces mid-graph evals — diagnostic only). | ### Compile / decode control -| Var | Effect | -| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| ~~`MLX_NO_COMPILE=1`~~ | **REMOVED (dead).** The Qwen3.5 compiled C++ forward path this var gated was deleted in the chat-engine refactor (`ee88b92b`); Qwen3.5 decode is now pure-Rust eager (per-op graph build). Nothing reads this var today. | -| ~~`MLX_EVAL_ALL_CACHES=1`~~ | **REMOVED (dead).** Token-only eval is the only strategy; nothing reads this var today. | -| `MLX_WEIGHT_MATERIALIZE_CHUNK_MB` | Weight-loading chunk size | -| `MLX_GDN_KERNEL=perstep\|chunked` | Force GDN recurrence kernel (default per-step on all archs; `chunked` is A/B-only and changes generated tokens by 1–2 bf16 ULP → different greedy continuation on some long prompts) | -| `MLX_LFM2_PAGED_PREFILL_PAGED_ATTENTION` | Opt-in (default off): multi-turn LFM2 cache-hit prefill tries the graph-native paged-attention bridge (gather_kv_for_prefill_chunk) before read_kv_range, skipping the forced per-layer pool sync. Held opt-in pending a stable-checkpoint paged-vs-flat gate (fused-vs-masked-SDPA ~1-ULP divergence) | -| `MLX_LFM2_FUSED_BATCH_DECODE` | Opt-in (default off): LFM2 multi-row decode waves run one fused `[N,1]` forward instead of the default row-exact wave (per-row N=1 forwards, bit-identical to serial). Fused M=N GEMM tiles round differently than M=1 and the carried ShortConv state amplifies the ULP diff into occasional greedy near-tie flips | -| `MLX_BONSAI_HOIST_METADATA=1` | Experimental, default off: materialize rotated PQ2 projection scales/biases in FP32 once at load. Rotated projections promote their transformed inputs to FP32 (the 16-bit residual stream a normal Prism load produces), so outputs propagate FP32; adds about 0.8 GB for Bonsai 2 27B. No stable speedup is claimed. | -| `MLX_BONSAI_SHARE_HADAMARD=1` | Experimental, default off: reuse identical signed Hadamard transforms within one paged Qwen3.5 decode step. Cache entries retain input/sign owners and are cleared on return or error; flat inference and prefill do not retain entries. | -| `MLX_QMM_SPLITK_MIN_M` | Diagnostic override for the quantized-matmul vector/GEMM dispatch boundary: quantized matvecs with M ≥ this many rows take the `qmm`/split-k path instead of `qmv_wide`. A/B tuning only; unset keeps the hardware-derived heuristic. | -| `MLX_KQUANT_SMALL_M_BENCH=1` | Body-level opt-in for the `#[ignore]`d `kquant_small_m_bench` integration test: exact-shape K/IQ small-M quantized-matmul timings (MLP + lm_head shapes, M sweep). Run `MLX_KQUANT_SMALL_M_BENCH=1 cargo test -p mlx-core --test kquant_small_m_bench -- --ignored --test-threads=1 --nocapture` (serial — the two benches share the GPU). | -| `MLX_DISABLE_QUANTIZED_GATE_UP_MERGE=1` | Restore the per-projection quantized matmuls for quantized MLP gate\|up pairs (no load-time packed row-merge). Outputs are bit-identical either way; the switch exists for A/B and driver rollback. | -| `MLX_DISABLE_QATTN_KV_MERGE=1` | Restore two separate quantized matmuls for attention k\|v pairs where the merge applies (checked per forward, so it can flip without reload). Bit-identical; A/B and rollback. Calibration-keyed sites merge too — the merged projection records under both source keys. | -| `MLX_DISABLE_E51_STACKED_GDN_IN_PROJ=1` | Revert the GDN `in_proj_qkvz`/`in_proj_ba` pair to two matmuls — covers both the dense stacked-transpose path and the quantized row-merge. Bit-identical; A/B only. | -| `MLX_DFLASH2_DRAFT_QUANT=q4\|q8` | Opt-in (default off): affine-quantize the DFlash2 draft's own projections at load and run a draft-precision clone of the target `lm_head` for proposal logits (~2.4 GiB resident saved on the Qwen3.8 pack). Verify still uses the target head, so emitted tokens are unaffected; draft precision only shifts proposal acceptance. | -| `MLX_DFLASH2_CONV_ELEMENTWISE=1` | Force the elementwise pad/slice/add/mul chain for the DFlash2 grouped dynamic causal conv instead of the fused Metal dispatch. Bit-identical; A/B and rollback. | -| `MLX_DISABLE_QWEN35_WINDOW_CONV=1` | Restore the generic `conv_general` + concat-history path for Qwen3.5 GDN conv instead of the fused `window_conv` Metal dispatch (history prepend + depthwise conv + SiLU + next-history in one kernel). Same math; A/B and rollback. | -| `MLX_DISABLE_QWEN35_GDN_PREPARE=1` | Disable the fully-fused `gdn_prepare` dispatch (window conv + SiLU + q|k|v split + q/k L2-norm + decay/beta gating in one kernel, decode/verify only). Fallback is the window_conv + split + norm + gating chain. ~1 bf16 ULP differences (norm eps placement, f32 beta); A/B and rollback. | -| `MLX_DISABLE_SDPA_VERIFY_SPLIT=1` | Disable the two-chunk query split that keeps speculative verify blocks inside the fused vector SDPA kernel when `q_len × gqa > 32` (threadgroup limit). Fallback is MLX's decomposed ~15-op masked-softmax path. A/B and rollback; outputs differ by ~1 bf16 ULP (two valid reduction orderings). | -| `MLX_METAL_OP_TRACE=1\|2\|3` | Diagnostic: print one line per Metal primitive eval to stderr — `commit`/`kernel`/`synchronize` events with node counts (1), plus primitive names (2) and input→output dtypes (3). Zero cost when unset. Used to count dispatches per decode/verify cycle. | -| `MLX_BENCH_PROMPT` / `MLX_BENCH_DEPTHS` | Inputs for the ignored `qwen3_8_dflash2_bench` e2e test: decode prompt (literal, or `@file` relative to the repo root) and comma-separated draft depths to sweep. | +| Var | Effect | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| ~~`MLX_NO_COMPILE=1`~~ | **REMOVED (dead).** The Qwen3.5 compiled C++ forward path this var gated was deleted in the chat-engine refactor (`ee88b92b`); Qwen3.5 decode is now pure-Rust eager (per-op graph build). Nothing reads this var today. | +| ~~`MLX_EVAL_ALL_CACHES=1`~~ | **REMOVED (dead).** Token-only eval is the only strategy; nothing reads this var today. | +| `MLX_WEIGHT_MATERIALIZE_CHUNK_MB` | Weight-loading chunk size | +| `MLX_GDN_KERNEL=perstep\|chunked` | Force GDN recurrence kernel (default per-step on all archs; `chunked` is A/B-only and changes generated tokens by 1–2 bf16 ULP → different greedy continuation on some long prompts) | +| `MLX_LFM2_PAGED_PREFILL_PAGED_ATTENTION` | Opt-in (default off): multi-turn LFM2 cache-hit prefill tries the graph-native paged-attention bridge (gather_kv_for_prefill_chunk) before read_kv_range, skipping the forced per-layer pool sync. Held opt-in pending a stable-checkpoint paged-vs-flat gate (fused-vs-masked-SDPA ~1-ULP divergence) | +| `MLX_LFM2_FUSED_BATCH_DECODE` | Opt-in (default off): LFM2 multi-row decode waves run one fused `[N,1]` forward instead of the default row-exact wave (per-row N=1 forwards, bit-identical to serial). Fused M=N GEMM tiles round differently than M=1 and the carried ShortConv state amplifies the ULP diff into occasional greedy near-tie flips | +| `MLX_BONSAI_HOIST_METADATA=1` | Experimental, default off: materialize rotated PQ2 projection scales/biases in FP32 once at load. Rotated projections promote their transformed inputs to FP32 (the 16-bit residual stream a normal Prism load produces), so outputs propagate FP32; adds about 0.8 GB for Bonsai 2 27B. No stable speedup is claimed. | +| `MLX_BONSAI_SHARE_HADAMARD=1` | Experimental, default off: reuse identical signed Hadamard transforms within one paged Qwen3.5 decode step. Cache entries retain input/sign owners and are cleared on return or error; flat inference and prefill do not retain entries. | +| `MLX_QMM_SPLITK_MIN_M` | Diagnostic override for the quantized-matmul vector/GEMM dispatch boundary: quantized matvecs with M ≥ this many rows take the `qmm`/split-k path instead of `qmv_wide`. A/B tuning only; unset keeps the hardware-derived heuristic. | +| `MLX_KQUANT_SMALL_M_BENCH=1` | Body-level opt-in for the `#[ignore]`d `kquant_small_m_bench` integration test: exact-shape K/IQ small-M quantized-matmul timings (MLP + lm_head shapes, M sweep). Run `MLX_KQUANT_SMALL_M_BENCH=1 cargo test -p mlx-core --test kquant_small_m_bench -- --ignored --test-threads=1 --nocapture` (serial — the two benches share the GPU). | +| `MLX_DISABLE_QUANTIZED_GATE_UP_MERGE=1` | Restore the per-projection quantized matmuls for quantized MLP gate\|up pairs (no load-time packed row-merge). Outputs are bit-identical either way; the switch exists for A/B and driver rollback. | +| `MLX_DISABLE_QATTN_KV_MERGE=1` | Restore two separate quantized matmuls for attention k\|v pairs where the merge applies (checked per forward, so it can flip without reload). Bit-identical; A/B and rollback. Calibration-keyed sites merge too — the merged projection records under both source keys. | +| `MLX_DISABLE_E51_STACKED_GDN_IN_PROJ=1` | Revert the GDN `in_proj_qkvz`/`in_proj_ba` pair to two matmuls — covers both the dense stacked-transpose path and the quantized row-merge. Bit-identical; A/B only. | +| `MLX_DFLASH2_DRAFT_QUANT=q4\|q8` | Opt-in (default off): affine-quantize the DFlash2 draft's own projections at load and run a draft-precision clone of the target `lm_head` for proposal logits (~2.4 GiB resident saved on the Qwen3.8 pack). Verify still uses the target head, so emitted tokens are unaffected; draft precision only shifts proposal acceptance. | +| `MLX_DFLASH2_CONV_ELEMENTWISE=1` | Force the elementwise pad/slice/add/mul chain for the DFlash2 grouped dynamic causal conv instead of the fused Metal dispatch. Bit-identical; A/B and rollback. | +| `MLX_DISABLE_QWEN35_WINDOW_CONV=1` | Restore the generic `conv_general` + concat-history path for Qwen3.5 GDN conv instead of the fused `window_conv` Metal dispatch (history prepend + depthwise conv + SiLU + next-history in one kernel). Same math; A/B and rollback. | +| `MLX_DISABLE_QWEN35_GDN_PREPARE=1` | Disable the fully-fused `gdn_prepare` dispatch (window conv + SiLU + q | k | v split + q/k L2-norm + decay/beta gating in one kernel, decode/verify only). Fallback is the window_conv + split + norm + gating chain. ~1 bf16 ULP differences (norm eps placement, f32 beta); A/B and rollback. | +| `MLX_DISABLE_SDPA_VERIFY_SPLIT=1` | Disable the two-chunk query split that keeps speculative verify blocks inside the fused vector SDPA kernel when `q_len × gqa > 32` (threadgroup limit). Fallback is MLX's decomposed ~15-op masked-softmax path. A/B and rollback; outputs differ by ~1 bf16 ULP (two valid reduction orderings). | +| `MLX_METAL_OP_TRACE=1\|2\|3` | Diagnostic: print one line per Metal primitive eval to stderr — `commit`/`kernel`/`synchronize` events with node counts (1), plus primitive names (2) and input→output dtypes (3). Zero cost when unset. Used to count dispatches per decode/verify cycle. | +| `MLX_BENCH_PROMPT` / `MLX_BENCH_DEPTHS` | Inputs for the ignored `qwen3_8_dflash2_bench` e2e test: decode prompt (literal, or `@file` relative to the repo root) and comma-separated draft depths to sweep. | ### Paged-attention -| Var | Effect | -| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MLX_PAGED_DECODE_CACHE_CLEAR_INTERVAL` | Override decode-time `clear_cache` cadence | -| `MLX_PAGED_PREFILL_EVAL_INTERVAL` | Override prefill `eval` cadence | -| `MLX_PAGED_PREFILL_CHUNK_SIZE` | Prefill chunk size | -| `MLX_PAGED_NATIVE_KV_WRITE=0` | Disable the default-on graph-native paged K/V write for every paged family. Whole-turn decode falls back to the synchronous write (also taken automatically on a native-write error). Batched and ragged decode instead **hard-error**, so this switch turns continuous batching off for every paged family | -| `MLX_PAGED_GRAPH_DECODE_GATHER=0` | Disable the default-on graph-native paged decode gather for every paged family. Whole-turn decode falls back to the synchronous gather (also taken automatically when the graph gather cannot serve the inputs). LFM2 and Nemotron-H batched decode instead **hard-error** | -| `MLX_PAGED_GROUPED_QWEN35=0` | Disable the default-on BF16 D256/BS16 grouped decode kernel (24Q/4KV above 16K q1 / 8K q2; 16Q/2KV above 32K q1 / 16K q2) for same-binary A/B or driver rollback | -| `MLX_PAGED_GROUPED_D512=0\|force` | Disable the direct-read BF16 D512/BS16 kernel or force a supported geometry for diagnostics. Ordinary single-row Gemma AR learns its route from completed tokens; other paths retain their existing policy. Metal pipeline and shape guards always apply. | -| `MLX_PAGED_GROUPED_D512_STRIPES` | Diagnostic partition override (`4\|8\|16\|32\|64\|128\|256`). It does not enable grouped attention by itself; pair with `MLX_PAGED_GROUPED_D512=force` when testing an unmeasured geometry. Automatic Gemma AR selects partitions on the current device. | -| `MLX_GEMMA4_PAGED_DECODE_ROUTE` | Diagnostic Gemma decode route (`auto\|sdpa\|paged`). Auto uses the learned grouped choice when available; other routes keep their geometry and live-memory guards. An explicit value disables attention-route learning. | -| `MLX_GEMMA4_DECODE_TUNING=0` | Disable device-local Gemma AR calibration at process startup. By default it learns grouped-D512 partitions and early-submission depth from completed real tokens, cached per loaded model and context scale. It adds no forward passes or GPU waits. | -| `MLX_GEMMA4_DECODE_EARLY_EVAL_LAYERS` | Diagnostic override for how many leading eager decode layers are submitted early. Set before process startup; default depth is learned from completed token timings rather than a chip-name table. | -| `MLX_GEMMA4_MIXED_QMV=0` | Restore stock QMM for single-row affine Q4/group32 BF16 decode. The default Metal kernel reads original FP16/FP32 sidecars directly and derives a symmetric bias only when checkpoint metadata authorizes it. Set before loading the model. | -| `MLX_TEST_PAGED` | Test-only paged-path toggle | +| Var | Effect | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MLX_PAGED_DECODE_CACHE_CLEAR_INTERVAL` | Override decode-time `clear_cache` cadence | +| `MLX_PAGED_PREFILL_EVAL_INTERVAL` | Override prefill `eval` cadence | +| `MLX_PAGED_PREFILL_CHUNK_SIZE` | Prefill chunk size | +| `MLX_PAGED_NATIVE_KV_WRITE=0` | Disable the default-on graph-native paged K/V write for every paged family. Whole-turn decode falls back to the synchronous write (also taken automatically on a native-write error). Batched and ragged decode instead **hard-error**, so this switch turns continuous batching off for every paged family | +| `MLX_PAGED_GRAPH_DECODE_GATHER=0` | Disable the default-on graph-native paged decode gather for every paged family. Whole-turn decode falls back to the synchronous gather (also taken automatically when the graph gather cannot serve the inputs). LFM2 and Nemotron-H batched decode instead **hard-error** | +| `MLX_PAGED_GROUPED_QWEN35=0` | Disable the default-on BF16 D256/BS16 grouped decode kernel (24Q/4KV above 16K q1 / 8K q2; 16Q/2KV above 32K q1 / 16K q2) for same-binary A/B or driver rollback | +| `MLX_PAGED_GROUPED_D512=0\|force` | Disable the direct-read BF16 D512/BS16 kernel or force a supported geometry for diagnostics. Ordinary single-row Gemma AR learns its route from completed tokens; other paths retain their existing policy. Metal pipeline and shape guards always apply. | +| `MLX_PAGED_GROUPED_D512_STRIPES` | Diagnostic partition override (`4\|8\|16\|32\|64\|128\|256`). It does not enable grouped attention by itself; pair with `MLX_PAGED_GROUPED_D512=force` when testing an unmeasured geometry. Automatic Gemma AR selects partitions on the current device. | +| `MLX_GEMMA4_PAGED_DECODE_ROUTE` | Diagnostic Gemma decode route (`auto\|sdpa\|paged`). Auto uses the learned grouped choice when available; other routes keep their geometry and live-memory guards. An explicit value disables attention-route learning. | +| `MLX_GEMMA4_DECODE_TUNING=0` | Disable device-local Gemma AR calibration at process startup. By default it learns grouped-D512 partitions and early-submission depth from completed real tokens, cached per loaded model and context scale. It adds no forward passes or GPU waits. | +| `MLX_GEMMA4_DECODE_EARLY_EVAL_LAYERS` | Diagnostic override for how many leading eager decode layers are submitted early. Set before process startup; default depth is learned from completed token timings rather than a chip-name table. | +| `MLX_GEMMA4_MIXED_QMV=0` | Restore stock QMM for single-row affine Q4/group32 BF16 decode. The default Metal kernel reads original FP16/FP32 sidecars directly and derives a symmetric bias only when checkpoint metadata authorizes it. Set before loading the model. | +| `MLX_TEST_PAGED` | Test-only paged-path toggle | | `MLX_PAGED_CACHE_INITIAL_MB` | Initial pool size (u32 MiB) for the qwen3_5 / qwen3_5_moe grow-on-demand paged pools. Env wins over the `paged_cache_initial_memory_mb` config field; a set-but-unparseable value falls back to config, and unset everywhere keeps the historical fixed pool (initial == max). The pool doubles on exhaustion up to the max (`paged_cache_memory_mb` / the auto one-full-context default). The agent override manager writes `paged_cache_initial_memory_mb: 2048` into qwen3_5 clones by default | -| `MLX_PERSIST_PAGED_CACHE` | Override the per-model `persistPagedCache` config for the SSD-backed cold KV tier. Precedence: env > config alias > off. Parsed leniently (`1`/`true`/`on`/`yes` → on, `0`/`false`/`off`/`no` → off; unset/other falls through to config). Off by default at library level | +| `MLX_PERSIST_PAGED_CACHE` | Override the per-model `persistPagedCache` config for the SSD-backed cold KV tier. Precedence: env > config alias > off. Parsed leniently (`1`/`true`/`on`/`yes` → on, `0`/`false`/`off`/`no` → off; unset/other falls through to config). Off by default at library level | Gemma calibration is a bounded search over supported shapes and kernel parameters, not a guarantee of the global optimum. It excludes first-use timings and requires @@ -150,7 +150,7 @@ the user-set `mtpDepth` and needs per-session resolution. | `MLX_MTP_CHAINED_CYCLES` | M5+: ON / M1–M4: OFF | W6.5 | config-gated | Each cycle's `verify_hidden[K]` slice seeds the next cycle's first MTP draft, skipping the Step-A target forward on full-accept cycles (slice batched into the next-cycle `async_eval`) — exactly MTPLX's 1-forward-per-cycle design. **Default ON on M5+ (GPU arch gen ≥ 17), default OFF on M1–M4 (gen 13–16)**; `MLX_MTP_CHAINED_CYCLES=0`/`false`/`off` forces OFF even on M5+, `=1`/`true`/`on` forces ON even on M1–M4. **CONFIG-DEPENDENT** (see "Same-checkpoint engine head-to-head"): on M3 Max / bf16 / nvfp4 / hard prompt a controlled A/B found it neutral@depth1 / a regression@depth3 (Step-A only ~14% of cycle there + an unresolved lazy-slice acceptance regression); but on **M5 Max / affine-int4** Step-A is ~37% of the cycle and removing it wins on BOTH a naive cold A/B (**≈1.17×, 45.0→52.8 tok/s**) and the controlled harness (**+23.7pp ratio, 1.116 vs 0.879, beyond noise; acceptance also up 1.30 vs 0.91**) — the biggest structural lever toward MTPLX parity. M5+ default ON is measured net-positive (affine +16% / nvfp4 byte-identical to AR); M1–M4 stays default OFF pending the lazy-slice eval-scheduling fix. | | `MLX_MTP_BUCKETED_VERIFY` | ~~ON~~ | W6.29 | ~~opt-OUT~~ | **REMOVED (dead).** Per-bucket compiled verify graphs were deleted with the compiled-C++ MTP path (`ee88b92b`); nothing reads this var today. The measured long-decode win it documented (AR +12.0%, MTP +26.1% on qwen3.5-4b / M3 Max) is currently **not implemented** — the verify forward re-traces eagerly per cycle at whatever kv_len exists. Restoring per-kv-len compiled verify graphs is an open perf follow-up. | | `MLX_MTP_ACCEPT_GATE` | ON | — | opt-OUT | MTP acceptance gate, **depth-1-scoped, adaptive-exempt, and exact-binomial**. When ON (default), the model aggregates the first-draft acceptance counts (accepted/attempted at draft slot 0) across completed fixed depth-1 turns and disables speculation for the NEXT fixed depth-1 turn only when an exact-binomial test at the 5% level shows the aggregate is inconsistent with the `0.6` break-even — plain AR — so a weak draft head does not pay the verify cost for zero speedup. The exact test preserves a uniform false-gate bound at every sample size: a 1-cycle turn, a 2-of-4 streak from a healthy 0.756 head (~25% of turns), and 0-of-2 / 0-of-3 / 1-of-5 aggregates (~5.95% / 1.45% / 1.43% false rates) never gate; only extreme aggregates like 0-of-4 (false rate ~0.35%) do. The aggregate is bounded (halved past ~512 samples) so a long healthy phase cannot drown out a later degradation. Depth > 1 turns and `mtpAdaptiveDepth` turns (which sweep depths 1-5) are never gated and do not publish history. State is per-model; the first turn of a model load probes, after 3 consecutive gated turns the gate re-probes, and a full session reset (`reset_caches`) clears the history. Measured first-draft acceptance on real checkpoints: 1.000 (qwen3.5-4b, counting), 0.756 (qwen3.5-4b, complex task), 0.000 (qwen3.5-0.8b — MTP is a ~0.6× net loss there). Set to `0` / `false` / `off` to always run MTP when requested. | -| `MLX_NEMOTRON_MTP_DEFAULT` | OFF | — | opt-IN | **NemotronH only, and a default-policy switch rather than a perf knob.** It gates `NemotronHModel::mtp_auto_enabled()` (`models/nemotron_h/model.rs`), the native getter `ChatSession` consults before auto-setting `enableMtp` for a caller who set nothing. Unset (the default) the getter returns `false`, so the family never auto-enables MTP even though `hasMtpWeights()` is true on every shipped checkpoint; `=1` makes it return `true`, but only on a checkpoint that actually carries a head (the env read is ANDed with `mtp_active`). **The one knob here that is off the shared truthy vocabulary**: the read is `std::env::var("MLX_NEMOTRON_MTP_DEFAULT").is_ok_and(\|v\| v == "1")`, so `true`/`on`/`TRUE`do NOT enable it. Cached in a process-wide`OnceLock`at the first`mtpAutoEnabled()`call, so it is neither per-model nor changeable afterwards. It never blocks an explicit request —`enableMtp: true`or`generation_mode: "mtp"` runs MTP with the var unset. Why the default is off (and why the throughput argument for it was retracted): docs/models.md. | +| `MLX_NEMOTRON_MTP_DEFAULT` | OFF | — | opt-IN | **NemotronH only, and a default-policy switch rather than a perf knob.** It gates `NemotronHModel::mtp_auto_enabled()` (`models/nemotron_h/model.rs`), the native getter `ChatSession` consults before auto-setting `enableMtp` for a caller who set nothing. Unset (the default) the getter returns `false`, so the family never auto-enables MTP even though `hasMtpWeights()` is true on every shipped checkpoint; `=1` makes it return `true`, but only on a checkpoint that actually carries a head (the env read is ANDed with `mtp_active`). **The one knob here that is off the shared truthy vocabulary**: the read is `std::env::var("MLX_NEMOTRON_MTP_DEFAULT").is_ok_and(\|v\| v == "1")`, so `true`/`on`/`TRUE`do NOT enable it. Cached in a process-wide`OnceLock`at the first`mtpAutoEnabled()`call, so it is neither per-model nor changeable afterwards. It never blocks an explicit request —`enableMtp: true`or`generation_mode: "mtp"` runs MTP with the var unset. Why the default is off (and why the throughput argument for it was retracted): docs/models.md. | ## Committed-history MTP cache (Phase C) diff --git a/docs/research/gemma4-gguf-performance.md b/docs/research/gemma4-gguf-performance.md index beafeb0a0..186cd7cde 100644 --- a/docs/research/gemma4-gguf-performance.md +++ b/docs/research/gemma4-gguf-performance.md @@ -6,11 +6,11 @@ Essential findings from [PR #144](https://github.com/mlx-node/mlx-node/pull/144) The checkpoint has 328 Q4_0 projections and a Q6_K tied embedding/output head. Its 48 layers comprise 40 sliding-attention layers (16Q/8KV, D256, window 1,024) and eight global layers (16Q/1KV, D512). -| Cause | Implemented correction | Source | -| --- | --- | --- | +| Cause | Implemented correction | Source | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | Affine QMM promoted BF16 activations plus FP16 scale/bias arrays to FP32, rebuilding large metadata casts on each forward. Missing nested text dtype also disabled the existing load-time hoist. | Record the actual nested BF16 text dtype in the native cache; hoist prefill metadata once. Single-row Q4 decode reads original scales directly, derives a symmetric bias in registers, accumulates in FP32, and returns BF16. | [Persistence](../../crates/mlx-core/src/models/gemma4/persistence.rs), [Q4 Metal bridge](../../crates/mlx-sys/src/mlx_affine_qmv.cpp) | -| Sliding attention retained full logical history in its read view, partitioned over it, and masked old tokens after loading keys. Prefill gathered the full history too. | Trim retired whole pages from the private decode view; gather only window plus current prefill chunk. For this fixture the gather is bounded by 1,536 tokens. | [Paged cache adapter](../../crates/mlx-core/src/transformer/paged_kv_cache_adapter.rs) | -| Conservative global-attention routing and CPU submission left supported faster paths unused. | Calibrate attention partitions and early submission from completed real decode tokens on the loaded device. | [Decode tuning](../../crates/mlx-core/src/models/gemma4/decode_tuning.rs) | +| Sliding attention retained full logical history in its read view, partitioned over it, and masked old tokens after loading keys. Prefill gathered the full history too. | Trim retired whole pages from the private decode view; gather only window plus current prefill chunk. For this fixture the gather is bounded by 1,536 tokens. | [Paged cache adapter](../../crates/mlx-core/src/transformer/paged_kv_cache_adapter.rs) | +| Conservative global-attention routing and CPU submission left supported faster paths unused. | Calibrate attention partitions and early submission from completed real decode tokens on the loaded device. | [Decode tuning](../../crates/mlx-core/src/models/gemma4/decode_tuning.rs) | This was **not a whole-weight Q4 → BF16 → Q4 cycle**. Weights stayed packed; embedding lookup dequantizes selected rows, and the tied output head uses packed matmul. @@ -21,10 +21,10 @@ Qwen's result in [issue #142](https://github.com/mlx-node/mlx-node/issues/142) c Medians of three fresh-process runs per runtime and context on an M5 Max, 40 GPU cores, 128 GB, macOS 26.6.2. The baseline MLX measurements were earlier that day; llama.cpp below was freshly measured alongside optimized MLX. | Input tokens | Original MLX decode tok/s | Optimized decode tok/s MLX / llama.cpp | Prefill tok/s MLX / llama.cpp | Request seconds MLX / llama.cpp | -| ---: | ---: | ---: | ---: | ---: | -| 7,733 | 28.21 | 50.53 / 48.70 | 1,153.2 / 1,252.0 | 11.76 / 11.24 | -| 40,528 | 16.15 | 39.84 / 35.29 | 1,000.4 / 740.9 | 46.99 / 61.93 | -| 66,904 | 12.22 | 34.05 / 28.80 | 872.1 / 526.5 | 84.40 / 135.93 | +| -----------: | ------------------------: | -------------------------------------: | ----------------------------: | ------------------------------: | +| 7,733 | 28.21 | 50.53 / 48.70 | 1,153.2 / 1,252.0 | 11.76 / 11.24 | +| 40,528 | 16.15 | 39.84 / 35.29 | 1,000.4 / 740.9 | 46.99 / 61.93 | +| 66,904 | 12.22 | 34.05 / 28.80 | 872.1 / 526.5 | 84.40 / 135.93 | Decode improved 1.79–2.79× over original MLX; the shortest request remains slower than llama.cpp overall. Three repeats on one active desktop do not establish statistical confidence or performance on other devices. No competing inference/compilation or thermal warning was recorded. @@ -64,13 +64,13 @@ Projection differences stayed within one BF16 ULP (relative L2 approximately `9. ## Evidence anchors -| Artifact | Pinned identity | -| --- | --- | -| Model SHA-256 | `93567e57a8fe10b23569b9d9ec38cd005deedf71e29477c421a4b83f418a538b` | -| MLX library commit | `6d45ab90cfec5e7fe0cfe25bc635a23ac8a351bb` (unchanged submodule) | -| llama.cpp commit | `a14dba686aaafba3a2d6b5eb8820b0df5c5d2d92` (build 10610, Metal/Accelerate) | +| Artifact | Pinned identity | +| ----------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| Model SHA-256 | `93567e57a8fe10b23569b9d9ec38cd005deedf71e29477c421a4b83f418a538b` | +| MLX library commit | `6d45ab90cfec5e7fe0cfe25bc635a23ac8a351bb` (unchanged submodule) | +| llama.cpp commit | `a14dba686aaafba3a2d6b5eb8820b0df5c5d2d92` (build 10610, Metal/Accelerate) | | Measured source patch SHA-256 | `df19f7b76b8b1e02067012667bb464725bb55048f91447cb87251568ba75f6f4` (base `185bc1b0`, including new source files) | -| Measured native addon SHA-256 | `2f380276a3fa2e3dc39f3e5ff377bc213152554d34d2ba3d819db733d3adface` | +| Measured native addon SHA-256 | `2f380276a3fa2e3dc39f3e5ff377bc213152554d34d2ba3d819db733d3adface` | Archived `gemma4-optimization-2026-09-10/results.json` holds all 18 samples, source/output hashes, and plans; runners and baseline/audit evidence share the Git-history link above. Later formatting/preflight/discovery changes leave the timed inference path unchanged; the matrix was not rerun. diff --git a/docs/research/qwen35-agent-mlxfast/README.md b/docs/research/qwen35-agent-mlxfast/README.md index 84710e45a..dcd8b7b03 100644 --- a/docs/research/qwen35-agent-mlxfast/README.md +++ b/docs/research/qwen35-agent-mlxfast/README.md @@ -34,10 +34,10 @@ the canonical model existed and needed no reconversion. **2.50% paired speedup across 12 warm continuations**, with 11 wins and one regression. These measurements predate CPU transfers; the gains are separate. -| Warm continuation | Mean control | Mean optimized | Paired speedup | -| --- | ---: | ---: | ---: | -| After full prefill | 13.701 s | 13.386 s | 2.33% | -| After SSD restart | 12.968 s | 12.631 s | 2.67% | +| Warm continuation | Mean control | Mean optimized | Paired speedup | +| ------------------ | -----------: | -------------: | -------------: | +| After full prefill | 13.701 s | 13.386 s | 2.33% | +| After SSD restart | 12.968 s | 12.631 s | 2.67% | All 14 output pairs, including setup, and their MTP statistics matched. Each SSD arm restored 3,920 tokens (6.2% of input), installed one recurrent sidecar @@ -51,12 +51,12 @@ one recurrent sidecar and read **4,158,439,110 bytes per arm**. All four pairs improved; outputs, MTP cycles and acceptance statistics matched exactly. Early submission stayed enabled in both arms. -| Pair (first arm) | Native TTFT: GPU transfers | CPU transfers | Whole turn: GPU | CPU | -| --- | ---: | ---: | ---: | ---: | -| 1 (GPU) | 3.184 s | 2.814 s | 16.748 s | 16.558 s | -| 2 (CPU) | 3.281 s | 2.573 s | 16.815 s | 16.219 s | -| 3 (GPU) | 3.311 s | 2.658 s | 17.294 s | 16.451 s | -| 4 (CPU) | 3.205 s | 2.479 s | 17.843 s | 16.089 s | +| Pair (first arm) | Native TTFT: GPU transfers | CPU transfers | Whole turn: GPU | CPU | +| ---------------- | -------------------------: | ------------: | --------------: | -------: | +| 1 (GPU) | 3.184 s | 2.814 s | 16.748 s | 16.558 s | +| 2 (CPU) | 3.281 s | 2.573 s | 16.815 s | 16.219 s | +| 3 (GPU) | 3.311 s | 2.658 s | 17.294 s | 16.451 s | +| 4 (CPU) | 3.205 s | 2.479 s | 17.843 s | 16.089 s | Native time to first token averaged **3.245 → 2.631 s**: **19.0% lower paired latency**. This includes cache preparation and the uncached suffix. The first diff --git a/docs/superpowers/specs/2026-08-10-muse-glimmer-30b-design.md b/docs/superpowers/specs/2026-08-10-muse-glimmer-30b-design.md index da276b609..fad0047c2 100644 --- a/docs/superpowers/specs/2026-08-10-muse-glimmer-30b-design.md +++ b/docs/superpowers/specs/2026-08-10-muse-glimmer-30b-design.md @@ -8,12 +8,12 @@ ## 1. Scope -| In scope | Out of scope | -| -------- | ------------ | -| Text decoder, bf16, streaming chat | Video (`<\|video\|>`, `patch_temporal`, frame sampling) | -| Image path, end to end | MTP / speculative decoding (no draft heads in the checkpoint) | -| Flat KV, block-paged KV, SSD cold tier | Training / SFT / GRPO | -| `mlx convert` quantization recipe | CUDA / Linux | +| In scope | Out of scope | +| -------------------------------------- | ------------------------------------------------------------- | +| Text decoder, bf16, streaming chat | Video (`<\|video\|>`, `patch_temporal`, frame sampling) | +| Image path, end to end | MTP / speculative decoding (no draft heads in the checkpoint) | +| Flat KV, block-paged KV, SSD cold tier | Training / SFT / GRPO | +| `mlx convert` quantization recipe | CUDA / Linux | "Done" means images work end to end. Video is deliberately deferred; the reference plumbing for it is understood and recorded in §8 so a later pass is cheap. @@ -24,20 +24,20 @@ plumbing for it is understood and recorded in §8 so a later pass is cheap. hybrid text decoder. The upstream `modular_muse_glimmer.py` declares its lineage explicitly, which is the fastest way to see what mlx-node can reuse: -| Component | Derived from | mlx-node status | -| --------- | ------------ | --------------- | -| `MuseGlimmerTextConfig` | `Gemma2Config` | gemma4 config is close | -| `MuseGlimmerRMSNorm` | `Gemma4RMSNorm` | exists | -| `MuseGlimmerTextCenteredRMSNorm` | `Gemma2RMSNorm` | **no analog** (`(1+w)` centering) | -| `MuseGlimmerTextMLP` / `RotaryEmbedding` | `Gemma2` | exists | -| `MuseGlimmerTextAttention` | **`AfmoeAttention`** | **no analog** (sigmoid output gate) | -| `MuseGlimmerTextDecoderLayer` | `Gemma2DecoderLayer` | 4-norm block, gemma4-like | -| `MuseGlimmerTextNormedEmbedding` | `nn.Embedding` | **no analog** (RMS-normed embedding) | -| `MuseGlimmerVisionConfig/Attention/MLP/EncoderLayer` | `Kimi_K25Vision*` | **no analog** (windowed) | -| `MuseGlimmerVisionPatchEmbedder` | `PaddleOCRVisionEmbeddings` | paddleocr_vl exists | -| `MuseGlimmerVisionRotaryEmbedding` | `Gemma4VisionRotaryEmbedding` | exists, kernel reusable | -| `MuseGlimmerVisionAdapter` | — | new, trivial | -| image processor | `Glm4vImageProcessor` | **no analog** (token-capped smart_resize) | +| Component | Derived from | mlx-node status | +| ---------------------------------------------------- | ----------------------------- | ----------------------------------------- | +| `MuseGlimmerTextConfig` | `Gemma2Config` | gemma4 config is close | +| `MuseGlimmerRMSNorm` | `Gemma4RMSNorm` | exists | +| `MuseGlimmerTextCenteredRMSNorm` | `Gemma2RMSNorm` | **no analog** (`(1+w)` centering) | +| `MuseGlimmerTextMLP` / `RotaryEmbedding` | `Gemma2` | exists | +| `MuseGlimmerTextAttention` | **`AfmoeAttention`** | **no analog** (sigmoid output gate) | +| `MuseGlimmerTextDecoderLayer` | `Gemma2DecoderLayer` | 4-norm block, gemma4-like | +| `MuseGlimmerTextNormedEmbedding` | `nn.Embedding` | **no analog** (RMS-normed embedding) | +| `MuseGlimmerVisionConfig/Attention/MLP/EncoderLayer` | `Kimi_K25Vision*` | **no analog** (windowed) | +| `MuseGlimmerVisionPatchEmbedder` | `PaddleOCRVisionEmbeddings` | paddleocr_vl exists | +| `MuseGlimmerVisionRotaryEmbedding` | `Gemma4VisionRotaryEmbedding` | exists, kernel reusable | +| `MuseGlimmerVisionAdapter` | — | new, trivial | +| image processor | `Glm4vImageProcessor` | **no analog** (token-capped smart_resize) | ### 2.1 Text decoder — exact forward @@ -82,7 +82,7 @@ Facts that are easy to get wrong and are load-bearing: - The KV cache stores **post-norm, post-rope K** and **raw V**. - Sliding window is `[p-2047, p]` — 2048 visible keys **including self**. `RotatingKVCache::new(2048, None)` is correct; do not "compensate" with 2047. -- Because the global layers are NoPE, their KV is **position independent** *as arithmetic*. +- Because the global layers are NoPE, their KV is **position independent** _as arithmetic_. This is **not actionable in the cache**: block hashes are chained over the prefix, so a cached block stays bound to the offset it was computed at regardless of layer kind. See the NoPE row of the hybrid-KV rules in §8. @@ -136,16 +136,16 @@ generation prompt = "<|start|>assistant" # bare; model emits " to=…<|me Non-reserved special tokens — exactly 15: -| token | id | | token | id | -| --- | --- | --- | --- | --- | -| `<\|begin_of_text\|>` | 200000 | | `<\|image_start\|>` | 200080 | -| `<\|end_of_text\|>` | 200001 (stop) | | `<\|image_end\|>` | 200081 | -| `<\|eom\|>` | 200007 (**not** a stop) | | `<\|vid_start\|>` | 200082 | -| `<\|eot\|>` | 200008 (stop) | | `<\|vid_end\|>` | 200083 | -| `<\|finetune_right_pad\|>` | 200018 | | `<\|vid_frame_separator\|>` | 200087 | -| `<\|start\|>` | 200022 | | `<\|image\|>` | 200090 (**decoy, unused**) | -| `<\|message\|>` | 200023 | | `<\|video\|>` | 200091 | -| | | | `<\|patch\|>` | 200092 | +| token | id | | token | id | +| -------------------------- | ----------------------- | --- | --------------------------- | -------------------------- | +| `<\|begin_of_text\|>` | 200000 | | `<\|image_start\|>` | 200080 | +| `<\|end_of_text\|>` | 200001 (stop) | | `<\|image_end\|>` | 200081 | +| `<\|eom\|>` | 200007 (**not** a stop) | | `<\|vid_start\|>` | 200082 | +| `<\|eot\|>` | 200008 (stop) | | `<\|vid_end\|>` | 200083 | +| `<\|finetune_right_pad\|>` | 200018 | | `<\|vid_frame_separator\|>` | 200087 | +| `<\|start\|>` | 200022 | | `<\|image\|>` | 200090 (**decoy, unused**) | +| `<\|message\|>` | 200023 | | `<\|video\|>` | 200091 | +| | | | `<\|patch\|>` | 200092 | This is **not** harmony. There are no channel tokens (`<|channel|>`, `<|constrain|>`, `<|return|>` are absent from all 2048 added tokens), and identical-looking names carry @@ -185,20 +185,20 @@ resemblance that is only skin deep. The genuinely reusable parts New, under `crates/mlx-core/src/models/muse_glimmer/`: -| File | Contents | -| ---- | -------- | -| `config.rs` | `MuseGlimmerConfig`, text/vision sub-configs, layer-kind + NoPE tables, fail-closed asserts (both configs, including the vision tower's divisors) | -| `kv_cache.rs` | the batching seam: per-layer specs, the two-group contract, sliding pool reservations, and the **window-carrying contract** every paged dispatch must clear (§8) | -| `model.rs` | load, forward (flat + paged), `logits_tail()` | -| `attention.rs` | qk-norm x 3.87, optional RoPE, sigmoid output gate | -| `decoder_layer.rs` | 4-norm block, dual epsilon | -| `mlp.rs` | SwiGLU | -| `layer_cache.rs` | per-layer kind -> `RotatingKVCache` / paged / flat | -| `vision.rs`, `vision_embedder.rs`, `vision_window.rs` | tower, patch embed + zero-pad resample, window index | -| `image_processor.rs` | token-capped smart_resize, LANCZOS, `(t,c,ph,pw)` flatten | -| `output_parser.rs` | `response_template`-driven ATEM parser; `terminators` = the text fields' closes UNIONED with `tokenizer_config.json`'s `eos_token` | -| `stream_guard.rs` | per-turn streaming safety: channel routing, byte-exact header grammar, provenance-gated scan (`authority_spans`), `TurnEnd` | -| `persistence.rs`, `sliding_sidecar.rs` | quant load, cold-tier sidecar | +| File | Contents | +| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `config.rs` | `MuseGlimmerConfig`, text/vision sub-configs, layer-kind + NoPE tables, fail-closed asserts (both configs, including the vision tower's divisors) | +| `kv_cache.rs` | the batching seam: per-layer specs, the two-group contract, sliding pool reservations, and the **window-carrying contract** every paged dispatch must clear (§8) | +| `model.rs` | load, forward (flat + paged), `logits_tail()` | +| `attention.rs` | qk-norm x 3.87, optional RoPE, sigmoid output gate | +| `decoder_layer.rs` | 4-norm block, dual epsilon | +| `mlp.rs` | SwiGLU | +| `layer_cache.rs` | per-layer kind -> `RotatingKVCache` / paged / flat | +| `vision.rs`, `vision_embedder.rs`, `vision_window.rs` | tower, patch embed + zero-pad resample, window index | +| `image_processor.rs` | token-capped smart_resize, LANCZOS, `(t,c,ph,pw)` flatten | +| `output_parser.rs` | `response_template`-driven ATEM parser; `terminators` = the text fields' closes UNIONED with `tokenizer_config.json`'s `eos_token` | +| `stream_guard.rs` | per-turn streaming safety: channel routing, byte-exact header grammar, provenance-gated scan (`authority_spans`), `TurnEnd` | +| `persistence.rs`, `sliding_sidecar.rs` | quant load, cold-tier sidecar | Reused as-is: `vision::{VisionRotaryEmbedding, encoder}`, `RotatingKVCache`, `PagedKVCacheAdapter`, `BlockAllocator`, `LayerKVPool`, `quant_dispatch`, @@ -222,13 +222,13 @@ Ranked by probability x silence. Every one produces fluent-but-wrong output, not over all 209 norm tensors of the real checkpoint (safetensors header parse + bf16 decode of the norm tensors only): - | class | min | max | mean | - | ----- | --: | --: | ---: | - | `input_layernorm` | -1.00000 | 3.79688 | 0.30957 | - | `post_attention_layernorm` | -0.88672 | 3.34375 | 0.03046 | - | `pre_feedforward_layernorm` | -1.00000 | 2.85938 | 0.05941 | + | class | min | max | mean | + | ---------------------------- | -------: | ------: | ------: | + | `input_layernorm` | -1.00000 | 3.79688 | 0.30957 | + | `post_attention_layernorm` | -0.88672 | 3.34375 | 0.03046 | + | `pre_feedforward_layernorm` | -1.00000 | 2.85938 | 0.05941 | | `post_feedforward_layernorm` | -0.80859 | 6.75000 | 0.31077 | - | final `norm.weight` | -4.93750 | 4.37500 | 0.01688 | + | final `norm.weight` | -4.93750 | 4.37500 | 0.01688 | Both plausible statistical guards are refuted by these numbers. "Centered norms have min exactly `-1.0`" holds for only 2 of the 4 classes. "Centered weights cluster near 0, @@ -245,6 +245,7 @@ Ranked by probability x silence. Every one produces fluent-but-wrong output, not Related: do not copy `Qwen35Recipe`'s norm-shift list — its `"model.norm.weight"` suffix matches `model.language_model.norm.weight`, shifting the one norm that must not move, while omitting the two `*_feedforward_layernorm` entries that must. + 2. **`output_multiplier` x softcap ordering.** Fixed order: `*1/sqrt(26)`, `/20`, `tanh`, `*20`. A missed multiplier is invisible under greedy decoding (argmax is invariant) but makes temperature, top-p and logprobs all wrong by ~5x. Route every site through one @@ -254,8 +255,8 @@ Ranked by probability x silence. Every one produces fluent-but-wrong output, not the whole scale is ~44x off; treating it as gemma's `query_pre_attn_scalar` (used as `scalar**-0.5`) is ~7.6x off. Store it as a struct field, never a literal duplicated across SDPA sites. -4. **NoPE read as identity rotation.** `layer_rope_theta[i] == 0` means *no rotation - applied*, not `theta = 1`. Polarity is inverted vs gemma4, which gives global layers +4. **NoPE read as identity rotation.** `layer_rope_theta[i] == 0` means _no rotation + applied_, not `theta = 1`. Polarity is inverted vs gemma4, which gives global layers the long theta. 5. **Vision patch flatten order.** `(t,c,ph,pw)`, not `(c,t,h,w)`. Element count matches so nothing errors. Harmless for images (the two temporal halves are identical), @@ -272,10 +273,10 @@ Ranked by probability x silence. Every one produces fluent-but-wrong output, not 10. **Stop-token set — and it is NOT wired through the tokenizer.** Three sources disagree and only `generation_config.json` is right: it lists `[200001, 200008]`, while `config.json text_config.eos_token_id` is `200001` alone and `tokenizer_config.json - eos_token` is `<|end_of_text|>`. Nothing on the decode path reads +eos_token` is `<|end_of_text|>`. Nothing on the decode path reads `resolve_special_tokens` / `tokenizer.get_eos_token_id()`; the stop site is `engine/decode.rs`'s `stops_at_eos = token_id == eos_id || - extra_eos_ids.contains(&token_id)`, resolved once per turn from two `ChatBackend` +extra_eos_ids.contains(&token_id)`, resolved once per turn from two `ChatBackend` hooks. M1 contract: `session_eos_id` (`backend.rs:675`) returns `<|eot|>` 200008; `extra_eos_ids` (`backend.rs:947`) returns `gen_defaults.eos_token_ids`, populated at load by `parse_generation_defaults` (`persistence.rs:947`). Both overrides are @@ -301,7 +302,7 @@ Ranked by probability x silence. Every one produces fluent-but-wrong output, not cannot consume a trailing `if`, and the argument loop then meets the identifier `if` where it demands `,` or `)`. Minimal repro: `{% set n = namespace(name=a if a else '') %}` → `syntax error: unexpected identifier, - expected ','`. The checkpoint's tool-name fallback is exactly that shape, at byte 5737. +expected ','`. The checkpoint's tool-name fallback is exactly that shape, at byte 5737. Python Jinja2 3.1.6 accepts both spellings, so the checkpoint is well-formed and miniJinja is stricter. There is no syntax option, 2.23.0 is the newest release, the dependency is plain crates.io with no `[patch]`. The restriction is general to all call @@ -320,7 +321,7 @@ Ranked by probability x silence. Every one produces fluent-but-wrong output, not AgentWorld, agents-a1 and LFM2.5-1.2B have no `.get(` at all; LFM2.5-2.6B/8B use it only for truthiness, `==` and kind-tests; qianfan / PaddleOCR-VL / Harrier ship no cached template. No template anywhere applies `is defined` / `is undefined` to a - `.get()` result. gemma4 argues *for* the fix: our serializer never emits a top-level + `.get()` result. gemma4 argues _for_ the fix: our serializer never emits a top-level `name`, so its `follow.get('name')` misses on every tool round trip and survival rides on the `tc.id == tcid` conjunct — post-fix miniJinja is 1:1 with HF on all five id-presence cases, converting quiet prompt corruption into a loud failure. Ship the @@ -344,7 +345,7 @@ Blocking / build-breaking: loop, not turn 1**, because the tool-definitions block uses no `.items()`. Any test matrix must include a full tool round trip. - `packages/.../__test__/models/model-loader-registry.test.ts:8-19` — `as const satisfies - Record`; a new `ModelType` fails `yarn typecheck` / +Record`; a new `ModelType` fails `yarn typecheck` / `build:ts` / CI lint until the key is added. **The whole TypeScript surface lands in M1, in one commit, and cannot land earlier.** @@ -395,7 +396,7 @@ Robustness, from the reference template's own failure modes: - `<|eom|>` is not a stop, so the model can emit `<|start|>user<|message|>…` and self-play a conversation. Needs a role guard plus per-turn message/token caps. - Validate `to=` and `` against `{self, user} ∪ exact tool names for this - turn`; the advertised `# Valid recipients:` globs are prose, not the executable set. +turn`; the advertised `# Valid recipients:` globs are prose, not the executable set. - Stream emitter needs a `>= 24`-char hold-back (longest literals are 22 chars) or `<|message|>`, which must be fully buffered before routing or the @@ -444,8 +445,7 @@ status table in §8. Gemma4 is now the reference implementation to copy, not a b spec used to propose, whose prescribed `block_size 4` was **impossible**: the Metal kernel instantiates only block_size 8 / 16 / 32, and the C++ validator checks only `block_size > 0`, so a block_size-4 dispatch fails at kernel LOOKUP and an implementer would read the null return as -their own bug. The landed test uses a 2x-scaled geometry (block 8, window 16, 48 tokens, prefix -32) preserving every ratio. +their own bug. The landed test uses a 2x-scaled geometry (block 8, window 16, 48 tokens, prefix 32) preserving every ratio. Tier 2 — real weights, local `#[ignore]`: @@ -472,14 +472,14 @@ green under an injected `+1` on the final norm is not a gate. ## 8. Milestones -| # | Deliverable | Gate | -| - | ----------- | ---- | -| M0 | tokenizer, prompt render, `response_template` parser, `.items()` fix | golden strings incl. full tool round trip; no GPU | -| M1 | text bf16, flat KV, streaming chat | logit parity vs HF | -| M2 | image path end to end | image parity; both wrapper tokens present; N+2 accounting | -| M3 | paged adapter | byte-equal vs flat, **then** A/B before defaulting on | -| M4 | cold tier: sidecar + both allowlists + drift guard | restore parity, process-restart test | -| M5 | fixed Unsloth `mlx convert` recipe (**converter landed; runtime quality gate follows M1**) | artifact structure and dequantization checks now; quantized vs bf16 inference after M1 | +| # | Deliverable | Gate | +| --- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- | +| M0 | tokenizer, prompt render, `response_template` parser, `.items()` fix | golden strings incl. full tool round trip; no GPU | +| M1 | text bf16, flat KV, streaming chat | logit parity vs HF | +| M2 | image path end to end | image parity; both wrapper tokens present; N+2 accounting | +| M3 | paged adapter | byte-equal vs flat, **then** A/B before defaulting on | +| M4 | cold tier: sidecar + both allowlists + drift guard | restore parity, process-restart test | +| M5 | fixed Unsloth `mlx convert` recipe (**converter landed; runtime quality gate follows M1**) | artifact structure and dequantization checks now; quantized vs bf16 inference after M1 | M0 first is deliberate: it needs no GPU, it unblocks every later test's prompt path, and it is where the one hard error lives. It found two more (traps 13 and 14). @@ -496,7 +496,7 @@ be settled inside M0. module-private, so a sibling (`stream_guard`, or M1's `model.rs`) cannot name it at all. That privacy IS the boundary, and it is stronger than the `pub(super)` version an earlier round shipped. Do not re-implement span building in M1. The finding that produced it: `<|eot|>` rendered from - real token 200008 and `<|eot|>` written as seven literal characters are *the same bytes* + real token 200008 and `<|eot|>` written as seven literal characters are _the same bytes_ once a `&str` reaches the parser, so a message terminator quoted inside an answer let the anchored tool call after it execute — `rm {path:"/"}` out of explanatory prose. Every signal the parser could add is itself made of those bytes, so the fix is a signature, not @@ -571,14 +571,14 @@ be settled inside M0. sees `rm`, which may be legitimately declared, and cannot know the message was addressed to `userX`. Equality is protocol fidelity rather than a restriction — `chat_template.jinja` builds `'<|start|>assistant to=' + tc.function.name` and `render_atem`'s invoke tag from - the *same string*, one call per message. The spec's `repeats: true` permits repeated + the _same string_, one call per message. The spec's `repeats: true` permits repeated matches and repeated messages; it does not license a different invoke name under one recipient. Keep registry validation at dispatch as defence in depth, not as the control. - **The guard takes decoded tokens, not a count and not a chunk.** Inferring the count from decoded character length was wrong, not merely imprecise: **70 decoded tokens exceed the old 64-character assumption** (id 169871 at 113 characters, id 162250 at 112), and the guard truncated a legitimate 560-character answer to 448. Note the measurement trap — - 74 *raw BPE lexemes* exceed 64 characters but only 70 *decoded* tokens do, and counting + 74 _raw BPE lexemes_ exceed 64 characters but only 70 _decoded_ tokens do, and counting vocabulary keys instead of decoded output is what produced the wrong figure first time. Trusting a caller-supplied number is not enforcement either: a slice with one entry per token is, which is why `push(&[&str])` and `push_token(&str)` replaced it. @@ -633,20 +633,20 @@ All three are `pub(crate)` — in-crate only, no NAPI surface of their own. **Eleven** methods, one const and four associated types have no default body. (A count of "12 methods" folds in the const; there are 11 `fn`s.) -| Required member | line | Why it constrains the M1 forward pass | -| --------------- | ---: | ------------------------------------- | -| `type Command` / `RestoreTicket` / `OwnerState` / `StepExecutor<'a>` | 82-88 | `OwnerState: Default` means per-owner state must be constructible empty — no "must have run a turn first" invariant | -| `const SCHEDULER_NAME` | 89 | Interpolated into every scheduler error message; make it `"Muse-Glimmer"` so the `supports_delta` refusal below is attributable | -| `paged_adapter` / `paged_adapter_mut` | 94/95 | Return `Option<&PagedKVCacheAdapter>` — **one** adapter. A hybrid returns its *full* group's adapter here and coordinates the rest itself, exactly as gemma4 does | -| `max_position_embeddings` | 178 | Scheduler clamps every turn's output budget against it (`hybrid_scheduler.rs:1437-1440`, `min`'d with `scheduler_per_seq_context()`). Return `text_config.max_position_embeddings` — the field is parsed and validated as of `c8287d3b` (`MuseGlimmerTextConfig`, `config.rs:142`); note the trait returns `i32` while the config holds `usize`, and the scheduler's `unwrap_or(1).max(1)` means a bad conversion degrades to a 1-token context rather than erroring | -| `activate_paged_seq` | 194 | Rows are per-`SeqId`; the forward pass may not assume one live request | -| `run_paged_decode_step_batched` | 199 | The single hardest requirement — see the shape rule below | -| `replace_cached_token_history` | 200 | The scheduler swaps whole token histories between rows; a forward pass that caches a `Vec` privately must expose the swap | -| `owner_tokens` | 201 | Static fn on `OwnerState`, no `&self` — owner history may not live inside the model | -| `capture_owner_state` | 206 | **Not** preemption. Its only call site in the crate is turn completion with the owner keeping its cache: `finish_completed` (`hybrid_scheduler.rs:2124`) calls it at `:2187-2191`, guarded by `outcome.is_ok() && turn.payload.reuse_cache`. `grep` returns exactly two hits, that call and the trait decl. Preemption (`:1960-1975`) never touches it — it calls `preempt_scheduled_cache`, and the state comes BACK via `install_owner_state` (`:1404/:1641/:2040/:2142`). So the row is **not** being evicted: capture what the next turn on this owner must see, and do not skip state the live turn still needs. It runs on the "mlx-model" thread right after a completed turn, so keep it cheap — but "no GPU" is a latency preference at that site, not a hard constraint the way it would be during eviction | -| `build_scheduled_prefix` | 218 | Constructs a `PrefixState` for an arbitrary `(cached_prefix_len, suffix_len, first_chunk)` triple. This is where a hybrid's *sliding* re-prefill boundary has to be expressible, not just the full group's | -| `step_executor` | 281 | | -| `execute_barrier` | 282 | Family-specific commands (media, convert, save) stay ordered barriers | +| Required member | line | Why it constrains the M1 forward pass | +| -------------------------------------------------------------------- | ----: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `type Command` / `RestoreTicket` / `OwnerState` / `StepExecutor<'a>` | 82-88 | `OwnerState: Default` means per-owner state must be constructible empty — no "must have run a turn first" invariant | +| `const SCHEDULER_NAME` | 89 | Interpolated into every scheduler error message; make it `"Muse-Glimmer"` so the `supports_delta` refusal below is attributable | +| `paged_adapter` / `paged_adapter_mut` | 94/95 | Return `Option<&PagedKVCacheAdapter>` — **one** adapter. A hybrid returns its _full_ group's adapter here and coordinates the rest itself, exactly as gemma4 does | +| `max_position_embeddings` | 178 | Scheduler clamps every turn's output budget against it (`hybrid_scheduler.rs:1437-1440`, `min`'d with `scheduler_per_seq_context()`). Return `text_config.max_position_embeddings` — the field is parsed and validated as of `c8287d3b` (`MuseGlimmerTextConfig`, `config.rs:142`); note the trait returns `i32` while the config holds `usize`, and the scheduler's `unwrap_or(1).max(1)` means a bad conversion degrades to a 1-token context rather than erroring | +| `activate_paged_seq` | 194 | Rows are per-`SeqId`; the forward pass may not assume one live request | +| `run_paged_decode_step_batched` | 199 | The single hardest requirement — see the shape rule below | +| `replace_cached_token_history` | 200 | The scheduler swaps whole token histories between rows; a forward pass that caches a `Vec` privately must expose the swap | +| `owner_tokens` | 201 | Static fn on `OwnerState`, no `&self` — owner history may not live inside the model | +| `capture_owner_state` | 206 | **Not** preemption. Its only call site in the crate is turn completion with the owner keeping its cache: `finish_completed` (`hybrid_scheduler.rs:2124`) calls it at `:2187-2191`, guarded by `outcome.is_ok() && turn.payload.reuse_cache`. `grep` returns exactly two hits, that call and the trait decl. Preemption (`:1960-1975`) never touches it — it calls `preempt_scheduled_cache`, and the state comes BACK via `install_owner_state` (`:1404/:1641/:2040/:2142`). So the row is **not** being evicted: capture what the next turn on this owner must see, and do not skip state the live turn still needs. It runs on the "mlx-model" thread right after a completed turn, so keep it cheap — but "no GPU" is a latency preference at that site, not a hard constraint the way it would be during eviction | +| `build_scheduled_prefix` | 218 | Constructs a `PrefixState` for an arbitrary `(cached_prefix_len, suffix_len, first_chunk)` triple. This is where a hybrid's _sliding_ re-prefill boundary has to be expressible, not just the full group's | +| `step_executor` | 281 | | +| `execute_barrier` | 282 | Family-specific commands (media, convert, save) stay ordered barriers | `run_paged_decode_step_batched` **must return `[N, 1, vocab]` with `N == rows.len()` and row order preserved.** The shape is not in the signature, and **nothing downstream enforces it.** @@ -667,8 +667,8 @@ catch it for you. precisely why gate (d) — asserting the fused epilogue actually engaged — is the only non-vacuous assertion available here. - `hybrid_scheduler.rs:958` — `logits.slice_axis(0, index, index+1)` indexes by **row - position**. A permuted or short batch dimension does not error; it silently hands row *i*'s - logits to row *j*. + position**. A permuted or short batch dimension does not error; it silently hands row _i_'s + logits to row _j_. Seven recurrent-state hooks (`hybrid_scheduler.rs:179-198`) are defaulted no-ops. Muse-Glimmer is pure-KV (no GDN, no conv state), so it omits all seven; `recurrent_state_bytes() == 0` @@ -677,7 +677,7 @@ copied by anything with a recurrent lane. #### The one hard error: `supports_delta` must be `true` -Everything else in this contract fails *closed by running serially*. This one hard-errors. +Everything else in this contract fails _closed by running serially_. This one hard-errors. ``` execution_plan() -> PagedAttentionPlan { supports_delta: false } @@ -707,7 +707,7 @@ Verified against gemma4's constructor (`gemma4/model.rs:2262-2292`): a fresh `BlockAllocator` and `LayerKVPool` per group, then `new` vs `new_sliding`. **The two spans use the same Metal kernel**; the entire difference at dispatch is one integer, the kernel's `sliding_window: i32`. That is why the trap in the next-but-one subsection is a dropped argument -rather than a missing code path — and why the *sign* of that integer is load-bearing too: the +rather than a missing code path — and why the _sign_ of that integer is load-bearing too: the window is range-checked into an `i32` at admission rather than cast there, because a `u32 as i32` in that slot is how a 3-billion-token config window became `-1294967296`. See the `sliding_window` validation subsection. @@ -723,10 +723,10 @@ one `head_dim 128`, one `num_key_value_heads 2`, no global overrides, no `k_eq_v at runtime and do not assume the reverse ordering. This is safe to hard-code **because `compute_layer_kv_cache_groups` fails closed when it cannot hold**: a single-kind `layer_types` table parses cleanly (the config's NoPE↔Full biconditional only fires when the - two tables *disagree*, and a uniform table agrees with itself) and would collapse grouping to + two tables _disagree_, and a uniform table agrees with itself) and would collapse grouping to ONE group. All-sliding is the silent direction — `groups[0]`, whose adapter is returned from `paged_adapter()` and publishes into the content-addressed prefix cache, would be the - *sliding* group, and a sliding block's contents depend on where the window was when it was + _sliding_ group, and a sliding block's contents depend on where the window was when it was written. `muse_glimmer/kv_cache.rs` now refuses any grouping without at least one group of each kind, naming the observed counts. It deliberately does **not** enforce the `[S,S,S,F] × 13` pattern: a future hybrid ratio is still a hybrid. @@ -740,7 +740,7 @@ one `head_dim 128`, one `num_key_value_heads 2`, no global overrides, no `k_eq_v are unreachable. Do not port gemma4's alias branch (`gemma4/model.rs:8135-8143`). **Deliberate divergence from vLLM, recorded so nobody "fixes" it.** vLLM's grouper -(`vllm/v1/core/kv_cache_utils.py:1106-1211`) buckets on the *entire* frozen spec dataclass, +(`vllm/v1/core/kv_cache_utils.py:1106-1211`) buckets on the _entire_ frozen spec dataclass, then merges, then **splits every bucket to an equal layer count**: for 39+13 it emits **4 groups of 13** (3 sliding + 1 full, zero padding since 39 % 13 == 0). It needs that because all its groups draw block IDs from **one shared free list** with a single uniform page size @@ -753,10 +753,10 @@ while geometry stays uniform. #### The three inputs the seam needs -| Input | gemma4's source | Muse-Glimmer | -| ----- | --------------- | ------------ | -| `block_size` | `config.paged_block_size.unwrap_or(16)` (`gemma4/model.rs:2076`), a config knob | **caller argument.** This family's config is Rust-internal with no NAPI surface by design (`config.rs`), so the knob has to come from the paged-adapter construction site, not the checkpoint | -| `cache_dtype` | `KVCacheDType::BFloat16` (`gemma4/model.rs:2085-2091`) | same — checkpoint dtype is `bfloat16`; keep it a caller argument | +| Input | gemma4's source | Muse-Glimmer | +| --------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `block_size` | `config.paged_block_size.unwrap_or(16)` (`gemma4/model.rs:2076`), a config knob | **caller argument.** This family's config is Rust-internal with no NAPI surface by design (`config.rs`), so the knob has to come from the paged-adapter construction site, not the checkpoint | +| `cache_dtype` | `KVCacheDType::BFloat16` (`gemma4/model.rs:2085-2091`) | same — checkpoint dtype is `bfloat16`; keep it a caller argument | | `max_model_len` | `u32::try_from(config.max_position_embeddings)` (`gemma4/model.rs:8158`), a **required** config field | `text_config.max_position_embeddings` (`MuseGlimmerTextConfig`, `config.rs:142`), **landed in `c8287d3b`** — required, no `#[serde(default)]`, validated non-zero and u32-fitting in `from_json_str`'s `max_position_embeddings` arm (`config.rs:451-464`). Consumed by `compute_layer_kv_cache_groups`, which re-checks both halves (`kv_cache.rs:199-212`). **Cite by symbol, not line:** this family's `config.rs` and `kv_cache.rs` are still churning, and every line pair above was already stale once — `config.rs:346-358` now lands on the `num_hidden_layers == 0` guard, which looks like a plausible validation and has no u32 clause | **Landed in `c8287d3b`; the reasoning below is kept as the rationale for why it must not be @@ -796,31 +796,31 @@ natural bound and was the one originally shipped. But both things that consume t fits the `u32` spec payload, used to fit `PagedWindowSlot`, and still arrived at a dispatch **negative**. Measured end to end on this seam before the fix, with `"sliding_window": 3000000000`: -| stage | before | after | -| ----- | ------ | ----- | -| serde -> `usize` | `3000000000` | same | -| `from_json_str` guard | `Ok` (`u32::try_from`) | **`Err`**, naming the field and `i32::MAX` | -| `compute_layer_kv_cache_specs` guard | `Ok`, 39 sliding specs | **`Err`** | -| `sliding_window_max_admission_blocks` | `Ok(8193)` — caps, see below | unreachable | -| `PagedWindowSlot::kernel_slot()` | `Some(-1294967296)` | **`Err`** at admission | -| `PagedWindowSlot::mask_window()` | `Some(-1294967296)` | **`Err`** at admission | +| stage | before | after | +| ------------------------------------- | ---------------------------- | ------------------------------------------ | +| serde -> `usize` | `3000000000` | same | +| `from_json_str` guard | `Ok` (`u32::try_from`) | **`Err`**, naming the field and `i32::MAX` | +| `compute_layer_kv_cache_specs` guard | `Ok`, 39 sliding specs | **`Err`** | +| `sliding_window_max_admission_blocks` | `Ok(8193)` — caps, see below | unreachable | +| `PagedWindowSlot::kernel_slot()` | `Some(-1294967296)` | **`Err`** at admission | +| `PagedWindowSlot::mask_window()` | `Some(-1294967296)` | **`Err`** at admission | The two routes then diverged, and only one of them was loud — which is why the fix is described honestly as two different things: - **`KernelArgument` already failed CLOSED.** The C++ validator rejects a negative window - (`mlx_paged_ops.cpp:483`, *"must be >= 0 (use 0 to disable the sliding mask)"*), the extern-C + (`mlx_paged_ops.cpp:483`, _"must be >= 0 (use 0 to disable the sliding mask)"_), the extern-C entry point catches it and returns `nullptr`, and `check_handle` turns that into an `Err`. For this route the new refusal is a **diagnostics** improvement only: same outcome, named at the config field instead of six frames into the FFI. Do not oversell it as a security fix. - **`ExplicitMask` failed OPEN and SILENT, and that is the half worth closing.** `create_causal_mask` keeps a cell only when `linds >= rinds && linds < rinds + window_size`, so a negative width keeps **nothing**. Measured on real MLX: 0 of 96 cells kept, versus 68 for the - causal baseline. An all-`false` mask fed to the *fused* `scaled_dot_product_attention` does + causal baseline. An all-`false` mask fed to the _fused_ `scaled_dot_product_attention` does **not** produce NaN and does not error — every query row comes back byte-identical to `mean(v, axis=keys)`, i.e. the uniform mean of every key including future positions and never-written null-block pages, `max|delta| 1.2485` against the windowed reference. (The naive - unfused `where(mask, s, -inf)` + softmax path *does* give all-NaN; only the fused kernel is + unfused `where(mask, s, -inf)` + softmax path _does_ give all-NaN; only the fused kernel is silent, and the fused kernel is the one in use.) That is the same failure shape as the gemma4 dropped window this whole seam exists to prevent. @@ -829,8 +829,8 @@ honestly as two different things: structural rather than remembered — the same argument that makes the fields private. **Do not tighten the bound further, and specifically not to `max_model_len`.** A window larger -than the context is legal and must stay expressible: gemma4 decides *"this window cannot bite, -keep the unmasked causal fast path"* by comparing the window against the context +than the context is legal and must stay expressible: gemma4 decides _"this window cannot bite, +keep the unmasked causal fast path"_ by comparing the window against the context (`cache_hit_dense_window_arg`), which requires representing it first. Note also that `max_position_embeddings`' guard stays `u32::try_from` — that field's carrier really is a `u32` (`max_model_len`), so narrowing it to `i32` would be a wrong bound copied from a neighbour. @@ -870,25 +870,25 @@ is not block-aligned, so it straddles one extra block, and `prune_sliding_window only whole blocks below `cutoff / block_size`. Without the `+1` the blocks actually held exceed the reservation, which is a mid-prefill OOM or an admission deadlock. vLLM's `ChunkedLocalAttentionSpec` has the same formula with **no** `+1` precisely because chunk -boundaries *are* block-aligned — that contrast is the proof of what the `+1` buys. +boundaries _are_ block-aligned — that contrast is the proof of what the `+1` buys. Concrete, at `block_size 16`, `max_model_len 131072`, `window 2048`: -| group | blocks/request | tokens covered | KV bytes (bf16, 2 kv heads x 128) | -| ----- | -------------: | -------------: | --------------------------------: | -| full (13 layers) | 8192 | 131072 | 1.745 GB | -| sliding (39 layers), `max_chunk` 512 | **161** | 2576 | 102.9 MB | -| sliding, `max_chunk` 1024 | 193 | 3088 | 123.3 MB | -| sliding, `max_chunk` 2048 | 257 | 4112 | 164.2 MB | -| dense 52-layer equivalent | 8192 | 131072 | 6.979 GB | +| group | blocks/request | tokens covered | KV bytes (bf16, 2 kv heads x 128) | +| ------------------------------------ | -------------: | -------------: | --------------------------------: | +| full (13 layers) | 8192 | 131072 | 1.745 GB | +| sliding (39 layers), `max_chunk` 512 | **161** | 2576 | 102.9 MB | +| sliding, `max_chunk` 1024 | 193 | 3088 | 123.3 MB | +| sliding, `max_chunk` 2048 | 257 | 4112 | 164.2 MB | +| dense 52-layer equivalent | 8192 | 131072 | 6.979 GB | A 50.9x block reduction on 39 of 52 layers is the whole prize, and it **scales with `max_chunk`**: raising the prefill chunk under-provisions a pool sized for the old chunk. The pool sizer and the runtime admission gate must therefore call **one** function — vLLM keeps them on one source of truth for exactly this reason. The comment that names both failure modes is `single_type_kv_cache_manager.py:178-186`, inside `get_num_blocks_to_allocate`'s admission -cap: *"Drift between the two would re-introduce the deadlock from issue #39734 or, worse, -mid-prefill OOM."* (`grep -niE "deadlock|oom"` over that file and `kv_cache_interface.py` at +cap: _"Drift between the two would re-introduce the deadlock from issue #39734 or, worse, +mid-prefill OOM."_ (`grep -niE "deadlock|oom"` over that file and `kv_cache_interface.py` at `b369f10d5c` returns exactly those two lines.) The lookup that wires the single source is `:1860-1875`, where `get_manager_for_kv_cache_spec` passes `kv_cache_spec.max_admission_blocks_per_request(...)` — the same spec method the startup sizer @@ -913,10 +913,10 @@ Two more sizing facts: - Mirror gemma4's reserved-blocks rule verbatim rather than re-deriving it: a sliding group **widens** to `max_admission_blocks.max(scheduler_width) + 1`, a full group stays at `max_admission_blocks` (`gemma4/model.rs:8169-8180`). Mirror the **reason** too, and note it - is a *different* `+1` from the one in the admission formula above. **That** `+1` is the + is a _different_ `+1` from the one in the admission formula above. **That** `+1` is the straddled window block and is already spent inside `max_admission_blocks` (161 = `div_ceil(2047 + 512, 16) + 1`; vLLM's `max_memory_usage_bytes` is `max_blocks * - page_size_bytes` with nothing added on top). The reservation's `+1` is the group's +page_size_bytes` with nothing added on top). The reservation's `+1` is the group's **null-block sentinel** — gemma4's `null_block_bytes` term, one block per sliding group (`gemma4/model.rs:2148-2157`), which is why `required_bytes_for_width(1)` equals `minimum_pool_bytes` exactly (`:2182-2196`) and why gemma4's own test calls it "plus its null @@ -930,32 +930,32 @@ Two more sizing facts: #### Hybrid KV rules, each with the reason and where it is enforced today -| Rule | Reason | Enforced at | -| ---- | ------ | ----------- | -| **Never narrow a sliding block-table row.** The admission cap belongs to allocation accounting only | The row index **is** `absolute_position / block_size`. Blocks are recycled; rows are append-only. Narrowing the row breaks the index identity, which RoPE positions and the sliding mask both depend on | vLLM's `SlidingWindowSpec` deliberately has no `max_num_blocks_per_req` override (`kv_cache_interface.py:242`); our `prune_sliding_window_for` replaces in place | -| **Substitute a null-block sentinel; never compact.** | Compacting shifts every later row index. Absolute positions are load-bearing twice over — for RoPE on the 39 sliding layers and for the kernel's own window mask | `paged_kv_cache_adapter.rs:3662-3672` — `table.replace_block(index, null_block)` | -| **Retirement cutoff uses floor, not `div_ceil`.** | `first_live_block = (num_tokens - window) / block_size`. `div_ceil` would retire the block that still holds live in-window tokens | `paged_kv_cache_adapter.rs:3656-3657` (integer division) | -| **Flush pending pool writes before reclaiming.** | A freed block can be handed straight back out; an unflushed write then lands in someone else's block | `eval_pending_pool_writes()` at `paged_kv_cache_adapter.rs:3644`, before the free loop | -| **The window comes from the LAYERS.** A group with mixed kinds must yield **no** window | A window applied to a full layer silently discards everything older than it — fluent, wrong, no error | Structural for us: grouping keys on `attention_kind`, so a mixed group cannot exist. vLLM has to enforce it in code — `get_kv_cache_spec_sliding_window` returns `None` for a `FullAttentionSpec` *even when that spec carries a non-`None` `sliding_window` field* (`kv_cache_interface.py:970`) | -| **Never publish sliding blocks into the content-addressed prefix cache.** | A sliding block's contents depend on where the window was when it was written, not only on the token prefix that hashes to it | `gemma4/model.rs:490-494`: `if is_full { finalize_turn_keep_live_per_block(..) } else { finalize_turn_keep_live_no_prefix() }`; cold capture uses `full_adapter_mut()` only (`:499-510`) | -| **A hybrid prefix hit is joint, or it is refused.** Never a full-group-only hit | The groups must resume at one boundary or the sliding layers' KV describes a different prefix than the full layers' | `gemma4/model.rs:4884-4887` hard-errors when the sliding primed boundary differs from the full one; `:409` refuses group disagreement on the live continuation boundary | -| **NoPE is invisible to scheduling and to cache reuse.** Do not try to exploit position independence | Block hashes are **chained over the prefix**, so a cached block is bound to the offset it was computed at regardless of layer kind. Confirmed by exhaustion in vLLM: a word-boundary grep for rope/nope/rotary/theta across all of `vllm/v1/core/` and `kv_cache_interface.py` returns only two MLA byte-layout comments | — (this supersedes §2.1's "unusually friendly to cross-request prefix reuse", which is true of the *arithmetic* and **not** actionable in the cache) | -| **Keep `layer_rope_theta == 0` and `sliding_window == 0` in separate namespaces.** | They are different sentinels. `layer_rope_theta == 0` means NoPE. `sliding_window == 0` is read by our own Metal kernel and C++ validator as *"disable the sliding mask"*, i.e. **full causal attention** | `mlx_paged_ops.cpp:482` — "use 0 to disable the sliding mask"; `config.rs` refuses a zero window since `3c0c6859` | -| **"Full" and "NoPE" stay two independent per-layer facts.** | The coupling is Muse-Glimmer-specific, not structural. Cohere2-MoE has full layers that **keep** RoPE (`cohere2_moe.py:213` sets `force_rope`); Olmo3 selects different `rope_parameters` per `attn_type`. And NoPE does not mean position-independent: Llama 4 applies `attn_temperature_tuning` **only** on its NoPE layers, as a function of positions (`llama4.py:207`) | `config.rs` already keeps `layer_kinds` and `layer_rope_theta` as separate tables and asserts the coupling bidirectionally. **Keep the assertion, keep it labelled as this family's fact, and do not lift it into the seam** | +| Rule | Reason | Enforced at | +| --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Never narrow a sliding block-table row.** The admission cap belongs to allocation accounting only | The row index **is** `absolute_position / block_size`. Blocks are recycled; rows are append-only. Narrowing the row breaks the index identity, which RoPE positions and the sliding mask both depend on | vLLM's `SlidingWindowSpec` deliberately has no `max_num_blocks_per_req` override (`kv_cache_interface.py:242`); our `prune_sliding_window_for` replaces in place | +| **Substitute a null-block sentinel; never compact.** | Compacting shifts every later row index. Absolute positions are load-bearing twice over — for RoPE on the 39 sliding layers and for the kernel's own window mask | `paged_kv_cache_adapter.rs:3662-3672` — `table.replace_block(index, null_block)` | +| **Retirement cutoff uses floor, not `div_ceil`.** | `first_live_block = (num_tokens - window) / block_size`. `div_ceil` would retire the block that still holds live in-window tokens | `paged_kv_cache_adapter.rs:3656-3657` (integer division) | +| **Flush pending pool writes before reclaiming.** | A freed block can be handed straight back out; an unflushed write then lands in someone else's block | `eval_pending_pool_writes()` at `paged_kv_cache_adapter.rs:3644`, before the free loop | +| **The window comes from the LAYERS.** A group with mixed kinds must yield **no** window | A window applied to a full layer silently discards everything older than it — fluent, wrong, no error | Structural for us: grouping keys on `attention_kind`, so a mixed group cannot exist. vLLM has to enforce it in code — `get_kv_cache_spec_sliding_window` returns `None` for a `FullAttentionSpec` _even when that spec carries a non-`None` `sliding_window` field_ (`kv_cache_interface.py:970`) | +| **Never publish sliding blocks into the content-addressed prefix cache.** | A sliding block's contents depend on where the window was when it was written, not only on the token prefix that hashes to it | `gemma4/model.rs:490-494`: `if is_full { finalize_turn_keep_live_per_block(..) } else { finalize_turn_keep_live_no_prefix() }`; cold capture uses `full_adapter_mut()` only (`:499-510`) | +| **A hybrid prefix hit is joint, or it is refused.** Never a full-group-only hit | The groups must resume at one boundary or the sliding layers' KV describes a different prefix than the full layers' | `gemma4/model.rs:4884-4887` hard-errors when the sliding primed boundary differs from the full one; `:409` refuses group disagreement on the live continuation boundary | +| **NoPE is invisible to scheduling and to cache reuse.** Do not try to exploit position independence | Block hashes are **chained over the prefix**, so a cached block is bound to the offset it was computed at regardless of layer kind. Confirmed by exhaustion in vLLM: a word-boundary grep for rope/nope/rotary/theta across all of `vllm/v1/core/` and `kv_cache_interface.py` returns only two MLA byte-layout comments | — (this supersedes §2.1's "unusually friendly to cross-request prefix reuse", which is true of the _arithmetic_ and **not** actionable in the cache) | +| **Keep `layer_rope_theta == 0` and `sliding_window == 0` in separate namespaces.** | They are different sentinels. `layer_rope_theta == 0` means NoPE. `sliding_window == 0` is read by our own Metal kernel and C++ validator as _"disable the sliding mask"_, i.e. **full causal attention** | `mlx_paged_ops.cpp:482` — "use 0 to disable the sliding mask"; `config.rs` refuses a zero window since `3c0c6859` | +| **"Full" and "NoPE" stay two independent per-layer facts.** | The coupling is Muse-Glimmer-specific, not structural. Cohere2-MoE has full layers that **keep** RoPE (`cohere2_moe.py:213` sets `force_rope`); Olmo3 selects different `rope_parameters` per `attn_type`. And NoPE does not mean position-independent: Llama 4 applies `attn_temperature_tuning` **only** on its NoPE layers, as a function of positions (`llama4.py:207`) | `config.rs` already keeps `layer_kinds` and `layer_rope_theta` as separate tables and asserts the coupling bidirectionally. **Keep the assertion, keep it labelled as this family's fact, and do not lift it into the seam** | One correction to carry, from re-reading vLLM's current tree at `b369f10d5c`: "a divergent per-group hit reconciles to **zero**" is too strong as a general statement. vLLM reconciles to the greatest **common** boundary — a fixed-point loop drives `hit_length` down until every -group accepts it — and separately refuses a full-group-only *deeper* hit +group accepts it — and separately refuses a full-group-only _deeper_ hit (`kv_cache_coordinator.py:747`) rather than discarding the whole hit. Zero is only the degenerate case. Our stance is stricter still and simpler: gemma4 takes one joint boundary and **errors** on disagreement, and Muse-Glimmer should copy that, because "min" needs a -reconciliation loop we do not have. Record it as *joint or refuse*, and know that the strict +reconciliation loop we do not have. Record it as _joint or refuse_, and know that the strict version is our choice, not an inherited necessity. Also on the record: vLLM imposes **no** limit on the number of full-attention groups (`HybridKVCacheCoordinator` asserts only a lower bound of 2 groups), so gemma4's ">1 full -group" refusal is our own shortcut. It is accidentally protective — with two *distinct* full +group" refusal is our own shortcut. It is accidentally protective — with two _distinct_ full specs, `find_longest_cache_hit` truncates blocks for `attention_groups[0]` only (`kv_cache_coordinator.py:818`), so a second full group can return blocks past a reconciled hit of 0. Muse-Glimmer's uniform geometry means one full group, so this cannot bite us; if the @@ -972,14 +972,14 @@ hedge. diagnosis, kept because M1 needs to know what the shape of the bug was. The tree no longer behaves this way: -| Commit | What it landed | -| ------ | -------------- | -| `179e826d` | The regression test: a sliding window must survive a cache-hit prefill chunk | -| `eb5713e3` | The fix: the window travels through every cache-hit prefill route | -| `1904b138` | The "sliding adapter x attention numerics" cell, occupied | -| `7a898db4` | One dense cache-hit implementation, made testable | -| `f28e99f4` | The window is taken from the gather that produced the K/V, and a mask is asked for only when the window can bite | -| `c0faba4a` | The adapter refuses a windowed group on a window-blind dense read, for every model. Weaker than its first description here: `read_kv_range` refuses by WIDTH, not outright — see the table below | +| Commit | What it landed | +| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `179e826d` | The regression test: a sliding window must survive a cache-hit prefill chunk | +| `eb5713e3` | The fix: the window travels through every cache-hit prefill route | +| `1904b138` | The "sliding adapter x attention numerics" cell, occupied | +| `7a898db4` | One dense cache-hit implementation, made testable | +| `f28e99f4` | The window is taken from the gather that produced the K/V, and a mask is asked for only when the window can bite | +| `c0faba4a` | The adapter refuses a windowed group on a window-blind dense read, for every model. Weaker than its first description here: `read_kv_range` refuses by WIDTH, not outright — see the table below | | this round | The window is bounded at `i32::MAX` at config load, at spec derivation and at dispatch admission, and `PagedWindowSlot` stores the `i32` rather than casting a `u32`. Loud for the kernel route (already fail-closed in the C++ validator, so: diagnostics), load-bearing for the mask route (a negative width was an all-`false` mask, answered silently with the uniform mean of every key) | **Copy this shape, do not re-derive it.** `CacheHitPrefillPlan.sliding_window` is a @@ -993,20 +993,20 @@ parameter; and `gather_kv_for_dense_cache_hit_prefill` / **That last clause was overstated and is corrected here**, because M1 is meant to lean on it. The two window-blind readers refuse with different strengths, verified by reproduction: -| reader | refuses | does NOT refuse | -| ------ | ------- | --------------- | -| `gather_kv_for_prefill_sdpa` | **any** sliding group, unconditionally (`if self.sliding_window != 0 { Err }`) | — | -| `read_kv_range` | a read **wider than the window** (`num_tokens > sliding_window`) | a read whose length is `<= window` no matter how old it is; `start_pos` is not consulted | +| reader | refuses | does NOT refuse | +| ---------------------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | +| `gather_kv_for_prefill_sdpa` | **any** sliding group, unconditionally (`if self.sliding_window != 0 { Err }`) | — | +| `read_kv_range` | a read **wider than the window** (`num_tokens > sliding_window`) | a read whose length is `<= window` no matter how old it is; `start_pos` is not consulted | So the honest statement is: **M1 cannot obtain placeholder-laden dense K/V for a route whose context exceeds the window** — which is the only regime where being window-blind is wrong, since -below it nothing is out of range and nothing has been retired. It is *not* true that +below it nothing is out of range and nothing has been retired. It is _not_ true that `read_kv_range` refuses a sliding group outright. Measured on a window-16 / block-8 adapter with 48 recorded tokens: after `prune_sliding_window_for` replaced table indices 0..3 with the reserved null block, `read_kv_range(0, 0, 16)` returned **`Ok`** with all-zero K/V where `1001..1016` had been written — i.e. the never-written null block, whose contents are formally **undefined** -(`layer_kv_pool.rs:499`: `StorageModePrivate`, *"not zeroed … returns whatever the driver handed -out"*). Today's all-zero readback is a driver accident, not a guarantee, in either direction. +(`layer_kv_pool.rs:499`: `StorageModePrivate`, _"not zeroed … returns whatever the driver handed +out"_). Today's all-zero readback is a driver accident, not a guarantee, in either direction. No caller bleeds that data today (`new_sliding` has one production callsite; the only sliding `read_kv_range` caller, gemma4's cold-tier capture, declines out-of-window ranges itself), so this @@ -1045,28 +1045,28 @@ which is the worst outcome available. Measured impact, two independent measurements: -| Measurement | Result | -| ----------- | ------ | -| Kernel A/B, one process, one physical pool, sliding adapter window 1024, 4096 tokens written in 512-token chunks with prune after each, target chunk `[3584,4096)` | max abs delta vs the windowed reference: **0.124512** with window slot `0`, **0.000977** (1 bf16 ULP) with window slot `1024`. `rms(correct output) = 0.035562`, so the error is **3.5x the signal's own RMS** and 128x the correct route's residual — from one argument | -| Real gemma-4-12b-it greedy continuation, flat path as ground truth, temperature 0. **Two runs, not interchangeable** | `MLX_GEMMA4_PAGED_PREFILL_ROUTE=sdpa` (the route `Auto` selects) over 9 lengths: **6 of 9** mismatch — matches at 1205 / 1307 / **2771**, mismatches at 1374 / 1424 / 1475 / 1540 / 2066 / 4008. Last match 1307, **first mismatch 1374**, untested between. Route unset (`Auto`) over 10 lengths: **4 of 10** mismatch, first mismatch **1475**; `Auto` was never run at 1374, so its own bisect interval is 1205-1475. Divergences are fluent and coherent (first differing char at ~token 13), i.e. a silently different model, which is why it went unnoticed for so long. Note 2771 matching on **every** route while the defect was live: divergence is not monotonic in prompt length, so a single-length parity gate can be green for the wrong reason | -| Null-block regime | varlen's output matches "full causal over the whole 4096-token context with the pruned range treated as zero K/V" to **0.000244**, below bf16 ULP — so the window-0 kernel verifiably dereferences 2560 never-written slots per row. Those slots read back `max\|K\| = max\|V\| = 0` on this machine's driver. **Do not rely on that in either direction**: `layer_kv_pool.rs:499` says the pool is `StorageModePrivate` and **not zeroed**, and the probe used `Q = 0`, so it bounds the V bytes and says nothing about a logit blow-up from garbage K under real non-zero Q | +| Measurement | Result | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Kernel A/B, one process, one physical pool, sliding adapter window 1024, 4096 tokens written in 512-token chunks with prune after each, target chunk `[3584,4096)` | max abs delta vs the windowed reference: **0.124512** with window slot `0`, **0.000977** (1 bf16 ULP) with window slot `1024`. `rms(correct output) = 0.035562`, so the error is **3.5x the signal's own RMS** and 128x the correct route's residual — from one argument | +| Real gemma-4-12b-it greedy continuation, flat path as ground truth, temperature 0. **Two runs, not interchangeable** | `MLX_GEMMA4_PAGED_PREFILL_ROUTE=sdpa` (the route `Auto` selects) over 9 lengths: **6 of 9** mismatch — matches at 1205 / 1307 / **2771**, mismatches at 1374 / 1424 / 1475 / 1540 / 2066 / 4008. Last match 1307, **first mismatch 1374**, untested between. Route unset (`Auto`) over 10 lengths: **4 of 10** mismatch, first mismatch **1475**; `Auto` was never run at 1374, so its own bisect interval is 1205-1475. Divergences are fluent and coherent (first differing char at ~token 13), i.e. a silently different model, which is why it went unnoticed for so long. Note 2771 matching on **every** route while the defect was live: divergence is not monotonic in prompt length, so a single-length parity gate can be green for the wrong reason | +| Null-block regime | varlen's output matches "full causal over the whole 4096-token context with the pruned range treated as zero K/V" to **0.000244**, below bf16 ULP — so the window-0 kernel verifiably dereferences 2560 never-written slots per row. Those slots read back `max\|K\| = max\|V\| = 0` on this machine's driver. **Do not rely on that in either direction**: `layer_kv_pool.rs:499` says the pool is `StorageModePrivate` and **not zeroed**, and the probe used `Q = 0`, so it bounds the V bytes and says nothing about a logit blow-up from garbage K under real non-zero Q | **Two damage regimes, with corrected thresholds.** The earlier ~514 / ~1026 figures came from gemma4's serde default `sliding_window() -> 512`, which **never applies**: every gemma-4 `config.json` on disk sets the key explicitly (12b-it / 26b-a4b / 31b-it = 1024; e2b = 512). -| Regime | Onset (gemma-4-12b-it, window 1024, chunk 512) | Onset (Muse-Glimmer, window 2048, chunk 512) | -| ------ | --- | --- | -| **Over-attention**: sliding layers attend real out-of-window tokens. First body chunk with `cached_prefix > 0` and `total_ctx > window` | ~1026 prompt tokens (structural). First greedy-token flip **measured** on the `sdpa` route between 1307 (match) and **1374** (mismatch); on `Auto` between 1205 and **1475**. The interval is what was measured — no length between the two was tested | ~2050 prompt tokens | -| **Undefined read**: the block table holds the reserved null block, and the kernel dereferences K/V slots that were never written. Needs a chunk end >= `window + block_size`, and chunk ends are multiples of 512 | ~1538 prompt tokens | ~2562 prompt tokens | +| Regime | Onset (gemma-4-12b-it, window 1024, chunk 512) | Onset (Muse-Glimmer, window 2048, chunk 512) | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------- | +| **Over-attention**: sliding layers attend real out-of-window tokens. First body chunk with `cached_prefix > 0` and `total_ctx > window` | ~1026 prompt tokens (structural). First greedy-token flip **measured** on the `sdpa` route between 1307 (match) and **1374** (mismatch); on `Auto` between 1205 and **1475**. The interval is what was measured — no length between the two was tested | ~2050 prompt tokens | +| **Undefined read**: the block table holds the reserved null block, and the kernel dereferences K/V slots that were never written. Needs a chunk end >= `window + block_size`, and chunk ends are multiples of 512 | ~1538 prompt tokens | ~2562 prompt tokens | Muse-Glimmer's exposure is strictly worse in shape even though its onset is later: **39 of 52 layers (75%)** are sliding versus gemma4's 40 of 48, and the wider window means each affected row over-attends a larger absolute span. -**The fix shape — corrected.** An earlier revision said *"a windowed adapter reaching a path +**The fix shape — corrected.** An earlier revision said _"a windowed adapter reaching a path that cannot carry its window is an `Err`, not a silent `0`. Fail closed … three of the four -sub-paths have no window concept at all."* Both halves are wrong, and the second is dangerous: +sub-paths have no window concept at all."_ Both halves are wrong, and the second is dangerous: - **All four sub-paths CAN express the window**, so there is nothing to refuse. The varlen kernel is already per-query-row bottom-right aligned @@ -1074,13 +1074,13 @@ sub-paths have no window concept at all."* Both halves are wrong, and the second `sliding_lower` derives from it), so one `i32` fixes a whole multi-token chunk with no per-row work by the caller. And `create_causal_mask(seq_len, offset, window_size)` (`array/mask.rs:33-74`) **already has the window parameter** — `linds >= rinds && linds < - rinds + window`, bool `true = keep` — and the host-read call site was passing `None` into it. +rinds + window`, bool `true = keep` — and the host-read call site was passing `None` into it. "No window concept at all" was wrong for two of the three: the concept, the argument and the builder all existed. Refusing instead of threading would turn every gemma4 prompt over ~1026 tokens into an error. - **An `Err` raised inside a gather does NOT fail closed.** Each gather failure on that path is downgraded to `tracing::warn!` and chains to the next route, terminating at an - *unconditional* host-read. So a refusal below `forward_paged_cache_hit_prefill` is swallowed + _unconditional_ host-read. So a refusal below `forward_paged_cache_hit_prefill` is swallowed and re-routed to another window-blind path — the opposite of fail-closed. Any refusal has to be raised **at or above** that function. @@ -1121,7 +1121,7 @@ Three things must not move, each because moving it silently undoes the fix: **`MLX_GEMMA4_PAGED_PREFILL_ROUTE=legacy` is not a mitigation.** It is the one route that passed the window, so it reads like a workaround. Measured: it diverges from flat at 2066 -prompt tokens, 3/3 independent processes, identical wrong text, and produces the *same* wrong +prompt tokens, 3/3 independent processes, identical wrong text, and produces the _same_ wrong continuation as `Auto`/`sdpa` there — it has its own defect at short-final-chunk geometry (a 17-token chunk at `cached_prefix 2048`). Diagnostic reference only. @@ -1130,12 +1130,12 @@ cannot inherit the bug today — `new_sliding` / `SlidingPaged` appear nowhere u `models/muse_glimmer/`. It would inherit it by construction the moment M1 wires a sliding adapter. The contract therefore lands in the KV-cache seam first, in `muse_glimmer/kv_cache.rs`: -| Item | What it is | -| ---- | ---------- | -| `WindowCarrier` | How a route can express a window: `KernelArgument` (the kernel's `sliding_window: i32`), `ExplicitMask` (`create_causal_mask`'s `window_size`), `Unsupported`. Supplied by the dispatch site, because capability is a fact the route knows and the group does not. A fifth route means a new variant, which is a **compile error** at the admitting match rather than a silently window-blind path | -| `PagedWindowSlot` | The window argument for one admitted dispatch. Private fields, no `Default`, no public constructor — the only way to get one derives the window from the group's own `AttentionKind`, so **there is no parameter for a literal `0` to be written into**. Its two accessors are carrier-scoped and both return `Option`: `kernel_slot()` answers only on kernel routes, `mask_window()` only on mask routes, and each returns `None` — never a plausible `0` — when asked the other route's question. The window is stored as the **`i32`** both consumers take, range-checked once at admission, so **no cast survives inside the type**: storing the spec's `u32` and casting in the accessors is what let a config window in `[2^31, 2^32-1]` present itself as a NEGATIVE window (`3000000000` -> `-1294967296`) | -| `paged_window_for_kind` / `paged_window_for_group` | Admit one dispatch, or `Err`. Refuses **three** things: a windowed group on an `Unsupported` route; a `SlidingWindow { sliding_window: 0 }` payload, a third time (config load and spec derivation being the first two) because by the time a 0 reaches a kernel slot it is indistinguishable from "disable the mask"; and a window **past `i32::MAX`**, because both consumers take an `i32` and the mask route turns a negative width into an all-`false` mask that the fused SDPA answers with the uniform mean of every key — no NaN, no error. The group-level form names the **39 layers** in its error; "group 1" understates the blast radius | -| `admit_paged_dispatch_plan` | The per-turn choke point. Admits every group against the routes that turn selected, before any launches. `carriers` is indexed by `group_id`, so a plan that forgets a group is an arity error and a permuted group list is refused — pairing the full group's carrier with the sliding group is the same defect with a different cause | +| Item | What it is | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `WindowCarrier` | How a route can express a window: `KernelArgument` (the kernel's `sliding_window: i32`), `ExplicitMask` (`create_causal_mask`'s `window_size`), `Unsupported`. Supplied by the dispatch site, because capability is a fact the route knows and the group does not. A fifth route means a new variant, which is a **compile error** at the admitting match rather than a silently window-blind path | +| `PagedWindowSlot` | The window argument for one admitted dispatch. Private fields, no `Default`, no public constructor — the only way to get one derives the window from the group's own `AttentionKind`, so **there is no parameter for a literal `0` to be written into**. Its two accessors are carrier-scoped and both return `Option`: `kernel_slot()` answers only on kernel routes, `mask_window()` only on mask routes, and each returns `None` — never a plausible `0` — when asked the other route's question. The window is stored as the **`i32`** both consumers take, range-checked once at admission, so **no cast survives inside the type**: storing the spec's `u32` and casting in the accessors is what let a config window in `[2^31, 2^32-1]` present itself as a NEGATIVE window (`3000000000` -> `-1294967296`) | +| `paged_window_for_kind` / `paged_window_for_group` | Admit one dispatch, or `Err`. Refuses **three** things: a windowed group on an `Unsupported` route; a `SlidingWindow { sliding_window: 0 }` payload, a third time (config load and spec derivation being the first two) because by the time a 0 reaches a kernel slot it is indistinguishable from "disable the mask"; and a window **past `i32::MAX`**, because both consumers take an `i32` and the mask route turns a negative width into an all-`false` mask that the fused SDPA answers with the uniform mean of every key — no NaN, no error. The group-level form names the **39 layers** in its error; "group 1" understates the blast radius | +| `admit_paged_dispatch_plan` | The per-turn choke point. Admits every group against the routes that turn selected, before any launches. `carriers` is indexed by `group_id`, so a plan that forgets a group is an arity error and a permuted group list is refused — pairing the full group's carrier with the sliding group is the same defect with a different cause | A full-attention group is admitted on **every** route, including `Unsupported`, and asks for no mask. That is deliberate over-strictness avoidance: refusing it would refuse a legal global @@ -1176,10 +1176,10 @@ Three things it does **not** do, named here because overstating it is the failur to prevent, and because an earlier revision of this paragraph said "appears in any `models/muse_glimmer/*.rs`", which was literally false for a subdirectory: -| Hole | Covered by | -| ---- | ---------- | -| **Textual.** A bare `use super::kv_cache::PagedWindowSlot;` beside a hand-written FFI call satisfies it | Nothing. Naming the seam is the floor | -| **Per-file, not per-call.** One admitted dispatch covers for a second hand-written one in the same file | Nothing. This is the "only M1 can enforce" part | +| Hole | Covered by | +| --------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| **Textual.** A bare `use super::kv_cache::PagedWindowSlot;` beside a hand-written FFI call satisfies it | Nothing. Naming the seam is the floor | +| **Per-file, not per-call.** One admitted dispatch covers for a second hand-written one in the same file | Nothing. This is the "only M1 can enforce" part | | **This family's directory only.** Wiring in `transformer/block.rs` or a new generic paged forward trips nothing | The adapter refusal (`c0faba4a`) — a shared route cannot serve a windowed group window-blind, whoever calls it | It does not prove every dispatch is admitted; it does make "wire a sliding adapter and forget the @@ -1230,10 +1230,10 @@ deleted: - `paged_attention.metal`'s "zero contribution to softmax / V reduction" for a masked position. The **softmax** half is enforced (`stored_logit = -INFINITY`, and `exp(-INF - qk_max)` is exactly `0.0f`). The **V** half is not: both non-striped V loops zero lanes only past the - *causal* cutoff, never below `sliding_lower`; only the grouped striped kernel checks both. So + _causal_ cutoff, never below `sliding_lower`; only the grouped striped kernel checks both. So `0.0f * NaN = NaN` would poison a whole head from one stray byte, and gemma4 sliding **decode** runs the generic kernel, not the guarded striped one. Measured benign today (`max|K| = max|V| - = 0`) — which is driver luck, not a contract. **Status: STILL OPEN**, tracked as the P2 += 0`) — which is driver luck, not a contract. **Status: STILL OPEN**, tracked as the P2 hardening below; the fixes above stop the mask from being dropped but do not make the kernel's V half honour `sliding_lower`. - `gemma4/decoder_layer.rs`'s "`mask` is normally None for global layers … which `forward_paged` @@ -1255,16 +1255,15 @@ is what makes the `null_block` doc's guarantee survive the next bug. Both need a rebuild (`PATH=/usr/bin:$PATH SDKROOT=$(xcrun --show-sdk-path) yarn build:native`, never plain `cargo build`). - #### Opt-in wiring: three lines, and two ways to be silently serial The trait is opt-in. Three easily-forgotten lines, all verified against gemma4: -| # | Line | Where gemma4 has it | -| - | ---- | ------------------- | -| 1 | `pub(crate) type MuseGlimmerSchedulerState = HybridSchedulerState;` | `gemma4/scheduler.rs:62` | -| 2 | `MuseGlimmerSchedulerState::new(inner)?` at model-thread spawn | `gemma4/persistence.rs:2899` | -| 3 | `\|state, receiver\| state.drive(receiver)` as the loop body | `gemma4/persistence.rs:2912` | +| # | Line | Where gemma4 has it | +| --- | ------------------------------------------------------------------------------------- | ---------------------------- | +| 1 | `pub(crate) type MuseGlimmerSchedulerState = HybridSchedulerState;` | `gemma4/scheduler.rs:62` | +| 2 | `MuseGlimmerSchedulerState::new(inner)?` at model-thread spawn | `gemma4/persistence.rs:2899` | +| 3 | `\|state, receiver\| state.drive(receiver)` as the loop body | `gemma4/persistence.rs:2912` | And there is **no compile-time link between the native trait and the TS server**. The three `#[napi]` methods — `has_block_paged_cache`, `max_concurrent_sequences`, `scheduler_stats` @@ -1281,12 +1280,12 @@ For a **hybrid** the gate is a TypeScript E2E, not a Rust integration test. gemm `__test__/models/gemma4-concurrency-e2e.test.ts`, `describe.skip` unless its model-path env var is present: -| # | Assertion | Line | -| - | --------- | ---: | -| a | `maxConcurrentSequences() >= 2` (and `hasBlockPagedCache() === true`) | 98-99 | -| b | `rawText` identical, serial replay vs two concurrent starts **and** two concurrent continuations | 112-121 | -| c | `maxBatchOccupancy >= 2` **and** some histogram bucket with `occupancy >= 2 && steps > 0` | 124-125 | -| d | `fusedGreedyEpilogueSteps > 0` | 126 | +| # | Assertion | Line | +| --- | ------------------------------------------------------------------------------------------------ | ------: | +| a | `maxConcurrentSequences() >= 2` (and `hasBlockPagedCache() === true`) | 98-99 | +| b | `rawText` identical, serial replay vs two concurrent starts **and** two concurrent continuations | 112-121 | +| c | `maxBatchOccupancy >= 2` **and** some histogram bucket with `occupancy >= 2 && steps > 0` | 124-125 | +| d | `fusedGreedyEpilogueSteps > 0` | 126 | **(d) is the only non-vacuity check.** (a)-(c) all pass under a scalar-loop fake: a backend that reports capacity, runs rows one at a time and returns per-row logits satisfies capacity, @@ -1364,14 +1363,14 @@ embedding. It preserves the final seven `o_proj` matrices and `lm_head` (the pub Q5_K class), both vocabulary matrices, every norm, the projector, and the vision tower in BF16. NVFP4, partial inventories, and family mismatches fail closed. Byte budget: -| bucket | GB bf16 | share | -| ------ | ------: | ----: | -| text_body | 50.33 | 84.5% | -| vision_tower | 3.71 | 6.2% | -| embed_tokens | 2.69 | 4.5% | -| lm_head (untied) | 2.69 | 4.5% | -| projector | 0.14 | 0.2% | -| text norms (209 tensors) | 0.00 | 0.0% | +| bucket | GB bf16 | share | +| ------------------------ | ------: | ----: | +| text_body | 50.33 | 84.5% | +| vision_tower | 3.71 | 6.2% | +| embed_tokens | 2.69 | 4.5% | +| lm_head (untied) | 2.69 | 4.5% | +| projector | 0.14 | 0.2% | +| text norms (209 tensors) | 0.00 | 0.0% | The converter canonicalizes raw HF text keys before recipe selection, so quantization metadata and tensor names share the `language_model.model.*` namespace. The converted diff --git a/examples/grpo/lsp.ts b/examples/grpo/lsp.ts index 2e0a428d5..142cb1748 100644 --- a/examples/grpo/lsp.ts +++ b/examples/grpo/lsp.ts @@ -84,26 +84,16 @@ export interface LspService { // ============================================================================ /** - * Find the node_modules directory by searching upward from the current file. + * Resolve a package file from the nearest node_modules that contains it. */ -function findNodeModules(): string { +function resolvePackagePath(packageName: string, internalPath: string): string { let dir = __dirname; while (dir !== path.dirname(dir)) { - const nodeModules = path.join(dir, 'node_modules'); - if (fs.existsSync(nodeModules)) { - return nodeModules; - } + const candidate = path.join(dir, 'node_modules', packageName, internalPath); + if (fs.existsSync(candidate)) return candidate; dir = path.dirname(dir); } - throw new Error('Could not find node_modules directory'); -} - -/** - * Resolve a path within a package. - */ -function resolvePackagePath(packageName: string, internalPath: string): string { - const nodeModules = findNodeModules(); - return path.join(nodeModules, packageName, internalPath); + throw new Error(`Could not find ${packageName}/${internalPath} in a parent node_modules directory`); } // ============================================================================ diff --git a/experiments/flashqla/README.md b/experiments/flashqla/README.md index b8adf4438..d6e98fb31 100644 --- a/experiments/flashqla/README.md +++ b/experiments/flashqla/README.md @@ -7,7 +7,7 @@ Started from "can FlashQLA (Qwen's chunked GDN linear-attention kernel) be imple > **NOTE (2026-06-05):** the `experiments/flashqla/` `.py` harnesses listed in the artifact index below were **lost** (untracked scratch removed by an interrupted file-move). Every _finding_ they produced is preserved here and in the project memory bank; the production int8 kernel is preserved verbatim in `crates/mlx-sys/src/metal/na_int8_gemm.metal.inc`. Harnesses can be re-derived from the descriptions below if needed. -> **CLOSURE v2 (2026-08-08)** — the "chunked/FlashQLA/NA on the recurrence" closure below (§1) was re-litigated on the NAX fork with corrected premises and the verdict **stands, for a different reason**. Two of its pillars were wrong: the geometry is Dk=Dv=**128** on every shipped checkpoint (the K=192 here matches only `config.rs` *defaults*), and the NAX unaligned-K garbage band is fixed (7–16 TF measured at the true batched WY shapes, not 5.5). The real killer, measured: the 63-step serial triangular solve is **71%** of a 332 ms modeled floor vs a 272–386 ms per-step bar — serialization/launch latency, not GEMM speed; NAX-vs-simdgroup is a wash (0.93–1.11×) at the big batched rows. Full numbers, methodology, and the one remaining door (batched doubling solve, ceiling ≤ +10–15% TTFT): **`experiments/nax-2026-08/README.md`**. +> **CLOSURE v2 (2026-08-08)** — the "chunked/FlashQLA/NA on the recurrence" closure below (§1) was re-litigated on the NAX fork with corrected premises and the verdict **stands, for a different reason**. Two of its pillars were wrong: the geometry is Dk=Dv=**128** on every shipped checkpoint (the K=192 here matches only `config.rs` _defaults_), and the NAX unaligned-K garbage band is fixed (7–16 TF measured at the true batched WY shapes, not 5.5). The real killer, measured: the 63-step serial triangular solve is **71%** of a 332 ms modeled floor vs a 272–386 ms per-step bar — serialization/launch latency, not GEMM speed; NAX-vs-simdgroup is a wash (0.93–1.11×) at the big batched rows. Full numbers, methodology, and the one remaining door (batched doubling solve, ceiling ≤ +10–15% TTFT): **`experiments/nax-2026-08/README.md`**. --- diff --git a/experiments/nax-2026-08/README.md b/experiments/nax-2026-08/README.md index ec229b21f..70baa86ac 100644 --- a/experiments/nax-2026-08/README.md +++ b/experiments/nax-2026-08/README.md @@ -7,12 +7,12 @@ All work ran isolated in worktree `nax-experiments` (2026-08-06 → 08-08), adve ## TL;DR -| # | finding | verdict | -|---|---|---| -| 1 | **Opt A — quantized split-K→NAX reroute** (`qmm_splitk` at `split_k<=2`) | VALIDATED: +2.68% ttft@32 gemma4-26B, +2.31% ornith-35B; no regressions; decode untouched (never reaches `qmm_splitk`) | -| 2 | **Opt B — masked D=256 fused NAX SDPA** (bool-mask sliding layers) | VALIDATED: kernel 1.22–1.28× vs unfused, +2.63% gemma4 6k-tok TTFT (needs `MLX_PAGED_PREFILL_CHUNK_SIZE=2048`), ~0.4–0.6 GB less transient/sliding layer | -| 3 | **`mlx agent` default-path audit + measurements** | warm turns are append-prefill-bound (sliding ladder healthy, replay_delta=0); **T=0 sampling = +3.05% decode REAL** on the 26B default; thinking-clamp lever REFUTED (`reasoningTokens` always 0); warm-TTFT floor 230–360 ms with a small-append prefill inversion (190 tok slower than 543 tok — needs a sweep) | -| 4 | **flashqla closure v2** (chunked WY-UT GDN on NAX) | RE-CLOSED with corrected data: chunked loses to **serialization/launch latency, not GEMM speed**. The June closure's premises were wrong (geometry K=192 vs real 128; 5.5 TF floor vs 7–16 TF today) but the verdict stands | +| # | finding | verdict | +| --- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | **Opt A — quantized split-K→NAX reroute** (`qmm_splitk` at `split_k<=2`) | VALIDATED: +2.68% ttft@32 gemma4-26B, +2.31% ornith-35B; no regressions; decode untouched (never reaches `qmm_splitk`) | +| 2 | **Opt B — masked D=256 fused NAX SDPA** (bool-mask sliding layers) | VALIDATED: kernel 1.22–1.28× vs unfused, +2.63% gemma4 6k-tok TTFT (needs `MLX_PAGED_PREFILL_CHUNK_SIZE=2048`), ~0.4–0.6 GB less transient/sliding layer | +| 3 | **`mlx agent` default-path audit + measurements** | warm turns are append-prefill-bound (sliding ladder healthy, replay_delta=0); **T=0 sampling = +3.05% decode REAL** on the 26B default; thinking-clamp lever REFUTED (`reasoningTokens` always 0); warm-TTFT floor 230–360 ms with a small-append prefill inversion (190 tok slower than 543 tok — needs a sweep) | +| 4 | **flashqla closure v2** (chunked WY-UT GDN on NAX) | RE-CLOSED with corrected data: chunked loses to **serialization/launch latency, not GEMM speed**. The June closure's premises were wrong (geometry K=192 vs real 128; 5.5 TF floor vs 7–16 TF today) but the verdict stands | The June "K∈[128,256) NAX GEMM garbage band" is **empirically FIXED** on this pin (K=64/128/192 all bf16-rounding-clean), despite `gemm_nax.h`/`nax.h` being byte-identical since the garbage era — the fix came from the toolchain/metallib side, not kernel source. In-tree `test_support.rs` canary docs are stale and should be re-run before being believed. @@ -48,6 +48,7 @@ reroute to plain `qmm` iff `split_k <= MLX_QMM_SPLITK_NAX_MAX_SK` (default **2** Real default on this machine = `Gemma-4-26B-A4B-Unsloth-MXFP4-mlx` via `~/.mlx-node/agent/settings.json` (catalog `isDefault` only orders the first-run wizard). Paged cache required; gemma4 `draft/` hidden (speculative executor is flat-cache-only ⇒ structurally exclusive with paged prefix reuse); sampling preset T=0.7/topP.95/topK64, `maxNewTokens` 16384; full-history jinja re-render + retokenize every turn (the token-splice fast path is Continue-only; agent always sends Start). Measured (traced live session, 4 warm turns): + - **Sliding-rung ladder healthy**: warm turns all `prefix_checkpoint` with `replay_delta_tokens=0` — pure append-prefill. Per-turn `cold_restore_declined reason=no_backed_boundary` lines are benign. Cross-process SSD restore: 4096/7466 tokens → TTFT 1270 ms vs 2019 cold. - **Warm TTFT floor 230–360 ms regardless of append size**; single-sample inversion: 190-tok append prefill 353 ms (538 tok/s) vs 543-tok 228 ms (2381 tok/s) — fixed per-turn cost (O(context) KV gather + underfilled GEMMs + full re-render). Needs an append-size sweep before acting. - **T=0 vs agent preset decode A/B: +3.05% REAL** (89.1→91.9 tok/s, 4/4 pairs +2.9–3.5%, clears strict control hurdle 2.16%). The cost is compiled top-k/top-p over the 262,144-entry vocab every step; T=0 collapses to argmax. Product tradeoff open (T=0 + repetitionPenalty 1.0 + cutoffs-off = loop risk). @@ -59,7 +60,7 @@ Measured (traced live session, 4 warm turns): June-2026 closure premises re-litigated and found wrong — verdict re-derived and **upheld** on corrected data: -- **Geometry**: closure said K=Dk=192 "pinned by head geometry". Every shipped qwen3.5 checkpoint (0.8B/4B/27B/35B-A3B) has **Dk=Dv=128, Hk=16** (75/75 configs). `config.rs` *defaults* to 192 — the likely source of the June harnesses' error. +- **Geometry**: closure said K=Dk=192 "pinned by head geometry". Every shipped qwen3.5 checkpoint (0.8B/4B/27B/35B-A3B) has **Dk=Dv=128, Hk=16** (75/75 configs). `config.rs` _defaults_ to 192 — the likely source of the June harnesses' error. - **NAX correctness**: the K∈[128,256) garbage band is gone (measured, both arms, full-mantissa inputs). - **GEMM floor**: 7–16 TF at the true batched WY shapes (vs June's 5.5 TF) — but **NAX vs simdgroup is a wash at the big batch-2048 rows (0.93–1.11×, bandwidth-bound)**; real NAX wins only on small-batch bf16 state rows (1.38–1.69×). The ops path's internal f32 cast alone halves throughput. - **The actual killer, measured**: modeled floor **332 ms faithful / 170 ms optimistic** vs today's per-step bar **272–386 ms** (2.77 µs/tok warm; June: 293–483). Split: **63-step serial tri-solve = 71%** (9.88 ms/layer faithful; 70.6 µs/serial step; the full-M `where_` scatter doubles the 4.45 ms gemv-only chain), chunk-serial state carry 16% (2.24 ms), ALL GEMM terms **12%** (41 ms/stack). Scalar chunked Metal kernel re-measured 15–16× slower than per-step (PR #68 confirmed). @@ -77,15 +78,15 @@ June-2026 closure premises re-litigated and found wrong — verdict re-derived a ## 6. Env-var inventory introduced/used -| var | default | meaning | -|---|---|---| -| `MLX_ENABLE_QMM_SPLITK_NAX` | 1 | Opt A master switch | -| `MLX_QMM_SPLITK_NAX_MAX_SK` | 2 | reroute only when split_k ≤ this | -| `MLX_QMM_SPLITK_NAX_DEBUG` | off | `[splitk-dbg]` per-dispatch trace | -| `MLX_ENABLE_D256_MASKED_SDPA` | 1 | Opt B master switch | -| `MLX_DISABLE_GEMM_NAX` | 0 | dense-GEMM NAX kill switch (read per dispatch; added for the flashqla re-litigation) | -| `MLX_GDN_KERNEL` | auto | `chunked` = scalar Metal chunked kernel; `chunked_ops` parses but is non-Metal-only | -| `MLX_AB_SAMPLING` | unset | harness arm selector: `agent-gemma4` = T0.7/topP.95/topK64, unset = greedy | +| var | default | meaning | +| ----------------------------- | ------- | ------------------------------------------------------------------------------------ | +| `MLX_ENABLE_QMM_SPLITK_NAX` | 1 | Opt A master switch | +| `MLX_QMM_SPLITK_NAX_MAX_SK` | 2 | reroute only when split_k ≤ this | +| `MLX_QMM_SPLITK_NAX_DEBUG` | off | `[splitk-dbg]` per-dispatch trace | +| `MLX_ENABLE_D256_MASKED_SDPA` | 1 | Opt B master switch | +| `MLX_DISABLE_GEMM_NAX` | 0 | dense-GEMM NAX kill switch (read per dispatch; added for the flashqla re-litigation) | +| `MLX_GDN_KERNEL` | auto | `chunked` = scalar Metal chunked kernel; `chunked_ops` parses but is non-Metal-only | +| `MLX_AB_SAMPLING` | unset | harness arm selector: `agent-gemma4` = T0.7/topP.95/topK64, unset = greedy | ## 7. Test-suite designs (code removed; recreate from these) @@ -135,7 +136,7 @@ index 17726635..062ff11b 100644 @@ -70,6 +70,38 @@ bool nax_supports_mode(const std::string& mode, NaxPath path) { return path == NaxPath::QmmT; } - + +// Whether a transposed qmm on this input would take the NAX tensor-op branch. +// One predicate shared by `qmm` and the split-K reroute in `qmm_splitk`, so the +// two call sites cannot drift apart: K % 64 == 0 is the NAX kernel's K-tile @@ -224,7 +225,7 @@ index 697e9a61..a96d84f3 100644 @@ -23,6 +23,14 @@ bool d256_full_sdpa_enabled() { return enabled; } - + +bool d256_masked_sdpa_enabled() { + // Independent kill switch for the bool-array-masked D=256 route. It only + // narrows: the masked route also requires d256_full_sdpa_available(), so @@ -239,7 +240,7 @@ index 697e9a61..a96d84f3 100644 @@ -695,12 +703,37 @@ bool d256_full_sdpa_would_use( !has_array_mask && d256_full_sdpa_available(effective_dtype_is_float32); } - + +bool d256_masked_sdpa_would_use( + bool effective_dtype_is_float32, + int32_t query_head_dim, @@ -371,11 +372,11 @@ index f70ac7c0..40286d2f 100644 + bool has_bool_mask); } // namespace mlx::core::fast #endif - + @@ -221,6 +228,46 @@ int32_t mlx_metal_d256_full_sdpa_would_use( #endif } - + +// Bool-array-masked D=256 eligibility probe (Gemma4 sliding-window prefill). +// Calls the same helper used inside ScaledDotProductAttention::use_fallback; +// callers still own the dispatcher's outer inference/stream gates. Same 0/-1 @@ -515,12 +516,7 @@ function median(xs: number[]): number { const relevantToggles: Record = {}; for (const [k, v] of Object.entries(process.env)) { - if ( - k.startsWith('MLX_LFM2_') || - k === 'MLX_NO_COMPILE' || - k === 'MLX_DISABLE_COMPILE' || - k === 'MLX_AB_SAMPLING' - ) { + if (k.startsWith('MLX_LFM2_') || k === 'MLX_NO_COMPILE' || k === 'MLX_DISABLE_COMPILE' || k === 'MLX_AB_SAMPLING') { relevantToggles[k] = v ?? ''; } } @@ -529,10 +525,7 @@ for (const [k, v] of Object.entries(process.env)) { // unset -> greedy (temperature 0) // 'agent-gemma4' -> the mlx agent's gemma4 preset (server presets.ts) const samplingArm = process.env.MLX_AB_SAMPLING ?? ''; -const samplingConfig = - samplingArm === 'agent-gemma4' - ? { temperature: 0.7, topP: 0.95, topK: 64 } - : { temperature: 0 }; +const samplingConfig = samplingArm === 'agent-gemma4' ? { temperature: 0.7, topP: 0.95, topK: 64 } : { temperature: 0 }; if (samplingArm && samplingArm !== 'agent-gemma4') { throw new Error(`unknown MLX_AB_SAMPLING arm: ${samplingArm}`); } diff --git a/package.json b/package.json index 895927ba7..8dd8455d9 100644 --- a/package.json +++ b/package.json @@ -39,11 +39,10 @@ "oxc-parser": "^0.147.0", "prettier": "^3.9.6", "typescript": "^7.0.2", - "vite-plus": "0.3.0", - "vitest": "^4.1.11" + "vite-plus": "catalog:" }, "resolutions": { - "vite": "npm:@voidzero-dev/vite-plus-core@0.3.0" + "vite": "npm:@voidzero-dev/vite-plus-core@0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62" }, "packageManager": "yarn@4.18.0" } diff --git a/packages/agent/__test__/catalog.test.ts b/packages/agent/__test__/catalog.test.ts index 67d9b5210..f7b1e8ebd 100644 --- a/packages/agent/__test__/catalog.test.ts +++ b/packages/agent/__test__/catalog.test.ts @@ -92,7 +92,10 @@ describe('MODEL_CATALOG', () => { for (const entry of visibleCatalog()) { expect(entry.globs, entry.label).toBeDefined(); expect(entry.globs!.length, entry.label).toBeGreaterThan(0); - expect(entry.globs!.some((glob) => glob.includes('UD-Q4_K_XL')), entry.label).toBe(true); + expect( + entry.globs!.some((glob) => glob.includes('UD-Q4_K_XL')), + entry.label, + ).toBe(true); } }); diff --git a/packages/cli/__test__/convert-cmd.test.ts b/packages/cli/__test__/convert-cmd.test.ts index 89f5ed5c0..4af15333f 100644 --- a/packages/cli/__test__/convert-cmd.test.ts +++ b/packages/cli/__test__/convert-cmd.test.ts @@ -398,12 +398,10 @@ describe('mlx convert model-type auto-detection', () => { }); it("detects an architecture-only Nemotron config (no model_type) as 'nemotron_h'", async () => { - expect( - await detectModelTypeFromConfig({ architectures: ['NemotronHForCausalLM'] }), - ).toBe('nemotron_h'); + expect(await detectModelTypeFromConfig({ architectures: ['NemotronHForCausalLM'] })).toBe('nemotron_h'); }); - it("the Nemotron architecture beats a stale recognized model_type", async () => { + it('the Nemotron architecture beats a stale recognized model_type', async () => { // The architecture is authoritative (native parser + runtime registry): // a Nemotron checkpoint carrying a stale-but-recognized model_type must // not route to another family's sanitizer. @@ -562,9 +560,7 @@ describe('mlx convert Unsloth MXFP messaging', () => { ]); const warnings = warnSpy.mock.calls.map((call) => String(call[0])).join('\n'); - expect(warnings).toContain( - 'The requested fixed Qwen hybrid or exact SafeTensors Gemma4 MoE', - ); + expect(warnings).toContain('The requested fixed Qwen hybrid or exact SafeTensors Gemma4 MoE'); expect(warnings).toContain('rejects one anyway'); expect(warnings).toContain('need no calibration'); expect(warnings).toContain('unsupported inputs will be rejected'); diff --git a/packages/cli/src/commands/download-model.ts b/packages/cli/src/commands/download-model.ts index 671fb3124..ce19cf511 100644 --- a/packages/cli/src/commands/download-model.ts +++ b/packages/cli/src/commands/download-model.ts @@ -920,9 +920,7 @@ export async function run(argv: string[]) { // later run with a resolvable revision repairs it then. This also covers // the `planned.length === 0 && revision === null` case: nothing to do. if (plan.revision === null) { - console.warn( - `Could not resolve the latest revision of "${assetsRepo}"; skipping sidecar repair for this run.\n`, - ); + console.warn(`Could not resolve the latest revision of "${assetsRepo}"; skipping sidecar repair for this run.\n`); return; } const planned = plan.candidates.map((file) => file.path); diff --git a/packages/core/index.cjs b/packages/core/index.cjs index 71d09c487..0365b19c0 100644 --- a/packages/core/index.cjs +++ b/packages/core/index.cjs @@ -3,7 +3,7 @@ // @ts-nocheck /* auto-generated by NAPI-RS */ -const { readFileSync } = require('fs') +const { readFileSync } = require('fs'); let nativeBinding = null; const loadErrors = []; diff --git a/packages/dashboard/__test__/loading-shape-lists.test.ts b/packages/dashboard/__test__/loading-shape-lists.test.ts index ce458aba7..d60181b96 100644 --- a/packages/dashboard/__test__/loading-shape-lists.test.ts +++ b/packages/dashboard/__test__/loading-shape-lists.test.ts @@ -172,8 +172,8 @@ function modelsRoutes(): Record { present: false, blockedByForeignDir: false, localRevision: null, - localAssetsRepo: null, - localAssetsRevision: null, + localAssetsRepo: null, + localAssetsRevision: null, }, { label: 'Qwen-AgentWorld-35B-A3B', @@ -185,8 +185,8 @@ function modelsRoutes(): Record { present: false, blockedByForeignDir: false, localRevision: null, - localAssetsRepo: null, - localAssetsRevision: null, + localAssetsRepo: null, + localAssetsRevision: null, }, { label: 'Gemma-4-26B-A4B', @@ -198,8 +198,8 @@ function modelsRoutes(): Record { present: false, blockedByForeignDir: false, localRevision: null, - localAssetsRepo: null, - localAssetsRevision: null, + localAssetsRepo: null, + localAssetsRevision: null, }, ], }; diff --git a/packages/dashboard/src/models.ts b/packages/dashboard/src/models.ts index e1d8e36ab..75b75fa1c 100644 --- a/packages/dashboard/src/models.ts +++ b/packages/dashboard/src/models.ts @@ -169,9 +169,7 @@ export function isModelPresent(modelDir: string): boolean { // counting one as present disables Install on the card for a directory the // loader never lists. if ( - entries.some( - (file) => file.endsWith('.gguf') && !isGgufCompanionName(file) && isRegularFile(join(modelDir, file)), - ) + entries.some((file) => file.endsWith('.gguf') && !isGgufCompanionName(file) && isRegularFile(join(modelDir, file))) ) { return true; } diff --git a/packages/dashboard/src/worker/client.ts b/packages/dashboard/src/worker/client.ts index a2d250388..01e527a64 100644 --- a/packages/dashboard/src/worker/client.ts +++ b/packages/dashboard/src/worker/client.ts @@ -229,11 +229,14 @@ export function startDbWorker(opts: DbWorkerOptions): DbWorkerClient { forcedDownReason ??= reason; // Wait for termination/exit before settling through goDown. Until then a // live worker could still reach the queued mutation we are retracting. - void worker.terminate().then(() => goDown(forcedDownReason!)).catch(() => { - // A rejected terminate is not proof the worker stopped. Keep waiting - // for its real response or eventual error/exit rather than report a - // failure while it may still execute the request later. - }); + void worker + .terminate() + .then(() => goDown(forcedDownReason!)) + .catch(() => { + // A rejected terminate is not proof the worker stopped. Keep waiting + // for its real response or eventual error/exit rather than report a + // failure while it may still execute the request later. + }); }; const requestWithdrawal = (reason: string): void => { if (settled || withdrawalReason !== null) return; diff --git a/packages/dashboard/ui/vite.config.ts b/packages/dashboard/ui/vite.config.ts index b878f0af6..ab7d77d5f 100644 --- a/packages/dashboard/ui/vite.config.ts +++ b/packages/dashboard/ui/vite.config.ts @@ -2,7 +2,7 @@ import { resolve } from 'node:path'; import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react'; -import { defineConfig } from 'vite'; +import { defineConfig } from 'vite-plus'; // No dev-server `/api` proxy: the SPA reaches the runtime over a MessagePort // handed to it by the desktop app's preload, not over HTTP. There is no port to diff --git a/packages/desktop/__test__/control-panel-session.test.ts b/packages/desktop/__test__/control-panel-session.test.ts index 83a8e18bd..3d5ae9d56 100644 --- a/packages/desktop/__test__/control-panel-session.test.ts +++ b/packages/desktop/__test__/control-panel-session.test.ts @@ -166,9 +166,9 @@ describe('attach', () => { expect(await Promise.race([oldReply.then(() => 'settled'), settle().then(() => 'pending')])).toBe('pending'); // Replacing the connection must not hold up unrelated work on the new one. - expect( - await ask(second.peer, { kind: 'call', id: 2, call: { method: 'GET', path: '/api/models' } }), - ).toMatchObject({ kind: 'response', id: 2 }); + expect(await ask(second.peer, { kind: 'call', id: 2, call: { method: 'GET', path: '/api/models' } })).toMatchObject( + { kind: 'response', id: 2 }, + ); // Cancellation lost the race to a mutation that had already started. The // retired connection stays alive long enough to report that real result. diff --git a/packages/desktop/scripts/verify-bundle.ts b/packages/desktop/scripts/verify-bundle.ts index a9b40c8e9..04fe943fa 100644 --- a/packages/desktop/scripts/verify-bundle.ts +++ b/packages/desktop/scripts/verify-bundle.ts @@ -333,7 +333,7 @@ const ARGV_BUDGET = 128 * 1024; export function findMachOFiles(root: string): string[] { const files = listRegularFiles(root); let output = ''; - for (let i = 0; i < files.length; ) { + for (let i = 0; i < files.length;) { const batch: string[] = []; let bytes = 0; while (i < files.length && (batch.length === 0 || bytes + files[i].length + 1 < ARGV_BUDGET)) { diff --git a/packages/desktop/spike/index.html b/packages/desktop/spike/index.html index 80b1fe8e2..e0352e3b3 100644 --- a/packages/desktop/spike/index.html +++ b/packages/desktop/spike/index.html @@ -2,15 +2,53 @@ mlx-node — Phase 0 watchdog spike
@@ -24,7 +62,11 @@ // so the window is genuinely repainting while MLX saturates the GPU. let frames = 0; const framesEl = document.getElementById('frames'); - (function tick() { frames++; framesEl.textContent = frames; requestAnimationFrame(tick); })(); + (function tick() { + frames++; + framesEl.textContent = frames; + requestAnimationFrame(tick); + })(); let tokens = 0; const t0 = Date.now(); diff --git a/packages/desktop/spike/main.mjs b/packages/desktop/spike/main.mjs index 994b85e5b..4d6638f69 100644 --- a/packages/desktop/spike/main.mjs +++ b/packages/desktop/spike/main.mjs @@ -13,10 +13,11 @@ * run: yarn workspace @mlx-node/desktop spike:electron */ -import { app, BrowserWindow, utilityProcess } from 'electron'; -import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; import { appendFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { app, BrowserWindow, utilityProcess } from 'electron'; const HERE = dirname(fileURLToPath(import.meta.url)); const LOG_DIR = process.env.MLX_SPIKE_LOG_DIR ?? join(HERE, '.spike-logs'); diff --git a/packages/lm/__test__/chat-session-template-history.test.ts b/packages/lm/__test__/chat-session-template-history.test.ts index 73fc9a447..763b0da66 100644 --- a/packages/lm/__test__/chat-session-template-history.test.ts +++ b/packages/lm/__test__/chat-session-template-history.test.ts @@ -1,5 +1,5 @@ import type { ChatConfig, ChatMessage, ChatResult, ToolCallResult, ToolDefinition } from '@mlx-node/core'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vite-plus/test'; import { ChatSession, type SessionCapableModel } from '../src/chat-session.js'; import { K2HorizonModel, type ChatStreamEvent } from '../src/stream.js'; diff --git a/packages/lm/__test__/stream-template-policy.test.ts b/packages/lm/__test__/stream-template-policy.test.ts index 4601311eb..951902a26 100644 --- a/packages/lm/__test__/stream-template-policy.test.ts +++ b/packages/lm/__test__/stream-template-policy.test.ts @@ -1,5 +1,5 @@ import type { ChatMessage, ChatResult } from '@mlx-node/core'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'; const tokenizerMocks = vi.hoisted(() => ({ applyChatTemplate: vi.fn(), diff --git a/packages/lm/package.json b/packages/lm/package.json index 743f3a2ff..4f2273cae 100644 --- a/packages/lm/package.json +++ b/packages/lm/package.json @@ -47,7 +47,7 @@ }, "scripts": { "build": "tsc -b", - "test": "vite test run" + "test": "vp test run --config ../../vite.config.ts --dir ." }, "dependencies": { "@mlx-node/core": "workspace:*" diff --git a/packages/privacy/package.json b/packages/privacy/package.json index d3717c7ae..10a68937c 100644 --- a/packages/privacy/package.json +++ b/packages/privacy/package.json @@ -27,7 +27,7 @@ }, "scripts": { "build": "tsc -b", - "test": "vite test run" + "test": "vp test run --config ../../vite.config.ts --dir ." }, "dependencies": { "@mlx-node/core": "workspace:*" diff --git a/packages/trl/package.json b/packages/trl/package.json index c98e84245..4000ca8da 100644 --- a/packages/trl/package.json +++ b/packages/trl/package.json @@ -27,8 +27,8 @@ }, "scripts": { "build": "tsc -b", - "test": "vite test run", - "test:trainer": "TEST_TRAINER=1 vite test run" + "test": "cd ../.. && vp test run __test__/trainers", + "test:trainer": "cd ../.. && TEST_TRAINER=1 vp test run __test__/trainers" }, "dependencies": { "@mlx-node/core": "workspace:*", diff --git a/packages/vlm/package.json b/packages/vlm/package.json index 3f56749e9..487f72382 100644 --- a/packages/vlm/package.json +++ b/packages/vlm/package.json @@ -27,7 +27,7 @@ }, "scripts": { "build": "tsc -b", - "test": "vite test run" + "test": "cd ../.. && vp test run __test__/models" }, "dependencies": { "@mlx-node/core": "workspace:*", diff --git a/scripts/benchmark-model.ts b/scripts/benchmark-model.ts index 45ec4e165..a98158067 100644 --- a/scripts/benchmark-model.ts +++ b/scripts/benchmark-model.ts @@ -1,27 +1,14 @@ #!/usr/bin/env oxnode -import { fork } from "node:child_process"; -import { - mkdir, - readFile, - rename, - stat, - unlink, - writeFile, -} from "node:fs/promises"; -import { createRequire } from "node:module"; -import { - cpus, - platform, - release, - totalmem, - version as osVersion, -} from "node:os"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { parseArgs } from "node:util"; - -import type { PerformanceMetrics, SessionCapableModel } from "@mlx-node/lm"; +import { fork } from 'node:child_process'; +import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { cpus, platform, release, totalmem, version as osVersion } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; + +import type { PerformanceMetrics, SessionCapableModel } from '@mlx-node/lm'; const DEFAULT_RUNS = 3; const DEFAULT_MAX_NEW_TOKENS = 512; @@ -30,9 +17,9 @@ const DEFAULT_WARMUP_RUNS = 0; const MINIMUM_COMPLETION_RATIO = 0.95; const SYSTEM_PROMPT = - "You are a technical writer in a deterministic inference benchmark. Produce a continuous, detailed response and do not conclude early."; + 'You are a technical writer in a deterministic inference benchmark. Produce a continuous, detailed response and do not conclude early.'; const DEFAULT_PROMPT = - "Write a comprehensive technical handbook chapter about building a production compiler. Cover lexical analysis, parsing, semantic analysis, type checking, intermediate representations, optimization, code generation, linking, testing, debugging, and deployment. Use detailed explanations and concrete examples. Do not summarize or conclude early; keep expanding the chapter until the generation limit is reached."; + 'Write a comprehensive technical handbook chapter about building a production compiler. Cover lexical analysis, parsing, semantic analysis, type checking, intermediate representations, optimization, code generation, linking, testing, debugging, and deployment. Use detailed explanations and concrete examples. Do not summarize or conclude early; keep expanding the chapter until the generation limit is reached.'; const SCRIPT_PATH = fileURLToPath(import.meta.url); const require = createRequire(import.meta.url); @@ -65,12 +52,12 @@ interface Sample { } interface RunReport extends Sample { - phase: "warmup" | "measured"; + phase: 'warmup' | 'measured'; run: number; } interface WorkerMessage { - type: "benchmark-model-sample"; + type: 'benchmark-model-sample'; sample: Sample; } @@ -79,16 +66,13 @@ interface PackageMetadata { version: string; } -async function readPackageFile( - packageFile: string, - packageName: string, -): Promise { +async function readPackageFile(packageFile: string, packageName: string): Promise { try { - const parsed: unknown = JSON.parse(await readFile(packageFile, "utf8")); + const parsed: unknown = JSON.parse(await readFile(packageFile, 'utf8')); if ( isRecord(parsed) && parsed.name === packageName && - typeof parsed.version === "string" && + typeof parsed.version === 'string' && parsed.version.length > 0 ) { return { name: packageName, version: parsed.version }; @@ -130,9 +114,7 @@ function parseInteger(raw: string, name: string, minimum: number): number { } const value = Number(raw); if (!Number.isSafeInteger(value) || value < minimum) { - throw new Error( - `${name} must be at least ${minimum}, got ${JSON.stringify(raw)}`, - ); + throw new Error(`${name} must be at least ${minimum}, got ${JSON.stringify(raw)}`); } return value; } @@ -140,9 +122,7 @@ function parseInteger(raw: string, name: string, minimum: number): number { function parseCooldown(raw: string): number { const value = Number(raw); if (!Number.isFinite(value) || value < 0) { - throw new Error( - `--cooldown must be a non-negative number of seconds, got ${JSON.stringify(raw)}`, - ); + throw new Error(`--cooldown must be a non-negative number of seconds, got ${JSON.stringify(raw)}`); } return value; } @@ -153,19 +133,19 @@ function parseCli(): CliOptions | null { allowPositionals: true, strict: true, options: { - runs: { type: "string", default: String(DEFAULT_RUNS) }, - "max-new-tokens": { - type: "string", + runs: { type: 'string', default: String(DEFAULT_RUNS) }, + 'max-new-tokens': { + type: 'string', default: String(DEFAULT_MAX_NEW_TOKENS), }, - cooldown: { type: "string", default: String(DEFAULT_COOLDOWN_SECONDS) }, - "warmup-runs": { type: "string", default: String(DEFAULT_WARMUP_RUNS) }, - prompt: { type: "string", default: DEFAULT_PROMPT }, - output: { type: "string" }, - help: { type: "boolean", short: "h", default: false }, + cooldown: { type: 'string', default: String(DEFAULT_COOLDOWN_SECONDS) }, + 'warmup-runs': { type: 'string', default: String(DEFAULT_WARMUP_RUNS) }, + prompt: { type: 'string', default: DEFAULT_PROMPT }, + output: { type: 'string' }, + help: { type: 'boolean', short: 'h', default: false }, // Internal child-process mode. Kept on the same entrypoint so the // parent and worker always execute exactly the same checked-in code. - worker: { type: "boolean", default: false }, + worker: { type: 'boolean', default: false }, }, }); @@ -178,43 +158,32 @@ function parseCli(): CliOptions | null { } const prompt = values.prompt.trim(); - if (prompt.length === 0) throw new Error("--prompt must not be empty"); + if (prompt.length === 0) throw new Error('--prompt must not be empty'); const output = values.output?.trim(); - if (output !== undefined && output.length === 0) - throw new Error("--output must not be empty"); + if (output !== undefined && output.length === 0) throw new Error('--output must not be empty'); return { modelPath: resolve(positionals[0]!), - runs: parseInteger(values.runs, "--runs", 1), - maxNewTokens: parseInteger(values["max-new-tokens"], "--max-new-tokens", 1), + runs: parseInteger(values.runs, '--runs', 1), + maxNewTokens: parseInteger(values['max-new-tokens'], '--max-new-tokens', 1), cooldownSeconds: parseCooldown(values.cooldown), - warmupRuns: parseInteger(values["warmup-runs"], "--warmup-runs", 0), + warmupRuns: parseInteger(values['warmup-runs'], '--warmup-runs', 0), prompt, outputPath: output === undefined ? null : resolve(output), worker: values.worker, }; } -function assertMetric( - value: unknown, - name: keyof PerformanceMetrics, - allowZero = false, -): asserts value is number { - if ( - typeof value !== "number" || - !Number.isFinite(value) || - (allowZero ? value < 0 : value <= 0) - ) { - throw new Error( - `Missing or invalid performance metric ${name}: ${String(value)}`, - ); +function assertMetric(value: unknown, name: keyof PerformanceMetrics, allowZero = false): asserts value is number { + if (typeof value !== 'number' || !Number.isFinite(value) || (allowZero ? value < 0 : value <= 0)) { + throw new Error(`Missing or invalid performance metric ${name}: ${String(value)}`); } } async function runWorker(options: CliOptions): Promise { // Deliberately delayed until worker mode so --help and argument validation // never initialize the native binding or load a checkpoint. - const { ChatSession, HarrierModel, loadModel } = await import("@mlx-node/lm"); + const { ChatSession, HarrierModel, loadModel } = await import('@mlx-node/lm'); const totalStartedAt = performance.now(); const loadStartedAt = totalStartedAt; @@ -222,9 +191,7 @@ async function runWorker(options: CliOptions): Promise { const loadedAt = performance.now(); if (model instanceof HarrierModel) { - throw new Error( - `Expected a generative model, got an embedding model: ${options.modelPath}`, - ); + throw new Error(`Expected a generative model, got an embedding model: ${options.modelPath}`); } const session = new ChatSession(model as unknown as SessionCapableModel, { @@ -235,7 +202,7 @@ async function runWorker(options: CliOptions): Promise { config: { maxNewTokens: options.maxNewTokens, temperature: 0, - reasoningEffort: "none", + reasoningEffort: 'none', reportPerformance: true, }, }); @@ -243,25 +210,16 @@ async function runWorker(options: CliOptions): Promise { const metrics = result.performance; if (metrics === undefined) { - throw new Error( - "Generation returned no performance metrics despite reportPerformance: true", - ); + throw new Error('Generation returned no performance metrics despite reportPerformance: true'); } - assertMetric(metrics.ttftMs, "ttftMs"); - assertMetric(metrics.prefillTokensPerSecond, "prefillTokensPerSecond"); + assertMetric(metrics.ttftMs, 'ttftMs'); + assertMetric(metrics.prefillTokensPerSecond, 'prefillTokensPerSecond'); // A one-token plumbing check has no decode interval by definition, so zero // is valid only for that short-validation case. Normal benchmark runs must // provide a positive decode throughput. - assertMetric( - metrics.decodeTokensPerSecond, - "decodeTokensPerSecond", - options.maxNewTokens === 1, - ); + assertMetric(metrics.decodeTokensPerSecond, 'decodeTokensPerSecond', options.maxNewTokens === 1); - const minimumGeneratedTokens = Math.max( - 1, - Math.ceil(options.maxNewTokens * MINIMUM_COMPLETION_RATIO), - ); + const minimumGeneratedTokens = Math.max(1, Math.ceil(options.maxNewTokens * MINIMUM_COMPLETION_RATIO)); if (result.numTokens < minimumGeneratedTokens) { throw new Error( `Generation ended materially early: got ${result.numTokens}/${options.maxNewTokens} tokens ` + @@ -287,30 +245,25 @@ async function runWorker(options: CliOptions): Promise { } function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; + return typeof value === 'object' && value !== null; } function isWorkerMessage(value: unknown): value is WorkerMessage { - if ( - !isRecord(value) || - value.type !== "benchmark-model-sample" || - !isRecord(value.sample) - ) - return false; + if (!isRecord(value) || value.type !== 'benchmark-model-sample' || !isRecord(value.sample)) return false; const sample = value.sample; return ( - typeof sample.pid === "number" && - typeof sample.loadMs === "number" && - typeof sample.ttftMs === "number" && - typeof sample.prefillTokensPerSecond === "number" && - typeof sample.decodeTokensPerSecond === "number" && - typeof sample.generatedTokens === "number" + typeof sample.pid === 'number' && + typeof sample.loadMs === 'number' && + typeof sample.ttftMs === 'number' && + typeof sample.prefillTokensPerSecond === 'number' && + typeof sample.decodeTokensPerSecond === 'number' && + typeof sample.generatedTokens === 'number' ); } async function sendWorkerMessage(message: WorkerMessage): Promise { if (process.send === undefined || process.disconnect === undefined) - throw new Error("Benchmark worker was started without an IPC channel"); + throw new Error('Benchmark worker was started without an IPC channel'); await new Promise((resolvePromise, rejectPromise) => { process.send!(message, (error) => { if (error === null) resolvePromise(); @@ -323,16 +276,16 @@ async function sendWorkerMessage(message: WorkerMessage): Promise { function workerArgs(options: CliOptions): string[] { return [ options.modelPath, - "--worker", - "--runs", + '--worker', + '--runs', String(options.runs), - "--max-new-tokens", + '--max-new-tokens', String(options.maxNewTokens), - "--cooldown", + '--cooldown', String(options.cooldownSeconds), - "--warmup-runs", + '--warmup-runs', String(options.warmupRuns), - "--prompt", + '--prompt', options.prompt, ]; } @@ -341,32 +294,23 @@ function runIsolatedSample(options: CliOptions): Promise { return new Promise((resolvePromise, rejectPromise) => { const child = fork(SCRIPT_PATH, workerArgs(options), { execArgv: process.execArgv, - stdio: ["ignore", "inherit", "inherit", "ipc"], + stdio: ['ignore', 'inherit', 'inherit', 'ipc'], }); let sample: Sample | undefined; let protocolError: Error | undefined; - child.on("message", (message) => { + child.on('message', (message) => { if (isWorkerMessage(message)) sample = message.sample; - else - protocolError = new Error( - "Benchmark worker returned an invalid IPC message", - ); + else protocolError = new Error('Benchmark worker returned an invalid IPC message'); }); - child.once("error", rejectPromise); - child.once("exit", (code, signal) => { + child.once('error', rejectPromise); + child.once('exit', (code, signal) => { if (protocolError !== undefined) { rejectPromise(protocolError); } else if (code !== 0) { - rejectPromise( - new Error( - `Benchmark worker failed (${signal === null ? `exit ${code}` : `signal ${signal}`})`, - ), - ); + rejectPromise(new Error(`Benchmark worker failed (${signal === null ? `exit ${code}` : `signal ${signal}`})`)); } else if (sample === undefined) { - rejectPromise( - new Error("Benchmark worker exited without returning a sample"), - ); + rejectPromise(new Error('Benchmark worker exited without returning a sample')); } else { resolvePromise(sample); } @@ -375,8 +319,7 @@ function runIsolatedSample(options: CliOptions): Promise { } function formatSample(report: RunReport): string { - const label = - report.phase === "warmup" ? `warmup ${report.run}` : `run ${report.run}`; + const label = report.phase === 'warmup' ? `warmup ${report.run}` : `run ${report.run}`; return ( `${label}: load ${report.loadMs.toFixed(0)} ms | TTFT ${report.ttftMs.toFixed(0)} ms | ` + `prefill ${report.prefillTokensPerSecond.toFixed(1)} tok/s | ` + @@ -386,13 +329,10 @@ function formatSample(report: RunReport): string { } function median(values: number[]): number { - if (values.length === 0) - throw new Error("Cannot take the median of an empty sample set"); + if (values.length === 0) throw new Error('Cannot take the median of an empty sample set'); const sorted = [...values].sort((left, right) => left - right); const middle = Math.floor(sorted.length / 2); - return sorted.length % 2 === 1 - ? sorted[middle]! - : (sorted[middle - 1]! + sorted[middle]!) / 2; + return sorted.length % 2 === 1 ? sorted[middle]! : (sorted[middle - 1]! + sorted[middle]!) / 2; } function medians(reports: RunReport[]) { @@ -401,41 +341,30 @@ function medians(reports: RunReport[]) { generationWallMs: median(reports.map((report) => report.generationWallMs)), totalWallMs: median(reports.map((report) => report.totalWallMs)), ttftMs: median(reports.map((report) => report.ttftMs)), - prefillTokensPerSecond: median( - reports.map((report) => report.prefillTokensPerSecond), - ), - decodeTokensPerSecond: median( - reports.map((report) => report.decodeTokensPerSecond), - ), + prefillTokensPerSecond: median(reports.map((report) => report.prefillTokensPerSecond)), + decodeTokensPerSecond: median(reports.map((report) => report.decodeTokensPerSecond)), promptTokens: median(reports.map((report) => report.promptTokens)), generatedTokens: median(reports.map((report) => report.generatedTokens)), }; } -async function readPackageMetadata( - packageName: string, -): Promise { +async function readPackageMetadata(packageName: string): Promise { let entryPath: string; try { entryPath = require.resolve(packageName); } catch { const workspacePackageFile = - packageName === "@mlx-node/lm" - ? resolve(dirname(SCRIPT_PATH), "../packages/lm/package.json") - : packageName === "@mlx-node/core" - ? resolve(dirname(SCRIPT_PATH), "../packages/core/package.json") + packageName === '@mlx-node/lm' + ? resolve(dirname(SCRIPT_PATH), '../packages/lm/package.json') + : packageName === '@mlx-node/core' + ? resolve(dirname(SCRIPT_PATH), '../packages/core/package.json') : null; - return workspacePackageFile === null - ? null - : await readPackageFile(workspacePackageFile, packageName); + return workspacePackageFile === null ? null : await readPackageFile(workspacePackageFile, packageName); } let currentDirectory = dirname(entryPath); for (let depth = 0; depth < 5; depth++) { - const metadata = await readPackageFile( - resolve(currentDirectory, "package.json"), - packageName, - ); + const metadata = await readPackageFile(resolve(currentDirectory, 'package.json'), packageName); if (metadata !== null) return metadata; // Keep walking: the resolved entry is commonly inside a dist/ folder. const parentDirectory = dirname(currentDirectory); @@ -446,13 +375,9 @@ async function readPackageMetadata( } function nativePackageCandidates(): string[] { - if (process.platform === "darwin" && process.arch === "arm64") - return ["@mlx-node/core-darwin-arm64"]; - if (process.platform === "linux" && process.arch === "arm64") { - return [ - "@mlx-node/core-linux-arm64-gnu", - "@mlx-node/core-linux-arm64-musl", - ]; + if (process.platform === 'darwin' && process.arch === 'arm64') return ['@mlx-node/core-darwin-arm64']; + if (process.platform === 'linux' && process.arch === 'arm64') { + return ['@mlx-node/core-linux-arm64-gnu', '@mlx-node/core-linux-arm64-musl']; } return []; } @@ -474,39 +399,31 @@ async function environmentMetadata() { totalMemoryBytes: totalmem(), nodeVersion: process.version, packages: { - lm: await readPackageMetadata("@mlx-node/lm"), - core: await readPackageMetadata("@mlx-node/core"), + lm: await readPackageMetadata('@mlx-node/lm'), + core: await readPackageMetadata('@mlx-node/core'), native: nativePackage, }, }; } function sleep(milliseconds: number): Promise { - return new Promise((resolvePromise) => - setTimeout(resolvePromise, milliseconds), - ); + return new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds)); } -async function writeJsonAtomically( - outputPath: string, - contents: string, -): Promise { +async function writeJsonAtomically(outputPath: string, contents: string): Promise { const outputDirectory = dirname(outputPath); await mkdir(outputDirectory, { recursive: true }); const temporaryPath = `${outputPath}.${process.pid}.${Date.now()}.tmp`; try { await writeFile(temporaryPath, `${contents}\n`, { - encoding: "utf8", - flag: "wx", + encoding: 'utf8', + flag: 'wx', }); await rename(temporaryPath, outputPath); } catch (error) { await unlink(temporaryPath).catch(() => undefined); - throw new Error( - `Failed to write JSON summary to ${JSON.stringify(outputPath)}`, - { cause: error }, - ); + throw new Error(`Failed to write JSON summary to ${JSON.stringify(outputPath)}`, { cause: error }); } } @@ -515,10 +432,7 @@ async function assertModelPath(modelPath: string): Promise { try { modelStat = await stat(modelPath); } catch (error) { - throw new Error( - `Cannot access model directory ${JSON.stringify(modelPath)}`, - { cause: error }, - ); + throw new Error(`Cannot access model directory ${JSON.stringify(modelPath)}`, { cause: error }); } if (!modelStat.isDirectory() && !modelStat.isFile()) { throw new Error(`Model path is neither a directory nor a file: ${modelPath}`); @@ -539,17 +453,11 @@ async function runParent(options: CliOptions): Promise { `${options.runs} measured x ${options.maxNewTokens} tokens; ` + `${options.warmupRuns} warmup; ${options.cooldownSeconds}s cooldown; temperature 0`, ); - console.log("Each sample uses a fresh child process and model load."); + console.log('Each sample uses a fresh child process and model load.'); - const executePhase = async ( - phase: RunReport["phase"], - count: number, - destination: RunReport[], - ) => { + const executePhase = async (phase: RunReport['phase'], count: number, destination: RunReport[]) => { for (let index = 1; index <= count; index++) { - console.log( - `\n${phase === "warmup" ? "Warmup" : "Run"} ${index}/${count}: starting`, - ); + console.log(`\n${phase === 'warmup' ? 'Warmup' : 'Run'} ${index}/${count}: starting`); const report: RunReport = { ...(await runIsolatedSample(options)), phase, @@ -566,8 +474,8 @@ async function runParent(options: CliOptions): Promise { } }; - await executePhase("warmup", options.warmupRuns, warmups); - await executePhase("measured", options.runs, runs); + await executePhase('warmup', options.warmupRuns, warmups); + await executePhase('measured', options.runs, runs); const summary = { schemaVersion: 1, @@ -581,12 +489,12 @@ async function runParent(options: CliOptions): Promise { cooldownSeconds: options.cooldownSeconds, warmupRuns: options.warmupRuns, temperature: 0, - reasoningEffort: "none", + reasoningEffort: 'none', reportPerformance: true, minimumCompletionRatio: MINIMUM_COMPLETION_RATIO, systemPrompt: SYSTEM_PROMPT, prompt: options.prompt, - isolation: "fresh child process and model load per sample", + isolation: 'fresh child process and model load per sample', }, warmups, runs, @@ -598,7 +506,7 @@ async function runParent(options: CliOptions): Promise { await writeJsonAtomically(options.outputPath, summaryJson); console.log(`\nWrote JSON summary to ${options.outputPath}`); } - console.log("\nJSON summary:"); + console.log('\nJSON summary:'); console.log(summaryJson); } @@ -608,7 +516,7 @@ async function main(): Promise { if (options.worker) { const sample = await runWorker(options); - await sendWorkerMessage({ type: "benchmark-model-sample", sample }); + await sendWorkerMessage({ type: 'benchmark-model-sample', sample }); return; } diff --git a/scripts/smoke-model.ts b/scripts/smoke-model.ts index 0f1692718..1588a6d21 100644 --- a/scripts/smoke-model.ts +++ b/scripts/smoke-model.ts @@ -2,12 +2,7 @@ import { resolve } from 'node:path'; -import { - ChatSession, - HarrierModel, - loadModel, - type SessionCapableModel, -} from '@mlx-node/lm'; +import { ChatSession, HarrierModel, loadModel, type SessionCapableModel } from '@mlx-node/lm'; const modelArg = process.argv[2]; if (modelArg == null) { diff --git a/vite.config.ts b/vite.config.ts index 9f050695f..74fdd9f36 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -101,6 +101,8 @@ export default defineConfig({ '/mlx-rs', '/crates/mlx-sys/mlx', '**/*.metal.inc', + // This upstream excerpt is checked byte-for-byte by the GGML provenance test. + 'crates/mlx-core/vendor/ggml/ggml_quants_upstream.inc', ], }, lint: { @@ -114,6 +116,11 @@ export default defineConfig({ '*.rs': ['cargo fmt --all --'], }, test: { + // Vitest v4 compatibility: preserve mock call history. + // Remove after tests no longer rely on calls from setup or earlier tests. + // https://release-v1-0-0-rc-0-viteplus-dev.voidzero-docs.workers.dev/guide/vitest-v5#remove-unneeded-compatibility-settings + // https://vitest.dev/guide/migration/#clearmocks-is-enabled-by-default + clearMocks: false, globals: true, environment: 'node', // Tests must never write inference telemetry to the developer's real diff --git a/yarn.lock b/yarn.lock index 7374bae4b..467e637ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -530,10 +530,10 @@ __metadata: languageName: node linkType: hard -"@blazediff/core@npm:1.9.1": - version: 1.9.1 - resolution: "@blazediff/core@npm:1.9.1" - checksum: 10c0/fd45cdd0544002341d74831a179ef693a81414abd348c1ff0c01086c0ea03f5e5ee284c4e16c2e6fb3670c265f90a3d85752b9360320efa9a835928e604dae77 +"@blazediff/core@npm:1.10.0": + version: 1.10.0 + resolution: "@blazediff/core@npm:1.10.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40blazediff%2Fcore%2F-%2Fcore-1.10.0.tgz" + checksum: 10c0/4b6095d39338fccd09964fff06f6c5a75628e6296cb2d598149376a876b5c390e083a3769e6ebdaeaa53550f29f1ed5744688df66e4f10b0be0bbc41e9393c8c languageName: node linkType: hard @@ -1373,7 +1373,14 @@ __metadata: languageName: node linkType: hard -"@jridgewell/trace-mapping@npm:^0.3.24": +"@jridgewell/sourcemap-codec@npm:^1.6.0": + version: 1.6.0 + resolution: "@jridgewell/sourcemap-codec@npm:1.6.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40jridgewell%2Fsourcemap-codec%2F-%2Fsourcemap-codec-1.6.0.tgz" + checksum: 10c0/b5be700e45a775f218589c3466c4ffea630582b4988657652da464e5ab8a7d18bf928ee4fd2363fb346ede4bea9c6ff0bae05a538358c4565ece6199531b5f72 + languageName: node + linkType: hard + +"@jridgewell/trace-mapping@npm:0.3.31, @jridgewell/trace-mapping@npm:^0.3.24": version: 0.3.31 resolution: "@jridgewell/trace-mapping@npm:0.3.31" dependencies: @@ -3444,17 +3451,17 @@ __metadata: languageName: node linkType: hard -"@oxc-project/runtime@npm:=0.146.0": - version: 0.146.0 - resolution: "@oxc-project/runtime@npm:0.146.0" - checksum: 10c0/2c0a788fb7e34fe74a8c4874bd9a55aec02c24b26de99f3289cf97d974690a714a3f2083c55ce0ef01e4933ecaec192a23b045a40a63ce88fc504a25ed40d127 +"@oxc-project/runtime@npm:=0.151.0": + version: 0.151.0 + resolution: "@oxc-project/runtime@npm:0.151.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxc-project%2Fruntime%2F-%2Fruntime-0.151.0.tgz" + checksum: 10c0/09b0ab7b87184ddd727793e47bb7883262dd2290ed1fccb69660676cc1004e450feb164875a1d12ae13a033a549d8949c624ad9daa1b461db73b7900ab9d469a languageName: node linkType: hard -"@oxc-project/types@npm:=0.146.0": - version: 0.146.0 - resolution: "@oxc-project/types@npm:0.146.0" - checksum: 10c0/15e99d1d4d9233244262779b6e3bbaf7f11a4e62b57c21ef3755acbec469cb90cd468548103fec282af649096e9c6389fd5221c28b268579061352c49b29f4c8 +"@oxc-project/types@npm:=0.151.0": + version: 0.151.0 + resolution: "@oxc-project/types@npm:0.151.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxc-project%2Ftypes%2F-%2Ftypes-0.151.0.tgz" + checksum: 10c0/7dac5399345abf368449735c31c218439e6c3a2acf7b8cbc715a88b45989390208f61bea0f3eb28bee5a034a509a7f4142ae60069e6611b07c70a30867c8ca84 languageName: node linkType: hard @@ -3465,310 +3472,310 @@ __metadata: languageName: node linkType: hard -"@oxfmt/binding-android-arm-eabi@npm:0.64.0": - version: 0.64.0 - resolution: "@oxfmt/binding-android-arm-eabi@npm:0.64.0" +"@oxfmt/binding-android-arm-eabi@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-android-arm-eabi@npm:0.70.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxfmt%2Fbinding-android-arm-eabi%2F-%2Fbinding-android-arm-eabi-0.70.0.tgz" conditions: os=android & cpu=arm languageName: node linkType: hard -"@oxfmt/binding-android-arm64@npm:0.64.0": - version: 0.64.0 - resolution: "@oxfmt/binding-android-arm64@npm:0.64.0" +"@oxfmt/binding-android-arm64@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-android-arm64@npm:0.70.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxfmt%2Fbinding-android-arm64%2F-%2Fbinding-android-arm64-0.70.0.tgz" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@oxfmt/binding-darwin-arm64@npm:0.64.0": - version: 0.64.0 - resolution: "@oxfmt/binding-darwin-arm64@npm:0.64.0" +"@oxfmt/binding-darwin-arm64@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-darwin-arm64@npm:0.70.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxfmt%2Fbinding-darwin-arm64%2F-%2Fbinding-darwin-arm64-0.70.0.tgz" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@oxfmt/binding-darwin-x64@npm:0.64.0": - version: 0.64.0 - resolution: "@oxfmt/binding-darwin-x64@npm:0.64.0" +"@oxfmt/binding-darwin-x64@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-darwin-x64@npm:0.70.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxfmt%2Fbinding-darwin-x64%2F-%2Fbinding-darwin-x64-0.70.0.tgz" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@oxfmt/binding-freebsd-x64@npm:0.64.0": - version: 0.64.0 - resolution: "@oxfmt/binding-freebsd-x64@npm:0.64.0" +"@oxfmt/binding-freebsd-x64@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-freebsd-x64@npm:0.70.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxfmt%2Fbinding-freebsd-x64%2F-%2Fbinding-freebsd-x64-0.70.0.tgz" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@oxfmt/binding-linux-arm-gnueabihf@npm:0.64.0": - version: 0.64.0 - resolution: "@oxfmt/binding-linux-arm-gnueabihf@npm:0.64.0" +"@oxfmt/binding-linux-arm-gnueabihf@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-linux-arm-gnueabihf@npm:0.70.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxfmt%2Fbinding-linux-arm-gnueabihf%2F-%2Fbinding-linux-arm-gnueabihf-0.70.0.tgz" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@oxfmt/binding-linux-arm-musleabihf@npm:0.64.0": - version: 0.64.0 - resolution: "@oxfmt/binding-linux-arm-musleabihf@npm:0.64.0" +"@oxfmt/binding-linux-arm-musleabihf@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-linux-arm-musleabihf@npm:0.70.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxfmt%2Fbinding-linux-arm-musleabihf%2F-%2Fbinding-linux-arm-musleabihf-0.70.0.tgz" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@oxfmt/binding-linux-arm64-gnu@npm:0.64.0": - version: 0.64.0 - resolution: "@oxfmt/binding-linux-arm64-gnu@npm:0.64.0" +"@oxfmt/binding-linux-arm64-gnu@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-linux-arm64-gnu@npm:0.70.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxfmt%2Fbinding-linux-arm64-gnu%2F-%2Fbinding-linux-arm64-gnu-0.70.0.tgz" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@oxfmt/binding-linux-arm64-musl@npm:0.64.0": - version: 0.64.0 - resolution: "@oxfmt/binding-linux-arm64-musl@npm:0.64.0" +"@oxfmt/binding-linux-arm64-musl@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-linux-arm64-musl@npm:0.70.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxfmt%2Fbinding-linux-arm64-musl%2F-%2Fbinding-linux-arm64-musl-0.70.0.tgz" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@oxfmt/binding-linux-ppc64-gnu@npm:0.64.0": - version: 0.64.0 - resolution: "@oxfmt/binding-linux-ppc64-gnu@npm:0.64.0" +"@oxfmt/binding-linux-ppc64-gnu@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-linux-ppc64-gnu@npm:0.70.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxfmt%2Fbinding-linux-ppc64-gnu%2F-%2Fbinding-linux-ppc64-gnu-0.70.0.tgz" conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard -"@oxfmt/binding-linux-riscv64-gnu@npm:0.64.0": - version: 0.64.0 - resolution: "@oxfmt/binding-linux-riscv64-gnu@npm:0.64.0" +"@oxfmt/binding-linux-riscv64-gnu@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-linux-riscv64-gnu@npm:0.70.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxfmt%2Fbinding-linux-riscv64-gnu%2F-%2Fbinding-linux-riscv64-gnu-0.70.0.tgz" conditions: os=linux & cpu=riscv64 & libc=glibc languageName: node linkType: hard -"@oxfmt/binding-linux-riscv64-musl@npm:0.64.0": - version: 0.64.0 - resolution: "@oxfmt/binding-linux-riscv64-musl@npm:0.64.0" +"@oxfmt/binding-linux-riscv64-musl@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-linux-riscv64-musl@npm:0.70.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxfmt%2Fbinding-linux-riscv64-musl%2F-%2Fbinding-linux-riscv64-musl-0.70.0.tgz" conditions: os=linux & cpu=riscv64 & libc=musl languageName: node linkType: hard -"@oxfmt/binding-linux-s390x-gnu@npm:0.64.0": - version: 0.64.0 - resolution: "@oxfmt/binding-linux-s390x-gnu@npm:0.64.0" +"@oxfmt/binding-linux-s390x-gnu@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-linux-s390x-gnu@npm:0.70.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxfmt%2Fbinding-linux-s390x-gnu%2F-%2Fbinding-linux-s390x-gnu-0.70.0.tgz" conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard -"@oxfmt/binding-linux-x64-gnu@npm:0.64.0": - version: 0.64.0 - resolution: "@oxfmt/binding-linux-x64-gnu@npm:0.64.0" +"@oxfmt/binding-linux-x64-gnu@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-linux-x64-gnu@npm:0.70.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxfmt%2Fbinding-linux-x64-gnu%2F-%2Fbinding-linux-x64-gnu-0.70.0.tgz" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@oxfmt/binding-linux-x64-musl@npm:0.64.0": - version: 0.64.0 - resolution: "@oxfmt/binding-linux-x64-musl@npm:0.64.0" +"@oxfmt/binding-linux-x64-musl@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-linux-x64-musl@npm:0.70.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxfmt%2Fbinding-linux-x64-musl%2F-%2Fbinding-linux-x64-musl-0.70.0.tgz" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@oxfmt/binding-openharmony-arm64@npm:0.64.0": - version: 0.64.0 - resolution: "@oxfmt/binding-openharmony-arm64@npm:0.64.0" +"@oxfmt/binding-openharmony-arm64@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-openharmony-arm64@npm:0.70.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxfmt%2Fbinding-openharmony-arm64%2F-%2Fbinding-openharmony-arm64-0.70.0.tgz" conditions: os=openharmony & cpu=arm64 languageName: node linkType: hard -"@oxfmt/binding-win32-arm64-msvc@npm:0.64.0": - version: 0.64.0 - resolution: "@oxfmt/binding-win32-arm64-msvc@npm:0.64.0" +"@oxfmt/binding-win32-arm64-msvc@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-win32-arm64-msvc@npm:0.70.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxfmt%2Fbinding-win32-arm64-msvc%2F-%2Fbinding-win32-arm64-msvc-0.70.0.tgz" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@oxfmt/binding-win32-ia32-msvc@npm:0.64.0": - version: 0.64.0 - resolution: "@oxfmt/binding-win32-ia32-msvc@npm:0.64.0" +"@oxfmt/binding-win32-ia32-msvc@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-win32-ia32-msvc@npm:0.70.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxfmt%2Fbinding-win32-ia32-msvc%2F-%2Fbinding-win32-ia32-msvc-0.70.0.tgz" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@oxfmt/binding-win32-x64-msvc@npm:0.64.0": - version: 0.64.0 - resolution: "@oxfmt/binding-win32-x64-msvc@npm:0.64.0" +"@oxfmt/binding-win32-x64-msvc@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-win32-x64-msvc@npm:0.70.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxfmt%2Fbinding-win32-x64-msvc%2F-%2Fbinding-win32-x64-msvc-0.70.0.tgz" conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"@oxlint-tsgolint/darwin-arm64@npm:7.0.2001": - version: 7.0.2001 - resolution: "@oxlint-tsgolint/darwin-arm64@npm:7.0.2001" +"@oxlint-tsgolint/darwin-arm64@npm:7.0.2002": + version: 7.0.2002 + resolution: "@oxlint-tsgolint/darwin-arm64@npm:7.0.2002::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxlint-tsgolint%2Fdarwin-arm64%2F-%2Fdarwin-arm64-7.0.2002.tgz" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@oxlint-tsgolint/darwin-x64@npm:7.0.2001": - version: 7.0.2001 - resolution: "@oxlint-tsgolint/darwin-x64@npm:7.0.2001" +"@oxlint-tsgolint/darwin-x64@npm:7.0.2002": + version: 7.0.2002 + resolution: "@oxlint-tsgolint/darwin-x64@npm:7.0.2002::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxlint-tsgolint%2Fdarwin-x64%2F-%2Fdarwin-x64-7.0.2002.tgz" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@oxlint-tsgolint/linux-arm64@npm:7.0.2001": - version: 7.0.2001 - resolution: "@oxlint-tsgolint/linux-arm64@npm:7.0.2001" +"@oxlint-tsgolint/linux-arm64@npm:7.0.2002": + version: 7.0.2002 + resolution: "@oxlint-tsgolint/linux-arm64@npm:7.0.2002::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxlint-tsgolint%2Flinux-arm64%2F-%2Flinux-arm64-7.0.2002.tgz" conditions: os=linux & cpu=arm64 languageName: node linkType: hard -"@oxlint-tsgolint/linux-x64@npm:7.0.2001": - version: 7.0.2001 - resolution: "@oxlint-tsgolint/linux-x64@npm:7.0.2001" +"@oxlint-tsgolint/linux-x64@npm:7.0.2002": + version: 7.0.2002 + resolution: "@oxlint-tsgolint/linux-x64@npm:7.0.2002::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxlint-tsgolint%2Flinux-x64%2F-%2Flinux-x64-7.0.2002.tgz" conditions: os=linux & cpu=x64 languageName: node linkType: hard -"@oxlint-tsgolint/win32-arm64@npm:7.0.2001": - version: 7.0.2001 - resolution: "@oxlint-tsgolint/win32-arm64@npm:7.0.2001" +"@oxlint-tsgolint/win32-arm64@npm:7.0.2002": + version: 7.0.2002 + resolution: "@oxlint-tsgolint/win32-arm64@npm:7.0.2002::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxlint-tsgolint%2Fwin32-arm64%2F-%2Fwin32-arm64-7.0.2002.tgz" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@oxlint-tsgolint/win32-x64@npm:7.0.2001": - version: 7.0.2001 - resolution: "@oxlint-tsgolint/win32-x64@npm:7.0.2001" +"@oxlint-tsgolint/win32-x64@npm:7.0.2002": + version: 7.0.2002 + resolution: "@oxlint-tsgolint/win32-x64@npm:7.0.2002::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxlint-tsgolint%2Fwin32-x64%2F-%2Fwin32-x64-7.0.2002.tgz" conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"@oxlint/binding-android-arm-eabi@npm:1.79.0": - version: 1.79.0 - resolution: "@oxlint/binding-android-arm-eabi@npm:1.79.0" +"@oxlint/binding-android-arm-eabi@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-android-arm-eabi@npm:1.85.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxlint%2Fbinding-android-arm-eabi%2F-%2Fbinding-android-arm-eabi-1.85.0.tgz" conditions: os=android & cpu=arm languageName: node linkType: hard -"@oxlint/binding-android-arm64@npm:1.79.0": - version: 1.79.0 - resolution: "@oxlint/binding-android-arm64@npm:1.79.0" +"@oxlint/binding-android-arm64@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-android-arm64@npm:1.85.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxlint%2Fbinding-android-arm64%2F-%2Fbinding-android-arm64-1.85.0.tgz" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@oxlint/binding-darwin-arm64@npm:1.79.0": - version: 1.79.0 - resolution: "@oxlint/binding-darwin-arm64@npm:1.79.0" +"@oxlint/binding-darwin-arm64@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-darwin-arm64@npm:1.85.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxlint%2Fbinding-darwin-arm64%2F-%2Fbinding-darwin-arm64-1.85.0.tgz" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@oxlint/binding-darwin-x64@npm:1.79.0": - version: 1.79.0 - resolution: "@oxlint/binding-darwin-x64@npm:1.79.0" +"@oxlint/binding-darwin-x64@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-darwin-x64@npm:1.85.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxlint%2Fbinding-darwin-x64%2F-%2Fbinding-darwin-x64-1.85.0.tgz" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@oxlint/binding-freebsd-x64@npm:1.79.0": - version: 1.79.0 - resolution: "@oxlint/binding-freebsd-x64@npm:1.79.0" +"@oxlint/binding-freebsd-x64@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-freebsd-x64@npm:1.85.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxlint%2Fbinding-freebsd-x64%2F-%2Fbinding-freebsd-x64-1.85.0.tgz" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@oxlint/binding-linux-arm-gnueabihf@npm:1.79.0": - version: 1.79.0 - resolution: "@oxlint/binding-linux-arm-gnueabihf@npm:1.79.0" +"@oxlint/binding-linux-arm-gnueabihf@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-linux-arm-gnueabihf@npm:1.85.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxlint%2Fbinding-linux-arm-gnueabihf%2F-%2Fbinding-linux-arm-gnueabihf-1.85.0.tgz" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@oxlint/binding-linux-arm-musleabihf@npm:1.79.0": - version: 1.79.0 - resolution: "@oxlint/binding-linux-arm-musleabihf@npm:1.79.0" +"@oxlint/binding-linux-arm-musleabihf@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-linux-arm-musleabihf@npm:1.85.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxlint%2Fbinding-linux-arm-musleabihf%2F-%2Fbinding-linux-arm-musleabihf-1.85.0.tgz" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@oxlint/binding-linux-arm64-gnu@npm:1.79.0": - version: 1.79.0 - resolution: "@oxlint/binding-linux-arm64-gnu@npm:1.79.0" +"@oxlint/binding-linux-arm64-gnu@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-linux-arm64-gnu@npm:1.85.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxlint%2Fbinding-linux-arm64-gnu%2F-%2Fbinding-linux-arm64-gnu-1.85.0.tgz" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@oxlint/binding-linux-arm64-musl@npm:1.79.0": - version: 1.79.0 - resolution: "@oxlint/binding-linux-arm64-musl@npm:1.79.0" +"@oxlint/binding-linux-arm64-musl@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-linux-arm64-musl@npm:1.85.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxlint%2Fbinding-linux-arm64-musl%2F-%2Fbinding-linux-arm64-musl-1.85.0.tgz" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@oxlint/binding-linux-ppc64-gnu@npm:1.79.0": - version: 1.79.0 - resolution: "@oxlint/binding-linux-ppc64-gnu@npm:1.79.0" +"@oxlint/binding-linux-ppc64-gnu@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-linux-ppc64-gnu@npm:1.85.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxlint%2Fbinding-linux-ppc64-gnu%2F-%2Fbinding-linux-ppc64-gnu-1.85.0.tgz" conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard -"@oxlint/binding-linux-riscv64-gnu@npm:1.79.0": - version: 1.79.0 - resolution: "@oxlint/binding-linux-riscv64-gnu@npm:1.79.0" +"@oxlint/binding-linux-riscv64-gnu@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-linux-riscv64-gnu@npm:1.85.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxlint%2Fbinding-linux-riscv64-gnu%2F-%2Fbinding-linux-riscv64-gnu-1.85.0.tgz" conditions: os=linux & cpu=riscv64 & libc=glibc languageName: node linkType: hard -"@oxlint/binding-linux-riscv64-musl@npm:1.79.0": - version: 1.79.0 - resolution: "@oxlint/binding-linux-riscv64-musl@npm:1.79.0" +"@oxlint/binding-linux-riscv64-musl@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-linux-riscv64-musl@npm:1.85.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxlint%2Fbinding-linux-riscv64-musl%2F-%2Fbinding-linux-riscv64-musl-1.85.0.tgz" conditions: os=linux & cpu=riscv64 & libc=musl languageName: node linkType: hard -"@oxlint/binding-linux-s390x-gnu@npm:1.79.0": - version: 1.79.0 - resolution: "@oxlint/binding-linux-s390x-gnu@npm:1.79.0" +"@oxlint/binding-linux-s390x-gnu@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-linux-s390x-gnu@npm:1.85.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxlint%2Fbinding-linux-s390x-gnu%2F-%2Fbinding-linux-s390x-gnu-1.85.0.tgz" conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard -"@oxlint/binding-linux-x64-gnu@npm:1.79.0": - version: 1.79.0 - resolution: "@oxlint/binding-linux-x64-gnu@npm:1.79.0" +"@oxlint/binding-linux-x64-gnu@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-linux-x64-gnu@npm:1.85.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxlint%2Fbinding-linux-x64-gnu%2F-%2Fbinding-linux-x64-gnu-1.85.0.tgz" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@oxlint/binding-linux-x64-musl@npm:1.79.0": - version: 1.79.0 - resolution: "@oxlint/binding-linux-x64-musl@npm:1.79.0" +"@oxlint/binding-linux-x64-musl@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-linux-x64-musl@npm:1.85.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxlint%2Fbinding-linux-x64-musl%2F-%2Fbinding-linux-x64-musl-1.85.0.tgz" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@oxlint/binding-openharmony-arm64@npm:1.79.0": - version: 1.79.0 - resolution: "@oxlint/binding-openharmony-arm64@npm:1.79.0" +"@oxlint/binding-openharmony-arm64@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-openharmony-arm64@npm:1.85.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxlint%2Fbinding-openharmony-arm64%2F-%2Fbinding-openharmony-arm64-1.85.0.tgz" conditions: os=openharmony & cpu=arm64 languageName: node linkType: hard -"@oxlint/binding-win32-arm64-msvc@npm:1.79.0": - version: 1.79.0 - resolution: "@oxlint/binding-win32-arm64-msvc@npm:1.79.0" +"@oxlint/binding-win32-arm64-msvc@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-win32-arm64-msvc@npm:1.85.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxlint%2Fbinding-win32-arm64-msvc%2F-%2Fbinding-win32-arm64-msvc-1.85.0.tgz" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@oxlint/binding-win32-ia32-msvc@npm:1.79.0": - version: 1.79.0 - resolution: "@oxlint/binding-win32-ia32-msvc@npm:1.79.0" +"@oxlint/binding-win32-ia32-msvc@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-win32-ia32-msvc@npm:1.85.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxlint%2Fbinding-win32-ia32-msvc%2F-%2Fbinding-win32-ia32-msvc-1.85.0.tgz" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@oxlint/binding-win32-x64-msvc@npm:1.79.0": - version: 1.79.0 - resolution: "@oxlint/binding-win32-x64-msvc@npm:1.79.0" +"@oxlint/binding-win32-x64-msvc@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-win32-x64-msvc@npm:1.85.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40oxlint%2Fbinding-win32-x64-msvc%2F-%2Fbinding-win32-x64-msvc-1.85.0.tgz" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -4750,12 +4757,12 @@ __metadata: languageName: node linkType: hard -"@testing-library/user-event@npm:^14.6.1": - version: 14.6.6 - resolution: "@testing-library/user-event@npm:14.6.6" +"@testing-library/user-event@npm:^14.6.6": + version: 14.6.7 + resolution: "@testing-library/user-event@npm:14.6.7::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40testing-library%2Fuser-event%2F-%2Fuser-event-14.6.7.tgz" peerDependencies: "@testing-library/dom": ">=7.21.4" - checksum: 10c0/2cb82d3a364a2a29da19bb5d2a614aedaa474ac6f599a42fc28c1926056f7b91f5cec37b33452bafcb3f6b37a950024da289dea88ead3c2056c6005c91c7f942 + checksum: 10c0/21f5e01bea2699b069947ae06d0b0fbabe0e90d2a2206b8f56a3f46126d09bd85320cba3fd3910c4d18710b1873e6966fb9021668966c615aaa14bf35d7df3ef languageName: node linkType: hard @@ -5240,58 +5247,46 @@ __metadata: languageName: node linkType: hard -"@vitest/browser-preview@npm:4.1.11": - version: 4.1.11 - resolution: "@vitest/browser-preview@npm:4.1.11" +"@vitest/browser-preview@npm:5.0.1": + version: 5.0.1 + resolution: "@vitest/browser-preview@npm:5.0.1::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40vitest%2Fbrowser-preview%2F-%2Fbrowser-preview-5.0.1.tgz" dependencies: "@testing-library/dom": "npm:^10.4.1" - "@testing-library/user-event": "npm:^14.6.1" - "@vitest/browser": "npm:4.1.11" + "@testing-library/user-event": "npm:^14.6.6" + "@vitest/browser": "npm:5.0.1" peerDependencies: - vitest: 4.1.11 - checksum: 10c0/830f3a173a12d379e4a525820d1139d7ba3ed9d0ab920dfb39aa3465a9f471eec9bd3540c63e52221193fc092a5c25bd2286ed9f5a734cd757cc8a7287f7b242 + vitest: 5.0.1 + checksum: 10c0/a15afd64908f23d026c4426913efc1b11b104bc0ffe04300a31b65bb41a864622a0b7ac69383d898ed4a07ba62699055abdf302b66547fa85794ef4f49a6552f languageName: node linkType: hard -"@vitest/browser@npm:4.1.11": - version: 4.1.11 - resolution: "@vitest/browser@npm:4.1.11" +"@vitest/browser@npm:5.0.1": + version: 5.0.1 + resolution: "@vitest/browser@npm:5.0.1::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40vitest%2Fbrowser%2F-%2Fbrowser-5.0.1.tgz" dependencies: - "@blazediff/core": "npm:1.9.1" - "@vitest/mocker": "npm:4.1.11" - "@vitest/utils": "npm:4.1.11" - magic-string: "npm:^0.30.21" + "@blazediff/core": "npm:1.10.0" + "@vitest/mocker": "npm:5.0.1" + "@vitest/ui": "npm:5.0.1" + "@vitest/utils": "npm:5.0.1" + magic-string: "npm:^1.2.3" pngjs: "npm:^7.0.0" sirv: "npm:^3.0.2" - tinyrainbow: "npm:^3.1.0" - ws: "npm:^8.19.0" + tinyrainbow: "npm:^3.1.1" + ws: "npm:^8.21.3" peerDependencies: - vitest: 4.1.11 - checksum: 10c0/a16b95d043650e82e0706131454d259e660e11fca01d2ce9e336320a482626b2175051e12a93ea18efaf9a794f6cce221919fe59fe27fba04bd9f0e253e6957e + vitest: 5.0.1 + checksum: 10c0/1fb73d5140aebbf9bbc93b4d09077051689afa7a8bbb45ae22f2136f3eddd1bcb6f44a63569b5fb09dded1846e79e9f3dd6e05a055d05af75de780ef6056a3e9 languageName: node linkType: hard -"@vitest/expect@npm:4.1.11": - version: 4.1.11 - resolution: "@vitest/expect@npm:4.1.11" - dependencies: - "@standard-schema/spec": "npm:^1.1.0" - "@types/chai": "npm:^5.2.2" - "@vitest/spy": "npm:4.1.11" - "@vitest/utils": "npm:4.1.11" - chai: "npm:^6.2.2" - tinyrainbow: "npm:^3.1.0" - checksum: 10c0/0aa5e0973aca93a58cbdc3041c6bfed5897e976124965203b96a23b811f4ca403590c7eb15802c8d8366ec27a1db0682451e8a91c4d06617636de262baf86e4b - languageName: node - linkType: hard - -"@vitest/mocker@npm:4.1.11": - version: 4.1.11 - resolution: "@vitest/mocker@npm:4.1.11" +"@vitest/mocker@npm:5.0.1": + version: 5.0.1 + resolution: "@vitest/mocker@npm:5.0.1::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40vitest%2Fmocker%2F-%2Fmocker-5.0.1.tgz" dependencies: - "@vitest/spy": "npm:4.1.11" + "@jridgewell/trace-mapping": "npm:0.3.31" + "@vitest/spy": "npm:5.0.1" estree-walker: "npm:^3.0.3" - magic-string: "npm:^0.30.21" + magic-string: "npm:^1.2.3" peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -5300,206 +5295,117 @@ __metadata: optional: true vite: optional: true - checksum: 10c0/3111ea34bd5046f6c70bbd67cf8b89608ee8c41cb27ab2bd61d42f4b68810e9ea16a9a757a71bc254c105f73b407d00ebb6bab1ab0f7f5cdcc9d7d16602a3933 + checksum: 10c0/21f0456c7452d7f65449fc0f43f1ccbf222d353eb11fcf511b78b5b8c4ae38ac9ca286b56936d91856c483575cd86688f1b6dc00f02ffd85ed1108b12cf219cd languageName: node linkType: hard -"@vitest/pretty-format@npm:4.1.11": - version: 4.1.11 - resolution: "@vitest/pretty-format@npm:4.1.11" +"@vitest/pretty-format@npm:5.0.1": + version: 5.0.1 + resolution: "@vitest/pretty-format@npm:5.0.1::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40vitest%2Fpretty-format%2F-%2Fpretty-format-5.0.1.tgz" dependencies: - tinyrainbow: "npm:^3.1.0" - checksum: 10c0/ad32525c73807c0b72f38dc29bc51fd5a17879dc650f37995a9c5adbb8526e15f787691f76aa8768448ec7ed5bf5ba2b12329eac8fda1428b4d6b8c036390f71 + tinyrainbow: "npm:^3.1.1" + checksum: 10c0/1fb025ae15b02b683b8ef8bd00babb585c6462bf9bc19985f39a8124f9d46489da943484a4c058b49123165489be5cb9bedac4ea41bf7c525380fff5e2182a0e languageName: node linkType: hard -"@vitest/runner@npm:4.1.11": - version: 4.1.11 - resolution: "@vitest/runner@npm:4.1.11" +"@vitest/snapshot@npm:5.0.1": + version: 5.0.1 + resolution: "@vitest/snapshot@npm:5.0.1::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40vitest%2Fsnapshot%2F-%2Fsnapshot-5.0.1.tgz" dependencies: - "@vitest/utils": "npm:4.1.11" + "@vitest/pretty-format": "npm:5.0.1" + "@vitest/utils": "npm:5.0.1" + magic-string: "npm:^1.2.3" pathe: "npm:^2.0.3" - checksum: 10c0/3c782b055e9e688e1785f7c8937bd1669bad1b0e5758cb8b844f918f30a324b1d21e174d46c941ce80ebf37f2b89b55b6d619a675025914a1ece6377b95909e2 + checksum: 10c0/a29c658fb3b22e1787f11236648256fd79cfbea13ace28d35678d14f2ecc60cf511be4b2f9500a64ad6dbfdc48e81a2cfd20fad3419b19011a6e73040b2acaf8 languageName: node linkType: hard -"@vitest/snapshot@npm:4.1.11": - version: 4.1.11 - resolution: "@vitest/snapshot@npm:4.1.11" - dependencies: - "@vitest/pretty-format": "npm:4.1.11" - "@vitest/utils": "npm:4.1.11" - magic-string: "npm:^0.30.21" - pathe: "npm:^2.0.3" - checksum: 10c0/35d82a7c2a3e4b57529c30387d568d9106b6bf960189214e5d8cb1f5288cb042305c2e5c7b0b2f8cb56a2c814973de0310bc56d72deb79427bea272b6224190c +"@vitest/spy@npm:5.0.1": + version: 5.0.1 + resolution: "@vitest/spy@npm:5.0.1::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40vitest%2Fspy%2F-%2Fspy-5.0.1.tgz" + checksum: 10c0/88838f6dda5237f0a40c365598ce7112922611e9157b390ca90c11760ae8697c37214f621263bfbe12e2bd27234df577cbbecc93de003ced366d23be24383c37 languageName: node linkType: hard -"@vitest/spy@npm:4.1.11": - version: 4.1.11 - resolution: "@vitest/spy@npm:4.1.11" - checksum: 10c0/06c68247a8efd21006abe7532fee17f30ba83c8cda3e0b952ef89e278d58058f4b1de69b6bbaa2ed614c568bf4f5769fcc76be42802dedf2bcdd9c0aae601740 +"@vitest/ui@npm:5.0.1": + version: 5.0.1 + resolution: "@vitest/ui@npm:5.0.1::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40vitest%2Fui%2F-%2Fui-5.0.1.tgz" + dependencies: + "@vitest/utils": "npm:5.0.1" + fflate: "npm:^0.8.3" + flatted: "npm:^3.4.4" + pathe: "npm:^2.0.3" + sirv: "npm:^3.0.2" + tinyrainbow: "npm:^3.1.1" + peerDependencies: + vitest: 5.0.1 + checksum: 10c0/24db3f5c27080c54841cd32ba0c82f9df59066923f860a7a26ff9c33c17f3246e5f6d0dc86ea214b5d06d986d7f54a23e5ee45a7013bc5dccccee48a67e44490 languageName: node linkType: hard -"@vitest/utils@npm:4.1.11": - version: 4.1.11 - resolution: "@vitest/utils@npm:4.1.11" +"@vitest/utils@npm:5.0.1": + version: 5.0.1 + resolution: "@vitest/utils@npm:5.0.1::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40vitest%2Futils%2F-%2Futils-5.0.1.tgz" dependencies: - "@vitest/pretty-format": "npm:4.1.11" + "@vitest/pretty-format": "npm:5.0.1" convert-source-map: "npm:^2.0.0" - tinyrainbow: "npm:^3.1.0" - checksum: 10c0/a2c1ddc64333458c3e031465c1ee0440a7660fd1b298c54ccbf865ef1e5cf3ebdd6c5c75a5ea235d8a672498b394e7121f992b096f021f45014ab25e9abd1b52 + tinyrainbow: "npm:^3.1.1" + checksum: 10c0/c16c5d0cf3795e5c3bc46212bc03a4192fb2e515cf51e1465188578d591a1feeacb911c0d554ebbb6e1dcf115e96a528b6175dedf0b2a4f8b4ce4a5ce61ab5bd languageName: node linkType: hard -"@voidzero-dev/vite-plus-core@npm:0.3.0, vite@npm:@voidzero-dev/vite-plus-core@0.3.0": - version: 0.3.0 - resolution: "@voidzero-dev/vite-plus-core@npm:0.3.0" - dependencies: - "@oxc-project/runtime": "npm:=0.146.0" - "@oxc-project/types": "npm:=0.146.0" - "@voidzero-dev/vite-plus-darwin-arm64": "npm:0.3.0" - "@voidzero-dev/vite-plus-darwin-x64": "npm:0.3.0" - "@voidzero-dev/vite-plus-linux-arm64-gnu": "npm:0.3.0" - "@voidzero-dev/vite-plus-linux-arm64-musl": "npm:0.3.0" - "@voidzero-dev/vite-plus-linux-x64-gnu": "npm:0.3.0" - "@voidzero-dev/vite-plus-linux-x64-musl": "npm:0.3.0" - "@voidzero-dev/vite-plus-win32-arm64-msvc": "npm:0.3.0" - "@voidzero-dev/vite-plus-win32-x64-msvc": "npm:0.3.0" - fsevents: "npm:~2.3.3" - lightningcss: "npm:^1.33.0" - postcss: "npm:^8.5.6" - yuku-codegen: "npm:^0.5.44" - yuku-parser: "npm:^0.5.44" - peerDependencies: - "@arethetypeswrong/core": ^0.18.1 - "@types/node": ^20.19.0 || >=22.12.0 - "@vitejs/devtools": ^0.4.0 || ^0.5.0 - esbuild: ^0.27.0 || ^0.28.0 - jiti: ">=1.21.0" - less: ^4.0.0 - publint: ^0.3.8 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: ">=0.54.8" - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 - unplugin-unused: ^0.5.0 - unrun: "*" - yaml: ^2.4.2 - dependenciesMeta: - "@voidzero-dev/vite-plus-darwin-arm64": - optional: true - "@voidzero-dev/vite-plus-darwin-x64": - optional: true - "@voidzero-dev/vite-plus-linux-arm64-gnu": - optional: true - "@voidzero-dev/vite-plus-linux-arm64-musl": - optional: true - "@voidzero-dev/vite-plus-linux-x64-gnu": - optional: true - "@voidzero-dev/vite-plus-linux-x64-musl": - optional: true - "@voidzero-dev/vite-plus-win32-arm64-msvc": - optional: true - "@voidzero-dev/vite-plus-win32-x64-msvc": - optional: true - fsevents: - optional: true - peerDependenciesMeta: - "@arethetypeswrong/core": - optional: true - "@types/node": - optional: true - "@vitejs/devtools": - optional: true - esbuild: - optional: true - jiti: - optional: true - less: - optional: true - publint: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - typescript: - optional: true - unplugin-unused: - optional: true - unrun: - optional: true - yaml: - optional: true - checksum: 10c0/ddc2683f3bf2a178b417a30457c557d73133286291c51592f2f557a733a6cdfe17d1432ae54e752f3c4861cc9ef1b6e4e85f28e4625fe2b2d92cba6a4c8142e1 - languageName: node - linkType: hard - -"@voidzero-dev/vite-plus-darwin-arm64@npm:0.3.0": - version: 0.3.0 - resolution: "@voidzero-dev/vite-plus-darwin-arm64@npm:0.3.0" +"@voidzero-dev/vite-plus-darwin-arm64@npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62": + version: 0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62 + resolution: "@voidzero-dev/vite-plus-darwin-arm64@npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62::__archiveUrl=https%3A%2F%2Fregistry-bridge.viteplus.dev%2Ftarballs%2F%40voidzero-dev%2Fvite-plus-darwin-arm64%2F0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62%2F224428aaabccb80a2d89c48cc178d90431f0b0f4.tgz" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@voidzero-dev/vite-plus-darwin-x64@npm:0.3.0": - version: 0.3.0 - resolution: "@voidzero-dev/vite-plus-darwin-x64@npm:0.3.0" +"@voidzero-dev/vite-plus-darwin-x64@npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62": + version: 0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62 + resolution: "@voidzero-dev/vite-plus-darwin-x64@npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62::__archiveUrl=https%3A%2F%2Fregistry-bridge.viteplus.dev%2Ftarballs%2F%40voidzero-dev%2Fvite-plus-darwin-x64%2F0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62%2Ffc067f0155412cce599e8adc77e1ee74a49effea.tgz" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@voidzero-dev/vite-plus-linux-arm64-gnu@npm:0.3.0": - version: 0.3.0 - resolution: "@voidzero-dev/vite-plus-linux-arm64-gnu@npm:0.3.0" +"@voidzero-dev/vite-plus-linux-arm64-gnu@npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62": + version: 0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62 + resolution: "@voidzero-dev/vite-plus-linux-arm64-gnu@npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62::__archiveUrl=https%3A%2F%2Fregistry-bridge.viteplus.dev%2Ftarballs%2F%40voidzero-dev%2Fvite-plus-linux-arm64-gnu%2F0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62%2F1d6df80655cd88a546d340d3cb2e0332e085d60c.tgz" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@voidzero-dev/vite-plus-linux-arm64-musl@npm:0.3.0": - version: 0.3.0 - resolution: "@voidzero-dev/vite-plus-linux-arm64-musl@npm:0.3.0" +"@voidzero-dev/vite-plus-linux-arm64-musl@npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62": + version: 0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62 + resolution: "@voidzero-dev/vite-plus-linux-arm64-musl@npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62::__archiveUrl=https%3A%2F%2Fregistry-bridge.viteplus.dev%2Ftarballs%2F%40voidzero-dev%2Fvite-plus-linux-arm64-musl%2F0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62%2F94305d1b3b601d70f065bb19202da119f1237d5a.tgz" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@voidzero-dev/vite-plus-linux-x64-gnu@npm:0.3.0": - version: 0.3.0 - resolution: "@voidzero-dev/vite-plus-linux-x64-gnu@npm:0.3.0" +"@voidzero-dev/vite-plus-linux-x64-gnu@npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62": + version: 0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62 + resolution: "@voidzero-dev/vite-plus-linux-x64-gnu@npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62::__archiveUrl=https%3A%2F%2Fregistry-bridge.viteplus.dev%2Ftarballs%2F%40voidzero-dev%2Fvite-plus-linux-x64-gnu%2F0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62%2F34a1bad544cf61f0e0f1f81af6ff9c9022d5b8bf.tgz" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@voidzero-dev/vite-plus-linux-x64-musl@npm:0.3.0": - version: 0.3.0 - resolution: "@voidzero-dev/vite-plus-linux-x64-musl@npm:0.3.0" +"@voidzero-dev/vite-plus-linux-x64-musl@npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62": + version: 0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62 + resolution: "@voidzero-dev/vite-plus-linux-x64-musl@npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62::__archiveUrl=https%3A%2F%2Fregistry-bridge.viteplus.dev%2Ftarballs%2F%40voidzero-dev%2Fvite-plus-linux-x64-musl%2F0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62%2F680e1cbadf5449d340d0b6f786b4b7353f8dac50.tgz" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@voidzero-dev/vite-plus-win32-arm64-msvc@npm:0.3.0": - version: 0.3.0 - resolution: "@voidzero-dev/vite-plus-win32-arm64-msvc@npm:0.3.0" +"@voidzero-dev/vite-plus-win32-arm64-msvc@npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62": + version: 0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62 + resolution: "@voidzero-dev/vite-plus-win32-arm64-msvc@npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62::__archiveUrl=https%3A%2F%2Fregistry-bridge.viteplus.dev%2Ftarballs%2F%40voidzero-dev%2Fvite-plus-win32-arm64-msvc%2F0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62%2F870036960974b661505288b7fe9db553868b137e.tgz" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@voidzero-dev/vite-plus-win32-x64-msvc@npm:0.3.0": - version: 0.3.0 - resolution: "@voidzero-dev/vite-plus-win32-x64-msvc@npm:0.3.0" +"@voidzero-dev/vite-plus-win32-x64-msvc@npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62": + version: 0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62 + resolution: "@voidzero-dev/vite-plus-win32-x64-msvc@npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62::__archiveUrl=https%3A%2F%2Fregistry-bridge.viteplus.dev%2Ftarballs%2F%40voidzero-dev%2Fvite-plus-win32-x64-msvc%2F0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62%2F5a609289a18bf85102f9c4741f00e3870fc7c9ec.tgz" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -5609,164 +5515,178 @@ __metadata: languageName: node linkType: hard -"@yuku-codegen/binding-darwin-arm64@npm:0.5.48": - version: 0.5.48 - resolution: "@yuku-codegen/binding-darwin-arm64@npm:0.5.48" +"@yuku-codegen/binding-android-arm64@npm:0.9.5": + version: 0.9.5 + resolution: "@yuku-codegen/binding-android-arm64@npm:0.9.5::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40yuku-codegen%2Fbinding-android-arm64%2F-%2Fbinding-android-arm64-0.9.5.tgz" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@yuku-codegen/binding-darwin-arm64@npm:0.9.5": + version: 0.9.5 + resolution: "@yuku-codegen/binding-darwin-arm64@npm:0.9.5::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40yuku-codegen%2Fbinding-darwin-arm64%2F-%2Fbinding-darwin-arm64-0.9.5.tgz" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@yuku-codegen/binding-darwin-x64@npm:0.5.48": - version: 0.5.48 - resolution: "@yuku-codegen/binding-darwin-x64@npm:0.5.48" +"@yuku-codegen/binding-darwin-x64@npm:0.9.5": + version: 0.9.5 + resolution: "@yuku-codegen/binding-darwin-x64@npm:0.9.5::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40yuku-codegen%2Fbinding-darwin-x64%2F-%2Fbinding-darwin-x64-0.9.5.tgz" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@yuku-codegen/binding-freebsd-x64@npm:0.5.48": - version: 0.5.48 - resolution: "@yuku-codegen/binding-freebsd-x64@npm:0.5.48" +"@yuku-codegen/binding-freebsd-x64@npm:0.9.5": + version: 0.9.5 + resolution: "@yuku-codegen/binding-freebsd-x64@npm:0.9.5::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40yuku-codegen%2Fbinding-freebsd-x64%2F-%2Fbinding-freebsd-x64-0.9.5.tgz" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@yuku-codegen/binding-linux-arm-gnu@npm:0.5.48": - version: 0.5.48 - resolution: "@yuku-codegen/binding-linux-arm-gnu@npm:0.5.48" +"@yuku-codegen/binding-linux-arm-gnu@npm:0.9.5": + version: 0.9.5 + resolution: "@yuku-codegen/binding-linux-arm-gnu@npm:0.9.5::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40yuku-codegen%2Fbinding-linux-arm-gnu%2F-%2Fbinding-linux-arm-gnu-0.9.5.tgz" conditions: os=linux & cpu=arm & libc=glibc languageName: node linkType: hard -"@yuku-codegen/binding-linux-arm-musl@npm:0.5.48": - version: 0.5.48 - resolution: "@yuku-codegen/binding-linux-arm-musl@npm:0.5.48" +"@yuku-codegen/binding-linux-arm-musl@npm:0.9.5": + version: 0.9.5 + resolution: "@yuku-codegen/binding-linux-arm-musl@npm:0.9.5::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40yuku-codegen%2Fbinding-linux-arm-musl%2F-%2Fbinding-linux-arm-musl-0.9.5.tgz" conditions: os=linux & cpu=arm & libc=musl languageName: node linkType: hard -"@yuku-codegen/binding-linux-arm64-gnu@npm:0.5.48": - version: 0.5.48 - resolution: "@yuku-codegen/binding-linux-arm64-gnu@npm:0.5.48" +"@yuku-codegen/binding-linux-arm64-gnu@npm:0.9.5": + version: 0.9.5 + resolution: "@yuku-codegen/binding-linux-arm64-gnu@npm:0.9.5::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40yuku-codegen%2Fbinding-linux-arm64-gnu%2F-%2Fbinding-linux-arm64-gnu-0.9.5.tgz" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@yuku-codegen/binding-linux-arm64-musl@npm:0.5.48": - version: 0.5.48 - resolution: "@yuku-codegen/binding-linux-arm64-musl@npm:0.5.48" +"@yuku-codegen/binding-linux-arm64-musl@npm:0.9.5": + version: 0.9.5 + resolution: "@yuku-codegen/binding-linux-arm64-musl@npm:0.9.5::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40yuku-codegen%2Fbinding-linux-arm64-musl%2F-%2Fbinding-linux-arm64-musl-0.9.5.tgz" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@yuku-codegen/binding-linux-x64-gnu@npm:0.5.48": - version: 0.5.48 - resolution: "@yuku-codegen/binding-linux-x64-gnu@npm:0.5.48" +"@yuku-codegen/binding-linux-x64-gnu@npm:0.9.5": + version: 0.9.5 + resolution: "@yuku-codegen/binding-linux-x64-gnu@npm:0.9.5::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40yuku-codegen%2Fbinding-linux-x64-gnu%2F-%2Fbinding-linux-x64-gnu-0.9.5.tgz" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@yuku-codegen/binding-linux-x64-musl@npm:0.5.48": - version: 0.5.48 - resolution: "@yuku-codegen/binding-linux-x64-musl@npm:0.5.48" +"@yuku-codegen/binding-linux-x64-musl@npm:0.9.5": + version: 0.9.5 + resolution: "@yuku-codegen/binding-linux-x64-musl@npm:0.9.5::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40yuku-codegen%2Fbinding-linux-x64-musl%2F-%2Fbinding-linux-x64-musl-0.9.5.tgz" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@yuku-codegen/binding-win32-arm64@npm:0.5.48": - version: 0.5.48 - resolution: "@yuku-codegen/binding-win32-arm64@npm:0.5.48" +"@yuku-codegen/binding-win32-arm64@npm:0.9.5": + version: 0.9.5 + resolution: "@yuku-codegen/binding-win32-arm64@npm:0.9.5::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40yuku-codegen%2Fbinding-win32-arm64%2F-%2Fbinding-win32-arm64-0.9.5.tgz" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@yuku-codegen/binding-win32-x64@npm:0.5.48": - version: 0.5.48 - resolution: "@yuku-codegen/binding-win32-x64@npm:0.5.48" +"@yuku-codegen/binding-win32-x64@npm:0.9.5": + version: 0.9.5 + resolution: "@yuku-codegen/binding-win32-x64@npm:0.9.5::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40yuku-codegen%2Fbinding-win32-x64%2F-%2Fbinding-win32-x64-0.9.5.tgz" conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"@yuku-parser/binding-darwin-arm64@npm:0.5.48": - version: 0.5.48 - resolution: "@yuku-parser/binding-darwin-arm64@npm:0.5.48" +"@yuku-parser/binding-android-arm64@npm:0.9.5": + version: 0.9.5 + resolution: "@yuku-parser/binding-android-arm64@npm:0.9.5::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40yuku-parser%2Fbinding-android-arm64%2F-%2Fbinding-android-arm64-0.9.5.tgz" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@yuku-parser/binding-darwin-arm64@npm:0.9.5": + version: 0.9.5 + resolution: "@yuku-parser/binding-darwin-arm64@npm:0.9.5::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40yuku-parser%2Fbinding-darwin-arm64%2F-%2Fbinding-darwin-arm64-0.9.5.tgz" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@yuku-parser/binding-darwin-x64@npm:0.5.48": - version: 0.5.48 - resolution: "@yuku-parser/binding-darwin-x64@npm:0.5.48" +"@yuku-parser/binding-darwin-x64@npm:0.9.5": + version: 0.9.5 + resolution: "@yuku-parser/binding-darwin-x64@npm:0.9.5::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40yuku-parser%2Fbinding-darwin-x64%2F-%2Fbinding-darwin-x64-0.9.5.tgz" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@yuku-parser/binding-freebsd-x64@npm:0.5.48": - version: 0.5.48 - resolution: "@yuku-parser/binding-freebsd-x64@npm:0.5.48" +"@yuku-parser/binding-freebsd-x64@npm:0.9.5": + version: 0.9.5 + resolution: "@yuku-parser/binding-freebsd-x64@npm:0.9.5::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40yuku-parser%2Fbinding-freebsd-x64%2F-%2Fbinding-freebsd-x64-0.9.5.tgz" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@yuku-parser/binding-linux-arm-gnu@npm:0.5.48": - version: 0.5.48 - resolution: "@yuku-parser/binding-linux-arm-gnu@npm:0.5.48" +"@yuku-parser/binding-linux-arm-gnu@npm:0.9.5": + version: 0.9.5 + resolution: "@yuku-parser/binding-linux-arm-gnu@npm:0.9.5::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40yuku-parser%2Fbinding-linux-arm-gnu%2F-%2Fbinding-linux-arm-gnu-0.9.5.tgz" conditions: os=linux & cpu=arm & libc=glibc languageName: node linkType: hard -"@yuku-parser/binding-linux-arm-musl@npm:0.5.48": - version: 0.5.48 - resolution: "@yuku-parser/binding-linux-arm-musl@npm:0.5.48" +"@yuku-parser/binding-linux-arm-musl@npm:0.9.5": + version: 0.9.5 + resolution: "@yuku-parser/binding-linux-arm-musl@npm:0.9.5::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40yuku-parser%2Fbinding-linux-arm-musl%2F-%2Fbinding-linux-arm-musl-0.9.5.tgz" conditions: os=linux & cpu=arm & libc=musl languageName: node linkType: hard -"@yuku-parser/binding-linux-arm64-gnu@npm:0.5.48": - version: 0.5.48 - resolution: "@yuku-parser/binding-linux-arm64-gnu@npm:0.5.48" +"@yuku-parser/binding-linux-arm64-gnu@npm:0.9.5": + version: 0.9.5 + resolution: "@yuku-parser/binding-linux-arm64-gnu@npm:0.9.5::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40yuku-parser%2Fbinding-linux-arm64-gnu%2F-%2Fbinding-linux-arm64-gnu-0.9.5.tgz" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@yuku-parser/binding-linux-arm64-musl@npm:0.5.48": - version: 0.5.48 - resolution: "@yuku-parser/binding-linux-arm64-musl@npm:0.5.48" +"@yuku-parser/binding-linux-arm64-musl@npm:0.9.5": + version: 0.9.5 + resolution: "@yuku-parser/binding-linux-arm64-musl@npm:0.9.5::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40yuku-parser%2Fbinding-linux-arm64-musl%2F-%2Fbinding-linux-arm64-musl-0.9.5.tgz" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@yuku-parser/binding-linux-x64-gnu@npm:0.5.48": - version: 0.5.48 - resolution: "@yuku-parser/binding-linux-x64-gnu@npm:0.5.48" +"@yuku-parser/binding-linux-x64-gnu@npm:0.9.5": + version: 0.9.5 + resolution: "@yuku-parser/binding-linux-x64-gnu@npm:0.9.5::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40yuku-parser%2Fbinding-linux-x64-gnu%2F-%2Fbinding-linux-x64-gnu-0.9.5.tgz" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@yuku-parser/binding-linux-x64-musl@npm:0.5.48": - version: 0.5.48 - resolution: "@yuku-parser/binding-linux-x64-musl@npm:0.5.48" +"@yuku-parser/binding-linux-x64-musl@npm:0.9.5": + version: 0.9.5 + resolution: "@yuku-parser/binding-linux-x64-musl@npm:0.9.5::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40yuku-parser%2Fbinding-linux-x64-musl%2F-%2Fbinding-linux-x64-musl-0.9.5.tgz" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@yuku-parser/binding-win32-arm64@npm:0.5.48": - version: 0.5.48 - resolution: "@yuku-parser/binding-win32-arm64@npm:0.5.48" +"@yuku-parser/binding-win32-arm64@npm:0.9.5": + version: 0.9.5 + resolution: "@yuku-parser/binding-win32-arm64@npm:0.9.5::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40yuku-parser%2Fbinding-win32-arm64%2F-%2Fbinding-win32-arm64-0.9.5.tgz" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@yuku-parser/binding-win32-x64@npm:0.5.48": - version: 0.5.48 - resolution: "@yuku-parser/binding-win32-x64@npm:0.5.48" +"@yuku-parser/binding-win32-x64@npm:0.9.5": + version: 0.9.5 + resolution: "@yuku-parser/binding-win32-x64@npm:0.9.5::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40yuku-parser%2Fbinding-win32-x64%2F-%2Fbinding-win32-x64-0.9.5.tgz" conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"@yuku-toolchain/types@npm:0.5.43": - version: 0.5.43 - resolution: "@yuku-toolchain/types@npm:0.5.43" - checksum: 10c0/d06dc8fb9b128a3b70e3a1076c08c442c1052c5a28abc68ba96aa12bd3386c1cbb6f1d874fb0cfb13ce3a8c1b6f8148a9695f25400a944ae17b7a85fd67ff131 +"@yuku-toolchain/types@npm:^0.9.5": + version: 0.9.5 + resolution: "@yuku-toolchain/types@npm:0.9.5::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2F%40yuku-toolchain%2Ftypes%2F-%2Ftypes-0.9.5.tgz" + checksum: 10c0/cc20d7de43e393656501263a8d123cb2b03a9f281e23fbb8f9a2984dded5fab5df1c3007587ec978ada752328ca708f2ffadd1e0046c5f4a60c4d06a46ff694c languageName: node linkType: hard @@ -7019,9 +6939,9 @@ __metadata: languageName: node linkType: hard -"es-module-lexer@npm:^2.0.0": +"es-module-lexer@npm:^2.3.2": version: 2.3.2 - resolution: "es-module-lexer@npm:2.3.2" + resolution: "es-module-lexer@npm:2.3.2::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2Fes-module-lexer%2F-%2Fes-module-lexer-2.3.2.tgz" checksum: 10c0/5e7389424c43478439f12f9a6aca1750f6f99afa384fc3de329f4a45f152ab156671055008adaafc74960877abf8cc338aeebf6bed8c21297b146fd6eb7a22f8 languageName: node linkType: hard @@ -7127,9 +7047,9 @@ __metadata: languageName: unknown linkType: soft -"expect-type@npm:^1.3.0": +"expect-type@npm:^1.4.0": version: 1.4.0 - resolution: "expect-type@npm:1.4.0" + resolution: "expect-type@npm:1.4.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2Fexpect-type%2F-%2Fexpect-type-1.4.0.tgz" checksum: 10c0/d40d76b8570695d36587beb3cc28494da2ca3ec8f04e67f5622ed2d372d850e401a9adef19c6835e1a8173903f157c79540b34c7b3fbd7cd8ce726cc903c57b7 languageName: node linkType: hard @@ -7231,6 +7151,13 @@ __metadata: languageName: node linkType: hard +"fflate@npm:^0.8.3": + version: 0.8.3 + resolution: "fflate@npm:0.8.3::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2Ffflate%2F-%2Ffflate-0.8.3.tgz" + checksum: 10c0/eab181ca37f5348ae76d4b6f840e0026e30220e33153289ac942222d8b9638237d486507dbcc09878d724095bd354993a2ee48bbee99c8f2c6440d4448719aa7 + languageName: node + linkType: hard + "filelist@npm:^1.0.4": version: 1.0.6 resolution: "filelist@npm:1.0.6" @@ -7265,6 +7192,13 @@ __metadata: languageName: node linkType: hard +"flatted@npm:^3.4.4": + version: 3.4.4 + resolution: "flatted@npm:3.4.4::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2Fflatted%2F-%2Fflatted-3.4.4.tgz" + checksum: 10c0/a3a52a88ea5a4c333e5a1f097dcd87a037fa31c236a77cf46222c2aa4036ef895ab9bc76138a66d9dc22bdb3652128f9598cd26e7439561bf19f67276e2bc37e + languageName: node + linkType: hard + "flora-colossus@npm:^3.0.2": version: 3.0.2 resolution: "flora-colossus@npm:3.0.2" @@ -8443,6 +8377,15 @@ __metadata: languageName: node linkType: hard +"magic-string@npm:^1.2.3": + version: 1.4.1 + resolution: "magic-string@npm:1.4.1::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2Fmagic-string%2F-%2Fmagic-string-1.4.1.tgz" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.6.0" + checksum: 10c0/d5162ed2cef7ffa4ee5bcf44fbaba3a814f65b9919f0f3eba43076fda4223161b1064779da32901c3d1f157e7bbc0b6dd973e06c7856e550424f5e73e9217f0a + languageName: node + linkType: hard + "markdown-table@npm:^3.0.0": version: 3.0.4 resolution: "markdown-table@npm:3.0.4" @@ -9161,8 +9104,7 @@ __metadata: oxc-parser: "npm:^0.147.0" prettier: "npm:^3.9.6" typescript: "npm:^7.0.2" - vite-plus: "npm:0.3.0" - vitest: "npm:^4.1.11" + vite-plus: "catalog:" languageName: unknown linkType: soft @@ -9315,13 +9257,20 @@ __metadata: languageName: node linkType: hard -"obug@npm:^2.1.1, obug@npm:^2.1.2": +"obug@npm:^2.1.2": version: 2.1.4 resolution: "obug@npm:2.1.4" checksum: 10c0/34a0ee97cd88573cfd97d384c2a79f07118ae5680d7e45d1de6e99c74eddefe145e8ca27a2db02195a1ee5fded5aa22b924869c842728c201b9f109a27d0ef19 languageName: node linkType: hard +"obug@npm:^2.1.4": + version: 2.2.1 + resolution: "obug@npm:2.2.1::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2Fobug%2F-%2Fobug-2.2.1.tgz" + checksum: 10c0/8a7c001847d880fa6ba83c7bbee6a80fd271ee628439cd508c8647d53b217abb1759e2d5e4aaeb09a35d0a274bdb225a0a5a78eded3213d63e656153cf411b63 + languageName: node + linkType: hard + "octokit@npm:^5.0.5": version: 5.0.5 resolution: "octokit@npm:5.0.5" @@ -9432,30 +9381,30 @@ __metadata: languageName: node linkType: hard -"oxfmt@npm:=0.64.0": - version: 0.64.0 - resolution: "oxfmt@npm:0.64.0" - dependencies: - "@oxfmt/binding-android-arm-eabi": "npm:0.64.0" - "@oxfmt/binding-android-arm64": "npm:0.64.0" - "@oxfmt/binding-darwin-arm64": "npm:0.64.0" - "@oxfmt/binding-darwin-x64": "npm:0.64.0" - "@oxfmt/binding-freebsd-x64": "npm:0.64.0" - "@oxfmt/binding-linux-arm-gnueabihf": "npm:0.64.0" - "@oxfmt/binding-linux-arm-musleabihf": "npm:0.64.0" - "@oxfmt/binding-linux-arm64-gnu": "npm:0.64.0" - "@oxfmt/binding-linux-arm64-musl": "npm:0.64.0" - "@oxfmt/binding-linux-ppc64-gnu": "npm:0.64.0" - "@oxfmt/binding-linux-riscv64-gnu": "npm:0.64.0" - "@oxfmt/binding-linux-riscv64-musl": "npm:0.64.0" - "@oxfmt/binding-linux-s390x-gnu": "npm:0.64.0" - "@oxfmt/binding-linux-x64-gnu": "npm:0.64.0" - "@oxfmt/binding-linux-x64-musl": "npm:0.64.0" - "@oxfmt/binding-openharmony-arm64": "npm:0.64.0" - "@oxfmt/binding-win32-arm64-msvc": "npm:0.64.0" - "@oxfmt/binding-win32-ia32-msvc": "npm:0.64.0" - "@oxfmt/binding-win32-x64-msvc": "npm:0.64.0" - tinypool: "npm:2.1.0" +"oxfmt@npm:=0.70.0": + version: 0.70.0 + resolution: "oxfmt@npm:0.70.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2Foxfmt%2F-%2Foxfmt-0.70.0.tgz" + dependencies: + "@oxfmt/binding-android-arm-eabi": "npm:0.70.0" + "@oxfmt/binding-android-arm64": "npm:0.70.0" + "@oxfmt/binding-darwin-arm64": "npm:0.70.0" + "@oxfmt/binding-darwin-x64": "npm:0.70.0" + "@oxfmt/binding-freebsd-x64": "npm:0.70.0" + "@oxfmt/binding-linux-arm-gnueabihf": "npm:0.70.0" + "@oxfmt/binding-linux-arm-musleabihf": "npm:0.70.0" + "@oxfmt/binding-linux-arm64-gnu": "npm:0.70.0" + "@oxfmt/binding-linux-arm64-musl": "npm:0.70.0" + "@oxfmt/binding-linux-ppc64-gnu": "npm:0.70.0" + "@oxfmt/binding-linux-riscv64-gnu": "npm:0.70.0" + "@oxfmt/binding-linux-riscv64-musl": "npm:0.70.0" + "@oxfmt/binding-linux-s390x-gnu": "npm:0.70.0" + "@oxfmt/binding-linux-x64-gnu": "npm:0.70.0" + "@oxfmt/binding-linux-x64-musl": "npm:0.70.0" + "@oxfmt/binding-openharmony-arm64": "npm:0.70.0" + "@oxfmt/binding-win32-arm64-msvc": "npm:0.70.0" + "@oxfmt/binding-win32-ia32-msvc": "npm:0.70.0" + "@oxfmt/binding-win32-x64-msvc": "npm:0.70.0" + tinypool: "npm:2.1.2" peerDependencies: svelte: ^5.0.0 vite-plus: "*" @@ -9505,20 +9454,20 @@ __metadata: optional: true bin: oxfmt: bin/oxfmt - checksum: 10c0/be3fd9739b731845afa888929ed15c7c0acc868008b55162e9d2d6a8f8ffb91dfca9ac75cd7caef0aea0ee0ca24eaffd95c3cfa0e324ed8cd3f1637178c2c532 + checksum: 10c0/9641cb661ed50183daacd90ecae767972f6c1cc8725b44d4f093014a9104cdc05659575ba94178b830fbe1ea53238bc0f81e70e132b4bcaaca2cbd4bb8ebbd42 languageName: node linkType: hard -"oxlint-tsgolint@npm:=7.0.2001": - version: 7.0.2001 - resolution: "oxlint-tsgolint@npm:7.0.2001" +"oxlint-tsgolint@npm:=7.0.2002": + version: 7.0.2002 + resolution: "oxlint-tsgolint@npm:7.0.2002::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2Foxlint-tsgolint%2F-%2Foxlint-tsgolint-7.0.2002.tgz" dependencies: - "@oxlint-tsgolint/darwin-arm64": "npm:7.0.2001" - "@oxlint-tsgolint/darwin-x64": "npm:7.0.2001" - "@oxlint-tsgolint/linux-arm64": "npm:7.0.2001" - "@oxlint-tsgolint/linux-x64": "npm:7.0.2001" - "@oxlint-tsgolint/win32-arm64": "npm:7.0.2001" - "@oxlint-tsgolint/win32-x64": "npm:7.0.2001" + "@oxlint-tsgolint/darwin-arm64": "npm:7.0.2002" + "@oxlint-tsgolint/darwin-x64": "npm:7.0.2002" + "@oxlint-tsgolint/linux-arm64": "npm:7.0.2002" + "@oxlint-tsgolint/linux-x64": "npm:7.0.2002" + "@oxlint-tsgolint/win32-arm64": "npm:7.0.2002" + "@oxlint-tsgolint/win32-x64": "npm:7.0.2002" dependenciesMeta: "@oxlint-tsgolint/darwin-arm64": optional: true @@ -9534,33 +9483,33 @@ __metadata: optional: true bin: tsgolint: ./bin/tsgolint.js - checksum: 10c0/b8cb29fd2981e7a0ad8636bac3ea86a3c553e1f78745fcb1dcb612efd3453d61780ae004bb413bfee7c02cf7d431da004405e49089abb9548d9bd374377eed19 - languageName: node - linkType: hard - -"oxlint@npm:=1.79.0": - version: 1.79.0 - resolution: "oxlint@npm:1.79.0" - dependencies: - "@oxlint/binding-android-arm-eabi": "npm:1.79.0" - "@oxlint/binding-android-arm64": "npm:1.79.0" - "@oxlint/binding-darwin-arm64": "npm:1.79.0" - "@oxlint/binding-darwin-x64": "npm:1.79.0" - "@oxlint/binding-freebsd-x64": "npm:1.79.0" - "@oxlint/binding-linux-arm-gnueabihf": "npm:1.79.0" - "@oxlint/binding-linux-arm-musleabihf": "npm:1.79.0" - "@oxlint/binding-linux-arm64-gnu": "npm:1.79.0" - "@oxlint/binding-linux-arm64-musl": "npm:1.79.0" - "@oxlint/binding-linux-ppc64-gnu": "npm:1.79.0" - "@oxlint/binding-linux-riscv64-gnu": "npm:1.79.0" - "@oxlint/binding-linux-riscv64-musl": "npm:1.79.0" - "@oxlint/binding-linux-s390x-gnu": "npm:1.79.0" - "@oxlint/binding-linux-x64-gnu": "npm:1.79.0" - "@oxlint/binding-linux-x64-musl": "npm:1.79.0" - "@oxlint/binding-openharmony-arm64": "npm:1.79.0" - "@oxlint/binding-win32-arm64-msvc": "npm:1.79.0" - "@oxlint/binding-win32-ia32-msvc": "npm:1.79.0" - "@oxlint/binding-win32-x64-msvc": "npm:1.79.0" + checksum: 10c0/3bc2fa23ae383aec90e0b191a916a63622c14127a10619e902bb7413180004ac48417bbb1a9bf86a5e88857140349a57db8006d8a608bca5fb9ed6b852f38a3d + languageName: node + linkType: hard + +"oxlint@npm:=1.85.0": + version: 1.85.0 + resolution: "oxlint@npm:1.85.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2Foxlint%2F-%2Foxlint-1.85.0.tgz" + dependencies: + "@oxlint/binding-android-arm-eabi": "npm:1.85.0" + "@oxlint/binding-android-arm64": "npm:1.85.0" + "@oxlint/binding-darwin-arm64": "npm:1.85.0" + "@oxlint/binding-darwin-x64": "npm:1.85.0" + "@oxlint/binding-freebsd-x64": "npm:1.85.0" + "@oxlint/binding-linux-arm-gnueabihf": "npm:1.85.0" + "@oxlint/binding-linux-arm-musleabihf": "npm:1.85.0" + "@oxlint/binding-linux-arm64-gnu": "npm:1.85.0" + "@oxlint/binding-linux-arm64-musl": "npm:1.85.0" + "@oxlint/binding-linux-ppc64-gnu": "npm:1.85.0" + "@oxlint/binding-linux-riscv64-gnu": "npm:1.85.0" + "@oxlint/binding-linux-riscv64-musl": "npm:1.85.0" + "@oxlint/binding-linux-s390x-gnu": "npm:1.85.0" + "@oxlint/binding-linux-x64-gnu": "npm:1.85.0" + "@oxlint/binding-linux-x64-musl": "npm:1.85.0" + "@oxlint/binding-openharmony-arm64": "npm:1.85.0" + "@oxlint/binding-win32-arm64-msvc": "npm:1.85.0" + "@oxlint/binding-win32-ia32-msvc": "npm:1.85.0" + "@oxlint/binding-win32-x64-msvc": "npm:1.85.0" peerDependencies: oxlint-tsgolint: ">=7.0.2001" vite-plus: "*" @@ -9610,7 +9559,7 @@ __metadata: optional: true bin: oxlint: bin/oxlint - checksum: 10c0/8d7fa7c018c202d6528750518ab99b0b8ebd6ac94269cc270dd732bb841ea28f8f3404b32593a7a48c978a98b14795c1c5633926ee6c24e60f489935eab3f300 + checksum: 10c0/8ed3d50ddd19a3beea4b0ceed69596c7587036ec830c8bd2d71cd6317d6bebaaf2d6b9b1783c3806e6187a310e37dbe42347cfd103f4bbff83cf5f99ba1d35b5 languageName: node linkType: hard @@ -9737,13 +9686,20 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^4.0.3, picomatch@npm:^4.0.4": +"picomatch@npm:^4.0.4": version: 4.0.7 resolution: "picomatch@npm:4.0.7" checksum: 10c0/beb6ae02c43ae44e84883b90830196d9046b1726ead292adcf7f57945e0bb0d992d68563d87e03b484b6f3c9a5c6defda7523477f047d7f0e663f126cc01787f languageName: node linkType: hard +"picomatch@npm:^4.0.7": + version: 4.0.7 + resolution: "picomatch@npm:4.0.7::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2Fpicomatch%2F-%2Fpicomatch-4.0.7.tgz" + checksum: 10c0/beb6ae02c43ae44e84883b90830196d9046b1726ead292adcf7f57945e0bb0d992d68563d87e03b484b6f3c9a5c6defda7523477f047d7f0e663f126cc01787f + languageName: node + linkType: hard + "pirates@npm:^4.0.7": version: 4.0.7 resolution: "pirates@npm:4.0.7" @@ -10543,9 +10499,9 @@ __metadata: languageName: node linkType: hard -"std-env@npm:^4.0.0-rc.1": +"std-env@npm:^4.2.0": version: 4.2.0 - resolution: "std-env@npm:4.2.0" + resolution: "std-env@npm:4.2.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2Fstd-env%2F-%2Fstd-env-4.2.0.tgz" checksum: 10c0/40ac525ce7b7c556abc332a7376f14356eeb1a7f17f6ff9a003eb9f52326ff1f3745d3e1b43452675b1ec6fcc319f1b1d6f3b0d386cf3f91058479ad883cff69 languageName: node linkType: hard @@ -10692,21 +10648,21 @@ __metadata: languageName: node linkType: hard -"tinybench@npm:^2.9.0": - version: 2.9.0 - resolution: "tinybench@npm:2.9.0" - checksum: 10c0/c3500b0f60d2eb8db65250afe750b66d51623057ee88720b7f064894a6cb7eb93360ca824a60a31ab16dab30c7b1f06efe0795b352e37914a9d4bad86386a20c +"tinybench@npm:6.1.4": + version: 6.1.4 + resolution: "tinybench@npm:6.1.4::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2Ftinybench%2F-%2Ftinybench-6.1.4.tgz" + checksum: 10c0/7a815318a02270a98247298ddd289b75c841725606c977f0b74749bc5ec811a44998267ccb60bd3deb23b551dbfaf4d979eb08e9b04fbc87ff571f9b39cc61c3 languageName: node linkType: hard -"tinyexec@npm:^1.0.2": +"tinyexec@npm:1.3.0": version: 1.3.0 resolution: "tinyexec@npm:1.3.0" checksum: 10c0/e9b89f97489d2aab2cef408da279e6b32547e738d1275032ccb8fd0028a006d93eb70fc51c6cffd9fc2f5aca6c2a273d8b6f73b52d46ee5116da6b94969ef958 languageName: node linkType: hard -"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.15": +"tinyglobby@npm:^0.2.12": version: 0.2.17 resolution: "tinyglobby@npm:0.2.17" dependencies: @@ -10716,6 +10672,16 @@ __metadata: languageName: node linkType: hard +"tinyglobby@npm:^0.2.17": + version: 0.2.17 + resolution: "tinyglobby@npm:0.2.17::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2Ftinyglobby%2F-%2Ftinyglobby-0.2.17.tgz" + dependencies: + fdir: "npm:^6.5.0" + picomatch: "npm:^4.0.4" + checksum: 10c0/7f7bb0f197c88bc4b20c231e0deca4240ca3bf313a88f5a7fee93a872b84966a4d50220947c0455ad07a60b3b360961c5b7fd979222aeb716a9f99b412002e4c + languageName: node + linkType: hard + "tinylogic@npm:^2.0.0": version: 2.0.0 resolution: "tinylogic@npm:2.0.0" @@ -10723,16 +10689,16 @@ __metadata: languageName: node linkType: hard -"tinypool@npm:2.1.0": - version: 2.1.0 - resolution: "tinypool@npm:2.1.0" - checksum: 10c0/9fb1c760558c6264e0f4cfde96a63b12450b43f1730fbe6274aa24ddbdf488745c08924d0dea7a1303b47d555416a6415f2113898c69b6ecf731e75ac95238a5 +"tinypool@npm:2.1.2": + version: 2.1.2 + resolution: "tinypool@npm:2.1.2::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2Ftinypool%2F-%2Ftinypool-2.1.2.tgz" + checksum: 10c0/4d8c0cf2d9710eff4aa9cb4318fecaac09d847f1d7dce3eb314d36bea35ed23f754315592a3c00c1d0aa21d9eb4a1f69e84c69feb8517b4cd769e1e0a7ee308e languageName: node linkType: hard -"tinyrainbow@npm:^3.1.0": +"tinyrainbow@npm:^3.1.1": version: 3.1.1 - resolution: "tinyrainbow@npm:3.1.1" + resolution: "tinyrainbow@npm:3.1.1::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2Ftinyrainbow%2F-%2Ftinyrainbow-3.1.1.tgz" checksum: 10c0/f9d2743832c6191f753408f36224fe817620b8abcef572b2e570204c673a901d753ff84ca8e7b88f9c79e934295b3ffc6fcbc56a06f126e24e1ec6186dcad40d languageName: node linkType: hard @@ -11266,37 +11232,35 @@ __metadata: languageName: node linkType: hard -"vite-plus@npm:0.3.0": - version: 0.3.0 - resolution: "vite-plus@npm:0.3.0" +"vite-plus@npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62": + version: 0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62 + resolution: "vite-plus@npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62::__archiveUrl=https%3A%2F%2Fregistry-bridge.viteplus.dev%2Ftarballs%2Fvite-plus%2F0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62%2F69e99ae4c5c1d31125eda5b3d5cee284084fbda9.tgz" dependencies: - "@oxc-project/types": "npm:=0.146.0" + "@oxc-project/types": "npm:=0.151.0" "@oxlint/plugins": "npm:=1.79.0" - "@vitest/browser": "npm:4.1.11" - "@vitest/browser-preview": "npm:4.1.11" - "@vitest/expect": "npm:4.1.11" - "@vitest/mocker": "npm:4.1.11" - "@vitest/pretty-format": "npm:4.1.11" - "@vitest/runner": "npm:4.1.11" - "@vitest/snapshot": "npm:4.1.11" - "@vitest/spy": "npm:4.1.11" - "@vitest/utils": "npm:4.1.11" - "@voidzero-dev/vite-plus-core": "npm:0.3.0" - "@voidzero-dev/vite-plus-darwin-arm64": "npm:0.3.0" - "@voidzero-dev/vite-plus-darwin-x64": "npm:0.3.0" - "@voidzero-dev/vite-plus-linux-arm64-gnu": "npm:0.3.0" - "@voidzero-dev/vite-plus-linux-arm64-musl": "npm:0.3.0" - "@voidzero-dev/vite-plus-linux-x64-gnu": "npm:0.3.0" - "@voidzero-dev/vite-plus-linux-x64-musl": "npm:0.3.0" - "@voidzero-dev/vite-plus-win32-arm64-msvc": "npm:0.3.0" - "@voidzero-dev/vite-plus-win32-x64-msvc": "npm:0.3.0" - oxfmt: "npm:=0.64.0" - oxlint: "npm:=1.79.0" - oxlint-tsgolint: "npm:=7.0.2001" - vitest: "npm:4.1.11" + "@vitest/browser": "npm:5.0.1" + "@vitest/browser-preview": "npm:5.0.1" + "@vitest/mocker": "npm:5.0.1" + "@vitest/pretty-format": "npm:5.0.1" + "@vitest/snapshot": "npm:5.0.1" + "@vitest/spy": "npm:5.0.1" + "@vitest/utils": "npm:5.0.1" + "@voidzero-dev/vite-plus-darwin-arm64": "npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62" + "@voidzero-dev/vite-plus-darwin-x64": "npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62" + "@voidzero-dev/vite-plus-linux-arm64-gnu": "npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62" + "@voidzero-dev/vite-plus-linux-arm64-musl": "npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62" + "@voidzero-dev/vite-plus-linux-x64-gnu": "npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62" + "@voidzero-dev/vite-plus-linux-x64-musl": "npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62" + "@voidzero-dev/vite-plus-win32-arm64-msvc": "npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62" + "@voidzero-dev/vite-plus-win32-x64-msvc": "npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62" + oxfmt: "npm:=0.70.0" + oxlint: "npm:=1.85.0" + oxlint-tsgolint: "npm:=7.0.2002" + vite: "npm:@voidzero-dev/vite-plus-core@0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62" + vitest: "npm:5.0.1" peerDependencies: - "@vitest/browser-playwright": 4.1.11 - "@vitest/browser-webdriverio": 4.1.11 + "@vitest/browser-playwright": 5.0.1 + "@vitest/browser-webdriverio": ^5.0.0-beta.5 || >=5.0.0 dependenciesMeta: "@voidzero-dev/vite-plus-darwin-arm64": optional: true @@ -11320,51 +11284,137 @@ __metadata: "@vitest/browser-webdriverio": optional: true bin: - oxfmt: ./bin/oxfmt - oxlint: ./bin/oxlint vp: ./bin/vp vpr: ./bin/vpr - checksum: 10c0/3d3af822f13d940a9e941c966d1580d24b4edac7be806acff33d0f9045ab1ba693d47ec46346ee0515c2de1d987accc4d769795cd70e294ca578f5fa93c13c56 + checksum: 10c0/5f9c1a7be4f66846496f94442500a86e2fc79564b69370efb19e22c3be5da36667dfeb1fa3ce7e035400b13c72732bbfa8a85bc40b09221df0bc55f9bcb5472f languageName: node linkType: hard -"vitest@npm:4.1.11, vitest@npm:^4.1.11": - version: 4.1.11 - resolution: "vitest@npm:4.1.11" +"vite@npm:@voidzero-dev/vite-plus-core@0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62": + version: 0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62 + resolution: "@voidzero-dev/vite-plus-core@npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62::__archiveUrl=https%3A%2F%2Fregistry-bridge.viteplus.dev%2Ftarballs%2F%40voidzero-dev%2Fvite-plus-core%2F0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62%2F3297ae22cda839a49ec1dba9a095d729a08ff355.tgz" dependencies: - "@vitest/expect": "npm:4.1.11" - "@vitest/mocker": "npm:4.1.11" - "@vitest/pretty-format": "npm:4.1.11" - "@vitest/runner": "npm:4.1.11" - "@vitest/snapshot": "npm:4.1.11" - "@vitest/spy": "npm:4.1.11" - "@vitest/utils": "npm:4.1.11" - es-module-lexer: "npm:^2.0.0" - expect-type: "npm:^1.3.0" - magic-string: "npm:^0.30.21" - obug: "npm:^2.1.1" - pathe: "npm:^2.0.3" - picomatch: "npm:^4.0.3" - std-env: "npm:^4.0.0-rc.1" - tinybench: "npm:^2.9.0" - tinyexec: "npm:^1.0.2" - tinyglobby: "npm:^0.2.15" - tinyrainbow: "npm:^3.1.0" - vite: "npm:^6.0.0 || ^7.0.0 || ^8.0.0" + "@oxc-project/runtime": "npm:=0.151.0" + "@oxc-project/types": "npm:=0.151.0" + "@voidzero-dev/vite-plus-darwin-arm64": "npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62" + "@voidzero-dev/vite-plus-darwin-x64": "npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62" + "@voidzero-dev/vite-plus-linux-arm64-gnu": "npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62" + "@voidzero-dev/vite-plus-linux-arm64-musl": "npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62" + "@voidzero-dev/vite-plus-linux-x64-gnu": "npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62" + "@voidzero-dev/vite-plus-linux-x64-musl": "npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62" + "@voidzero-dev/vite-plus-win32-arm64-msvc": "npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62" + "@voidzero-dev/vite-plus-win32-x64-msvc": "npm:0.0.0-commit.e0f77ee2353b38c77d7165d3305f9f6d705b4d62" + fsevents: "npm:~2.3.3" + lightningcss: "npm:^1.33.0" + postcss: "npm:^8.5.6" + yuku-codegen: "npm:^0.9.3" + yuku-parser: "npm:^0.9.3" + peerDependencies: + "@arethetypeswrong/core": ^0.18.1 + "@types/node": ^20.19.0 || >=22.12.0 + "@vitejs/devtools": ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: ">=1.21.0" + less: ^4.0.0 + publint: ^0.3.8 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: ">=0.54.8" + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 + unplugin-unused: ">=0.5.0" + unrun: "*" + yaml: ^2.4.2 + dependenciesMeta: + "@voidzero-dev/vite-plus-darwin-arm64": + optional: true + "@voidzero-dev/vite-plus-darwin-x64": + optional: true + "@voidzero-dev/vite-plus-linux-arm64-gnu": + optional: true + "@voidzero-dev/vite-plus-linux-arm64-musl": + optional: true + "@voidzero-dev/vite-plus-linux-x64-gnu": + optional: true + "@voidzero-dev/vite-plus-linux-x64-musl": + optional: true + "@voidzero-dev/vite-plus-win32-arm64-msvc": + optional: true + "@voidzero-dev/vite-plus-win32-x64-msvc": + optional: true + fsevents: + optional: true + peerDependenciesMeta: + "@arethetypeswrong/core": + optional: true + "@types/node": + optional: true + "@vitejs/devtools": + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + publint: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + unrun: + optional: true + yaml: + optional: true + checksum: 10c0/da082cdc05226cafc332d10328dafdbe821f0eea16cf08d361b125c88107c399a800811c9ce3817e88dd961234bc0ff8280b1619365da3878da8dcb9792043a4 + languageName: node + linkType: hard + +"vitest@npm:5.0.1": + version: 5.0.1 + resolution: "vitest@npm:5.0.1::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2Fvitest%2F-%2Fvitest-5.0.1.tgz" + dependencies: + "@types/chai": "npm:^5.2.2" + "@vitest/mocker": "npm:5.0.1" + chai: "npm:^6.2.2" + es-module-lexer: "npm:^2.3.2" + expect-type: "npm:^1.4.0" + magic-string: "npm:^1.2.3" + obug: "npm:^2.1.4" + picomatch: "npm:^4.0.7" + std-env: "npm:^4.2.0" + tinybench: "npm:6.1.4" + tinyexec: "npm:1.3.0" + tinyglobby: "npm:^0.2.17" why-is-node-running: "npm:^2.3.0" peerDependencies: "@edge-runtime/vm": "*" "@opentelemetry/api": ^1.9.0 - "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 - "@vitest/browser-playwright": 4.1.11 - "@vitest/browser-preview": 4.1.11 - "@vitest/browser-webdriverio": 4.1.11 - "@vitest/coverage-istanbul": 4.1.11 - "@vitest/coverage-v8": 4.1.11 - "@vitest/ui": 4.1.11 + "@types/node": ^22.0.0 || >=24.0.0 + "@vitest/browser-playwright": 5.0.1 + "@vitest/browser-preview": 5.0.1 + "@vitest/browser-webdriverio": ^5.0.0-beta.5 || >=5.0.0 + "@vitest/coverage-istanbul": 5.0.1 + "@vitest/coverage-v8": 5.0.1 + "@vitest/ui": 5.0.1 happy-dom: "*" jsdom: "*" - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: "@edge-runtime/vm": optional: true @@ -11392,7 +11442,7 @@ __metadata: optional: false bin: vitest: ./vitest.mjs - checksum: 10c0/3fa0948cf74adcccc8cbcdb4e6d30ada6933bdfb1816ff99200f3d3b689325b37dc483b22535b57b6d911f7a7b64eaa6a5f8da1606cfe7f2466f48000c21e296 + checksum: 10c0/1b49fe8eb7274ffd43ca5a29cccbcc942cf991488020a74114f3a725a1168c549d47820715b8ed3f521a0cd9ccc0013ab9511cc6e0f8e0a5bdfb751e3ddc4d2d languageName: node linkType: hard @@ -11486,7 +11536,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.18.0, ws@npm:^8.19.0, ws@npm:^8.21.0": +"ws@npm:^8.18.0, ws@npm:^8.21.0": version: 8.21.3 resolution: "ws@npm:8.21.3" peerDependencies: @@ -11501,6 +11551,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^8.21.3": + version: 8.21.3 + resolution: "ws@npm:8.21.3::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2Fws%2F-%2Fws-8.21.3.tgz" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/7b28dc2863ea0e2cece68d142a3eee90361021b73f750431e6d8076bb7dede5fdfdb75b3d29534b62f411261147b76b1bc80fb5cc63ab0aabd467280e85b22e0 + languageName: node + linkType: hard + "xmlbuilder@npm:^15.1.1": version: 15.1.1 resolution: "xmlbuilder@npm:15.1.1" @@ -11545,23 +11610,35 @@ __metadata: languageName: node linkType: hard -"yuku-codegen@npm:^0.5.44": - version: 0.5.48 - resolution: "yuku-codegen@npm:0.5.48" +"yuku-ast@npm:^0.9.5": + version: 0.9.5 + resolution: "yuku-ast@npm:0.9.5::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2Fyuku-ast%2F-%2Fyuku-ast-0.9.5.tgz" + dependencies: + "@yuku-toolchain/types": "npm:^0.9.5" + checksum: 10c0/cc6159880196008f10765e9c64d6fbc6ceab87ea5451d88907992bf007d10e1bc533c93a5a7b00bb3ac5903e13fed76be447f65d3f8e6a6da210509009e32043 + languageName: node + linkType: hard + +"yuku-codegen@npm:^0.9.3": + version: 0.9.5 + resolution: "yuku-codegen@npm:0.9.5::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2Fyuku-codegen%2F-%2Fyuku-codegen-0.9.5.tgz" dependencies: - "@yuku-codegen/binding-darwin-arm64": "npm:0.5.48" - "@yuku-codegen/binding-darwin-x64": "npm:0.5.48" - "@yuku-codegen/binding-freebsd-x64": "npm:0.5.48" - "@yuku-codegen/binding-linux-arm-gnu": "npm:0.5.48" - "@yuku-codegen/binding-linux-arm-musl": "npm:0.5.48" - "@yuku-codegen/binding-linux-arm64-gnu": "npm:0.5.48" - "@yuku-codegen/binding-linux-arm64-musl": "npm:0.5.48" - "@yuku-codegen/binding-linux-x64-gnu": "npm:0.5.48" - "@yuku-codegen/binding-linux-x64-musl": "npm:0.5.48" - "@yuku-codegen/binding-win32-arm64": "npm:0.5.48" - "@yuku-codegen/binding-win32-x64": "npm:0.5.48" - "@yuku-toolchain/types": "npm:0.5.43" + "@yuku-codegen/binding-android-arm64": "npm:0.9.5" + "@yuku-codegen/binding-darwin-arm64": "npm:0.9.5" + "@yuku-codegen/binding-darwin-x64": "npm:0.9.5" + "@yuku-codegen/binding-freebsd-x64": "npm:0.9.5" + "@yuku-codegen/binding-linux-arm-gnu": "npm:0.9.5" + "@yuku-codegen/binding-linux-arm-musl": "npm:0.9.5" + "@yuku-codegen/binding-linux-arm64-gnu": "npm:0.9.5" + "@yuku-codegen/binding-linux-arm64-musl": "npm:0.9.5" + "@yuku-codegen/binding-linux-x64-gnu": "npm:0.9.5" + "@yuku-codegen/binding-linux-x64-musl": "npm:0.9.5" + "@yuku-codegen/binding-win32-arm64": "npm:0.9.5" + "@yuku-codegen/binding-win32-x64": "npm:0.9.5" + "@yuku-toolchain/types": "npm:^0.9.5" dependenciesMeta: + "@yuku-codegen/binding-android-arm64": + optional: true "@yuku-codegen/binding-darwin-arm64": optional: true "@yuku-codegen/binding-darwin-x64": @@ -11584,27 +11661,31 @@ __metadata: optional: true "@yuku-codegen/binding-win32-x64": optional: true - checksum: 10c0/7c3bdb1a4a7b9906c731727b4972b70a62bb6b01de70fca084963b6c42eed95db4d3da2dfe8c3bba25533ecc4e58ac4018415926d295e3bdf16e67ecd38c0ee7 + checksum: 10c0/fff0d52b38af71a4f2f76ef44406a0c3237957ca2ed0118c156e495b48d03a431f8132808b7f52e8c09d9838267c3cbd6074b9a3e4bca825d442b7a4613bedd8 languageName: node linkType: hard -"yuku-parser@npm:^0.5.44": - version: 0.5.48 - resolution: "yuku-parser@npm:0.5.48" +"yuku-parser@npm:^0.9.3": + version: 0.9.5 + resolution: "yuku-parser@npm:0.9.5::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2Fyuku-parser%2F-%2Fyuku-parser-0.9.5.tgz" dependencies: - "@yuku-parser/binding-darwin-arm64": "npm:0.5.48" - "@yuku-parser/binding-darwin-x64": "npm:0.5.48" - "@yuku-parser/binding-freebsd-x64": "npm:0.5.48" - "@yuku-parser/binding-linux-arm-gnu": "npm:0.5.48" - "@yuku-parser/binding-linux-arm-musl": "npm:0.5.48" - "@yuku-parser/binding-linux-arm64-gnu": "npm:0.5.48" - "@yuku-parser/binding-linux-arm64-musl": "npm:0.5.48" - "@yuku-parser/binding-linux-x64-gnu": "npm:0.5.48" - "@yuku-parser/binding-linux-x64-musl": "npm:0.5.48" - "@yuku-parser/binding-win32-arm64": "npm:0.5.48" - "@yuku-parser/binding-win32-x64": "npm:0.5.48" - "@yuku-toolchain/types": "npm:0.5.43" + "@yuku-parser/binding-android-arm64": "npm:0.9.5" + "@yuku-parser/binding-darwin-arm64": "npm:0.9.5" + "@yuku-parser/binding-darwin-x64": "npm:0.9.5" + "@yuku-parser/binding-freebsd-x64": "npm:0.9.5" + "@yuku-parser/binding-linux-arm-gnu": "npm:0.9.5" + "@yuku-parser/binding-linux-arm-musl": "npm:0.9.5" + "@yuku-parser/binding-linux-arm64-gnu": "npm:0.9.5" + "@yuku-parser/binding-linux-arm64-musl": "npm:0.9.5" + "@yuku-parser/binding-linux-x64-gnu": "npm:0.9.5" + "@yuku-parser/binding-linux-x64-musl": "npm:0.9.5" + "@yuku-parser/binding-win32-arm64": "npm:0.9.5" + "@yuku-parser/binding-win32-x64": "npm:0.9.5" + "@yuku-toolchain/types": "npm:^0.9.5" + yuku-ast: "npm:^0.9.5" dependenciesMeta: + "@yuku-parser/binding-android-arm64": + optional: true "@yuku-parser/binding-darwin-arm64": optional: true "@yuku-parser/binding-darwin-x64": @@ -11627,7 +11708,7 @@ __metadata: optional: true "@yuku-parser/binding-win32-x64": optional: true - checksum: 10c0/6d0668746d8e3c5d686396cb2c2c835524aad837c81dd8a5a322fc94fb536453a827f4764915036d11b0c1c9eba1d24c9de7385a85b8aa751f54cdcfc640be30 + checksum: 10c0/e01bd82ac6369166320cfe64770e9e3cbd48a502844cd11afc5b11bb5cbbe484c70af804e0dde50142cc6daad9689ffeb88099731a836e32601df07813527489 languageName: node linkType: hard