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/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 { createState as createState2 } from "oracular";
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 = /* @__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
- }
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 HELP = `Usage: oracular replay <log.ndjson>... --config <file> [options]
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: https://yuribenjamin.github.io/oracular/guide/shadow-mode
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 parseReplayArgs(argv) {
479
- const out = { logs: [], includeLive: false, json: false, help: false };
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
- switch (flag) {
500
- case "--config":
501
- out.config = value();
502
- break;
503
- case "--thresholds":
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
- 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":
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.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
- }
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 replayCommand(argv, io) {
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(createState2(config).config.intents);
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 args.logs) {
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 && !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
- );
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(HELP);
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 !== "replay") throw new UsageError(`unknown command ${JSON.stringify(command)}`);
636
- return await replayCommand(rest, io);
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}