oracular 0.4.0 → 0.5.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/CHANGELOG.md +10 -0
- package/README.md +10 -4
- package/dist/cli.js +688 -85
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#24](https://github.com/Yuribenjamin/oracular/pull/24) [`7809c6a`](https://github.com/Yuribenjamin/oracular/commit/7809c6af5c26ec836e43b76eac647bcf85c42514) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **`npx oracular tune <log>... --config <file>`** seeds a config from a shadow log. Grouped by install, it reads what the engine itself counts, hits, misses and episode totals, off the state after every record, so it cannot disagree with what a device would learn from the same events. The base rate becomes the prior; the spread of per-install base rates becomes the prior strength (method of moments on a Beta, once the spread that sampling alone would produce is taken out; kept as it was below 10 installs); each declared signal's weight is `priorStrength × lift` over the episodes without it, with a 90% Wilson interval and the sample sizes on both sides; and the half-life that predicts the log best (lowest Brier score under `replay`) wins a grid search over 1h to 30d plus the current value. It prints the fit and the config to paste, which `replay --config` takes as it is.
|
|
8
|
+
|
|
9
|
+
Like replay, it counts shadow periods only: an episode in which any record was live is left out, a mixed log gets a warning, a live-only log is refused unless `--include-live` is passed. `--all-events` also measures every other event name in the log as a candidate signal; `--half-lives 12h,1d,3d` sets the grid; `--window` sets the scoring window when the intent has no cooldown. Every number is a starting point: the engine corrects the weights per user.
|
|
10
|
+
|
|
11
|
+
`oracular --help` now lists both commands; `oracular replay --help` and `oracular tune --help` have the options.
|
|
12
|
+
|
|
3
13
|
## 0.4.0
|
|
4
14
|
|
|
5
15
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -73,7 +73,7 @@ That block is copied verbatim into the test suite, so it always runs.
|
|
|
73
73
|
- [API](#api)
|
|
74
74
|
- [Persistence](#persistence)
|
|
75
75
|
- [Explainability](#explainability)
|
|
76
|
-
- [Shadow mode
|
|
76
|
+
- [Shadow mode, replay and tune](#shadow-mode-replay-and-tune)
|
|
77
77
|
- [Design rules](#design-rules)
|
|
78
78
|
- [Roadmap](#roadmap)
|
|
79
79
|
- [Contributing](#contributing)
|
|
@@ -363,7 +363,7 @@ explain(state, 'upgrade', Date.now());
|
|
|
363
363
|
|
|
364
364
|
Rows are sorted by the size of their impact. `limit.hit` has no history yet, so its effective weight is exactly the initial one. `pricing.viewed` was followed by an upgrade in six of its seven sessions while the three sessions without it saw one upgrade, so it learned a weight above the initial 3. `export.clicked` appeared in every session: it has no counterfactual, so whatever its hit rate, its weight stands at exactly the initial 1 until a session happens without it. See [Episodes & learning](https://yuribenjamin.github.io/oracular/guide/concepts/episodes-and-learning) for why learning needs contrast.
|
|
365
365
|
|
|
366
|
-
## Shadow mode
|
|
366
|
+
## Shadow mode, replay and tune
|
|
367
367
|
|
|
368
368
|
Run oracular silently first. Record what it decides, export the log, and measure a config offline before any reveal is shown:
|
|
369
369
|
|
|
@@ -376,7 +376,13 @@ log.append(JSON.stringify(createRecord(state, event, { installId, mode: 'shadow'
|
|
|
376
376
|
npx oracular replay shadow.ndjson --config oracular.config.json
|
|
377
377
|
```
|
|
378
378
|
|
|
379
|
-
`replay` reports precision, recall, lead time and reveals per user for every intent and threshold, and a reliability table with a Brier score. It scores only the periods in which reveals were not shown, because once they are, the reveal itself causes outcomes. `installId` is a random id your app generates once and stores; it is never derived from the device. The log holds event names and times, never `props`, and leaves the device only if your app sends it.
|
|
379
|
+
`replay` reports precision, recall, lead time and reveals per user for every intent and threshold, and a reliability table with a Brier score. It scores only the periods in which reveals were not shown, because once they are, the reveal itself causes outcomes. `installId` is a random id your app generates once and stores; it is never derived from the device. The log holds event names and times, never `props`, and leaves the device only if your app sends it.
|
|
380
|
+
|
|
381
|
+
```sh
|
|
382
|
+
npx oracular tune shadow.ndjson --config oracular.config.json
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
`tune` seeds a config from the same log: grouped by install, the base rate becomes the prior, the spread of per-install base rates the prior strength, each signal's lift over the episodes without it its weight, and the half-life that predicts the log best wins a grid search scored by `replay`. The counts come from the engine itself, so tune cannot disagree with what a device would learn. It prints sample sizes and a 90% interval per weight, and the config to paste; the engine corrects it per user from there. The [Shadow mode guide](https://yuribenjamin.github.io/oracular/guide/shadow-mode) has the workflow, the record format and both commands.
|
|
380
386
|
|
|
381
387
|
## Design rules
|
|
382
388
|
|
|
@@ -387,7 +393,7 @@ npx oracular replay shadow.ndjson --config oracular.config.json
|
|
|
387
393
|
|
|
388
394
|
## Roadmap
|
|
389
395
|
|
|
390
|
-
Now is 0.4, themed **learning that matches how people behave**: cross-episode attribution, so a session's signals can earn credit for an outcome days later
|
|
396
|
+
Now is 0.4, themed **learning that matches how people behave**: cross-episode attribution, so a session's signals can earn credit for an outcome days later, and presets. Shipped so far: `npx oracular tune` to seed a config from a shadow log, and small ergonomics (`validateConfig`, `ms`, a JSON Schema for the config). 0.3 shipped shadow mode, `npx oracular replay` and per-episode saturation. Then a written stability policy before 1.0. Framework bindings wait for a third integration. The full list, with the reasoning and what was declined, is on the [roadmap page](https://yuribenjamin.github.io/oracular/guide/roadmap).
|
|
391
397
|
|
|
392
398
|
## Contributing
|
|
393
399
|
|
package/dist/cli.js
CHANGED
|
@@ -20,6 +20,31 @@ var LogError = class extends Error {
|
|
|
20
20
|
function isObject(value) {
|
|
21
21
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
22
22
|
}
|
|
23
|
+
function groupByInstall(entries) {
|
|
24
|
+
const byInstall = /* @__PURE__ */ new Map();
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
const list = byInstall.get(entry.installId);
|
|
27
|
+
if (list === void 0) byInstall.set(entry.installId, [entry]);
|
|
28
|
+
else list.push(entry);
|
|
29
|
+
}
|
|
30
|
+
let start = null;
|
|
31
|
+
let end = null;
|
|
32
|
+
let shadow = 0;
|
|
33
|
+
let live = 0;
|
|
34
|
+
let mixedInstalls = 0;
|
|
35
|
+
for (const list of byInstall.values()) {
|
|
36
|
+
list.sort((a, b) => a.at - b.at);
|
|
37
|
+
const modes = new Set(list.map((e) => e.mode));
|
|
38
|
+
if (modes.size > 1) mixedInstalls++;
|
|
39
|
+
for (const e of list) {
|
|
40
|
+
if (e.mode === "shadow") shadow++;
|
|
41
|
+
else live++;
|
|
42
|
+
start = start === null ? e.at : Math.min(start, e.at);
|
|
43
|
+
end = end === null ? e.at : Math.max(end, e.at);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { byInstall, start, end, shadow, live, mixedInstalls };
|
|
47
|
+
}
|
|
23
48
|
function parseLog(text, source) {
|
|
24
49
|
const entries = [];
|
|
25
50
|
const unknownKinds = /* @__PURE__ */ new Map();
|
|
@@ -126,28 +151,7 @@ function replay(entries, config, options = {}) {
|
|
|
126
151
|
};
|
|
127
152
|
});
|
|
128
153
|
const allThresholds = [...new Set(plans.flatMap((p) => p.thresholds))].sort((a, b) => a - b);
|
|
129
|
-
const byInstall
|
|
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
|
-
}
|
|
154
|
+
const { byInstall, start, end, shadow, live, mixedInstalls } = groupByInstall(entries);
|
|
151
155
|
const logEnd = end ?? 0;
|
|
152
156
|
const tallies = plans.map((plan) => ({
|
|
153
157
|
plan,
|
|
@@ -414,10 +418,492 @@ function formatJson(report) {
|
|
|
414
418
|
`;
|
|
415
419
|
}
|
|
416
420
|
|
|
421
|
+
// cli/tune.ts
|
|
422
|
+
import {
|
|
423
|
+
createState as createState2,
|
|
424
|
+
endEpisode as endEpisode2,
|
|
425
|
+
episodeClosed,
|
|
426
|
+
markRevealed as markRevealed2,
|
|
427
|
+
observe as observe2,
|
|
428
|
+
predict as predict2
|
|
429
|
+
} from "oracular";
|
|
430
|
+
|
|
431
|
+
// src/math.ts
|
|
432
|
+
function laplace(successes, total) {
|
|
433
|
+
return (successes + 1) / (total + 2);
|
|
434
|
+
}
|
|
435
|
+
function lift(q, b) {
|
|
436
|
+
if (q >= b) return b >= 1 ? 0 : (q - b) / (1 - b);
|
|
437
|
+
return b <= 0 ? 0 : (q - b) / b;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// cli/tune.ts
|
|
441
|
+
var HOUR2 = 36e5;
|
|
442
|
+
var DAY2 = 24 * HOUR2;
|
|
443
|
+
var DEFAULT_HALF_LIVES = [HOUR2, 6 * HOUR2, DAY2, 3 * DAY2, 7 * DAY2, 14 * DAY2, 30 * DAY2];
|
|
444
|
+
var DEFAULT_PRIOR_EPISODES = 10;
|
|
445
|
+
var MIN_INSTALLS_FOR_SPREAD = 10;
|
|
446
|
+
var STRENGTH_RANGE = [2, 50];
|
|
447
|
+
var Z90 = 1.6449;
|
|
448
|
+
var NONE = { hits: 0, misses: 0 };
|
|
449
|
+
function stepDelta(prev, next) {
|
|
450
|
+
const d = { hits: {}, misses: {}, outcomes: 0, episodes: 0 };
|
|
451
|
+
let changed = false;
|
|
452
|
+
for (const [signal, record] of Object.entries(next.learned)) {
|
|
453
|
+
const before = Object.hasOwn(prev.learned, signal) ? prev.learned[signal] ?? NONE : NONE;
|
|
454
|
+
const hits = record.hits - before.hits;
|
|
455
|
+
const misses = record.misses - before.misses;
|
|
456
|
+
if (hits !== 0) {
|
|
457
|
+
d.hits[signal] = hits;
|
|
458
|
+
changed = true;
|
|
459
|
+
}
|
|
460
|
+
if (misses !== 0) {
|
|
461
|
+
d.misses[signal] = misses;
|
|
462
|
+
changed = true;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
d.outcomes = next.episodes.hits - prev.episodes.hits;
|
|
466
|
+
d.episodes = d.outcomes + (next.episodes.misses - prev.episodes.misses);
|
|
467
|
+
if (d.episodes !== 0) changed = true;
|
|
468
|
+
return changed ? d : null;
|
|
469
|
+
}
|
|
470
|
+
function addTally(into, d) {
|
|
471
|
+
for (const [signal, n] of Object.entries(d.hits)) {
|
|
472
|
+
into.hits[signal] = (Object.hasOwn(into.hits, signal) ? into.hits[signal] ?? 0 : 0) + n;
|
|
473
|
+
}
|
|
474
|
+
for (const [signal, n] of Object.entries(d.misses)) {
|
|
475
|
+
into.misses[signal] = (Object.hasOwn(into.misses, signal) ? into.misses[signal] ?? 0 : 0) + n;
|
|
476
|
+
}
|
|
477
|
+
into.outcomes += d.outcomes;
|
|
478
|
+
into.episodes += d.episodes;
|
|
479
|
+
}
|
|
480
|
+
var own = (record, key) => Object.hasOwn(record, key) ? record[key] ?? 0 : 0;
|
|
481
|
+
function wilson(successes, n, z) {
|
|
482
|
+
if (n <= 0) return [0, 1];
|
|
483
|
+
const p = successes / n;
|
|
484
|
+
const z2 = z * z;
|
|
485
|
+
const centre = (p + z2 / (2 * n)) / (1 + z2 / n);
|
|
486
|
+
const half = z / (1 + z2 / n) * Math.sqrt(p * (1 - p) / n + z2 / (4 * n * n));
|
|
487
|
+
return [Math.max(0, centre - half), Math.min(1, centre + half)];
|
|
488
|
+
}
|
|
489
|
+
function fitSignal(signal, current, tally, strength, priorEpisodes) {
|
|
490
|
+
const hits = own(tally.hits, signal);
|
|
491
|
+
const misses = own(tally.misses, signal);
|
|
492
|
+
const nWith = hits + misses;
|
|
493
|
+
const without = Math.max(0, tally.episodes - nWith);
|
|
494
|
+
const outcomesWithout = Math.min(without, Math.max(0, tally.outcomes - hits));
|
|
495
|
+
const qWith = laplace(hits, nWith);
|
|
496
|
+
const qWithout = laplace(outcomesWithout, without);
|
|
497
|
+
const l = nWith === 0 ? 0 : lift(qWith, qWithout);
|
|
498
|
+
const contrast = nWith + without === 0 ? 0 : nWith * without / (nWith + without);
|
|
499
|
+
const [qLow, qHigh] = wilson(hits, nWith, Z90);
|
|
500
|
+
const rounded = Math.round(strength * l * 100) / 100;
|
|
501
|
+
return {
|
|
502
|
+
signal,
|
|
503
|
+
current,
|
|
504
|
+
hits,
|
|
505
|
+
misses,
|
|
506
|
+
without,
|
|
507
|
+
outcomesWithout,
|
|
508
|
+
qWith,
|
|
509
|
+
qWithout,
|
|
510
|
+
lift: l,
|
|
511
|
+
weight: nWith === 0 || rounded === 0 ? null : rounded,
|
|
512
|
+
low: strength * lift(qLow, qWithout),
|
|
513
|
+
high: strength * lift(qHigh, qWithout),
|
|
514
|
+
contrast,
|
|
515
|
+
thin: contrast < priorEpisodes
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
function fitStrength(installs, current) {
|
|
519
|
+
const k = installs.length;
|
|
520
|
+
const plural2 = (n) => n === 1 ? "" : "s";
|
|
521
|
+
if (k < MIN_INSTALLS_FOR_SPREAD) {
|
|
522
|
+
return {
|
|
523
|
+
value: current,
|
|
524
|
+
current,
|
|
525
|
+
source: "kept",
|
|
526
|
+
note: `kept: ${k} install${plural2(k)} with a counted episode, reading the spread needs ${MIN_INSTALLS_FOR_SPREAD}`
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
const total = installs.reduce((acc, i) => acc + i.episodes, 0);
|
|
530
|
+
const m = installs.reduce((acc, i) => acc + i.outcomes, 0) / total;
|
|
531
|
+
if (m <= 0 || m >= 1) {
|
|
532
|
+
return {
|
|
533
|
+
value: current,
|
|
534
|
+
current,
|
|
535
|
+
source: "kept",
|
|
536
|
+
note: "kept: every counted episode ended the same way, so there is no spread to read"
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
const observed = installs.reduce((acc, i) => acc + i.episodes * (i.outcomes / i.episodes - m) ** 2, 0) / total;
|
|
540
|
+
const sampling = k * m * (1 - m) / total;
|
|
541
|
+
const between = observed - sampling;
|
|
542
|
+
if (between <= 0) {
|
|
543
|
+
return {
|
|
544
|
+
value: current,
|
|
545
|
+
current,
|
|
546
|
+
source: "kept",
|
|
547
|
+
note: `kept: the base rates of ${k} installs differ no more than chance would make them`
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
const raw = m * (1 - m) / between - 1;
|
|
551
|
+
const [min, max] = STRENGTH_RANGE;
|
|
552
|
+
const clamped = Math.min(max, Math.max(min, raw));
|
|
553
|
+
const value = Math.round(clamped * 10) / 10;
|
|
554
|
+
const capped = clamped !== raw ? `, capped to ${min}\u2013${max}` : "";
|
|
555
|
+
return {
|
|
556
|
+
value,
|
|
557
|
+
current,
|
|
558
|
+
source: "spread",
|
|
559
|
+
note: `spread of base rates across ${k} installs (sd ${Math.sqrt(between).toFixed(2)} around ${m.toFixed(2)})${capped}`
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
function withIntent(config, name, intent) {
|
|
563
|
+
return { ...config, intents: { ...config.intents, [name]: intent } };
|
|
564
|
+
}
|
|
565
|
+
function withoutLearnedDecay(config) {
|
|
566
|
+
if (config.learning?.halfLife === void 0) return config;
|
|
567
|
+
const learning = Object.fromEntries(
|
|
568
|
+
Object.entries(config.learning).filter(([key]) => key !== "halfLife")
|
|
569
|
+
);
|
|
570
|
+
return { ...config, learning };
|
|
571
|
+
}
|
|
572
|
+
function cooldownOf(base, name) {
|
|
573
|
+
return predict2(markRevealed2(base, name, 0), name, 0).suppressedUntil ?? 0;
|
|
574
|
+
}
|
|
575
|
+
function tune(entries, config, options = {}) {
|
|
576
|
+
const base = createState2(config);
|
|
577
|
+
const normalized = base.config;
|
|
578
|
+
const includeLive = options.includeLive === true;
|
|
579
|
+
const priorEpisodes = normalized.learning?.priorEpisodes ?? DEFAULT_PRIOR_EPISODES;
|
|
580
|
+
const skipped = [];
|
|
581
|
+
const names = options.intents ?? Object.keys(normalized.intents);
|
|
582
|
+
for (const name of names) {
|
|
583
|
+
if (!Object.hasOwn(normalized.intents, name)) {
|
|
584
|
+
throw new RangeError(
|
|
585
|
+
`unknown intent ${JSON.stringify(name)} (the config declares: ${Object.keys(normalized.intents).join(", ")})`
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
const fitting = names.filter((name) => {
|
|
590
|
+
if (normalized.intents[name].outcome !== void 0) return true;
|
|
591
|
+
skipped.push({ intent: name, reason: "declares no outcome event, so there is nothing to fit" });
|
|
592
|
+
return false;
|
|
593
|
+
});
|
|
594
|
+
const grouped = groupByInstall(entries);
|
|
595
|
+
const windows = /* @__PURE__ */ new Map();
|
|
596
|
+
for (const name of fitting) {
|
|
597
|
+
const window = options.window ?? cooldownOf(base, name);
|
|
598
|
+
if (!(window > 0)) {
|
|
599
|
+
throw new RangeError(
|
|
600
|
+
options.window === void 0 ? `intent ${JSON.stringify(name)} has a reveal cooldown of 0, so there is no scoring window for the half-life grid; pass --window (for example --window 7d)` : "the scoring window must be greater than 0"
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
windows.set(name, window);
|
|
604
|
+
}
|
|
605
|
+
const eventNames = /* @__PURE__ */ new Set();
|
|
606
|
+
for (const entry of entries) if (entry.kind === "event") eventNames.add(entry.name);
|
|
607
|
+
const candidatesOf = (intent) => options.allEvents === true ? [...eventNames].filter(
|
|
608
|
+
(name) => name !== "__proto__" && name !== intent.outcome && !Object.hasOwn(intent.signals, name)
|
|
609
|
+
) : [];
|
|
610
|
+
let countConfig = withoutLearnedDecay(normalized);
|
|
611
|
+
const plans = fitting.map((name) => {
|
|
612
|
+
const intent = normalized.intents[name];
|
|
613
|
+
const candidates = candidatesOf(intent);
|
|
614
|
+
if (candidates.length > 0) {
|
|
615
|
+
countConfig = withIntent(countConfig, name, {
|
|
616
|
+
...intent,
|
|
617
|
+
signals: { ...intent.signals, ...Object.fromEntries(candidates.map((c) => [c, 1])) }
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
return {
|
|
621
|
+
name,
|
|
622
|
+
intent,
|
|
623
|
+
candidates,
|
|
624
|
+
tally: { hits: {}, misses: {}, outcomes: 0, episodes: 0 },
|
|
625
|
+
excluded: 0,
|
|
626
|
+
installs: []
|
|
627
|
+
};
|
|
628
|
+
});
|
|
629
|
+
const countBase = createState2(countConfig);
|
|
630
|
+
for (const list of grouped.byInstall.values()) {
|
|
631
|
+
if (!includeLive && !list.some((e) => e.mode === "shadow")) continue;
|
|
632
|
+
let state = countBase;
|
|
633
|
+
let liveInSpan = false;
|
|
634
|
+
const perInstall = new Map(plans.map((plan) => [plan.name, { outcomes: 0, episodes: 0 }]));
|
|
635
|
+
for (const entry of list) {
|
|
636
|
+
const prev = state;
|
|
637
|
+
state = entry.kind === "end" ? endEpisode2(state, entry.at) : observe2(state, { name: entry.name, at: entry.at });
|
|
638
|
+
const closed = episodeClosed(prev, state);
|
|
639
|
+
const opensNext = closed && entry.kind === "event" && state.episode.startedAt === entry.at;
|
|
640
|
+
const contaminated = liveInSpan || !opensNext && entry.mode === "live";
|
|
641
|
+
for (const plan of plans) {
|
|
642
|
+
const d = stepDelta(
|
|
643
|
+
prev.intents[plan.name],
|
|
644
|
+
state.intents[plan.name]
|
|
645
|
+
);
|
|
646
|
+
if (d === null) continue;
|
|
647
|
+
if (contaminated && !includeLive) {
|
|
648
|
+
plan.excluded += d.episodes;
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
addTally(plan.tally, d);
|
|
652
|
+
const mine = perInstall.get(plan.name);
|
|
653
|
+
mine.outcomes += d.outcomes;
|
|
654
|
+
mine.episodes += d.episodes;
|
|
655
|
+
}
|
|
656
|
+
if (closed) liveInSpan = opensNext && entry.mode === "live";
|
|
657
|
+
else if (entry.mode === "live") liveInSpan = true;
|
|
658
|
+
}
|
|
659
|
+
for (const plan of plans) {
|
|
660
|
+
const mine = perInstall.get(plan.name);
|
|
661
|
+
if (mine.episodes > 0) plan.installs.push(mine);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
let fitted = normalized;
|
|
665
|
+
const intents = [];
|
|
666
|
+
for (const plan of plans) {
|
|
667
|
+
const { name, intent, tally } = plan;
|
|
668
|
+
if (tally.episodes === 0) {
|
|
669
|
+
skipped.push({
|
|
670
|
+
intent: name,
|
|
671
|
+
reason: plan.excluded > 0 ? "every completed episode had a live reveal inside it, so nothing could be counted (pass --include-live to count them)" : "no completed episode in the log, so nothing could be counted"
|
|
672
|
+
});
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
const prior = laplace(tally.outcomes, tally.episodes);
|
|
676
|
+
const strength = fitStrength(plan.installs, intent.priorStrength);
|
|
677
|
+
const signals = Object.keys(intent.signals).map(
|
|
678
|
+
(signal) => fitSignal(signal, intent.signals[signal], tally, strength.value, priorEpisodes)
|
|
679
|
+
);
|
|
680
|
+
const candidates = plan.candidates.map(
|
|
681
|
+
(signal) => fitSignal(signal, null, tally, strength.value, priorEpisodes)
|
|
682
|
+
);
|
|
683
|
+
const roundedPrior = Math.round(prior * 1e4) / 1e4;
|
|
684
|
+
const fittedIntent = {
|
|
685
|
+
...intent,
|
|
686
|
+
prior: roundedPrior > 0 && roundedPrior < 1 ? roundedPrior : prior,
|
|
687
|
+
priorStrength: strength.value,
|
|
688
|
+
signals: Object.fromEntries(
|
|
689
|
+
signals.filter((f) => f.weight !== null).map((f) => [f.signal, f.weight])
|
|
690
|
+
)
|
|
691
|
+
};
|
|
692
|
+
const window = windows.get(name);
|
|
693
|
+
const threshold = intent.reveal?.threshold ?? normalized.reveal?.threshold ?? DEFAULT_THRESHOLD;
|
|
694
|
+
const grid = [.../* @__PURE__ */ new Set([...options.halfLives ?? DEFAULT_HALF_LIVES, intent.halfLife])].sort(
|
|
695
|
+
(a, b) => a - b
|
|
696
|
+
);
|
|
697
|
+
let brierBaseline = null;
|
|
698
|
+
const curve = grid.map((halfLife2) => {
|
|
699
|
+
const report = replay(entries, withIntent(fitted, name, { ...fittedIntent, halfLife: halfLife2 }), {
|
|
700
|
+
intents: [name],
|
|
701
|
+
thresholds: [threshold],
|
|
702
|
+
window,
|
|
703
|
+
includeLive
|
|
704
|
+
});
|
|
705
|
+
const scored = report.intents[0];
|
|
706
|
+
brierBaseline = scored?.brierBaseline ?? brierBaseline;
|
|
707
|
+
return { halfLife: halfLife2, brier: scored?.brier ?? null, points: scored?.points ?? 0 };
|
|
708
|
+
});
|
|
709
|
+
const scoredPoints = curve.filter((p) => p.brier !== null);
|
|
710
|
+
let halfLife;
|
|
711
|
+
if (scoredPoints.length === 0) {
|
|
712
|
+
halfLife = {
|
|
713
|
+
value: intent.halfLife,
|
|
714
|
+
current: intent.halfLife,
|
|
715
|
+
source: "kept",
|
|
716
|
+
note: "kept: no point in the log could be scored, so the grid says nothing",
|
|
717
|
+
window,
|
|
718
|
+
curve,
|
|
719
|
+
brierBaseline
|
|
720
|
+
};
|
|
721
|
+
} else {
|
|
722
|
+
const best = Math.min(...scoredPoints.map((p) => p.brier));
|
|
723
|
+
const ties = scoredPoints.filter((p) => p.brier - best <= 1e-12);
|
|
724
|
+
const nearest = (p) => Math.abs(Math.log(p.halfLife / intent.halfLife));
|
|
725
|
+
const chosen = ties.reduce((a, b) => nearest(b) < nearest(a) ? b : a);
|
|
726
|
+
halfLife = {
|
|
727
|
+
value: chosen.halfLife,
|
|
728
|
+
current: intent.halfLife,
|
|
729
|
+
source: "grid",
|
|
730
|
+
note: `lowest Brier score over the grid, ${best.toFixed(3)}${brierBaseline === null ? "" : ` (always predicting the base rate: ${brierBaseline.toFixed(3)})`}`,
|
|
731
|
+
window,
|
|
732
|
+
curve,
|
|
733
|
+
brierBaseline
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
fitted = withIntent(fitted, name, { ...fittedIntent, halfLife: halfLife.value });
|
|
737
|
+
intents.push({
|
|
738
|
+
intent: name,
|
|
739
|
+
outcome: intent.outcome,
|
|
740
|
+
installs: plan.installs.length,
|
|
741
|
+
episodes: tally.episodes,
|
|
742
|
+
outcomes: tally.outcomes,
|
|
743
|
+
excluded: plan.excluded,
|
|
744
|
+
prior: { value: prior, current: intent.prior },
|
|
745
|
+
priorStrength: strength,
|
|
746
|
+
halfLife,
|
|
747
|
+
priorEpisodes,
|
|
748
|
+
signals,
|
|
749
|
+
candidates
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
return {
|
|
753
|
+
installs: grouped.byInstall.size,
|
|
754
|
+
records: entries.length,
|
|
755
|
+
shadow: grouped.shadow,
|
|
756
|
+
live: grouped.live,
|
|
757
|
+
mixedInstalls: grouped.mixedInstalls,
|
|
758
|
+
start: grouped.start,
|
|
759
|
+
end: grouped.end,
|
|
760
|
+
scored: includeLive ? "all" : "shadow",
|
|
761
|
+
intents,
|
|
762
|
+
skipped,
|
|
763
|
+
config: createState2(fitted).config
|
|
764
|
+
// normalized once more: it must be a config the engine takes
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// cli/tune-report.ts
|
|
769
|
+
var signed = (x, digits) => `${x >= 0 ? "+" : ""}${x.toFixed(digits)}`;
|
|
770
|
+
var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
771
|
+
function note(fit, priorEpisodes) {
|
|
772
|
+
if (fit.hits + fit.misses === 0) return "never seen in a counted episode";
|
|
773
|
+
const notes = [];
|
|
774
|
+
if (fit.weight === null) notes.push("no measurable lift, left out of the config");
|
|
775
|
+
if (fit.thin) notes.push(`thin: ${fit.contrast.toFixed(1)} of ${priorEpisodes} contrast`);
|
|
776
|
+
return notes.join("; ");
|
|
777
|
+
}
|
|
778
|
+
function signalTable(fits, priorEpisodes, withCurrent) {
|
|
779
|
+
const headers = [
|
|
780
|
+
"signal",
|
|
781
|
+
"weight",
|
|
782
|
+
...withCurrent ? ["was"] : [],
|
|
783
|
+
"90% interval",
|
|
784
|
+
"lift",
|
|
785
|
+
"with: hits / misses",
|
|
786
|
+
"without: outcomes / episodes",
|
|
787
|
+
"note"
|
|
788
|
+
];
|
|
789
|
+
return table(
|
|
790
|
+
headers,
|
|
791
|
+
fits.map((f) => [
|
|
792
|
+
f.signal,
|
|
793
|
+
num(f.weight, 2),
|
|
794
|
+
...withCurrent ? [num(f.current, 2)] : [],
|
|
795
|
+
`${f.low.toFixed(2)} to ${f.high.toFixed(2)}`,
|
|
796
|
+
signed(f.lift, 2),
|
|
797
|
+
`${f.hits} / ${f.misses}`,
|
|
798
|
+
`${f.outcomesWithout} / ${f.without}`,
|
|
799
|
+
note(f, priorEpisodes)
|
|
800
|
+
])
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
function formatIntent2(fit) {
|
|
804
|
+
const out = [];
|
|
805
|
+
const excluded = fit.excluded > 0 ? `, ${plural(fit.excluded, "episode")} left out (a reveal was live)` : "";
|
|
806
|
+
out.push(
|
|
807
|
+
`${fit.intent}: outcome ${fit.outcome}, ${plural(fit.installs, "install")}, ${plural(fit.episodes, "counted episode")}, ${fit.outcomes} with the outcome${excluded}`
|
|
808
|
+
);
|
|
809
|
+
out.push("");
|
|
810
|
+
out.push(
|
|
811
|
+
table(
|
|
812
|
+
["knob", "fitted", "was", "from"],
|
|
813
|
+
[
|
|
814
|
+
[
|
|
815
|
+
"prior",
|
|
816
|
+
fit.prior.value.toFixed(4),
|
|
817
|
+
String(fit.prior.current),
|
|
818
|
+
`${fit.outcomes} of ${fit.episodes} episodes ended with the outcome, smoothed (${fit.outcomes}+1)/(${fit.episodes}+2)`
|
|
819
|
+
],
|
|
820
|
+
[
|
|
821
|
+
"priorStrength",
|
|
822
|
+
num(fit.priorStrength.value, 1),
|
|
823
|
+
num(fit.priorStrength.current, 1),
|
|
824
|
+
fit.priorStrength.note
|
|
825
|
+
],
|
|
826
|
+
[
|
|
827
|
+
"halfLife",
|
|
828
|
+
formatDuration(fit.halfLife.value),
|
|
829
|
+
formatDuration(fit.halfLife.current),
|
|
830
|
+
fit.halfLife.note
|
|
831
|
+
]
|
|
832
|
+
]
|
|
833
|
+
)
|
|
834
|
+
);
|
|
835
|
+
out.push("");
|
|
836
|
+
if (fit.signals.length === 0) {
|
|
837
|
+
out.push("No declared signals.");
|
|
838
|
+
} else {
|
|
839
|
+
out.push(signalTable(fit.signals, fit.priorEpisodes, true));
|
|
840
|
+
}
|
|
841
|
+
out.push("");
|
|
842
|
+
out.push(
|
|
843
|
+
`Half-life grid, Brier score against "the outcome follows within ${formatDuration(fit.halfLife.window)}" (lower is better): ${fit.halfLife.curve.map(
|
|
844
|
+
(p) => `${formatDuration(p.halfLife)} ${num(p.brier, 3)}${p.halfLife === fit.halfLife.value && fit.halfLife.source === "grid" ? " *" : ""}`
|
|
845
|
+
).join(", ")}`
|
|
846
|
+
);
|
|
847
|
+
if (fit.candidates.length > 0) {
|
|
848
|
+
out.push("");
|
|
849
|
+
out.push(
|
|
850
|
+
"Candidates: every other event in the log, measured the same way. Add one as a signal only if it says something your signals do not already say."
|
|
851
|
+
);
|
|
852
|
+
out.push(signalTable(fit.candidates, fit.priorEpisodes, false));
|
|
853
|
+
}
|
|
854
|
+
return out.join("\n");
|
|
855
|
+
}
|
|
856
|
+
function formatTuneText(report) {
|
|
857
|
+
const out = [];
|
|
858
|
+
out.push(
|
|
859
|
+
`oracular tune: ${plural(report.installs, "install")}, ${report.records} records (${report.shadow} shadow, ${report.live} live), ${formatTime(report.start)} to ${formatTime(report.end)}`
|
|
860
|
+
);
|
|
861
|
+
out.push(
|
|
862
|
+
`Counted: ${report.scored === "shadow" ? "shadow episodes only" : "every episode, live included"}. Durations assume \`at\` is in milliseconds. Every number is a starting point: the engine corrects the weights per user.`
|
|
863
|
+
);
|
|
864
|
+
for (const intent of report.intents) {
|
|
865
|
+
out.push("");
|
|
866
|
+
out.push(formatIntent2(intent));
|
|
867
|
+
}
|
|
868
|
+
for (const { intent, reason } of report.skipped) {
|
|
869
|
+
out.push("");
|
|
870
|
+
out.push(`${intent}: skipped, ${reason}.`);
|
|
871
|
+
}
|
|
872
|
+
if (report.intents.length > 0) {
|
|
873
|
+
out.push("");
|
|
874
|
+
out.push(
|
|
875
|
+
"Config with every fitted value swapped in (paste it, or pass it to oracular replay --config):"
|
|
876
|
+
);
|
|
877
|
+
out.push(JSON.stringify(report.config, null, 2));
|
|
878
|
+
}
|
|
879
|
+
return `${out.join("\n")}
|
|
880
|
+
`;
|
|
881
|
+
}
|
|
882
|
+
function formatTuneJson(report) {
|
|
883
|
+
return `${JSON.stringify(report, null, 2)}
|
|
884
|
+
`;
|
|
885
|
+
}
|
|
886
|
+
|
|
417
887
|
// cli/run.ts
|
|
418
888
|
var UsageError = class extends Error {
|
|
419
889
|
};
|
|
420
|
-
var
|
|
890
|
+
var DOCS = "https://yuribenjamin.github.io/oracular/guide/shadow-mode";
|
|
891
|
+
var USAGE = `Usage: oracular <command> [options]
|
|
892
|
+
|
|
893
|
+
Commands:
|
|
894
|
+
replay <log.ndjson>... --config <file> score a recorder log against a config: precision,
|
|
895
|
+
recall, lead time, reveals per user and calibration
|
|
896
|
+
tune <log.ndjson>... --config <file> seed a config from a recorder log: prior, prior
|
|
897
|
+
strength, weights and half-life, read off what the
|
|
898
|
+
engine itself counts
|
|
899
|
+
|
|
900
|
+
Run "oracular <command> --help" for the command's options.
|
|
901
|
+
-h, --help show this help
|
|
902
|
+
-v, --version print the version
|
|
903
|
+
|
|
904
|
+
Docs: ${DOCS}
|
|
905
|
+
`;
|
|
906
|
+
var REPLAY_HELP = `Usage: oracular replay <log.ndjson>... --config <file> [options]
|
|
421
907
|
|
|
422
908
|
Replays a recorder log (see the Shadow mode guide) through the engine under a config, and
|
|
423
909
|
reports for every intent and threshold: precision, recall, lead time from reveal to outcome,
|
|
@@ -445,7 +931,42 @@ Only shadow periods are scored by default; a log with live records gets a warnin
|
|
|
445
931
|
with nothing but live records is refused unless --include-live is given.
|
|
446
932
|
|
|
447
933
|
Exit codes: 0 report printed, 1 unreadable input or nothing to score, 2 usage error.
|
|
448
|
-
Docs:
|
|
934
|
+
Docs: ${DOCS}
|
|
935
|
+
`;
|
|
936
|
+
var TUNE_HELP = `Usage: oracular tune <log.ndjson>... --config <file> [options]
|
|
937
|
+
|
|
938
|
+
Seeds a config from a recorder log (see the Shadow mode guide). Grouped by install, it reads what
|
|
939
|
+
the engine itself counts: the base rate becomes the prior, the spread of per-install base rates
|
|
940
|
+
the prior strength, each signal's lift over the episodes without it its weight, and the half-life
|
|
941
|
+
that predicts the log best under replay (lowest Brier score) wins a grid search. Prints the fit,
|
|
942
|
+
with sample sizes and a 90% interval per weight, and the config to paste.
|
|
943
|
+
|
|
944
|
+
Options:
|
|
945
|
+
--config <file> the config to start from: a .json file, or a .js, .mjs or .cjs module
|
|
946
|
+
whose default (or \`config\`) export is the config. A top-level "$schema"
|
|
947
|
+
key in a .json file is ignored. Its intents, outcome names and signals
|
|
948
|
+
say what to fit; what tune cannot read from the log is kept as it is
|
|
949
|
+
--intent <name> fit only this intent; repeat for more
|
|
950
|
+
(default: every intent that declares an outcome)
|
|
951
|
+
--half-lives <list> half-lives to try, comma separated: 12h,1d,3d,7d
|
|
952
|
+
(default: 1h, 6h, 1d, 3d, 7d, 14d, 30d, plus the intent's current value)
|
|
953
|
+
--window <duration> how long after a point an outcome still counts for it when the grid is
|
|
954
|
+
scored: 3d, 12h, 90m (default: each intent's reveal cooldown; needed
|
|
955
|
+
when that is 0). A plain number is in the unit of \`at\`; the suffixes
|
|
956
|
+
ms, s, m, h, d and w assume milliseconds
|
|
957
|
+
--all-events also measure every other event name in the log as a candidate signal
|
|
958
|
+
--include-live also count episodes in which reveals were live. While reveals are
|
|
959
|
+
shown they cause outcomes, so the prior and weights there overstate
|
|
960
|
+
the signals
|
|
961
|
+
--json print the report as JSON
|
|
962
|
+
-h, --help show this help
|
|
963
|
+
|
|
964
|
+
Only shadow periods are counted by default: an episode in which any record was live is left out,
|
|
965
|
+
a log with live records gets a warning, and a log with nothing but live records is refused unless
|
|
966
|
+
--include-live is given. Every number is a starting point: the engine corrects weights per user.
|
|
967
|
+
|
|
968
|
+
Exit codes: 0 report printed, 1 unreadable input or nothing to fit, 2 usage error.
|
|
969
|
+
Docs: ${DOCS}#tune
|
|
449
970
|
`;
|
|
450
971
|
var UNITS = {
|
|
451
972
|
ms: 1,
|
|
@@ -456,7 +977,7 @@ var UNITS = {
|
|
|
456
977
|
w: 6048e5
|
|
457
978
|
};
|
|
458
979
|
function parseDuration(text, flag) {
|
|
459
|
-
const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d|w)?$/.exec(text);
|
|
980
|
+
const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d|w)?$/.exec(text.trim());
|
|
460
981
|
const value = match === null ? Number.NaN : Number(match[1]) * UNITS[match[2] ?? "ms"];
|
|
461
982
|
if (!(value > 0) || !Number.isFinite(value)) {
|
|
462
983
|
throw new UsageError(
|
|
@@ -476,8 +997,10 @@ function parseThresholds(text) {
|
|
|
476
997
|
return t;
|
|
477
998
|
});
|
|
478
999
|
}
|
|
479
|
-
function
|
|
480
|
-
|
|
1000
|
+
function parseDurations(text, flag) {
|
|
1001
|
+
return text.split(",").map((part) => parseDuration(part, flag));
|
|
1002
|
+
}
|
|
1003
|
+
function parseFlags(argv, flags, positional) {
|
|
481
1004
|
for (let i = 0; i < argv.length; i++) {
|
|
482
1005
|
let flag = argv[i];
|
|
483
1006
|
let inline;
|
|
@@ -497,40 +1020,82 @@ function parseReplayArgs(argv) {
|
|
|
497
1020
|
const noValue = () => {
|
|
498
1021
|
if (inline !== void 0) throw new UsageError(`${flag} takes no value`);
|
|
499
1022
|
};
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
1023
|
+
const handler = flags[flag];
|
|
1024
|
+
if (handler !== void 0) handler(value, noValue);
|
|
1025
|
+
else if (flag.startsWith("-")) throw new UsageError(`unknown option ${flag}`);
|
|
1026
|
+
else positional(flag);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
function commonFlags(out) {
|
|
1030
|
+
return {
|
|
1031
|
+
"--config": (value) => {
|
|
1032
|
+
out.config = value();
|
|
1033
|
+
},
|
|
1034
|
+
"--window": (value) => {
|
|
1035
|
+
out.window = parseDuration(value(), "--window");
|
|
1036
|
+
},
|
|
1037
|
+
"--intent": (value) => {
|
|
1038
|
+
out.intents = [...out.intents ?? [], value()];
|
|
1039
|
+
},
|
|
1040
|
+
"--include-live": (_value, noValue) => {
|
|
1041
|
+
noValue();
|
|
1042
|
+
out.includeLive = true;
|
|
1043
|
+
},
|
|
1044
|
+
"--json": (_value, noValue) => {
|
|
1045
|
+
noValue();
|
|
1046
|
+
out.json = true;
|
|
1047
|
+
},
|
|
1048
|
+
"--help": () => {
|
|
1049
|
+
out.help = true;
|
|
1050
|
+
},
|
|
1051
|
+
"-h": () => {
|
|
1052
|
+
out.help = true;
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
function requireLogsAndConfig(out, command) {
|
|
1057
|
+
if (out.help) return;
|
|
1058
|
+
if (out.logs.length === 0) throw new UsageError(`${command} needs at least one log file`);
|
|
1059
|
+
if (out.config === void 0) throw new UsageError(`${command} needs --config <file>`);
|
|
1060
|
+
}
|
|
1061
|
+
function parseReplayArgs(argv) {
|
|
1062
|
+
const out = { logs: [], includeLive: false, json: false, help: false };
|
|
1063
|
+
parseFlags(
|
|
1064
|
+
argv,
|
|
1065
|
+
{
|
|
1066
|
+
...commonFlags(out),
|
|
1067
|
+
"--thresholds": (value) => {
|
|
505
1068
|
out.thresholds = parseThresholds(value());
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
1069
|
+
}
|
|
1070
|
+
},
|
|
1071
|
+
(arg) => out.logs.push(arg)
|
|
1072
|
+
);
|
|
1073
|
+
requireLogsAndConfig(out, "replay");
|
|
1074
|
+
return out;
|
|
1075
|
+
}
|
|
1076
|
+
function parseTuneArgs(argv) {
|
|
1077
|
+
const out = {
|
|
1078
|
+
logs: [],
|
|
1079
|
+
includeLive: false,
|
|
1080
|
+
json: false,
|
|
1081
|
+
help: false,
|
|
1082
|
+
allEvents: false
|
|
1083
|
+
};
|
|
1084
|
+
parseFlags(
|
|
1085
|
+
argv,
|
|
1086
|
+
{
|
|
1087
|
+
...commonFlags(out),
|
|
1088
|
+
"--half-lives": (value) => {
|
|
1089
|
+
out.halfLives = parseDurations(value(), "--half-lives");
|
|
1090
|
+
},
|
|
1091
|
+
"--all-events": (_value, noValue) => {
|
|
518
1092
|
noValue();
|
|
519
|
-
out.
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
default:
|
|
526
|
-
if (flag.startsWith("-")) throw new UsageError(`unknown option ${flag}`);
|
|
527
|
-
out.logs.push(flag);
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
if (!out.help) {
|
|
531
|
-
if (out.logs.length === 0) throw new UsageError("replay needs at least one log file");
|
|
532
|
-
if (out.config === void 0) throw new UsageError("replay needs --config <file>");
|
|
533
|
-
}
|
|
1093
|
+
out.allEvents = true;
|
|
1094
|
+
}
|
|
1095
|
+
},
|
|
1096
|
+
(arg) => out.logs.push(arg)
|
|
1097
|
+
);
|
|
1098
|
+
requireLogsAndConfig(out, "tune");
|
|
534
1099
|
return out;
|
|
535
1100
|
}
|
|
536
1101
|
function version() {
|
|
@@ -562,12 +1127,7 @@ async function loadConfig(path, io) {
|
|
|
562
1127
|
}
|
|
563
1128
|
return value;
|
|
564
1129
|
}
|
|
565
|
-
async function
|
|
566
|
-
const args = parseReplayArgs(argv);
|
|
567
|
-
if (args.help) {
|
|
568
|
-
io.stdout(HELP);
|
|
569
|
-
return 0;
|
|
570
|
-
}
|
|
1130
|
+
async function loadCheckedConfig(args, io) {
|
|
571
1131
|
const configPath = resolve(io.cwd, args.config);
|
|
572
1132
|
const config = await loadConfig(configPath, io);
|
|
573
1133
|
let declared;
|
|
@@ -583,33 +1143,42 @@ async function replayCommand(argv, io) {
|
|
|
583
1143
|
throw new UsageError(`--intent ${name}: the config declares ${declared.join(", ")}`);
|
|
584
1144
|
}
|
|
585
1145
|
}
|
|
1146
|
+
return config;
|
|
1147
|
+
}
|
|
1148
|
+
function readLogs(files, io) {
|
|
586
1149
|
const entries = [];
|
|
587
|
-
for (const file of
|
|
1150
|
+
for (const file of files) {
|
|
588
1151
|
const parsed = parseLog(io.readFile(resolve(io.cwd, file)), file);
|
|
589
1152
|
for (const warning of parsed.warnings) io.stderr(`oracular: warning: ${warning}
|
|
590
1153
|
`);
|
|
591
1154
|
entries.push(...parsed.entries);
|
|
592
1155
|
}
|
|
593
1156
|
if (entries.length === 0) throw new Error("the log holds no records");
|
|
1157
|
+
return entries;
|
|
1158
|
+
}
|
|
1159
|
+
function checkModes(entries, includeLive, io, wording) {
|
|
594
1160
|
const live = entries.filter((e) => e.mode === "live").length;
|
|
595
1161
|
const shadow = entries.length - live;
|
|
596
|
-
if (shadow === 0 && !
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
io.stderr(
|
|
609
|
-
`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.
|
|
610
|
-
`
|
|
611
|
-
);
|
|
1162
|
+
if (shadow === 0 && !includeLive) throw new Error(wording.refused);
|
|
1163
|
+
if (live > 0 && !includeLive)
|
|
1164
|
+
io.stderr(`oracular: warning: ${wording.mixed(live, entries.length)}
|
|
1165
|
+
`);
|
|
1166
|
+
if (live > 0 && includeLive) io.stderr(`oracular: warning: ${wording.included(live)}
|
|
1167
|
+
`);
|
|
1168
|
+
}
|
|
1169
|
+
async function replayCommand(argv, io) {
|
|
1170
|
+
const args = parseReplayArgs(argv);
|
|
1171
|
+
if (args.help) {
|
|
1172
|
+
io.stdout(REPLAY_HELP);
|
|
1173
|
+
return 0;
|
|
612
1174
|
}
|
|
1175
|
+
const config = await loadCheckedConfig(args, io);
|
|
1176
|
+
const entries = readLogs(args.logs, io);
|
|
1177
|
+
checkModes(entries, args.includeLive, io, {
|
|
1178
|
+
refused: "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.",
|
|
1179
|
+
mixed: (live, total) => `the log mixes shadow and live records (${live} of ${total} 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.`,
|
|
1180
|
+
included: (live) => `scoring ${live} live records as asked. While reveals are shown they cause outcomes, so precision and recall over live periods overstate the engine.`
|
|
1181
|
+
});
|
|
613
1182
|
const options = { includeLive: args.includeLive };
|
|
614
1183
|
if (args.thresholds !== void 0) options.thresholds = args.thresholds;
|
|
615
1184
|
if (args.window !== void 0) options.window = args.window;
|
|
@@ -624,11 +1193,44 @@ async function replayCommand(argv, io) {
|
|
|
624
1193
|
io.stdout(args.json ? formatJson(report) : formatText(report));
|
|
625
1194
|
return 0;
|
|
626
1195
|
}
|
|
1196
|
+
async function tuneCommand(argv, io) {
|
|
1197
|
+
const args = parseTuneArgs(argv);
|
|
1198
|
+
if (args.help) {
|
|
1199
|
+
io.stdout(TUNE_HELP);
|
|
1200
|
+
return 0;
|
|
1201
|
+
}
|
|
1202
|
+
const config = await loadCheckedConfig(args, io);
|
|
1203
|
+
const entries = readLogs(args.logs, io);
|
|
1204
|
+
checkModes(entries, args.includeLive, io, {
|
|
1205
|
+
refused: "every record in the log is live, so there is nothing to fit. Tune reads the engine where reveals were not shown; once they are, the reveal itself causes outcomes. Pass --include-live to count the live episodes anyway, knowing the prior and the weights are contaminated.",
|
|
1206
|
+
mixed: (live, total) => `the log mixes shadow and live records (${live} of ${total} live). Only shadow episodes are counted: an episode in which any record was live is left out. Pass --include-live to count everything.`,
|
|
1207
|
+
included: (live) => `counting ${live} live records as asked. While reveals are shown they cause outcomes, so the prior and the weights fitted over live periods overstate the signals.`
|
|
1208
|
+
});
|
|
1209
|
+
const options = { includeLive: args.includeLive, allEvents: args.allEvents };
|
|
1210
|
+
if (args.halfLives !== void 0) options.halfLives = args.halfLives;
|
|
1211
|
+
if (args.window !== void 0) options.window = args.window;
|
|
1212
|
+
if (args.intents !== void 0) options.intents = args.intents;
|
|
1213
|
+
let report;
|
|
1214
|
+
try {
|
|
1215
|
+
report = tune(entries, config, options);
|
|
1216
|
+
} catch (error) {
|
|
1217
|
+
if (error instanceof RangeError) throw new UsageError(error.message);
|
|
1218
|
+
throw error;
|
|
1219
|
+
}
|
|
1220
|
+
if (report.installs < 2 * MIN_INSTALLS_FOR_SPREAD) {
|
|
1221
|
+
io.stderr(
|
|
1222
|
+
`oracular: warning: ${report.installs} install${report.installs === 1 ? "" : "s"} in the log. Treat every number as a rough starting point: the spread of base rates needs ${MIN_INSTALLS_FOR_SPREAD} installs, and a weight needs episodes both with and without its signal.
|
|
1223
|
+
`
|
|
1224
|
+
);
|
|
1225
|
+
}
|
|
1226
|
+
io.stdout(args.json ? formatTuneJson(report) : formatTuneText(report));
|
|
1227
|
+
return 0;
|
|
1228
|
+
}
|
|
627
1229
|
async function run(argv, io) {
|
|
628
1230
|
try {
|
|
629
1231
|
const [command, ...rest] = argv;
|
|
630
1232
|
if (command === void 0 || command === "--help" || command === "-h" || command === "help") {
|
|
631
|
-
io.stdout(
|
|
1233
|
+
io.stdout(USAGE);
|
|
632
1234
|
return 0;
|
|
633
1235
|
}
|
|
634
1236
|
if (command === "--version" || command === "-v") {
|
|
@@ -636,8 +1238,9 @@ async function run(argv, io) {
|
|
|
636
1238
|
`);
|
|
637
1239
|
return 0;
|
|
638
1240
|
}
|
|
639
|
-
if (command
|
|
640
|
-
return await
|
|
1241
|
+
if (command === "replay") return await replayCommand(rest, io);
|
|
1242
|
+
if (command === "tune") return await tuneCommand(rest, io);
|
|
1243
|
+
throw new UsageError(`unknown command ${JSON.stringify(command)}`);
|
|
641
1244
|
} catch (error) {
|
|
642
1245
|
if (error instanceof UsageError) {
|
|
643
1246
|
io.stderr(`oracular: ${error.message}
|