oracular 0.2.1 → 0.3.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 CHANGED
@@ -18,6 +18,9 @@ function clamp01(x) {
18
18
  if (x > 1) return 1;
19
19
  return x;
20
20
  }
21
+ function repeatFactor(n) {
22
+ return Math.log1p(1 / n) / Math.LN2;
23
+ }
21
24
  function laplace(successes, total) {
22
25
  return (successes + 1) / (total + 2);
23
26
  }
@@ -143,6 +146,7 @@ var DEFAULT_THRESHOLD = 0.5;
143
146
  var DEFAULT_Z = 1.28;
144
147
  var DEFAULT_GAIN = 2;
145
148
  var DEFAULT_PRIOR_EPISODES = 10;
149
+ var MAX_MAGNITUDE = 1e6;
146
150
  var CONFIG_KEYS = ["intents", "episodeTimeout", "learning", "reveal"];
147
151
  var INTENT_KEYS = [
148
152
  "prior",
@@ -151,6 +155,7 @@ var INTENT_KEYS = [
151
155
  "signals",
152
156
  "outcome",
153
157
  "resetOnOutcome",
158
+ "saturate",
154
159
  "reveal"
155
160
  ];
156
161
  var REVEAL_KEYS = ["threshold", "z", "cooldown"];
@@ -186,6 +191,11 @@ function expectPositive(value, path) {
186
191
  if (n <= 0) rangeError(path, "must be greater than 0");
187
192
  return n;
188
193
  }
194
+ function expectBoundedPositive(value, path) {
195
+ const n = expectPositive(value, path);
196
+ if (n > MAX_MAGNITUDE) rangeError(path, `must be at most ${MAX_MAGNITUDE}`);
197
+ return n;
198
+ }
189
199
  function expectNonNegative(value, path) {
190
200
  const n = expectNumber(value, path);
191
201
  if (n < 0) rangeError(path, "must be 0 or greater");
@@ -230,9 +240,9 @@ function normalizeLearning(value, path) {
230
240
  const out = {};
231
241
  if (record2.halfLife !== void 0)
232
242
  out.halfLife = expectPositive(record2.halfLife, `${path}.halfLife`);
233
- if (record2.gain !== void 0) out.gain = expectPositive(record2.gain, `${path}.gain`);
243
+ if (record2.gain !== void 0) out.gain = expectBoundedPositive(record2.gain, `${path}.gain`);
234
244
  if (record2.priorEpisodes !== void 0) {
235
- out.priorEpisodes = expectPositive(record2.priorEpisodes, `${path}.priorEpisodes`);
245
+ out.priorEpisodes = expectBoundedPositive(record2.priorEpisodes, `${path}.priorEpisodes`);
236
246
  }
237
247
  return out;
238
248
  }
@@ -250,11 +260,17 @@ function normalizeIntentConfig(value, path) {
250
260
  "must be non-zero (positive is evidence for the intent, negative is evidence against)"
251
261
  );
252
262
  }
263
+ if (Math.abs(w) > MAX_MAGNITUDE) {
264
+ rangeError(
265
+ `${path}.signals[${JSON.stringify(name)}]`,
266
+ `must be between -${MAX_MAGNITUDE} and ${MAX_MAGNITUDE}`
267
+ );
268
+ }
253
269
  signals[name] = w;
254
270
  }
255
271
  const out = {
256
272
  prior: expectOpenUnit(record2.prior, `${path}.prior`),
257
- priorStrength: expectPositive(record2.priorStrength, `${path}.priorStrength`),
273
+ priorStrength: expectBoundedPositive(record2.priorStrength, `${path}.priorStrength`),
258
274
  halfLife: expectPositive(record2.halfLife, `${path}.halfLife`),
259
275
  signals
260
276
  };
@@ -265,6 +281,10 @@ function normalizeIntentConfig(value, path) {
265
281
  }
266
282
  out.resetOnOutcome = record2.resetOnOutcome;
267
283
  }
284
+ if (record2.saturate !== void 0) {
285
+ if (typeof record2.saturate !== "boolean") typeError(`${path}.saturate`, "must be a boolean");
286
+ out.saturate = record2.saturate;
287
+ }
268
288
  if (record2.reveal !== void 0)
269
289
  out.reveal = normalizeRevealOptions(record2.reveal, `${path}.reveal`);
270
290
  return out;
@@ -368,22 +388,27 @@ function extendEpisode(episode, at) {
368
388
  };
369
389
  }
370
390
  function episodeRecord(episode, intent) {
371
- return getOwn(episode.intents, intent) ?? { seen: [], resolved: false };
391
+ return getOwn(episode.intents, intent) ?? { seen: [], counts: {}, resolved: false };
392
+ }
393
+ function occurrences(episode, intent, signal) {
394
+ return getOwn(episodeRecord(episode, intent).counts, signal) ?? 0;
372
395
  }
373
396
  function markSeen(episode, intent, signal) {
374
397
  const record2 = episodeRecord(episode, intent);
375
- if (record2.seen.includes(signal)) return episode;
398
+ const seen = record2.seen.includes(signal) ? record2.seen : [...record2.seen, signal];
399
+ const counts = { ...record2.counts, [signal]: (getOwn(record2.counts, signal) ?? 0) + 1 };
400
+ return { ...episode, intents: { ...episode.intents, [intent]: { ...record2, seen, counts } } };
401
+ }
402
+ function markResolved(episode, intent) {
376
403
  return {
377
404
  ...episode,
378
- intents: { ...episode.intents, [intent]: { ...record2, seen: [...record2.seen, signal] } }
405
+ intents: { ...episode.intents, [intent]: { seen: [], counts: {}, resolved: true } }
379
406
  };
380
407
  }
381
- function markResolved(episode, intent) {
382
- return { ...episode, intents: { ...episode.intents, [intent]: { seen: [], resolved: true } } };
383
- }
384
408
 
385
409
  // src/version.ts
386
- var STATE_VERSION = 1;
410
+ var STATE_VERSION = 2;
411
+ var RECORD_VERSION = 1;
387
412
 
388
413
  // src/state.ts
389
414
  function createState(config) {
@@ -543,7 +568,10 @@ function observe(state, event) {
543
568
  is = decayLearned(is, learning, at);
544
569
  if (isSignal) {
545
570
  const { effective } = signalWeights(is, intent, learning, name);
546
- is = applySignal(is, intent, name, effective);
571
+ const n = occurrences(episode, intentName, name) + 1;
572
+ const saturate = intent.saturate ?? s.config.episodeTimeout !== void 0;
573
+ const factor = saturate ? repeatFactor(n) : 1;
574
+ is = applySignal(is, intent, name, effective * factor);
547
575
  if (!isOutcome) episode = markSeen(episode, intentName, name);
548
576
  }
549
577
  if (isOutcome) {
@@ -614,6 +642,103 @@ function markRevealed(state, intent, at) {
614
642
  };
615
643
  }
616
644
 
645
+ // src/recorder.ts
646
+ var RECORD_KEYS = ["installId", "mode", "top"];
647
+ var DEFAULT_TOP = 3;
648
+ function normalizeRecordOptions(value) {
649
+ if (!isRecord(value)) {
650
+ throw new TypeError("oracular: options must be an object like { installId, mode }");
651
+ }
652
+ for (const key of Object.keys(value)) {
653
+ if (!RECORD_KEYS.includes(key)) {
654
+ throw new TypeError(
655
+ `oracular: options.${key} is not a recognised option (expected one of: ${RECORD_KEYS.join(", ")})`
656
+ );
657
+ }
658
+ }
659
+ const installId = expectName(value.installId, "options.installId");
660
+ const mode = value.mode;
661
+ if (mode !== "shadow" && mode !== "live") {
662
+ throw new TypeError('oracular: options.mode must be "shadow" or "live"');
663
+ }
664
+ let top = DEFAULT_TOP;
665
+ if (value.top !== void 0) {
666
+ if (typeof value.top !== "number" || !Number.isFinite(value.top)) {
667
+ throw new TypeError("oracular: options.top must be a finite number");
668
+ }
669
+ if (!Number.isInteger(value.top) || value.top < 0) {
670
+ throw new RangeError("oracular: options.top must be a non-negative integer");
671
+ }
672
+ top = value.top;
673
+ }
674
+ return { installId, mode, top };
675
+ }
676
+ function createRecord(state, event, opts) {
677
+ const s = requireState(state);
678
+ const { name, at } = validateEvent(event);
679
+ const { installId, mode, top } = normalizeRecordOptions(opts);
680
+ const learning = resolveLearning(s.config);
681
+ const decisions = {};
682
+ for (const [intentName, intent] of Object.entries(s.config.intents)) {
683
+ const reveal = resolveReveal(s.config, intent, void 0);
684
+ const is = assessIntent(s, intentName, intent, learning, at);
685
+ const prediction = buildPrediction(is, intentName, reveal, at);
686
+ const rows = buildExplanation(is, intent, learning, {});
687
+ const weights = {};
688
+ for (const signal of Object.keys(intent.signals)) {
689
+ weights[signal] = rows.find((row) => row.signal === signal).effectiveWeight;
690
+ }
691
+ decisions[intentName] = {
692
+ p: prediction.p,
693
+ lower: prediction.lower,
694
+ shouldReveal: decideReveal(prediction, reveal),
695
+ suppressedUntil: prediction.suppressedUntil,
696
+ top: rows.filter((row) => row.contribution !== 0).slice(0, top).map((row) => ({ signal: row.signal, impact: row.impact })),
697
+ weights
698
+ };
699
+ }
700
+ return { v: RECORD_VERSION, kind: "event", installId, mode, event: { name, at }, decisions };
701
+ }
702
+ function createEndRecord(at, opts) {
703
+ const t = expectTimestamp(at, "at");
704
+ const { installId, mode } = normalizeRecordOptions(opts);
705
+ return { v: RECORD_VERSION, kind: "end", installId, mode, at: t };
706
+ }
707
+
708
+ // src/migrate.ts
709
+ var MIGRATIONS = {
710
+ 1: fromV1
711
+ };
712
+ function canMigrate(version) {
713
+ return hasOwn(MIGRATIONS, String(version));
714
+ }
715
+ function migrateStored(stored) {
716
+ let current = stored;
717
+ while (typeof current.version === "number" && canMigrate(current.version)) {
718
+ current = MIGRATIONS[current.version](current);
719
+ }
720
+ return current;
721
+ }
722
+ function fromV1(stored) {
723
+ const episode = stored.episode;
724
+ if (!isRecord(episode) || !isRecord(episode.intents)) return { ...stored, version: 2 };
725
+ const intents = {};
726
+ for (const key of Object.keys(episode.intents)) {
727
+ if (key === "__proto__") continue;
728
+ const record2 = episode.intents[key];
729
+ if (isRecord(record2) && Array.isArray(record2.seen)) {
730
+ const counts = {};
731
+ for (const signal of record2.seen) {
732
+ if (typeof signal === "string" && signal !== "__proto__") counts[signal] = 1;
733
+ }
734
+ intents[key] = { ...record2, counts };
735
+ } else {
736
+ intents[key] = record2;
737
+ }
738
+ }
739
+ return { ...stored, version: 2, episode: { ...episode, intents } };
740
+ }
741
+
617
742
  // src/serialize.ts
618
743
  function serialize(state) {
619
744
  return JSON.stringify(requireState(state));
@@ -642,16 +767,19 @@ function deserialize(json, opts = {}) {
642
767
  }
643
768
  let stored = parsed;
644
769
  if (parsed.version !== STATE_VERSION) {
645
- if (opts.migrate === void 0) {
770
+ if (canMigrate(parsed.version)) {
771
+ stored = migrateStored(parsed);
772
+ } else if (opts.migrate === void 0) {
646
773
  throw new RangeError(
647
- `oracular: stored state is version ${parsed.version} but this library reads version ${STATE_VERSION}; pass a migrate function`
774
+ `oracular: stored state is version ${parsed.version} but this library reads versions 1 to ${STATE_VERSION}; pass a migrate function`
648
775
  );
776
+ } else {
777
+ stored = opts.migrate(parsed, parsed.version);
649
778
  }
650
- stored = opts.migrate(parsed, parsed.version);
651
779
  }
652
780
  const raw = parseState(stored);
653
781
  const config = opts.config === void 0 ? raw.config : normalizeConfig(opts.config);
654
- return reconcile(raw, config);
782
+ return requireComputable(reconcile(raw, config));
655
783
  }
656
784
  function typeError2(path, message) {
657
785
  throw new TypeError(`oracular: ${path} ${message}`);
@@ -715,13 +843,21 @@ function intentState(value, path) {
715
843
  revealCount
716
844
  };
717
845
  }
846
+ function occurrenceCount(value, path) {
847
+ const n = finite(value, path);
848
+ if (!Number.isInteger(n) || n < 1) {
849
+ throw new RangeError(`oracular: ${path} must be a positive integer`);
850
+ }
851
+ return n;
852
+ }
718
853
  function episodeIntent(value, path) {
719
854
  const r = record(value, path);
720
855
  if (!Array.isArray(r.seen) || !r.seen.every((s) => typeof s === "string")) {
721
856
  typeError2(`${path}.seen`, "must be an array of strings");
722
857
  }
858
+ const counts = mapOwn(r.counts, `${path}.counts`, occurrenceCount);
723
859
  if (typeof r.resolved !== "boolean") typeError2(`${path}.resolved`, "must be a boolean");
724
- return { seen: r.seen, resolved: r.resolved };
860
+ return { seen: r.seen, counts, resolved: r.resolved };
725
861
  }
726
862
  function episodeState(value, path) {
727
863
  const r = record(value, path);
@@ -743,6 +879,25 @@ function parseState(value) {
743
879
  episode: episodeState(r.episode, "state.episode")
744
880
  };
745
881
  }
882
+ function requireComputable(state) {
883
+ const learning = resolveLearning(state.config);
884
+ for (const [name, intent] of Object.entries(state.config.intents)) {
885
+ const is = state.intents[name];
886
+ const path = `state.intents[${JSON.stringify(name)}]`;
887
+ if (!Number.isFinite(is.alpha + is.beta) || !Number.isFinite(is.alpha * is.beta)) {
888
+ throw new RangeError(`oracular: ${path} holds more evidence than can be computed with`);
889
+ }
890
+ for (const signal of Object.keys(intent.signals)) {
891
+ const w = signalWeights(is, intent, learning, signal);
892
+ if (!Number.isFinite(w.learned) || !Number.isFinite(w.effective)) {
893
+ throw new RangeError(
894
+ `oracular: ${path} holds learned counts for ${JSON.stringify(signal)} too large to compute with`
895
+ );
896
+ }
897
+ }
898
+ }
899
+ return state;
900
+ }
746
901
  function keep(source, declared) {
747
902
  const out = {};
748
903
  for (const [key, value] of Object.entries(source)) {
@@ -769,7 +924,9 @@ function reconcile(raw, config) {
769
924
  const seen = episode.seen.filter(
770
925
  (s, i) => hasOwn(intent.signals, s) && episode.seen.indexOf(s) === i
771
926
  );
772
- episodeIntents[name] = { seen, resolved: episode.resolved };
927
+ const counts = {};
928
+ for (const s of seen) counts[s] = getOwn(episode.counts, s) ?? 1;
929
+ episodeIntents[name] = { seen, counts, resolved: episode.resolved };
773
930
  }
774
931
  }
775
932
  return {
@@ -784,6 +941,6 @@ function reconcile(raw, config) {
784
941
  };
785
942
  }
786
943
 
787
- export { STATE_VERSION, createState, deserialize, endEpisode, episodeClosed, evaluate, evidenceToReveal, explain, markRevealed, observe, predict, serialize, shouldReveal };
944
+ export { RECORD_VERSION, STATE_VERSION, createEndRecord, createRecord, createState, deserialize, endEpisode, episodeClosed, evaluate, evidenceToReveal, explain, markRevealed, observe, predict, serialize, shouldReveal };
788
945
  //# sourceMappingURL=index.js.map
789
946
  //# sourceMappingURL=index.js.map