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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#56](https://github.com/Yuribenjamin/oracular/pull/56) [`1529d8e`](https://github.com/Yuribenjamin/oracular/commit/1529d8e1568d79959afc7573ba8fa6e7f3e7ff59) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **Configs that cannot work are refused.** `createState`, `validateConfig` and the `config` passed to `deserialize` now throw for a reveal `threshold` of 0 or 1 (`RangeError`, strictly between 0 and 1), a `z` above 10 (`RangeError`; 3.09 is already a one-sided 99.9% bound, and 128 is a misplaced 1.28), and an `outcome` that is also one of the intent's own signals while `resetOnOutcome` is on (`TypeError`: the reset wipes the evidence the signal just added; set `resetOnOutcome: false` to keep such a signal, which then reads as "did it once, will again"). The JSON Schema says the same. Per-call reveal options keep accepting 0 and 1, since a probe such as `evidenceToReveal` at those values is a legitimate question. A stored state whose config predates these checks still loads: `deserialize` reads the stored config without them and replaces it with the one you pass. An intent with no signals stays accepted; it is a prior-only gate. The presets in `oracular/presets` are now frozen through and through, so a change by one caller cannot reach every other; spread one to change a value, as the guide shows.
8
+
9
+ **Size.** The checks, the anchor pin and the duplicate-outcome rule add about 0.2 kB: the core goes from 7.5 kB to 7.72 kB minified and gzipped, under the 7.9 kB ceiling, and the quickstart entry (`createState`, `observe`, `shouldReveal`, `markRevealed`) from 4.43 kB to 4.63 kB, past its 4.5 kB budget, which moves to 4.7 kB.
10
+
11
+ - [#56](https://github.com/Yuribenjamin/oracular/pull/56) [`1529d8e`](https://github.com/Yuribenjamin/oracular/commit/1529d8e1568d79959afc7573ba8fa6e7f3e7ff59) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **A clock that was ahead no longer freezes an intent.** A production audit found that one event stamped in the future (a device clock that was wrong for a moment) held an intent until the clock caught up: decay stopped, because the anchor kept the later time; the open episode never timed out, so learning stopped; and a reveal or outcome stamped in the future kept the gate shut until that date plus the cooldown. Every write now moves its anchor to the event's own time, even when that is earlier: the evidence is held (no decay for a backwards step, as before) and decay resumes from the new time, for `updatedAt`, the learned counts' `learnedAt` and the episode's `lastEventAt`. A reveal or outcome stamped ahead of the clock counts as having happened now: `predict` reports `suppressedUntil` one cooldown after the current time rather than years ahead, and the next event pins the stored time to its own. `markRevealed` follows the same rule and trusts the time it is given, so an earlier `at` moves the cooldown start back. What moves for a clock that only ever runs forward: nothing. What moves for one that jumps backwards: the anchor now follows it, so a log replayed with timestamps out of order decays slightly more than before. `decayFactor` of an infinite gap is now 0, not 1. An episode held under `attribution` with a close stamped in the future is left as it was.
12
+
13
+ - [#56](https://github.com/Yuribenjamin/oracular/pull/56) [`1529d8e`](https://github.com/Yuribenjamin/oracular/commit/1529d8e1568d79959afc7573ba8fa6e7f3e7ff59) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **A repeated outcome counts once.** An outcome rewards the signals seen since the episode started or since the intent's outcome last arrived, and counts the episode toward the base rate. A second outcome event with no signal seen since the first (a duplicate event, or two outcomes back to back) counted another hit episode, so a session with two outcome events inflated the base rate; it now counts nothing, and only `lastOutcomeAt` and the reset of the evidence happen again. Signals seen after an outcome in the same episode are unchanged: they earn a hit if another outcome follows them, and stay neutral at the close, since what they would predict is a second outcome in the same session, which is not what the weight is for. `tune` counts the same way. The state format is unchanged.
14
+
15
+ - [#56](https://github.com/Yuribenjamin/oracular/pull/56) [`1529d8e`](https://github.com/Yuribenjamin/oracular/commit/1529d8e1568d79959afc7573ba8fa6e7f3e7ff59) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **`tune` no longer reports a lift with nothing to compare it to.** A signal seen in every counted episode had its outcome rate compared with a smoothed rate over zero episodes, a made-up 0.5, and the report printed a lift and a 90% interval from it. For such a signal `lift`, `score`, `low` and `high` are now `null` in `--json`, and the text report prints "no contrast" in place of the interval; the weight was already left out as ubiquitous.
16
+
17
+ ### Patch Changes
18
+
19
+ - [#56](https://github.com/Yuribenjamin/oracular/pull/56) [`1529d8e`](https://github.com/Yuribenjamin/oracular/commit/1529d8e1568d79959afc7573ba8fa6e7f3e7ff59) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **Two CLI fixes.** `replay` and `tune` no longer refuse a whole config because one intent has a reveal cooldown of 0: that intent is scored over its half-life, the text report says so, and the `--json` reports gain `windowSource` (`option`, `cooldown` or `halfLife`) next to the existing `windowIsCooldown`. `oracular replay --version` and `oracular tune --version` (or `-v`) print the version, as the help already claimed.
20
+
3
21
  ## 0.9.0
4
22
 
5
23
  ### Minor Changes
package/README.md CHANGED
@@ -186,7 +186,7 @@ Before any update or reading, the evidence decays by 2^(−Δt / halfLife), wher
186
186
 
187
187
  ### The user's history corrects your weights
188
188
 
189
- Events happen inside **episodes** (sessions). An episode ends when you call `endEpisode`, or on its own after `episodeTimeout` of inactivity. Signals seen in the episode are remembered per intent. When the intent's `outcome` event arrives, each remembered signal gets a **hit**; when the episode ends without it, each gets a **miss**, or, with `attribution` set on the intent, the episode waits and takes the share `2^(−age / halfLife)` of a hit if the outcome arrives inside the window. The engine also counts, per intent, how many episodes ended with and without the outcome: the intent's **base rate**.
189
+ Events happen inside **episodes** (sessions). An episode ends when you call `endEpisode`, or on its own after `episodeTimeout` of inactivity. Signals seen in the episode are remembered per intent. When the intent's `outcome` event arrives, each remembered signal gets a **hit**; when the episode ends without it, each gets a **miss**, or, with `attribution` set on the intent, the episode waits and takes the share `2^(−age / halfLife)` of a hit if the outcome arrives inside the window. The engine also counts, per intent, how many episodes ended with and without the outcome: the intent's **base rate**. Signals seen after an outcome in the same episode count only if another outcome follows them; a repeated outcome with nothing seen since counts nothing.
190
190
 
191
191
  A signal's weight is then a blend of your prior and the learned estimate:
192
192
 
@@ -308,7 +308,7 @@ interface RevealOptions {
308
308
  }
309
309
  ```
310
310
 
311
- Reveal options resolve per call → intent → engine → defaults. Invalid config throws a `TypeError` or `RangeError` with the offending path in the message. `validateConfig(config)` runs those checks without building a state and returns the normalized copy `createState` stores, so a bad weight fails at startup or in a test rather than on first use. For a config kept as JSON, the package ships a [JSON Schema](./schema/config.schema.json) as `oracular/config.schema.json`, also served at <https://yuribenjamin.github.io/oracular/config.schema.json>, for editor completion; `oracular replay` ignores the `$schema` key such a file carries, the engine does not.
311
+ Reveal options resolve per call → intent → engine → defaults. Invalid config throws a `TypeError` or `RangeError` with the offending path in the message, including a config that cannot work: a reveal `threshold` of 0 or 1, a `z` above 10, or an outcome that is also one of the intent's signals while `resetOnOutcome` is on (the reset would wipe what the signal just added; set it to `false` to keep such a signal). A stored state whose config predates these checks still loads. `validateConfig(config)` runs those checks without building a state and returns the normalized copy `createState` stores, so a bad weight fails at startup or in a test rather than on first use. For a config kept as JSON, the package ships a [JSON Schema](./schema/config.schema.json) as `oracular/config.schema.json`, also served at <https://yuribenjamin.github.io/oracular/config.schema.json>, for editor completion; `oracular replay` ignores the `$schema` key such a file carries, the engine does not.
312
312
 
313
313
  ### Results
314
314
 
@@ -339,7 +339,7 @@ interface SignalExplanation {
339
339
 
340
340
  ### Time
341
341
 
342
- `at` is a timestamp. Every duration (`halfLife`, `cooldown`, `episodeTimeout`, `learning.halfLife`) is in the same unit as `at`. Milliseconds from `Date.now()` are the obvious choice, but the library never calls `Date.now()` itself: you pass time in, which keeps every function deterministic and makes tests trivial. A clock that jumps backwards is tolerated (no decay, no error). When `at` is in milliseconds, `ms.days(7)`, `ms.hours(3)` and `ms.minutes(30)` spell the durations; they are plain multiplications.
342
+ `at` is a timestamp. Every duration (`halfLife`, `cooldown`, `episodeTimeout`, `learning.halfLife`) is in the same unit as `at`. Milliseconds from `Date.now()` are the obvious choice, but the library never calls `Date.now()` itself: you pass time in, which keeps every function deterministic and makes tests trivial. A clock that jumps backwards is tolerated: the evidence is held (no decay, no error) and the anchor moves to the new time, so decay resumes from there. One event stamped ahead of the clock therefore cannot hold an intent until the clock catches up, and a reveal or outcome stamped ahead of it counts as having happened now: its cooldown runs from the current time until the next event pins it there. When `at` is in milliseconds, `ms.days(7)`, `ms.hours(3)` and `ms.minutes(30)` spell the durations; they are plain multiplications.
343
343
 
344
344
  ## Persistence
345
345
 
package/dist/cli.js CHANGED
@@ -107,6 +107,14 @@ import {
107
107
  } from "oracular";
108
108
  var DEFAULT_THRESHOLDS = [0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9];
109
109
  var DEFAULT_THRESHOLD = 0.5;
110
+ function scoringWindow(given, cooldown, halfLife) {
111
+ if (given !== void 0) {
112
+ if (!(given > 0)) throw new RangeError("the scoring window must be greater than 0");
113
+ return { window: given, windowSource: "option" };
114
+ }
115
+ if (cooldown > 0) return { window: cooldown, windowSource: "cooldown" };
116
+ return { window: halfLife, windowSource: "halfLife" };
117
+ }
110
118
  function replay(entries, config, options = {}) {
111
119
  const base = createState(config);
112
120
  const normalized = base.config;
@@ -135,19 +143,15 @@ function replay(entries, config, options = {}) {
135
143
  const list = options.thresholds ?? [...DEFAULT_THRESHOLDS, configured];
136
144
  const thresholds = [...new Set(list)].sort((a, b) => a - b);
137
145
  const cooldown = predict(markRevealed(base, name, 0), name, 0).suppressedUntil ?? 0;
138
- const window = options.window ?? cooldown;
139
- if (!(window > 0)) {
140
- throw new RangeError(
141
- options.window === void 0 ? `intent ${JSON.stringify(name)} has a reveal cooldown of 0, so there is no default scoring window; pass --window (for example --window 7d)` : "the scoring window must be greater than 0"
142
- );
143
- }
146
+ const { window, windowSource } = scoringWindow(options.window, cooldown, intent.halfLife);
144
147
  return {
145
148
  name,
146
149
  outcome: intent.outcome,
147
150
  configured,
148
151
  thresholds,
149
152
  window,
150
- windowIsCooldown: options.window === void 0
153
+ windowSource,
154
+ windowIsCooldown: windowSource === "cooldown"
151
155
  };
152
156
  });
153
157
  const allThresholds = [...new Set(plans.flatMap((p) => p.thresholds))].sort((a, b) => a - b);
@@ -261,6 +265,7 @@ function replay(entries, config, options = {}) {
261
265
  intent: plan.name,
262
266
  outcome: plan.outcome,
263
267
  window: plan.window,
268
+ windowSource: plan.windowSource,
264
269
  windowIsCooldown: plan.windowIsCooldown,
265
270
  installs,
266
271
  thresholds: totals.map((s) => {
@@ -347,7 +352,7 @@ function table(headers, rows) {
347
352
  }
348
353
  function formatIntent(intent) {
349
354
  const window = formatDuration(intent.window);
350
- const source = intent.windowIsCooldown ? "the intent's reveal cooldown" : "as given";
355
+ const source = intent.windowSource === "cooldown" ? "the intent's reveal cooldown" : intent.windowSource === "halfLife" ? "the intent's half-life, since its reveal cooldown is 0" : "as given";
351
356
  const out = [];
352
357
  out.push(
353
358
  `${intent.intent}: outcome ${intent.outcome}, window ${window} (${source}), ${intent.installs} install${intent.installs === 1 ? "" : "s"}`
@@ -526,11 +531,12 @@ function fitSignal(signal, current, tally, strength, priorEpisodes, liftType) {
526
531
  const qWith = laplace(hits, nWith);
527
532
  const qWithout = laplace(outcomesWithout, without);
528
533
  const baseline = liftType === "association-rule" ? (hits + outcomesWithout + 2) / (nWith + without + 4) : qWithout;
529
- const rawLift = nWith === 0 ? 0 : lift(qWith, baseline, liftType);
530
- const score = nWith === 0 ? 0 : learningScore(rawLift, liftType);
534
+ const noContrast = without === 0;
535
+ const rawLift = noContrast ? null : nWith === 0 ? 0 : lift(qWith, baseline, liftType);
536
+ const score = rawLift === null ? null : nWith === 0 ? 0 : learningScore(rawLift, liftType);
531
537
  const contrast = nWith + without === 0 ? 0 : nWith * without / (nWith + without);
532
538
  const [qLow, qHigh] = wilson(hits, nWith, Z90);
533
- const rounded = Math.round(strength * score * 100) / 100;
539
+ const rounded = score === null ? 0 : Math.round(strength * score * 100) / 100;
534
540
  const share = tally.episodes === 0 ? 0 : nWith / tally.episodes;
535
541
  const ubiquitous = nWith > UBIQUITY_SHARE * tally.episodes;
536
542
  return {
@@ -547,8 +553,8 @@ function fitSignal(signal, current, tally, strength, priorEpisodes, liftType) {
547
553
  weight: nWith === 0 || ubiquitous || rounded === 0 ? null : rounded,
548
554
  share,
549
555
  ubiquitous,
550
- low: strength * learningScore(lift(qLow, baseline, liftType), liftType),
551
- high: strength * learningScore(lift(qHigh, baseline, liftType), liftType),
556
+ low: noContrast ? null : strength * learningScore(lift(qLow, baseline, liftType), liftType),
557
+ high: noContrast ? null : strength * learningScore(lift(qHigh, baseline, liftType), liftType),
552
558
  contrast,
553
559
  thin: contrast < priorEpisodes
554
560
  };
@@ -643,13 +649,8 @@ function tune(entries, config, options = {}) {
643
649
  const grouped = groupByInstall(entries);
644
650
  const windows = /* @__PURE__ */ new Map();
645
651
  for (const name of fitting) {
646
- const window = options.window ?? cooldownOf(base, name);
647
- if (!(window > 0)) {
648
- throw new RangeError(
649
- options.window === void 0 ? `intent ${JSON.stringify(name)} has a reveal cooldown of 0, so there is no scoring window for the half-life grid; pass --window (for example --window 7d)` : "the scoring window must be greater than 0"
650
- );
651
- }
652
- windows.set(name, window);
652
+ const intent = normalized.intents[name];
653
+ windows.set(name, scoringWindow(options.window, cooldownOf(base, name), intent.halfLife));
653
654
  }
654
655
  const eventNames = /* @__PURE__ */ new Set();
655
656
  for (const entry of entries) if (entry.kind === "event") eventNames.add(entry.name);
@@ -745,7 +746,7 @@ function tune(entries, config, options = {}) {
745
746
  signals.filter((f) => f.weight !== null).map((f) => [f.signal, f.weight])
746
747
  )
747
748
  };
748
- const window = windows.get(name);
749
+ const { window, windowSource } = windows.get(name);
749
750
  const threshold = intent.reveal?.threshold ?? normalized.reveal?.threshold ?? DEFAULT_THRESHOLD;
750
751
  const grid = [.../* @__PURE__ */ new Set([...options.halfLives ?? DEFAULT_HALF_LIVES, intent.halfLife])].sort(
751
752
  (a, b) => a - b
@@ -772,6 +773,7 @@ function tune(entries, config, options = {}) {
772
773
  source: "kept",
773
774
  note: "kept: no point in the log could be scored, so the grid says nothing",
774
775
  window,
776
+ windowSource,
775
777
  curve,
776
778
  brierBaseline
777
779
  };
@@ -789,6 +791,7 @@ function tune(entries, config, options = {}) {
789
791
  source: "kept",
790
792
  note: `kept: the grid is flat, the best point (${formatDuration(chosen.halfLife)}, ${best.toFixed(4)}) improves on the current value (${atCurrent.brier.toFixed(4)}) by ${(gain * 100).toFixed(2)}%, less than ${FLAT_GRID_GAIN * 100}%${baseline}`,
791
793
  window,
794
+ windowSource,
792
795
  curve,
793
796
  brierBaseline
794
797
  };
@@ -799,6 +802,7 @@ function tune(entries, config, options = {}) {
799
802
  source: "grid",
800
803
  note: `lowest Brier score over the grid, ${best.toFixed(4)}${baseline}`,
801
804
  window,
805
+ windowSource,
802
806
  curve,
803
807
  brierBaseline
804
808
  };
@@ -874,9 +878,9 @@ function signalTable(fits, priorEpisodes, withCurrent) {
874
878
  f.signal,
875
879
  num(f.weight, 2),
876
880
  ...withCurrent ? [num(f.current, 2)] : [],
877
- `${f.low.toFixed(2)} to ${f.high.toFixed(2)}`,
878
- signed(f.lift, 2),
879
- ...showScore ? [signed(f.score, 2)] : [],
881
+ f.low === null || f.high === null ? "no contrast" : `${f.low.toFixed(2)} to ${f.high.toFixed(2)}`,
882
+ f.lift === null ? "" : signed(f.lift, 2),
883
+ ...showScore ? [f.score === null ? "" : signed(f.score, 2)] : [],
880
884
  `${f.hits} / ${f.misses}`,
881
885
  `${f.outcomesWithout} / ${f.without}`,
882
886
  note(f, priorEpisodes)
@@ -923,7 +927,7 @@ function formatIntent2(fit) {
923
927
  }
924
928
  out.push("");
925
929
  out.push(
926
- `Half-life grid, Brier score against "the outcome follows within ${formatDuration(fit.halfLife.window)}" (lower is better): ${fit.halfLife.curve.map(
930
+ `Half-life grid, Brier score against "the outcome follows within ${formatDuration(fit.halfLife.window)}"${fit.halfLife.windowSource === "halfLife" ? " (the intent's half-life, since its reveal cooldown is 0)" : ""} (lower is better): ${fit.halfLife.curve.map(
927
931
  (p) => `${formatDuration(p.halfLife)} ${num(p.brier, 4)}${p.halfLife === fit.halfLife.value && p.brier !== null ? " *" : ""}`
928
932
  ).join(", ")}`
929
933
  );
@@ -1052,6 +1056,7 @@ Options:
1052
1056
  the signals
1053
1057
  --json print the report as JSON
1054
1058
  -h, --help show this help
1059
+ -v, --version print the version
1055
1060
 
1056
1061
  Only shadow periods are counted by default: an episode in which any record was live is left out,
1057
1062
  a log with live records gets a warning, and a log with nothing but live records is refused unless
@@ -1142,16 +1147,29 @@ function commonFlags(out) {
1142
1147
  },
1143
1148
  "-h": () => {
1144
1149
  out.help = true;
1150
+ },
1151
+ "--version": (_value, noValue) => {
1152
+ noValue();
1153
+ out.version = true;
1154
+ },
1155
+ "-v": () => {
1156
+ out.version = true;
1145
1157
  }
1146
1158
  };
1147
1159
  }
1148
1160
  function requireLogsAndConfig(out, command) {
1149
- if (out.help) return;
1161
+ if (out.help || out.version) return;
1150
1162
  if (out.logs.length === 0) throw new UsageError(`${command} needs at least one log file`);
1151
1163
  if (out.config === void 0) throw new UsageError(`${command} needs --config <file>`);
1152
1164
  }
1153
1165
  function parseReplayArgs(argv) {
1154
- const out = { logs: [], includeLive: false, json: false, help: false };
1166
+ const out = {
1167
+ logs: [],
1168
+ includeLive: false,
1169
+ json: false,
1170
+ help: false,
1171
+ version: false
1172
+ };
1155
1173
  parseFlags(
1156
1174
  argv,
1157
1175
  {
@@ -1171,6 +1189,7 @@ function parseTuneArgs(argv) {
1171
1189
  includeLive: false,
1172
1190
  json: false,
1173
1191
  help: false,
1192
+ version: false,
1174
1193
  allEvents: false
1175
1194
  };
1176
1195
  parseFlags(
@@ -1264,6 +1283,11 @@ async function replayCommand(argv, io) {
1264
1283
  io.stdout(REPLAY_HELP);
1265
1284
  return 0;
1266
1285
  }
1286
+ if (args.version) {
1287
+ io.stdout(`${version()}
1288
+ `);
1289
+ return 0;
1290
+ }
1267
1291
  const config = await loadCheckedConfig(args, io);
1268
1292
  const entries = readLogs(args.logs, io);
1269
1293
  checkModes(entries, args.includeLive, io, {
@@ -1291,6 +1315,11 @@ async function tuneCommand(argv, io) {
1291
1315
  io.stdout(TUNE_HELP);
1292
1316
  return 0;
1293
1317
  }
1318
+ if (args.version) {
1319
+ io.stdout(`${version()}
1320
+ `);
1321
+ return 0;
1322
+ }
1294
1323
  const config = await loadCheckedConfig(args, io);
1295
1324
  const entries = readLogs(args.logs, io);
1296
1325
  checkModes(entries, args.includeLive, io, {
package/dist/index.cjs CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/math.ts
4
4
  function decayFactor(elapsed, halfLife) {
5
- if (!(elapsed > 0) || !Number.isFinite(elapsed)) return 1;
5
+ if (!(elapsed > 0)) return 1;
6
6
  return 2 ** (-elapsed / halfLife);
7
7
  }
8
8
  function betaMean(alpha, beta) {
@@ -138,12 +138,11 @@ function mapRecord(record2, fn) {
138
138
  var NONE = { hits: 0, misses: 0 };
139
139
  function decayLearned(state, learning, at) {
140
140
  if (state.learnedAt === null) return { ...state, learnedAt: at };
141
- const learnedAt = Math.max(state.learnedAt, at);
142
141
  const d = learning.halfLife === null ? 1 : decayFactor(at - state.learnedAt, learning.halfLife);
143
- if (d === 1) return learnedAt === state.learnedAt ? state : { ...state, learnedAt };
142
+ if (d === 1) return at === state.learnedAt ? state : { ...state, learnedAt: at };
144
143
  return {
145
144
  ...state,
146
- learnedAt,
145
+ learnedAt: at,
147
146
  learned: mapRecord(state.learned, (r) => ({ hits: r.hits * d, misses: r.misses * d })),
148
147
  episodes: { hits: state.episodes.hits * d, misses: state.episodes.misses * d }
149
148
  };
@@ -252,13 +251,12 @@ function withContributions(state, intent, contributions) {
252
251
  function decayIntent(state, intent, at) {
253
252
  if (state.updatedAt === null) return { ...state, updatedAt: at };
254
253
  const d = decayFactor(at - state.updatedAt, intent.halfLife);
255
- const updatedAt = Math.max(state.updatedAt, at);
256
- if (d === 1) return updatedAt === state.updatedAt ? state : { ...state, updatedAt };
254
+ if (d === 1) return at === state.updatedAt ? state : { ...state, updatedAt: at };
257
255
  const contributions = mapRecord(state.contributions, (c) => ({
258
256
  alpha: c.alpha * d,
259
257
  beta: c.beta * d
260
258
  }));
261
- return withContributions({ ...state, updatedAt }, intent, contributions);
259
+ return withContributions({ ...state, updatedAt: at }, intent, contributions);
262
260
  }
263
261
  function applySignal(state, intent, signal, weight) {
264
262
  const prev = getOwn(state.contributions, signal) ?? { alpha: 0, beta: 0 };
@@ -275,6 +273,7 @@ var DEFAULT_Z = 1.28;
275
273
  var DEFAULT_GAIN = 2;
276
274
  var DEFAULT_PRIOR_EPISODES = 10;
277
275
  var MAX_MAGNITUDE = 1e6;
276
+ var MAX_Z = 10;
278
277
  var CONFIG_KEYS = ["intents", "episodeTimeout", "learning", "reveal"];
279
278
  var INTENT_KEYS = [
280
279
  "prior",
@@ -359,14 +358,17 @@ function expectKeyName(name, path) {
359
358
  if (name === "__proto__") typeError(path, 'must not use the reserved name "__proto__"');
360
359
  return name;
361
360
  }
362
- function normalizeRevealOptions(value, path) {
361
+ function normalizeRevealOptions(value, path, strict = true) {
363
362
  const record2 = expectRecord(value, path);
364
363
  expectKeys(record2, REVEAL_KEYS, path);
365
364
  const out = {};
366
365
  if (record2.threshold !== void 0) {
367
- out.threshold = expectClosedUnit(record2.threshold, `${path}.threshold`);
366
+ out.threshold = strict ? expectOpenUnit(record2.threshold, `${path}.threshold`) : expectClosedUnit(record2.threshold, `${path}.threshold`);
367
+ }
368
+ if (record2.z !== void 0) {
369
+ out.z = expectNonNegative(record2.z, `${path}.z`);
370
+ if (strict && out.z > MAX_Z) rangeError(`${path}.z`, `must be at most ${MAX_Z}`);
368
371
  }
369
- if (record2.z !== void 0) out.z = expectNonNegative(record2.z, `${path}.z`);
370
372
  if (record2.cooldown !== void 0) {
371
373
  out.cooldown = expectNonNegative(record2.cooldown, `${path}.cooldown`);
372
374
  }
@@ -396,7 +398,7 @@ function normalizeLearning(value, path) {
396
398
  }
397
399
  return out;
398
400
  }
399
- function normalizeIntentConfig(value, path) {
401
+ function normalizeIntentConfig(value, path, strict = true) {
400
402
  const record2 = expectRecord(value, path);
401
403
  expectKeys(record2, INTENT_KEYS, path);
402
404
  const signalsRecord = expectRecord(record2.signals, `${path}.signals`);
@@ -438,11 +440,18 @@ function normalizeIntentConfig(value, path) {
438
440
  if (record2.attribution !== void 0) {
439
441
  out.attribution = expectPositive(record2.attribution, `${path}.attribution`);
440
442
  }
441
- if (record2.reveal !== void 0)
442
- out.reveal = normalizeRevealOptions(record2.reveal, `${path}.reveal`);
443
+ if (record2.reveal !== void 0) {
444
+ out.reveal = normalizeRevealOptions(record2.reveal, `${path}.reveal`, strict);
445
+ }
446
+ if (strict && out.outcome !== void 0 && hasOwn(signals, out.outcome) && out.resetOnOutcome !== false) {
447
+ typeError(
448
+ `${path}.outcome`,
449
+ "is also one of its signals: the reset on outcome would wipe what it adds; set resetOnOutcome: false to keep it"
450
+ );
451
+ }
443
452
  return out;
444
453
  }
445
- function normalizeConfig(value) {
454
+ function normalizeConfig(value, strict = true) {
446
455
  const record2 = expectRecord(value, "config");
447
456
  expectKeys(record2, CONFIG_KEYS, "config");
448
457
  const intentsRecord = expectRecord(record2.intents, "config.intents");
@@ -453,7 +462,8 @@ function normalizeConfig(value) {
453
462
  expectKeyName(name, "config.intents");
454
463
  intents[name] = normalizeIntentConfig(
455
464
  intentsRecord[name],
456
- `config.intents[${JSON.stringify(name)}]`
465
+ `config.intents[${JSON.stringify(name)}]`,
466
+ strict
457
467
  );
458
468
  }
459
469
  const out = { intents };
@@ -462,8 +472,9 @@ function normalizeConfig(value) {
462
472
  }
463
473
  if (record2.learning !== void 0)
464
474
  out.learning = normalizeLearning(record2.learning, "config.learning");
465
- if (record2.reveal !== void 0)
466
- out.reveal = normalizeRevealOptions(record2.reveal, "config.reveal");
475
+ if (record2.reveal !== void 0) {
476
+ out.reveal = normalizeRevealOptions(record2.reveal, "config.reveal", strict);
477
+ }
467
478
  return out;
468
479
  }
469
480
  function normalizeExplainOptions(value, path) {
@@ -493,12 +504,12 @@ function splitEvaluateOptions(value, path) {
493
504
  else explain2[key] = v;
494
505
  }
495
506
  return {
496
- reveal: normalizeRevealOptions(reveal, path),
507
+ reveal: normalizeRevealOptions(reveal, path, false),
497
508
  explain: normalizeExplainOptions(explain2, path)
498
509
  };
499
510
  }
500
511
  function resolveReveal(config, intent, opts, normalized = false) {
501
- const o = opts === void 0 ? {} : normalized ? opts : normalizeRevealOptions(opts, "options");
512
+ const o = opts === void 0 ? {} : normalized ? opts : normalizeRevealOptions(opts, "options", false);
502
513
  return {
503
514
  threshold: o.threshold ?? intent.reveal?.threshold ?? config?.reveal?.threshold ?? DEFAULT_THRESHOLD,
504
515
  z: o.z ?? intent.reveal?.z ?? config?.reveal?.z ?? DEFAULT_Z,
@@ -536,11 +547,7 @@ function timedOutAt(episode, timeout, at) {
536
547
  return at - episode.lastEventAt > timeout ? episode.lastEventAt + timeout : null;
537
548
  }
538
549
  function extendEpisode(episode, at) {
539
- return {
540
- startedAt: episode.startedAt ?? at,
541
- lastEventAt: episode.lastEventAt === null ? at : Math.max(episode.lastEventAt, at),
542
- intents: episode.intents
543
- };
550
+ return { startedAt: episode.startedAt ?? at, lastEventAt: at, intents: episode.intents };
544
551
  }
545
552
  function episodeRecord(episode, intent) {
546
553
  return getOwn(episode.intents, intent) ?? { seen: [], counts: {}, resolved: false };
@@ -598,7 +605,7 @@ function assessIntent(state, name, intent, learning, at) {
598
605
  }
599
606
  function cooldownEnd(since, cooldown, at) {
600
607
  if (since === null) return null;
601
- const end = since + cooldown;
608
+ const end = Math.min(since, at) + cooldown;
602
609
  return at < end ? end : null;
603
610
  }
604
611
  function buildPrediction(is, name, reveal, at) {
@@ -726,6 +733,7 @@ function observe(state, event) {
726
733
  let is = getIntentState(s, intentName, intent);
727
734
  is = decayIntent(is, intent, at);
728
735
  is = settlePending(is, intent, learning, at);
736
+ is = pinAnchors(is, at);
729
737
  if (isSignal) {
730
738
  const { effective } = signalWeights(is, intent, learning, name);
731
739
  const n = occurrences(episode, intentName, name) + 1;
@@ -735,7 +743,8 @@ function observe(state, event) {
735
743
  if (!isOutcome) episode = markSeen(episode, intentName, name);
736
744
  }
737
745
  if (isOutcome) {
738
- is = recordEpisode(is, episodeRecord(episode, intentName).seen, 1);
746
+ const record2 = episodeRecord(episode, intentName);
747
+ if (!record2.resolved || record2.seen.length > 0) is = recordEpisode(is, record2.seen, 1);
739
748
  is = creditPending(is, intent, at);
740
749
  episode = markResolved(episode, intentName);
741
750
  is = { ...is, lastOutcomeAt: at };
@@ -745,6 +754,16 @@ function observe(state, event) {
745
754
  }
746
755
  return { ...s, intents, episode };
747
756
  }
757
+ function pinAnchors(is, at) {
758
+ const reveal = is.lastRevealedAt !== null && is.lastRevealedAt > at;
759
+ const outcome = is.lastOutcomeAt !== null && is.lastOutcomeAt > at;
760
+ if (!reveal && !outcome) return is;
761
+ return {
762
+ ...is,
763
+ lastRevealedAt: reveal ? at : is.lastRevealedAt,
764
+ lastOutcomeAt: outcome ? at : is.lastOutcomeAt
765
+ };
766
+ }
748
767
  function endEpisode(state, at) {
749
768
  const s = requireState(state);
750
769
  const t = expectTimestamp(at, "at");
@@ -1062,7 +1081,9 @@ function parseState(value) {
1062
1081
  }
1063
1082
  return {
1064
1083
  version: STATE_VERSION,
1065
- config: normalizeConfig(r.config),
1084
+ // Without the strict checks: a config this release would refuse at createState still loads
1085
+ // when it is only stored, and the config passed in replaces it anyway.
1086
+ config: normalizeConfig(r.config, false),
1066
1087
  intents: mapOwn(r.intents, "state.intents", intentState),
1067
1088
  episode: episodeState(r.episode, "state.episode")
1068
1089
  };