mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
feat(local-model): hardware probe, model catalogue and the pure recommendation
The core of the read-only recommendation: what this Mac is, what the catalogue knows, and which model fits. Nothing is downloaded, installed or written. - hardware.ts: one bounded async spawn of /usr/sbin/sysctl with seven keys, without -n, parsed as "key: value" lines by the pure parseSysctl(). Free disk of the data dir through statfs behind the bounded path probe. Fallbacks from node:os, a 60 s cache, never throws, never system_profiler. A wired limit of 0 means "not set". - local-model-catalog.ts: seven models, three harness rows (pi gated) and the chips table as a compiled constant, validated by a Zod schema on first use. No override file. Model ids are name:tag in lowercase without a slash; every model carries paramsB and an (empty) tested list. Chip names match on the end of the chip string, so 'M4' is not the chip of 'Apple M4 Pro'. - recommend.ts: device budget (the wired limit when above 0, else 2/3 of memory below 36 GB and 3/4 from 36 GB, less the reserve), need = weights + KV cache + 1 GiB + prompt cache, fit good up to 0.85 and tight up to 0.98, speed ok, unknown or slow (a chip in no chips row is unknown), and the ranking. Verdicts ok, limited (best model under 12 billion parameters) and none with its reason. "Recommended" only for a pick with a tested entry for this Mac, which no row has today. - types/local-model.ts: comments brought in line with the code (chip matching, budgetShare rounding, a budget of 0 on an Intel Mac). - cli-registry-no-id-branching.test.ts: the guard also catches a comparison of a `cli` field with a CLI id (plus one stray blank line Prettier refused). Tests: test/local-model-format.test.ts, test/local-model-hardware.test.ts (the captured output of two real Macs), test/local-model-catalog.test.ts, test/local-model-recommend.test.ts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
73ce6a1819
commit
92c20fe572
9 files changed
+3423
-79
No files matched your search
@@ -2,16 +2,7 @@
|
||||
* @fileoverview The local model catalogue: which models, harnesses and chips the recommendation
|
||||
* knows, as a compiled constant validated by a Zod schema.
|
||||
*
|
||||
* SKELETON: the schema below is the shape of the contract (it mirrors `LocalModelCatalog` in
|
||||
* src/types/local-model.ts) and the stock constant is EMPTY. Still to come here:
|
||||
* - the model rows (ids and byte sizes from the registry manifests, `paramsB`, an empty
|
||||
* `tested` array each; `flint:40b` stays out), the harness rows and the chips table;
|
||||
* - the cross-row rules the schema cannot say field by field: unique model ids, unique harness
|
||||
* `cli`s, `contexts` largest first, `chips` most specific first, `promptCacheTiers` ascending
|
||||
* and starting at 0;
|
||||
* - `getLocalModelCatalog()` parsing the constant once, on first use, never at import.
|
||||
*
|
||||
* Rules that hold from the start:
|
||||
* Rules:
|
||||
* - There is NO override file and no runtime download data in this stage: nothing here reads
|
||||
* the data dir, and a data file can never decide what gets recommended.
|
||||
* - A model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`), so it can
|
||||
@@ -20,14 +11,71 @@
|
||||
* registry id as data: no code may compare it with a literal. Whether that CLI exists and can
|
||||
* take a custom model is checked against the registry at call time, in
|
||||
* src/local-model/harness-facts.ts, never here.
|
||||
* - The schema checks more than fields. Across rows it requires: unique model ids and ranks,
|
||||
* unique harness `cli`s and ranks, `contexts` largest first, a `tested` entry naming a harness
|
||||
* row, `chips` most specific first, and `promptCacheTiers` ascending from 0.
|
||||
* - `getLocalModelCatalog()` parses the constant once, on first use and never at import, and
|
||||
* throws on a bad row. test/local-model-catalog.test.ts pins that the stock constant parses,
|
||||
* so a bad row fails the gate and never reaches the route.
|
||||
*
|
||||
* Where each figure of the stock rows comes from, and what was checked (2026-11-06):
|
||||
*
|
||||
* | Figure | Source | Checked |
|
||||
* |----------------------|-----------------------------------------------------------|--------------------------------------|
|
||||
* | `id`, `bytes` | registry manifest of the tag, sum of its layer sizes | yes, to the byte, by the facts review |
|
||||
* | `license` | the model card (all Apache-2.0) | read by the research, not re-checked |
|
||||
* | `paramsB` | 27.8 from the model card; the others are the count in the | no. Only the 12 billion line reads |
|
||||
* | | model's name | it, and no row is near that line |
|
||||
* | `kvBytesPerToken` | COMPUTED from each model's config: 2 x full-attention | no. Not measured in a running |
|
||||
* | | layers x KV heads x head size x 2 bytes | runtime. The review called the |
|
||||
* | | | gpt-oss figure plausible, no more |
|
||||
* | `contexts`, `rank` | a judgement of the design | no |
|
||||
* | `speedPer100Gbps` | one published run on an M1 Max (400 GB/s): generation | the run was read. It stands for less |
|
||||
* | | only, short prompt. The two q8_0 rows are SCALED by file | than a session: no long prompt, no |
|
||||
* | | size, not measured | thinking tokens |
|
||||
* | `minMacosMajor` | the runtime's documentation (macOS 14) | yes, by the facts review |
|
||||
* | `promptCacheTiers` | a decision of the design, not a measurement | no |
|
||||
* | harness `minContext` | 65536: the runtime's documentation for both harnesses. | 65536 yes. 32768 no, which is why |
|
||||
* | | 32768: recalled | that row is gated |
|
||||
* | `chips` | the bandwidth column of | yes, read at the source for this |
|
||||
* | | https://github.com/ggml-org/llama.cpp/discussions/4167 | file |
|
||||
*
|
||||
* `tested` is empty on every row: nobody has run any of these models with a harness on a Mac
|
||||
* yet. Until a row has an entry, the recommendation never says "Recommended".
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { LocalModelCatalog } from '../types.js';
|
||||
import type { ChipEntry, LocalModelCatalog } from '../types.js';
|
||||
|
||||
/** A model id: lowercase name, one colon, a tag. No slash, so no registry host or namespace can ride along. */
|
||||
export const LOCAL_MODEL_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*:[a-z0-9][a-z0-9._-]*$/;
|
||||
|
||||
/**
|
||||
* Whether `chip` (a `machdep.cpu.brand_string`, 'Apple M4 Pro') is the chip a `ChipEntry.match`
|
||||
* ('M4 Pro') names: the string must END with the match, and the match must start at the
|
||||
* beginning or after a space. Case is ignored.
|
||||
*
|
||||
* Stricter than "the match occurs in the string" on purpose. 'M4' is not the chip of
|
||||
* 'Apple M4 Pro', 'M1' is not the chip of 'Apple M10', and a variant this table has never heard
|
||||
* of ('Apple M6 Pro') matches no row instead of borrowing the bandwidth of its base chip.
|
||||
*/
|
||||
export function chipNameMatches(chip: string, match: string): boolean {
|
||||
const name = chip.trim().toLowerCase();
|
||||
const wanted = match.trim().toLowerCase();
|
||||
if (wanted === '' || !name.endsWith(wanted)) return false;
|
||||
const start = name.length - wanted.length;
|
||||
return start === 0 || name[start - 1] === ' ';
|
||||
}
|
||||
|
||||
/**
|
||||
* The memory bandwidth (GB/s) of `chip`, from the first row that names it. `undefined` when the
|
||||
* chip string is absent or no row names it: the speed is then UNKNOWN, a bandwidth is never guessed.
|
||||
*/
|
||||
export function chipBandwidthGbps(chips: readonly ChipEntry[], chip: string | undefined): number | undefined {
|
||||
if (!chip) return undefined;
|
||||
return chips.find((row) => chipNameMatches(chip, row.match))?.gbps;
|
||||
}
|
||||
|
||||
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'must be an ISO date (YYYY-MM-DD)');
|
||||
const positiveInt = z.number().int().positive();
|
||||
|
||||
@@ -79,7 +127,20 @@ const PromptCacheTierSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Validates a whole catalogue. Typed against `LocalModelCatalog`, so the two cannot drift apart. */
|
||||
/** The index of the first value that already occurred earlier in the list, or -1. */
|
||||
function firstRepeat<T>(values: readonly T[]): number {
|
||||
const seen = new Set<T>();
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
if (seen.has(values[i])) return i;
|
||||
seen.add(values[i]);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a whole catalogue: every field, then the rules that hold across rows (see the file
|
||||
* header). Typed against `LocalModelCatalog`, so the two cannot drift apart.
|
||||
*/
|
||||
export const LocalModelCatalogSchema: z.ZodType<LocalModelCatalog> = z
|
||||
.object({
|
||||
schema: z.literal(1),
|
||||
@@ -90,33 +151,223 @@ export const LocalModelCatalogSchema: z.ZodType<LocalModelCatalog> = z
|
||||
harnesses: z.array(LocalHarnessEntrySchema),
|
||||
chips: z.array(ChipEntrySchema),
|
||||
})
|
||||
.strict();
|
||||
.strict()
|
||||
.superRefine((catalog, ctx) => {
|
||||
const issue = (path: (string | number)[], message: string): void => {
|
||||
ctx.addIssue({ code: 'custom', path, message });
|
||||
};
|
||||
|
||||
// A repeated id would make "the model" ambiguous, a repeated rank would make the order of
|
||||
// two models depend on where they sit in the array.
|
||||
const repeatedId = firstRepeat(catalog.models.map((m) => m.id));
|
||||
if (repeatedId >= 0) issue(['models', repeatedId, 'id'], 'model ids must be unique');
|
||||
const repeatedRank = firstRepeat(catalog.models.map((m) => m.rank));
|
||||
if (repeatedRank >= 0) issue(['models', repeatedRank, 'rank'], 'model ranks must be unique');
|
||||
|
||||
const harnessClis = catalog.harnesses.map((h) => h.cli);
|
||||
const repeatedCli = firstRepeat(harnessClis);
|
||||
if (repeatedCli >= 0) issue(['harnesses', repeatedCli, 'cli'], 'a harness may be listed only once');
|
||||
const repeatedHarnessRank = firstRepeat(catalog.harnesses.map((h) => h.rank));
|
||||
if (repeatedHarnessRank >= 0) issue(['harnesses', repeatedHarnessRank, 'rank'], 'harness ranks must be unique');
|
||||
|
||||
catalog.models.forEach((model, m) => {
|
||||
for (let i = 1; i < model.contexts.length; i++) {
|
||||
if (model.contexts[i] >= model.contexts[i - 1]) {
|
||||
issue(['models', m, 'contexts', i], 'contexts must be listed largest first, without repeats');
|
||||
break;
|
||||
}
|
||||
}
|
||||
// An entry for a harness the catalogue does not list could never match a candidate: a typo.
|
||||
model.tested.forEach((entry, t) => {
|
||||
if (!harnessClis.includes(entry.cli)) {
|
||||
issue(['models', m, 'tested', t, 'cli'], 'a tested entry must name a harness of this catalogue');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Most specific first ('M4 Max' before 'M4'): a row whose text sits inside a LATER row's text
|
||||
// is the less specific of the two, so it is in the wrong place. A repeated match is the same
|
||||
// mistake.
|
||||
for (let later = 1; later < catalog.chips.length; later++) {
|
||||
const laterMatch = catalog.chips[later].match.toLowerCase();
|
||||
const shadowing = catalog.chips.slice(0, later).find((row) => laterMatch.includes(row.match.toLowerCase()));
|
||||
if (shadowing) {
|
||||
issue(
|
||||
['chips', later, 'match'],
|
||||
`chips must be listed most specific first: '${shadowing.match}' comes before '${catalog.chips[later].match}'`
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// The tiers are read as "the last one whose minimum is not above this Mac's memory", so they
|
||||
// must start at 0 (every Mac has a tier) and ascend.
|
||||
if (catalog.promptCacheTiers[0].minMemGb !== 0) {
|
||||
issue(['promptCacheTiers', 0, 'minMemGb'], 'the first prompt cache tier must start at 0');
|
||||
}
|
||||
for (let i = 1; i < catalog.promptCacheTiers.length; i++) {
|
||||
if (catalog.promptCacheTiers[i].minMemGb <= catalog.promptCacheTiers[i - 1].minMemGb) {
|
||||
issue(['promptCacheTiers', i, 'minMemGb'], 'prompt cache tiers must ascend by minMemGb');
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const KIB = 1024;
|
||||
const MIB = 1024 ** 2;
|
||||
const GIB = 1024 ** 3;
|
||||
|
||||
/**
|
||||
* The catalogue that ships with Codeman. EMPTY for now (no model, harness or chip rows), but
|
||||
* valid: with it `recommend()` can only answer verdict `none`.
|
||||
*
|
||||
* The two constants that are already decided are filled in: macOS 14 is the oldest version the
|
||||
* runtime supports, and the prompt cache is 512 MiB on a Mac below 32 GB and 2 GiB from 32 GB.
|
||||
* The catalogue that ships with Codeman. Never read directly outside this file and its test:
|
||||
* callers use `getLocalModelCatalog()`, which validates it first.
|
||||
*/
|
||||
export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
schema: 1,
|
||||
updated: '2026-11-06',
|
||||
// The oldest macOS the model runtime supports.
|
||||
minMacosMajor: 14,
|
||||
// Host memory the runtime's prompt cache may take beside the model: 512 MiB on a Mac below
|
||||
// 32 GB, 2 GiB from 32 GB. Part of the need of every candidate.
|
||||
promptCacheTiers: [
|
||||
{ minMemGb: 0, bytes: 512 * 1024 ** 2 },
|
||||
{ minMemGb: 32, bytes: 2 * 1024 ** 3 },
|
||||
{ minMemGb: 0, bytes: 512 * MIB },
|
||||
{ minMemGb: 32, bytes: 2 * GIB },
|
||||
],
|
||||
// Ordered by rank. The higher rank wins among models that fit equally well and are equally
|
||||
// fast. `flint:40b` is left out on purpose: its plain tag is the slow build on a Mac and it
|
||||
// has an open bug with tool arguments.
|
||||
models: [
|
||||
{
|
||||
id: 'qwen3.5:9b',
|
||||
label: 'Qwen3.5 9B',
|
||||
license: 'Apache-2.0',
|
||||
paramsB: 9,
|
||||
bytes: 6_550_825_373,
|
||||
kvBytesPerToken: 32 * KIB,
|
||||
contexts: [65536, 32768],
|
||||
rank: 1,
|
||||
// 37.1 tokens per second measured on 400 GB/s.
|
||||
speedPer100Gbps: 9.3,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
id: 'qwen3.5:9b-q8_0',
|
||||
label: 'Qwen3.5 9B (Q8)',
|
||||
license: 'Apache-2.0',
|
||||
paramsB: 9,
|
||||
bytes: 10_449_217_949,
|
||||
kvBytesPerToken: 32 * KIB,
|
||||
contexts: [65536, 32768],
|
||||
rank: 2,
|
||||
// Not measured: the 9.3 of the row above scaled by file size.
|
||||
speedPer100Gbps: 5.8,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
id: 'gpt-oss:20b',
|
||||
label: 'GPT-OSS 20B',
|
||||
license: 'Apache-2.0',
|
||||
paramsB: 20,
|
||||
bytes: 13_793_441_427,
|
||||
kvBytesPerToken: 24 * KIB,
|
||||
contexts: [131072, 65536],
|
||||
rank: 3,
|
||||
// No speed figure: its speed is unknown on every chip.
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
id: 'qwen3.6:35b-a3b',
|
||||
label: 'Qwen3.6 35B-A3B',
|
||||
license: 'Apache-2.0',
|
||||
paramsB: 35,
|
||||
bytes: 22_621_314_161,
|
||||
kvBytesPerToken: 20 * KIB,
|
||||
contexts: [131072, 65536],
|
||||
rank: 4,
|
||||
// 54.5 tokens per second measured on 400 GB/s (3 billion parameters are active per token).
|
||||
speedPer100Gbps: 13.6,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
id: 'qwen3.8:27b',
|
||||
label: 'Qwen3.8 27B',
|
||||
license: 'Apache-2.0',
|
||||
paramsB: 27.8,
|
||||
bytes: 17_741_871_939,
|
||||
kvBytesPerToken: 64 * KIB,
|
||||
contexts: [131072, 65536, 32768],
|
||||
rank: 5,
|
||||
// 11.8 tokens per second measured on 400 GB/s.
|
||||
speedPer100Gbps: 2.95,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
id: 'qwen3.8:27b-q8_0',
|
||||
label: 'Qwen3.8 27B (Q8)',
|
||||
license: 'Apache-2.0',
|
||||
paramsB: 27.8,
|
||||
bytes: 29_978_241_837,
|
||||
kvBytesPerToken: 64 * KIB,
|
||||
contexts: [262144, 131072],
|
||||
rank: 6,
|
||||
// Not measured: the 2.95 of the row above scaled by file size.
|
||||
speedPer100Gbps: 1.7,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
id: 'qwen3.5:122b-a10b',
|
||||
label: 'Qwen3.5 122B-A10B',
|
||||
license: 'Apache-2.0',
|
||||
paramsB: 122,
|
||||
bytes: 81_370_035_877,
|
||||
kvBytesPerToken: 24 * KIB,
|
||||
contexts: [131072],
|
||||
rank: 7,
|
||||
// No speed figure: its speed is unknown on every chip.
|
||||
tested: [],
|
||||
},
|
||||
],
|
||||
// The higher rank wins when a model can run with more than one harness.
|
||||
harnesses: [
|
||||
{ cli: 'opencode', minContext: 65536, rank: 3 },
|
||||
// Sends a fixed prompt of about 36,000 tokens, so the first answer takes minutes.
|
||||
{ cli: 'claude', minContext: 65536, rank: 2, slowFirstTurn: true },
|
||||
// Gated: neither its 32768 minimum nor its handling of a `name:tag` model id is verified.
|
||||
{ cli: 'pi', minContext: 32768, rank: 1, gated: true },
|
||||
],
|
||||
// Memory bandwidth in GB/s, most specific first. Only chips that shipped and are in the source
|
||||
// table are listed. Where one name covers two bandwidths (M3 Max 300 or 400, M4 Max 410 or 546,
|
||||
// M5 Max 460 or 614) the LOWER one stands, so a speed computed from it is a floor.
|
||||
chips: [
|
||||
{ match: 'M5 Ultra', gbps: 1228 },
|
||||
{ match: 'M3 Ultra', gbps: 800 },
|
||||
{ match: 'M2 Ultra', gbps: 800 },
|
||||
{ match: 'M1 Ultra', gbps: 800 },
|
||||
{ match: 'M5 Max', gbps: 460 },
|
||||
{ match: 'M4 Max', gbps: 410 },
|
||||
{ match: 'M3 Max', gbps: 300 },
|
||||
{ match: 'M2 Max', gbps: 400 },
|
||||
{ match: 'M1 Max', gbps: 400 },
|
||||
{ match: 'M5 Pro', gbps: 307 },
|
||||
{ match: 'M4 Pro', gbps: 273 },
|
||||
{ match: 'M3 Pro', gbps: 150 },
|
||||
{ match: 'M2 Pro', gbps: 200 },
|
||||
{ match: 'M1 Pro', gbps: 200 },
|
||||
{ match: 'M5', gbps: 154 },
|
||||
{ match: 'M4', gbps: 120 },
|
||||
{ match: 'M3', gbps: 100 },
|
||||
{ match: 'M2', gbps: 100 },
|
||||
{ match: 'M1', gbps: 68 },
|
||||
],
|
||||
models: [],
|
||||
harnesses: [],
|
||||
chips: [],
|
||||
};
|
||||
|
||||
let validated: LocalModelCatalog | undefined;
|
||||
|
||||
/**
|
||||
* The catalogue in force. There is no override in this stage, so this is the stock constant;
|
||||
* callers go through this function anyway, so a later stage can change where it comes from
|
||||
* without touching them.
|
||||
* The catalogue in force: the stock constant, validated once on first use. Callers go through
|
||||
* this function, so a later stage can change where the catalogue comes from without touching
|
||||
* them. Throws when the constant breaks its own schema, which the catalogue test rules out.
|
||||
*/
|
||||
export function getLocalModelCatalog(): LocalModelCatalog {
|
||||
return STOCK_LOCAL_MODEL_CATALOG;
|
||||
validated ??= LocalModelCatalogSchema.parse(STOCK_LOCAL_MODEL_CATALOG);
|
||||
return validated;
|
||||
}
|
||||
+314
-11
@@ -1,8 +1,6 @@
|
||||
/**
|
||||
* @fileoverview The hardware probe of the local model recommendation: what this Mac is.
|
||||
*
|
||||
* SKELETON: the exported signatures are final, the bodies are not written yet. This module
|
||||
* will hold:
|
||||
* - `parseSysctl()`: a pure parser of `key: value` lines. The command runs WITHOUT `-n`, so a
|
||||
* key that is missing on a machine (`sysctl.proc_translated` on Intel) cannot shift the
|
||||
* others, and a line it does not know is ignored.
|
||||
@@ -12,17 +10,30 @@
|
||||
*
|
||||
* Rules:
|
||||
* - Never throws and never rejects: a failed probe is an answer (`source: 'os-fallback'`), and
|
||||
* off macOS nothing is spawned (`source: 'not-probed'`).
|
||||
* off macOS nothing is spawned and no disk is read (`source: 'not-probed'`).
|
||||
* - Never `system_profiler` (tens of milliseconds and a parse of free text), never a second
|
||||
* spawn, never a synchronous exec on the server's event loop.
|
||||
* - A non-zero exit is tolerated once `hw.memsize` parsed (a missing key makes sysctl exit 1).
|
||||
* Without `hw.memsize` nothing else of that output is trusted: the answer is the fallback.
|
||||
* - Only a line that ends in a newline counts. A cut-off tail is not a value: half of
|
||||
* `hw.memsize` would read as a smaller, valid number.
|
||||
* - `iogpu.wired_limit_mb` of 0 means "not set": it is reported as 0 and only a value above 0
|
||||
* is ever used as a limit.
|
||||
* - The data dir can sit on a network mount that stopped answering, so the disk read is
|
||||
* bounded too: the folder is probed with the bounded path probe first, the `statfs` is raced
|
||||
* against a timeout, and a `statfs` that never came back is not started a second time.
|
||||
* - `memGb`, `memClassGb` and `freeDiskGb` are filled here through src/local-model/format.ts,
|
||||
* the one place a size is formatted.
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { statfs } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import { dirname } from 'node:path';
|
||||
import type { HardwareFacts } from '../types.js';
|
||||
import { getDataDir } from '../config/instance.js';
|
||||
import { probePathKind, type PathProbeKind } from '../utils/bounded-path-probe.js';
|
||||
import { formatGb, memClassGb } from './format.js';
|
||||
|
||||
/** Absolute path, so the server's PATH (minimal under a service manager) cannot change what runs. */
|
||||
export const SYSCTL_PATH = '/usr/sbin/sysctl';
|
||||
@@ -44,6 +55,18 @@ export const SYSCTL_TIMEOUT_MS = 2000;
|
||||
/** How long one probe result is reused. */
|
||||
export const HARDWARE_CACHE_TTL_MS = 60_000;
|
||||
|
||||
/** Upper bound of the wait for the free disk figure. */
|
||||
export const FREE_DISK_TIMEOUT_MS = 2000;
|
||||
|
||||
/** Seven short lines are well under this. A larger output is not sysctl's. */
|
||||
const SYSCTL_MAX_OUTPUT_BYTES = 64 * 1024;
|
||||
|
||||
/** Longest text value kept (a chip name, a model identifier, a version). */
|
||||
const MAX_TEXT_VALUE_LENGTH = 100;
|
||||
|
||||
/** How many parent folders are tried when the data dir does not exist yet. */
|
||||
const FREE_DISK_MAX_PARENT_STEPS = 6;
|
||||
|
||||
/** What `parseSysctl()` read. Every field is absent when its key was missing or its value did not parse. */
|
||||
export interface SysctlFacts {
|
||||
/** `hw.memsize`. */
|
||||
@@ -82,25 +105,305 @@ export interface HardwareProbeDeps {
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/** A whole number without sign, as sysctl prints one. */
|
||||
function parseCount(value: string): number | undefined {
|
||||
if (!/^\d+$/.test(value)) return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
/** sysctl prints a flag as 0 or 1. Anything else is not a flag. */
|
||||
function parseFlag(value: string): boolean | undefined {
|
||||
if (value === '1') return true;
|
||||
if (value === '0') return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseText(value: string): string | undefined {
|
||||
return value !== '' && value.length <= MAX_TEXT_VALUE_LENGTH ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the output of `sysctl <keys>` (no `-n`): one `key: value` line per key it knows.
|
||||
* Pure. Unknown lines, blank lines and error lines are skipped.
|
||||
* Pure. Unknown lines, blank lines and error lines are skipped, and so is a last line that does
|
||||
* not end in a newline. The first line of a key wins.
|
||||
*/
|
||||
export function parseSysctl(text: string): SysctlFacts {
|
||||
void text;
|
||||
throw new Error('not implemented: parseSysctl');
|
||||
const facts: SysctlFacts = {};
|
||||
const lines = text.split('\n');
|
||||
// What follows the last newline is either empty or a cut-off line.
|
||||
lines.pop();
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.replace(/\r$/, '');
|
||||
const colon = line.indexOf(':');
|
||||
if (colon <= 0) continue;
|
||||
const key = line.slice(0, colon).trim();
|
||||
const value = line.slice(colon + 1).trim();
|
||||
switch (key) {
|
||||
case 'hw.memsize': {
|
||||
const bytes = parseCount(value);
|
||||
if (bytes !== undefined && bytes > 0) facts.memBytes ??= bytes;
|
||||
break;
|
||||
}
|
||||
case 'machdep.cpu.brand_string':
|
||||
facts.chip ??= parseText(value);
|
||||
break;
|
||||
case 'hw.optional.arm64':
|
||||
facts.arm64 ??= parseFlag(value);
|
||||
break;
|
||||
case 'sysctl.proc_translated':
|
||||
facts.translated ??= parseFlag(value);
|
||||
break;
|
||||
case 'iogpu.wired_limit_mb':
|
||||
facts.wiredLimitMb ??= parseCount(value);
|
||||
break;
|
||||
case 'kern.osproductversion': {
|
||||
const version = /^(\d+)(?:\.\d+)*$/.exec(value);
|
||||
if (version && value.length <= MAX_TEXT_VALUE_LENGTH && facts.macosVersion === undefined) {
|
||||
facts.macosVersion = value;
|
||||
facts.macosMajor = Number(version[1]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'hw.model':
|
||||
facts.model ??= parseText(value);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one spawn: `command` with `SYSCTL_KEYS` as its arguments and nothing else (no `-n`).
|
||||
* Resolves stdout when the program ran, also after a non-zero exit (a key this machine lacks
|
||||
* makes sysctl exit 1 while the other keys are printed). Resolves `undefined` when it could not
|
||||
* be started, was killed (the timeout) or printed too much. Never rejects.
|
||||
*
|
||||
* The two parameters exist for the test, which runs a script of its own in place of sysctl.
|
||||
*/
|
||||
export function spawnSysctl(
|
||||
command: string = SYSCTL_PATH,
|
||||
timeoutMs: number = SYSCTL_TIMEOUT_MS
|
||||
): Promise<string | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
execFile(
|
||||
command,
|
||||
[...SYSCTL_KEYS],
|
||||
{
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: SYSCTL_MAX_OUTPUT_BYTES,
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
// sysctl needs nothing from the server's environment, which can hold a password.
|
||||
env: { LC_ALL: 'C' },
|
||||
},
|
||||
(error, stdout) => {
|
||||
if (!error) return resolve(stdout);
|
||||
// A numeric code is an exit status: the program ran. A string code (ENOENT, EACCES,
|
||||
// the buffer limit) or a signal means it did not run to its end.
|
||||
const ranToEnd = typeof error.code === 'number' && !error.killed && !error.signal;
|
||||
resolve(ranToEnd ? stdout : undefined);
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Free bytes on the filesystem that holds `path` (`bavail * bsize`: what an unprivileged
|
||||
* process can use), or `undefined` when the filesystem does not say. Not bounded by itself: see
|
||||
* `dataDirFreeBytes()`.
|
||||
*/
|
||||
export async function freeDiskBytesOf(path: string): Promise<number | undefined> {
|
||||
try {
|
||||
const stat = await statfs(path);
|
||||
const free = Number(stat.bavail) * Number(stat.bsize);
|
||||
return Number.isFinite(free) && free >= 0 ? free : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** The one `statfs` that may be pending. While it is, no second one is started. */
|
||||
let pendingStatfs: Promise<number | undefined> | undefined;
|
||||
|
||||
/** The IO of `dataDirFreeBytes()`. Each member defaults to the real thing; a test passes its own. */
|
||||
export interface FreeDiskDeps {
|
||||
/** Default `getDataDir()`. */
|
||||
dataDir?: () => string;
|
||||
/** Default the bounded path probe (`probePathKind`). */
|
||||
probeKind?: (path: string) => Promise<PathProbeKind>;
|
||||
/** Default `freeDiskBytesOf`. */
|
||||
freeBytesOf?: (path: string) => Promise<number | undefined>;
|
||||
/** Default `FREE_DISK_TIMEOUT_MS`. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Free bytes on the data dir's filesystem, bounded. The folder (or, when it does not exist yet,
|
||||
* its nearest existing parent) is found with the bounded path probe, which never waits on a
|
||||
* dead mount twice; the `statfs` itself is raced against a timeout. `undefined` when the
|
||||
* filesystem does not answer in time, or is not known to be a folder.
|
||||
*/
|
||||
export async function dataDirFreeBytes(deps: FreeDiskDeps = {}): Promise<number | undefined> {
|
||||
// Concurrent probes share one promise in `readHardware()`, so a `statfs` that is still pending
|
||||
// here was started by an EARLIER probe and has outlived its timeout: a dead mount. It holds
|
||||
// one threadpool worker, and that is the most this module may ever hold.
|
||||
if (pendingStatfs) return undefined;
|
||||
|
||||
const probeKind = deps.probeKind ?? probePathKind;
|
||||
let folder: string;
|
||||
try {
|
||||
folder = (deps.dataDir ?? getDataDir)();
|
||||
let kind = await probeKind(folder);
|
||||
for (let step = 0; kind === 'absent' && step < FREE_DISK_MAX_PARENT_STEPS; step++) {
|
||||
const parent = dirname(folder);
|
||||
if (parent === folder) break;
|
||||
folder = parent;
|
||||
kind = await probeKind(folder);
|
||||
}
|
||||
// 'unknown' is a mount that does not answer (or an unreadable path): do not touch it.
|
||||
if (kind !== 'directory') return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const started = (deps.freeBytesOf ?? freeDiskBytesOf)(folder).catch(() => undefined);
|
||||
pendingStatfs = started;
|
||||
const forget = (): void => {
|
||||
if (pendingStatfs === started) pendingStatfs = undefined;
|
||||
};
|
||||
started.then(forget, forget);
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
started,
|
||||
new Promise<undefined>((resolve) => {
|
||||
timer = setTimeout(() => resolve(undefined), deps.timeoutMs ?? FREE_DISK_TIMEOUT_MS);
|
||||
timer.unref?.();
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function defaultCpuModel(): string | undefined {
|
||||
return os.cpus()[0]?.model;
|
||||
}
|
||||
|
||||
/** A dependency that must never break the probe: its value, or `undefined` when it threw or rejected. */
|
||||
async function attempt<T>(read: () => T | Promise<T>): Promise<T | undefined> {
|
||||
try {
|
||||
return await read();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Memory and the three fields derived from it. */
|
||||
function memoryFields(memBytes: number | undefined): Pick<HardwareFacts, 'memBytes' | 'memGb' | 'memClassGb'> {
|
||||
const bytes = memBytes !== undefined && Number.isFinite(memBytes) && memBytes > 0 ? memBytes : 0;
|
||||
return { memBytes: bytes, memGb: formatGb(bytes), memClassGb: memClassGb(bytes) };
|
||||
}
|
||||
|
||||
async function probeHardware(deps: HardwareProbeDeps): Promise<HardwareFacts> {
|
||||
const platform = deps.platform ?? process.platform;
|
||||
const arch = deps.arch ?? os.arch();
|
||||
const totalMemBytes = deps.totalMemBytes ?? os.totalmem;
|
||||
const cpuModel = deps.cpuModel ?? defaultCpuModel;
|
||||
|
||||
if (platform !== 'darwin') {
|
||||
return { platform, arch, ...memoryFields(await attempt(totalMemBytes)), source: 'not-probed' };
|
||||
}
|
||||
|
||||
const [stdout, freeDisk] = await Promise.all([
|
||||
attempt(deps.runSysctl ?? (() => spawnSysctl())),
|
||||
attempt(deps.freeDiskBytes ?? (() => dataDirFreeBytes())),
|
||||
]);
|
||||
const disk: Pick<HardwareFacts, 'freeDiskBytes' | 'freeDiskGb'> =
|
||||
freeDisk !== undefined && Number.isFinite(freeDisk) && freeDisk >= 0
|
||||
? { freeDiskBytes: freeDisk, freeDiskGb: formatGb(freeDisk) }
|
||||
: {};
|
||||
|
||||
const facts = typeof stdout === 'string' ? parseSysctl(stdout) : {};
|
||||
if (facts.memBytes === undefined) {
|
||||
const chip = await attempt(cpuModel);
|
||||
return {
|
||||
platform,
|
||||
arch,
|
||||
...memoryFields(await attempt(totalMemBytes)),
|
||||
...(chip ? { chip } : {}),
|
||||
...disk,
|
||||
source: 'os-fallback',
|
||||
};
|
||||
}
|
||||
|
||||
const chip = facts.chip ?? (await attempt(cpuModel));
|
||||
return {
|
||||
platform,
|
||||
arch,
|
||||
...(facts.arm64 !== undefined ? { arm64: facts.arm64 } : {}),
|
||||
...(facts.translated !== undefined ? { translated: facts.translated } : {}),
|
||||
...memoryFields(facts.memBytes),
|
||||
...(chip ? { chip } : {}),
|
||||
...(facts.model !== undefined ? { model: facts.model } : {}),
|
||||
...(facts.macosVersion !== undefined ? { macosVersion: facts.macosVersion } : {}),
|
||||
...(facts.macosMajor !== undefined ? { macosMajor: facts.macosMajor } : {}),
|
||||
...(facts.wiredLimitMb !== undefined ? { wiredLimitMb: facts.wiredLimitMb } : {}),
|
||||
...disk,
|
||||
source: 'sysctl',
|
||||
};
|
||||
}
|
||||
|
||||
let cached: { at: number; facts: HardwareFacts } | undefined;
|
||||
let inFlight: Promise<HardwareFacts> | undefined;
|
||||
|
||||
/**
|
||||
* What this machine is. Cached for `HARDWARE_CACHE_TTL_MS`; concurrent callers share one probe.
|
||||
* Never rejects.
|
||||
*
|
||||
* The cache is one per process and does not look at `deps`: within the 60 s a second call
|
||||
* returns the first call's answer whatever it injects. A test resets it first.
|
||||
*/
|
||||
export async function readHardware(deps?: HardwareProbeDeps): Promise<HardwareFacts> {
|
||||
void deps;
|
||||
throw new Error('not implemented: readHardware');
|
||||
export async function readHardware(deps: HardwareProbeDeps = {}): Promise<HardwareFacts> {
|
||||
const now = deps.now ?? Date.now;
|
||||
if (cached) {
|
||||
const age = now() - cached.at;
|
||||
if (age >= 0 && age < HARDWARE_CACHE_TTL_MS) return cached.facts;
|
||||
}
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
const probe = probeHardware(deps)
|
||||
.catch(
|
||||
// Unreachable by design (every read above is guarded), and still an answer if it is reached.
|
||||
(): HardwareFacts => ({
|
||||
platform: deps.platform ?? process.platform,
|
||||
arch: deps.arch ?? os.arch(),
|
||||
...memoryFields(undefined),
|
||||
source: (deps.platform ?? process.platform) === 'darwin' ? 'os-fallback' : 'not-probed',
|
||||
})
|
||||
)
|
||||
.then((facts) => {
|
||||
cached = { at: now(), facts };
|
||||
return facts;
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlight === probe) inFlight = undefined;
|
||||
});
|
||||
inFlight = probe;
|
||||
return probe;
|
||||
}
|
||||
|
||||
/** Drop the cached probe result (tests, and nothing else). */
|
||||
/** Drop the cached probe result and the memory of a pending disk read (tests, and nothing else). */
|
||||
export function resetHardwareCache(): void {
|
||||
throw new Error('not implemented: resetHardwareCache');
|
||||
cached = undefined;
|
||||
inFlight = undefined;
|
||||
pendingStatfs = undefined;
|
||||
}
|
||||
+300
-32
@@ -2,54 +2,231 @@
|
||||
* @fileoverview The recommendation itself: which model, at which context, with which harness
|
||||
* fits this Mac. Pure: no IO, no clock, no CLI registry, no data dir.
|
||||
*
|
||||
* SKELETON: the exported signatures are final, the bodies are not written yet. This module
|
||||
* will hold:
|
||||
* 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 from 64 GB.
|
||||
* 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.
|
||||
* - `recommend()`: the candidates (model, context, harness) with `ctx >= harness.minContext`,
|
||||
* the harness installed or installable, accepting the model id and not gated; the fit (F/B up
|
||||
* to 0.85 good, up to 0.98 tight, above out); the speed (ok, slow or unknown: a chip that is
|
||||
* in no chips row is unknown, a bandwidth is never guessed); the ranking (good before tight,
|
||||
* then ok before unknown before slow, then model rank, harness rank, larger context); and the
|
||||
* verdict.
|
||||
* - 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. 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.
|
||||
*
|
||||
* Verdict `none`, in this order (the first that applies is the reason):
|
||||
* 1. `not-macos`: the server does not run on macOS.
|
||||
* 2. `intel`: a Mac without Apple silicon.
|
||||
* 3. `macos`: macOS is older than the catalogue's minimum (an unknown version does not block).
|
||||
* 4. `memory`: no model fits the budget at any of its contexts, whatever the harness.
|
||||
* 5. `no-harness`: no harness row is usable (not gated, known to the caller, installed or
|
||||
* installable).
|
||||
* 6. `memory`: models fit, but none at a context a usable harness needs.
|
||||
* 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).
|
||||
*
|
||||
* Rules:
|
||||
* - Verdict `limited` when the best candidate's model has fewer than 12 billion parameters.
|
||||
* - The word "Recommended" (`LOCAL_MODEL_LABELS.headlineTested`) only for a pick whose model
|
||||
* has a `tested` entry matching this Mac. Otherwise `headlineOk` plus `noteUntested`.
|
||||
* - Reasons for `none`: not-macos, intel, memory, disk (free disk minus the download is below
|
||||
* 10 GiB for every fitting model), macos, no-harness.
|
||||
* - Verdict `limited` when the pick's model has fewer than 12 billion parameters.
|
||||
* - 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 size text from
|
||||
* `formatGb()`. 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`.
|
||||
*/
|
||||
|
||||
import type {
|
||||
DeviceBudget,
|
||||
HardwareFacts,
|
||||
HarnessFact,
|
||||
LocalModelCatalog,
|
||||
LocalModelEntry,
|
||||
Recommendation,
|
||||
import {
|
||||
LOCAL_MODEL_LABELS,
|
||||
LOCAL_MODEL_NONE_LABELS,
|
||||
type Candidate,
|
||||
type DeviceBudget,
|
||||
type FitGrade,
|
||||
type HardwareFacts,
|
||||
type HarnessFact,
|
||||
type LocalHarnessEntry,
|
||||
type LocalModelCatalog,
|
||||
type LocalModelEntry,
|
||||
type NoneReason,
|
||||
type Recommendation,
|
||||
type SpeedGrade,
|
||||
} from '../types.js';
|
||||
import { chipBandwidthGbps } from '../config/local-model-catalog.js';
|
||||
import { formatGb, GIB, MIB } from './format.js';
|
||||
|
||||
/** Need over budget up to this share is a `good` fit. */
|
||||
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`. */
|
||||
export const SLOW_BELOW_TOKENS_PER_SECOND = 10;
|
||||
/** A pick whose model has fewer than this many billion parameters makes the verdict `limited`. */
|
||||
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;
|
||||
|
||||
/** A memory size of at least this many GB (as sold) gets 3/4 of its memory as device limit, below it 2/3. */
|
||||
const LARGE_LIMIT_FROM_GB = 36;
|
||||
|
||||
/** The memory the system, Codeman, a browser and the agent keep for themselves, by the size the Mac is sold as. */
|
||||
function reserveBytes(memClassGb: number): number {
|
||||
if (memClassGb <= 18) return 6 * GIB;
|
||||
if (memClassGb <= 48) return 8 * GIB;
|
||||
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 {
|
||||
void hw;
|
||||
throw new Error('not implemented: deviceBudget');
|
||||
const memBytes = Number.isFinite(hw.memBytes) && hw.memBytes > 0 ? hw.memBytes : 0;
|
||||
// 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)));
|
||||
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 {
|
||||
void model;
|
||||
void ctx;
|
||||
void catalog;
|
||||
void hw;
|
||||
throw new Error('not implemented: needBytes');
|
||||
return model.bytes + model.kvBytesPerToken * ctx + RUNTIME_OVERHEAD_BYTES + promptCacheBytes(catalog, hw.memClassGb);
|
||||
}
|
||||
|
||||
function fitGrade(need: number, budgetBytes: number): FitGrade {
|
||||
if (budgetBytes <= 0) return 'out';
|
||||
const share = need / budgetBytes;
|
||||
if (share <= FIT_GOOD_MAX_SHARE) return 'good';
|
||||
if (share <= FIT_TIGHT_MAX_SHARE) return 'tight';
|
||||
return 'out';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the MACHINE is Apple silicon. `hw.optional.arm64` decides when it was read. Without
|
||||
* it: a process under Rosetta, an arm64 process or an Apple chip name each prove Apple silicon
|
||||
* (`arch` alone cannot say no, because Node under Rosetta reports x64).
|
||||
*/
|
||||
function isAppleSilicon(hw: HardwareFacts): boolean {
|
||||
if (hw.arm64 !== undefined) return hw.arm64;
|
||||
if (hw.translated === true || hw.arch === 'arm64') return true;
|
||||
return /^Apple\b/i.test(hw.chip ?? '');
|
||||
}
|
||||
|
||||
function round(value: number, decimals: number): number {
|
||||
const factor = 10 ** decimals;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
/** A candidate plus the two ranks the ordering needs, which are not part of the payload. */
|
||||
interface Ranked {
|
||||
candidate: Candidate;
|
||||
modelRank: number;
|
||||
harnessRank: number;
|
||||
}
|
||||
|
||||
/** A catalogue harness row with the caller's fact about it. */
|
||||
interface UsableHarness {
|
||||
row: LocalHarnessEntry;
|
||||
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. */
|
||||
function compareRanked(a: Ranked, b: Ranked): number {
|
||||
return (
|
||||
FIT_ORDER[a.candidate.fit] - FIT_ORDER[b.candidate.fit] ||
|
||||
SPEED_ORDER[a.candidate.speed] - SPEED_ORDER[b.candidate.speed] ||
|
||||
b.modelRank - a.modelRank ||
|
||||
b.harnessRank - a.harnessRank ||
|
||||
b.candidate.context - a.candidate.context
|
||||
);
|
||||
}
|
||||
|
||||
/** A function supplied by the caller must not be able to break a pure answer. */
|
||||
function accepts(fact: HarnessFact, modelId: string): boolean {
|
||||
try {
|
||||
return fact.acceptsModelId(modelId) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildCandidate(
|
||||
model: LocalModelEntry,
|
||||
ctx: number,
|
||||
harness: UsableHarness,
|
||||
need: number,
|
||||
fit: Candidate['fit'],
|
||||
hw: HardwareFacts,
|
||||
catalog: LocalModelCatalog,
|
||||
budgetBytes: number
|
||||
): Candidate {
|
||||
const gbps = chipBandwidthGbps(catalog.chips, hw.chip);
|
||||
const tokensPerSecond =
|
||||
gbps !== undefined && model.speedPer100Gbps !== undefined
|
||||
? round((model.speedPer100Gbps * gbps) / 100, 1)
|
||||
: undefined;
|
||||
const speed: SpeedGrade =
|
||||
tokensPerSecond === undefined ? 'unknown' : tokensPerSecond < SLOW_BELOW_TOKENS_PER_SECOND ? 'slow' : 'ok';
|
||||
// "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
|
||||
);
|
||||
return {
|
||||
modelId: model.id,
|
||||
modelLabel: model.label,
|
||||
license: model.license,
|
||||
paramsB: model.paramsB,
|
||||
context: ctx,
|
||||
cli: harness.row.cli,
|
||||
cliLabel: harness.fact.label,
|
||||
cliInstalled: harness.fact.installed,
|
||||
...(harness.row.slowFirstTurn ? { slowFirstTurn: true } : {}),
|
||||
needBytes: need,
|
||||
needGb: formatGb(need),
|
||||
downloadBytes: model.bytes,
|
||||
downloadGb: formatGb(model.bytes),
|
||||
budgetShare: round(need / budgetBytes, 3),
|
||||
fit,
|
||||
fitLabel: fit === 'good' ? LOCAL_MODEL_LABELS.fitGood : LOCAL_MODEL_LABELS.fitTight,
|
||||
speed,
|
||||
...(speed === 'slow' ? { speedLabel: LOCAL_MODEL_LABELS.speedSlow } : {}),
|
||||
...(speed === 'unknown' ? { speedLabel: LOCAL_MODEL_LABELS.speedUnknown } : {}),
|
||||
...(tokensPerSecond !== undefined ? { tokensPerSecond } : {}),
|
||||
tested,
|
||||
};
|
||||
}
|
||||
|
||||
function none(reason: NoneReason, budget: DeviceBudget, vars?: Record<string, string>): Recommendation {
|
||||
return {
|
||||
verdict: 'none',
|
||||
reason,
|
||||
headline: { text: LOCAL_MODEL_NONE_LABELS[reason], ...(vars ? { vars } : {}) },
|
||||
notes: [],
|
||||
alternatives: [],
|
||||
budgetBytes: budget.budgetBytes,
|
||||
budgetGb: formatGb(budget.budgetBytes),
|
||||
budgetSource: budget.source,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,8 +238,99 @@ export function recommend(
|
||||
catalog: LocalModelCatalog,
|
||||
harnessFacts: readonly HarnessFact[]
|
||||
): Recommendation {
|
||||
void hw;
|
||||
void catalog;
|
||||
void harnessFacts;
|
||||
throw new Error('not implemented: recommend');
|
||||
// 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);
|
||||
if (hw.macosMajor !== undefined && hw.macosMajor < catalog.minMacosMajor) {
|
||||
return none('macos', budget, {
|
||||
version: hw.macosVersion ?? String(hw.macosMajor),
|
||||
minimum: String(catalog.minMacosMajor),
|
||||
});
|
||||
}
|
||||
|
||||
// Every (model, context) that fits the budget, before any harness is looked at.
|
||||
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 fit = fitGrade(need, budget.budgetBytes);
|
||||
if (fit !== 'out') fitting.push({ model, ctx, need, fit });
|
||||
}
|
||||
}
|
||||
const tooLittleMemory = (): Recommendation => none('memory', budget, { memory: hw.memGb });
|
||||
if (fitting.length === 0) return tooLittleMemory();
|
||||
|
||||
// A gated row is never offered, whatever the caller knows about its CLI.
|
||||
const usable: UsableHarness[] = [];
|
||||
for (const row of catalog.harnesses) {
|
||||
if (row.gated) continue;
|
||||
const fact = harnessFacts.find((candidate) => candidate.cli === row.cli);
|
||||
if (fact && (fact.installed || fact.installable)) usable.push({ row, fact });
|
||||
}
|
||||
if (usable.length === 0) return none('no-harness', budget);
|
||||
|
||||
const atHarnessContext = fitting.flatMap((entry) =>
|
||||
usable.filter((harness) => entry.ctx >= harness.row.minContext).map((harness) => ({ ...entry, harness }))
|
||||
);
|
||||
if (atHarnessContext.length === 0) return tooLittleMemory();
|
||||
|
||||
const accepted = atHarnessContext.filter((entry) => accepts(entry.harness.fact, entry.model.id));
|
||||
if (accepted.length === 0) return none('no-harness', budget);
|
||||
|
||||
// The download must leave headroom on the disk. An unknown free disk does not block.
|
||||
const free = hw.freeDiskBytes;
|
||||
const downloadable =
|
||||
free === undefined ? accepted : accepted.filter((entry) => free - entry.model.bytes >= DISK_HEADROOM_BYTES);
|
||||
if (free !== undefined && downloadable.length === 0) {
|
||||
const smallest = Math.min(...accepted.map((entry) => entry.model.bytes));
|
||||
return none('disk', budget, {
|
||||
download: formatGb(smallest),
|
||||
free: hw.freeDiskGb ?? formatGb(free),
|
||||
missing: formatGb(smallest + DISK_HEADROOM_BYTES - free),
|
||||
});
|
||||
}
|
||||
|
||||
const ranked: Ranked[] = downloadable
|
||||
.map((entry) => ({
|
||||
candidate: buildCandidate(
|
||||
entry.model,
|
||||
entry.ctx,
|
||||
entry.harness,
|
||||
entry.need,
|
||||
entry.fit,
|
||||
hw,
|
||||
catalog,
|
||||
budget.budgetBytes
|
||||
),
|
||||
modelRank: entry.model.rank,
|
||||
harnessRank: entry.harness.row.rank,
|
||||
}))
|
||||
.sort(compareRanked);
|
||||
|
||||
// The first candidate of each model is that model at its best context and harness.
|
||||
const bestPerModel: Candidate[] = [];
|
||||
for (const { candidate } of ranked) {
|
||||
if (!bestPerModel.some((kept) => kept.modelId === candidate.modelId)) bestPerModel.push(candidate);
|
||||
}
|
||||
const [pick, ...others] = bestPerModel;
|
||||
|
||||
const limited = pick.paramsB < LIMITED_BELOW_PARAMS_B;
|
||||
const headline = limited
|
||||
? LOCAL_MODEL_LABELS.headlineLimited
|
||||
: pick.tested
|
||||
? LOCAL_MODEL_LABELS.headlineTested
|
||||
: LOCAL_MODEL_LABELS.headlineOk;
|
||||
return {
|
||||
verdict: limited ? 'limited' : 'ok',
|
||||
headline: { text: headline },
|
||||
notes: pick.tested ? [] : [{ text: LOCAL_MODEL_LABELS.noteUntested }],
|
||||
pick,
|
||||
alternatives: others.slice(0, MAX_ALTERNATIVES),
|
||||
budgetBytes: budget.budgetBytes,
|
||||
budgetGb: formatGb(budget.budgetBytes),
|
||||
budgetSource: budget.source,
|
||||
};
|
||||
}
|
||||
@@ -129,11 +129,14 @@ export interface LocalHarnessEntry {
|
||||
|
||||
/**
|
||||
* Memory bandwidth of one chip family. `LocalModelCatalog.chips` is ordered most specific first
|
||||
* ('M4 Max' before 'M4') and the first row whose `match` occurs in `HardwareFacts.chip` wins. A
|
||||
* chip that matches no row has an UNKNOWN speed: a bandwidth is never guessed.
|
||||
* ('M4 Max' before 'M4') and the first row that names `HardwareFacts.chip` wins. A row names a
|
||||
* chip when the chip string ENDS with its `match`, starting at the beginning or after a space,
|
||||
* case ignored (`chipNameMatches()` in src/config/local-model-catalog.ts): 'M4' is not the chip
|
||||
* of 'Apple M4 Pro', and a variant the table has never heard of ('Apple M6 Pro') matches no row.
|
||||
* A chip that matches no row has an UNKNOWN speed: a bandwidth is never guessed.
|
||||
*/
|
||||
export interface ChipEntry {
|
||||
/** Text that must occur in the chip string ('M4 Max'). */
|
||||
/** The chip name the chip string must end with ('M4 Max'). */
|
||||
match: string;
|
||||
/** Memory bandwidth in GB/s. */
|
||||
gbps: number;
|
||||
@@ -305,7 +308,7 @@ export interface Candidate {
|
||||
downloadBytes: number;
|
||||
/** `downloadBytes` as decimal GB text, no unit. */
|
||||
downloadGb: string;
|
||||
/** `needBytes / Recommendation.budgetBytes` (0.83). */
|
||||
/** `needBytes / Recommendation.budgetBytes`, rounded to three decimals (0.83). The fit grade is decided on the unrounded ratio. */
|
||||
budgetShare: number;
|
||||
/** A candidate in a `Recommendation` is never `out`. */
|
||||
fit: Exclude<FitGrade, 'out'>;
|
||||
@@ -339,7 +342,7 @@ export interface Recommendation {
|
||||
pick?: Candidate;
|
||||
/** The next models that fit (at most four, each at its best context and harness), best first. Empty when `verdict` is `none`. */
|
||||
alternatives: Candidate[];
|
||||
/** `DeviceBudget.budgetBytes`. 0 when the budget could not be computed (not macOS). */
|
||||
/** `DeviceBudget.budgetBytes`. 0 when no budget is computed: not macOS, or a Mac without Apple silicon. */
|
||||
budgetBytes: number;
|
||||
/** `budgetBytes` as decimal GB text, no unit. */
|
||||
budgetGb: string;
|
||||
|
||||
Reference in new issue
Block a user