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