oracular 0.1.1 → 0.2.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 +12 -0
- package/README.md +17 -11
- package/dist/index.cjs +266 -161
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +103 -5
- package/dist/index.d.ts +103 -5
- package/dist/index.js +264 -162
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#8](https://github.com/Yuribenjamin/oracular/pull/8) [`72c5f3c`](https://github.com/Yuribenjamin/oracular/commit/72c5f3c296c80ed99f8def26cdb120cdbfc239b3) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - Learning compares a signal's outcome rate against sessions without the signal, and only trusts the comparison once both have been seen. Weights no longer drift after a single outcome.
|
|
8
|
+
|
|
9
|
+
## 0.1.2
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- [#6](https://github.com/Yuribenjamin/oracular/pull/6) [`57d121f`](https://github.com/Yuribenjamin/oracular/commit/57d121f107f4ed92f2e59bf354cc6e19f705de1b) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - Docs corrections from the first production integration; new evaluate(), evidenceToReveal(), episodeClosed() and explain() filtering; default export condition.
|
|
14
|
+
|
|
3
15
|
## 0.1.1
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -97,14 +97,17 @@ Events happen inside **episodes** (sessions). An episode ends when you call `end
|
|
|
97
97
|
A signal's weight is then a blend of your prior and the learned estimate:
|
|
98
98
|
|
|
99
99
|
```
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
100
|
+
nWith = hits + misses episodes with the signal
|
|
101
|
+
nWithout = episodes − nWith episodes without it
|
|
102
|
+
qWith = (hits + 1) / (nWith + 2) outcome rate with the signal
|
|
103
|
+
qWithout = (outcomes − hits + 1) / (nWithout + 2) outcome rate without it
|
|
104
|
+
lift = (qWith − qWithout) / (1 − qWithout) if qWith ≥ qWithout, else (qWith − qWithout) / qWithout → in [−1, 1]
|
|
105
|
+
learned = gain × |w₀| × lift 0 until the signal has history
|
|
106
|
+
nEff = (nWith × nWithout) / (nWith + nWithout) 0 if either side is 0
|
|
107
|
+
effective = (k × w₀ + nEff × learned) / (k + nEff) k = priorEpisodes
|
|
105
108
|
```
|
|
106
109
|
|
|
107
|
-
`w₀` is your initial weight, `gain` (default 2) caps how far a perfectly predictive signal can grow, and `priorEpisodes` (default 10) is how
|
|
110
|
+
`w₀` is your initial weight, `gain` (default 2) caps how far a perfectly predictive signal can grow, and `priorEpisodes` (default 10) is how much contrast it takes for the user's own history to count as much as your guess. A signal's strength and your confidence in it are separate knobs. A signal is judged against the sessions in which it did *not* appear, so for an outcome that happens in 3% of sessions, a signal followed by it 30% of the time is strong evidence, not weak. And learning needs contrast: a signal that has never been absent has no counterfactual, so its weight stands until there are sessions without it. Nothing drifts after a single outcome.
|
|
108
111
|
|
|
109
112
|
Learned counts never decay unless you set `learning.halfLife`.
|
|
110
113
|
|
|
@@ -223,7 +226,10 @@ endEpisode(state, at: number): EngineState
|
|
|
223
226
|
predict(state, intent: string, at: number, opts?: RevealOptions): Prediction
|
|
224
227
|
shouldReveal(state, intent: string, at: number, opts?: RevealOptions): boolean
|
|
225
228
|
markRevealed(state, intent: string, at: number): EngineState
|
|
226
|
-
explain(state, intent: string, at: number): SignalExplanation[]
|
|
229
|
+
explain(state, intent: string, at: number, opts?: { nonZero?: boolean; limit?: number }): SignalExplanation[]
|
|
230
|
+
evaluate(state, intent: string, at: number, opts?): { prediction, reveal, explanation } // all three, one decay pass
|
|
231
|
+
evidenceToReveal(intentConfig, opts?): number // pseudo-counts at which shouldReveal first becomes true
|
|
232
|
+
episodeClosed(prev, next): boolean // did an episode close between two states?
|
|
227
233
|
serialize(state): string
|
|
228
234
|
deserialize(json: string, opts?: { config?: EngineConfig; migrate?: Migrate }): EngineState
|
|
229
235
|
STATE_VERSION // 1
|
|
@@ -327,13 +333,13 @@ After ten sessions in which `pricing.viewed` led to an upgrade six times out of
|
|
|
327
333
|
```ts
|
|
328
334
|
explain(state, 'upgrade', Date.now());
|
|
329
335
|
// [
|
|
330
|
-
// { signal: 'pricing.viewed', contribution:
|
|
331
|
-
// { signal: 'limit.hit', contribution: 2, impact: 0.10, initialWeight: 2, learnedWeight: 0,
|
|
332
|
-
// { signal: 'export.clicked', contribution: 0, impact: 0, initialWeight: 1, learnedWeight: 0, effectiveWeight:
|
|
336
|
+
// { signal: 'pricing.viewed', contribution: 3.12, impact: 0.16, initialWeight: 3, learnedWeight: 3.78, effectiveWeight: 3.13, hits: 6, misses: 1 },
|
|
337
|
+
// { signal: 'limit.hit', contribution: 2, impact: 0.10, initialWeight: 2, learnedWeight: 0, effectiveWeight: 2, hits: 0, misses: 0 },
|
|
338
|
+
// { signal: 'export.clicked', contribution: 0, impact: 0, initialWeight: 1, learnedWeight: 0.67, effectiveWeight: 1, hits: 7, misses: 3 },
|
|
333
339
|
// ]
|
|
334
340
|
```
|
|
335
341
|
|
|
336
|
-
Rows are sorted by the size of their impact. `limit.hit` has no history yet, so its effective weight is exactly the initial one. `
|
|
342
|
+
Rows are sorted by the size of their impact. `limit.hit` has no history yet, so its effective weight is exactly the initial one. `pricing.viewed` was followed by an upgrade in six of its seven sessions while the three sessions without it saw one upgrade, so it learned a weight above the initial 3. `export.clicked` appeared in every session: it has no counterfactual, so whatever its hit rate, its weight stands at exactly the initial 1 until a session happens without it. See [Episodes & learning](https://yuribenjamin.github.io/oracular/guide/concepts/episodes-and-learning) for why learning needs contrast.
|
|
337
343
|
|
|
338
344
|
## Design rules
|
|
339
345
|
|
package/dist/index.cjs
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
// src/math.ts
|
|
4
|
+
function decayFactor(elapsed, halfLife) {
|
|
5
|
+
if (!(elapsed > 0) || !Number.isFinite(elapsed)) return 1;
|
|
6
|
+
return 2 ** (-elapsed / halfLife);
|
|
7
|
+
}
|
|
8
|
+
function betaMean(alpha, beta) {
|
|
9
|
+
return alpha / (alpha + beta);
|
|
10
|
+
}
|
|
11
|
+
function betaVariance(alpha, beta) {
|
|
12
|
+
const n = alpha + beta;
|
|
13
|
+
return alpha * beta / (n * n * (n + 1));
|
|
14
|
+
}
|
|
15
|
+
function betaSd(alpha, beta) {
|
|
16
|
+
return Math.sqrt(betaVariance(alpha, beta));
|
|
17
|
+
}
|
|
18
|
+
function clamp01(x) {
|
|
19
|
+
if (x < 0) return 0;
|
|
20
|
+
if (x > 1) return 1;
|
|
21
|
+
return x;
|
|
22
|
+
}
|
|
23
|
+
function laplace(successes, total) {
|
|
24
|
+
return (successes + 1) / (total + 2);
|
|
25
|
+
}
|
|
26
|
+
function lift(q, b) {
|
|
27
|
+
if (q >= b) return b >= 1 ? 0 : (q - b) / (1 - b);
|
|
28
|
+
return b <= 0 ? 0 : (q - b) / b;
|
|
29
|
+
}
|
|
30
|
+
|
|
3
31
|
// src/util.ts
|
|
4
32
|
function isRecord(value) {
|
|
5
33
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -18,6 +46,100 @@ function mapRecord(record2, fn) {
|
|
|
18
46
|
return out;
|
|
19
47
|
}
|
|
20
48
|
|
|
49
|
+
// src/intent.ts
|
|
50
|
+
function priorCounts(intent) {
|
|
51
|
+
return {
|
|
52
|
+
alpha0: intent.prior * intent.priorStrength,
|
|
53
|
+
beta0: (1 - intent.prior) * intent.priorStrength
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function initIntent(intent) {
|
|
57
|
+
const { alpha0, beta0 } = priorCounts(intent);
|
|
58
|
+
return {
|
|
59
|
+
alpha: alpha0,
|
|
60
|
+
beta: beta0,
|
|
61
|
+
updatedAt: null,
|
|
62
|
+
contributions: {},
|
|
63
|
+
learned: {},
|
|
64
|
+
episodes: { hits: 0, misses: 0 },
|
|
65
|
+
learnedAt: null,
|
|
66
|
+
lastRevealedAt: null,
|
|
67
|
+
lastOutcomeAt: null,
|
|
68
|
+
revealCount: 0
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function withContributions(state, intent, contributions) {
|
|
72
|
+
const { alpha0, beta0 } = priorCounts(intent);
|
|
73
|
+
let alpha = alpha0;
|
|
74
|
+
let beta = beta0;
|
|
75
|
+
for (const c of Object.values(contributions)) {
|
|
76
|
+
alpha += c.alpha;
|
|
77
|
+
beta += c.beta;
|
|
78
|
+
}
|
|
79
|
+
return { ...state, alpha, beta, contributions };
|
|
80
|
+
}
|
|
81
|
+
function decayIntent(state, intent, at) {
|
|
82
|
+
if (state.updatedAt === null) return { ...state, updatedAt: at };
|
|
83
|
+
const d = decayFactor(at - state.updatedAt, intent.halfLife);
|
|
84
|
+
const updatedAt = Math.max(state.updatedAt, at);
|
|
85
|
+
if (d === 1) return updatedAt === state.updatedAt ? state : { ...state, updatedAt };
|
|
86
|
+
const contributions = mapRecord(state.contributions, (c) => ({
|
|
87
|
+
alpha: c.alpha * d,
|
|
88
|
+
beta: c.beta * d
|
|
89
|
+
}));
|
|
90
|
+
return withContributions({ ...state, updatedAt }, intent, contributions);
|
|
91
|
+
}
|
|
92
|
+
function applySignal(state, intent, signal, weight) {
|
|
93
|
+
const prev = getOwn(state.contributions, signal) ?? { alpha: 0, beta: 0 };
|
|
94
|
+
const next = weight >= 0 ? { alpha: prev.alpha + weight, beta: prev.beta } : { alpha: prev.alpha, beta: prev.beta - weight };
|
|
95
|
+
return withContributions(state, intent, { ...state.contributions, [signal]: next });
|
|
96
|
+
}
|
|
97
|
+
function resetToPrior(state, intent) {
|
|
98
|
+
return withContributions(state, intent, {});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/learning.ts
|
|
102
|
+
var NONE = { hits: 0, misses: 0 };
|
|
103
|
+
function decayLearned(state, learning, at) {
|
|
104
|
+
if (state.learnedAt === null) return { ...state, learnedAt: at };
|
|
105
|
+
const learnedAt = Math.max(state.learnedAt, at);
|
|
106
|
+
const d = learning.halfLife === null ? 1 : decayFactor(at - state.learnedAt, learning.halfLife);
|
|
107
|
+
if (d === 1) return learnedAt === state.learnedAt ? state : { ...state, learnedAt };
|
|
108
|
+
return {
|
|
109
|
+
...state,
|
|
110
|
+
learnedAt,
|
|
111
|
+
learned: mapRecord(state.learned, (r) => ({ hits: r.hits * d, misses: r.misses * d })),
|
|
112
|
+
episodes: { hits: state.episodes.hits * d, misses: state.episodes.misses * d }
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function signalWeights(state, intent, learning, signal) {
|
|
116
|
+
const initial = getOwn(intent.signals, signal);
|
|
117
|
+
if (initial === void 0) {
|
|
118
|
+
throw new RangeError(`oracular: ${JSON.stringify(signal)} is not a signal of this intent`);
|
|
119
|
+
}
|
|
120
|
+
const record2 = getOwn(state.learned, signal) ?? NONE;
|
|
121
|
+
const nWith = record2.hits + record2.misses;
|
|
122
|
+
const qWith = laplace(record2.hits, nWith);
|
|
123
|
+
const totalEpisodes = state.episodes.hits + state.episodes.misses;
|
|
124
|
+
const nWithout = Math.max(0, totalEpisodes - nWith);
|
|
125
|
+
const outcomesWithout = Math.min(nWithout, Math.max(0, state.episodes.hits - record2.hits));
|
|
126
|
+
const qWithout = laplace(outcomesWithout, nWithout);
|
|
127
|
+
const learned = nWith === 0 ? 0 : learning.gain * Math.abs(initial) * lift(qWith, qWithout);
|
|
128
|
+
const nEff = nWith + nWithout === 0 ? 0 : nWith * nWithout / (nWith + nWithout);
|
|
129
|
+
const k = learning.priorEpisodes;
|
|
130
|
+
const effective = (k * initial + nEff * learned) / (k + nEff);
|
|
131
|
+
return { initial, learned, effective, hits: record2.hits, misses: record2.misses };
|
|
132
|
+
}
|
|
133
|
+
function recordEpisode(state, signals, outcome) {
|
|
134
|
+
const learned = { ...state.learned };
|
|
135
|
+
for (const signal of signals) {
|
|
136
|
+
const r = getOwn(learned, signal) ?? NONE;
|
|
137
|
+
learned[signal] = outcome ? { hits: r.hits + 1, misses: r.misses } : { hits: r.hits, misses: r.misses + 1 };
|
|
138
|
+
}
|
|
139
|
+
const episodes = outcome ? { hits: state.episodes.hits + 1, misses: state.episodes.misses } : { hits: state.episodes.hits, misses: state.episodes.misses + 1 };
|
|
140
|
+
return { ...state, learned, episodes };
|
|
141
|
+
}
|
|
142
|
+
|
|
21
143
|
// src/config.ts
|
|
22
144
|
var DEFAULT_THRESHOLD = 0.5;
|
|
23
145
|
var DEFAULT_Z = 1.28;
|
|
@@ -35,6 +157,7 @@ var INTENT_KEYS = [
|
|
|
35
157
|
];
|
|
36
158
|
var REVEAL_KEYS = ["threshold", "z", "cooldown"];
|
|
37
159
|
var LEARNING_KEYS = ["halfLife", "gain", "priorEpisodes"];
|
|
160
|
+
var EXPLAIN_KEYS = ["nonZero", "limit"];
|
|
38
161
|
function typeError(path, message) {
|
|
39
162
|
throw new TypeError(`oracular: ${path} ${message}`);
|
|
40
163
|
}
|
|
@@ -115,7 +238,7 @@ function normalizeLearning(value, path) {
|
|
|
115
238
|
}
|
|
116
239
|
return out;
|
|
117
240
|
}
|
|
118
|
-
function
|
|
241
|
+
function normalizeIntentConfig(value, path) {
|
|
119
242
|
const record2 = expectRecord(value, path);
|
|
120
243
|
expectKeys(record2, INTENT_KEYS, path);
|
|
121
244
|
const signalsRecord = expectRecord(record2.signals, `${path}.signals`);
|
|
@@ -157,7 +280,10 @@ function normalizeConfig(value) {
|
|
|
157
280
|
const intents = {};
|
|
158
281
|
for (const name of names) {
|
|
159
282
|
expectKeyName(name, "config.intents");
|
|
160
|
-
intents[name] =
|
|
283
|
+
intents[name] = normalizeIntentConfig(
|
|
284
|
+
intentsRecord[name],
|
|
285
|
+
`config.intents[${JSON.stringify(name)}]`
|
|
286
|
+
);
|
|
161
287
|
}
|
|
162
288
|
const out = { intents };
|
|
163
289
|
if (record2.episodeTimeout !== void 0) {
|
|
@@ -169,12 +295,43 @@ function normalizeConfig(value) {
|
|
|
169
295
|
out.reveal = normalizeRevealOptions(record2.reveal, "config.reveal");
|
|
170
296
|
return out;
|
|
171
297
|
}
|
|
172
|
-
function
|
|
173
|
-
const
|
|
298
|
+
function normalizeExplainOptions(value, path) {
|
|
299
|
+
const record2 = expectRecord(value, path);
|
|
300
|
+
expectKeys(record2, EXPLAIN_KEYS, path);
|
|
301
|
+
const out = {};
|
|
302
|
+
if (record2.nonZero !== void 0) {
|
|
303
|
+
if (typeof record2.nonZero !== "boolean") typeError(`${path}.nonZero`, "must be a boolean");
|
|
304
|
+
out.nonZero = record2.nonZero;
|
|
305
|
+
}
|
|
306
|
+
if (record2.limit !== void 0) {
|
|
307
|
+
const n = expectNumber(record2.limit, `${path}.limit`);
|
|
308
|
+
if (!Number.isInteger(n) || n < 0)
|
|
309
|
+
rangeError(`${path}.limit`, "must be a non-negative integer");
|
|
310
|
+
out.limit = n;
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
function splitEvaluateOptions(value, path) {
|
|
315
|
+
if (value === void 0) return { reveal: {}, explain: {} };
|
|
316
|
+
const record2 = expectRecord(value, path);
|
|
317
|
+
expectKeys(record2, [...REVEAL_KEYS, ...EXPLAIN_KEYS], path);
|
|
318
|
+
const reveal = {};
|
|
319
|
+
const explain2 = {};
|
|
320
|
+
for (const [key, v] of Object.entries(record2)) {
|
|
321
|
+
if (REVEAL_KEYS.includes(key)) reveal[key] = v;
|
|
322
|
+
else explain2[key] = v;
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
reveal: normalizeRevealOptions(reveal, path),
|
|
326
|
+
explain: normalizeExplainOptions(explain2, path)
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
function resolveReveal(config, intent, opts, normalized = false) {
|
|
330
|
+
const o = opts === void 0 ? {} : normalized ? opts : normalizeRevealOptions(opts, "options");
|
|
174
331
|
return {
|
|
175
|
-
threshold: o.threshold ?? intent.reveal?.threshold ?? config
|
|
176
|
-
z: o.z ?? intent.reveal?.z ?? config
|
|
177
|
-
cooldown: o.cooldown ?? intent.reveal?.cooldown ?? config
|
|
332
|
+
threshold: o.threshold ?? intent.reveal?.threshold ?? config?.reveal?.threshold ?? DEFAULT_THRESHOLD,
|
|
333
|
+
z: o.z ?? intent.reveal?.z ?? config?.reveal?.z ?? DEFAULT_Z,
|
|
334
|
+
cooldown: o.cooldown ?? intent.reveal?.cooldown ?? config?.reveal?.cooldown ?? intent.halfLife
|
|
178
335
|
};
|
|
179
336
|
}
|
|
180
337
|
function resolveLearning(config) {
|
|
@@ -197,124 +354,6 @@ function validateEvent(event) {
|
|
|
197
354
|
return { name, at };
|
|
198
355
|
}
|
|
199
356
|
|
|
200
|
-
// src/math.ts
|
|
201
|
-
function decayFactor(elapsed, halfLife) {
|
|
202
|
-
if (!(elapsed > 0) || !Number.isFinite(elapsed)) return 1;
|
|
203
|
-
return 2 ** (-elapsed / halfLife);
|
|
204
|
-
}
|
|
205
|
-
function betaMean(alpha, beta) {
|
|
206
|
-
return alpha / (alpha + beta);
|
|
207
|
-
}
|
|
208
|
-
function betaVariance(alpha, beta) {
|
|
209
|
-
const n = alpha + beta;
|
|
210
|
-
return alpha * beta / (n * n * (n + 1));
|
|
211
|
-
}
|
|
212
|
-
function betaSd(alpha, beta) {
|
|
213
|
-
return Math.sqrt(betaVariance(alpha, beta));
|
|
214
|
-
}
|
|
215
|
-
function clamp01(x) {
|
|
216
|
-
if (x < 0) return 0;
|
|
217
|
-
if (x > 1) return 1;
|
|
218
|
-
return x;
|
|
219
|
-
}
|
|
220
|
-
function laplace(successes, total) {
|
|
221
|
-
return (successes + 1) / (total + 2);
|
|
222
|
-
}
|
|
223
|
-
function lift(q, b) {
|
|
224
|
-
if (q >= b) return b >= 1 ? 0 : (q - b) / (1 - b);
|
|
225
|
-
return b <= 0 ? 0 : (q - b) / b;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// src/intent.ts
|
|
229
|
-
function priorCounts(intent) {
|
|
230
|
-
return {
|
|
231
|
-
alpha0: intent.prior * intent.priorStrength,
|
|
232
|
-
beta0: (1 - intent.prior) * intent.priorStrength
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
function initIntent(intent) {
|
|
236
|
-
const { alpha0, beta0 } = priorCounts(intent);
|
|
237
|
-
return {
|
|
238
|
-
alpha: alpha0,
|
|
239
|
-
beta: beta0,
|
|
240
|
-
updatedAt: null,
|
|
241
|
-
contributions: {},
|
|
242
|
-
learned: {},
|
|
243
|
-
episodes: { hits: 0, misses: 0 },
|
|
244
|
-
learnedAt: null,
|
|
245
|
-
lastRevealedAt: null,
|
|
246
|
-
lastOutcomeAt: null,
|
|
247
|
-
revealCount: 0
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
function withContributions(state, intent, contributions) {
|
|
251
|
-
const { alpha0, beta0 } = priorCounts(intent);
|
|
252
|
-
let alpha = alpha0;
|
|
253
|
-
let beta = beta0;
|
|
254
|
-
for (const c of Object.values(contributions)) {
|
|
255
|
-
alpha += c.alpha;
|
|
256
|
-
beta += c.beta;
|
|
257
|
-
}
|
|
258
|
-
return { ...state, alpha, beta, contributions };
|
|
259
|
-
}
|
|
260
|
-
function decayIntent(state, intent, at) {
|
|
261
|
-
if (state.updatedAt === null) return { ...state, updatedAt: at };
|
|
262
|
-
const d = decayFactor(at - state.updatedAt, intent.halfLife);
|
|
263
|
-
const updatedAt = Math.max(state.updatedAt, at);
|
|
264
|
-
if (d === 1) return updatedAt === state.updatedAt ? state : { ...state, updatedAt };
|
|
265
|
-
const contributions = mapRecord(state.contributions, (c) => ({
|
|
266
|
-
alpha: c.alpha * d,
|
|
267
|
-
beta: c.beta * d
|
|
268
|
-
}));
|
|
269
|
-
return withContributions({ ...state, updatedAt }, intent, contributions);
|
|
270
|
-
}
|
|
271
|
-
function applySignal(state, intent, signal, weight) {
|
|
272
|
-
const prev = getOwn(state.contributions, signal) ?? { alpha: 0, beta: 0 };
|
|
273
|
-
const next = weight >= 0 ? { alpha: prev.alpha + weight, beta: prev.beta } : { alpha: prev.alpha, beta: prev.beta - weight };
|
|
274
|
-
return withContributions(state, intent, { ...state.contributions, [signal]: next });
|
|
275
|
-
}
|
|
276
|
-
function resetToPrior(state, intent) {
|
|
277
|
-
return withContributions(state, intent, {});
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// src/learning.ts
|
|
281
|
-
var NONE = { hits: 0, misses: 0 };
|
|
282
|
-
function decayLearned(state, learning, at) {
|
|
283
|
-
if (state.learnedAt === null) return { ...state, learnedAt: at };
|
|
284
|
-
const learnedAt = Math.max(state.learnedAt, at);
|
|
285
|
-
const d = learning.halfLife === null ? 1 : decayFactor(at - state.learnedAt, learning.halfLife);
|
|
286
|
-
if (d === 1) return learnedAt === state.learnedAt ? state : { ...state, learnedAt };
|
|
287
|
-
return {
|
|
288
|
-
...state,
|
|
289
|
-
learnedAt,
|
|
290
|
-
learned: mapRecord(state.learned, (r) => ({ hits: r.hits * d, misses: r.misses * d })),
|
|
291
|
-
episodes: { hits: state.episodes.hits * d, misses: state.episodes.misses * d }
|
|
292
|
-
};
|
|
293
|
-
}
|
|
294
|
-
function signalWeights(state, intent, learning, signal) {
|
|
295
|
-
const initial = getOwn(intent.signals, signal);
|
|
296
|
-
if (initial === void 0) {
|
|
297
|
-
throw new RangeError(`oracular: ${JSON.stringify(signal)} is not a signal of this intent`);
|
|
298
|
-
}
|
|
299
|
-
const record2 = getOwn(state.learned, signal) ?? NONE;
|
|
300
|
-
const n = record2.hits + record2.misses;
|
|
301
|
-
const q = laplace(record2.hits, n);
|
|
302
|
-
const b = laplace(state.episodes.hits, state.episodes.hits + state.episodes.misses);
|
|
303
|
-
const learned = n === 0 ? 0 : learning.gain * Math.abs(initial) * lift(q, b);
|
|
304
|
-
const k = learning.priorEpisodes;
|
|
305
|
-
const effective = (k * initial + n * learned) / (k + n);
|
|
306
|
-
return { initial, learned, effective, hits: record2.hits, misses: record2.misses };
|
|
307
|
-
}
|
|
308
|
-
function recordEpisode(state, signals, outcome) {
|
|
309
|
-
const learned = { ...state.learned };
|
|
310
|
-
for (const signal of signals) {
|
|
311
|
-
const r = getOwn(learned, signal) ?? NONE;
|
|
312
|
-
learned[signal] = outcome ? { hits: r.hits + 1, misses: r.misses } : { hits: r.hits, misses: r.misses + 1 };
|
|
313
|
-
}
|
|
314
|
-
const episodes = outcome ? { hits: state.episodes.hits + 1, misses: state.episodes.misses } : { hits: state.episodes.hits, misses: state.episodes.misses + 1 };
|
|
315
|
-
return { ...state, learned, episodes };
|
|
316
|
-
}
|
|
317
|
-
|
|
318
357
|
// src/episode.ts
|
|
319
358
|
function emptyEpisode() {
|
|
320
359
|
return { startedAt: null, lastEventAt: null, intents: {} };
|
|
@@ -374,20 +413,42 @@ function getIntentState(state, name, intent) {
|
|
|
374
413
|
return getOwn(state.intents, name) ?? initIntent(intent);
|
|
375
414
|
}
|
|
376
415
|
|
|
377
|
-
// src/
|
|
378
|
-
function
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
416
|
+
// src/assess.ts
|
|
417
|
+
function assessIntent(state, name, intent, learning, at) {
|
|
418
|
+
return decayLearned(decayIntent(getIntentState(state, name, intent), intent, at), learning, at);
|
|
419
|
+
}
|
|
420
|
+
function cooldownEnd(since, cooldown, at) {
|
|
421
|
+
if (since === null) return null;
|
|
422
|
+
const end = since + cooldown;
|
|
423
|
+
return at < end ? end : null;
|
|
424
|
+
}
|
|
425
|
+
function buildPrediction(is, name, reveal, at) {
|
|
426
|
+
const p = betaMean(is.alpha, is.beta);
|
|
427
|
+
const sd = betaSd(is.alpha, is.beta);
|
|
428
|
+
const revealEnd = cooldownEnd(is.lastRevealedAt, reveal.cooldown, at);
|
|
429
|
+
const outcomeEnd = cooldownEnd(is.lastOutcomeAt, reveal.cooldown, at);
|
|
430
|
+
const suppressedUntil = revealEnd === null ? outcomeEnd : outcomeEnd === null ? revealEnd : Math.max(revealEnd, outcomeEnd);
|
|
431
|
+
return {
|
|
432
|
+
intent: name,
|
|
433
|
+
p,
|
|
434
|
+
sd,
|
|
435
|
+
lower: clamp01(p - reveal.z * sd),
|
|
436
|
+
confidence: clamp01(1 - 2 * sd),
|
|
437
|
+
evidence: is.alpha + is.beta,
|
|
438
|
+
revealed: revealEnd !== null,
|
|
439
|
+
revealedAt: is.lastRevealedAt,
|
|
440
|
+
suppressedUntil
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
function decideReveal(prediction, reveal) {
|
|
444
|
+
return prediction.suppressedUntil === null && prediction.p >= reveal.threshold && prediction.lower >= reveal.threshold;
|
|
445
|
+
}
|
|
446
|
+
function buildExplanation(is, intent, learning, opts) {
|
|
386
447
|
const p = betaMean(is.alpha, is.beta);
|
|
387
448
|
const out = [];
|
|
388
|
-
for (const signal of Object.keys(
|
|
449
|
+
for (const signal of Object.keys(intent.signals)) {
|
|
389
450
|
const c = getOwn(is.contributions, signal) ?? { alpha: 0, beta: 0 };
|
|
390
|
-
const w = signalWeights(is,
|
|
451
|
+
const w = signalWeights(is, intent, learning, signal);
|
|
391
452
|
const without = betaMean(is.alpha - c.alpha, is.beta - c.beta);
|
|
392
453
|
out.push({
|
|
393
454
|
signal,
|
|
@@ -401,7 +462,8 @@ function explain(state, intent, at) {
|
|
|
401
462
|
});
|
|
402
463
|
}
|
|
403
464
|
out.sort((a, b) => Math.abs(b.impact) - Math.abs(a.impact) || compare(a.signal, b.signal));
|
|
404
|
-
|
|
465
|
+
const filtered = opts.nonZero ? out.filter((row) => row.contribution !== 0) : out;
|
|
466
|
+
return opts.limit === void 0 ? filtered : filtered.slice(0, opts.limit);
|
|
405
467
|
}
|
|
406
468
|
function compare(a, b) {
|
|
407
469
|
if (a < b) return -1;
|
|
@@ -409,6 +471,62 @@ function compare(a, b) {
|
|
|
409
471
|
return 0;
|
|
410
472
|
}
|
|
411
473
|
|
|
474
|
+
// src/evaluate.ts
|
|
475
|
+
function evaluate(state, intent, at, opts) {
|
|
476
|
+
const s = requireState(state);
|
|
477
|
+
const name = expectName(intent, "intent");
|
|
478
|
+
const t = expectTimestamp(at, "at");
|
|
479
|
+
const config = requireIntentConfig(s, name);
|
|
480
|
+
const split = splitEvaluateOptions(opts, "options");
|
|
481
|
+
const reveal = resolveReveal(s.config, config, split.reveal, true);
|
|
482
|
+
const learning = resolveLearning(s.config);
|
|
483
|
+
const is = assessIntent(s, name, config, learning, t);
|
|
484
|
+
const prediction = buildPrediction(is, name, reveal, t);
|
|
485
|
+
return {
|
|
486
|
+
prediction,
|
|
487
|
+
reveal: decideReveal(prediction, reveal),
|
|
488
|
+
explanation: buildExplanation(is, config, learning, split.explain)
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// src/evidence.ts
|
|
493
|
+
function evidenceToReveal(intent, opts) {
|
|
494
|
+
const config = normalizeIntentConfig(intent, "intent");
|
|
495
|
+
const { threshold, z } = resolveReveal(void 0, config, opts);
|
|
496
|
+
const { alpha0, beta0 } = priorCounts(config);
|
|
497
|
+
const open = (w) => {
|
|
498
|
+
const alpha = alpha0 + w;
|
|
499
|
+
const p = betaMean(alpha, beta0);
|
|
500
|
+
return p >= threshold && clamp01(p - z * betaSd(alpha, beta0)) >= threshold;
|
|
501
|
+
};
|
|
502
|
+
if (threshold >= 1) return Number.POSITIVE_INFINITY;
|
|
503
|
+
if (open(0)) return 0;
|
|
504
|
+
let lo = 0;
|
|
505
|
+
let hi = 1;
|
|
506
|
+
while (!open(hi)) {
|
|
507
|
+
lo = hi;
|
|
508
|
+
hi *= 2;
|
|
509
|
+
if (hi > 1e15) return Number.POSITIVE_INFINITY;
|
|
510
|
+
}
|
|
511
|
+
for (let i = 0; i < 200 && hi - lo > 1e-9; i++) {
|
|
512
|
+
const mid = (lo + hi) / 2;
|
|
513
|
+
if (open(mid)) hi = mid;
|
|
514
|
+
else lo = mid;
|
|
515
|
+
}
|
|
516
|
+
return hi;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// src/explain.ts
|
|
520
|
+
function explain(state, intent, at, opts) {
|
|
521
|
+
const s = requireState(state);
|
|
522
|
+
const name = expectName(intent, "intent");
|
|
523
|
+
const t = expectTimestamp(at, "at");
|
|
524
|
+
const config = requireIntentConfig(s, name);
|
|
525
|
+
const filters = opts === void 0 ? {} : normalizeExplainOptions(opts, "options");
|
|
526
|
+
const learning = resolveLearning(s.config);
|
|
527
|
+
return buildExplanation(assessIntent(s, name, config, learning, t), config, learning, filters);
|
|
528
|
+
}
|
|
529
|
+
|
|
412
530
|
// src/observe.ts
|
|
413
531
|
function observe(state, event) {
|
|
414
532
|
let s = requireState(state);
|
|
@@ -460,6 +578,11 @@ function closeEpisode(state, at) {
|
|
|
460
578
|
}
|
|
461
579
|
return { ...state, intents, episode: emptyEpisode() };
|
|
462
580
|
}
|
|
581
|
+
function episodeClosed(prev, next) {
|
|
582
|
+
const a = requireState(prev);
|
|
583
|
+
const b = requireState(next);
|
|
584
|
+
return a.episode.startedAt !== null && b.episode.startedAt !== a.episode.startedAt;
|
|
585
|
+
}
|
|
463
586
|
|
|
464
587
|
// src/predict.ts
|
|
465
588
|
function predict(state, intent, at, opts) {
|
|
@@ -469,35 +592,14 @@ function predict(state, intent, at, opts) {
|
|
|
469
592
|
const config = requireIntentConfig(s, name);
|
|
470
593
|
const reveal = resolveReveal(s.config, config, opts);
|
|
471
594
|
const is = decayIntent(getIntentState(s, name, config), config, t);
|
|
472
|
-
|
|
473
|
-
const sd = betaSd(is.alpha, is.beta);
|
|
474
|
-
const revealEnd = cooldownEnd(is.lastRevealedAt, reveal.cooldown, t);
|
|
475
|
-
const outcomeEnd = cooldownEnd(is.lastOutcomeAt, reveal.cooldown, t);
|
|
476
|
-
const suppressedUntil = revealEnd === null ? outcomeEnd : outcomeEnd === null ? revealEnd : Math.max(revealEnd, outcomeEnd);
|
|
477
|
-
return {
|
|
478
|
-
intent: name,
|
|
479
|
-
p,
|
|
480
|
-
sd,
|
|
481
|
-
lower: clamp01(p - reveal.z * sd),
|
|
482
|
-
confidence: clamp01(1 - 2 * sd),
|
|
483
|
-
evidence: is.alpha + is.beta,
|
|
484
|
-
revealed: revealEnd !== null,
|
|
485
|
-
revealedAt: is.lastRevealedAt,
|
|
486
|
-
suppressedUntil
|
|
487
|
-
};
|
|
488
|
-
}
|
|
489
|
-
function cooldownEnd(since, cooldown, at) {
|
|
490
|
-
if (since === null) return null;
|
|
491
|
-
const end = since + cooldown;
|
|
492
|
-
return at < end ? end : null;
|
|
595
|
+
return buildPrediction(is, name, reveal, t);
|
|
493
596
|
}
|
|
494
597
|
function shouldReveal(state, intent, at, opts) {
|
|
495
598
|
const s = requireState(state);
|
|
496
599
|
const name = expectName(intent, "intent");
|
|
497
600
|
const config = requireIntentConfig(s, name);
|
|
498
|
-
const
|
|
499
|
-
|
|
500
|
-
return prediction.suppressedUntil === null && prediction.p >= threshold && prediction.lower >= threshold;
|
|
601
|
+
const reveal = resolveReveal(s.config, config, opts);
|
|
602
|
+
return decideReveal(predict(s, name, at, opts), reveal);
|
|
501
603
|
}
|
|
502
604
|
function markRevealed(state, intent, at) {
|
|
503
605
|
const s = requireState(state);
|
|
@@ -688,6 +790,9 @@ exports.STATE_VERSION = STATE_VERSION;
|
|
|
688
790
|
exports.createState = createState;
|
|
689
791
|
exports.deserialize = deserialize;
|
|
690
792
|
exports.endEpisode = endEpisode;
|
|
793
|
+
exports.episodeClosed = episodeClosed;
|
|
794
|
+
exports.evaluate = evaluate;
|
|
795
|
+
exports.evidenceToReveal = evidenceToReveal;
|
|
691
796
|
exports.explain = explain;
|
|
692
797
|
exports.markRevealed = markRevealed;
|
|
693
798
|
exports.observe = observe;
|