oracular 0.6.0 → 0.7.1

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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#42](https://github.com/Yuribenjamin/oracular/pull/42) [`44fee50`](https://github.com/Yuribenjamin/oracular/commit/44fee509d28f5c7ad6ef77a53cfa5952db50560b) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **A load then a save writes the same string.** `deserialize` rebuilt the open episode's per-intent records in the order the config lists intents, while `observe` writes them in the order the intents were first touched in that episode. The content was identical, and the order of keys in a serialized state is not API, but an app that skips a save when the string has not changed, a common persistence pattern, wrote once spuriously after every load. `deserialize` now keeps the stored order and drops only intents the config no longer declares, so `serialize(deserialize(json))` is `json` on the first pass. The property-based tests pin it.
8
+
9
+ ## 0.7.0
10
+
11
+ ### Minor Changes
12
+
13
+ - [#36](https://github.com/Yuribenjamin/oracular/pull/36) [`a585808`](https://github.com/Yuribenjamin/oracular/commit/a58580846d39fa14269096fa3e4f0891749eee88) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **Inspect.** `inspect(state, at, opts?)` in the main entry returns every intent's prediction, gate and explanation as one object, in config order. `oracular/inspect` renders that snapshot: `renderInspector` as an HTML fragment with inline styles and every string escaped, `mountInspector` into anything with an `innerHTML`, with `update` and `unmount`; neither touches a DOM API or sets a timer, and neither carries engine code. The subpath is experimental, as the stability page says: its markup and options may change in a minor. The types `Snapshot` and `IntentSnapshot` are exported from the main entry.
14
+
15
+ - [#36](https://github.com/Yuribenjamin/oracular/pull/36) [`a585808`](https://github.com/Yuribenjamin/oracular/commit/a58580846d39fa14269096fa3e4f0891749eee88) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **Presets.** `oracular/presets` exports `presets`, four plain `IntentConfig` objects to spread into your config and override: `willBuy`, tuned by `oracular tune` on a public e-commerce clickstream of 2.7 million events (one shop, May to September 2015, 4.8% of sessions ending with an order; prior 0.048, strength 4, `cart.added` 2.3, half-life one hour, 181,080 sessions behind it), and `upgrade`, `stuck` and `churn`, the documentation's running examples, hand-written and labelled so. `presetSources` carries each preset's provenance as data, and the types `PresetName` and `PresetSource` are exported from the main entry.
16
+
17
+ ### Patch Changes
18
+
19
+ - [#36](https://github.com/Yuribenjamin/oracular/pull/36) [`a585808`](https://github.com/Yuribenjamin/oracular/commit/a58580846d39fa14269096fa3e4f0891749eee88) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **Rollback.** `deserialize` reads a state one version newer than `STATE_VERSION` as the current one, ignoring the fields it does not know, so a rollback by one release keeps every user's history. The rule that allows it, a state version bump only adds fields, is now a commitment on the new stability page, together with what counts as public API, how versions are read before 1.0, the deprecation rule, the state-format guarantee, the 1.0 criteria and their status, and a 7 kB ceiling for the core. Two versions ahead still throws the `RangeError`, and `migrate` is still consulted for it. From now on the changeset of every state version bump says what an older reader silently loses; this release bumps nothing, so a 0.6 reader loses nothing but was never able to read ahead.
20
+
3
21
  ## 0.6.0
4
22
 
5
23
  ### Minor Changes
package/README.md CHANGED
@@ -64,9 +64,21 @@ With these numbers the offer fires after roughly six strong signals across separ
64
64
 
65
65
  That block is copied verbatim into the test suite, so it always runs.
66
66
 
67
+ ## The workflow: shadow, tune, ship, learn
68
+
69
+ The weights above are a guess, and the point of oracular is that you do not have to live with a guess. The intended path:
70
+
71
+ 1. **Start from a preset or a guess.** `oracular/presets` has `willBuy`, `upgrade`, `stuck` and `churn` as plain configs to spread and override; each says whether its numbers were fitted or written by hand.
72
+ 2. **Run in shadow mode.** The engine computes every decision and your app records it with `createRecord`, one JSON line per event, and shows nothing yet.
73
+ 3. **Tune.** `npx oracular tune shadow.ndjson --config oracular.config.json` reads what the engine itself counted across your users and prints the config to ship: the base rate as the prior, each signal's measured lift as its weight, the half-life that predicts the log best, with sample sizes and intervals. `npx oracular replay` scores any config on the same log before you commit to it.
74
+ 4. **Ship, and let it learn.** The fitted config is the population's answer; on each device the engine corrects it from that user's own outcomes.
75
+
76
+ On a public clickstream of 2.7 million events, a hand-written config scored worse than always guessing the base rate, and the tuned one 10% better, with a reveal followed by an order six times as often as chance. Against the rule a skeptic would write, reveal on the third cart add, it ties for first-time visitors, as it must, and for returning ones catches 43% more orders at three points higher precision, because it has learned who fills carts without buying. The [case study](https://yuribenjamin.github.io/oracular/guide/case-study-clickstream) has every number; the [Shadow mode guide](https://yuribenjamin.github.io/oracular/guide/shadow-mode) has the workflow.
77
+
67
78
  ## Contents
68
79
 
69
80
  - [Why oracular](#why-oracular)
81
+ - [The workflow: shadow, tune, ship, learn](#the-workflow-shadow-tune-ship-learn)
70
82
  - [How it works](#how-it-works)
71
83
  - [Examples](#examples)
72
84
  - [Using with React](#using-with-react)
@@ -188,12 +200,12 @@ const churn = {
188
200
  ### Ad placement
189
201
 
190
202
  ```ts
203
+ import { presets } from 'oracular/presets';
204
+
205
+ // The tuned preset (prior 0.048, strength 4, cart.added 2.3, from a real clickstream), plus your own signals.
191
206
  const willBuy = {
192
- prior: 0.02,
193
- priorStrength: 50,
194
- halfLife: 3 * 24 * 60 * 60 * 1000,
195
- signals: { 'product.viewed': 1, 'cart.added': 3, 'wishlist.added': 2, 'cart.emptied': -3 },
196
- outcome: 'checkout.completed',
207
+ ...presets.willBuy,
208
+ signals: { ...presets.willBuy.signals, 'wishlist.added': 2, 'cart.emptied': -3 },
197
209
  };
198
210
  // shouldReveal(state, 'willBuy', now) → show the sponsored placement, then markRevealed.
199
211
  ```
@@ -244,6 +256,7 @@ shouldReveal(state, intent: string, at: number, opts?: RevealOptions): boolean
244
256
  markRevealed(state, intent: string, at: number): EngineState
245
257
  explain(state, intent: string, at: number, opts?: { nonZero?: boolean; limit?: number }): SignalExplanation[]
246
258
  evaluate(state, intent: string, at: number, opts?): { prediction, reveal, explanation } // all three, one decay pass
259
+ inspect(state, at: number, opts?): { at, intents: [{ intent, prediction, reveal, explanation }] } // evaluate for every intent
247
260
  evidenceToReveal(intentConfig, opts?): number // pseudo-counts at which shouldReveal first becomes true
248
261
  episodeClosed(prev, next): boolean // did an episode close between two states?
249
262
  createRecord(state, event, { installId, mode }): EventRecord // one recorder line: the event and what was decided
@@ -364,6 +377,8 @@ explain(state, 'upgrade', Date.now());
364
377
 
365
378
  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.
366
379
 
380
+ `inspect(state, now)` returns all of that for every intent as one object, and `oracular/inspect` renders it as a live, framework-free panel: `mountInspector(element, () => inspect(state, Date.now()))`, then `update()` after each `observe`. See [Inspect](https://yuribenjamin.github.io/oracular/guide/inspect).
381
+
367
382
  ## Shadow mode, replay and tune
368
383
 
369
384
  Run oracular silently first. Record what it decides, export the log, and measure a config offline before any reveal is shown:
@@ -383,7 +398,7 @@ npx oracular replay shadow.ndjson --config oracular.config.json
383
398
  npx oracular tune shadow.ndjson --config oracular.config.json
384
399
  ```
385
400
 
386
- `tune` seeds a config from the same log: grouped by install, the base rate becomes the prior, the spread of per-install base rates the prior strength, each signal's lift over the episodes without it its weight, and the half-life that predicts the log best wins a grid search scored by `replay`. The counts come from the engine itself, so tune cannot disagree with what a device would learn. It prints sample sizes and a 90% interval per weight, and the config to paste; the engine corrects it per user from there. Three rules learned from a real clickstream keep it honest: a signal seen in nearly every episode is left out, a flat grid keeps your half-life, and the report says when an intent still does not beat its base rate. The [Shadow mode guide](https://yuribenjamin.github.io/oracular/guide/shadow-mode) has the workflow, the record format and both commands.
401
+ `tune` seeds a config from the same log: grouped by install, the base rate becomes the prior, the spread of per-install base rates the prior strength, each signal's lift over the episodes without it its weight, and the half-life that predicts the log best wins a grid search scored by `replay`. The counts come from the engine itself, so tune cannot disagree with what a device would learn. It prints sample sizes and a 90% interval per weight, and the config to paste; the engine corrects it per user from there. Three rules learned from a real clickstream keep it honest: a signal seen in nearly every episode is left out, a flat grid keeps your half-life, and the report says when an intent still does not beat its base rate. The [Shadow mode guide](https://yuribenjamin.github.io/oracular/guide/shadow-mode) has the workflow, the record format and both commands; the [case study](https://yuribenjamin.github.io/oracular/guide/case-study-clickstream) runs both on a public clickstream of 2.7 million events.
387
402
 
388
403
  ## Design rules
389
404
 
@@ -391,10 +406,11 @@ npx oracular tune shadow.ndjson --config oracular.config.json
391
406
  - **Portable.** No DOM, Node or React Native specific APIs; the source compiles against the ES2020 library alone. ESM and CommonJS builds with type declarations.
392
407
  - **Private.** Nothing leaves the device because nothing *can*: there is no I/O in the library.
393
408
  - **Honest about uncertainty.** `shouldReveal` needs both a high mean and a high lower bound.
409
+ - **Committed.** What counts as public API, how the state format may change before 1.0 and what 1.0 will promise are written down in [Stability and 1.0](https://yuribenjamin.github.io/oracular/guide/stability).
394
410
 
395
411
  ## Roadmap
396
412
 
397
- Now is 0.4, themed **learning that matches how people behave**. Shipped so far: cross-episode attribution (`attribution` on an intent, so a session's signals earn a share of an outcome days later), `npx oracular tune` to seed a config from a shadow log, and small ergonomics (`validateConfig`, `ms`, a JSON Schema for the config). Presets come once tune has run on more real logs. 0.3 shipped shadow mode, `npx oracular replay` and per-episode saturation. Then a written stability policy before 1.0. Framework bindings wait for a third integration. The full list, with the reasoning and what was declined, is on the [roadmap page](https://yuribenjamin.github.io/oracular/guide/roadmap).
413
+ The 0.4 theme, **learning that matches how people behave**, is complete: cross-episode attribution (`attribution` on an intent, so a session's signals earn a share of an outcome days later), `npx oracular tune` to seed a config from a shadow log, presets, and small ergonomics (`validateConfig`, `ms`, a JSON Schema for the config). 0.3 shipped shadow mode, `npx oracular replay` and per-episode saturation. Next is the road to 1.0, whose criteria are on [Stability and 1.0](https://yuribenjamin.github.io/oracular/guide/stability). 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).
398
414
 
399
415
  ## Contributing
400
416
 
package/dist/index.cjs CHANGED
@@ -557,6 +557,14 @@ function evaluate(state, intent, at, opts) {
557
557
  explanation: buildExplanation(is, config, learning, split.explain)
558
558
  };
559
559
  }
560
+ function inspect(state, at, opts) {
561
+ const s = requireState(state);
562
+ const intents = Object.keys(s.config.intents).map((intent) => ({
563
+ intent,
564
+ ...evaluate(s, intent, at, opts)
565
+ }));
566
+ return { at, intents };
567
+ }
560
568
 
561
569
  // src/evidence.ts
562
570
  function evidenceToReveal(intent, opts) {
@@ -827,9 +835,11 @@ function deserialize(json, opts = {}) {
827
835
  if (parsed.version !== STATE_VERSION) {
828
836
  if (canMigrate(parsed.version)) {
829
837
  stored = migrateStored(parsed);
838
+ } else if (parsed.version === STATE_VERSION + 1) {
839
+ stored = { ...parsed, version: STATE_VERSION };
830
840
  } else if (opts.migrate === void 0) {
831
841
  throw new RangeError(
832
- `oracular: stored state is version ${parsed.version} but this library reads versions 1 to ${STATE_VERSION}; pass a migrate function`
842
+ `oracular: stored state is version ${parsed.version} but this library reads versions 1 to ${STATE_VERSION + 1}; pass a migrate function`
833
843
  );
834
844
  } else {
835
845
  stored = opts.migrate(parsed, parsed.version);
@@ -1001,8 +1011,11 @@ function reconcile(raw, config) {
1001
1011
  keep(stored.contributions, intent.signals)
1002
1012
  );
1003
1013
  }
1004
- const episode = getOwn(raw.episode.intents, name);
1005
- if (episode !== void 0 && raw.episode.startedAt !== null) {
1014
+ }
1015
+ if (raw.episode.startedAt !== null) {
1016
+ for (const [name, episode] of Object.entries(raw.episode.intents)) {
1017
+ const intent = getOwn(config.intents, name);
1018
+ if (intent === void 0) continue;
1006
1019
  const seen = episode.seen.filter(
1007
1020
  (s, i) => hasOwn(intent.signals, s) && episode.seen.indexOf(s) === i
1008
1021
  );
@@ -1048,6 +1061,7 @@ exports.episodeClosed = episodeClosed;
1048
1061
  exports.evaluate = evaluate;
1049
1062
  exports.evidenceToReveal = evidenceToReveal;
1050
1063
  exports.explain = explain;
1064
+ exports.inspect = inspect;
1051
1065
  exports.markRevealed = markRevealed;
1052
1066
  exports.ms = ms;
1053
1067
  exports.observe = observe;