oracular 0.15.0 → 0.17.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/dist/index.d.cts CHANGED
@@ -69,8 +69,25 @@ interface RevealOptions {
69
69
  */
70
70
  bound?: 'normal' | 'exact';
71
71
  }
72
- /** The association measure converted to a signed, bounded score when learning signal weights. */
73
- type LiftType = 'absolute-difference' | 'relative-lift' | 'risk-ratio' | 'log-lift' | 'odds-ratio' | 'normalized-difference' | 'association-rule';
72
+ /**
73
+ * The association measure converted to a signed, bounded score when learning signal weights.
74
+ * Every value but the default, `'normalized-difference'`, is deprecated: a sweep of all seven on
75
+ * the clickstream case study found none that beats the default on either split, so the six are
76
+ * removed in 1.0. They keep working, unchanged, until then; `learning.lift` itself stays.
77
+ */
78
+ type LiftType =
79
+ /** @deprecated Removed in 1.0: use the default, `'normalized-difference'`. */
80
+ 'absolute-difference'
81
+ /** @deprecated Removed in 1.0: use the default, `'normalized-difference'`. */
82
+ | 'relative-lift'
83
+ /** @deprecated Removed in 1.0: use the default, `'normalized-difference'`. */
84
+ | 'risk-ratio'
85
+ /** @deprecated Removed in 1.0: use the default, `'normalized-difference'`. */
86
+ | 'log-lift'
87
+ /** @deprecated Removed in 1.0: use the default, `'normalized-difference'`. */
88
+ | 'odds-ratio' | 'normalized-difference'
89
+ /** @deprecated Removed in 1.0: use the default, `'normalized-difference'`. */
90
+ | 'association-rule';
74
91
  /**
75
92
  * How the engine learns from the user's own outcomes.
76
93
  *
@@ -89,6 +106,7 @@ interface LearningConfig {
89
106
  * absolute/normalized differences keep their scale. Omit to preserve the existing normalized
90
107
  * difference exactly. 'association-rule' uses the overall outcome rate as its baseline; the
91
108
  * others use episodes without the signal. Raw ratios are reporting metrics, never weights.
109
+ * Every value but the default is deprecated and removed in 1.0 (see {@link LiftType}).
92
110
  */
93
111
  lift?: LiftType;
94
112
  /** Half-life of learned hit/miss counts, in the same unit as `at`. Omit for no forgetting. */
@@ -474,6 +492,15 @@ interface RecordedDecision {
474
492
  }[];
475
493
  /** Effective weight of every declared signal, in config order. */
476
494
  weights: Record<string, number>;
495
+ /**
496
+ * The prediction just before the event, on the record of this intent's own outcome event, when
497
+ * {@link createRecord} was given the state before it: the number `resetOnOutcome` wipes, which
498
+ * `p` above no longer shows. Absent on every other record.
499
+ */
500
+ before?: {
501
+ p: number;
502
+ lower: number;
503
+ };
477
504
  }
478
505
  /**
479
506
  * One line of a recorder log: an event the app observed and what the engine decided right after
@@ -821,25 +848,33 @@ declare function markRevealed(state: EngineState, intent: string, at: number): E
821
848
  * and the effective weight of every signal. Pure: the install id comes from the app, never from
822
849
  * here. See the Shadow mode guide for the whole workflow.
823
850
  *
851
+ * On the record of an intent's own outcome event, `p` is what the engine decides right after it,
852
+ * which with `resetOnOutcome` is the prior: the estimate the outcome confirmed is gone. Pass the
853
+ * state from before the event as `previous` and that intent's decision gains `before`, its `p`
854
+ * and `lower` at `event.at` on that state. Replay never reads it; people reading the log do.
855
+ *
824
856
  * @param state - The state after `observe(state, event)`. Not mutated.
825
857
  * @param event - The event just observed. Only `name` and `at` are recorded.
826
858
  * @param opts - `installId` (generated and stored by the app), `mode` (`'shadow'` while
827
859
  * reveals are computed but not shown, `'live'` once they are), and `top` (default 3).
860
+ * @param previous - The state before `observe(state, event)`, optional. Only read for the
861
+ * intents whose outcome the event is.
828
862
  * @returns A plain, JSON-serializable {@link EventRecord}.
829
- * @throws `TypeError` when `state` is not an engine state, the event is malformed, `opts` is
830
- * not an object, has an unknown key, an empty `installId` or a `mode` other than
831
- * `'shadow'` or `'live'`.
863
+ * @throws `TypeError` when `state` or `previous` is not an engine state, the event is
864
+ * malformed, `opts` is not an object, has an unknown key, an empty `installId` or a `mode`
865
+ * other than `'shadow'` or `'live'`.
832
866
  * @throws `RangeError` when `opts.top` is negative or fractional.
833
867
  * @example
834
868
  * ```ts
835
869
  * const recording = { installId: localStorage.getItem('oracular.install')!, mode: 'shadow' } as const;
836
870
  *
837
871
  * const event = { name: 'pricing.viewed', at: Date.now() };
872
+ * const previous = state;
838
873
  * state = observe(state, event);
839
- * appendLine(JSON.stringify(createRecord(state, event, recording)));
874
+ * appendLine(JSON.stringify(createRecord(state, event, recording, previous)));
840
875
  * ```
841
876
  */
842
- declare function createRecord(state: EngineState, event: IntentEvent, opts: RecordOptions): EventRecord;
877
+ declare function createRecord(state: EngineState, event: IntentEvent, opts: RecordOptions, previous?: EngineState): EventRecord;
843
878
  /**
844
879
  * A recorder record for the end of an episode. Write one whenever the app calls
845
880
  * {@link endEpisode}, with the same `at`, so that replay can end the episode at the same moment
package/dist/index.d.ts CHANGED
@@ -69,8 +69,25 @@ interface RevealOptions {
69
69
  */
70
70
  bound?: 'normal' | 'exact';
71
71
  }
72
- /** The association measure converted to a signed, bounded score when learning signal weights. */
73
- type LiftType = 'absolute-difference' | 'relative-lift' | 'risk-ratio' | 'log-lift' | 'odds-ratio' | 'normalized-difference' | 'association-rule';
72
+ /**
73
+ * The association measure converted to a signed, bounded score when learning signal weights.
74
+ * Every value but the default, `'normalized-difference'`, is deprecated: a sweep of all seven on
75
+ * the clickstream case study found none that beats the default on either split, so the six are
76
+ * removed in 1.0. They keep working, unchanged, until then; `learning.lift` itself stays.
77
+ */
78
+ type LiftType =
79
+ /** @deprecated Removed in 1.0: use the default, `'normalized-difference'`. */
80
+ 'absolute-difference'
81
+ /** @deprecated Removed in 1.0: use the default, `'normalized-difference'`. */
82
+ | 'relative-lift'
83
+ /** @deprecated Removed in 1.0: use the default, `'normalized-difference'`. */
84
+ | 'risk-ratio'
85
+ /** @deprecated Removed in 1.0: use the default, `'normalized-difference'`. */
86
+ | 'log-lift'
87
+ /** @deprecated Removed in 1.0: use the default, `'normalized-difference'`. */
88
+ | 'odds-ratio' | 'normalized-difference'
89
+ /** @deprecated Removed in 1.0: use the default, `'normalized-difference'`. */
90
+ | 'association-rule';
74
91
  /**
75
92
  * How the engine learns from the user's own outcomes.
76
93
  *
@@ -89,6 +106,7 @@ interface LearningConfig {
89
106
  * absolute/normalized differences keep their scale. Omit to preserve the existing normalized
90
107
  * difference exactly. 'association-rule' uses the overall outcome rate as its baseline; the
91
108
  * others use episodes without the signal. Raw ratios are reporting metrics, never weights.
109
+ * Every value but the default is deprecated and removed in 1.0 (see {@link LiftType}).
92
110
  */
93
111
  lift?: LiftType;
94
112
  /** Half-life of learned hit/miss counts, in the same unit as `at`. Omit for no forgetting. */
@@ -474,6 +492,15 @@ interface RecordedDecision {
474
492
  }[];
475
493
  /** Effective weight of every declared signal, in config order. */
476
494
  weights: Record<string, number>;
495
+ /**
496
+ * The prediction just before the event, on the record of this intent's own outcome event, when
497
+ * {@link createRecord} was given the state before it: the number `resetOnOutcome` wipes, which
498
+ * `p` above no longer shows. Absent on every other record.
499
+ */
500
+ before?: {
501
+ p: number;
502
+ lower: number;
503
+ };
477
504
  }
478
505
  /**
479
506
  * One line of a recorder log: an event the app observed and what the engine decided right after
@@ -821,25 +848,33 @@ declare function markRevealed(state: EngineState, intent: string, at: number): E
821
848
  * and the effective weight of every signal. Pure: the install id comes from the app, never from
822
849
  * here. See the Shadow mode guide for the whole workflow.
823
850
  *
851
+ * On the record of an intent's own outcome event, `p` is what the engine decides right after it,
852
+ * which with `resetOnOutcome` is the prior: the estimate the outcome confirmed is gone. Pass the
853
+ * state from before the event as `previous` and that intent's decision gains `before`, its `p`
854
+ * and `lower` at `event.at` on that state. Replay never reads it; people reading the log do.
855
+ *
824
856
  * @param state - The state after `observe(state, event)`. Not mutated.
825
857
  * @param event - The event just observed. Only `name` and `at` are recorded.
826
858
  * @param opts - `installId` (generated and stored by the app), `mode` (`'shadow'` while
827
859
  * reveals are computed but not shown, `'live'` once they are), and `top` (default 3).
860
+ * @param previous - The state before `observe(state, event)`, optional. Only read for the
861
+ * intents whose outcome the event is.
828
862
  * @returns A plain, JSON-serializable {@link EventRecord}.
829
- * @throws `TypeError` when `state` is not an engine state, the event is malformed, `opts` is
830
- * not an object, has an unknown key, an empty `installId` or a `mode` other than
831
- * `'shadow'` or `'live'`.
863
+ * @throws `TypeError` when `state` or `previous` is not an engine state, the event is
864
+ * malformed, `opts` is not an object, has an unknown key, an empty `installId` or a `mode`
865
+ * other than `'shadow'` or `'live'`.
832
866
  * @throws `RangeError` when `opts.top` is negative or fractional.
833
867
  * @example
834
868
  * ```ts
835
869
  * const recording = { installId: localStorage.getItem('oracular.install')!, mode: 'shadow' } as const;
836
870
  *
837
871
  * const event = { name: 'pricing.viewed', at: Date.now() };
872
+ * const previous = state;
838
873
  * state = observe(state, event);
839
- * appendLine(JSON.stringify(createRecord(state, event, recording)));
874
+ * appendLine(JSON.stringify(createRecord(state, event, recording, previous)));
840
875
  * ```
841
876
  */
842
- declare function createRecord(state: EngineState, event: IntentEvent, opts: RecordOptions): EventRecord;
877
+ declare function createRecord(state: EngineState, event: IntentEvent, opts: RecordOptions, previous?: EngineState): EventRecord;
843
878
  /**
844
879
  * A recorder record for the end of an episode. Write one whenever the app calls
845
880
  * {@link endEpisode}, with the same `at`, so that replay can end the episode at the same moment
package/dist/index.js CHANGED
@@ -897,10 +897,11 @@ function normalizeRecordOptions(value) {
897
897
  }
898
898
  return { installId, mode, top };
899
899
  }
900
- function createRecord(state, event, opts) {
900
+ function createRecord(state, event, opts, previous) {
901
901
  const s = requireState(state);
902
902
  const { name, at } = validateEvent(event);
903
903
  const { installId, mode, top } = normalizeRecordOptions(opts);
904
+ const prev = previous === void 0 ? void 0 : requireState(previous);
904
905
  const learning = resolveLearning(s.config);
905
906
  const decisions = {};
906
907
  for (const [intentName, intent] of Object.entries(s.config.intents)) {
@@ -912,7 +913,7 @@ function createRecord(state, event, opts) {
912
913
  for (const signal of Object.keys(intent.signals)) {
913
914
  weights[signal] = rows.find((row) => row.signal === signal).effectiveWeight;
914
915
  }
915
- decisions[intentName] = {
916
+ const decision = {
916
917
  p: prediction.p,
917
918
  lower: prediction.lower,
918
919
  shouldReveal: decideReveal(prediction, reveal),
@@ -920,6 +921,17 @@ function createRecord(state, event, opts) {
920
921
  top: rows.filter((row) => row.contribution !== 0).slice(0, top).map((row) => ({ signal: row.signal, impact: row.impact })),
921
922
  weights
922
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;
923
935
  }
924
936
  return { v: RECORD_VERSION, kind: "event", installId, mode, event: { name, at }, decisions };
925
937
  }