oracular 0.0.1 → 0.1.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.
@@ -0,0 +1,476 @@
1
+ /**
2
+ * Version of the serialized state shape, stored in every state as `state.version`.
3
+ * {@link deserialize} compares it with the stored value and calls your `migrate` function when
4
+ * they differ. Bumped only when `EngineState` changes incompatibly.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * import { STATE_VERSION, deserialize } from 'oracular';
9
+ *
10
+ * const state = deserialize(saved, {
11
+ * migrate: (stored, version) => {
12
+ * if (version === 0) return upgradeFromV0(stored);
13
+ * throw new Error(`cannot migrate version ${version} to ${STATE_VERSION}`);
14
+ * },
15
+ * });
16
+ * ```
17
+ */
18
+ declare const STATE_VERSION: 1;
19
+
20
+ /**
21
+ * Options that decide when `shouldReveal` fires and how long cooldowns last. Resolved per call →
22
+ * intent → engine → defaults.
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * shouldReveal(state, 'upgrade', Date.now(), { threshold: 0.7, z: 1.64 });
27
+ * ```
28
+ */
29
+ interface RevealOptions {
30
+ /** Probability the intent must reach, in [0, 1]. Default 0.5. */
31
+ threshold?: number;
32
+ /** How many standard deviations below p must still clear the threshold. Default 1.28 (one-sided 90%). */
33
+ z?: number;
34
+ /** How long after `markRevealed` the intent stays revealed and `shouldReveal` is false. Default: the intent's `halfLife`. */
35
+ cooldown?: number;
36
+ }
37
+ /**
38
+ * How the engine learns from the user's own outcomes.
39
+ *
40
+ * @example
41
+ * ```ts
42
+ * const config = {
43
+ * intents,
44
+ * learning: { priorEpisodes: 5, gain: 1.5, halfLife: 90 * 24 * 60 * 60 * 1000 },
45
+ * };
46
+ * ```
47
+ */
48
+ interface LearningConfig {
49
+ /** Half-life of learned hit/miss counts, in the same unit as `at`. Omit for no forgetting. */
50
+ halfLife?: number;
51
+ /** A perfectly predictive signal converges to `gain × |initial weight|`. Default 2. */
52
+ gain?: number;
53
+ /** Pseudo-count behind the developer's initial weight: how many observed episodes it takes to weigh as much as the prior. Default 10. */
54
+ priorEpisodes?: number;
55
+ }
56
+ /**
57
+ * One thing the user might be about to do, tracked as a Beta(α, β) distribution with
58
+ * α₀ = prior × priorStrength and β₀ = (1 − prior) × priorStrength.
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * const churn: IntentConfig = {
63
+ * prior: 0.08,
64
+ * priorStrength: 20,
65
+ * halfLife: 14 * 24 * 60 * 60 * 1000,
66
+ * signals: { 'session.short': 1, 'notifications.disabled': 3, 'feature.new.used': -2 },
67
+ * outcome: 'account.cancelled',
68
+ * reveal: { threshold: 0.5, z: 1.64 },
69
+ * };
70
+ * ```
71
+ */
72
+ interface IntentConfig {
73
+ /** Prior probability, strictly between 0 and 1. */
74
+ prior: number;
75
+ /** Strength of the prior as a pseudo-count, greater than 0. */
76
+ priorStrength: number;
77
+ /** Half-life of evidence, in the same unit as `at`. Evidence decays back toward the prior. */
78
+ halfLife: number;
79
+ /** Event name → initial weight. Positive is evidence for the intent, negative against. Non-zero. */
80
+ signals: Record<string, number>;
81
+ /** Event name that confirms the intent happened, e.g. `'checkout.completed'`. */
82
+ outcome?: string;
83
+ /** Reset α, β and contributions to the prior when the outcome arrives. Default true. */
84
+ resetOnOutcome?: boolean;
85
+ /** Reveal defaults for this intent. */
86
+ reveal?: RevealOptions;
87
+ }
88
+ /**
89
+ * Everything `createState` needs. A normalized copy is stored in the state; pass the current
90
+ * config to `deserialize` on load.
91
+ *
92
+ * @example
93
+ * ```ts
94
+ * const config: EngineConfig = {
95
+ * episodeTimeout: 30 * 60 * 1000,
96
+ * intents: { upgrade, churn },
97
+ * reveal: { z: 1.28 },
98
+ * };
99
+ * ```
100
+ */
101
+ interface EngineConfig {
102
+ intents: Record<string, IntentConfig>;
103
+ /** Idle gap after which the current episode ends on its own, in the same unit as `at`. Omit to end episodes only via `endEpisode`. */
104
+ episodeTimeout?: number;
105
+ learning?: LearningConfig;
106
+ /** Reveal defaults for every intent. */
107
+ reveal?: RevealOptions;
108
+ }
109
+ /** Pseudo-counts a single signal has added to an intent: `alpha` for, `beta` against. Decays with the intent. */
110
+ interface SignalContribution {
111
+ alpha: number;
112
+ beta: number;
113
+ }
114
+ /** What this user's history says about one signal. Decays with `learning.halfLife`. */
115
+ interface SignalRecord {
116
+ /** Episodes in which the signal was followed by the outcome. */
117
+ hits: number;
118
+ /** Episodes in which it was not. */
119
+ misses: number;
120
+ }
121
+ /**
122
+ * Everything the engine knows about one intent for this user. Plain data; read it for debugging
123
+ * or dashboards, but change it only through the engine's functions.
124
+ */
125
+ interface IntentState {
126
+ /** Beta α. Always equals prior α plus the sum of `contributions[*].alpha`. */
127
+ alpha: number;
128
+ /** Beta β. Always equals prior β plus the sum of `contributions[*].beta`. */
129
+ beta: number;
130
+ /** Time α, β and contributions were last decayed to. */
131
+ updatedAt: number | null;
132
+ contributions: Record<string, SignalContribution>;
133
+ learned: Record<string, SignalRecord>;
134
+ /** Episodes that ended with (`hits`) or without (`misses`) the outcome: the base rate. */
135
+ episodes: SignalRecord;
136
+ /** Time `learned` and `episodes` were last decayed to. */
137
+ learnedAt: number | null;
138
+ lastRevealedAt: number | null;
139
+ /** Time the intent's outcome event last arrived. `shouldReveal` stays false for the cooldown after it. */
140
+ lastOutcomeAt: number | null;
141
+ revealCount: number;
142
+ }
143
+ /** What one intent has seen in the current episode. */
144
+ interface EpisodeIntentRecord {
145
+ /** Signals seen since the episode started (or since this intent's last outcome). */
146
+ seen: string[];
147
+ /** Whether the outcome arrived in this episode. */
148
+ resolved: boolean;
149
+ }
150
+ /** The current episode (session). One episode is open at a time, shared by all intents. */
151
+ interface EpisodeState {
152
+ startedAt: number | null;
153
+ lastEventAt: number | null;
154
+ intents: Record<string, EpisodeIntentRecord>;
155
+ }
156
+ /**
157
+ * The whole engine state: plain JSON with no functions, class instances or hidden fields.
158
+ * Persist it with `serialize`, restore it with `deserialize`.
159
+ *
160
+ * @example
161
+ * ```ts
162
+ * const state = createState(config);
163
+ * state.intents.upgrade.alpha; // 0.5 for prior 0.05 × priorStrength 10
164
+ * JSON.stringify(state) === serialize(state); // true
165
+ * ```
166
+ */
167
+ interface EngineState {
168
+ version: typeof STATE_VERSION;
169
+ /** Snapshot of the config this state was built with. Pass your current config to `deserialize` on load. */
170
+ config: EngineConfig;
171
+ intents: Record<string, IntentState>;
172
+ episode: EpisodeState;
173
+ }
174
+ /**
175
+ * What the app feeds to `observe`. Any event name is accepted; only names that appear in some
176
+ * intent's `signals` or as its `outcome` change evidence, every event extends the episode.
177
+ *
178
+ * @example
179
+ * ```ts
180
+ * observe(state, { name: 'pricing.viewed', at: Date.now(), props: { plan: 'pro' } });
181
+ * ```
182
+ */
183
+ interface IntentEvent {
184
+ name: string;
185
+ /** Timestamp. Every duration in the config uses the same unit (milliseconds recommended). */
186
+ at: number;
187
+ /** Accepted and ignored in 0.1; never stored. */
188
+ props?: Record<string, unknown>;
189
+ }
190
+ /**
191
+ * What `predict` returns for one intent at one moment.
192
+ *
193
+ * @example
194
+ * ```ts
195
+ * const { p, lower, confidence, revealed, suppressedUntil } = predict(state, 'upgrade', now);
196
+ * ```
197
+ */
198
+ interface Prediction {
199
+ intent: string;
200
+ /** Mean of the Beta distribution: α / (α + β). */
201
+ p: number;
202
+ /** Standard deviation of the Beta distribution. */
203
+ sd: number;
204
+ /** p − z·sd, clamped to [0, 1]. This is what `shouldReveal` compares to the threshold. */
205
+ lower: number;
206
+ /** 1 − 2·sd, clamped to [0, 1]. A heuristic: 1 when the estimate is sharp, 0 when it is as vague as possible. */
207
+ confidence: number;
208
+ /** α + β: total pseudo-observations, prior strength included. */
209
+ evidence: number;
210
+ /** True while inside the cooldown that follows `markRevealed`. */
211
+ revealed: boolean;
212
+ revealedAt: number | null;
213
+ /**
214
+ * When the current suppression ends: the later of the reveal cooldown (after `markRevealed`)
215
+ * and the outcome cooldown (after the outcome event) still running at `at`, or null when
216
+ * neither is. `shouldReveal` is false while this is non-null.
217
+ */
218
+ suppressedUntil: number | null;
219
+ }
220
+ /**
221
+ * One row of `explain`: what a single signal is doing to an intent right now, and what has been
222
+ * learned about it.
223
+ *
224
+ * @example
225
+ * ```ts
226
+ * const [strongest] = explain(state, 'upgrade', now);
227
+ * strongest?.signal; // the signal with the largest |impact|
228
+ * ```
229
+ */
230
+ interface SignalExplanation {
231
+ signal: string;
232
+ /** Signed pseudo-counts this signal currently holds: positive in α, negative in β, after decay. */
233
+ contribution: number;
234
+ /** p minus what p would be without this signal's contribution. */
235
+ impact: number;
236
+ /** The developer's weight. */
237
+ initialWeight: number;
238
+ /** What this user's history alone says the weight should be. */
239
+ learnedWeight: number;
240
+ /** The blend the next occurrence of the signal will actually add. */
241
+ effectiveWeight: number;
242
+ hits: number;
243
+ misses: number;
244
+ }
245
+ /**
246
+ * Turns a stored state of an older version into a current `EngineState`. Receives the parsed
247
+ * JSON as-is and the version it declares.
248
+ *
249
+ * @example
250
+ * ```ts
251
+ * const migrate: Migrate = (stored, version) => {
252
+ * if (version === 0) return { ...(stored as object), version: 1 } as EngineState;
253
+ * throw new Error(`unknown state version ${version}`);
254
+ * };
255
+ * ```
256
+ */
257
+ type Migrate = (stored: unknown, version: number) => EngineState;
258
+ /**
259
+ * Options for `deserialize`.
260
+ *
261
+ * @example
262
+ * ```ts
263
+ * deserialize(saved, { config: currentConfig, migrate });
264
+ * ```
265
+ */
266
+ interface DeserializeOptions {
267
+ /** The app's current config. Validated, swapped in, and used to prune signals and intents no longer declared. */
268
+ config?: EngineConfig;
269
+ /** Called when the stored version differs from `STATE_VERSION`. */
270
+ migrate?: Migrate;
271
+ }
272
+
273
+ /**
274
+ * Why the intent's p is what it is at time `at`: one entry per declared signal, the ones that
275
+ * matter most first. `contribution` is the signed pseudo-count the signal currently holds after
276
+ * decay, `impact` is how much p would change if that contribution were removed, and the three
277
+ * weights show what the developer said, what this user's history says, and what the next
278
+ * occurrence will actually add. Read-only.
279
+ *
280
+ * @param state - The current state. Not mutated.
281
+ * @param intent - Name of an intent declared in the config.
282
+ * @param at - The time to evaluate at; contributions and learned counts are decayed to it.
283
+ * @returns One {@link SignalExplanation} per declared signal, sorted by |impact| descending,
284
+ * then by name. Empty for an intent that declares no signals.
285
+ * @throws `RangeError` when `intent` is not declared in the config.
286
+ * @throws `TypeError` when `state` is not an engine state or `at` is not finite.
287
+ * @example
288
+ * ```ts
289
+ * for (const row of explain(state, 'upgrade', Date.now())) {
290
+ * console.log(row.signal, row.impact.toFixed(2), row.effectiveWeight.toFixed(2));
291
+ * }
292
+ * ```
293
+ */
294
+ declare function explain(state: EngineState, intent: string, at: number): SignalExplanation[];
295
+
296
+ /**
297
+ * Feeds one event to the engine.
298
+ *
299
+ * For every intent the event is a signal of: decay its evidence to `event.at`, then add the
300
+ * signal's effective weight to α (positive) or β (negative) and remember the signal for this
301
+ * episode. For every intent the event is the outcome of: give the remembered signals a hit,
302
+ * count the episode toward the base rate, record `lastOutcomeAt`, and (by default) reset the
303
+ * evidence to the prior. Any event opens or extends the current episode; an idle gap longer
304
+ * than `config.episodeTimeout` closes the previous episode first.
305
+ *
306
+ * @param state - The current state. Not mutated.
307
+ * @param event - `name` is matched against every intent's signals and outcome; `at` is the
308
+ * event's timestamp, in the same unit as your durations; `props` are accepted and ignored,
309
+ * and never stored.
310
+ * @returns The next state. Intents the event does not touch keep their stored numbers; their
311
+ * decay is applied when they are next read or updated.
312
+ * @throws `TypeError` when `state` is not an engine state, or the event has no non-empty
313
+ * `name`, no finite `at`, or a `props` that is not a plain object.
314
+ * @example
315
+ * ```ts
316
+ * let state = createState(config);
317
+ * state = observe(state, { name: 'pricing.viewed', at: Date.now() });
318
+ * state = observe(state, { name: 'upgrade.completed', at: Date.now() }); // outcome
319
+ * ```
320
+ */
321
+ declare function observe(state: EngineState, event: IntentEvent): EngineState;
322
+ /**
323
+ * Ends the current episode at `at`. Every intent whose outcome did not arrive gives a miss to
324
+ * each signal seen this episode and counts the episode toward its base rate. Call it when a
325
+ * session ends: tab closed, app backgrounded, user signed out.
326
+ *
327
+ * @param state - The current state. Not mutated.
328
+ * @param at - When the episode ended. If it had already timed out under
329
+ * `config.episodeTimeout`, it is closed at the moment it timed out instead.
330
+ * @returns The next state with no open episode, or the very same state object when no episode
331
+ * was open.
332
+ * @throws `TypeError` when `state` is not an engine state or `at` is not a finite number.
333
+ * @example
334
+ * ```ts
335
+ * window.addEventListener('pagehide', () => {
336
+ * state = endEpisode(state, Date.now());
337
+ * localStorage.setItem('oracular', serialize(state));
338
+ * });
339
+ * ```
340
+ */
341
+ declare function endEpisode(state: EngineState, at: number): EngineState;
342
+
343
+ /**
344
+ * The intent's current estimate at time `at`: the Beta mean p, its standard deviation, the
345
+ * lower confidence bound that {@link shouldReveal} tests, and the cooldown status. Read-only:
346
+ * the stored state is decayed to `at` for the reading but not changed.
347
+ *
348
+ * @param state - The current state. Not mutated.
349
+ * @param intent - Name of an intent declared in the config.
350
+ * @param at - The time to evaluate at, in the same unit as your durations.
351
+ * @param opts - Overrides for `threshold`, `z` and `cooldown`, resolved as call → intent →
352
+ * engine → defaults. `z` sets `lower`; `cooldown` sets `revealed` and `suppressedUntil`;
353
+ * `threshold` only matters to {@link shouldReveal}.
354
+ * @returns The prediction. `p`, `sd`, `lower` and `confidence` are in [0, 1]; `evidence` is
355
+ * α + β.
356
+ * @throws `RangeError` when `intent` is not declared in the config.
357
+ * @throws `TypeError` when `state` is not an engine state, `at` is not finite, or `opts` is
358
+ * malformed.
359
+ * @example
360
+ * ```ts
361
+ * const { p, lower, suppressedUntil } = predict(state, 'upgrade', Date.now());
362
+ * if (suppressedUntil !== null) scheduleNextCheck(suppressedUntil);
363
+ * ```
364
+ */
365
+ declare function predict(state: EngineState, intent: string, at: number, opts?: RevealOptions): Prediction;
366
+ /**
367
+ * Whether to act on the intent now. True when p ≥ `threshold`, the lower confidence bound
368
+ * p − z·sd also clears `threshold`, and no cooldown is running: neither the one started by
369
+ * {@link markRevealed} nor the one started by the intent's outcome event.
370
+ *
371
+ * @param state - The current state. Not mutated.
372
+ * @param intent - Name of an intent declared in the config.
373
+ * @param at - The time to evaluate at.
374
+ * @param opts - Overrides for `threshold` (default 0.5), `z` (default 1.28, a one-sided 90%
375
+ * bound) and `cooldown` (default: the intent's `halfLife`).
376
+ * @returns True when the app should show, offer or reveal whatever this intent gates.
377
+ * @throws `RangeError` when `intent` is not declared in the config.
378
+ * @throws `TypeError` when `state` is not an engine state, `at` is not finite, or `opts` is
379
+ * malformed.
380
+ * @example
381
+ * ```ts
382
+ * if (shouldReveal(state, 'upgrade', Date.now())) {
383
+ * showUpgradeOffer();
384
+ * state = markRevealed(state, 'upgrade', Date.now());
385
+ * }
386
+ * ```
387
+ */
388
+ declare function shouldReveal(state: EngineState, intent: string, at: number, opts?: RevealOptions): boolean;
389
+ /**
390
+ * Records that the app acted on the intent at `at`. Starts the reveal cooldown, during which
391
+ * `predict().revealed` is true and {@link shouldReveal} is false, and increments
392
+ * `revealCount`. Evidence and learned data are untouched.
393
+ *
394
+ * @param state - The current state. Not mutated.
395
+ * @param intent - Name of an intent declared in the config.
396
+ * @param at - When the reveal happened.
397
+ * @returns The next state.
398
+ * @throws `RangeError` when `intent` is not declared in the config.
399
+ * @throws `TypeError` when `state` is not an engine state or `at` is not finite.
400
+ * @example
401
+ * ```ts
402
+ * state = markRevealed(state, 'upgrade', Date.now());
403
+ * predict(state, 'upgrade', Date.now()).revealed; // true until the cooldown ends
404
+ * ```
405
+ */
406
+ declare function markRevealed(state: EngineState, intent: string, at: number): EngineState;
407
+
408
+ /**
409
+ * The state as a JSON string, for the host app to persist wherever it likes. The state is plain
410
+ * data, so this is `JSON.stringify` after a shape check. The output never contains event props.
411
+ *
412
+ * @param state - The state to persist.
413
+ * @returns A JSON string that {@link deserialize} restores losslessly.
414
+ * @throws `TypeError` when `state` is not an engine state.
415
+ * @example
416
+ * ```ts
417
+ * localStorage.setItem('oracular', serialize(state));
418
+ * ```
419
+ */
420
+ declare function serialize(state: EngineState): string;
421
+ /**
422
+ * Restores a state saved with {@link serialize}.
423
+ *
424
+ * Always pass the app's current `config`: it is validated and replaces the stored snapshot,
425
+ * intents and signals no longer declared are dropped (α and β are recomputed from the kept
426
+ * contributions), and newly declared intents start at their prior. Pass `migrate` to upgrade a
427
+ * state saved by an older version of this library.
428
+ *
429
+ * @param json - A string produced by {@link serialize}.
430
+ * @param opts - `config`: the app's current config. `migrate`: called as
431
+ * `migrate(stored, version)` when the stored version differs from {@link STATE_VERSION};
432
+ * it must return a state of the current version.
433
+ * @returns A validated state that satisfies the engine's invariants.
434
+ * @throws `SyntaxError` when `json` is not valid JSON.
435
+ * @throws `TypeError` when the stored data, the result of `migrate`, `opts` or `opts.config`
436
+ * has the wrong shape.
437
+ * @throws `RangeError` when the stored version differs and no `migrate` is given, when
438
+ * `migrate` does not return the current version, or when a stored number is out of range.
439
+ * @example
440
+ * ```ts
441
+ * const saved = localStorage.getItem('oracular');
442
+ * let state = saved ? deserialize(saved, { config }) : createState(config);
443
+ * ```
444
+ */
445
+ declare function deserialize(json: string, opts?: DeserializeOptions): EngineState;
446
+
447
+ /**
448
+ * Builds a fresh engine state from a config. Every intent starts at its prior and no episode is
449
+ * open. The config is validated and a normalized copy is stored inside the state, so a persisted
450
+ * state remembers the weights it was built with; pass your current config to {@link deserialize}
451
+ * when loading.
452
+ *
453
+ * @param config - Intents with their priors and signals, plus engine-wide options. Validated
454
+ * strictly: unknown keys, non-finite numbers and out-of-range values throw.
455
+ * @returns A new, JSON-serializable state. `config` is not mutated.
456
+ * @throws `TypeError` when a value has the wrong type or a key is not recognised.
457
+ * @throws `RangeError` when a number is out of range, for example a `prior` outside (0, 1) or a
458
+ * signal weight of 0.
459
+ * @example
460
+ * ```ts
461
+ * const state = createState({
462
+ * intents: {
463
+ * upgrade: {
464
+ * prior: 0.05,
465
+ * priorStrength: 10,
466
+ * halfLife: 7 * 24 * 60 * 60 * 1000,
467
+ * signals: { 'pricing.viewed': 3, 'limit.hit': 2, 'plan.downgraded': -4 },
468
+ * outcome: 'upgrade.completed',
469
+ * },
470
+ * },
471
+ * });
472
+ * ```
473
+ */
474
+ declare function createState(config: EngineConfig): EngineState;
475
+
476
+ export { type DeserializeOptions, type EngineConfig, type EngineState, type EpisodeIntentRecord, type EpisodeState, type IntentConfig, type IntentEvent, type IntentState, type LearningConfig, type Migrate, type Prediction, type RevealOptions, STATE_VERSION, type SignalContribution, type SignalExplanation, type SignalRecord, createState, deserialize, endEpisode, explain, markRevealed, observe, predict, serialize, shouldReveal };