oracular 0.5.2 → 0.6.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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#32](https://github.com/Yuribenjamin/oracular/pull/32) [`6951d70`](https://github.com/Yuribenjamin/oracular/commit/6951d70b4ff1e654ab17a61357d882fd82240968) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **Cross-episode attribution.** `IntentConfig.attribution` is how long a closed episode's signals wait for the outcome before they take a miss. Until now a signal earned a hit only when the outcome arrived in the same episode, so for an intent that builds over days the sessions that built it took misses and the session where the user acted got all the credit. With a window set, an episode that ends without the outcome is held instead of counted; when the outcome arrives inside the window, each held episode gets the share `2^(−age / halfLife)` of a hit that its evidence still holds, and the rest of a miss, so every episode still counts exactly once and the contrast that learning rests on is unchanged; when the window passes, the episode is a miss recorded at the moment the window ended. Reads settle what has expired in what they show and store nothing. Off unless set: every state and estimate without `attribution` is exactly what it was.
8
+
9
+ **State version 3.** `IntentState.pending` lists the episodes waiting, at most 32 per intent (past that the oldest is a miss at once). `deserialize` upgrades version 1 and 2 states on its own, adding an empty list and changing no number; oracular 0.5.x cannot read a version 3 state, so the rollback advice stays: catch and start from `createState(config)`. `PendingEpisode` is exported as a type; the config JSON Schema gains `attribution`.
10
+
11
+ `oracular tune` counts each episode's own verdict, with the window off while counting, keeps it on for the half-life grid, and keeps whatever `attribution` you set in the printed config.
12
+
3
13
  ## 0.5.2
4
14
 
5
15
  ### Patch Changes
package/README.md CHANGED
@@ -107,7 +107,7 @@ Before any update or reading, the evidence decays by 2^(−Δt / halfLife), wher
107
107
 
108
108
  ### The user's history corrects your weights
109
109
 
110
- 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**. The engine also counts, per intent, how many episodes ended with and without the outcome: the intent's **base rate**.
110
+ 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**.
111
111
 
112
112
  A signal's weight is then a blend of your prior and the learned estimate:
113
113
 
@@ -252,7 +252,7 @@ serialize(state): string
252
252
  deserialize(json: string, opts?: { config?: EngineConfig; migrate?: Migrate }): EngineState
253
253
  validateConfig(config: unknown): EngineConfig // the checks createState runs, without a state; returns the normalized copy
254
254
  ms.seconds(n), ms.minutes(n), ms.hours(n), ms.days(n), ms.weeks(n) // durations in milliseconds
255
- STATE_VERSION // 2
255
+ STATE_VERSION // 3
256
256
  RECORD_VERSION // 1
257
257
  ```
258
258
 
@@ -278,6 +278,7 @@ interface IntentConfig {
278
278
  outcome?: string; // event that confirms the intent
279
279
  resetOnOutcome?: boolean; // default true
280
280
  saturate?: boolean; // repeats within an episode add less; default: episodeTimeout is set
281
+ attribution?: number; // how long a closed episode waits for the outcome before it is a miss; off if omitted
281
282
  reveal?: RevealOptions;
282
283
  }
283
284
 
@@ -337,7 +338,7 @@ const state = deserialize(saved, { config });
337
338
 
338
339
  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.
339
340
 
340
- States saved by an older version of this library are upgraded on load: `deserialize` migrates the state shape written by oracular 0.1.0 to 0.2.x (`STATE_VERSION` 1) 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`:
341
+ 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`:
341
342
 
342
343
  ```ts
343
344
  deserialize(saved, {
@@ -393,7 +394,7 @@ npx oracular tune shadow.ndjson --config oracular.config.json
393
394
 
394
395
  ## Roadmap
395
396
 
396
- Now is 0.4, themed **learning that matches how people behave**: cross-episode attribution, so a session's signals can earn credit for an outcome days later, and presets. Shipped so far: `npx oracular tune` to seed a config from a shadow log, and small ergonomics (`validateConfig`, `ms`, a JSON Schema for the config). 0.3 shipped shadow mode, `npx oracular replay` and per-episode saturation. Then a written stability policy before 1.0. Framework bindings wait for a third integration. The full list, with the reasoning and what was declined, is on the [roadmap page](https://yuribenjamin.github.io/oracular/guide/roadmap).
397
+ Now is 0.4, themed **learning that matches how people behave**. Shipped so far: cross-episode attribution (`attribution` on an intent, so a session's signals earn a share of an outcome days later), `npx oracular tune` to seed a config from a shadow log, and small ergonomics (`validateConfig`, `ms`, a JSON Schema for the config). Presets come once tune has run on more real logs. 0.3 shipped shadow mode, `npx oracular replay` and per-episode saturation. Then a written stability policy before 1.0. Framework bindings wait for a third integration. The full list, with the reasoning and what was declined, is on the [roadmap page](https://yuribenjamin.github.io/oracular/guide/roadmap).
397
398
 
398
399
  ## Contributing
399
400
 
package/dist/cli.js CHANGED
@@ -575,6 +575,16 @@ function withoutLearnedDecay(config) {
575
575
  );
576
576
  return { ...config, learning };
577
577
  }
578
+ function withoutAttribution(config) {
579
+ const intents = Object.fromEntries(
580
+ Object.entries(config.intents).map(([name, intent]) => {
581
+ if (intent.attribution === void 0) return [name, intent];
582
+ const { attribution: _attribution, ...rest } = intent;
583
+ return [name, rest];
584
+ })
585
+ );
586
+ return { ...config, intents };
587
+ }
578
588
  function cooldownOf(base, name) {
579
589
  return predict2(markRevealed2(base, name, 0), name, 0).suppressedUntil ?? 0;
580
590
  }
@@ -613,7 +623,7 @@ function tune(entries, config, options = {}) {
613
623
  const candidatesOf = (intent) => options.allEvents === true ? [...eventNames].filter(
614
624
  (name) => name !== "__proto__" && name !== intent.outcome && !Object.hasOwn(intent.signals, name)
615
625
  ) : [];
616
- let countConfig = withoutLearnedDecay(normalized);
626
+ let countConfig = withoutAttribution(withoutLearnedDecay(normalized));
617
627
  const plans = fitting.map((name) => {
618
628
  const intent = normalized.intents[name];
619
629
  const candidates = candidatesOf(intent);
package/dist/index.cjs CHANGED
@@ -49,6 +49,86 @@ function mapRecord(record2, fn) {
49
49
  return out;
50
50
  }
51
51
 
52
+ // src/learning.ts
53
+ var NONE = { hits: 0, misses: 0 };
54
+ function decayLearned(state, learning, at) {
55
+ if (state.learnedAt === null) return { ...state, learnedAt: at };
56
+ const learnedAt = Math.max(state.learnedAt, at);
57
+ const d = learning.halfLife === null ? 1 : decayFactor(at - state.learnedAt, learning.halfLife);
58
+ if (d === 1) return learnedAt === state.learnedAt ? state : { ...state, learnedAt };
59
+ return {
60
+ ...state,
61
+ learnedAt,
62
+ learned: mapRecord(state.learned, (r) => ({ hits: r.hits * d, misses: r.misses * d })),
63
+ episodes: { hits: state.episodes.hits * d, misses: state.episodes.misses * d }
64
+ };
65
+ }
66
+ function signalWeights(state, intent, learning, signal) {
67
+ const initial = getOwn(intent.signals, signal);
68
+ if (initial === void 0) {
69
+ throw new RangeError(`oracular: ${JSON.stringify(signal)} is not a signal of this intent`);
70
+ }
71
+ const record2 = getOwn(state.learned, signal) ?? NONE;
72
+ const nWith = record2.hits + record2.misses;
73
+ const qWith = laplace(record2.hits, nWith);
74
+ const totalEpisodes = state.episodes.hits + state.episodes.misses;
75
+ const nWithout = Math.max(0, totalEpisodes - nWith);
76
+ const outcomesWithout = Math.min(nWithout, Math.max(0, state.episodes.hits - record2.hits));
77
+ const qWithout = laplace(outcomesWithout, nWithout);
78
+ const learned = nWith === 0 ? 0 : learning.gain * Math.abs(initial) * lift(qWith, qWithout);
79
+ const nEff = nWith + nWithout === 0 ? 0 : nWith * nWithout / (nWith + nWithout);
80
+ const k = learning.priorEpisodes;
81
+ const effective = (k * initial + nEff * learned) / (k + nEff);
82
+ return { initial, learned, effective, hits: record2.hits, misses: record2.misses };
83
+ }
84
+ function recordEpisode(state, signals, credit) {
85
+ const miss = 1 - credit;
86
+ const learned = { ...state.learned };
87
+ for (const signal of signals) {
88
+ const r = getOwn(learned, signal) ?? NONE;
89
+ learned[signal] = { hits: r.hits + credit, misses: r.misses + miss };
90
+ }
91
+ const episodes = { hits: state.episodes.hits + credit, misses: state.episodes.misses + miss };
92
+ return { ...state, learned, episodes };
93
+ }
94
+
95
+ // src/attribution.ts
96
+ var MAX_PENDING = 32;
97
+ function expiry(episode, intent) {
98
+ return episode.endedAt + (intent.attribution ?? 0);
99
+ }
100
+ function settlePending(state, intent, learning, at) {
101
+ let is = state;
102
+ let settled = 0;
103
+ for (const episode of state.pending) {
104
+ const end = expiry(episode, intent);
105
+ if (!(end < at)) break;
106
+ is = recordEpisode(decayLearned(is, learning, end), episode.seen, 0);
107
+ settled++;
108
+ }
109
+ if (settled > 0) is = { ...is, pending: state.pending.slice(settled) };
110
+ return decayLearned(is, learning, at);
111
+ }
112
+ function deferEpisode(state, seen, endedAt) {
113
+ const pending = insert(state.pending, { endedAt, seen: [...seen] });
114
+ if (pending.length <= MAX_PENDING) return { ...state, pending };
115
+ const [oldest, ...rest] = pending;
116
+ return { ...recordEpisode(state, oldest.seen, 0), pending: rest };
117
+ }
118
+ function insert(pending, episode) {
119
+ let i = pending.length;
120
+ while (i > 0 && pending[i - 1].endedAt > episode.endedAt) i--;
121
+ return [...pending.slice(0, i), episode, ...pending.slice(i)];
122
+ }
123
+ function creditPending(state, intent, at) {
124
+ if (state.pending.length === 0) return state;
125
+ let is = state;
126
+ for (const episode of state.pending) {
127
+ is = recordEpisode(is, episode.seen, decayFactor(at - episode.endedAt, intent.halfLife));
128
+ }
129
+ return { ...is, pending: [] };
130
+ }
131
+
52
132
  // src/intent.ts
53
133
  function priorCounts(intent) {
54
134
  return {
@@ -65,6 +145,7 @@ function initIntent(intent) {
65
145
  contributions: {},
66
146
  learned: {},
67
147
  episodes: { hits: 0, misses: 0 },
148
+ pending: [],
68
149
  learnedAt: null,
69
150
  lastRevealedAt: null,
70
151
  lastOutcomeAt: null,
@@ -101,48 +182,6 @@ function resetToPrior(state, intent) {
101
182
  return withContributions(state, intent, {});
102
183
  }
103
184
 
104
- // src/learning.ts
105
- var NONE = { hits: 0, misses: 0 };
106
- function decayLearned(state, learning, at) {
107
- if (state.learnedAt === null) return { ...state, learnedAt: at };
108
- const learnedAt = Math.max(state.learnedAt, at);
109
- const d = learning.halfLife === null ? 1 : decayFactor(at - state.learnedAt, learning.halfLife);
110
- if (d === 1) return learnedAt === state.learnedAt ? state : { ...state, learnedAt };
111
- return {
112
- ...state,
113
- learnedAt,
114
- learned: mapRecord(state.learned, (r) => ({ hits: r.hits * d, misses: r.misses * d })),
115
- episodes: { hits: state.episodes.hits * d, misses: state.episodes.misses * d }
116
- };
117
- }
118
- function signalWeights(state, intent, learning, signal) {
119
- const initial = getOwn(intent.signals, signal);
120
- if (initial === void 0) {
121
- throw new RangeError(`oracular: ${JSON.stringify(signal)} is not a signal of this intent`);
122
- }
123
- const record2 = getOwn(state.learned, signal) ?? NONE;
124
- const nWith = record2.hits + record2.misses;
125
- const qWith = laplace(record2.hits, nWith);
126
- const totalEpisodes = state.episodes.hits + state.episodes.misses;
127
- const nWithout = Math.max(0, totalEpisodes - nWith);
128
- const outcomesWithout = Math.min(nWithout, Math.max(0, state.episodes.hits - record2.hits));
129
- const qWithout = laplace(outcomesWithout, nWithout);
130
- const learned = nWith === 0 ? 0 : learning.gain * Math.abs(initial) * lift(qWith, qWithout);
131
- const nEff = nWith + nWithout === 0 ? 0 : nWith * nWithout / (nWith + nWithout);
132
- const k = learning.priorEpisodes;
133
- const effective = (k * initial + nEff * learned) / (k + nEff);
134
- return { initial, learned, effective, hits: record2.hits, misses: record2.misses };
135
- }
136
- function recordEpisode(state, signals, outcome) {
137
- const learned = { ...state.learned };
138
- for (const signal of signals) {
139
- const r = getOwn(learned, signal) ?? NONE;
140
- learned[signal] = outcome ? { hits: r.hits + 1, misses: r.misses } : { hits: r.hits, misses: r.misses + 1 };
141
- }
142
- const episodes = outcome ? { hits: state.episodes.hits + 1, misses: state.episodes.misses } : { hits: state.episodes.hits, misses: state.episodes.misses + 1 };
143
- return { ...state, learned, episodes };
144
- }
145
-
146
185
  // src/config.ts
147
186
  var DEFAULT_THRESHOLD = 0.5;
148
187
  var DEFAULT_Z = 1.28;
@@ -158,6 +197,7 @@ var INTENT_KEYS = [
158
197
  "outcome",
159
198
  "resetOnOutcome",
160
199
  "saturate",
200
+ "attribution",
161
201
  "reveal"
162
202
  ];
163
203
  var REVEAL_KEYS = ["threshold", "z", "cooldown"];
@@ -287,6 +327,9 @@ function normalizeIntentConfig(value, path) {
287
327
  if (typeof record2.saturate !== "boolean") typeError(`${path}.saturate`, "must be a boolean");
288
328
  out.saturate = record2.saturate;
289
329
  }
330
+ if (record2.attribution !== void 0) {
331
+ out.attribution = expectPositive(record2.attribution, `${path}.attribution`);
332
+ }
290
333
  if (record2.reveal !== void 0)
291
334
  out.reveal = normalizeRevealOptions(record2.reveal, `${path}.reveal`);
292
335
  return out;
@@ -409,7 +452,7 @@ function markResolved(episode, intent) {
409
452
  }
410
453
 
411
454
  // src/version.ts
412
- var STATE_VERSION = 2;
455
+ var STATE_VERSION = 3;
413
456
  var RECORD_VERSION = 1;
414
457
 
415
458
  // src/state.ts
@@ -440,7 +483,8 @@ function getIntentState(state, name, intent) {
440
483
 
441
484
  // src/assess.ts
442
485
  function assessIntent(state, name, intent, learning, at) {
443
- return decayLearned(decayIntent(getIntentState(state, name, intent), intent, at), learning, at);
486
+ const is = decayIntent(getIntentState(state, name, intent), intent, at);
487
+ return settlePending(is, intent, learning, at);
444
488
  }
445
489
  function cooldownEnd(since, cooldown, at) {
446
490
  if (since === null) return null;
@@ -567,7 +611,7 @@ function observe(state, event) {
567
611
  if (!isSignal && !isOutcome) continue;
568
612
  let is = getIntentState(s, intentName, intent);
569
613
  is = decayIntent(is, intent, at);
570
- is = decayLearned(is, learning, at);
614
+ is = settlePending(is, intent, learning, at);
571
615
  if (isSignal) {
572
616
  const { effective } = signalWeights(is, intent, learning, name);
573
617
  const n = occurrences(episode, intentName, name) + 1;
@@ -577,7 +621,8 @@ function observe(state, event) {
577
621
  if (!isOutcome) episode = markSeen(episode, intentName, name);
578
622
  }
579
623
  if (isOutcome) {
580
- is = recordEpisode(is, episodeRecord(episode, intentName).seen, true);
624
+ is = recordEpisode(is, episodeRecord(episode, intentName).seen, 1);
625
+ is = creditPending(is, intent, at);
581
626
  episode = markResolved(episode, intentName);
582
627
  is = { ...is, lastOutcomeAt: at };
583
628
  if (intent.resetOnOutcome ?? true) is = resetToPrior(is, intent);
@@ -600,8 +645,8 @@ function closeEpisode(state, at) {
600
645
  const record2 = episodeRecord(state.episode, intentName);
601
646
  if (record2.resolved) continue;
602
647
  let is = getIntentState(state, intentName, intent);
603
- is = decayLearned(is, learning, at);
604
- is = recordEpisode(is, record2.seen, false);
648
+ is = settlePending(is, intent, learning, at);
649
+ is = intent.attribution === void 0 ? recordEpisode(is, record2.seen, 0) : deferEpisode(is, record2.seen, at);
605
650
  intents[intentName] = is;
606
651
  }
607
652
  return { ...state, intents, episode: emptyEpisode() };
@@ -709,7 +754,8 @@ function createEndRecord(at, opts) {
709
754
 
710
755
  // src/migrate.ts
711
756
  var MIGRATIONS = {
712
- 1: fromV1
757
+ 1: fromV1,
758
+ 2: fromV2
713
759
  };
714
760
  function canMigrate(version) {
715
761
  return hasOwn(MIGRATIONS, String(version));
@@ -740,6 +786,16 @@ function fromV1(stored) {
740
786
  }
741
787
  return { ...stored, version: 2, episode: { ...episode, intents } };
742
788
  }
789
+ function fromV2(stored) {
790
+ if (!isRecord(stored.intents)) return { ...stored, version: 3 };
791
+ const intents = {};
792
+ for (const key of Object.keys(stored.intents)) {
793
+ if (key === "__proto__") continue;
794
+ const intent = stored.intents[key];
795
+ intents[key] = isRecord(intent) ? { ...intent, pending: [] } : intent;
796
+ }
797
+ return { ...stored, version: 3, intents };
798
+ }
743
799
 
744
800
  // src/serialize.ts
745
801
  function serialize(state) {
@@ -826,6 +882,19 @@ function mapOwn(value, path, fn) {
826
882
  }
827
883
  return out;
828
884
  }
885
+ function pendingEpisodes(value, path) {
886
+ if (!Array.isArray(value)) typeError2(path, "must be an array");
887
+ if (value.length > MAX_PENDING) {
888
+ throw new RangeError(`oracular: ${path} must hold at most ${MAX_PENDING} episodes`);
889
+ }
890
+ return value.map((item, i) => {
891
+ const r = record(item, `${path}[${i}]`);
892
+ if (!Array.isArray(r.seen) || !r.seen.every((s) => typeof s === "string")) {
893
+ typeError2(`${path}[${i}].seen`, "must be an array of strings");
894
+ }
895
+ return { endedAt: finite(r.endedAt, `${path}[${i}].endedAt`), seen: r.seen };
896
+ });
897
+ }
829
898
  function intentState(value, path) {
830
899
  const r = record(value, path);
831
900
  const revealCount = finite(r.revealCount, `${path}.revealCount`);
@@ -839,6 +908,7 @@ function intentState(value, path) {
839
908
  contributions: mapOwn(r.contributions, `${path}.contributions`, contribution),
840
909
  learned: mapOwn(r.learned, `${path}.learned`, signalRecord),
841
910
  episodes: signalRecord(r.episodes, `${path}.episodes`),
911
+ pending: pendingEpisodes(r.pending, `${path}.pending`),
842
912
  learnedAt: nullOrFinite(r.learnedAt, `${path}.learnedAt`),
843
913
  lastRevealedAt: nullOrFinite(r.lastRevealedAt, `${path}.lastRevealedAt`),
844
914
  lastOutcomeAt: nullOrFinite(r.lastOutcomeAt, `${path}.lastOutcomeAt`),
@@ -907,6 +977,12 @@ function keep(source, declared) {
907
977
  }
908
978
  return out;
909
979
  }
980
+ function keepPending(pending, signals) {
981
+ return pending.map((episode) => ({
982
+ endedAt: episode.endedAt,
983
+ seen: episode.seen.filter((s, i) => hasOwn(signals, s) && episode.seen.indexOf(s) === i)
984
+ })).sort((a, b) => a.endedAt - b.endedAt);
985
+ }
910
986
  function reconcile(raw, config) {
911
987
  const intents = {};
912
988
  const episodeIntents = {};
@@ -916,7 +992,11 @@ function reconcile(raw, config) {
916
992
  intents[name] = initIntent(intent);
917
993
  } else {
918
994
  intents[name] = withContributions(
919
- { ...stored, learned: keep(stored.learned, intent.signals) },
995
+ {
996
+ ...stored,
997
+ learned: keep(stored.learned, intent.signals),
998
+ pending: keepPending(stored.pending, intent.signals)
999
+ },
920
1000
  intent,
921
1001
  keep(stored.contributions, intent.signals)
922
1002
  );