feat(local-model): judge the fit on the measured peak and rank a tested Mac first

The catalogue rows carry the figures measured on a Mac (the MLX build of
qwen3.6:35b-a3b is 23,105,798,956 bytes, about 37 KB held per context token,
about 3 GB of runner overhead, about 5 GB more for a moment while a long
prompt is processed). A model's need is now its peak. Rows that were not
measured use the same per-token and overhead figures and say `estimated`.

qwen3.6:35b-a3b gets the tested entry for an Apple M5 Pro with 48 GB and
OpenCode, with its measured 67.7 tokens per second, so that row may say
"Tested on a Mac like this".

The ranking changes with it: a pair tested on a Mac like this one comes
first, then speed (ok, unknown, slow), then model rank, harness rank and the
larger context. The fit grade decides whether a row is listed and what it
says, it no longer sorts, so a model is offered at the largest context it
fits at. recommend() takes the budget the runtime measured
(`budgetSource: measured`), and modelFitsBudget() judges one model against it.

Candidate rows now always carry `needSource`, `harnesses`, `installBytes`,
`installGb` and, where one applies, `tag`. `kvBytesPerToken` and the prompt
cache tiers are gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 22:16:37 +01:00
1 parent 84c1017487
commit 492df776d0
7 files changed
+1241 -502

No files matched your search

+6 -6
View File
@@ -7,12 +7,12 @@
* `budgetGb`, and the size slots of a `LocalModelMessage`). Nothing else may turn bytes into a
* size for display, the browser included: it prints the text it was sent.
*
* GiB and MiB exist only for the budget math (reserves, the wired limit, the prompt cache) and
* never reach a person, with one exception: `memClassGb()`, the memory size a Mac is sold as,
* is how the card names the installed memory ("48 GB memory", what About This Mac shows). It
* is a name of the machine, not a computed size: `formatGb()` of the same bytes reads 51.5,
* which no owner recognises. Every computed size (need, budget, download, free disk) is
* `formatGb()`.
* GiB and MiB exist only for the budget math (reserves, the wired limit, the device total the
* runtime reports) and never reach a person, with one exception: `memClassGb()`, the memory
* size a Mac is sold as, is how the card names the installed memory ("48 GB memory", what
* About This Mac shows). It is a name of the machine, not a computed size: `formatGb()` of the
* same bytes reads 51.5, which no owner recognises. Every computed size (need, budget,
* download, free disk) is `formatGb()`.
*
* Pure: no IO, no state.
*/
+154 -70
View File
@@ -3,21 +3,33 @@
* fits this Mac. Pure: no IO, no clock, no CLI registry, no data dir.
*
* The math, in the order it runs:
* - `deviceBudget()`: L is the wired limit when it is above 0, else 2/3 of memory below 36 GB
* and 3/4 from 36 GB. B = min(L - 1 GiB, memory - reserve), with a reserve of 6 GiB up to
* 18 GB, 8 GiB up to 48 GB and 12 GiB above.
* - `needBytes()`: F = weights + kvBytesPerToken * ctx + 1 GiB + the prompt cache tier of the
* catalogue for this Mac's memory.
* - Fit: F/B up to 0.85 is good, up to 0.98 is tight, above is out.
* - Speed: tokens per second = the model's `speedPer100Gbps` x the chip's bandwidth / 100.
* Below 10 is slow, decided on the unrounded product (9.96 is slow although it prints as
* 10). A chip that is in no chips row, or a model without a speed figure, is UNKNOWN: a
* - `deviceBudget()`. ESTIMATED (all there is before the runtime ran): L is the wired limit
* when it is above 0, else 2/3 of memory below 36 GB and 3/4 from 36 GB, and
* B = min(L - 1 GiB, memory - reserve), with a reserve of 6 GiB up to 18 GB, 8 GiB up to
* 48 GB and 12 GiB above. MEASURED (`RecommendOptions.measuredTotalBytes`, the device total
* the runtime printed when it started): L is that total and B = min(L, memory - reserve),
* without the 1 GiB, because the figure is already what the runtime will really use.
* - `needBytes()`: the PEAK, `bytes + heldBytesPerToken * ctx + overheadBytes +
* peakExtraBytes` of the catalogue row: the weights, what stays held per token of context,
* what the runner holds beside both, and what it takes for a moment while a long prompt is
* processed. Measured for one model on one Mac (see the catalogue header); every other row
* carries the same figures and says `estimated`.
* - Fit: need over budget up to 0.85 is good, up to 0.98 is tight, above is out. The grade
* decides whether a model is offered at all and what its row says. It does NOT sort: the
* need is the worst moment at the full context, so a tight fit is a model that runs, and a
* Mac whose best model is tight should be offered that model, not a smaller one.
* - Speed: a candidate that matches a `tested` entry with a measured speed reports that
* speed. Every other one is estimated: the model's `speedPer100Gbps` x the chip's bandwidth
* / 100. Below 10 is slow, decided on the unrounded figure (9.96 is slow although it prints
* as 10). A chip that is in no chips row, or a model without a speed figure, is UNKNOWN: a
* bandwidth is never guessed.
* - Candidates: (model, context, harness) with `ctx >= harness.minContext`, the harness not
* gated, installed or installable, and accepting the model id.
* - Ranking, lexicographic: good before tight; ok before unknown before slow; the higher model
* rank; the higher harness rank; the larger context. The pick is the first candidate, the
* alternatives are the next four MODELS, each at its own best context and harness.
* - Ranking, lexicographic: tested on a Mac like this before not tested (a measurement beats
* a formula); ok before unknown before slow; the higher model rank; the higher harness
* rank; the larger context. The pick is the first candidate, the alternatives are the next
* four MODELS, each at its own best context and harness, so a model is always offered at
* the largest context it fits at.
* - Disk: a model whose download would leave less than 10 GiB free is not offered. It is not
* dropped in silence: the best-ranked model left out that way is named in `noteDiskDropped`
* with what is missing.
@@ -32,8 +44,8 @@
* installable).
* 6. `memory`: models fit, but none at a context a usable harness needs. Its own sentence
* (`noneMemoryContext`) with the budget and the smallest need at such a context, because
* this Mac can miss by a hair (the 16 GB Mac mini: 10.3 of 10.4 GB) and "too little
* memory" would hide that the answer rests on an estimate.
* "too little memory" would hide that a model does fit at a smaller context and that the
* answer rests on estimates.
* 7. `no-harness`: models fit at such a context, but no usable harness accepts their id.
* 8. `disk`: for every remaining model, free disk minus the download is below 10 GiB (an
* unknown free disk does not block).
@@ -42,16 +54,20 @@
* - Verdict `limited` when NO model with 12 billion parameters or more fits this Mac's memory
* at a context a usable harness works at. It is a statement about the Mac ("This Mac can run
* a small model"), so it is judged on everything that fits, BEFORE the disk filter and
* whatever the pick is: a 36 GB Mac whose pick is a 9B model while a 20B, a 27B and a 35B
* are listed under it is not a small-model Mac, and a 48 GB Mac does not become one because
* its disk is full. A small pick under verdict `ok` carries `noteSmallPick` instead.
* whatever the pick is: a 36 GB Mac whose pick is a 9B model while a 20B is listed under it
* is not a small-model Mac, and a 48 GB Mac does not become one because its disk is full. A
* small pick under verdict `ok` carries `noteSmallPick` instead.
* - The word "Recommended" (`LOCAL_MODEL_LABELS.headlineTested`) only for verdict `ok` with a
* pick whose model has a `tested` entry for exactly this chip, this memory size and this
* harness. Otherwise `headlineOk` (or `headlineLimited`) plus `noteUntested`.
* - Every label in the result comes from `LOCAL_MODEL_LABELS`, every computed size text from
* `formatGb()`. The one size that is not decimal GB is the installed memory in `noneMemory`:
* `HardwareFacts.memClassGb`, the size the Mac is sold as. No number is spliced into a
* sentence here.
* - Every row carries what the chooser needs and nothing the browser must work out: its one
* tag (`tagFor()`), every harness it can run with (`Candidate.harnesses`, the flat harness
* fields first), and the whole download of an install (`installBytes`: the model plus
* `RecommendOptions.runtimeDownloadBytes`).
* - Every label in the result comes from `LOCAL_MODEL_LABELS` or `LOCAL_MODEL_TAG_LABELS`,
* every computed size text from `formatGb()`. The one size that is not decimal GB is the
* installed memory in `noneMemory`: `HardwareFacts.memClassGb`, the size the Mac is sold
* as. No number is spliced into a sentence here.
* - No CLI id is ever compared with a literal: a harness is a catalogue row plus a
* `HarnessFact`.
*/
@@ -59,7 +75,11 @@
import {
LOCAL_MODEL_LABELS,
LOCAL_MODEL_NONE_LABELS,
LOCAL_MODEL_TAG_LABELS,
type Candidate,
type CandidateHarness,
type CandidateTag,
type CandidateTagKind,
type DeviceBudget,
type FitGrade,
type HardwareFacts,
@@ -73,6 +93,7 @@ import {
type Recommendation,
type RecommendOptions,
type SpeedGrade,
type TestedOn,
} from '../types.js';
import { chipBandwidthGbps } from '../config/local-model-catalog.js';
import { formatGb, GIB, MIB } from './format.js';
@@ -81,14 +102,12 @@ import { formatGb, GIB, MIB } from './format.js';
export const FIT_GOOD_MAX_SHARE = 0.85;
/** Need over budget up to this share is a `tight` fit; above it the model is out. */
export const FIT_TIGHT_MAX_SHARE = 0.98;
/** An estimated speed below this many tokens per second is `slow`. */
/** A speed below this many tokens per second is `slow`. */
export const SLOW_BELOW_TOKENS_PER_SECOND = 10;
/** When no model that fits has at least this many billion parameters, the verdict is `limited`. A pick below it is a "small model". */
export const LIMITED_BELOW_PARAMS_B = 12;
/** What must stay free on the disk after the model download. */
export const DISK_HEADROOM_BYTES = 10 * GIB;
/** What the runtime needs beside the weights, the KV cache and the prompt cache (compute buffers). */
export const RUNTIME_OVERHEAD_BYTES = GIB;
/** How many other models are listed under the pick. */
export const MAX_ALTERNATIVES = 4;
@@ -102,30 +121,39 @@ function reserveBytes(memClassGb: number): number {
return 12 * GIB;
}
/** The memory a model may use on this Mac: the device limit L, the budget B and where L came from. */
export function deviceBudget(hw: HardwareFacts): DeviceBudget {
/** A byte count a caller supplied, when it is one: finite and above 0. */
function positiveBytes(value: number | undefined): number | undefined {
return value !== undefined && Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined;
}
/**
* The memory a model may use on this Mac: the device limit L, the budget B and where L came
* from. With `options.measuredTotalBytes` the limit is what the runtime itself reported and
* the 1 GiB the estimate keeps back is not subtracted; the reserve bounds both.
*/
export function deviceBudget(hw: HardwareFacts, options: RecommendOptions = {}): DeviceBudget {
const memBytes = Number.isFinite(hw.memBytes) && hw.memBytes > 0 ? hw.memBytes : 0;
const afterReserve = memBytes - reserveBytes(hw.memClassGb);
const measured = positiveBytes(options.measuredTotalBytes);
if (measured !== undefined) {
return { limitBytes: measured, budgetBytes: Math.max(0, Math.min(measured, afterReserve)), source: 'measured' };
}
// 0 means "not set" (both test Macs report 0): only a value above 0 is a limit.
const wired = hw.wiredLimitMb !== undefined && Number.isFinite(hw.wiredLimitMb) && hw.wiredLimitMb > 0;
const limitBytes = wired
? Math.floor((hw.wiredLimitMb as number) * MIB)
: Math.floor(memBytes * (hw.memClassGb < LARGE_LIMIT_FROM_GB ? 2 / 3 : 3 / 4));
const budgetBytes = Math.max(0, Math.min(limitBytes - GIB, memBytes - reserveBytes(hw.memClassGb)));
const budgetBytes = Math.max(0, Math.min(limitBytes - GIB, afterReserve));
return { limitBytes, budgetBytes, source: wired ? 'wired-limit' : 'estimated' };
}
/** The prompt cache of the last tier whose minimum is not above this Mac's memory size. */
function promptCacheBytes(catalog: LocalModelCatalog, memClassGb: number): number {
let bytes = 0;
for (const tier of catalog.promptCacheTiers) {
if (tier.minMemGb <= memClassGb) bytes = tier.bytes;
}
return bytes;
}
/** F: the bytes `model` needs in memory at a context of `ctx` tokens on this Mac. */
export function needBytes(model: LocalModelEntry, ctx: number, catalog: LocalModelCatalog, hw: HardwareFacts): number {
return model.bytes + model.kvBytesPerToken * ctx + RUNTIME_OVERHEAD_BYTES + promptCacheBytes(catalog, hw.memClassGb);
/**
* The bytes `model` needs in memory at a context of `ctx` tokens, at its PEAK: the weights,
* what stays held for the context, what the runner holds beside them, and what it takes for a
* moment while a long prompt is processed. It does not depend on the Mac.
*/
export function needBytes(model: LocalModelEntry, ctx: number): number {
return model.bytes + model.heldBytesPerToken * ctx + model.overheadBytes + model.peakExtraBytes;
}
function fitGrade(need: number, budgetBytes: number): FitGrade {
@@ -139,12 +167,10 @@ function fitGrade(need: number, budgetBytes: number): FitGrade {
/**
* Whether the catalogue model `modelId` at `context` tokens fits this Mac's budget (a `good`
* or a `tight` fit). The install job asks this right after the runtime started, with the
* budget the runtime measured, BEFORE it downloads the model: a `false` ends the job with
* `does-not-fit`. It is the same judgement `recommend()` makes for a candidate, without the
* ranking, so a model that fits is never ruled out for being listed sixth.
*
* `options` is the stage 2 input, as for `recommend()`; nothing reads it yet, so the budget
* is still the estimate. False for a model the catalogue does not have.
* budget the runtime measured (`options.measuredTotalBytes`), BEFORE it downloads the model: a
* `false` ends the job with `does-not-fit`. It is the same judgement `recommend()` makes for a
* candidate, without the ranking, so a model that fits is never ruled out for being listed
* sixth. False for a model the catalogue does not have.
*/
export function modelFitsBudget(
hw: HardwareFacts,
@@ -153,10 +179,9 @@ export function modelFitsBudget(
context: number,
options: RecommendOptions = {}
): boolean {
void options;
const model = catalog.models.find((entry) => entry.id === modelId);
if (!model) return false;
return fitGrade(needBytes(model, context, catalog, hw), deviceBudget(hw).budgetBytes) !== 'out';
if (!model || !Number.isFinite(context) || context <= 0) return false;
return fitGrade(needBytes(model, context), deviceBudget(hw, options).budgetBytes) !== 'out';
}
/**
@@ -189,13 +214,12 @@ interface UsableHarness {
fact: HarnessFact;
}
const FIT_ORDER: Record<Candidate['fit'], number> = { good: 0, tight: 1 };
const SPEED_ORDER: Record<SpeedGrade, number> = { ok: 0, unknown: 1, slow: 2 };
/** Best first. */
/** Best first. The fit grade is not a key: every candidate here fits, see the file header. */
function compareRanked(a: Ranked, b: Ranked): number {
return (
FIT_ORDER[a.candidate.fit] - FIT_ORDER[b.candidate.fit] ||
Number(b.candidate.tested) - Number(a.candidate.tested) ||
SPEED_ORDER[a.candidate.speed] - SPEED_ORDER[b.candidate.speed] ||
b.modelRank - a.modelRank ||
b.harnessRank - a.harnessRank ||
@@ -212,27 +236,53 @@ function accepts(fact: HarnessFact, modelId: string): boolean {
}
}
/** What the chooser's "Runs with" select needs to know about one harness. */
function candidateHarness(harness: UsableHarness): CandidateHarness {
const { row, fact } = harness;
return {
cli: row.cli,
cliLabel: fact.label,
installed: fact.installed,
// Only a harness that still has to be installed has a command to show.
...(!fact.installed && fact.installCommand ? { installCommand: fact.installCommand } : {}),
...(row.slowFirstTurn ? { slowFirstTurn: true } : {}),
};
}
/**
* The entry that makes this model "tested on a Mac like this": exactly the same chip string,
* the same memory size as sold, the same harness.
*/
function testedEntry(model: LocalModelEntry, hw: HardwareFacts, cli: string): TestedOn | undefined {
return model.tested.find((entry) => entry.chip === hw.chip && entry.memGb === hw.memClassGb && entry.cli === cli);
}
function buildCandidate(
model: LocalModelEntry,
ctx: number,
harness: UsableHarness,
/** Every usable harness that can run this model at this context, `harness` among them. */
runsWith: readonly UsableHarness[],
need: number,
fit: Candidate['fit'],
hw: HardwareFacts,
catalog: LocalModelCatalog,
budgetBytes: number
budgetBytes: number,
runtimeDownloadBytes: number
): Candidate {
const testedOn = testedEntry(model, hw, harness.row.cli);
const gbps = chipBandwidthGbps(catalog.chips, hw.chip);
// A speed measured on a Mac like this one beats the estimate from the chip's bandwidth.
const rawSpeed =
gbps !== undefined && model.speedPer100Gbps !== undefined ? (model.speedPer100Gbps * gbps) / 100 : undefined;
testedOn?.tokensPerSecond ??
(gbps !== undefined && model.speedPer100Gbps !== undefined ? (model.speedPer100Gbps * gbps) / 100 : undefined);
// Graded on the unrounded figure: 9.96 rounds to 10.0 for the payload and is still slow.
const speed: SpeedGrade =
rawSpeed === undefined ? 'unknown' : rawSpeed < SLOW_BELOW_TOKENS_PER_SECOND ? 'slow' : 'ok';
const tokensPerSecond = rawSpeed === undefined ? undefined : round(rawSpeed, 1);
// "A Mac like this" is exact: the same chip string, the same memory size as sold, the same harness.
const tested = model.tested.some(
(entry) => entry.chip === hw.chip && entry.memGb === hw.memClassGb && entry.cli === harness.row.cli
);
// The harness of the flat fields first, then the others, best first.
const others = runsWith.filter((other) => other !== harness).sort((a, b) => b.row.rank - a.row.rank);
const installBytes = model.bytes + runtimeDownloadBytes;
return {
modelId: model.id,
modelLabel: model.label,
@@ -254,10 +304,39 @@ function buildCandidate(
...(speed === 'slow' ? { speedLabel: LOCAL_MODEL_LABELS.speedSlow } : {}),
...(speed === 'unknown' ? { speedLabel: LOCAL_MODEL_LABELS.speedUnknown } : {}),
...(tokensPerSecond !== undefined ? { tokensPerSecond } : {}),
tested,
tested: testedOn !== undefined,
needSource: model.figures,
harnesses: [harness, ...others].map(candidateHarness),
installBytes,
installGb: formatGb(installBytes),
};
}
/**
* The one tag of a row: the first kind that applies, in the order tested, best (the pick),
* tight, slow, small. `undefined` when none does.
*/
function tagFor(candidate: Candidate, isPick: boolean): CandidateTag | undefined {
const kind: CandidateTagKind | undefined = candidate.tested
? 'tested'
: isPick
? 'best'
: candidate.fit === 'tight'
? 'tight'
: candidate.speed === 'slow'
? 'slow'
: candidate.paramsB < LIMITED_BELOW_PARAMS_B
? 'small'
: undefined;
return kind === undefined ? undefined : { kind, label: LOCAL_MODEL_TAG_LABELS[kind] };
}
/** `candidate` with its tag, when it has one. */
function withTag(candidate: Candidate, isPick: boolean): Candidate {
const tag = tagFor(candidate, isPick);
return tag ? { ...candidate, tag } : candidate;
}
/** A `none` answer. `text` replaces the reason's default sentence (the `memory` reason has three). */
function none(
reason: NoneReason,
@@ -281,9 +360,9 @@ function none(
* The recommendation for this Mac. `harnessFacts` holds one fact per catalogue harness the
* caller could resolve; a catalogue harness without a fact is not offered.
*
* `options` is the stage 2 input (`RecommendOptions`: the budget the runtime measured, and
* the runtime download an install still needs). The parameter is in place so callers compile
* against the final signature; nothing reads it yet.
* `options` is what the caller knows beyond the probe: the budget the runtime measured (the
* answer then says `budgetSource: 'measured'`), and the runtime download an install still
* needs (it is added to every row's `installBytes`).
*/
export function recommend(
hw: HardwareFacts,
@@ -291,13 +370,13 @@ export function recommend(
harnessFacts: readonly HarnessFact[],
options: RecommendOptions = {}
): Recommendation {
void options;
// Off macOS and on an Intel Mac there is no budget to compute: a model has no memory to run in.
const noBudget: DeviceBudget = { limitBytes: 0, budgetBytes: 0, source: 'estimated' };
if (hw.platform !== 'darwin') return none('not-macos', noBudget);
if (!isAppleSilicon(hw)) return none('intel', noBudget);
const budget = deviceBudget(hw);
const budget = deviceBudget(hw, options);
const runtimeDownloadBytes = positiveBytes(options.runtimeDownloadBytes) ?? 0;
if (hw.macosMajor !== undefined && hw.macosMajor < catalog.minMacosMajor) {
return none('macos', budget, {
version: hw.macosVersion ?? String(hw.macosMajor),
@@ -309,7 +388,7 @@ export function recommend(
const fitting: { model: LocalModelEntry; ctx: number; need: number; fit: Candidate['fit'] }[] = [];
for (const model of catalog.models) {
for (const ctx of model.contexts) {
const need = needBytes(model, ctx, catalog, hw);
const need = needBytes(model, ctx);
const fit = fitGrade(need, budget.budgetBytes);
if (fit !== 'out') fitting.push({ model, ctx, need, fit });
}
@@ -335,14 +414,14 @@ export function recommend(
usable.filter((harness) => entry.ctx >= harness.row.minContext).map((harness) => ({ ...entry, harness }))
);
if (atHarnessContext.length === 0) {
// Models fit, but only below the context a harness needs. Say both numbers: the smallest
// need at such a context can sit a hair over the line, and the budget is an estimate.
// Models fit, but only below the context a harness needs. Say both numbers: the reader
// sees how far apart they are, and that both are estimates.
const smallestContext = Math.min(...usable.map((harness) => harness.row.minContext));
let smallestNeed: number | undefined;
for (const model of catalog.models) {
for (const ctx of model.contexts) {
if (ctx < smallestContext) continue;
const need = needBytes(model, ctx, catalog, hw);
const need = needBytes(model, ctx);
if (smallestNeed === undefined || need < smallestNeed) smallestNeed = need;
}
}
@@ -379,11 +458,15 @@ export function recommend(
entry.model,
entry.ctx,
entry.harness,
accepted
.filter((other) => other.model === entry.model && other.ctx === entry.ctx)
.map((other) => other.harness),
entry.need,
entry.fit,
hw,
catalog,
budget.budgetBytes
budget.budgetBytes,
runtimeDownloadBytes
),
model: entry.model,
modelRank: entry.model.rank,
@@ -398,7 +481,8 @@ export function recommend(
}
const offered = bestPerModel.filter((entry) => hasRoom(entry.model)).map((entry) => entry.candidate);
const leftOutForDisk = bestPerModel.find((entry) => !hasRoom(entry.model));
const [pick, ...others] = offered;
const pick = withTag(offered[0], true);
const alternatives = offered.slice(1, 1 + MAX_ALTERNATIVES).map((candidate) => withTag(candidate, false));
// About the Mac, not the pick, and judged before the disk filter (see the file header).
const limited = !accepted.some((entry) => entry.model.paramsB >= LIMITED_BELOW_PARAMS_B);
@@ -427,7 +511,7 @@ export function recommend(
headline: { text: headline },
notes,
pick,
alternatives: others.slice(0, MAX_ALTERNATIVES),
alternatives,
budgetBytes: budget.budgetBytes,
budgetGb: formatGb(budget.budgetBytes),
budgetSource: budget.source,