oracular 0.2.1 → 0.4.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 +32 -0
- package/README.md +48 -11
- package/dist/cli.js +660 -0
- package/dist/index.cjs +193 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +282 -30
- package/dist/index.d.ts +282 -30
- package/dist/index.js +189 -18
- package/dist/index.js.map +1 -1
- package/package.json +15 -4
- package/schema/config.schema.json +141 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#22](https://github.com/Yuribenjamin/oracular/pull/22) [`0bb5841`](https://github.com/Yuribenjamin/oracular/commit/0bb5841fb93d2f16dcac74601f1ee218b70dda0e) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - Three small ergonomics for writing configs.
|
|
8
|
+
|
|
9
|
+
- **`validateConfig(config)`** runs the checks `createState` and `deserialize` run, without building a state, and returns the normalized copy they would store. It accepts `unknown`, so parsed JSON can be passed as it is; use it to fail fast at startup or in a test.
|
|
10
|
+
- **`ms`**: `ms.seconds(n)`, `ms.minutes(n)`, `ms.hours(n)`, `ms.days(n)` and `ms.weeks(n)` spell durations in milliseconds, so `halfLife: ms.days(7)` replaces `7 * 24 * 60 * 60 * 1000`. Plain multiplications, nothing else.
|
|
11
|
+
- **A JSON Schema for the config**, shipped in the package as `oracular/config.schema.json` and served at https://yuribenjamin.github.io/oracular/config.schema.json, for completion and checks on a config kept as JSON. `oracular replay --config file.json` now ignores a top-level `$schema` key, so a file can point at the schema and still be the config the engine runs.
|
|
12
|
+
|
|
13
|
+
## 0.3.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- [#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.
|
|
18
|
+
|
|
19
|
+
- [#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.
|
|
20
|
+
|
|
21
|
+
**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:
|
|
22
|
+
|
|
23
|
+
- **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`.
|
|
24
|
+
- **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.
|
|
25
|
+
|
|
26
|
+
`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)`.
|
|
27
|
+
|
|
28
|
+
- [#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.
|
|
29
|
+
|
|
30
|
+
- **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.
|
|
31
|
+
- **`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.
|
|
32
|
+
|
|
33
|
+
The new Shadow mode guide describes the workflow and the record format.
|
|
34
|
+
|
|
3
35
|
## 0.2.1
|
|
4
36
|
|
|
5
37
|
### Patch 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,14 @@ 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
|
-
|
|
253
|
+
validateConfig(config: unknown): EngineConfig // the checks createState runs, without a state; returns the normalized copy
|
|
254
|
+
ms.seconds(n), ms.minutes(n), ms.hours(n), ms.days(n), ms.weeks(n) // durations in milliseconds
|
|
255
|
+
STATE_VERSION // 2
|
|
256
|
+
RECORD_VERSION // 1
|
|
236
257
|
```
|
|
237
258
|
|
|
238
259
|
### Config
|
|
@@ -243,19 +264,20 @@ interface EngineConfig {
|
|
|
243
264
|
episodeTimeout?: number; // idle gap that ends an episode; off if omitted
|
|
244
265
|
learning?: {
|
|
245
266
|
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
|
|
267
|
+
gain?: number; // learned weight cap = gain × |initial|; default 2, at most 1e6
|
|
268
|
+
priorEpisodes?: number; // pseudo-count behind your weights; default 10, at most 1e6
|
|
248
269
|
};
|
|
249
270
|
reveal?: RevealOptions; // defaults for every intent
|
|
250
271
|
}
|
|
251
272
|
|
|
252
273
|
interface IntentConfig {
|
|
253
274
|
prior: number; // (0, 1)
|
|
254
|
-
priorStrength: number; // > 0
|
|
275
|
+
priorStrength: number; // > 0, at most 1e6
|
|
255
276
|
halfLife: number; // > 0
|
|
256
|
-
signals: Record<string, number>; // event name → weight, non-zero
|
|
277
|
+
signals: Record<string, number>; // event name → weight, non-zero, between −1e6 and 1e6
|
|
257
278
|
outcome?: string; // event that confirms the intent
|
|
258
279
|
resetOnOutcome?: boolean; // default true
|
|
280
|
+
saturate?: boolean; // repeats within an episode add less; default: episodeTimeout is set
|
|
259
281
|
reveal?: RevealOptions;
|
|
260
282
|
}
|
|
261
283
|
|
|
@@ -266,7 +288,7 @@ interface RevealOptions {
|
|
|
266
288
|
}
|
|
267
289
|
```
|
|
268
290
|
|
|
269
|
-
Reveal options resolve per call → intent → engine → defaults. Invalid config throws a `TypeError` or `RangeError` with the offending path in the message.
|
|
291
|
+
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.
|
|
270
292
|
|
|
271
293
|
### Results
|
|
272
294
|
|
|
@@ -289,7 +311,7 @@ interface SignalExplanation {
|
|
|
289
311
|
impact: number; // p − p without this signal
|
|
290
312
|
initialWeight: number;
|
|
291
313
|
learnedWeight: number;
|
|
292
|
-
effectiveWeight: number; // what the
|
|
314
|
+
effectiveWeight: number; // what the signal's first occurrence in an episode adds
|
|
293
315
|
hits: number;
|
|
294
316
|
misses: number;
|
|
295
317
|
}
|
|
@@ -297,7 +319,7 @@ interface SignalExplanation {
|
|
|
297
319
|
|
|
298
320
|
### Time
|
|
299
321
|
|
|
300
|
-
`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).
|
|
322
|
+
`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.
|
|
301
323
|
|
|
302
324
|
## Persistence
|
|
303
325
|
|
|
@@ -315,7 +337,7 @@ const state = deserialize(saved, { config });
|
|
|
315
337
|
|
|
316
338
|
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
339
|
|
|
318
|
-
|
|
340
|
+
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
341
|
|
|
320
342
|
```ts
|
|
321
343
|
deserialize(saved, {
|
|
@@ -341,6 +363,21 @@ explain(state, 'upgrade', Date.now());
|
|
|
341
363
|
|
|
342
364
|
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
365
|
|
|
366
|
+
## Shadow mode & replay
|
|
367
|
+
|
|
368
|
+
Run oracular silently first. Record what it decides, export the log, and measure a config offline before any reveal is shown:
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
state = observe(state, event);
|
|
372
|
+
log.append(JSON.stringify(createRecord(state, event, { installId, mode: 'shadow' })));
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
```sh
|
|
376
|
+
npx oracular replay shadow.ndjson --config oracular.config.json
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
`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.
|
|
380
|
+
|
|
344
381
|
## Design rules
|
|
345
382
|
|
|
346
383
|
- **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 +387,7 @@ Rows are sorted by the size of their impact. `limit.hit` has no history yet, so
|
|
|
350
387
|
|
|
351
388
|
## Roadmap
|
|
352
389
|
|
|
353
|
-
|
|
390
|
+
Now is 0.4, themed **learning that matches how people behave**: cross-episode attribution, so a session's signals can earn credit for 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. Then 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
391
|
|
|
355
392
|
## Contributing
|
|
356
393
|
|