@otto-code/brain 0.8.12 → 0.8.14

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.
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import http from "node:http";
10
10
  import path from "node:path";
11
- import { forModel, getCalibration, loadBrainConfig, loadProfilesStore, resolveBrainPaths, } from "../config/index.js";
11
+ import { forModel, getCalibrationForBudget, loadBrainConfig, loadProfilesStore, resolveBrainPaths, } from "../config/index.js";
12
12
  import { query as queryGpu } from "../gpu.js";
13
13
  import { pickModel, scanModels } from "../models/index.js";
14
14
  import { CommandError } from "../output/types.js";
@@ -172,7 +172,7 @@ async function runBenchSuite(options, _command) {
172
172
  // that ran is the profile that was configured, and the calibration is what
173
173
  // says whether the fit's own VRAM figures were measured or guessed. A score
174
174
  // read without them is a score nobody can diagnose.
175
- const calibration = getCalibration(store, model, profile);
175
+ const calibration = getCalibrationForBudget(store, model, profile);
176
176
  let fit = null;
177
177
  const gpu = await queryGpu();
178
178
  if (gpu) {
@@ -1,4 +1,4 @@
1
- import { getCalibration, forModel, loadBrainConfig, loadProfilesStore, putCalibration, saveProfilesStore, } from "../config/index.js";
1
+ import { getCalibrationForBudget, forModel, loadBrainConfig, loadProfilesStore, putCalibration, saveProfilesStore, } from "../config/index.js";
2
2
  import { query as queryGpu } from "../gpu.js";
3
3
  import { pickModel, scanModels } from "../models/index.js";
4
4
  import { CommandError } from "../output/types.js";
@@ -35,12 +35,12 @@ export async function runCalibrateCommand(options, _command) {
35
35
  const catalog = scanModels(config);
36
36
  const model = pickModel(catalog, options.model ?? store.lastModelId ?? undefined);
37
37
  const profile = forModel(store, model, config.defaults);
38
- // A measurement from the previous run is the best prior on bytes/token for
39
- // this exact profile shape. The sample cap uses it so the high sample never
40
- // lands at a context that would spill the KV cache to CPU and bias the new
41
- // slope low. Inherited family calibrations are excluded: they were measured
42
- // on another file and are not a trustworthy budget input here.
43
- const prior = getCalibration(store, model, profile);
38
+ // The last direct measurement is the best prior on bytes/token available for
39
+ // capping the next sample. It keeps the calibration path stable while a
40
+ // changed profile is awaiting a fresh measurement. Inherited family
41
+ // calibrations are excluded: they were measured on another file and are not
42
+ // a trustworthy budget input here.
43
+ const prior = getCalibrationForBudget(store, model, profile);
44
44
  // Announced so the Brain rail can show the host as busy: a calibrate loads the
45
45
  // model at several context sizes and will make anything else queue behind it.
46
46
  const measurement = await withActivity("calibrate", { target: model.displayName }, () => calibrate({
@@ -66,7 +66,7 @@ export async function runCalibrateCommand(options, _command) {
66
66
  ? vram.maxContextThatFits({
67
67
  model,
68
68
  profile,
69
- calibration: getCalibration(store, model, profile),
69
+ calibration: getCalibrationForBudget(store, model, profile),
70
70
  totalVramBytes: gpu.totalBytes,
71
71
  })
72
72
  : null;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `otto brain pull <model>` - download a model from the catalog into the managed
3
3
  * models directory, using only Node's fetch (no external downloader). The catalog
4
- * is the same one seeded from docs/candidate-models.md. `--list-quants` shows what
4
+ * is the same one seeded from docs/brain-model-catalog.md. `--list-quants` shows what
5
5
  * quantizations the repo offers and `--quant <label>` downloads a specific one.
6
6
  */
7
7
  import type { Command } from "commander";
@@ -4,7 +4,7 @@ export { ensurePrivateDirectory, writePrivateFileAtomicSync } from "./private-fi
4
4
  export { resolveBrainPaths, packageRoot, type BrainPaths } from "./paths.js";
5
5
  export { parseBooleanEnv, applyEnvOverrides } from "./env.js";
6
6
  export { loadBrainConfig, loadPersistedConfig, saveBrainConfig, loadProfilesStore, saveProfilesStore, loadCatalog, } from "./store.js";
7
- export { defaultProfile, forModel, put, calibrationKey, geometryKey, getCalibration, putCalibration, hasStaleCalibration, } from "./profiles.js";
7
+ export { defaultProfile, forModel, put, calibrationKey, geometryKey, getCalibration, getCalibrationForBudget, getLastCalibration, putCalibration, hasStaleCalibration, } from "./profiles.js";
8
8
  export { effectiveHostingProfile, resolveHostingProfileForLaunch } from "./hosting-profiles.js";
9
9
  export { calibrationInfo, nativeContextLimit, profileFieldDescriptors, profileWarnings, sanitizeProfilePatch, formatReasoningBudget, CACHE_TYPE_CYCLE, REASONING_BUDGET_CYCLE, UNRESTRICTED_REASONING_BUDGET, PRESERVE_REASONING_CYCLE, SAMPLING_RANGES, preserveReasoningFromOption, preserveReasoningOption, type CalibrationInfo, type CalibrationState, type ProfileFieldDescriptor, type ProfileWarning, } from "./profile-edit.js";
10
10
  export * from "./schema.js";
@@ -4,7 +4,7 @@ export { ensurePrivateDirectory, writePrivateFileAtomicSync } from "./private-fi
4
4
  export { resolveBrainPaths, packageRoot } from "./paths.js";
5
5
  export { parseBooleanEnv, applyEnvOverrides } from "./env.js";
6
6
  export { loadBrainConfig, loadPersistedConfig, saveBrainConfig, loadProfilesStore, saveProfilesStore, loadCatalog, } from "./store.js";
7
- export { defaultProfile, forModel, put, calibrationKey, geometryKey, getCalibration, putCalibration, hasStaleCalibration, } from "./profiles.js";
7
+ export { defaultProfile, forModel, put, calibrationKey, geometryKey, getCalibration, getCalibrationForBudget, getLastCalibration, putCalibration, hasStaleCalibration, } from "./profiles.js";
8
8
  export { effectiveHostingProfile, resolveHostingProfileForLaunch } from "./hosting-profiles.js";
9
9
  export { calibrationInfo, nativeContextLimit, profileFieldDescriptors, profileWarnings, sanitizeProfilePatch, formatReasoningBudget, CACHE_TYPE_CYCLE, REASONING_BUDGET_CYCLE, UNRESTRICTED_REASONING_BUDGET, PRESERVE_REASONING_CYCLE, SAMPLING_RANGES, preserveReasoningFromOption, preserveReasoningOption, } from "./profile-edit.js";
10
10
  export * from "./schema.js";
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import os from "node:os";
21
21
  import { CACHE_TYPE_BYTES, formatGiB, promptCacheSize } from "../vram.js";
22
- import { getCalibration, hasStaleCalibration } from "./profiles.js";
22
+ import { getCalibration, getCalibrationForBudget, hasStaleCalibration } from "./profiles.js";
23
23
  /**
24
24
  * Sampler ranges. llama.cpp itself bounds almost none of these - it will take a
25
25
  * temperature of 50 - so the bounds are the useful range rather than the legal
@@ -329,8 +329,10 @@ export function profileWarnings(profile, model, store) {
329
329
  // A count above 0 can only be priced from a measurement: without one the
330
330
  // theoretical KV cost runs to multiples of the real one, and naming a figure
331
331
  // derived from it would invite reserving several times the RAM actually
332
- // needed. So the unmeasured count falls back to the calibration request.
333
- const calibration = model && store ? getCalibration(store, model, profile) : null;
332
+ // needed. Once a model has been measured, keep its last value while edits are
333
+ // pending recalibration; the stale calibration state makes the reduced trust
334
+ // visible without making the number jump.
335
+ const calibration = model && store ? getCalibrationForBudget(store, model, profile) : null;
334
336
  const cache = model ? promptCacheSize(model, profile, calibration) : null;
335
337
  const hasCount = (profile.cachedChats ?? 0) > 0;
336
338
  if (hasCount && (!cache || cache.source !== "measured")) {
@@ -359,7 +361,7 @@ export function profileWarnings(profile, model, store) {
359
361
  warnings.push({
360
362
  field: "contextMultiplier",
361
363
  severity: "warn",
362
- message: `YaRN ×${profile.contextMultiplier} extrapolates beyond the native context. Recalibrate before relying on this profile.`,
364
+ message: "YaRN extrapolates beyond the native context.",
363
365
  blocksStart: false,
364
366
  });
365
367
  }
@@ -370,7 +372,7 @@ export function profileWarnings(profile, model, store) {
370
372
  warnings.push({
371
373
  field: "cacheTypeK",
372
374
  severity: "info",
373
- message: "Cache types changed since the last measurement. Recalibrate for a real budget.",
375
+ message: "Cache types changed since the last measurement.",
374
376
  blocksStart: false,
375
377
  });
376
378
  }
@@ -382,24 +384,22 @@ export function profileWarnings(profile, model, store) {
382
384
  * model's layer count, which the UI must never present as measured on this file.
383
385
  */
384
386
  export function calibrationInfo(store, model, profile) {
385
- // A historical measurement is invalid as soon as any VRAM-affecting setting
386
- // changes. Keep the data for comparison, but do not present or use it as the
387
- // current model budget until a calibration commits the new profile.
388
- if (profile.calibrationRequired) {
387
+ const current = getCalibration(store, model, profile);
388
+ const calibration = getCalibrationForBudget(store, model, profile);
389
+ if (!calibration) {
389
390
  return {
390
- state: "theoretical",
391
+ state: hasStaleCalibration(store, model, profile) ? "stale" : "theoretical",
391
392
  kvBytesPerToken: null,
392
393
  measuredAt: null,
393
394
  measuredOn: null,
394
395
  };
395
396
  }
396
- const calibration = getCalibration(store, model, profile);
397
- if (!calibration) {
397
+ if (profile.calibrationRequired || !current) {
398
398
  return {
399
- state: hasStaleCalibration(store, model, profile) ? "stale" : "theoretical",
400
- kvBytesPerToken: null,
401
- measuredAt: null,
402
- measuredOn: null,
399
+ state: "stale",
400
+ kvBytesPerToken: calibration.kvBytesPerToken,
401
+ measuredAt: calibration.measuredAt ?? null,
402
+ measuredOn: calibration.measuredOn ?? null,
403
403
  };
404
404
  }
405
405
  return {
@@ -14,9 +14,9 @@ export declare function put(store: ProfilesStore, model: Model, profile: Profile
14
14
  /** Calibration is keyed by cache types, since those change bytes/token. */
15
15
  export declare function calibrationKey(profile: Profile): string;
16
16
  /**
17
- * True when this model has a stored calibration, but for different cache types
18
- * than the profile currently uses - i.e. the measurement is stale and the budget
19
- * has fallen back to the theoretical estimate. Drives the "recalibrate" prompt.
17
+ * True when this model has stored calibrations but none for the profile's
18
+ * current calibration key. Historical entries do not make an exact current
19
+ * measurement stale when the matching key is also present.
20
20
  */
21
21
  export declare function hasStaleCalibration(store: ProfilesStore, model: Model, profile: Profile): boolean;
22
22
  /**
@@ -30,5 +30,21 @@ export declare function hasStaleCalibration(store: ProfilesStore, model: Model,
30
30
  */
31
31
  export declare function geometryKey(model: Model, profile: Profile): string | null;
32
32
  export declare function getCalibration(store: ProfilesStore, model: Model, profile: Profile): Calibration | null;
33
+ /**
34
+ * Return the most recent direct calibration stored for a model, regardless of
35
+ * which profile key produced it. A stale measurement is still the stable value
36
+ * the UI and budget math should keep showing until a new calibration replaces
37
+ * it; callers use the calibration state to make its reduced trust visible.
38
+ */
39
+ export declare function getLastCalibration(store: ProfilesStore, model: Model): Calibration | null;
40
+ /**
41
+ * Resolve the calibration used by VRAM budgeting and launch arguments.
42
+ *
43
+ * An exact or inherited current-profile calibration remains preferred while the
44
+ * profile is current. Once an edit requires recalibration, retain the model's
45
+ * most recent direct measurement instead of jumping to the theoretical formula.
46
+ * The profile/calibration state still tells the caller that this value is stale.
47
+ */
48
+ export declare function getCalibrationForBudget(store: ProfilesStore, model: Model, profile: Profile): Calibration | null;
33
49
  export declare function putCalibration(store: ProfilesStore, model: Model, profile: Profile, measurement: Calibration): ProfilesStore;
34
50
  //# sourceMappingURL=profiles.d.ts.map
@@ -115,16 +115,16 @@ export function calibrationKey(profile) {
115
115
  : `${profile.cacheTypeK}:${profile.cacheTypeV}${multiplier}`;
116
116
  }
117
117
  /**
118
- * True when this model has a stored calibration, but for different cache types
119
- * than the profile currently uses - i.e. the measurement is stale and the budget
120
- * has fallen back to the theoretical estimate. Drives the "recalibrate" prompt.
118
+ * True when this model has stored calibrations but none for the profile's
119
+ * current calibration key. Historical entries do not make an exact current
120
+ * measurement stale when the matching key is also present.
121
121
  */
122
122
  export function hasStaleCalibration(store, model, profile) {
123
123
  const measured = store.calibrations?.[model.id];
124
124
  if (!measured)
125
125
  return false;
126
126
  const key = calibrationKey(profile);
127
- return Object.keys(measured).some((k) => k !== key);
127
+ return !Object.hasOwn(measured, key) && Object.keys(measured).length > 0;
128
128
  }
129
129
  /**
130
130
  * KV cost per token is a property of the attention geometry, not of the particular
@@ -167,6 +167,38 @@ export function getCalibration(store, model, profile) {
167
167
  inherited: true,
168
168
  };
169
169
  }
170
+ /**
171
+ * Return the most recent direct calibration stored for a model, regardless of
172
+ * which profile key produced it. A stale measurement is still the stable value
173
+ * the UI and budget math should keep showing until a new calibration replaces
174
+ * it; callers use the calibration state to make its reduced trust visible.
175
+ */
176
+ export function getLastCalibration(store, model) {
177
+ const entries = Object.values(store.calibrations?.[model.id] ?? {});
178
+ if (entries.length === 0)
179
+ return null;
180
+ return entries.reduce((latest, candidate) => {
181
+ const latestTime = latest.measuredAt ? Date.parse(latest.measuredAt) : Number.NEGATIVE_INFINITY;
182
+ const candidateTime = candidate.measuredAt
183
+ ? Date.parse(candidate.measuredAt)
184
+ : Number.NEGATIVE_INFINITY;
185
+ return candidateTime >= latestTime ? candidate : latest;
186
+ });
187
+ }
188
+ /**
189
+ * Resolve the calibration used by VRAM budgeting and launch arguments.
190
+ *
191
+ * An exact or inherited current-profile calibration remains preferred while the
192
+ * profile is current. Once an edit requires recalibration, retain the model's
193
+ * most recent direct measurement instead of jumping to the theoretical formula.
194
+ * The profile/calibration state still tells the caller that this value is stale.
195
+ */
196
+ export function getCalibrationForBudget(store, model, profile) {
197
+ const current = getCalibration(store, model, profile);
198
+ if (!profile.calibrationRequired && current)
199
+ return current;
200
+ return getLastCalibration(store, model) ?? current;
201
+ }
170
202
  export function putCalibration(store, model, profile, measurement) {
171
203
  if (!store.calibrations)
172
204
  store.calibrations = {};
@@ -1516,6 +1516,10 @@ export declare const BrainConfigSchema: z.ZodObject<{
1516
1516
  modelsDir: z.ZodDefault<z.ZodNullable<z.ZodString>>;
1517
1517
  hfToken: z.ZodDefault<z.ZodNullable<z.ZodString>>;
1518
1518
  defaultModel: z.ZodDefault<z.ZodNullable<z.ZodString>>;
1519
+ /** Maximum independently hosted llama-server model processes. */
1520
+ maxLoadedModels: z.ZodDefault<z.ZodNumber>;
1521
+ /** Stable ids selected for residency while model locking is enabled. */
1522
+ lockedModels: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
1519
1523
  lockModel: z.ZodDefault<z.ZodBoolean>;
1520
1524
  allowRemoteConfig: z.ZodDefault<z.ZodBoolean>;
1521
1525
  allowInsecureBind: z.ZodDefault<z.ZodBoolean>;
@@ -1571,6 +1575,8 @@ export declare const BrainConfigSchema: z.ZodObject<{
1571
1575
  modelsDir: string | null;
1572
1576
  hfToken: string | null;
1573
1577
  defaultModel: string | null;
1578
+ maxLoadedModels: number;
1579
+ lockedModels: string[];
1574
1580
  lockModel: boolean;
1575
1581
  allowRemoteConfig: boolean;
1576
1582
  allowInsecureBind: boolean;
@@ -1612,6 +1618,8 @@ export declare const BrainConfigSchema: z.ZodObject<{
1612
1618
  modelsDir?: string | null | undefined;
1613
1619
  hfToken?: string | null | undefined;
1614
1620
  defaultModel?: string | null | undefined;
1621
+ maxLoadedModels?: number | undefined;
1622
+ lockedModels?: string[] | undefined;
1615
1623
  lockModel?: boolean | undefined;
1616
1624
  allowRemoteConfig?: boolean | undefined;
1617
1625
  allowInsecureBind?: boolean | undefined;
@@ -251,9 +251,13 @@ export const BrainConfigSchema = z
251
251
  // persisted fallback so users can set it once (config set hfToken <token>).
252
252
  hfToken: z.string().nullable().default(null),
253
253
  defaultModel: z.string().nullable().default(null),
254
- // Pin the host to a single model: serve only the default/resident model and
255
- // refuse completion requests that name a different one, instead of queuing a
256
- // switch. For hosts that load one model and must not thrash between clients.
254
+ /** Maximum independently hosted llama-server model processes. */
255
+ maxLoadedModels: z.number().int().min(1).max(16).default(1),
256
+ /** Stable ids selected for residency while model locking is enabled. */
257
+ lockedModels: z.array(z.string()).default([]),
258
+ // Pin the host to the selected resident set and refuse completion requests
259
+ // that name a different model. With a one-process host this preserves the
260
+ // original single-model lock behavior.
257
261
  lockModel: z.boolean().default(false),
258
262
  // Sharing/control gates (off by default - a brain is not remotely
259
263
  // controllable until its owner opts in). `allowRemoteConfig`: a client with
@@ -1,4 +1,17 @@
1
1
  import type { Model } from "../types.js";
2
+ /**
3
+ * Raw model output archive.
4
+ *
5
+ * Scoring is a separate, replayable pass over stored transcripts rather than
6
+ * something that only happens live. This exists because a bug in the scorer
7
+ * (a filename matcher that attributed every test block to the wrong file) made
8
+ * seven models look identical, and fixing it cost a full re-run on the GPU. The
9
+ * model outputs had been correct all along - only the grading was wrong.
10
+ *
11
+ * With the transcript on disk, a scorer fix re-grades history in seconds.
12
+ */
13
+ /** Resolve the writable transcript store for a given host environment. */
14
+ declare function resolveArchiveDir(env?: NodeJS.ProcessEnv): string;
2
15
  declare const ARCHIVE_DIR: string;
3
16
  /** One archived request/response exchange. */
4
17
  export interface TranscriptEntry {
@@ -30,5 +43,5 @@ declare function load(id: string): Record<string, TranscriptEntry[]>;
30
43
  declare function list(): string[];
31
44
  /** Total bytes held, so the archive can be pruned knowingly. */
32
45
  declare function size(): number;
33
- export { ARCHIVE_DIR, runId, runDir, put, load, list, size };
46
+ export { ARCHIVE_DIR, resolveArchiveDir, runId, runDir, put, load, list, size };
34
47
  //# sourceMappingURL=archive.d.ts.map
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import crypto from "node:crypto";
4
- import { fileURLToPath } from "node:url";
4
+ import { resolveBrainPaths } from "../config/paths.js";
5
5
  /**
6
6
  * Raw model output archive.
7
7
  *
@@ -13,9 +13,13 @@ import { fileURLToPath } from "node:url";
13
13
  *
14
14
  * With the transcript on disk, a scorer fix re-grades history in seconds.
15
15
  */
16
- const HERE = path.dirname(fileURLToPath(import.meta.url));
17
- const ROOT = path.resolve(HERE, "..", "..");
18
- const ARCHIVE_DIR = path.join(ROOT, "results", "transcripts");
16
+ /** Resolve the writable transcript store for a given host environment. */
17
+ function resolveArchiveDir(env = process.env) {
18
+ return path.join(resolveBrainPaths(env).resultsDir, "transcripts");
19
+ }
20
+ // Raw benchmark exchanges are host state too. They must follow the score store
21
+ // into OTTO_HOME rather than attempting to write beside the installed package.
22
+ const ARCHIVE_DIR = resolveArchiveDir();
19
23
  function runId(model, timestamp = new Date()) {
20
24
  const stamp = timestamp.toISOString().replace(/[:.]/g, "-");
21
25
  const slug = String(model?.displayName || "unknown")
@@ -101,5 +105,5 @@ function size() {
101
105
  }
102
106
  return bytes;
103
107
  }
104
- export { ARCHIVE_DIR, runId, runDir, put, load, list, size };
108
+ export { ARCHIVE_DIR, resolveArchiveDir, runId, runDir, put, load, list, size };
105
109
  //# sourceMappingURL=archive.js.map
@@ -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 {
@@ -368,5 +388,5 @@ declare function variance(records?: RunRecord[]): VarianceRow[];
368
388
  declare function rankModels(records?: RunRecord[]): RankedModel[];
369
389
  /** All task ids seen across a set of records, in a stable order. */
370
390
  declare function taskColumns(records: RunRecord[]): TaskColumn[];
371
- export { RESULTS_DIR, save, loadAll, latestPerConfig, grouped, variance, stats, rankModels, taskColumns, configKey, slugify, ENGINE_SAMPLER_DEFAULTS, };
391
+ export { RESULTS_DIR, resolveResultsDir, save, loadAll, latestPerConfig, grouped, variance, stats, rankModels, taskColumns, configKey, slugify, ENGINE_SAMPLER_DEFAULTS, };
372
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()
@@ -383,5 +388,5 @@ function taskColumns(records) {
383
388
  }
384
389
  return [...seen.entries()].map(([id, category]) => ({ id, category }));
385
390
  }
386
- export { RESULTS_DIR, save, loadAll, latestPerConfig, grouped, variance, stats, rankModels, taskColumns, configKey, slugify, ENGINE_SAMPLER_DEFAULTS, };
391
+ export { RESULTS_DIR, resolveResultsDir, save, loadAll, latestPerConfig, grouped, variance, stats, rankModels, taskColumns, configKey, slugify, ENGINE_SAMPLER_DEFAULTS, };
387
392
  //# sourceMappingURL=results.js.map
@@ -73,6 +73,41 @@ export interface InferenceActivitySnapshot {
73
73
  */
74
74
  slotStages?: Record<string, InferenceStage>;
75
75
  }
76
+ /**
77
+ * A caller's exclusive claim on one tracked request.
78
+ *
79
+ * Handed out by `ReasoningTracker.begin` and released exactly once. Releasing
80
+ * is terminal: every method on a released lease is a no-op, so a chunk that
81
+ * arrives after the client has gone cannot revive the request it names. That is
82
+ * not a theoretical concern - it is the shape of the bug this type exists to
83
+ * make impossible (see `begin`).
84
+ */
85
+ export interface InferenceLease {
86
+ /** This request's id, for logs and for joining to an engine slot. */
87
+ readonly id: string;
88
+ observe(text: string): void;
89
+ setSlot(slotId: number): void;
90
+ /** Idempotent, and safe to call from every branch that can end a stream. */
91
+ end(): void;
92
+ }
93
+ /** What the engine says about itself, for `ReasoningTracker.reconcile`. */
94
+ export interface EngineSlotTruth {
95
+ /**
96
+ * Slot ids llama-server reports as actively processing, or null when this
97
+ * build reports a count but no per-slot rows. Null demotes every request to
98
+ * the conservative unpinned rule.
99
+ */
100
+ busySlots: ReadonlySet<number> | null;
101
+ /** How many slots are busy, or null when the sample failed - which reaps nothing. */
102
+ busyCount: number | null;
103
+ }
104
+ /** One request the reaper cleared, and the evidence worth logging about it. */
105
+ export interface ReapedRequest {
106
+ id: string;
107
+ stage: InferenceStage;
108
+ slotId: number | null;
109
+ ageMs: number;
110
+ }
76
111
  /**
77
112
  * Which in-flight completions are currently mid-thought.
78
113
  *
@@ -97,23 +132,69 @@ export declare class ReasoningTracker {
97
132
  * one stage do not notify; slot sampling owns bounded token-rate updates.
98
133
  */
99
134
  onChange(listener: () => void): () => void;
100
- /** A completion was dispatched to llama-server and awaits its first output delta. */
101
- begin(requestId: string): void;
102
135
  /**
103
- * Record the engine slot this request was pinned to. Called exactly once per
104
- * request, at dispatch - the pin is injected into the outbound body before
105
- * the request goes out, so the association exists before the first chunk and
106
- * `observe` never has to learn about it.
136
+ * Open a lease for a completion that has just been dispatched to
137
+ * llama-server and awaits its first output delta.
138
+ *
139
+ * A lease rather than an id the caller carries around, because every stuck
140
+ * "thinking" this tracker has produced was a release that did not happen on
141
+ * some branch of the proxy's event wiring. A lease makes both halves of that
142
+ * bug unrepresentable: nothing can advance a request without holding its
143
+ * lease, and a released lease is inert, so a chunk that lands after the
144
+ * release cannot resurrect the request it belongs to. (The same shape as
145
+ * `beginActivity` above, for the same reason.)
146
+ *
147
+ * Ids are minted here rather than by the caller: two callers sharing one id
148
+ * would silently share one request's state.
149
+ */
150
+ begin(): InferenceLease;
151
+ /**
152
+ * Forget every slot pin, because the engine's slots did not survive its
153
+ * relaunch.
154
+ *
155
+ * The mirror of `Scheduler.forgetSlots`, and required for the same reason: a
156
+ * pin that outlives the process it named is no longer evidence. Worse than
157
+ * useless, in fact - a stale pin can collide with a NEW request's slot id,
158
+ * and the reaper would read that unrelated busy row as proof the dead request
159
+ * is still alive. Dropping the pins demotes those requests to the
160
+ * conservative unpinned rule, which clears them once the engine is quiet.
161
+ */
162
+ forgetSlots(): void;
163
+ /**
164
+ * Drop tracked requests the engine's own account of itself contradicts.
165
+ *
166
+ * The safety net under the lease, and it exists because `active` outranks
167
+ * every engine signal on the rail: one release that never happened claims
168
+ * "thinking" until the service restarts. The ops tracker already refuses that
169
+ * bargain by probing the recorded pid, on the principle that a status stuck
170
+ * on "calibrating" forever is worse than no status at all. This is the
171
+ * inference half of the same rule.
172
+ *
173
+ * **It must never clear valid work**, so it acts only on positive evidence,
174
+ * and only on evidence a live request could not produce:
175
+ *
176
+ * 1. A request that has sent a chunk (or been pinned, or been dispatched)
177
+ * within `INFERENCE_QUIET_MS` is alive. A streaming request is therefore
178
+ * never a candidate at all, whatever the engine says this instant.
179
+ * 2. A PINNED request is checked against its own slot. llama-server marks a
180
+ * slot processing for the whole task, prefill included, so a request that
181
+ * is genuinely running makes its row busy. That row being idle is the
182
+ * contradiction. This is what lets one chat's leak be cleared while
183
+ * another chat keeps generating.
184
+ * 3. An UNPINNED request - or any request when the engine reports no
185
+ * per-slot rows - cannot be attributed to a row, so it is cleared only
186
+ * when the engine reports nothing running at all. Ambiguity is not
187
+ * evidence.
188
+ * 4. The contradiction has to hold for `INFERENCE_STRIKES` samples in a row.
189
+ * A single sample can race the dispatch window, where a request has been
190
+ * begun and the engine has not picked it up yet; a run of them cannot.
191
+ *
192
+ * A failed slot sample is not evidence either, and reconciles nothing.
107
193
  *
108
- * Idempotent and self-cleaning: a repeat for the same slot is a no-op, and a
109
- * different slot replaces it, so a request that somehow moves slots (a
110
- * restarted engine hands a task out again) reports where it is now.
194
+ * Returns what it reaped, so the caller can log a leak rather than silently
195
+ * paper over it.
111
196
  */
112
- setSlot(requestId: string, slotId: number): void;
113
- /** Note a chunk of `requestId`'s stream. Cheap enough to call per chunk. */
114
- observe(requestId: string, text: string): void;
115
- /** Forget the request. Must be called on end *and* on error, or the flag sticks. */
116
- end(requestId: string): void;
197
+ reconcile(truth: EngineSlotTruth): ReapedRequest[];
117
198
  get active(): boolean;
118
199
  get count(): number;
119
200
  /** Aggregate request stages. Counts stay exact even with several parallel slots. */