oracular 0.2.1 → 0.4.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.ts CHANGED
@@ -1,21 +1,40 @@
1
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.
2
+ * Version of the serialized state shape, stored in every state as `state.version`. Bumped only
3
+ * when `EngineState` changes incompatibly, and every bump ships its migration: {@link deserialize}
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
7
  *
6
8
  * @example
7
9
  * ```ts
8
10
  * import { STATE_VERSION, deserialize } from 'oracular';
9
11
  *
10
12
  * const state = deserialize(saved, {
13
+ * config,
14
+ * // Only reached for versions this library cannot migrate itself, such as a state written by a
15
+ * // newer oracular before a rollback.
11
16
  * migrate: (stored, version) => {
12
- * if (version === 0) return upgradeFromV0(stored);
13
- * throw new Error(`cannot migrate version ${version} to ${STATE_VERSION}`);
17
+ * throw new Error(`cannot read oracular state version ${version} (this build reads ${STATE_VERSION})`);
14
18
  * },
15
19
  * });
16
20
  * ```
17
21
  */
18
- declare const STATE_VERSION: 1;
22
+ declare const STATE_VERSION: 2;
23
+ /**
24
+ * Version of the recorder format: the `v` field of every record that {@link createRecord} and
25
+ * {@link createEndRecord} produce. A new optional field does not change it; readers ignore fields
26
+ * they do not know and skip records of a `kind` they do not know. Anything a reader written for
27
+ * this version could misread bumps it.
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * import { RECORD_VERSION } from 'oracular';
32
+ *
33
+ * const record = JSON.parse(line);
34
+ * if (record.v !== RECORD_VERSION) throw new Error(`unsupported recorder format ${record.v}`);
35
+ * ```
36
+ */
37
+ declare const RECORD_VERSION: 1;
19
38
 
20
39
  /**
21
40
  * Options that decide when `shouldReveal` fires and how long cooldowns last. Resolved per call →
@@ -48,9 +67,9 @@ interface RevealOptions {
48
67
  interface LearningConfig {
49
68
  /** Half-life of learned hit/miss counts, in the same unit as `at`. Omit for no forgetting. */
50
69
  halfLife?: number;
51
- /** A perfectly predictive signal converges to `gain × |initial weight|`. Default 2. */
70
+ /** A perfectly predictive signal converges to `gain × |initial weight|`. Default 2; at most 1e6. */
52
71
  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. */
72
+ /** Pseudo-count behind the developer's initial weight: how many observed episodes it takes to weigh as much as the prior. Default 10; at most 1e6. */
54
73
  priorEpisodes?: number;
55
74
  }
56
75
  /**
@@ -72,16 +91,27 @@ interface LearningConfig {
72
91
  interface IntentConfig {
73
92
  /** Prior probability, strictly between 0 and 1. */
74
93
  prior: number;
75
- /** Strength of the prior as a pseudo-count, greater than 0. */
94
+ /** Strength of the prior as a pseudo-count, greater than 0 and at most 1e6. */
76
95
  priorStrength: number;
77
96
  /** Half-life of evidence, in the same unit as `at`. Evidence decays back toward the prior. */
78
97
  halfLife: number;
79
- /** Event name → initial weight. Positive is evidence for the intent, negative against. Non-zero. */
98
+ /** Event name → initial weight. Positive is evidence for the intent, negative against. Non-zero, between −1e6 and 1e6. */
80
99
  signals: Record<string, number>;
81
100
  /** Event name that confirms the intent happened, e.g. `'checkout.completed'`. */
82
101
  outcome?: string;
83
102
  /** Reset α, β and contributions to the prior when the outcome arrives. Default true. */
84
103
  resetOnOutcome?: boolean;
104
+ /**
105
+ * Repeats of a signal within one episode add less: the n-th occurrence adds
106
+ * `w × log2(1 + 1/n)`, so ten occurrences add 3.46 weights, not ten. The count restarts when
107
+ * the episode ends or this intent's outcome arrives. Default: true when `episodeTimeout` is
108
+ * set, false otherwise, because without an idle timeout episodes end only when the app calls
109
+ * `endEpisode`, and if it never does the count would never restart. Set true to saturate
110
+ * anyway (your app ends every episode itself), or false for intents where the repetition
111
+ * itself is the evidence (a user stuck on the same step), so every occurrence adds its full
112
+ * weight.
113
+ */
114
+ saturate?: boolean;
85
115
  /** Reveal defaults for this intent. */
86
116
  reveal?: RevealOptions;
87
117
  }
@@ -144,6 +174,11 @@ interface IntentState {
144
174
  interface EpisodeIntentRecord {
145
175
  /** Signals seen since the episode started (or since this intent's last outcome). */
146
176
  seen: string[];
177
+ /**
178
+ * How many times each signal in `seen` has occurred over the same span; its keys are exactly the
179
+ * signals in `seen`. Repeats saturate: the n-th occurrence adds less than the first.
180
+ */
181
+ counts: Record<string, number>;
147
182
  /** Whether the outcome arrived in this episode. */
148
183
  resolved: boolean;
149
184
  }
@@ -237,7 +272,10 @@ interface SignalExplanation {
237
272
  initialWeight: number;
238
273
  /** What this user's history alone says the weight should be. */
239
274
  learnedWeight: number;
240
- /** The blend the next occurrence of the signal will actually add. */
275
+ /**
276
+ * The blend of initial and learned weight: what the signal adds the first time it occurs in an
277
+ * episode. Repeats within the episode add less (see {@link IntentConfig.saturate}).
278
+ */
241
279
  effectiveWeight: number;
242
280
  hits: number;
243
281
  misses: number;
@@ -274,8 +312,9 @@ interface Evaluation {
274
312
  explanation: SignalExplanation[];
275
313
  }
276
314
  /**
277
- * Turns a stored state of an older version into a current `EngineState`. Receives the parsed
278
- * JSON as-is and the version it declares.
315
+ * Turns a stored state of a version this library cannot migrate itself into a current
316
+ * `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.
279
318
  *
280
319
  * @example
281
320
  * ```ts
@@ -297,9 +336,103 @@ type Migrate = (stored: unknown, version: number) => EngineState;
297
336
  interface DeserializeOptions {
298
337
  /** The app's current config. Validated, swapped in, and used to prune signals and intents no longer declared. */
299
338
  config?: EngineConfig;
300
- /** Called when the stored version differs from `STATE_VERSION`. */
339
+ /**
340
+ * 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).
342
+ */
301
343
  migrate?: Migrate;
302
344
  }
345
+ /**
346
+ * Whether the app acted on reveals when a record was made: `'shadow'` when it only computed them
347
+ * (nothing shown to the user), `'live'` when it showed them. Replay only measures shadow periods
348
+ * honestly: once a reveal is live, the reveal itself causes outcomes.
349
+ */
350
+ type RecordMode = 'shadow' | 'live';
351
+ /**
352
+ * What every record needs from the app, for {@link createRecord} and {@link createEndRecord}.
353
+ *
354
+ * @example
355
+ * ```ts
356
+ * const recording: RecordOptions = { installId: storedInstallId, mode: 'shadow' };
357
+ * ```
358
+ */
359
+ interface RecordOptions {
360
+ /**
361
+ * A random id the app generates once, on first use, and stores (for example
362
+ * `crypto.randomUUID()`). It groups one install's records across exports and nothing else; never
363
+ * derive it from the device, the account or anything else about the user.
364
+ */
365
+ installId: string;
366
+ /** Whether reveals are shown to the user right now. */
367
+ mode: RecordMode;
368
+ /** How many signals to list in `top`, by size of impact. Default 3. */
369
+ top?: number;
370
+ }
371
+ /**
372
+ * What the engine decided for one intent at the moment of a record. It is there to compare what
373
+ * shipped with what a different config would have done; replay recomputes everything from the
374
+ * events and never reads it.
375
+ */
376
+ interface RecordedDecision {
377
+ /** As {@link Prediction.p}. */
378
+ p: number;
379
+ /** As {@link Prediction.lower}. */
380
+ lower: number;
381
+ /** What `shouldReveal` returned with the state's own reveal options. */
382
+ shouldReveal: boolean;
383
+ /** As {@link Prediction.suppressedUntil}. */
384
+ suppressedUntil: number | null;
385
+ /** Signals currently holding evidence, largest |impact| first, at most `RecordOptions.top`. */
386
+ top: {
387
+ signal: string;
388
+ impact: number;
389
+ }[];
390
+ /** Effective weight of every declared signal, in config order. */
391
+ weights: Record<string, number>;
392
+ }
393
+ /**
394
+ * One line of a recorder log: an event the app observed and what the engine decided right after
395
+ * it. The event (`name` and `at`, never `props`) is what replay recomputes from; the decisions
396
+ * are kept apart so they can never be mistaken for inputs.
397
+ *
398
+ * @example
399
+ * ```ts
400
+ * state = observe(state, event);
401
+ * log.append(JSON.stringify(createRecord(state, event, recording)));
402
+ * ```
403
+ */
404
+ interface EventRecord {
405
+ v: typeof RECORD_VERSION;
406
+ kind: 'event';
407
+ installId: string;
408
+ mode: RecordMode;
409
+ event: {
410
+ name: string;
411
+ at: number;
412
+ };
413
+ /** One entry per declared intent. */
414
+ decisions: Record<string, RecordedDecision>;
415
+ }
416
+ /**
417
+ * One line of a recorder log: the app ended the episode with {@link endEpisode}. Replay needs it
418
+ * to reproduce the misses that the episode's end records; episodes that end through
419
+ * `episodeTimeout` need no record.
420
+ *
421
+ * @example
422
+ * ```ts
423
+ * state = endEpisode(state, at);
424
+ * log.append(JSON.stringify(createEndRecord(at, recording)));
425
+ * ```
426
+ */
427
+ interface EndRecord {
428
+ v: typeof RECORD_VERSION;
429
+ kind: 'end';
430
+ installId: string;
431
+ mode: RecordMode;
432
+ at: number;
433
+ }
434
+ /** A line of a recorder log. */
435
+ type RecorderRecord = EventRecord | EndRecord;
303
436
 
304
437
  /**
305
438
  * {@link predict}, {@link shouldReveal} and {@link explain} in one call, from a single decay
@@ -328,8 +461,10 @@ declare function evaluate(state: EngineState, intent: string, at: number, opts?:
328
461
  /**
329
462
  * How much positive evidence an intent needs before {@link shouldReveal} first becomes true:
330
463
  * the smallest pseudo-count `w` such that Beta(α₀ + w, β₀) clears both `p ≥ threshold` and
331
- * `p − z·sd ≥ threshold`. Divide by a signal's weight and round up for the number of identical
332
- * signals, fired together, that open the gate. Solved numerically to 1e-9.
464
+ * `p − z·sd ≥ threshold`. Divide by a signal's weight and round up for the number of full-weight
465
+ * occurrences that open the gate: distinct signals, or one occurrence per episode. Repeats within
466
+ * one episode add less (n of them add `w × log2(1 + n)`, see {@link IntentConfig.saturate}).
467
+ * Solved numerically to 1e-9.
333
468
  *
334
469
  * Cooldowns and decay are not part of the answer: this is the evidence needed at one instant.
335
470
  *
@@ -342,7 +477,7 @@ declare function evaluate(state: EngineState, intent: string, at: number, opts?:
342
477
  * @example
343
478
  * ```ts
344
479
  * const needed = evidenceToReveal(config.intents.upgrade); // ≈ 15.1 for the quickstart config
345
- * Math.ceil(needed / 3); // 6 signals of weight 3
480
+ * Math.ceil(needed / 3); // 6 full-weight signals of weight 3
346
481
  * ```
347
482
  */
348
483
  declare function evidenceToReveal(intent: IntentConfig, opts?: RevealOptions): number;
@@ -378,10 +513,14 @@ declare function explain(state: EngineState, intent: string, at: number, opts?:
378
513
  *
379
514
  * For every intent the event is a signal of: decay its evidence to `event.at`, then add the
380
515
  * signal's effective weight to α (positive) or β (negative) and remember the signal for this
381
- * episode. For every intent the event is the outcome of: give the remembered signals a hit,
382
- * count the episode toward the base rate, record `lastOutcomeAt`, and (by default) reset the
383
- * evidence to the prior. Any event opens or extends the current episode; an idle gap longer
384
- * than `config.episodeTimeout` closes the previous episode first.
516
+ * episode. Repeats within an episode saturate: the n-th occurrence of a signal adds
517
+ * `w × log2(1 + 1/n)`, counted since the episode started or the intent's outcome last arrived.
518
+ * That is the default when `config.episodeTimeout` is set, and `IntentConfig.saturate` overrides
519
+ * 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.
385
524
  *
386
525
  * @param state - The current state. Not mutated.
387
526
  * @param event - `name` is matched against every intent's signals and outcome; `at` is the
@@ -503,6 +642,56 @@ declare function shouldReveal(state: EngineState, intent: string, at: number, op
503
642
  */
504
643
  declare function markRevealed(state: EngineState, intent: string, at: number): EngineState;
505
644
 
645
+ /**
646
+ * A recorder record for an event: the event itself and, for every intent, what the engine decides
647
+ * right after it. Call it with the state `observe` returned for that event, then append
648
+ * `JSON.stringify(record)` as one line of the log (NDJSON). The record holds the event's `name` and
649
+ * `at`, never its `props`.
650
+ *
651
+ * Each decision is what {@link evaluate} reports at `event.at` with the state's own reveal
652
+ * options: `p`, `lower`, `shouldReveal`, `suppressedUntil`, the signals with the largest impact,
653
+ * and the effective weight of every signal. Pure: the install id comes from the app, never from
654
+ * here. See the Shadow mode guide for the whole workflow.
655
+ *
656
+ * @param state - The state after `observe(state, event)`. Not mutated.
657
+ * @param event - The event just observed. Only `name` and `at` are recorded.
658
+ * @param opts - `installId` (generated and stored by the app), `mode` (`'shadow'` while
659
+ * reveals are computed but not shown, `'live'` once they are), and `top` (default 3).
660
+ * @returns A plain, JSON-serializable {@link EventRecord}.
661
+ * @throws `TypeError` when `state` is not an engine state, the event is malformed, `opts` is
662
+ * not an object, has an unknown key, an empty `installId` or a `mode` other than
663
+ * `'shadow'` or `'live'`.
664
+ * @throws `RangeError` when `opts.top` is negative or fractional.
665
+ * @example
666
+ * ```ts
667
+ * const recording = { installId: localStorage.getItem('oracular.install')!, mode: 'shadow' } as const;
668
+ *
669
+ * const event = { name: 'pricing.viewed', at: Date.now() };
670
+ * state = observe(state, event);
671
+ * appendLine(JSON.stringify(createRecord(state, event, recording)));
672
+ * ```
673
+ */
674
+ declare function createRecord(state: EngineState, event: IntentEvent, opts: RecordOptions): EventRecord;
675
+ /**
676
+ * A recorder record for the end of an episode. Write one whenever the app calls
677
+ * {@link endEpisode}, with the same `at`, so that replay can end the episode at the same moment
678
+ * and reproduce the misses it records. Episodes that close through `episodeTimeout` need none:
679
+ * replay applies the timeout itself.
680
+ *
681
+ * @param at - The time passed to `endEpisode`.
682
+ * @param opts - The same options as for {@link createRecord}; `top` is accepted and ignored.
683
+ * @returns A plain, JSON-serializable {@link EndRecord}.
684
+ * @throws `TypeError` when `at` is not a finite number or `opts` is malformed.
685
+ * @throws `RangeError` when `opts.top` is negative or fractional.
686
+ * @example
687
+ * ```ts
688
+ * const at = Date.now();
689
+ * state = endEpisode(state, at);
690
+ * appendLine(JSON.stringify(createEndRecord(at, recording)));
691
+ * ```
692
+ */
693
+ declare function createEndRecord(at: number, opts: RecordOptions): EndRecord;
694
+
506
695
  /**
507
696
  * The state as a JSON string, for the host app to persist wherever it likes. The state is plain
508
697
  * data, so this is `JSON.stringify` after a shape check. The output never contains event props.
@@ -521,19 +710,22 @@ declare function serialize(state: EngineState): string;
521
710
  *
522
711
  * Always pass the app's current `config`: it is validated and replaces the stored snapshot,
523
712
  * intents and signals no longer declared are dropped (α and β are recomputed from the kept
524
- * contributions), and newly declared intents start at their prior. Pass `migrate` to upgrade a
525
- * state saved by an older version of this library.
713
+ * 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
715
+ * this library does not know.
526
716
  *
527
717
  * @param json - A string produced by {@link serialize}.
528
718
  * @param opts - `config`: the app's current config. `migrate`: called as
529
- * `migrate(stored, version)` when the stored version differs from {@link STATE_VERSION};
530
- * it must return a state of the current version.
719
+ * `migrate(stored, version)` when the stored version is neither {@link STATE_VERSION} nor one
720
+ * this library migrates itself; it must return a state of the current version.
531
721
  * @returns A validated state that satisfies the engine's invariants.
532
722
  * @throws `SyntaxError` when `json` is not valid JSON.
533
723
  * @throws `TypeError` when the stored data, the result of `migrate`, `opts` or `opts.config`
534
724
  * has the wrong shape.
535
- * @throws `RangeError` when the stored version differs and no `migrate` is given, when
536
- * `migrate` does not return the current version, or when a stored number is out of range.
725
+ * @throws `RangeError` when the stored version is unknown and no `migrate` is given, when
726
+ * `migrate` does not return the current version, when a stored number is out of range, or
727
+ * when stored numbers are finite but too large to compute with (a tampered or corrupted state
728
+ * that would otherwise answer NaN).
537
729
  * @example
538
730
  * ```ts
539
731
  * const saved = localStorage.getItem('oracular');
@@ -552,8 +744,9 @@ declare function deserialize(json: string, opts?: DeserializeOptions): EngineSta
552
744
  * strictly: unknown keys, non-finite numbers and out-of-range values throw.
553
745
  * @returns A new, JSON-serializable state. `config` is not mutated.
554
746
  * @throws `TypeError` when a value has the wrong type or a key is not recognised.
555
- * @throws `RangeError` when a number is out of range, for example a `prior` outside (0, 1) or a
556
- * signal weight of 0.
747
+ * @throws `RangeError` when a number is out of range, for example a `prior` outside (0, 1), a
748
+ * signal weight of 0, or a weight, `priorStrength`, `learning.gain` or
749
+ * `learning.priorEpisodes` beyond 1e6 (larger values could overflow the arithmetic).
557
750
  * @example
558
751
  * ```ts
559
752
  * const state = createState({
@@ -571,4 +764,63 @@ declare function deserialize(json: string, opts?: DeserializeOptions): EngineSta
571
764
  */
572
765
  declare function createState(config: EngineConfig): EngineState;
573
766
 
574
- export { type DeserializeOptions, type EngineConfig, type EngineState, type EpisodeIntentRecord, type EpisodeState, type EvaluateOptions, type Evaluation, type ExplainOptions, 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, episodeClosed, evaluate, evidenceToReveal, explain, markRevealed, observe, predict, serialize, shouldReveal };
767
+ /** The units of {@link ms}: each takes a count and returns milliseconds. */
768
+ interface Units {
769
+ /** `n` seconds in milliseconds: `n × 1000`. */
770
+ readonly seconds: (n: number) => number;
771
+ /** `n` minutes in milliseconds: `n × 60 000`. */
772
+ readonly minutes: (n: number) => number;
773
+ /** `n` hours in milliseconds: `n × 3 600 000`. */
774
+ readonly hours: (n: number) => number;
775
+ /** `n` days in milliseconds: `n × 86 400 000`. */
776
+ readonly days: (n: number) => number;
777
+ /** `n` weeks in milliseconds: `n × 604 800 000`. */
778
+ readonly weeks: (n: number) => number;
779
+ }
780
+ /**
781
+ * Durations in milliseconds, the unit of `Date.now()`: `ms.days(7)` is `604800000`. Plain
782
+ * multiplications and nothing else, for `halfLife`, `cooldown`, `episodeTimeout` and
783
+ * `learning.halfLife` when your timestamps are in milliseconds. If your `at` is in another unit,
784
+ * write durations in that unit instead: the engine only needs the two to agree.
785
+ *
786
+ * @example
787
+ * ```ts
788
+ * const config = {
789
+ * episodeTimeout: ms.minutes(30),
790
+ * intents: {
791
+ * upgrade: {
792
+ * prior: 0.05,
793
+ * priorStrength: 10,
794
+ * halfLife: ms.days(7),
795
+ * signals: { 'pricing.viewed': 3 },
796
+ * reveal: { cooldown: ms.weeks(2) },
797
+ * },
798
+ * },
799
+ * };
800
+ * ```
801
+ */
802
+ declare const ms: Units;
803
+
804
+ /**
805
+ * Checks a config exactly as {@link createState} and {@link deserialize} do, without building a
806
+ * state, and returns the normalized copy they would store: only the recognised keys, in a fixed
807
+ * order, with every `undefined` dropped, so it survives a JSON round trip unchanged. Use it to
808
+ * fail fast at startup or in a test, or on a config loaded from a file, before the engine runs.
809
+ *
810
+ * @param config - The config to check. Typed `unknown` so parsed JSON can be passed as it is.
811
+ * @returns A fresh, normalized {@link EngineConfig}. `config` is not mutated.
812
+ * @throws `TypeError` when a value has the wrong type or a key is not recognised.
813
+ * @throws `RangeError` when a number is out of range: a `prior` outside (0, 1), a signal weight of
814
+ * 0, or a weight, `priorStrength`, `learning.gain` or `learning.priorEpisodes` beyond 1e6.
815
+ * @example
816
+ * ```ts
817
+ * import { readFileSync } from 'node:fs';
818
+ * import { validateConfig } from 'oracular';
819
+ *
820
+ * // Throws with the offending path in the message, e.g. config.intents["upgrade"].prior
821
+ * const config = validateConfig(JSON.parse(readFileSync('oracular.config.json', 'utf8')));
822
+ * ```
823
+ */
824
+ declare function validateConfig(config: unknown): EngineConfig;
825
+
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 };