oracular 0.5.1 → 0.6.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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#32](https://github.com/Yuribenjamin/oracular/pull/32) [`6951d70`](https://github.com/Yuribenjamin/oracular/commit/6951d70b4ff1e654ab17a61357d882fd82240968) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **Cross-episode attribution.** `IntentConfig.attribution` is how long a closed episode's signals wait for the outcome before they take a miss. Until now a signal earned a hit only when the outcome arrived in the same episode, so for an intent that builds over days the sessions that built it took misses and the session where the user acted got all the credit. With a window set, an episode that ends without the outcome is held instead of counted; when the outcome arrives inside the window, each held episode gets the share `2^(−age / halfLife)` of a hit that its evidence still holds, and the rest of a miss, so every episode still counts exactly once and the contrast that learning rests on is unchanged; when the window passes, the episode is a miss recorded at the moment the window ended. Reads settle what has expired in what they show and store nothing. Off unless set: every state and estimate without `attribution` is exactly what it was.
8
+
9
+ **State version 3.** `IntentState.pending` lists the episodes waiting, at most 32 per intent (past that the oldest is a miss at once). `deserialize` upgrades version 1 and 2 states on its own, adding an empty list and changing no number; oracular 0.5.x cannot read a version 3 state, so the rollback advice stays: catch and start from `createState(config)`. `PendingEpisode` is exported as a type; the config JSON Schema gains `attribution`.
10
+
11
+ `oracular tune` counts each episode's own verdict, with the window off while counting, keeps it on for the half-life grid, and keeps whatever `attribution` you set in the printed config.
12
+
13
+ ## 0.5.2
14
+
15
+ ### Patch Changes
16
+
17
+ - [#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.
18
+
3
19
  ## 0.5.1
4
20
 
5
21
  ### Patch Changes
package/README.md CHANGED
@@ -107,7 +107,7 @@ Before any update or reading, the evidence decays by 2^(−Δt / halfLife), wher
107
107
 
108
108
  ### The user's history corrects your weights
109
109
 
110
- Events happen inside **episodes** (sessions). An episode ends when you call `endEpisode`, or on its own after `episodeTimeout` of inactivity. Signals seen in the episode are remembered per intent. When the intent's `outcome` event arrives, each remembered signal gets a **hit**; when the episode ends without it, each gets a **miss**. The engine also counts, per intent, how many episodes ended with and without the outcome: the intent's **base rate**.
110
+ Events happen inside **episodes** (sessions). An episode ends when you call `endEpisode`, or on its own after `episodeTimeout` of inactivity. Signals seen in the episode are remembered per intent. When the intent's `outcome` event arrives, each remembered signal gets a **hit**; when the episode ends without it, each gets a **miss**, or, with `attribution` set on the intent, the episode waits and takes the share `2^(−age / halfLife)` of a hit if the outcome arrives inside the window. The engine also counts, per intent, how many episodes ended with and without the outcome: the intent's **base rate**.
111
111
 
112
112
  A signal's weight is then a blend of your prior and the learned estimate:
113
113
 
@@ -252,7 +252,7 @@ serialize(state): string
252
252
  deserialize(json: string, opts?: { config?: EngineConfig; migrate?: Migrate }): EngineState
253
253
  validateConfig(config: unknown): EngineConfig // the checks createState runs, without a state; returns the normalized copy
254
254
  ms.seconds(n), ms.minutes(n), ms.hours(n), ms.days(n), ms.weeks(n) // durations in milliseconds
255
- STATE_VERSION // 2
255
+ STATE_VERSION // 3
256
256
  RECORD_VERSION // 1
257
257
  ```
258
258
 
@@ -278,6 +278,7 @@ interface IntentConfig {
278
278
  outcome?: string; // event that confirms the intent
279
279
  resetOnOutcome?: boolean; // default true
280
280
  saturate?: boolean; // repeats within an episode add less; default: episodeTimeout is set
281
+ attribution?: number; // how long a closed episode waits for the outcome before it is a miss; off if omitted
281
282
  reveal?: RevealOptions;
282
283
  }
283
284
 
@@ -337,7 +338,7 @@ const state = deserialize(saved, { config });
337
338
 
338
339
  The new config is validated and swapped in. Intents and signals you no longer declare are dropped (α and β are recomputed from what remains), newly declared intents start at their prior, and everything learned about signals that still exist is kept.
339
340
 
340
- States saved by an older version of this library are upgraded on load: `deserialize` migrates the state shape written by oracular 0.1.0 to 0.2.x (`STATE_VERSION` 1) to the current one on its own. A `migrate` function is consulted only for a version the library does not know, such as a state written by a newer oracular before a rollback; without one, that throws a `RangeError`:
341
+ States saved by an older version of this library are upgraded on load: `deserialize` migrates the state shapes written by oracular 0.1.0 to 0.5.x (`STATE_VERSION` 1 and 2) to the current one on its own. A `migrate` function is consulted only for a version the library does not know, such as a state written by a newer oracular before a rollback; without one, that throws a `RangeError`:
341
342
 
342
343
  ```ts
343
344
  deserialize(saved, {
@@ -382,7 +383,7 @@ npx oracular replay shadow.ndjson --config oracular.config.json
382
383
  npx oracular tune shadow.ndjson --config oracular.config.json
383
384
  ```
384
385
 
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.
386
+ `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
387
 
387
388
  ## Design rules
388
389
 
@@ -393,7 +394,7 @@ npx oracular tune shadow.ndjson --config oracular.config.json
393
394
 
394
395
  ## Roadmap
395
396
 
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).
397
+ Now is 0.4, themed **learning that matches how people behave**. Shipped so far: cross-episode attribution (`attribution` on an intent, so a session's signals earn a share of an outcome days later), `npx oracular tune` to seed a config from a shadow log, and small ergonomics (`validateConfig`, `ms`, a JSON Schema for the config). Presets come once tune has run on more real logs. 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).
397
398
 
398
399
  ## Contributing
399
400
 
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,
@@ -569,6 +575,16 @@ function withoutLearnedDecay(config) {
569
575
  );
570
576
  return { ...config, learning };
571
577
  }
578
+ function withoutAttribution(config) {
579
+ const intents = Object.fromEntries(
580
+ Object.entries(config.intents).map(([name, intent]) => {
581
+ if (intent.attribution === void 0) return [name, intent];
582
+ const { attribution: _attribution, ...rest } = intent;
583
+ return [name, rest];
584
+ })
585
+ );
586
+ return { ...config, intents };
587
+ }
572
588
  function cooldownOf(base, name) {
573
589
  return predict2(markRevealed2(base, name, 0), name, 0).suppressedUntil ?? 0;
574
590
  }
@@ -607,7 +623,7 @@ function tune(entries, config, options = {}) {
607
623
  const candidatesOf = (intent) => options.allEvents === true ? [...eventNames].filter(
608
624
  (name) => name !== "__proto__" && name !== intent.outcome && !Object.hasOwn(intent.signals, name)
609
625
  ) : [];
610
- let countConfig = withoutLearnedDecay(normalized);
626
+ let countConfig = withoutAttribution(withoutLearnedDecay(normalized));
611
627
  const plans = fitting.map((name) => {
612
628
  const intent = normalized.intents[name];
613
629
  const candidates = candidatesOf(intent);
@@ -707,6 +723,7 @@ function tune(entries, config, options = {}) {
707
723
  return { halfLife: halfLife2, brier: scored?.brier ?? null, points: scored?.points ?? 0 };
708
724
  });
709
725
  const scoredPoints = curve.filter((p) => p.brier !== null);
726
+ const atCurrent = curve.find((p) => p.halfLife === intent.halfLife);
710
727
  let halfLife;
711
728
  if (scoredPoints.length === 0) {
712
729
  halfLife = {
@@ -723,17 +740,33 @@ function tune(entries, config, options = {}) {
723
740
  const ties = scoredPoints.filter((p) => p.brier - best <= 1e-12);
724
741
  const nearest = (p) => Math.abs(Math.log(p.halfLife / intent.halfLife));
725
742
  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
- };
743
+ const baseline = brierBaseline === null ? "" : ` (always predicting the base rate: ${brierBaseline.toFixed(4)})`;
744
+ const gain = atCurrent.brier === null || atCurrent.brier <= 0 ? Number.POSITIVE_INFINITY : (atCurrent.brier - best) / atCurrent.brier;
745
+ if (chosen.halfLife !== intent.halfLife && gain < FLAT_GRID_GAIN) {
746
+ halfLife = {
747
+ value: intent.halfLife,
748
+ current: intent.halfLife,
749
+ source: "kept",
750
+ 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}`,
751
+ window,
752
+ curve,
753
+ brierBaseline
754
+ };
755
+ } else {
756
+ halfLife = {
757
+ value: chosen.halfLife,
758
+ current: intent.halfLife,
759
+ source: "grid",
760
+ note: `lowest Brier score over the grid, ${best.toFixed(4)}${baseline}`,
761
+ window,
762
+ curve,
763
+ brierBaseline
764
+ };
765
+ }
735
766
  }
736
767
  fitted = withIntent(fitted, name, { ...fittedIntent, halfLife: halfLife.value });
768
+ const scoredAt = curve.find((p) => p.halfLife === halfLife.value);
769
+ const beatsBaseRate = scoredAt.brier === null || brierBaseline === null ? null : scoredAt.brier < brierBaseline;
737
770
  intents.push({
738
771
  intent: name,
739
772
  outcome: intent.outcome,
@@ -745,6 +778,7 @@ function tune(entries, config, options = {}) {
745
778
  priorStrength: strength,
746
779
  halfLife,
747
780
  priorEpisodes,
781
+ beatsBaseRate,
748
782
  signals,
749
783
  candidates
750
784
  });
@@ -771,7 +805,13 @@ var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
771
805
  function note(fit, priorEpisodes) {
772
806
  if (fit.hits + fit.misses === 0) return "never seen in a counted episode";
773
807
  const notes = [];
774
- if (fit.weight === null) notes.push("no measurable lift, left out of the config");
808
+ if (fit.ubiquitous) {
809
+ notes.push(
810
+ `in ${(fit.share * 100).toFixed(1)}% of episodes, so it cannot tell them apart; left out of the config`
811
+ );
812
+ } else if (fit.weight === null) {
813
+ notes.push("no measurable lift, left out of the config");
814
+ }
775
815
  if (fit.thin) notes.push(`thin: ${fit.contrast.toFixed(1)} of ${priorEpisodes} contrast`);
776
816
  return notes.join("; ");
777
817
  }
@@ -841,9 +881,18 @@ function formatIntent2(fit) {
841
881
  out.push("");
842
882
  out.push(
843
883
  `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" ? " *" : ""}`
884
+ (p) => `${formatDuration(p.halfLife)} ${num(p.brier, 4)}${p.halfLife === fit.halfLife.value && p.brier !== null ? " *" : ""}`
845
885
  ).join(", ")}`
846
886
  );
887
+ if (fit.beatsBaseRate === false) {
888
+ const scored = fit.halfLife.curve.find((p) => p.halfLife === fit.halfLife.value);
889
+ const window = formatDuration(fit.halfLife.window);
890
+ 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}.`;
891
+ out.push("");
892
+ out.push(
893
+ `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).`
894
+ );
895
+ }
847
896
  if (fit.candidates.length > 0) {
848
897
  out.push("");
849
898
  out.push(
package/dist/index.cjs CHANGED
@@ -49,6 +49,86 @@ function mapRecord(record2, fn) {
49
49
  return out;
50
50
  }
51
51
 
52
+ // src/learning.ts
53
+ var NONE = { hits: 0, misses: 0 };
54
+ function decayLearned(state, learning, at) {
55
+ if (state.learnedAt === null) return { ...state, learnedAt: at };
56
+ const learnedAt = Math.max(state.learnedAt, at);
57
+ const d = learning.halfLife === null ? 1 : decayFactor(at - state.learnedAt, learning.halfLife);
58
+ if (d === 1) return learnedAt === state.learnedAt ? state : { ...state, learnedAt };
59
+ return {
60
+ ...state,
61
+ learnedAt,
62
+ learned: mapRecord(state.learned, (r) => ({ hits: r.hits * d, misses: r.misses * d })),
63
+ episodes: { hits: state.episodes.hits * d, misses: state.episodes.misses * d }
64
+ };
65
+ }
66
+ function signalWeights(state, intent, learning, signal) {
67
+ const initial = getOwn(intent.signals, signal);
68
+ if (initial === void 0) {
69
+ throw new RangeError(`oracular: ${JSON.stringify(signal)} is not a signal of this intent`);
70
+ }
71
+ const record2 = getOwn(state.learned, signal) ?? NONE;
72
+ const nWith = record2.hits + record2.misses;
73
+ const qWith = laplace(record2.hits, nWith);
74
+ const totalEpisodes = state.episodes.hits + state.episodes.misses;
75
+ const nWithout = Math.max(0, totalEpisodes - nWith);
76
+ const outcomesWithout = Math.min(nWithout, Math.max(0, state.episodes.hits - record2.hits));
77
+ const qWithout = laplace(outcomesWithout, nWithout);
78
+ const learned = nWith === 0 ? 0 : learning.gain * Math.abs(initial) * lift(qWith, qWithout);
79
+ const nEff = nWith + nWithout === 0 ? 0 : nWith * nWithout / (nWith + nWithout);
80
+ const k = learning.priorEpisodes;
81
+ const effective = (k * initial + nEff * learned) / (k + nEff);
82
+ return { initial, learned, effective, hits: record2.hits, misses: record2.misses };
83
+ }
84
+ function recordEpisode(state, signals, credit) {
85
+ const miss = 1 - credit;
86
+ const learned = { ...state.learned };
87
+ for (const signal of signals) {
88
+ const r = getOwn(learned, signal) ?? NONE;
89
+ learned[signal] = { hits: r.hits + credit, misses: r.misses + miss };
90
+ }
91
+ const episodes = { hits: state.episodes.hits + credit, misses: state.episodes.misses + miss };
92
+ return { ...state, learned, episodes };
93
+ }
94
+
95
+ // src/attribution.ts
96
+ var MAX_PENDING = 32;
97
+ function expiry(episode, intent) {
98
+ return episode.endedAt + (intent.attribution ?? 0);
99
+ }
100
+ function settlePending(state, intent, learning, at) {
101
+ let is = state;
102
+ let settled = 0;
103
+ for (const episode of state.pending) {
104
+ const end = expiry(episode, intent);
105
+ if (!(end < at)) break;
106
+ is = recordEpisode(decayLearned(is, learning, end), episode.seen, 0);
107
+ settled++;
108
+ }
109
+ if (settled > 0) is = { ...is, pending: state.pending.slice(settled) };
110
+ return decayLearned(is, learning, at);
111
+ }
112
+ function deferEpisode(state, seen, endedAt) {
113
+ const pending = insert(state.pending, { endedAt, seen: [...seen] });
114
+ if (pending.length <= MAX_PENDING) return { ...state, pending };
115
+ const [oldest, ...rest] = pending;
116
+ return { ...recordEpisode(state, oldest.seen, 0), pending: rest };
117
+ }
118
+ function insert(pending, episode) {
119
+ let i = pending.length;
120
+ while (i > 0 && pending[i - 1].endedAt > episode.endedAt) i--;
121
+ return [...pending.slice(0, i), episode, ...pending.slice(i)];
122
+ }
123
+ function creditPending(state, intent, at) {
124
+ if (state.pending.length === 0) return state;
125
+ let is = state;
126
+ for (const episode of state.pending) {
127
+ is = recordEpisode(is, episode.seen, decayFactor(at - episode.endedAt, intent.halfLife));
128
+ }
129
+ return { ...is, pending: [] };
130
+ }
131
+
52
132
  // src/intent.ts
53
133
  function priorCounts(intent) {
54
134
  return {
@@ -65,6 +145,7 @@ function initIntent(intent) {
65
145
  contributions: {},
66
146
  learned: {},
67
147
  episodes: { hits: 0, misses: 0 },
148
+ pending: [],
68
149
  learnedAt: null,
69
150
  lastRevealedAt: null,
70
151
  lastOutcomeAt: null,
@@ -101,48 +182,6 @@ function resetToPrior(state, intent) {
101
182
  return withContributions(state, intent, {});
102
183
  }
103
184
 
104
- // src/learning.ts
105
- var NONE = { hits: 0, misses: 0 };
106
- function decayLearned(state, learning, at) {
107
- if (state.learnedAt === null) return { ...state, learnedAt: at };
108
- const learnedAt = Math.max(state.learnedAt, at);
109
- const d = learning.halfLife === null ? 1 : decayFactor(at - state.learnedAt, learning.halfLife);
110
- if (d === 1) return learnedAt === state.learnedAt ? state : { ...state, learnedAt };
111
- return {
112
- ...state,
113
- learnedAt,
114
- learned: mapRecord(state.learned, (r) => ({ hits: r.hits * d, misses: r.misses * d })),
115
- episodes: { hits: state.episodes.hits * d, misses: state.episodes.misses * d }
116
- };
117
- }
118
- function signalWeights(state, intent, learning, signal) {
119
- const initial = getOwn(intent.signals, signal);
120
- if (initial === void 0) {
121
- throw new RangeError(`oracular: ${JSON.stringify(signal)} is not a signal of this intent`);
122
- }
123
- const record2 = getOwn(state.learned, signal) ?? NONE;
124
- const nWith = record2.hits + record2.misses;
125
- const qWith = laplace(record2.hits, nWith);
126
- const totalEpisodes = state.episodes.hits + state.episodes.misses;
127
- const nWithout = Math.max(0, totalEpisodes - nWith);
128
- const outcomesWithout = Math.min(nWithout, Math.max(0, state.episodes.hits - record2.hits));
129
- const qWithout = laplace(outcomesWithout, nWithout);
130
- const learned = nWith === 0 ? 0 : learning.gain * Math.abs(initial) * lift(qWith, qWithout);
131
- const nEff = nWith + nWithout === 0 ? 0 : nWith * nWithout / (nWith + nWithout);
132
- const k = learning.priorEpisodes;
133
- const effective = (k * initial + nEff * learned) / (k + nEff);
134
- return { initial, learned, effective, hits: record2.hits, misses: record2.misses };
135
- }
136
- function recordEpisode(state, signals, outcome) {
137
- const learned = { ...state.learned };
138
- for (const signal of signals) {
139
- const r = getOwn(learned, signal) ?? NONE;
140
- learned[signal] = outcome ? { hits: r.hits + 1, misses: r.misses } : { hits: r.hits, misses: r.misses + 1 };
141
- }
142
- const episodes = outcome ? { hits: state.episodes.hits + 1, misses: state.episodes.misses } : { hits: state.episodes.hits, misses: state.episodes.misses + 1 };
143
- return { ...state, learned, episodes };
144
- }
145
-
146
185
  // src/config.ts
147
186
  var DEFAULT_THRESHOLD = 0.5;
148
187
  var DEFAULT_Z = 1.28;
@@ -158,6 +197,7 @@ var INTENT_KEYS = [
158
197
  "outcome",
159
198
  "resetOnOutcome",
160
199
  "saturate",
200
+ "attribution",
161
201
  "reveal"
162
202
  ];
163
203
  var REVEAL_KEYS = ["threshold", "z", "cooldown"];
@@ -287,6 +327,9 @@ function normalizeIntentConfig(value, path) {
287
327
  if (typeof record2.saturate !== "boolean") typeError(`${path}.saturate`, "must be a boolean");
288
328
  out.saturate = record2.saturate;
289
329
  }
330
+ if (record2.attribution !== void 0) {
331
+ out.attribution = expectPositive(record2.attribution, `${path}.attribution`);
332
+ }
290
333
  if (record2.reveal !== void 0)
291
334
  out.reveal = normalizeRevealOptions(record2.reveal, `${path}.reveal`);
292
335
  return out;
@@ -409,7 +452,7 @@ function markResolved(episode, intent) {
409
452
  }
410
453
 
411
454
  // src/version.ts
412
- var STATE_VERSION = 2;
455
+ var STATE_VERSION = 3;
413
456
  var RECORD_VERSION = 1;
414
457
 
415
458
  // src/state.ts
@@ -440,7 +483,8 @@ function getIntentState(state, name, intent) {
440
483
 
441
484
  // src/assess.ts
442
485
  function assessIntent(state, name, intent, learning, at) {
443
- return decayLearned(decayIntent(getIntentState(state, name, intent), intent, at), learning, at);
486
+ const is = decayIntent(getIntentState(state, name, intent), intent, at);
487
+ return settlePending(is, intent, learning, at);
444
488
  }
445
489
  function cooldownEnd(since, cooldown, at) {
446
490
  if (since === null) return null;
@@ -567,7 +611,7 @@ function observe(state, event) {
567
611
  if (!isSignal && !isOutcome) continue;
568
612
  let is = getIntentState(s, intentName, intent);
569
613
  is = decayIntent(is, intent, at);
570
- is = decayLearned(is, learning, at);
614
+ is = settlePending(is, intent, learning, at);
571
615
  if (isSignal) {
572
616
  const { effective } = signalWeights(is, intent, learning, name);
573
617
  const n = occurrences(episode, intentName, name) + 1;
@@ -577,7 +621,8 @@ function observe(state, event) {
577
621
  if (!isOutcome) episode = markSeen(episode, intentName, name);
578
622
  }
579
623
  if (isOutcome) {
580
- is = recordEpisode(is, episodeRecord(episode, intentName).seen, true);
624
+ is = recordEpisode(is, episodeRecord(episode, intentName).seen, 1);
625
+ is = creditPending(is, intent, at);
581
626
  episode = markResolved(episode, intentName);
582
627
  is = { ...is, lastOutcomeAt: at };
583
628
  if (intent.resetOnOutcome ?? true) is = resetToPrior(is, intent);
@@ -600,8 +645,8 @@ function closeEpisode(state, at) {
600
645
  const record2 = episodeRecord(state.episode, intentName);
601
646
  if (record2.resolved) continue;
602
647
  let is = getIntentState(state, intentName, intent);
603
- is = decayLearned(is, learning, at);
604
- is = recordEpisode(is, record2.seen, false);
648
+ is = settlePending(is, intent, learning, at);
649
+ is = intent.attribution === void 0 ? recordEpisode(is, record2.seen, 0) : deferEpisode(is, record2.seen, at);
605
650
  intents[intentName] = is;
606
651
  }
607
652
  return { ...state, intents, episode: emptyEpisode() };
@@ -709,7 +754,8 @@ function createEndRecord(at, opts) {
709
754
 
710
755
  // src/migrate.ts
711
756
  var MIGRATIONS = {
712
- 1: fromV1
757
+ 1: fromV1,
758
+ 2: fromV2
713
759
  };
714
760
  function canMigrate(version) {
715
761
  return hasOwn(MIGRATIONS, String(version));
@@ -740,6 +786,16 @@ function fromV1(stored) {
740
786
  }
741
787
  return { ...stored, version: 2, episode: { ...episode, intents } };
742
788
  }
789
+ function fromV2(stored) {
790
+ if (!isRecord(stored.intents)) return { ...stored, version: 3 };
791
+ const intents = {};
792
+ for (const key of Object.keys(stored.intents)) {
793
+ if (key === "__proto__") continue;
794
+ const intent = stored.intents[key];
795
+ intents[key] = isRecord(intent) ? { ...intent, pending: [] } : intent;
796
+ }
797
+ return { ...stored, version: 3, intents };
798
+ }
743
799
 
744
800
  // src/serialize.ts
745
801
  function serialize(state) {
@@ -826,6 +882,19 @@ function mapOwn(value, path, fn) {
826
882
  }
827
883
  return out;
828
884
  }
885
+ function pendingEpisodes(value, path) {
886
+ if (!Array.isArray(value)) typeError2(path, "must be an array");
887
+ if (value.length > MAX_PENDING) {
888
+ throw new RangeError(`oracular: ${path} must hold at most ${MAX_PENDING} episodes`);
889
+ }
890
+ return value.map((item, i) => {
891
+ const r = record(item, `${path}[${i}]`);
892
+ if (!Array.isArray(r.seen) || !r.seen.every((s) => typeof s === "string")) {
893
+ typeError2(`${path}[${i}].seen`, "must be an array of strings");
894
+ }
895
+ return { endedAt: finite(r.endedAt, `${path}[${i}].endedAt`), seen: r.seen };
896
+ });
897
+ }
829
898
  function intentState(value, path) {
830
899
  const r = record(value, path);
831
900
  const revealCount = finite(r.revealCount, `${path}.revealCount`);
@@ -839,6 +908,7 @@ function intentState(value, path) {
839
908
  contributions: mapOwn(r.contributions, `${path}.contributions`, contribution),
840
909
  learned: mapOwn(r.learned, `${path}.learned`, signalRecord),
841
910
  episodes: signalRecord(r.episodes, `${path}.episodes`),
911
+ pending: pendingEpisodes(r.pending, `${path}.pending`),
842
912
  learnedAt: nullOrFinite(r.learnedAt, `${path}.learnedAt`),
843
913
  lastRevealedAt: nullOrFinite(r.lastRevealedAt, `${path}.lastRevealedAt`),
844
914
  lastOutcomeAt: nullOrFinite(r.lastOutcomeAt, `${path}.lastOutcomeAt`),
@@ -907,6 +977,12 @@ function keep(source, declared) {
907
977
  }
908
978
  return out;
909
979
  }
980
+ function keepPending(pending, signals) {
981
+ return pending.map((episode) => ({
982
+ endedAt: episode.endedAt,
983
+ seen: episode.seen.filter((s, i) => hasOwn(signals, s) && episode.seen.indexOf(s) === i)
984
+ })).sort((a, b) => a.endedAt - b.endedAt);
985
+ }
910
986
  function reconcile(raw, config) {
911
987
  const intents = {};
912
988
  const episodeIntents = {};
@@ -916,7 +992,11 @@ function reconcile(raw, config) {
916
992
  intents[name] = initIntent(intent);
917
993
  } else {
918
994
  intents[name] = withContributions(
919
- { ...stored, learned: keep(stored.learned, intent.signals) },
995
+ {
996
+ ...stored,
997
+ learned: keep(stored.learned, intent.signals),
998
+ pending: keepPending(stored.pending, intent.signals)
999
+ },
920
1000
  intent,
921
1001
  keep(stored.contributions, intent.signals)
922
1002
  );