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/dist/index.d.cts CHANGED
@@ -2,8 +2,8 @@
2
2
  * Version of the serialized state shape, stored in every state as `state.version`. Bumped only
3
3
  * when `EngineState` changes incompatibly, and every bump ships its migration: {@link deserialize}
4
4
  * upgrades states saved by earlier versions of this library on its own (version 1, written by
5
- * oracular 0.1.0 to 0.2.x, becomes version 2) and calls your `migrate` function only for a version
6
- * it does not know.
5
+ * oracular 0.1.0 to 0.2.x, and version 2, written by 0.3.0 to 0.5.x, become version 3) and calls
6
+ * your `migrate` function only for a version it does not know.
7
7
  *
8
8
  * @example
9
9
  * ```ts
@@ -19,7 +19,7 @@
19
19
  * });
20
20
  * ```
21
21
  */
22
- declare const STATE_VERSION: 2;
22
+ declare const STATE_VERSION: 3;
23
23
  /**
24
24
  * Version of the recorder format: the `v` field of every record that {@link createRecord} and
25
25
  * {@link createEndRecord} produce. A new optional field does not change it; readers ignore fields
@@ -112,6 +112,17 @@ interface IntentConfig {
112
112
  * weight.
113
113
  */
114
114
  saturate?: boolean;
115
+ /**
116
+ * How long a closed episode's signals wait for the outcome before they take a miss, in the same
117
+ * unit as `at`. With it set, an episode that ends without the outcome is held instead of
118
+ * counted. When the outcome arrives inside the window, each held episode gets a share
119
+ * `2^(−age / halfLife)` of a hit and the rest of a miss, with `age` measured from the episode's
120
+ * close: the sessions that built up to the outcome are credited by how much of their evidence
121
+ * still stands. When the window passes, the episode is a miss, recorded at the moment the window
122
+ * ended. Unset: an episode that ends without the outcome is a miss at once. A natural first value
123
+ * is the intent's `halfLife`, where the smallest share inside the window is one half.
124
+ */
125
+ attribution?: number;
115
126
  /** Reveal defaults for this intent. */
116
127
  reveal?: RevealOptions;
117
128
  }
@@ -143,11 +154,18 @@ interface SignalContribution {
143
154
  }
144
155
  /** What this user's history says about one signal. Decays with `learning.halfLife`. */
145
156
  interface SignalRecord {
146
- /** Episodes in which the signal was followed by the outcome. */
157
+ /** Episodes in which the signal was followed by the outcome. An episode credited by a later outcome under `attribution` adds its share here and the rest to `misses`. */
147
158
  hits: number;
148
159
  /** Episodes in which it was not. */
149
160
  misses: number;
150
161
  }
162
+ /** A closed episode waiting for the intent's outcome inside its `attribution` window. */
163
+ interface PendingEpisode {
164
+ /** When the episode closed. Its wait ends `attribution` later. */
165
+ endedAt: number;
166
+ /** The signals seen in it, each once. */
167
+ seen: string[];
168
+ }
151
169
  /**
152
170
  * Everything the engine knows about one intent for this user. Plain data; read it for debugging
153
171
  * or dashboards, but change it only through the engine's functions.
@@ -163,6 +181,11 @@ interface IntentState {
163
181
  learned: Record<string, SignalRecord>;
164
182
  /** Episodes that ended with (`hits`) or without (`misses`) the outcome: the base rate. */
165
183
  episodes: SignalRecord;
184
+ /**
185
+ * Closed episodes whose verdict waits for the outcome (see {@link IntentConfig.attribution}),
186
+ * in order of close, at most 32. Empty unless `attribution` is set.
187
+ */
188
+ pending: PendingEpisode[];
166
189
  /** Time `learned` and `episodes` were last decayed to. */
167
190
  learnedAt: number | null;
168
191
  lastRevealedAt: number | null;
@@ -314,7 +337,7 @@ interface Evaluation {
314
337
  /**
315
338
  * Turns a stored state of a version this library cannot migrate itself into a current
316
339
  * `EngineState`. Receives the parsed JSON as-is and the version it declares. States saved by
317
- * oracular 0.1.0 to 0.2.x (version 1) never reach it: `deserialize` upgrades those on its own.
340
+ * oracular 0.1.0 to 0.5.x (versions 1 and 2) never reach it: `deserialize` upgrades those on its own.
318
341
  *
319
342
  * @example
320
343
  * ```ts
@@ -338,7 +361,7 @@ interface DeserializeOptions {
338
361
  config?: EngineConfig;
339
362
  /**
340
363
  * Called when the stored version is neither `STATE_VERSION` nor one this library migrates
341
- * itself (states from oracular 0.1.0 to 0.2.x are upgraded without it).
364
+ * itself (states from oracular 0.1.0 to 0.5.x are upgraded without it).
342
365
  */
343
366
  migrate?: Migrate;
344
367
  }
@@ -517,10 +540,11 @@ declare function explain(state: EngineState, intent: string, at: number, opts?:
517
540
  * `w × log2(1 + 1/n)`, counted since the episode started or the intent's outcome last arrived.
518
541
  * That is the default when `config.episodeTimeout` is set, and `IntentConfig.saturate` overrides
519
542
  * it either way; otherwise every occurrence adds all of `w`. For every intent the event is the
520
- * outcome of: give the remembered signals a hit, count the episode toward the base rate, record
521
- * `lastOutcomeAt`, and (by default) reset the evidence to the prior. Any event opens or extends
522
- * the current episode; an idle gap longer than `config.episodeTimeout` closes the previous
523
- * episode first.
543
+ * outcome of: give the remembered signals a hit, count the episode toward the base rate, credit
544
+ * the closed episodes still waiting under `IntentConfig.attribution` by `2^(−age / halfLife)`
545
+ * each, record `lastOutcomeAt`, and (by default) reset the evidence to the prior. Any event opens
546
+ * or extends the current episode; an idle gap longer than `config.episodeTimeout` closes the
547
+ * previous episode first.
524
548
  *
525
549
  * @param state - The current state. Not mutated.
526
550
  * @param event - `name` is matched against every intent's signals and outcome; `at` is the
@@ -540,7 +564,8 @@ declare function explain(state: EngineState, intent: string, at: number, opts?:
540
564
  declare function observe(state: EngineState, event: IntentEvent): EngineState;
541
565
  /**
542
566
  * Ends the current episode at `at`. Every intent whose outcome did not arrive gives a miss to
543
- * each signal seen this episode and counts the episode toward its base rate. Call it when a
567
+ * each signal seen this episode and counts the episode toward its base rate, or, with
568
+ * `IntentConfig.attribution` set, holds the episode for the outcome instead. Call it when a
544
569
  * session ends: tab closed, app backgrounded, user signed out.
545
570
  *
546
571
  * @param state - The current state. Not mutated.
@@ -562,7 +587,8 @@ declare function endEpisode(state: EngineState, at: number): EngineState;
562
587
  * Whether an episode closed between two states: `prev` had an open episode and `next` either
563
588
  * has none or has a different one. Useful after `observe` or `endEpisode` to react to a
564
589
  * session ending, for example to persist, or to read the misses it produced as
565
- * `next.intents[x].episodes.misses - prev.intents[x].episodes.misses`.
590
+ * `next.intents[x].episodes.misses - prev.intents[x].episodes.misses` (0 for an intent with
591
+ * `attribution` set, whose verdict waits).
566
592
  *
567
593
  * @param prev - The state before the operation.
568
594
  * @param next - The state after it.
@@ -711,7 +737,7 @@ declare function serialize(state: EngineState): string;
711
737
  * Always pass the app's current `config`: it is validated and replaces the stored snapshot,
712
738
  * intents and signals no longer declared are dropped (α and β are recomputed from the kept
713
739
  * contributions), and newly declared intents start at their prior. States saved by oracular
714
- * 0.1.0 to 0.2.x are upgraded to the current shape on their own; `migrate` is only for a version
740
+ * 0.1.0 to 0.5.x are upgraded to the current shape on their own; `migrate` is only for a version
715
741
  * this library does not know.
716
742
  *
717
743
  * @param json - A string produced by {@link serialize}.
@@ -823,4 +849,4 @@ declare const ms: Units;
823
849
  */
824
850
  declare function validateConfig(config: unknown): EngineConfig;
825
851
 
826
- export { type DeserializeOptions, type EndRecord, type EngineConfig, type EngineState, type EpisodeIntentRecord, type EpisodeState, type EvaluateOptions, type Evaluation, type EventRecord, type ExplainOptions, type IntentConfig, type IntentEvent, type IntentState, type LearningConfig, type Migrate, type Prediction, RECORD_VERSION, type RecordMode, type RecordOptions, type RecordedDecision, type RecorderRecord, type RevealOptions, STATE_VERSION, type SignalContribution, type SignalExplanation, type SignalRecord, type Units, createEndRecord, createRecord, createState, deserialize, endEpisode, episodeClosed, evaluate, evidenceToReveal, explain, markRevealed, ms, observe, predict, serialize, shouldReveal, validateConfig };
852
+ export { type DeserializeOptions, type EndRecord, type EngineConfig, type EngineState, type EpisodeIntentRecord, type EpisodeState, type EvaluateOptions, type Evaluation, type EventRecord, type ExplainOptions, type IntentConfig, type IntentEvent, type IntentState, type LearningConfig, type Migrate, type PendingEpisode, type Prediction, RECORD_VERSION, type RecordMode, type RecordOptions, type RecordedDecision, type RecorderRecord, type RevealOptions, STATE_VERSION, type SignalContribution, type SignalExplanation, type SignalRecord, type Units, createEndRecord, createRecord, createState, deserialize, endEpisode, episodeClosed, evaluate, evidenceToReveal, explain, markRevealed, ms, observe, predict, serialize, shouldReveal, validateConfig };
package/dist/index.d.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  * Version of the serialized state shape, stored in every state as `state.version`. Bumped only
3
3
  * when `EngineState` changes incompatibly, and every bump ships its migration: {@link deserialize}
4
4
  * upgrades states saved by earlier versions of this library on its own (version 1, written by
5
- * oracular 0.1.0 to 0.2.x, becomes version 2) and calls your `migrate` function only for a version
6
- * it does not know.
5
+ * oracular 0.1.0 to 0.2.x, and version 2, written by 0.3.0 to 0.5.x, become version 3) and calls
6
+ * your `migrate` function only for a version it does not know.
7
7
  *
8
8
  * @example
9
9
  * ```ts
@@ -19,7 +19,7 @@
19
19
  * });
20
20
  * ```
21
21
  */
22
- declare const STATE_VERSION: 2;
22
+ declare const STATE_VERSION: 3;
23
23
  /**
24
24
  * Version of the recorder format: the `v` field of every record that {@link createRecord} and
25
25
  * {@link createEndRecord} produce. A new optional field does not change it; readers ignore fields
@@ -112,6 +112,17 @@ interface IntentConfig {
112
112
  * weight.
113
113
  */
114
114
  saturate?: boolean;
115
+ /**
116
+ * How long a closed episode's signals wait for the outcome before they take a miss, in the same
117
+ * unit as `at`. With it set, an episode that ends without the outcome is held instead of
118
+ * counted. When the outcome arrives inside the window, each held episode gets a share
119
+ * `2^(−age / halfLife)` of a hit and the rest of a miss, with `age` measured from the episode's
120
+ * close: the sessions that built up to the outcome are credited by how much of their evidence
121
+ * still stands. When the window passes, the episode is a miss, recorded at the moment the window
122
+ * ended. Unset: an episode that ends without the outcome is a miss at once. A natural first value
123
+ * is the intent's `halfLife`, where the smallest share inside the window is one half.
124
+ */
125
+ attribution?: number;
115
126
  /** Reveal defaults for this intent. */
116
127
  reveal?: RevealOptions;
117
128
  }
@@ -143,11 +154,18 @@ interface SignalContribution {
143
154
  }
144
155
  /** What this user's history says about one signal. Decays with `learning.halfLife`. */
145
156
  interface SignalRecord {
146
- /** Episodes in which the signal was followed by the outcome. */
157
+ /** Episodes in which the signal was followed by the outcome. An episode credited by a later outcome under `attribution` adds its share here and the rest to `misses`. */
147
158
  hits: number;
148
159
  /** Episodes in which it was not. */
149
160
  misses: number;
150
161
  }
162
+ /** A closed episode waiting for the intent's outcome inside its `attribution` window. */
163
+ interface PendingEpisode {
164
+ /** When the episode closed. Its wait ends `attribution` later. */
165
+ endedAt: number;
166
+ /** The signals seen in it, each once. */
167
+ seen: string[];
168
+ }
151
169
  /**
152
170
  * Everything the engine knows about one intent for this user. Plain data; read it for debugging
153
171
  * or dashboards, but change it only through the engine's functions.
@@ -163,6 +181,11 @@ interface IntentState {
163
181
  learned: Record<string, SignalRecord>;
164
182
  /** Episodes that ended with (`hits`) or without (`misses`) the outcome: the base rate. */
165
183
  episodes: SignalRecord;
184
+ /**
185
+ * Closed episodes whose verdict waits for the outcome (see {@link IntentConfig.attribution}),
186
+ * in order of close, at most 32. Empty unless `attribution` is set.
187
+ */
188
+ pending: PendingEpisode[];
166
189
  /** Time `learned` and `episodes` were last decayed to. */
167
190
  learnedAt: number | null;
168
191
  lastRevealedAt: number | null;
@@ -314,7 +337,7 @@ interface Evaluation {
314
337
  /**
315
338
  * Turns a stored state of a version this library cannot migrate itself into a current
316
339
  * `EngineState`. Receives the parsed JSON as-is and the version it declares. States saved by
317
- * oracular 0.1.0 to 0.2.x (version 1) never reach it: `deserialize` upgrades those on its own.
340
+ * oracular 0.1.0 to 0.5.x (versions 1 and 2) never reach it: `deserialize` upgrades those on its own.
318
341
  *
319
342
  * @example
320
343
  * ```ts
@@ -338,7 +361,7 @@ interface DeserializeOptions {
338
361
  config?: EngineConfig;
339
362
  /**
340
363
  * Called when the stored version is neither `STATE_VERSION` nor one this library migrates
341
- * itself (states from oracular 0.1.0 to 0.2.x are upgraded without it).
364
+ * itself (states from oracular 0.1.0 to 0.5.x are upgraded without it).
342
365
  */
343
366
  migrate?: Migrate;
344
367
  }
@@ -517,10 +540,11 @@ declare function explain(state: EngineState, intent: string, at: number, opts?:
517
540
  * `w × log2(1 + 1/n)`, counted since the episode started or the intent's outcome last arrived.
518
541
  * That is the default when `config.episodeTimeout` is set, and `IntentConfig.saturate` overrides
519
542
  * it either way; otherwise every occurrence adds all of `w`. For every intent the event is the
520
- * outcome of: give the remembered signals a hit, count the episode toward the base rate, record
521
- * `lastOutcomeAt`, and (by default) reset the evidence to the prior. Any event opens or extends
522
- * the current episode; an idle gap longer than `config.episodeTimeout` closes the previous
523
- * episode first.
543
+ * outcome of: give the remembered signals a hit, count the episode toward the base rate, credit
544
+ * the closed episodes still waiting under `IntentConfig.attribution` by `2^(−age / halfLife)`
545
+ * each, record `lastOutcomeAt`, and (by default) reset the evidence to the prior. Any event opens
546
+ * or extends the current episode; an idle gap longer than `config.episodeTimeout` closes the
547
+ * previous episode first.
524
548
  *
525
549
  * @param state - The current state. Not mutated.
526
550
  * @param event - `name` is matched against every intent's signals and outcome; `at` is the
@@ -540,7 +564,8 @@ declare function explain(state: EngineState, intent: string, at: number, opts?:
540
564
  declare function observe(state: EngineState, event: IntentEvent): EngineState;
541
565
  /**
542
566
  * Ends the current episode at `at`. Every intent whose outcome did not arrive gives a miss to
543
- * each signal seen this episode and counts the episode toward its base rate. Call it when a
567
+ * each signal seen this episode and counts the episode toward its base rate, or, with
568
+ * `IntentConfig.attribution` set, holds the episode for the outcome instead. Call it when a
544
569
  * session ends: tab closed, app backgrounded, user signed out.
545
570
  *
546
571
  * @param state - The current state. Not mutated.
@@ -562,7 +587,8 @@ declare function endEpisode(state: EngineState, at: number): EngineState;
562
587
  * Whether an episode closed between two states: `prev` had an open episode and `next` either
563
588
  * has none or has a different one. Useful after `observe` or `endEpisode` to react to a
564
589
  * session ending, for example to persist, or to read the misses it produced as
565
- * `next.intents[x].episodes.misses - prev.intents[x].episodes.misses`.
590
+ * `next.intents[x].episodes.misses - prev.intents[x].episodes.misses` (0 for an intent with
591
+ * `attribution` set, whose verdict waits).
566
592
  *
567
593
  * @param prev - The state before the operation.
568
594
  * @param next - The state after it.
@@ -711,7 +737,7 @@ declare function serialize(state: EngineState): string;
711
737
  * Always pass the app's current `config`: it is validated and replaces the stored snapshot,
712
738
  * intents and signals no longer declared are dropped (α and β are recomputed from the kept
713
739
  * contributions), and newly declared intents start at their prior. States saved by oracular
714
- * 0.1.0 to 0.2.x are upgraded to the current shape on their own; `migrate` is only for a version
740
+ * 0.1.0 to 0.5.x are upgraded to the current shape on their own; `migrate` is only for a version
715
741
  * this library does not know.
716
742
  *
717
743
  * @param json - A string produced by {@link serialize}.
@@ -823,4 +849,4 @@ declare const ms: Units;
823
849
  */
824
850
  declare function validateConfig(config: unknown): EngineConfig;
825
851
 
826
- export { type DeserializeOptions, type EndRecord, type EngineConfig, type EngineState, type EpisodeIntentRecord, type EpisodeState, type EvaluateOptions, type Evaluation, type EventRecord, type ExplainOptions, type IntentConfig, type IntentEvent, type IntentState, type LearningConfig, type Migrate, type Prediction, RECORD_VERSION, type RecordMode, type RecordOptions, type RecordedDecision, type RecorderRecord, type RevealOptions, STATE_VERSION, type SignalContribution, type SignalExplanation, type SignalRecord, type Units, createEndRecord, createRecord, createState, deserialize, endEpisode, episodeClosed, evaluate, evidenceToReveal, explain, markRevealed, ms, observe, predict, serialize, shouldReveal, validateConfig };
852
+ export { type DeserializeOptions, type EndRecord, type EngineConfig, type EngineState, type EpisodeIntentRecord, type EpisodeState, type EvaluateOptions, type Evaluation, type EventRecord, type ExplainOptions, type IntentConfig, type IntentEvent, type IntentState, type LearningConfig, type Migrate, type PendingEpisode, type Prediction, RECORD_VERSION, type RecordMode, type RecordOptions, type RecordedDecision, type RecorderRecord, type RevealOptions, STATE_VERSION, type SignalContribution, type SignalExplanation, type SignalRecord, type Units, createEndRecord, createRecord, createState, deserialize, endEpisode, episodeClosed, evaluate, evidenceToReveal, explain, markRevealed, ms, observe, predict, serialize, shouldReveal, validateConfig };
package/dist/index.js CHANGED
@@ -47,6 +47,86 @@ function mapRecord(record2, fn) {
47
47
  return out;
48
48
  }
49
49
 
50
+ // src/learning.ts
51
+ var NONE = { hits: 0, misses: 0 };
52
+ function decayLearned(state, learning, at) {
53
+ if (state.learnedAt === null) return { ...state, learnedAt: at };
54
+ const learnedAt = Math.max(state.learnedAt, at);
55
+ const d = learning.halfLife === null ? 1 : decayFactor(at - state.learnedAt, learning.halfLife);
56
+ if (d === 1) return learnedAt === state.learnedAt ? state : { ...state, learnedAt };
57
+ return {
58
+ ...state,
59
+ learnedAt,
60
+ learned: mapRecord(state.learned, (r) => ({ hits: r.hits * d, misses: r.misses * d })),
61
+ episodes: { hits: state.episodes.hits * d, misses: state.episodes.misses * d }
62
+ };
63
+ }
64
+ function signalWeights(state, intent, learning, signal) {
65
+ const initial = getOwn(intent.signals, signal);
66
+ if (initial === void 0) {
67
+ throw new RangeError(`oracular: ${JSON.stringify(signal)} is not a signal of this intent`);
68
+ }
69
+ const record2 = getOwn(state.learned, signal) ?? NONE;
70
+ const nWith = record2.hits + record2.misses;
71
+ const qWith = laplace(record2.hits, nWith);
72
+ const totalEpisodes = state.episodes.hits + state.episodes.misses;
73
+ const nWithout = Math.max(0, totalEpisodes - nWith);
74
+ const outcomesWithout = Math.min(nWithout, Math.max(0, state.episodes.hits - record2.hits));
75
+ const qWithout = laplace(outcomesWithout, nWithout);
76
+ const learned = nWith === 0 ? 0 : learning.gain * Math.abs(initial) * lift(qWith, qWithout);
77
+ const nEff = nWith + nWithout === 0 ? 0 : nWith * nWithout / (nWith + nWithout);
78
+ const k = learning.priorEpisodes;
79
+ const effective = (k * initial + nEff * learned) / (k + nEff);
80
+ return { initial, learned, effective, hits: record2.hits, misses: record2.misses };
81
+ }
82
+ function recordEpisode(state, signals, credit) {
83
+ const miss = 1 - credit;
84
+ const learned = { ...state.learned };
85
+ for (const signal of signals) {
86
+ const r = getOwn(learned, signal) ?? NONE;
87
+ learned[signal] = { hits: r.hits + credit, misses: r.misses + miss };
88
+ }
89
+ const episodes = { hits: state.episodes.hits + credit, misses: state.episodes.misses + miss };
90
+ return { ...state, learned, episodes };
91
+ }
92
+
93
+ // src/attribution.ts
94
+ var MAX_PENDING = 32;
95
+ function expiry(episode, intent) {
96
+ return episode.endedAt + (intent.attribution ?? 0);
97
+ }
98
+ function settlePending(state, intent, learning, at) {
99
+ let is = state;
100
+ let settled = 0;
101
+ for (const episode of state.pending) {
102
+ const end = expiry(episode, intent);
103
+ if (!(end < at)) break;
104
+ is = recordEpisode(decayLearned(is, learning, end), episode.seen, 0);
105
+ settled++;
106
+ }
107
+ if (settled > 0) is = { ...is, pending: state.pending.slice(settled) };
108
+ return decayLearned(is, learning, at);
109
+ }
110
+ function deferEpisode(state, seen, endedAt) {
111
+ const pending = insert(state.pending, { endedAt, seen: [...seen] });
112
+ if (pending.length <= MAX_PENDING) return { ...state, pending };
113
+ const [oldest, ...rest] = pending;
114
+ return { ...recordEpisode(state, oldest.seen, 0), pending: rest };
115
+ }
116
+ function insert(pending, episode) {
117
+ let i = pending.length;
118
+ while (i > 0 && pending[i - 1].endedAt > episode.endedAt) i--;
119
+ return [...pending.slice(0, i), episode, ...pending.slice(i)];
120
+ }
121
+ function creditPending(state, intent, at) {
122
+ if (state.pending.length === 0) return state;
123
+ let is = state;
124
+ for (const episode of state.pending) {
125
+ is = recordEpisode(is, episode.seen, decayFactor(at - episode.endedAt, intent.halfLife));
126
+ }
127
+ return { ...is, pending: [] };
128
+ }
129
+
50
130
  // src/intent.ts
51
131
  function priorCounts(intent) {
52
132
  return {
@@ -63,6 +143,7 @@ function initIntent(intent) {
63
143
  contributions: {},
64
144
  learned: {},
65
145
  episodes: { hits: 0, misses: 0 },
146
+ pending: [],
66
147
  learnedAt: null,
67
148
  lastRevealedAt: null,
68
149
  lastOutcomeAt: null,
@@ -99,48 +180,6 @@ function resetToPrior(state, intent) {
99
180
  return withContributions(state, intent, {});
100
181
  }
101
182
 
102
- // src/learning.ts
103
- var NONE = { hits: 0, misses: 0 };
104
- function decayLearned(state, learning, at) {
105
- if (state.learnedAt === null) return { ...state, learnedAt: at };
106
- const learnedAt = Math.max(state.learnedAt, at);
107
- const d = learning.halfLife === null ? 1 : decayFactor(at - state.learnedAt, learning.halfLife);
108
- if (d === 1) return learnedAt === state.learnedAt ? state : { ...state, learnedAt };
109
- return {
110
- ...state,
111
- learnedAt,
112
- learned: mapRecord(state.learned, (r) => ({ hits: r.hits * d, misses: r.misses * d })),
113
- episodes: { hits: state.episodes.hits * d, misses: state.episodes.misses * d }
114
- };
115
- }
116
- function signalWeights(state, intent, learning, signal) {
117
- const initial = getOwn(intent.signals, signal);
118
- if (initial === void 0) {
119
- throw new RangeError(`oracular: ${JSON.stringify(signal)} is not a signal of this intent`);
120
- }
121
- const record2 = getOwn(state.learned, signal) ?? NONE;
122
- const nWith = record2.hits + record2.misses;
123
- const qWith = laplace(record2.hits, nWith);
124
- const totalEpisodes = state.episodes.hits + state.episodes.misses;
125
- const nWithout = Math.max(0, totalEpisodes - nWith);
126
- const outcomesWithout = Math.min(nWithout, Math.max(0, state.episodes.hits - record2.hits));
127
- const qWithout = laplace(outcomesWithout, nWithout);
128
- const learned = nWith === 0 ? 0 : learning.gain * Math.abs(initial) * lift(qWith, qWithout);
129
- const nEff = nWith + nWithout === 0 ? 0 : nWith * nWithout / (nWith + nWithout);
130
- const k = learning.priorEpisodes;
131
- const effective = (k * initial + nEff * learned) / (k + nEff);
132
- return { initial, learned, effective, hits: record2.hits, misses: record2.misses };
133
- }
134
- function recordEpisode(state, signals, outcome) {
135
- const learned = { ...state.learned };
136
- for (const signal of signals) {
137
- const r = getOwn(learned, signal) ?? NONE;
138
- learned[signal] = outcome ? { hits: r.hits + 1, misses: r.misses } : { hits: r.hits, misses: r.misses + 1 };
139
- }
140
- const episodes = outcome ? { hits: state.episodes.hits + 1, misses: state.episodes.misses } : { hits: state.episodes.hits, misses: state.episodes.misses + 1 };
141
- return { ...state, learned, episodes };
142
- }
143
-
144
183
  // src/config.ts
145
184
  var DEFAULT_THRESHOLD = 0.5;
146
185
  var DEFAULT_Z = 1.28;
@@ -156,6 +195,7 @@ var INTENT_KEYS = [
156
195
  "outcome",
157
196
  "resetOnOutcome",
158
197
  "saturate",
198
+ "attribution",
159
199
  "reveal"
160
200
  ];
161
201
  var REVEAL_KEYS = ["threshold", "z", "cooldown"];
@@ -285,6 +325,9 @@ function normalizeIntentConfig(value, path) {
285
325
  if (typeof record2.saturate !== "boolean") typeError(`${path}.saturate`, "must be a boolean");
286
326
  out.saturate = record2.saturate;
287
327
  }
328
+ if (record2.attribution !== void 0) {
329
+ out.attribution = expectPositive(record2.attribution, `${path}.attribution`);
330
+ }
288
331
  if (record2.reveal !== void 0)
289
332
  out.reveal = normalizeRevealOptions(record2.reveal, `${path}.reveal`);
290
333
  return out;
@@ -407,7 +450,7 @@ function markResolved(episode, intent) {
407
450
  }
408
451
 
409
452
  // src/version.ts
410
- var STATE_VERSION = 2;
453
+ var STATE_VERSION = 3;
411
454
  var RECORD_VERSION = 1;
412
455
 
413
456
  // src/state.ts
@@ -438,7 +481,8 @@ function getIntentState(state, name, intent) {
438
481
 
439
482
  // src/assess.ts
440
483
  function assessIntent(state, name, intent, learning, at) {
441
- return decayLearned(decayIntent(getIntentState(state, name, intent), intent, at), learning, at);
484
+ const is = decayIntent(getIntentState(state, name, intent), intent, at);
485
+ return settlePending(is, intent, learning, at);
442
486
  }
443
487
  function cooldownEnd(since, cooldown, at) {
444
488
  if (since === null) return null;
@@ -565,7 +609,7 @@ function observe(state, event) {
565
609
  if (!isSignal && !isOutcome) continue;
566
610
  let is = getIntentState(s, intentName, intent);
567
611
  is = decayIntent(is, intent, at);
568
- is = decayLearned(is, learning, at);
612
+ is = settlePending(is, intent, learning, at);
569
613
  if (isSignal) {
570
614
  const { effective } = signalWeights(is, intent, learning, name);
571
615
  const n = occurrences(episode, intentName, name) + 1;
@@ -575,7 +619,8 @@ function observe(state, event) {
575
619
  if (!isOutcome) episode = markSeen(episode, intentName, name);
576
620
  }
577
621
  if (isOutcome) {
578
- is = recordEpisode(is, episodeRecord(episode, intentName).seen, true);
622
+ is = recordEpisode(is, episodeRecord(episode, intentName).seen, 1);
623
+ is = creditPending(is, intent, at);
579
624
  episode = markResolved(episode, intentName);
580
625
  is = { ...is, lastOutcomeAt: at };
581
626
  if (intent.resetOnOutcome ?? true) is = resetToPrior(is, intent);
@@ -598,8 +643,8 @@ function closeEpisode(state, at) {
598
643
  const record2 = episodeRecord(state.episode, intentName);
599
644
  if (record2.resolved) continue;
600
645
  let is = getIntentState(state, intentName, intent);
601
- is = decayLearned(is, learning, at);
602
- is = recordEpisode(is, record2.seen, false);
646
+ is = settlePending(is, intent, learning, at);
647
+ is = intent.attribution === void 0 ? recordEpisode(is, record2.seen, 0) : deferEpisode(is, record2.seen, at);
603
648
  intents[intentName] = is;
604
649
  }
605
650
  return { ...state, intents, episode: emptyEpisode() };
@@ -707,7 +752,8 @@ function createEndRecord(at, opts) {
707
752
 
708
753
  // src/migrate.ts
709
754
  var MIGRATIONS = {
710
- 1: fromV1
755
+ 1: fromV1,
756
+ 2: fromV2
711
757
  };
712
758
  function canMigrate(version) {
713
759
  return hasOwn(MIGRATIONS, String(version));
@@ -738,6 +784,16 @@ function fromV1(stored) {
738
784
  }
739
785
  return { ...stored, version: 2, episode: { ...episode, intents } };
740
786
  }
787
+ function fromV2(stored) {
788
+ if (!isRecord(stored.intents)) return { ...stored, version: 3 };
789
+ const intents = {};
790
+ for (const key of Object.keys(stored.intents)) {
791
+ if (key === "__proto__") continue;
792
+ const intent = stored.intents[key];
793
+ intents[key] = isRecord(intent) ? { ...intent, pending: [] } : intent;
794
+ }
795
+ return { ...stored, version: 3, intents };
796
+ }
741
797
 
742
798
  // src/serialize.ts
743
799
  function serialize(state) {
@@ -824,6 +880,19 @@ function mapOwn(value, path, fn) {
824
880
  }
825
881
  return out;
826
882
  }
883
+ function pendingEpisodes(value, path) {
884
+ if (!Array.isArray(value)) typeError2(path, "must be an array");
885
+ if (value.length > MAX_PENDING) {
886
+ throw new RangeError(`oracular: ${path} must hold at most ${MAX_PENDING} episodes`);
887
+ }
888
+ return value.map((item, i) => {
889
+ const r = record(item, `${path}[${i}]`);
890
+ if (!Array.isArray(r.seen) || !r.seen.every((s) => typeof s === "string")) {
891
+ typeError2(`${path}[${i}].seen`, "must be an array of strings");
892
+ }
893
+ return { endedAt: finite(r.endedAt, `${path}[${i}].endedAt`), seen: r.seen };
894
+ });
895
+ }
827
896
  function intentState(value, path) {
828
897
  const r = record(value, path);
829
898
  const revealCount = finite(r.revealCount, `${path}.revealCount`);
@@ -837,6 +906,7 @@ function intentState(value, path) {
837
906
  contributions: mapOwn(r.contributions, `${path}.contributions`, contribution),
838
907
  learned: mapOwn(r.learned, `${path}.learned`, signalRecord),
839
908
  episodes: signalRecord(r.episodes, `${path}.episodes`),
909
+ pending: pendingEpisodes(r.pending, `${path}.pending`),
840
910
  learnedAt: nullOrFinite(r.learnedAt, `${path}.learnedAt`),
841
911
  lastRevealedAt: nullOrFinite(r.lastRevealedAt, `${path}.lastRevealedAt`),
842
912
  lastOutcomeAt: nullOrFinite(r.lastOutcomeAt, `${path}.lastOutcomeAt`),
@@ -905,6 +975,12 @@ function keep(source, declared) {
905
975
  }
906
976
  return out;
907
977
  }
978
+ function keepPending(pending, signals) {
979
+ return pending.map((episode) => ({
980
+ endedAt: episode.endedAt,
981
+ seen: episode.seen.filter((s, i) => hasOwn(signals, s) && episode.seen.indexOf(s) === i)
982
+ })).sort((a, b) => a.endedAt - b.endedAt);
983
+ }
908
984
  function reconcile(raw, config) {
909
985
  const intents = {};
910
986
  const episodeIntents = {};
@@ -914,7 +990,11 @@ function reconcile(raw, config) {
914
990
  intents[name] = initIntent(intent);
915
991
  } else {
916
992
  intents[name] = withContributions(
917
- { ...stored, learned: keep(stored.learned, intent.signals) },
993
+ {
994
+ ...stored,
995
+ learned: keep(stored.learned, intent.signals),
996
+ pending: keepPending(stored.pending, intent.signals)
997
+ },
918
998
  intent,
919
999
  keep(stored.contributions, intent.signals)
920
1000
  );