oracular 0.1.1 → 0.2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#8](https://github.com/Yuribenjamin/oracular/pull/8) [`72c5f3c`](https://github.com/Yuribenjamin/oracular/commit/72c5f3c296c80ed99f8def26cdb120cdbfc239b3) Thanks [@Yuribenjamin](https://github.com/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.
8
+
9
+ ## 0.1.2
10
+
11
+ ### Patch Changes
12
+
13
+ - [#6](https://github.com/Yuribenjamin/oracular/pull/6) [`57d121f`](https://github.com/Yuribenjamin/oracular/commit/57d121f107f4ed92f2e59bf354cc6e19f705de1b) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - Docs corrections from the first production integration; new evaluate(), evidenceToReveal(), episodeClosed() and explain() filtering; default export condition.
14
+
3
15
  ## 0.1.1
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -97,14 +97,17 @@ Events happen inside **episodes** (sessions). An episode ends when you call `end
97
97
  A signal's weight is then a blend of your prior and the learned estimate:
98
98
 
99
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
100
+ nWith = hits + misses episodes with the signal
101
+ nWithout = episodes − nWith episodes without it
102
+ qWith = (hits + 1) / (nWith + 2) outcome rate with the signal
103
+ qWithout = (outcomes − hits + 1) / (nWithout + 2) outcome rate without it
104
+ lift = (qWith − qWithout) / (1 − qWithout) if qWith ≥ qWithout, else (qWith − qWithout) / qWithout → in [−1, 1]
105
+ learned = gain × |w₀| × lift 0 until the signal has history
106
+ nEff = (nWith × nWithout) / (nWith + nWithout) 0 if either side is 0
107
+ effective = (k × w₀ + nEff × learned) / (k + nEff) k = priorEpisodes
105
108
  ```
106
109
 
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.
110
+ `w₀` is your initial weight, `gain` (default 2) caps how far a perfectly predictive signal can grow, and `priorEpisodes` (default 10) is how much contrast 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. A signal is judged against the sessions in which it did *not* appear, so for an outcome that happens in 3% of sessions, a signal followed by it 30% of the time is strong evidence, not weak. And learning needs contrast: a signal that has never been absent has no counterfactual, so its weight stands until there are sessions without it. Nothing drifts after a single outcome.
108
111
 
109
112
  Learned counts never decay unless you set `learning.halfLife`.
110
113
 
@@ -223,7 +226,10 @@ endEpisode(state, at: number): EngineState
223
226
  predict(state, intent: string, at: number, opts?: RevealOptions): Prediction
224
227
  shouldReveal(state, intent: string, at: number, opts?: RevealOptions): boolean
225
228
  markRevealed(state, intent: string, at: number): EngineState
226
- explain(state, intent: string, at: number): SignalExplanation[]
229
+ explain(state, intent: string, at: number, opts?: { nonZero?: boolean; limit?: number }): SignalExplanation[]
230
+ evaluate(state, intent: string, at: number, opts?): { prediction, reveal, explanation } // all three, one decay pass
231
+ evidenceToReveal(intentConfig, opts?): number // pseudo-counts at which shouldReveal first becomes true
232
+ episodeClosed(prev, next): boolean // did an episode close between two states?
227
233
  serialize(state): string
228
234
  deserialize(json: string, opts?: { config?: EngineConfig; migrate?: Migrate }): EngineState
229
235
  STATE_VERSION // 1
@@ -327,13 +333,13 @@ After ten sessions in which `pricing.viewed` led to an upgrade six times out of
327
333
  ```ts
328
334
  explain(state, 'upgrade', Date.now());
329
335
  // [
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 },
336
+ // { signal: 'pricing.viewed', contribution: 3.12, impact: 0.16, initialWeight: 3, learnedWeight: 3.78, effectiveWeight: 3.13, hits: 6, misses: 1 },
337
+ // { signal: 'limit.hit', contribution: 2, impact: 0.10, initialWeight: 2, learnedWeight: 0, effectiveWeight: 2, hits: 0, misses: 0 },
338
+ // { signal: 'export.clicked', contribution: 0, impact: 0, initialWeight: 1, learnedWeight: 0.67, effectiveWeight: 1, hits: 7, misses: 3 },
333
339
  // ]
334
340
  ```
335
341
 
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.
342
+ Rows are sorted by the size of their impact. `limit.hit` has no history yet, so its effective weight is exactly the initial one. `pricing.viewed` was followed by an upgrade in six of its seven sessions while the three sessions without it saw one upgrade, so it learned a weight above the initial 3. `export.clicked` appeared in every session: it has no counterfactual, so whatever its hit rate, its weight stands at exactly the initial 1 until a session happens without it. See [Episodes & learning](https://yuribenjamin.github.io/oracular/guide/concepts/episodes-and-learning) for why learning needs contrast.
337
343
 
338
344
  ## Design rules
339
345
 
package/dist/index.cjs CHANGED
@@ -1,5 +1,33 @@
1
1
  'use strict';
2
2
 
3
+ // src/math.ts
4
+ function decayFactor(elapsed, halfLife) {
5
+ if (!(elapsed > 0) || !Number.isFinite(elapsed)) return 1;
6
+ return 2 ** (-elapsed / halfLife);
7
+ }
8
+ function betaMean(alpha, beta) {
9
+ return alpha / (alpha + beta);
10
+ }
11
+ function betaVariance(alpha, beta) {
12
+ const n = alpha + beta;
13
+ return alpha * beta / (n * n * (n + 1));
14
+ }
15
+ function betaSd(alpha, beta) {
16
+ return Math.sqrt(betaVariance(alpha, beta));
17
+ }
18
+ function clamp01(x) {
19
+ if (x < 0) return 0;
20
+ if (x > 1) return 1;
21
+ return x;
22
+ }
23
+ function laplace(successes, total) {
24
+ return (successes + 1) / (total + 2);
25
+ }
26
+ function lift(q, b) {
27
+ if (q >= b) return b >= 1 ? 0 : (q - b) / (1 - b);
28
+ return b <= 0 ? 0 : (q - b) / b;
29
+ }
30
+
3
31
  // src/util.ts
4
32
  function isRecord(value) {
5
33
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -18,6 +46,100 @@ function mapRecord(record2, fn) {
18
46
  return out;
19
47
  }
20
48
 
49
+ // src/intent.ts
50
+ function priorCounts(intent) {
51
+ return {
52
+ alpha0: intent.prior * intent.priorStrength,
53
+ beta0: (1 - intent.prior) * intent.priorStrength
54
+ };
55
+ }
56
+ function initIntent(intent) {
57
+ const { alpha0, beta0 } = priorCounts(intent);
58
+ return {
59
+ alpha: alpha0,
60
+ beta: beta0,
61
+ updatedAt: null,
62
+ contributions: {},
63
+ learned: {},
64
+ episodes: { hits: 0, misses: 0 },
65
+ learnedAt: null,
66
+ lastRevealedAt: null,
67
+ lastOutcomeAt: null,
68
+ revealCount: 0
69
+ };
70
+ }
71
+ function withContributions(state, intent, contributions) {
72
+ const { alpha0, beta0 } = priorCounts(intent);
73
+ let alpha = alpha0;
74
+ let beta = beta0;
75
+ for (const c of Object.values(contributions)) {
76
+ alpha += c.alpha;
77
+ beta += c.beta;
78
+ }
79
+ return { ...state, alpha, beta, contributions };
80
+ }
81
+ function decayIntent(state, intent, at) {
82
+ if (state.updatedAt === null) return { ...state, updatedAt: at };
83
+ const d = decayFactor(at - state.updatedAt, intent.halfLife);
84
+ const updatedAt = Math.max(state.updatedAt, at);
85
+ if (d === 1) return updatedAt === state.updatedAt ? state : { ...state, updatedAt };
86
+ const contributions = mapRecord(state.contributions, (c) => ({
87
+ alpha: c.alpha * d,
88
+ beta: c.beta * d
89
+ }));
90
+ return withContributions({ ...state, updatedAt }, intent, contributions);
91
+ }
92
+ function applySignal(state, intent, signal, weight) {
93
+ const prev = getOwn(state.contributions, signal) ?? { alpha: 0, beta: 0 };
94
+ const next = weight >= 0 ? { alpha: prev.alpha + weight, beta: prev.beta } : { alpha: prev.alpha, beta: prev.beta - weight };
95
+ return withContributions(state, intent, { ...state.contributions, [signal]: next });
96
+ }
97
+ function resetToPrior(state, intent) {
98
+ return withContributions(state, intent, {});
99
+ }
100
+
101
+ // src/learning.ts
102
+ var NONE = { hits: 0, misses: 0 };
103
+ function decayLearned(state, learning, at) {
104
+ if (state.learnedAt === null) return { ...state, learnedAt: at };
105
+ const learnedAt = Math.max(state.learnedAt, at);
106
+ const d = learning.halfLife === null ? 1 : decayFactor(at - state.learnedAt, learning.halfLife);
107
+ if (d === 1) return learnedAt === state.learnedAt ? state : { ...state, learnedAt };
108
+ return {
109
+ ...state,
110
+ learnedAt,
111
+ learned: mapRecord(state.learned, (r) => ({ hits: r.hits * d, misses: r.misses * d })),
112
+ episodes: { hits: state.episodes.hits * d, misses: state.episodes.misses * d }
113
+ };
114
+ }
115
+ function signalWeights(state, intent, learning, signal) {
116
+ const initial = getOwn(intent.signals, signal);
117
+ if (initial === void 0) {
118
+ throw new RangeError(`oracular: ${JSON.stringify(signal)} is not a signal of this intent`);
119
+ }
120
+ const record2 = getOwn(state.learned, signal) ?? NONE;
121
+ const nWith = record2.hits + record2.misses;
122
+ const qWith = laplace(record2.hits, nWith);
123
+ const totalEpisodes = state.episodes.hits + state.episodes.misses;
124
+ const nWithout = Math.max(0, totalEpisodes - nWith);
125
+ const outcomesWithout = Math.min(nWithout, Math.max(0, state.episodes.hits - record2.hits));
126
+ const qWithout = laplace(outcomesWithout, nWithout);
127
+ const learned = nWith === 0 ? 0 : learning.gain * Math.abs(initial) * lift(qWith, qWithout);
128
+ const nEff = nWith + nWithout === 0 ? 0 : nWith * nWithout / (nWith + nWithout);
129
+ const k = learning.priorEpisodes;
130
+ const effective = (k * initial + nEff * learned) / (k + nEff);
131
+ return { initial, learned, effective, hits: record2.hits, misses: record2.misses };
132
+ }
133
+ function recordEpisode(state, signals, outcome) {
134
+ const learned = { ...state.learned };
135
+ for (const signal of signals) {
136
+ const r = getOwn(learned, signal) ?? NONE;
137
+ learned[signal] = outcome ? { hits: r.hits + 1, misses: r.misses } : { hits: r.hits, misses: r.misses + 1 };
138
+ }
139
+ const episodes = outcome ? { hits: state.episodes.hits + 1, misses: state.episodes.misses } : { hits: state.episodes.hits, misses: state.episodes.misses + 1 };
140
+ return { ...state, learned, episodes };
141
+ }
142
+
21
143
  // src/config.ts
22
144
  var DEFAULT_THRESHOLD = 0.5;
23
145
  var DEFAULT_Z = 1.28;
@@ -35,6 +157,7 @@ var INTENT_KEYS = [
35
157
  ];
36
158
  var REVEAL_KEYS = ["threshold", "z", "cooldown"];
37
159
  var LEARNING_KEYS = ["halfLife", "gain", "priorEpisodes"];
160
+ var EXPLAIN_KEYS = ["nonZero", "limit"];
38
161
  function typeError(path, message) {
39
162
  throw new TypeError(`oracular: ${path} ${message}`);
40
163
  }
@@ -115,7 +238,7 @@ function normalizeLearning(value, path) {
115
238
  }
116
239
  return out;
117
240
  }
118
- function normalizeIntent(value, path) {
241
+ function normalizeIntentConfig(value, path) {
119
242
  const record2 = expectRecord(value, path);
120
243
  expectKeys(record2, INTENT_KEYS, path);
121
244
  const signalsRecord = expectRecord(record2.signals, `${path}.signals`);
@@ -157,7 +280,10 @@ function normalizeConfig(value) {
157
280
  const intents = {};
158
281
  for (const name of names) {
159
282
  expectKeyName(name, "config.intents");
160
- intents[name] = normalizeIntent(intentsRecord[name], `config.intents[${JSON.stringify(name)}]`);
283
+ intents[name] = normalizeIntentConfig(
284
+ intentsRecord[name],
285
+ `config.intents[${JSON.stringify(name)}]`
286
+ );
161
287
  }
162
288
  const out = { intents };
163
289
  if (record2.episodeTimeout !== void 0) {
@@ -169,12 +295,43 @@ function normalizeConfig(value) {
169
295
  out.reveal = normalizeRevealOptions(record2.reveal, "config.reveal");
170
296
  return out;
171
297
  }
172
- function resolveReveal(config, intent, opts) {
173
- const o = opts === void 0 ? {} : normalizeRevealOptions(opts, "options");
298
+ function normalizeExplainOptions(value, path) {
299
+ const record2 = expectRecord(value, path);
300
+ expectKeys(record2, EXPLAIN_KEYS, path);
301
+ const out = {};
302
+ if (record2.nonZero !== void 0) {
303
+ if (typeof record2.nonZero !== "boolean") typeError(`${path}.nonZero`, "must be a boolean");
304
+ out.nonZero = record2.nonZero;
305
+ }
306
+ if (record2.limit !== void 0) {
307
+ const n = expectNumber(record2.limit, `${path}.limit`);
308
+ if (!Number.isInteger(n) || n < 0)
309
+ rangeError(`${path}.limit`, "must be a non-negative integer");
310
+ out.limit = n;
311
+ }
312
+ return out;
313
+ }
314
+ function splitEvaluateOptions(value, path) {
315
+ if (value === void 0) return { reveal: {}, explain: {} };
316
+ const record2 = expectRecord(value, path);
317
+ expectKeys(record2, [...REVEAL_KEYS, ...EXPLAIN_KEYS], path);
318
+ const reveal = {};
319
+ const explain2 = {};
320
+ for (const [key, v] of Object.entries(record2)) {
321
+ if (REVEAL_KEYS.includes(key)) reveal[key] = v;
322
+ else explain2[key] = v;
323
+ }
324
+ return {
325
+ reveal: normalizeRevealOptions(reveal, path),
326
+ explain: normalizeExplainOptions(explain2, path)
327
+ };
328
+ }
329
+ function resolveReveal(config, intent, opts, normalized = false) {
330
+ const o = opts === void 0 ? {} : normalized ? opts : normalizeRevealOptions(opts, "options");
174
331
  return {
175
- threshold: o.threshold ?? intent.reveal?.threshold ?? config.reveal?.threshold ?? DEFAULT_THRESHOLD,
176
- z: o.z ?? intent.reveal?.z ?? config.reveal?.z ?? DEFAULT_Z,
177
- cooldown: o.cooldown ?? intent.reveal?.cooldown ?? config.reveal?.cooldown ?? intent.halfLife
332
+ threshold: o.threshold ?? intent.reveal?.threshold ?? config?.reveal?.threshold ?? DEFAULT_THRESHOLD,
333
+ z: o.z ?? intent.reveal?.z ?? config?.reveal?.z ?? DEFAULT_Z,
334
+ cooldown: o.cooldown ?? intent.reveal?.cooldown ?? config?.reveal?.cooldown ?? intent.halfLife
178
335
  };
179
336
  }
180
337
  function resolveLearning(config) {
@@ -197,124 +354,6 @@ function validateEvent(event) {
197
354
  return { name, at };
198
355
  }
199
356
 
200
- // src/math.ts
201
- function decayFactor(elapsed, halfLife) {
202
- if (!(elapsed > 0) || !Number.isFinite(elapsed)) return 1;
203
- return 2 ** (-elapsed / halfLife);
204
- }
205
- function betaMean(alpha, beta) {
206
- return alpha / (alpha + beta);
207
- }
208
- function betaVariance(alpha, beta) {
209
- const n = alpha + beta;
210
- return alpha * beta / (n * n * (n + 1));
211
- }
212
- function betaSd(alpha, beta) {
213
- return Math.sqrt(betaVariance(alpha, beta));
214
- }
215
- function clamp01(x) {
216
- if (x < 0) return 0;
217
- if (x > 1) return 1;
218
- return x;
219
- }
220
- function laplace(successes, total) {
221
- return (successes + 1) / (total + 2);
222
- }
223
- function lift(q, b) {
224
- if (q >= b) return b >= 1 ? 0 : (q - b) / (1 - b);
225
- return b <= 0 ? 0 : (q - b) / b;
226
- }
227
-
228
- // src/intent.ts
229
- function priorCounts(intent) {
230
- return {
231
- alpha0: intent.prior * intent.priorStrength,
232
- beta0: (1 - intent.prior) * intent.priorStrength
233
- };
234
- }
235
- function initIntent(intent) {
236
- const { alpha0, beta0 } = priorCounts(intent);
237
- return {
238
- alpha: alpha0,
239
- beta: beta0,
240
- updatedAt: null,
241
- contributions: {},
242
- learned: {},
243
- episodes: { hits: 0, misses: 0 },
244
- learnedAt: null,
245
- lastRevealedAt: null,
246
- lastOutcomeAt: null,
247
- revealCount: 0
248
- };
249
- }
250
- function withContributions(state, intent, contributions) {
251
- const { alpha0, beta0 } = priorCounts(intent);
252
- let alpha = alpha0;
253
- let beta = beta0;
254
- for (const c of Object.values(contributions)) {
255
- alpha += c.alpha;
256
- beta += c.beta;
257
- }
258
- return { ...state, alpha, beta, contributions };
259
- }
260
- function decayIntent(state, intent, at) {
261
- if (state.updatedAt === null) return { ...state, updatedAt: at };
262
- const d = decayFactor(at - state.updatedAt, intent.halfLife);
263
- const updatedAt = Math.max(state.updatedAt, at);
264
- if (d === 1) return updatedAt === state.updatedAt ? state : { ...state, updatedAt };
265
- const contributions = mapRecord(state.contributions, (c) => ({
266
- alpha: c.alpha * d,
267
- beta: c.beta * d
268
- }));
269
- return withContributions({ ...state, updatedAt }, intent, contributions);
270
- }
271
- function applySignal(state, intent, signal, weight) {
272
- const prev = getOwn(state.contributions, signal) ?? { alpha: 0, beta: 0 };
273
- const next = weight >= 0 ? { alpha: prev.alpha + weight, beta: prev.beta } : { alpha: prev.alpha, beta: prev.beta - weight };
274
- return withContributions(state, intent, { ...state.contributions, [signal]: next });
275
- }
276
- function resetToPrior(state, intent) {
277
- return withContributions(state, intent, {});
278
- }
279
-
280
- // src/learning.ts
281
- var NONE = { hits: 0, misses: 0 };
282
- function decayLearned(state, learning, at) {
283
- if (state.learnedAt === null) return { ...state, learnedAt: at };
284
- const learnedAt = Math.max(state.learnedAt, at);
285
- const d = learning.halfLife === null ? 1 : decayFactor(at - state.learnedAt, learning.halfLife);
286
- if (d === 1) return learnedAt === state.learnedAt ? state : { ...state, learnedAt };
287
- return {
288
- ...state,
289
- learnedAt,
290
- learned: mapRecord(state.learned, (r) => ({ hits: r.hits * d, misses: r.misses * d })),
291
- episodes: { hits: state.episodes.hits * d, misses: state.episodes.misses * d }
292
- };
293
- }
294
- function signalWeights(state, intent, learning, signal) {
295
- const initial = getOwn(intent.signals, signal);
296
- if (initial === void 0) {
297
- throw new RangeError(`oracular: ${JSON.stringify(signal)} is not a signal of this intent`);
298
- }
299
- const record2 = getOwn(state.learned, signal) ?? NONE;
300
- const n = record2.hits + record2.misses;
301
- const q = laplace(record2.hits, n);
302
- const b = laplace(state.episodes.hits, state.episodes.hits + state.episodes.misses);
303
- const learned = n === 0 ? 0 : learning.gain * Math.abs(initial) * lift(q, b);
304
- const k = learning.priorEpisodes;
305
- const effective = (k * initial + n * learned) / (k + n);
306
- return { initial, learned, effective, hits: record2.hits, misses: record2.misses };
307
- }
308
- function recordEpisode(state, signals, outcome) {
309
- const learned = { ...state.learned };
310
- for (const signal of signals) {
311
- const r = getOwn(learned, signal) ?? NONE;
312
- learned[signal] = outcome ? { hits: r.hits + 1, misses: r.misses } : { hits: r.hits, misses: r.misses + 1 };
313
- }
314
- const episodes = outcome ? { hits: state.episodes.hits + 1, misses: state.episodes.misses } : { hits: state.episodes.hits, misses: state.episodes.misses + 1 };
315
- return { ...state, learned, episodes };
316
- }
317
-
318
357
  // src/episode.ts
319
358
  function emptyEpisode() {
320
359
  return { startedAt: null, lastEventAt: null, intents: {} };
@@ -374,20 +413,42 @@ function getIntentState(state, name, intent) {
374
413
  return getOwn(state.intents, name) ?? initIntent(intent);
375
414
  }
376
415
 
377
- // src/explain.ts
378
- function explain(state, intent, at) {
379
- const s = requireState(state);
380
- const name = expectName(intent, "intent");
381
- const t = expectTimestamp(at, "at");
382
- const config = requireIntentConfig(s, name);
383
- const learning = resolveLearning(s.config);
384
- let is = decayIntent(getIntentState(s, name, config), config, t);
385
- is = decayLearned(is, learning, t);
416
+ // src/assess.ts
417
+ function assessIntent(state, name, intent, learning, at) {
418
+ return decayLearned(decayIntent(getIntentState(state, name, intent), intent, at), learning, at);
419
+ }
420
+ function cooldownEnd(since, cooldown, at) {
421
+ if (since === null) return null;
422
+ const end = since + cooldown;
423
+ return at < end ? end : null;
424
+ }
425
+ function buildPrediction(is, name, reveal, at) {
426
+ const p = betaMean(is.alpha, is.beta);
427
+ const sd = betaSd(is.alpha, is.beta);
428
+ const revealEnd = cooldownEnd(is.lastRevealedAt, reveal.cooldown, at);
429
+ const outcomeEnd = cooldownEnd(is.lastOutcomeAt, reveal.cooldown, at);
430
+ const suppressedUntil = revealEnd === null ? outcomeEnd : outcomeEnd === null ? revealEnd : Math.max(revealEnd, outcomeEnd);
431
+ return {
432
+ intent: name,
433
+ p,
434
+ sd,
435
+ lower: clamp01(p - reveal.z * sd),
436
+ confidence: clamp01(1 - 2 * sd),
437
+ evidence: is.alpha + is.beta,
438
+ revealed: revealEnd !== null,
439
+ revealedAt: is.lastRevealedAt,
440
+ suppressedUntil
441
+ };
442
+ }
443
+ function decideReveal(prediction, reveal) {
444
+ return prediction.suppressedUntil === null && prediction.p >= reveal.threshold && prediction.lower >= reveal.threshold;
445
+ }
446
+ function buildExplanation(is, intent, learning, opts) {
386
447
  const p = betaMean(is.alpha, is.beta);
387
448
  const out = [];
388
- for (const signal of Object.keys(config.signals)) {
449
+ for (const signal of Object.keys(intent.signals)) {
389
450
  const c = getOwn(is.contributions, signal) ?? { alpha: 0, beta: 0 };
390
- const w = signalWeights(is, config, learning, signal);
451
+ const w = signalWeights(is, intent, learning, signal);
391
452
  const without = betaMean(is.alpha - c.alpha, is.beta - c.beta);
392
453
  out.push({
393
454
  signal,
@@ -401,7 +462,8 @@ function explain(state, intent, at) {
401
462
  });
402
463
  }
403
464
  out.sort((a, b) => Math.abs(b.impact) - Math.abs(a.impact) || compare(a.signal, b.signal));
404
- return out;
465
+ const filtered = opts.nonZero ? out.filter((row) => row.contribution !== 0) : out;
466
+ return opts.limit === void 0 ? filtered : filtered.slice(0, opts.limit);
405
467
  }
406
468
  function compare(a, b) {
407
469
  if (a < b) return -1;
@@ -409,6 +471,62 @@ function compare(a, b) {
409
471
  return 0;
410
472
  }
411
473
 
474
+ // src/evaluate.ts
475
+ function evaluate(state, intent, at, opts) {
476
+ const s = requireState(state);
477
+ const name = expectName(intent, "intent");
478
+ const t = expectTimestamp(at, "at");
479
+ const config = requireIntentConfig(s, name);
480
+ const split = splitEvaluateOptions(opts, "options");
481
+ const reveal = resolveReveal(s.config, config, split.reveal, true);
482
+ const learning = resolveLearning(s.config);
483
+ const is = assessIntent(s, name, config, learning, t);
484
+ const prediction = buildPrediction(is, name, reveal, t);
485
+ return {
486
+ prediction,
487
+ reveal: decideReveal(prediction, reveal),
488
+ explanation: buildExplanation(is, config, learning, split.explain)
489
+ };
490
+ }
491
+
492
+ // src/evidence.ts
493
+ function evidenceToReveal(intent, opts) {
494
+ const config = normalizeIntentConfig(intent, "intent");
495
+ const { threshold, z } = resolveReveal(void 0, config, opts);
496
+ const { alpha0, beta0 } = priorCounts(config);
497
+ const open = (w) => {
498
+ const alpha = alpha0 + w;
499
+ const p = betaMean(alpha, beta0);
500
+ return p >= threshold && clamp01(p - z * betaSd(alpha, beta0)) >= threshold;
501
+ };
502
+ if (threshold >= 1) return Number.POSITIVE_INFINITY;
503
+ if (open(0)) return 0;
504
+ let lo = 0;
505
+ let hi = 1;
506
+ while (!open(hi)) {
507
+ lo = hi;
508
+ hi *= 2;
509
+ if (hi > 1e15) return Number.POSITIVE_INFINITY;
510
+ }
511
+ for (let i = 0; i < 200 && hi - lo > 1e-9; i++) {
512
+ const mid = (lo + hi) / 2;
513
+ if (open(mid)) hi = mid;
514
+ else lo = mid;
515
+ }
516
+ return hi;
517
+ }
518
+
519
+ // src/explain.ts
520
+ function explain(state, intent, at, opts) {
521
+ const s = requireState(state);
522
+ const name = expectName(intent, "intent");
523
+ const t = expectTimestamp(at, "at");
524
+ const config = requireIntentConfig(s, name);
525
+ const filters = opts === void 0 ? {} : normalizeExplainOptions(opts, "options");
526
+ const learning = resolveLearning(s.config);
527
+ return buildExplanation(assessIntent(s, name, config, learning, t), config, learning, filters);
528
+ }
529
+
412
530
  // src/observe.ts
413
531
  function observe(state, event) {
414
532
  let s = requireState(state);
@@ -460,6 +578,11 @@ function closeEpisode(state, at) {
460
578
  }
461
579
  return { ...state, intents, episode: emptyEpisode() };
462
580
  }
581
+ function episodeClosed(prev, next) {
582
+ const a = requireState(prev);
583
+ const b = requireState(next);
584
+ return a.episode.startedAt !== null && b.episode.startedAt !== a.episode.startedAt;
585
+ }
463
586
 
464
587
  // src/predict.ts
465
588
  function predict(state, intent, at, opts) {
@@ -469,35 +592,14 @@ function predict(state, intent, at, opts) {
469
592
  const config = requireIntentConfig(s, name);
470
593
  const reveal = resolveReveal(s.config, config, opts);
471
594
  const is = decayIntent(getIntentState(s, name, config), config, t);
472
- const p = betaMean(is.alpha, is.beta);
473
- const sd = betaSd(is.alpha, is.beta);
474
- const revealEnd = cooldownEnd(is.lastRevealedAt, reveal.cooldown, t);
475
- const outcomeEnd = cooldownEnd(is.lastOutcomeAt, reveal.cooldown, t);
476
- const suppressedUntil = revealEnd === null ? outcomeEnd : outcomeEnd === null ? revealEnd : Math.max(revealEnd, outcomeEnd);
477
- return {
478
- intent: name,
479
- p,
480
- sd,
481
- lower: clamp01(p - reveal.z * sd),
482
- confidence: clamp01(1 - 2 * sd),
483
- evidence: is.alpha + is.beta,
484
- revealed: revealEnd !== null,
485
- revealedAt: is.lastRevealedAt,
486
- suppressedUntil
487
- };
488
- }
489
- function cooldownEnd(since, cooldown, at) {
490
- if (since === null) return null;
491
- const end = since + cooldown;
492
- return at < end ? end : null;
595
+ return buildPrediction(is, name, reveal, t);
493
596
  }
494
597
  function shouldReveal(state, intent, at, opts) {
495
598
  const s = requireState(state);
496
599
  const name = expectName(intent, "intent");
497
600
  const config = requireIntentConfig(s, name);
498
- const { threshold } = resolveReveal(s.config, config, opts);
499
- const prediction = predict(s, name, at, opts);
500
- return prediction.suppressedUntil === null && prediction.p >= threshold && prediction.lower >= threshold;
601
+ const reveal = resolveReveal(s.config, config, opts);
602
+ return decideReveal(predict(s, name, at, opts), reveal);
501
603
  }
502
604
  function markRevealed(state, intent, at) {
503
605
  const s = requireState(state);
@@ -688,6 +790,9 @@ exports.STATE_VERSION = STATE_VERSION;
688
790
  exports.createState = createState;
689
791
  exports.deserialize = deserialize;
690
792
  exports.endEpisode = endEpisode;
793
+ exports.episodeClosed = episodeClosed;
794
+ exports.evaluate = evaluate;
795
+ exports.evidenceToReveal = evidenceToReveal;
691
796
  exports.explain = explain;
692
797
  exports.markRevealed = markRevealed;
693
798
  exports.observe = observe;