oracular 0.8.1 → 0.10.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/dist/presets.cjs CHANGED
@@ -47,13 +47,20 @@ var churn = {
47
47
  outcome: "account.cancelled",
48
48
  reveal: { threshold: 0.5, z: 1.64 }
49
49
  };
50
- var presets = {
50
+ function freeze(value) {
51
+ if (typeof value === "object" && value !== null) {
52
+ for (const inner of Object.values(value)) freeze(inner);
53
+ Object.freeze(value);
54
+ }
55
+ return value;
56
+ }
57
+ var presets = freeze({
51
58
  willBuy,
52
59
  upgrade,
53
60
  stuck,
54
61
  churn
55
- };
56
- var presetSources = {
62
+ });
63
+ var presetSources = freeze({
57
64
  willBuy: {
58
65
  kind: "tuned",
59
66
  note: "oracular tune on a public e-commerce clickstream: one shop, May to September 2015, 2.7 million events, 4.8% of sessions ending with an order; fitted October 2026, the fitted 0.0477 and 2.32 rounded",
@@ -78,7 +85,7 @@ var presetSources = {
78
85
  episodes: null,
79
86
  installs: null
80
87
  }
81
- };
88
+ });
82
89
 
83
90
  exports.presetSources = presetSources;
84
91
  exports.presets = presets;
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/units.ts","../src/presets.ts"],"names":[],"mappings":"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","file":"presets.cjs","sourcesContent":["/** The units of {@link ms}: each takes a count and returns milliseconds. */\nexport interface Units {\n /** `n` seconds in milliseconds: `n × 1000`. */\n readonly seconds: (n: number) => number;\n /** `n` minutes in milliseconds: `n × 60 000`. */\n readonly minutes: (n: number) => number;\n /** `n` hours in milliseconds: `n × 3 600 000`. */\n readonly hours: (n: number) => number;\n /** `n` days in milliseconds: `n × 86 400 000`. */\n readonly days: (n: number) => number;\n /** `n` weeks in milliseconds: `n × 604 800 000`. */\n readonly weeks: (n: number) => number;\n}\n\n/**\n * Durations in milliseconds, the unit of `Date.now()`: `ms.days(7)` is `604800000`. Plain\n * multiplications and nothing else, for `halfLife`, `cooldown`, `episodeTimeout` and\n * `learning.halfLife` when your timestamps are in milliseconds. If your `at` is in another unit,\n * write durations in that unit instead: the engine only needs the two to agree.\n *\n * @example\n * ```ts\n * const config = {\n * episodeTimeout: ms.minutes(30),\n * intents: {\n * upgrade: {\n * prior: 0.05,\n * priorStrength: 10,\n * halfLife: ms.days(7),\n * signals: { 'pricing.viewed': 3 },\n * reveal: { cooldown: ms.weeks(2) },\n * },\n * },\n * };\n * ```\n */\nexport const ms: Units = {\n seconds: (n) => n * 1_000,\n minutes: (n) => n * 60_000,\n hours: (n) => n * 3_600_000,\n days: (n) => n * 86_400_000,\n weeks: (n) => n * 604_800_000,\n};\n","import type { IntentConfig, PresetName, PresetSource } from './types';\nimport { ms } from './units';\n\nexport type { PresetName, PresetSource } from './types';\n\n/**\n * A visitor about to buy. Tuned on a public e-commerce clickstream of 2.7 million events (see the\n * case study in the guide): one shop, one summer (May to September 2015), 181,080 sessions from\n * 93,537 visitors, 8,628 of them ending with an order, a session conversion of 4.8%; scored on as\n * many visitors the fit never saw. A measured starting point, not a universal number: a shop that\n * converts 1% or 20% of its sessions has a different prior and most likely a different weight.\n * Start here, run in shadow mode, and let `tune` replace these with your own numbers.\n *\n * - `prior` 0.048: the share of sessions that end with an order (fitted 0.0477).\n * - `priorStrength` 4: visitors differ a lot in how often they buy, so one visitor's own history\n * should outweigh the population after a handful of sessions.\n * - `halfLife` one hour: the best point of the grid; buying intent within a visit is a matter of\n * minutes, and tomorrow's visit starts over.\n * - `cart.added` 2.3: its measured lift (fitted 2.32). 58% of sessions with a cart add end with an\n * order, against 0.8% of those without. There is no weight for viewing a product: on that data a\n * view is in 99% of sessions and says nothing about buying. Add one only if your data disagrees.\n * - `cooldown` one day: after an order, or after acting on the intent, stay quiet for a day; the\n * half-life would otherwise make the default cooldown an hour.\n *\n * Rename `cart.added` and `checkout.completed` to your events by spreading.\n */\nconst willBuy: IntentConfig = {\n prior: 0.048,\n priorStrength: 4,\n halfLife: ms.hours(1),\n signals: { 'cart.added': 2.3 },\n outcome: 'checkout.completed',\n reveal: { cooldown: ms.days(1) },\n};\n\n/**\n * A user about to upgrade: the quickstart config, hand-written. Interest in a paid plan builds over\n * days, so the half-life is a week; the prior is a guess at the share of users who upgrade in a\n * week; `priorStrength` 10 means about six full-weight signals across sessions open the default\n * gate; the weights rank the signals by how directly they point at paying. Nobody has measured\n * these against a log.\n */\nconst upgrade: IntentConfig = {\n prior: 0.05,\n priorStrength: 10,\n halfLife: ms.days(7),\n signals: { 'pricing.viewed': 3, 'limit.hit': 2, 'export.clicked': 1 },\n outcome: 'upgrade.completed',\n};\n\n/**\n * A user stuck on a task, hand-written. Being stuck is a state of the current session, so the\n * half-life is ten minutes and the cooldown an hour; `saturate: false` because here repetition is\n * the evidence (the fifth undo in a row says more than the first); `task.completed` is strong\n * evidence against; the gate is raised to 0.6 because an unwanted tip is an interruption. Nobody\n * has measured these against a log.\n */\nconst stuck: IntentConfig = {\n prior: 0.1,\n priorStrength: 5,\n halfLife: ms.minutes(10),\n signals: { 'editor.undo': 1, 'search.empty': 2, 'help.opened': 3, 'task.completed': -4 },\n outcome: 'help.article.read',\n saturate: false,\n reveal: { threshold: 0.6, cooldown: ms.hours(1) },\n};\n\n/**\n * A user about to leave, hand-written. Churn builds over weeks, so the half-life is two weeks and\n * the prior strength 20: it should take a lot to call someone a churn risk. Exporting all their\n * data is the strongest signal; using a new feature counts against. `z` 1.64 asks for a one-sided\n * 95% bound before a save offer, because a discount has a real cost. Nobody has measured these\n * against a log.\n */\nconst churn: IntentConfig = {\n prior: 0.08,\n priorStrength: 20,\n halfLife: ms.days(14),\n signals: {\n 'session.short': 1,\n 'notifications.disabled': 3,\n 'export.all': 4,\n 'feature.new.used': -2,\n },\n outcome: 'account.cancelled',\n reveal: { threshold: 0.5, z: 1.64 },\n};\n\n/**\n * Starting points for four common intents, as plain config objects. Spread one into your config,\n * rename its events to yours, override what you know better; see {@link presetSources} for where\n * each preset's numbers come from.\n *\n * @example\n * ```ts\n * import { presets } from 'oracular/presets';\n *\n * const config = {\n * episodeTimeout: 30 * 60 * 1000,\n * intents: {\n * buy: { ...presets.willBuy, outcome: 'order.placed' },\n * },\n * };\n * ```\n */\nexport const presets: Readonly<Record<PresetName, IntentConfig>> = {\n willBuy,\n upgrade,\n stuck,\n churn,\n};\n\n/**\n * Where each preset's numbers come from. Read it before trusting a preset: a tuned one carries its\n * sample size, a hand-written one carries nothing but reasoning.\n *\n * @example\n * ```ts\n * import { presetSources } from 'oracular/presets';\n * presetSources.willBuy.kind; // 'tuned'\n * ```\n */\nexport const presetSources: Readonly<Record<PresetName, PresetSource>> = {\n willBuy: {\n kind: 'tuned',\n note: 'oracular tune on a public e-commerce clickstream: one shop, May to September 2015, 2.7 million events, 4.8% of sessions ending with an order; fitted October 2026, the fitted 0.0477 and 2.32 rounded',\n episodes: 181_080,\n installs: 93_537,\n },\n upgrade: {\n kind: 'hand-written',\n note: 'the quickstart config from the documentation; no log behind it',\n episodes: null,\n installs: null,\n },\n stuck: {\n kind: 'hand-written',\n note: 'the onboarding-tip example from the documentation; no log behind it',\n episodes: null,\n installs: null,\n },\n churn: {\n kind: 'hand-written',\n note: 'the churn-save example from the documentation; no log behind it',\n episodes: null,\n installs: null,\n },\n};\n"]}
1
+ {"version":3,"sources":["../src/units.ts","../src/presets.ts"],"names":[],"mappings":"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","file":"presets.cjs","sourcesContent":["/** The units of {@link ms}: each takes a count and returns milliseconds. */\nexport interface Units {\n /** `n` seconds in milliseconds: `n × 1000`. */\n readonly seconds: (n: number) => number;\n /** `n` minutes in milliseconds: `n × 60 000`. */\n readonly minutes: (n: number) => number;\n /** `n` hours in milliseconds: `n × 3 600 000`. */\n readonly hours: (n: number) => number;\n /** `n` days in milliseconds: `n × 86 400 000`. */\n readonly days: (n: number) => number;\n /** `n` weeks in milliseconds: `n × 604 800 000`. */\n readonly weeks: (n: number) => number;\n}\n\n/**\n * Durations in milliseconds, the unit of `Date.now()`: `ms.days(7)` is `604800000`. Plain\n * multiplications and nothing else, for `halfLife`, `cooldown`, `episodeTimeout` and\n * `learning.halfLife` when your timestamps are in milliseconds. If your `at` is in another unit,\n * write durations in that unit instead: the engine only needs the two to agree.\n *\n * @example\n * ```ts\n * const config = {\n * episodeTimeout: ms.minutes(30),\n * intents: {\n * upgrade: {\n * prior: 0.05,\n * priorStrength: 10,\n * halfLife: ms.days(7),\n * signals: { 'pricing.viewed': 3 },\n * reveal: { cooldown: ms.weeks(2) },\n * },\n * },\n * };\n * ```\n */\nexport const ms: Units = {\n seconds: (n) => n * 1_000,\n minutes: (n) => n * 60_000,\n hours: (n) => n * 3_600_000,\n days: (n) => n * 86_400_000,\n weeks: (n) => n * 604_800_000,\n};\n","import type { IntentConfig, PresetName, PresetSource } from './types';\nimport { ms } from './units';\n\nexport type { PresetName, PresetSource } from './types';\n\n/**\n * A visitor about to buy. Tuned on a public e-commerce clickstream of 2.7 million events (see the\n * case study in the guide): one shop, one summer (May to September 2015), 181,080 sessions from\n * 93,537 visitors, 8,628 of them ending with an order, a session conversion of 4.8%; scored on as\n * many visitors the fit never saw. A measured starting point, not a universal number: a shop that\n * converts 1% or 20% of its sessions has a different prior and most likely a different weight.\n * Start here, run in shadow mode, and let `tune` replace these with your own numbers.\n *\n * - `prior` 0.048: the share of sessions that end with an order (fitted 0.0477).\n * - `priorStrength` 4: visitors differ a lot in how often they buy, so one visitor's own history\n * should outweigh the population after a handful of sessions.\n * - `halfLife` one hour: the best point of the grid; buying intent within a visit is a matter of\n * minutes, and tomorrow's visit starts over.\n * - `cart.added` 2.3: its measured lift (fitted 2.32). 58% of sessions with a cart add end with an\n * order, against 0.8% of those without. There is no weight for viewing a product: on that data a\n * view is in 99% of sessions and says nothing about buying. Add one only if your data disagrees.\n * - `cooldown` one day: after an order, or after acting on the intent, stay quiet for a day; the\n * half-life would otherwise make the default cooldown an hour.\n *\n * Rename `cart.added` and `checkout.completed` to your events by spreading.\n */\nconst willBuy: IntentConfig = {\n prior: 0.048,\n priorStrength: 4,\n halfLife: ms.hours(1),\n signals: { 'cart.added': 2.3 },\n outcome: 'checkout.completed',\n reveal: { cooldown: ms.days(1) },\n};\n\n/**\n * A user about to upgrade: the quickstart config, hand-written. Interest in a paid plan builds over\n * days, so the half-life is a week; the prior is a guess at the share of users who upgrade in a\n * week; `priorStrength` 10 means about six full-weight signals across sessions open the default\n * gate; the weights rank the signals by how directly they point at paying. Nobody has measured\n * these against a log.\n */\nconst upgrade: IntentConfig = {\n prior: 0.05,\n priorStrength: 10,\n halfLife: ms.days(7),\n signals: { 'pricing.viewed': 3, 'limit.hit': 2, 'export.clicked': 1 },\n outcome: 'upgrade.completed',\n};\n\n/**\n * A user stuck on a task, hand-written. Being stuck is a state of the current session, so the\n * half-life is ten minutes and the cooldown an hour; `saturate: false` because here repetition is\n * the evidence (the fifth undo in a row says more than the first); `task.completed` is strong\n * evidence against; the gate is raised to 0.6 because an unwanted tip is an interruption. Nobody\n * has measured these against a log.\n */\nconst stuck: IntentConfig = {\n prior: 0.1,\n priorStrength: 5,\n halfLife: ms.minutes(10),\n signals: { 'editor.undo': 1, 'search.empty': 2, 'help.opened': 3, 'task.completed': -4 },\n outcome: 'help.article.read',\n saturate: false,\n reveal: { threshold: 0.6, cooldown: ms.hours(1) },\n};\n\n/**\n * A user about to leave, hand-written. Churn builds over weeks, so the half-life is two weeks and\n * the prior strength 20: it should take a lot to call someone a churn risk. Exporting all their\n * data is the strongest signal; using a new feature counts against. `z` 1.64 asks for a one-sided\n * 95% bound before a save offer, because a discount has a real cost. Nobody has measured these\n * against a log.\n */\nconst churn: IntentConfig = {\n prior: 0.08,\n priorStrength: 20,\n halfLife: ms.days(14),\n signals: {\n 'session.short': 1,\n 'notifications.disabled': 3,\n 'export.all': 4,\n 'feature.new.used': -2,\n },\n outcome: 'account.cancelled',\n reveal: { threshold: 0.5, z: 1.64 },\n};\n\n/**\n * Starting points for four common intents, as plain config objects. Spread one into your config,\n * rename its events to yours, override what you know better; see {@link presetSources} for where\n * each preset's numbers come from.\n *\n * @example\n * ```ts\n * import { presets } from 'oracular/presets';\n *\n * const config = {\n * episodeTimeout: 30 * 60 * 1000,\n * intents: {\n * buy: { ...presets.willBuy, outcome: 'order.placed' },\n * },\n * };\n * ```\n */\n/** Frozen through and through: a preset is shared, so a change by one caller would reach every other. */\nfunction freeze<T>(value: T): T {\n if (typeof value === 'object' && value !== null) {\n for (const inner of Object.values(value)) freeze(inner);\n Object.freeze(value);\n }\n return value;\n}\n\nexport const presets: Readonly<Record<PresetName, IntentConfig>> = freeze({\n willBuy,\n upgrade,\n stuck,\n churn,\n});\n\n/**\n * Where each preset's numbers come from. Read it before trusting a preset: a tuned one carries its\n * sample size, a hand-written one carries nothing but reasoning.\n *\n * @example\n * ```ts\n * import { presetSources } from 'oracular/presets';\n * presetSources.willBuy.kind; // 'tuned'\n * ```\n */\nexport const presetSources: Readonly<Record<PresetName, PresetSource>> = freeze({\n willBuy: {\n kind: 'tuned',\n note: 'oracular tune on a public e-commerce clickstream: one shop, May to September 2015, 2.7 million events, 4.8% of sessions ending with an order; fitted October 2026, the fitted 0.0477 and 2.32 rounded',\n episodes: 181_080,\n installs: 93_537,\n },\n upgrade: {\n kind: 'hand-written',\n note: 'the quickstart config from the documentation; no log behind it',\n episodes: null,\n installs: null,\n },\n stuck: {\n kind: 'hand-written',\n note: 'the onboarding-tip example from the documentation; no log behind it',\n episodes: null,\n installs: null,\n },\n churn: {\n kind: 'hand-written',\n note: 'the churn-save example from the documentation; no log behind it',\n episodes: null,\n installs: null,\n },\n});\n"]}
@@ -103,23 +103,6 @@ interface PresetSource {
103
103
  installs: number | null;
104
104
  }
105
105
 
106
- /**
107
- * Starting points for four common intents, as plain config objects. Spread one into your config,
108
- * rename its events to yours, override what you know better; see {@link presetSources} for where
109
- * each preset's numbers come from.
110
- *
111
- * @example
112
- * ```ts
113
- * import { presets } from 'oracular/presets';
114
- *
115
- * const config = {
116
- * episodeTimeout: 30 * 60 * 1000,
117
- * intents: {
118
- * buy: { ...presets.willBuy, outcome: 'order.placed' },
119
- * },
120
- * };
121
- * ```
122
- */
123
106
  declare const presets: Readonly<Record<PresetName, IntentConfig>>;
124
107
  /**
125
108
  * Where each preset's numbers come from. Read it before trusting a preset: a tuned one carries its
package/dist/presets.d.ts CHANGED
@@ -103,23 +103,6 @@ interface PresetSource {
103
103
  installs: number | null;
104
104
  }
105
105
 
106
- /**
107
- * Starting points for four common intents, as plain config objects. Spread one into your config,
108
- * rename its events to yours, override what you know better; see {@link presetSources} for where
109
- * each preset's numbers come from.
110
- *
111
- * @example
112
- * ```ts
113
- * import { presets } from 'oracular/presets';
114
- *
115
- * const config = {
116
- * episodeTimeout: 30 * 60 * 1000,
117
- * intents: {
118
- * buy: { ...presets.willBuy, outcome: 'order.placed' },
119
- * },
120
- * };
121
- * ```
122
- */
123
106
  declare const presets: Readonly<Record<PresetName, IntentConfig>>;
124
107
  /**
125
108
  * Where each preset's numbers come from. Read it before trusting a preset: a tuned one carries its
package/dist/presets.js CHANGED
@@ -45,13 +45,20 @@ var churn = {
45
45
  outcome: "account.cancelled",
46
46
  reveal: { threshold: 0.5, z: 1.64 }
47
47
  };
48
- var presets = {
48
+ function freeze(value) {
49
+ if (typeof value === "object" && value !== null) {
50
+ for (const inner of Object.values(value)) freeze(inner);
51
+ Object.freeze(value);
52
+ }
53
+ return value;
54
+ }
55
+ var presets = freeze({
49
56
  willBuy,
50
57
  upgrade,
51
58
  stuck,
52
59
  churn
53
- };
54
- var presetSources = {
60
+ });
61
+ var presetSources = freeze({
55
62
  willBuy: {
56
63
  kind: "tuned",
57
64
  note: "oracular tune on a public e-commerce clickstream: one shop, May to September 2015, 2.7 million events, 4.8% of sessions ending with an order; fitted October 2026, the fitted 0.0477 and 2.32 rounded",
@@ -76,7 +83,7 @@ var presetSources = {
76
83
  episodes: null,
77
84
  installs: null
78
85
  }
79
- };
86
+ });
80
87
 
81
88
  export { presetSources, presets };
82
89
  //# sourceMappingURL=presets.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/units.ts","../src/presets.ts"],"names":[],"mappings":"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","file":"presets.js","sourcesContent":["/** The units of {@link ms}: each takes a count and returns milliseconds. */\nexport interface Units {\n /** `n` seconds in milliseconds: `n × 1000`. */\n readonly seconds: (n: number) => number;\n /** `n` minutes in milliseconds: `n × 60 000`. */\n readonly minutes: (n: number) => number;\n /** `n` hours in milliseconds: `n × 3 600 000`. */\n readonly hours: (n: number) => number;\n /** `n` days in milliseconds: `n × 86 400 000`. */\n readonly days: (n: number) => number;\n /** `n` weeks in milliseconds: `n × 604 800 000`. */\n readonly weeks: (n: number) => number;\n}\n\n/**\n * Durations in milliseconds, the unit of `Date.now()`: `ms.days(7)` is `604800000`. Plain\n * multiplications and nothing else, for `halfLife`, `cooldown`, `episodeTimeout` and\n * `learning.halfLife` when your timestamps are in milliseconds. If your `at` is in another unit,\n * write durations in that unit instead: the engine only needs the two to agree.\n *\n * @example\n * ```ts\n * const config = {\n * episodeTimeout: ms.minutes(30),\n * intents: {\n * upgrade: {\n * prior: 0.05,\n * priorStrength: 10,\n * halfLife: ms.days(7),\n * signals: { 'pricing.viewed': 3 },\n * reveal: { cooldown: ms.weeks(2) },\n * },\n * },\n * };\n * ```\n */\nexport const ms: Units = {\n seconds: (n) => n * 1_000,\n minutes: (n) => n * 60_000,\n hours: (n) => n * 3_600_000,\n days: (n) => n * 86_400_000,\n weeks: (n) => n * 604_800_000,\n};\n","import type { IntentConfig, PresetName, PresetSource } from './types';\nimport { ms } from './units';\n\nexport type { PresetName, PresetSource } from './types';\n\n/**\n * A visitor about to buy. Tuned on a public e-commerce clickstream of 2.7 million events (see the\n * case study in the guide): one shop, one summer (May to September 2015), 181,080 sessions from\n * 93,537 visitors, 8,628 of them ending with an order, a session conversion of 4.8%; scored on as\n * many visitors the fit never saw. A measured starting point, not a universal number: a shop that\n * converts 1% or 20% of its sessions has a different prior and most likely a different weight.\n * Start here, run in shadow mode, and let `tune` replace these with your own numbers.\n *\n * - `prior` 0.048: the share of sessions that end with an order (fitted 0.0477).\n * - `priorStrength` 4: visitors differ a lot in how often they buy, so one visitor's own history\n * should outweigh the population after a handful of sessions.\n * - `halfLife` one hour: the best point of the grid; buying intent within a visit is a matter of\n * minutes, and tomorrow's visit starts over.\n * - `cart.added` 2.3: its measured lift (fitted 2.32). 58% of sessions with a cart add end with an\n * order, against 0.8% of those without. There is no weight for viewing a product: on that data a\n * view is in 99% of sessions and says nothing about buying. Add one only if your data disagrees.\n * - `cooldown` one day: after an order, or after acting on the intent, stay quiet for a day; the\n * half-life would otherwise make the default cooldown an hour.\n *\n * Rename `cart.added` and `checkout.completed` to your events by spreading.\n */\nconst willBuy: IntentConfig = {\n prior: 0.048,\n priorStrength: 4,\n halfLife: ms.hours(1),\n signals: { 'cart.added': 2.3 },\n outcome: 'checkout.completed',\n reveal: { cooldown: ms.days(1) },\n};\n\n/**\n * A user about to upgrade: the quickstart config, hand-written. Interest in a paid plan builds over\n * days, so the half-life is a week; the prior is a guess at the share of users who upgrade in a\n * week; `priorStrength` 10 means about six full-weight signals across sessions open the default\n * gate; the weights rank the signals by how directly they point at paying. Nobody has measured\n * these against a log.\n */\nconst upgrade: IntentConfig = {\n prior: 0.05,\n priorStrength: 10,\n halfLife: ms.days(7),\n signals: { 'pricing.viewed': 3, 'limit.hit': 2, 'export.clicked': 1 },\n outcome: 'upgrade.completed',\n};\n\n/**\n * A user stuck on a task, hand-written. Being stuck is a state of the current session, so the\n * half-life is ten minutes and the cooldown an hour; `saturate: false` because here repetition is\n * the evidence (the fifth undo in a row says more than the first); `task.completed` is strong\n * evidence against; the gate is raised to 0.6 because an unwanted tip is an interruption. Nobody\n * has measured these against a log.\n */\nconst stuck: IntentConfig = {\n prior: 0.1,\n priorStrength: 5,\n halfLife: ms.minutes(10),\n signals: { 'editor.undo': 1, 'search.empty': 2, 'help.opened': 3, 'task.completed': -4 },\n outcome: 'help.article.read',\n saturate: false,\n reveal: { threshold: 0.6, cooldown: ms.hours(1) },\n};\n\n/**\n * A user about to leave, hand-written. Churn builds over weeks, so the half-life is two weeks and\n * the prior strength 20: it should take a lot to call someone a churn risk. Exporting all their\n * data is the strongest signal; using a new feature counts against. `z` 1.64 asks for a one-sided\n * 95% bound before a save offer, because a discount has a real cost. Nobody has measured these\n * against a log.\n */\nconst churn: IntentConfig = {\n prior: 0.08,\n priorStrength: 20,\n halfLife: ms.days(14),\n signals: {\n 'session.short': 1,\n 'notifications.disabled': 3,\n 'export.all': 4,\n 'feature.new.used': -2,\n },\n outcome: 'account.cancelled',\n reveal: { threshold: 0.5, z: 1.64 },\n};\n\n/**\n * Starting points for four common intents, as plain config objects. Spread one into your config,\n * rename its events to yours, override what you know better; see {@link presetSources} for where\n * each preset's numbers come from.\n *\n * @example\n * ```ts\n * import { presets } from 'oracular/presets';\n *\n * const config = {\n * episodeTimeout: 30 * 60 * 1000,\n * intents: {\n * buy: { ...presets.willBuy, outcome: 'order.placed' },\n * },\n * };\n * ```\n */\nexport const presets: Readonly<Record<PresetName, IntentConfig>> = {\n willBuy,\n upgrade,\n stuck,\n churn,\n};\n\n/**\n * Where each preset's numbers come from. Read it before trusting a preset: a tuned one carries its\n * sample size, a hand-written one carries nothing but reasoning.\n *\n * @example\n * ```ts\n * import { presetSources } from 'oracular/presets';\n * presetSources.willBuy.kind; // 'tuned'\n * ```\n */\nexport const presetSources: Readonly<Record<PresetName, PresetSource>> = {\n willBuy: {\n kind: 'tuned',\n note: 'oracular tune on a public e-commerce clickstream: one shop, May to September 2015, 2.7 million events, 4.8% of sessions ending with an order; fitted October 2026, the fitted 0.0477 and 2.32 rounded',\n episodes: 181_080,\n installs: 93_537,\n },\n upgrade: {\n kind: 'hand-written',\n note: 'the quickstart config from the documentation; no log behind it',\n episodes: null,\n installs: null,\n },\n stuck: {\n kind: 'hand-written',\n note: 'the onboarding-tip example from the documentation; no log behind it',\n episodes: null,\n installs: null,\n },\n churn: {\n kind: 'hand-written',\n note: 'the churn-save example from the documentation; no log behind it',\n episodes: null,\n installs: null,\n },\n};\n"]}
1
+ {"version":3,"sources":["../src/units.ts","../src/presets.ts"],"names":[],"mappings":"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","file":"presets.js","sourcesContent":["/** The units of {@link ms}: each takes a count and returns milliseconds. */\nexport interface Units {\n /** `n` seconds in milliseconds: `n × 1000`. */\n readonly seconds: (n: number) => number;\n /** `n` minutes in milliseconds: `n × 60 000`. */\n readonly minutes: (n: number) => number;\n /** `n` hours in milliseconds: `n × 3 600 000`. */\n readonly hours: (n: number) => number;\n /** `n` days in milliseconds: `n × 86 400 000`. */\n readonly days: (n: number) => number;\n /** `n` weeks in milliseconds: `n × 604 800 000`. */\n readonly weeks: (n: number) => number;\n}\n\n/**\n * Durations in milliseconds, the unit of `Date.now()`: `ms.days(7)` is `604800000`. Plain\n * multiplications and nothing else, for `halfLife`, `cooldown`, `episodeTimeout` and\n * `learning.halfLife` when your timestamps are in milliseconds. If your `at` is in another unit,\n * write durations in that unit instead: the engine only needs the two to agree.\n *\n * @example\n * ```ts\n * const config = {\n * episodeTimeout: ms.minutes(30),\n * intents: {\n * upgrade: {\n * prior: 0.05,\n * priorStrength: 10,\n * halfLife: ms.days(7),\n * signals: { 'pricing.viewed': 3 },\n * reveal: { cooldown: ms.weeks(2) },\n * },\n * },\n * };\n * ```\n */\nexport const ms: Units = {\n seconds: (n) => n * 1_000,\n minutes: (n) => n * 60_000,\n hours: (n) => n * 3_600_000,\n days: (n) => n * 86_400_000,\n weeks: (n) => n * 604_800_000,\n};\n","import type { IntentConfig, PresetName, PresetSource } from './types';\nimport { ms } from './units';\n\nexport type { PresetName, PresetSource } from './types';\n\n/**\n * A visitor about to buy. Tuned on a public e-commerce clickstream of 2.7 million events (see the\n * case study in the guide): one shop, one summer (May to September 2015), 181,080 sessions from\n * 93,537 visitors, 8,628 of them ending with an order, a session conversion of 4.8%; scored on as\n * many visitors the fit never saw. A measured starting point, not a universal number: a shop that\n * converts 1% or 20% of its sessions has a different prior and most likely a different weight.\n * Start here, run in shadow mode, and let `tune` replace these with your own numbers.\n *\n * - `prior` 0.048: the share of sessions that end with an order (fitted 0.0477).\n * - `priorStrength` 4: visitors differ a lot in how often they buy, so one visitor's own history\n * should outweigh the population after a handful of sessions.\n * - `halfLife` one hour: the best point of the grid; buying intent within a visit is a matter of\n * minutes, and tomorrow's visit starts over.\n * - `cart.added` 2.3: its measured lift (fitted 2.32). 58% of sessions with a cart add end with an\n * order, against 0.8% of those without. There is no weight for viewing a product: on that data a\n * view is in 99% of sessions and says nothing about buying. Add one only if your data disagrees.\n * - `cooldown` one day: after an order, or after acting on the intent, stay quiet for a day; the\n * half-life would otherwise make the default cooldown an hour.\n *\n * Rename `cart.added` and `checkout.completed` to your events by spreading.\n */\nconst willBuy: IntentConfig = {\n prior: 0.048,\n priorStrength: 4,\n halfLife: ms.hours(1),\n signals: { 'cart.added': 2.3 },\n outcome: 'checkout.completed',\n reveal: { cooldown: ms.days(1) },\n};\n\n/**\n * A user about to upgrade: the quickstart config, hand-written. Interest in a paid plan builds over\n * days, so the half-life is a week; the prior is a guess at the share of users who upgrade in a\n * week; `priorStrength` 10 means about six full-weight signals across sessions open the default\n * gate; the weights rank the signals by how directly they point at paying. Nobody has measured\n * these against a log.\n */\nconst upgrade: IntentConfig = {\n prior: 0.05,\n priorStrength: 10,\n halfLife: ms.days(7),\n signals: { 'pricing.viewed': 3, 'limit.hit': 2, 'export.clicked': 1 },\n outcome: 'upgrade.completed',\n};\n\n/**\n * A user stuck on a task, hand-written. Being stuck is a state of the current session, so the\n * half-life is ten minutes and the cooldown an hour; `saturate: false` because here repetition is\n * the evidence (the fifth undo in a row says more than the first); `task.completed` is strong\n * evidence against; the gate is raised to 0.6 because an unwanted tip is an interruption. Nobody\n * has measured these against a log.\n */\nconst stuck: IntentConfig = {\n prior: 0.1,\n priorStrength: 5,\n halfLife: ms.minutes(10),\n signals: { 'editor.undo': 1, 'search.empty': 2, 'help.opened': 3, 'task.completed': -4 },\n outcome: 'help.article.read',\n saturate: false,\n reveal: { threshold: 0.6, cooldown: ms.hours(1) },\n};\n\n/**\n * A user about to leave, hand-written. Churn builds over weeks, so the half-life is two weeks and\n * the prior strength 20: it should take a lot to call someone a churn risk. Exporting all their\n * data is the strongest signal; using a new feature counts against. `z` 1.64 asks for a one-sided\n * 95% bound before a save offer, because a discount has a real cost. Nobody has measured these\n * against a log.\n */\nconst churn: IntentConfig = {\n prior: 0.08,\n priorStrength: 20,\n halfLife: ms.days(14),\n signals: {\n 'session.short': 1,\n 'notifications.disabled': 3,\n 'export.all': 4,\n 'feature.new.used': -2,\n },\n outcome: 'account.cancelled',\n reveal: { threshold: 0.5, z: 1.64 },\n};\n\n/**\n * Starting points for four common intents, as plain config objects. Spread one into your config,\n * rename its events to yours, override what you know better; see {@link presetSources} for where\n * each preset's numbers come from.\n *\n * @example\n * ```ts\n * import { presets } from 'oracular/presets';\n *\n * const config = {\n * episodeTimeout: 30 * 60 * 1000,\n * intents: {\n * buy: { ...presets.willBuy, outcome: 'order.placed' },\n * },\n * };\n * ```\n */\n/** Frozen through and through: a preset is shared, so a change by one caller would reach every other. */\nfunction freeze<T>(value: T): T {\n if (typeof value === 'object' && value !== null) {\n for (const inner of Object.values(value)) freeze(inner);\n Object.freeze(value);\n }\n return value;\n}\n\nexport const presets: Readonly<Record<PresetName, IntentConfig>> = freeze({\n willBuy,\n upgrade,\n stuck,\n churn,\n});\n\n/**\n * Where each preset's numbers come from. Read it before trusting a preset: a tuned one carries its\n * sample size, a hand-written one carries nothing but reasoning.\n *\n * @example\n * ```ts\n * import { presetSources } from 'oracular/presets';\n * presetSources.willBuy.kind; // 'tuned'\n * ```\n */\nexport const presetSources: Readonly<Record<PresetName, PresetSource>> = freeze({\n willBuy: {\n kind: 'tuned',\n note: 'oracular tune on a public e-commerce clickstream: one shop, May to September 2015, 2.7 million events, 4.8% of sessions ending with an order; fitted October 2026, the fitted 0.0477 and 2.32 rounded',\n episodes: 181_080,\n installs: 93_537,\n },\n upgrade: {\n kind: 'hand-written',\n note: 'the quickstart config from the documentation; no log behind it',\n episodes: null,\n installs: null,\n },\n stuck: {\n kind: 'hand-written',\n note: 'the onboarding-tip example from the documentation; no log behind it',\n episodes: null,\n installs: null,\n },\n churn: {\n kind: 'hand-written',\n note: 'the churn-save example from the documentation; no log behind it',\n episodes: null,\n installs: null,\n },\n});\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oracular",
3
- "version": "0.8.1",
3
+ "version": "0.10.0",
4
4
  "description": "On-device intent engine. Your app learns when the user is ready. Nothing leaves the device.",
5
5
  "keywords": [
6
6
  "oracular",
@@ -89,8 +89,12 @@
89
89
  },
90
90
  "workspaces": [
91
91
  ".",
92
- "packages/*"
92
+ "packages/*",
93
+ "examples/react-vite"
93
94
  ],
95
+ "overrides": {
96
+ "@types/react": "^19.3.0"
97
+ },
94
98
  "scripts": {
95
99
  "build": "tsup && tsup --config tsup.presets.config.ts && tsup --config tsup.inspect.config.ts",
96
100
  "test": "vitest run",
@@ -102,12 +106,13 @@
102
106
  "size": "size-limit",
103
107
  "pack:check": "node scripts/pack-check.mjs",
104
108
  "check:core": "npm run lint && npm run typecheck && npm test && npm run build && npm run smoke && npm run size && npm run pack:check",
105
- "check": "npm run check:core",
109
+ "check": "npm run check:core && npm run check -w packages/react && npm run build -w examples/react-vite",
106
110
  "prepublishOnly": "npm run check:core",
107
111
  "docs:api": "typedoc",
108
112
  "docs:dev": "npm run docs:api && vitepress dev docs",
109
113
  "docs:build": "npm run docs:api && vitepress build docs",
110
114
  "docs:preview": "vitepress preview docs",
115
+ "docs:gif": "node scripts/record-inspector.mjs",
111
116
  "changeset": "changeset",
112
117
  "release:version": "changeset version && npm install --package-lock-only --ignore-scripts",
113
118
  "release": "node scripts/release.mjs"
@@ -120,6 +125,8 @@
120
125
  "@size-limit/preset-small-lib": "^12.1.0",
121
126
  "@types/node": "^22.20.5",
122
127
  "fast-check": "^4.10.2",
128
+ "gifenc": "1.0.3",
129
+ "playwright-core": "1.64.1",
123
130
  "publint": "^0.3.25",
124
131
  "size-limit": "^12.1.0",
125
132
  "tsup": "^8.5.1",
@@ -107,13 +107,14 @@
107
107
  "properties": {
108
108
  "threshold": {
109
109
  "type": "number",
110
- "minimum": 0,
111
- "maximum": 1,
112
- "description": "Probability the intent must reach. Default 0.5."
110
+ "exclusiveMinimum": 0,
111
+ "exclusiveMaximum": 1,
112
+ "description": "Probability the intent must reach, strictly between 0 and 1. Default 0.5."
113
113
  },
114
114
  "z": {
115
115
  "type": "number",
116
116
  "minimum": 0,
117
+ "maximum": 10,
117
118
  "description": "How many standard deviations below p must still clear the threshold. Default 1.28."
118
119
  },
119
120
  "cooldown": {
@@ -138,11 +139,24 @@
138
139
  },
139
140
  "gain": {
140
141
  "$ref": "#/$defs/bounded",
141
- "description": "A perfectly predictive signal converges to gain × |initial weight|. Default 2."
142
+ "description": "Caps learned magnitude at gain × |initial weight| for every lift option. Default 2."
142
143
  },
143
144
  "priorEpisodes": {
144
145
  "$ref": "#/$defs/bounded",
145
146
  "description": "Pseudo-count behind the developer's initial weight: how many observed episodes it takes to weigh as much as the prior. Default 10."
147
+ },
148
+ "lift": {
149
+ "type": "string",
150
+ "enum": [
151
+ "absolute-difference",
152
+ "relative-lift",
153
+ "risk-ratio",
154
+ "log-lift",
155
+ "odds-ratio",
156
+ "normalized-difference",
157
+ "association-rule"
158
+ ],
159
+ "description": "Association measure, converted to an evidence score in [-1, 1], neutral at zero. Ratios use (R - 1)/(R + 1), relative/log lift use x/(1 + abs(x)), and absolute/normalized differences keep their scale. Default normalized-difference preserves existing behavior. Association-rule compares with the overall outcome rate."
146
160
  }
147
161
  }
148
162
  }