@danielblomma/cortex-mcp 2.1.1 → 2.2.0
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 +72 -24
- package/bin/cortex.mjs +50 -21
- package/mcp-registry-submission.json +63 -0
- package/package.json +19 -5
- package/scaffold/AGENTS.md +13 -9
- package/scaffold/CLAUDE.md +13 -9
- package/scaffold/docs/architecture.md +1 -1
- package/scaffold/mcp/build.mjs +92 -0
- package/scaffold/mcp/package.json +4 -4
- package/scaffold/mcp/src/cli/query.ts +455 -0
- package/scaffold/mcp/src/embed.ts +181 -77
- package/scaffold/mcp/src/embedScheduler.ts +509 -0
- package/scaffold/mcp/src/embeddings.ts +31 -30
- package/scaffold/mcp/src/graphCsv.ts +65 -0
- package/scaffold/mcp/src/jsonl.ts +69 -14
- package/scaffold/mcp/src/loadGraph.ts +276 -40
- package/scaffold/mcp/src/lruCache.ts +41 -0
- package/scaffold/mcp/src/searchCore.ts +1 -1
- package/scaffold/mcp/src/searchResults.ts +3 -3
- package/scaffold/mcp/src/types.ts +6 -1
- package/scaffold/mcp/tests/embed-scheduler.test.mjs +474 -0
- package/scaffold/mcp/tests/embedding-jsonl.test.mjs +39 -0
- package/scaffold/mcp/tests/graph-bulk-load.test.mjs +258 -0
- package/scaffold/mcp/tests/graph-csv.test.mjs +118 -0
- package/scaffold/mcp/tests/lru-cache.test.mjs +37 -0
- package/scaffold/mcp/tests/query-cli.test.mjs +115 -0
- package/scaffold/mcp/tests/vector-index.test.mjs +109 -0
- package/scaffold/mcp/tsconfig.json +3 -1
- package/scaffold/scripts/bootstrap.sh +37 -4
- package/scaffold/scripts/dashboard.mjs +1 -1
- package/scaffold/scripts/doctor.sh +9 -8
- package/scaffold/scripts/embed.sh +3 -2
- package/scaffold/scripts/ingest-parsers.mjs +387 -0
- package/scaffold/scripts/ingest-worker.mjs +52 -0
- package/scaffold/scripts/ingest.mjs +550 -378
- package/scaffold/scripts/load-ryu.sh +3 -2
- package/scaffold/scripts/memory-compile.mjs +5 -2
- package/scaffold/scripts/memory-lint.mjs +5 -2
- package/scaffold/scripts/parsers/csharp.mjs +2 -1
- package/scaffold/scripts/parsers/javascript/ast.mjs +160 -8
- package/scaffold/scripts/parsers/javascript/chunks.mjs +129 -22
- package/scaffold/scripts/parsers/javascript/imports.mjs +38 -4
- package/scaffold/scripts/parsers/vbnet.mjs +2 -1
- package/scaffold/scripts/status.sh +50 -14
- package/scaffold/scripts/parsers/node_modules/.package-lock.json +0 -56
- package/scaffold/scripts/parsers/node_modules/acorn/CHANGELOG.md +0 -972
- package/scaffold/scripts/parsers/node_modules/acorn/LICENSE +0 -21
- package/scaffold/scripts/parsers/node_modules/acorn/README.md +0 -301
- package/scaffold/scripts/parsers/node_modules/acorn/bin/acorn +0 -4
- package/scaffold/scripts/parsers/node_modules/acorn/dist/acorn.d.mts +0 -883
- package/scaffold/scripts/parsers/node_modules/acorn/dist/acorn.d.ts +0 -883
- package/scaffold/scripts/parsers/node_modules/acorn/dist/acorn.js +0 -6295
- package/scaffold/scripts/parsers/node_modules/acorn/dist/acorn.mjs +0 -6266
- package/scaffold/scripts/parsers/node_modules/acorn/dist/bin.js +0 -90
- package/scaffold/scripts/parsers/node_modules/acorn/package.json +0 -50
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/CHANGELOG.md +0 -421
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/LICENSE +0 -21
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/README.md +0 -81
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/error.d.ts +0 -103
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/error.js +0 -78
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/error.js.map +0 -1
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/decorators.d.ts +0 -167
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/decorators.js +0 -75
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/decorators.js.map +0 -1
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/import-assertions.d.ts +0 -177
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/import-assertions.js +0 -56
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/import-assertions.js.map +0 -1
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/jsx/index.d.ts +0 -198
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/jsx/index.js +0 -327
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/jsx/index.js.map +0 -1
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/jsx/xhtml.d.ts +0 -256
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/jsx/xhtml.js +0 -256
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/extentions/jsx/xhtml.js.map +0 -1
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/index.d.ts +0 -472
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/index.js +0 -1
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/index.js.map +0 -1
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/index.mjs +0 -1
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/middleware.d.ts +0 -159
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/middleware.js +0 -2
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/middleware.js.map +0 -1
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/parseutil.d.ts +0 -10
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/parseutil.js +0 -38
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/parseutil.js.map +0 -1
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/scopeflags.d.ts +0 -12
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/scopeflags.js +0 -29
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/scopeflags.js.map +0 -1
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/tokenType.d.ts +0 -2
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/tokenType.js +0 -118
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/tokenType.js.map +0 -1
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/types.d.ts +0 -60
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/types.js +0 -2
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/types.js.map +0 -1
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/whitespace.d.ts +0 -2
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/whitespace.js +0 -19
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/lib/whitespace.js.map +0 -1
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/package.json +0 -53
- package/scaffold/scripts/parsers/node_modules/acorn-typescript/tsconfig.json +0 -19
- package/scaffold/scripts/parsers/node_modules/acorn-walk/CHANGELOG.md +0 -209
- package/scaffold/scripts/parsers/node_modules/acorn-walk/LICENSE +0 -21
- package/scaffold/scripts/parsers/node_modules/acorn-walk/README.md +0 -124
- package/scaffold/scripts/parsers/node_modules/acorn-walk/dist/walk.d.mts +0 -152
- package/scaffold/scripts/parsers/node_modules/acorn-walk/dist/walk.d.ts +0 -152
- package/scaffold/scripts/parsers/node_modules/acorn-walk/dist/walk.js +0 -485
- package/scaffold/scripts/parsers/node_modules/acorn-walk/dist/walk.mjs +0 -467
- package/scaffold/scripts/parsers/node_modules/acorn-walk/package.json +0 -50
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/LICENSE +0 -24
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/README.md +0 -23
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-bash.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-c.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-c_sharp.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-cpp.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-css.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-dart.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-elisp.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-elixir.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-elm.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-embedded_template.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-go.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-html.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-java.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-javascript.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-json.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-kotlin.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-lua.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-objc.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-ocaml.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-php.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-python.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-ql.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-rescript.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-ruby.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-rust.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-scala.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-solidity.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-swift.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-systemrdl.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-tlaplus.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-toml.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-tsx.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-typescript.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-vue.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-yaml.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/out/tree-sitter-zig.wasm +0 -0
- package/scaffold/scripts/parsers/node_modules/tree-sitter-wasms/package.json +0 -64
- package/scaffold/scripts/parsers/node_modules/web-tree-sitter/LICENSE +0 -21
- package/scaffold/scripts/parsers/node_modules/web-tree-sitter/README.md +0 -198
- package/scaffold/scripts/parsers/node_modules/web-tree-sitter/package.json +0 -37
- package/scaffold/scripts/parsers/node_modules/web-tree-sitter/tree-sitter-web.d.ts +0 -242
- package/scaffold/scripts/parsers/node_modules/web-tree-sitter/tree-sitter.js +0 -1
- package/scaffold/scripts/parsers/node_modules/web-tree-sitter/tree-sitter.wasm +0 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_SCHEDULER_OPTIONS,
|
|
6
|
+
groupDuplicates,
|
|
7
|
+
packWorkUnits,
|
|
8
|
+
resolvePoolConfig,
|
|
9
|
+
runWorkUnits,
|
|
10
|
+
sliceBatchVectors
|
|
11
|
+
} from "../dist/embedScheduler.js";
|
|
12
|
+
|
|
13
|
+
function measured(text, tokens, indexes) {
|
|
14
|
+
return { text, tokens, indexes };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function fakeTensor(rows) {
|
|
18
|
+
return { dims: [rows.length, rows[0]?.length ?? 0], data: Float32Array.from(rows.flat()) };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ─── dedup ───────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
test("groupDuplicates: identical texts share one unique entry with all indexes", () => {
|
|
24
|
+
const unique = groupDuplicates([
|
|
25
|
+
{ index: 0, text: "same" },
|
|
26
|
+
{ index: 3, text: "other" },
|
|
27
|
+
{ index: 7, text: "same" }
|
|
28
|
+
]);
|
|
29
|
+
assert.equal(unique.length, 2);
|
|
30
|
+
const same = unique.find((u) => u.text === "same");
|
|
31
|
+
assert.deepEqual(same.indexes, [0, 7]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// ─── packing ─────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
test("packWorkUnits: long texts become singles, shorts are batched when enabled", () => {
|
|
37
|
+
const units = packWorkUnits(
|
|
38
|
+
[
|
|
39
|
+
measured("long", 4000, [0]),
|
|
40
|
+
measured("s1", 20, [1]),
|
|
41
|
+
measured("s2", 21, [2]),
|
|
42
|
+
measured("s3", 22, [3])
|
|
43
|
+
],
|
|
44
|
+
{ ...DEFAULT_SCHEDULER_OPTIONS, batchMaxItems: 32 }
|
|
45
|
+
);
|
|
46
|
+
const single = units.find((u) => u.texts.includes("long"));
|
|
47
|
+
assert.equal(single.kind, "single");
|
|
48
|
+
const batch = units.find((u) => u.kind === "batch");
|
|
49
|
+
assert.deepEqual(batch.texts.sort(), ["s1", "s2", "s3"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("packWorkUnits: respects the pad-waste bound", () => {
|
|
53
|
+
// 10-token and 100-token texts in one batch would waste 45% padding.
|
|
54
|
+
const units = packWorkUnits(
|
|
55
|
+
[measured("tiny", 10, [0]), measured("bigger", 100, [1])],
|
|
56
|
+
{ ...DEFAULT_SCHEDULER_OPTIONS, shortMaxTokens: 128, batchMaxItems: 32 }
|
|
57
|
+
);
|
|
58
|
+
assert.equal(units.length, 2);
|
|
59
|
+
assert.ok(units.every((u) => u.texts.length === 1));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("packWorkUnits: respects token budget and max items", () => {
|
|
63
|
+
const many = Array.from({ length: 100 }, (_, i) => measured(`t${i}`, 64, [i]));
|
|
64
|
+
const units = packWorkUnits(many, {
|
|
65
|
+
...DEFAULT_SCHEDULER_OPTIONS,
|
|
66
|
+
batchTokenBudget: 64 * 8,
|
|
67
|
+
batchMaxItems: 8
|
|
68
|
+
});
|
|
69
|
+
assert.ok(units.every((u) => u.texts.length <= 8));
|
|
70
|
+
assert.ok(units.every((u) => u.texts.length * u.maxTokens <= 64 * 8));
|
|
71
|
+
const total = units.reduce((acc, u) => acc + u.texts.length, 0);
|
|
72
|
+
assert.equal(total, 100);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("packWorkUnits: default options produce no batches (byte-identity default)", () => {
|
|
76
|
+
const units = packWorkUnits(
|
|
77
|
+
[measured("a", 10, [0]), measured("b", 11, [1]), measured("c", 12, [2])],
|
|
78
|
+
DEFAULT_SCHEDULER_OPTIONS
|
|
79
|
+
);
|
|
80
|
+
assert.ok(units.every((u) => u.kind === "single"));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("packWorkUnits: batchMaxItems=1 disables batching entirely", () => {
|
|
84
|
+
const units = packWorkUnits(
|
|
85
|
+
[measured("a", 10, [0]), measured("b", 11, [1])],
|
|
86
|
+
{ ...DEFAULT_SCHEDULER_OPTIONS, batchMaxItems: 1 }
|
|
87
|
+
);
|
|
88
|
+
assert.ok(units.every((u) => u.kind === "single"));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("packWorkUnits: orders units by descending cost", () => {
|
|
92
|
+
const units = packWorkUnits(
|
|
93
|
+
[measured("small", 10, [0]), measured("huge", 5000, [1]), measured("mid", 500, [2])],
|
|
94
|
+
DEFAULT_SCHEDULER_OPTIONS
|
|
95
|
+
);
|
|
96
|
+
const costs = units.map((u) => u.cost);
|
|
97
|
+
assert.deepEqual(costs, [...costs].sort((a, b) => b - a));
|
|
98
|
+
assert.equal(units[0].texts[0], "huge");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ─── pool config ─────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
test("resolvePoolConfig: splits the budget into 4-thread sessions", () => {
|
|
104
|
+
assert.deepEqual(resolvePoolConfig({ threadBudget: 12, uniqueCount: 1000 }), {
|
|
105
|
+
sessions: 3,
|
|
106
|
+
threadsPerSession: 4
|
|
107
|
+
});
|
|
108
|
+
assert.deepEqual(resolvePoolConfig({ threadBudget: 4, uniqueCount: 1000 }), {
|
|
109
|
+
sessions: 1,
|
|
110
|
+
threadsPerSession: 4
|
|
111
|
+
});
|
|
112
|
+
assert.deepEqual(resolvePoolConfig({ threadBudget: 2, uniqueCount: 1000 }), {
|
|
113
|
+
sessions: 1,
|
|
114
|
+
threadsPerSession: 2
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("resolvePoolConfig: tiny workloads skip the pool", () => {
|
|
119
|
+
assert.deepEqual(resolvePoolConfig({ threadBudget: 12, uniqueCount: 10 }), {
|
|
120
|
+
sessions: 1,
|
|
121
|
+
threadsPerSession: 12
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("resolvePoolConfig: explicit override wins", () => {
|
|
126
|
+
assert.deepEqual(resolvePoolConfig({ threadBudget: 12, poolOverride: 2, uniqueCount: 10 }), {
|
|
127
|
+
sessions: 2,
|
|
128
|
+
threadsPerSession: 6
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ─── execution ───────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
test("runWorkUnits: assigns vectors to every duplicate index", async () => {
|
|
135
|
+
const extractor = async (texts) => {
|
|
136
|
+
const list = Array.isArray(texts) ? texts : [texts];
|
|
137
|
+
return fakeTensor(list.map((t) => [t.length, 1]));
|
|
138
|
+
};
|
|
139
|
+
const units = packWorkUnits(
|
|
140
|
+
[measured("aaa", 3, [0, 5]), measured("bb", 2, [1])],
|
|
141
|
+
{ ...DEFAULT_SCHEDULER_OPTIONS, batchMaxItems: 32 }
|
|
142
|
+
);
|
|
143
|
+
const result = await runWorkUnits(units, [extractor]);
|
|
144
|
+
assert.equal(result.failures.length, 0);
|
|
145
|
+
assert.deepEqual(result.vectors.get(0), [3, 1]);
|
|
146
|
+
assert.deepEqual(result.vectors.get(5), [3, 1]);
|
|
147
|
+
assert.deepEqual(result.vectors.get(1), [2, 1]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("runWorkUnits: onVector consumes vectors without retaining them", async () => {
|
|
151
|
+
const extractor = async (texts) => {
|
|
152
|
+
const list = Array.isArray(texts) ? texts : [texts];
|
|
153
|
+
return fakeTensor(list.map((t) => [t.length, 1]));
|
|
154
|
+
};
|
|
155
|
+
const units = packWorkUnits(
|
|
156
|
+
[measured("aaa", 3, [0, 5]), measured("bb", 2, [1])],
|
|
157
|
+
{ ...DEFAULT_SCHEDULER_OPTIONS, batchMaxItems: 32 }
|
|
158
|
+
);
|
|
159
|
+
const consumed = new Map();
|
|
160
|
+
const result = await runWorkUnits(units, [extractor], {
|
|
161
|
+
onVector(index, vector) {
|
|
162
|
+
consumed.set(index, vector);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
assert.equal(result.failures.length, 0);
|
|
166
|
+
assert.equal(result.vectors.size, 0);
|
|
167
|
+
assert.deepEqual(consumed.get(0), [3, 1]);
|
|
168
|
+
assert.deepEqual(consumed.get(5), [3, 1]);
|
|
169
|
+
assert.deepEqual(consumed.get(1), [2, 1]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("runWorkUnits: batch failure retries per item and isolates the poison text", async () => {
|
|
173
|
+
const extractor = async (texts) => {
|
|
174
|
+
if (Array.isArray(texts)) {
|
|
175
|
+
throw new Error("batch exploded");
|
|
176
|
+
}
|
|
177
|
+
if (texts === "bad") {
|
|
178
|
+
throw new Error("poison");
|
|
179
|
+
}
|
|
180
|
+
return fakeTensor([[texts.length]]);
|
|
181
|
+
};
|
|
182
|
+
const units = [
|
|
183
|
+
{
|
|
184
|
+
kind: "batch",
|
|
185
|
+
texts: ["ok", "bad", "fine"],
|
|
186
|
+
members: [[0], [1, 9], [2]],
|
|
187
|
+
maxTokens: 4,
|
|
188
|
+
cost: 48
|
|
189
|
+
}
|
|
190
|
+
];
|
|
191
|
+
const result = await runWorkUnits(units, [extractor]);
|
|
192
|
+
assert.deepEqual(result.vectors.get(0), [2]);
|
|
193
|
+
assert.deepEqual(result.vectors.get(2), [4]);
|
|
194
|
+
assert.equal(result.vectors.has(1), false);
|
|
195
|
+
// the poison text fails for every index it owns
|
|
196
|
+
assert.deepEqual(result.failures.map((f) => f.index).sort(), [1, 9]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("runWorkUnits: distributes units across multiple extractors", async () => {
|
|
200
|
+
const calls = { a: 0, b: 0 };
|
|
201
|
+
const make = (name) => async (texts) => {
|
|
202
|
+
calls[name] += 1;
|
|
203
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
204
|
+
const list = Array.isArray(texts) ? texts : [texts];
|
|
205
|
+
return fakeTensor(list.map((t) => [t.length]));
|
|
206
|
+
};
|
|
207
|
+
const units = Array.from({ length: 8 }, (_, i) => ({
|
|
208
|
+
kind: "single",
|
|
209
|
+
texts: [`t${i}`],
|
|
210
|
+
members: [[i]],
|
|
211
|
+
maxTokens: 2,
|
|
212
|
+
cost: 4
|
|
213
|
+
}));
|
|
214
|
+
const result = await runWorkUnits(units, [make("a"), make("b")]);
|
|
215
|
+
assert.equal(result.vectors.size, 8);
|
|
216
|
+
assert.ok(calls.a > 0 && calls.b > 0, `expected both lanes used, got ${JSON.stringify(calls)}`);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("sliceBatchVectors: rejects shape mismatches", () => {
|
|
220
|
+
assert.throws(
|
|
221
|
+
() => sliceBatchVectors({ dims: [2, 3], data: Float32Array.from([1, 2, 3, 4, 5]) }, 2),
|
|
222
|
+
/shape mismatch/
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("runWorkUnits: in-flight token gate serializes giant units", async () => {
|
|
227
|
+
let concurrent = 0;
|
|
228
|
+
let peak = 0;
|
|
229
|
+
const extractor = async (texts) => {
|
|
230
|
+
concurrent += 1;
|
|
231
|
+
peak = Math.max(peak, concurrent);
|
|
232
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
233
|
+
concurrent -= 1;
|
|
234
|
+
const list = Array.isArray(texts) ? texts : [texts];
|
|
235
|
+
return { dims: [list.length, 1], data: Float32Array.from(list.map((t) => t.length)) };
|
|
236
|
+
};
|
|
237
|
+
// three giants of 8000 tokens each; gate of 12288 allows only one at a time
|
|
238
|
+
const units = Array.from({ length: 3 }, (_, i) => ({
|
|
239
|
+
kind: "single",
|
|
240
|
+
texts: [`giant${i}`],
|
|
241
|
+
members: [[i]],
|
|
242
|
+
maxTokens: 8000,
|
|
243
|
+
cost: 8000 * 8000
|
|
244
|
+
}));
|
|
245
|
+
const result = await runWorkUnits(units, [extractor, extractor, extractor], {
|
|
246
|
+
maxInFlightTokens: 12288
|
|
247
|
+
});
|
|
248
|
+
assert.equal(result.vectors.size, 3);
|
|
249
|
+
assert.equal(peak, 1, `expected serialized giants, saw peak concurrency ${peak}`);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("runWorkUnits: shorts flow while a giant occupies the budget", async () => {
|
|
253
|
+
const seen = [];
|
|
254
|
+
const extractor = async (texts) => {
|
|
255
|
+
const list = Array.isArray(texts) ? texts : [texts];
|
|
256
|
+
seen.push(list[0]);
|
|
257
|
+
await new Promise((resolve) => setTimeout(resolve, list[0].startsWith("giant") ? 40 : 5));
|
|
258
|
+
return { dims: [list.length, 1], data: Float32Array.from(list.map((t) => t.length)) };
|
|
259
|
+
};
|
|
260
|
+
const units = [
|
|
261
|
+
{ kind: "single", texts: ["giant"], members: [[0]], maxTokens: 8000, cost: 64e6 },
|
|
262
|
+
...Array.from({ length: 6 }, (_, i) => ({
|
|
263
|
+
kind: "single",
|
|
264
|
+
texts: [`s${i}`],
|
|
265
|
+
members: [[i + 1]],
|
|
266
|
+
maxTokens: 50,
|
|
267
|
+
cost: 2500
|
|
268
|
+
}))
|
|
269
|
+
];
|
|
270
|
+
const result = await runWorkUnits(units, [extractor, extractor], { maxInFlightTokens: 12288 });
|
|
271
|
+
assert.equal(result.vectors.size, 7);
|
|
272
|
+
// shorts must have run while the giant was in flight (second lane busy)
|
|
273
|
+
assert.ok(seen.slice(0, 3).some((t) => t.startsWith("s")), `lane starvation: ${seen.join(",")}`);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("runWorkUnits: gate never deadlocks on units larger than the budget", async () => {
|
|
277
|
+
const extractor = async (texts) => {
|
|
278
|
+
const list = Array.isArray(texts) ? texts : [texts];
|
|
279
|
+
return { dims: [list.length, 1], data: Float32Array.from(list.map((t) => t.length)) };
|
|
280
|
+
};
|
|
281
|
+
const units = [
|
|
282
|
+
{ kind: "single", texts: ["mega"], members: [[0]], maxTokens: 50000, cost: 25e8 }
|
|
283
|
+
];
|
|
284
|
+
const result = await runWorkUnits(units, [extractor], { maxInFlightTokens: 1000 });
|
|
285
|
+
assert.equal(result.vectors.size, 1);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ─── review fixes ────────────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
test("resolvePoolConfig: override never exceeds the thread budget", () => {
|
|
291
|
+
const config = resolvePoolConfig({ threadBudget: 4, poolOverride: 8, uniqueCount: 10 });
|
|
292
|
+
assert.ok(
|
|
293
|
+
config.sessions * config.threadsPerSession <= 4,
|
|
294
|
+
`cap violated: ${JSON.stringify(config)}`
|
|
295
|
+
);
|
|
296
|
+
assert.deepEqual(config, { sessions: 4, threadsPerSession: 1 });
|
|
297
|
+
// and an absurd override cannot demand absurd model copies
|
|
298
|
+
const huge = resolvePoolConfig({ threadBudget: 64, poolOverride: 100, uniqueCount: 1000 });
|
|
299
|
+
assert.ok(huge.sessions <= 8);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("runWorkUnits: an oversized unit does not unlock the gate for others", async () => {
|
|
303
|
+
let concurrent = 0;
|
|
304
|
+
let peak = 0;
|
|
305
|
+
const extractor = async (texts) => {
|
|
306
|
+
concurrent += 1;
|
|
307
|
+
peak = Math.max(peak, concurrent);
|
|
308
|
+
await new Promise((resolve) => setTimeout(resolve, 8));
|
|
309
|
+
concurrent -= 1;
|
|
310
|
+
const list = Array.isArray(texts) ? texts : [texts];
|
|
311
|
+
return { dims: [list.length, 1], data: Float32Array.from(list.map((t) => t.length)) };
|
|
312
|
+
};
|
|
313
|
+
const units = [
|
|
314
|
+
{ kind: "single", texts: ["mega"], members: [[0]], maxTokens: 50000, cost: 25e8 },
|
|
315
|
+
...Array.from({ length: 3 }, (_, i) => ({
|
|
316
|
+
kind: "single",
|
|
317
|
+
texts: [`giant${i}`],
|
|
318
|
+
members: [[i + 1]],
|
|
319
|
+
maxTokens: 8000,
|
|
320
|
+
cost: 64e6
|
|
321
|
+
}))
|
|
322
|
+
];
|
|
323
|
+
const result = await runWorkUnits(units, [extractor, extractor, extractor], {
|
|
324
|
+
maxInFlightTokens: 12288
|
|
325
|
+
});
|
|
326
|
+
assert.equal(result.vectors.size, 4);
|
|
327
|
+
assert.equal(peak, 1, `mega must not disable the gate; saw peak ${peak}`);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("runWorkUnits: rejects units with invalid token counts", async () => {
|
|
331
|
+
const extractor = async () => ({ dims: [1, 1], data: Float32Array.from([1]) });
|
|
332
|
+
for (const bad of [NaN, 0, -5, Infinity]) {
|
|
333
|
+
await assert.rejects(
|
|
334
|
+
() =>
|
|
335
|
+
runWorkUnits(
|
|
336
|
+
[{ kind: "single", texts: ["x"], members: [[0]], maxTokens: bad, cost: 1 }],
|
|
337
|
+
[extractor]
|
|
338
|
+
),
|
|
339
|
+
/invalid maxTokens/
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("runWorkUnits: empty units resolve, empty extractors throw", async () => {
|
|
345
|
+
const extractor = async () => ({ dims: [1, 1], data: Float32Array.from([1]) });
|
|
346
|
+
const empty = await runWorkUnits([], [extractor]);
|
|
347
|
+
assert.equal(empty.vectors.size, 0);
|
|
348
|
+
assert.equal(empty.failures.length, 0);
|
|
349
|
+
await assert.rejects(() => runWorkUnits([], []), /at least one extractor/);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("runWorkUnits: failures are reported in slot-index order", async () => {
|
|
353
|
+
const extractor = async (texts) => {
|
|
354
|
+
const text = Array.isArray(texts) ? texts[0] : texts;
|
|
355
|
+
await new Promise((resolve) => setTimeout(resolve, text === "fail-late" ? 1 : 15));
|
|
356
|
+
throw new Error(`boom:${text}`);
|
|
357
|
+
};
|
|
358
|
+
const units = [
|
|
359
|
+
{ kind: "single", texts: ["fail-early"], members: [[7]], maxTokens: 10, cost: 100 },
|
|
360
|
+
{ kind: "single", texts: ["fail-late"], members: [[2]], maxTokens: 10, cost: 100 }
|
|
361
|
+
];
|
|
362
|
+
const result = await runWorkUnits(units, [extractor, extractor]);
|
|
363
|
+
assert.deepEqual(result.failures.map((f) => f.index), [2, 7]);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("sliceBatchVectors: infers dimensions when dims metadata is missing", () => {
|
|
367
|
+
const rows = sliceBatchVectors({ data: Float32Array.from([1, 2, 3, 4]) }, 2);
|
|
368
|
+
assert.deepEqual(rows, [
|
|
369
|
+
[1, 2],
|
|
370
|
+
[3, 4]
|
|
371
|
+
]);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("packWorkUnits: a lone short over the token budget still packs as batch-of-1", () => {
|
|
375
|
+
const units = packWorkUnits([measured("wide", 100, [0])], {
|
|
376
|
+
...DEFAULT_SCHEDULER_OPTIONS,
|
|
377
|
+
shortMaxTokens: 128,
|
|
378
|
+
batchTokenBudget: 64,
|
|
379
|
+
batchMaxItems: 32
|
|
380
|
+
});
|
|
381
|
+
assert.equal(units.length, 1);
|
|
382
|
+
assert.equal(units[0].texts.length, 1);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("resolvePoolConfig: derates sessions on low-memory machines", () => {
|
|
386
|
+
const lowMem = resolvePoolConfig({ threadBudget: 12, uniqueCount: 1000, memoryBytes: 4e9 });
|
|
387
|
+
assert.equal(lowMem.sessions, 1);
|
|
388
|
+
const midMem = resolvePoolConfig({ threadBudget: 12, uniqueCount: 1000, memoryBytes: 7e9 });
|
|
389
|
+
assert.equal(midMem.sessions, 2);
|
|
390
|
+
const bigMem = resolvePoolConfig({ threadBudget: 12, uniqueCount: 1000, memoryBytes: 64e9 });
|
|
391
|
+
assert.equal(bigMem.sessions, 3);
|
|
392
|
+
// unknown memory keeps the thread-derived shape
|
|
393
|
+
const unknown = resolvePoolConfig({ threadBudget: 12, uniqueCount: 1000 });
|
|
394
|
+
assert.equal(unknown.sessions, 3);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("resolveInFlightTokens: adapts to memory within model-max bounds", async () => {
|
|
398
|
+
const { resolveInFlightTokens } = await import("../dist/embedScheduler.js");
|
|
399
|
+
// plenty of memory: capped at 8x model max
|
|
400
|
+
assert.equal(resolveInFlightTokens({ memoryBytes: 64e9, modelMaxTokens: 8192 }), 8 * 8192);
|
|
401
|
+
// tight memory: never below one model-max unit
|
|
402
|
+
assert.equal(resolveInFlightTokens({ memoryBytes: 1e9, modelMaxTokens: 8192 }), 8192);
|
|
403
|
+
// mid memory: proportional
|
|
404
|
+
const mid = resolveInFlightTokens({ memoryBytes: 12e9, modelMaxTokens: 8192 });
|
|
405
|
+
assert.ok(mid > 8192 && mid < 8 * 8192, `expected proportional gate, got ${mid}`);
|
|
406
|
+
// unknown memory: static default floor
|
|
407
|
+
assert.equal(resolveInFlightTokens({ modelMaxTokens: 512 }), 12288);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// ─── round-2 review fixes ────────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
test("resolveMemoryHeadroom: floors macOS-style tiny freemem at a share of total", async () => {
|
|
413
|
+
const { resolveMemoryHeadroom } = await import("../dist/embedScheduler.js");
|
|
414
|
+
// 24GB laptop reporting 0.5GB free (reclaimable cache counted as used)
|
|
415
|
+
const mac = resolveMemoryHeadroom({ freeMemory: 0.5e9, totalMemory: 24e9 });
|
|
416
|
+
assert.ok(mac >= 24e9 * 0.375 && mac <= 24e9 * 0.5, `got ${mac}`);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test("resolveMemoryHeadroom: container limits cap host-sized memory", async () => {
|
|
420
|
+
const { resolveMemoryHeadroom } = await import("../dist/embedScheduler.js");
|
|
421
|
+
// 4GB cgroup limit inside a 64GB host: headroom must reflect the limit
|
|
422
|
+
const container = resolveMemoryHeadroom({
|
|
423
|
+
freeMemory: 50e9,
|
|
424
|
+
totalMemory: 64e9,
|
|
425
|
+
constrainedMemory: 4e9,
|
|
426
|
+
availableMemory: 3e9
|
|
427
|
+
});
|
|
428
|
+
assert.ok(container <= 4e9 * 0.5, `container headroom leaked host memory: ${container}`);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("resolveMemoryHeadroom: big idle host plans within half of total", async () => {
|
|
432
|
+
const { resolveMemoryHeadroom } = await import("../dist/embedScheduler.js");
|
|
433
|
+
const big = resolveMemoryHeadroom({ freeMemory: 50e9, totalMemory: 64e9 });
|
|
434
|
+
assert.equal(big, 32e9);
|
|
435
|
+
// garbage inputs stay sane
|
|
436
|
+
const garbage = resolveMemoryHeadroom({ freeMemory: NaN, totalMemory: -1 });
|
|
437
|
+
assert.ok(garbage > 0 && garbage <= 8e9);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("resolveModelMaxTokens: sane values pass, sentinels and absurdities fall back", async () => {
|
|
441
|
+
const { resolveModelMaxTokens } = await import("../dist/embedScheduler.js");
|
|
442
|
+
assert.equal(resolveModelMaxTokens(512), 512);
|
|
443
|
+
assert.equal(resolveModelMaxTokens(8192), 8192);
|
|
444
|
+
assert.equal(resolveModelMaxTokens(131072), 131072);
|
|
445
|
+
assert.equal(resolveModelMaxTokens(1e8), 8192);
|
|
446
|
+
assert.equal(resolveModelMaxTokens(1e30), 8192);
|
|
447
|
+
assert.equal(resolveModelMaxTokens(undefined), 8192);
|
|
448
|
+
assert.equal(resolveModelMaxTokens(0), 8192);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("createTokenCounter: uses the tokenizer, clamps at model max, survives failures", async () => {
|
|
452
|
+
const { createTokenCounter } = await import("../dist/embedScheduler.js");
|
|
453
|
+
const tokenizer = (text) => ({ input_ids: { dims: [1, text.length * 2] } });
|
|
454
|
+
const count = createTokenCounter(tokenizer, 100);
|
|
455
|
+
assert.equal(count("abc"), 6);
|
|
456
|
+
assert.equal(count("x".repeat(200)), 100); // clamped
|
|
457
|
+
const broken = createTokenCounter(() => {
|
|
458
|
+
throw new Error("boom");
|
|
459
|
+
}, 100);
|
|
460
|
+
assert.equal(broken("x".repeat(40)), 10); // chars/4 fallback
|
|
461
|
+
const missing = createTokenCounter(undefined, 100);
|
|
462
|
+
assert.equal(missing("x".repeat(40)), 10);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test("resolveMemoryHeadroom: constrainedMemory 0 means unconstrained (Node sentinel)", async () => {
|
|
466
|
+
const { resolveMemoryHeadroom } = await import("../dist/embedScheduler.js");
|
|
467
|
+
const unconstrained = resolveMemoryHeadroom({ freeMemory: 50e9, totalMemory: 64e9 });
|
|
468
|
+
const zeroSentinel = resolveMemoryHeadroom({
|
|
469
|
+
freeMemory: 50e9,
|
|
470
|
+
totalMemory: 64e9,
|
|
471
|
+
constrainedMemory: 0
|
|
472
|
+
});
|
|
473
|
+
assert.equal(zeroSentinel, unconstrained);
|
|
474
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import { readJsonlRecords, writeJsonlRecords } from "../dist/jsonl.js";
|
|
8
|
+
|
|
9
|
+
test("writeJsonlRecords writes canonical JSONL without building a joined body", () => {
|
|
10
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-jsonl-"));
|
|
11
|
+
const filePath = path.join(dir, "embeddings.jsonl");
|
|
12
|
+
const records = [
|
|
13
|
+
{ id: "a", vector: [1, 2.5], model: "m" },
|
|
14
|
+
{ id: "b", vector: [0], model: "m", label: "two" }
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const count = writeJsonlRecords(filePath, records);
|
|
18
|
+
|
|
19
|
+
assert.equal(count, 2);
|
|
20
|
+
assert.equal(fs.readFileSync(filePath, "utf8"), records.map(JSON.stringify).join("\n") + "\n");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("readJsonlRecords streams valid records and skips blank or invalid lines", () => {
|
|
24
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-jsonl-"));
|
|
25
|
+
const filePath = path.join(dir, "embeddings.jsonl");
|
|
26
|
+
fs.writeFileSync(filePath, '{"id":"a"}\r\n\nnot json\n{"id":"b"}\n', "utf8");
|
|
27
|
+
|
|
28
|
+
assert.deepEqual(Array.from(readJsonlRecords(filePath)), [{ id: "a" }, { id: "b" }]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("writeJsonlRecords creates an empty file for empty output", () => {
|
|
32
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-jsonl-"));
|
|
33
|
+
const filePath = path.join(dir, "empty.jsonl");
|
|
34
|
+
|
|
35
|
+
const count = writeJsonlRecords(filePath, []);
|
|
36
|
+
|
|
37
|
+
assert.equal(count, 0);
|
|
38
|
+
assert.equal(fs.readFileSync(filePath, "utf8"), "");
|
|
39
|
+
});
|