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.d.cts CHANGED
@@ -62,6 +62,8 @@ interface RevealOptions {
62
62
  */
63
63
  bound?: 'normal' | 'exact';
64
64
  }
65
+ /** The association measure converted to a signed, bounded score when learning signal weights. */
66
+ type LiftType = 'absolute-difference' | 'relative-lift' | 'risk-ratio' | 'log-lift' | 'odds-ratio' | 'normalized-difference' | 'association-rule';
65
67
  /**
66
68
  * How the engine learns from the user's own outcomes.
67
69
  *
@@ -74,9 +76,17 @@ interface RevealOptions {
74
76
  * ```
75
77
  */
76
78
  interface LearningConfig {
79
+ /**
80
+ * Optional association measure. Every choice is converted to a score in [−1, 1], neutral at
81
+ * zero, before learning: ratios use (R − 1)/(R + 1), relative/log lift use x/(1 + |x|), and
82
+ * absolute/normalized differences keep their scale. Omit to preserve the existing normalized
83
+ * difference exactly. 'association-rule' uses the overall outcome rate as its baseline; the
84
+ * others use episodes without the signal. Raw ratios are reporting metrics, never weights.
85
+ */
86
+ lift?: LiftType;
77
87
  /** Half-life of learned hit/miss counts, in the same unit as `at`. Omit for no forgetting. */
78
88
  halfLife?: number;
79
- /** A perfectly predictive signal converges to `gain × |initial weight|`. Default 2; at most 1e6. */
89
+ /** Caps learned magnitude at `gain × |initial weight|` for every lift option. Default 2; at most 1e6. */
80
90
  gain?: number;
81
91
  /** Pseudo-count behind the developer's initial weight: how many observed episodes it takes to weigh as much as the prior. Default 10; at most 1e6. */
82
92
  priorEpisodes?: number;
@@ -619,7 +629,9 @@ declare function explain(state: EngineState, intent: string, at: number, opts?:
619
629
  * `w × log2(1 + 1/n)`, counted since the episode started or the intent's outcome last arrived.
620
630
  * That is the default when `config.episodeTimeout` is set, and `IntentConfig.saturate` overrides
621
631
  * it either way; otherwise every occurrence adds all of `w`. For every intent the event is the
622
- * 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
623
635
  * the closed episodes still waiting under `IntentConfig.attribution` by `2^(−age / halfLife)`
624
636
  * each, record `lastOutcomeAt`, and (by default) reset the evidence to the prior. Any event opens
625
637
  * or extends the current episode; an idle gap longer than `config.episodeTimeout` closes the
@@ -814,7 +826,9 @@ declare function serialize(state: EngineState): string;
814
826
  /**
815
827
  * Restores a state saved with {@link serialize}.
816
828
  *
817
- * 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),
818
832
  * intents and signals no longer declared are dropped (α and β are recomputed from the kept
819
833
  * contributions), and newly declared intents start at their prior. States saved by oracular
820
834
  * 0.1.0 to 0.5.x are upgraded to the current shape on their own, and a state one version newer
@@ -847,15 +861,19 @@ declare function deserialize(json: string, opts?: DeserializeOptions): EngineSta
847
861
  * Builds a fresh engine state from a config. Every intent starts at its prior and no episode is
848
862
  * open. The config is validated and a normalized copy is stored inside the state, so a persisted
849
863
  * state remembers the weights it was built with; pass your current config to {@link deserialize}
850
- * when loading.
864
+ * when loading. `config.learning.lift` selects the lift formula; omitting it preserves the
865
+ * normalized difference used by existing configs and states.
851
866
  *
852
867
  * @param config - Intents with their priors and signals, plus engine-wide options. Validated
853
868
  * strictly: unknown keys, non-finite numbers and out-of-range values throw.
854
869
  * @returns A new, JSON-serializable state. `config` is not mutated.
855
- * @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).
856
873
  * @throws `RangeError` when a number is out of range, for example a `prior` outside (0, 1), a
857
- * signal weight of 0, or a weight, `priorStrength`, `learning.gain` or
858
- * `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).
859
877
  * @example
860
878
  * ```ts
861
879
  * const state = createState({
@@ -918,9 +936,11 @@ declare const ms: Units;
918
936
  *
919
937
  * @param config - The config to check. Typed `unknown` so parsed JSON can be passed as it is.
920
938
  * @returns A fresh, normalized {@link EngineConfig}. `config` is not mutated.
921
- * @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.
922
941
  * @throws `RangeError` when a number is out of range: a `prior` outside (0, 1), a signal weight of
923
- * 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.
924
944
  * @example
925
945
  * ```ts
926
946
  * import { readFileSync } from 'node:fs';
@@ -932,4 +952,4 @@ declare const ms: Units;
932
952
  */
933
953
  declare function validateConfig(config: unknown): EngineConfig;
934
954
 
935
- export { type DeserializeOptions, type EndRecord, type EngineConfig, type EngineState, type EpisodeIntentRecord, type EpisodeState, type EvaluateOptions, type Evaluation, type EventRecord, type ExplainOptions, type IntentConfig, type IntentEvent, type IntentSnapshot, type IntentState, type LearningConfig, type Migrate, type PendingEpisode, type Prediction, type PresetName, type PresetSource, RECORD_VERSION, type RecordMode, type RecordOptions, type RecordedDecision, type RecorderRecord, type RevealOptions, STATE_VERSION, type SignalContribution, type SignalExplanation, type SignalRecord, type Snapshot, type Units, createEndRecord, createRecord, createState, deserialize, endEpisode, episodeClosed, evaluate, evidenceToReveal, explain, inspect, markRevealed, ms, observe, predict, serialize, shouldReveal, validateConfig };
955
+ export { type DeserializeOptions, type EndRecord, type EngineConfig, type EngineState, type EpisodeIntentRecord, type EpisodeState, type EvaluateOptions, type Evaluation, type EventRecord, type ExplainOptions, type IntentConfig, type IntentEvent, type IntentSnapshot, type IntentState, type LearningConfig, type LiftType, type Migrate, type PendingEpisode, type Prediction, type PresetName, type PresetSource, RECORD_VERSION, type RecordMode, type RecordOptions, type RecordedDecision, type RecorderRecord, type RevealOptions, STATE_VERSION, type SignalContribution, type SignalExplanation, type SignalRecord, type Snapshot, type Units, createEndRecord, createRecord, createState, deserialize, endEpisode, episodeClosed, evaluate, evidenceToReveal, explain, inspect, markRevealed, ms, observe, predict, serialize, shouldReveal, validateConfig };
package/dist/index.d.ts CHANGED
@@ -62,6 +62,8 @@ interface RevealOptions {
62
62
  */
63
63
  bound?: 'normal' | 'exact';
64
64
  }
65
+ /** The association measure converted to a signed, bounded score when learning signal weights. */
66
+ type LiftType = 'absolute-difference' | 'relative-lift' | 'risk-ratio' | 'log-lift' | 'odds-ratio' | 'normalized-difference' | 'association-rule';
65
67
  /**
66
68
  * How the engine learns from the user's own outcomes.
67
69
  *
@@ -74,9 +76,17 @@ interface RevealOptions {
74
76
  * ```
75
77
  */
76
78
  interface LearningConfig {
79
+ /**
80
+ * Optional association measure. Every choice is converted to a score in [−1, 1], neutral at
81
+ * zero, before learning: ratios use (R − 1)/(R + 1), relative/log lift use x/(1 + |x|), and
82
+ * absolute/normalized differences keep their scale. Omit to preserve the existing normalized
83
+ * difference exactly. 'association-rule' uses the overall outcome rate as its baseline; the
84
+ * others use episodes without the signal. Raw ratios are reporting metrics, never weights.
85
+ */
86
+ lift?: LiftType;
77
87
  /** Half-life of learned hit/miss counts, in the same unit as `at`. Omit for no forgetting. */
78
88
  halfLife?: number;
79
- /** A perfectly predictive signal converges to `gain × |initial weight|`. Default 2; at most 1e6. */
89
+ /** Caps learned magnitude at `gain × |initial weight|` for every lift option. Default 2; at most 1e6. */
80
90
  gain?: number;
81
91
  /** Pseudo-count behind the developer's initial weight: how many observed episodes it takes to weigh as much as the prior. Default 10; at most 1e6. */
82
92
  priorEpisodes?: number;
@@ -619,7 +629,9 @@ declare function explain(state: EngineState, intent: string, at: number, opts?:
619
629
  * `w × log2(1 + 1/n)`, counted since the episode started or the intent's outcome last arrived.
620
630
  * That is the default when `config.episodeTimeout` is set, and `IntentConfig.saturate` overrides
621
631
  * it either way; otherwise every occurrence adds all of `w`. For every intent the event is the
622
- * 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
623
635
  * the closed episodes still waiting under `IntentConfig.attribution` by `2^(−age / halfLife)`
624
636
  * each, record `lastOutcomeAt`, and (by default) reset the evidence to the prior. Any event opens
625
637
  * or extends the current episode; an idle gap longer than `config.episodeTimeout` closes the
@@ -814,7 +826,9 @@ declare function serialize(state: EngineState): string;
814
826
  /**
815
827
  * Restores a state saved with {@link serialize}.
816
828
  *
817
- * 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),
818
832
  * intents and signals no longer declared are dropped (α and β are recomputed from the kept
819
833
  * contributions), and newly declared intents start at their prior. States saved by oracular
820
834
  * 0.1.0 to 0.5.x are upgraded to the current shape on their own, and a state one version newer
@@ -847,15 +861,19 @@ declare function deserialize(json: string, opts?: DeserializeOptions): EngineSta
847
861
  * Builds a fresh engine state from a config. Every intent starts at its prior and no episode is
848
862
  * open. The config is validated and a normalized copy is stored inside the state, so a persisted
849
863
  * state remembers the weights it was built with; pass your current config to {@link deserialize}
850
- * when loading.
864
+ * when loading. `config.learning.lift` selects the lift formula; omitting it preserves the
865
+ * normalized difference used by existing configs and states.
851
866
  *
852
867
  * @param config - Intents with their priors and signals, plus engine-wide options. Validated
853
868
  * strictly: unknown keys, non-finite numbers and out-of-range values throw.
854
869
  * @returns A new, JSON-serializable state. `config` is not mutated.
855
- * @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).
856
873
  * @throws `RangeError` when a number is out of range, for example a `prior` outside (0, 1), a
857
- * signal weight of 0, or a weight, `priorStrength`, `learning.gain` or
858
- * `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).
859
877
  * @example
860
878
  * ```ts
861
879
  * const state = createState({
@@ -918,9 +936,11 @@ declare const ms: Units;
918
936
  *
919
937
  * @param config - The config to check. Typed `unknown` so parsed JSON can be passed as it is.
920
938
  * @returns A fresh, normalized {@link EngineConfig}. `config` is not mutated.
921
- * @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.
922
941
  * @throws `RangeError` when a number is out of range: a `prior` outside (0, 1), a signal weight of
923
- * 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.
924
944
  * @example
925
945
  * ```ts
926
946
  * import { readFileSync } from 'node:fs';
@@ -932,4 +952,4 @@ declare const ms: Units;
932
952
  */
933
953
  declare function validateConfig(config: unknown): EngineConfig;
934
954
 
935
- export { type DeserializeOptions, type EndRecord, type EngineConfig, type EngineState, type EpisodeIntentRecord, type EpisodeState, type EvaluateOptions, type Evaluation, type EventRecord, type ExplainOptions, type IntentConfig, type IntentEvent, type IntentSnapshot, type IntentState, type LearningConfig, type Migrate, type PendingEpisode, type Prediction, type PresetName, type PresetSource, RECORD_VERSION, type RecordMode, type RecordOptions, type RecordedDecision, type RecorderRecord, type RevealOptions, STATE_VERSION, type SignalContribution, type SignalExplanation, type SignalRecord, type Snapshot, type Units, createEndRecord, createRecord, createState, deserialize, endEpisode, episodeClosed, evaluate, evidenceToReveal, explain, inspect, markRevealed, ms, observe, predict, serialize, shouldReveal, validateConfig };
955
+ export { type DeserializeOptions, type EndRecord, type EngineConfig, type EngineState, type EpisodeIntentRecord, type EpisodeState, type EvaluateOptions, type Evaluation, type EventRecord, type ExplainOptions, type IntentConfig, type IntentEvent, type IntentSnapshot, type IntentState, type LearningConfig, type LiftType, type Migrate, type PendingEpisode, type Prediction, type PresetName, type PresetSource, RECORD_VERSION, type RecordMode, type RecordOptions, type RecordedDecision, type RecorderRecord, type RevealOptions, STATE_VERSION, type SignalContribution, type SignalExplanation, type SignalRecord, type Snapshot, type Units, createEndRecord, createRecord, createState, deserialize, endEpisode, episodeClosed, evaluate, evidenceToReveal, explain, inspect, markRevealed, ms, observe, predict, serialize, shouldReveal, validateConfig };
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) {
@@ -80,10 +80,39 @@ function repeatFactor(n) {
80
80
  function laplace(successes, total) {
81
81
  return (successes + 1) / (total + 2);
82
82
  }
83
- function lift(q, b) {
83
+ function lift(q, b, type = "normalized-difference") {
84
+ if (type === "absolute-difference") return q - b;
85
+ if (type !== "normalized-difference") {
86
+ const rate = Math.max(Number.EPSILON, Math.min(1 - Number.EPSILON, q));
87
+ const base = Math.max(Number.EPSILON, Math.min(1 - Number.EPSILON, b));
88
+ switch (type) {
89
+ case "relative-lift":
90
+ return (rate - base) / base;
91
+ case "log-lift":
92
+ return Math.log(rate / base);
93
+ case "odds-ratio":
94
+ return rate / (1 - rate) / (base / (1 - base));
95
+ case "risk-ratio":
96
+ case "association-rule":
97
+ return rate / base;
98
+ }
99
+ }
84
100
  if (q >= b) return b >= 1 ? 0 : (q - b) / (1 - b);
85
101
  return b <= 0 ? 0 : (q - b) / b;
86
102
  }
103
+ function learningScore(value, type = "normalized-difference") {
104
+ switch (type) {
105
+ case "risk-ratio":
106
+ case "odds-ratio":
107
+ case "association-rule":
108
+ return (value - 1) / (value + 1);
109
+ case "relative-lift":
110
+ case "log-lift":
111
+ return value / (1 + Math.abs(value));
112
+ default:
113
+ return value;
114
+ }
115
+ }
87
116
 
88
117
  // src/util.ts
89
118
  function isRecord(value) {
@@ -107,12 +136,11 @@ function mapRecord(record2, fn) {
107
136
  var NONE = { hits: 0, misses: 0 };
108
137
  function decayLearned(state, learning, at) {
109
138
  if (state.learnedAt === null) return { ...state, learnedAt: at };
110
- const learnedAt = Math.max(state.learnedAt, at);
111
139
  const d = learning.halfLife === null ? 1 : decayFactor(at - state.learnedAt, learning.halfLife);
112
- if (d === 1) return learnedAt === state.learnedAt ? state : { ...state, learnedAt };
140
+ if (d === 1) return at === state.learnedAt ? state : { ...state, learnedAt: at };
113
141
  return {
114
142
  ...state,
115
- learnedAt,
143
+ learnedAt: at,
116
144
  learned: mapRecord(state.learned, (r) => ({ hits: r.hits * d, misses: r.misses * d })),
117
145
  episodes: { hits: state.episodes.hits * d, misses: state.episodes.misses * d }
118
146
  };
@@ -129,7 +157,9 @@ function signalWeights(state, intent, learning, signal) {
129
157
  const nWithout = Math.max(0, totalEpisodes - nWith);
130
158
  const outcomesWithout = Math.min(nWithout, Math.max(0, state.episodes.hits - record2.hits));
131
159
  const qWithout = laplace(outcomesWithout, nWithout);
132
- const learned = nWith === 0 ? 0 : learning.gain * Math.abs(initial) * lift(qWith, qWithout);
160
+ const baseline = learning.lift === "association-rule" ? (record2.hits + outcomesWithout + 2) / (nWith + nWithout + 4) : qWithout;
161
+ const score = learningScore(lift(qWith, baseline, learning.lift), learning.lift);
162
+ const learned = nWith === 0 ? 0 : learning.gain * Math.abs(initial) * score;
133
163
  const nEff = nWith + nWithout === 0 ? 0 : nWith * nWithout / (nWith + nWithout);
134
164
  const k = learning.priorEpisodes;
135
165
  const effective = (k * initial + nEff * learned) / (k + nEff);
@@ -219,13 +249,12 @@ function withContributions(state, intent, contributions) {
219
249
  function decayIntent(state, intent, at) {
220
250
  if (state.updatedAt === null) return { ...state, updatedAt: at };
221
251
  const d = decayFactor(at - state.updatedAt, intent.halfLife);
222
- const updatedAt = Math.max(state.updatedAt, at);
223
- if (d === 1) return updatedAt === state.updatedAt ? state : { ...state, updatedAt };
252
+ if (d === 1) return at === state.updatedAt ? state : { ...state, updatedAt: at };
224
253
  const contributions = mapRecord(state.contributions, (c) => ({
225
254
  alpha: c.alpha * d,
226
255
  beta: c.beta * d
227
256
  }));
228
- return withContributions({ ...state, updatedAt }, intent, contributions);
257
+ return withContributions({ ...state, updatedAt: at }, intent, contributions);
229
258
  }
230
259
  function applySignal(state, intent, signal, weight) {
231
260
  const prev = getOwn(state.contributions, signal) ?? { alpha: 0, beta: 0 };
@@ -242,6 +271,7 @@ var DEFAULT_Z = 1.28;
242
271
  var DEFAULT_GAIN = 2;
243
272
  var DEFAULT_PRIOR_EPISODES = 10;
244
273
  var MAX_MAGNITUDE = 1e6;
274
+ var MAX_Z = 10;
245
275
  var CONFIG_KEYS = ["intents", "episodeTimeout", "learning", "reveal"];
246
276
  var INTENT_KEYS = [
247
277
  "prior",
@@ -255,7 +285,16 @@ var INTENT_KEYS = [
255
285
  "reveal"
256
286
  ];
257
287
  var REVEAL_KEYS = ["threshold", "z", "cooldown", "bound"];
258
- var LEARNING_KEYS = ["halfLife", "gain", "priorEpisodes"];
288
+ var LEARNING_KEYS = ["halfLife", "gain", "priorEpisodes", "lift"];
289
+ var LIFT_TYPES = [
290
+ "absolute-difference",
291
+ "relative-lift",
292
+ "risk-ratio",
293
+ "log-lift",
294
+ "odds-ratio",
295
+ "normalized-difference",
296
+ "association-rule"
297
+ ];
259
298
  var EXPLAIN_KEYS = ["nonZero", "limit"];
260
299
  function typeError(path, message) {
261
300
  throw new TypeError(`oracular: ${path} ${message}`);
@@ -317,14 +356,17 @@ function expectKeyName(name, path) {
317
356
  if (name === "__proto__") typeError(path, 'must not use the reserved name "__proto__"');
318
357
  return name;
319
358
  }
320
- function normalizeRevealOptions(value, path) {
359
+ function normalizeRevealOptions(value, path, strict = true) {
321
360
  const record2 = expectRecord(value, path);
322
361
  expectKeys(record2, REVEAL_KEYS, path);
323
362
  const out = {};
324
363
  if (record2.threshold !== void 0) {
325
- 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}`);
326
369
  }
327
- if (record2.z !== void 0) out.z = expectNonNegative(record2.z, `${path}.z`);
328
370
  if (record2.cooldown !== void 0) {
329
371
  out.cooldown = expectNonNegative(record2.cooldown, `${path}.cooldown`);
330
372
  }
@@ -340,6 +382,12 @@ function normalizeLearning(value, path) {
340
382
  const record2 = expectRecord(value, path);
341
383
  expectKeys(record2, LEARNING_KEYS, path);
342
384
  const out = {};
385
+ if (record2.lift !== void 0) {
386
+ if (!LIFT_TYPES.includes(record2.lift)) {
387
+ typeError(`${path}.lift`, `must be one of: ${LIFT_TYPES.join(", ")}`);
388
+ }
389
+ out.lift = record2.lift;
390
+ }
343
391
  if (record2.halfLife !== void 0)
344
392
  out.halfLife = expectPositive(record2.halfLife, `${path}.halfLife`);
345
393
  if (record2.gain !== void 0) out.gain = expectBoundedPositive(record2.gain, `${path}.gain`);
@@ -348,7 +396,7 @@ function normalizeLearning(value, path) {
348
396
  }
349
397
  return out;
350
398
  }
351
- function normalizeIntentConfig(value, path) {
399
+ function normalizeIntentConfig(value, path, strict = true) {
352
400
  const record2 = expectRecord(value, path);
353
401
  expectKeys(record2, INTENT_KEYS, path);
354
402
  const signalsRecord = expectRecord(record2.signals, `${path}.signals`);
@@ -390,11 +438,18 @@ function normalizeIntentConfig(value, path) {
390
438
  if (record2.attribution !== void 0) {
391
439
  out.attribution = expectPositive(record2.attribution, `${path}.attribution`);
392
440
  }
393
- if (record2.reveal !== void 0)
394
- 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
+ }
395
450
  return out;
396
451
  }
397
- function normalizeConfig(value) {
452
+ function normalizeConfig(value, strict = true) {
398
453
  const record2 = expectRecord(value, "config");
399
454
  expectKeys(record2, CONFIG_KEYS, "config");
400
455
  const intentsRecord = expectRecord(record2.intents, "config.intents");
@@ -405,7 +460,8 @@ function normalizeConfig(value) {
405
460
  expectKeyName(name, "config.intents");
406
461
  intents[name] = normalizeIntentConfig(
407
462
  intentsRecord[name],
408
- `config.intents[${JSON.stringify(name)}]`
463
+ `config.intents[${JSON.stringify(name)}]`,
464
+ strict
409
465
  );
410
466
  }
411
467
  const out = { intents };
@@ -414,8 +470,9 @@ function normalizeConfig(value) {
414
470
  }
415
471
  if (record2.learning !== void 0)
416
472
  out.learning = normalizeLearning(record2.learning, "config.learning");
417
- if (record2.reveal !== void 0)
418
- out.reveal = normalizeRevealOptions(record2.reveal, "config.reveal");
473
+ if (record2.reveal !== void 0) {
474
+ out.reveal = normalizeRevealOptions(record2.reveal, "config.reveal", strict);
475
+ }
419
476
  return out;
420
477
  }
421
478
  function normalizeExplainOptions(value, path) {
@@ -445,12 +502,12 @@ function splitEvaluateOptions(value, path) {
445
502
  else explain2[key] = v;
446
503
  }
447
504
  return {
448
- reveal: normalizeRevealOptions(reveal, path),
505
+ reveal: normalizeRevealOptions(reveal, path, false),
449
506
  explain: normalizeExplainOptions(explain2, path)
450
507
  };
451
508
  }
452
509
  function resolveReveal(config, intent, opts, normalized = false) {
453
- const o = opts === void 0 ? {} : normalized ? opts : normalizeRevealOptions(opts, "options");
510
+ const o = opts === void 0 ? {} : normalized ? opts : normalizeRevealOptions(opts, "options", false);
454
511
  return {
455
512
  threshold: o.threshold ?? intent.reveal?.threshold ?? config?.reveal?.threshold ?? DEFAULT_THRESHOLD,
456
513
  z: o.z ?? intent.reveal?.z ?? config?.reveal?.z ?? DEFAULT_Z,
@@ -460,6 +517,7 @@ function resolveReveal(config, intent, opts, normalized = false) {
460
517
  }
461
518
  function resolveLearning(config) {
462
519
  return {
520
+ lift: config.learning?.lift ?? "normalized-difference",
463
521
  halfLife: config.learning?.halfLife ?? null,
464
522
  gain: config.learning?.gain ?? DEFAULT_GAIN,
465
523
  priorEpisodes: config.learning?.priorEpisodes ?? DEFAULT_PRIOR_EPISODES
@@ -487,11 +545,7 @@ function timedOutAt(episode, timeout, at) {
487
545
  return at - episode.lastEventAt > timeout ? episode.lastEventAt + timeout : null;
488
546
  }
489
547
  function extendEpisode(episode, at) {
490
- return {
491
- startedAt: episode.startedAt ?? at,
492
- lastEventAt: episode.lastEventAt === null ? at : Math.max(episode.lastEventAt, at),
493
- intents: episode.intents
494
- };
548
+ return { startedAt: episode.startedAt ?? at, lastEventAt: at, intents: episode.intents };
495
549
  }
496
550
  function episodeRecord(episode, intent) {
497
551
  return getOwn(episode.intents, intent) ?? { seen: [], counts: {}, resolved: false };
@@ -549,7 +603,7 @@ function assessIntent(state, name, intent, learning, at) {
549
603
  }
550
604
  function cooldownEnd(since, cooldown, at) {
551
605
  if (since === null) return null;
552
- const end = since + cooldown;
606
+ const end = Math.min(since, at) + cooldown;
553
607
  return at < end ? end : null;
554
608
  }
555
609
  function buildPrediction(is, name, reveal, at) {
@@ -677,6 +731,7 @@ function observe(state, event) {
677
731
  let is = getIntentState(s, intentName, intent);
678
732
  is = decayIntent(is, intent, at);
679
733
  is = settlePending(is, intent, learning, at);
734
+ is = pinAnchors(is, at);
680
735
  if (isSignal) {
681
736
  const { effective } = signalWeights(is, intent, learning, name);
682
737
  const n = occurrences(episode, intentName, name) + 1;
@@ -686,7 +741,8 @@ function observe(state, event) {
686
741
  if (!isOutcome) episode = markSeen(episode, intentName, name);
687
742
  }
688
743
  if (isOutcome) {
689
- 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);
690
746
  is = creditPending(is, intent, at);
691
747
  episode = markResolved(episode, intentName);
692
748
  is = { ...is, lastOutcomeAt: at };
@@ -696,6 +752,16 @@ function observe(state, event) {
696
752
  }
697
753
  return { ...s, intents, episode };
698
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
+ }
699
765
  function endEpisode(state, at) {
700
766
  const s = requireState(state);
701
767
  const t = expectTimestamp(at, "at");
@@ -1013,7 +1079,9 @@ function parseState(value) {
1013
1079
  }
1014
1080
  return {
1015
1081
  version: STATE_VERSION,
1016
- 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),
1017
1085
  intents: mapOwn(r.intents, "state.intents", intentState),
1018
1086
  episode: episodeState(r.episode, "state.episode")
1019
1087
  };