oracular 0.2.0 → 0.3.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 +28 -0
- package/README.md +44 -9
- package/dist/cli.js +656 -0
- package/dist/index.cjs +177 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +223 -30
- package/dist/index.d.ts +223 -30
- package/dist/index.js +175 -18
- package/dist/index.js.map +1 -1
- package/package.json +14 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#18](https://github.com/Yuribenjamin/oracular/pull/18) [`d2d95a4`](https://github.com/Yuribenjamin/oracular/commit/d2d95a4f3409280d999500f42851772cf2b6a767) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - `deserialize` now rejects, with a `RangeError`, a stored state whose numbers are each finite but too large to compute with. Before, a contribution or episode count near 1e308 (a corrupted or hand-edited state) loaded and then answered NaN. Config validation now caps signal weights, `priorStrength`, `learning.gain` and `learning.priorEpisodes` at 1e6 in magnitude (`RangeError`), because larger values could overflow the same arithmetic in `createState` too; no real config comes close. A fuzz test over states saved by every published version (0.1.0 to 0.2.1) guards both: corrupt, truncated and tampered input either throws a `SyntaxError`, `TypeError` or `RangeError`, or loads a state whose estimates stay finite.
|
|
8
|
+
|
|
9
|
+
- [#18](https://github.com/Yuribenjamin/oracular/pull/18) [`d2d95a4`](https://github.com/Yuribenjamin/oracular/commit/d2d95a4f3409280d999500f42851772cf2b6a767) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - Repeats of a signal within one episode now saturate when the config sets `episodeTimeout`. The n-th occurrence adds `w × log2(1 + 1/n)`, so n occurrences add `w × log2(1 + n)`: ten `pricing.viewed` are worth 3.46 weights, not ten. The count restarts when the episode ends or the intent's outcome arrives, so the same signal in a new session still counts in full. Learning is unchanged: hits and misses still count once per episode.
|
|
10
|
+
|
|
11
|
+
**This changes when reveals fire.** An intent whose evidence came from one signal repeated in one session now needs more. The quickstart's upgrade offer needs 32 `pricing.viewed` in one session, where it used to need 6; six in separate sessions still fire it. Two things to check after upgrading:
|
|
12
|
+
|
|
13
|
+
- **Saturation is on by default only when `episodeTimeout` is set.** Without an idle timeout, episodes end only when your app calls `endEpisode`, and an app that never does would otherwise discount a signal it saw yesterday; such configs keep their 0.2 behaviour. If your app ends every episode itself, set `saturate: true` on the intents that should saturate. The quickstart now sets `episodeTimeout: 30 * 60 * 1000`.
|
|
14
|
+
- **Opt out where repetition is the evidence.** Set the new intent option `saturate: false` for intents where repeating a signal is itself the evidence, such as a user stuck on the same step. Every occurrence then adds its full weight, exactly as in 0.2.
|
|
15
|
+
|
|
16
|
+
`STATE_VERSION` is now 2: the open episode also records how often each signal occurred (`EpisodeIntentRecord.counts`). `deserialize` migrates states saved by oracular 0.1.0 to 0.2.x on its own, keeping every estimate exactly as it was; your `migrate` function is only called for a version the library does not know. A version 2 state cannot be read by 0.2.x, so if you might roll back, catch the `RangeError` and fall back to `createState(config)`.
|
|
17
|
+
|
|
18
|
+
- [#18](https://github.com/Yuribenjamin/oracular/pull/18) [`d2d95a4`](https://github.com/Yuribenjamin/oracular/commit/d2d95a4f3409280d999500f42851772cf2b6a767) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - Shadow mode is now a supported workflow: run oracular silently, record what it decides, and measure a config offline before any reveal is shown.
|
|
19
|
+
|
|
20
|
+
- **Recorder format and helpers.** `createRecord(state, event, { installId, mode })` returns one line of a versioned NDJSON log (`RECORD_VERSION` 1). Each line holds the event (`name`, `at`; `props` are never recorded) and, as a separate field, what the engine decided for every intent: `p`, `lower`, `shouldReveal`, `suppressedUntil`, the top signals by impact and the effective weights. `createEndRecord(at, options)` records an `endEpisode` call. Every record says whether reveals were `shadow` or `live`. The install id comes from your app (random, generated once, stored); the helpers are pure and generate nothing.
|
|
21
|
+
- **`npx oracular replay <log>... --config <file>`.** Replays the logged events through the engine under any config, and reports per intent and threshold: precision, recall, median lead time from reveal to outcome, reveals per user, and a reliability table with a Brier score. Only shadow periods are scored: a log with live records gets a warning, and a log of nothing but live records is refused unless `--include-live` is passed. The command ships as the package's `bin`, still with zero runtime dependencies.
|
|
22
|
+
|
|
23
|
+
The new Shadow mode guide describes the workflow and the record format.
|
|
24
|
+
|
|
25
|
+
## 0.2.1
|
|
26
|
+
|
|
27
|
+
### Patch Changes
|
|
28
|
+
|
|
29
|
+
- [#10](https://github.com/Yuribenjamin/oracular/pull/10) [`04fb9aa`](https://github.com/Yuribenjamin/oracular/commit/04fb9aad298287fd280a28608f175290db691208) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - Fix the `exports` map: the `default` condition now points at the CommonJS build and stays last, so resolvers without an `import`/`require` condition (Jest's jsdom environment, some bundlers) get a file they can load. ESM-first resolvers still get the ESM build.
|
|
30
|
+
|
|
3
31
|
## 0.2.0
|
|
4
32
|
|
|
5
33
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
<a href="https://bundlephobia.com/package/oracular"><img src="https://img.shields.io/bundlephobia/minzip/oracular" alt="bundle size"></a>
|
|
13
13
|
<a href="https://www.npmjs.com/package/oracular"><img src="https://img.shields.io/npm/dm/oracular" alt="npm downloads"></a>
|
|
14
14
|
<a href="./LICENSE"><img src="https://img.shields.io/npm/l/oracular" alt="MIT license"></a>
|
|
15
|
+
<a href="https://www.bestpractices.dev/projects/15643"><img src="https://www.bestpractices.dev/projects/15643/badge" alt="OpenSSF Best Practices"></a>
|
|
16
|
+
<a href="https://scorecard.dev/viewer/?uri=github.com/Yuribenjamin/oracular"><img src="https://api.scorecard.dev/projects/github.com/Yuribenjamin/oracular/badge" alt="OpenSSF Scorecard"></a>
|
|
15
17
|
</p>
|
|
16
18
|
|
|
17
19
|
<p align="center">
|
|
@@ -43,6 +45,7 @@ Install with `npm install oracular`, then:
|
|
|
43
45
|
```ts
|
|
44
46
|
import { createState, markRevealed, observe, shouldReveal } from 'oracular';
|
|
45
47
|
let state = createState({
|
|
48
|
+
episodeTimeout: 30 * 60 * 1000, // 30 idle minutes end a session
|
|
46
49
|
intents: {
|
|
47
50
|
upgrade: {
|
|
48
51
|
prior: 0.05, priorStrength: 10, halfLife: 7 * 24 * 60 * 60 * 1000, // one week, in ms
|
|
@@ -57,7 +60,7 @@ if (shouldReveal(state, 'upgrade', Date.now())) {
|
|
|
57
60
|
}
|
|
58
61
|
```
|
|
59
62
|
|
|
60
|
-
With these numbers the offer fires after roughly six strong signals inside a week (p must clear 0.5 with a 90% lower bound);
|
|
63
|
+
With these numbers the offer fires after roughly six strong signals across separate sessions inside a week (p must clear 0.5 with a 90% lower bound); repeats within one session count for less. Lower `priorStrength` or raise the weights to fire sooner.
|
|
61
64
|
|
|
62
65
|
That block is copied verbatim into the test suite, so it always runs.
|
|
63
66
|
|
|
@@ -70,6 +73,7 @@ That block is copied verbatim into the test suite, so it always runs.
|
|
|
70
73
|
- [API](#api)
|
|
71
74
|
- [Persistence](#persistence)
|
|
72
75
|
- [Explainability](#explainability)
|
|
76
|
+
- [Shadow mode & replay](#shadow-mode--replay)
|
|
73
77
|
- [Design rules](#design-rules)
|
|
74
78
|
- [Roadmap](#roadmap)
|
|
75
79
|
- [Contributing](#contributing)
|
|
@@ -86,6 +90,17 @@ You declare intents. Each has a `prior` probability and a `priorStrength` (how m
|
|
|
86
90
|
|
|
87
91
|
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.
|
|
88
92
|
|
|
93
|
+
### Repeats within a session count for less
|
|
94
|
+
|
|
95
|
+
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:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
n-th occurrence adds w × log2(1 + 1/n) 1, 0.58, 0.42, 0.32 … of the weight
|
|
99
|
+
n occurrences add w × log2(1 + n) every doubling adds one more weight; ten add 3.46, not 10
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
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.
|
|
103
|
+
|
|
89
104
|
### Evidence decays toward the prior
|
|
90
105
|
|
|
91
106
|
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.
|
|
@@ -141,6 +156,7 @@ const config = {
|
|
|
141
156
|
halfLife: 10 * 60 * 1000, // ten minutes: being stuck is a short-lived state
|
|
142
157
|
signals: { 'editor.undo': 1, 'search.empty': 2, 'help.opened': 3, 'task.completed': -4 },
|
|
143
158
|
outcome: 'help.article.read',
|
|
159
|
+
saturate: false, // the fifth undo in a row is more evidence, not less
|
|
144
160
|
reveal: { threshold: 0.6, cooldown: 60 * 60 * 1000 },
|
|
145
161
|
},
|
|
146
162
|
},
|
|
@@ -230,9 +246,12 @@ explain(state, intent: string, at: number, opts?: { nonZero?: boolean; limit?: n
|
|
|
230
246
|
evaluate(state, intent: string, at: number, opts?): { prediction, reveal, explanation } // all three, one decay pass
|
|
231
247
|
evidenceToReveal(intentConfig, opts?): number // pseudo-counts at which shouldReveal first becomes true
|
|
232
248
|
episodeClosed(prev, next): boolean // did an episode close between two states?
|
|
249
|
+
createRecord(state, event, { installId, mode }): EventRecord // one recorder line: the event and what was decided
|
|
250
|
+
createEndRecord(at, { installId, mode }): EndRecord // one recorder line: the episode ended
|
|
233
251
|
serialize(state): string
|
|
234
252
|
deserialize(json: string, opts?: { config?: EngineConfig; migrate?: Migrate }): EngineState
|
|
235
|
-
STATE_VERSION //
|
|
253
|
+
STATE_VERSION // 2
|
|
254
|
+
RECORD_VERSION // 1
|
|
236
255
|
```
|
|
237
256
|
|
|
238
257
|
### Config
|
|
@@ -243,19 +262,20 @@ interface EngineConfig {
|
|
|
243
262
|
episodeTimeout?: number; // idle gap that ends an episode; off if omitted
|
|
244
263
|
learning?: {
|
|
245
264
|
halfLife?: number; // forgetting of learned counts; off if omitted
|
|
246
|
-
gain?: number; // learned weight cap = gain × |initial|; default 2
|
|
247
|
-
priorEpisodes?: number; // pseudo-count behind your weights; default 10
|
|
265
|
+
gain?: number; // learned weight cap = gain × |initial|; default 2, at most 1e6
|
|
266
|
+
priorEpisodes?: number; // pseudo-count behind your weights; default 10, at most 1e6
|
|
248
267
|
};
|
|
249
268
|
reveal?: RevealOptions; // defaults for every intent
|
|
250
269
|
}
|
|
251
270
|
|
|
252
271
|
interface IntentConfig {
|
|
253
272
|
prior: number; // (0, 1)
|
|
254
|
-
priorStrength: number; // > 0
|
|
273
|
+
priorStrength: number; // > 0, at most 1e6
|
|
255
274
|
halfLife: number; // > 0
|
|
256
|
-
signals: Record<string, number>; // event name → weight, non-zero
|
|
275
|
+
signals: Record<string, number>; // event name → weight, non-zero, between −1e6 and 1e6
|
|
257
276
|
outcome?: string; // event that confirms the intent
|
|
258
277
|
resetOnOutcome?: boolean; // default true
|
|
278
|
+
saturate?: boolean; // repeats within an episode add less; default: episodeTimeout is set
|
|
259
279
|
reveal?: RevealOptions;
|
|
260
280
|
}
|
|
261
281
|
|
|
@@ -289,7 +309,7 @@ interface SignalExplanation {
|
|
|
289
309
|
impact: number; // p − p without this signal
|
|
290
310
|
initialWeight: number;
|
|
291
311
|
learnedWeight: number;
|
|
292
|
-
effectiveWeight: number; // what the
|
|
312
|
+
effectiveWeight: number; // what the signal's first occurrence in an episode adds
|
|
293
313
|
hits: number;
|
|
294
314
|
misses: number;
|
|
295
315
|
}
|
|
@@ -315,7 +335,7 @@ const state = deserialize(saved, { config });
|
|
|
315
335
|
|
|
316
336
|
The new config is validated and swapped in. Intents and signals you no longer declare are dropped (α and β are recomputed from what remains), newly declared intents start at their prior, and everything learned about signals that still exist is kept.
|
|
317
337
|
|
|
318
|
-
|
|
338
|
+
States saved by an older version of this library are upgraded on load: `deserialize` migrates the state shape written by oracular 0.1.0 to 0.2.x (`STATE_VERSION` 1) to the current one on its own. A `migrate` function is consulted only for a version the library does not know, such as a state written by a newer oracular before a rollback; without one, that throws a `RangeError`:
|
|
319
339
|
|
|
320
340
|
```ts
|
|
321
341
|
deserialize(saved, {
|
|
@@ -341,6 +361,21 @@ explain(state, 'upgrade', Date.now());
|
|
|
341
361
|
|
|
342
362
|
Rows are sorted by the size of their impact. `limit.hit` has no history yet, so its effective weight is exactly the initial one. `pricing.viewed` was followed by an upgrade in six of its seven sessions while the three sessions without it saw one upgrade, so it learned a weight above the initial 3. `export.clicked` appeared in every session: it has no counterfactual, so whatever its hit rate, its weight stands at exactly the initial 1 until a session happens without it. See [Episodes & learning](https://yuribenjamin.github.io/oracular/guide/concepts/episodes-and-learning) for why learning needs contrast.
|
|
343
363
|
|
|
364
|
+
## Shadow mode & replay
|
|
365
|
+
|
|
366
|
+
Run oracular silently first. Record what it decides, export the log, and measure a config offline before any reveal is shown:
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
state = observe(state, event);
|
|
370
|
+
log.append(JSON.stringify(createRecord(state, event, { installId, mode: 'shadow' })));
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
```sh
|
|
374
|
+
npx oracular replay shadow.ndjson --config oracular.config.json
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
`replay` reports precision, recall, lead time and reveals per user for every intent and threshold, and a reliability table with a Brier score. It scores only the periods in which reveals were not shown, because once they are, the reveal itself causes outcomes. `installId` is a random id your app generates once and stores; it is never derived from the device. The log holds event names and times, never `props`, and leaves the device only if your app sends it. The [Shadow mode guide](https://yuribenjamin.github.io/oracular/guide/shadow-mode) has the workflow and the record format.
|
|
378
|
+
|
|
344
379
|
## Design rules
|
|
345
380
|
|
|
346
381
|
- **Pure.** Inputs are never mutated; the same input always gives the same output. Internally, α and β are always the prior plus the sum of per-signal contributions, so `explain` adds up.
|
|
@@ -350,7 +385,7 @@ Rows are sorted by the size of their impact. `limit.hit` has no history yet, so
|
|
|
350
385
|
|
|
351
386
|
## Roadmap
|
|
352
387
|
|
|
353
|
-
|
|
388
|
+
Next is 0.3, themed **proof**: a recorder format for shadow mode, `npx oracular replay` to measure a config offline, per-episode saturation, and state robustness tests. Then cross-episode attribution and `tune` in 0.4, and a written stability policy before 1.0. 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).
|
|
354
389
|
|
|
355
390
|
## Contributing
|
|
356
391
|
|