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/dist/index.d.cts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Version of the serialized state shape, stored in every state as `state.version`. Bumped only
|
|
3
3
|
* when `EngineState` changes incompatibly, and every bump ships its migration: {@link deserialize}
|
|
4
4
|
* upgrades states saved by earlier versions of this library on its own (version 1, written by
|
|
5
|
-
* oracular 0.1.0 to 0.2.x,
|
|
6
|
-
* it does not know.
|
|
5
|
+
* oracular 0.1.0 to 0.2.x, and version 2, written by 0.3.0 to 0.5.x, become version 3) and calls
|
|
6
|
+
* your `migrate` function only for a version it does not know.
|
|
7
7
|
*
|
|
8
8
|
* @example
|
|
9
9
|
* ```ts
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* });
|
|
20
20
|
* ```
|
|
21
21
|
*/
|
|
22
|
-
declare const STATE_VERSION:
|
|
22
|
+
declare const STATE_VERSION: 3;
|
|
23
23
|
/**
|
|
24
24
|
* Version of the recorder format: the `v` field of every record that {@link createRecord} and
|
|
25
25
|
* {@link createEndRecord} produce. A new optional field does not change it; readers ignore fields
|
|
@@ -112,6 +112,17 @@ interface IntentConfig {
|
|
|
112
112
|
* weight.
|
|
113
113
|
*/
|
|
114
114
|
saturate?: boolean;
|
|
115
|
+
/**
|
|
116
|
+
* How long a closed episode's signals wait for the outcome before they take a miss, in the same
|
|
117
|
+
* unit as `at`. With it set, an episode that ends without the outcome is held instead of
|
|
118
|
+
* counted. When the outcome arrives inside the window, each held episode gets a share
|
|
119
|
+
* `2^(−age / halfLife)` of a hit and the rest of a miss, with `age` measured from the episode's
|
|
120
|
+
* close: the sessions that built up to the outcome are credited by how much of their evidence
|
|
121
|
+
* still stands. When the window passes, the episode is a miss, recorded at the moment the window
|
|
122
|
+
* ended. Unset: an episode that ends without the outcome is a miss at once. A natural first value
|
|
123
|
+
* is the intent's `halfLife`, where the smallest share inside the window is one half.
|
|
124
|
+
*/
|
|
125
|
+
attribution?: number;
|
|
115
126
|
/** Reveal defaults for this intent. */
|
|
116
127
|
reveal?: RevealOptions;
|
|
117
128
|
}
|
|
@@ -143,11 +154,18 @@ interface SignalContribution {
|
|
|
143
154
|
}
|
|
144
155
|
/** What this user's history says about one signal. Decays with `learning.halfLife`. */
|
|
145
156
|
interface SignalRecord {
|
|
146
|
-
/** Episodes in which the signal was followed by the outcome. */
|
|
157
|
+
/** Episodes in which the signal was followed by the outcome. An episode credited by a later outcome under `attribution` adds its share here and the rest to `misses`. */
|
|
147
158
|
hits: number;
|
|
148
159
|
/** Episodes in which it was not. */
|
|
149
160
|
misses: number;
|
|
150
161
|
}
|
|
162
|
+
/** A closed episode waiting for the intent's outcome inside its `attribution` window. */
|
|
163
|
+
interface PendingEpisode {
|
|
164
|
+
/** When the episode closed. Its wait ends `attribution` later. */
|
|
165
|
+
endedAt: number;
|
|
166
|
+
/** The signals seen in it, each once. */
|
|
167
|
+
seen: string[];
|
|
168
|
+
}
|
|
151
169
|
/**
|
|
152
170
|
* Everything the engine knows about one intent for this user. Plain data; read it for debugging
|
|
153
171
|
* or dashboards, but change it only through the engine's functions.
|
|
@@ -163,6 +181,11 @@ interface IntentState {
|
|
|
163
181
|
learned: Record<string, SignalRecord>;
|
|
164
182
|
/** Episodes that ended with (`hits`) or without (`misses`) the outcome: the base rate. */
|
|
165
183
|
episodes: SignalRecord;
|
|
184
|
+
/**
|
|
185
|
+
* Closed episodes whose verdict waits for the outcome (see {@link IntentConfig.attribution}),
|
|
186
|
+
* in order of close, at most 32. Empty unless `attribution` is set.
|
|
187
|
+
*/
|
|
188
|
+
pending: PendingEpisode[];
|
|
166
189
|
/** Time `learned` and `episodes` were last decayed to. */
|
|
167
190
|
learnedAt: number | null;
|
|
168
191
|
lastRevealedAt: number | null;
|
|
@@ -314,7 +337,7 @@ interface Evaluation {
|
|
|
314
337
|
/**
|
|
315
338
|
* Turns a stored state of a version this library cannot migrate itself into a current
|
|
316
339
|
* `EngineState`. Receives the parsed JSON as-is and the version it declares. States saved by
|
|
317
|
-
* oracular 0.1.0 to 0.
|
|
340
|
+
* oracular 0.1.0 to 0.5.x (versions 1 and 2) never reach it: `deserialize` upgrades those on its own.
|
|
318
341
|
*
|
|
319
342
|
* @example
|
|
320
343
|
* ```ts
|
|
@@ -338,7 +361,7 @@ interface DeserializeOptions {
|
|
|
338
361
|
config?: EngineConfig;
|
|
339
362
|
/**
|
|
340
363
|
* Called when the stored version is neither `STATE_VERSION` nor one this library migrates
|
|
341
|
-
* itself (states from oracular 0.1.0 to 0.
|
|
364
|
+
* itself (states from oracular 0.1.0 to 0.5.x are upgraded without it).
|
|
342
365
|
*/
|
|
343
366
|
migrate?: Migrate;
|
|
344
367
|
}
|
|
@@ -517,10 +540,11 @@ declare function explain(state: EngineState, intent: string, at: number, opts?:
|
|
|
517
540
|
* `w × log2(1 + 1/n)`, counted since the episode started or the intent's outcome last arrived.
|
|
518
541
|
* That is the default when `config.episodeTimeout` is set, and `IntentConfig.saturate` overrides
|
|
519
542
|
* it either way; otherwise every occurrence adds all of `w`. For every intent the event is the
|
|
520
|
-
* outcome of: give the remembered signals a hit, count the episode toward the base rate,
|
|
521
|
-
*
|
|
522
|
-
*
|
|
523
|
-
* episode
|
|
543
|
+
* outcome of: give the remembered signals a hit, count the episode toward the base rate, credit
|
|
544
|
+
* the closed episodes still waiting under `IntentConfig.attribution` by `2^(−age / halfLife)`
|
|
545
|
+
* each, record `lastOutcomeAt`, and (by default) reset the evidence to the prior. Any event opens
|
|
546
|
+
* or extends the current episode; an idle gap longer than `config.episodeTimeout` closes the
|
|
547
|
+
* previous episode first.
|
|
524
548
|
*
|
|
525
549
|
* @param state - The current state. Not mutated.
|
|
526
550
|
* @param event - `name` is matched against every intent's signals and outcome; `at` is the
|
|
@@ -540,7 +564,8 @@ declare function explain(state: EngineState, intent: string, at: number, opts?:
|
|
|
540
564
|
declare function observe(state: EngineState, event: IntentEvent): EngineState;
|
|
541
565
|
/**
|
|
542
566
|
* Ends the current episode at `at`. Every intent whose outcome did not arrive gives a miss to
|
|
543
|
-
* each signal seen this episode and counts the episode toward its base rate
|
|
567
|
+
* each signal seen this episode and counts the episode toward its base rate, or, with
|
|
568
|
+
* `IntentConfig.attribution` set, holds the episode for the outcome instead. Call it when a
|
|
544
569
|
* session ends: tab closed, app backgrounded, user signed out.
|
|
545
570
|
*
|
|
546
571
|
* @param state - The current state. Not mutated.
|
|
@@ -562,7 +587,8 @@ declare function endEpisode(state: EngineState, at: number): EngineState;
|
|
|
562
587
|
* Whether an episode closed between two states: `prev` had an open episode and `next` either
|
|
563
588
|
* has none or has a different one. Useful after `observe` or `endEpisode` to react to a
|
|
564
589
|
* session ending, for example to persist, or to read the misses it produced as
|
|
565
|
-
* `next.intents[x].episodes.misses - prev.intents[x].episodes.misses
|
|
590
|
+
* `next.intents[x].episodes.misses - prev.intents[x].episodes.misses` (0 for an intent with
|
|
591
|
+
* `attribution` set, whose verdict waits).
|
|
566
592
|
*
|
|
567
593
|
* @param prev - The state before the operation.
|
|
568
594
|
* @param next - The state after it.
|
|
@@ -711,7 +737,7 @@ declare function serialize(state: EngineState): string;
|
|
|
711
737
|
* Always pass the app's current `config`: it is validated and replaces the stored snapshot,
|
|
712
738
|
* intents and signals no longer declared are dropped (α and β are recomputed from the kept
|
|
713
739
|
* contributions), and newly declared intents start at their prior. States saved by oracular
|
|
714
|
-
* 0.1.0 to 0.
|
|
740
|
+
* 0.1.0 to 0.5.x are upgraded to the current shape on their own; `migrate` is only for a version
|
|
715
741
|
* this library does not know.
|
|
716
742
|
*
|
|
717
743
|
* @param json - A string produced by {@link serialize}.
|
|
@@ -823,4 +849,4 @@ declare const ms: Units;
|
|
|
823
849
|
*/
|
|
824
850
|
declare function validateConfig(config: unknown): EngineConfig;
|
|
825
851
|
|
|
826
|
-
export { type DeserializeOptions, type EndRecord, type EngineConfig, type EngineState, type EpisodeIntentRecord, type EpisodeState, type EvaluateOptions, type Evaluation, type EventRecord, type ExplainOptions, type IntentConfig, type IntentEvent, type IntentState, type LearningConfig, type Migrate, type Prediction, RECORD_VERSION, type RecordMode, type RecordOptions, type RecordedDecision, type RecorderRecord, type RevealOptions, STATE_VERSION, type SignalContribution, type SignalExplanation, type SignalRecord, type Units, createEndRecord, createRecord, createState, deserialize, endEpisode, episodeClosed, evaluate, evidenceToReveal, explain, markRevealed, ms, observe, predict, serialize, shouldReveal, validateConfig };
|
|
852
|
+
export { type DeserializeOptions, type EndRecord, type EngineConfig, type EngineState, type EpisodeIntentRecord, type EpisodeState, type EvaluateOptions, type Evaluation, type EventRecord, type ExplainOptions, type IntentConfig, type IntentEvent, type IntentState, type LearningConfig, type Migrate, type PendingEpisode, type Prediction, RECORD_VERSION, type RecordMode, type RecordOptions, type RecordedDecision, type RecorderRecord, type RevealOptions, STATE_VERSION, type SignalContribution, type SignalExplanation, type SignalRecord, type Units, createEndRecord, createRecord, createState, deserialize, endEpisode, episodeClosed, evaluate, evidenceToReveal, explain, markRevealed, ms, observe, predict, serialize, shouldReveal, validateConfig };
|
package/dist/index.d.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Version of the serialized state shape, stored in every state as `state.version`. Bumped only
|
|
3
3
|
* when `EngineState` changes incompatibly, and every bump ships its migration: {@link deserialize}
|
|
4
4
|
* upgrades states saved by earlier versions of this library on its own (version 1, written by
|
|
5
|
-
* oracular 0.1.0 to 0.2.x,
|
|
6
|
-
* it does not know.
|
|
5
|
+
* oracular 0.1.0 to 0.2.x, and version 2, written by 0.3.0 to 0.5.x, become version 3) and calls
|
|
6
|
+
* your `migrate` function only for a version it does not know.
|
|
7
7
|
*
|
|
8
8
|
* @example
|
|
9
9
|
* ```ts
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* });
|
|
20
20
|
* ```
|
|
21
21
|
*/
|
|
22
|
-
declare const STATE_VERSION:
|
|
22
|
+
declare const STATE_VERSION: 3;
|
|
23
23
|
/**
|
|
24
24
|
* Version of the recorder format: the `v` field of every record that {@link createRecord} and
|
|
25
25
|
* {@link createEndRecord} produce. A new optional field does not change it; readers ignore fields
|
|
@@ -112,6 +112,17 @@ interface IntentConfig {
|
|
|
112
112
|
* weight.
|
|
113
113
|
*/
|
|
114
114
|
saturate?: boolean;
|
|
115
|
+
/**
|
|
116
|
+
* How long a closed episode's signals wait for the outcome before they take a miss, in the same
|
|
117
|
+
* unit as `at`. With it set, an episode that ends without the outcome is held instead of
|
|
118
|
+
* counted. When the outcome arrives inside the window, each held episode gets a share
|
|
119
|
+
* `2^(−age / halfLife)` of a hit and the rest of a miss, with `age` measured from the episode's
|
|
120
|
+
* close: the sessions that built up to the outcome are credited by how much of their evidence
|
|
121
|
+
* still stands. When the window passes, the episode is a miss, recorded at the moment the window
|
|
122
|
+
* ended. Unset: an episode that ends without the outcome is a miss at once. A natural first value
|
|
123
|
+
* is the intent's `halfLife`, where the smallest share inside the window is one half.
|
|
124
|
+
*/
|
|
125
|
+
attribution?: number;
|
|
115
126
|
/** Reveal defaults for this intent. */
|
|
116
127
|
reveal?: RevealOptions;
|
|
117
128
|
}
|
|
@@ -143,11 +154,18 @@ interface SignalContribution {
|
|
|
143
154
|
}
|
|
144
155
|
/** What this user's history says about one signal. Decays with `learning.halfLife`. */
|
|
145
156
|
interface SignalRecord {
|
|
146
|
-
/** Episodes in which the signal was followed by the outcome. */
|
|
157
|
+
/** Episodes in which the signal was followed by the outcome. An episode credited by a later outcome under `attribution` adds its share here and the rest to `misses`. */
|
|
147
158
|
hits: number;
|
|
148
159
|
/** Episodes in which it was not. */
|
|
149
160
|
misses: number;
|
|
150
161
|
}
|
|
162
|
+
/** A closed episode waiting for the intent's outcome inside its `attribution` window. */
|
|
163
|
+
interface PendingEpisode {
|
|
164
|
+
/** When the episode closed. Its wait ends `attribution` later. */
|
|
165
|
+
endedAt: number;
|
|
166
|
+
/** The signals seen in it, each once. */
|
|
167
|
+
seen: string[];
|
|
168
|
+
}
|
|
151
169
|
/**
|
|
152
170
|
* Everything the engine knows about one intent for this user. Plain data; read it for debugging
|
|
153
171
|
* or dashboards, but change it only through the engine's functions.
|
|
@@ -163,6 +181,11 @@ interface IntentState {
|
|
|
163
181
|
learned: Record<string, SignalRecord>;
|
|
164
182
|
/** Episodes that ended with (`hits`) or without (`misses`) the outcome: the base rate. */
|
|
165
183
|
episodes: SignalRecord;
|
|
184
|
+
/**
|
|
185
|
+
* Closed episodes whose verdict waits for the outcome (see {@link IntentConfig.attribution}),
|
|
186
|
+
* in order of close, at most 32. Empty unless `attribution` is set.
|
|
187
|
+
*/
|
|
188
|
+
pending: PendingEpisode[];
|
|
166
189
|
/** Time `learned` and `episodes` were last decayed to. */
|
|
167
190
|
learnedAt: number | null;
|
|
168
191
|
lastRevealedAt: number | null;
|
|
@@ -314,7 +337,7 @@ interface Evaluation {
|
|
|
314
337
|
/**
|
|
315
338
|
* Turns a stored state of a version this library cannot migrate itself into a current
|
|
316
339
|
* `EngineState`. Receives the parsed JSON as-is and the version it declares. States saved by
|
|
317
|
-
* oracular 0.1.0 to 0.
|
|
340
|
+
* oracular 0.1.0 to 0.5.x (versions 1 and 2) never reach it: `deserialize` upgrades those on its own.
|
|
318
341
|
*
|
|
319
342
|
* @example
|
|
320
343
|
* ```ts
|
|
@@ -338,7 +361,7 @@ interface DeserializeOptions {
|
|
|
338
361
|
config?: EngineConfig;
|
|
339
362
|
/**
|
|
340
363
|
* Called when the stored version is neither `STATE_VERSION` nor one this library migrates
|
|
341
|
-
* itself (states from oracular 0.1.0 to 0.
|
|
364
|
+
* itself (states from oracular 0.1.0 to 0.5.x are upgraded without it).
|
|
342
365
|
*/
|
|
343
366
|
migrate?: Migrate;
|
|
344
367
|
}
|
|
@@ -517,10 +540,11 @@ declare function explain(state: EngineState, intent: string, at: number, opts?:
|
|
|
517
540
|
* `w × log2(1 + 1/n)`, counted since the episode started or the intent's outcome last arrived.
|
|
518
541
|
* That is the default when `config.episodeTimeout` is set, and `IntentConfig.saturate` overrides
|
|
519
542
|
* it either way; otherwise every occurrence adds all of `w`. For every intent the event is the
|
|
520
|
-
* outcome of: give the remembered signals a hit, count the episode toward the base rate,
|
|
521
|
-
*
|
|
522
|
-
*
|
|
523
|
-
* episode
|
|
543
|
+
* outcome of: give the remembered signals a hit, count the episode toward the base rate, credit
|
|
544
|
+
* the closed episodes still waiting under `IntentConfig.attribution` by `2^(−age / halfLife)`
|
|
545
|
+
* each, record `lastOutcomeAt`, and (by default) reset the evidence to the prior. Any event opens
|
|
546
|
+
* or extends the current episode; an idle gap longer than `config.episodeTimeout` closes the
|
|
547
|
+
* previous episode first.
|
|
524
548
|
*
|
|
525
549
|
* @param state - The current state. Not mutated.
|
|
526
550
|
* @param event - `name` is matched against every intent's signals and outcome; `at` is the
|
|
@@ -540,7 +564,8 @@ declare function explain(state: EngineState, intent: string, at: number, opts?:
|
|
|
540
564
|
declare function observe(state: EngineState, event: IntentEvent): EngineState;
|
|
541
565
|
/**
|
|
542
566
|
* Ends the current episode at `at`. Every intent whose outcome did not arrive gives a miss to
|
|
543
|
-
* each signal seen this episode and counts the episode toward its base rate
|
|
567
|
+
* each signal seen this episode and counts the episode toward its base rate, or, with
|
|
568
|
+
* `IntentConfig.attribution` set, holds the episode for the outcome instead. Call it when a
|
|
544
569
|
* session ends: tab closed, app backgrounded, user signed out.
|
|
545
570
|
*
|
|
546
571
|
* @param state - The current state. Not mutated.
|
|
@@ -562,7 +587,8 @@ declare function endEpisode(state: EngineState, at: number): EngineState;
|
|
|
562
587
|
* Whether an episode closed between two states: `prev` had an open episode and `next` either
|
|
563
588
|
* has none or has a different one. Useful after `observe` or `endEpisode` to react to a
|
|
564
589
|
* session ending, for example to persist, or to read the misses it produced as
|
|
565
|
-
* `next.intents[x].episodes.misses - prev.intents[x].episodes.misses
|
|
590
|
+
* `next.intents[x].episodes.misses - prev.intents[x].episodes.misses` (0 for an intent with
|
|
591
|
+
* `attribution` set, whose verdict waits).
|
|
566
592
|
*
|
|
567
593
|
* @param prev - The state before the operation.
|
|
568
594
|
* @param next - The state after it.
|
|
@@ -711,7 +737,7 @@ declare function serialize(state: EngineState): string;
|
|
|
711
737
|
* Always pass the app's current `config`: it is validated and replaces the stored snapshot,
|
|
712
738
|
* intents and signals no longer declared are dropped (α and β are recomputed from the kept
|
|
713
739
|
* contributions), and newly declared intents start at their prior. States saved by oracular
|
|
714
|
-
* 0.1.0 to 0.
|
|
740
|
+
* 0.1.0 to 0.5.x are upgraded to the current shape on their own; `migrate` is only for a version
|
|
715
741
|
* this library does not know.
|
|
716
742
|
*
|
|
717
743
|
* @param json - A string produced by {@link serialize}.
|
|
@@ -823,4 +849,4 @@ declare const ms: Units;
|
|
|
823
849
|
*/
|
|
824
850
|
declare function validateConfig(config: unknown): EngineConfig;
|
|
825
851
|
|
|
826
|
-
export { type DeserializeOptions, type EndRecord, type EngineConfig, type EngineState, type EpisodeIntentRecord, type EpisodeState, type EvaluateOptions, type Evaluation, type EventRecord, type ExplainOptions, type IntentConfig, type IntentEvent, type IntentState, type LearningConfig, type Migrate, type Prediction, RECORD_VERSION, type RecordMode, type RecordOptions, type RecordedDecision, type RecorderRecord, type RevealOptions, STATE_VERSION, type SignalContribution, type SignalExplanation, type SignalRecord, type Units, createEndRecord, createRecord, createState, deserialize, endEpisode, episodeClosed, evaluate, evidenceToReveal, explain, markRevealed, ms, observe, predict, serialize, shouldReveal, validateConfig };
|
|
852
|
+
export { type DeserializeOptions, type EndRecord, type EngineConfig, type EngineState, type EpisodeIntentRecord, type EpisodeState, type EvaluateOptions, type Evaluation, type EventRecord, type ExplainOptions, type IntentConfig, type IntentEvent, type IntentState, type LearningConfig, type Migrate, type PendingEpisode, type Prediction, RECORD_VERSION, type RecordMode, type RecordOptions, type RecordedDecision, type RecorderRecord, type RevealOptions, STATE_VERSION, type SignalContribution, type SignalExplanation, type SignalRecord, type Units, createEndRecord, createRecord, createState, deserialize, endEpisode, episodeClosed, evaluate, evidenceToReveal, explain, markRevealed, ms, observe, predict, serialize, shouldReveal, validateConfig };
|
package/dist/index.js
CHANGED
|
@@ -47,6 +47,86 @@ function mapRecord(record2, fn) {
|
|
|
47
47
|
return out;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// src/learning.ts
|
|
51
|
+
var NONE = { hits: 0, misses: 0 };
|
|
52
|
+
function decayLearned(state, learning, at) {
|
|
53
|
+
if (state.learnedAt === null) return { ...state, learnedAt: at };
|
|
54
|
+
const learnedAt = Math.max(state.learnedAt, at);
|
|
55
|
+
const d = learning.halfLife === null ? 1 : decayFactor(at - state.learnedAt, learning.halfLife);
|
|
56
|
+
if (d === 1) return learnedAt === state.learnedAt ? state : { ...state, learnedAt };
|
|
57
|
+
return {
|
|
58
|
+
...state,
|
|
59
|
+
learnedAt,
|
|
60
|
+
learned: mapRecord(state.learned, (r) => ({ hits: r.hits * d, misses: r.misses * d })),
|
|
61
|
+
episodes: { hits: state.episodes.hits * d, misses: state.episodes.misses * d }
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function signalWeights(state, intent, learning, signal) {
|
|
65
|
+
const initial = getOwn(intent.signals, signal);
|
|
66
|
+
if (initial === void 0) {
|
|
67
|
+
throw new RangeError(`oracular: ${JSON.stringify(signal)} is not a signal of this intent`);
|
|
68
|
+
}
|
|
69
|
+
const record2 = getOwn(state.learned, signal) ?? NONE;
|
|
70
|
+
const nWith = record2.hits + record2.misses;
|
|
71
|
+
const qWith = laplace(record2.hits, nWith);
|
|
72
|
+
const totalEpisodes = state.episodes.hits + state.episodes.misses;
|
|
73
|
+
const nWithout = Math.max(0, totalEpisodes - nWith);
|
|
74
|
+
const outcomesWithout = Math.min(nWithout, Math.max(0, state.episodes.hits - record2.hits));
|
|
75
|
+
const qWithout = laplace(outcomesWithout, nWithout);
|
|
76
|
+
const learned = nWith === 0 ? 0 : learning.gain * Math.abs(initial) * lift(qWith, qWithout);
|
|
77
|
+
const nEff = nWith + nWithout === 0 ? 0 : nWith * nWithout / (nWith + nWithout);
|
|
78
|
+
const k = learning.priorEpisodes;
|
|
79
|
+
const effective = (k * initial + nEff * learned) / (k + nEff);
|
|
80
|
+
return { initial, learned, effective, hits: record2.hits, misses: record2.misses };
|
|
81
|
+
}
|
|
82
|
+
function recordEpisode(state, signals, credit) {
|
|
83
|
+
const miss = 1 - credit;
|
|
84
|
+
const learned = { ...state.learned };
|
|
85
|
+
for (const signal of signals) {
|
|
86
|
+
const r = getOwn(learned, signal) ?? NONE;
|
|
87
|
+
learned[signal] = { hits: r.hits + credit, misses: r.misses + miss };
|
|
88
|
+
}
|
|
89
|
+
const episodes = { hits: state.episodes.hits + credit, misses: state.episodes.misses + miss };
|
|
90
|
+
return { ...state, learned, episodes };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// src/attribution.ts
|
|
94
|
+
var MAX_PENDING = 32;
|
|
95
|
+
function expiry(episode, intent) {
|
|
96
|
+
return episode.endedAt + (intent.attribution ?? 0);
|
|
97
|
+
}
|
|
98
|
+
function settlePending(state, intent, learning, at) {
|
|
99
|
+
let is = state;
|
|
100
|
+
let settled = 0;
|
|
101
|
+
for (const episode of state.pending) {
|
|
102
|
+
const end = expiry(episode, intent);
|
|
103
|
+
if (!(end < at)) break;
|
|
104
|
+
is = recordEpisode(decayLearned(is, learning, end), episode.seen, 0);
|
|
105
|
+
settled++;
|
|
106
|
+
}
|
|
107
|
+
if (settled > 0) is = { ...is, pending: state.pending.slice(settled) };
|
|
108
|
+
return decayLearned(is, learning, at);
|
|
109
|
+
}
|
|
110
|
+
function deferEpisode(state, seen, endedAt) {
|
|
111
|
+
const pending = insert(state.pending, { endedAt, seen: [...seen] });
|
|
112
|
+
if (pending.length <= MAX_PENDING) return { ...state, pending };
|
|
113
|
+
const [oldest, ...rest] = pending;
|
|
114
|
+
return { ...recordEpisode(state, oldest.seen, 0), pending: rest };
|
|
115
|
+
}
|
|
116
|
+
function insert(pending, episode) {
|
|
117
|
+
let i = pending.length;
|
|
118
|
+
while (i > 0 && pending[i - 1].endedAt > episode.endedAt) i--;
|
|
119
|
+
return [...pending.slice(0, i), episode, ...pending.slice(i)];
|
|
120
|
+
}
|
|
121
|
+
function creditPending(state, intent, at) {
|
|
122
|
+
if (state.pending.length === 0) return state;
|
|
123
|
+
let is = state;
|
|
124
|
+
for (const episode of state.pending) {
|
|
125
|
+
is = recordEpisode(is, episode.seen, decayFactor(at - episode.endedAt, intent.halfLife));
|
|
126
|
+
}
|
|
127
|
+
return { ...is, pending: [] };
|
|
128
|
+
}
|
|
129
|
+
|
|
50
130
|
// src/intent.ts
|
|
51
131
|
function priorCounts(intent) {
|
|
52
132
|
return {
|
|
@@ -63,6 +143,7 @@ function initIntent(intent) {
|
|
|
63
143
|
contributions: {},
|
|
64
144
|
learned: {},
|
|
65
145
|
episodes: { hits: 0, misses: 0 },
|
|
146
|
+
pending: [],
|
|
66
147
|
learnedAt: null,
|
|
67
148
|
lastRevealedAt: null,
|
|
68
149
|
lastOutcomeAt: null,
|
|
@@ -99,48 +180,6 @@ function resetToPrior(state, intent) {
|
|
|
99
180
|
return withContributions(state, intent, {});
|
|
100
181
|
}
|
|
101
182
|
|
|
102
|
-
// src/learning.ts
|
|
103
|
-
var NONE = { hits: 0, misses: 0 };
|
|
104
|
-
function decayLearned(state, learning, at) {
|
|
105
|
-
if (state.learnedAt === null) return { ...state, learnedAt: at };
|
|
106
|
-
const learnedAt = Math.max(state.learnedAt, at);
|
|
107
|
-
const d = learning.halfLife === null ? 1 : decayFactor(at - state.learnedAt, learning.halfLife);
|
|
108
|
-
if (d === 1) return learnedAt === state.learnedAt ? state : { ...state, learnedAt };
|
|
109
|
-
return {
|
|
110
|
-
...state,
|
|
111
|
-
learnedAt,
|
|
112
|
-
learned: mapRecord(state.learned, (r) => ({ hits: r.hits * d, misses: r.misses * d })),
|
|
113
|
-
episodes: { hits: state.episodes.hits * d, misses: state.episodes.misses * d }
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
function signalWeights(state, intent, learning, signal) {
|
|
117
|
-
const initial = getOwn(intent.signals, signal);
|
|
118
|
-
if (initial === void 0) {
|
|
119
|
-
throw new RangeError(`oracular: ${JSON.stringify(signal)} is not a signal of this intent`);
|
|
120
|
-
}
|
|
121
|
-
const record2 = getOwn(state.learned, signal) ?? NONE;
|
|
122
|
-
const nWith = record2.hits + record2.misses;
|
|
123
|
-
const qWith = laplace(record2.hits, nWith);
|
|
124
|
-
const totalEpisodes = state.episodes.hits + state.episodes.misses;
|
|
125
|
-
const nWithout = Math.max(0, totalEpisodes - nWith);
|
|
126
|
-
const outcomesWithout = Math.min(nWithout, Math.max(0, state.episodes.hits - record2.hits));
|
|
127
|
-
const qWithout = laplace(outcomesWithout, nWithout);
|
|
128
|
-
const learned = nWith === 0 ? 0 : learning.gain * Math.abs(initial) * lift(qWith, qWithout);
|
|
129
|
-
const nEff = nWith + nWithout === 0 ? 0 : nWith * nWithout / (nWith + nWithout);
|
|
130
|
-
const k = learning.priorEpisodes;
|
|
131
|
-
const effective = (k * initial + nEff * learned) / (k + nEff);
|
|
132
|
-
return { initial, learned, effective, hits: record2.hits, misses: record2.misses };
|
|
133
|
-
}
|
|
134
|
-
function recordEpisode(state, signals, outcome) {
|
|
135
|
-
const learned = { ...state.learned };
|
|
136
|
-
for (const signal of signals) {
|
|
137
|
-
const r = getOwn(learned, signal) ?? NONE;
|
|
138
|
-
learned[signal] = outcome ? { hits: r.hits + 1, misses: r.misses } : { hits: r.hits, misses: r.misses + 1 };
|
|
139
|
-
}
|
|
140
|
-
const episodes = outcome ? { hits: state.episodes.hits + 1, misses: state.episodes.misses } : { hits: state.episodes.hits, misses: state.episodes.misses + 1 };
|
|
141
|
-
return { ...state, learned, episodes };
|
|
142
|
-
}
|
|
143
|
-
|
|
144
183
|
// src/config.ts
|
|
145
184
|
var DEFAULT_THRESHOLD = 0.5;
|
|
146
185
|
var DEFAULT_Z = 1.28;
|
|
@@ -156,6 +195,7 @@ var INTENT_KEYS = [
|
|
|
156
195
|
"outcome",
|
|
157
196
|
"resetOnOutcome",
|
|
158
197
|
"saturate",
|
|
198
|
+
"attribution",
|
|
159
199
|
"reveal"
|
|
160
200
|
];
|
|
161
201
|
var REVEAL_KEYS = ["threshold", "z", "cooldown"];
|
|
@@ -285,6 +325,9 @@ function normalizeIntentConfig(value, path) {
|
|
|
285
325
|
if (typeof record2.saturate !== "boolean") typeError(`${path}.saturate`, "must be a boolean");
|
|
286
326
|
out.saturate = record2.saturate;
|
|
287
327
|
}
|
|
328
|
+
if (record2.attribution !== void 0) {
|
|
329
|
+
out.attribution = expectPositive(record2.attribution, `${path}.attribution`);
|
|
330
|
+
}
|
|
288
331
|
if (record2.reveal !== void 0)
|
|
289
332
|
out.reveal = normalizeRevealOptions(record2.reveal, `${path}.reveal`);
|
|
290
333
|
return out;
|
|
@@ -407,7 +450,7 @@ function markResolved(episode, intent) {
|
|
|
407
450
|
}
|
|
408
451
|
|
|
409
452
|
// src/version.ts
|
|
410
|
-
var STATE_VERSION =
|
|
453
|
+
var STATE_VERSION = 3;
|
|
411
454
|
var RECORD_VERSION = 1;
|
|
412
455
|
|
|
413
456
|
// src/state.ts
|
|
@@ -438,7 +481,8 @@ function getIntentState(state, name, intent) {
|
|
|
438
481
|
|
|
439
482
|
// src/assess.ts
|
|
440
483
|
function assessIntent(state, name, intent, learning, at) {
|
|
441
|
-
|
|
484
|
+
const is = decayIntent(getIntentState(state, name, intent), intent, at);
|
|
485
|
+
return settlePending(is, intent, learning, at);
|
|
442
486
|
}
|
|
443
487
|
function cooldownEnd(since, cooldown, at) {
|
|
444
488
|
if (since === null) return null;
|
|
@@ -565,7 +609,7 @@ function observe(state, event) {
|
|
|
565
609
|
if (!isSignal && !isOutcome) continue;
|
|
566
610
|
let is = getIntentState(s, intentName, intent);
|
|
567
611
|
is = decayIntent(is, intent, at);
|
|
568
|
-
is =
|
|
612
|
+
is = settlePending(is, intent, learning, at);
|
|
569
613
|
if (isSignal) {
|
|
570
614
|
const { effective } = signalWeights(is, intent, learning, name);
|
|
571
615
|
const n = occurrences(episode, intentName, name) + 1;
|
|
@@ -575,7 +619,8 @@ function observe(state, event) {
|
|
|
575
619
|
if (!isOutcome) episode = markSeen(episode, intentName, name);
|
|
576
620
|
}
|
|
577
621
|
if (isOutcome) {
|
|
578
|
-
is = recordEpisode(is, episodeRecord(episode, intentName).seen,
|
|
622
|
+
is = recordEpisode(is, episodeRecord(episode, intentName).seen, 1);
|
|
623
|
+
is = creditPending(is, intent, at);
|
|
579
624
|
episode = markResolved(episode, intentName);
|
|
580
625
|
is = { ...is, lastOutcomeAt: at };
|
|
581
626
|
if (intent.resetOnOutcome ?? true) is = resetToPrior(is, intent);
|
|
@@ -598,8 +643,8 @@ function closeEpisode(state, at) {
|
|
|
598
643
|
const record2 = episodeRecord(state.episode, intentName);
|
|
599
644
|
if (record2.resolved) continue;
|
|
600
645
|
let is = getIntentState(state, intentName, intent);
|
|
601
|
-
is =
|
|
602
|
-
is = recordEpisode(is, record2.seen,
|
|
646
|
+
is = settlePending(is, intent, learning, at);
|
|
647
|
+
is = intent.attribution === void 0 ? recordEpisode(is, record2.seen, 0) : deferEpisode(is, record2.seen, at);
|
|
603
648
|
intents[intentName] = is;
|
|
604
649
|
}
|
|
605
650
|
return { ...state, intents, episode: emptyEpisode() };
|
|
@@ -707,7 +752,8 @@ function createEndRecord(at, opts) {
|
|
|
707
752
|
|
|
708
753
|
// src/migrate.ts
|
|
709
754
|
var MIGRATIONS = {
|
|
710
|
-
1: fromV1
|
|
755
|
+
1: fromV1,
|
|
756
|
+
2: fromV2
|
|
711
757
|
};
|
|
712
758
|
function canMigrate(version) {
|
|
713
759
|
return hasOwn(MIGRATIONS, String(version));
|
|
@@ -738,6 +784,16 @@ function fromV1(stored) {
|
|
|
738
784
|
}
|
|
739
785
|
return { ...stored, version: 2, episode: { ...episode, intents } };
|
|
740
786
|
}
|
|
787
|
+
function fromV2(stored) {
|
|
788
|
+
if (!isRecord(stored.intents)) return { ...stored, version: 3 };
|
|
789
|
+
const intents = {};
|
|
790
|
+
for (const key of Object.keys(stored.intents)) {
|
|
791
|
+
if (key === "__proto__") continue;
|
|
792
|
+
const intent = stored.intents[key];
|
|
793
|
+
intents[key] = isRecord(intent) ? { ...intent, pending: [] } : intent;
|
|
794
|
+
}
|
|
795
|
+
return { ...stored, version: 3, intents };
|
|
796
|
+
}
|
|
741
797
|
|
|
742
798
|
// src/serialize.ts
|
|
743
799
|
function serialize(state) {
|
|
@@ -824,6 +880,19 @@ function mapOwn(value, path, fn) {
|
|
|
824
880
|
}
|
|
825
881
|
return out;
|
|
826
882
|
}
|
|
883
|
+
function pendingEpisodes(value, path) {
|
|
884
|
+
if (!Array.isArray(value)) typeError2(path, "must be an array");
|
|
885
|
+
if (value.length > MAX_PENDING) {
|
|
886
|
+
throw new RangeError(`oracular: ${path} must hold at most ${MAX_PENDING} episodes`);
|
|
887
|
+
}
|
|
888
|
+
return value.map((item, i) => {
|
|
889
|
+
const r = record(item, `${path}[${i}]`);
|
|
890
|
+
if (!Array.isArray(r.seen) || !r.seen.every((s) => typeof s === "string")) {
|
|
891
|
+
typeError2(`${path}[${i}].seen`, "must be an array of strings");
|
|
892
|
+
}
|
|
893
|
+
return { endedAt: finite(r.endedAt, `${path}[${i}].endedAt`), seen: r.seen };
|
|
894
|
+
});
|
|
895
|
+
}
|
|
827
896
|
function intentState(value, path) {
|
|
828
897
|
const r = record(value, path);
|
|
829
898
|
const revealCount = finite(r.revealCount, `${path}.revealCount`);
|
|
@@ -837,6 +906,7 @@ function intentState(value, path) {
|
|
|
837
906
|
contributions: mapOwn(r.contributions, `${path}.contributions`, contribution),
|
|
838
907
|
learned: mapOwn(r.learned, `${path}.learned`, signalRecord),
|
|
839
908
|
episodes: signalRecord(r.episodes, `${path}.episodes`),
|
|
909
|
+
pending: pendingEpisodes(r.pending, `${path}.pending`),
|
|
840
910
|
learnedAt: nullOrFinite(r.learnedAt, `${path}.learnedAt`),
|
|
841
911
|
lastRevealedAt: nullOrFinite(r.lastRevealedAt, `${path}.lastRevealedAt`),
|
|
842
912
|
lastOutcomeAt: nullOrFinite(r.lastOutcomeAt, `${path}.lastOutcomeAt`),
|
|
@@ -905,6 +975,12 @@ function keep(source, declared) {
|
|
|
905
975
|
}
|
|
906
976
|
return out;
|
|
907
977
|
}
|
|
978
|
+
function keepPending(pending, signals) {
|
|
979
|
+
return pending.map((episode) => ({
|
|
980
|
+
endedAt: episode.endedAt,
|
|
981
|
+
seen: episode.seen.filter((s, i) => hasOwn(signals, s) && episode.seen.indexOf(s) === i)
|
|
982
|
+
})).sort((a, b) => a.endedAt - b.endedAt);
|
|
983
|
+
}
|
|
908
984
|
function reconcile(raw, config) {
|
|
909
985
|
const intents = {};
|
|
910
986
|
const episodeIntents = {};
|
|
@@ -914,7 +990,11 @@ function reconcile(raw, config) {
|
|
|
914
990
|
intents[name] = initIntent(intent);
|
|
915
991
|
} else {
|
|
916
992
|
intents[name] = withContributions(
|
|
917
|
-
{
|
|
993
|
+
{
|
|
994
|
+
...stored,
|
|
995
|
+
learned: keep(stored.learned, intent.signals),
|
|
996
|
+
pending: keepPending(stored.pending, intent.signals)
|
|
997
|
+
},
|
|
918
998
|
intent,
|
|
919
999
|
keep(stored.contributions, intent.signals)
|
|
920
1000
|
);
|