premanmcp 1.1.4 → 1.1.6

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/bin/predict.js ADDED
@@ -0,0 +1,857 @@
1
+ /**
2
+ * One command from a cold machine to an accuracy experiment streaming on screen.
3
+ *
4
+ * The pieces are individually simple and collectively easy to get wrong, which
5
+ * is the only reason this exists. Six things have to be true at once before a
6
+ * run is *visible*: Postgres has to be up, the API has to be up (it runs the
7
+ * migrations and the worker that executes the run in-process, so those are not
8
+ * separate steps), the dashboard has to be serving the working tree, the app
9
+ * has to be the from-source build pointed at this backend, it has to hold a
10
+ * session for the same account the run is filed under, and it has to already be
11
+ * on the Accuracy page.
12
+ *
13
+ * Miss any one and the run still happens correctly while showing you nothing.
14
+ * That is the failure this command exists to stop reproducing -- the account
15
+ * mismatch especially, which produces no error anywhere, just an empty page.
16
+ *
17
+ * Dev-only. It drives a checkout, so it refuses to run from a bare npm install
18
+ * rather than half-working from one.
19
+ */
20
+ import { spawn, spawnSync } from "node:child_process";
21
+ import fs from "node:fs";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
27
+
28
+ const DEFAULT_BACKEND = "http://127.0.0.1:8000";
29
+ const DASHBOARD_ORIGIN = "http://localhost:8080";
30
+ const DESKTOP_PORT = "8790";
31
+ const ACCURACY_ROUTE = "/workbench/workload/accuracy";
32
+ // Where a forecast is read, and where the sources that fed it can be switched
33
+ // off and the run repeated. A different page from the accuracy experiment
34
+ // because they answer different questions: one is "how right is this thing",
35
+ // the other is "what does it tell me to do, and on what evidence".
36
+ const FORECAST_ROUTE = "/workbench/workload/forecast";
37
+ const DEV_LOCAL = path.join(os.homedir(), ".preman", "dev-local.json");
38
+ // Mirrors DEFAULT_SEEDS in flowtest/services/workload_eval_service.py.
39
+ const DEFAULT_SEEDS = [11, 23, 37];
40
+
41
+ export const PREDICT_HELP = `
42
+ Predict options (local development; 'preman run --predict' works too):
43
+ --seeds <n|a,b,c> How many seeds per regime, or the exact seeds to use
44
+ --regimes <a,b,c> Only these regimes. Defaults to all of them
45
+ --train-days <n> Days of history the forecaster learns from (default 14)
46
+ --test-days <n> Days it is scored against (default 14)
47
+ --tiers [a,b,c] Also score each access tier, to show what connecting
48
+ more data actually buys. Bare flag means T1,T2,T3,T4
49
+ --backend <url> Local backend. Defaults to ${DEFAULT_BACKEND}
50
+ --no-app Skip the desktop app; stream to this terminal only
51
+ --app-only Bring the stack and app up, but start no run
52
+ --show-inputs Print what a run reads, and what it does not, then stop
53
+
54
+ Forecast options (one forecast for this customer, rather than an accuracy run):
55
+ --forecast Forecast instead of scoring, and open the app on the
56
+ Forecast page, where every source has a switch
57
+ --without <a,b> Run without these sources, to see what is lost. One or
58
+ more of: reporting, logs, repository, infrastructure
59
+ --pack <key> Which representation pack. Defaults to simula
60
+ --horizon <days> How far ahead to forecast. Defaults to 90
61
+ `;
62
+
63
+ function say(message) {
64
+ process.stdout.write(`\n\u001b[36m==>\u001b[0m ${message}\n`);
65
+ }
66
+
67
+ function note(message) {
68
+ process.stdout.write(` ${message}\n`);
69
+ }
70
+
71
+ function fail(message, code = 2) {
72
+ process.stderr.write(`\n${message}\n`);
73
+ return code;
74
+ }
75
+
76
+ /**
77
+ * The checkout this CLI is part of, or null when installed from the registry.
78
+ *
79
+ * Resolved from this file rather than from cwd so the command works from
80
+ * anywhere, and so a global `npm link` still finds the tree it was linked from.
81
+ */
82
+ function resolveCheckout() {
83
+ const backendDir = path.resolve(__dirname, "..", "..");
84
+ const root = path.resolve(backendDir, "..");
85
+ const dashboardDir = path.join(root, "PreMan-Dashboard");
86
+ const desktopDir = path.join(root, "PreMan-API-Testing-tool", "desktop");
87
+ if (!fs.existsSync(path.join(backendDir, "api.py"))) return null;
88
+ return { root, backendDir, dashboardDir, desktopDir };
89
+ }
90
+
91
+ async function reachable(url, { headers = {}, timeoutMs = 2000 } = {}) {
92
+ try {
93
+ const response = await fetch(url, {
94
+ headers,
95
+ signal: AbortSignal.timeout(timeoutMs),
96
+ });
97
+ return response.status;
98
+ } catch {
99
+ return 0;
100
+ }
101
+ }
102
+
103
+ async function waitUntil(check, { seconds, label }) {
104
+ for (let attempt = 0; attempt < seconds; attempt += 1) {
105
+ if (await check()) return true;
106
+ if (attempt === 2) note(`still waiting for ${label}…`);
107
+ await new Promise((resolve) => setTimeout(resolve, 1000));
108
+ }
109
+ return false;
110
+ }
111
+
112
+ /**
113
+ * Start something that has to outlive this command.
114
+ *
115
+ * `detached` so the child gets its own process group: without it the backend
116
+ * and dashboard die with the terminal the moment the run finishes, which is
117
+ * never what "bring the stack up" meant.
118
+ *
119
+ * ELECTRON_RUN_AS_NODE is stripped from every child, not just the app. Editors
120
+ * built on Electron -- Cursor, VS Code -- export it to their integrated
121
+ * terminals, and it turns any Electron binary into a plain Node process that
122
+ * runs no app, prints nothing, and exits 0 in under 100ms. Inherited, it makes
123
+ * the window silently never appear.
124
+ */
125
+ function startDetached(command, args, { cwd, env = {}, logFile }) {
126
+ const childEnv = { ...process.env, ...env };
127
+ delete childEnv.ELECTRON_RUN_AS_NODE;
128
+ const out = fs.openSync(logFile, "a");
129
+ const child = spawn(command, args, {
130
+ cwd,
131
+ env: childEnv,
132
+ detached: true,
133
+ stdio: ["ignore", out, out],
134
+ });
135
+ child.unref();
136
+ return child;
137
+ }
138
+
139
+ function runSync(command, args, { cwd, env = {}, quiet = false } = {}) {
140
+ const childEnv = { ...process.env, ...env };
141
+ delete childEnv.ELECTRON_RUN_AS_NODE;
142
+ return spawnSync(command, args, {
143
+ cwd,
144
+ env: childEnv,
145
+ encoding: "utf8",
146
+ stdio: quiet ? "pipe" : ["ignore", "pipe", "pipe"],
147
+ });
148
+ }
149
+
150
+ async function ensureDatabase(backendDir) {
151
+ // `--wait db` rather than a bare `up -d`: the compose file also carries an
152
+ // api container on another port, and two APIs against one database is a
153
+ // confusing way to spend an afternoon. --wait blocks on the healthcheck, so
154
+ // the API never starts against a Postgres that is still opening its socket.
155
+ const result = runSync("docker", ["compose", "up", "-d", "--wait", "db"], {
156
+ cwd: backendDir,
157
+ });
158
+ if (result.status !== 0) {
159
+ const detail = (result.stderr || result.stdout || "").trim().split("\n").slice(-3).join("\n");
160
+ throw new Error(`could not start Postgres:\n${detail}`);
161
+ }
162
+ }
163
+
164
+ async function ensureBackend(backendDir, backendUrl) {
165
+ if ((await reachable(`${backendUrl}/health`)) === 200) {
166
+ note(`already up on ${backendUrl}`);
167
+ return;
168
+ }
169
+ say("starting Postgres");
170
+ await ensureDatabase(backendDir);
171
+
172
+ say(`starting the API on ${backendUrl}`);
173
+ // api.py runs the migrations and the workload worker in-process during
174
+ // lifespan startup, so this one process is the whole backend: there is no
175
+ // separate migrate step to forget and no worker to start beside it.
176
+ const port = new URL(backendUrl).port || "8000";
177
+ startDetached("uv", ["run", "--no-sync", "python", "api.py"], {
178
+ cwd: backendDir,
179
+ env: { PORT: port },
180
+ logFile: "/tmp/preman-predict-api.log",
181
+ });
182
+ const up = await waitUntil(async () => (await reachable(`${backendUrl}/health`)) === 200, {
183
+ seconds: 90,
184
+ label: "the API",
185
+ });
186
+ if (!up) {
187
+ throw new Error(
188
+ `the API did not answer on ${backendUrl}/health within 90s.\n` +
189
+ `Its output is in /tmp/preman-predict-api.log`
190
+ );
191
+ }
192
+ }
193
+
194
+ /**
195
+ * One key decides two things that have to agree: which account the window logs
196
+ * into, and which account the run is filed under. When they disagree the run
197
+ * succeeds and the page stays empty, because the app is looking at a different
198
+ * tenant -- no error anywhere.
199
+ *
200
+ * So it is resolved once, here. ~/.preman/dev-local.json comes before dev.env's
201
+ * EVAL_API_KEY, which belongs to a different account and would send the window
202
+ * somewhere you are not. ~/.preman/credentials.json is deliberately not
203
+ * consulted: that one is for production.
204
+ */
205
+ function resolveApiKey() {
206
+ const fromEnv = (process.env.PREMAN_API_KEY || "").trim();
207
+ if (fromEnv) return { key: fromEnv, origin: "PREMAN_API_KEY" };
208
+ if (fs.existsSync(DEV_LOCAL)) {
209
+ try {
210
+ const parsed = JSON.parse(fs.readFileSync(DEV_LOCAL, "utf8"));
211
+ if (parsed?.api_key) return { key: String(parsed.api_key), origin: DEV_LOCAL };
212
+ } catch {
213
+ // A corrupt dev file is not worth a stack trace; fall through to the error.
214
+ }
215
+ }
216
+ return { key: "", origin: "" };
217
+ }
218
+
219
+ /**
220
+ * Prove the key works here *and* that the account owning it can see the
221
+ * product, before anything slow happens. A 402 is not an error to report at the
222
+ * end of a two-minute run.
223
+ */
224
+ async function ensureEntitlement({ backendDir, backendUrl, key, email }) {
225
+ const status = await reachable(`${backendUrl}/workload/evals/regimes`, {
226
+ headers: { authorization: `Bearer ${key}` },
227
+ });
228
+ if (status === 200) return true;
229
+ if (status === 401) {
230
+ throw new Error(
231
+ `that key is not valid for ${backendUrl}.\n` +
232
+ `The key in ~/.preman/credentials.json is for production; this needs a local one.`
233
+ );
234
+ }
235
+ if (status !== 402) {
236
+ throw new Error(`${backendUrl}/workload/evals/regimes answered ${status || "nothing"}`);
237
+ }
238
+
239
+ say("granting this account the workload product");
240
+ // Gated per-account and off by default, so a fresh database always lands here
241
+ // once. Granting it by email rather than letting the script pick the oldest
242
+ // user is the whole point: the oldest user need not be the one signing in.
243
+ const args = ["run", "--no-sync", "python", "scripts/enable_workload_locally.py"];
244
+ if (email) args.push("--email", email);
245
+ const result = runSync("uv", args, { cwd: backendDir });
246
+ if (result.status !== 0) {
247
+ throw new Error(
248
+ `could not grant the workload product:\n${(result.stderr || result.stdout || "").trim()}`
249
+ );
250
+ }
251
+ note((result.stdout || "").trim().split("\n").pop() || "granted");
252
+ return (
253
+ (await reachable(`${backendUrl}/workload/evals/regimes`, {
254
+ headers: { authorization: `Bearer ${key}` },
255
+ })) === 200
256
+ );
257
+ }
258
+
259
+ async function ensureDashboard(dashboardDir) {
260
+ // /@vite/client rather than /: something unrelated may hold the port, and
261
+ // pointing the shell at it would put a stranger's app in a PreMan window.
262
+ // Only Vite serves that path, so it is the question worth asking.
263
+ const serving = async () => (await reachable(`${DASHBOARD_ORIGIN}/@vite/client`)) === 200;
264
+ if (await serving()) {
265
+ note(`already serving on ${DASHBOARD_ORIGIN}`);
266
+ return true;
267
+ }
268
+ say(`starting the dashboard on ${DASHBOARD_ORIGIN}`);
269
+ startDetached("npm", ["run", "dev"], {
270
+ cwd: dashboardDir,
271
+ logFile: "/tmp/preman-predict-dashboard.log",
272
+ });
273
+ const up = await waitUntil(serving, { seconds: 60, label: "Vite" });
274
+ if (!up) {
275
+ throw new Error(
276
+ `Vite did not come up on ${DASHBOARD_ORIGIN} within 60s.\n` +
277
+ `Its output is in /tmp/preman-predict-dashboard.log`
278
+ );
279
+ }
280
+ return true;
281
+ }
282
+
283
+ /**
284
+ * Hand the app a session, and learn which account it belongs to.
285
+ *
286
+ * Without this the window cold-starts on a login screen and makes no requests
287
+ * at all. The app consumes the file on read, so it is minted fresh on every
288
+ * launch rather than reused. The email it prints is the one fact that proves
289
+ * the window and the run will agree about who they are.
290
+ */
291
+ function mintDesktopSession(backendDir, key) {
292
+ const result = runSync("uv", ["run", "--no-sync", "python", "scripts/dev_desktop_session.py"], {
293
+ cwd: backendDir,
294
+ env: { PREMAN_API_KEY: key },
295
+ });
296
+ const output = `${result.stdout || ""}${result.stderr || ""}`.trim();
297
+ if (result.status !== 0) throw new Error(`could not mint a desktop session:\n${output}`);
298
+ const email = output.match(/<([^>]+)>/)?.[1] || "";
299
+ return { email, line: output.split("\n").pop() || "" };
300
+ }
301
+
302
+ /**
303
+ * Restart the app on the Accuracy page, from source.
304
+ *
305
+ * From source rather than the installed build for two reasons: the release in
306
+ * /Applications predates this page, and only a from-source shell honours
307
+ * PREMAN_START_ROUTE. Pointed at Vite so the window runs the working tree with
308
+ * hot reload rather than a bundle that was current whenever it was built.
309
+ *
310
+ * Restarted rather than reused because a running instance cannot pick up new
311
+ * environment variables, and -9 because the app traps SIGTERM for a 15s cleanup
312
+ * phase while squatting the port the next one wants.
313
+ */
314
+ async function restartDesktop({ desktopDir, backendUrl, route = ACCURACY_ROUTE }) {
315
+ spawnSync("pkill", ["-9", "-f", "PreMan.app/Contents/MacOS/PreMan"], { stdio: "ignore" });
316
+ spawnSync("pkill", ["-9", "-f", "desktop/node_modules/electron"], { stdio: "ignore" });
317
+ await new Promise((resolve) => setTimeout(resolve, 2000));
318
+
319
+ const electron = path.join(desktopDir, "node_modules", ".bin", "electron");
320
+ if (!fs.existsSync(electron)) {
321
+ throw new Error(
322
+ `no electron in ${desktopDir}.\n` + `Run \`npm install\` there once, or pass --no-app.`
323
+ );
324
+ }
325
+ startDetached(electron, [desktopDir], {
326
+ cwd: desktopDir,
327
+ env: {
328
+ PREMAN_API_URL: backendUrl,
329
+ PREMAN_DEV_SERVER: DASHBOARD_ORIGIN,
330
+ PREMAN_DESKTOP_PORT: DESKTOP_PORT,
331
+ PREMAN_START_ROUTE: route,
332
+ },
333
+ logFile: "/tmp/preman-predict-app.log",
334
+ });
335
+ const up = await waitUntil(
336
+ async () => (await reachable(`http://127.0.0.1:${DESKTOP_PORT}/`)) > 0,
337
+ { seconds: 45, label: "the window" }
338
+ );
339
+ if (!up) {
340
+ note("the window did not answer in 45s; carrying on — /tmp/preman-predict-app.log has why");
341
+ }
342
+ return up;
343
+ }
344
+
345
+ /**
346
+ * What a run actually reads, said out loud.
347
+ *
348
+ * Worth its own flag because the honest answer is counter-intuitive and the
349
+ * intuitive answer is wrong in a way that wastes an afternoon: this reads no
350
+ * repository, no logs and no infrastructure. It runs from any directory and
351
+ * would behave identically from an empty one. The world comes from a pack
352
+ * declared out of Simula's public SDK, and the traffic from a seeded simulator
353
+ * -- which is what makes the scoring possible at all, since only a synthetic
354
+ * world has a ground truth to be scored against.
355
+ */
356
+ async function showInputs({ backendUrl, key }) {
357
+ const authed = { headers: { authorization: `Bearer ${key}` } };
358
+ const packsResponse = await fetch(`${backendUrl}/workload/packs`, authed);
359
+ const regimesResponse = await fetch(`${backendUrl}/workload/evals/regimes`, authed);
360
+ if (!packsResponse.ok) {
361
+ throw new Error(`${backendUrl}/workload/packs answered ${packsResponse.status}`);
362
+ }
363
+ const { packs = [] } = await packsResponse.json();
364
+ const regimesBody = regimesResponse.ok ? await regimesResponse.json() : {};
365
+ const regimes = regimesBody.regimes || regimesBody || [];
366
+
367
+ say("what a run reads");
368
+ note("nothing from this directory: no repository, no logs, no cloud account.");
369
+ note("it would produce the same numbers run from an empty folder.");
370
+
371
+ for (const pack of packs) {
372
+ say(`the world: ${pack.label || pack.key}`);
373
+ if (pack.summary) note(pack.summary);
374
+ note(
375
+ `${pack.endpoints} endpoints, ${pack.drivers} drivers, ` +
376
+ `${pack.fanout_units} fan-out units, ${(pack.worlds || []).length} worlds`
377
+ );
378
+ if (pack.findings?.length) {
379
+ say("claims it makes about Simula, from their public SDK alone");
380
+ for (const finding of pack.findings) note(`- ${finding}`);
381
+ }
382
+ if (pack.open_questions?.length) {
383
+ say("what it does not know, and what would answer it");
384
+ for (const question of pack.open_questions) {
385
+ note(`- ${question.question}`);
386
+ if (question.answerable_at_tier) {
387
+ note(` readable once you grant: ${question.answerable_at_tier}`);
388
+ }
389
+ }
390
+ }
391
+ }
392
+
393
+ say("the traffic: a seeded simulator, not a recording");
394
+ note("each regime is a world with a known answer, which is what makes scoring possible.");
395
+ for (const regime of regimes) {
396
+ note(`- ${regime.key || regime}: ${regime.label || ""}`);
397
+ if (regime.hypothesis) note(` expects: ${regime.hypothesis}`);
398
+ }
399
+ return 0;
400
+ }
401
+
402
+ async function startEval({ backendUrl, key, body }) {
403
+ const response = await fetch(`${backendUrl}/workload/evals`, {
404
+ method: "POST",
405
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
406
+ body: JSON.stringify(body),
407
+ });
408
+ const text = await response.text();
409
+ if (response.status === 409) {
410
+ // The identical experiment is already in flight. Deduplicated on purpose --
411
+ // attaching to it is what the reader wanted anyway.
412
+ return { alreadyRunning: true };
413
+ }
414
+ if (!response.ok) {
415
+ throw new Error(`starting the experiment failed (${response.status}): ${text.slice(0, 400)}`);
416
+ }
417
+ return { run: JSON.parse(text) };
418
+ }
419
+
420
+ async function latestEval({ backendUrl, key }) {
421
+ const response = await fetch(`${backendUrl}/workload/evals/latest`, {
422
+ headers: { authorization: `Bearer ${key}` },
423
+ });
424
+ if (!response.ok) return null;
425
+ return response.json();
426
+ }
427
+
428
+ /**
429
+ * `--seeds 5` means five seeds, not the single seed 5.
430
+ *
431
+ * The API takes the seed values themselves, because which seeds were used is
432
+ * part of what makes a result reproducible. But nobody choosing how long to
433
+ * wait is thinking in seed values, they are thinking in "how many runs", so a
434
+ * bare count is expanded here and an explicit list is passed through for anyone
435
+ * reproducing a specific experiment.
436
+ */
437
+ function parseSeeds(raw) {
438
+ const text = String(raw).trim();
439
+ if (text.includes(",")) {
440
+ return text.split(",").map((part) => Number(part.trim())).filter(Number.isFinite);
441
+ }
442
+ const count = Number(text);
443
+ if (!Number.isFinite(count) || count < 1) return DEFAULT_SEEDS.slice(0, 1);
444
+ // Extending the service's own defaults rather than inventing a parallel set,
445
+ // so `--seeds 3` reproduces exactly what a default run measured.
446
+ const seeds = [];
447
+ for (let index = 0; index < count; index += 1) {
448
+ seeds.push(DEFAULT_SEEDS[index] ?? DEFAULT_SEEDS[0] + index * 7);
449
+ }
450
+ return seeds;
451
+ }
452
+
453
+ /**
454
+ * Which access tiers to sweep, or null for the default single-tier run.
455
+ *
456
+ * Usable as a bare flag, because the whole ladder is what nearly everyone
457
+ * asking this question wants; a list is for narrowing it afterwards. The bare
458
+ * form is why this cannot read the next argument unconditionally:
459
+ * `--tiers --no-app` asked for a tier named "--NO-APP", which normalised to
460
+ * nothing and ran without the sweep. The run still succeeded and still printed
461
+ * a headline, so the only sign was one sentence from the analyst saying the
462
+ * sweep had not happened.
463
+ */
464
+ export function tiersFromArgs(args) {
465
+ const index = args.indexOf("--tiers");
466
+ if (index < 0) return null;
467
+ const next = args[index + 1];
468
+ if (!next || next.startsWith("-")) return ["T1", "T2", "T3", "T4"];
469
+ return next
470
+ .split(",")
471
+ .map((part) => part.trim().toUpperCase())
472
+ .filter(Boolean);
473
+ }
474
+
475
+ /** Skill is a fraction of the available headroom, so 0.484 is 48%. */
476
+ function percent(value) {
477
+ return value === null || value === undefined ? "n/a" : `${Math.round(Number(value) * 100)}%`;
478
+ }
479
+
480
+ /**
481
+ * Render one event as a line somebody would want to read.
482
+ *
483
+ * The runtime prefixes every event with `eval_`, including the two the service
484
+ * already named `eval_started` and `eval_finished` -- so those arrive as
485
+ * `eval_eval_started`. Normalised here rather than fixed there: the page reads
486
+ * these same names, and renaming a published event to tidy a prefix would break
487
+ * a reader to please a log.
488
+ *
489
+ * Unknown types print their name rather than being dropped, so a run that grows
490
+ * a new phase shows it here without this file needing an edit.
491
+ */
492
+ function describeEvent(rawType, data) {
493
+ const type = String(rawType).replace(/^eval_/, "").replace(/^eval_/, "");
494
+ if (type === "started") {
495
+ return `measuring ${data.regimes?.length ?? "?"} regimes x ${data.seeds?.length ?? data.seeds ?? "?"} seeds`;
496
+ }
497
+ if (type === "ingested") {
498
+ return (
499
+ `ingested ${data.regime}: ${data.requests_total ?? "?"} requests over ` +
500
+ `${data.endpoints_seen ?? "?"} endpoints, ${data.train_days ?? "?"} days of history`
501
+ );
502
+ }
503
+ if (type === "regime_started") return `scoring ${data.regime}…`;
504
+ if (type === "regime_finished") {
505
+ const result = data.result || {};
506
+ // A regime whose baseline is already at the oracle floor has no accuracy
507
+ // left to win, and a skill figure computed against roughly zero headroom is
508
+ // noise. The scoring says so with skill_is_meaningful; quoting 0% there
509
+ // would read as "no better than the baseline" rather than "nothing to beat".
510
+ const skill =
511
+ result.skill_is_meaningful === false
512
+ ? "no headroom to beat"
513
+ : `${percent(result.skill_vs_baseline)} unaided, ${percent(result.skill_if_answered)} answered`;
514
+ // Always say whether the prediction held, never only when it failed.
515
+ // Some regimes are not scored on skill at all -- unmodelled_shock exists to
516
+ // check the model reports an unexplained residual rather than absorbing it
517
+ // -- so "no headroom to beat" alone reads as a dud result when it is
518
+ // actually the regime passing the test it was written for.
519
+ const held =
520
+ result.hypothesis_held === true
521
+ ? " [hypothesis held]"
522
+ : result.hypothesis_held === false
523
+ ? " [hypothesis did NOT hold]"
524
+ : "";
525
+ return `${result.label || data.regime}: ${skill}${held}`;
526
+ }
527
+ if (type === "reading") {
528
+ return data.ran ? "the analyst read the table" : `analyst skipped: ${data.reason || "no key"}`;
529
+ }
530
+ if (type === "finished") return "experiment finished";
531
+ return type;
532
+ }
533
+
534
+ /**
535
+ * Follow the run over the same stream the app reads.
536
+ *
537
+ * Terminal output is a courtesy here, not the deliverable -- the app is the
538
+ * deliverable -- so a stream that drops is reported and the command still exits
539
+ * on the run's real status, read back from the API.
540
+ */
541
+ async function streamRun({ backendUrl, key, runId }) {
542
+ const response = await fetch(`${backendUrl}/workbench/automation-runs/${runId}/stream`, {
543
+ headers: { authorization: `Bearer ${key}`, accept: "text/event-stream" },
544
+ });
545
+ if (!response.ok || !response.body) {
546
+ note(`could not attach to the stream (${response.status}); the run continues regardless`);
547
+ return null;
548
+ }
549
+
550
+ const decoder = new TextDecoder();
551
+ let buffer = "";
552
+ let status = null;
553
+
554
+ for await (const chunk of response.body) {
555
+ buffer += decoder.decode(chunk, { stream: true });
556
+ const frames = buffer.split("\n\n");
557
+ buffer = frames.pop() ?? "";
558
+ for (const frame of frames) {
559
+ const line = frame.split("\n").find((candidate) => candidate.startsWith("data: "));
560
+ if (!line) continue;
561
+ let payload;
562
+ try {
563
+ payload = JSON.parse(line.slice(6));
564
+ } catch {
565
+ continue;
566
+ }
567
+ if (payload.type === "snapshot") {
568
+ status = payload.run?.status ?? status;
569
+ } else if (payload.type === "event") {
570
+ // `type`, not `event_type`: the column is event_type but the repo's
571
+ // projection renames it, and this reads the projection.
572
+ const { type: eventType, data } = payload.event || {};
573
+ if (eventType?.startsWith("eval_")) note(describeEvent(eventType, data || {}));
574
+ if (eventType === "run_finished" || eventType === "run_failed") status = "done";
575
+ }
576
+ }
577
+ }
578
+ return status;
579
+ }
580
+
581
+ function summarise(run) {
582
+ const artifacts = run?.artifacts || [];
583
+ const summaryArtifact = artifacts.find((a) => a.kind === "workload_eval_summary");
584
+ if (!summaryArtifact) return;
585
+ const { summary, reading } = summaryArtifact.content || {};
586
+ if (summary?.verdict) {
587
+ say("what it measured");
588
+ for (const line of String(summary.verdict).trim().split("\n")) note(line);
589
+ if (summary.measured_at_tier) {
590
+ note("");
591
+ note(`measured with ${summary.measured_at_tier} access unless a tier is named below`);
592
+ }
593
+ }
594
+ if (Array.isArray(summary?.tier_curve) && summary.tier_curve.length) {
595
+ say("what each level of access bought");
596
+ note("tier mix error skill names 1st with a number what it adds");
597
+ for (const rung of summary.tier_curve) {
598
+ const skill = rung.skill_is_meaningful === false ? "n/a" : percent(rung.skill_vs_baseline);
599
+ note(
600
+ `${String(rung.tier).padEnd(6)} ${Number(rung.mix_error).toFixed(4).padEnd(11)} ` +
601
+ `${skill.padEnd(8)} ${percent(rung.names_the_first_to_break).padEnd(11)} ` +
602
+ `${percent(rung.endpoints_with_a_number).padEnd(15)} ` +
603
+ `${String(rung.what_it_adds || "").split(".")[0]}`
604
+ );
605
+ }
606
+ // The sentence a customer hears, rather than the columns an engineer
607
+ // reads. "88% of endpoints carry a multiple" is the claim; "0.0812" is
608
+ // not something anybody can act on in a meeting.
609
+ say("what you could tell them at each level");
610
+ for (const rung of summary.tier_curve) {
611
+ if (!rung.what_it_can_say) continue;
612
+ note(`${rung.tier}: ${rung.what_it_can_say}`);
613
+ note("");
614
+ }
615
+ }
616
+ if (reading?.ran && reading?.text) {
617
+ say("what the analyst made of it");
618
+ for (const line of String(reading.text).trim().split("\n")) note(line);
619
+ }
620
+ }
621
+
622
+ /** The value after a flag, or a fallback. Module level so the forecast path
623
+ * and the experiment path read arguments the same way. */
624
+ function valueIn(args, name, fallback = "") {
625
+ const index = args.indexOf(name);
626
+ return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
627
+ }
628
+
629
+
630
+ /** Run one forecast and stream it, with the app open on the page that shows it.
631
+ *
632
+ * Separate from the accuracy experiment because they answer different
633
+ * questions. The experiment scores the forecaster across six synthetic worlds
634
+ * and takes a minute; this asks the forecaster what it makes of *this*
635
+ * customer, which is the thing somebody wants to look at first.
636
+ */
637
+ async function runForecast({ backendUrl, key, wantApp, args }) {
638
+ const withhold = (valueIn(args, "--without") || "")
639
+ .split(",")
640
+ .map((source) => source.trim())
641
+ .filter(Boolean);
642
+
643
+ const body = {
644
+ pack_key: valueIn(args, "--pack") || "simula",
645
+ unattended: true,
646
+ horizon_days: Number(valueIn(args, "--horizon")) || 90,
647
+ ...(withhold.length ? { withhold } : {}),
648
+ };
649
+
650
+ say("starting the forecast");
651
+ if (withhold.length) note(`running without: ${withhold.join(", ")}`);
652
+
653
+ const response = await fetch(`${backendUrl}/workload/forecasts`, {
654
+ method: "POST",
655
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
656
+ body: JSON.stringify(body),
657
+ });
658
+ if (!response.ok) {
659
+ return fail(`the forecast would not start: ${response.status} ${await response.text()}`);
660
+ }
661
+ const run = await response.json();
662
+ if (!run?.id) return fail("the run started but the API would not say which one it was");
663
+ note(`run ${run.id}`);
664
+ if (wantApp) note("the app is watching this run on the Forecast page");
665
+
666
+ say("following along");
667
+ await streamRun({ backendUrl, key, runId: run.id });
668
+
669
+ const forecast = await fetch(`${backendUrl}/workload/forecast`, {
670
+ headers: { authorization: `Bearer ${key}` },
671
+ })
672
+ .then((result) => (result.ok ? result.json() : null))
673
+ .catch(() => null);
674
+
675
+ if (forecast) {
676
+ const summary = forecast.summary || {};
677
+ say("what it read");
678
+ for (const row of summary.inputs || []) {
679
+ note(`[${row.status}] ${row.label} -- ${row.detail}`);
680
+ }
681
+ // Said here rather than only on the page, and before the recommendation,
682
+ // because it is what the recommendation rests on. The sources above are
683
+ // measurement; everything below this line comes from a model of what the
684
+ // customer's users do, and the identity check is that model being tested
685
+ // against the logs rather than fitted to them.
686
+ const behaviour = summary.behaviour || {};
687
+ const identity = behaviour.identity || {};
688
+ say("does the model match the traffic");
689
+ if (identity.checked) {
690
+ const off = (Number(identity.error) * 100).toFixed(1);
691
+ note(
692
+ identity.agrees
693
+ ? `Yes, to within ${off}%: ${Number(identity.observed).toFixed(2)} tracking calls per impression against the ${identity.expected} the SDK makes.`
694
+ : `No: ${Number(identity.observed).toFixed(2)} tracking calls per impression against the ${identity.expected} the SDK makes, so load is arriving from outside the modelled funnel.`
695
+ );
696
+ } else {
697
+ note("Not checked: the test needs both request logs and reported impressions.");
698
+ }
699
+ note(
700
+ `${behaviour.measured_drivers ?? 0} of ${behaviour.total_drivers ?? 0} drivers were measured; the rest are the model's priors.`
701
+ );
702
+
703
+ // Grouped by the constraint each purchase relieves rather than by the
704
+ // endpoint that revealed it, and each line says what it buys. A plan that
705
+ // recommended work without saying what the work moves is selling work.
706
+ const plan = summary.resource_plan || {};
707
+ const actions = plan.actions || [];
708
+ if (actions.length) {
709
+ say("resource planning");
710
+ for (const action of actions) {
711
+ note(`[${action.factor}] ${action.headline}`);
712
+ const relieved = action.relieved_to_multiple;
713
+ note(
714
+ ` binds at ${action.binds_at_multiple}x today's traffic; ` +
715
+ (relieved
716
+ ? `doing it moves that to ${relieved}x, then ${action.then_binds_on} binds`
717
+ : "no other ceiling here was measured, so the gain is unknown")
718
+ );
719
+ note(` ${action.why}`);
720
+ }
721
+ if (plan.unmeasured_endpoints) {
722
+ note(
723
+ `${plan.unmeasured_endpoints} endpoints have no measured ceiling, so nothing is suggested for them.`
724
+ );
725
+ }
726
+ }
727
+
728
+ const lead = (summary.index_recommendations || [])[0];
729
+ if (lead) {
730
+ say("the statement to run");
731
+ note(lead.statement);
732
+ }
733
+ say("where to look");
734
+ note(`the Forecast page, at tier ${forecast.access_tier}. Every source above`);
735
+ note("has a switch there: turn one off and run it again to see what is lost.");
736
+ note("the Behaviour model tab shows what your users are assumed to do, with");
737
+ note("the pack's guess beside the measurement wherever the data reached it.");
738
+ }
739
+ return 0;
740
+ }
741
+
742
+
743
+ export async function predictCommand(commandArgs = []) {
744
+ const args = commandArgs;
745
+ const flag = (name) => args.includes(name);
746
+ const value = (name, fallback = "") => valueIn(args, name, fallback);
747
+
748
+ // Before anything is started. Asking what a command does and having it boot
749
+ // Postgres, the API, the dashboard and restart your editor's window is a
750
+ // trap, and `--help` is the one flag somebody types precisely because they
751
+ // do not yet know what the command will do.
752
+ if (flag("--help") || flag("-h")) {
753
+ process.stdout.write(PREDICT_HELP);
754
+ return 0;
755
+ }
756
+
757
+ const checkout = resolveCheckout();
758
+ if (!checkout) {
759
+ return fail(
760
+ "preman predict drives a local checkout, and this copy was installed from the registry.\n" +
761
+ "Run it from the repo: `node PreMan-Backend/preman-mcp/bin/cli.js predict`."
762
+ );
763
+ }
764
+ const { backendDir, dashboardDir, desktopDir } = checkout;
765
+ const backendUrl = (value("--backend", DEFAULT_BACKEND) || DEFAULT_BACKEND).replace(/\/$/, "");
766
+ const wantApp = !flag("--no-app");
767
+
768
+ try {
769
+ say("checking the backend");
770
+ await ensureBackend(backendDir, backendUrl);
771
+
772
+ const { key, origin } = resolveApiKey();
773
+ if (!key) {
774
+ return fail(
775
+ `no local API key.\n` +
776
+ `Mint one with:\n` +
777
+ ` cd ${backendDir} && uv run --no-sync python scripts/create_test_user.py --mint-api-key\n` +
778
+ `then save it to ${DEV_LOCAL} as {"api_key": "pm_live_…"}, or export PREMAN_API_KEY.`
779
+ );
780
+ }
781
+ note(`key from ${origin}`);
782
+
783
+ if (flag("--show-inputs")) {
784
+ // Before the app, the session and the run: this answers a question, and
785
+ // restarting somebody's window to answer it would be rude.
786
+ if (!(await ensureEntitlement({ backendDir, backendUrl, key, email: "" }))) {
787
+ return fail("the workload product is not enabled for that account");
788
+ }
789
+ return showInputs({ backendUrl, key });
790
+ }
791
+
792
+ let email = "";
793
+ if (wantApp) {
794
+ say("giving the app a session to start logged in");
795
+ const session = mintDesktopSession(backendDir, key);
796
+ email = session.email;
797
+ note(session.line);
798
+ }
799
+
800
+ if (!(await ensureEntitlement({ backendDir, backendUrl, key, email }))) {
801
+ return fail("the workload product is still not enabled for that account");
802
+ }
803
+
804
+ const forecasting = flag("--forecast");
805
+ const page = forecasting ? "Forecast" : "Accuracy";
806
+
807
+ if (wantApp) {
808
+ say("checking the dashboard");
809
+ await ensureDashboard(dashboardDir);
810
+ say(`restarting the app on the ${page} page`);
811
+ await restartDesktop({
812
+ desktopDir,
813
+ backendUrl,
814
+ route: forecasting ? FORECAST_ROUTE : ACCURACY_ROUTE,
815
+ });
816
+ }
817
+
818
+ if (flag("--app-only")) {
819
+ say("ready");
820
+ note(`the stack is up and the app is on the ${page} page; no run was started`);
821
+ return 0;
822
+ }
823
+
824
+ if (forecasting) {
825
+ return runForecast({ backendUrl, key, wantApp, args });
826
+ }
827
+
828
+ const body = {};
829
+ if (value("--seeds")) body.seeds = parseSeeds(value("--seeds"));
830
+ if (value("--regimes")) body.regime_keys = value("--regimes").split(",").map((s) => s.trim());
831
+ if (value("--train-days")) body.train_days = Number(value("--train-days"));
832
+ if (value("--test-days")) body.test_days = Number(value("--test-days"));
833
+ const tiers = tiersFromArgs(args);
834
+ if (tiers) body.sweep_tiers = tiers;
835
+
836
+ say("starting the accuracy experiment");
837
+ const started = await startEval({ backendUrl, key, body });
838
+ if (started.alreadyRunning) note("that experiment is already in flight; attaching to it");
839
+
840
+ const latest = await latestEval({ backendUrl, key });
841
+ const runId = started.run?.id || latest?.id;
842
+ if (!runId) return fail("the run started but the API would not say which one it was");
843
+ note(`run ${runId}`);
844
+ if (wantApp) note("the app is watching this run on the Accuracy page");
845
+
846
+ say("following along");
847
+ await streamRun({ backendUrl, key, runId });
848
+
849
+ const finished = await latestEval({ backendUrl, key });
850
+ summarise(finished);
851
+ const status = finished?.status || "unknown";
852
+ say(`run ${status}`);
853
+ return status === "failed" ? 1 : 0;
854
+ } catch (error) {
855
+ return fail(String(error?.message || error));
856
+ }
857
+ }