@danielblomma/cortex-mcp 2.2.3 → 2.2.5
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/README.md +14 -6
- package/package.json +1 -1
- package/scaffold/mcp/src/embed.ts +59 -16
- package/scaffold/mcp/src/embedScheduler.ts +130 -2
- package/scaffold/mcp/tests/embed-scheduler.test.mjs +101 -0
package/README.md
CHANGED
|
@@ -413,12 +413,20 @@ Required npm configuration:
|
|
|
413
413
|
## Embedding performance
|
|
414
414
|
|
|
415
415
|
Embedding generation tunes itself to the machine: the number of parallel
|
|
416
|
-
workers, memory limits for long files, and skip-work caching are
|
|
417
|
-
from the available CPU cores and
|
|
418
|
-
included). No configuration is needed — on a laptop or
|
|
419
|
-
picks safe
|
|
420
|
-
|
|
421
|
-
|
|
416
|
+
workers, memory limits for long files, token budget, and skip-work caching are
|
|
417
|
+
all derived from the available CPU cores, RAM, and repository size at run time
|
|
418
|
+
(container memory limits included). No configuration is needed — on a laptop or
|
|
419
|
+
a CI runner, cortex picks safe settings by itself.
|
|
420
|
+
|
|
421
|
+
The embedding token budget defaults to `auto`, which is quality-first but
|
|
422
|
+
memory-aware. Cortex starts from the embedding model's own maximum context and
|
|
423
|
+
only lowers the cap when the local memory headroom is unlikely to fit that
|
|
424
|
+
model/context combination. Degraded auto runs are explicit in logs, for example
|
|
425
|
+
`token_budget=auto_degraded reason=memory_headroom cap=2048`. To force a
|
|
426
|
+
specific capped run, set `CORTEX_EMBED_MAX_TOKENS` to a number such as `2048`;
|
|
427
|
+
set it to `model` or `full` to force the full-model baseline even on tight
|
|
428
|
+
machines. When several cortex instances share one machine, set
|
|
429
|
+
`CORTEX_EMBED_THREADS` to give each its fair share of cores.
|
|
422
430
|
|
|
423
431
|
## Limitations
|
|
424
432
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danielblomma/cortex-mcp",
|
|
3
3
|
"mcpName": "io.github.DanielBlomma/cortex",
|
|
4
|
-
"version": "2.2.
|
|
4
|
+
"version": "2.2.5",
|
|
5
5
|
"description": "Local, repo-scoped context platform for coding assistants. Semantic search, graph relationships, and architectural rule context.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"author": "Daniel Blomma",
|
|
@@ -11,11 +11,14 @@ import {
|
|
|
11
11
|
DEFAULT_SCHEDULER_OPTIONS,
|
|
12
12
|
groupDuplicates,
|
|
13
13
|
packWorkUnits,
|
|
14
|
+
resolveEffectiveTokenBudget,
|
|
14
15
|
resolveInFlightTokens,
|
|
15
16
|
resolveMemoryHeadroom,
|
|
16
17
|
resolveModelMaxTokens,
|
|
17
18
|
resolvePoolConfig,
|
|
19
|
+
resolveTokenBudgetChoice,
|
|
18
20
|
runWorkUnits,
|
|
21
|
+
truncateTextToTokenBudget,
|
|
19
22
|
type EmbedExtractor,
|
|
20
23
|
type MeasuredText,
|
|
21
24
|
type PendingText
|
|
@@ -405,6 +408,14 @@ function roundVector(values: number[]): number[] {
|
|
|
405
408
|
return values.map((value) => Number(value.toFixed(6)));
|
|
406
409
|
}
|
|
407
410
|
|
|
411
|
+
function resolveSignatureProfile(maxTokenCap: number | null): string {
|
|
412
|
+
return maxTokenCap ? `embed|max_tokens=${maxTokenCap}` : "";
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function embeddingSignature(entitySignature: string, profile: string): string {
|
|
416
|
+
return profile ? hashText(`${profile}|${entitySignature}`) : entitySignature;
|
|
417
|
+
}
|
|
418
|
+
|
|
408
419
|
function* presentEmbeddingRecords(
|
|
409
420
|
slots: Array<EmbeddingRecord | null>
|
|
410
421
|
): Generator<EmbeddingRecord> {
|
|
@@ -438,8 +449,12 @@ async function main(): Promise<void> {
|
|
|
438
449
|
const chunks = parseChunkEntities(readJsonl(path.join(CACHE_DIR, "entities.chunk.jsonl")), filePathById);
|
|
439
450
|
|
|
440
451
|
const entities: SearchEntity[] = [...documents, ...rules, ...adrs, ...modules, ...projects, ...chunks].sort((a, b) => a.id.localeCompare(b.id));
|
|
441
|
-
|
|
442
|
-
const
|
|
452
|
+
const uniqueSignatures = new Set<string>();
|
|
453
|
+
for (const entity of entities) {
|
|
454
|
+
uniqueSignatures.add(entity.signature);
|
|
455
|
+
}
|
|
456
|
+
const uniqueTextCount = uniqueSignatures.size;
|
|
457
|
+
uniqueSignatures.clear();
|
|
443
458
|
|
|
444
459
|
env.cacheDir = MODEL_CACHE_DIR;
|
|
445
460
|
// Total thread budget for embedding. CORTEX_EMBED_THREADS caps it so
|
|
@@ -448,6 +463,29 @@ async function main(): Promise<void> {
|
|
|
448
463
|
const threadsRaw = Number(process.env.CORTEX_EMBED_THREADS);
|
|
449
464
|
const threadBudget =
|
|
450
465
|
Number.isFinite(threadsRaw) && threadsRaw >= 1 ? Math.floor(threadsRaw) : os.cpus().length;
|
|
466
|
+
const readHeadroom = () =>
|
|
467
|
+
resolveMemoryHeadroom({
|
|
468
|
+
freeMemory: os.freemem(),
|
|
469
|
+
totalMemory: os.totalmem(),
|
|
470
|
+
constrainedMemory: process.constrainedMemory?.() ?? null,
|
|
471
|
+
availableMemory: process.availableMemory?.() ?? null
|
|
472
|
+
});
|
|
473
|
+
const memoryHeadroom = readHeadroom();
|
|
474
|
+
const previewPoolConfig = resolvePoolConfig({
|
|
475
|
+
threadBudget,
|
|
476
|
+
uniqueCount: uniqueTextCount,
|
|
477
|
+
memoryBytes: memoryHeadroom
|
|
478
|
+
});
|
|
479
|
+
const requestedTokenBudget = resolveTokenBudgetChoice(process.env.CORTEX_EMBED_MAX_TOKENS, uniqueTextCount);
|
|
480
|
+
const tokenBudget = resolveEffectiveTokenBudget({
|
|
481
|
+
choice: requestedTokenBudget,
|
|
482
|
+
modelMaxTokens: resolveModelMaxTokens(undefined, requestedTokenBudget.cap ?? undefined),
|
|
483
|
+
memoryBytes: memoryHeadroom,
|
|
484
|
+
sessions: previewPoolConfig.sessions
|
|
485
|
+
});
|
|
486
|
+
const signatureProfile = resolveSignatureProfile(tokenBudget.cap);
|
|
487
|
+
|
|
488
|
+
const existing = parseExistingEmbeddings(readJsonlRecords(EMBEDDINGS_PATH), modelId);
|
|
451
489
|
|
|
452
490
|
let reused = 0;
|
|
453
491
|
// Slot per entity keeps output in entity order; failed slots stay null.
|
|
@@ -456,8 +494,9 @@ async function main(): Promise<void> {
|
|
|
456
494
|
let dimensions = 0;
|
|
457
495
|
|
|
458
496
|
entities.forEach((entity, index) => {
|
|
497
|
+
const signature = embeddingSignature(entity.signature, signatureProfile);
|
|
459
498
|
const previous = existing.get(entity.id);
|
|
460
|
-
if (previous && previous.signature ===
|
|
499
|
+
if (previous && previous.signature === signature && previous.vector.length > 0) {
|
|
461
500
|
reused += 1;
|
|
462
501
|
dimensions = dimensions || previous.vector.length;
|
|
463
502
|
slots[index] = {
|
|
@@ -470,7 +509,7 @@ async function main(): Promise<void> {
|
|
|
470
509
|
source_of_truth: entity.source_of_truth,
|
|
471
510
|
trust_level: entity.trust_level,
|
|
472
511
|
updated_at: entity.updated_at,
|
|
473
|
-
signature
|
|
512
|
+
signature,
|
|
474
513
|
model: modelId,
|
|
475
514
|
dimensions: previous.vector.length
|
|
476
515
|
};
|
|
@@ -486,14 +525,6 @@ async function main(): Promise<void> {
|
|
|
486
525
|
// adapts to the machine so no tuning is expected from users. Container
|
|
487
526
|
// limits (cgroups) and platforms that under-report free memory are both
|
|
488
527
|
// handled inside resolveMemoryHeadroom.
|
|
489
|
-
const readHeadroom = () =>
|
|
490
|
-
resolveMemoryHeadroom({
|
|
491
|
-
freeMemory: os.freemem(),
|
|
492
|
-
totalMemory: os.totalmem(),
|
|
493
|
-
constrainedMemory: process.constrainedMemory?.() ?? null,
|
|
494
|
-
availableMemory: process.availableMemory?.() ?? null
|
|
495
|
-
});
|
|
496
|
-
const memoryHeadroom = readHeadroom();
|
|
497
528
|
const poolConfig = resolvePoolConfig({
|
|
498
529
|
threadBudget,
|
|
499
530
|
poolOverride: Number(process.env.CORTEX_EMBED_POOL) || null,
|
|
@@ -522,6 +553,7 @@ async function main(): Promise<void> {
|
|
|
522
553
|
// Fully warm cache: nothing to embed, so skip model loading entirely —
|
|
523
554
|
// this is the common repeat-bootstrap / small-update path.
|
|
524
555
|
let embedded = 0;
|
|
556
|
+
let modelMaxTokensUsed = 0;
|
|
525
557
|
let result: { vectors: Map<number, number[]>; failures: Array<{ index: number; message: string }> } = {
|
|
526
558
|
vectors: new Map(),
|
|
527
559
|
failures: []
|
|
@@ -569,11 +601,22 @@ async function main(): Promise<void> {
|
|
|
569
601
|
|
|
570
602
|
// Inference truncates at the model max; token counts must too, or one
|
|
571
603
|
// giant file inflates scheduling cost and gate mass far beyond reality.
|
|
572
|
-
const modelMaxTokens = resolveModelMaxTokens(
|
|
604
|
+
const modelMaxTokens = resolveModelMaxTokens(
|
|
605
|
+
first.tokenizer?.model_max_length,
|
|
606
|
+
tokenBudget.cap ?? undefined
|
|
607
|
+
);
|
|
608
|
+
modelMaxTokensUsed = modelMaxTokens;
|
|
573
609
|
const countTokens = createTokenCounter(first.tokenizer, modelMaxTokens);
|
|
610
|
+
const countRawTokens = createTokenCounter(first.tokenizer, 131072);
|
|
574
611
|
|
|
575
|
-
const measured: MeasuredText[] = unique.map((item) =>
|
|
612
|
+
const measured: MeasuredText[] = unique.map((item) => {
|
|
613
|
+
const text = truncateTextToTokenBudget(item.text, countRawTokens, modelMaxTokens);
|
|
614
|
+
return { ...item, text, tokens: countTokens(text) };
|
|
615
|
+
});
|
|
576
616
|
const units = packWorkUnits(measured, schedulerOptions);
|
|
617
|
+
console.log(
|
|
618
|
+
`[embed] scheduler unique=${unique.length} units=${units.length} max_tokens<=${modelMaxTokens} token_budget=${tokenBudget.mode} reason=${tokenBudget.reason}`
|
|
619
|
+
);
|
|
577
620
|
// Recompute headroom after the model copies are resident so the gate
|
|
578
621
|
// reflects what is actually left for inference activations.
|
|
579
622
|
const inFlightRaw = Number(process.env.CORTEX_EMBED_INFLIGHT_TOKENS);
|
|
@@ -598,7 +641,7 @@ async function main(): Promise<void> {
|
|
|
598
641
|
source_of_truth: entity.source_of_truth,
|
|
599
642
|
trust_level: entity.trust_level,
|
|
600
643
|
updated_at: entity.updated_at,
|
|
601
|
-
signature: entity.signature,
|
|
644
|
+
signature: embeddingSignature(entity.signature, signatureProfile),
|
|
602
645
|
model: modelId,
|
|
603
646
|
dimensions: vector.length,
|
|
604
647
|
vector
|
|
@@ -632,7 +675,7 @@ async function main(): Promise<void> {
|
|
|
632
675
|
fs.writeFileSync(EMBEDDINGS_MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
633
676
|
|
|
634
677
|
console.log(
|
|
635
|
-
`[embed] mode=${mode} model=${modelId} dim=${dimensions} pool=${poolConfig.sessions}x${poolConfig.threadsPerSession} batch<=${schedulerOptions.batchMaxItems}`
|
|
678
|
+
`[embed] mode=${mode} model=${modelId} dim=${dimensions} pool=${poolConfig.sessions}x${poolConfig.threadsPerSession} batch<=${schedulerOptions.batchMaxItems} max_tokens<=${modelMaxTokensUsed || tokenBudget.cap || "model"} token_budget=${tokenBudget.mode} reason=${tokenBudget.reason}`
|
|
636
679
|
);
|
|
637
680
|
console.log(
|
|
638
681
|
`[embed] entities=${entities.length} embedded=${embedded} reused=${reused} failed=${failed}`
|
|
@@ -183,9 +183,114 @@ export function resolveMemoryHeadroom({
|
|
|
183
183
|
/** Sanitizes a tokenizer-reported maximum sequence length. Sentinel and
|
|
184
184
|
* absurd values (some configs report 1e30) fall back to 8192; anything
|
|
185
185
|
* beyond 128k tokens is treated as absurd. */
|
|
186
|
-
export function resolveModelMaxTokens(raw: unknown): number {
|
|
186
|
+
export function resolveModelMaxTokens(raw: unknown, overrideRaw?: unknown): number {
|
|
187
187
|
const value = Number(raw);
|
|
188
|
-
|
|
188
|
+
const modelMax = Number.isFinite(value) && value >= 1 && value <= 131072 ? Math.floor(value) : 8192;
|
|
189
|
+
const override = Number(overrideRaw);
|
|
190
|
+
if (Number.isFinite(override) && override >= 16 && override <= 131072) {
|
|
191
|
+
return Math.min(modelMax, Math.floor(override));
|
|
192
|
+
}
|
|
193
|
+
return modelMax;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export type TokenBudgetChoice = {
|
|
197
|
+
cap: number | null;
|
|
198
|
+
mode: "auto" | "auto_degraded" | "explicit" | "model";
|
|
199
|
+
reason: string;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Chooses the requested embedding token budget before model load, so cache
|
|
204
|
+
* signatures can include the same cap that inference will later enforce.
|
|
205
|
+
* Auto mode is quality-preserving: it uses the model's own maximum by default
|
|
206
|
+
* and only truncates when the user explicitly requests a numeric cap.
|
|
207
|
+
*/
|
|
208
|
+
export function resolveTokenBudgetChoice(overrideRaw: unknown, uniqueCount: number): TokenBudgetChoice {
|
|
209
|
+
const raw = String(overrideRaw ?? "").trim().toLowerCase();
|
|
210
|
+
const override = Number(raw);
|
|
211
|
+
if (Number.isFinite(override) && override >= 16 && override <= 131072) {
|
|
212
|
+
const cap = Math.floor(override);
|
|
213
|
+
return { cap, mode: "explicit", reason: "env_override" };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (raw === "model" || raw === "none" || raw === "off" || raw === "full") {
|
|
217
|
+
return { cap: null, mode: "model", reason: "env_full_model" };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const count = Number.isFinite(uniqueCount) && uniqueCount > 0 ? Math.floor(uniqueCount) : 0;
|
|
221
|
+
return {
|
|
222
|
+
cap: null,
|
|
223
|
+
mode: "auto",
|
|
224
|
+
reason: `quality_preserving_model_max:unique=${count}`
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const AUTO_TOKEN_BUDGET_CANDIDATES = [8192, 4096, 2048];
|
|
229
|
+
const AUTO_TOKEN_BUDGET_MIN = 2048;
|
|
230
|
+
const AUTO_TOKEN_BUDGET_SESSION_BYTES = 2.4e9;
|
|
231
|
+
const AUTO_TOKEN_BUDGET_ACTIVATION_BYTES_AT_4096 = 3e9;
|
|
232
|
+
const AUTO_TOKEN_BUDGET_MEMORY_MARGIN = 0.95;
|
|
233
|
+
|
|
234
|
+
function estimateTokenBudgetMemoryBytes(maxTokens: number, sessions: number): number {
|
|
235
|
+
const sessionCount = Number.isFinite(sessions) && sessions >= 1 ? Math.floor(sessions) : 1;
|
|
236
|
+
const tokenCount = Number.isFinite(maxTokens) && maxTokens >= 1 ? Math.floor(maxTokens) : 8192;
|
|
237
|
+
const activationBytes =
|
|
238
|
+
AUTO_TOKEN_BUDGET_ACTIVATION_BYTES_AT_4096 * Math.pow(tokenCount / 4096, 2);
|
|
239
|
+
return sessionCount * AUTO_TOKEN_BUDGET_SESSION_BYTES + activationBytes;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Keeps auto quality-first but avoids choosing a token budget that is likely
|
|
244
|
+
* to OOM this process. Explicit numeric caps and explicit full-model modes are
|
|
245
|
+
* operator choices and are never degraded here.
|
|
246
|
+
*/
|
|
247
|
+
export function resolveEffectiveTokenBudget({
|
|
248
|
+
choice,
|
|
249
|
+
modelMaxTokens,
|
|
250
|
+
memoryBytes,
|
|
251
|
+
sessions
|
|
252
|
+
}: {
|
|
253
|
+
choice: TokenBudgetChoice;
|
|
254
|
+
modelMaxTokens: number;
|
|
255
|
+
memoryBytes?: number;
|
|
256
|
+
sessions?: number;
|
|
257
|
+
}): TokenBudgetChoice {
|
|
258
|
+
if (choice.mode !== "auto" || choice.cap !== null) {
|
|
259
|
+
return choice;
|
|
260
|
+
}
|
|
261
|
+
const modelMax =
|
|
262
|
+
Number.isFinite(modelMaxTokens) && modelMaxTokens >= 1 ? Math.floor(modelMaxTokens) : 8192;
|
|
263
|
+
if (modelMax <= AUTO_TOKEN_BUDGET_MIN) {
|
|
264
|
+
return choice;
|
|
265
|
+
}
|
|
266
|
+
if (!Number.isFinite(memoryBytes) || (memoryBytes as number) <= 0) {
|
|
267
|
+
return choice;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const available = (memoryBytes as number) * AUTO_TOKEN_BUDGET_MEMORY_MARGIN;
|
|
271
|
+
const candidates = AUTO_TOKEN_BUDGET_CANDIDATES
|
|
272
|
+
.filter((candidate) => candidate <= modelMax)
|
|
273
|
+
.sort((a, b) => b - a);
|
|
274
|
+
if (!candidates.includes(AUTO_TOKEN_BUDGET_MIN)) {
|
|
275
|
+
candidates.push(AUTO_TOKEN_BUDGET_MIN);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const selected =
|
|
279
|
+
candidates.find((candidate) => estimateTokenBudgetMemoryBytes(candidate, sessions ?? 1) <= available) ??
|
|
280
|
+
AUTO_TOKEN_BUDGET_MIN;
|
|
281
|
+
|
|
282
|
+
if (selected >= modelMax) {
|
|
283
|
+
return choice;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
cap: selected,
|
|
288
|
+
mode: "auto_degraded",
|
|
289
|
+
reason: `memory_headroom cap=${selected} model_max=${modelMax} sessions=${Math.max(
|
|
290
|
+
1,
|
|
291
|
+
Math.floor(sessions ?? 1)
|
|
292
|
+
)} headroom_mb=${Math.round((memoryBytes as number) / 1024 / 1024)}`
|
|
293
|
+
};
|
|
189
294
|
}
|
|
190
295
|
|
|
191
296
|
/**
|
|
@@ -216,6 +321,29 @@ export function createTokenCounter(
|
|
|
216
321
|
};
|
|
217
322
|
}
|
|
218
323
|
|
|
324
|
+
export function truncateTextToTokenBudget(
|
|
325
|
+
text: string,
|
|
326
|
+
countTokens: (text: string) => number,
|
|
327
|
+
maxTokens: number
|
|
328
|
+
): string {
|
|
329
|
+
if (!Number.isFinite(maxTokens) || maxTokens < 1 || countTokens(text) <= maxTokens) {
|
|
330
|
+
return text;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
let low = 0;
|
|
334
|
+
let high = text.length;
|
|
335
|
+
while (low < high) {
|
|
336
|
+
const mid = Math.ceil((low + high) / 2);
|
|
337
|
+
if (countTokens(text.slice(0, mid)) <= maxTokens) {
|
|
338
|
+
low = mid;
|
|
339
|
+
} else {
|
|
340
|
+
high = mid - 1;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return text.slice(0, low).trimEnd();
|
|
345
|
+
}
|
|
346
|
+
|
|
219
347
|
/** Estimated working memory one full pool session needs (model copy plus
|
|
220
348
|
* inference activations for a base-size model). Used only as a derating
|
|
221
349
|
* heuristic; deliberately conservative. */
|
|
@@ -448,6 +448,100 @@ test("resolveModelMaxTokens: sane values pass, sentinels and absurdities fall ba
|
|
|
448
448
|
assert.equal(resolveModelMaxTokens(0), 8192);
|
|
449
449
|
});
|
|
450
450
|
|
|
451
|
+
test("resolveModelMaxTokens: explicit override caps but never raises model max", async () => {
|
|
452
|
+
const { resolveModelMaxTokens } = await import("../dist/embedScheduler.js");
|
|
453
|
+
assert.equal(resolveModelMaxTokens(8192, 2048), 2048);
|
|
454
|
+
assert.equal(resolveModelMaxTokens(512, 2048), 512);
|
|
455
|
+
assert.equal(resolveModelMaxTokens(8192, "1024"), 1024);
|
|
456
|
+
assert.equal(resolveModelMaxTokens(8192, 4), 8192);
|
|
457
|
+
assert.equal(resolveModelMaxTokens(8192, "nope"), 8192);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("resolveTokenBudgetChoice: auto preserves model max regardless of repo size", async () => {
|
|
461
|
+
const { resolveTokenBudgetChoice } = await import("../dist/embedScheduler.js");
|
|
462
|
+
assert.deepEqual(resolveTokenBudgetChoice(undefined, 180), {
|
|
463
|
+
cap: null,
|
|
464
|
+
mode: "auto",
|
|
465
|
+
reason: "quality_preserving_model_max:unique=180"
|
|
466
|
+
});
|
|
467
|
+
assert.deepEqual(resolveTokenBudgetChoice("auto", 20000), {
|
|
468
|
+
cap: null,
|
|
469
|
+
mode: "auto",
|
|
470
|
+
reason: "quality_preserving_model_max:unique=20000"
|
|
471
|
+
});
|
|
472
|
+
assert.deepEqual(resolveTokenBudgetChoice(undefined, 48533), {
|
|
473
|
+
cap: null,
|
|
474
|
+
mode: "auto",
|
|
475
|
+
reason: "quality_preserving_model_max:unique=48533"
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test("resolveTokenBudgetChoice: explicit caps and full-model opt-out win over auto", async () => {
|
|
480
|
+
const { resolveTokenBudgetChoice } = await import("../dist/embedScheduler.js");
|
|
481
|
+
assert.deepEqual(resolveTokenBudgetChoice("4096", 48533), {
|
|
482
|
+
cap: 4096,
|
|
483
|
+
mode: "explicit",
|
|
484
|
+
reason: "env_override"
|
|
485
|
+
});
|
|
486
|
+
assert.deepEqual(resolveTokenBudgetChoice("model", 48533), {
|
|
487
|
+
cap: null,
|
|
488
|
+
mode: "model",
|
|
489
|
+
reason: "env_full_model"
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("resolveEffectiveTokenBudget: auto degrades when model max is too expensive for headroom", async () => {
|
|
494
|
+
const { resolveEffectiveTokenBudget, resolveTokenBudgetChoice } = await import("../dist/embedScheduler.js");
|
|
495
|
+
const choice = resolveTokenBudgetChoice(undefined, 2237);
|
|
496
|
+
const degraded = resolveEffectiveTokenBudget({
|
|
497
|
+
choice,
|
|
498
|
+
modelMaxTokens: 8192,
|
|
499
|
+
memoryBytes: 7e9,
|
|
500
|
+
sessions: 2
|
|
501
|
+
});
|
|
502
|
+
assert.equal(degraded.mode, "auto_degraded");
|
|
503
|
+
assert.equal(degraded.cap, 2048);
|
|
504
|
+
assert.match(degraded.reason, /memory_headroom/);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("resolveEffectiveTokenBudget: auto keeps model max when headroom is sufficient", async () => {
|
|
508
|
+
const { resolveEffectiveTokenBudget, resolveTokenBudgetChoice } = await import("../dist/embedScheduler.js");
|
|
509
|
+
const choice = resolveTokenBudgetChoice("auto", 2237);
|
|
510
|
+
assert.deepEqual(
|
|
511
|
+
resolveEffectiveTokenBudget({
|
|
512
|
+
choice,
|
|
513
|
+
modelMaxTokens: 8192,
|
|
514
|
+
memoryBytes: 64e9,
|
|
515
|
+
sessions: 2
|
|
516
|
+
}),
|
|
517
|
+
choice
|
|
518
|
+
);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test("resolveEffectiveTokenBudget: explicit caps and full-model opt-out are never degraded", async () => {
|
|
522
|
+
const { resolveEffectiveTokenBudget, resolveTokenBudgetChoice } = await import("../dist/embedScheduler.js");
|
|
523
|
+
const explicit = resolveTokenBudgetChoice("4096", 2237);
|
|
524
|
+
assert.deepEqual(
|
|
525
|
+
resolveEffectiveTokenBudget({
|
|
526
|
+
choice: explicit,
|
|
527
|
+
modelMaxTokens: 8192,
|
|
528
|
+
memoryBytes: 1e9,
|
|
529
|
+
sessions: 4
|
|
530
|
+
}),
|
|
531
|
+
explicit
|
|
532
|
+
);
|
|
533
|
+
const full = resolveTokenBudgetChoice("full", 2237);
|
|
534
|
+
assert.deepEqual(
|
|
535
|
+
resolveEffectiveTokenBudget({
|
|
536
|
+
choice: full,
|
|
537
|
+
modelMaxTokens: 8192,
|
|
538
|
+
memoryBytes: 1e9,
|
|
539
|
+
sessions: 4
|
|
540
|
+
}),
|
|
541
|
+
full
|
|
542
|
+
);
|
|
543
|
+
});
|
|
544
|
+
|
|
451
545
|
test("createTokenCounter: uses the tokenizer, clamps at model max, survives failures", async () => {
|
|
452
546
|
const { createTokenCounter } = await import("../dist/embedScheduler.js");
|
|
453
547
|
const tokenizer = (text) => ({ input_ids: { dims: [1, text.length * 2] } });
|
|
@@ -462,6 +556,13 @@ test("createTokenCounter: uses the tokenizer, clamps at model max, survives fail
|
|
|
462
556
|
assert.equal(missing("x".repeat(40)), 10);
|
|
463
557
|
});
|
|
464
558
|
|
|
559
|
+
test("truncateTextToTokenBudget: trims a prefix to the requested token cap", async () => {
|
|
560
|
+
const { truncateTextToTokenBudget } = await import("../dist/embedScheduler.js");
|
|
561
|
+
const countTokens = (text) => text.length;
|
|
562
|
+
assert.equal(truncateTextToTokenBudget("abcdef", countTokens, 3), "abc");
|
|
563
|
+
assert.equal(truncateTextToTokenBudget("abc", countTokens, 3), "abc");
|
|
564
|
+
});
|
|
565
|
+
|
|
465
566
|
test("resolveMemoryHeadroom: constrainedMemory 0 means unconstrained (Node sentinel)", async () => {
|
|
466
567
|
const { resolveMemoryHeadroom } = await import("../dist/embedScheduler.js");
|
|
467
568
|
const unconstrained = resolveMemoryHeadroom({ freeMemory: 50e9, totalMemory: 64e9 });
|