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/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#22](https://github.com/Yuribenjamin/oracular/pull/22) [`0bb5841`](https://github.com/Yuribenjamin/oracular/commit/0bb5841fb93d2f16dcac74601f1ee218b70dda0e) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - Three small ergonomics for writing configs.
8
+
9
+ - **`validateConfig(config)`** runs the checks `createState` and `deserialize` run, without building a state, and returns the normalized copy they would store. It accepts `unknown`, so parsed JSON can be passed as it is; use it to fail fast at startup or in a test.
10
+ - **`ms`**: `ms.seconds(n)`, `ms.minutes(n)`, `ms.hours(n)`, `ms.days(n)` and `ms.weeks(n)` spell durations in milliseconds, so `halfLife: ms.days(7)` replaces `7 * 24 * 60 * 60 * 1000`. Plain multiplications, nothing else.
11
+ - **A JSON Schema for the config**, shipped in the package as `oracular/config.schema.json` and served at https://yuribenjamin.github.io/oracular/config.schema.json, for completion and checks on a config kept as JSON. `oracular replay --config file.json` now ignores a top-level `$schema` key, so a file can point at the schema and still be the config the engine runs.
12
+
13
+ ## 0.3.0
14
+
15
+ ### Minor Changes
16
+
17
+ - [#18](https://github.com/Yuribenjamin/oracular/pull/18) [`d2d95a4`](https://github.com/Yuribenjamin/oracular/commit/d2d95a4f3409280d999500f42851772cf2b6a767) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - `deserialize` now rejects, with a `RangeError`, a stored state whose numbers are each finite but too large to compute with. Before, a contribution or episode count near 1e308 (a corrupted or hand-edited state) loaded and then answered NaN. Config validation now caps signal weights, `priorStrength`, `learning.gain` and `learning.priorEpisodes` at 1e6 in magnitude (`RangeError`), because larger values could overflow the same arithmetic in `createState` too; no real config comes close. A fuzz test over states saved by every published version (0.1.0 to 0.2.1) guards both: corrupt, truncated and tampered input either throws a `SyntaxError`, `TypeError` or `RangeError`, or loads a state whose estimates stay finite.
18
+
19
+ - [#18](https://github.com/Yuribenjamin/oracular/pull/18) [`d2d95a4`](https://github.com/Yuribenjamin/oracular/commit/d2d95a4f3409280d999500f42851772cf2b6a767) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - Repeats of a signal within one episode now saturate when the config sets `episodeTimeout`. The n-th occurrence adds `w × log2(1 + 1/n)`, so n occurrences add `w × log2(1 + n)`: ten `pricing.viewed` are worth 3.46 weights, not ten. The count restarts when the episode ends or the intent's outcome arrives, so the same signal in a new session still counts in full. Learning is unchanged: hits and misses still count once per episode.
20
+
21
+ **This changes when reveals fire.** An intent whose evidence came from one signal repeated in one session now needs more. The quickstart's upgrade offer needs 32 `pricing.viewed` in one session, where it used to need 6; six in separate sessions still fire it. Two things to check after upgrading:
22
+
23
+ - **Saturation is on by default only when `episodeTimeout` is set.** Without an idle timeout, episodes end only when your app calls `endEpisode`, and an app that never does would otherwise discount a signal it saw yesterday; such configs keep their 0.2 behaviour. If your app ends every episode itself, set `saturate: true` on the intents that should saturate. The quickstart now sets `episodeTimeout: 30 * 60 * 1000`.
24
+ - **Opt out where repetition is the evidence.** Set the new intent option `saturate: false` for intents where repeating a signal is itself the evidence, such as a user stuck on the same step. Every occurrence then adds its full weight, exactly as in 0.2.
25
+
26
+ `STATE_VERSION` is now 2: the open episode also records how often each signal occurred (`EpisodeIntentRecord.counts`). `deserialize` migrates states saved by oracular 0.1.0 to 0.2.x on its own, keeping every estimate exactly as it was; your `migrate` function is only called for a version the library does not know. A version 2 state cannot be read by 0.2.x, so if you might roll back, catch the `RangeError` and fall back to `createState(config)`.
27
+
28
+ - [#18](https://github.com/Yuribenjamin/oracular/pull/18) [`d2d95a4`](https://github.com/Yuribenjamin/oracular/commit/d2d95a4f3409280d999500f42851772cf2b6a767) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - Shadow mode is now a supported workflow: run oracular silently, record what it decides, and measure a config offline before any reveal is shown.
29
+
30
+ - **Recorder format and helpers.** `createRecord(state, event, { installId, mode })` returns one line of a versioned NDJSON log (`RECORD_VERSION` 1). Each line holds the event (`name`, `at`; `props` are never recorded) and, as a separate field, what the engine decided for every intent: `p`, `lower`, `shouldReveal`, `suppressedUntil`, the top signals by impact and the effective weights. `createEndRecord(at, options)` records an `endEpisode` call. Every record says whether reveals were `shadow` or `live`. The install id comes from your app (random, generated once, stored); the helpers are pure and generate nothing.
31
+ - **`npx oracular replay <log>... --config <file>`.** Replays the logged events through the engine under any config, and reports per intent and threshold: precision, recall, median lead time from reveal to outcome, reveals per user, and a reliability table with a Brier score. Only shadow periods are scored: a log with live records gets a warning, and a log of nothing but live records is refused unless `--include-live` is passed. The command ships as the package's `bin`, still with zero runtime dependencies.
32
+
33
+ The new Shadow mode guide describes the workflow and the record format.
34
+
3
35
  ## 0.2.1
4
36
 
5
37
  ### Patch Changes
package/README.md CHANGED
@@ -12,6 +12,8 @@
12
12
  <a href="https://bundlephobia.com/package/oracular"><img src="https://img.shields.io/bundlephobia/minzip/oracular" alt="bundle size"></a>
13
13
  <a href="https://www.npmjs.com/package/oracular"><img src="https://img.shields.io/npm/dm/oracular" alt="npm downloads"></a>
14
14
  <a href="./LICENSE"><img src="https://img.shields.io/npm/l/oracular" alt="MIT license"></a>
15
+ <a href="https://www.bestpractices.dev/projects/15643"><img src="https://www.bestpractices.dev/projects/15643/badge" alt="OpenSSF Best Practices"></a>
16
+ <a href="https://scorecard.dev/viewer/?uri=github.com/Yuribenjamin/oracular"><img src="https://api.scorecard.dev/projects/github.com/Yuribenjamin/oracular/badge" alt="OpenSSF Scorecard"></a>
15
17
  </p>
16
18
 
17
19
  <p align="center">
@@ -43,6 +45,7 @@ Install with `npm install oracular`, then:
43
45
  ```ts
44
46
  import { createState, markRevealed, observe, shouldReveal } from 'oracular';
45
47
  let state = createState({
48
+ episodeTimeout: 30 * 60 * 1000, // 30 idle minutes end a session
46
49
  intents: {
47
50
  upgrade: {
48
51
  prior: 0.05, priorStrength: 10, halfLife: 7 * 24 * 60 * 60 * 1000, // one week, in ms
@@ -57,7 +60,7 @@ if (shouldReveal(state, 'upgrade', Date.now())) {
57
60
  }
58
61
  ```
59
62
 
60
- With these numbers the offer fires after roughly six strong signals inside a week (p must clear 0.5 with a 90% lower bound); lower `priorStrength` or raise the weights to fire sooner.
63
+ With these numbers the offer fires after roughly six strong signals across separate sessions inside a week (p must clear 0.5 with a 90% lower bound); repeats within one session count for less. Lower `priorStrength` or raise the weights to fire sooner.
61
64
 
62
65
  That block is copied verbatim into the test suite, so it always runs.
63
66
 
@@ -70,6 +73,7 @@ That block is copied verbatim into the test suite, so it always runs.
70
73
  - [API](#api)
71
74
  - [Persistence](#persistence)
72
75
  - [Explainability](#explainability)
76
+ - [Shadow mode & replay](#shadow-mode--replay)
73
77
  - [Design rules](#design-rules)
74
78
  - [Roadmap](#roadmap)
75
79
  - [Contributing](#contributing)
@@ -86,6 +90,17 @@ You declare intents. Each has a `prior` probability and a `priorStrength` (how m
86
90
 
87
91
  Each intent maps event names to weights. A positive weight is evidence *for* the intent and is added to α; a negative weight is evidence *against* and its magnitude is added to β. Events the config doesn't mention cost nothing.
88
92
 
93
+ ### Repeats within a session count for less
94
+
95
+ Ten views of the pricing page in one sitting are one burst of interest, not ten independent pieces of evidence. Within an episode, the n-th occurrence of a signal adds less than the first:
96
+
97
+ ```
98
+ n-th occurrence adds w × log2(1 + 1/n) 1, 0.58, 0.42, 0.32 … of the weight
99
+ n occurrences add w × log2(1 + n) every doubling adds one more weight; ten add 3.46, not 10
100
+ ```
101
+
102
+ The count restarts when the episode ends or the intent's outcome arrives, so the same signal in a new session counts fully again. Saturation is on by default when `episodeTimeout` is set. Without it, episodes end only when you call `endEpisode`, so repeats add their full weight unless you set `saturate: true`. For intents where the repetition itself is the evidence (a user stuck on the same step), set `saturate: false` and every occurrence adds its full weight.
103
+
89
104
  ### Evidence decays toward the prior
90
105
 
91
106
  Before any update or reading, the evidence decays by 2^(−Δt / halfLife), where Δt is the time since the intent was last updated. Only the *excess over the prior* decays: with no events, p drifts back to `prior` and α + β back to `priorStrength`. Someone who looked at pricing a month ago is not someone who looked at pricing today.
@@ -141,6 +156,7 @@ const config = {
141
156
  halfLife: 10 * 60 * 1000, // ten minutes: being stuck is a short-lived state
142
157
  signals: { 'editor.undo': 1, 'search.empty': 2, 'help.opened': 3, 'task.completed': -4 },
143
158
  outcome: 'help.article.read',
159
+ saturate: false, // the fifth undo in a row is more evidence, not less
144
160
  reveal: { threshold: 0.6, cooldown: 60 * 60 * 1000 },
145
161
  },
146
162
  },
@@ -230,9 +246,14 @@ explain(state, intent: string, at: number, opts?: { nonZero?: boolean; limit?: n
230
246
  evaluate(state, intent: string, at: number, opts?): { prediction, reveal, explanation } // all three, one decay pass
231
247
  evidenceToReveal(intentConfig, opts?): number // pseudo-counts at which shouldReveal first becomes true
232
248
  episodeClosed(prev, next): boolean // did an episode close between two states?
249
+ createRecord(state, event, { installId, mode }): EventRecord // one recorder line: the event and what was decided
250
+ createEndRecord(at, { installId, mode }): EndRecord // one recorder line: the episode ended
233
251
  serialize(state): string
234
252
  deserialize(json: string, opts?: { config?: EngineConfig; migrate?: Migrate }): EngineState
235
- STATE_VERSION // 1
253
+ validateConfig(config: unknown): EngineConfig // the checks createState runs, without a state; returns the normalized copy
254
+ ms.seconds(n), ms.minutes(n), ms.hours(n), ms.days(n), ms.weeks(n) // durations in milliseconds
255
+ STATE_VERSION // 2
256
+ RECORD_VERSION // 1
236
257
  ```
237
258
 
238
259
  ### Config
@@ -243,19 +264,20 @@ interface EngineConfig {
243
264
  episodeTimeout?: number; // idle gap that ends an episode; off if omitted
244
265
  learning?: {
245
266
  halfLife?: number; // forgetting of learned counts; off if omitted
246
- gain?: number; // learned weight cap = gain × |initial|; default 2
247
- priorEpisodes?: number; // pseudo-count behind your weights; default 10
267
+ gain?: number; // learned weight cap = gain × |initial|; default 2, at most 1e6
268
+ priorEpisodes?: number; // pseudo-count behind your weights; default 10, at most 1e6
248
269
  };
249
270
  reveal?: RevealOptions; // defaults for every intent
250
271
  }
251
272
 
252
273
  interface IntentConfig {
253
274
  prior: number; // (0, 1)
254
- priorStrength: number; // > 0
275
+ priorStrength: number; // > 0, at most 1e6
255
276
  halfLife: number; // > 0
256
- signals: Record<string, number>; // event name → weight, non-zero
277
+ signals: Record<string, number>; // event name → weight, non-zero, between −1e6 and 1e6
257
278
  outcome?: string; // event that confirms the intent
258
279
  resetOnOutcome?: boolean; // default true
280
+ saturate?: boolean; // repeats within an episode add less; default: episodeTimeout is set
259
281
  reveal?: RevealOptions;
260
282
  }
261
283
 
@@ -266,7 +288,7 @@ interface RevealOptions {
266
288
  }
267
289
  ```
268
290
 
269
- Reveal options resolve per call → intent → engine → defaults. Invalid config throws a `TypeError` or `RangeError` with the offending path in the message.
291
+ Reveal options resolve per call → intent → engine → defaults. Invalid config throws a `TypeError` or `RangeError` with the offending path in the message. `validateConfig(config)` runs those checks without building a state and returns the normalized copy `createState` stores, so a bad weight fails at startup or in a test rather than on first use. For a config kept as JSON, the package ships a [JSON Schema](./schema/config.schema.json) as `oracular/config.schema.json`, also served at <https://yuribenjamin.github.io/oracular/config.schema.json>, for editor completion; `oracular replay` ignores the `$schema` key such a file carries, the engine does not.
270
292
 
271
293
  ### Results
272
294
 
@@ -289,7 +311,7 @@ interface SignalExplanation {
289
311
  impact: number; // p − p without this signal
290
312
  initialWeight: number;
291
313
  learnedWeight: number;
292
- effectiveWeight: number; // what the next occurrence will add
314
+ effectiveWeight: number; // what the signal's first occurrence in an episode adds
293
315
  hits: number;
294
316
  misses: number;
295
317
  }
@@ -297,7 +319,7 @@ interface SignalExplanation {
297
319
 
298
320
  ### Time
299
321
 
300
- `at` is a timestamp. Every duration (`halfLife`, `cooldown`, `episodeTimeout`, `learning.halfLife`) is in the same unit as `at`. Milliseconds from `Date.now()` are the obvious choice, but the library never calls `Date.now()` itself: you pass time in, which keeps every function deterministic and makes tests trivial. A clock that jumps backwards is tolerated (no decay, no error).
322
+ `at` is a timestamp. Every duration (`halfLife`, `cooldown`, `episodeTimeout`, `learning.halfLife`) is in the same unit as `at`. Milliseconds from `Date.now()` are the obvious choice, but the library never calls `Date.now()` itself: you pass time in, which keeps every function deterministic and makes tests trivial. A clock that jumps backwards is tolerated (no decay, no error). When `at` is in milliseconds, `ms.days(7)`, `ms.hours(3)` and `ms.minutes(30)` spell the durations; they are plain multiplications.
301
323
 
302
324
  ## Persistence
303
325
 
@@ -315,7 +337,7 @@ const state = deserialize(saved, { config });
315
337
 
316
338
  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.
317
339
 
318
- If you change the shape of the state itself across versions of this library, `deserialize` throws a `RangeError` unless you pass a `migrate` function, which receives the parsed object and its version and returns a current state:
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`:
319
341
 
320
342
  ```ts
321
343
  deserialize(saved, {
@@ -341,6 +363,21 @@ explain(state, 'upgrade', Date.now());
341
363
 
342
364
  Rows are sorted by the size of their impact. `limit.hit` has no history yet, so its effective weight is exactly the initial one. `pricing.viewed` was followed by an upgrade in six of its seven sessions while the three sessions without it saw one upgrade, so it learned a weight above the initial 3. `export.clicked` appeared in every session: it has no counterfactual, so whatever its hit rate, its weight stands at exactly the initial 1 until a session happens without it. See [Episodes & learning](https://yuribenjamin.github.io/oracular/guide/concepts/episodes-and-learning) for why learning needs contrast.
343
365
 
366
+ ## Shadow mode & replay
367
+
368
+ Run oracular silently first. Record what it decides, export the log, and measure a config offline before any reveal is shown:
369
+
370
+ ```ts
371
+ state = observe(state, event);
372
+ log.append(JSON.stringify(createRecord(state, event, { installId, mode: 'shadow' })));
373
+ ```
374
+
375
+ ```sh
376
+ npx oracular replay shadow.ndjson --config oracular.config.json
377
+ ```
378
+
379
+ `replay` reports precision, recall, lead time and reveals per user for every intent and threshold, and a reliability table with a Brier score. It scores only the periods in which reveals were not shown, because once they are, the reveal itself causes outcomes. `installId` is a random id your app generates once and stores; it is never derived from the device. The log holds event names and times, never `props`, and leaves the device only if your app sends it. The [Shadow mode guide](https://yuribenjamin.github.io/oracular/guide/shadow-mode) has the workflow and the record format.
380
+
344
381
  ## Design rules
345
382
 
346
383
  - **Pure.** Inputs are never mutated; the same input always gives the same output. Internally, α and β are always the prior plus the sum of per-signal contributions, so `explain` adds up.
@@ -350,7 +387,7 @@ Rows are sorted by the size of their impact. `limit.hit` has no history yet, so
350
387
 
351
388
  ## Roadmap
352
389
 
353
- Not in 0.1, deliberately left room for: a React hook, a Vue composable, a devtools panel for watching `explain` live, and presets for common intents. Details and how to propose more on the [roadmap page](https://yuribenjamin.github.io/oracular/guide/roadmap).
390
+ 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; `npx oracular tune` to seed a config from a shadow log; presets; 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).
354
391
 
355
392
  ## Contributing
356
393