oracular 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,687 @@
1
+ // src/util.ts
2
+ function isRecord(value) {
3
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4
+ }
5
+ function hasOwn(record2, key) {
6
+ return Object.prototype.hasOwnProperty.call(record2, key);
7
+ }
8
+ function getOwn(record2, key) {
9
+ return hasOwn(record2, key) ? record2[key] : void 0;
10
+ }
11
+ function mapRecord(record2, fn) {
12
+ const out = {};
13
+ for (const [key, value] of Object.entries(record2)) {
14
+ out[key] = fn(value, key);
15
+ }
16
+ return out;
17
+ }
18
+
19
+ // src/config.ts
20
+ var DEFAULT_THRESHOLD = 0.5;
21
+ var DEFAULT_Z = 1.28;
22
+ var DEFAULT_GAIN = 2;
23
+ var DEFAULT_PRIOR_EPISODES = 10;
24
+ var CONFIG_KEYS = ["intents", "episodeTimeout", "learning", "reveal"];
25
+ var INTENT_KEYS = [
26
+ "prior",
27
+ "priorStrength",
28
+ "halfLife",
29
+ "signals",
30
+ "outcome",
31
+ "resetOnOutcome",
32
+ "reveal"
33
+ ];
34
+ var REVEAL_KEYS = ["threshold", "z", "cooldown"];
35
+ var LEARNING_KEYS = ["halfLife", "gain", "priorEpisodes"];
36
+ function typeError(path, message) {
37
+ throw new TypeError(`oracular: ${path} ${message}`);
38
+ }
39
+ function rangeError(path, message) {
40
+ throw new RangeError(`oracular: ${path} ${message}`);
41
+ }
42
+ function expectRecord(value, path) {
43
+ if (!isRecord(value)) typeError(path, "must be a plain object");
44
+ return value;
45
+ }
46
+ function expectKeys(record2, allowed, path) {
47
+ for (const key of Object.keys(record2)) {
48
+ if (!allowed.includes(key)) {
49
+ typeError(
50
+ `${path}.${key}`,
51
+ `is not a recognised option (expected one of: ${allowed.join(", ")})`
52
+ );
53
+ }
54
+ }
55
+ }
56
+ function expectNumber(value, path) {
57
+ if (typeof value !== "number" || !Number.isFinite(value))
58
+ typeError(path, "must be a finite number");
59
+ return value;
60
+ }
61
+ function expectPositive(value, path) {
62
+ const n = expectNumber(value, path);
63
+ if (n <= 0) rangeError(path, "must be greater than 0");
64
+ return n;
65
+ }
66
+ function expectNonNegative(value, path) {
67
+ const n = expectNumber(value, path);
68
+ if (n < 0) rangeError(path, "must be 0 or greater");
69
+ return n;
70
+ }
71
+ function expectOpenUnit(value, path) {
72
+ const n = expectNumber(value, path);
73
+ if (n <= 0 || n >= 1) rangeError(path, "must be strictly between 0 and 1");
74
+ return n;
75
+ }
76
+ function expectClosedUnit(value, path) {
77
+ const n = expectNumber(value, path);
78
+ if (n < 0 || n > 1) rangeError(path, "must be between 0 and 1");
79
+ return n;
80
+ }
81
+ function expectName(value, path) {
82
+ if (typeof value !== "string" || value.length === 0)
83
+ typeError(path, "must be a non-empty string");
84
+ return value;
85
+ }
86
+ function expectKeyName(name, path) {
87
+ if (name.length === 0) typeError(path, "names must be non-empty strings");
88
+ if (name === "__proto__") typeError(path, 'must not use the reserved name "__proto__"');
89
+ return name;
90
+ }
91
+ function normalizeRevealOptions(value, path) {
92
+ const record2 = expectRecord(value, path);
93
+ expectKeys(record2, REVEAL_KEYS, path);
94
+ const out = {};
95
+ if (record2.threshold !== void 0) {
96
+ out.threshold = expectClosedUnit(record2.threshold, `${path}.threshold`);
97
+ }
98
+ if (record2.z !== void 0) out.z = expectNonNegative(record2.z, `${path}.z`);
99
+ if (record2.cooldown !== void 0) {
100
+ out.cooldown = expectNonNegative(record2.cooldown, `${path}.cooldown`);
101
+ }
102
+ return out;
103
+ }
104
+ function normalizeLearning(value, path) {
105
+ const record2 = expectRecord(value, path);
106
+ expectKeys(record2, LEARNING_KEYS, path);
107
+ const out = {};
108
+ if (record2.halfLife !== void 0)
109
+ out.halfLife = expectPositive(record2.halfLife, `${path}.halfLife`);
110
+ if (record2.gain !== void 0) out.gain = expectPositive(record2.gain, `${path}.gain`);
111
+ if (record2.priorEpisodes !== void 0) {
112
+ out.priorEpisodes = expectPositive(record2.priorEpisodes, `${path}.priorEpisodes`);
113
+ }
114
+ return out;
115
+ }
116
+ function normalizeIntent(value, path) {
117
+ const record2 = expectRecord(value, path);
118
+ expectKeys(record2, INTENT_KEYS, path);
119
+ const signalsRecord = expectRecord(record2.signals, `${path}.signals`);
120
+ const signals = {};
121
+ for (const [name, weight] of Object.entries(signalsRecord)) {
122
+ expectKeyName(name, `${path}.signals`);
123
+ const w = expectNumber(weight, `${path}.signals[${JSON.stringify(name)}]`);
124
+ if (w === 0) {
125
+ rangeError(
126
+ `${path}.signals[${JSON.stringify(name)}]`,
127
+ "must be non-zero (positive is evidence for the intent, negative is evidence against)"
128
+ );
129
+ }
130
+ signals[name] = w;
131
+ }
132
+ const out = {
133
+ prior: expectOpenUnit(record2.prior, `${path}.prior`),
134
+ priorStrength: expectPositive(record2.priorStrength, `${path}.priorStrength`),
135
+ halfLife: expectPositive(record2.halfLife, `${path}.halfLife`),
136
+ signals
137
+ };
138
+ if (record2.outcome !== void 0) out.outcome = expectName(record2.outcome, `${path}.outcome`);
139
+ if (record2.resetOnOutcome !== void 0) {
140
+ if (typeof record2.resetOnOutcome !== "boolean") {
141
+ typeError(`${path}.resetOnOutcome`, "must be a boolean");
142
+ }
143
+ out.resetOnOutcome = record2.resetOnOutcome;
144
+ }
145
+ if (record2.reveal !== void 0)
146
+ out.reveal = normalizeRevealOptions(record2.reveal, `${path}.reveal`);
147
+ return out;
148
+ }
149
+ function normalizeConfig(value) {
150
+ const record2 = expectRecord(value, "config");
151
+ expectKeys(record2, CONFIG_KEYS, "config");
152
+ const intentsRecord = expectRecord(record2.intents, "config.intents");
153
+ const names = Object.keys(intentsRecord);
154
+ if (names.length === 0) typeError("config.intents", "must declare at least one intent");
155
+ const intents = {};
156
+ for (const name of names) {
157
+ expectKeyName(name, "config.intents");
158
+ intents[name] = normalizeIntent(intentsRecord[name], `config.intents[${JSON.stringify(name)}]`);
159
+ }
160
+ const out = { intents };
161
+ if (record2.episodeTimeout !== void 0) {
162
+ out.episodeTimeout = expectPositive(record2.episodeTimeout, "config.episodeTimeout");
163
+ }
164
+ if (record2.learning !== void 0)
165
+ out.learning = normalizeLearning(record2.learning, "config.learning");
166
+ if (record2.reveal !== void 0)
167
+ out.reveal = normalizeRevealOptions(record2.reveal, "config.reveal");
168
+ return out;
169
+ }
170
+ function resolveReveal(config, intent, opts) {
171
+ const o = opts === void 0 ? {} : normalizeRevealOptions(opts, "options");
172
+ 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
176
+ };
177
+ }
178
+ function resolveLearning(config) {
179
+ return {
180
+ halfLife: config.learning?.halfLife ?? null,
181
+ gain: config.learning?.gain ?? DEFAULT_GAIN,
182
+ priorEpisodes: config.learning?.priorEpisodes ?? DEFAULT_PRIOR_EPISODES
183
+ };
184
+ }
185
+ function expectTimestamp(value, path) {
186
+ return expectNumber(value, path);
187
+ }
188
+ function validateEvent(event) {
189
+ if (!isRecord(event)) typeError("event", "must be an object like { name, at }");
190
+ const name = expectName(event.name, "event.name");
191
+ const at = expectTimestamp(event.at, "event.at");
192
+ if (event.props !== void 0 && !isRecord(event.props)) {
193
+ typeError("event.props", "must be a plain object when present");
194
+ }
195
+ return { name, at };
196
+ }
197
+
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
+ // src/episode.ts
317
+ function emptyEpisode() {
318
+ return { startedAt: null, lastEventAt: null, intents: {} };
319
+ }
320
+ function timedOutAt(episode, timeout, at) {
321
+ if (timeout === void 0 || episode.lastEventAt === null) return null;
322
+ return at - episode.lastEventAt > timeout ? episode.lastEventAt + timeout : null;
323
+ }
324
+ function extendEpisode(episode, at) {
325
+ return {
326
+ startedAt: episode.startedAt ?? at,
327
+ lastEventAt: episode.lastEventAt === null ? at : Math.max(episode.lastEventAt, at),
328
+ intents: episode.intents
329
+ };
330
+ }
331
+ function episodeRecord(episode, intent) {
332
+ return getOwn(episode.intents, intent) ?? { seen: [], resolved: false };
333
+ }
334
+ function markSeen(episode, intent, signal) {
335
+ const record2 = episodeRecord(episode, intent);
336
+ if (record2.seen.includes(signal)) return episode;
337
+ return {
338
+ ...episode,
339
+ intents: { ...episode.intents, [intent]: { ...record2, seen: [...record2.seen, signal] } }
340
+ };
341
+ }
342
+ function markResolved(episode, intent) {
343
+ return { ...episode, intents: { ...episode.intents, [intent]: { seen: [], resolved: true } } };
344
+ }
345
+
346
+ // src/version.ts
347
+ var STATE_VERSION = 1;
348
+
349
+ // src/state.ts
350
+ function createState(config) {
351
+ const normalized = normalizeConfig(config);
352
+ const intents = {};
353
+ for (const [name, intent] of Object.entries(normalized.intents)) {
354
+ intents[name] = initIntent(intent);
355
+ }
356
+ return { version: STATE_VERSION, config: normalized, intents, episode: emptyEpisode() };
357
+ }
358
+ function requireState(state) {
359
+ if (!isRecord(state) || state.version !== STATE_VERSION || !isRecord(state.config) || !isRecord(state.intents) || !isRecord(state.episode)) {
360
+ throw new TypeError("oracular: state must be an EngineState from createState or deserialize");
361
+ }
362
+ return state;
363
+ }
364
+ function requireIntentConfig(state, name) {
365
+ const intent = getOwn(state.config.intents, name);
366
+ if (intent === void 0) {
367
+ throw new RangeError(`oracular: unknown intent ${JSON.stringify(name)}`);
368
+ }
369
+ return intent;
370
+ }
371
+ function getIntentState(state, name, intent) {
372
+ return getOwn(state.intents, name) ?? initIntent(intent);
373
+ }
374
+
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);
384
+ const p = betaMean(is.alpha, is.beta);
385
+ const out = [];
386
+ for (const signal of Object.keys(config.signals)) {
387
+ const c = getOwn(is.contributions, signal) ?? { alpha: 0, beta: 0 };
388
+ const w = signalWeights(is, config, learning, signal);
389
+ const without = betaMean(is.alpha - c.alpha, is.beta - c.beta);
390
+ out.push({
391
+ signal,
392
+ contribution: c.alpha - c.beta,
393
+ impact: p - without,
394
+ initialWeight: w.initial,
395
+ learnedWeight: w.learned,
396
+ effectiveWeight: w.effective,
397
+ hits: w.hits,
398
+ misses: w.misses
399
+ });
400
+ }
401
+ out.sort((a, b) => Math.abs(b.impact) - Math.abs(a.impact) || compare(a.signal, b.signal));
402
+ return out;
403
+ }
404
+ function compare(a, b) {
405
+ if (a < b) return -1;
406
+ if (a > b) return 1;
407
+ return 0;
408
+ }
409
+
410
+ // src/observe.ts
411
+ function observe(state, event) {
412
+ let s = requireState(state);
413
+ const { name, at } = validateEvent(event);
414
+ const closeAt = timedOutAt(s.episode, s.config.episodeTimeout, at);
415
+ if (closeAt !== null) s = closeEpisode(s, closeAt);
416
+ const learning = resolveLearning(s.config);
417
+ const intents = { ...s.intents };
418
+ let episode = extendEpisode(s.episode, at);
419
+ for (const [intentName, intent] of Object.entries(s.config.intents)) {
420
+ const isSignal = hasOwn(intent.signals, name);
421
+ const isOutcome = intent.outcome === name;
422
+ if (!isSignal && !isOutcome) continue;
423
+ let is = getIntentState(s, intentName, intent);
424
+ is = decayIntent(is, intent, at);
425
+ is = decayLearned(is, learning, at);
426
+ if (isSignal) {
427
+ const { effective } = signalWeights(is, intent, learning, name);
428
+ is = applySignal(is, intent, name, effective);
429
+ if (!isOutcome) episode = markSeen(episode, intentName, name);
430
+ }
431
+ if (isOutcome) {
432
+ is = recordEpisode(is, episodeRecord(episode, intentName).seen, true);
433
+ episode = markResolved(episode, intentName);
434
+ is = { ...is, lastOutcomeAt: at };
435
+ if (intent.resetOnOutcome ?? true) is = resetToPrior(is, intent);
436
+ }
437
+ intents[intentName] = is;
438
+ }
439
+ return { ...s, intents, episode };
440
+ }
441
+ function endEpisode(state, at) {
442
+ const s = requireState(state);
443
+ const t = expectTimestamp(at, "at");
444
+ if (s.episode.startedAt === null) return s;
445
+ const closeAt = timedOutAt(s.episode, s.config.episodeTimeout, t);
446
+ return closeEpisode(s, closeAt ?? t);
447
+ }
448
+ function closeEpisode(state, at) {
449
+ const learning = resolveLearning(state.config);
450
+ const intents = { ...state.intents };
451
+ for (const [intentName, intent] of Object.entries(state.config.intents)) {
452
+ const record2 = episodeRecord(state.episode, intentName);
453
+ if (record2.resolved) continue;
454
+ let is = getIntentState(state, intentName, intent);
455
+ is = decayLearned(is, learning, at);
456
+ is = recordEpisode(is, record2.seen, false);
457
+ intents[intentName] = is;
458
+ }
459
+ return { ...state, intents, episode: emptyEpisode() };
460
+ }
461
+
462
+ // src/predict.ts
463
+ function predict(state, intent, at, opts) {
464
+ const s = requireState(state);
465
+ const name = expectName(intent, "intent");
466
+ const t = expectTimestamp(at, "at");
467
+ const config = requireIntentConfig(s, name);
468
+ const reveal = resolveReveal(s.config, config, opts);
469
+ 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;
491
+ }
492
+ function shouldReveal(state, intent, at, opts) {
493
+ const s = requireState(state);
494
+ const name = expectName(intent, "intent");
495
+ 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;
499
+ }
500
+ function markRevealed(state, intent, at) {
501
+ const s = requireState(state);
502
+ const name = expectName(intent, "intent");
503
+ const t = expectTimestamp(at, "at");
504
+ const config = requireIntentConfig(s, name);
505
+ const is = getIntentState(s, name, config);
506
+ return {
507
+ ...s,
508
+ intents: {
509
+ ...s.intents,
510
+ [name]: { ...is, lastRevealedAt: t, revealCount: is.revealCount + 1 }
511
+ }
512
+ };
513
+ }
514
+
515
+ // src/serialize.ts
516
+ function serialize(state) {
517
+ return JSON.stringify(requireState(state));
518
+ }
519
+ function deserialize(json, opts = {}) {
520
+ if (typeof json !== "string") throw new TypeError("oracular: deserialize expects a JSON string");
521
+ if (!isRecord(opts)) throw new TypeError("oracular: options must be a plain object");
522
+ for (const key of Object.keys(opts)) {
523
+ if (key !== "config" && key !== "migrate") {
524
+ throw new TypeError(
525
+ `oracular: options.${key} is not a recognised option (expected one of: config, migrate)`
526
+ );
527
+ }
528
+ }
529
+ if (opts.migrate !== void 0 && typeof opts.migrate !== "function") {
530
+ throw new TypeError("oracular: options.migrate must be a function");
531
+ }
532
+ let parsed;
533
+ try {
534
+ parsed = JSON.parse(json);
535
+ } catch (error) {
536
+ throw new SyntaxError(`oracular: stored state is not valid JSON (${error.message})`);
537
+ }
538
+ if (!isRecord(parsed) || typeof parsed.version !== "number") {
539
+ throw new TypeError("oracular: stored state has no numeric version field");
540
+ }
541
+ let stored = parsed;
542
+ if (parsed.version !== STATE_VERSION) {
543
+ if (opts.migrate === void 0) {
544
+ throw new RangeError(
545
+ `oracular: stored state is version ${parsed.version} but this library reads version ${STATE_VERSION}; pass a migrate function`
546
+ );
547
+ }
548
+ stored = opts.migrate(parsed, parsed.version);
549
+ }
550
+ const raw = parseState(stored);
551
+ const config = opts.config === void 0 ? raw.config : normalizeConfig(opts.config);
552
+ return reconcile(raw, config);
553
+ }
554
+ function typeError2(path, message) {
555
+ throw new TypeError(`oracular: ${path} ${message}`);
556
+ }
557
+ function record(value, path) {
558
+ if (!isRecord(value)) typeError2(path, "must be a plain object");
559
+ return value;
560
+ }
561
+ function finite(value, path) {
562
+ if (typeof value !== "number" || !Number.isFinite(value))
563
+ typeError2(path, "must be a finite number");
564
+ return value;
565
+ }
566
+ function nonNegative(value, path) {
567
+ const n = finite(value, path);
568
+ if (n < 0) throw new RangeError(`oracular: ${path} must be 0 or greater`);
569
+ return n;
570
+ }
571
+ function nullOrFinite(value, path) {
572
+ return value === null ? null : finite(value, path);
573
+ }
574
+ function signalRecord(value, path) {
575
+ const r = record(value, path);
576
+ return {
577
+ hits: nonNegative(r.hits, `${path}.hits`),
578
+ misses: nonNegative(r.misses, `${path}.misses`)
579
+ };
580
+ }
581
+ function contribution(value, path) {
582
+ const r = record(value, path);
583
+ return {
584
+ alpha: nonNegative(r.alpha, `${path}.alpha`),
585
+ beta: nonNegative(r.beta, `${path}.beta`)
586
+ };
587
+ }
588
+ function mapOwn(value, path, fn) {
589
+ const r = record(value, path);
590
+ const out = {};
591
+ for (const key of Object.keys(r)) {
592
+ if (key === "__proto__") continue;
593
+ out[key] = fn(r[key], `${path}[${JSON.stringify(key)}]`);
594
+ }
595
+ return out;
596
+ }
597
+ function intentState(value, path) {
598
+ const r = record(value, path);
599
+ const revealCount = finite(r.revealCount, `${path}.revealCount`);
600
+ if (!Number.isInteger(revealCount) || revealCount < 0) {
601
+ throw new RangeError(`oracular: ${path}.revealCount must be a non-negative integer`);
602
+ }
603
+ return {
604
+ alpha: finite(r.alpha, `${path}.alpha`),
605
+ beta: finite(r.beta, `${path}.beta`),
606
+ updatedAt: nullOrFinite(r.updatedAt, `${path}.updatedAt`),
607
+ contributions: mapOwn(r.contributions, `${path}.contributions`, contribution),
608
+ learned: mapOwn(r.learned, `${path}.learned`, signalRecord),
609
+ episodes: signalRecord(r.episodes, `${path}.episodes`),
610
+ learnedAt: nullOrFinite(r.learnedAt, `${path}.learnedAt`),
611
+ lastRevealedAt: nullOrFinite(r.lastRevealedAt, `${path}.lastRevealedAt`),
612
+ lastOutcomeAt: nullOrFinite(r.lastOutcomeAt, `${path}.lastOutcomeAt`),
613
+ revealCount
614
+ };
615
+ }
616
+ function episodeIntent(value, path) {
617
+ const r = record(value, path);
618
+ if (!Array.isArray(r.seen) || !r.seen.every((s) => typeof s === "string")) {
619
+ typeError2(`${path}.seen`, "must be an array of strings");
620
+ }
621
+ if (typeof r.resolved !== "boolean") typeError2(`${path}.resolved`, "must be a boolean");
622
+ return { seen: r.seen, resolved: r.resolved };
623
+ }
624
+ function episodeState(value, path) {
625
+ const r = record(value, path);
626
+ return {
627
+ startedAt: nullOrFinite(r.startedAt, `${path}.startedAt`),
628
+ lastEventAt: nullOrFinite(r.lastEventAt, `${path}.lastEventAt`),
629
+ intents: mapOwn(r.intents, `${path}.intents`, episodeIntent)
630
+ };
631
+ }
632
+ function parseState(value) {
633
+ const r = record(value, "state");
634
+ if (r.version !== STATE_VERSION) {
635
+ throw new RangeError(`oracular: state.version must be ${STATE_VERSION} after migration`);
636
+ }
637
+ return {
638
+ version: STATE_VERSION,
639
+ config: normalizeConfig(r.config),
640
+ intents: mapOwn(r.intents, "state.intents", intentState),
641
+ episode: episodeState(r.episode, "state.episode")
642
+ };
643
+ }
644
+ function keep(source, declared) {
645
+ const out = {};
646
+ for (const [key, value] of Object.entries(source)) {
647
+ if (hasOwn(declared, key)) out[key] = value;
648
+ }
649
+ return out;
650
+ }
651
+ function reconcile(raw, config) {
652
+ const intents = {};
653
+ const episodeIntents = {};
654
+ for (const [name, intent] of Object.entries(config.intents)) {
655
+ const stored = getOwn(raw.intents, name);
656
+ if (stored === void 0) {
657
+ intents[name] = initIntent(intent);
658
+ } else {
659
+ intents[name] = withContributions(
660
+ { ...stored, learned: keep(stored.learned, intent.signals) },
661
+ intent,
662
+ keep(stored.contributions, intent.signals)
663
+ );
664
+ }
665
+ const episode = getOwn(raw.episode.intents, name);
666
+ if (episode !== void 0 && raw.episode.startedAt !== null) {
667
+ const seen = episode.seen.filter(
668
+ (s, i) => hasOwn(intent.signals, s) && episode.seen.indexOf(s) === i
669
+ );
670
+ episodeIntents[name] = { seen, resolved: episode.resolved };
671
+ }
672
+ }
673
+ return {
674
+ version: STATE_VERSION,
675
+ config,
676
+ intents,
677
+ episode: {
678
+ startedAt: raw.episode.startedAt,
679
+ lastEventAt: raw.episode.startedAt === null ? null : raw.episode.lastEventAt,
680
+ intents: episodeIntents
681
+ }
682
+ };
683
+ }
684
+
685
+ export { STATE_VERSION, createState, deserialize, endEpisode, explain, markRevealed, observe, predict, serialize, shouldReveal };
686
+ //# sourceMappingURL=index.js.map
687
+ //# sourceMappingURL=index.js.map