oracular 0.8.1 → 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 +8 -0
- package/README.md +88 -83
- package/dist/cli.js +52 -9
- package/dist/index.cjs +50 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +14 -3
- package/dist/index.d.ts +14 -3
- package/dist/index.js +50 -3
- package/dist/index.js.map +1 -1
- package/package.json +10 -3
- package/schema/config.schema.json +14 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
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
|
+
|
|
3
11
|
## 0.8.1
|
|
4
12
|
|
|
5
13
|
### 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**.
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
const reveal = (
|
|
235
|
-
|
|
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
|
-
|
|
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
|
|
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).
|
|
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
|
|
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 *
|
|
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:
|
|
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,
|
|
519
|
-
high: strength * lift(qHigh,
|
|
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(
|
|
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
|
|
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
|