oracular 0.11.0 → 0.12.1

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,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#64](https://github.com/Yuribenjamin/oracular/pull/64) [`c108ca9`](https://github.com/Yuribenjamin/oracular/commit/c108ca92b4b66b90749664379bc676a230462726) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **`replay` shows the reveals it could not score.** A reveal whose window runs past the end of the log is not scored for precision, since its verdict is not in yet, but an outcome it preceded still counts for recall, so a short log printed `reveals 0` beside `recall 100.0% of 1`, and `--json` showed `reveals: 0, caught: 1, censored: 1`. Both numbers were right and the table did not say why. The text table now shows such reveals in the row (`0 (+1 censored)`) with a footnote that says what recall still counts, and a log shorter than an intent's scoring window, in which every reveal and point is censored, gets a warning on stderr that points at `--window`. The `--json` report is unchanged: `censored` was already there. Found by the second production integration on a one-minute log with the default one-hour window.
8
+
9
+ ## 0.12.0
10
+
11
+ ### Minor Changes
12
+
13
+ - [#62](https://github.com/Yuribenjamin/oracular/pull/62) [`0502b78`](https://github.com/Yuribenjamin/oracular/commit/0502b789049cb8ef8643af587ec94b78ce0c39b1) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **The clock rule: one glitch cannot move the engine.** 0.10.0 made every write trust the latest event's time, so that one event stamped in the future could no longer hold an intent until the clock caught up. A production re-test found the cost: a clock that steps *backwards* (a phone that wakes up in 1970 before it syncs, a seconds-for-milliseconds bug) pulled every anchor back with it, and the next event decayed the whole gap again and closed the session, recording a miss nobody earned. The two cases are one ambiguity: after one backward step the engine cannot tell whether the new stamp or the old anchor is the wrong clock. It now waits one event. The engine keeps a clock, the latest event time it trusts; an event stamped behind it counts now, at the clock's time, and its stamp is held. If the next event is also behind the clock, the clock was corrected: every anchor ahead of the held stamp (evidence, learned counts, reveals, outcomes, the open episode, the episodes waiting under `attribution`) moves back to it, and decay and cooldowns resume from there. If the next event is at or after the clock, the held stamp was a glitch, and nothing moved. Monotone clocks see no difference at all, and every number in the docs is unchanged. What changes: a clock that was ahead heals two events after it is corrected rather than one, and heals every intent at once rather than only the ones later events touch; a reveal stamped ahead of the clock is pinned by the next event on any intent; a single backward stamp wipes nothing and closes nothing.
14
+
15
+ Two costs, stated. The first event after a correction is processed at the uncorrected time, so the intents it touches are decayed to that time and their episodes waiting under `attribution` can settle as misses before the second event moves everything back. And two bad stamps in a row read as a correction, which costs exactly what 0.10 paid on every backward step: the anchors move to the bad time and the next good event decays the gap again. One case no rule covers: a device that boots in 1970 and syncs forward later makes one monotone jump of decades, indistinguishable from a long absence, so hold `observe` until the device clock is trusted.
16
+
17
+ **State version 4.** The clock is stored, so `STATE_VERSION` is 4 and version 3 states (0.6.0 to 0.11.x) migrate on load: the clock is set to the latest time the stored anchors were written at, nothing is held, and no estimate changes. Under rule 5 of the stability policy, 0.11 reads a version 4 state as its own; what it loses is the clock: a backward stamp awaiting confirmation is forgotten, and the next event is trusted the way 0.11 trusted every event.
18
+
19
+ ### Patch Changes
20
+
21
+ - [#62](https://github.com/Yuribenjamin/oracular/pull/62) [`0502b78`](https://github.com/Yuribenjamin/oracular/commit/0502b789049cb8ef8643af587ec94b78ce0c39b1) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **Size.** The whole `oracular` entry goes from 7.72 kB to 8.09 kB minified and gzipped with the clock rule and its state version, the quickstart import (`createState`, `observe`, `shouldReveal`, `markRevealed`) from 4.63 kB to 4.85 kB. That crosses the 7.9 kB ceiling the stability page set in 0.8, so the ceiling moves as it did then, to the shipped size plus about 10%: 8.9 kB, with the quickstart budget at 5 kB.
22
+
23
+ - [#62](https://github.com/Yuribenjamin/oracular/pull/62) [`0502b78`](https://github.com/Yuribenjamin/oracular/commit/0502b789049cb8ef8643af587ec94b78ce0c39b1) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **Presets are read-only in the types too.** `presets` and `presetSources` are `DeepReadonly<...>`, matching the frozen objects: assigning into a preset is a type error, spreading one is unchanged.
24
+
3
25
  ## 0.11.0
4
26
 
5
27
  ### Minor Changes
package/README.md CHANGED
@@ -270,7 +270,7 @@ serialize(state): string
270
270
  deserialize(json: string, opts?: { config?: EngineConfig; migrate?: Migrate }): EngineState
271
271
  validateConfig(config: unknown): EngineConfig // the checks createState runs, without a state; returns the normalized copy
272
272
  ms.seconds(n), ms.minutes(n), ms.hours(n), ms.days(n), ms.weeks(n) // durations in milliseconds
273
- STATE_VERSION // 3
273
+ STATE_VERSION // 4
274
274
  RECORD_VERSION // 1
275
275
  ```
276
276
 
@@ -340,7 +340,7 @@ interface SignalExplanation {
340
340
 
341
341
  ### Time
342
342
 
343
- `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
+ `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. The engine keeps a clock, the latest event time it trusts, and resolves every stamp against it. An event stamped behind the clock is tolerated: it counts now, at the clock's time, with no decay and no error, and its stamp is held. If the next event is also behind the clock, the clock was corrected (a device whose time was ahead caught up): every anchor ahead of the held stamp moves back to it, and decay and cooldowns resume from the corrected time. If the next event is at or after the clock, the held stamp was one glitch (a phone that woke up in 1970 before it synced) and nothing moved. So one event stamped years ahead holds an intent for at most the two events that follow it, and one stamped years behind wipes nothing and closes no session. A reveal or outcome stamped ahead of the clock counts as having happened now, and the next event pins it to its own time. A device that boots in 1970 and syncs forward later makes one monotone jump that no rule can tell from a long absence, so hold `observe` until its clock is trusted. When `at` is in milliseconds, `ms.days(7)`, `ms.hours(3)` and `ms.minutes(30)` spell the durations; they are plain multiplications.
344
344
 
345
345
  ## Persistence
346
346
 
@@ -358,7 +358,7 @@ const state = deserialize(saved, { config });
358
358
 
359
359
  The new config is validated and swapped in. Intents and signals you no longer declare are dropped (α and β are recomputed from what remains), newly declared intents start at their prior, and everything learned about signals that still exist is kept.
360
360
 
361
- States saved by an older version of this library are upgraded on load: `deserialize` migrates the state shapes written by oracular 0.1.0 to 0.5.x (`STATE_VERSION` 1 and 2) to the current one on its own. A `migrate` function is consulted only for a version the library does not know, such as a state written by a newer oracular before a rollback; without one, that throws a `RangeError`:
361
+ States saved by an older version of this library are upgraded on load: `deserialize` migrates the state shapes written by oracular 0.1.0 to 0.11.x (`STATE_VERSION` 1 to 3) to the current one on its own. A `migrate` function is consulted only for a version the library does not know, such as a state written by a newer oracular before a rollback; without one, that throws a `RangeError`:
362
362
 
363
363
  ```ts
364
364
  deserialize(saved, {
package/dist/cli.js CHANGED
@@ -363,7 +363,7 @@ function formatIntent(intent) {
363
363
  ["threshold", "reveals", "per user", "precision", "recall", "median lead time"],
364
364
  intent.thresholds.map((t) => [
365
365
  `${t.threshold}${t.configured ? " *" : ""}`,
366
- String(t.reveals),
366
+ t.censored > 0 ? `${t.reveals} (+${t.censored} censored)` : String(t.reveals),
367
367
  num(t.revealsPerUser, 2),
368
368
  pct(t.precision),
369
369
  `${pct(t.recall)} of ${t.outcomes}`,
@@ -371,9 +371,9 @@ function formatIntent(intent) {
371
371
  ])
372
372
  )
373
373
  );
374
- const censored = intent.thresholds.reduce((acc, t) => Math.max(acc, t.censored), 0);
374
+ const censored = intent.thresholds.some((t) => t.censored > 0);
375
375
  out.push(
376
- `* the threshold your config resolves for this intent.${censored > 0 ? ` Reveals whose window runs past the end of the log are not scored (up to ${censored} per threshold).` : ""}`
376
+ `* the threshold your config resolves for this intent.${censored ? " A reveal whose window runs past the end of the log (the +n) is not scored for precision, since its verdict is not in yet; an outcome it preceded still counts as caught for recall." : ""}`
377
377
  );
378
378
  out.push("");
379
379
  out.push(
@@ -1310,9 +1310,22 @@ async function replayCommand(argv, io) {
1310
1310
  if (error instanceof RangeError) throw new UsageError(error.message);
1311
1311
  throw error;
1312
1312
  }
1313
+ warnShortLog(report, io);
1313
1314
  io.stdout(args.json ? formatJson(report) : formatText(report));
1314
1315
  return 0;
1315
1316
  }
1317
+ function warnShortLog(report, io) {
1318
+ if (report.start === null || report.end === null) return;
1319
+ const span = report.end - report.start;
1320
+ for (const intent of report.intents) {
1321
+ if (span < intent.window) {
1322
+ io.stderr(
1323
+ `oracular: warning: ${intent.intent}: the log spans ${formatDuration(span)}, less than its ${formatDuration(intent.window)} scoring window, so every reveal and point is censored and only recall can be counted. Pass --window with a duration shorter than the log, or record for longer than the window.
1324
+ `
1325
+ );
1326
+ }
1327
+ }
1328
+ }
1316
1329
  async function tuneCommand(argv, io) {
1317
1330
  const args = parseTuneArgs(argv);
1318
1331
  if (args.help) {
package/dist/index.cjs CHANGED
@@ -138,8 +138,9 @@ 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
+ if (!(at > state.learnedAt)) return state;
141
142
  const d = learning.halfLife === null ? 1 : decayFactor(at - state.learnedAt, learning.halfLife);
142
- if (d === 1) return at === state.learnedAt ? state : { ...state, learnedAt: at };
143
+ if (d === 1) return { ...state, learnedAt: at };
143
144
  return {
144
145
  ...state,
145
146
  learnedAt: at,
@@ -251,8 +252,9 @@ function withContributions(state, intent, contributions) {
251
252
  }
252
253
  function decayIntent(state, intent, at) {
253
254
  if (state.updatedAt === null) return { ...state, updatedAt: at };
255
+ if (!(at > state.updatedAt)) return state;
254
256
  const d = decayFactor(at - state.updatedAt, intent.halfLife);
255
- if (d === 1) return at === state.updatedAt ? state : { ...state, updatedAt: at };
257
+ if (d === 1) return { ...state, updatedAt: at };
256
258
  const contributions = mapRecord(state.contributions, (c) => ({
257
259
  alpha: c.alpha * d,
258
260
  beta: c.beta * d
@@ -570,7 +572,7 @@ function markResolved(episode, intent) {
570
572
  }
571
573
 
572
574
  // src/version.ts
573
- var STATE_VERSION = 3;
575
+ var STATE_VERSION = 4;
574
576
  var RECORD_VERSION = 1;
575
577
 
576
578
  // src/state.ts
@@ -580,10 +582,16 @@ function createState(config) {
580
582
  for (const [name, intent] of Object.entries(normalized.intents)) {
581
583
  intents[name] = initIntent(intent);
582
584
  }
583
- return { version: STATE_VERSION, config: normalized, intents, episode: emptyEpisode() };
585
+ return {
586
+ version: STATE_VERSION,
587
+ config: normalized,
588
+ intents,
589
+ episode: emptyEpisode(),
590
+ clock: { at: null, behindAt: null }
591
+ };
584
592
  }
585
593
  function requireState(state) {
586
- if (!isRecord(state) || state.version !== STATE_VERSION || !isRecord(state.config) || !isRecord(state.intents) || !isRecord(state.episode)) {
594
+ if (!isRecord(state) || state.version !== STATE_VERSION || !isRecord(state.config) || !isRecord(state.intents) || !isRecord(state.episode) || !isRecord(state.clock)) {
587
595
  throw new TypeError("oracular: state must be an EngineState from createState or deserialize");
588
596
  }
589
597
  return state;
@@ -720,8 +728,10 @@ function explain(state, intent, at, opts) {
720
728
 
721
729
  // src/observe.ts
722
730
  function observe(state, event) {
723
- let s = requireState(state);
724
- const { name, at } = validateEvent(event);
731
+ const { name, at: stamp } = validateEvent(event);
732
+ const synced = syncClock(requireState(state), stamp);
733
+ let s = synced.state;
734
+ const at = synced.at;
725
735
  const closeAt = timedOutAt(s.episode, s.config.episodeTimeout, at);
726
736
  if (closeAt !== null) s = closeEpisode(s, closeAt);
727
737
  const learning = resolveLearning(s.config);
@@ -734,7 +744,6 @@ function observe(state, event) {
734
744
  let is = getIntentState(s, intentName, intent);
735
745
  is = decayIntent(is, intent, at);
736
746
  is = settlePending(is, intent, learning, at);
737
- is = pinAnchors(is, at);
738
747
  if (isSignal) {
739
748
  const { effective } = signalWeights(is, intent, learning, name);
740
749
  const n = occurrences(episode, intentName, name) + 1;
@@ -755,15 +764,36 @@ function observe(state, event) {
755
764
  }
756
765
  return { ...s, intents, episode };
757
766
  }
758
- function pinAnchors(is, at) {
759
- const reveal = is.lastRevealedAt !== null && is.lastRevealedAt > at;
760
- const outcome = is.lastOutcomeAt !== null && is.lastOutcomeAt > at;
761
- if (!reveal && !outcome) return is;
762
- return {
763
- ...is,
764
- lastRevealedAt: reveal ? at : is.lastRevealedAt,
765
- lastOutcomeAt: outcome ? at : is.lastOutcomeAt
766
- };
767
+ function syncClock(state, stamp) {
768
+ const { at, behindAt } = state.clock;
769
+ if (at === null || stamp >= at) return { state: pinTo(state, stamp), at: stamp };
770
+ if (behindAt !== null) return syncClock(pinTo(state, behindAt), stamp);
771
+ return { state: { ...state, clock: { at, behindAt: stamp } }, at };
772
+ }
773
+ var INTENT_ANCHORS = ["updatedAt", "learnedAt", "lastRevealedAt", "lastOutcomeAt"];
774
+ var EPISODE_ANCHORS = ["startedAt", "lastEventAt"];
775
+ function pinTo(state, t) {
776
+ let intents = state.intents;
777
+ for (const [name, is] of Object.entries(state.intents)) {
778
+ let pinned = pinKeys(is, INTENT_ANCHORS, t);
779
+ if (is.pending.some((p) => p.endedAt > t)) {
780
+ pinned = {
781
+ ...pinned,
782
+ pending: is.pending.map((p) => p.endedAt > t ? { ...p, endedAt: t } : p)
783
+ };
784
+ }
785
+ if (pinned !== is) intents = { ...intents, [name]: pinned };
786
+ }
787
+ const episode = pinKeys(state.episode, EPISODE_ANCHORS, t);
788
+ return { ...state, intents, episode, clock: { at: t, behindAt: null } };
789
+ }
790
+ function pinKeys(obj, keys, t) {
791
+ let out = obj;
792
+ for (const key of keys) {
793
+ const value = obj[key];
794
+ if (typeof value === "number" && value > t) out = { ...out, [key]: t };
795
+ }
796
+ return out;
767
797
  }
768
798
  function endEpisode(state, at) {
769
799
  const s = requireState(state);
@@ -889,7 +919,8 @@ function createEndRecord(at, opts) {
889
919
  // src/migrate.ts
890
920
  var MIGRATIONS = {
891
921
  1: fromV1,
892
- 2: fromV2
922
+ 2: fromV2,
923
+ 3: fromV3
893
924
  };
894
925
  function canMigrate(version) {
895
926
  return hasOwn(MIGRATIONS, String(version));
@@ -930,6 +961,9 @@ function fromV2(stored) {
930
961
  }
931
962
  return { ...stored, version: 3, intents };
932
963
  }
964
+ function fromV3(stored) {
965
+ return { ...stored, version: 4, clock: { at: null, behindAt: null } };
966
+ }
933
967
 
934
968
  // src/serialize.ts
935
969
  function serialize(state) {
@@ -1075,6 +1109,13 @@ function episodeState(value, path) {
1075
1109
  intents: mapOwn(r.intents, `${path}.intents`, episodeIntent)
1076
1110
  };
1077
1111
  }
1112
+ function clockState(value, path) {
1113
+ const r = record(value, path);
1114
+ return {
1115
+ at: nullOrFinite(r.at, `${path}.at`),
1116
+ behindAt: nullOrFinite(r.behindAt, `${path}.behindAt`)
1117
+ };
1118
+ }
1078
1119
  function parseState(value) {
1079
1120
  const r = record(value, "state");
1080
1121
  if (r.version !== STATE_VERSION) {
@@ -1086,7 +1127,8 @@ function parseState(value) {
1086
1127
  // when it is only stored, and the config passed in replaces it anyway.
1087
1128
  config: normalizeConfig(r.config, false),
1088
1129
  intents: mapOwn(r.intents, "state.intents", intentState),
1089
- episode: episodeState(r.episode, "state.episode")
1130
+ episode: episodeState(r.episode, "state.episode"),
1131
+ clock: clockState(r.clock, "state.clock")
1090
1132
  };
1091
1133
  }
1092
1134
  function requireComputable(state) {
@@ -1160,9 +1202,24 @@ function reconcile(raw, config) {
1160
1202
  startedAt: raw.episode.startedAt,
1161
1203
  lastEventAt: raw.episode.startedAt === null ? null : raw.episode.lastEventAt,
1162
1204
  intents: episodeIntents
1163
- }
1205
+ },
1206
+ clock: trustedClock(raw.clock, intents, raw.episode)
1164
1207
  };
1165
1208
  }
1209
+ function trustedClock(clock, intents, episode) {
1210
+ let at = clock.at;
1211
+ const see = (value) => {
1212
+ if (value !== null && (at === null || value > at)) at = value;
1213
+ };
1214
+ see(episode.startedAt);
1215
+ see(episode.lastEventAt);
1216
+ for (const is of Object.values(intents)) {
1217
+ see(is.updatedAt);
1218
+ see(is.lastOutcomeAt);
1219
+ }
1220
+ const behindAt = clock.behindAt !== null && at !== null && clock.behindAt < at ? clock.behindAt : null;
1221
+ return at === clock.at && behindAt === clock.behindAt ? clock : { at, behindAt };
1222
+ }
1166
1223
 
1167
1224
  // src/units.ts
1168
1225
  var ms = {