oracular 0.9.0 → 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.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) {
@@ -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
  };
@@ -250,13 +249,12 @@ function withContributions(state, intent, contributions) {
250
249
  function decayIntent(state, intent, at) {
251
250
  if (state.updatedAt === null) return { ...state, updatedAt: at };
252
251
  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 };
252
+ if (d === 1) return at === state.updatedAt ? state : { ...state, updatedAt: at };
255
253
  const contributions = mapRecord(state.contributions, (c) => ({
256
254
  alpha: c.alpha * d,
257
255
  beta: c.beta * d
258
256
  }));
259
- return withContributions({ ...state, updatedAt }, intent, contributions);
257
+ return withContributions({ ...state, updatedAt: at }, intent, contributions);
260
258
  }
261
259
  function applySignal(state, intent, signal, weight) {
262
260
  const prev = getOwn(state.contributions, signal) ?? { alpha: 0, beta: 0 };
@@ -273,6 +271,7 @@ var DEFAULT_Z = 1.28;
273
271
  var DEFAULT_GAIN = 2;
274
272
  var DEFAULT_PRIOR_EPISODES = 10;
275
273
  var MAX_MAGNITUDE = 1e6;
274
+ var MAX_Z = 10;
276
275
  var CONFIG_KEYS = ["intents", "episodeTimeout", "learning", "reveal"];
277
276
  var INTENT_KEYS = [
278
277
  "prior",
@@ -357,14 +356,17 @@ function expectKeyName(name, path) {
357
356
  if (name === "__proto__") typeError(path, 'must not use the reserved name "__proto__"');
358
357
  return name;
359
358
  }
360
- function normalizeRevealOptions(value, path) {
359
+ function normalizeRevealOptions(value, path, strict = true) {
361
360
  const record2 = expectRecord(value, path);
362
361
  expectKeys(record2, REVEAL_KEYS, path);
363
362
  const out = {};
364
363
  if (record2.threshold !== void 0) {
365
- out.threshold = expectClosedUnit(record2.threshold, `${path}.threshold`);
364
+ out.threshold = strict ? expectOpenUnit(record2.threshold, `${path}.threshold`) : expectClosedUnit(record2.threshold, `${path}.threshold`);
365
+ }
366
+ if (record2.z !== void 0) {
367
+ out.z = expectNonNegative(record2.z, `${path}.z`);
368
+ if (strict && out.z > MAX_Z) rangeError(`${path}.z`, `must be at most ${MAX_Z}`);
366
369
  }
367
- if (record2.z !== void 0) out.z = expectNonNegative(record2.z, `${path}.z`);
368
370
  if (record2.cooldown !== void 0) {
369
371
  out.cooldown = expectNonNegative(record2.cooldown, `${path}.cooldown`);
370
372
  }
@@ -394,7 +396,7 @@ function normalizeLearning(value, path) {
394
396
  }
395
397
  return out;
396
398
  }
397
- function normalizeIntentConfig(value, path) {
399
+ function normalizeIntentConfig(value, path, strict = true) {
398
400
  const record2 = expectRecord(value, path);
399
401
  expectKeys(record2, INTENT_KEYS, path);
400
402
  const signalsRecord = expectRecord(record2.signals, `${path}.signals`);
@@ -436,11 +438,18 @@ function normalizeIntentConfig(value, path) {
436
438
  if (record2.attribution !== void 0) {
437
439
  out.attribution = expectPositive(record2.attribution, `${path}.attribution`);
438
440
  }
439
- if (record2.reveal !== void 0)
440
- out.reveal = normalizeRevealOptions(record2.reveal, `${path}.reveal`);
441
+ if (record2.reveal !== void 0) {
442
+ out.reveal = normalizeRevealOptions(record2.reveal, `${path}.reveal`, strict);
443
+ }
444
+ if (strict && out.outcome !== void 0 && hasOwn(signals, out.outcome) && out.resetOnOutcome !== false) {
445
+ typeError(
446
+ `${path}.outcome`,
447
+ "is also one of its signals: the reset on outcome would wipe what it adds; set resetOnOutcome: false to keep it"
448
+ );
449
+ }
441
450
  return out;
442
451
  }
443
- function normalizeConfig(value) {
452
+ function normalizeConfig(value, strict = true) {
444
453
  const record2 = expectRecord(value, "config");
445
454
  expectKeys(record2, CONFIG_KEYS, "config");
446
455
  const intentsRecord = expectRecord(record2.intents, "config.intents");
@@ -451,7 +460,8 @@ function normalizeConfig(value) {
451
460
  expectKeyName(name, "config.intents");
452
461
  intents[name] = normalizeIntentConfig(
453
462
  intentsRecord[name],
454
- `config.intents[${JSON.stringify(name)}]`
463
+ `config.intents[${JSON.stringify(name)}]`,
464
+ strict
455
465
  );
456
466
  }
457
467
  const out = { intents };
@@ -460,8 +470,9 @@ function normalizeConfig(value) {
460
470
  }
461
471
  if (record2.learning !== void 0)
462
472
  out.learning = normalizeLearning(record2.learning, "config.learning");
463
- if (record2.reveal !== void 0)
464
- out.reveal = normalizeRevealOptions(record2.reveal, "config.reveal");
473
+ if (record2.reveal !== void 0) {
474
+ out.reveal = normalizeRevealOptions(record2.reveal, "config.reveal", strict);
475
+ }
465
476
  return out;
466
477
  }
467
478
  function normalizeExplainOptions(value, path) {
@@ -491,12 +502,12 @@ function splitEvaluateOptions(value, path) {
491
502
  else explain2[key] = v;
492
503
  }
493
504
  return {
494
- reveal: normalizeRevealOptions(reveal, path),
505
+ reveal: normalizeRevealOptions(reveal, path, false),
495
506
  explain: normalizeExplainOptions(explain2, path)
496
507
  };
497
508
  }
498
509
  function resolveReveal(config, intent, opts, normalized = false) {
499
- const o = opts === void 0 ? {} : normalized ? opts : normalizeRevealOptions(opts, "options");
510
+ const o = opts === void 0 ? {} : normalized ? opts : normalizeRevealOptions(opts, "options", false);
500
511
  return {
501
512
  threshold: o.threshold ?? intent.reveal?.threshold ?? config?.reveal?.threshold ?? DEFAULT_THRESHOLD,
502
513
  z: o.z ?? intent.reveal?.z ?? config?.reveal?.z ?? DEFAULT_Z,
@@ -534,11 +545,7 @@ function timedOutAt(episode, timeout, at) {
534
545
  return at - episode.lastEventAt > timeout ? episode.lastEventAt + timeout : null;
535
546
  }
536
547
  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
- };
548
+ return { startedAt: episode.startedAt ?? at, lastEventAt: at, intents: episode.intents };
542
549
  }
543
550
  function episodeRecord(episode, intent) {
544
551
  return getOwn(episode.intents, intent) ?? { seen: [], counts: {}, resolved: false };
@@ -596,7 +603,7 @@ function assessIntent(state, name, intent, learning, at) {
596
603
  }
597
604
  function cooldownEnd(since, cooldown, at) {
598
605
  if (since === null) return null;
599
- const end = since + cooldown;
606
+ const end = Math.min(since, at) + cooldown;
600
607
  return at < end ? end : null;
601
608
  }
602
609
  function buildPrediction(is, name, reveal, at) {
@@ -724,6 +731,7 @@ function observe(state, event) {
724
731
  let is = getIntentState(s, intentName, intent);
725
732
  is = decayIntent(is, intent, at);
726
733
  is = settlePending(is, intent, learning, at);
734
+ is = pinAnchors(is, at);
727
735
  if (isSignal) {
728
736
  const { effective } = signalWeights(is, intent, learning, name);
729
737
  const n = occurrences(episode, intentName, name) + 1;
@@ -733,7 +741,8 @@ function observe(state, event) {
733
741
  if (!isOutcome) episode = markSeen(episode, intentName, name);
734
742
  }
735
743
  if (isOutcome) {
736
- is = recordEpisode(is, episodeRecord(episode, intentName).seen, 1);
744
+ const record2 = episodeRecord(episode, intentName);
745
+ if (!record2.resolved || record2.seen.length > 0) is = recordEpisode(is, record2.seen, 1);
737
746
  is = creditPending(is, intent, at);
738
747
  episode = markResolved(episode, intentName);
739
748
  is = { ...is, lastOutcomeAt: at };
@@ -743,6 +752,16 @@ function observe(state, event) {
743
752
  }
744
753
  return { ...s, intents, episode };
745
754
  }
755
+ function pinAnchors(is, at) {
756
+ const reveal = is.lastRevealedAt !== null && is.lastRevealedAt > at;
757
+ const outcome = is.lastOutcomeAt !== null && is.lastOutcomeAt > at;
758
+ if (!reveal && !outcome) return is;
759
+ return {
760
+ ...is,
761
+ lastRevealedAt: reveal ? at : is.lastRevealedAt,
762
+ lastOutcomeAt: outcome ? at : is.lastOutcomeAt
763
+ };
764
+ }
746
765
  function endEpisode(state, at) {
747
766
  const s = requireState(state);
748
767
  const t = expectTimestamp(at, "at");
@@ -1060,7 +1079,9 @@ function parseState(value) {
1060
1079
  }
1061
1080
  return {
1062
1081
  version: STATE_VERSION,
1063
- config: normalizeConfig(r.config),
1082
+ // Without the strict checks: a config this release would refuse at createState still loads
1083
+ // when it is only stored, and the config passed in replaces it anyway.
1084
+ config: normalizeConfig(r.config, false),
1064
1085
  intents: mapOwn(r.intents, "state.intents", intentState),
1065
1086
  episode: episodeState(r.episode, "state.episode")
1066
1087
  };