oracular 0.2.0 → 0.3.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/dist/cli.js ADDED
@@ -0,0 +1,656 @@
1
+ #!/usr/bin/env node
2
+
3
+ // cli/main.ts
4
+ import { readFileSync } from "fs";
5
+
6
+ // cli/run.ts
7
+ import { createRequire } from "module";
8
+ import { extname, resolve } from "path";
9
+ import { pathToFileURL } from "url";
10
+ import { createState as createState2 } from "oracular";
11
+
12
+ // cli/log.ts
13
+ import { RECORD_VERSION } from "oracular";
14
+ var LogError = class extends Error {
15
+ constructor() {
16
+ super(...arguments);
17
+ this.name = "LogError";
18
+ }
19
+ };
20
+ function isObject(value) {
21
+ return typeof value === "object" && value !== null && !Array.isArray(value);
22
+ }
23
+ function parseLog(text, source) {
24
+ const entries = [];
25
+ const unknownKinds = /* @__PURE__ */ new Map();
26
+ const lines = text.split(/\r?\n/);
27
+ for (const [i, raw] of lines.entries()) {
28
+ if (raw.trim() === "") continue;
29
+ const where = `${source}:${i + 1}`;
30
+ let record;
31
+ try {
32
+ record = JSON.parse(raw);
33
+ } catch {
34
+ throw new LogError(`${where}: not valid JSON`);
35
+ }
36
+ if (!isObject(record)) throw new LogError(`${where}: a record must be a JSON object`);
37
+ if (record.v !== RECORD_VERSION) {
38
+ throw new LogError(
39
+ `${where}: recorder format v${String(record.v)} is not supported (this replay reads v${RECORD_VERSION})`
40
+ );
41
+ }
42
+ const { installId, mode, kind } = record;
43
+ if (typeof installId !== "string" || installId === "") {
44
+ throw new LogError(`${where}: installId must be a non-empty string`);
45
+ }
46
+ if (mode !== "shadow" && mode !== "live") {
47
+ throw new LogError(`${where}: mode must be "shadow" or "live"`);
48
+ }
49
+ if (kind === "event") {
50
+ const event = record.event;
51
+ if (!isObject(event) || typeof event.name !== "string" || event.name === "") {
52
+ throw new LogError(`${where}: event.name must be a non-empty string`);
53
+ }
54
+ if (typeof event.at !== "number" || !Number.isFinite(event.at)) {
55
+ throw new LogError(`${where}: event.at must be a finite number`);
56
+ }
57
+ entries.push({ kind, installId, mode, name: event.name, at: event.at });
58
+ } else if (kind === "end") {
59
+ if (typeof record.at !== "number" || !Number.isFinite(record.at)) {
60
+ throw new LogError(`${where}: at must be a finite number`);
61
+ }
62
+ entries.push({ kind, installId, mode, at: record.at });
63
+ } else {
64
+ const label = typeof kind === "string" ? kind : String(kind);
65
+ unknownKinds.set(label, (unknownKinds.get(label) ?? 0) + 1);
66
+ }
67
+ }
68
+ const warnings = [...unknownKinds].map(
69
+ ([kind, n]) => `${source}: skipped ${n} record${n === 1 ? "" : "s"} of unknown kind "${kind}"`
70
+ );
71
+ return { entries, warnings };
72
+ }
73
+
74
+ // cli/replay.ts
75
+ import {
76
+ createState,
77
+ endEpisode,
78
+ markRevealed,
79
+ observe,
80
+ predict,
81
+ shouldReveal
82
+ } from "oracular";
83
+ var DEFAULT_THRESHOLDS = [0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9];
84
+ var DEFAULT_THRESHOLD = 0.5;
85
+ function replay(entries, config, options = {}) {
86
+ const base = createState(config);
87
+ const normalized = base.config;
88
+ const includeLive = options.includeLive === true;
89
+ const bins = options.bins ?? 10;
90
+ const skipped = [];
91
+ const names = options.intents ?? Object.keys(normalized.intents);
92
+ for (const name of names) {
93
+ if (!Object.hasOwn(normalized.intents, name)) {
94
+ throw new RangeError(
95
+ `unknown intent ${JSON.stringify(name)} (the config declares: ${Object.keys(normalized.intents).join(", ")})`
96
+ );
97
+ }
98
+ }
99
+ const scored = names.filter((name) => {
100
+ if (normalized.intents[name].outcome !== void 0) return true;
101
+ skipped.push({
102
+ intent: name,
103
+ reason: "declares no outcome event, so there is nothing to score"
104
+ });
105
+ return false;
106
+ });
107
+ const plans = scored.map((name) => {
108
+ const intent = normalized.intents[name];
109
+ const configured = intent.reveal?.threshold ?? normalized.reveal?.threshold ?? DEFAULT_THRESHOLD;
110
+ const list = options.thresholds ?? [...DEFAULT_THRESHOLDS, configured];
111
+ const thresholds = [...new Set(list)].sort((a, b) => a - b);
112
+ const cooldown = predict(markRevealed(base, name, 0), name, 0).suppressedUntil ?? 0;
113
+ const window = options.window ?? cooldown;
114
+ if (!(window > 0)) {
115
+ throw new RangeError(
116
+ options.window === void 0 ? `intent ${JSON.stringify(name)} has a reveal cooldown of 0, so there is no default scoring window; pass --window (for example --window 7d)` : "the scoring window must be greater than 0"
117
+ );
118
+ }
119
+ return {
120
+ name,
121
+ outcome: intent.outcome,
122
+ configured,
123
+ thresholds,
124
+ window,
125
+ windowIsCooldown: options.window === void 0
126
+ };
127
+ });
128
+ const allThresholds = [...new Set(plans.flatMap((p) => p.thresholds))].sort((a, b) => a - b);
129
+ const byInstall = /* @__PURE__ */ new Map();
130
+ for (const entry of entries) {
131
+ const list = byInstall.get(entry.installId);
132
+ if (list === void 0) byInstall.set(entry.installId, [entry]);
133
+ else list.push(entry);
134
+ }
135
+ let start = null;
136
+ let end = null;
137
+ let shadow = 0;
138
+ let live = 0;
139
+ let mixedInstalls = 0;
140
+ for (const list of byInstall.values()) {
141
+ list.sort((a, b) => a.at - b.at);
142
+ const modes = new Set(list.map((e) => e.mode));
143
+ if (modes.size > 1) mixedInstalls++;
144
+ for (const e of list) {
145
+ if (e.mode === "shadow") shadow++;
146
+ else live++;
147
+ start = start === null ? e.at : Math.min(start, e.at);
148
+ end = end === null ? e.at : Math.max(end, e.at);
149
+ }
150
+ }
151
+ const logEnd = end ?? 0;
152
+ const tallies = plans.map((plan) => ({
153
+ plan,
154
+ installs: 0,
155
+ totals: plan.thresholds.map(
156
+ (threshold) => ({
157
+ threshold,
158
+ reveals: 0,
159
+ correct: 0,
160
+ outcomes: 0,
161
+ caught: 0,
162
+ leads: [],
163
+ censored: 0
164
+ })
165
+ ),
166
+ points: [],
167
+ censored: 0
168
+ }));
169
+ for (const list of byInstall.values()) {
170
+ if (!includeLive && !list.some((e) => e.mode === "shadow")) continue;
171
+ const liveTimes = list.filter((e) => e.mode === "live").map((e) => e.at);
172
+ const shadowSpan = (from, to) => includeLive || !liveTimes.some((t) => t >= from && t <= to);
173
+ const states = new Map(allThresholds.map((t) => [t, base]));
174
+ const tracks = tallies.map((tally) => ({
175
+ tally,
176
+ outcomes: [],
177
+ points: [],
178
+ reveals: tally.plan.thresholds.map((threshold) => ({ threshold, times: [] }))
179
+ }));
180
+ for (const entry of list) {
181
+ for (const [t, s] of states) {
182
+ states.set(
183
+ t,
184
+ entry.kind === "end" ? endEpisode(s, entry.at) : observe(s, { name: entry.name, at: entry.at })
185
+ );
186
+ }
187
+ if (entry.kind === "end") continue;
188
+ for (const track of tracks) {
189
+ const { plan } = track.tally;
190
+ if (entry.name === plan.outcome) {
191
+ track.outcomes.push(entry.at);
192
+ } else {
193
+ const any = states.get(allThresholds[0]);
194
+ track.points.push({ at: entry.at, p: predict(any, plan.name, entry.at).p });
195
+ }
196
+ for (const slot of track.reveals) {
197
+ const s = states.get(slot.threshold);
198
+ if (shouldReveal(s, plan.name, entry.at, { threshold: slot.threshold })) {
199
+ slot.times.push(entry.at);
200
+ states.set(slot.threshold, markRevealed(s, plan.name, entry.at));
201
+ }
202
+ }
203
+ }
204
+ }
205
+ for (const { tally, outcomes, points, reveals } of tracks) {
206
+ const { window } = tally.plan;
207
+ tally.installs++;
208
+ const firstOutcomeAfter = (t) => outcomes.find((o) => o > t && o <= t + window);
209
+ for (const point of points) {
210
+ if (!shadowSpan(point.at, point.at + window)) continue;
211
+ if (point.at + window > logEnd) {
212
+ tally.censored++;
213
+ continue;
214
+ }
215
+ tally.points.push({ p: point.p, y: firstOutcomeAfter(point.at) === void 0 ? 0 : 1 });
216
+ }
217
+ for (const [k, slot] of reveals.entries()) {
218
+ const sum = tally.totals[k];
219
+ for (const r of slot.times) {
220
+ if (!shadowSpan(r, r + window)) continue;
221
+ if (r + window > logEnd) {
222
+ sum.censored++;
223
+ continue;
224
+ }
225
+ sum.reveals++;
226
+ const o = firstOutcomeAfter(r);
227
+ if (o !== void 0) {
228
+ sum.correct++;
229
+ sum.leads.push(o - r);
230
+ }
231
+ }
232
+ for (const o of outcomes) {
233
+ if (!shadowSpan(o - window, o)) continue;
234
+ sum.outcomes++;
235
+ if (slot.times.some((r) => r >= o - window && r < o)) sum.caught++;
236
+ }
237
+ }
238
+ }
239
+ }
240
+ const intents = tallies.map(({ plan, installs, totals, points, censored }) => {
241
+ const n = points.length;
242
+ const hits = points.reduce((acc, pt) => acc + pt.y, 0);
243
+ const baseRate = n === 0 ? null : hits / n;
244
+ const reliability = [];
245
+ for (let b = 0; b < bins; b++) {
246
+ const inBin = points.filter((pt) => Math.min(Math.floor(pt.p * bins), bins - 1) === b);
247
+ if (inBin.length === 0) continue;
248
+ reliability.push({
249
+ from: b / bins,
250
+ to: (b + 1) / bins,
251
+ count: inBin.length,
252
+ meanP: inBin.reduce((acc, pt) => acc + pt.p, 0) / inBin.length,
253
+ observed: inBin.reduce((acc, pt) => acc + pt.y, 0) / inBin.length
254
+ });
255
+ }
256
+ return {
257
+ intent: plan.name,
258
+ outcome: plan.outcome,
259
+ window: plan.window,
260
+ windowIsCooldown: plan.windowIsCooldown,
261
+ installs,
262
+ thresholds: totals.map((s) => {
263
+ return {
264
+ threshold: s.threshold,
265
+ configured: s.threshold === plan.configured,
266
+ reveals: s.reveals,
267
+ correct: s.correct,
268
+ precision: s.reveals === 0 ? null : s.correct / s.reveals,
269
+ outcomes: s.outcomes,
270
+ caught: s.caught,
271
+ recall: s.outcomes === 0 ? null : s.caught / s.outcomes,
272
+ leadTime: median(s.leads),
273
+ revealsPerUser: installs === 0 ? null : s.reveals / installs,
274
+ censored: s.censored
275
+ };
276
+ }),
277
+ points: n,
278
+ censored,
279
+ reliability,
280
+ brier: n === 0 ? null : points.reduce((acc, pt) => acc + (pt.p - pt.y) ** 2, 0) / n,
281
+ baseRate,
282
+ brierBaseline: baseRate === null ? null : baseRate * (1 - baseRate)
283
+ };
284
+ });
285
+ return {
286
+ installs: byInstall.size,
287
+ records: entries.length,
288
+ shadow,
289
+ live,
290
+ mixedInstalls,
291
+ start,
292
+ end,
293
+ scored: includeLive ? "all" : "shadow",
294
+ intents,
295
+ skipped
296
+ };
297
+ }
298
+ function median(values) {
299
+ if (values.length === 0) return null;
300
+ const sorted = [...values].sort((a, b) => a - b);
301
+ const mid = Math.floor(sorted.length / 2);
302
+ const at = (i) => sorted[i];
303
+ return sorted.length % 2 === 1 ? at(mid) : (at(mid - 1) + at(mid)) / 2;
304
+ }
305
+
306
+ // cli/report.ts
307
+ var SECOND = 1e3;
308
+ var MINUTE = 60 * SECOND;
309
+ var HOUR = 60 * MINUTE;
310
+ var DAY = 24 * HOUR;
311
+ function formatDuration(ms) {
312
+ if (ms === null) return "\u2013";
313
+ if (ms < SECOND) return `${Math.round(ms)}ms`;
314
+ const units = [
315
+ [DAY, "d"],
316
+ [HOUR, "h"],
317
+ [MINUTE, "m"],
318
+ [SECOND, "s"]
319
+ ];
320
+ const rounded = Math.round(ms / SECOND) * SECOND;
321
+ const i = units.findIndex(([size2]) => rounded >= size2);
322
+ const [size, label] = units[i];
323
+ const n = Math.floor(rounded / size);
324
+ const next = units[i + 1];
325
+ if (next === void 0) return `${n}${label}`;
326
+ const m = Math.floor((rounded - n * size) / next[0]);
327
+ return m > 0 ? `${n}${label} ${m}${next[1]}` : `${n}${label}`;
328
+ }
329
+ function formatTime(at) {
330
+ if (at === null) return "\u2013";
331
+ if (at >= 1e11 && at <= 1e14)
332
+ return `${new Date(at).toISOString().slice(0, 16).replace("T", " ")} UTC`;
333
+ return String(at);
334
+ }
335
+ var pct = (x) => x === null ? "\u2013" : `${(x * 100).toFixed(1)}%`;
336
+ var num = (x, digits) => x === null ? "\u2013" : x.toFixed(digits);
337
+ function table(headers, rows) {
338
+ const widths = headers.map(
339
+ (h, i) => Math.max(h.length, ...rows.map((r) => r[i].length))
340
+ );
341
+ const line = (cells) => `| ${cells.map((c, i) => c.padEnd(widths[i])).join(" | ")} |`;
342
+ return [line(headers), line(widths.map((w) => "-".repeat(w))), ...rows.map(line)].join("\n");
343
+ }
344
+ function formatIntent(intent) {
345
+ const window = formatDuration(intent.window);
346
+ const source = intent.windowIsCooldown ? "the intent's reveal cooldown" : "as given";
347
+ const out = [];
348
+ out.push(
349
+ `${intent.intent}: outcome ${intent.outcome}, window ${window} (${source}), ${intent.installs} install${intent.installs === 1 ? "" : "s"}`
350
+ );
351
+ out.push("");
352
+ out.push(
353
+ table(
354
+ ["threshold", "reveals", "per user", "precision", "recall", "median lead time"],
355
+ intent.thresholds.map((t) => [
356
+ `${t.threshold}${t.configured ? " *" : ""}`,
357
+ String(t.reveals),
358
+ num(t.revealsPerUser, 2),
359
+ pct(t.precision),
360
+ `${pct(t.recall)} of ${t.outcomes}`,
361
+ formatDuration(t.leadTime)
362
+ ])
363
+ )
364
+ );
365
+ const censored = intent.thresholds.reduce((acc, t) => Math.max(acc, t.censored), 0);
366
+ out.push(
367
+ `* the threshold your config resolves for this intent.${censored > 0 ? ` Reveals whose window runs past the end of the log are not scored (up to ${censored} per threshold).` : ""}`
368
+ );
369
+ out.push("");
370
+ out.push(
371
+ `Reliability: p after every event except the outcome, against "the outcome follows within ${window}".`
372
+ );
373
+ if (intent.points === 0) {
374
+ out.push("No scored points.");
375
+ } else {
376
+ out.push(
377
+ table(
378
+ ["p", "points", "mean p", "observed"],
379
+ intent.reliability.map((b) => [
380
+ `${b.from.toFixed(1)}\u2013${b.to.toFixed(1)}`,
381
+ String(b.count),
382
+ b.meanP.toFixed(3),
383
+ pct(b.observed)
384
+ ])
385
+ )
386
+ );
387
+ out.push(
388
+ `Brier score ${num(intent.brier, 3)} over ${intent.points} points (always predicting the base rate of ${pct(intent.baseRate)}: ${num(intent.brierBaseline, 3)}). Lower is better.${intent.censored > 0 ? ` ${intent.censored} points too close to the end of the log are not scored.` : ""}`
389
+ );
390
+ }
391
+ return out.join("\n");
392
+ }
393
+ function formatText(report) {
394
+ const out = [];
395
+ out.push(
396
+ `oracular replay: ${report.installs} install${report.installs === 1 ? "" : "s"}, ${report.records} records (${report.shadow} shadow, ${report.live} live), ${formatTime(report.start)} to ${formatTime(report.end)}`
397
+ );
398
+ out.push(
399
+ `Scored: ${report.scored === "shadow" ? "shadow periods only" : "every record, live included"}. Durations assume \`at\` is in milliseconds.`
400
+ );
401
+ for (const intent of report.intents) {
402
+ out.push("");
403
+ out.push(formatIntent(intent));
404
+ }
405
+ for (const { intent, reason } of report.skipped) {
406
+ out.push("");
407
+ out.push(`${intent}: skipped, ${reason}.`);
408
+ }
409
+ return `${out.join("\n")}
410
+ `;
411
+ }
412
+ function formatJson(report) {
413
+ return `${JSON.stringify(report, null, 2)}
414
+ `;
415
+ }
416
+
417
+ // cli/run.ts
418
+ var UsageError = class extends Error {
419
+ };
420
+ var HELP = `Usage: oracular replay <log.ndjson>... --config <file> [options]
421
+
422
+ Replays a recorder log (see the Shadow mode guide) through the engine under a config, and
423
+ reports for every intent and threshold: precision, recall, lead time from reveal to outcome,
424
+ reveals per user, and a reliability table with a Brier score.
425
+
426
+ Options:
427
+ --config <file> the config to replay under: a .json file, or a .js, .mjs or .cjs
428
+ module whose default (or \`config\`) export is the config
429
+ --thresholds <list> thresholds to score, comma separated
430
+ (default: 0.3 to 0.9 by 0.1, plus each intent's configured threshold)
431
+ --window <duration> how long after a reveal an outcome still counts for it: 3d, 12h, 90m
432
+ (default: each intent's reveal cooldown; needed when that is 0).
433
+ A plain number is in the unit of \`at\`; the suffixes ms, s, m, h, d
434
+ and w assume milliseconds
435
+ --intent <name> score only this intent; repeat for more
436
+ (default: every intent that declares an outcome)
437
+ --include-live also score live periods. While reveals are shown they cause outcomes,
438
+ so precision and recall there overstate the engine
439
+ --json print the report as JSON
440
+ -h, --help show this help
441
+ -v, --version print the version
442
+
443
+ Only shadow periods are scored by default; a log with live records gets a warning, and a log
444
+ with nothing but live records is refused unless --include-live is given.
445
+
446
+ Exit codes: 0 report printed, 1 unreadable input or nothing to score, 2 usage error.
447
+ Docs: https://yuribenjamin.github.io/oracular/guide/shadow-mode
448
+ `;
449
+ var UNITS = {
450
+ ms: 1,
451
+ s: 1e3,
452
+ m: 6e4,
453
+ h: 36e5,
454
+ d: 864e5,
455
+ w: 6048e5
456
+ };
457
+ function parseDuration(text, flag) {
458
+ const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d|w)?$/.exec(text);
459
+ const value = match === null ? Number.NaN : Number(match[1]) * UNITS[match[2] ?? "ms"];
460
+ if (!(value > 0) || !Number.isFinite(value)) {
461
+ throw new UsageError(
462
+ `${flag} must be a positive duration such as 3d, 12h, 90m or 5000, got ${JSON.stringify(text)}`
463
+ );
464
+ }
465
+ return value;
466
+ }
467
+ function parseThresholds(text) {
468
+ return text.split(",").map((part) => {
469
+ const t = Number(part.trim());
470
+ if (part.trim() === "" || !Number.isFinite(t) || t < 0 || t > 1) {
471
+ throw new UsageError(
472
+ `--thresholds must be numbers between 0 and 1, got ${JSON.stringify(part)}`
473
+ );
474
+ }
475
+ return t;
476
+ });
477
+ }
478
+ function parseReplayArgs(argv) {
479
+ const out = { logs: [], includeLive: false, json: false, help: false };
480
+ for (let i = 0; i < argv.length; i++) {
481
+ let flag = argv[i];
482
+ let inline;
483
+ const eq = flag.indexOf("=");
484
+ if (flag.startsWith("--") && eq > 0) {
485
+ inline = flag.slice(eq + 1);
486
+ flag = flag.slice(0, eq);
487
+ }
488
+ const value = () => {
489
+ if (inline !== void 0) return inline;
490
+ const next = argv[i + 1];
491
+ if (next === void 0 || next.startsWith("--"))
492
+ throw new UsageError(`${flag} needs a value`);
493
+ i++;
494
+ return next;
495
+ };
496
+ const noValue = () => {
497
+ if (inline !== void 0) throw new UsageError(`${flag} takes no value`);
498
+ };
499
+ switch (flag) {
500
+ case "--config":
501
+ out.config = value();
502
+ break;
503
+ case "--thresholds":
504
+ out.thresholds = parseThresholds(value());
505
+ break;
506
+ case "--window":
507
+ out.window = parseDuration(value(), "--window");
508
+ break;
509
+ case "--intent":
510
+ out.intents = [...out.intents ?? [], value()];
511
+ break;
512
+ case "--include-live":
513
+ noValue();
514
+ out.includeLive = true;
515
+ break;
516
+ case "--json":
517
+ noValue();
518
+ out.json = true;
519
+ break;
520
+ case "--help":
521
+ case "-h":
522
+ out.help = true;
523
+ break;
524
+ default:
525
+ if (flag.startsWith("-")) throw new UsageError(`unknown option ${flag}`);
526
+ out.logs.push(flag);
527
+ }
528
+ }
529
+ if (!out.help) {
530
+ if (out.logs.length === 0) throw new UsageError("replay needs at least one log file");
531
+ if (out.config === void 0) throw new UsageError("replay needs --config <file>");
532
+ }
533
+ return out;
534
+ }
535
+ function version() {
536
+ return createRequire(import.meta.url)("../package.json").version;
537
+ }
538
+ async function loadConfig(path, io) {
539
+ const ext = extname(path);
540
+ let value;
541
+ if (ext === ".json") {
542
+ try {
543
+ value = JSON.parse(io.readFile(path));
544
+ } catch (error) {
545
+ throw new Error(`${path}: ${error.message}`);
546
+ }
547
+ } else if (ext === ".js" || ext === ".mjs" || ext === ".cjs") {
548
+ const mod = await import(pathToFileURL(path).href);
549
+ value = mod.default ?? mod.config;
550
+ if (typeof value === "object" && value !== null && !("intents" in value) && "config" in value) {
551
+ value = value.config;
552
+ }
553
+ } else {
554
+ throw new UsageError("--config must be a .json, .js, .mjs or .cjs file");
555
+ }
556
+ if (typeof value !== "object" || value === null) {
557
+ throw new Error(`${path} holds no config object`);
558
+ }
559
+ return value;
560
+ }
561
+ async function replayCommand(argv, io) {
562
+ const args = parseReplayArgs(argv);
563
+ if (args.help) {
564
+ io.stdout(HELP);
565
+ return 0;
566
+ }
567
+ const configPath = resolve(io.cwd, args.config);
568
+ const config = await loadConfig(configPath, io);
569
+ let declared;
570
+ try {
571
+ declared = Object.keys(createState2(config).config.intents);
572
+ } catch (error) {
573
+ throw new Error(
574
+ `${configPath} is not a valid config: ${error.message.replace(/^oracular: /, "")}`
575
+ );
576
+ }
577
+ for (const name of args.intents ?? []) {
578
+ if (!declared.includes(name)) {
579
+ throw new UsageError(`--intent ${name}: the config declares ${declared.join(", ")}`);
580
+ }
581
+ }
582
+ const entries = [];
583
+ for (const file of args.logs) {
584
+ const parsed = parseLog(io.readFile(resolve(io.cwd, file)), file);
585
+ for (const warning of parsed.warnings) io.stderr(`oracular: warning: ${warning}
586
+ `);
587
+ entries.push(...parsed.entries);
588
+ }
589
+ if (entries.length === 0) throw new Error("the log holds no records");
590
+ const live = entries.filter((e) => e.mode === "live").length;
591
+ const shadow = entries.length - live;
592
+ if (shadow === 0 && !args.includeLive) {
593
+ throw new Error(
594
+ "every record in the log is live, so there is nothing to measure. Replay measures the engine where reveals were not shown; once they are, the reveal itself causes outcomes. Pass --include-live to score the live records anyway, knowing precision and recall are contaminated."
595
+ );
596
+ }
597
+ if (live > 0 && !args.includeLive) {
598
+ io.stderr(
599
+ `oracular: warning: the log mixes shadow and live records (${live} of ${entries.length} live). Only shadow periods are scored: a reveal, point or outcome whose window touches a live record is left out. Pass --include-live to score everything.
600
+ `
601
+ );
602
+ }
603
+ if (live > 0 && args.includeLive) {
604
+ io.stderr(
605
+ `oracular: warning: scoring ${live} live records as asked. While reveals are shown they cause outcomes, so precision and recall over live periods overstate the engine.
606
+ `
607
+ );
608
+ }
609
+ const options = { includeLive: args.includeLive };
610
+ if (args.thresholds !== void 0) options.thresholds = args.thresholds;
611
+ if (args.window !== void 0) options.window = args.window;
612
+ if (args.intents !== void 0) options.intents = args.intents;
613
+ let report;
614
+ try {
615
+ report = replay(entries, config, options);
616
+ } catch (error) {
617
+ if (error instanceof RangeError) throw new UsageError(error.message);
618
+ throw error;
619
+ }
620
+ io.stdout(args.json ? formatJson(report) : formatText(report));
621
+ return 0;
622
+ }
623
+ async function run(argv, io) {
624
+ try {
625
+ const [command, ...rest] = argv;
626
+ if (command === void 0 || command === "--help" || command === "-h" || command === "help") {
627
+ io.stdout(HELP);
628
+ return 0;
629
+ }
630
+ if (command === "--version" || command === "-v") {
631
+ io.stdout(`${version()}
632
+ `);
633
+ return 0;
634
+ }
635
+ if (command !== "replay") throw new UsageError(`unknown command ${JSON.stringify(command)}`);
636
+ return await replayCommand(rest, io);
637
+ } catch (error) {
638
+ if (error instanceof UsageError) {
639
+ io.stderr(`oracular: ${error.message}
640
+ Run "oracular --help" for usage.
641
+ `);
642
+ return 2;
643
+ }
644
+ io.stderr(`oracular: ${error.message}
645
+ `);
646
+ return 1;
647
+ }
648
+ }
649
+
650
+ // cli/main.ts
651
+ process.exitCode = await run(process.argv.slice(2), {
652
+ stdout: (text) => process.stdout.write(text),
653
+ stderr: (text) => process.stderr.write(text),
654
+ readFile: (path) => readFileSync(path, "utf8"),
655
+ cwd: process.cwd()
656
+ });