oracular 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/math.ts
4
4
  function decayFactor(elapsed, halfLife) {
5
- if (!(elapsed > 0) || !Number.isFinite(elapsed)) return 1;
5
+ if (!(elapsed > 0)) return 1;
6
6
  return 2 ** (-elapsed / halfLife);
7
7
  }
8
8
  function betaMean(alpha, beta) {
@@ -82,10 +82,39 @@ function repeatFactor(n) {
82
82
  function laplace(successes, total) {
83
83
  return (successes + 1) / (total + 2);
84
84
  }
85
- function lift(q, b) {
85
+ function lift(q, b, type = "normalized-difference") {
86
+ if (type === "absolute-difference") return q - b;
87
+ if (type !== "normalized-difference") {
88
+ const rate = Math.max(Number.EPSILON, Math.min(1 - Number.EPSILON, q));
89
+ const base = Math.max(Number.EPSILON, Math.min(1 - Number.EPSILON, b));
90
+ switch (type) {
91
+ case "relative-lift":
92
+ return (rate - base) / base;
93
+ case "log-lift":
94
+ return Math.log(rate / base);
95
+ case "odds-ratio":
96
+ return rate / (1 - rate) / (base / (1 - base));
97
+ case "risk-ratio":
98
+ case "association-rule":
99
+ return rate / base;
100
+ }
101
+ }
86
102
  if (q >= b) return b >= 1 ? 0 : (q - b) / (1 - b);
87
103
  return b <= 0 ? 0 : (q - b) / b;
88
104
  }
105
+ function learningScore(value, type = "normalized-difference") {
106
+ switch (type) {
107
+ case "risk-ratio":
108
+ case "odds-ratio":
109
+ case "association-rule":
110
+ return (value - 1) / (value + 1);
111
+ case "relative-lift":
112
+ case "log-lift":
113
+ return value / (1 + Math.abs(value));
114
+ default:
115
+ return value;
116
+ }
117
+ }
89
118
 
90
119
  // src/util.ts
91
120
  function isRecord(value) {
@@ -109,12 +138,11 @@ function mapRecord(record2, fn) {
109
138
  var NONE = { hits: 0, misses: 0 };
110
139
  function decayLearned(state, learning, at) {
111
140
  if (state.learnedAt === null) return { ...state, learnedAt: at };
112
- const learnedAt = Math.max(state.learnedAt, at);
113
141
  const d = learning.halfLife === null ? 1 : decayFactor(at - state.learnedAt, learning.halfLife);
114
- if (d === 1) return learnedAt === state.learnedAt ? state : { ...state, learnedAt };
142
+ if (d === 1) return at === state.learnedAt ? state : { ...state, learnedAt: at };
115
143
  return {
116
144
  ...state,
117
- learnedAt,
145
+ learnedAt: at,
118
146
  learned: mapRecord(state.learned, (r) => ({ hits: r.hits * d, misses: r.misses * d })),
119
147
  episodes: { hits: state.episodes.hits * d, misses: state.episodes.misses * d }
120
148
  };
@@ -131,7 +159,9 @@ function signalWeights(state, intent, learning, signal) {
131
159
  const nWithout = Math.max(0, totalEpisodes - nWith);
132
160
  const outcomesWithout = Math.min(nWithout, Math.max(0, state.episodes.hits - record2.hits));
133
161
  const qWithout = laplace(outcomesWithout, nWithout);
134
- const learned = nWith === 0 ? 0 : learning.gain * Math.abs(initial) * lift(qWith, qWithout);
162
+ const baseline = learning.lift === "association-rule" ? (record2.hits + outcomesWithout + 2) / (nWith + nWithout + 4) : qWithout;
163
+ const score = learningScore(lift(qWith, baseline, learning.lift), learning.lift);
164
+ const learned = nWith === 0 ? 0 : learning.gain * Math.abs(initial) * score;
135
165
  const nEff = nWith + nWithout === 0 ? 0 : nWith * nWithout / (nWith + nWithout);
136
166
  const k = learning.priorEpisodes;
137
167
  const effective = (k * initial + nEff * learned) / (k + nEff);
@@ -221,13 +251,12 @@ function withContributions(state, intent, contributions) {
221
251
  function decayIntent(state, intent, at) {
222
252
  if (state.updatedAt === null) return { ...state, updatedAt: at };
223
253
  const d = decayFactor(at - state.updatedAt, intent.halfLife);
224
- const updatedAt = Math.max(state.updatedAt, at);
225
- if (d === 1) return updatedAt === state.updatedAt ? state : { ...state, updatedAt };
254
+ if (d === 1) return at === state.updatedAt ? state : { ...state, updatedAt: at };
226
255
  const contributions = mapRecord(state.contributions, (c) => ({
227
256
  alpha: c.alpha * d,
228
257
  beta: c.beta * d
229
258
  }));
230
- return withContributions({ ...state, updatedAt }, intent, contributions);
259
+ return withContributions({ ...state, updatedAt: at }, intent, contributions);
231
260
  }
232
261
  function applySignal(state, intent, signal, weight) {
233
262
  const prev = getOwn(state.contributions, signal) ?? { alpha: 0, beta: 0 };
@@ -244,6 +273,7 @@ var DEFAULT_Z = 1.28;
244
273
  var DEFAULT_GAIN = 2;
245
274
  var DEFAULT_PRIOR_EPISODES = 10;
246
275
  var MAX_MAGNITUDE = 1e6;
276
+ var MAX_Z = 10;
247
277
  var CONFIG_KEYS = ["intents", "episodeTimeout", "learning", "reveal"];
248
278
  var INTENT_KEYS = [
249
279
  "prior",
@@ -257,7 +287,16 @@ var INTENT_KEYS = [
257
287
  "reveal"
258
288
  ];
259
289
  var REVEAL_KEYS = ["threshold", "z", "cooldown", "bound"];
260
- var LEARNING_KEYS = ["halfLife", "gain", "priorEpisodes"];
290
+ var LEARNING_KEYS = ["halfLife", "gain", "priorEpisodes", "lift"];
291
+ var LIFT_TYPES = [
292
+ "absolute-difference",
293
+ "relative-lift",
294
+ "risk-ratio",
295
+ "log-lift",
296
+ "odds-ratio",
297
+ "normalized-difference",
298
+ "association-rule"
299
+ ];
261
300
  var EXPLAIN_KEYS = ["nonZero", "limit"];
262
301
  function typeError(path, message) {
263
302
  throw new TypeError(`oracular: ${path} ${message}`);
@@ -319,14 +358,17 @@ function expectKeyName(name, path) {
319
358
  if (name === "__proto__") typeError(path, 'must not use the reserved name "__proto__"');
320
359
  return name;
321
360
  }
322
- function normalizeRevealOptions(value, path) {
361
+ function normalizeRevealOptions(value, path, strict = true) {
323
362
  const record2 = expectRecord(value, path);
324
363
  expectKeys(record2, REVEAL_KEYS, path);
325
364
  const out = {};
326
365
  if (record2.threshold !== void 0) {
327
- out.threshold = expectClosedUnit(record2.threshold, `${path}.threshold`);
366
+ out.threshold = strict ? expectOpenUnit(record2.threshold, `${path}.threshold`) : expectClosedUnit(record2.threshold, `${path}.threshold`);
367
+ }
368
+ if (record2.z !== void 0) {
369
+ out.z = expectNonNegative(record2.z, `${path}.z`);
370
+ if (strict && out.z > MAX_Z) rangeError(`${path}.z`, `must be at most ${MAX_Z}`);
328
371
  }
329
- if (record2.z !== void 0) out.z = expectNonNegative(record2.z, `${path}.z`);
330
372
  if (record2.cooldown !== void 0) {
331
373
  out.cooldown = expectNonNegative(record2.cooldown, `${path}.cooldown`);
332
374
  }
@@ -342,6 +384,12 @@ function normalizeLearning(value, path) {
342
384
  const record2 = expectRecord(value, path);
343
385
  expectKeys(record2, LEARNING_KEYS, path);
344
386
  const out = {};
387
+ if (record2.lift !== void 0) {
388
+ if (!LIFT_TYPES.includes(record2.lift)) {
389
+ typeError(`${path}.lift`, `must be one of: ${LIFT_TYPES.join(", ")}`);
390
+ }
391
+ out.lift = record2.lift;
392
+ }
345
393
  if (record2.halfLife !== void 0)
346
394
  out.halfLife = expectPositive(record2.halfLife, `${path}.halfLife`);
347
395
  if (record2.gain !== void 0) out.gain = expectBoundedPositive(record2.gain, `${path}.gain`);
@@ -350,7 +398,7 @@ function normalizeLearning(value, path) {
350
398
  }
351
399
  return out;
352
400
  }
353
- function normalizeIntentConfig(value, path) {
401
+ function normalizeIntentConfig(value, path, strict = true) {
354
402
  const record2 = expectRecord(value, path);
355
403
  expectKeys(record2, INTENT_KEYS, path);
356
404
  const signalsRecord = expectRecord(record2.signals, `${path}.signals`);
@@ -392,11 +440,18 @@ function normalizeIntentConfig(value, path) {
392
440
  if (record2.attribution !== void 0) {
393
441
  out.attribution = expectPositive(record2.attribution, `${path}.attribution`);
394
442
  }
395
- if (record2.reveal !== void 0)
396
- out.reveal = normalizeRevealOptions(record2.reveal, `${path}.reveal`);
443
+ if (record2.reveal !== void 0) {
444
+ out.reveal = normalizeRevealOptions(record2.reveal, `${path}.reveal`, strict);
445
+ }
446
+ if (strict && out.outcome !== void 0 && hasOwn(signals, out.outcome) && out.resetOnOutcome !== false) {
447
+ typeError(
448
+ `${path}.outcome`,
449
+ "is also one of its signals: the reset on outcome would wipe what it adds; set resetOnOutcome: false to keep it"
450
+ );
451
+ }
397
452
  return out;
398
453
  }
399
- function normalizeConfig(value) {
454
+ function normalizeConfig(value, strict = true) {
400
455
  const record2 = expectRecord(value, "config");
401
456
  expectKeys(record2, CONFIG_KEYS, "config");
402
457
  const intentsRecord = expectRecord(record2.intents, "config.intents");
@@ -407,7 +462,8 @@ function normalizeConfig(value) {
407
462
  expectKeyName(name, "config.intents");
408
463
  intents[name] = normalizeIntentConfig(
409
464
  intentsRecord[name],
410
- `config.intents[${JSON.stringify(name)}]`
465
+ `config.intents[${JSON.stringify(name)}]`,
466
+ strict
411
467
  );
412
468
  }
413
469
  const out = { intents };
@@ -416,8 +472,9 @@ function normalizeConfig(value) {
416
472
  }
417
473
  if (record2.learning !== void 0)
418
474
  out.learning = normalizeLearning(record2.learning, "config.learning");
419
- if (record2.reveal !== void 0)
420
- out.reveal = normalizeRevealOptions(record2.reveal, "config.reveal");
475
+ if (record2.reveal !== void 0) {
476
+ out.reveal = normalizeRevealOptions(record2.reveal, "config.reveal", strict);
477
+ }
421
478
  return out;
422
479
  }
423
480
  function normalizeExplainOptions(value, path) {
@@ -447,12 +504,12 @@ function splitEvaluateOptions(value, path) {
447
504
  else explain2[key] = v;
448
505
  }
449
506
  return {
450
- reveal: normalizeRevealOptions(reveal, path),
507
+ reveal: normalizeRevealOptions(reveal, path, false),
451
508
  explain: normalizeExplainOptions(explain2, path)
452
509
  };
453
510
  }
454
511
  function resolveReveal(config, intent, opts, normalized = false) {
455
- const o = opts === void 0 ? {} : normalized ? opts : normalizeRevealOptions(opts, "options");
512
+ const o = opts === void 0 ? {} : normalized ? opts : normalizeRevealOptions(opts, "options", false);
456
513
  return {
457
514
  threshold: o.threshold ?? intent.reveal?.threshold ?? config?.reveal?.threshold ?? DEFAULT_THRESHOLD,
458
515
  z: o.z ?? intent.reveal?.z ?? config?.reveal?.z ?? DEFAULT_Z,
@@ -462,6 +519,7 @@ function resolveReveal(config, intent, opts, normalized = false) {
462
519
  }
463
520
  function resolveLearning(config) {
464
521
  return {
522
+ lift: config.learning?.lift ?? "normalized-difference",
465
523
  halfLife: config.learning?.halfLife ?? null,
466
524
  gain: config.learning?.gain ?? DEFAULT_GAIN,
467
525
  priorEpisodes: config.learning?.priorEpisodes ?? DEFAULT_PRIOR_EPISODES
@@ -489,11 +547,7 @@ function timedOutAt(episode, timeout, at) {
489
547
  return at - episode.lastEventAt > timeout ? episode.lastEventAt + timeout : null;
490
548
  }
491
549
  function extendEpisode(episode, at) {
492
- return {
493
- startedAt: episode.startedAt ?? at,
494
- lastEventAt: episode.lastEventAt === null ? at : Math.max(episode.lastEventAt, at),
495
- intents: episode.intents
496
- };
550
+ return { startedAt: episode.startedAt ?? at, lastEventAt: at, intents: episode.intents };
497
551
  }
498
552
  function episodeRecord(episode, intent) {
499
553
  return getOwn(episode.intents, intent) ?? { seen: [], counts: {}, resolved: false };
@@ -551,7 +605,7 @@ function assessIntent(state, name, intent, learning, at) {
551
605
  }
552
606
  function cooldownEnd(since, cooldown, at) {
553
607
  if (since === null) return null;
554
- const end = since + cooldown;
608
+ const end = Math.min(since, at) + cooldown;
555
609
  return at < end ? end : null;
556
610
  }
557
611
  function buildPrediction(is, name, reveal, at) {
@@ -679,6 +733,7 @@ function observe(state, event) {
679
733
  let is = getIntentState(s, intentName, intent);
680
734
  is = decayIntent(is, intent, at);
681
735
  is = settlePending(is, intent, learning, at);
736
+ is = pinAnchors(is, at);
682
737
  if (isSignal) {
683
738
  const { effective } = signalWeights(is, intent, learning, name);
684
739
  const n = occurrences(episode, intentName, name) + 1;
@@ -688,7 +743,8 @@ function observe(state, event) {
688
743
  if (!isOutcome) episode = markSeen(episode, intentName, name);
689
744
  }
690
745
  if (isOutcome) {
691
- is = recordEpisode(is, episodeRecord(episode, intentName).seen, 1);
746
+ const record2 = episodeRecord(episode, intentName);
747
+ if (!record2.resolved || record2.seen.length > 0) is = recordEpisode(is, record2.seen, 1);
692
748
  is = creditPending(is, intent, at);
693
749
  episode = markResolved(episode, intentName);
694
750
  is = { ...is, lastOutcomeAt: at };
@@ -698,6 +754,16 @@ function observe(state, event) {
698
754
  }
699
755
  return { ...s, intents, episode };
700
756
  }
757
+ function pinAnchors(is, at) {
758
+ const reveal = is.lastRevealedAt !== null && is.lastRevealedAt > at;
759
+ const outcome = is.lastOutcomeAt !== null && is.lastOutcomeAt > at;
760
+ if (!reveal && !outcome) return is;
761
+ return {
762
+ ...is,
763
+ lastRevealedAt: reveal ? at : is.lastRevealedAt,
764
+ lastOutcomeAt: outcome ? at : is.lastOutcomeAt
765
+ };
766
+ }
701
767
  function endEpisode(state, at) {
702
768
  const s = requireState(state);
703
769
  const t = expectTimestamp(at, "at");
@@ -1015,7 +1081,9 @@ function parseState(value) {
1015
1081
  }
1016
1082
  return {
1017
1083
  version: STATE_VERSION,
1018
- config: normalizeConfig(r.config),
1084
+ // Without the strict checks: a config this release would refuse at createState still loads
1085
+ // when it is only stored, and the config passed in replaces it anyway.
1086
+ config: normalizeConfig(r.config, false),
1019
1087
  intents: mapOwn(r.intents, "state.intents", intentState),
1020
1088
  episode: episodeState(r.episode, "state.episode")
1021
1089
  };