oracular 0.5.0 → 0.5.2

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [#30](https://github.com/Yuribenjamin/oracular/pull/30) [`8be17e0`](https://github.com/Yuribenjamin/oracular/commit/8be17e032bb1b7886ce1d7278701555c39bb03cf) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - `oracular tune` learned three rules from a public e-commerce clickstream of 2.7 million events. A signal seen in more than 90% of counted episodes is left out of the fitted config, whatever its lift: it cannot tell episodes apart, and the few episodes without it are rarely typical sessions, so the contrast measured on them says little (on that log, `item.viewed` sat in 99% of sessions and the contrast called it strongly negative; dropping it scored better than any weight). The current half-life is kept when the best grid point improves on it by less than 1%, and the grid is printed to four decimals so a flat grid reads as flat. The report says when the fitted intent still does not beat its own base rate. The JSON report gains `share` and `ubiquitous` per signal and `beatsBaseRate` per intent.
8
+
9
+ ## 0.5.1
10
+
11
+ ### Patch Changes
12
+
13
+ - [#28](https://github.com/Yuribenjamin/oracular/pull/28) [`250a32d`](https://github.com/Yuribenjamin/oracular/commit/250a32d9764aeabdff020f99074c0a512ea7194f) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - `oracular replay` and `oracular tune` read logs of any size. A log of more than about a hundred thousand records made both commands fail with "Maximum call stack size exceeded", because every file's entries were spread into one `push` call. Found by replaying the Retailrocket clickstream (2.7 million events).
14
+
3
15
  ## 0.5.0
4
16
 
5
17
  ### Minor Changes
package/README.md CHANGED
@@ -382,7 +382,7 @@ npx oracular replay shadow.ndjson --config oracular.config.json
382
382
  npx oracular tune shadow.ndjson --config oracular.config.json
383
383
  ```
384
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.
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. Three rules learned from a real clickstream keep it honest: a signal seen in nearly every episode is left out, a flat grid keeps your half-life, and the report says when an intent still does not beat its base rate. The [Shadow mode guide](https://yuribenjamin.github.io/oracular/guide/shadow-mode) has the workflow, the record format and both commands.
386
386
 
387
387
  ## Design rules
388
388
 
package/dist/cli.js CHANGED
@@ -444,6 +444,8 @@ var DEFAULT_HALF_LIVES = [HOUR2, 6 * HOUR2, DAY2, 3 * DAY2, 7 * DAY2, 14 * DAY2,
444
444
  var DEFAULT_PRIOR_EPISODES = 10;
445
445
  var MIN_INSTALLS_FOR_SPREAD = 10;
446
446
  var STRENGTH_RANGE = [2, 50];
447
+ var UBIQUITY_SHARE = 0.9;
448
+ var FLAT_GRID_GAIN = 0.01;
447
449
  var Z90 = 1.6449;
448
450
  var NONE = { hits: 0, misses: 0 };
449
451
  function stepDelta(prev, next) {
@@ -498,6 +500,8 @@ function fitSignal(signal, current, tally, strength, priorEpisodes) {
498
500
  const contrast = nWith + without === 0 ? 0 : nWith * without / (nWith + without);
499
501
  const [qLow, qHigh] = wilson(hits, nWith, Z90);
500
502
  const rounded = Math.round(strength * l * 100) / 100;
503
+ const share = tally.episodes === 0 ? 0 : nWith / tally.episodes;
504
+ const ubiquitous = nWith > UBIQUITY_SHARE * tally.episodes;
501
505
  return {
502
506
  signal,
503
507
  current,
@@ -508,7 +512,9 @@ function fitSignal(signal, current, tally, strength, priorEpisodes) {
508
512
  qWith,
509
513
  qWithout,
510
514
  lift: l,
511
- weight: nWith === 0 || rounded === 0 ? null : rounded,
515
+ weight: nWith === 0 || ubiquitous || rounded === 0 ? null : rounded,
516
+ share,
517
+ ubiquitous,
512
518
  low: strength * lift(qLow, qWithout),
513
519
  high: strength * lift(qHigh, qWithout),
514
520
  contrast,
@@ -707,6 +713,7 @@ function tune(entries, config, options = {}) {
707
713
  return { halfLife: halfLife2, brier: scored?.brier ?? null, points: scored?.points ?? 0 };
708
714
  });
709
715
  const scoredPoints = curve.filter((p) => p.brier !== null);
716
+ const atCurrent = curve.find((p) => p.halfLife === intent.halfLife);
710
717
  let halfLife;
711
718
  if (scoredPoints.length === 0) {
712
719
  halfLife = {
@@ -723,17 +730,33 @@ function tune(entries, config, options = {}) {
723
730
  const ties = scoredPoints.filter((p) => p.brier - best <= 1e-12);
724
731
  const nearest = (p) => Math.abs(Math.log(p.halfLife / intent.halfLife));
725
732
  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
- };
733
+ const baseline = brierBaseline === null ? "" : ` (always predicting the base rate: ${brierBaseline.toFixed(4)})`;
734
+ const gain = atCurrent.brier === null || atCurrent.brier <= 0 ? Number.POSITIVE_INFINITY : (atCurrent.brier - best) / atCurrent.brier;
735
+ if (chosen.halfLife !== intent.halfLife && gain < FLAT_GRID_GAIN) {
736
+ halfLife = {
737
+ value: intent.halfLife,
738
+ current: intent.halfLife,
739
+ source: "kept",
740
+ note: `kept: the grid is flat, the best point (${formatDuration(chosen.halfLife)}, ${best.toFixed(4)}) improves on the current value (${atCurrent.brier.toFixed(4)}) by ${(gain * 100).toFixed(2)}%, less than ${FLAT_GRID_GAIN * 100}%${baseline}`,
741
+ window,
742
+ curve,
743
+ brierBaseline
744
+ };
745
+ } else {
746
+ halfLife = {
747
+ value: chosen.halfLife,
748
+ current: intent.halfLife,
749
+ source: "grid",
750
+ note: `lowest Brier score over the grid, ${best.toFixed(4)}${baseline}`,
751
+ window,
752
+ curve,
753
+ brierBaseline
754
+ };
755
+ }
735
756
  }
736
757
  fitted = withIntent(fitted, name, { ...fittedIntent, halfLife: halfLife.value });
758
+ const scoredAt = curve.find((p) => p.halfLife === halfLife.value);
759
+ const beatsBaseRate = scoredAt.brier === null || brierBaseline === null ? null : scoredAt.brier < brierBaseline;
737
760
  intents.push({
738
761
  intent: name,
739
762
  outcome: intent.outcome,
@@ -745,6 +768,7 @@ function tune(entries, config, options = {}) {
745
768
  priorStrength: strength,
746
769
  halfLife,
747
770
  priorEpisodes,
771
+ beatsBaseRate,
748
772
  signals,
749
773
  candidates
750
774
  });
@@ -771,7 +795,13 @@ var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
771
795
  function note(fit, priorEpisodes) {
772
796
  if (fit.hits + fit.misses === 0) return "never seen in a counted episode";
773
797
  const notes = [];
774
- if (fit.weight === null) notes.push("no measurable lift, left out of the config");
798
+ if (fit.ubiquitous) {
799
+ notes.push(
800
+ `in ${(fit.share * 100).toFixed(1)}% of episodes, so it cannot tell them apart; left out of the config`
801
+ );
802
+ } else if (fit.weight === null) {
803
+ notes.push("no measurable lift, left out of the config");
804
+ }
775
805
  if (fit.thin) notes.push(`thin: ${fit.contrast.toFixed(1)} of ${priorEpisodes} contrast`);
776
806
  return notes.join("; ");
777
807
  }
@@ -841,9 +871,18 @@ function formatIntent2(fit) {
841
871
  out.push("");
842
872
  out.push(
843
873
  `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" ? " *" : ""}`
874
+ (p) => `${formatDuration(p.halfLife)} ${num(p.brier, 4)}${p.halfLife === fit.halfLife.value && p.brier !== null ? " *" : ""}`
845
875
  ).join(", ")}`
846
876
  );
877
+ if (fit.beatsBaseRate === false) {
878
+ const scored = fit.halfLife.curve.find((p) => p.halfLife === fit.halfLife.value);
879
+ const window = formatDuration(fit.halfLife.window);
880
+ const why = fit.signals.some((f) => f.weight !== null) ? `Its signals do not separate the events that ${fit.outcome} follows within ${window} from the rest.` : `No signal is left in the config, so p never leaves the prior, and the prior (the share of episodes that end with ${fit.outcome}) is not the share of events that ${fit.outcome} follows within ${window}.`;
881
+ out.push("");
882
+ out.push(
883
+ `This intent does not beat its base rate: the fitted config scores ${num(scored?.brier ?? null, 4)} where always predicting the base rate scores ${num(fit.halfLife.brierBaseline, 4)} (lower is better). ${why} Drop the intent, or find signals that tell its episodes apart (--all-events measures every other event in the log).`
884
+ );
885
+ }
847
886
  if (fit.candidates.length > 0) {
848
887
  out.push("");
849
888
  out.push(
@@ -1151,7 +1190,7 @@ function readLogs(files, io) {
1151
1190
  const parsed = parseLog(io.readFile(resolve(io.cwd, file)), file);
1152
1191
  for (const warning of parsed.warnings) io.stderr(`oracular: warning: ${warning}
1153
1192
  `);
1154
- entries.push(...parsed.entries);
1193
+ for (const entry of parsed.entries) entries.push(entry);
1155
1194
  }
1156
1195
  if (entries.length === 0) throw new Error("the log holds no records");
1157
1196
  return entries;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oracular",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "On-device intent engine. Your app learns when the user is ready. Nothing leaves the device.",
5
5
  "keywords": [
6
6
  "oracular",