oracular 0.9.0 → 0.11.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.d.cts CHANGED
@@ -629,7 +629,9 @@ declare function explain(state: EngineState, intent: string, at: number, opts?:
629
629
  * `w × log2(1 + 1/n)`, counted since the episode started or the intent's outcome last arrived.
630
630
  * That is the default when `config.episodeTimeout` is set, and `IntentConfig.saturate` overrides
631
631
  * it either way; otherwise every occurrence adds all of `w`. For every intent the event is the
632
- * outcome of: give the remembered signals a hit, count the episode toward the base rate, credit
632
+ * outcome of: give the signals remembered since the episode started or the outcome last arrived a
633
+ * hit and count the episode toward the base rate (a repeated outcome with nothing seen since
634
+ * counts nothing), credit
633
635
  * the closed episodes still waiting under `IntentConfig.attribution` by `2^(−age / halfLife)`
634
636
  * each, record `lastOutcomeAt`, and (by default) reset the evidence to the prior. Any event opens
635
637
  * or extends the current episode; an idle gap longer than `config.episodeTimeout` closes the
@@ -824,7 +826,9 @@ declare function serialize(state: EngineState): string;
824
826
  /**
825
827
  * Restores a state saved with {@link serialize}.
826
828
  *
827
- * Always pass the app's current `config`: it is validated and replaces the stored snapshot,
829
+ * Always pass the app's current `config`: it is validated and replaces the stored snapshot (the
830
+ * stored one is read without the checks that refuse a config that cannot work, so a state saved
831
+ * before those checks existed still loads),
828
832
  * intents and signals no longer declared are dropped (α and β are recomputed from the kept
829
833
  * contributions), and newly declared intents start at their prior. States saved by oracular
830
834
  * 0.1.0 to 0.5.x are upgraded to the current shape on their own, and a state one version newer
@@ -863,10 +867,13 @@ declare function deserialize(json: string, opts?: DeserializeOptions): EngineSta
863
867
  * @param config - Intents with their priors and signals, plus engine-wide options. Validated
864
868
  * strictly: unknown keys, non-finite numbers and out-of-range values throw.
865
869
  * @returns A new, JSON-serializable state. `config` is not mutated.
866
- * @throws `TypeError` when a value has the wrong type or a key is not recognised.
870
+ * @throws `TypeError` when a value has the wrong type, a key is not recognised, or an intent's
871
+ * `outcome` is also one of its signals while `resetOnOutcome` is on (the reset would wipe what
872
+ * the signal just added).
867
873
  * @throws `RangeError` when a number is out of range, for example a `prior` outside (0, 1), a
868
- * signal weight of 0, or a weight, `priorStrength`, `learning.gain` or
869
- * `learning.priorEpisodes` beyond 1e6 (larger values could overflow the arithmetic).
874
+ * signal weight of 0, a reveal `threshold` of 0 or 1, a `z` above 10, or a weight,
875
+ * `priorStrength`, `learning.gain` or `learning.priorEpisodes` beyond 1e6 (larger values could
876
+ * overflow the arithmetic).
870
877
  * @example
871
878
  * ```ts
872
879
  * const state = createState({
@@ -929,9 +936,11 @@ declare const ms: Units;
929
936
  *
930
937
  * @param config - The config to check. Typed `unknown` so parsed JSON can be passed as it is.
931
938
  * @returns A fresh, normalized {@link EngineConfig}. `config` is not mutated.
932
- * @throws `TypeError` when a value has the wrong type or a key is not recognised.
939
+ * @throws `TypeError` when a value has the wrong type, a key is not recognised, or an intent's
940
+ * `outcome` is also one of its signals while `resetOnOutcome` is on.
933
941
  * @throws `RangeError` when a number is out of range: a `prior` outside (0, 1), a signal weight of
934
- * 0, or a weight, `priorStrength`, `learning.gain` or `learning.priorEpisodes` beyond 1e6.
942
+ * 0, a reveal `threshold` of 0 or 1, a `z` above 10, or a weight, `priorStrength`,
943
+ * `learning.gain` or `learning.priorEpisodes` beyond 1e6.
935
944
  * @example
936
945
  * ```ts
937
946
  * import { readFileSync } from 'node:fs';
package/dist/index.d.ts CHANGED
@@ -629,7 +629,9 @@ declare function explain(state: EngineState, intent: string, at: number, opts?:
629
629
  * `w × log2(1 + 1/n)`, counted since the episode started or the intent's outcome last arrived.
630
630
  * That is the default when `config.episodeTimeout` is set, and `IntentConfig.saturate` overrides
631
631
  * it either way; otherwise every occurrence adds all of `w`. For every intent the event is the
632
- * outcome of: give the remembered signals a hit, count the episode toward the base rate, credit
632
+ * outcome of: give the signals remembered since the episode started or the outcome last arrived a
633
+ * hit and count the episode toward the base rate (a repeated outcome with nothing seen since
634
+ * counts nothing), credit
633
635
  * the closed episodes still waiting under `IntentConfig.attribution` by `2^(−age / halfLife)`
634
636
  * each, record `lastOutcomeAt`, and (by default) reset the evidence to the prior. Any event opens
635
637
  * or extends the current episode; an idle gap longer than `config.episodeTimeout` closes the
@@ -824,7 +826,9 @@ declare function serialize(state: EngineState): string;
824
826
  /**
825
827
  * Restores a state saved with {@link serialize}.
826
828
  *
827
- * Always pass the app's current `config`: it is validated and replaces the stored snapshot,
829
+ * Always pass the app's current `config`: it is validated and replaces the stored snapshot (the
830
+ * stored one is read without the checks that refuse a config that cannot work, so a state saved
831
+ * before those checks existed still loads),
828
832
  * intents and signals no longer declared are dropped (α and β are recomputed from the kept
829
833
  * contributions), and newly declared intents start at their prior. States saved by oracular
830
834
  * 0.1.0 to 0.5.x are upgraded to the current shape on their own, and a state one version newer
@@ -863,10 +867,13 @@ declare function deserialize(json: string, opts?: DeserializeOptions): EngineSta
863
867
  * @param config - Intents with their priors and signals, plus engine-wide options. Validated
864
868
  * strictly: unknown keys, non-finite numbers and out-of-range values throw.
865
869
  * @returns A new, JSON-serializable state. `config` is not mutated.
866
- * @throws `TypeError` when a value has the wrong type or a key is not recognised.
870
+ * @throws `TypeError` when a value has the wrong type, a key is not recognised, or an intent's
871
+ * `outcome` is also one of its signals while `resetOnOutcome` is on (the reset would wipe what
872
+ * the signal just added).
867
873
  * @throws `RangeError` when a number is out of range, for example a `prior` outside (0, 1), a
868
- * signal weight of 0, or a weight, `priorStrength`, `learning.gain` or
869
- * `learning.priorEpisodes` beyond 1e6 (larger values could overflow the arithmetic).
874
+ * signal weight of 0, a reveal `threshold` of 0 or 1, a `z` above 10, or a weight,
875
+ * `priorStrength`, `learning.gain` or `learning.priorEpisodes` beyond 1e6 (larger values could
876
+ * overflow the arithmetic).
870
877
  * @example
871
878
  * ```ts
872
879
  * const state = createState({
@@ -929,9 +936,11 @@ declare const ms: Units;
929
936
  *
930
937
  * @param config - The config to check. Typed `unknown` so parsed JSON can be passed as it is.
931
938
  * @returns A fresh, normalized {@link EngineConfig}. `config` is not mutated.
932
- * @throws `TypeError` when a value has the wrong type or a key is not recognised.
939
+ * @throws `TypeError` when a value has the wrong type, a key is not recognised, or an intent's
940
+ * `outcome` is also one of its signals while `resetOnOutcome` is on.
933
941
  * @throws `RangeError` when a number is out of range: a `prior` outside (0, 1), a signal weight of
934
- * 0, or a weight, `priorStrength`, `learning.gain` or `learning.priorEpisodes` beyond 1e6.
942
+ * 0, a reveal `threshold` of 0 or 1, a `z` above 10, or a weight, `priorStrength`,
943
+ * `learning.gain` or `learning.priorEpisodes` beyond 1e6.
935
944
  * @example
936
945
  * ```ts
937
946
  * import { readFileSync } from 'node:fs';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/math.ts
2
2
  function decayFactor(elapsed, halfLife) {
3
- if (!(elapsed > 0) || !Number.isFinite(elapsed)) return 1;
3
+ if (!(elapsed > 0)) return 1;
4
4
  return 2 ** (-elapsed / halfLife);
5
5
  }
6
6
  function betaMean(alpha, beta) {
@@ -77,8 +77,8 @@ function lowerBound(alpha, beta, z, bound) {
77
77
  function repeatFactor(n) {
78
78
  return Math.log1p(1 / n) / Math.LN2;
79
79
  }
80
- function laplace(successes, total) {
81
- return (successes + 1) / (total + 2);
80
+ function smoothRate(successes, total, rate) {
81
+ return (successes + 2 * rate) / (total + 2);
82
82
  }
83
83
  function lift(q, b, type = "normalized-difference") {
84
84
  if (type === "absolute-difference") return q - b;
@@ -136,12 +136,11 @@ function mapRecord(record2, fn) {
136
136
  var NONE = { hits: 0, misses: 0 };
137
137
  function decayLearned(state, learning, at) {
138
138
  if (state.learnedAt === null) return { ...state, learnedAt: at };
139
- const learnedAt = Math.max(state.learnedAt, at);
140
139
  const d = learning.halfLife === null ? 1 : decayFactor(at - state.learnedAt, learning.halfLife);
141
- if (d === 1) return learnedAt === state.learnedAt ? state : { ...state, learnedAt };
140
+ if (d === 1) return at === state.learnedAt ? state : { ...state, learnedAt: at };
142
141
  return {
143
142
  ...state,
144
- learnedAt,
143
+ learnedAt: at,
145
144
  learned: mapRecord(state.learned, (r) => ({ hits: r.hits * d, misses: r.misses * d })),
146
145
  episodes: { hits: state.episodes.hits * d, misses: state.episodes.misses * d }
147
146
  };
@@ -153,12 +152,13 @@ function signalWeights(state, intent, learning, signal) {
153
152
  }
154
153
  const record2 = getOwn(state.learned, signal) ?? NONE;
155
154
  const nWith = record2.hits + record2.misses;
156
- const qWith = laplace(record2.hits, nWith);
157
155
  const totalEpisodes = state.episodes.hits + state.episodes.misses;
158
156
  const nWithout = Math.max(0, totalEpisodes - nWith);
159
157
  const outcomesWithout = Math.min(nWithout, Math.max(0, state.episodes.hits - record2.hits));
160
- const qWithout = laplace(outcomesWithout, nWithout);
161
- const baseline = learning.lift === "association-rule" ? (record2.hits + outcomesWithout + 2) / (nWith + nWithout + 4) : qWithout;
158
+ const base = totalEpisodes === 0 ? 0 : state.episodes.hits / totalEpisodes;
159
+ const qWith = smoothRate(record2.hits, nWith, base);
160
+ const qWithout = smoothRate(outcomesWithout, nWithout, base);
161
+ const baseline = learning.lift === "association-rule" ? smoothRate(record2.hits + outcomesWithout, nWith + nWithout, base) : qWithout;
162
162
  const score = learningScore(lift(qWith, baseline, learning.lift), learning.lift);
163
163
  const learned = nWith === 0 ? 0 : learning.gain * Math.abs(initial) * score;
164
164
  const nEff = nWith + nWithout === 0 ? 0 : nWith * nWithout / (nWith + nWithout);
@@ -250,13 +250,12 @@ function withContributions(state, intent, contributions) {
250
250
  function decayIntent(state, intent, at) {
251
251
  if (state.updatedAt === null) return { ...state, updatedAt: at };
252
252
  const d = decayFactor(at - state.updatedAt, intent.halfLife);
253
- const updatedAt = Math.max(state.updatedAt, at);
254
- if (d === 1) return updatedAt === state.updatedAt ? state : { ...state, updatedAt };
253
+ if (d === 1) return at === state.updatedAt ? state : { ...state, updatedAt: at };
255
254
  const contributions = mapRecord(state.contributions, (c) => ({
256
255
  alpha: c.alpha * d,
257
256
  beta: c.beta * d
258
257
  }));
259
- return withContributions({ ...state, updatedAt }, intent, contributions);
258
+ return withContributions({ ...state, updatedAt: at }, intent, contributions);
260
259
  }
261
260
  function applySignal(state, intent, signal, weight) {
262
261
  const prev = getOwn(state.contributions, signal) ?? { alpha: 0, beta: 0 };
@@ -273,6 +272,7 @@ var DEFAULT_Z = 1.28;
273
272
  var DEFAULT_GAIN = 2;
274
273
  var DEFAULT_PRIOR_EPISODES = 10;
275
274
  var MAX_MAGNITUDE = 1e6;
275
+ var MAX_Z = 10;
276
276
  var CONFIG_KEYS = ["intents", "episodeTimeout", "learning", "reveal"];
277
277
  var INTENT_KEYS = [
278
278
  "prior",
@@ -357,14 +357,17 @@ function expectKeyName(name, path) {
357
357
  if (name === "__proto__") typeError(path, 'must not use the reserved name "__proto__"');
358
358
  return name;
359
359
  }
360
- function normalizeRevealOptions(value, path) {
360
+ function normalizeRevealOptions(value, path, strict = true) {
361
361
  const record2 = expectRecord(value, path);
362
362
  expectKeys(record2, REVEAL_KEYS, path);
363
363
  const out = {};
364
364
  if (record2.threshold !== void 0) {
365
- out.threshold = expectClosedUnit(record2.threshold, `${path}.threshold`);
365
+ out.threshold = strict ? expectOpenUnit(record2.threshold, `${path}.threshold`) : expectClosedUnit(record2.threshold, `${path}.threshold`);
366
+ }
367
+ if (record2.z !== void 0) {
368
+ out.z = expectNonNegative(record2.z, `${path}.z`);
369
+ if (strict && out.z > MAX_Z) rangeError(`${path}.z`, `must be at most ${MAX_Z}`);
366
370
  }
367
- if (record2.z !== void 0) out.z = expectNonNegative(record2.z, `${path}.z`);
368
371
  if (record2.cooldown !== void 0) {
369
372
  out.cooldown = expectNonNegative(record2.cooldown, `${path}.cooldown`);
370
373
  }
@@ -394,7 +397,7 @@ function normalizeLearning(value, path) {
394
397
  }
395
398
  return out;
396
399
  }
397
- function normalizeIntentConfig(value, path) {
400
+ function normalizeIntentConfig(value, path, strict = true) {
398
401
  const record2 = expectRecord(value, path);
399
402
  expectKeys(record2, INTENT_KEYS, path);
400
403
  const signalsRecord = expectRecord(record2.signals, `${path}.signals`);
@@ -436,11 +439,18 @@ function normalizeIntentConfig(value, path) {
436
439
  if (record2.attribution !== void 0) {
437
440
  out.attribution = expectPositive(record2.attribution, `${path}.attribution`);
438
441
  }
439
- if (record2.reveal !== void 0)
440
- out.reveal = normalizeRevealOptions(record2.reveal, `${path}.reveal`);
442
+ if (record2.reveal !== void 0) {
443
+ out.reveal = normalizeRevealOptions(record2.reveal, `${path}.reveal`, strict);
444
+ }
445
+ if (strict && out.outcome !== void 0 && hasOwn(signals, out.outcome) && out.resetOnOutcome !== false) {
446
+ typeError(
447
+ `${path}.outcome`,
448
+ "is also one of its signals: the reset on outcome would wipe what it adds; set resetOnOutcome: false to keep it"
449
+ );
450
+ }
441
451
  return out;
442
452
  }
443
- function normalizeConfig(value) {
453
+ function normalizeConfig(value, strict = true) {
444
454
  const record2 = expectRecord(value, "config");
445
455
  expectKeys(record2, CONFIG_KEYS, "config");
446
456
  const intentsRecord = expectRecord(record2.intents, "config.intents");
@@ -451,7 +461,8 @@ function normalizeConfig(value) {
451
461
  expectKeyName(name, "config.intents");
452
462
  intents[name] = normalizeIntentConfig(
453
463
  intentsRecord[name],
454
- `config.intents[${JSON.stringify(name)}]`
464
+ `config.intents[${JSON.stringify(name)}]`,
465
+ strict
455
466
  );
456
467
  }
457
468
  const out = { intents };
@@ -460,8 +471,9 @@ function normalizeConfig(value) {
460
471
  }
461
472
  if (record2.learning !== void 0)
462
473
  out.learning = normalizeLearning(record2.learning, "config.learning");
463
- if (record2.reveal !== void 0)
464
- out.reveal = normalizeRevealOptions(record2.reveal, "config.reveal");
474
+ if (record2.reveal !== void 0) {
475
+ out.reveal = normalizeRevealOptions(record2.reveal, "config.reveal", strict);
476
+ }
465
477
  return out;
466
478
  }
467
479
  function normalizeExplainOptions(value, path) {
@@ -491,12 +503,12 @@ function splitEvaluateOptions(value, path) {
491
503
  else explain2[key] = v;
492
504
  }
493
505
  return {
494
- reveal: normalizeRevealOptions(reveal, path),
506
+ reveal: normalizeRevealOptions(reveal, path, false),
495
507
  explain: normalizeExplainOptions(explain2, path)
496
508
  };
497
509
  }
498
510
  function resolveReveal(config, intent, opts, normalized = false) {
499
- const o = opts === void 0 ? {} : normalized ? opts : normalizeRevealOptions(opts, "options");
511
+ const o = opts === void 0 ? {} : normalized ? opts : normalizeRevealOptions(opts, "options", false);
500
512
  return {
501
513
  threshold: o.threshold ?? intent.reveal?.threshold ?? config?.reveal?.threshold ?? DEFAULT_THRESHOLD,
502
514
  z: o.z ?? intent.reveal?.z ?? config?.reveal?.z ?? DEFAULT_Z,
@@ -534,11 +546,7 @@ function timedOutAt(episode, timeout, at) {
534
546
  return at - episode.lastEventAt > timeout ? episode.lastEventAt + timeout : null;
535
547
  }
536
548
  function extendEpisode(episode, at) {
537
- return {
538
- startedAt: episode.startedAt ?? at,
539
- lastEventAt: episode.lastEventAt === null ? at : Math.max(episode.lastEventAt, at),
540
- intents: episode.intents
541
- };
549
+ return { startedAt: episode.startedAt ?? at, lastEventAt: at, intents: episode.intents };
542
550
  }
543
551
  function episodeRecord(episode, intent) {
544
552
  return getOwn(episode.intents, intent) ?? { seen: [], counts: {}, resolved: false };
@@ -596,7 +604,7 @@ function assessIntent(state, name, intent, learning, at) {
596
604
  }
597
605
  function cooldownEnd(since, cooldown, at) {
598
606
  if (since === null) return null;
599
- const end = since + cooldown;
607
+ const end = Math.min(since, at) + cooldown;
600
608
  return at < end ? end : null;
601
609
  }
602
610
  function buildPrediction(is, name, reveal, at) {
@@ -724,6 +732,7 @@ function observe(state, event) {
724
732
  let is = getIntentState(s, intentName, intent);
725
733
  is = decayIntent(is, intent, at);
726
734
  is = settlePending(is, intent, learning, at);
735
+ is = pinAnchors(is, at);
727
736
  if (isSignal) {
728
737
  const { effective } = signalWeights(is, intent, learning, name);
729
738
  const n = occurrences(episode, intentName, name) + 1;
@@ -733,7 +742,8 @@ function observe(state, event) {
733
742
  if (!isOutcome) episode = markSeen(episode, intentName, name);
734
743
  }
735
744
  if (isOutcome) {
736
- is = recordEpisode(is, episodeRecord(episode, intentName).seen, 1);
745
+ const record2 = episodeRecord(episode, intentName);
746
+ if (!record2.resolved || record2.seen.length > 0) is = recordEpisode(is, record2.seen, 1);
737
747
  is = creditPending(is, intent, at);
738
748
  episode = markResolved(episode, intentName);
739
749
  is = { ...is, lastOutcomeAt: at };
@@ -743,6 +753,16 @@ function observe(state, event) {
743
753
  }
744
754
  return { ...s, intents, episode };
745
755
  }
756
+ function pinAnchors(is, at) {
757
+ const reveal = is.lastRevealedAt !== null && is.lastRevealedAt > at;
758
+ const outcome = is.lastOutcomeAt !== null && is.lastOutcomeAt > at;
759
+ if (!reveal && !outcome) return is;
760
+ return {
761
+ ...is,
762
+ lastRevealedAt: reveal ? at : is.lastRevealedAt,
763
+ lastOutcomeAt: outcome ? at : is.lastOutcomeAt
764
+ };
765
+ }
746
766
  function endEpisode(state, at) {
747
767
  const s = requireState(state);
748
768
  const t = expectTimestamp(at, "at");
@@ -1060,7 +1080,9 @@ function parseState(value) {
1060
1080
  }
1061
1081
  return {
1062
1082
  version: STATE_VERSION,
1063
- config: normalizeConfig(r.config),
1083
+ // Without the strict checks: a config this release would refuse at createState still loads
1084
+ // when it is only stored, and the config passed in replaces it anyway.
1085
+ config: normalizeConfig(r.config, false),
1064
1086
  intents: mapOwn(r.intents, "state.intents", intentState),
1065
1087
  episode: episodeState(r.episode, "state.episode")
1066
1088
  };