oracular 0.8.1 → 0.10.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,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#56](https://github.com/Yuribenjamin/oracular/pull/56) [`1529d8e`](https://github.com/Yuribenjamin/oracular/commit/1529d8e1568d79959afc7573ba8fa6e7f3e7ff59) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **Configs that cannot work are refused.** `createState`, `validateConfig` and the `config` passed to `deserialize` now throw for a reveal `threshold` of 0 or 1 (`RangeError`, strictly between 0 and 1), a `z` above 10 (`RangeError`; 3.09 is already a one-sided 99.9% bound, and 128 is a misplaced 1.28), and an `outcome` that is also one of the intent's own signals while `resetOnOutcome` is on (`TypeError`: the reset wipes the evidence the signal just added; set `resetOnOutcome: false` to keep such a signal, which then reads as "did it once, will again"). The JSON Schema says the same. Per-call reveal options keep accepting 0 and 1, since a probe such as `evidenceToReveal` at those values is a legitimate question. A stored state whose config predates these checks still loads: `deserialize` reads the stored config without them and replaces it with the one you pass. An intent with no signals stays accepted; it is a prior-only gate. The presets in `oracular/presets` are now frozen through and through, so a change by one caller cannot reach every other; spread one to change a value, as the guide shows.
8
+
9
+ **Size.** The checks, the anchor pin and the duplicate-outcome rule add about 0.2 kB: the core goes from 7.5 kB to 7.72 kB minified and gzipped, under the 7.9 kB ceiling, and the quickstart entry (`createState`, `observe`, `shouldReveal`, `markRevealed`) from 4.43 kB to 4.63 kB, past its 4.5 kB budget, which moves to 4.7 kB.
10
+
11
+ - [#56](https://github.com/Yuribenjamin/oracular/pull/56) [`1529d8e`](https://github.com/Yuribenjamin/oracular/commit/1529d8e1568d79959afc7573ba8fa6e7f3e7ff59) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **A clock that was ahead no longer freezes an intent.** A production audit found that one event stamped in the future (a device clock that was wrong for a moment) held an intent until the clock caught up: decay stopped, because the anchor kept the later time; the open episode never timed out, so learning stopped; and a reveal or outcome stamped in the future kept the gate shut until that date plus the cooldown. Every write now moves its anchor to the event's own time, even when that is earlier: the evidence is held (no decay for a backwards step, as before) and decay resumes from the new time, for `updatedAt`, the learned counts' `learnedAt` and the episode's `lastEventAt`. A reveal or outcome stamped ahead of the clock counts as having happened now: `predict` reports `suppressedUntil` one cooldown after the current time rather than years ahead, and the next event pins the stored time to its own. `markRevealed` follows the same rule and trusts the time it is given, so an earlier `at` moves the cooldown start back. What moves for a clock that only ever runs forward: nothing. What moves for one that jumps backwards: the anchor now follows it, so a log replayed with timestamps out of order decays slightly more than before. `decayFactor` of an infinite gap is now 0, not 1. An episode held under `attribution` with a close stamped in the future is left as it was.
12
+
13
+ - [#56](https://github.com/Yuribenjamin/oracular/pull/56) [`1529d8e`](https://github.com/Yuribenjamin/oracular/commit/1529d8e1568d79959afc7573ba8fa6e7f3e7ff59) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **A repeated outcome counts once.** An outcome rewards the signals seen since the episode started or since the intent's outcome last arrived, and counts the episode toward the base rate. A second outcome event with no signal seen since the first (a duplicate event, or two outcomes back to back) counted another hit episode, so a session with two outcome events inflated the base rate; it now counts nothing, and only `lastOutcomeAt` and the reset of the evidence happen again. Signals seen after an outcome in the same episode are unchanged: they earn a hit if another outcome follows them, and stay neutral at the close, since what they would predict is a second outcome in the same session, which is not what the weight is for. `tune` counts the same way. The state format is unchanged.
14
+
15
+ - [#56](https://github.com/Yuribenjamin/oracular/pull/56) [`1529d8e`](https://github.com/Yuribenjamin/oracular/commit/1529d8e1568d79959afc7573ba8fa6e7f3e7ff59) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **`tune` no longer reports a lift with nothing to compare it to.** A signal seen in every counted episode had its outcome rate compared with a smoothed rate over zero episodes, a made-up 0.5, and the report printed a lift and a 90% interval from it. For such a signal `lift`, `score`, `low` and `high` are now `null` in `--json`, and the text report prints "no contrast" in place of the interval; the weight was already left out as ubiquitous.
16
+
17
+ ### Patch Changes
18
+
19
+ - [#56](https://github.com/Yuribenjamin/oracular/pull/56) [`1529d8e`](https://github.com/Yuribenjamin/oracular/commit/1529d8e1568d79959afc7573ba8fa6e7f3e7ff59) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **Two CLI fixes.** `replay` and `tune` no longer refuse a whole config because one intent has a reveal cooldown of 0: that intent is scored over its half-life, the text report says so, and the `--json` reports gain `windowSource` (`option`, `cooldown` or `halfLife`) next to the existing `windowIsCooldown`. `oracular replay --version` and `oracular tune --version` (or `-v`) print the version, as the help already claimed.
20
+
21
+ ## 0.9.0
22
+
23
+ ### Minor Changes
24
+
25
+ - [#52](https://github.com/Yuribenjamin/oracular/pull/52) [`e8ea457`](https://github.com/Yuribenjamin/oracular/commit/e8ea457b29edd23d0cda8446d6826ad45478b9b9) Thanks [@omaremam](https://github.com/omaremam)! - **Choosing the comparison.** `learning.lift` chooses how a signal's outcome rate is compared with its baseline: absolute difference, relative lift, risk ratio, log lift, odds ratio, normalized difference or association-rule lift. Every choice becomes a signed score in [−1, 1], with no association at zero, before it changes a weight. A risk ratio of 1 learns zero, 3 gives a score of 0.5 and 1/3 gives −0.5; `gain` caps the learned magnitude at `gain × |initial weight|` whichever comparison is set. `oracular tune` uses the same score for weights and intervals, and reports the raw measure alongside it when the two differ. Nothing moves unless the option is set: normalized difference remains the default, with exactly the same arithmetic.
26
+
27
+ **Rollback.** A stored config that sets `lift` is read by 0.9 and later; a 0.8 reader throws a `TypeError` on the unknown key, so remove `learning.lift` from the saved config before rolling a deploy back. Passing an older config to `deserialize` alone does not avoid that check. The state version is unchanged. Existing learned counts are kept; evidence already accumulated under another comparison is not rebuilt and continues to decay or reset on outcome.
28
+
3
29
  ## 0.8.1
4
30
 
5
31
  ### Patch Changes
package/README.md CHANGED
@@ -21,12 +21,19 @@
21
21
  · <a href="https://yuribenjamin.github.io/oracular/guide/introduction">Guide</a>
22
22
  · <a href="https://yuribenjamin.github.io/oracular/api/">API</a>
23
23
  · <a href="https://yuribenjamin.github.io/oracular/playground">Playground</a>
24
+ · <a href="https://yuribenjamin.github.io/oracular/guide/is-it-for-me">Is it for me?</a>
25
+ </p>
26
+
27
+ <p align="center">
28
+ <img src="https://raw.githubusercontent.com/Yuribenjamin/oracular/main/assets/inspector.gif" width="800" alt="A pricing page next to the oracular inspector panel. Over a simulated day, each click adds a signal and p and the lower bound rise, until both clear 0.5: the gate opens, an upgrade offer appears and a cooldown starts. Eight days later the evidence has decayed back toward the prior.">
24
29
  </p>
25
30
 
26
31
  **oracular** is a tiny, zero-dependency, pure-functional intent engine that runs entirely on the device. Feed it the events your app already emits. It keeps a probabilistic estimate of what the user is about to do (upgrade, churn, get stuck, buy…) and tells you when that estimate crosses a threshold *with enough confidence* to act on it: show a tip, make an offer, reveal a feature, place an ad. It learns from each user's own outcomes, so your initial guesses get corrected on their device, not on a server.
27
32
 
28
33
  It never touches the network, storage, DOM, timers or globals. Your app owns persistence and side effects; the library owns the math.
29
34
 
35
+ The job, concretely: your pricing page learns when to show *this* user the upgrade offer, and their events never leave their browser. On a [public clickstream of 2.7 million events](https://yuribenjamin.github.io/oracular/guide/case-study-clickstream#the-rule-a-skeptic-would-write), among the visitors who came back, the tuned engine caught 27.9% of the orders at 48.9% precision, where the best rule a skeptic would write caught 6.4% at 39.2%; for first-time visitors the two tie, as they must.
36
+
30
37
  ## Why oracular
31
38
 
32
39
  | | oracular | Hosted analytics SDKs | Rule engines |
@@ -79,8 +86,8 @@ On a public clickstream of 2.7 million events, a hand-written config scored wors
79
86
 
80
87
  - [Why oracular](#why-oracular)
81
88
  - [The workflow: shadow, tune, ship, learn](#the-workflow-shadow-tune-ship-learn)
82
- - [How it works](#how-it-works)
83
89
  - [Examples](#examples)
90
+ - [How it works](#how-it-works)
84
91
  - [Using with React](#using-with-react)
85
92
  - [API](#api)
86
93
  - [Persistence](#persistence)
@@ -92,66 +99,6 @@ On a public clickstream of 2.7 million events, a hand-written config scored wors
92
99
 
93
100
  The [documentation site](https://yuribenjamin.github.io/oracular/) has the long form: one page per concept, a tuning guide with tables, recipes, framework examples, the generated API reference and a playground that runs the engine in your browser.
94
101
 
95
- ## How it works
96
-
97
- ### Intents are Beta distributions
98
-
99
- You declare intents. Each has a `prior` probability and a `priorStrength` (how many observations the prior is worth). Internally an intent is a Beta(α, β) distribution with α₀ = prior × priorStrength and β₀ = (1 − prior) × priorStrength. Its current estimate is p = α / (α + β).
100
-
101
- ### Signals add evidence
102
-
103
- 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.
104
-
105
- ### Repeats within a session count for less
106
-
107
- 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:
108
-
109
- ```
110
- n-th occurrence adds w × log2(1 + 1/n) 1, 0.58, 0.42, 0.32 … of the weight
111
- n occurrences add w × log2(1 + n) every doubling adds one more weight; ten add 3.46, not 10
112
- ```
113
-
114
- 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.
115
-
116
- ### Evidence decays toward the prior
117
-
118
- 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.
119
-
120
- ### The user's history corrects your weights
121
-
122
- Events happen inside **episodes** (sessions). An episode ends when you call `endEpisode`, or on its own after `episodeTimeout` of inactivity. Signals seen in the episode are remembered per intent. When the intent's `outcome` event arrives, each remembered signal gets a **hit**; when the episode ends without it, each gets a **miss**, or, with `attribution` set on the intent, the episode waits and takes the share `2^(−age / halfLife)` of a hit if the outcome arrives inside the window. The engine also counts, per intent, how many episodes ended with and without the outcome: the intent's **base rate**.
123
-
124
- A signal's weight is then a blend of your prior and the learned estimate:
125
-
126
- ```
127
- nWith = hits + misses episodes with the signal
128
- nWithout = episodes − nWith episodes without it
129
- qWith = (hits + 1) / (nWith + 2) outcome rate with the signal
130
- qWithout = (outcomes − hits + 1) / (nWithout + 2) outcome rate without it
131
- lift = (qWith − qWithout) / (1 − qWithout) if qWith ≥ qWithout, else (qWith − qWithout) / qWithout → in [−1, 1]
132
- learned = gain × |w₀| × lift 0 until the signal has history
133
- nEff = (nWith × nWithout) / (nWith + nWithout) 0 if either side is 0
134
- effective = (k × w₀ + nEff × learned) / (k + nEff) k = priorEpisodes
135
- ```
136
-
137
- `w₀` is your initial weight, `gain` (default 2) caps how far a perfectly predictive signal can grow, and `priorEpisodes` (default 10) is how much contrast it takes for the user's own history to count as much as your guess. A signal's strength and your confidence in it are separate knobs. A signal is judged against the sessions in which it did *not* appear, so for an outcome that happens in 3% of sessions, a signal followed by it 30% of the time is strong evidence, not weak. And learning needs contrast: a signal that has never been absent has no counterfactual, so its weight stands until there are sessions without it. Nothing drifts after a single outcome.
138
-
139
- Learned counts never decay unless you set `learning.halfLife`.
140
-
141
- ### Reveal only when confident
142
-
143
- `shouldReveal` is true when p ≥ threshold **and** p − z·sd ≥ threshold, where sd is the Beta standard deviation and z defaults to 1.28 (a one-sided 90% bound). Thin evidence with a high mean does not fire; it takes a few consistent signals. That lower bound is a normal approximation; `bound: 'exact'` swaps in the Beta distribution's own quantile at the same level, which asks for a little more on thin evidence. After you `markRevealed`, the intent stays revealed for a `cooldown` (default: its half-life) so you don't fire on every event. The same cooldown runs after the intent's outcome event. `predict` reports both: `revealed` is true inside a reveal cooldown, and `suppressedUntil` is when the later of the two cooldowns ends, or null when `shouldReveal` is free to fire.
144
-
145
- ### Outcomes reset the slate and pause reveals
146
-
147
- When the outcome event arrives, the intent's evidence returns to the prior (α, β and contributions), while everything learned is kept. The user already upgraded; stop predicting that they will. Set `resetOnOutcome: false` for intents where the outcome can repeat inside one episode.
148
-
149
- The outcome also starts the intent's `cooldown`: `shouldReveal` stays false until it ends, however strong the fresh evidence, so a user who just bought is not immediately pitched again. The time of the last outcome is kept on the state as `lastOutcomeAt`.
150
-
151
- ### Every answer comes with its reasons
152
-
153
- `explain` returns, per signal, how much evidence it currently holds and how much p would move without it, plus the initial, learned and effective weights and the hit/miss record. If a reveal looks wrong, you can see exactly why it fired.
154
-
155
102
  ## Examples
156
103
 
157
104
  The ad case is one of these, not the point of the library.
@@ -212,36 +159,93 @@ const willBuy = {
212
159
 
213
160
  More in the docs: [Recipes](https://yuribenjamin.github.io/oracular/guide/recipes) and the [Tuning guide](https://yuribenjamin.github.io/oracular/guide/tuning).
214
161
 
162
+ ## How it works
163
+
164
+ ### Intents are Beta distributions
165
+
166
+ You declare intents. Each has a `prior` probability and a `priorStrength` (how many observations the prior is worth). Internally an intent is a Beta(α, β) distribution with α₀ = prior × priorStrength and β₀ = (1 − prior) × priorStrength. Its current estimate is p = α / (α + β).
167
+
168
+ ### Signals add evidence
169
+
170
+ 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.
171
+
172
+ ### Repeats within a session count for less
173
+
174
+ 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:
175
+
176
+ ```
177
+ n-th occurrence adds w × log2(1 + 1/n) 1, 0.58, 0.42, 0.32 … of the weight
178
+ n occurrences add w × log2(1 + n) every doubling adds one more weight; ten add 3.46, not 10
179
+ ```
180
+
181
+ 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.
182
+
183
+ ### Evidence decays toward the prior
184
+
185
+ 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.
186
+
187
+ ### The user's history corrects your weights
188
+
189
+ Events happen inside **episodes** (sessions). An episode ends when you call `endEpisode`, or on its own after `episodeTimeout` of inactivity. Signals seen in the episode are remembered per intent. When the intent's `outcome` event arrives, each remembered signal gets a **hit**; when the episode ends without it, each gets a **miss**, or, with `attribution` set on the intent, the episode waits and takes the share `2^(−age / halfLife)` of a hit if the outcome arrives inside the window. The engine also counts, per intent, how many episodes ended with and without the outcome: the intent's **base rate**. Signals seen after an outcome in the same episode count only if another outcome follows them; a repeated outcome with nothing seen since counts nothing.
190
+
191
+ A signal's weight is then a blend of your prior and the learned estimate:
192
+
193
+ ```
194
+ nWith = hits + misses episodes with the signal
195
+ nWithout = episodes − nWith episodes without it
196
+ qWith = (hits + 1) / (nWith + 2) outcome rate with the signal
197
+ qWithout = (outcomes − hits + 1) / (nWithout + 2) outcome rate without it
198
+ lift = (qWith − qWithout) / (1 − qWithout) if qWith ≥ qWithout, else (qWith − qWithout) / qWithout → in [−1, 1]
199
+ learned = gain × |w₀| × lift 0 until the signal has history
200
+ nEff = (nWith × nWithout) / (nWith + nWithout) 0 if either side is 0
201
+ effective = (k × w₀ + nEff × learned) / (k + nEff) k = priorEpisodes
202
+ ```
203
+
204
+ `w₀` is your initial weight, `gain` (default 2) caps the learned magnitude at `gain × |w₀|` for every lift option, and `priorEpisodes` (default 10) is how much contrast it takes for the user's own history to count as much as your guess. A signal's strength and your confidence in it are separate knobs. A signal is judged against the sessions in which it did *not* appear, so for an outcome that happens in 3% of sessions, a signal followed by it 30% of the time is strong evidence, not weak. And learning needs contrast: a signal that has never been absent has no counterfactual, so its weight stands until there are sessions without it. Nothing drifts after a single outcome.
205
+
206
+ The formula above is the default **normalized difference**. `learning.lift` is an optional
207
+ setting for advanced users who want to choose the learning scale. Choose another at construction with
208
+ `createState({ intents, learning: { lift: 'relative-lift' } })`. Omit `learning.lift` to keep the
209
+ current behavior, including when loading older saved states. The available names and formulas
210
+ are listed in [Episodes & learning](https://yuribenjamin.github.io/oracular/guide/concepts/episodes-and-learning#choosing-a-lift-formula).
211
+ Every choice produces a signed evidence score in [−1, 1], with independence at zero. Ratios use
212
+ `(R − 1) / (R + 1)`, relative/log lift use `x / (1 + |x|)`, and absolute/normalized differences
213
+ keep their scale. Learning uses `gain × |w₀| × score`; `oracular tune` uses the same score for
214
+ weights and intervals and reports raw lift separately when it differs from the score.
215
+
216
+ Learned counts never decay unless you set `learning.halfLife`.
217
+
218
+ ### Reveal only when confident
219
+
220
+ `shouldReveal` is true when p ≥ threshold **and** p − z·sd ≥ threshold, where sd is the Beta standard deviation and z defaults to 1.28 (a one-sided 90% bound). Thin evidence with a high mean does not fire; it takes a few consistent signals. That lower bound is a normal approximation; `bound: 'exact'` swaps in the Beta distribution's own quantile at the same level, which asks for a little more on thin evidence. After you `markRevealed`, the intent stays revealed for a `cooldown` (default: its half-life) so you don't fire on every event. The same cooldown runs after the intent's outcome event. `predict` reports both: `revealed` is true inside a reveal cooldown, and `suppressedUntil` is when the later of the two cooldowns ends, or null when `shouldReveal` is free to fire.
221
+
222
+ ### Outcomes reset the slate and pause reveals
223
+
224
+ When the outcome event arrives, the intent's evidence returns to the prior (α, β and contributions), while everything learned is kept. The user already upgraded; stop predicting that they will. Set `resetOnOutcome: false` for intents where the outcome can repeat inside one episode.
225
+
226
+ The outcome also starts the intent's `cooldown`: `shouldReveal` stays false until it ends, however strong the fresh evidence, so a user who just bought is not immediately pitched again. The time of the last outcome is kept on the state as `lastOutcomeAt`.
227
+
228
+ ### Every answer comes with its reasons
229
+
230
+ `explain` returns, per signal, how much evidence it currently holds and how much p would move without it, plus the initial, learned and effective weights and the hit/miss record. If a reveal looks wrong, you can see exactly why it fired.
231
+
215
232
  ## Using with React
216
233
 
217
- oracular ships no framework code; this is the userland shape. Load with your current config, observe, persist on change:
234
+ `@oracular/react` is the binding: a framework-free store, the `useIntent` hook, a provider, and adapters for `localStorage`, AsyncStorage and MMKV.
218
235
 
219
236
  ```tsx
220
- import { useEffect, useState } from 'react';
221
- import { createState, deserialize, markRevealed, observe, serialize, shouldReveal } from 'oracular';
237
+ import { createIntentStore, localStorageAdapter, useIntent } from '@oracular/react';
222
238
  import { config } from './intent-config';
223
239
 
224
- function load() {
225
- const saved = localStorage.getItem('oracular');
226
- return saved ? deserialize(saved, { config }) : createState(config); // always pass the config you ship now
227
- }
228
-
229
- export function useIntent() {
230
- const [state, setState] = useState(load);
231
- useEffect(() => localStorage.setItem('oracular', serialize(state)), [state]);
240
+ export const store = createIntentStore(config, { persist: localStorageAdapter() }); // once, for the app
232
241
 
233
- const track = (name: string) => setState((s) => observe(s, { name, at: Date.now() }));
234
- const reveal = (intent: string) => {
235
- const now = Date.now();
236
- if (!shouldReveal(state, intent, now)) return false;
237
- setState((s) => markRevealed(s, intent, now));
238
- return true;
239
- };
240
- return { track, reveal };
242
+ export function Pricing() {
243
+ const { track, reveal } = useIntent(store);
244
+ return <button onClick={() => { track('pricing.viewed'); if (reveal('upgrade')) openOffer(); }}>See plans</button>;
241
245
  }
242
246
  ```
243
247
 
244
- Add `endEpisode` on `pagehide` and you have the whole lifecycle. The same loop for Vue, React Native and Node is on the [frameworks page](https://yuribenjamin.github.io/oracular/guide/frameworks).
248
+ Reads go to the store's current state, so `track` then `reveal` in the same click sees the event. The hook loads the persisted state on mount, flushes the pending save when the page hides and on unmount, and never ends an episode on those events; set `episodeTimeout` and idle gaps end sessions on their own. The same loop for Vue, React Native and Node, and the hook written by hand, are on the [frameworks page](https://yuribenjamin.github.io/oracular/guide/frameworks); a runnable app is in [`examples/react-vite`](./examples/react-vite).
245
249
 
246
250
  ## API
247
251
 
@@ -277,7 +281,8 @@ interface EngineConfig {
277
281
  episodeTimeout?: number; // idle gap that ends an episode; off if omitted
278
282
  learning?: {
279
283
  halfLife?: number; // forgetting of learned counts; off if omitted
280
- gain?: number; // learned weight cap = gain × |initial|; default 2, at most 1e6
284
+ gain?: number; // learned weight multiplier; default 2, at most 1e6
285
+ lift?: LiftType; // default 'normalized-difference'; see Episodes & learning
281
286
  priorEpisodes?: number; // pseudo-count behind your weights; default 10, at most 1e6
282
287
  };
283
288
  reveal?: RevealOptions; // defaults for every intent
@@ -303,7 +308,7 @@ interface RevealOptions {
303
308
  }
304
309
  ```
305
310
 
306
- 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.
311
+ Reveal options resolve per call → intent → engine → defaults. Invalid config throws a `TypeError` or `RangeError` with the offending path in the message, including a config that cannot work: a reveal `threshold` of 0 or 1, a `z` above 10, or an outcome that is also one of the intent's signals while `resetOnOutcome` is on (the reset would wipe what the signal just added; set it to `false` to keep such a signal). A stored state whose config predates these checks still loads. `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.
307
312
 
308
313
  ### Results
309
314
 
@@ -334,7 +339,7 @@ interface SignalExplanation {
334
339
 
335
340
  ### Time
336
341
 
337
- `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.
342
+ `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: the evidence is held (no decay, no error) and the anchor moves to the new time, so decay resumes from there. One event stamped ahead of the clock therefore cannot hold an intent until the clock catches up, and a reveal or outcome stamped ahead of it counts as having happened now: its cooldown runs from the current time until the next event pins it there. When `at` is in milliseconds, `ms.days(7)`, `ms.hours(3)` and `ms.minutes(30)` spell the durations; they are plain multiplications.
338
343
 
339
344
  ## Persistence
340
345
 
@@ -411,7 +416,7 @@ npx oracular tune shadow.ndjson --config oracular.config.json
411
416
 
412
417
  ## Roadmap
413
418
 
414
- 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).
419
+ 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). `@oracular/react` ships the React binding; a Vue composable follows the same store. The full list, with the reasoning and what was declined, is on the [roadmap page](https://yuribenjamin.github.io/oracular/guide/roadmap).
415
420
 
416
421
  ## Contributing
417
422
 
package/dist/cli.js CHANGED
@@ -107,6 +107,14 @@ import {
107
107
  } from "oracular";
108
108
  var DEFAULT_THRESHOLDS = [0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9];
109
109
  var DEFAULT_THRESHOLD = 0.5;
110
+ function scoringWindow(given, cooldown, halfLife) {
111
+ if (given !== void 0) {
112
+ if (!(given > 0)) throw new RangeError("the scoring window must be greater than 0");
113
+ return { window: given, windowSource: "option" };
114
+ }
115
+ if (cooldown > 0) return { window: cooldown, windowSource: "cooldown" };
116
+ return { window: halfLife, windowSource: "halfLife" };
117
+ }
110
118
  function replay(entries, config, options = {}) {
111
119
  const base = createState(config);
112
120
  const normalized = base.config;
@@ -135,19 +143,15 @@ function replay(entries, config, options = {}) {
135
143
  const list = options.thresholds ?? [...DEFAULT_THRESHOLDS, configured];
136
144
  const thresholds = [...new Set(list)].sort((a, b) => a - b);
137
145
  const cooldown = predict(markRevealed(base, name, 0), name, 0).suppressedUntil ?? 0;
138
- const window = options.window ?? cooldown;
139
- if (!(window > 0)) {
140
- throw new RangeError(
141
- options.window === void 0 ? `intent ${JSON.stringify(name)} has a reveal cooldown of 0, so there is no default scoring window; pass --window (for example --window 7d)` : "the scoring window must be greater than 0"
142
- );
143
- }
146
+ const { window, windowSource } = scoringWindow(options.window, cooldown, intent.halfLife);
144
147
  return {
145
148
  name,
146
149
  outcome: intent.outcome,
147
150
  configured,
148
151
  thresholds,
149
152
  window,
150
- windowIsCooldown: options.window === void 0
153
+ windowSource,
154
+ windowIsCooldown: windowSource === "cooldown"
151
155
  };
152
156
  });
153
157
  const allThresholds = [...new Set(plans.flatMap((p) => p.thresholds))].sort((a, b) => a - b);
@@ -261,6 +265,7 @@ function replay(entries, config, options = {}) {
261
265
  intent: plan.name,
262
266
  outcome: plan.outcome,
263
267
  window: plan.window,
268
+ windowSource: plan.windowSource,
264
269
  windowIsCooldown: plan.windowIsCooldown,
265
270
  installs,
266
271
  thresholds: totals.map((s) => {
@@ -347,7 +352,7 @@ function table(headers, rows) {
347
352
  }
348
353
  function formatIntent(intent) {
349
354
  const window = formatDuration(intent.window);
350
- const source = intent.windowIsCooldown ? "the intent's reveal cooldown" : "as given";
355
+ const source = intent.windowSource === "cooldown" ? "the intent's reveal cooldown" : intent.windowSource === "halfLife" ? "the intent's half-life, since its reveal cooldown is 0" : "as given";
351
356
  const out = [];
352
357
  out.push(
353
358
  `${intent.intent}: outcome ${intent.outcome}, window ${window} (${source}), ${intent.installs} install${intent.installs === 1 ? "" : "s"}`
@@ -432,10 +437,39 @@ import {
432
437
  function laplace(successes, total) {
433
438
  return (successes + 1) / (total + 2);
434
439
  }
435
- function lift(q, b) {
440
+ function lift(q, b, type = "normalized-difference") {
441
+ if (type === "absolute-difference") return q - b;
442
+ if (type !== "normalized-difference") {
443
+ const rate = Math.max(Number.EPSILON, Math.min(1 - Number.EPSILON, q));
444
+ const base = Math.max(Number.EPSILON, Math.min(1 - Number.EPSILON, b));
445
+ switch (type) {
446
+ case "relative-lift":
447
+ return (rate - base) / base;
448
+ case "log-lift":
449
+ return Math.log(rate / base);
450
+ case "odds-ratio":
451
+ return rate / (1 - rate) / (base / (1 - base));
452
+ case "risk-ratio":
453
+ case "association-rule":
454
+ return rate / base;
455
+ }
456
+ }
436
457
  if (q >= b) return b >= 1 ? 0 : (q - b) / (1 - b);
437
458
  return b <= 0 ? 0 : (q - b) / b;
438
459
  }
460
+ function learningScore(value, type = "normalized-difference") {
461
+ switch (type) {
462
+ case "risk-ratio":
463
+ case "odds-ratio":
464
+ case "association-rule":
465
+ return (value - 1) / (value + 1);
466
+ case "relative-lift":
467
+ case "log-lift":
468
+ return value / (1 + Math.abs(value));
469
+ default:
470
+ return value;
471
+ }
472
+ }
439
473
 
440
474
  // cli/tune.ts
441
475
  var HOUR2 = 36e5;
@@ -488,7 +522,7 @@ function wilson(successes, n, z) {
488
522
  const half = z / (1 + z2 / n) * Math.sqrt(p * (1 - p) / n + z2 / (4 * n * n));
489
523
  return [Math.max(0, centre - half), Math.min(1, centre + half)];
490
524
  }
491
- function fitSignal(signal, current, tally, strength, priorEpisodes) {
525
+ function fitSignal(signal, current, tally, strength, priorEpisodes, liftType) {
492
526
  const hits = own(tally.hits, signal);
493
527
  const misses = own(tally.misses, signal);
494
528
  const nWith = hits + misses;
@@ -496,10 +530,13 @@ function fitSignal(signal, current, tally, strength, priorEpisodes) {
496
530
  const outcomesWithout = Math.min(without, Math.max(0, tally.outcomes - hits));
497
531
  const qWith = laplace(hits, nWith);
498
532
  const qWithout = laplace(outcomesWithout, without);
499
- const l = nWith === 0 ? 0 : lift(qWith, qWithout);
533
+ const baseline = liftType === "association-rule" ? (hits + outcomesWithout + 2) / (nWith + without + 4) : qWithout;
534
+ const noContrast = without === 0;
535
+ const rawLift = noContrast ? null : nWith === 0 ? 0 : lift(qWith, baseline, liftType);
536
+ const score = rawLift === null ? null : nWith === 0 ? 0 : learningScore(rawLift, liftType);
500
537
  const contrast = nWith + without === 0 ? 0 : nWith * without / (nWith + without);
501
538
  const [qLow, qHigh] = wilson(hits, nWith, Z90);
502
- const rounded = Math.round(strength * l * 100) / 100;
539
+ const rounded = score === null ? 0 : Math.round(strength * score * 100) / 100;
503
540
  const share = tally.episodes === 0 ? 0 : nWith / tally.episodes;
504
541
  const ubiquitous = nWith > UBIQUITY_SHARE * tally.episodes;
505
542
  return {
@@ -511,12 +548,13 @@ function fitSignal(signal, current, tally, strength, priorEpisodes) {
511
548
  outcomesWithout,
512
549
  qWith,
513
550
  qWithout,
514
- lift: l,
551
+ lift: rawLift,
552
+ score,
515
553
  weight: nWith === 0 || ubiquitous || rounded === 0 ? null : rounded,
516
554
  share,
517
555
  ubiquitous,
518
- low: strength * lift(qLow, qWithout),
519
- high: strength * lift(qHigh, qWithout),
556
+ low: noContrast ? null : strength * learningScore(lift(qLow, baseline, liftType), liftType),
557
+ high: noContrast ? null : strength * learningScore(lift(qHigh, baseline, liftType), liftType),
520
558
  contrast,
521
559
  thin: contrast < priorEpisodes
522
560
  };
@@ -593,6 +631,7 @@ function tune(entries, config, options = {}) {
593
631
  const normalized = base.config;
594
632
  const includeLive = options.includeLive === true;
595
633
  const priorEpisodes = normalized.learning?.priorEpisodes ?? DEFAULT_PRIOR_EPISODES;
634
+ const liftType = normalized.learning?.lift ?? "normalized-difference";
596
635
  const skipped = [];
597
636
  const names = options.intents ?? Object.keys(normalized.intents);
598
637
  for (const name of names) {
@@ -610,13 +649,8 @@ function tune(entries, config, options = {}) {
610
649
  const grouped = groupByInstall(entries);
611
650
  const windows = /* @__PURE__ */ new Map();
612
651
  for (const name of fitting) {
613
- const window = options.window ?? cooldownOf(base, name);
614
- if (!(window > 0)) {
615
- throw new RangeError(
616
- options.window === void 0 ? `intent ${JSON.stringify(name)} has a reveal cooldown of 0, so there is no scoring window for the half-life grid; pass --window (for example --window 7d)` : "the scoring window must be greater than 0"
617
- );
618
- }
619
- windows.set(name, window);
652
+ const intent = normalized.intents[name];
653
+ windows.set(name, scoringWindow(options.window, cooldownOf(base, name), intent.halfLife));
620
654
  }
621
655
  const eventNames = /* @__PURE__ */ new Set();
622
656
  for (const entry of entries) if (entry.kind === "event") eventNames.add(entry.name);
@@ -691,10 +725,17 @@ function tune(entries, config, options = {}) {
691
725
  const prior = laplace(tally.outcomes, tally.episodes);
692
726
  const strength = fitStrength(plan.installs, intent.priorStrength);
693
727
  const signals = Object.keys(intent.signals).map(
694
- (signal) => fitSignal(signal, intent.signals[signal], tally, strength.value, priorEpisodes)
728
+ (signal) => fitSignal(
729
+ signal,
730
+ intent.signals[signal],
731
+ tally,
732
+ strength.value,
733
+ priorEpisodes,
734
+ liftType
735
+ )
695
736
  );
696
737
  const candidates = plan.candidates.map(
697
- (signal) => fitSignal(signal, null, tally, strength.value, priorEpisodes)
738
+ (signal) => fitSignal(signal, null, tally, strength.value, priorEpisodes, liftType)
698
739
  );
699
740
  const roundedPrior = Math.round(prior * 1e4) / 1e4;
700
741
  const fittedIntent = {
@@ -705,7 +746,7 @@ function tune(entries, config, options = {}) {
705
746
  signals.filter((f) => f.weight !== null).map((f) => [f.signal, f.weight])
706
747
  )
707
748
  };
708
- const window = windows.get(name);
749
+ const { window, windowSource } = windows.get(name);
709
750
  const threshold = intent.reveal?.threshold ?? normalized.reveal?.threshold ?? DEFAULT_THRESHOLD;
710
751
  const grid = [.../* @__PURE__ */ new Set([...options.halfLives ?? DEFAULT_HALF_LIVES, intent.halfLife])].sort(
711
752
  (a, b) => a - b
@@ -732,6 +773,7 @@ function tune(entries, config, options = {}) {
732
773
  source: "kept",
733
774
  note: "kept: no point in the log could be scored, so the grid says nothing",
734
775
  window,
776
+ windowSource,
735
777
  curve,
736
778
  brierBaseline
737
779
  };
@@ -749,6 +791,7 @@ function tune(entries, config, options = {}) {
749
791
  source: "kept",
750
792
  note: `kept: the grid is flat, the best point (${formatDuration(chosen.halfLife)}, ${best.toFixed(4)}) improves on the current value (${atCurrent.brier.toFixed(4)}) by ${(gain * 100).toFixed(2)}%, less than ${FLAT_GRID_GAIN * 100}%${baseline}`,
751
793
  window,
794
+ windowSource,
752
795
  curve,
753
796
  brierBaseline
754
797
  };
@@ -759,6 +802,7 @@ function tune(entries, config, options = {}) {
759
802
  source: "grid",
760
803
  note: `lowest Brier score over the grid, ${best.toFixed(4)}${baseline}`,
761
804
  window,
805
+ windowSource,
762
806
  curve,
763
807
  brierBaseline
764
808
  };
@@ -816,12 +860,14 @@ function note(fit, priorEpisodes) {
816
860
  return notes.join("; ");
817
861
  }
818
862
  function signalTable(fits, priorEpisodes, withCurrent) {
863
+ const showScore = fits.some((fit) => fit.score !== fit.lift);
819
864
  const headers = [
820
865
  "signal",
821
866
  "weight",
822
867
  ...withCurrent ? ["was"] : [],
823
868
  "90% interval",
824
869
  "lift",
870
+ ...showScore ? ["score"] : [],
825
871
  "with: hits / misses",
826
872
  "without: outcomes / episodes",
827
873
  "note"
@@ -832,8 +878,9 @@ function signalTable(fits, priorEpisodes, withCurrent) {
832
878
  f.signal,
833
879
  num(f.weight, 2),
834
880
  ...withCurrent ? [num(f.current, 2)] : [],
835
- `${f.low.toFixed(2)} to ${f.high.toFixed(2)}`,
836
- signed(f.lift, 2),
881
+ f.low === null || f.high === null ? "no contrast" : `${f.low.toFixed(2)} to ${f.high.toFixed(2)}`,
882
+ f.lift === null ? "" : signed(f.lift, 2),
883
+ ...showScore ? [f.score === null ? "" : signed(f.score, 2)] : [],
837
884
  `${f.hits} / ${f.misses}`,
838
885
  `${f.outcomesWithout} / ${f.without}`,
839
886
  note(f, priorEpisodes)
@@ -880,7 +927,7 @@ function formatIntent2(fit) {
880
927
  }
881
928
  out.push("");
882
929
  out.push(
883
- `Half-life grid, Brier score against "the outcome follows within ${formatDuration(fit.halfLife.window)}" (lower is better): ${fit.halfLife.curve.map(
930
+ `Half-life grid, Brier score against "the outcome follows within ${formatDuration(fit.halfLife.window)}"${fit.halfLife.windowSource === "halfLife" ? " (the intent's half-life, since its reveal cooldown is 0)" : ""} (lower is better): ${fit.halfLife.curve.map(
884
931
  (p) => `${formatDuration(p.halfLife)} ${num(p.brier, 4)}${p.halfLife === fit.halfLife.value && p.brier !== null ? " *" : ""}`
885
932
  ).join(", ")}`
886
933
  );
@@ -1009,6 +1056,7 @@ Options:
1009
1056
  the signals
1010
1057
  --json print the report as JSON
1011
1058
  -h, --help show this help
1059
+ -v, --version print the version
1012
1060
 
1013
1061
  Only shadow periods are counted by default: an episode in which any record was live is left out,
1014
1062
  a log with live records gets a warning, and a log with nothing but live records is refused unless
@@ -1099,16 +1147,29 @@ function commonFlags(out) {
1099
1147
  },
1100
1148
  "-h": () => {
1101
1149
  out.help = true;
1150
+ },
1151
+ "--version": (_value, noValue) => {
1152
+ noValue();
1153
+ out.version = true;
1154
+ },
1155
+ "-v": () => {
1156
+ out.version = true;
1102
1157
  }
1103
1158
  };
1104
1159
  }
1105
1160
  function requireLogsAndConfig(out, command) {
1106
- if (out.help) return;
1161
+ if (out.help || out.version) return;
1107
1162
  if (out.logs.length === 0) throw new UsageError(`${command} needs at least one log file`);
1108
1163
  if (out.config === void 0) throw new UsageError(`${command} needs --config <file>`);
1109
1164
  }
1110
1165
  function parseReplayArgs(argv) {
1111
- const out = { logs: [], includeLive: false, json: false, help: false };
1166
+ const out = {
1167
+ logs: [],
1168
+ includeLive: false,
1169
+ json: false,
1170
+ help: false,
1171
+ version: false
1172
+ };
1112
1173
  parseFlags(
1113
1174
  argv,
1114
1175
  {
@@ -1128,6 +1189,7 @@ function parseTuneArgs(argv) {
1128
1189
  includeLive: false,
1129
1190
  json: false,
1130
1191
  help: false,
1192
+ version: false,
1131
1193
  allEvents: false
1132
1194
  };
1133
1195
  parseFlags(
@@ -1221,6 +1283,11 @@ async function replayCommand(argv, io) {
1221
1283
  io.stdout(REPLAY_HELP);
1222
1284
  return 0;
1223
1285
  }
1286
+ if (args.version) {
1287
+ io.stdout(`${version()}
1288
+ `);
1289
+ return 0;
1290
+ }
1224
1291
  const config = await loadCheckedConfig(args, io);
1225
1292
  const entries = readLogs(args.logs, io);
1226
1293
  checkModes(entries, args.includeLive, io, {
@@ -1248,6 +1315,11 @@ async function tuneCommand(argv, io) {
1248
1315
  io.stdout(TUNE_HELP);
1249
1316
  return 0;
1250
1317
  }
1318
+ if (args.version) {
1319
+ io.stdout(`${version()}
1320
+ `);
1321
+ return 0;
1322
+ }
1251
1323
  const config = await loadCheckedConfig(args, io);
1252
1324
  const entries = readLogs(args.logs, io);
1253
1325
  checkModes(entries, args.includeLive, io, {