@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.
Files changed (65) hide show
  1. package/dist/commands/bench.js +2 -2
  2. package/dist/commands/calibrate.js +11 -2
  3. package/dist/commands/catalog.d.ts +1 -0
  4. package/dist/commands/catalog.js +1 -0
  5. package/dist/commands/pull.d.ts +1 -0
  6. package/dist/commands/pull.js +12 -3
  7. package/dist/commands/search.d.ts +1 -0
  8. package/dist/commands/search.js +12 -2
  9. package/dist/config/index.d.ts +2 -2
  10. package/dist/config/index.js +2 -2
  11. package/dist/config/profile-edit.d.ts +88 -1
  12. package/dist/config/profile-edit.js +294 -43
  13. package/dist/config/profiles.d.ts +19 -3
  14. package/dist/config/profiles.js +52 -4
  15. package/dist/config/schema.d.ts +616 -0
  16. package/dist/config/schema.js +65 -3
  17. package/dist/config/store.js +7 -4
  18. package/dist/gguf.d.ts +7 -0
  19. package/dist/gguf.js +15 -2
  20. package/dist/models/download.d.ts +1 -1
  21. package/dist/models/download.js +2 -2
  22. package/dist/models/enrich.d.ts +6 -0
  23. package/dist/models/enrich.js +27 -1
  24. package/dist/models/index.d.ts +1 -1
  25. package/dist/models/index.js +4 -3
  26. package/dist/ops/archive.d.ts +14 -1
  27. package/dist/ops/archive.js +9 -5
  28. package/dist/ops/calibrate.d.ts +38 -3
  29. package/dist/ops/calibrate.js +68 -19
  30. package/dist/ops/report.js +51 -1
  31. package/dist/ops/results.d.ts +77 -11
  32. package/dist/ops/results.js +84 -14
  33. package/dist/ops/sweep.d.ts +38 -1
  34. package/dist/ops/sweep.js +61 -10
  35. package/dist/runtime/args.d.ts +15 -2
  36. package/dist/runtime/args.js +60 -5
  37. package/dist/runtime/managed.js +2 -2
  38. package/dist/service/activity.d.ts +19 -0
  39. package/dist/service/activity.js +47 -4
  40. package/dist/service/host-api.d.ts +28 -4
  41. package/dist/service/host-api.js +109 -28
  42. package/dist/service/log-format.d.ts +18 -0
  43. package/dist/service/log-format.js +32 -0
  44. package/dist/service/process-pool.d.ts +45 -0
  45. package/dist/service/process-pool.js +271 -0
  46. package/dist/service/router.d.ts +74 -3
  47. package/dist/service/router.js +277 -51
  48. package/dist/service/run-log.d.ts +6 -1
  49. package/dist/service/run-log.js +46 -4
  50. package/dist/service/scheduler.d.ts +250 -31
  51. package/dist/service/scheduler.js +408 -63
  52. package/dist/service/serve.d.ts +4 -0
  53. package/dist/service/serve.js +376 -142
  54. package/dist/service/status-events.d.ts +14 -1
  55. package/dist/service/status-events.js +112 -12
  56. package/dist/service/supervisor.d.ts +9 -7
  57. package/dist/service/supervisor.js +37 -12
  58. package/dist/sysmon.d.ts +15 -0
  59. package/dist/sysmon.js +56 -9
  60. package/dist/tui/app.d.ts +8 -2
  61. package/dist/tui/app.js +83 -26
  62. package/dist/types.d.ts +18 -0
  63. package/dist/vram.d.ts +37 -0
  64. package/dist/vram.js +57 -18
  65. package/package.json +1 -1
@@ -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>
@@ -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 2 as of the setup-capture change: 2 carries the full profile plus
177
- * `setup` and `suite`; 1 carried six profile fields and neither. Readers must
178
- * treat everything added in 2 as absent on an older record rather than assuming
179
- * it, because those runs are still perfectly good scores - they just cannot say
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
- * Deliberately unchanged when the record grew: this string is the grouping key
290
- * every stored run was written with, and widening it would not re-key history -
291
- * it would split each model's past runs from its future ones and quietly reset
292
- * every variance figure on the page. New settings are recorded as data, not
293
- * folded in here.
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
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { fileURLToPath } from "node:url";
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
- const HERE = path.dirname(fileURLToPath(import.meta.url));
23
- const ROOT = path.resolve(HERE, "..", "..");
24
- const RESULTS_DIR = path.join(ROOT, "results");
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
- * Deliberately unchanged when the record grew: this string is the grouping key
36
- * every stored run was written with, and widening it would not re-key history -
37
- * it would split each model's past runs from its future ones and quietly reset
38
- * every variance figure on the page. New settings are recorded as data, not
39
- * folded in here.
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
- return [
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
- ].join("_");
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: 2,
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
@@ -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 score by delivered content per second.
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 score by delivered content per second.
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
- export const DEFAULT_BUDGETS = [0, 512, 1536, 3072, -1];
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 }, { preserveLogs: Boolean(optionsSupervisor) });
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
- // Prefer runs that delivered every file; break ties on content per second.
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
@@ -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
@@ -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-webui",
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
- if (Object.keys(profile.chatTemplateKwargs ?? {}).length > 0) {
82
- args.push("--chat-template-kwargs", JSON.stringify(profile.chatTemplateKwargs));
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);