oracular 0.10.0 → 0.12.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,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#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.
8
+
9
+ 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.
10
+
11
+ **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.
12
+
13
+ ### Patch Changes
14
+
15
+ - [#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.
16
+
17
+ - [#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.
18
+
19
+ ## 0.11.0
20
+
21
+ ### Minor Changes
22
+
23
+ - [#58](https://github.com/Yuribenjamin/oracular/pull/58) [`d6d1e87`](https://github.com/Yuribenjamin/oracular/commit/d6d1e87e176fc0112384e47fb4782e12d6fa1d1a) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **Learning no longer drifts without outcomes.** The outcome rates a signal is judged by, with it and without it, were Laplace-smoothed, which centres both on 0.5 with one pseudo-hit each: two groups with no outcome at all then differ only by their sizes, so with zero outcomes a common signal drifted negative and a rare one positive, and a weight could change sign on no evidence (a weight of 2 seen in 90 of 100 outcome-free episodes ended at −0.59). Both rates are now shrunk toward the intent's own base rate by two pseudo-episodes, `(hits + 2·q₀) / (n + 2)` with `q₀ = outcomes / episodes`: a signal whose outcome rate equals the base rate learns exactly zero whatever the sample sizes, with no outcome every rate is 0 and no weight moves away from the developer's, and a weight never changes sign without outcomes on at least one side. The association-rule baseline becomes the base rate itself. `tune` fits with the same smoothing, so its weights, intervals and lifts move a little on small samples (a signal seen once with the outcome over a low base rate is worth a little less than before); on large samples the change is in the third decimal. Every learned number in the documentation's generated tables was regenerated. The state format is unchanged, and existing learned counts are kept: only the weight read from them moves.
24
+
3
25
  ## 0.10.0
4
26
 
5
27
  ### Minor Changes
package/README.md CHANGED
@@ -193,8 +193,9 @@ A signal's weight is then a blend of your prior and the learned estimate:
193
193
  ```
194
194
  nWith = hits + misses episodes with the signal
195
195
  nWithout = episodes − nWith episodes without it
196
- qWith = (hits + 1) / (nWith + 2) outcome rate with the signal
197
- qWithout = (outcomes − hits + 1) / (nWithout + 2) outcome rate without it
196
+ q₀ = outcomes / episodes the intent's own base rate
197
+ qWith = (hits + 2·q₀) / (nWith + 2) outcome rate with the signal
198
+ qWithout = (outcomes − hits + 2·q₀) / (nWithout + 2) outcome rate without it
198
199
  lift = (qWith − qWithout) / (1 − qWithout) if qWith ≥ qWithout, else (qWith − qWithout) / qWithout → in [−1, 1]
199
200
  learned = gain × |w₀| × lift 0 until the signal has history
200
201
  nEff = (nWith × nWithout) / (nWith + nWithout) 0 if either side is 0
@@ -269,7 +270,7 @@ serialize(state): string
269
270
  deserialize(json: string, opts?: { config?: EngineConfig; migrate?: Migrate }): EngineState
270
271
  validateConfig(config: unknown): EngineConfig // the checks createState runs, without a state; returns the normalized copy
271
272
  ms.seconds(n), ms.minutes(n), ms.hours(n), ms.days(n), ms.weeks(n) // durations in milliseconds
272
- STATE_VERSION // 3
273
+ STATE_VERSION // 4
273
274
  RECORD_VERSION // 1
274
275
  ```
275
276
 
@@ -339,7 +340,7 @@ interface SignalExplanation {
339
340
 
340
341
  ### Time
341
342
 
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
+ `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.
343
344
 
344
345
  ## Persistence
345
346
 
@@ -357,7 +358,7 @@ const state = deserialize(saved, { config });
357
358
 
358
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.
359
360
 
360
- 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`:
361
362
 
362
363
  ```ts
363
364
  deserialize(saved, {
package/dist/cli.js CHANGED
@@ -437,6 +437,9 @@ import {
437
437
  function laplace(successes, total) {
438
438
  return (successes + 1) / (total + 2);
439
439
  }
440
+ function smoothRate(successes, total, rate) {
441
+ return (successes + 2 * rate) / (total + 2);
442
+ }
440
443
  function lift(q, b, type = "normalized-difference") {
441
444
  if (type === "absolute-difference") return q - b;
442
445
  if (type !== "normalized-difference") {
@@ -528,9 +531,10 @@ function fitSignal(signal, current, tally, strength, priorEpisodes, liftType) {
528
531
  const nWith = hits + misses;
529
532
  const without = Math.max(0, tally.episodes - nWith);
530
533
  const outcomesWithout = Math.min(without, Math.max(0, tally.outcomes - hits));
531
- const qWith = laplace(hits, nWith);
532
- const qWithout = laplace(outcomesWithout, without);
533
- const baseline = liftType === "association-rule" ? (hits + outcomesWithout + 2) / (nWith + without + 4) : qWithout;
534
+ const base = tally.episodes === 0 ? 0 : tally.outcomes / tally.episodes;
535
+ const qWith = smoothRate(hits, nWith, base);
536
+ const qWithout = smoothRate(outcomesWithout, without, base);
537
+ const baseline = liftType === "association-rule" ? smoothRate(hits + outcomesWithout, nWith + without, base) : qWithout;
534
538
  const noContrast = without === 0;
535
539
  const rawLift = noContrast ? null : nWith === 0 ? 0 : lift(qWith, baseline, liftType);
536
540
  const score = rawLift === null ? null : nWith === 0 ? 0 : learningScore(rawLift, liftType);
package/dist/index.cjs CHANGED
@@ -79,8 +79,8 @@ function lowerBound(alpha, beta, z, bound) {
79
79
  function repeatFactor(n) {
80
80
  return Math.log1p(1 / n) / Math.LN2;
81
81
  }
82
- function laplace(successes, total) {
83
- return (successes + 1) / (total + 2);
82
+ function smoothRate(successes, total, rate) {
83
+ return (successes + 2 * rate) / (total + 2);
84
84
  }
85
85
  function lift(q, b, type = "normalized-difference") {
86
86
  if (type === "absolute-difference") return q - b;
@@ -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,
@@ -154,12 +155,13 @@ function signalWeights(state, intent, learning, signal) {
154
155
  }
155
156
  const record2 = getOwn(state.learned, signal) ?? NONE;
156
157
  const nWith = record2.hits + record2.misses;
157
- const qWith = laplace(record2.hits, nWith);
158
158
  const totalEpisodes = state.episodes.hits + state.episodes.misses;
159
159
  const nWithout = Math.max(0, totalEpisodes - nWith);
160
160
  const outcomesWithout = Math.min(nWithout, Math.max(0, state.episodes.hits - record2.hits));
161
- const qWithout = laplace(outcomesWithout, nWithout);
162
- const baseline = learning.lift === "association-rule" ? (record2.hits + outcomesWithout + 2) / (nWith + nWithout + 4) : qWithout;
161
+ const base = totalEpisodes === 0 ? 0 : state.episodes.hits / totalEpisodes;
162
+ const qWith = smoothRate(record2.hits, nWith, base);
163
+ const qWithout = smoothRate(outcomesWithout, nWithout, base);
164
+ const baseline = learning.lift === "association-rule" ? smoothRate(record2.hits + outcomesWithout, nWith + nWithout, base) : qWithout;
163
165
  const score = learningScore(lift(qWith, baseline, learning.lift), learning.lift);
164
166
  const learned = nWith === 0 ? 0 : learning.gain * Math.abs(initial) * score;
165
167
  const nEff = nWith + nWithout === 0 ? 0 : nWith * nWithout / (nWith + nWithout);
@@ -250,8 +252,9 @@ function withContributions(state, intent, contributions) {
250
252
  }
251
253
  function decayIntent(state, intent, at) {
252
254
  if (state.updatedAt === null) return { ...state, updatedAt: at };
255
+ if (!(at > state.updatedAt)) return state;
253
256
  const d = decayFactor(at - state.updatedAt, intent.halfLife);
254
- if (d === 1) return at === state.updatedAt ? state : { ...state, updatedAt: at };
257
+ if (d === 1) return { ...state, updatedAt: at };
255
258
  const contributions = mapRecord(state.contributions, (c) => ({
256
259
  alpha: c.alpha * d,
257
260
  beta: c.beta * d
@@ -569,7 +572,7 @@ function markResolved(episode, intent) {
569
572
  }
570
573
 
571
574
  // src/version.ts
572
- var STATE_VERSION = 3;
575
+ var STATE_VERSION = 4;
573
576
  var RECORD_VERSION = 1;
574
577
 
575
578
  // src/state.ts
@@ -579,10 +582,16 @@ function createState(config) {
579
582
  for (const [name, intent] of Object.entries(normalized.intents)) {
580
583
  intents[name] = initIntent(intent);
581
584
  }
582
- 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
+ };
583
592
  }
584
593
  function requireState(state) {
585
- 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)) {
586
595
  throw new TypeError("oracular: state must be an EngineState from createState or deserialize");
587
596
  }
588
597
  return state;
@@ -719,8 +728,10 @@ function explain(state, intent, at, opts) {
719
728
 
720
729
  // src/observe.ts
721
730
  function observe(state, event) {
722
- let s = requireState(state);
723
- 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;
724
735
  const closeAt = timedOutAt(s.episode, s.config.episodeTimeout, at);
725
736
  if (closeAt !== null) s = closeEpisode(s, closeAt);
726
737
  const learning = resolveLearning(s.config);
@@ -733,7 +744,6 @@ function observe(state, event) {
733
744
  let is = getIntentState(s, intentName, intent);
734
745
  is = decayIntent(is, intent, at);
735
746
  is = settlePending(is, intent, learning, at);
736
- is = pinAnchors(is, at);
737
747
  if (isSignal) {
738
748
  const { effective } = signalWeights(is, intent, learning, name);
739
749
  const n = occurrences(episode, intentName, name) + 1;
@@ -754,15 +764,36 @@ function observe(state, event) {
754
764
  }
755
765
  return { ...s, intents, episode };
756
766
  }
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
- };
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;
766
797
  }
767
798
  function endEpisode(state, at) {
768
799
  const s = requireState(state);
@@ -888,7 +919,8 @@ function createEndRecord(at, opts) {
888
919
  // src/migrate.ts
889
920
  var MIGRATIONS = {
890
921
  1: fromV1,
891
- 2: fromV2
922
+ 2: fromV2,
923
+ 3: fromV3
892
924
  };
893
925
  function canMigrate(version) {
894
926
  return hasOwn(MIGRATIONS, String(version));
@@ -929,6 +961,9 @@ function fromV2(stored) {
929
961
  }
930
962
  return { ...stored, version: 3, intents };
931
963
  }
964
+ function fromV3(stored) {
965
+ return { ...stored, version: 4, clock: { at: null, behindAt: null } };
966
+ }
932
967
 
933
968
  // src/serialize.ts
934
969
  function serialize(state) {
@@ -1074,6 +1109,13 @@ function episodeState(value, path) {
1074
1109
  intents: mapOwn(r.intents, `${path}.intents`, episodeIntent)
1075
1110
  };
1076
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
+ }
1077
1119
  function parseState(value) {
1078
1120
  const r = record(value, "state");
1079
1121
  if (r.version !== STATE_VERSION) {
@@ -1085,7 +1127,8 @@ function parseState(value) {
1085
1127
  // when it is only stored, and the config passed in replaces it anyway.
1086
1128
  config: normalizeConfig(r.config, false),
1087
1129
  intents: mapOwn(r.intents, "state.intents", intentState),
1088
- episode: episodeState(r.episode, "state.episode")
1130
+ episode: episodeState(r.episode, "state.episode"),
1131
+ clock: clockState(r.clock, "state.clock")
1089
1132
  };
1090
1133
  }
1091
1134
  function requireComputable(state) {
@@ -1159,9 +1202,24 @@ function reconcile(raw, config) {
1159
1202
  startedAt: raw.episode.startedAt,
1160
1203
  lastEventAt: raw.episode.startedAt === null ? null : raw.episode.lastEventAt,
1161
1204
  intents: episodeIntents
1162
- }
1205
+ },
1206
+ clock: trustedClock(raw.clock, intents, raw.episode)
1163
1207
  };
1164
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
+ }
1165
1223
 
1166
1224
  // src/units.ts
1167
1225
  var ms = {