oracular 0.1.1 → 0.1.2

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/index.d.ts CHANGED
@@ -242,6 +242,37 @@ interface SignalExplanation {
242
242
  hits: number;
243
243
  misses: number;
244
244
  }
245
+ /**
246
+ * Filters for `explain` and `evaluate`. By default every declared signal is returned.
247
+ *
248
+ * @example
249
+ * ```ts
250
+ * explain(state, 'upgrade', now, { nonZero: true, limit: 3 }); // the three signals doing the most
251
+ * ```
252
+ */
253
+ interface ExplainOptions {
254
+ /** Only signals that currently hold evidence (a non-zero contribution). Default false. */
255
+ nonZero?: boolean;
256
+ /** At most this many rows, taken from the top of the impact-sorted list. Default: all. */
257
+ limit?: number;
258
+ }
259
+ /** Reveal overrides and explain filters together, for `evaluate`. */
260
+ interface EvaluateOptions extends RevealOptions, ExplainOptions {
261
+ }
262
+ /**
263
+ * What `evaluate` returns: the same values `predict`, `shouldReveal` and `explain` would give
264
+ * for the same inputs, computed from a single decay pass.
265
+ *
266
+ * @example
267
+ * ```ts
268
+ * const { prediction, reveal, explanation } = evaluate(state, 'upgrade', Date.now());
269
+ * ```
270
+ */
271
+ interface Evaluation {
272
+ prediction: Prediction;
273
+ reveal: boolean;
274
+ explanation: SignalExplanation[];
275
+ }
245
276
  /**
246
277
  * Turns a stored state of an older version into a current `EngineState`. Receives the parsed
247
278
  * JSON as-is and the version it declares.
@@ -270,6 +301,52 @@ interface DeserializeOptions {
270
301
  migrate?: Migrate;
271
302
  }
272
303
 
304
+ /**
305
+ * {@link predict}, {@link shouldReveal} and {@link explain} in one call, from a single decay
306
+ * pass over the intent. The three results are identical to what the separate functions return
307
+ * for the same inputs; this is the one to call on every event when you need all of them.
308
+ * Read-only.
309
+ *
310
+ * @param state - The current state. Not mutated.
311
+ * @param intent - Name of an intent declared in the config.
312
+ * @param at - The time to evaluate at.
313
+ * @param opts - Reveal overrides (`threshold`, `z`, `cooldown`) and explain filters (`nonZero`,
314
+ * `limit`) in one object.
315
+ * @returns `prediction` (as `predict`), `reveal` (as `shouldReveal`) and `explanation` (as
316
+ * `explain` with the same filters).
317
+ * @throws `RangeError` when `intent` is not declared in the config.
318
+ * @throws `TypeError` when `state` is not an engine state, `at` is not finite, or `opts` is
319
+ * malformed.
320
+ * @example
321
+ * ```ts
322
+ * const { prediction, reveal, explanation } = evaluate(state, 'upgrade', Date.now(), { limit: 3 });
323
+ * if (reveal) showOffer(explanation[0]?.signal);
324
+ * ```
325
+ */
326
+ declare function evaluate(state: EngineState, intent: string, at: number, opts?: EvaluateOptions): Evaluation;
327
+
328
+ /**
329
+ * How much positive evidence an intent needs before {@link shouldReveal} first becomes true:
330
+ * the smallest pseudo-count `w` such that Beta(α₀ + w, β₀) clears both `p ≥ threshold` and
331
+ * `p − z·sd ≥ threshold`. Divide by a signal's weight and round up for the number of identical
332
+ * signals, fired together, that open the gate. Solved numerically to 1e-9.
333
+ *
334
+ * Cooldowns and decay are not part of the answer: this is the evidence needed at one instant.
335
+ *
336
+ * @param intent - One intent's config, validated as `createState` would.
337
+ * @param opts - `threshold` and `z` overrides, resolved as call → intent → defaults (there is
338
+ * no engine config here). `cooldown` is accepted and ignored.
339
+ * @returns The pseudo-count sum; 0 when the prior alone already clears the gate; `Infinity`
340
+ * when no finite amount of evidence can (threshold 1).
341
+ * @throws `TypeError` or `RangeError` when `intent` or `opts` is invalid.
342
+ * @example
343
+ * ```ts
344
+ * const needed = evidenceToReveal(config.intents.upgrade); // ≈ 15.1 for the quickstart config
345
+ * Math.ceil(needed / 3); // 6 signals of weight 3
346
+ * ```
347
+ */
348
+ declare function evidenceToReveal(intent: IntentConfig, opts?: RevealOptions): number;
349
+
273
350
  /**
274
351
  * Why the intent's p is what it is at time `at`: one entry per declared signal, the ones that
275
352
  * matter most first. `contribution` is the signed pseudo-count the signal currently holds after
@@ -280,18 +357,21 @@ interface DeserializeOptions {
280
357
  * @param state - The current state. Not mutated.
281
358
  * @param intent - Name of an intent declared in the config.
282
359
  * @param at - The time to evaluate at; contributions and learned counts are decayed to it.
360
+ * @param opts - Optional filters: `nonZero` keeps only signals that currently hold evidence,
361
+ * `limit` caps the number of rows. Default: every declared signal.
283
362
  * @returns One {@link SignalExplanation} per declared signal, sorted by |impact| descending,
284
- * then by name. Empty for an intent that declares no signals.
363
+ * then by name, then filtered by `opts`. Empty for an intent that declares no signals.
285
364
  * @throws `RangeError` when `intent` is not declared in the config.
286
- * @throws `TypeError` when `state` is not an engine state or `at` is not finite.
365
+ * @throws `TypeError` when `state` is not an engine state, `at` is not finite, or `opts` is
366
+ * malformed.
287
367
  * @example
288
368
  * ```ts
289
- * for (const row of explain(state, 'upgrade', Date.now())) {
369
+ * for (const row of explain(state, 'upgrade', Date.now(), { nonZero: true })) {
290
370
  * console.log(row.signal, row.impact.toFixed(2), row.effectiveWeight.toFixed(2));
291
371
  * }
292
372
  * ```
293
373
  */
294
- declare function explain(state: EngineState, intent: string, at: number): SignalExplanation[];
374
+ declare function explain(state: EngineState, intent: string, at: number, opts?: ExplainOptions): SignalExplanation[];
295
375
 
296
376
  /**
297
377
  * Feeds one event to the engine.
@@ -339,6 +419,24 @@ declare function observe(state: EngineState, event: IntentEvent): EngineState;
339
419
  * ```
340
420
  */
341
421
  declare function endEpisode(state: EngineState, at: number): EngineState;
422
+ /**
423
+ * Whether an episode closed between two states: `prev` had an open episode and `next` either
424
+ * has none or has a different one. Useful after `observe` or `endEpisode` to react to a
425
+ * session ending, for example to persist, or to read the misses it produced as
426
+ * `next.intents[x].episodes.misses - prev.intents[x].episodes.misses`.
427
+ *
428
+ * @param prev - The state before the operation.
429
+ * @param next - The state after it.
430
+ * @returns True when `prev.episode.startedAt` is not null and differs from
431
+ * `next.episode.startedAt`.
432
+ * @throws `TypeError` when either argument is not an engine state.
433
+ * @example
434
+ * ```ts
435
+ * const next = observe(state, event);
436
+ * if (episodeClosed(state, next)) persist(next);
437
+ * ```
438
+ */
439
+ declare function episodeClosed(prev: EngineState, next: EngineState): boolean;
342
440
 
343
441
  /**
344
442
  * The intent's current estimate at time `at`: the Beta mean p, its standard deviation, the
@@ -473,4 +571,4 @@ declare function deserialize(json: string, opts?: DeserializeOptions): EngineSta
473
571
  */
474
572
  declare function createState(config: EngineConfig): EngineState;
475
573
 
476
- export { type DeserializeOptions, type EngineConfig, type EngineState, type EpisodeIntentRecord, type EpisodeState, type IntentConfig, type IntentEvent, type IntentState, type LearningConfig, type Migrate, type Prediction, type RevealOptions, STATE_VERSION, type SignalContribution, type SignalExplanation, type SignalRecord, createState, deserialize, endEpisode, explain, markRevealed, observe, predict, serialize, shouldReveal };
574
+ export { type DeserializeOptions, type EngineConfig, type EngineState, type EpisodeIntentRecord, type EpisodeState, type EvaluateOptions, type Evaluation, type ExplainOptions, type IntentConfig, type IntentEvent, type IntentState, type LearningConfig, type Migrate, type Prediction, type RevealOptions, STATE_VERSION, type SignalContribution, type SignalExplanation, type SignalRecord, createState, deserialize, endEpisode, episodeClosed, evaluate, evidenceToReveal, explain, markRevealed, observe, predict, serialize, shouldReveal };
package/dist/index.js CHANGED
@@ -1,3 +1,31 @@
1
+ // src/math.ts
2
+ function decayFactor(elapsed, halfLife) {
3
+ if (!(elapsed > 0) || !Number.isFinite(elapsed)) return 1;
4
+ return 2 ** (-elapsed / halfLife);
5
+ }
6
+ function betaMean(alpha, beta) {
7
+ return alpha / (alpha + beta);
8
+ }
9
+ function betaVariance(alpha, beta) {
10
+ const n = alpha + beta;
11
+ return alpha * beta / (n * n * (n + 1));
12
+ }
13
+ function betaSd(alpha, beta) {
14
+ return Math.sqrt(betaVariance(alpha, beta));
15
+ }
16
+ function clamp01(x) {
17
+ if (x < 0) return 0;
18
+ if (x > 1) return 1;
19
+ return x;
20
+ }
21
+ function laplace(successes, total) {
22
+ return (successes + 1) / (total + 2);
23
+ }
24
+ function lift(q, b) {
25
+ if (q >= b) return b >= 1 ? 0 : (q - b) / (1 - b);
26
+ return b <= 0 ? 0 : (q - b) / b;
27
+ }
28
+
1
29
  // src/util.ts
2
30
  function isRecord(value) {
3
31
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -16,6 +44,96 @@ function mapRecord(record2, fn) {
16
44
  return out;
17
45
  }
18
46
 
47
+ // src/intent.ts
48
+ function priorCounts(intent) {
49
+ return {
50
+ alpha0: intent.prior * intent.priorStrength,
51
+ beta0: (1 - intent.prior) * intent.priorStrength
52
+ };
53
+ }
54
+ function initIntent(intent) {
55
+ const { alpha0, beta0 } = priorCounts(intent);
56
+ return {
57
+ alpha: alpha0,
58
+ beta: beta0,
59
+ updatedAt: null,
60
+ contributions: {},
61
+ learned: {},
62
+ episodes: { hits: 0, misses: 0 },
63
+ learnedAt: null,
64
+ lastRevealedAt: null,
65
+ lastOutcomeAt: null,
66
+ revealCount: 0
67
+ };
68
+ }
69
+ function withContributions(state, intent, contributions) {
70
+ const { alpha0, beta0 } = priorCounts(intent);
71
+ let alpha = alpha0;
72
+ let beta = beta0;
73
+ for (const c of Object.values(contributions)) {
74
+ alpha += c.alpha;
75
+ beta += c.beta;
76
+ }
77
+ return { ...state, alpha, beta, contributions };
78
+ }
79
+ function decayIntent(state, intent, at) {
80
+ if (state.updatedAt === null) return { ...state, updatedAt: at };
81
+ const d = decayFactor(at - state.updatedAt, intent.halfLife);
82
+ const updatedAt = Math.max(state.updatedAt, at);
83
+ if (d === 1) return updatedAt === state.updatedAt ? state : { ...state, updatedAt };
84
+ const contributions = mapRecord(state.contributions, (c) => ({
85
+ alpha: c.alpha * d,
86
+ beta: c.beta * d
87
+ }));
88
+ return withContributions({ ...state, updatedAt }, intent, contributions);
89
+ }
90
+ function applySignal(state, intent, signal, weight) {
91
+ const prev = getOwn(state.contributions, signal) ?? { alpha: 0, beta: 0 };
92
+ const next = weight >= 0 ? { alpha: prev.alpha + weight, beta: prev.beta } : { alpha: prev.alpha, beta: prev.beta - weight };
93
+ return withContributions(state, intent, { ...state.contributions, [signal]: next });
94
+ }
95
+ function resetToPrior(state, intent) {
96
+ return withContributions(state, intent, {});
97
+ }
98
+
99
+ // src/learning.ts
100
+ var NONE = { hits: 0, misses: 0 };
101
+ function decayLearned(state, learning, at) {
102
+ if (state.learnedAt === null) return { ...state, learnedAt: at };
103
+ const learnedAt = Math.max(state.learnedAt, at);
104
+ const d = learning.halfLife === null ? 1 : decayFactor(at - state.learnedAt, learning.halfLife);
105
+ if (d === 1) return learnedAt === state.learnedAt ? state : { ...state, learnedAt };
106
+ return {
107
+ ...state,
108
+ learnedAt,
109
+ learned: mapRecord(state.learned, (r) => ({ hits: r.hits * d, misses: r.misses * d })),
110
+ episodes: { hits: state.episodes.hits * d, misses: state.episodes.misses * d }
111
+ };
112
+ }
113
+ function signalWeights(state, intent, learning, signal) {
114
+ const initial = getOwn(intent.signals, signal);
115
+ if (initial === void 0) {
116
+ throw new RangeError(`oracular: ${JSON.stringify(signal)} is not a signal of this intent`);
117
+ }
118
+ const record2 = getOwn(state.learned, signal) ?? NONE;
119
+ const n = record2.hits + record2.misses;
120
+ const q = laplace(record2.hits, n);
121
+ const b = laplace(state.episodes.hits, state.episodes.hits + state.episodes.misses);
122
+ const learned = n === 0 ? 0 : learning.gain * Math.abs(initial) * lift(q, b);
123
+ const k = learning.priorEpisodes;
124
+ const effective = (k * initial + n * learned) / (k + n);
125
+ return { initial, learned, effective, hits: record2.hits, misses: record2.misses };
126
+ }
127
+ function recordEpisode(state, signals, outcome) {
128
+ const learned = { ...state.learned };
129
+ for (const signal of signals) {
130
+ const r = getOwn(learned, signal) ?? NONE;
131
+ learned[signal] = outcome ? { hits: r.hits + 1, misses: r.misses } : { hits: r.hits, misses: r.misses + 1 };
132
+ }
133
+ const episodes = outcome ? { hits: state.episodes.hits + 1, misses: state.episodes.misses } : { hits: state.episodes.hits, misses: state.episodes.misses + 1 };
134
+ return { ...state, learned, episodes };
135
+ }
136
+
19
137
  // src/config.ts
20
138
  var DEFAULT_THRESHOLD = 0.5;
21
139
  var DEFAULT_Z = 1.28;
@@ -33,6 +151,7 @@ var INTENT_KEYS = [
33
151
  ];
34
152
  var REVEAL_KEYS = ["threshold", "z", "cooldown"];
35
153
  var LEARNING_KEYS = ["halfLife", "gain", "priorEpisodes"];
154
+ var EXPLAIN_KEYS = ["nonZero", "limit"];
36
155
  function typeError(path, message) {
37
156
  throw new TypeError(`oracular: ${path} ${message}`);
38
157
  }
@@ -113,7 +232,7 @@ function normalizeLearning(value, path) {
113
232
  }
114
233
  return out;
115
234
  }
116
- function normalizeIntent(value, path) {
235
+ function normalizeIntentConfig(value, path) {
117
236
  const record2 = expectRecord(value, path);
118
237
  expectKeys(record2, INTENT_KEYS, path);
119
238
  const signalsRecord = expectRecord(record2.signals, `${path}.signals`);
@@ -155,7 +274,10 @@ function normalizeConfig(value) {
155
274
  const intents = {};
156
275
  for (const name of names) {
157
276
  expectKeyName(name, "config.intents");
158
- intents[name] = normalizeIntent(intentsRecord[name], `config.intents[${JSON.stringify(name)}]`);
277
+ intents[name] = normalizeIntentConfig(
278
+ intentsRecord[name],
279
+ `config.intents[${JSON.stringify(name)}]`
280
+ );
159
281
  }
160
282
  const out = { intents };
161
283
  if (record2.episodeTimeout !== void 0) {
@@ -167,12 +289,43 @@ function normalizeConfig(value) {
167
289
  out.reveal = normalizeRevealOptions(record2.reveal, "config.reveal");
168
290
  return out;
169
291
  }
170
- function resolveReveal(config, intent, opts) {
171
- const o = opts === void 0 ? {} : normalizeRevealOptions(opts, "options");
292
+ function normalizeExplainOptions(value, path) {
293
+ const record2 = expectRecord(value, path);
294
+ expectKeys(record2, EXPLAIN_KEYS, path);
295
+ const out = {};
296
+ if (record2.nonZero !== void 0) {
297
+ if (typeof record2.nonZero !== "boolean") typeError(`${path}.nonZero`, "must be a boolean");
298
+ out.nonZero = record2.nonZero;
299
+ }
300
+ if (record2.limit !== void 0) {
301
+ const n = expectNumber(record2.limit, `${path}.limit`);
302
+ if (!Number.isInteger(n) || n < 0)
303
+ rangeError(`${path}.limit`, "must be a non-negative integer");
304
+ out.limit = n;
305
+ }
306
+ return out;
307
+ }
308
+ function splitEvaluateOptions(value, path) {
309
+ if (value === void 0) return { reveal: {}, explain: {} };
310
+ const record2 = expectRecord(value, path);
311
+ expectKeys(record2, [...REVEAL_KEYS, ...EXPLAIN_KEYS], path);
312
+ const reveal = {};
313
+ const explain2 = {};
314
+ for (const [key, v] of Object.entries(record2)) {
315
+ if (REVEAL_KEYS.includes(key)) reveal[key] = v;
316
+ else explain2[key] = v;
317
+ }
318
+ return {
319
+ reveal: normalizeRevealOptions(reveal, path),
320
+ explain: normalizeExplainOptions(explain2, path)
321
+ };
322
+ }
323
+ function resolveReveal(config, intent, opts, normalized = false) {
324
+ const o = opts === void 0 ? {} : normalized ? opts : normalizeRevealOptions(opts, "options");
172
325
  return {
173
- threshold: o.threshold ?? intent.reveal?.threshold ?? config.reveal?.threshold ?? DEFAULT_THRESHOLD,
174
- z: o.z ?? intent.reveal?.z ?? config.reveal?.z ?? DEFAULT_Z,
175
- cooldown: o.cooldown ?? intent.reveal?.cooldown ?? config.reveal?.cooldown ?? intent.halfLife
326
+ threshold: o.threshold ?? intent.reveal?.threshold ?? config?.reveal?.threshold ?? DEFAULT_THRESHOLD,
327
+ z: o.z ?? intent.reveal?.z ?? config?.reveal?.z ?? DEFAULT_Z,
328
+ cooldown: o.cooldown ?? intent.reveal?.cooldown ?? config?.reveal?.cooldown ?? intent.halfLife
176
329
  };
177
330
  }
178
331
  function resolveLearning(config) {
@@ -195,124 +348,6 @@ function validateEvent(event) {
195
348
  return { name, at };
196
349
  }
197
350
 
198
- // src/math.ts
199
- function decayFactor(elapsed, halfLife) {
200
- if (!(elapsed > 0) || !Number.isFinite(elapsed)) return 1;
201
- return 2 ** (-elapsed / halfLife);
202
- }
203
- function betaMean(alpha, beta) {
204
- return alpha / (alpha + beta);
205
- }
206
- function betaVariance(alpha, beta) {
207
- const n = alpha + beta;
208
- return alpha * beta / (n * n * (n + 1));
209
- }
210
- function betaSd(alpha, beta) {
211
- return Math.sqrt(betaVariance(alpha, beta));
212
- }
213
- function clamp01(x) {
214
- if (x < 0) return 0;
215
- if (x > 1) return 1;
216
- return x;
217
- }
218
- function laplace(successes, total) {
219
- return (successes + 1) / (total + 2);
220
- }
221
- function lift(q, b) {
222
- if (q >= b) return b >= 1 ? 0 : (q - b) / (1 - b);
223
- return b <= 0 ? 0 : (q - b) / b;
224
- }
225
-
226
- // src/intent.ts
227
- function priorCounts(intent) {
228
- return {
229
- alpha0: intent.prior * intent.priorStrength,
230
- beta0: (1 - intent.prior) * intent.priorStrength
231
- };
232
- }
233
- function initIntent(intent) {
234
- const { alpha0, beta0 } = priorCounts(intent);
235
- return {
236
- alpha: alpha0,
237
- beta: beta0,
238
- updatedAt: null,
239
- contributions: {},
240
- learned: {},
241
- episodes: { hits: 0, misses: 0 },
242
- learnedAt: null,
243
- lastRevealedAt: null,
244
- lastOutcomeAt: null,
245
- revealCount: 0
246
- };
247
- }
248
- function withContributions(state, intent, contributions) {
249
- const { alpha0, beta0 } = priorCounts(intent);
250
- let alpha = alpha0;
251
- let beta = beta0;
252
- for (const c of Object.values(contributions)) {
253
- alpha += c.alpha;
254
- beta += c.beta;
255
- }
256
- return { ...state, alpha, beta, contributions };
257
- }
258
- function decayIntent(state, intent, at) {
259
- if (state.updatedAt === null) return { ...state, updatedAt: at };
260
- const d = decayFactor(at - state.updatedAt, intent.halfLife);
261
- const updatedAt = Math.max(state.updatedAt, at);
262
- if (d === 1) return updatedAt === state.updatedAt ? state : { ...state, updatedAt };
263
- const contributions = mapRecord(state.contributions, (c) => ({
264
- alpha: c.alpha * d,
265
- beta: c.beta * d
266
- }));
267
- return withContributions({ ...state, updatedAt }, intent, contributions);
268
- }
269
- function applySignal(state, intent, signal, weight) {
270
- const prev = getOwn(state.contributions, signal) ?? { alpha: 0, beta: 0 };
271
- const next = weight >= 0 ? { alpha: prev.alpha + weight, beta: prev.beta } : { alpha: prev.alpha, beta: prev.beta - weight };
272
- return withContributions(state, intent, { ...state.contributions, [signal]: next });
273
- }
274
- function resetToPrior(state, intent) {
275
- return withContributions(state, intent, {});
276
- }
277
-
278
- // src/learning.ts
279
- var NONE = { hits: 0, misses: 0 };
280
- function decayLearned(state, learning, at) {
281
- if (state.learnedAt === null) return { ...state, learnedAt: at };
282
- const learnedAt = Math.max(state.learnedAt, at);
283
- const d = learning.halfLife === null ? 1 : decayFactor(at - state.learnedAt, learning.halfLife);
284
- if (d === 1) return learnedAt === state.learnedAt ? state : { ...state, learnedAt };
285
- return {
286
- ...state,
287
- learnedAt,
288
- learned: mapRecord(state.learned, (r) => ({ hits: r.hits * d, misses: r.misses * d })),
289
- episodes: { hits: state.episodes.hits * d, misses: state.episodes.misses * d }
290
- };
291
- }
292
- function signalWeights(state, intent, learning, signal) {
293
- const initial = getOwn(intent.signals, signal);
294
- if (initial === void 0) {
295
- throw new RangeError(`oracular: ${JSON.stringify(signal)} is not a signal of this intent`);
296
- }
297
- const record2 = getOwn(state.learned, signal) ?? NONE;
298
- const n = record2.hits + record2.misses;
299
- const q = laplace(record2.hits, n);
300
- const b = laplace(state.episodes.hits, state.episodes.hits + state.episodes.misses);
301
- const learned = n === 0 ? 0 : learning.gain * Math.abs(initial) * lift(q, b);
302
- const k = learning.priorEpisodes;
303
- const effective = (k * initial + n * learned) / (k + n);
304
- return { initial, learned, effective, hits: record2.hits, misses: record2.misses };
305
- }
306
- function recordEpisode(state, signals, outcome) {
307
- const learned = { ...state.learned };
308
- for (const signal of signals) {
309
- const r = getOwn(learned, signal) ?? NONE;
310
- learned[signal] = outcome ? { hits: r.hits + 1, misses: r.misses } : { hits: r.hits, misses: r.misses + 1 };
311
- }
312
- const episodes = outcome ? { hits: state.episodes.hits + 1, misses: state.episodes.misses } : { hits: state.episodes.hits, misses: state.episodes.misses + 1 };
313
- return { ...state, learned, episodes };
314
- }
315
-
316
351
  // src/episode.ts
317
352
  function emptyEpisode() {
318
353
  return { startedAt: null, lastEventAt: null, intents: {} };
@@ -372,20 +407,42 @@ function getIntentState(state, name, intent) {
372
407
  return getOwn(state.intents, name) ?? initIntent(intent);
373
408
  }
374
409
 
375
- // src/explain.ts
376
- function explain(state, intent, at) {
377
- const s = requireState(state);
378
- const name = expectName(intent, "intent");
379
- const t = expectTimestamp(at, "at");
380
- const config = requireIntentConfig(s, name);
381
- const learning = resolveLearning(s.config);
382
- let is = decayIntent(getIntentState(s, name, config), config, t);
383
- is = decayLearned(is, learning, t);
410
+ // src/assess.ts
411
+ function assessIntent(state, name, intent, learning, at) {
412
+ return decayLearned(decayIntent(getIntentState(state, name, intent), intent, at), learning, at);
413
+ }
414
+ function cooldownEnd(since, cooldown, at) {
415
+ if (since === null) return null;
416
+ const end = since + cooldown;
417
+ return at < end ? end : null;
418
+ }
419
+ function buildPrediction(is, name, reveal, at) {
420
+ const p = betaMean(is.alpha, is.beta);
421
+ const sd = betaSd(is.alpha, is.beta);
422
+ const revealEnd = cooldownEnd(is.lastRevealedAt, reveal.cooldown, at);
423
+ const outcomeEnd = cooldownEnd(is.lastOutcomeAt, reveal.cooldown, at);
424
+ const suppressedUntil = revealEnd === null ? outcomeEnd : outcomeEnd === null ? revealEnd : Math.max(revealEnd, outcomeEnd);
425
+ return {
426
+ intent: name,
427
+ p,
428
+ sd,
429
+ lower: clamp01(p - reveal.z * sd),
430
+ confidence: clamp01(1 - 2 * sd),
431
+ evidence: is.alpha + is.beta,
432
+ revealed: revealEnd !== null,
433
+ revealedAt: is.lastRevealedAt,
434
+ suppressedUntil
435
+ };
436
+ }
437
+ function decideReveal(prediction, reveal) {
438
+ return prediction.suppressedUntil === null && prediction.p >= reveal.threshold && prediction.lower >= reveal.threshold;
439
+ }
440
+ function buildExplanation(is, intent, learning, opts) {
384
441
  const p = betaMean(is.alpha, is.beta);
385
442
  const out = [];
386
- for (const signal of Object.keys(config.signals)) {
443
+ for (const signal of Object.keys(intent.signals)) {
387
444
  const c = getOwn(is.contributions, signal) ?? { alpha: 0, beta: 0 };
388
- const w = signalWeights(is, config, learning, signal);
445
+ const w = signalWeights(is, intent, learning, signal);
389
446
  const without = betaMean(is.alpha - c.alpha, is.beta - c.beta);
390
447
  out.push({
391
448
  signal,
@@ -399,7 +456,8 @@ function explain(state, intent, at) {
399
456
  });
400
457
  }
401
458
  out.sort((a, b) => Math.abs(b.impact) - Math.abs(a.impact) || compare(a.signal, b.signal));
402
- return out;
459
+ const filtered = opts.nonZero ? out.filter((row) => row.contribution !== 0) : out;
460
+ return opts.limit === void 0 ? filtered : filtered.slice(0, opts.limit);
403
461
  }
404
462
  function compare(a, b) {
405
463
  if (a < b) return -1;
@@ -407,6 +465,62 @@ function compare(a, b) {
407
465
  return 0;
408
466
  }
409
467
 
468
+ // src/evaluate.ts
469
+ function evaluate(state, intent, at, opts) {
470
+ const s = requireState(state);
471
+ const name = expectName(intent, "intent");
472
+ const t = expectTimestamp(at, "at");
473
+ const config = requireIntentConfig(s, name);
474
+ const split = splitEvaluateOptions(opts, "options");
475
+ const reveal = resolveReveal(s.config, config, split.reveal, true);
476
+ const learning = resolveLearning(s.config);
477
+ const is = assessIntent(s, name, config, learning, t);
478
+ const prediction = buildPrediction(is, name, reveal, t);
479
+ return {
480
+ prediction,
481
+ reveal: decideReveal(prediction, reveal),
482
+ explanation: buildExplanation(is, config, learning, split.explain)
483
+ };
484
+ }
485
+
486
+ // src/evidence.ts
487
+ function evidenceToReveal(intent, opts) {
488
+ const config = normalizeIntentConfig(intent, "intent");
489
+ const { threshold, z } = resolveReveal(void 0, config, opts);
490
+ const { alpha0, beta0 } = priorCounts(config);
491
+ const open = (w) => {
492
+ const alpha = alpha0 + w;
493
+ const p = betaMean(alpha, beta0);
494
+ return p >= threshold && clamp01(p - z * betaSd(alpha, beta0)) >= threshold;
495
+ };
496
+ if (threshold >= 1) return Number.POSITIVE_INFINITY;
497
+ if (open(0)) return 0;
498
+ let lo = 0;
499
+ let hi = 1;
500
+ while (!open(hi)) {
501
+ lo = hi;
502
+ hi *= 2;
503
+ if (hi > 1e15) return Number.POSITIVE_INFINITY;
504
+ }
505
+ for (let i = 0; i < 200 && hi - lo > 1e-9; i++) {
506
+ const mid = (lo + hi) / 2;
507
+ if (open(mid)) hi = mid;
508
+ else lo = mid;
509
+ }
510
+ return hi;
511
+ }
512
+
513
+ // src/explain.ts
514
+ function explain(state, intent, at, opts) {
515
+ const s = requireState(state);
516
+ const name = expectName(intent, "intent");
517
+ const t = expectTimestamp(at, "at");
518
+ const config = requireIntentConfig(s, name);
519
+ const filters = opts === void 0 ? {} : normalizeExplainOptions(opts, "options");
520
+ const learning = resolveLearning(s.config);
521
+ return buildExplanation(assessIntent(s, name, config, learning, t), config, learning, filters);
522
+ }
523
+
410
524
  // src/observe.ts
411
525
  function observe(state, event) {
412
526
  let s = requireState(state);
@@ -458,6 +572,11 @@ function closeEpisode(state, at) {
458
572
  }
459
573
  return { ...state, intents, episode: emptyEpisode() };
460
574
  }
575
+ function episodeClosed(prev, next) {
576
+ const a = requireState(prev);
577
+ const b = requireState(next);
578
+ return a.episode.startedAt !== null && b.episode.startedAt !== a.episode.startedAt;
579
+ }
461
580
 
462
581
  // src/predict.ts
463
582
  function predict(state, intent, at, opts) {
@@ -467,35 +586,14 @@ function predict(state, intent, at, opts) {
467
586
  const config = requireIntentConfig(s, name);
468
587
  const reveal = resolveReveal(s.config, config, opts);
469
588
  const is = decayIntent(getIntentState(s, name, config), config, t);
470
- const p = betaMean(is.alpha, is.beta);
471
- const sd = betaSd(is.alpha, is.beta);
472
- const revealEnd = cooldownEnd(is.lastRevealedAt, reveal.cooldown, t);
473
- const outcomeEnd = cooldownEnd(is.lastOutcomeAt, reveal.cooldown, t);
474
- const suppressedUntil = revealEnd === null ? outcomeEnd : outcomeEnd === null ? revealEnd : Math.max(revealEnd, outcomeEnd);
475
- return {
476
- intent: name,
477
- p,
478
- sd,
479
- lower: clamp01(p - reveal.z * sd),
480
- confidence: clamp01(1 - 2 * sd),
481
- evidence: is.alpha + is.beta,
482
- revealed: revealEnd !== null,
483
- revealedAt: is.lastRevealedAt,
484
- suppressedUntil
485
- };
486
- }
487
- function cooldownEnd(since, cooldown, at) {
488
- if (since === null) return null;
489
- const end = since + cooldown;
490
- return at < end ? end : null;
589
+ return buildPrediction(is, name, reveal, t);
491
590
  }
492
591
  function shouldReveal(state, intent, at, opts) {
493
592
  const s = requireState(state);
494
593
  const name = expectName(intent, "intent");
495
594
  const config = requireIntentConfig(s, name);
496
- const { threshold } = resolveReveal(s.config, config, opts);
497
- const prediction = predict(s, name, at, opts);
498
- return prediction.suppressedUntil === null && prediction.p >= threshold && prediction.lower >= threshold;
595
+ const reveal = resolveReveal(s.config, config, opts);
596
+ return decideReveal(predict(s, name, at, opts), reveal);
499
597
  }
500
598
  function markRevealed(state, intent, at) {
501
599
  const s = requireState(state);
@@ -682,6 +780,6 @@ function reconcile(raw, config) {
682
780
  };
683
781
  }
684
782
 
685
- export { STATE_VERSION, createState, deserialize, endEpisode, explain, markRevealed, observe, predict, serialize, shouldReveal };
783
+ export { STATE_VERSION, createState, deserialize, endEpisode, episodeClosed, evaluate, evidenceToReveal, explain, markRevealed, observe, predict, serialize, shouldReveal };
686
784
  //# sourceMappingURL=index.js.map
687
785
  //# sourceMappingURL=index.js.map