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 +18 -0
- package/README.md +23 -7
- package/dist/index.cjs +17 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +81 -10
- package/dist/index.d.ts +81 -10
- package/dist/index.js +17 -4
- package/dist/index.js.map +1 -1
- package/dist/inspect.cjs +67 -0
- package/dist/inspect.cjs.map +1 -0
- package/dist/inspect.d.cts +143 -0
- package/dist/inspect.d.ts +143 -0
- package/dist/inspect.js +64 -0
- package/dist/inspect.js.map +1 -0
- package/dist/presets.cjs +86 -0
- package/dist/presets.cjs.map +1 -0
- package/dist/presets.d.cts +129 -0
- package/dist/presets.d.ts +129 -0
- package/dist/presets.js +83 -0
- package/dist/presets.js.map +1 -0
- package/package.json +35 -2
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1005
|
-
|
|
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;
|