tryharness 0.7.22 → 0.7.23

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.
@@ -0,0 +1,326 @@
1
+ import fs from "node:fs";
2
+ import net from "node:net";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { spawn, spawnSync } from "node:child_process";
6
+ import { MODELS, modelById, modelDir, modelFile, modelsPresent, ensureLlama, llamaServer } from "./localmodels.mjs";
7
+ import { ensureDir } from "./files.mjs";
8
+
9
+ // The relay's local inference host. GGUF models (the search model) run in one llama.cpp server in
10
+ // router mode, on the GPU when the machine has one that is faster, the CPU otherwise; the small ONNX classifiers run in
11
+ // native ONNX Runtime inside the daemon, one thread each (measured: about 13x less CPU energy per item than the
12
+ // WebAssembly build). Every request goes through one scheduler with three lanes: interactive (the person is waiting),
13
+ // handoff (a brief or post is in flight) and background (indexing, the nightly learner). Background work runs at the
14
+ // OS's background priority and waits while the machine is on low battery, in Low Power Mode or hot.
15
+
16
+ const LANES = { interactive: 0, handoff: 1, background: 2 };
17
+ // Models a sleeping llama.cpp instance keeps about 100 MB of; idle ONNX sessions are dropped outright.
18
+ const SMALL_MACHINE = os.totalmem() <= 8.5 * 2 ** 30;
19
+ // The smoke local-runtime shortens both to watch models go (HARNESS_ONNX_IDLE_MS, HARNESS_LLAMA_SLEEP_SECONDS).
20
+ const ONNX_IDLE_MS = Number(process.env.HARNESS_ONNX_IDLE_MS) || (SMALL_MACHINE ? 60_000 : 5 * 60_000);
21
+ const SLEEP_IDLE_SECONDS = Number(process.env.HARNESS_LLAMA_SLEEP_SECONDS) || (SMALL_MACHINE ? 30 : 120);
22
+ const POWER_CHECK_MS = 60_000;
23
+
24
+ const freePort = () => new Promise((resolve, reject) => {
25
+ const server = net.createServer();
26
+ server.unref();
27
+ server.on("error", reject);
28
+ server.listen(0, "127.0.0.1", () => { const { port } = server.address(); server.close(() => resolve(port)); });
29
+ });
30
+
31
+ /** Battery, Low Power Mode and heat, read from the OS without any permission. paused() is true when background work should wait. */
32
+ export function createPower({ platform = process.platform, run = spawnSync } = {}) {
33
+ let state = { onBattery: false, percent: null, lowPower: false, hot: false, at: 0 };
34
+ const read = () => {
35
+ const next = { onBattery: false, percent: null, lowPower: false, hot: false, at: Date.now() };
36
+ if (platform === "darwin") {
37
+ const batt = run("pmset", ["-g", "batt"], { encoding: "utf8", timeout: 3_000 }).stdout || "";
38
+ next.onBattery = /Battery Power/.test(batt);
39
+ next.percent = Number(batt.match(/(\d+)%/)?.[1] ?? NaN);
40
+ next.lowPower = /lowpowermode\s+1/.test(run("pmset", ["-g"], { encoding: "utf8", timeout: 3_000 }).stdout || "");
41
+ const therm = run("pmset", ["-g", "therm"], { encoding: "utf8", timeout: 3_000 }).stdout || "";
42
+ next.hot = /CPU_Speed_Limit\s*=\s*([0-9]+)/.test(therm) && Number(therm.match(/CPU_Speed_Limit\s*=\s*([0-9]+)/)[1]) < 80;
43
+ } else if (platform === "linux") {
44
+ try {
45
+ const battery = fs.readdirSync("/sys/class/power_supply").find((name) => name.startsWith("BAT"));
46
+ if (battery) {
47
+ next.onBattery = fs.readFileSync(`/sys/class/power_supply/${battery}/status`, "utf8").trim() === "Discharging";
48
+ next.percent = Number(fs.readFileSync(`/sys/class/power_supply/${battery}/capacity`, "utf8"));
49
+ }
50
+ } catch {}
51
+ }
52
+ if (!Number.isFinite(next.percent)) next.percent = null;
53
+ state = next;
54
+ return state;
55
+ };
56
+ return {
57
+ state: () => (Date.now() - state.at > POWER_CHECK_MS ? read() : state),
58
+ paused() { const now = this.state(); return now.lowPower || now.hot || (now.onBattery && now.percent !== null && now.percent < 20); },
59
+ /** First index passes wait for the charger as well. */
60
+ onCharger() { return !this.state().onBattery; },
61
+ };
62
+ }
63
+
64
+ export function createInference({ root, log = () => {}, power = createPower(), list = MODELS }) {
65
+ ensureDir(root);
66
+ const deviceFile = path.join(root, "device.json");
67
+ let server = null;
68
+ let starting = null;
69
+ const queues = { llama: [], onnx: [] };
70
+ const busy = { llama: false, onnx: false };
71
+ const sessions = new Map();
72
+
73
+ const device = () => { try { return JSON.parse(fs.readFileSync(deviceFile, "utf8")); } catch { return null; } };
74
+
75
+ const preset = (gpu) => {
76
+ const lines = ["version = 1", "[*]", `sleep-idle-seconds = ${SLEEP_IDLE_SECONDS}`, `n-gpu-layers = ${gpu ? 99 : 0}`];
77
+ for (const entry of list.filter((model) => model.runtime === "llama" && modelsPresent(root, [model.id, ...(model.llama?.weights ? [model.llama.weights] : [])], list))) {
78
+ // A projector runs on another entry's weights (media on search's): its own preset, loaded only for media.
79
+ const weights = entry.llama?.weights ? modelById(entry.llama.weights, list) : null;
80
+ lines.push(`[${entry.id}]`, `model = ${modelFile(root, weights ?? entry)}`, ...(weights ? [`mmproj = ${modelFile(root, entry)}`] : []));
81
+ for (const [key, value] of Object.entries(entry.llama ?? {})) if (key !== "weights") lines.push(`${key} = ${value}`);
82
+ }
83
+ const file = path.join(root, "router.ini");
84
+ fs.writeFileSync(file, lines.join("\n") + "\n");
85
+ return file;
86
+ };
87
+
88
+ const launch = async (gpu) => {
89
+ const binary = llamaServer(root) ?? await ensureLlama({ root, gpu, log });
90
+ const port = await freePort();
91
+ const child = spawn(binary, ["--models-preset", preset(gpu), "--models-max", SMALL_MACHINE ? "1" : "2", "--host", "127.0.0.1", "--port", String(port), "--no-webui"],
92
+ { cwd: path.dirname(binary), stdio: ["ignore", "ignore", "pipe"] });
93
+ let tail = "";
94
+ child.stderr.on("data", (chunk) => { tail = (tail + chunk).slice(-4_000); });
95
+ const base = `http://127.0.0.1:${port}`;
96
+ for (let i = 0; i < 200; i += 1) {
97
+ if (child.exitCode !== null) throw new Error(`the local model server stopped: ${tail.trim().split("\n").at(-1) || child.exitCode}`);
98
+ try { if ((await fetch(`${base}/health`)).ok) return { child, base, gpu, media: modelsPresent(root, ["search", "media"], list), marker: null }; } catch {}
99
+ await new Promise((resolve) => setTimeout(resolve, 100));
100
+ }
101
+ child.kill();
102
+ throw new Error("the local model server did not start");
103
+ };
104
+
105
+ /** One-time check on this machine: the GPU build is kept unless it fails or is slower than the CPU on the search model. */
106
+ const chooseDevice = async () => {
107
+ const known = device();
108
+ if (known) return known.gpu;
109
+ const timed = async (gpu) => {
110
+ const trial = await launch(gpu);
111
+ try {
112
+ const texts = Array.from({ length: 8 }, (_, i) => `title: none | text: device check ${i} ${"lorem ipsum dolor sit amet ".repeat(40)}`);
113
+ await post(trial.base, "/v1/embeddings", { model: "search", input: texts.slice(0, 1) });
114
+ const started = performance.now();
115
+ await post(trial.base, "/v1/embeddings", { model: "search", input: texts });
116
+ return performance.now() - started;
117
+ } finally { trial.child.kill(); }
118
+ };
119
+ let gpuMs = null;
120
+ try { gpuMs = await timed(true); } catch (error) { log(`local models: GPU check failed (${error.message}); using the CPU`); }
121
+ const cpuMs = await timed(false).catch(() => null);
122
+ const gpu = gpuMs !== null && (cpuMs === null || gpuMs <= cpuMs);
123
+ fs.writeFileSync(deviceFile, JSON.stringify({ gpu, gpuMs, cpuMs, checkedAt: new Date().toISOString(), platform: `${process.platform}-${process.arch}` }, null, 2));
124
+ log(`local models: running on the ${gpu ? "GPU" : "CPU"} (search check ${gpuMs?.toFixed(0) ?? "failed"} ms GPU, ${cpuMs?.toFixed(0) ?? "failed"} ms CPU)`);
125
+ return gpu;
126
+ };
127
+
128
+ const ensureServer = () => {
129
+ if (server && server.child.exitCode === null) return Promise.resolve(server);
130
+ starting ||= (async () => {
131
+ if (!modelsPresent(root, ["search"], list)) throw new Error("the search model is not downloaded yet");
132
+ server = await launch(await chooseDevice());
133
+ server.child.on("exit", () => { server = null; });
134
+ return server;
135
+ })().finally(() => { starting = null; });
136
+ return starting;
137
+ };
138
+
139
+ async function post(base, pathname, body) {
140
+ const response = await fetch(`${base}${pathname}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
141
+ const text = await response.text();
142
+ if (!response.ok) throw new Error(`local model server ${pathname}: HTTP ${response.status} ${text.slice(0, 300)}`);
143
+ return JSON.parse(text);
144
+ }
145
+
146
+ // macOS lets a process move itself or its children in and out of the background band; the llama.cpp children are
147
+ // moved only while a background job runs so interactive requests are never slowed.
148
+ const background = (on) => {
149
+ if (process.platform !== "darwin" || !server) return;
150
+ const children = (spawnSync("pgrep", ["-P", String(server.child.pid)], { encoding: "utf8" }).stdout || "").split("\n").filter(Boolean);
151
+ for (const pid of [String(server.child.pid), ...children]) spawnSync("taskpolicy", [on ? "-b" : "-B", "-p", pid]);
152
+ };
153
+
154
+ const pump = (backend) => {
155
+ if (busy[backend]) return;
156
+ const queue = queues[backend];
157
+ queue.sort((a, b) => a.rank - b.rank || a.seq - b.seq);
158
+ const index = queue.findIndex((job) => job.rank < LANES.background || !power.paused());
159
+ if (index < 0) { if (queue.length) setTimeout(() => pump(backend), POWER_CHECK_MS).unref?.(); return; }
160
+ const [job] = queue.splice(index, 1);
161
+ busy[backend] = true;
162
+ const bg = job.rank === LANES.background && backend === "llama";
163
+ Promise.resolve().then(() => { if (bg) background(true); return job.run(); })
164
+ .then(job.resolve, job.reject)
165
+ .finally(() => { if (bg) background(false); busy[backend] = false; pump(backend); });
166
+ };
167
+ let seq = 0;
168
+ const schedule = (backend, lane, run) => new Promise((resolve, reject) => {
169
+ queues[backend].push({ rank: LANES[lane] ?? LANES.handoff, seq: seq++, run, resolve, reject });
170
+ pump(backend);
171
+ });
172
+
173
+ // Models on one vocabulary share its tokenizer while any of them is open.
174
+ const tokenizers = new Map();
175
+ const tokenizerOf = (Tokenizer, source) => {
176
+ const dir = modelDir(root, source);
177
+ let tokenizer = tokenizers.get(dir)?.deref();
178
+ if (!tokenizer) {
179
+ const tokenizerPath = source.files.find((file) => file.path.endsWith("tokenizer.json")).path;
180
+ const configPath = source.files.find((file) => file.path.endsWith("tokenizer_config.json"))?.path;
181
+ tokenizer = new Tokenizer(JSON.parse(fs.readFileSync(path.join(dir, tokenizerPath), "utf8")), configPath ? JSON.parse(fs.readFileSync(path.join(dir, configPath), "utf8")) : {});
182
+ tokenizers.set(dir, new WeakRef(tokenizer));
183
+ }
184
+ return tokenizer;
185
+ };
186
+
187
+ const session = async (id) => {
188
+ const entry = modelById(id, list);
189
+ if (!entry || entry.runtime !== "onnx") throw new Error(`no ONNX model ${id}`);
190
+ let held = sessions.get(id);
191
+ if (!held) {
192
+ held = (async () => {
193
+ const source = entry.tokenizer ? modelById(entry.tokenizer.from, list) : entry;
194
+ if (!modelsPresent(root, [...new Set([id, source.id])], list)) throw new Error(`${entry.name} is not downloaded yet`);
195
+ const [{ Tokenizer }, ort] = await Promise.all([import("@huggingface/tokenizers"), import("onnxruntime-node")]);
196
+ const onnx = await ort.InferenceSession.create(modelFile(root, entry), { executionProviders: ["cpu"], intraOpNumThreads: 1, interOpNumThreads: 1, logSeverityLevel: 3 });
197
+ return { ort, tokenizer: tokenizerOf(Tokenizer, source), prepare: entry.tokenizer?.prepare ?? ((text) => text), onnx, timer: null };
198
+ })();
199
+ sessions.set(id, held);
200
+ held.catch(() => sessions.delete(id));
201
+ }
202
+ const ready = await held;
203
+ clearTimeout(ready.timer);
204
+ ready.timer = setTimeout(() => { sessions.delete(id); ready.onnx.release?.(); }, ONNX_IDLE_MS);
205
+ ready.timer.unref?.();
206
+ return ready;
207
+ };
208
+
209
+ // Models made of several ONNX graphs (OCR's detector and recogniser, speech's encoder and decoder) open each graph
210
+ // on first use and drop all of them together once idle.
211
+ const graphs = new Map();
212
+ const graphsOf = async (id) => {
213
+ const entry = modelById(id, list);
214
+ if (!entry || entry.runtime !== "onnx") throw new Error(`no ONNX model ${id}`);
215
+ if (!modelsPresent(root, [id], list)) throw new Error(`${entry.name} is not downloaded yet`);
216
+ let held = graphs.get(id);
217
+ if (!held) graphs.set(id, (held = { ort: await import("onnxruntime-node"), open: new Map(), timer: null, threads: entry.threads ?? 1 }));
218
+ clearTimeout(held.timer);
219
+ held.timer = setTimeout(() => { graphs.delete(id); for (const graph of held.open.values()) graph.then((onnx) => onnx.release?.()).catch(() => {}); }, ONNX_IDLE_MS);
220
+ held.timer.unref?.();
221
+ const graph = (file) => {
222
+ if (!held.open.has(file)) {
223
+ const opened = held.ort.InferenceSession.create(path.join(modelDir(root, entry), ...file.split("/")), { executionProviders: ["cpu"], intraOpNumThreads: held.threads, interOpNumThreads: 1, logSeverityLevel: 3 });
224
+ held.open.set(file, opened);
225
+ opened.catch(() => held.open.delete(file));
226
+ }
227
+ return held.open.get(file);
228
+ };
229
+ return { ort: held.ort, graph, dir: modelDir(root, entry) };
230
+ };
231
+
232
+ /** Runs an ONNX model on each encoded input; returns the first output's leading row (its logits). */
233
+ const logits = async (id, pairs, maxLength = 512) => {
234
+ const { ort, tokenizer, prepare, onnx } = await session(id);
235
+ const rows = [];
236
+ for (const [text, pair] of pairs) {
237
+ const encoded = tokenizer.encode(prepare(text), pair === undefined ? { add_special_tokens: true } : { text_pair: prepare(pair), add_special_tokens: true });
238
+ const ids = encoded.ids.slice(0, maxLength);
239
+ const feeds = {};
240
+ for (const name of onnx.inputNames) {
241
+ if (name === "input_ids") feeds[name] = new ort.Tensor("int64", BigInt64Array.from(ids, BigInt), [1, ids.length]);
242
+ else if (name === "attention_mask") feeds[name] = new ort.Tensor("int64", new BigInt64Array(ids.length).fill(1n), [1, ids.length]);
243
+ else if (name === "token_type_ids") feeds[name] = new ort.Tensor("int64", BigInt64Array.from((encoded.type_ids ?? ids.map(() => 0)).slice(0, ids.length), BigInt), [1, ids.length]);
244
+ }
245
+ const out = await onnx.run(feeds);
246
+ rows.push(Array.from(out[onnx.outputNames[0]].data, Number));
247
+ }
248
+ return rows;
249
+ };
250
+
251
+ return {
252
+ device,
253
+ power,
254
+ /** Embeds texts with the search model. kind: "query" | "code-query" | "document" (title is a path or "none"). */
255
+ embed: (items, { lane = "handoff" } = {}) => schedule("llama", lane, async () => {
256
+ const { base } = await ensureServer();
257
+ const input = items.map((item) => typeof item === "string" ? item : item.kind === "document" ? `title: ${item.title || "none"} | text: ${item.text}`
258
+ : `task: ${item.kind === "code-query" ? "code retrieval" : "search result"} | query: ${item.text}`);
259
+ // A text too long for the model's window is retried alone, halved until it fits.
260
+ const one = async (text) => {
261
+ for (let cut = text; ; cut = cut.slice(0, Math.floor(cut.length / 2))) {
262
+ try { return (await post(base, "/v1/embeddings", { model: "search", input: [cut] })).data[0]; } catch (error) { if (cut.length < 200) throw error; }
263
+ }
264
+ };
265
+ const out = [];
266
+ for (let i = 0; i < input.length; i += 16) {
267
+ const slice = input.slice(i, i + 16);
268
+ const data = await post(base, "/v1/embeddings", { model: "search", input: slice }).then((reply) => reply.data.sort((a, b) => a.index - b.index))
269
+ .catch(async () => { const rows = []; for (const text of slice) rows.push(await one(text)); return rows; });
270
+ for (const row of data) {
271
+ const vector = Float32Array.from(row.embedding);
272
+ let norm = 0;
273
+ for (const value of vector) norm += value * value;
274
+ norm = Math.sqrt(norm) || 1;
275
+ for (let j = 0; j < vector.length; j += 1) vector[j] /= norm;
276
+ out.push(vector);
277
+ }
278
+ }
279
+ return out;
280
+ }),
281
+ /**
282
+ * Embeds recordings and images with the search model's projector, in the same space as typed questions. items:
283
+ * { title, data (a Buffer of WAV, MP3, FLAC, PNG or JPEG) }. A server started before the projector came down is
284
+ * restarted once with it.
285
+ */
286
+ embedMedia: (items, { lane = "background" } = {}) => schedule("llama", lane, async () => {
287
+ let ready = await ensureServer();
288
+ if (!ready.media) { ready.child.kill(); server = null; ready = await ensureServer(); }
289
+ if (!ready.media) throw new Error("the image and audio part of the search model is not downloaded yet");
290
+ // The marker that stands for a recording or an image in the text is the server's own, read once it has loaded.
291
+ if (!ready.marker) {
292
+ await post(ready.base, "/v1/embeddings", { model: "media", input: ["warm up"] });
293
+ ready.marker = (await (await fetch(`${ready.base}/props?model=media`)).json()).media_marker;
294
+ }
295
+ const out = [];
296
+ for (const item of items) {
297
+ const reply = await post(ready.base, "/v1/embeddings", { model: "media", input: [{ prompt_string: `title: ${item.title || "none"} | text: ${ready.marker}`, multimodal_data: [Buffer.from(item.data).toString("base64")] }] });
298
+ const vector = Float32Array.from(reply.data[0].embedding);
299
+ let norm = 0;
300
+ for (const value of vector) norm += value * value;
301
+ norm = Math.sqrt(norm) || 1;
302
+ out.push(vector.map((value) => value / norm));
303
+ }
304
+ return out;
305
+ }),
306
+ /** Raw logits rows from an ONNX sequence classifier, for models calibrated on logit margins. */
307
+ logits: (id, texts, { lane = "handoff", maxLength } = {}) => schedule("onnx", lane, () => logits(id, texts.map((text) => [text]), maxLength)),
308
+ /** Runs work against a multi-graph ONNX model in the scheduler: run({ ort, graph(file), dir }) opens graphs by file path. */
309
+ onnx: (id, run, { lane = "handoff" } = {}) => schedule("onnx", lane, async () => run(await graphsOf(id))),
310
+ running: () => Boolean(server && server.child.exitCode === null),
311
+ /** What is in memory now: the router's models and their state, and the ONNX models open (for `tryharness local`). */
312
+ loaded: async () => {
313
+ let llama = [];
314
+ if (server && server.child.exitCode === null) { try { llama = ((await (await fetch(`${server.base}/models`)).json()).data ?? []).map((model) => ({ id: model.id, state: model.status?.value ?? null })); } catch {} }
315
+ return { llama, onnx: [...sessions.keys(), ...graphs.keys()] };
316
+ },
317
+ stop() {
318
+ if (server?.child.exitCode === null) server.child.kill();
319
+ server = null;
320
+ for (const held of sessions.values()) held.then((ready) => { clearTimeout(ready.timer); ready.onnx.release?.(); }).catch(() => {});
321
+ sessions.clear();
322
+ for (const held of graphs.values()) { clearTimeout(held.timer); for (const graph of held.open.values()) graph.then((onnx) => onnx.release?.()).catch(() => {}); }
323
+ graphs.clear();
324
+ },
325
+ };
326
+ }
package/lib/learn.mjs ADDED
@@ -0,0 +1,120 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ // The context engine's personal ranking model: a logistic regression over a few signals the frozen models and the
5
+ // relay's own records give for each candidate file (meaning, words, names, what the swarm edited lately and last).
6
+ // It learns from finished runs: the files a run went on to edit are the right answers for its brief. A new model is
7
+ // trained on older runs and kept only if it beats the current one on the newest runs, which it never saw.
8
+
9
+ export const FEATURES = ["dense", "denseRank", "lexical", "lexicalRank", "named", "prior", "priorRead", "lastEdit", "lastRun", "recent", "test", "doc", "depth"];
10
+ const HALF_LIFE_DAYS = 7;
11
+
12
+ /**
13
+ * What the swarm did to each file before a moment: decayed edit and read counts from earlier runs' trails, when each
14
+ * file was last edited, and what the latest earlier run edited (work often picks up where the last session left off).
15
+ */
16
+ export function swarmPrior(history, at) {
17
+ const edits = new Map();
18
+ const reads = new Map();
19
+ const lastEdit = new Map();
20
+ let latest = null;
21
+ for (const run of history) {
22
+ if (!(run.at < at)) continue;
23
+ const weight = 0.5 ** ((at - run.at) / 86_400_000 / HALF_LIFE_DAYS);
24
+ for (const file of run.edited) { edits.set(file, (edits.get(file) ?? 0) + weight); lastEdit.set(file, Math.max(lastEdit.get(file) ?? 0, run.at)); }
25
+ for (const file of run.read) reads.set(file, (reads.get(file) ?? 0) + weight);
26
+ if (run.edited.length && (!latest || run.at > latest.at)) latest = run;
27
+ }
28
+ return { edits, reads, lastEdit, lastRun: new Set(latest?.edited ?? []), at };
29
+ }
30
+
31
+ /** One feature row per candidate. candidates: searchFiles output; extras: { prior, recent: Map(path→commits) }. */
32
+ export function featureRows(candidates, { prior, recent = new Map() }) {
33
+ const byDense = [...candidates].sort((a, b) => b.dense - a.dense);
34
+ const byWords = [...candidates].sort((a, b) => b.lexical - a.lexical);
35
+ const denseRank = new Map(byDense.map((row, i) => [row.path, i]));
36
+ const wordsRank = new Map(byWords.map((row, i) => [row.path, i]));
37
+ const topWords = byWords[0]?.lexical || 1;
38
+ return candidates.map((row) => ({
39
+ path: row.path,
40
+ x: {
41
+ dense: row.dense,
42
+ denseRank: Math.log1p(denseRank.get(row.path)),
43
+ lexical: row.lexical > 0 ? row.lexical / topWords : 0,
44
+ lexicalRank: row.lexical > 0 ? Math.log1p(wordsRank.get(row.path)) : Math.log1p(candidates.length),
45
+ named: row.named,
46
+ prior: Math.log1p(prior.edits.get(row.path) ?? 0),
47
+ priorRead: Math.log1p(prior.reads.get(row.path) ?? 0),
48
+ // Hours since any run last edited it, as a closeness from 1 (just now) toward 0 (weeks ago, or never).
49
+ lastEdit: prior.lastEdit.has(row.path) ? 1 / (1 + (prior.at - prior.lastEdit.get(row.path)) / 3_600_000 / 24) : 0,
50
+ lastRun: prior.lastRun.has(row.path) ? 1 : 0,
51
+ recent: Math.log1p(recent.get(row.path) ?? 0),
52
+ test: /(?:^|\/)(?:tests?|__tests__)\/|\.(?:test|spec)\.[^.]+$/.test(row.path) ? 1 : 0,
53
+ doc: /\.(?:md|mdx|txt)$/i.test(row.path) ? 1 : 0,
54
+ depth: row.path.split("/").length,
55
+ },
56
+ }));
57
+ }
58
+
59
+ /** The hand-set scorer used before any learning: meaning, words and names, with the swarm's recent edits as a tiebreak. */
60
+ export function baselineScore(x) {
61
+ return x.dense + 0.25 * x.lexical + 0.2 * x.named + 0.02 * x.prior;
62
+ }
63
+
64
+ /** What a pack is for: code. Notes and status docs (edited by nearly every session) are neither right answers nor misses. */
65
+ export const codeFiles = (files) => files.filter((file) => !/\.(?:md|mdx|txt)$/i.test(file));
66
+
67
+ /** Trains a logistic regression on rows { x, y }; positives weighted up to balance the classes. */
68
+ export function train(rows, { epochs = 300, rate = 0.05, l2 = 1e-3 } = {}) {
69
+ const n = rows.length;
70
+ const mean = Object.fromEntries(FEATURES.map((name) => [name, rows.reduce((sum, row) => sum + row.x[name], 0) / n]));
71
+ const std = Object.fromEntries(FEATURES.map((name) => [name, Math.sqrt(rows.reduce((sum, row) => sum + (row.x[name] - mean[name]) ** 2, 0) / n) || 1]));
72
+ const positives = rows.filter((row) => row.y).length;
73
+ const weightPos = positives ? Math.min(50, (n - positives) / positives) : 1;
74
+ const xs = rows.map((row) => FEATURES.map((name) => (row.x[name] - mean[name]) / std[name]));
75
+ const w = new Float64Array(FEATURES.length);
76
+ let b = 0;
77
+ const m = new Float64Array(FEATURES.length + 1), v = new Float64Array(FEATURES.length + 1);
78
+ for (let epoch = 1; epoch <= epochs; epoch += 1) {
79
+ const grad = new Float64Array(FEATURES.length + 1);
80
+ let total = 0;
81
+ for (let i = 0; i < n; i += 1) {
82
+ let z = b;
83
+ for (let j = 0; j < w.length; j += 1) z += w[j] * xs[i][j];
84
+ const p = 1 / (1 + Math.exp(-z));
85
+ const weight = rows[i].y ? weightPos : 1;
86
+ const error = (p - rows[i].y) * weight;
87
+ total += weight;
88
+ for (let j = 0; j < w.length; j += 1) grad[j] += error * xs[i][j];
89
+ grad[w.length] += error;
90
+ }
91
+ for (let j = 0; j <= w.length; j += 1) {
92
+ const g = grad[j] / total + (j < w.length ? l2 * w[j] : 0);
93
+ m[j] = 0.9 * m[j] + 0.1 * g;
94
+ v[j] = 0.999 * v[j] + 0.001 * g * g;
95
+ const step = rate * (m[j] / (1 - 0.9 ** epoch)) / (Math.sqrt(v[j] / (1 - 0.999 ** epoch)) + 1e-8);
96
+ if (j < w.length) w[j] -= step; else b -= step;
97
+ }
98
+ }
99
+ return { features: FEATURES, weights: Array.from(w), bias: b, mean, std, rows: n, positives };
100
+ }
101
+
102
+ export function modelScore(model, x) {
103
+ let z = model.bias;
104
+ model.features.forEach((name, j) => { z += model.weights[j] * ((x[name] - model.mean[name]) / model.std[name]); });
105
+ return z;
106
+ }
107
+
108
+ /** Recall at k of the right files among ranked paths, for one request. */
109
+ export function recallAt(ranked, wanted, k) {
110
+ if (!wanted.length) return null;
111
+ const top = new Set(ranked.slice(0, k));
112
+ return wanted.filter((file) => top.has(file)).length / wanted.length;
113
+ }
114
+
115
+ export const loadModel = (file) => { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } };
116
+ export function saveModel(file, model) {
117
+ fs.mkdirSync(path.dirname(file), { recursive: true });
118
+ fs.writeFileSync(`${file}.tmp`, JSON.stringify(model, null, 2));
119
+ fs.renameSync(`${file}.tmp`, file);
120
+ }