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 +26 -0
- package/README.md +90 -85
- package/dist/cli.js +102 -30
- package/dist/index.cjs +98 -30
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +30 -10
- package/dist/index.d.ts +30 -10
- package/dist/index.js +98 -30
- package/dist/index.js.map +1 -1
- package/dist/presets.cjs +11 -4
- package/dist/presets.cjs.map +1 -1
- package/dist/presets.d.cts +0 -17
- package/dist/presets.d.ts +0 -17
- package/dist/presets.js +11 -4
- package/dist/presets.js.map +1 -1
- package/package.json +10 -3
- package/schema/config.schema.json +18 -4
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
|
|
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
|
|
@@ -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).
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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 *
|
|
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:
|
|
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,
|
|
519
|
-
high: strength * lift(qHigh,
|
|
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
|
|
614
|
-
|
|
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(
|
|
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 = {
|
|
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, {
|