oracular 0.3.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 +20 -0
- package/README.md +14 -6
- package/dist/cli.js +695 -88
- package/dist/index.cjs +16 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +60 -1
- package/dist/index.d.ts +60 -1
- package/dist/index.js +15 -1
- package/dist/index.js.map +1 -1
- package/package.json +3 -1
- package/schema/config.schema.json +141 -0
package/dist/cli.js
CHANGED
|
@@ -7,7 +7,7 @@ import { readFileSync } from "fs";
|
|
|
7
7
|
import { createRequire } from "module";
|
|
8
8
|
import { extname, resolve } from "path";
|
|
9
9
|
import { pathToFileURL } from "url";
|
|
10
|
-
import {
|
|
10
|
+
import { validateConfig } from "oracular";
|
|
11
11
|
|
|
12
12
|
// cli/log.ts
|
|
13
13
|
import { RECORD_VERSION } from "oracular";
|
|
@@ -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,
|
|
@@ -425,7 +911,8 @@ reveals per user, and a reliability table with a Brier score.
|
|
|
425
911
|
|
|
426
912
|
Options:
|
|
427
913
|
--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
|
|
914
|
+
module whose default (or \`config\`) export is the config. A top-level
|
|
915
|
+
"$schema" key in a .json file is ignored
|
|
429
916
|
--thresholds <list> thresholds to score, comma separated
|
|
430
917
|
(default: 0.3 to 0.9 by 0.1, plus each intent's configured threshold)
|
|
431
918
|
--window <duration> how long after a reveal an outcome still counts for it: 3d, 12h, 90m
|
|
@@ -444,7 +931,42 @@ Only shadow periods are scored by default; a log with live records gets a warnin
|
|
|
444
931
|
with nothing but live records is refused unless --include-live is given.
|
|
445
932
|
|
|
446
933
|
Exit codes: 0 report printed, 1 unreadable input or nothing to score, 2 usage error.
|
|
447
|
-
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
|
|
448
970
|
`;
|
|
449
971
|
var UNITS = {
|
|
450
972
|
ms: 1,
|
|
@@ -455,7 +977,7 @@ var UNITS = {
|
|
|
455
977
|
w: 6048e5
|
|
456
978
|
};
|
|
457
979
|
function parseDuration(text, flag) {
|
|
458
|
-
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());
|
|
459
981
|
const value = match === null ? Number.NaN : Number(match[1]) * UNITS[match[2] ?? "ms"];
|
|
460
982
|
if (!(value > 0) || !Number.isFinite(value)) {
|
|
461
983
|
throw new UsageError(
|
|
@@ -475,8 +997,10 @@ function parseThresholds(text) {
|
|
|
475
997
|
return t;
|
|
476
998
|
});
|
|
477
999
|
}
|
|
478
|
-
function
|
|
479
|
-
|
|
1000
|
+
function parseDurations(text, flag) {
|
|
1001
|
+
return text.split(",").map((part) => parseDuration(part, flag));
|
|
1002
|
+
}
|
|
1003
|
+
function parseFlags(argv, flags, positional) {
|
|
480
1004
|
for (let i = 0; i < argv.length; i++) {
|
|
481
1005
|
let flag = argv[i];
|
|
482
1006
|
let inline;
|
|
@@ -496,40 +1020,82 @@ function parseReplayArgs(argv) {
|
|
|
496
1020
|
const noValue = () => {
|
|
497
1021
|
if (inline !== void 0) throw new UsageError(`${flag} takes no value`);
|
|
498
1022
|
};
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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) => {
|
|
504
1068
|
out.thresholds = parseThresholds(value());
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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) => {
|
|
517
1092
|
noValue();
|
|
518
|
-
out.
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
}
|
|
1093
|
+
out.allEvents = true;
|
|
1094
|
+
}
|
|
1095
|
+
},
|
|
1096
|
+
(arg) => out.logs.push(arg)
|
|
1097
|
+
);
|
|
1098
|
+
requireLogsAndConfig(out, "tune");
|
|
533
1099
|
return out;
|
|
534
1100
|
}
|
|
535
1101
|
function version() {
|
|
@@ -544,6 +1110,9 @@ async function loadConfig(path, io) {
|
|
|
544
1110
|
} catch (error) {
|
|
545
1111
|
throw new Error(`${path}: ${error.message}`);
|
|
546
1112
|
}
|
|
1113
|
+
if (typeof value === "object" && value !== null && "$schema" in value) {
|
|
1114
|
+
value = Object.fromEntries(Object.entries(value).filter(([key]) => key !== "$schema"));
|
|
1115
|
+
}
|
|
547
1116
|
} else if (ext === ".js" || ext === ".mjs" || ext === ".cjs") {
|
|
548
1117
|
const mod = await import(pathToFileURL(path).href);
|
|
549
1118
|
value = mod.default ?? mod.config;
|
|
@@ -558,17 +1127,12 @@ async function loadConfig(path, io) {
|
|
|
558
1127
|
}
|
|
559
1128
|
return value;
|
|
560
1129
|
}
|
|
561
|
-
async function
|
|
562
|
-
const args = parseReplayArgs(argv);
|
|
563
|
-
if (args.help) {
|
|
564
|
-
io.stdout(HELP);
|
|
565
|
-
return 0;
|
|
566
|
-
}
|
|
1130
|
+
async function loadCheckedConfig(args, io) {
|
|
567
1131
|
const configPath = resolve(io.cwd, args.config);
|
|
568
1132
|
const config = await loadConfig(configPath, io);
|
|
569
1133
|
let declared;
|
|
570
1134
|
try {
|
|
571
|
-
declared = Object.keys(
|
|
1135
|
+
declared = Object.keys(validateConfig(config).intents);
|
|
572
1136
|
} catch (error) {
|
|
573
1137
|
throw new Error(
|
|
574
1138
|
`${configPath} is not a valid config: ${error.message.replace(/^oracular: /, "")}`
|
|
@@ -579,33 +1143,42 @@ async function replayCommand(argv, io) {
|
|
|
579
1143
|
throw new UsageError(`--intent ${name}: the config declares ${declared.join(", ")}`);
|
|
580
1144
|
}
|
|
581
1145
|
}
|
|
1146
|
+
return config;
|
|
1147
|
+
}
|
|
1148
|
+
function readLogs(files, io) {
|
|
582
1149
|
const entries = [];
|
|
583
|
-
for (const file of
|
|
1150
|
+
for (const file of files) {
|
|
584
1151
|
const parsed = parseLog(io.readFile(resolve(io.cwd, file)), file);
|
|
585
1152
|
for (const warning of parsed.warnings) io.stderr(`oracular: warning: ${warning}
|
|
586
1153
|
`);
|
|
587
1154
|
entries.push(...parsed.entries);
|
|
588
1155
|
}
|
|
589
1156
|
if (entries.length === 0) throw new Error("the log holds no records");
|
|
1157
|
+
return entries;
|
|
1158
|
+
}
|
|
1159
|
+
function checkModes(entries, includeLive, io, wording) {
|
|
590
1160
|
const live = entries.filter((e) => e.mode === "live").length;
|
|
591
1161
|
const shadow = entries.length - live;
|
|
592
|
-
if (shadow === 0 && !
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
);
|
|
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;
|
|
608
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
|
+
});
|
|
609
1182
|
const options = { includeLive: args.includeLive };
|
|
610
1183
|
if (args.thresholds !== void 0) options.thresholds = args.thresholds;
|
|
611
1184
|
if (args.window !== void 0) options.window = args.window;
|
|
@@ -620,11 +1193,44 @@ async function replayCommand(argv, io) {
|
|
|
620
1193
|
io.stdout(args.json ? formatJson(report) : formatText(report));
|
|
621
1194
|
return 0;
|
|
622
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
|
+
}
|
|
623
1229
|
async function run(argv, io) {
|
|
624
1230
|
try {
|
|
625
1231
|
const [command, ...rest] = argv;
|
|
626
1232
|
if (command === void 0 || command === "--help" || command === "-h" || command === "help") {
|
|
627
|
-
io.stdout(
|
|
1233
|
+
io.stdout(USAGE);
|
|
628
1234
|
return 0;
|
|
629
1235
|
}
|
|
630
1236
|
if (command === "--version" || command === "-v") {
|
|
@@ -632,8 +1238,9 @@ async function run(argv, io) {
|
|
|
632
1238
|
`);
|
|
633
1239
|
return 0;
|
|
634
1240
|
}
|
|
635
|
-
if (command
|
|
636
|
-
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)}`);
|
|
637
1244
|
} catch (error) {
|
|
638
1245
|
if (error instanceof UsageError) {
|
|
639
1246
|
io.stderr(`oracular: ${error.message}
|