oracular 0.14.0 → 0.16.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 +14 -0
- package/README.md +3 -2
- package/dist/index.cjs +26 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +36 -6
- package/dist/index.d.ts +36 -6
- package/dist/index.js +26 -4
- package/dist/index.js.map +1 -1
- package/dist/presets.d.cts +12 -0
- package/dist/presets.d.ts +12 -0
- package/package.json +1 -1
- package/schema/config.schema.json +4 -0
package/dist/index.d.cts
CHANGED
|
@@ -138,6 +138,18 @@ interface IntentConfig {
|
|
|
138
138
|
* weight.
|
|
139
139
|
*/
|
|
140
140
|
saturate?: boolean;
|
|
141
|
+
/**
|
|
142
|
+
* Each outcome is an episode of its own for this intent, rather than the session. After the
|
|
143
|
+
* outcome arrives, the intent's bookkeeping starts over: a later outcome in the same session
|
|
144
|
+
* counts as another episode even with nothing seen since, and signals seen after an outcome
|
|
145
|
+
* take a miss at the session's close (or wait under `attribution`), as a first attempt would.
|
|
146
|
+
* Default false: the episode is the session, a repeated outcome with nothing seen since counts
|
|
147
|
+
* nothing, and signals seen after an outcome are neutral at the close. For intents shaped as
|
|
148
|
+
* attempts (an upload that fails, then completes, then completes again), where the second
|
|
149
|
+
* attempt is the contrast learning needs. With it set, a duplicated outcome event counts twice:
|
|
150
|
+
* dedupe such events in the app.
|
|
151
|
+
*/
|
|
152
|
+
episodePerOutcome?: boolean;
|
|
141
153
|
/**
|
|
142
154
|
* How long a closed episode's signals wait for the outcome before they take a miss, in the same
|
|
143
155
|
* unit as `at`. With it set, an episode that ends without the outcome is held instead of
|
|
@@ -462,6 +474,15 @@ interface RecordedDecision {
|
|
|
462
474
|
}[];
|
|
463
475
|
/** Effective weight of every declared signal, in config order. */
|
|
464
476
|
weights: Record<string, number>;
|
|
477
|
+
/**
|
|
478
|
+
* The prediction just before the event, on the record of this intent's own outcome event, when
|
|
479
|
+
* {@link createRecord} was given the state before it: the number `resetOnOutcome` wipes, which
|
|
480
|
+
* `p` above no longer shows. Absent on every other record.
|
|
481
|
+
*/
|
|
482
|
+
before?: {
|
|
483
|
+
p: number;
|
|
484
|
+
lower: number;
|
|
485
|
+
};
|
|
465
486
|
}
|
|
466
487
|
/**
|
|
467
488
|
* One line of a recorder log: an event the app observed and what the engine decided right after
|
|
@@ -657,7 +678,8 @@ declare function explain(state: EngineState, intent: string, at: number, opts?:
|
|
|
657
678
|
* it either way; otherwise every occurrence adds all of `w`. For every intent the event is the
|
|
658
679
|
* outcome of: give the signals remembered since the episode started or the outcome last arrived a
|
|
659
680
|
* hit and count the episode toward the base rate (a repeated outcome with nothing seen since
|
|
660
|
-
* counts nothing
|
|
681
|
+
* counts nothing, unless `IntentConfig.episodePerOutcome` makes every outcome an episode of its
|
|
682
|
+
* own for the intent), credit
|
|
661
683
|
* the closed episodes still waiting under `IntentConfig.attribution` by `2^(−age / halfLife)`
|
|
662
684
|
* each, record `lastOutcomeAt`, and (by default) reset the evidence to the prior. Any event opens
|
|
663
685
|
* or extends the current episode; an idle gap longer than `config.episodeTimeout` closes the
|
|
@@ -808,25 +830,33 @@ declare function markRevealed(state: EngineState, intent: string, at: number): E
|
|
|
808
830
|
* and the effective weight of every signal. Pure: the install id comes from the app, never from
|
|
809
831
|
* here. See the Shadow mode guide for the whole workflow.
|
|
810
832
|
*
|
|
833
|
+
* On the record of an intent's own outcome event, `p` is what the engine decides right after it,
|
|
834
|
+
* which with `resetOnOutcome` is the prior: the estimate the outcome confirmed is gone. Pass the
|
|
835
|
+
* state from before the event as `previous` and that intent's decision gains `before`, its `p`
|
|
836
|
+
* and `lower` at `event.at` on that state. Replay never reads it; people reading the log do.
|
|
837
|
+
*
|
|
811
838
|
* @param state - The state after `observe(state, event)`. Not mutated.
|
|
812
839
|
* @param event - The event just observed. Only `name` and `at` are recorded.
|
|
813
840
|
* @param opts - `installId` (generated and stored by the app), `mode` (`'shadow'` while
|
|
814
841
|
* reveals are computed but not shown, `'live'` once they are), and `top` (default 3).
|
|
842
|
+
* @param previous - The state before `observe(state, event)`, optional. Only read for the
|
|
843
|
+
* intents whose outcome the event is.
|
|
815
844
|
* @returns A plain, JSON-serializable {@link EventRecord}.
|
|
816
|
-
* @throws `TypeError` when `state` is not an engine state, the event is
|
|
817
|
-
* not an object, has an unknown key, an empty `installId` or a `mode`
|
|
818
|
-
* `'shadow'` or `'live'`.
|
|
845
|
+
* @throws `TypeError` when `state` or `previous` is not an engine state, the event is
|
|
846
|
+
* malformed, `opts` is not an object, has an unknown key, an empty `installId` or a `mode`
|
|
847
|
+
* other than `'shadow'` or `'live'`.
|
|
819
848
|
* @throws `RangeError` when `opts.top` is negative or fractional.
|
|
820
849
|
* @example
|
|
821
850
|
* ```ts
|
|
822
851
|
* const recording = { installId: localStorage.getItem('oracular.install')!, mode: 'shadow' } as const;
|
|
823
852
|
*
|
|
824
853
|
* const event = { name: 'pricing.viewed', at: Date.now() };
|
|
854
|
+
* const previous = state;
|
|
825
855
|
* state = observe(state, event);
|
|
826
|
-
* appendLine(JSON.stringify(createRecord(state, event, recording)));
|
|
856
|
+
* appendLine(JSON.stringify(createRecord(state, event, recording, previous)));
|
|
827
857
|
* ```
|
|
828
858
|
*/
|
|
829
|
-
declare function createRecord(state: EngineState, event: IntentEvent, opts: RecordOptions): EventRecord;
|
|
859
|
+
declare function createRecord(state: EngineState, event: IntentEvent, opts: RecordOptions, previous?: EngineState): EventRecord;
|
|
830
860
|
/**
|
|
831
861
|
* A recorder record for the end of an episode. Write one whenever the app calls
|
|
832
862
|
* {@link endEpisode}, with the same `at`, so that replay can end the episode at the same moment
|
package/dist/index.d.ts
CHANGED
|
@@ -138,6 +138,18 @@ interface IntentConfig {
|
|
|
138
138
|
* weight.
|
|
139
139
|
*/
|
|
140
140
|
saturate?: boolean;
|
|
141
|
+
/**
|
|
142
|
+
* Each outcome is an episode of its own for this intent, rather than the session. After the
|
|
143
|
+
* outcome arrives, the intent's bookkeeping starts over: a later outcome in the same session
|
|
144
|
+
* counts as another episode even with nothing seen since, and signals seen after an outcome
|
|
145
|
+
* take a miss at the session's close (or wait under `attribution`), as a first attempt would.
|
|
146
|
+
* Default false: the episode is the session, a repeated outcome with nothing seen since counts
|
|
147
|
+
* nothing, and signals seen after an outcome are neutral at the close. For intents shaped as
|
|
148
|
+
* attempts (an upload that fails, then completes, then completes again), where the second
|
|
149
|
+
* attempt is the contrast learning needs. With it set, a duplicated outcome event counts twice:
|
|
150
|
+
* dedupe such events in the app.
|
|
151
|
+
*/
|
|
152
|
+
episodePerOutcome?: boolean;
|
|
141
153
|
/**
|
|
142
154
|
* How long a closed episode's signals wait for the outcome before they take a miss, in the same
|
|
143
155
|
* unit as `at`. With it set, an episode that ends without the outcome is held instead of
|
|
@@ -462,6 +474,15 @@ interface RecordedDecision {
|
|
|
462
474
|
}[];
|
|
463
475
|
/** Effective weight of every declared signal, in config order. */
|
|
464
476
|
weights: Record<string, number>;
|
|
477
|
+
/**
|
|
478
|
+
* The prediction just before the event, on the record of this intent's own outcome event, when
|
|
479
|
+
* {@link createRecord} was given the state before it: the number `resetOnOutcome` wipes, which
|
|
480
|
+
* `p` above no longer shows. Absent on every other record.
|
|
481
|
+
*/
|
|
482
|
+
before?: {
|
|
483
|
+
p: number;
|
|
484
|
+
lower: number;
|
|
485
|
+
};
|
|
465
486
|
}
|
|
466
487
|
/**
|
|
467
488
|
* One line of a recorder log: an event the app observed and what the engine decided right after
|
|
@@ -657,7 +678,8 @@ declare function explain(state: EngineState, intent: string, at: number, opts?:
|
|
|
657
678
|
* it either way; otherwise every occurrence adds all of `w`. For every intent the event is the
|
|
658
679
|
* outcome of: give the signals remembered since the episode started or the outcome last arrived a
|
|
659
680
|
* hit and count the episode toward the base rate (a repeated outcome with nothing seen since
|
|
660
|
-
* counts nothing
|
|
681
|
+
* counts nothing, unless `IntentConfig.episodePerOutcome` makes every outcome an episode of its
|
|
682
|
+
* own for the intent), credit
|
|
661
683
|
* the closed episodes still waiting under `IntentConfig.attribution` by `2^(−age / halfLife)`
|
|
662
684
|
* each, record `lastOutcomeAt`, and (by default) reset the evidence to the prior. Any event opens
|
|
663
685
|
* or extends the current episode; an idle gap longer than `config.episodeTimeout` closes the
|
|
@@ -808,25 +830,33 @@ declare function markRevealed(state: EngineState, intent: string, at: number): E
|
|
|
808
830
|
* and the effective weight of every signal. Pure: the install id comes from the app, never from
|
|
809
831
|
* here. See the Shadow mode guide for the whole workflow.
|
|
810
832
|
*
|
|
833
|
+
* On the record of an intent's own outcome event, `p` is what the engine decides right after it,
|
|
834
|
+
* which with `resetOnOutcome` is the prior: the estimate the outcome confirmed is gone. Pass the
|
|
835
|
+
* state from before the event as `previous` and that intent's decision gains `before`, its `p`
|
|
836
|
+
* and `lower` at `event.at` on that state. Replay never reads it; people reading the log do.
|
|
837
|
+
*
|
|
811
838
|
* @param state - The state after `observe(state, event)`. Not mutated.
|
|
812
839
|
* @param event - The event just observed. Only `name` and `at` are recorded.
|
|
813
840
|
* @param opts - `installId` (generated and stored by the app), `mode` (`'shadow'` while
|
|
814
841
|
* reveals are computed but not shown, `'live'` once they are), and `top` (default 3).
|
|
842
|
+
* @param previous - The state before `observe(state, event)`, optional. Only read for the
|
|
843
|
+
* intents whose outcome the event is.
|
|
815
844
|
* @returns A plain, JSON-serializable {@link EventRecord}.
|
|
816
|
-
* @throws `TypeError` when `state` is not an engine state, the event is
|
|
817
|
-
* not an object, has an unknown key, an empty `installId` or a `mode`
|
|
818
|
-
* `'shadow'` or `'live'`.
|
|
845
|
+
* @throws `TypeError` when `state` or `previous` is not an engine state, the event is
|
|
846
|
+
* malformed, `opts` is not an object, has an unknown key, an empty `installId` or a `mode`
|
|
847
|
+
* other than `'shadow'` or `'live'`.
|
|
819
848
|
* @throws `RangeError` when `opts.top` is negative or fractional.
|
|
820
849
|
* @example
|
|
821
850
|
* ```ts
|
|
822
851
|
* const recording = { installId: localStorage.getItem('oracular.install')!, mode: 'shadow' } as const;
|
|
823
852
|
*
|
|
824
853
|
* const event = { name: 'pricing.viewed', at: Date.now() };
|
|
854
|
+
* const previous = state;
|
|
825
855
|
* state = observe(state, event);
|
|
826
|
-
* appendLine(JSON.stringify(createRecord(state, event, recording)));
|
|
856
|
+
* appendLine(JSON.stringify(createRecord(state, event, recording, previous)));
|
|
827
857
|
* ```
|
|
828
858
|
*/
|
|
829
|
-
declare function createRecord(state: EngineState, event: IntentEvent, opts: RecordOptions): EventRecord;
|
|
859
|
+
declare function createRecord(state: EngineState, event: IntentEvent, opts: RecordOptions, previous?: EngineState): EventRecord;
|
|
830
860
|
/**
|
|
831
861
|
* A recorder record for the end of an episode. Write one whenever the app calls
|
|
832
862
|
* {@link endEpisode}, with the same `at`, so that replay can end the episode at the same moment
|
package/dist/index.js
CHANGED
|
@@ -284,6 +284,7 @@ var INTENT_KEYS = [
|
|
|
284
284
|
"outcome",
|
|
285
285
|
"resetOnOutcome",
|
|
286
286
|
"saturate",
|
|
287
|
+
"episodePerOutcome",
|
|
287
288
|
"attribution",
|
|
288
289
|
"reveal"
|
|
289
290
|
];
|
|
@@ -441,6 +442,12 @@ function normalizeIntentConfig(value, path, strict = true) {
|
|
|
441
442
|
if (typeof record2.saturate !== "boolean") typeError(`${path}.saturate`, "must be a boolean");
|
|
442
443
|
out.saturate = record2.saturate;
|
|
443
444
|
}
|
|
445
|
+
if (record2.episodePerOutcome !== void 0) {
|
|
446
|
+
if (typeof record2.episodePerOutcome !== "boolean") {
|
|
447
|
+
typeError(`${path}.episodePerOutcome`, "must be a boolean");
|
|
448
|
+
}
|
|
449
|
+
out.episodePerOutcome = record2.episodePerOutcome;
|
|
450
|
+
}
|
|
444
451
|
if (record2.attribution !== void 0) {
|
|
445
452
|
out.attribution = expectPositive(record2.attribution, `${path}.attribution`);
|
|
446
453
|
}
|
|
@@ -757,7 +764,9 @@ function observe(state, event) {
|
|
|
757
764
|
}
|
|
758
765
|
if (isOutcome) {
|
|
759
766
|
const record2 = episodeRecord(episode, intentName);
|
|
760
|
-
if (!record2.resolved || record2.seen.length > 0
|
|
767
|
+
if (!record2.resolved || record2.seen.length > 0 || intent.episodePerOutcome === true) {
|
|
768
|
+
is = recordEpisode(is, record2.seen, 1);
|
|
769
|
+
}
|
|
761
770
|
is = creditPending(is, intent, at);
|
|
762
771
|
episode = markResolved(episode, intentName);
|
|
763
772
|
is = { ...is, lastOutcomeAt: at };
|
|
@@ -810,7 +819,8 @@ function closeEpisode(state, at) {
|
|
|
810
819
|
const intents = { ...state.intents };
|
|
811
820
|
for (const [intentName, intent] of Object.entries(state.config.intents)) {
|
|
812
821
|
const record2 = episodeRecord(state.episode, intentName);
|
|
813
|
-
if (record2.resolved)
|
|
822
|
+
if (record2.resolved && (intent.episodePerOutcome !== true || record2.seen.length === 0))
|
|
823
|
+
continue;
|
|
814
824
|
let is = getIntentState(state, intentName, intent);
|
|
815
825
|
is = settlePending(is, intent, learning, at);
|
|
816
826
|
is = intent.attribution === void 0 ? recordEpisode(is, record2.seen, 0) : deferEpisode(is, record2.seen, at);
|
|
@@ -887,10 +897,11 @@ function normalizeRecordOptions(value) {
|
|
|
887
897
|
}
|
|
888
898
|
return { installId, mode, top };
|
|
889
899
|
}
|
|
890
|
-
function createRecord(state, event, opts) {
|
|
900
|
+
function createRecord(state, event, opts, previous) {
|
|
891
901
|
const s = requireState(state);
|
|
892
902
|
const { name, at } = validateEvent(event);
|
|
893
903
|
const { installId, mode, top } = normalizeRecordOptions(opts);
|
|
904
|
+
const prev = previous === void 0 ? void 0 : requireState(previous);
|
|
894
905
|
const learning = resolveLearning(s.config);
|
|
895
906
|
const decisions = {};
|
|
896
907
|
for (const [intentName, intent] of Object.entries(s.config.intents)) {
|
|
@@ -902,7 +913,7 @@ function createRecord(state, event, opts) {
|
|
|
902
913
|
for (const signal of Object.keys(intent.signals)) {
|
|
903
914
|
weights[signal] = rows.find((row) => row.signal === signal).effectiveWeight;
|
|
904
915
|
}
|
|
905
|
-
|
|
916
|
+
const decision = {
|
|
906
917
|
p: prediction.p,
|
|
907
918
|
lower: prediction.lower,
|
|
908
919
|
shouldReveal: decideReveal(prediction, reveal),
|
|
@@ -910,6 +921,17 @@ function createRecord(state, event, opts) {
|
|
|
910
921
|
top: rows.filter((row) => row.contribution !== 0).slice(0, top).map((row) => ({ signal: row.signal, impact: row.impact })),
|
|
911
922
|
weights
|
|
912
923
|
};
|
|
924
|
+
const prevIntent = prev === void 0 ? void 0 : getOwn(prev.config.intents, intentName);
|
|
925
|
+
if (prev !== void 0 && prevIntent !== void 0 && intent.outcome === name) {
|
|
926
|
+
const was = buildPrediction(
|
|
927
|
+
assessIntent(prev, intentName, prevIntent, resolveLearning(prev.config), at),
|
|
928
|
+
intentName,
|
|
929
|
+
resolveReveal(prev.config, prevIntent, void 0),
|
|
930
|
+
at
|
|
931
|
+
);
|
|
932
|
+
decision.before = { p: was.p, lower: was.lower };
|
|
933
|
+
}
|
|
934
|
+
decisions[intentName] = decision;
|
|
913
935
|
}
|
|
914
936
|
return { v: RECORD_VERSION, kind: "event", installId, mode, event: { name, at }, decisions };
|
|
915
937
|
}
|