Changelog
0.9.0
Minor Changes
#52
e8ea457Thanks @omaremam! - Choosing the comparison.learning.liftchooses how a signal's outcome rate is compared with its baseline: absolute difference, relative lift, risk ratio, log lift, odds ratio, normalized difference or association-rule lift. Every choice becomes a signed score in [−1, 1], with no association at zero, before it changes a weight. A risk ratio of 1 learns zero, 3 gives a score of 0.5 and 1/3 gives −0.5;gaincaps the learned magnitude atgain × |initial weight|whichever comparison is set.oracular tuneuses the same score for weights and intervals, and reports the raw measure alongside it when the two differ. Nothing moves unless the option is set: normalized difference remains the default, with exactly the same arithmetic.Rollback. A stored config that sets
liftis read by 0.9 and later; a 0.8 reader throws aTypeErroron the unknown key, so removelearning.liftfrom the saved config before rolling a deploy back. Passing an older config todeserializealone does not avoid that check. The state version is unchanged. Existing learned counts are kept; evidence already accumulated under another comparison is not rebuilt and continues to decay or reset on outcome.
0.8.1
Patch Changes
- #46
d796e99Thanks @Yuribenjamin! - Release tags. From this release, each release is taggedoracular@<version>instead ofv<version>, and the GitHub release that goes with it is created per package. The reason is the repository's shape: it is now an npm workspace, with the root still theoracularpackage, so that a second package can be published from it; Changesets names tags that way in a workspace, and this release is the one that proves the pipeline end to end before anything depends on it. Nothing in the library changes. What also does not change: the existingv0.1.0tov0.8.0tags stay where they are, and there is novalias for new releases. If you pin by tag,oracular@0.8.1is the first of the new form, and from here on the tag is the package name followed by the version.
0.8.0
Minor Changes
- #44
912f60dThanks @Yuribenjamin! - Exact lower bound.RevealOptions.bound: 'exact'makeslowerthe Beta distribution's own quantile at the one-sided levelznames (Φ(−z), so 1.28 is still a 90% bound) instead of the normal approximation p − z·sd, which is optimistic exactly where the gate matters: thin evidence near the threshold. Beta(4, 3) puts p at 0.571, the approximation at 0.347 and the exact tenth percentile at 0.333; by Beta(30, 20) the two agree to a thousandth.predict,shouldReveal,evaluate, the recorder andevidenceToRevealall follow the option, resolved per call → intent → engine → default like the others, and the config schema lists it. Nothing moves unless it is set: every state and every number withoutboundis exactly what it was. Under'exact',z: 0is the median rather than the mean, andlowercan sit abovepfor a prior near 1 with little strength. A stored config that setsboundis read by 0.8 and later; 0.7 throws aTypeErroron the unknown key, so drop the option before rolling a deploy back. The state version is unchanged. The quantile costs 0.54 kB: the core goes from 6.65 kB to 7.19 kB minified and gzipped, past the 7 kB ceiling the stability page set, so the ceiling moves once, to 7.9 kB (the shipped size plus about 10%), and the quickstart budget from 3.9 kB to 4.5 kB; the stability page records the decision.
0.7.1
Patch Changes
- #42
44fee50Thanks @Yuribenjamin! - A load then a save writes the same string.deserializerebuilt the open episode's per-intent records in the order the config lists intents, whileobservewrites them in the order the intents were first touched in that episode. The content was identical, and the order of keys in a serialized state is not API, but an app that skips a save when the string has not changed, a common persistence pattern, wrote once spuriously after every load.deserializenow keeps the stored order and drops only intents the config no longer declares, soserialize(deserialize(json))isjsonon the first pass. The property-based tests pin it.
0.7.0
Minor Changes
#36
a585808Thanks @Yuribenjamin! - Inspect.inspect(state, at, opts?)in the main entry returns every intent's prediction, gate and explanation as one object, in config order.oracular/inspectrenders that snapshot:renderInspectoras an HTML fragment with inline styles and every string escaped,mountInspectorinto anything with aninnerHTML, withupdateandunmount; neither touches a DOM API or sets a timer, and neither carries engine code. The subpath is experimental, as the stability page says: its markup and options may change in a minor. The typesSnapshotandIntentSnapshotare exported from the main entry.#36
a585808Thanks @Yuribenjamin! - Presets.oracular/presetsexportspresets, four plainIntentConfigobjects to spread into your config and override:willBuy, tuned byoracular tuneon a public e-commerce clickstream of 2.7 million events (one shop, May to September 2015, 4.8% of sessions ending with an order; prior 0.048, strength 4,cart.added2.3, half-life one hour, 181,080 sessions behind it), andupgrade,stuckandchurn, the documentation's running examples, hand-written and labelled so.presetSourcescarries each preset's provenance as data, and the typesPresetNameandPresetSourceare exported from the main entry.
Patch Changes
- #36
a585808Thanks @Yuribenjamin! - Rollback.deserializereads a state one version newer thanSTATE_VERSIONas the current one, ignoring the fields it does not know, so a rollback by one release keeps every user's history. The rule that allows it, a state version bump only adds fields, is now a commitment on the new stability page, together with what counts as public API, how versions are read before 1.0, the deprecation rule, the state-format guarantee, the 1.0 criteria and their status, and a 7 kB ceiling for the core. Two versions ahead still throws theRangeError, andmigrateis still consulted for it. From now on the changeset of every state version bump says what an older reader silently loses; this release bumps nothing, so a 0.6 reader loses nothing but was never able to read ahead.
0.6.0
Minor Changes
#32
6951d70Thanks @Yuribenjamin! - Cross-episode attribution.IntentConfig.attributionis how long a closed episode's signals wait for the outcome before they take a miss. Until now a signal earned a hit only when the outcome arrived in the same episode, so for an intent that builds over days the sessions that built it took misses and the session where the user acted got all the credit. With a window set, an episode that ends without the outcome is held instead of counted; when the outcome arrives inside the window, each held episode gets the share2^(−age / halfLife)of a hit that its evidence still holds, and the rest of a miss, so every episode still counts exactly once and the contrast that learning rests on is unchanged; when the window passes, the episode is a miss recorded at the moment the window ended. Reads settle what has expired in what they show and store nothing. Off unless set: every state and estimate withoutattributionis exactly what it was.State version 3.
IntentState.pendinglists the episodes waiting, at most 32 per intent (past that the oldest is a miss at once).deserializeupgrades version 1 and 2 states on its own, adding an empty list and changing no number; oracular 0.5.x cannot read a version 3 state, so the rollback advice stays: catch and start fromcreateState(config).PendingEpisodeis exported as a type; the config JSON Schema gainsattribution.oracular tunecounts each episode's own verdict, with the window off while counting, keeps it on for the half-life grid, and keeps whateverattributionyou set in the printed config.
0.5.2
Patch Changes
- #30
8be17e0Thanks @Yuribenjamin! -oracular tunelearned three rules from a public e-commerce clickstream of 2.7 million events. A signal seen in more than 90% of counted episodes is left out of the fitted config, whatever its lift: it cannot tell episodes apart, and the few episodes without it are rarely typical sessions, so the contrast measured on them says little (on that log,item.viewedsat in 99% of sessions and the contrast called it strongly negative; dropping it scored better than any weight). The current half-life is kept when the best grid point improves on it by less than 1%, and the grid is printed to four decimals so a flat grid reads as flat. The report says when the fitted intent still does not beat its own base rate. The JSON report gainsshareandubiquitousper signal andbeatsBaseRateper intent.
0.5.1
Patch Changes
- #28
250a32dThanks @Yuribenjamin! -oracular replayandoracular tuneread logs of any size. A log of more than about a hundred thousand records made both commands fail with "Maximum call stack size exceeded", because every file's entries were spread into onepushcall. Found by replaying the Retailrocket clickstream (2.7 million events).
0.5.0
Minor Changes
#24
7809c6aThanks @Yuribenjamin! -npx oracular tune <log>... --config <file>seeds a config from a shadow log. Grouped by install, it reads what the engine itself counts, hits, misses and episode totals, off the state after every record, so it cannot disagree with what a device would learn from the same events. The base rate becomes the prior; the spread of per-install base rates becomes the prior strength (method of moments on a Beta, once the spread that sampling alone would produce is taken out; kept as it was below 10 installs); each declared signal's weight ispriorStrength × liftover the episodes without it, with a 90% Wilson interval and the sample sizes on both sides; and the half-life that predicts the log best (lowest Brier score underreplay) wins a grid search over 1h to 30d plus the current value. It prints the fit and the config to paste, whichreplay --configtakes as it is.Like replay, it counts shadow periods only: an episode in which any record was live is left out, a mixed log gets a warning, a live-only log is refused unless
--include-liveis passed.--all-eventsalso measures every other event name in the log as a candidate signal;--half-lives 12h,1d,3dsets the grid;--windowsets the scoring window when the intent has no cooldown. Every number is a starting point: the engine corrects the weights per user.oracular --helpnow lists both commands;oracular replay --helpandoracular tune --helphave the options.
0.4.0
Minor Changes
#22
0bb5841Thanks @Yuribenjamin! - Three small ergonomics for writing configs.validateConfig(config)runs the checkscreateStateanddeserializerun, without building a state, and returns the normalized copy they would store. It acceptsunknown, so parsed JSON can be passed as it is; use it to fail fast at startup or in a test.ms:ms.seconds(n),ms.minutes(n),ms.hours(n),ms.days(n)andms.weeks(n)spell durations in milliseconds, sohalfLife: ms.days(7)replaces7 * 24 * 60 * 60 * 1000. Plain multiplications, nothing else.- A JSON Schema for the config, shipped in the package as
oracular/config.schema.jsonand served at https://yuribenjamin.github.io/oracular/config.schema.json, for completion and checks on a config kept as JSON.oracular replay --config file.jsonnow ignores a top-level$schemakey, so a file can point at the schema and still be the config the engine runs.
0.3.0
Minor Changes
#18
d2d95a4Thanks @Yuribenjamin! -deserializenow rejects, with aRangeError, 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.gainandlearning.priorEpisodesat 1e6 in magnitude (RangeError), because larger values could overflow the same arithmetic increateStatetoo; 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 aSyntaxError,TypeErrororRangeError, or loads a state whose estimates stay finite.#18
d2d95a4Thanks @Yuribenjamin! - Repeats of a signal within one episode now saturate when the config setsepisodeTimeout. The n-th occurrence addsw × log2(1 + 1/n), so n occurrences addw × log2(1 + n): tenpricing.viewedare 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.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.viewedin one session, where it used to need 6; six in separate sessions still fire it. Two things to check after upgrading:- Saturation is on by default only when
episodeTimeoutis set. Without an idle timeout, episodes end only when your app callsendEpisode, 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, setsaturate: trueon the intents that should saturate. The quickstart now setsepisodeTimeout: 30 * 60 * 1000. - Opt out where repetition is the evidence. Set the new intent option
saturate: falsefor 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.
STATE_VERSIONis now 2: the open episode also records how often each signal occurred (EpisodeIntentRecord.counts).deserializemigrates states saved by oracular 0.1.0 to 0.2.x on its own, keeping every estimate exactly as it was; yourmigratefunction 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 theRangeErrorand fall back tocreateState(config).- Saturation is on by default only when
#18
d2d95a4Thanks @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.- Recorder format and helpers.
createRecord(state, event, { installId, mode })returns one line of a versioned NDJSON log (RECORD_VERSION1). Each line holds the event (name,at;propsare 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 anendEpisodecall. Every record says whether reveals wereshadoworlive. The install id comes from your app (random, generated once, stored); the helpers are pure and generate nothing. 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-liveis passed. The command ships as the package'sbin, still with zero runtime dependencies.
The new Shadow mode guide describes the workflow and the record format.
- Recorder format and helpers.
0.2.1
Patch Changes
- #10
04fb9aaThanks @Yuribenjamin! - Fix theexportsmap: thedefaultcondition now points at the CommonJS build and stays last, so resolvers without animport/requirecondition (Jest's jsdom environment, some bundlers) get a file they can load. ESM-first resolvers still get the ESM build.
0.2.0
Minor Changes
- #8
72c5f3cThanks @Yuribenjamin! - Learning compares a signal's outcome rate against sessions without the signal, and only trusts the comparison once both have been seen. Weights no longer drift after a single outcome.
0.1.2
Patch Changes
- #6
57d121fThanks @Yuribenjamin! - Docs corrections from the first production integration; new evaluate(), evidenceToReveal(), episodeClosed() and explain() filtering; default export condition.
0.1.1
Patch Changes
- #3
b5670dbThanks @Yuribenjamin! - No library changes. First version published through the automated release pipeline, with npm provenance attestation.
0.1.0
First release. Published as v0.1.0.
Minor Changes
createState,observe,endEpisode: intents tracked as Beta(α, β) with per-signal weights, exponential decay back toward the prior, and episode bookkeeping (explicit end orepisodeTimeout).- On-device learning: every signal seen in an episode gets a hit when the intent's outcome arrives and a miss when the episode ends without it. The learned weight is centered on the intent's own base rate and blended with the developer's weight by
learning.priorEpisodes. predict,shouldReveal,markRevealed: reveal only when p and the lower confidence bound p − z·sd both clear the threshold, with a cooldown after each reveal.resetOnOutcome(default on): the outcome event resets an intent's evidence to the prior while keeping what was learned.- Outcome cooldown: the outcome event is recorded as
lastOutcomeAtandshouldRevealstays false for the intent's cooldown afterwards;predictreportssuppressedUntil, the end of whichever cooldown (reveal or outcome) is still running. explain: per-signal contribution, impact on p, initial, learned and effective weights.serialize/deserializewith aversionfield, amigratehook, and aconfigoption that swaps in the app's current config and prunes signals and intents no longer declared.- ESM and CommonJS builds with type declarations; runs unchanged in browsers, Node 18+ and React Native.