oracular 0.0.1 → 0.1.1

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 ADDED
@@ -0,0 +1,32 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#3](https://github.com/Yuribenjamin/oracular/pull/3) [`b5670db`](https://github.com/Yuribenjamin/oracular/commit/b5670db087d4e63a7a6c74366c9b751b1dd05e9b) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - No library changes. First version published through the automated release pipeline, with npm provenance attestation.
8
+
9
+ ## 0.1.0
10
+
11
+ First release. Published as [v0.1.0](https://github.com/Yuribenjamin/oracular/releases/tag/v0.1.0).
12
+
13
+ ### Minor Changes
14
+
15
+ - `createState`, `observe`, `endEpisode`: intents tracked as Beta(α, β) with per-signal
16
+ weights, exponential decay back toward the prior, and episode bookkeeping (explicit end or
17
+ `episodeTimeout`).
18
+ - On-device learning: every signal seen in an episode gets a hit when the intent's outcome
19
+ arrives and a miss when the episode ends without it. The learned weight is centered on the
20
+ intent's own base rate and blended with the developer's weight by `learning.priorEpisodes`.
21
+ - `predict`, `shouldReveal`, `markRevealed`: reveal only when p and the lower confidence bound
22
+ p − z·sd both clear the threshold, with a cooldown after each reveal.
23
+ - `resetOnOutcome` (default on): the outcome event resets an intent's evidence to the prior
24
+ while keeping what was learned.
25
+ - Outcome cooldown: the outcome event is recorded as `lastOutcomeAt` and `shouldReveal` stays
26
+ false for the intent's cooldown afterwards; `predict` reports `suppressedUntil`, the end of
27
+ whichever cooldown (reveal or outcome) is still running.
28
+ - `explain`: per-signal contribution, impact on p, initial, learned and effective weights.
29
+ - `serialize` / `deserialize` with a `version` field, a `migrate` hook, and a `config` option
30
+ that swaps in the app's current config and prunes signals and intents no longer declared.
31
+ - ESM and CommonJS builds with type declarations; runs unchanged in browsers, Node 18+ and
32
+ React Native.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ibrahim Ragab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,355 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/Yuribenjamin/oracular/main/assets/logo.svg" width="128" height="128" alt="A tray of cookies">
3
+ </p>
4
+
5
+ <h1 align="center">Your app learns when the user is ready. Nothing leaves the device.</h1>
6
+
7
+ <p align="center"><em>Named for the Oracle, who knew what you'd choose before you did.</em></p>
8
+
9
+ <p align="center">
10
+ <a href="https://www.npmjs.com/package/oracular"><img src="https://img.shields.io/npm/v/oracular" alt="npm version"></a>
11
+ <a href="https://github.com/Yuribenjamin/oracular/actions/workflows/ci.yml"><img src="https://github.com/Yuribenjamin/oracular/actions/workflows/ci.yml/badge.svg" alt="CI status"></a>
12
+ <a href="https://bundlephobia.com/package/oracular"><img src="https://img.shields.io/bundlephobia/minzip/oracular" alt="bundle size"></a>
13
+ <a href="https://www.npmjs.com/package/oracular"><img src="https://img.shields.io/npm/dm/oracular" alt="npm downloads"></a>
14
+ <a href="./LICENSE"><img src="https://img.shields.io/npm/l/oracular" alt="MIT license"></a>
15
+ </p>
16
+
17
+ <p align="center">
18
+ <strong>Documentation:</strong> <a href="https://yuribenjamin.github.io/oracular/">yuribenjamin.github.io/oracular</a>
19
+ · <a href="https://yuribenjamin.github.io/oracular/guide/introduction">Guide</a>
20
+ · <a href="https://yuribenjamin.github.io/oracular/api/">API</a>
21
+ · <a href="https://yuribenjamin.github.io/oracular/playground">Playground</a>
22
+ </p>
23
+
24
+ **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.
25
+
26
+ It never touches the network, storage, DOM, timers or globals. Your app owns persistence and side effects; the library owns the math.
27
+
28
+ ## Why oracular
29
+
30
+ | | oracular | Hosted analytics SDKs | Rule engines |
31
+ | ---------------------------- | ----------------------------------------------------- | -------------------------------------------- | ----------------------------------------- |
32
+ | **Where scoring runs** | On the device, inside your app | On the vendor's servers | In your code, rule by rule |
33
+ | **Data leaving the device** | None; the library has no I/O | Events are sent to the vendor | None, unless you add it |
34
+ | **Learns per user** | Yes, from each user's own outcomes, on their device | Typically across users, server-side | No; rules are static until you edit them |
35
+ | **Explainable** | Per-signal contribution and impact for every answer | Varies; model internals are usually hidden | Yes, but only as far as the rules go |
36
+ | **Dependencies** | Zero | An SDK plus a network stack | Depends on the engine |
37
+ | **Works offline** | Always | Degrades or queues until online | Yes |
38
+
39
+ oracular sits between the other two: more adaptive than fixed rules, and private where hosted scoring is not. If you need population-level analytics or experiments, use a tool built for that; if you need to know when *this* user is ready, without telling anyone, this is the one.
40
+
41
+ Install with `npm install oracular`, then:
42
+
43
+ ```ts
44
+ import { createState, markRevealed, observe, shouldReveal } from 'oracular';
45
+ let state = createState({
46
+ intents: {
47
+ upgrade: {
48
+ prior: 0.05, priorStrength: 10, halfLife: 7 * 24 * 60 * 60 * 1000, // one week, in ms
49
+ signals: { 'pricing.viewed': 3, 'limit.hit': 2, 'export.clicked': 1 },
50
+ outcome: 'upgrade.completed',
51
+ },
52
+ },
53
+ });
54
+ state = observe(state, { name: 'pricing.viewed', at: Date.now() });
55
+ if (shouldReveal(state, 'upgrade', Date.now())) {
56
+ state = markRevealed(state, 'upgrade', Date.now()); // ...then show your offer
57
+ }
58
+ ```
59
+
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); lower `priorStrength` or raise the weights to fire sooner.
61
+
62
+ That block is copied verbatim into the test suite, so it always runs.
63
+
64
+ ## Contents
65
+
66
+ - [Why oracular](#why-oracular)
67
+ - [How it works](#how-it-works)
68
+ - [Examples](#examples)
69
+ - [Using with React](#using-with-react)
70
+ - [API](#api)
71
+ - [Persistence](#persistence)
72
+ - [Explainability](#explainability)
73
+ - [Design rules](#design-rules)
74
+ - [Roadmap](#roadmap)
75
+ - [Contributing](#contributing)
76
+
77
+ 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.
78
+
79
+ ## How it works
80
+
81
+ ### Intents are Beta distributions
82
+
83
+ 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 = α / (α + β).
84
+
85
+ ### Signals add evidence
86
+
87
+ 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
+
89
+ ### Evidence decays toward the prior
90
+
91
+ 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.
92
+
93
+ ### The user's history corrects your weights
94
+
95
+ 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**. The engine also counts, per intent, how many episodes ended with and without the outcome: the intent's **base rate**.
96
+
97
+ A signal's weight is then a blend of your prior and the learned estimate:
98
+
99
+ ```
100
+ q = (hits + 1) / (hits + misses + 2) Laplace-smoothed hit rate
101
+ b = (outcomes + 1) / (episodes + 2) Laplace-smoothed base rate
102
+ lift = (q − b) / (1 − b) if q ≥ b, else (q − b) / b → in [−1, 1]
103
+ learned = gain × |w₀| × lift 0 until the signal has history
104
+ effective = (k × w₀ + n × learned) / (k + n) n = hits + misses, k = priorEpisodes
105
+ ```
106
+
107
+ `w₀` is your initial weight, `gain` (default 2) caps how far a perfectly predictive signal can grow, and `priorEpisodes` (default 10) is how many completed episodes 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. Centering on the base rate matters: for an outcome that happens in 3% of sessions, a signal followed by it 30% of the time is strong evidence, not weak.
108
+
109
+ Learned counts never decay unless you set `learning.halfLife`.
110
+
111
+ ### Reveal only when confident
112
+
113
+ `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. 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.
114
+
115
+ ### Outcomes reset the slate and pause reveals
116
+
117
+ 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.
118
+
119
+ 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`.
120
+
121
+ ### Every answer comes with its reasons
122
+
123
+ `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.
124
+
125
+ ## Examples
126
+
127
+ The ad case is one of these, not the point of the library.
128
+
129
+ ### Onboarding tip when the user is stuck
130
+
131
+ ```ts
132
+ const config = {
133
+ episodeTimeout: 30 * 60 * 1000,
134
+ intents: {
135
+ stuck: {
136
+ prior: 0.1,
137
+ priorStrength: 5,
138
+ halfLife: 10 * 60 * 1000, // ten minutes: being stuck is a short-lived state
139
+ signals: { 'editor.undo': 1, 'search.empty': 2, 'help.opened': 3, 'task.completed': -4 },
140
+ outcome: 'help.article.read',
141
+ reveal: { threshold: 0.6, cooldown: 60 * 60 * 1000 },
142
+ },
143
+ },
144
+ };
145
+ ```
146
+
147
+ ### Upgrade offer
148
+
149
+ The quickstart above. Signals that are followed by `upgrade.completed` grow heavier for this user; the ones that aren't shrink, even to negative.
150
+
151
+ ### Churn save
152
+
153
+ ```ts
154
+ const churn = {
155
+ prior: 0.08,
156
+ priorStrength: 20,
157
+ halfLife: 14 * 24 * 60 * 60 * 1000,
158
+ signals: {
159
+ 'session.short': 1,
160
+ 'notifications.disabled': 3,
161
+ 'export.all': 4, // taking their data with them
162
+ 'feature.new.used': -2,
163
+ },
164
+ outcome: 'account.cancelled',
165
+ reveal: { threshold: 0.5, z: 1.64 }, // be surer before you offer a discount
166
+ };
167
+ ```
168
+
169
+ ### Ad placement
170
+
171
+ ```ts
172
+ const willBuy = {
173
+ prior: 0.02,
174
+ priorStrength: 50,
175
+ halfLife: 3 * 24 * 60 * 60 * 1000,
176
+ signals: { 'product.viewed': 1, 'cart.added': 3, 'wishlist.added': 2, 'cart.emptied': -3 },
177
+ outcome: 'checkout.completed',
178
+ };
179
+ // shouldReveal(state, 'willBuy', now) → show the sponsored placement, then markRevealed.
180
+ ```
181
+
182
+ More in the docs: [Recipes](https://yuribenjamin.github.io/oracular/guide/recipes) and the [Tuning guide](https://yuribenjamin.github.io/oracular/guide/tuning).
183
+
184
+ ## Using with React
185
+
186
+ oracular ships no framework code; this is the userland shape. Load with your current config, observe, persist on change:
187
+
188
+ ```tsx
189
+ import { useEffect, useState } from 'react';
190
+ import { createState, deserialize, markRevealed, observe, serialize, shouldReveal } from 'oracular';
191
+ import { config } from './intent-config';
192
+
193
+ function load() {
194
+ const saved = localStorage.getItem('oracular');
195
+ return saved ? deserialize(saved, { config }) : createState(config); // always pass the config you ship now
196
+ }
197
+
198
+ export function useIntent() {
199
+ const [state, setState] = useState(load);
200
+ useEffect(() => localStorage.setItem('oracular', serialize(state)), [state]);
201
+
202
+ const track = (name: string) => setState((s) => observe(s, { name, at: Date.now() }));
203
+ const reveal = (intent: string) => {
204
+ const now = Date.now();
205
+ if (!shouldReveal(state, intent, now)) return false;
206
+ setState((s) => markRevealed(s, intent, now));
207
+ return true;
208
+ };
209
+ return { track, reveal };
210
+ }
211
+ ```
212
+
213
+ Add `endEpisode` on `pagehide` and you have the whole lifecycle. The same loop for Vue, React Native and Node is on the [frameworks page](https://yuribenjamin.github.io/oracular/guide/frameworks).
214
+
215
+ ## API
216
+
217
+ Every function is pure: it never mutates its input and returns the same output for the same input. State is a plain, JSON-serializable object.
218
+
219
+ ```ts
220
+ createState(config: EngineConfig): EngineState
221
+ observe(state, event: { name: string; at: number; props?: Record<string, unknown> }): EngineState
222
+ endEpisode(state, at: number): EngineState
223
+ predict(state, intent: string, at: number, opts?: RevealOptions): Prediction
224
+ shouldReveal(state, intent: string, at: number, opts?: RevealOptions): boolean
225
+ markRevealed(state, intent: string, at: number): EngineState
226
+ explain(state, intent: string, at: number): SignalExplanation[]
227
+ serialize(state): string
228
+ deserialize(json: string, opts?: { config?: EngineConfig; migrate?: Migrate }): EngineState
229
+ STATE_VERSION // 1
230
+ ```
231
+
232
+ ### Config
233
+
234
+ ```ts
235
+ interface EngineConfig {
236
+ intents: Record<string, IntentConfig>;
237
+ episodeTimeout?: number; // idle gap that ends an episode; off if omitted
238
+ learning?: {
239
+ halfLife?: number; // forgetting of learned counts; off if omitted
240
+ gain?: number; // learned weight cap = gain × |initial|; default 2
241
+ priorEpisodes?: number; // pseudo-count behind your weights; default 10
242
+ };
243
+ reveal?: RevealOptions; // defaults for every intent
244
+ }
245
+
246
+ interface IntentConfig {
247
+ prior: number; // (0, 1)
248
+ priorStrength: number; // > 0
249
+ halfLife: number; // > 0
250
+ signals: Record<string, number>; // event name → weight, non-zero
251
+ outcome?: string; // event that confirms the intent
252
+ resetOnOutcome?: boolean; // default true
253
+ reveal?: RevealOptions;
254
+ }
255
+
256
+ interface RevealOptions {
257
+ threshold?: number; // default 0.5
258
+ z?: number; // default 1.28
259
+ cooldown?: number; // default: the intent's halfLife
260
+ }
261
+ ```
262
+
263
+ Reveal options resolve per call → intent → engine → defaults. Invalid config throws a `TypeError` or `RangeError` with the offending path in the message.
264
+
265
+ ### Results
266
+
267
+ ```ts
268
+ interface Prediction {
269
+ intent: string;
270
+ p: number; // α / (α + β)
271
+ sd: number; // Beta standard deviation
272
+ lower: number; // p − z·sd, clamped to [0, 1]; what shouldReveal compares to threshold
273
+ confidence: number; // 1 − 2·sd, clamped to [0, 1]. A heuristic: 1 is a sharp estimate, 0 is as vague as a Beta gets
274
+ evidence: number; // α + β, prior strength included
275
+ revealed: boolean; // inside the cooldown after markRevealed
276
+ revealedAt: number | null;
277
+ suppressedUntil: number | null; // end of the later running cooldown (reveal or outcome); null when none is running
278
+ }
279
+
280
+ interface SignalExplanation {
281
+ signal: string;
282
+ contribution: number; // signed pseudo-counts currently held, after decay
283
+ impact: number; // p − p without this signal
284
+ initialWeight: number;
285
+ learnedWeight: number;
286
+ effectiveWeight: number; // what the next occurrence will add
287
+ hits: number;
288
+ misses: number;
289
+ }
290
+ ```
291
+
292
+ ### Time
293
+
294
+ `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).
295
+
296
+ ## Persistence
297
+
298
+ The state is plain JSON. Save it however you like:
299
+
300
+ ```ts
301
+ localStorage.setItem('oracular', serialize(state));
302
+ ```
303
+
304
+ `createState` persists a snapshot of the config inside the state, so a saved state remembers the weights it was built with. On load, pass the config your app ships *now*:
305
+
306
+ ```ts
307
+ const state = deserialize(saved, { config });
308
+ ```
309
+
310
+ 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.
311
+
312
+ If you change the shape of the state itself across versions of this library, `deserialize` throws a `RangeError` unless you pass a `migrate` function, which receives the parsed object and its version and returns a current state:
313
+
314
+ ```ts
315
+ deserialize(saved, {
316
+ config,
317
+ migrate: (stored, version) => upgradeFromV(version, stored),
318
+ });
319
+ ```
320
+
321
+ Event `props` are accepted for forward compatibility and are never stored.
322
+
323
+ ## Explainability
324
+
325
+ After ten sessions in which `pricing.viewed` led to an upgrade six times out of seven and `export.clicked` appeared every time, whether or not the user upgraded:
326
+
327
+ ```ts
328
+ explain(state, 'upgrade', Date.now());
329
+ // [
330
+ // { signal: 'pricing.viewed', contribution: 2.58, impact: 0.14, initialWeight: 3, learnedWeight: 2, effectiveWeight: 2.59, hits: 6, misses: 1 },
331
+ // { signal: 'limit.hit', contribution: 2, impact: 0.10, initialWeight: 2, learnedWeight: 0, effectiveWeight: 2, hits: 0, misses: 0 },
332
+ // { signal: 'export.clicked', contribution: 0, impact: 0, initialWeight: 1, learnedWeight: 0, effectiveWeight: 0.5, hits: 7, misses: 3 },
333
+ // ]
334
+ ```
335
+
336
+ Rows are sorted by the size of their impact. `limit.hit` has no history yet, so its effective weight is exactly the initial one. `export.clicked` was followed by an upgrade exactly as often as any session was (7 of 10, the base rate), so this user's history says it carries no information, and its weight is on its way to zero. `pricing.viewed` beat the base rate, so it learned a positive weight; the blend is still below the initial 3 only because the developer's guess was higher than what seven episodes support so far.
337
+
338
+ ## Design rules
339
+
340
+ - **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.
341
+ - **Portable.** No DOM, Node or React Native specific APIs; the source compiles against the ES2020 library alone. ESM and CommonJS builds with type declarations.
342
+ - **Private.** Nothing leaves the device because nothing *can*: there is no I/O in the library.
343
+ - **Honest about uncertainty.** `shouldReveal` needs both a high mean and a high lower bound.
344
+
345
+ ## Roadmap
346
+
347
+ Not in 0.1, deliberately left room for: a React hook, a Vue composable, a devtools panel for watching `explain` live, and presets for common intents. Details and how to propose more on the [roadmap page](https://yuribenjamin.github.io/oracular/guide/roadmap).
348
+
349
+ ## Contributing
350
+
351
+ Bug reports, model discussions and pull requests are welcome. Start with the [contributing guide](./CONTRIBUTING.md) for the development setup, the test rules (every state-returning operation is followed by `assertInvariants`), and the release process. The project follows the [Contributor Covenant](./CODE_OF_CONDUCT.md); security issues go through the [security policy](./SECURITY.md), privately.
352
+
353
+ ## License
354
+
355
+ [MIT](./LICENSE) © Ibrahim Ragab