Quickstart
The whole engine in fifteen lines. This block is the file examples/quickstart.ts in the repository, executed by the test suite, so it always runs:
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
}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. The Tuning guide has the tables.
What each line does
createState(config)builds the state. Theupgradeintent starts at its prior: 5% likely, held with the strength of 10 observations (α = 0.5, β = 9.5).episodeTimeoutends a session after 30 idle minutes. Learning needs it (every session is an episode), and it is what switches on the discount for a signal repeated within one session; without it, repeats add their full weight. A normalized copy of the config is stored inside the state.observe(state, event)feeds an event.pricing.viewedis a signal ofupgradewith weight 3, so 3 pseudo-counts are added to α and the signal is remembered for this episode. Any other event name is ignored by this intent. The call returns a new state; the old one is untouched.shouldReveal(state, 'upgrade', now)is true when p ≥ 0.5 and p − 1.28·sd ≥ 0.5, and no cooldown is running. Onepricing.viewedgives p ≈ 0.27, so it is false here.markRevealed(state, 'upgrade', now)records that you acted, starting a cooldown (by default one half-life, a week) during whichshouldRevealstays false.
The rest of a real integration
A real app also persists the state and ends episodes. Here is the complete minimal shape for a web app:
import {
createState, deserialize, endEpisode, markRevealed, observe, serialize, shouldReveal,
type EngineConfig,
} from 'oracular';
const config: EngineConfig = {
episodeTimeout: 30 * 60 * 1000, // a 30-minute gap ends a session on its own
intents: {
upgrade: {
prior: 0.05, priorStrength: 10, halfLife: 7 * 24 * 60 * 60 * 1000,
signals: { 'pricing.viewed': 3, 'limit.hit': 2, 'export.clicked': 1 },
outcome: 'upgrade.completed',
},
},
};
// Load: always pass the config your app ships now, so changed weights apply
// and signals you removed are pruned. Fall back to a fresh state.
const saved = localStorage.getItem('oracular');
let state = saved ? deserialize(saved, { config }) : createState(config);
function persist() {
localStorage.setItem('oracular', serialize(state));
}
export function track(name: string) {
const now = Date.now();
state = observe(state, { name, at: now });
if (shouldReveal(state, 'upgrade', now)) {
showUpgradeOffer();
state = markRevealed(state, 'upgrade', now);
}
persist();
}
// End the episode when the session ends, so signals that were not followed by
// the outcome get their miss and the engine can learn from it.
window.addEventListener('pagehide', () => {
state = endEpisode(state, Date.now());
persist();
});Call track('pricing.viewed'), track('limit.hit') and track('upgrade.completed') from the places in your app where those things happen. That is the entire integration. Note that the outcome is not a separate API: an outcome is just observe() with the outcome's event name. The engine recognises upgrade.completed because the config names it in outcome.
Next
- Intents & priors: what
priorandpriorStrengthmean. - Episodes & learning: why
endEpisodeandoutcomematter. - Persistence: why
deserializetakes the config. - Using with frameworks: the same lifecycle in React, Vue, React Native and Node.