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/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), credit
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 malformed, `opts` is
817
- * not an object, has an unknown key, an empty `installId` or a `mode` other than
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), credit
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 malformed, `opts` is
817
- * not an object, has an unknown key, an empty `installId` or a `mode` other than
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) is = recordEpisode(is, record2.seen, 1);
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) continue;
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
- decisions[intentName] = {
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
  }