oracular 0.13.0 → 0.15.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
@@ -54,6 +54,13 @@ interface RevealOptions {
54
54
  z?: number;
55
55
  /** How long after `markRevealed` the intent stays revealed and `shouldReveal` is false. Default: the intent's `halfLife`. */
56
56
  cooldown?: number;
57
+ /**
58
+ * How long after the intent's outcome event `shouldReveal` stays false. Default: `cooldown`.
59
+ * 0 is allowed: the outcome then pauses nothing, while a reveal still starts its cooldown, for
60
+ * an intent whose outcome recurs within minutes, or a "stuck" intent whose help should be back
61
+ * at the next attempt.
62
+ */
63
+ outcomeCooldown?: number;
57
64
  /**
58
65
  * How `lower` is computed. `'normal'` (default): p − z·sd, clamped to [0, 1]. `'exact'`: the
59
66
  * Beta distribution's own quantile at the same one-sided level, Φ(−z), so `z` keeps its
@@ -131,6 +138,18 @@ interface IntentConfig {
131
138
  * weight.
132
139
  */
133
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;
134
153
  /**
135
154
  * How long a closed episode's signals wait for the outcome before they take a miss, in the same
136
155
  * unit as `at`. With it set, an episode that ends without the outcome is held instead of
@@ -307,8 +326,8 @@ interface Prediction {
307
326
  revealedAt: number | null;
308
327
  /**
309
328
  * When the current suppression ends: the later of the reveal cooldown (after `markRevealed`)
310
- * and the outcome cooldown (after the outcome event) still running at `at`, or null when
311
- * neither is. `shouldReveal` is false while this is non-null.
329
+ * and the outcome cooldown (`outcomeCooldown` after the outcome event) still running at `at`,
330
+ * or null when neither is. `shouldReveal` is false while this is non-null.
312
331
  */
313
332
  suppressedUntil: number | null;
314
333
  }
@@ -650,7 +669,8 @@ declare function explain(state: EngineState, intent: string, at: number, opts?:
650
669
  * it either way; otherwise every occurrence adds all of `w`. For every intent the event is the
651
670
  * outcome of: give the signals remembered since the episode started or the outcome last arrived a
652
671
  * hit and count the episode toward the base rate (a repeated outcome with nothing seen since
653
- * counts nothing), credit
672
+ * counts nothing, unless `IntentConfig.episodePerOutcome` makes every outcome an episode of its
673
+ * own for the intent), credit
654
674
  * the closed episodes still waiting under `IntentConfig.attribution` by `2^(−age / halfLife)`
655
675
  * each, record `lastOutcomeAt`, and (by default) reset the evidence to the prior. Any event opens
656
676
  * or extends the current episode; an idle gap longer than `config.episodeTimeout` closes the
@@ -730,9 +750,10 @@ declare function episodeClosed(prev: EngineState, next: EngineState): boolean;
730
750
  * @param state - The current state. Not mutated.
731
751
  * @param intent - Name of an intent declared in the config.
732
752
  * @param at - The time to evaluate at, in the same unit as your durations.
733
- * @param opts - Overrides for `threshold`, `z`, `cooldown` and `bound`, resolved as call →
734
- * intent → engine → defaults. `z` and `bound` set `lower`; `cooldown` sets `revealed` and
735
- * `suppressedUntil`; `threshold` only matters to {@link shouldReveal}.
753
+ * @param opts - Overrides for `threshold`, `z`, `cooldown`, `outcomeCooldown` and `bound`,
754
+ * resolved as call → intent → engine → defaults. `z` and `bound` set `lower`; `cooldown` sets
755
+ * `revealed` and, with `outcomeCooldown` (default: `cooldown`), `suppressedUntil`;
756
+ * `threshold` only matters to {@link shouldReveal}.
736
757
  * @returns The prediction. `p`, `sd`, `lower` and `confidence` are in [0, 1]; `evidence` is
737
758
  * α + β.
738
759
  * @throws `RangeError` when `intent` is not declared in the config.
@@ -755,7 +776,8 @@ declare function predict(state: EngineState, intent: string, at: number, opts?:
755
776
  * @param intent - Name of an intent declared in the config.
756
777
  * @param at - The time to evaluate at.
757
778
  * @param opts - Overrides for `threshold` (default 0.5), `z` (default 1.28, a one-sided 90%
758
- * bound) and `cooldown` (default: the intent's `halfLife`).
779
+ * bound), `cooldown` (default: the intent's `halfLife`) and `outcomeCooldown` (default:
780
+ * `cooldown`; 0 lets the outcome pause nothing).
759
781
  * @returns True when the app should show, offer or reveal whatever this intent gates.
760
782
  * @throws `RangeError` when `intent` is not declared in the config.
761
783
  * @throws `TypeError` when `state` is not an engine state, `at` is not finite, or `opts` is
package/dist/index.d.ts CHANGED
@@ -54,6 +54,13 @@ interface RevealOptions {
54
54
  z?: number;
55
55
  /** How long after `markRevealed` the intent stays revealed and `shouldReveal` is false. Default: the intent's `halfLife`. */
56
56
  cooldown?: number;
57
+ /**
58
+ * How long after the intent's outcome event `shouldReveal` stays false. Default: `cooldown`.
59
+ * 0 is allowed: the outcome then pauses nothing, while a reveal still starts its cooldown, for
60
+ * an intent whose outcome recurs within minutes, or a "stuck" intent whose help should be back
61
+ * at the next attempt.
62
+ */
63
+ outcomeCooldown?: number;
57
64
  /**
58
65
  * How `lower` is computed. `'normal'` (default): p − z·sd, clamped to [0, 1]. `'exact'`: the
59
66
  * Beta distribution's own quantile at the same one-sided level, Φ(−z), so `z` keeps its
@@ -131,6 +138,18 @@ interface IntentConfig {
131
138
  * weight.
132
139
  */
133
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;
134
153
  /**
135
154
  * How long a closed episode's signals wait for the outcome before they take a miss, in the same
136
155
  * unit as `at`. With it set, an episode that ends without the outcome is held instead of
@@ -307,8 +326,8 @@ interface Prediction {
307
326
  revealedAt: number | null;
308
327
  /**
309
328
  * When the current suppression ends: the later of the reveal cooldown (after `markRevealed`)
310
- * and the outcome cooldown (after the outcome event) still running at `at`, or null when
311
- * neither is. `shouldReveal` is false while this is non-null.
329
+ * and the outcome cooldown (`outcomeCooldown` after the outcome event) still running at `at`,
330
+ * or null when neither is. `shouldReveal` is false while this is non-null.
312
331
  */
313
332
  suppressedUntil: number | null;
314
333
  }
@@ -650,7 +669,8 @@ declare function explain(state: EngineState, intent: string, at: number, opts?:
650
669
  * it either way; otherwise every occurrence adds all of `w`. For every intent the event is the
651
670
  * outcome of: give the signals remembered since the episode started or the outcome last arrived a
652
671
  * hit and count the episode toward the base rate (a repeated outcome with nothing seen since
653
- * counts nothing), credit
672
+ * counts nothing, unless `IntentConfig.episodePerOutcome` makes every outcome an episode of its
673
+ * own for the intent), credit
654
674
  * the closed episodes still waiting under `IntentConfig.attribution` by `2^(−age / halfLife)`
655
675
  * each, record `lastOutcomeAt`, and (by default) reset the evidence to the prior. Any event opens
656
676
  * or extends the current episode; an idle gap longer than `config.episodeTimeout` closes the
@@ -730,9 +750,10 @@ declare function episodeClosed(prev: EngineState, next: EngineState): boolean;
730
750
  * @param state - The current state. Not mutated.
731
751
  * @param intent - Name of an intent declared in the config.
732
752
  * @param at - The time to evaluate at, in the same unit as your durations.
733
- * @param opts - Overrides for `threshold`, `z`, `cooldown` and `bound`, resolved as call →
734
- * intent → engine → defaults. `z` and `bound` set `lower`; `cooldown` sets `revealed` and
735
- * `suppressedUntil`; `threshold` only matters to {@link shouldReveal}.
753
+ * @param opts - Overrides for `threshold`, `z`, `cooldown`, `outcomeCooldown` and `bound`,
754
+ * resolved as call → intent → engine → defaults. `z` and `bound` set `lower`; `cooldown` sets
755
+ * `revealed` and, with `outcomeCooldown` (default: `cooldown`), `suppressedUntil`;
756
+ * `threshold` only matters to {@link shouldReveal}.
736
757
  * @returns The prediction. `p`, `sd`, `lower` and `confidence` are in [0, 1]; `evidence` is
737
758
  * α + β.
738
759
  * @throws `RangeError` when `intent` is not declared in the config.
@@ -755,7 +776,8 @@ declare function predict(state: EngineState, intent: string, at: number, opts?:
755
776
  * @param intent - Name of an intent declared in the config.
756
777
  * @param at - The time to evaluate at.
757
778
  * @param opts - Overrides for `threshold` (default 0.5), `z` (default 1.28, a one-sided 90%
758
- * bound) and `cooldown` (default: the intent's `halfLife`).
779
+ * bound), `cooldown` (default: the intent's `halfLife`) and `outcomeCooldown` (default:
780
+ * `cooldown`; 0 lets the outcome pause nothing).
759
781
  * @returns True when the app should show, offer or reveal whatever this intent gates.
760
782
  * @throws `RangeError` when `intent` is not declared in the config.
761
783
  * @throws `TypeError` when `state` is not an engine state, `at` is not finite, or `opts` is
package/dist/index.js CHANGED
@@ -284,10 +284,11 @@ var INTENT_KEYS = [
284
284
  "outcome",
285
285
  "resetOnOutcome",
286
286
  "saturate",
287
+ "episodePerOutcome",
287
288
  "attribution",
288
289
  "reveal"
289
290
  ];
290
- var REVEAL_KEYS = ["threshold", "z", "cooldown", "bound"];
291
+ var REVEAL_KEYS = ["threshold", "z", "cooldown", "outcomeCooldown", "bound"];
291
292
  var LEARNING_KEYS = ["halfLife", "gain", "priorEpisodes", "lift"];
292
293
  var LIFT_TYPES = [
293
294
  "absolute-difference",
@@ -373,6 +374,9 @@ function normalizeRevealOptions(value, path, strict = true) {
373
374
  if (record2.cooldown !== void 0) {
374
375
  out.cooldown = expectNonNegative(record2.cooldown, `${path}.cooldown`);
375
376
  }
377
+ if (record2.outcomeCooldown !== void 0) {
378
+ out.outcomeCooldown = expectNonNegative(record2.outcomeCooldown, `${path}.outcomeCooldown`);
379
+ }
376
380
  if (record2.bound !== void 0) {
377
381
  if (record2.bound !== "normal" && record2.bound !== "exact") {
378
382
  typeError(`${path}.bound`, 'must be "normal" or "exact"');
@@ -438,6 +442,12 @@ function normalizeIntentConfig(value, path, strict = true) {
438
442
  if (typeof record2.saturate !== "boolean") typeError(`${path}.saturate`, "must be a boolean");
439
443
  out.saturate = record2.saturate;
440
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
+ }
441
451
  if (record2.attribution !== void 0) {
442
452
  out.attribution = expectPositive(record2.attribution, `${path}.attribution`);
443
453
  }
@@ -511,10 +521,12 @@ function splitEvaluateOptions(value, path) {
511
521
  }
512
522
  function resolveReveal(config, intent, opts, normalized = false) {
513
523
  const o = opts === void 0 ? {} : normalized ? opts : normalizeRevealOptions(opts, "options", false);
524
+ const cooldown = o.cooldown ?? intent.reveal?.cooldown ?? config?.reveal?.cooldown ?? intent.halfLife;
514
525
  return {
515
526
  threshold: o.threshold ?? intent.reveal?.threshold ?? config?.reveal?.threshold ?? DEFAULT_THRESHOLD,
516
527
  z: o.z ?? intent.reveal?.z ?? config?.reveal?.z ?? DEFAULT_Z,
517
- cooldown: o.cooldown ?? intent.reveal?.cooldown ?? config?.reveal?.cooldown ?? intent.halfLife,
528
+ cooldown,
529
+ outcomeCooldown: o.outcomeCooldown ?? intent.reveal?.outcomeCooldown ?? config?.reveal?.outcomeCooldown ?? cooldown,
518
530
  bound: o.bound ?? intent.reveal?.bound ?? config?.reveal?.bound ?? "normal"
519
531
  };
520
532
  }
@@ -619,7 +631,7 @@ function buildPrediction(is, name, reveal, at) {
619
631
  const p = betaMean(is.alpha, is.beta);
620
632
  const sd = betaSd(is.alpha, is.beta);
621
633
  const revealEnd = cooldownEnd(is.lastRevealedAt, reveal.cooldown, at);
622
- const outcomeEnd = cooldownEnd(is.lastOutcomeAt, reveal.cooldown, at);
634
+ const outcomeEnd = cooldownEnd(is.lastOutcomeAt, reveal.outcomeCooldown, at);
623
635
  const suppressedUntil = revealEnd === null ? outcomeEnd : outcomeEnd === null ? revealEnd : Math.max(revealEnd, outcomeEnd);
624
636
  return {
625
637
  intent: name,
@@ -752,7 +764,9 @@ function observe(state, event) {
752
764
  }
753
765
  if (isOutcome) {
754
766
  const record2 = episodeRecord(episode, intentName);
755
- 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
+ }
756
770
  is = creditPending(is, intent, at);
757
771
  episode = markResolved(episode, intentName);
758
772
  is = { ...is, lastOutcomeAt: at };
@@ -805,7 +819,8 @@ function closeEpisode(state, at) {
805
819
  const intents = { ...state.intents };
806
820
  for (const [intentName, intent] of Object.entries(state.config.intents)) {
807
821
  const record2 = episodeRecord(state.episode, intentName);
808
- if (record2.resolved) continue;
822
+ if (record2.resolved && (intent.episodePerOutcome !== true || record2.seen.length === 0))
823
+ continue;
809
824
  let is = getIntentState(state, intentName, intent);
810
825
  is = settlePending(is, intent, learning, at);
811
826
  is = intent.attribution === void 0 ? recordEpisode(is, record2.seen, 0) : deferEpisode(is, record2.seen, at);