@otto-code/brain 0.8.10 → 0.8.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/bench.js +2 -2
- package/dist/commands/calibrate.js +11 -2
- package/dist/commands/catalog.d.ts +1 -0
- package/dist/commands/catalog.js +1 -0
- package/dist/commands/pull.d.ts +1 -0
- package/dist/commands/pull.js +12 -3
- package/dist/commands/search.d.ts +1 -0
- package/dist/commands/search.js +12 -2
- package/dist/config/index.d.ts +2 -2
- package/dist/config/index.js +2 -2
- package/dist/config/profile-edit.d.ts +88 -1
- package/dist/config/profile-edit.js +294 -43
- package/dist/config/profiles.d.ts +19 -3
- package/dist/config/profiles.js +52 -4
- package/dist/config/schema.d.ts +616 -0
- package/dist/config/schema.js +65 -3
- package/dist/config/store.js +7 -4
- package/dist/gguf.d.ts +7 -0
- package/dist/gguf.js +15 -2
- package/dist/models/download.d.ts +1 -1
- package/dist/models/download.js +2 -2
- package/dist/models/enrich.d.ts +6 -0
- package/dist/models/enrich.js +27 -1
- package/dist/models/index.d.ts +1 -1
- package/dist/models/index.js +4 -3
- package/dist/ops/archive.d.ts +14 -1
- package/dist/ops/archive.js +9 -5
- package/dist/ops/calibrate.d.ts +38 -3
- package/dist/ops/calibrate.js +68 -19
- package/dist/ops/report.js +51 -1
- package/dist/ops/results.d.ts +77 -11
- package/dist/ops/results.js +84 -14
- package/dist/ops/sweep.d.ts +38 -1
- package/dist/ops/sweep.js +61 -10
- package/dist/runtime/args.d.ts +15 -2
- package/dist/runtime/args.js +60 -5
- package/dist/runtime/managed.js +2 -2
- package/dist/service/activity.d.ts +19 -0
- package/dist/service/activity.js +47 -4
- package/dist/service/host-api.d.ts +28 -4
- package/dist/service/host-api.js +109 -28
- package/dist/service/log-format.d.ts +18 -0
- package/dist/service/log-format.js +32 -0
- package/dist/service/process-pool.d.ts +45 -0
- package/dist/service/process-pool.js +271 -0
- package/dist/service/router.d.ts +74 -3
- package/dist/service/router.js +277 -51
- package/dist/service/run-log.d.ts +6 -1
- package/dist/service/run-log.js +46 -4
- package/dist/service/scheduler.d.ts +250 -31
- package/dist/service/scheduler.js +408 -63
- package/dist/service/serve.d.ts +4 -0
- package/dist/service/serve.js +376 -142
- package/dist/service/status-events.d.ts +14 -1
- package/dist/service/status-events.js +112 -12
- package/dist/service/supervisor.d.ts +9 -7
- package/dist/service/supervisor.js +37 -12
- package/dist/sysmon.d.ts +15 -0
- package/dist/sysmon.js +56 -9
- package/dist/tui/app.d.ts +8 -2
- package/dist/tui/app.js +83 -26
- package/dist/types.d.ts +18 -0
- package/dist/vram.d.ts +37 -0
- package/dist/vram.js +57 -18
- package/package.json +1 -1
package/dist/ops/report.js
CHANGED
|
@@ -28,6 +28,54 @@ function escapeHtml(text) {
|
|
|
28
28
|
function formatGiB(bytes) {
|
|
29
29
|
return bytes ? `${(bytes / 1024 ** 3).toFixed(1)} GB` : "-";
|
|
30
30
|
}
|
|
31
|
+
// The sampler defaults come from `configKey`'s own constant: the report and
|
|
32
|
+
// the grouping key must agree on what counts as "the engine default", or the
|
|
33
|
+
// report would print settings the key says were never changed.
|
|
34
|
+
const SAMPLER_DEFAULTS = results.ENGINE_SAMPLER_DEFAULTS;
|
|
35
|
+
/**
|
|
36
|
+
* The schema-3 profile settings that changed what a run was measured with,
|
|
37
|
+
* as a short human line. Returns "" when nothing deviates from the engine
|
|
38
|
+
* defaults, so an untouched run prints exactly what it always did. Older
|
|
39
|
+
* records that predate these fields store `null` for every one, which also
|
|
40
|
+
* collapses to "" - the report never guesses a value it was not given.
|
|
41
|
+
*/
|
|
42
|
+
function formatProfileExtras(record) {
|
|
43
|
+
const p = record.profile;
|
|
44
|
+
if (!p)
|
|
45
|
+
return "";
|
|
46
|
+
const parts = [];
|
|
47
|
+
if (p.contextMultiplier !== null && p.contextMultiplier > 1) {
|
|
48
|
+
parts.push(`x${p.contextMultiplier} ctx`);
|
|
49
|
+
}
|
|
50
|
+
if (p.cachedChats !== null && p.cachedChats > 0) {
|
|
51
|
+
parts.push(`${p.cachedChats} cached`);
|
|
52
|
+
}
|
|
53
|
+
if (p.preserveReasoning === true)
|
|
54
|
+
parts.push("reasoning preserved");
|
|
55
|
+
else if (p.preserveReasoning === false)
|
|
56
|
+
parts.push("reasoning trimmed");
|
|
57
|
+
// Each entry pairs the display name with the `SAMPLER_DEFAULTS` key the
|
|
58
|
+
// default is looked up by - the two deliberately differ (see `configKey`).
|
|
59
|
+
const sampler = [
|
|
60
|
+
["temp", p.temperature, "temperature"],
|
|
61
|
+
["topP", p.topP, "topP"],
|
|
62
|
+
["topK", p.topK, "topK"],
|
|
63
|
+
["minP", p.minP, "minP"],
|
|
64
|
+
["pres", p.presencePenalty, "presencePenalty"],
|
|
65
|
+
["rep", p.repeatPenalty, "repeatPenalty"],
|
|
66
|
+
];
|
|
67
|
+
// `typeof` rather than a null check: a record from before the sampler was
|
|
68
|
+
// stored carries `undefined` for every one of these, and that must read as
|
|
69
|
+
// "not recorded", not as a deviation.
|
|
70
|
+
const deviated = sampler
|
|
71
|
+
.filter(([, value, key]) => typeof value === "number" && value !== SAMPLER_DEFAULTS[key])
|
|
72
|
+
.map(([name, value]) => `${name} ${value}`);
|
|
73
|
+
if (deviated.length > 0)
|
|
74
|
+
parts.push(deviated.join(", "));
|
|
75
|
+
if (p.hostingProfileId)
|
|
76
|
+
parts.push("hosting profile");
|
|
77
|
+
return parts.join(" · ");
|
|
78
|
+
}
|
|
31
79
|
/**
|
|
32
80
|
* Per-model score card: each task labelled directly with its weight, then the
|
|
33
81
|
* weighted Overall on its own row so the headline number reads as the sum of its
|
|
@@ -61,6 +109,7 @@ function groupedBars(records, columns, weightOf) {
|
|
|
61
109
|
record.model.quant,
|
|
62
110
|
`ctx ${(record.profile?.contextSize || 0).toLocaleString()}`,
|
|
63
111
|
`rb ${record.profile?.reasoningBudget}`,
|
|
112
|
+
formatProfileExtras(record),
|
|
64
113
|
]
|
|
65
114
|
.filter(Boolean)
|
|
66
115
|
.join(" · ");
|
|
@@ -267,6 +316,7 @@ function build(records, allRuns = records) {
|
|
|
267
316
|
return (`<tr><td>${escapeHtml(r.model.displayName)}</td><td>${escapeHtml(r.model.quant || "-")}</td>` +
|
|
268
317
|
`<td class="num">${(r.profile?.contextSize || 0).toLocaleString()}</td>` +
|
|
269
318
|
`<td class="num">${r.profile?.reasoningBudget ?? "-"}</td>` +
|
|
319
|
+
`<td class="muted">${escapeHtml(formatProfileExtras(r) || "-")}</td>` +
|
|
270
320
|
`${cells}<td class="num strong">${(r.overall * 100).toFixed(0)}%</td>` +
|
|
271
321
|
`<td class="num">${formatGiB(r.vramBytes)}</td>` +
|
|
272
322
|
`<td class="muted">${escapeHtml(r.ranAt.slice(0, 16).replace("T", " "))}</td></tr>`);
|
|
@@ -434,7 +484,7 @@ function build(records, allRuns = records) {
|
|
|
434
484
|
<h2>All runs</h2>
|
|
435
485
|
<div class="scroll">
|
|
436
486
|
<table>
|
|
437
|
-
<thead><tr><th>Model</th><th>Quant</th><th class="num">Context</th><th class="num">Reasoning</th>
|
|
487
|
+
<thead><tr><th>Model</th><th>Quant</th><th class="num">Context</th><th class="num">Reasoning</th><th>Settings</th>
|
|
438
488
|
${columns.map((c) => `<th class="num">${escapeHtml(c.category)}<span class="wt">×${weightOf.get(c.id) ?? "?"}</span></th>`).join("")}
|
|
439
489
|
<th class="num">Overall<span class="wt">wtd</span></th><th class="num">VRAM</th><th>Run at</th></tr></thead>
|
|
440
490
|
<tbody>${tableRows}</tbody>
|
package/dist/ops/results.d.ts
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
import type { GpuInfo, Model, ModelFeatures } from "../types.js";
|
|
2
2
|
import type { Calibration, Profile } from "../config/schema.js";
|
|
3
3
|
import type { FitResult } from "../vram.js";
|
|
4
|
+
/**
|
|
5
|
+
* Persistent benchmark history.
|
|
6
|
+
*
|
|
7
|
+
* One JSON file per run, so results accumulate across sessions and can be
|
|
8
|
+
* compared and charted later. Runs record the configuration they were measured
|
|
9
|
+
* under, because a score is meaningless without the quant, context size and
|
|
10
|
+
* reasoning budget that produced it.
|
|
11
|
+
*
|
|
12
|
+
* **A run records every value it was measured with, not a summary of them.** A
|
|
13
|
+
* bad score is far more often a bad setup than a bad model, and the difference
|
|
14
|
+
* is only visible from the settings: a context the VRAM fit had to cut, a
|
|
15
|
+
* reasoning budget the model spends entirely on thinking, a KV quant that
|
|
16
|
+
* wrecked recall, weights that fell off the GPU because `gpuLayers` did not
|
|
17
|
+
* cover them, a budget estimated from the formula rather than measured. None of
|
|
18
|
+
* that is recoverable after the fact, so it is all written down at save time -
|
|
19
|
+
* including the exact llama-server argv the run was served with, which is the
|
|
20
|
+
* only true statement of what ran.
|
|
21
|
+
*/
|
|
22
|
+
/** Resolve the writable store for benchmark scores for a given host environment. */
|
|
23
|
+
declare function resolveResultsDir(env?: NodeJS.ProcessEnv): string;
|
|
4
24
|
declare const RESULTS_DIR: string;
|
|
5
25
|
/** Aggregate of a numeric health series (nvidia-smi samples during a run). */
|
|
6
26
|
export interface AggStat {
|
|
@@ -78,7 +98,10 @@ export interface RecordModel {
|
|
|
78
98
|
* a bad score - `gpuLayers` short of the model's layer count silently runs part
|
|
79
99
|
* of it on the CPU, `parallelSlots` splits the context between slots so the
|
|
80
100
|
* effective window is a fraction of `contextSize`, and `extraArgs` can override
|
|
81
|
-
* anything above.
|
|
101
|
+
* anything above. Schema 3 added the settings that landed after the setup-capture
|
|
102
|
+
* change - `preserveReasoning`, `contextMultiplier`, `cachedChats`, the sampler
|
|
103
|
+
* values, and the hosting-profile identity - so the record keeps tracking the
|
|
104
|
+
* profile instead of silently stopping at the field set it was born with.
|
|
82
105
|
*/
|
|
83
106
|
export interface RecordProfile {
|
|
84
107
|
contextSize: number;
|
|
@@ -93,6 +116,28 @@ export interface RecordProfile {
|
|
|
93
116
|
batchSize: number | null;
|
|
94
117
|
ubatchSize: number | null;
|
|
95
118
|
extraArgs: string[];
|
|
119
|
+
/**
|
|
120
|
+
* Schema 3+. Absent on schema 2 records, which predate the setting.
|
|
121
|
+
* Tri-state: true/false as chosen, null for "the template's own default".
|
|
122
|
+
*/
|
|
123
|
+
preserveReasoning: boolean | null;
|
|
124
|
+
/** Schema 3+. RoPE extension factor; 1 is the GGUF-native window. */
|
|
125
|
+
contextMultiplier: number;
|
|
126
|
+
/** Schema 3+. Chats parked in system RAM; 0 leaves llama.cpp's default. */
|
|
127
|
+
cachedChats: number;
|
|
128
|
+
/** Schema 3+. The sampler the run was served with, server-level for every task. */
|
|
129
|
+
temperature: number | null;
|
|
130
|
+
topP: number | null;
|
|
131
|
+
topK: number | null;
|
|
132
|
+
minP: number | null;
|
|
133
|
+
presencePenalty: number | null;
|
|
134
|
+
repeatPenalty: number | null;
|
|
135
|
+
/**
|
|
136
|
+
* Schema 3+. The Brain-owned hosting profile in effect (id, not text): its
|
|
137
|
+
* chat template and system-prompt addendum change what the model is asked
|
|
138
|
+
* to do, so a run under one profile is not comparable to one under another.
|
|
139
|
+
*/
|
|
140
|
+
hostingProfileId: string | null;
|
|
96
141
|
}
|
|
97
142
|
/**
|
|
98
143
|
* How the run was actually set up, as opposed to how it was configured.
|
|
@@ -173,10 +218,12 @@ export interface RecordTask {
|
|
|
173
218
|
/**
|
|
174
219
|
* One stored benchmark run - the shared shape used across results and report.
|
|
175
220
|
*
|
|
176
|
-
* `schema` is
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
221
|
+
* `schema` is 3 as of the profile-completeness change: 3 carries every profile
|
|
222
|
+
* field that reaches llama-server, including the sampler values and the hosting
|
|
223
|
+
* profile; 2 carried the profile as of the setup-capture change plus `setup`
|
|
224
|
+
* and `suite`; 1 carried six profile fields and neither. Readers must treat
|
|
225
|
+
* everything added after a record's schema as absent rather than assuming it,
|
|
226
|
+
* because those runs are still perfectly good scores - they just cannot say
|
|
180
227
|
* what they were measured with.
|
|
181
228
|
*/
|
|
182
229
|
export interface RunRecord {
|
|
@@ -283,14 +330,33 @@ export interface TaskColumn {
|
|
|
283
330
|
category: string;
|
|
284
331
|
}
|
|
285
332
|
declare function slugify(text: string): string;
|
|
333
|
+
/**
|
|
334
|
+
* llama.cpp's own sampler defaults, the values an untouched profile stores
|
|
335
|
+
* verbatim (see `ProfileSchema`). A key token is only emitted when the profile
|
|
336
|
+
* deviates from these, which is what makes an untouched profile keep its
|
|
337
|
+
* historical key after the widening.
|
|
338
|
+
*/
|
|
339
|
+
declare const ENGINE_SAMPLER_DEFAULTS: {
|
|
340
|
+
readonly temperature: 0.8;
|
|
341
|
+
readonly topP: 0.95;
|
|
342
|
+
readonly topK: 40;
|
|
343
|
+
readonly minP: 0.05;
|
|
344
|
+
readonly presencePenalty: 0;
|
|
345
|
+
readonly repeatPenalty: 1;
|
|
346
|
+
};
|
|
286
347
|
/**
|
|
287
348
|
* Stable identity for "same model, same settings", so reruns can be grouped.
|
|
288
349
|
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
350
|
+
* The base four tokens predate the record and are never rewritten - a stored
|
|
351
|
+
* run's key is its key, and re-deriving the same string for the same setup is
|
|
352
|
+
* what lets old runs stay in their old groups. The tokens added in schema 3 are
|
|
353
|
+
* appended ONLY when they deviate from the engine default, because that is what
|
|
354
|
+
* the key means: the effective configuration, and a default is the historical
|
|
355
|
+
* configuration. An untouched profile therefore keeps exactly the key it had
|
|
356
|
+
* before the setting existed, so variance figures for unchanged setups survive
|
|
357
|
+
* the widening; a run that differs only in, say, `contextMultiplier` gets a
|
|
358
|
+
* new key instead of silently merging into its predecessor's group and
|
|
359
|
+
* contaminating that group's consistency line.
|
|
294
360
|
*/
|
|
295
361
|
declare function configKey(profile: Profile | null): string;
|
|
296
362
|
declare function save({ model, profile, report, gpu, runtime, archiveId, system, args, fit, calibration, suite, timestamp, }: SaveOptions): SaveResult;
|
|
@@ -322,5 +388,5 @@ declare function variance(records?: RunRecord[]): VarianceRow[];
|
|
|
322
388
|
declare function rankModels(records?: RunRecord[]): RankedModel[];
|
|
323
389
|
/** All task ids seen across a set of records, in a stable order. */
|
|
324
390
|
declare function taskColumns(records: RunRecord[]): TaskColumn[];
|
|
325
|
-
export { RESULTS_DIR, save, loadAll, latestPerConfig, grouped, variance, stats, rankModels, taskColumns, configKey, slugify, };
|
|
391
|
+
export { RESULTS_DIR, resolveResultsDir, save, loadAll, latestPerConfig, grouped, variance, stats, rankModels, taskColumns, configKey, slugify, ENGINE_SAMPLER_DEFAULTS, };
|
|
326
392
|
//# sourceMappingURL=results.d.ts.map
|
package/dist/ops/results.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { resolveBrainPaths } from "../config/paths.js";
|
|
4
4
|
/**
|
|
5
5
|
* Persistent benchmark history.
|
|
6
6
|
*
|
|
@@ -19,9 +19,14 @@ import { fileURLToPath } from "node:url";
|
|
|
19
19
|
* including the exact llama-server argv the run was served with, which is the
|
|
20
20
|
* only true statement of what ran.
|
|
21
21
|
*/
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
/** Resolve the writable store for benchmark scores for a given host environment. */
|
|
23
|
+
function resolveResultsDir(env = process.env) {
|
|
24
|
+
return resolveBrainPaths(env).resultsDir;
|
|
25
|
+
}
|
|
26
|
+
// Benchmark history is host state, not package data. Keeping it beside the
|
|
27
|
+
// Brain config also makes the service work from packaged/Electron installs,
|
|
28
|
+
// where the package directory may be an archive or read-only.
|
|
29
|
+
const RESULTS_DIR = resolveResultsDir();
|
|
25
30
|
function slugify(text) {
|
|
26
31
|
return String(text)
|
|
27
32
|
.toLowerCase()
|
|
@@ -29,24 +34,77 @@ function slugify(text) {
|
|
|
29
34
|
.replace(/^-|-$/g, "")
|
|
30
35
|
.slice(0, 80);
|
|
31
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* llama.cpp's own sampler defaults, the values an untouched profile stores
|
|
39
|
+
* verbatim (see `ProfileSchema`). A key token is only emitted when the profile
|
|
40
|
+
* deviates from these, which is what makes an untouched profile keep its
|
|
41
|
+
* historical key after the widening.
|
|
42
|
+
*/
|
|
43
|
+
const ENGINE_SAMPLER_DEFAULTS = {
|
|
44
|
+
temperature: 0.8,
|
|
45
|
+
topP: 0.95,
|
|
46
|
+
topK: 40,
|
|
47
|
+
minP: 0.05,
|
|
48
|
+
presencePenalty: 0,
|
|
49
|
+
repeatPenalty: 1,
|
|
50
|
+
};
|
|
32
51
|
/**
|
|
33
52
|
* Stable identity for "same model, same settings", so reruns can be grouped.
|
|
34
53
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
54
|
+
* The base four tokens predate the record and are never rewritten - a stored
|
|
55
|
+
* run's key is its key, and re-deriving the same string for the same setup is
|
|
56
|
+
* what lets old runs stay in their old groups. The tokens added in schema 3 are
|
|
57
|
+
* appended ONLY when they deviate from the engine default, because that is what
|
|
58
|
+
* the key means: the effective configuration, and a default is the historical
|
|
59
|
+
* configuration. An untouched profile therefore keeps exactly the key it had
|
|
60
|
+
* before the setting existed, so variance figures for unchanged setups survive
|
|
61
|
+
* the widening; a run that differs only in, say, `contextMultiplier` gets a
|
|
62
|
+
* new key instead of silently merging into its predecessor's group and
|
|
63
|
+
* contaminating that group's consistency line.
|
|
40
64
|
*/
|
|
41
65
|
function configKey(profile) {
|
|
42
66
|
if (!profile)
|
|
43
67
|
return "unknown";
|
|
44
|
-
|
|
68
|
+
const parts = [
|
|
45
69
|
`ctx${profile.contextSize}`,
|
|
46
70
|
`kv${profile.cacheTypeK}-${profile.cacheTypeV}`,
|
|
47
71
|
`rb${profile.reasoningBudget}`,
|
|
48
72
|
profile.vision ? "vision" : "novision",
|
|
49
|
-
]
|
|
73
|
+
];
|
|
74
|
+
if (profile.contextMultiplier !== undefined && profile.contextMultiplier > 1) {
|
|
75
|
+
parts.push(`x${profile.contextMultiplier}`);
|
|
76
|
+
}
|
|
77
|
+
if (profile.cachedChats !== undefined && profile.cachedChats > 0) {
|
|
78
|
+
parts.push(`cc${profile.cachedChats}`);
|
|
79
|
+
}
|
|
80
|
+
if (profile.preserveReasoning === true)
|
|
81
|
+
parts.push("pr");
|
|
82
|
+
else if (profile.preserveReasoning === false)
|
|
83
|
+
parts.push("nopr");
|
|
84
|
+
// The sampler deviates from the engine default only when the user set it -
|
|
85
|
+
// an untouched profile stores llama.cpp's own defaults verbatim, and those
|
|
86
|
+
// are the default, so they carry no token. Each entry pairs the short token
|
|
87
|
+
// name with the `ENGINE_SAMPLER_DEFAULTS` key the default is looked up by -
|
|
88
|
+
// the two deliberately differ, so conflating them would emit every sampler
|
|
89
|
+
// value on every key.
|
|
90
|
+
const sampler = [
|
|
91
|
+
["temp", profile.temperature, "temperature"],
|
|
92
|
+
["p", profile.topP, "topP"],
|
|
93
|
+
["k", profile.topK, "topK"],
|
|
94
|
+
["minp", profile.minP, "minP"],
|
|
95
|
+
["pres", profile.presencePenalty, "presencePenalty"],
|
|
96
|
+
["rep", profile.repeatPenalty, "repeatPenalty"],
|
|
97
|
+
];
|
|
98
|
+
for (const [name, value, defaultKey] of sampler) {
|
|
99
|
+
if (typeof value === "number" &&
|
|
100
|
+
Number.isFinite(value) &&
|
|
101
|
+
value !== ENGINE_SAMPLER_DEFAULTS[defaultKey]) {
|
|
102
|
+
parts.push(`${name}${value}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (profile.hostingProfileId)
|
|
106
|
+
parts.push(`hp${profile.hostingProfileId}`);
|
|
107
|
+
return parts.join("_");
|
|
50
108
|
}
|
|
51
109
|
/** Read a numeric GGUF metadata field, which is `null` when the header lacked it. */
|
|
52
110
|
function metadataNumber(model, key) {
|
|
@@ -83,7 +141,7 @@ function buildSetup({ args, fit, calibration, }) {
|
|
|
83
141
|
function save({ model, profile, report, gpu = null, runtime = null, archiveId = null, system = null, args = null, fit = null, calibration = null, suite = null, timestamp = new Date(), }) {
|
|
84
142
|
fs.mkdirSync(RESULTS_DIR, { recursive: true });
|
|
85
143
|
const record = {
|
|
86
|
-
schema:
|
|
144
|
+
schema: 3,
|
|
87
145
|
ranAt: timestamp.toISOString(),
|
|
88
146
|
model: {
|
|
89
147
|
id: model?.id ?? null,
|
|
@@ -99,7 +157,9 @@ function save({ model, profile, report, gpu = null, runtime = null, archiveId =
|
|
|
99
157
|
},
|
|
100
158
|
// The whole profile, not a summary of it. `ProfileSchema` is `.passthrough()`
|
|
101
159
|
// and its numeric fields carry defaults, so read them defensively: a profile
|
|
102
|
-
// written by an older brain can be missing anything below `vision
|
|
160
|
+
// written by an older brain can be missing anything below `vision`, and a
|
|
161
|
+
// schema-1-era profile can be missing the sampler values entirely (they
|
|
162
|
+
// were implicit in llama.cpp then).
|
|
103
163
|
profile: profile
|
|
104
164
|
? {
|
|
105
165
|
contextSize: profile.contextSize,
|
|
@@ -114,6 +174,16 @@ function save({ model, profile, report, gpu = null, runtime = null, archiveId =
|
|
|
114
174
|
batchSize: profile.batchSize ?? null,
|
|
115
175
|
ubatchSize: profile.ubatchSize ?? null,
|
|
116
176
|
extraArgs: profile.extraArgs ?? [],
|
|
177
|
+
preserveReasoning: profile.preserveReasoning ?? null,
|
|
178
|
+
contextMultiplier: profile.contextMultiplier ?? 1,
|
|
179
|
+
cachedChats: profile.cachedChats ?? 0,
|
|
180
|
+
temperature: profile.temperature ?? null,
|
|
181
|
+
topP: profile.topP ?? null,
|
|
182
|
+
topK: profile.topK ?? null,
|
|
183
|
+
minP: profile.minP ?? null,
|
|
184
|
+
presencePenalty: profile.presencePenalty ?? null,
|
|
185
|
+
repeatPenalty: profile.repeatPenalty ?? null,
|
|
186
|
+
hostingProfileId: profile.hostingProfileId ?? null,
|
|
117
187
|
}
|
|
118
188
|
: null,
|
|
119
189
|
setup: buildSetup({ args, fit, calibration }),
|
|
@@ -318,5 +388,5 @@ function taskColumns(records) {
|
|
|
318
388
|
}
|
|
319
389
|
return [...seen.entries()].map(([id, category]) => ({ id, category }));
|
|
320
390
|
}
|
|
321
|
-
export { RESULTS_DIR, save, loadAll, latestPerConfig, grouped, variance, stats, rankModels, taskColumns, configKey, slugify, };
|
|
391
|
+
export { RESULTS_DIR, resolveResultsDir, save, loadAll, latestPerConfig, grouped, variance, stats, rankModels, taskColumns, configKey, slugify, ENGINE_SAMPLER_DEFAULTS, };
|
|
322
392
|
//# sourceMappingURL=results.js.map
|
package/dist/ops/sweep.d.ts
CHANGED
|
@@ -7,7 +7,19 @@ import type { Profile } from "../config/schema.js";
|
|
|
7
7
|
* Thinking models default to an unrestricted budget (-1) and will happily burn
|
|
8
8
|
* an entire token allowance reasoning, returning no content at all. The right
|
|
9
9
|
* cap is model-specific, so measure it: run one long-horizon task per candidate
|
|
10
|
-
* budget and
|
|
10
|
+
* budget and take the largest budget that still delivers the whole task. See
|
|
11
|
+
* rankSweepResults for why "largest" and not "fastest".
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* The candidates a sweep tries, in ascending order of thinking room.
|
|
15
|
+
*
|
|
16
|
+
* Capped at 1536 deliberately. The ranking prefers the largest budget that
|
|
17
|
+
* still delivers, so whatever sits at the top of this list is what a healthy
|
|
18
|
+
* thinking model gets recommended - the list itself is the ceiling, and 1536 is
|
|
19
|
+
* the value every hand-set profile has run on without trouble. Each extra
|
|
20
|
+
* candidate also costs a full model load plus a long generation, so the ladder
|
|
21
|
+
* earns its length. `-1` stays because a model that fails under every finite cap
|
|
22
|
+
* still needs an answer; it ranks last and wins only in that case.
|
|
11
23
|
*/
|
|
12
24
|
export declare const DEFAULT_BUDGETS: number[];
|
|
13
25
|
export declare const LONG_TASK: string;
|
|
@@ -75,5 +87,30 @@ export interface SweepOptions {
|
|
|
75
87
|
onProgress?: (event: SweepProgress) => void;
|
|
76
88
|
}
|
|
77
89
|
export declare function sweep({ runtime, model, profile, budgets, maxTokens, temperature, internalPort, supervisor: optionsSupervisor, onProgress, }: SweepOptions): Promise<SweepReport>;
|
|
90
|
+
/**
|
|
91
|
+
* Rank a sweep's trials, best first. `ranked[0].budget` is the recommendation.
|
|
92
|
+
*
|
|
93
|
+
* Delivery decides first: a budget that dropped a file did not do the job.
|
|
94
|
+
* Among the budgets that delivered everything, prefer the **largest** - the most
|
|
95
|
+
* thinking room a model can have without failing to deliver.
|
|
96
|
+
*
|
|
97
|
+
* That tie used to break on content per second, which was backwards. LONG_TASK
|
|
98
|
+
* is pure output, so reasoning earns nothing on it and only costs wall clock:
|
|
99
|
+
* every budget large enough to finish delivered all four files, the tie-break
|
|
100
|
+
* therefore decided every sweep, and the smallest candidate won by construction
|
|
101
|
+
* - 0 included. That is how models ended up capped at 0 and 512, tight enough
|
|
102
|
+
* that llama-server guillotines an ordinary thought mid-sentence, injects
|
|
103
|
+
* `--reasoning-budget-message`, and the model's unfinished reasoning carries on
|
|
104
|
+
* over the content channel as user-visible prose. The sweep's job is to find the
|
|
105
|
+
* cap past which a model degenerates into reasoning forever, not the cap that
|
|
106
|
+
* types fastest. See the "reasoning budget makes thinking bleed into prose"
|
|
107
|
+
* finding in Otto Knowledge.
|
|
108
|
+
*
|
|
109
|
+
* `-1` is the one budget "largest" must not read as large: an unrestricted
|
|
110
|
+
* budget is the failure this package exists to prevent. It ranks below every
|
|
111
|
+
* finite cap and is recommended only when nothing else survived, which means a
|
|
112
|
+
* cap of any size broke the model.
|
|
113
|
+
*/
|
|
114
|
+
export declare function rankSweepResults(results: readonly SweepResult[]): SweepResult[];
|
|
78
115
|
export {};
|
|
79
116
|
//# sourceMappingURL=sweep.d.ts.map
|
package/dist/ops/sweep.js
CHANGED
|
@@ -6,9 +6,23 @@ import { DEFAULT_INTERNAL_PORT, Supervisor } from "../service/supervisor.js";
|
|
|
6
6
|
* Thinking models default to an unrestricted budget (-1) and will happily burn
|
|
7
7
|
* an entire token allowance reasoning, returning no content at all. The right
|
|
8
8
|
* cap is model-specific, so measure it: run one long-horizon task per candidate
|
|
9
|
-
* budget and
|
|
9
|
+
* budget and take the largest budget that still delivers the whole task. See
|
|
10
|
+
* rankSweepResults for why "largest" and not "fastest".
|
|
10
11
|
*/
|
|
11
|
-
|
|
12
|
+
/**
|
|
13
|
+
* The candidates a sweep tries, in ascending order of thinking room.
|
|
14
|
+
*
|
|
15
|
+
* Capped at 1536 deliberately. The ranking prefers the largest budget that
|
|
16
|
+
* still delivers, so whatever sits at the top of this list is what a healthy
|
|
17
|
+
* thinking model gets recommended - the list itself is the ceiling, and 1536 is
|
|
18
|
+
* the value every hand-set profile has run on without trouble. Each extra
|
|
19
|
+
* candidate also costs a full model load plus a long generation, so the ladder
|
|
20
|
+
* earns its length. `-1` stays because a model that fails under every finite cap
|
|
21
|
+
* still needs an answer; it ranks last and wins only in that case.
|
|
22
|
+
*/
|
|
23
|
+
export const DEFAULT_BUDGETS = [0, 512, 1536, -1];
|
|
24
|
+
/** llama.cpp's "no cap at all" sentinel, which a sweep must never recommend. */
|
|
25
|
+
const UNRESTRICTED_BUDGET = -1;
|
|
12
26
|
export const LONG_TASK = "Write a complete Python implementation of a thread-safe LRU cache with TTL " +
|
|
13
27
|
"expiry. Produce FOUR separate complete files, each fully implemented with no " +
|
|
14
28
|
"placeholders or elisions:\n" +
|
|
@@ -87,7 +101,7 @@ export async function sweep({ runtime, model, profile, budgets = DEFAULT_BUDGETS
|
|
|
87
101
|
const supervisor = optionsSupervisor ?? new Supervisor({ runtime, internalPort });
|
|
88
102
|
onProgress({ phase: "loading", budget });
|
|
89
103
|
try {
|
|
90
|
-
await supervisor.start(model, { ...profile, reasoningBudget: budget }
|
|
104
|
+
await supervisor.start(model, { ...profile, reasoningBudget: budget });
|
|
91
105
|
onProgress({ phase: "generating", budget });
|
|
92
106
|
const trial = await runTrial({ supervisor, maxTokens, temperature });
|
|
93
107
|
results.push({ budget, ...trial, error: null });
|
|
@@ -109,13 +123,7 @@ export async function sweep({ runtime, model, profile, budgets = DEFAULT_BUDGETS
|
|
|
109
123
|
await new Promise((resolve) => setTimeout(resolve, 2500));
|
|
110
124
|
}
|
|
111
125
|
}
|
|
112
|
-
|
|
113
|
-
const viable = results.filter((r) => !r.error && r.contentChars > 0);
|
|
114
|
-
const ranked = [...viable].sort((a, b) => {
|
|
115
|
-
if (b.filesDelivered !== a.filesDelivered)
|
|
116
|
-
return b.filesDelivered - a.filesDelivered;
|
|
117
|
-
return b.contentPerSecond - a.contentPerSecond;
|
|
118
|
-
});
|
|
126
|
+
const ranked = rankSweepResults(results);
|
|
119
127
|
return {
|
|
120
128
|
results,
|
|
121
129
|
recommended: ranked.length ? ranked[0].budget : null,
|
|
@@ -123,4 +131,47 @@ export async function sweep({ runtime, model, profile, budgets = DEFAULT_BUDGETS
|
|
|
123
131
|
sweptAt: new Date().toISOString(),
|
|
124
132
|
};
|
|
125
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Rank a sweep's trials, best first. `ranked[0].budget` is the recommendation.
|
|
136
|
+
*
|
|
137
|
+
* Delivery decides first: a budget that dropped a file did not do the job.
|
|
138
|
+
* Among the budgets that delivered everything, prefer the **largest** - the most
|
|
139
|
+
* thinking room a model can have without failing to deliver.
|
|
140
|
+
*
|
|
141
|
+
* That tie used to break on content per second, which was backwards. LONG_TASK
|
|
142
|
+
* is pure output, so reasoning earns nothing on it and only costs wall clock:
|
|
143
|
+
* every budget large enough to finish delivered all four files, the tie-break
|
|
144
|
+
* therefore decided every sweep, and the smallest candidate won by construction
|
|
145
|
+
* - 0 included. That is how models ended up capped at 0 and 512, tight enough
|
|
146
|
+
* that llama-server guillotines an ordinary thought mid-sentence, injects
|
|
147
|
+
* `--reasoning-budget-message`, and the model's unfinished reasoning carries on
|
|
148
|
+
* over the content channel as user-visible prose. The sweep's job is to find the
|
|
149
|
+
* cap past which a model degenerates into reasoning forever, not the cap that
|
|
150
|
+
* types fastest. See the "reasoning budget makes thinking bleed into prose"
|
|
151
|
+
* finding in Otto Knowledge.
|
|
152
|
+
*
|
|
153
|
+
* `-1` is the one budget "largest" must not read as large: an unrestricted
|
|
154
|
+
* budget is the failure this package exists to prevent. It ranks below every
|
|
155
|
+
* finite cap and is recommended only when nothing else survived, which means a
|
|
156
|
+
* cap of any size broke the model.
|
|
157
|
+
*/
|
|
158
|
+
export function rankSweepResults(results) {
|
|
159
|
+
const viable = results.filter((result) => !result.error && result.contentChars > 0);
|
|
160
|
+
return [...viable].sort((a, b) => {
|
|
161
|
+
if (b.filesDelivered !== a.filesDelivered) {
|
|
162
|
+
return b.filesDelivered - a.filesDelivered;
|
|
163
|
+
}
|
|
164
|
+
const rankA = budgetRank(a.budget);
|
|
165
|
+
const rankB = budgetRank(b.budget);
|
|
166
|
+
if (rankA === rankB) {
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
// Compared rather than subtracted: the sentinel's rank is -Infinity, and
|
|
170
|
+
// -Infinity - -Infinity is NaN, which would corrupt the whole sort.
|
|
171
|
+
return rankB > rankA ? 1 : -1;
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
function budgetRank(budget) {
|
|
175
|
+
return budget === UNRESTRICTED_BUDGET ? Number.NEGATIVE_INFINITY : budget;
|
|
176
|
+
}
|
|
126
177
|
//# sourceMappingURL=sweep.js.map
|
package/dist/runtime/args.d.ts
CHANGED
|
@@ -3,11 +3,24 @@
|
|
|
3
3
|
* needs. Runtime-source agnostic: works the same for an LM Studio runtime or a
|
|
4
4
|
* managed one, since both resolve to a `Runtime` (exe + optional vendorDir).
|
|
5
5
|
*/
|
|
6
|
-
import type { Profile } from "../config/schema.js";
|
|
6
|
+
import type { Calibration, Profile } from "../config/schema.js";
|
|
7
7
|
import type { Model, Runtime } from "../types.js";
|
|
8
8
|
export interface ServeTarget {
|
|
9
9
|
port: number;
|
|
10
10
|
host?: string;
|
|
11
|
+
/** llama.cpp log threshold; 3 preserves its upstream default Info output. */
|
|
12
|
+
logVerbosity?: number;
|
|
13
|
+
/**
|
|
14
|
+
* A directory the engine may use for its slot save/restore/erase actions
|
|
15
|
+
* (`POST /slots`). Passing it is what UNLOCKS the `action=erase` route:
|
|
16
|
+
* llama.cpp refuses every `POST /slots` action when `--slot-save-path` is not
|
|
17
|
+
* set, and the scheduler's cross-chat KV-bleed fix erases a slot's retained
|
|
18
|
+
* KV the moment it is handed to a different chat. The directory must exist -
|
|
19
|
+
* the engine validates it at launch and throws otherwise - so the caller
|
|
20
|
+
* creates it. Absent (null) means the engine's own default: slot actions
|
|
21
|
+
* disabled, which is the pre-fix behavior.
|
|
22
|
+
*/
|
|
23
|
+
slotSavePath?: string | null;
|
|
11
24
|
}
|
|
12
25
|
/**
|
|
13
26
|
* Loader environment the child process needs so it can resolve its shared
|
|
@@ -28,7 +41,7 @@ export declare function buildEnv(runtime: Runtime, baseEnv?: NodeJS.ProcessEnv,
|
|
|
28
41
|
* Only settings that demonstrably matter for stable local inference are emitted -
|
|
29
42
|
* no experimental sampler knobs.
|
|
30
43
|
*/
|
|
31
|
-
export declare function buildArgs(profile: Profile, { port, host }: ServeTarget, model?: Model): string[];
|
|
44
|
+
export declare function buildArgs(profile: Profile, { port, host, logVerbosity, slotSavePath }: ServeTarget, model?: Model, calibration?: Calibration | null): string[];
|
|
32
45
|
/** The same command as a copy-pasteable shell line, for the TUI to display. */
|
|
33
46
|
export declare function formatCommand(runtime: Runtime, args: string[]): string;
|
|
34
47
|
//# sourceMappingURL=args.d.ts.map
|
package/dist/runtime/args.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { promptCacheSize } from "../vram.js";
|
|
2
|
+
const MIB = 1024 * 1024;
|
|
1
3
|
/**
|
|
2
4
|
* Loader environment the child process needs so it can resolve its shared
|
|
3
5
|
* libraries. Both the runtime dir and its vendor dir go first, ahead of the
|
|
@@ -29,7 +31,7 @@ export function buildEnv(runtime, baseEnv = process.env, platform = process.plat
|
|
|
29
31
|
* Only settings that demonstrably matter for stable local inference are emitted -
|
|
30
32
|
* no experimental sampler knobs.
|
|
31
33
|
*/
|
|
32
|
-
export function buildArgs(profile, { port, host = "127.0.0.1" }, model) {
|
|
34
|
+
export function buildArgs(profile, { port, host = "127.0.0.1", logVerbosity = 3, slotSavePath = null }, model, calibration) {
|
|
33
35
|
const args = [
|
|
34
36
|
"-m",
|
|
35
37
|
profile.modelPath ?? "",
|
|
@@ -47,8 +49,19 @@ export function buildArgs(profile, { port, host = "127.0.0.1" }, model) {
|
|
|
47
49
|
host,
|
|
48
50
|
"--port",
|
|
49
51
|
String(port),
|
|
50
|
-
"--no-
|
|
52
|
+
"--no-ui",
|
|
53
|
+
"-lv",
|
|
54
|
+
String(logVerbosity),
|
|
51
55
|
];
|
|
56
|
+
// A slot-save directory unlocks the engine's `POST /slots` actions - the
|
|
57
|
+
// `action=erase` the scheduler needs to wipe a slot's retained KV before it
|
|
58
|
+
// is handed to a different chat (see OWNERSHIP in scheduler.ts). Without it
|
|
59
|
+
// llama.cpp refuses every slot action with "start it with --slot-save-path".
|
|
60
|
+
// The directory must already exist: the engine validates it at launch and
|
|
61
|
+
// throws otherwise, so the caller creates it before building the args.
|
|
62
|
+
if (slotSavePath) {
|
|
63
|
+
args.push("--slot-save-path", slotSavePath);
|
|
64
|
+
}
|
|
52
65
|
if (profile.vision && profile.mmprojPath) {
|
|
53
66
|
args.push("--mmproj", profile.mmprojPath);
|
|
54
67
|
}
|
|
@@ -64,8 +77,44 @@ export function buildArgs(profile, { port, host = "127.0.0.1" }, model) {
|
|
|
64
77
|
args.push("--reasoning-budget-message", profile.reasoningBudgetMessage);
|
|
65
78
|
}
|
|
66
79
|
}
|
|
80
|
+
// Whether the reasoning trace stays in the whole history or is trimmed to the
|
|
81
|
+
// last assistant message. Emitted only on an explicit choice: llama-server's
|
|
82
|
+
// own default is the template's, and there is no third flag spelling for "do
|
|
83
|
+
// what the template says" - the absence of the flag IS that state.
|
|
84
|
+
if (profile.preserveReasoning === true) {
|
|
85
|
+
args.push("--reasoning-preserve");
|
|
86
|
+
}
|
|
87
|
+
else if (profile.preserveReasoning === false) {
|
|
88
|
+
args.push("--no-reasoning-preserve");
|
|
89
|
+
}
|
|
90
|
+
// Sampler settings. Always emitted, because the profile stores llama.cpp's own
|
|
91
|
+
// defaults verbatim, so an untouched profile produces the run it always did
|
|
92
|
+
// while the values stay visible and editable rather than implicit.
|
|
93
|
+
for (const [flag, value] of [
|
|
94
|
+
["--temp", profile.temperature],
|
|
95
|
+
["--top-p", profile.topP],
|
|
96
|
+
["--top-k", profile.topK],
|
|
97
|
+
["--min-p", profile.minP],
|
|
98
|
+
["--presence-penalty", profile.presencePenalty],
|
|
99
|
+
["--repeat-penalty", profile.repeatPenalty],
|
|
100
|
+
]) {
|
|
101
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
102
|
+
args.push(flag, String(value));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
67
105
|
if (profile.parallelSlots)
|
|
68
106
|
args.push("--parallel", String(profile.parallelSlots));
|
|
107
|
+
// Prompt cache: how much host RAM llama-server may use to park the KV state
|
|
108
|
+
// of chats that lost their slot, so returning to one is a bulk copy instead
|
|
109
|
+
// of a full re-prefill. Sized from the chat count the user chose, because a
|
|
110
|
+
// raw MiB figure is unusable without knowing this model's KV bytes/token.
|
|
111
|
+
// Emitted only when the size is real: with no measurement, the theoretical
|
|
112
|
+
// KV cost overestimates by multiples, and reserving multiples of the RAM
|
|
113
|
+
// actually needed is worse than leaving llama.cpp's own default in place.
|
|
114
|
+
const cache = model ? promptCacheSize(model, profile, calibration) : null;
|
|
115
|
+
if (cache && cache.source === "measured") {
|
|
116
|
+
args.push("--cache-ram", String(Math.max(1, Math.round(cache.totalBytes / MIB))));
|
|
117
|
+
}
|
|
69
118
|
if (profile.contextMultiplier > 1) {
|
|
70
119
|
const nativeContext = model?.metadata?.contextLength;
|
|
71
120
|
args.push("--rope-scaling", "yarn", "--rope-scale", String(profile.contextMultiplier), ...(nativeContext ? ["--yarn-orig-ctx", String(nativeContext)] : []), ...(model?.metadata?.arch
|
|
@@ -78,9 +127,15 @@ export function buildArgs(profile, { port, host = "127.0.0.1" }, model) {
|
|
|
78
127
|
args.push("-ub", String(profile.ubatchSize));
|
|
79
128
|
if (profile.chatTemplateFile) {
|
|
80
129
|
args.push("--chat-template-file", profile.chatTemplateFile);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
130
|
+
}
|
|
131
|
+
const templateKwargs = { ...profile.chatTemplateKwargs };
|
|
132
|
+
const preservation = model?.reasoningPreservation;
|
|
133
|
+
if (preservation?.templateArgument) {
|
|
134
|
+
templateKwargs[preservation.templateArgument] =
|
|
135
|
+
profile.preserveReasoning ?? preservation.default ?? false;
|
|
136
|
+
}
|
|
137
|
+
if (Object.keys(templateKwargs).length > 0) {
|
|
138
|
+
args.push("--chat-template-kwargs", JSON.stringify(templateKwargs));
|
|
84
139
|
}
|
|
85
140
|
if (profile.extraArgs && profile.extraArgs.length)
|
|
86
141
|
args.push(...profile.extraArgs);
|