Merge #803: list every model that fits, and add Muse Glimmer 30B and Ornith 1.0 9B

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-11 20:15:30 +01:00
commit c9f84c443d
9 files changed
+390 -125

No files matched your search

+50 -12
View File
@@ -31,6 +31,7 @@ import {
LocalModelCatalogSchema,
STOCK_LOCAL_MODEL_CATALOG,
} from '../src/config/local-model-catalog.js';
import { MAX_ALTERNATIVES } from '../src/local-model/recommend.js';
import {
RUNTIME_CTX_CHECKPOINTS,
RUNTIME_HOST_PROMPT_CACHE_MIB,
@@ -63,9 +64,11 @@ const GIB = 1024 * MIB;
/**
* The architecture of every model the stock catalogue lists, read from its config.json on
* Hugging Face (2026-11-08): full-attention layers, their K/V heads and head size, and the
* layers whose cache does not grow with the context (Gated DeltaNet layers with their value
* and key heads, or sliding-window layers with their window). Fine-tunes share their base's.
* Hugging Face (2026-11-08; Muse Glimmer on 2026-11-10): full-attention layers, their K/V heads
* and head size, and the layers whose cache does not grow with the context (Gated DeltaNet
* layers with their value and key heads, or sliding-window layers with their window).
* Fine-tunes share their base's: the config.json of Ornith 1.0 9B (2026-11-10) is that of
* Qwen3.5-9B in every field below, and the GGUF header of its build says the same.
*/
const ARCHITECTURES = {
'Qwen3.5-9B': { attentionLayers: 8, kvHeads: 4, headDim: 256, linear: { layers: 24, vHeads: 32, kHeads: 16 } },
@@ -78,6 +81,8 @@ const ARCHITECTURES = {
linear: { layers: 36, vHeads: 64, kHeads: 16 },
},
'North-Mini-Code-1.0': { attentionLayers: 13, kvHeads: 4, headDim: 128, sliding: { layers: 36, window: 4096 } },
// 52 layers, three in four on a sliding window (the GGUF header's `sliding_window_pattern`).
'Muse-Glimmer-30B': { attentionLayers: 13, kvHeads: 2, headDim: 128, sliding: { layers: 39, window: 2048 } },
} as const;
type ArchitectureName = keyof typeof ARCHITECTURES;
@@ -86,7 +91,8 @@ type Architecture = (typeof ARCHITECTURES)[ArchitectureName];
/**
* Which architecture each stock row runs, and how many tokens the runtime drafts per step with
* its multi-token-prediction layer: 0 when the GGUF has none (`nextn_predict_layers` in the GGUF
* header, read on 2026-11-08 from the first bytes of each model layer), else the tag's own
* header, read on 2026-11-08 from the first bytes of each model layer; on 2026-11-10 for the
* Ornith 1.0 and Muse Glimmer rows), else the tag's own
* `draft_num_predict` (its params layer) or the runtime's default of 4 when the tag sets none.
* Prediction costs a cache for that layer and one rollback copy of the recurrent state per
* drafted token (see the catalogue header). A row the catalogue gains must be added here, or the
@@ -94,8 +100,12 @@ type Architecture = (typeof ARCHITECTURES)[ArchitectureName];
*/
const ROW_ARCHITECTURE: Record<string, { architecture: ArchitectureName; draftTokens: number }> = {
'qwen3.5:9b-q4_k_m': { architecture: 'Qwen3.5-9B', draftTokens: 0 },
// A fine-tune of Qwen3.5-9B; its GGUF has 32 blocks and no prediction layer.
'ornith:9b-q4_k_m': { architecture: 'Qwen3.5-9B', draftTokens: 0 },
'mimo-v2.6:9b-q4_k_m': { architecture: 'Qwen3.5-9B', draftTokens: 0 },
'north-mini-code-1.0:q4_k_m': { architecture: 'North-Mini-Code-1.0', draftTokens: 0 },
// No prediction layer; the tags with a separate draft model (`-dflash`) are not listed.
'muse-glimmer:30b-q4_k_m': { architecture: 'Muse-Glimmer-30B', draftTokens: 0 },
// Its params layer sets `draft_num_predict` 2.
'qwen3.6:35b-a3b-mtp-q4_k_m': { architecture: 'Qwen3.6-35B-A3B', draftTokens: 2 },
'qwen3.6:35b-a3b': { architecture: 'Qwen3.6-35B-A3B', draftTokens: 0 },
@@ -183,20 +193,22 @@ describe('the stock catalogue', () => {
expect(getLocalModelCatalog()).toBe(first);
});
it('lists eight models, by rank, and none of those set aside in the header', () => {
it('lists ten models, by rank, and none of those set aside in the header', () => {
expect(models.map((m) => m.id)).toEqual([
'qwen3.5:9b-q4_k_m',
'ornith:9b-q4_k_m',
'mimo-v2.6:9b-q4_k_m',
'north-mini-code-1.0:q4_k_m',
'muse-glimmer:30b-q4_k_m',
'qwen3.6:35b-a3b-mtp-q4_k_m',
'qwen3.6:35b-a3b',
'qwen3.8:27b-q8_0',
'qwen3.8:27b-mtp-q4_k_m',
'ornith-1.5:122b',
]);
expect(models.map((m) => m.rank)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
expect(models.map((m) => m.rank)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
const setAside =
/^(bonsai-2|gpt-oss|flint|lfm2\.5|laguna|qwen3\.8-flash-next|qwen3\.5:122b)|^ornith-1\.5:(9b|35b|397b)/;
/^(bonsai-2|gpt-oss|flint|lfm2\.5|laguna|qwen3\.8-flash-next|qwen3\.5:(122b|4b)|ripple-preview|nemotron-3\.5-super|qwen3-coder-next)|^ornith-1\.5:(9b|35b|397b)|^ornith:35b|^muse-glimmer:.*(mlx|dflash)/;
expect(models.filter((m) => setAside.test(m.id)).map((m) => m.id)).toEqual([]);
// The plain 9B and 27B tags are MLX builds on a Mac: their GGUF builds are listed instead.
for (const plain of ['qwen3.5:9b', 'mimo-v2.6:9b', 'qwen3.8:27b']) {
@@ -207,8 +219,10 @@ describe('the stock catalogue', () => {
it('names the runner a Mac uses for each tag', () => {
expect(Object.fromEntries(models.map((m) => [m.id, m.runner]))).toEqual({
'qwen3.5:9b-q4_k_m': 'llamacpp',
'ornith:9b-q4_k_m': 'llamacpp',
'mimo-v2.6:9b-q4_k_m': 'llamacpp',
'north-mini-code-1.0:q4_k_m': 'llamacpp',
'muse-glimmer:30b-q4_k_m': 'llamacpp',
'qwen3.6:35b-a3b-mtp-q4_k_m': 'llamacpp',
// Manifest lists: a Mac takes the MLX child.
'qwen3.6:35b-a3b': 'mlx',
@@ -222,8 +236,12 @@ describe('the stock catalogue', () => {
expect(Object.fromEntries(models.map((m) => [m.id, m.bytes]))).toEqual({
// The registry's manifests as fetched on 2026-11-08, in lowercase.
'qwen3.5:9b-q4_k_m': 6_550_825_550,
// Fetched on 2026-11-10. No vision projector: the weights, a system prompt, the licence.
'ornith:9b-q4_k_m': 5_629_110_568,
'mimo-v2.6:9b-q4_k_m': 6_550_811_033,
'north-mini-code-1.0:q4_k_m': 18_593_967_008,
// Fetched on 2026-11-10: 16.76 GB of weights and a 1.40 GB projector.
'muse-glimmer:30b-q4_k_m': 18_157_010_252,
'qwen3.6:35b-a3b-mtp-q4_k_m': 22_621_314_381,
// The MLX child of the list, and the total of a real pull on a Mac, byte for byte.
'qwen3.6:35b-a3b': 23_105_798_956,
@@ -254,8 +272,11 @@ describe('the stock catalogue', () => {
it('gives every model a parameter count, an id of the default registry and a license', () => {
expect(Object.fromEntries(models.map((m) => [m.id, [m.paramsB, m.license]]))).toEqual({
'qwen3.5:9b-q4_k_m': [9, 'Apache-2.0'],
'ornith:9b-q4_k_m': [9, 'MIT'],
'mimo-v2.6:9b-q4_k_m': [9, 'MIT'],
'north-mini-code-1.0:q4_k_m': [30, 'Apache-2.0'],
// The card's total: a 27.9B language model and a vision encoder.
'muse-glimmer:30b-q4_k_m': [29.6, 'Apache-2.0'],
'qwen3.6:35b-a3b-mtp-q4_k_m': [35, 'Apache-2.0'],
'qwen3.6:35b-a3b': [35, 'Apache-2.0'],
'qwen3.8:27b-q8_0': [27.8, 'Apache-2.0'],
@@ -263,8 +284,12 @@ describe('the stock catalogue', () => {
'ornith-1.5:122b': [122, 'MIT'],
});
for (const model of models) expect(model.id).toMatch(LOCAL_MODEL_ID_PATTERN);
// The two 9B rows are the ones under the 12 billion line of the `limited` verdict.
expect(models.filter((m) => m.paramsB < 12).map((m) => m.id)).toEqual(['qwen3.5:9b-q4_k_m', 'mimo-v2.6:9b-q4_k_m']);
// The three 9B rows are the ones under the 12 billion line of the `limited` verdict.
expect(models.filter((m) => m.paramsB < 12).map((m) => m.id)).toEqual([
'qwen3.5:9b-q4_k_m',
'ornith:9b-q4_k_m',
'mimo-v2.6:9b-q4_k_m',
]);
});
it('offers no model below the 65536 tokens a harness needs, nor beyond its own window', () => {
@@ -276,11 +301,14 @@ describe('the stock catalogue', () => {
it('has a speed figure only where one was measured or scaled from a measurement', () => {
expect(Object.fromEntries(models.map((m) => [m.id, m.speedPer100Gbps ?? null]))).toEqual({
// 37.1 tokens per second on 400 GB/s, and the same for the fine-tune of the same size.
// 37.1 tokens per second on 400 GB/s, and the same for the two fine-tunes of the same size.
'qwen3.5:9b-q4_k_m': 9.3,
'ornith:9b-q4_k_m': 9.3,
'mimo-v2.6:9b-q4_k_m': 9.3,
// The 35B-A3B's llama.cpp figure: both have 3 billion active parameters.
'north-mini-code-1.0:q4_k_m': 13.6,
// The dense 27B's figure: the weights of the two are the same size.
'muse-glimmer:30b-q4_k_m': 2.95,
// 54.5 on 400 GB/s with llama.cpp.
'qwen3.6:35b-a3b-mtp-q4_k_m': 13.6,
// 67.8 on 400 GB/s with MLX.
@@ -302,6 +330,13 @@ describe('the stock catalogue', () => {
]);
});
it('holds no more models than one window lists: the pick and MAX_ALTERNATIVES others', () => {
// recommend() cuts the list at MAX_ALTERNATIVES. While the catalogue stays under it, a Mac
// is shown every model that fits and the install accepts every one of them. A catalogue
// that outgrows the bound must fail here, so the bound is raised (or kept) on purpose.
expect(models.length - 1).toBeLessThanOrEqual(MAX_ALTERNATIVES);
});
it('names the oldest macOS and its schema version', () => {
expect(STOCK_LOCAL_MODEL_CATALOG.minMacosMajor).toBe(14);
expect(STOCK_LOCAL_MODEL_CATALOG.schema).toBe(1);
@@ -348,9 +383,12 @@ describe('the memory figures of the stock rows: each row has its own', () => {
expect(Object.fromEntries(models.map((m) => [m.id, figuresOf(m)]))).toEqual({
// State x (1 live + 8 checkpoints) + 1.0 GB + 1 GiB.
'qwen3.5:9b-q4_k_m': [17_408, 2_547_960_320, 0],
'ornith:9b-q4_k_m': [17_408, 2_547_960_320, 0],
'mimo-v2.6:9b-q4_k_m': [17_408, 2_547_960_320, 0],
// The sliding window x (1 live + 8 checkpoints) + 1.0 GB + 1 GiB.
'north-mini-code-1.0:q4_k_m': [14_144, 3_698_117_120, 0],
// The same rule: 39 layers x 2,560 cells x 544 bytes = 54,312,960 per copy.
'muse-glimmer:30b-q4_k_m': [7_072, 2_562_558_464, 0],
// State x (1 live + 2 rollback copies + 8 checkpoints) + 1.0 GB + 1 GiB; the prediction
// layer's cache in f16 per token. The 27B rows below draft 4 tokens: 4 rollback copies.
'qwen3.6:35b-a3b-mtp-q4_k_m': [12_928, 2_798_242_304, 0],
@@ -374,9 +412,9 @@ describe('the memory figures of the stock rows: each row has its own', () => {
byFigures.set(key, [...(byFigures.get(key) ?? []), model]);
}
const shared = [...byFigures.values()].filter((group) => group.length > 1);
// The two 9B rows (a fine-tune and its base) and the two 27B rows (two quantizations of one model).
// The three 9B rows (a base and two fine-tunes of it) and the two 27B rows (two quantizations of one model).
expect(shared.map((group) => group.map((m) => m.id))).toEqual([
['qwen3.5:9b-q4_k_m', 'mimo-v2.6:9b-q4_k_m'],
['qwen3.5:9b-q4_k_m', 'ornith:9b-q4_k_m', 'mimo-v2.6:9b-q4_k_m'],
['qwen3.8:27b-q8_0', 'qwen3.8:27b-mtp-q4_k_m'],
]);
for (const group of shared) {
+34 -6
View File
@@ -12,9 +12,9 @@
*/
import { describe, expect, it } from 'vitest';
import { getLocalModelCatalog } from '../src/config/local-model-catalog.js';
import { getLocalModelCatalog, LocalModelCatalogSchema } from '../src/config/local-model-catalog.js';
import { formatGb, memClassGb } from '../src/local-model/format.js';
import { modelFitsBudget, recommend } from '../src/local-model/recommend.js';
import { MAX_ALTERNATIVES, modelFitsBudget, recommend } from '../src/local-model/recommend.js';
import type { HardwareFacts, HarnessFact } from '../src/types.js';
/** A Mac with Apple silicon and `gb` GB of memory as sold. */
@@ -72,18 +72,46 @@ describe('modelFitsBudget', () => {
}
});
it('does not depend on the ranking: a model that fits and is listed nowhere still fits', () => {
// On a 128 GB Mac more models fit than the recommendation lists.
const hw = mac(128, 'Apple M5 Max');
it('does not depend on what the recommendation lists: a model left out for disk space still fits', () => {
// The recommendation lists every model that fits the memory, unless its download would
// leave under 10 GiB on the disk. With 40 GB free on a 128 GB Mac that leaves out the two
// largest downloads, which fit the memory all the same.
const hw = { ...mac(128, 'Apple M5 Max'), freeDiskBytes: 40_000_000_000 };
const offered = recommend(hw, catalog, harnessFacts());
const listed = new Set([offered.pick, ...offered.alternatives].map((row) => row?.modelId));
const unlisted = catalog.models.filter((model) => !listed.has(model.id));
expect(unlisted.length).toBeGreaterThan(0);
expect(unlisted.map((model) => model.id)).toEqual(['qwen3.8:27b-q8_0', 'ornith-1.5:122b']);
for (const model of unlisted) {
expect(modelFitsBudget(hw, catalog, model.id, model.contexts.at(-1) as number), model.id).toBe(true);
}
});
it('does not depend on the ranking: a model cut off by the length of the list still fits', () => {
// A catalogue longer than the list: the stock rows plus six more copies of its smallest.
const smallest = catalog.models[0];
const long = LocalModelCatalogSchema.parse({
...structuredClone(catalog),
models: [
...structuredClone(catalog.models),
...Array.from({ length: 6 }, (_, index) => ({
...structuredClone(smallest),
id: `extra-${index + 1}:9b`,
rank: -1 - index,
})),
],
});
expect(long.models.length).toBeGreaterThan(1 + MAX_ALTERNATIVES);
const hw = mac(512, 'Apple M5 Max');
const offered = recommend(hw, long, harnessFacts());
const listed = new Set([offered.pick, ...offered.alternatives].map((row) => row?.modelId));
expect(listed.size).toBe(1 + MAX_ALTERNATIVES);
const unlisted = long.models.filter((model) => !listed.has(model.id));
expect(unlisted.length).toBe(long.models.length - 1 - MAX_ALTERNATIVES);
for (const model of unlisted) {
expect(modelFitsBudget(hw, long, model.id, model.contexts.at(-1) as number), model.id).toBe(true);
}
});
it('rules out a model that is too large, and one the catalogue does not have', () => {
expect(modelFitsBudget(mac(48), catalog, 'ornith-1.5:122b', 131072)).toBe(false);
expect(modelFitsBudget(mac(48), catalog, 'no-such:1b', 65536)).toBe(false);
+136 -49
View File
@@ -487,8 +487,12 @@ describe('needBytes', () => {
it('states the need of every stock row at its largest context', () => {
expect(Object.fromEntries(STOCK.models.map((m) => [m.id, needBytes(m, m.contexts[0])]))).toEqual({
'qwen3.5:9b-q4_k_m': 11_380_487_246,
// 0.92 GB under the other two 9B builds: it has no vision projector.
'ornith:9b-q4_k_m': 10_458_772_264,
'mimo-v2.6:9b-q4_k_m': 11_380_472_729,
'north-mini-code-1.0:q4_k_m': 25_999_848_864,
// At its own window of 131072 tokens: 18.2 GB + 0.9 GB of K/V cache + 2.6 GB.
'muse-glimmer:30b-q4_k_m': 21_646_509_900,
'qwen3.6:35b-a3b-mtp-q4_k_m': 28_808_554_317,
'qwen3.6:35b-a3b': 35_955_462_956,
'qwen3.8:27b-q8_0': 44_292_142_594,
@@ -544,6 +548,9 @@ describe('the fit grade', () => {
// ---------- 2. The two real Macs ----------
describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => {
/** The same Mac without the probe: 48 GiB and the chip are all the fit and the speed depend on. */
const hw48 = mac(48, 'Apple M5 Pro');
/**
* Expected: Qwen3.6 35B-A3B at 131,072 tokens with OpenCode, a TIGHT fit, 67.7 tokens per
* second, "Recommended for this Mac".
@@ -642,7 +649,7 @@ describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => {
expect(result.notes).toEqual([]);
});
it('lists four other models that fit, each at the largest context it fits at, the fast ones first', async () => {
it('lists every other model that fits, each at the largest context it fits at, the fast ones first', async () => {
const result = recommend(await macbook(), STOCK, ALL_INSTALLED);
expect(
result.alternatives.map((c) => [c.modelId, c.context, c.fit, c.speed, c.tokensPerSecond ?? null, c.tag?.kind])
@@ -651,16 +658,21 @@ describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => {
['qwen3.6:35b-a3b-mtp-q4_k_m', 262144, 'good', 'ok', 41.8, undefined],
['north-mini-code-1.0:q4_k_m', 262144, 'good', 'ok', 41.8, undefined],
['mimo-v2.6:9b-q4_k_m', 131072, 'good', 'ok', 28.6, 'small'],
['ornith:9b-q4_k_m', 131072, 'good', 'ok', 28.6, 'small'],
['qwen3.5:9b-q4_k_m', 131072, 'good', 'ok', 28.6, 'small'],
// The dense models compute to 2.95 x 3.07 = 9.1 tokens per second on this chip (5.2 in
// 8 bits): slow, so they come after every fast model, in the order of their rank.
['qwen3.8:27b-mtp-q4_k_m', 262144, 'tight', 'slow', 9.1, 'tight'],
['qwen3.8:27b-q8_0', 65536, 'tight', 'slow', 5.2, 'tight'],
['muse-glimmer:30b-q4_k_m', 131072, 'good', 'slow', 9.1, 'slow'],
]);
// Only the tested row rests on a measurement of that model.
expect(result.alternatives.map((c) => c.needSource)).toEqual(['estimated', 'estimated', 'estimated', 'estimated']);
// The dense 27B fits too (32.1 GB at 262144) but computes to 2.95 x 3.07 = 9.1 tokens per
// second here: slow, so it ranks after four fast models and is not listed.
expect(modelFitsBudget(await macbook(), STOCK, 'qwen3.8:27b-mtp-q4_k_m', 262144)).toBe(true);
expect(order(result).some((entry) => entry.startsWith('qwen3.8:27b'))).toBe(false);
expect(new Set(result.alternatives.map((c) => c.needSource))).toEqual(new Set(['estimated']));
// Every model of the catalogue that fits is on the list: nothing is cut off after four.
const fitting = STOCK.models.filter((m) => m.contexts.some((ctx) => modelFitsBudget(hw48, STOCK, m.id, ctx)));
expect(rows(result).map((c) => c.modelId).sort()).toEqual(fitting.map((m) => m.id).sort());
// The 122B fits at no context.
expect(order(result).some((entry) => entry.startsWith('ornith'))).toBe(false);
expect(order(result).some((entry) => entry.startsWith('ornith-1.5'))).toBe(false);
});
it('would get the same model as "Best fit", with the estimated speed, had nobody tested it', async () => {
@@ -692,41 +704,68 @@ describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => {
['qwen3.8:27b-mtp-q4_k_m', 'unknown'],
['qwen3.8:27b-q8_0', 'unknown'],
['qwen3.6:35b-a3b-mtp-q4_k_m', 'unknown'],
['muse-glimmer:30b-q4_k_m', 'unknown'],
['north-mini-code-1.0:q4_k_m', 'unknown'],
['mimo-v2.6:9b-q4_k_m', 'unknown'],
['ornith:9b-q4_k_m', 'unknown'],
['qwen3.5:9b-q4_k_m', 'unknown'],
]);
});
});
describe('the Mac mini (Mac16,10: Apple M4, 16 GB)', () => {
/**
* Expected: verdict `none` (reason `memory`) on the estimated budget, and `limited` with
* MiMo V2.6 9B (a GGUF build) at 65,536 tokens, a TIGHT fit, once the runtime measured its
* budget.
* Expected: `limited` with Ornith 1.0 9B at 65,536 tokens, a TIGHT fit, on the estimated
* budget, and MiMo V2.6 9B at 65,536 tokens, also tight, once the runtime measured its budget.
*
* Why. The estimated budget is 9.67 GiB (10.4 GB: 2/3 of 16 GiB minus 1). The 9B on
* llama.cpp needs 6.55 GB of weights + 1.14 GB for a q8_0 K/V cache of 65,536 tokens +
* 2.55 GB beside them (its recurrent state live and in 8 context checkpoints, the runner, and
* the 1 GiB prompt cache the runtime allows) = 10.2 GB: 0.987 of the estimate, just past the
* 0.98 a tight fit may reach. The runtime measured 11.8 GiB on this Mac; the 6 GiB reserve
* leaves 10 GiB (10.7 GB) of it, and the same 10.2 GB is 0.954 of that.
* Why. The estimated budget is 9.67 GiB (10.4 GB: 2/3 of 16 GiB minus 1). A 9B on llama.cpp
* needs its weights + 1.14 GB for a q8_0 K/V cache of 65,536 tokens + 2.55 GB beside them (its
* recurrent state live and in 8 context checkpoints, the runner, and the 1 GiB prompt cache
* the runtime allows). MiMo and its base are 6.55 GB (a vision projector of 0.92 GB
* included): 10.2 GB, 0.987 of the estimate, just past the 0.98 a tight fit may reach. The
* Ornith build has no projector, 5.63 GB: 9.3 GB, 0.898 of the estimate. The runtime measured
* 11.8 GiB on this Mac; the 6 GiB reserve leaves 10 GiB (10.7 GB) of it, which admits all
* three, and the rank puts MiMo first.
*
* Every figure of this row is computed from the model's architecture, none was measured
* (`needSource: 'estimated'`), so this Mac is on the edge either way. Only a run of the 9B on
* it settles which side.
* Every figure of these rows is computed from the model's architecture, none was measured
* (`needSource: 'estimated'`), and no 9B was ever run on such a Mac by us.
*/
it('gets no model on the estimated budget: the 9B needs 0.987 of it', async () => {
it('gets Ornith 1.0 9B at 65536 tokens on the estimated budget, tight: the other 9B builds need 0.987 of it', async () => {
const hw = await macMini();
expect(hw.chip).toBe('Apple M4');
expect(hw.memClassGb).toBe(16);
const result = recommend(hw, STOCK, ALL_INSTALLED);
expect(result).toMatchObject({ verdict: 'none', reason: 'memory', budgetSource: 'estimated' });
expect(result.headline).toEqual({ text: LOCAL_MODEL_LABELS.noneMemory, vars: { memory: '16' } });
expect(result.pick).toBeUndefined();
expect(result).toMatchObject({ verdict: 'limited', budgetSource: 'estimated' });
expect(result.headline).toEqual({ text: LOCAL_MODEL_LABELS.headlineLimited });
expect(result.notes).toEqual([{ text: LOCAL_MODEL_LABELS.noteUntested }]);
// floor(17,179,869,184 x 2/3) - 1 GiB.
expect(result.budgetBytes).toBe(10_379_504_298);
expect(result.budgetGb).toBe('10.4');
expect(result.pick).toMatchObject({
modelId: 'ornith:9b-q4_k_m',
modelLabel: 'Ornith 1.0 9B',
license: 'MIT',
context: 65536,
cli: 'opencode',
// 5,629,110,568 + 17,408 x 65,536 + 2,547,960,320.
needBytes: 9_317_921_576,
needGb: '9.3',
memory: { modelBytes: 8_177_070_888, contextBytes: 1_140_850_688, peakBytes: 0 },
downloadGb: '5.6',
budgetShare: 0.898,
fit: 'tight',
// 9.3 x 120 / 100.
speed: 'ok',
tokensPerSecond: 11.2,
tested: false,
needSource: 'estimated',
tag: { kind: 'best-tight', label: 'Best fit, tight' },
});
// It is the only model that fits, and only at this context: at 131072 it needs 10.5 GB.
expect(order(result)).toEqual(['ornith:9b-q4_k_m@65536']);
expect(modelFitsBudget(hw, STOCK, 'ornith:9b-q4_k_m', 131072)).toBe(false);
const nineB = stockModel('mimo-v2.6:9b-q4_k_m');
// 6,550,811,033 + 17,408 x 65,536 + 2,547,960,320.
expect(needBytes(nineB, 65536)).toBe(10_239_622_041);
@@ -734,7 +773,7 @@ describe('the Mac mini (Mac16,10: Apple M4, 16 GB)', () => {
expect(needBytes(nineB, 65536) / result.budgetBytes).toBeLessThan(0.99);
});
it('would need 11.4 GB at 131072 tokens, more than the whole budget', async () => {
it('would need 11.4 GB for MiMo at 131072 tokens, more than the whole budget', async () => {
const hw = await macMini();
const nineB = stockModel('mimo-v2.6:9b-q4_k_m');
const budget = deviceBudget(hw).budgetBytes;
@@ -744,7 +783,7 @@ describe('the Mac mini (Mac16,10: Apple M4, 16 GB)', () => {
expect(modelFitsBudget(hw, STOCK, 'mimo-v2.6:9b-q4_k_m', 65536)).toBe(false);
});
it('gets a small model with the budget its runtime measured: the 9B at 65536 tokens, tight', async () => {
it('gets MiMo with the budget its runtime measured: at 65536 tokens, tight', async () => {
// The runtime reported 11.8 GiB here; the 6 GiB reserve leaves 10 GiB (10.7 GB) of it.
const result = recommend(await macMini(), STOCK, ALL_INSTALLED, { measuredTotalBytes: MACMINI_MEASURED_TOTAL });
expect(result).toMatchObject({
@@ -773,13 +812,20 @@ describe('the Mac mini (Mac16,10: Apple M4, 16 GB)', () => {
needSource: 'estimated',
tag: { kind: 'best-tight', label: 'Best fit, tight' },
});
// Its base model, the same figures, is the one alternative.
expect(order(result)).toEqual(['mimo-v2.6:9b-q4_k_m@65536', 'qwen3.5:9b-q4_k_m@65536']);
// The other two 9B builds follow by rank. The Ornith build is small enough for 131072
// tokens here (10.5 GB, 0.974), and a model is listed at the largest context it fits at.
expect(order(result)).toEqual([
'mimo-v2.6:9b-q4_k_m@65536',
'ornith:9b-q4_k_m@131072',
'qwen3.5:9b-q4_k_m@65536',
]);
});
it('gets the same with Claude Code as its only coding agent, which also needs 65536 tokens', async () => {
const hw = await macMini();
expect(recommend(hw, STOCK, [fact('claude')])).toMatchObject({ verdict: 'none', reason: 'memory' });
const estimated = recommend(hw, STOCK, [fact('claude')]);
expect(estimated.verdict).toBe('limited');
expect(estimated.pick).toMatchObject({ modelId: 'ornith:9b-q4_k_m', context: 65536, cli: 'claude' });
const measured = recommend(hw, STOCK, [fact('claude')], { measuredTotalBytes: MACMINI_MEASURED_TOTAL });
expect(measured.verdict).toBe('limited');
expect(measured.pick).toMatchObject({ modelId: 'mimo-v2.6:9b-q4_k_m', context: 65536, cli: 'claude' });
@@ -791,22 +837,26 @@ describe('every memory size, with the stock catalogue', () => {
// size. A change of a catalogue row or of a threshold that moves one of these answers has to
// change this table on purpose. docs/local-models.md has the same table for people.
const EXPECTED: [soldGb: number, chip: string, answer: string][] = [
// The smallest need at 65536 tokens is 10.2 GB; the budget is 2.1 GB.
// The smallest need at 65536 tokens is 9.3 GB; the budget is 2.1 GB.
[8, 'Apple M2', 'none memory'],
// The 9B needs 10.2 GB at 65536 tokens, 0.987 of the 10.4 GB estimate: just out. A runtime
// that measured the budget a real 16 GB Mac measured admits it (see the Mac mini above).
[16, 'Apple M4', 'none memory'],
[16, 'Apple M1', 'none memory'],
// The 9B build without a vision projector needs 9.3 GB at 65536 tokens, 0.898 of the 10.4 GB
// estimate. The other two need 10.2 GB, 0.987: just out, until a runtime measures what a
// real 16 GB Mac measured (see the Mac mini above). On an M1 (68 GB/s) a 9B is slow: 6.3.
[16, 'Apple M4', 'limited ornith:9b-q4_k_m@65536 tight ok'],
[16, 'Apple M1', 'limited ornith:9b-q4_k_m@65536 tight slow'],
// 11.4 GB at 131072 tokens, 0.964 of 11.8 GB, and nothing of 12 billion fits: `limited`.
[18, 'Apple M3 Pro', 'limited mimo-v2.6:9b-q4_k_m@131072 tight ok'],
// On a base chip with 100 GB/s the 9B computes to 9.3 tokens per second: slow.
[24, 'Apple M3', 'limited mimo-v2.6:9b-q4_k_m@131072 good slow'],
[24, 'Apple M4', 'limited mimo-v2.6:9b-q4_k_m@131072 good ok'],
[24, 'Apple M4 Pro', 'limited mimo-v2.6:9b-q4_k_m@131072 good ok'],
// North Mini Code needs 23.2 GB at 65536 tokens: 1.06 of the 21.8 GB estimate. A runtime
// that measures about 3/4 of the memory admits it (see below).
[32, 'Apple M4', 'limited mimo-v2.6:9b-q4_k_m@131072 good ok'],
[32, 'Apple M1 Max', 'limited mimo-v2.6:9b-q4_k_m@131072 good ok'],
// Muse Glimmer 30B fits at 65536 tokens: 21.2 GB, 0.970 of the 21.8 GB estimate. So a 32 GB
// Mac is no small-model Mac (`ok`). The model is dense: on a base chip it computes to 3.5
// tokens per second and the fast 9B stays the pick, on a Max chip (11.8) it is the pick.
// North Mini Code needs 23.2 GB at 65536 tokens, 1.06 of the estimate: a runtime that
// measures about 3/4 of the memory admits it (see below).
[32, 'Apple M4', 'ok mimo-v2.6:9b-q4_k_m@131072 good ok'],
[32, 'Apple M1 Max', 'ok muse-glimmer:30b-q4_k_m@65536 tight ok'],
// A Max chip runs the dense 27B fast enough (12.1); an M3 Max (300 GB/s) does not (8.9),
// and takes the 35B-A3B as a GGUF build, whose memory does not grow after it loaded. Both
// at 131072 tokens, 0.966 and 0.971 of the budget.
@@ -846,18 +896,25 @@ describe('every memory size, with the stock catalogue', () => {
expect(result.headline).toEqual({ text: LOCAL_MODEL_LABELS.noneMemory, vars: { memory: '8' } });
// The smallest model a harness can work with needs more than four times the budget.
const smallest = Math.min(...STOCK.models.map((m) => needBytes(m, 65536)));
expect(formatGb(smallest)).toBe('10.2');
expect(formatGb(smallest)).toBe('9.3');
expect(smallest / result.budgetBytes).toBeGreaterThan(4);
});
it('gives no model to 8 and 16 GB, a small model to every Mac from 18 to 32 GB, and every larger one a model of 27 billion or more', () => {
it('gives no model to 8 GB, a small model to every Mac from 16 to 24 GB, and every Mac from 36 GB a model of 27 billion or more', () => {
for (const hw of SWEEP) {
const result = recommend(hw, STOCK, ALL_INSTALLED);
const label = `${hw.memClassGb} GB ${hw.chip}`;
if (hw.memClassGb <= 16) expect(result.reason, label).toBe('memory');
else if (hw.memClassGb <= 32) {
if (hw.memClassGb <= 8) expect(result.reason, label).toBe('memory');
else if (hw.memClassGb <= 24) {
expect(result.verdict, label).toBe('limited');
expect(result.pick?.paramsB, label).toBe(9);
} else if (hw.memClassGb === 32) {
// A model of 30 billion fits, so the Mac is not `limited`. It is dense: the pick only
// where it is not slow (a chip this file knows is fast, or one it knows nothing about).
expect(result.verdict, label).toBe('ok');
expect(rows(result).map((c) => c.modelId), label).toContain('muse-glimmer:30b-q4_k_m');
expect(result.pick?.paramsB, label).toBe(hw.chip === 'Apple M4' ? 9 : 29.6);
if (hw.chip === 'Apple M4') expect(result.notes[0], label).toEqual({ text: LOCAL_MODEL_LABELS.noteSmallPick });
} else {
expect(result.verdict, label).toBe('ok');
expect(result.pick?.paramsB, label).toBeGreaterThanOrEqual(27);
@@ -1620,11 +1677,13 @@ describe('the ranking', () => {
expect(result.pick).toMatchObject({ context: 1024, fit: 'tight', budgetShare: 0.92 });
});
it('lists each model once, never the pick again, and at most four alternatives', () => {
expect(MAX_ALTERNATIVES).toBe(4);
// Seven models fit a 128 GB Mac; the pick plus four are shown.
it('lists each model once, never the pick again, and every model that fits', () => {
// The bound is above what either catalogue holds, so nothing that fits is cut off.
expect(MAX_ALTERNATIVES).toBe(11);
for (const [, catalog] of BOTH) expect(catalog.models.length - 1).toBeLessThanOrEqual(MAX_ALTERNATIVES);
// Seven models fit a 128 GB Mac: the pick and all six others are shown.
const result = recommend(mac(128, 'Apple M5 Ultra'), RULES, ALL_INSTALLED);
expect(result.alternatives).toHaveLength(4);
expect(result.alternatives).toHaveLength(6);
const ids = rows(result).map((c) => c.modelId);
expect(new Set(ids).size).toBe(ids.length);
for (const [, catalog] of BOTH) {
@@ -1633,9 +1692,26 @@ describe('the ranking', () => {
const sweptIds = rows(swept).map((c) => c.modelId);
expect(new Set(sweptIds).size).toBe(sweptIds.length);
expect(swept.alternatives.length).toBeLessThanOrEqual(MAX_ALTERNATIVES);
// Every model that fits at a context a harness works at is on the list (the sweep's
// Macs have 500 GB of free disk, so none is left out for room).
const fitting = catalog.models.filter((m) =>
m.contexts.some((ctx) => ctx >= 65536 && modelFitsBudget(hw, catalog, m.id, ctx))
);
expect([...sweptIds].sort(), `${hw.memClassGb} GB ${hw.chip}`).toEqual(fitting.map((m) => m.id).sort());
}
}
});
it('still cuts a list longer than the bound, and the install can only take what is listed', () => {
// Fourteen models that all fit: the pick and MAX_ALTERNATIVES others, best rank first.
const many = catalogOf(
Array.from({ length: 14 }, (_, index) => modelNeeding(`m${index + 1}:x`, GIB, { rank: index + 1 }))
);
const result = recommend(HUNDRED_GIB_MAC, many, [fact('alpha')]);
expect(rows(result)).toHaveLength(1 + MAX_ALTERNATIVES);
expect(rows(result)[0].modelId).toBe('m14:x');
expect(rows(result).at(-1)?.modelId).toBe('m3:x');
});
});
describe('harnesses', () => {
@@ -2064,6 +2140,8 @@ describe('what each row of the chooser carries', () => {
'qwen3.5:9b-q8_0': 'estimated',
'qwen3.5:9b': 'estimated',
'gpt-oss:20b': 'estimated',
// Slow on this chip, so it was the sixth row: listed since the list holds every model.
'qwen3.8:27b-q8_0': 'estimated',
});
});
});
@@ -2075,10 +2153,19 @@ describe('the measured budget, end to end', () => {
const measured = recommend(hw, STOCK, ALL_INSTALLED, { measuredTotalBytes: MACBOOK_MEASURED_TOTAL });
expect([estimated.budgetSource, estimated.budgetGb]).toEqual(['estimated', '37.6']);
expect([measured.budgetSource, measured.budgetGb]).toEqual(['measured', '40.2']);
// The same rows in the same order; only the share of each changes.
expect(order(measured)).toEqual(order(estimated));
expect(rows(estimated).map((c) => c.budgetShare)).toEqual([0.957, 0.767, 0.692, 0.303, 0.303]);
expect(rows(measured).map((c) => c.budgetShare)).toEqual([0.895, 0.717, 0.647, 0.283, 0.283]);
// The same models in the same order. The share of each changes, and one model steps up a
// context: the 27B in 8 bits fits 65536 tokens under the estimate (0.975) and 131072 under
// what the runtime reported (0.976).
expect(rows(measured).map((c) => c.modelId)).toEqual(rows(estimated).map((c) => c.modelId));
const stepped = order(estimated).filter((entry, index) => order(measured)[index] !== entry);
expect(stepped).toEqual(['qwen3.8:27b-q8_0@65536']);
expect(order(measured)).toContain('qwen3.8:27b-q8_0@131072');
expect(rows(estimated).map((c) => c.budgetShare)).toEqual([
0.957, 0.767, 0.692, 0.303, 0.278, 0.303, 0.853, 0.975, 0.576,
]);
expect(rows(measured).map((c) => c.budgetShare)).toEqual([
0.895, 0.717, 0.647, 0.283, 0.26, 0.283, 0.798, 0.976, 0.539,
]);
for (const candidate of rows(measured)) {
expect(candidate.budgetShare).toBeCloseTo(candidate.needBytes / MACBOOK_MEASURED_TOTAL, 3);
}