oracular 0.5.1 → 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 +16 -0
- package/README.md +6 -5
- package/dist/cli.js +62 -13
- package/dist/index.cjs +130 -50
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +40 -14
- package/dist/index.d.ts +40 -14
- package/dist/index.js +130 -50
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/schema/config.schema.json +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
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
|
+
|
|
13
|
+
## 0.5.2
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- [#30](https://github.com/Yuribenjamin/oracular/pull/30) [`8be17e0`](https://github.com/Yuribenjamin/oracular/commit/8be17e032bb1b7886ce1d7278701555c39bb03cf) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - `oracular tune` learned three rules from a public e-commerce clickstream of 2.7 million events. A signal seen in more than 90% of counted episodes is left out of the fitted config, whatever its lift: it cannot tell episodes apart, and the few episodes without it are rarely typical sessions, so the contrast measured on them says little (on that log, `item.viewed` sat in 99% of sessions and the contrast called it strongly negative; dropping it scored better than any weight). The current half-life is kept when the best grid point improves on it by less than 1%, and the grid is printed to four decimals so a flat grid reads as flat. The report says when the fitted intent still does not beat its own base rate. The JSON report gains `share` and `ubiquitous` per signal and `beatsBaseRate` per intent.
|
|
18
|
+
|
|
3
19
|
## 0.5.1
|
|
4
20
|
|
|
5
21
|
### 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
|
|
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 //
|
|
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
|
|
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, {
|
|
@@ -382,7 +383,7 @@ npx oracular replay shadow.ndjson --config oracular.config.json
|
|
|
382
383
|
npx oracular tune shadow.ndjson --config oracular.config.json
|
|
383
384
|
```
|
|
384
385
|
|
|
385
|
-
`tune` seeds a config from the same log: grouped by install, the base rate becomes the prior, the spread of per-install base rates the prior strength, each signal's lift over the episodes without it its weight, and the half-life that predicts the log best wins a grid search scored by `replay`. The counts come from the engine itself, so tune cannot disagree with what a device would learn. It prints sample sizes and a 90% interval per weight, and the config to paste; the engine corrects it per user from there. The [Shadow mode guide](https://yuribenjamin.github.io/oracular/guide/shadow-mode) has the workflow, the record format and both commands.
|
|
386
|
+
`tune` seeds a config from the same log: grouped by install, the base rate becomes the prior, the spread of per-install base rates the prior strength, each signal's lift over the episodes without it its weight, and the half-life that predicts the log best wins a grid search scored by `replay`. The counts come from the engine itself, so tune cannot disagree with what a device would learn. It prints sample sizes and a 90% interval per weight, and the config to paste; the engine corrects it per user from there. Three rules learned from a real clickstream keep it honest: a signal seen in nearly every episode is left out, a flat grid keeps your half-life, and the report says when an intent still does not beat its base rate. The [Shadow mode guide](https://yuribenjamin.github.io/oracular/guide/shadow-mode) has the workflow, the record format and both commands.
|
|
386
387
|
|
|
387
388
|
## Design rules
|
|
388
389
|
|
|
@@ -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
|
|
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
|
@@ -444,6 +444,8 @@ var DEFAULT_HALF_LIVES = [HOUR2, 6 * HOUR2, DAY2, 3 * DAY2, 7 * DAY2, 14 * DAY2,
|
|
|
444
444
|
var DEFAULT_PRIOR_EPISODES = 10;
|
|
445
445
|
var MIN_INSTALLS_FOR_SPREAD = 10;
|
|
446
446
|
var STRENGTH_RANGE = [2, 50];
|
|
447
|
+
var UBIQUITY_SHARE = 0.9;
|
|
448
|
+
var FLAT_GRID_GAIN = 0.01;
|
|
447
449
|
var Z90 = 1.6449;
|
|
448
450
|
var NONE = { hits: 0, misses: 0 };
|
|
449
451
|
function stepDelta(prev, next) {
|
|
@@ -498,6 +500,8 @@ function fitSignal(signal, current, tally, strength, priorEpisodes) {
|
|
|
498
500
|
const contrast = nWith + without === 0 ? 0 : nWith * without / (nWith + without);
|
|
499
501
|
const [qLow, qHigh] = wilson(hits, nWith, Z90);
|
|
500
502
|
const rounded = Math.round(strength * l * 100) / 100;
|
|
503
|
+
const share = tally.episodes === 0 ? 0 : nWith / tally.episodes;
|
|
504
|
+
const ubiquitous = nWith > UBIQUITY_SHARE * tally.episodes;
|
|
501
505
|
return {
|
|
502
506
|
signal,
|
|
503
507
|
current,
|
|
@@ -508,7 +512,9 @@ function fitSignal(signal, current, tally, strength, priorEpisodes) {
|
|
|
508
512
|
qWith,
|
|
509
513
|
qWithout,
|
|
510
514
|
lift: l,
|
|
511
|
-
weight: nWith === 0 || rounded === 0 ? null : rounded,
|
|
515
|
+
weight: nWith === 0 || ubiquitous || rounded === 0 ? null : rounded,
|
|
516
|
+
share,
|
|
517
|
+
ubiquitous,
|
|
512
518
|
low: strength * lift(qLow, qWithout),
|
|
513
519
|
high: strength * lift(qHigh, qWithout),
|
|
514
520
|
contrast,
|
|
@@ -569,6 +575,16 @@ function withoutLearnedDecay(config) {
|
|
|
569
575
|
);
|
|
570
576
|
return { ...config, learning };
|
|
571
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
|
+
}
|
|
572
588
|
function cooldownOf(base, name) {
|
|
573
589
|
return predict2(markRevealed2(base, name, 0), name, 0).suppressedUntil ?? 0;
|
|
574
590
|
}
|
|
@@ -607,7 +623,7 @@ function tune(entries, config, options = {}) {
|
|
|
607
623
|
const candidatesOf = (intent) => options.allEvents === true ? [...eventNames].filter(
|
|
608
624
|
(name) => name !== "__proto__" && name !== intent.outcome && !Object.hasOwn(intent.signals, name)
|
|
609
625
|
) : [];
|
|
610
|
-
let countConfig = withoutLearnedDecay(normalized);
|
|
626
|
+
let countConfig = withoutAttribution(withoutLearnedDecay(normalized));
|
|
611
627
|
const plans = fitting.map((name) => {
|
|
612
628
|
const intent = normalized.intents[name];
|
|
613
629
|
const candidates = candidatesOf(intent);
|
|
@@ -707,6 +723,7 @@ function tune(entries, config, options = {}) {
|
|
|
707
723
|
return { halfLife: halfLife2, brier: scored?.brier ?? null, points: scored?.points ?? 0 };
|
|
708
724
|
});
|
|
709
725
|
const scoredPoints = curve.filter((p) => p.brier !== null);
|
|
726
|
+
const atCurrent = curve.find((p) => p.halfLife === intent.halfLife);
|
|
710
727
|
let halfLife;
|
|
711
728
|
if (scoredPoints.length === 0) {
|
|
712
729
|
halfLife = {
|
|
@@ -723,17 +740,33 @@ function tune(entries, config, options = {}) {
|
|
|
723
740
|
const ties = scoredPoints.filter((p) => p.brier - best <= 1e-12);
|
|
724
741
|
const nearest = (p) => Math.abs(Math.log(p.halfLife / intent.halfLife));
|
|
725
742
|
const chosen = ties.reduce((a, b) => nearest(b) < nearest(a) ? b : a);
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
743
|
+
const baseline = brierBaseline === null ? "" : ` (always predicting the base rate: ${brierBaseline.toFixed(4)})`;
|
|
744
|
+
const gain = atCurrent.brier === null || atCurrent.brier <= 0 ? Number.POSITIVE_INFINITY : (atCurrent.brier - best) / atCurrent.brier;
|
|
745
|
+
if (chosen.halfLife !== intent.halfLife && gain < FLAT_GRID_GAIN) {
|
|
746
|
+
halfLife = {
|
|
747
|
+
value: intent.halfLife,
|
|
748
|
+
current: intent.halfLife,
|
|
749
|
+
source: "kept",
|
|
750
|
+
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}`,
|
|
751
|
+
window,
|
|
752
|
+
curve,
|
|
753
|
+
brierBaseline
|
|
754
|
+
};
|
|
755
|
+
} else {
|
|
756
|
+
halfLife = {
|
|
757
|
+
value: chosen.halfLife,
|
|
758
|
+
current: intent.halfLife,
|
|
759
|
+
source: "grid",
|
|
760
|
+
note: `lowest Brier score over the grid, ${best.toFixed(4)}${baseline}`,
|
|
761
|
+
window,
|
|
762
|
+
curve,
|
|
763
|
+
brierBaseline
|
|
764
|
+
};
|
|
765
|
+
}
|
|
735
766
|
}
|
|
736
767
|
fitted = withIntent(fitted, name, { ...fittedIntent, halfLife: halfLife.value });
|
|
768
|
+
const scoredAt = curve.find((p) => p.halfLife === halfLife.value);
|
|
769
|
+
const beatsBaseRate = scoredAt.brier === null || brierBaseline === null ? null : scoredAt.brier < brierBaseline;
|
|
737
770
|
intents.push({
|
|
738
771
|
intent: name,
|
|
739
772
|
outcome: intent.outcome,
|
|
@@ -745,6 +778,7 @@ function tune(entries, config, options = {}) {
|
|
|
745
778
|
priorStrength: strength,
|
|
746
779
|
halfLife,
|
|
747
780
|
priorEpisodes,
|
|
781
|
+
beatsBaseRate,
|
|
748
782
|
signals,
|
|
749
783
|
candidates
|
|
750
784
|
});
|
|
@@ -771,7 +805,13 @@ var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
|
771
805
|
function note(fit, priorEpisodes) {
|
|
772
806
|
if (fit.hits + fit.misses === 0) return "never seen in a counted episode";
|
|
773
807
|
const notes = [];
|
|
774
|
-
if (fit.
|
|
808
|
+
if (fit.ubiquitous) {
|
|
809
|
+
notes.push(
|
|
810
|
+
`in ${(fit.share * 100).toFixed(1)}% of episodes, so it cannot tell them apart; left out of the config`
|
|
811
|
+
);
|
|
812
|
+
} else if (fit.weight === null) {
|
|
813
|
+
notes.push("no measurable lift, left out of the config");
|
|
814
|
+
}
|
|
775
815
|
if (fit.thin) notes.push(`thin: ${fit.contrast.toFixed(1)} of ${priorEpisodes} contrast`);
|
|
776
816
|
return notes.join("; ");
|
|
777
817
|
}
|
|
@@ -841,9 +881,18 @@ function formatIntent2(fit) {
|
|
|
841
881
|
out.push("");
|
|
842
882
|
out.push(
|
|
843
883
|
`Half-life grid, Brier score against "the outcome follows within ${formatDuration(fit.halfLife.window)}" (lower is better): ${fit.halfLife.curve.map(
|
|
844
|
-
(p) => `${formatDuration(p.halfLife)} ${num(p.brier,
|
|
884
|
+
(p) => `${formatDuration(p.halfLife)} ${num(p.brier, 4)}${p.halfLife === fit.halfLife.value && p.brier !== null ? " *" : ""}`
|
|
845
885
|
).join(", ")}`
|
|
846
886
|
);
|
|
887
|
+
if (fit.beatsBaseRate === false) {
|
|
888
|
+
const scored = fit.halfLife.curve.find((p) => p.halfLife === fit.halfLife.value);
|
|
889
|
+
const window = formatDuration(fit.halfLife.window);
|
|
890
|
+
const why = fit.signals.some((f) => f.weight !== null) ? `Its signals do not separate the events that ${fit.outcome} follows within ${window} from the rest.` : `No signal is left in the config, so p never leaves the prior, and the prior (the share of episodes that end with ${fit.outcome}) is not the share of events that ${fit.outcome} follows within ${window}.`;
|
|
891
|
+
out.push("");
|
|
892
|
+
out.push(
|
|
893
|
+
`This intent does not beat its base rate: the fitted config scores ${num(scored?.brier ?? null, 4)} where always predicting the base rate scores ${num(fit.halfLife.brierBaseline, 4)} (lower is better). ${why} Drop the intent, or find signals that tell its episodes apart (--all-events measures every other event in the log).`
|
|
894
|
+
);
|
|
895
|
+
}
|
|
847
896
|
if (fit.candidates.length > 0) {
|
|
848
897
|
out.push("");
|
|
849
898
|
out.push(
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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 =
|
|
604
|
-
is = recordEpisode(is, record2.seen,
|
|
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
|
-
{
|
|
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
|
);
|