Every product has a moment it wants to catch: the user who is about to upgrade, the one who is stuck, the one who is about to leave. The usual ways to catch it are a hosted propensity model, which means shipping everyone's behaviour to a server, or a hand-written rule, if (pricingViews >= 3) showOffer(), which never learns. Firebase Predictions was the hosted version for a while; Google has since shut it down.
I wanted the third option: a probability per user, computed on the device, that learns from that user's own outcomes and never leaves. That is oracular, a 7 kB TypeScript library with zero dependencies and no I/O of any kind.
The model in one paragraph
You declare intents. Each is a Beta distribution with a prior (how likely before any evidence) and a prior strength (how many observations that belief is worth). Events your app already emits are signals with weights: a positive weight adds evidence for the intent, a negative one against. Evidence decays toward the prior with a half-life you choose, so last month's pricing visit is not today's. predict gives you p, its standard deviation and a one-sided lower bound; shouldReveal is true only when both p and the lower bound clear your threshold and no cooldown is running. Thin evidence with a high mean does not fire. And explain tells you, per signal, how much evidence it holds and how much p would move without it, so a reveal is never a mystery.
import { createState, markRevealed, observe, shouldReveal } from 'oracular';
let state = createState({
episodeTimeout: 30 * 60 * 1000, // 30 idle minutes end a session
intents: {
upgrade: {
prior: 0.05, priorStrength: 10, halfLife: 7 * 24 * 60 * 60 * 1000, // one week, in ms
signals: { 'pricing.viewed': 3, 'limit.hit': 2, 'export.clicked': 1 },
outcome: 'upgrade.completed',
},
},
});
state = observe(state, { name: 'pricing.viewed', at: Date.now() });
if (shouldReveal(state, 'upgrade', Date.now())) {
state = markRevealed(state, 'upgrade', Date.now()); // ...then show your offer
}
Every function is pure: state in, state out, and the state is plain JSON you persist however you like. Time is always passed in; nothing in the library reads a clock.
Where the learning happens
The weights you write are priors, not facts. Events happen inside episodes (sessions). When the intent's outcome arrives, every signal seen in the episode gets a hit; when the episode ends without it, a miss. A signal's weight then drifts from your guess toward what this user's history supports, judged against the sessions in which the signal did not appear, because that is the only comparison that means anything. A signal that fires in every session has no counterfactual and keeps your weight exactly; one followed by the outcome six times out of seven, while the sessions without it saw one, grows. Two knobs set how far and how fast, per user, on their device.
Nothing leaves the device, by construction
The library has no network, storage, DOM, timer or global access. Its source compiles against the ES2020 standard library alone, with no DOM or Node type definitions, so code that tried to call fetch would not compile. Privacy here is not a policy; it is the absence of the code that could break it. Open the network tab on the playground, which runs the real engine in your browser, and click around.
Does it beat the rule you would write?
That is the question every reviewer asked, so I answered it on a public clickstream: 2.7 million events from an online shop (the Retailrocket dataset on Kaggle), converted into the library's recorder format and split by visitor into a half for tuning and a half for scoring.
The workflow is the point. You run the engine in shadow mode first: it computes every decision, your app records one line per event, and nothing is shown. Then npx oracular tune reads the log and writes the config: the base rate becomes the prior, each signal's measured lift its weight, the half-life that predicts the log best wins a grid. npx oracular replay scores any config on the same log, with precision, recall, lead time and a Brier score.
What it said: the hand-written config, a 1% prior with a cart add worth 3, scored worse than always predicting the base rate. The tuned config scored 10% better, and a reveal at threshold 0.3 was followed by an order within a day 29% of the time, six times the base rate. Against the rule a skeptic would write, "reveal on the third cart add", the engine ties for first-time visitors, within half a point on precision and recall, and it has to: a Bayesian gate over one signal and a counter over one signal are the same policy until there is something to learn. Among visitors who came back, learning is the difference. The printed config caught 43% more orders than the rule at 3.3 points more precision, and with two learning constants fit on the tuning half it caught 27.9% of the orders at 48.9% precision, where the best stateful rule caught 6.4% at 39.2%.
The case study also reports what did not help (cross-episode attribution was neutral where 87% of visitors have one session), what the data changed in the library, and its limits: a server-side log, one shop, one summer. The scripts are in the repository and run on the published package.
Using it
npm install oracular, or oracular plus @oracular/react for the hook, the provider and persistence adapters for localStorage, AsyncStorage and MMKV. The guide has a page on whether it is for you and one on migrating from a rule, with the numbers above. If you try it on real traffic, the Discussions are where the model questions go, and a shadow log you can share is the most useful thing you could bring.

Top comments (0)