oracular 0.7.0 → 0.8.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 `lower` is computed. `'normal'` (default): p − z·sd, clamped to [0, 1]. `'exact'`: the
59
+ * Beta distribution's own quantile at the same one-sided level, Φ(−z), so `z` keeps its
60
+ * meaning. The two differ on thin evidence near the threshold, where the approximation is
61
+ * optimistic; under `'exact'`, `z: 0` is the median rather than the mean.
62
+ */
63
+ bound?: 'normal' | 'exact';
57
64
  }
58
65
  /**
59
66
  * How the engine learns from the user's own outcomes.
@@ -261,7 +268,10 @@ interface Prediction {
261
268
  p: number;
262
269
  /** Standard deviation of the Beta distribution. */
263
270
  sd: number;
264
- /** p − z·sd, clamped to [0, 1]. This is what `shouldReveal` compares to the threshold. */
271
+ /**
272
+ * The lower confidence bound `shouldReveal` compares to the threshold: p − z·sd clamped to
273
+ * [0, 1], or with `bound: 'exact'` the Beta quantile at Φ(−z).
274
+ */
265
275
  lower: number;
266
276
  /** 1 − 2·sd, clamped to [0, 1]. A heuristic: 1 when the estimate is sharp, 0 when it is as vague as possible. */
267
277
  confidence: number;
@@ -515,8 +525,8 @@ interface PresetSource {
515
525
  * @param state - The current state. Not mutated.
516
526
  * @param intent - Name of an intent declared in the config.
517
527
  * @param at - The time to evaluate at.
518
- * @param opts - Reveal overrides (`threshold`, `z`, `cooldown`) and explain filters (`nonZero`,
519
- * `limit`) in one object.
528
+ * @param opts - Reveal overrides (`threshold`, `z`, `cooldown`, `bound`) and explain filters
529
+ * (`nonZero`, `limit`) in one object.
520
530
  * @returns `prediction` (as `predict`), `reveal` (as `shouldReveal`) and `explanation` (as
521
531
  * `explain` with the same filters).
522
532
  * @throws `RangeError` when `intent` is not declared in the config.
@@ -552,7 +562,8 @@ declare function inspect(state: EngineState, at: number, opts?: EvaluateOptions)
552
562
  /**
553
563
  * How much positive evidence an intent needs before {@link shouldReveal} first becomes true:
554
564
  * the smallest pseudo-count `w` such that Beta(α₀ + w, β₀) clears both `p ≥ threshold` and
555
- * `p − z·sd ≥ threshold`. Divide by a signal's weight and round up for the number of full-weight
565
+ * `lower ≥ threshold`, with `lower` as `bound` defines it (p − z·sd, or the exact Beta quantile).
566
+ * Divide by a signal's weight and round up for the number of full-weight
556
567
  * occurrences that open the gate: distinct signals, or one occurrence per episode. Repeats within
557
568
  * one episode add less (n of them add `w × log2(1 + n)`, see {@link IntentConfig.saturate}).
558
569
  * Solved numerically to 1e-9.
@@ -560,8 +571,8 @@ declare function inspect(state: EngineState, at: number, opts?: EvaluateOptions)
560
571
  * Cooldowns and decay are not part of the answer: this is the evidence needed at one instant.
561
572
  *
562
573
  * @param intent - One intent's config, validated as `createState` would.
563
- * @param opts - `threshold` and `z` overrides, resolved as call → intent → defaults (there is
564
- * no engine config here). `cooldown` is accepted and ignored.
574
+ * @param opts - `threshold`, `z` and `bound` overrides, resolved as call → intent → defaults
575
+ * (there is no engine config here). `cooldown` is accepted and ignored.
565
576
  * @returns The pseudo-count sum; 0 when the prior alone already clears the gate; `Infinity`
566
577
  * when no finite amount of evidence can (threshold 1).
567
578
  * @throws `TypeError` or `RangeError` when `intent` or `opts` is invalid.
@@ -679,9 +690,9 @@ declare function episodeClosed(prev: EngineState, next: EngineState): boolean;
679
690
  * @param state - The current state. Not mutated.
680
691
  * @param intent - Name of an intent declared in the config.
681
692
  * @param at - The time to evaluate at, in the same unit as your durations.
682
- * @param opts - Overrides for `threshold`, `z` and `cooldown`, resolved as call → intent →
683
- * engine → defaults. `z` sets `lower`; `cooldown` sets `revealed` and `suppressedUntil`;
684
- * `threshold` only matters to {@link shouldReveal}.
693
+ * @param opts - Overrides for `threshold`, `z`, `cooldown` and `bound`, resolved as call →
694
+ * intent → engine → defaults. `z` and `bound` set `lower`; `cooldown` sets `revealed` and
695
+ * `suppressedUntil`; `threshold` only matters to {@link shouldReveal}.
685
696
  * @returns The prediction. `p`, `sd`, `lower` and `confidence` are in [0, 1]; `evidence` is
686
697
  * α + β.
687
698
  * @throws `RangeError` when `intent` is not declared in the config.
@@ -696,7 +707,8 @@ declare function episodeClosed(prev: EngineState, next: EngineState): boolean;
696
707
  declare function predict(state: EngineState, intent: string, at: number, opts?: RevealOptions): Prediction;
697
708
  /**
698
709
  * Whether to act on the intent now. True when p ≥ `threshold`, the lower confidence bound
699
- * p − z·sd also clears `threshold`, and no cooldown is running: neither the one started by
710
+ * (p − z·sd, or the exact Beta quantile with `bound: 'exact'`) also clears `threshold`, and no
711
+ * cooldown is running: neither the one started by
700
712
  * {@link markRevealed} nor the one started by the intent's outcome event.
701
713
  *
702
714
  * @param state - The current state. Not mutated.
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 `lower` is computed. `'normal'` (default): p − z·sd, clamped to [0, 1]. `'exact'`: the
59
+ * Beta distribution's own quantile at the same one-sided level, Φ(−z), so `z` keeps its
60
+ * meaning. The two differ on thin evidence near the threshold, where the approximation is
61
+ * optimistic; under `'exact'`, `z: 0` is the median rather than the mean.
62
+ */
63
+ bound?: 'normal' | 'exact';
57
64
  }
58
65
  /**
59
66
  * How the engine learns from the user's own outcomes.
@@ -261,7 +268,10 @@ interface Prediction {
261
268
  p: number;
262
269
  /** Standard deviation of the Beta distribution. */
263
270
  sd: number;
264
- /** p − z·sd, clamped to [0, 1]. This is what `shouldReveal` compares to the threshold. */
271
+ /**
272
+ * The lower confidence bound `shouldReveal` compares to the threshold: p − z·sd clamped to
273
+ * [0, 1], or with `bound: 'exact'` the Beta quantile at Φ(−z).
274
+ */
265
275
  lower: number;
266
276
  /** 1 − 2·sd, clamped to [0, 1]. A heuristic: 1 when the estimate is sharp, 0 when it is as vague as possible. */
267
277
  confidence: number;
@@ -515,8 +525,8 @@ interface PresetSource {
515
525
  * @param state - The current state. Not mutated.
516
526
  * @param intent - Name of an intent declared in the config.
517
527
  * @param at - The time to evaluate at.
518
- * @param opts - Reveal overrides (`threshold`, `z`, `cooldown`) and explain filters (`nonZero`,
519
- * `limit`) in one object.
528
+ * @param opts - Reveal overrides (`threshold`, `z`, `cooldown`, `bound`) and explain filters
529
+ * (`nonZero`, `limit`) in one object.
520
530
  * @returns `prediction` (as `predict`), `reveal` (as `shouldReveal`) and `explanation` (as
521
531
  * `explain` with the same filters).
522
532
  * @throws `RangeError` when `intent` is not declared in the config.
@@ -552,7 +562,8 @@ declare function inspect(state: EngineState, at: number, opts?: EvaluateOptions)
552
562
  /**
553
563
  * How much positive evidence an intent needs before {@link shouldReveal} first becomes true:
554
564
  * the smallest pseudo-count `w` such that Beta(α₀ + w, β₀) clears both `p ≥ threshold` and
555
- * `p − z·sd ≥ threshold`. Divide by a signal's weight and round up for the number of full-weight
565
+ * `lower ≥ threshold`, with `lower` as `bound` defines it (p − z·sd, or the exact Beta quantile).
566
+ * Divide by a signal's weight and round up for the number of full-weight
556
567
  * occurrences that open the gate: distinct signals, or one occurrence per episode. Repeats within
557
568
  * one episode add less (n of them add `w × log2(1 + n)`, see {@link IntentConfig.saturate}).
558
569
  * Solved numerically to 1e-9.
@@ -560,8 +571,8 @@ declare function inspect(state: EngineState, at: number, opts?: EvaluateOptions)
560
571
  * Cooldowns and decay are not part of the answer: this is the evidence needed at one instant.
561
572
  *
562
573
  * @param intent - One intent's config, validated as `createState` would.
563
- * @param opts - `threshold` and `z` overrides, resolved as call → intent → defaults (there is
564
- * no engine config here). `cooldown` is accepted and ignored.
574
+ * @param opts - `threshold`, `z` and `bound` overrides, resolved as call → intent → defaults
575
+ * (there is no engine config here). `cooldown` is accepted and ignored.
565
576
  * @returns The pseudo-count sum; 0 when the prior alone already clears the gate; `Infinity`
566
577
  * when no finite amount of evidence can (threshold 1).
567
578
  * @throws `TypeError` or `RangeError` when `intent` or `opts` is invalid.
@@ -679,9 +690,9 @@ declare function episodeClosed(prev: EngineState, next: EngineState): boolean;
679
690
  * @param state - The current state. Not mutated.
680
691
  * @param intent - Name of an intent declared in the config.
681
692
  * @param at - The time to evaluate at, in the same unit as your durations.
682
- * @param opts - Overrides for `threshold`, `z` and `cooldown`, resolved as call → intent →
683
- * engine → defaults. `z` sets `lower`; `cooldown` sets `revealed` and `suppressedUntil`;
684
- * `threshold` only matters to {@link shouldReveal}.
693
+ * @param opts - Overrides for `threshold`, `z`, `cooldown` and `bound`, resolved as call →
694
+ * intent → engine → defaults. `z` and `bound` set `lower`; `cooldown` sets `revealed` and
695
+ * `suppressedUntil`; `threshold` only matters to {@link shouldReveal}.
685
696
  * @returns The prediction. `p`, `sd`, `lower` and `confidence` are in [0, 1]; `evidence` is
686
697
  * α + β.
687
698
  * @throws `RangeError` when `intent` is not declared in the config.
@@ -696,7 +707,8 @@ declare function episodeClosed(prev: EngineState, next: EngineState): boolean;
696
707
  declare function predict(state: EngineState, intent: string, at: number, opts?: RevealOptions): Prediction;
697
708
  /**
698
709
  * Whether to act on the intent now. True when p ≥ `threshold`, the lower confidence bound
699
- * p − z·sd also clears `threshold`, and no cooldown is running: neither the one started by
710
+ * (p − z·sd, or the exact Beta quantile with `bound: 'exact'`) also clears `threshold`, and no
711
+ * cooldown is running: neither the one started by
700
712
  * {@link markRevealed} nor the one started by the intent's outcome event.
701
713
  *
702
714
  * @param state - The current state. Not mutated.
package/dist/index.js CHANGED
@@ -18,6 +18,62 @@ function clamp01(x) {
18
18
  if (x > 1) return 1;
19
19
  return x;
20
20
  }
21
+ function normalTail(z) {
22
+ const x = z / Math.SQRT2;
23
+ const t = 1 / (1 + 0.3275911 * x);
24
+ const poly = t * (0.254829592 + t * (-0.284496736 + t * (1.421413741 + t * (-1.453152027 + t * 1.061405429))));
25
+ return 0.5 * poly * Math.exp(-x * x);
26
+ }
27
+ function lgamma(x) {
28
+ let acc = 0;
29
+ let v = x;
30
+ while (v < 8) {
31
+ acc -= Math.log(v);
32
+ v += 1;
33
+ }
34
+ const r = 1 / v;
35
+ const r2 = r * r;
36
+ return acc + (v - 0.5) * Math.log(v) - v + 0.9189385332046728 + r * (1 / 12 - r2 * (1 / 360 - r2 * (1 / 1260 - r2 / 1680)));
37
+ }
38
+ function betacf(x, a, b) {
39
+ const tiny = 1e-300;
40
+ const guard = (v) => Math.abs(v) < tiny ? tiny : v;
41
+ let c = 1 / tiny;
42
+ let d = 1;
43
+ let h = 1;
44
+ for (let i = 1; i < 2e4; i++) {
45
+ const m = i >> 1;
46
+ const aa = (i & 1 ? -(a + m) * (a + b + m) : m * (b - m)) * x / ((a + i - 1) * (a + i));
47
+ d = 1 / guard(1 + aa * d);
48
+ c = guard(1 + aa / c);
49
+ const del = c * d;
50
+ h *= del;
51
+ if (Math.abs(del - 1) < 1e-12) break;
52
+ }
53
+ return h;
54
+ }
55
+ function incompleteBeta(x, a, b) {
56
+ if (x <= 0) return 0;
57
+ if (x >= 1) return 1;
58
+ const front = Math.exp(
59
+ lgamma(a + b) - lgamma(a) - lgamma(b) + a * Math.log(x) + b * Math.log(1 - x)
60
+ );
61
+ return x < (a + 1) / (a + b + 2) ? front * betacf(x, a, b) / a : 1 - front * betacf(1 - x, b, a) / b;
62
+ }
63
+ function betaQuantile(q, a, b) {
64
+ let lo = 0;
65
+ let hi = 1;
66
+ for (let i = 0; i < 40 && hi - lo > 1e-9; i++) {
67
+ const mid = (lo + hi) / 2;
68
+ if (incompleteBeta(mid, a, b) < q) lo = mid;
69
+ else hi = mid;
70
+ }
71
+ return (lo + hi) / 2;
72
+ }
73
+ function lowerBound(alpha, beta, z, bound) {
74
+ if (bound === "exact") return betaQuantile(normalTail(z), alpha, beta);
75
+ return clamp01(betaMean(alpha, beta) - z * betaSd(alpha, beta));
76
+ }
21
77
  function repeatFactor(n) {
22
78
  return Math.log1p(1 / n) / Math.LN2;
23
79
  }
@@ -198,7 +254,7 @@ var INTENT_KEYS = [
198
254
  "attribution",
199
255
  "reveal"
200
256
  ];
201
- var REVEAL_KEYS = ["threshold", "z", "cooldown"];
257
+ var REVEAL_KEYS = ["threshold", "z", "cooldown", "bound"];
202
258
  var LEARNING_KEYS = ["halfLife", "gain", "priorEpisodes"];
203
259
  var EXPLAIN_KEYS = ["nonZero", "limit"];
204
260
  function typeError(path, message) {
@@ -272,6 +328,12 @@ function normalizeRevealOptions(value, path) {
272
328
  if (record2.cooldown !== void 0) {
273
329
  out.cooldown = expectNonNegative(record2.cooldown, `${path}.cooldown`);
274
330
  }
331
+ if (record2.bound !== void 0) {
332
+ if (record2.bound !== "normal" && record2.bound !== "exact") {
333
+ typeError(`${path}.bound`, 'must be "normal" or "exact"');
334
+ }
335
+ out.bound = record2.bound;
336
+ }
275
337
  return out;
276
338
  }
277
339
  function normalizeLearning(value, path) {
@@ -392,7 +454,8 @@ function resolveReveal(config, intent, opts, normalized = false) {
392
454
  return {
393
455
  threshold: o.threshold ?? intent.reveal?.threshold ?? config?.reveal?.threshold ?? DEFAULT_THRESHOLD,
394
456
  z: o.z ?? intent.reveal?.z ?? config?.reveal?.z ?? DEFAULT_Z,
395
- cooldown: o.cooldown ?? intent.reveal?.cooldown ?? config?.reveal?.cooldown ?? intent.halfLife
457
+ cooldown: o.cooldown ?? intent.reveal?.cooldown ?? config?.reveal?.cooldown ?? intent.halfLife,
458
+ bound: o.bound ?? intent.reveal?.bound ?? config?.reveal?.bound ?? "normal"
396
459
  };
397
460
  }
398
461
  function resolveLearning(config) {
@@ -499,7 +562,7 @@ function buildPrediction(is, name, reveal, at) {
499
562
  intent: name,
500
563
  p,
501
564
  sd,
502
- lower: clamp01(p - reveal.z * sd),
565
+ lower: lowerBound(is.alpha, is.beta, reveal.z, reveal.bound),
503
566
  confidence: clamp01(1 - 2 * sd),
504
567
  evidence: is.alpha + is.beta,
505
568
  revealed: revealEnd !== null,
@@ -567,13 +630,9 @@ function inspect(state, at, opts) {
567
630
  // src/evidence.ts
568
631
  function evidenceToReveal(intent, opts) {
569
632
  const config = normalizeIntentConfig(intent, "intent");
570
- const { threshold, z } = resolveReveal(void 0, config, opts);
633
+ const { threshold, z, bound } = resolveReveal(void 0, config, opts);
571
634
  const { alpha0, beta0 } = priorCounts(config);
572
- const open = (w) => {
573
- const alpha = alpha0 + w;
574
- const p = betaMean(alpha, beta0);
575
- return p >= threshold && clamp01(p - z * betaSd(alpha, beta0)) >= threshold;
576
- };
635
+ const open = (w) => betaMean(alpha0 + w, beta0) >= threshold && lowerBound(alpha0 + w, beta0, z, bound) >= threshold;
577
636
  if (threshold >= 1) return Number.POSITIVE_INFINITY;
578
637
  if (open(0)) return 0;
579
638
  let lo = 0;
@@ -1009,8 +1068,11 @@ function reconcile(raw, config) {
1009
1068
  keep(stored.contributions, intent.signals)
1010
1069
  );
1011
1070
  }
1012
- const episode = getOwn(raw.episode.intents, name);
1013
- if (episode !== void 0 && raw.episode.startedAt !== null) {
1071
+ }
1072
+ if (raw.episode.startedAt !== null) {
1073
+ for (const [name, episode] of Object.entries(raw.episode.intents)) {
1074
+ const intent = getOwn(config.intents, name);
1075
+ if (intent === void 0) continue;
1014
1076
  const seen = episode.seen.filter(
1015
1077
  (s, i) => hasOwn(intent.signals, s) && episode.seen.indexOf(s) === i
1016
1078
  );