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/CHANGELOG.md +6 -0
- package/README.md +4 -1
- package/dist/index.cjs +262 -161
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +103 -5
- package/dist/index.d.ts +103 -5
- package/dist/index.js +260 -162
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
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
|
|
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
|
|
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
|
|
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] =
|
|
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
|
|
171
|
-
const
|
|
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
|
|
174
|
-
z: o.z ?? intent.reveal?.z ?? config
|
|
175
|
-
cooldown: o.cooldown ?? intent.reveal?.cooldown ?? config
|
|
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/
|
|
376
|
-
function
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
497
|
-
|
|
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
|