oracular 0.8.0 → 0.9.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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#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.
8
+
9
+ **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.
10
+
11
+ ## 0.8.1
12
+
13
+ ### Patch Changes
14
+
15
+ - [#46](https://github.com/Yuribenjamin/oracular/pull/46) [`d796e99`](https://github.com/Yuribenjamin/oracular/commit/d796e990fed2b15d2fbf9afa1744782f9da41433) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **Release tags.** From this release, each release is tagged `oracular@<version>` instead of `v<version>`, and the GitHub release that goes with it is created per package. The reason is the repository's shape: it is now an npm workspace, with the root still the `oracular` package, so that a second package can be published from it; Changesets names tags that way in a workspace, and this release is the one that proves the pipeline end to end before anything depends on it. Nothing in the library changes. What also does not change: the existing `v0.1.0` to `v0.8.0` tags stay where they are, and there is no `v` alias for new releases. If you pin by tag, `oracular@0.8.1` is the first of the new form, and from here on the tag is the package name followed by the version.
16
+
3
17
  ## 0.8.0
4
18
 
5
19
  ### Minor 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**.
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
@@ -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
@@ -432,10 +432,39 @@ import {
432
432
  function laplace(successes, total) {
433
433
  return (successes + 1) / (total + 2);
434
434
  }
435
- function lift(q, b) {
435
+ function lift(q, b, type = "normalized-difference") {
436
+ if (type === "absolute-difference") return q - b;
437
+ if (type !== "normalized-difference") {
438
+ const rate = Math.max(Number.EPSILON, Math.min(1 - Number.EPSILON, q));
439
+ const base = Math.max(Number.EPSILON, Math.min(1 - Number.EPSILON, b));
440
+ switch (type) {
441
+ case "relative-lift":
442
+ return (rate - base) / base;
443
+ case "log-lift":
444
+ return Math.log(rate / base);
445
+ case "odds-ratio":
446
+ return rate / (1 - rate) / (base / (1 - base));
447
+ case "risk-ratio":
448
+ case "association-rule":
449
+ return rate / base;
450
+ }
451
+ }
436
452
  if (q >= b) return b >= 1 ? 0 : (q - b) / (1 - b);
437
453
  return b <= 0 ? 0 : (q - b) / b;
438
454
  }
455
+ function learningScore(value, type = "normalized-difference") {
456
+ switch (type) {
457
+ case "risk-ratio":
458
+ case "odds-ratio":
459
+ case "association-rule":
460
+ return (value - 1) / (value + 1);
461
+ case "relative-lift":
462
+ case "log-lift":
463
+ return value / (1 + Math.abs(value));
464
+ default:
465
+ return value;
466
+ }
467
+ }
439
468
 
440
469
  // cli/tune.ts
441
470
  var HOUR2 = 36e5;
@@ -488,7 +517,7 @@ function wilson(successes, n, z) {
488
517
  const half = z / (1 + z2 / n) * Math.sqrt(p * (1 - p) / n + z2 / (4 * n * n));
489
518
  return [Math.max(0, centre - half), Math.min(1, centre + half)];
490
519
  }
491
- function fitSignal(signal, current, tally, strength, priorEpisodes) {
520
+ function fitSignal(signal, current, tally, strength, priorEpisodes, liftType) {
492
521
  const hits = own(tally.hits, signal);
493
522
  const misses = own(tally.misses, signal);
494
523
  const nWith = hits + misses;
@@ -496,10 +525,12 @@ function fitSignal(signal, current, tally, strength, priorEpisodes) {
496
525
  const outcomesWithout = Math.min(without, Math.max(0, tally.outcomes - hits));
497
526
  const qWith = laplace(hits, nWith);
498
527
  const qWithout = laplace(outcomesWithout, without);
499
- const l = nWith === 0 ? 0 : lift(qWith, qWithout);
528
+ const baseline = liftType === "association-rule" ? (hits + outcomesWithout + 2) / (nWith + without + 4) : qWithout;
529
+ const rawLift = nWith === 0 ? 0 : lift(qWith, baseline, liftType);
530
+ const score = nWith === 0 ? 0 : learningScore(rawLift, liftType);
500
531
  const contrast = nWith + without === 0 ? 0 : nWith * without / (nWith + without);
501
532
  const [qLow, qHigh] = wilson(hits, nWith, Z90);
502
- const rounded = Math.round(strength * l * 100) / 100;
533
+ const rounded = Math.round(strength * score * 100) / 100;
503
534
  const share = tally.episodes === 0 ? 0 : nWith / tally.episodes;
504
535
  const ubiquitous = nWith > UBIQUITY_SHARE * tally.episodes;
505
536
  return {
@@ -511,12 +542,13 @@ function fitSignal(signal, current, tally, strength, priorEpisodes) {
511
542
  outcomesWithout,
512
543
  qWith,
513
544
  qWithout,
514
- lift: l,
545
+ lift: rawLift,
546
+ score,
515
547
  weight: nWith === 0 || ubiquitous || rounded === 0 ? null : rounded,
516
548
  share,
517
549
  ubiquitous,
518
- low: strength * lift(qLow, qWithout),
519
- high: strength * lift(qHigh, qWithout),
550
+ low: strength * learningScore(lift(qLow, baseline, liftType), liftType),
551
+ high: strength * learningScore(lift(qHigh, baseline, liftType), liftType),
520
552
  contrast,
521
553
  thin: contrast < priorEpisodes
522
554
  };
@@ -593,6 +625,7 @@ function tune(entries, config, options = {}) {
593
625
  const normalized = base.config;
594
626
  const includeLive = options.includeLive === true;
595
627
  const priorEpisodes = normalized.learning?.priorEpisodes ?? DEFAULT_PRIOR_EPISODES;
628
+ const liftType = normalized.learning?.lift ?? "normalized-difference";
596
629
  const skipped = [];
597
630
  const names = options.intents ?? Object.keys(normalized.intents);
598
631
  for (const name of names) {
@@ -691,10 +724,17 @@ function tune(entries, config, options = {}) {
691
724
  const prior = laplace(tally.outcomes, tally.episodes);
692
725
  const strength = fitStrength(plan.installs, intent.priorStrength);
693
726
  const signals = Object.keys(intent.signals).map(
694
- (signal) => fitSignal(signal, intent.signals[signal], tally, strength.value, priorEpisodes)
727
+ (signal) => fitSignal(
728
+ signal,
729
+ intent.signals[signal],
730
+ tally,
731
+ strength.value,
732
+ priorEpisodes,
733
+ liftType
734
+ )
695
735
  );
696
736
  const candidates = plan.candidates.map(
697
- (signal) => fitSignal(signal, null, tally, strength.value, priorEpisodes)
737
+ (signal) => fitSignal(signal, null, tally, strength.value, priorEpisodes, liftType)
698
738
  );
699
739
  const roundedPrior = Math.round(prior * 1e4) / 1e4;
700
740
  const fittedIntent = {
@@ -816,12 +856,14 @@ function note(fit, priorEpisodes) {
816
856
  return notes.join("; ");
817
857
  }
818
858
  function signalTable(fits, priorEpisodes, withCurrent) {
859
+ const showScore = fits.some((fit) => fit.score !== fit.lift);
819
860
  const headers = [
820
861
  "signal",
821
862
  "weight",
822
863
  ...withCurrent ? ["was"] : [],
823
864
  "90% interval",
824
865
  "lift",
866
+ ...showScore ? ["score"] : [],
825
867
  "with: hits / misses",
826
868
  "without: outcomes / episodes",
827
869
  "note"
@@ -834,6 +876,7 @@ function signalTable(fits, priorEpisodes, withCurrent) {
834
876
  ...withCurrent ? [num(f.current, 2)] : [],
835
877
  `${f.low.toFixed(2)} to ${f.high.toFixed(2)}`,
836
878
  signed(f.lift, 2),
879
+ ...showScore ? [signed(f.score, 2)] : [],
837
880
  `${f.hits} / ${f.misses}`,
838
881
  `${f.outcomesWithout} / ${f.without}`,
839
882
  note(f, priorEpisodes)
package/dist/index.cjs CHANGED
@@ -82,10 +82,39 @@ function repeatFactor(n) {
82
82
  function laplace(successes, total) {
83
83
  return (successes + 1) / (total + 2);
84
84
  }
85
- function lift(q, b) {
85
+ function lift(q, b, type = "normalized-difference") {
86
+ if (type === "absolute-difference") return q - b;
87
+ if (type !== "normalized-difference") {
88
+ const rate = Math.max(Number.EPSILON, Math.min(1 - Number.EPSILON, q));
89
+ const base = Math.max(Number.EPSILON, Math.min(1 - Number.EPSILON, b));
90
+ switch (type) {
91
+ case "relative-lift":
92
+ return (rate - base) / base;
93
+ case "log-lift":
94
+ return Math.log(rate / base);
95
+ case "odds-ratio":
96
+ return rate / (1 - rate) / (base / (1 - base));
97
+ case "risk-ratio":
98
+ case "association-rule":
99
+ return rate / base;
100
+ }
101
+ }
86
102
  if (q >= b) return b >= 1 ? 0 : (q - b) / (1 - b);
87
103
  return b <= 0 ? 0 : (q - b) / b;
88
104
  }
105
+ function learningScore(value, type = "normalized-difference") {
106
+ switch (type) {
107
+ case "risk-ratio":
108
+ case "odds-ratio":
109
+ case "association-rule":
110
+ return (value - 1) / (value + 1);
111
+ case "relative-lift":
112
+ case "log-lift":
113
+ return value / (1 + Math.abs(value));
114
+ default:
115
+ return value;
116
+ }
117
+ }
89
118
 
90
119
  // src/util.ts
91
120
  function isRecord(value) {
@@ -131,7 +160,9 @@ function signalWeights(state, intent, learning, signal) {
131
160
  const nWithout = Math.max(0, totalEpisodes - nWith);
132
161
  const outcomesWithout = Math.min(nWithout, Math.max(0, state.episodes.hits - record2.hits));
133
162
  const qWithout = laplace(outcomesWithout, nWithout);
134
- const learned = nWith === 0 ? 0 : learning.gain * Math.abs(initial) * lift(qWith, qWithout);
163
+ const baseline = learning.lift === "association-rule" ? (record2.hits + outcomesWithout + 2) / (nWith + nWithout + 4) : qWithout;
164
+ const score = learningScore(lift(qWith, baseline, learning.lift), learning.lift);
165
+ const learned = nWith === 0 ? 0 : learning.gain * Math.abs(initial) * score;
135
166
  const nEff = nWith + nWithout === 0 ? 0 : nWith * nWithout / (nWith + nWithout);
136
167
  const k = learning.priorEpisodes;
137
168
  const effective = (k * initial + nEff * learned) / (k + nEff);
@@ -257,7 +288,16 @@ var INTENT_KEYS = [
257
288
  "reveal"
258
289
  ];
259
290
  var REVEAL_KEYS = ["threshold", "z", "cooldown", "bound"];
260
- var LEARNING_KEYS = ["halfLife", "gain", "priorEpisodes"];
291
+ var LEARNING_KEYS = ["halfLife", "gain", "priorEpisodes", "lift"];
292
+ var LIFT_TYPES = [
293
+ "absolute-difference",
294
+ "relative-lift",
295
+ "risk-ratio",
296
+ "log-lift",
297
+ "odds-ratio",
298
+ "normalized-difference",
299
+ "association-rule"
300
+ ];
261
301
  var EXPLAIN_KEYS = ["nonZero", "limit"];
262
302
  function typeError(path, message) {
263
303
  throw new TypeError(`oracular: ${path} ${message}`);
@@ -342,6 +382,12 @@ function normalizeLearning(value, path) {
342
382
  const record2 = expectRecord(value, path);
343
383
  expectKeys(record2, LEARNING_KEYS, path);
344
384
  const out = {};
385
+ if (record2.lift !== void 0) {
386
+ if (!LIFT_TYPES.includes(record2.lift)) {
387
+ typeError(`${path}.lift`, `must be one of: ${LIFT_TYPES.join(", ")}`);
388
+ }
389
+ out.lift = record2.lift;
390
+ }
345
391
  if (record2.halfLife !== void 0)
346
392
  out.halfLife = expectPositive(record2.halfLife, `${path}.halfLife`);
347
393
  if (record2.gain !== void 0) out.gain = expectBoundedPositive(record2.gain, `${path}.gain`);
@@ -462,6 +508,7 @@ function resolveReveal(config, intent, opts, normalized = false) {
462
508
  }
463
509
  function resolveLearning(config) {
464
510
  return {
511
+ lift: config.learning?.lift ?? "normalized-difference",
465
512
  halfLife: config.learning?.halfLife ?? null,
466
513
  gain: config.learning?.gain ?? DEFAULT_GAIN,
467
514
  priorEpisodes: config.learning?.priorEpisodes ?? DEFAULT_PRIOR_EPISODES