oracular 0.7.1 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#44](https://github.com/Yuribenjamin/oracular/pull/44) [`912f60d`](https://github.com/Yuribenjamin/oracular/commit/912f60da297fe01a64decc1f91916cdcd3a98c60) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **Exact lower bound.** `RevealOptions.bound: 'exact'` makes `lower` the Beta distribution's own quantile at the one-sided level `z` names (Φ(−z), so 1.28 is still a 90% bound) instead of the normal approximation p − z·sd, which is optimistic exactly where the gate matters: thin evidence near the threshold. Beta(4, 3) puts p at 0.571, the approximation at 0.347 and the exact tenth percentile at 0.333; by Beta(30, 20) the two agree to a thousandth. `predict`, `shouldReveal`, `evaluate`, the recorder and `evidenceToReveal` all follow the option, resolved per call → intent → engine → default like the others, and the config schema lists it. Nothing moves unless it is set: every state and every number without `bound` is exactly what it was. Under `'exact'`, `z: 0` is the median rather than the mean, and `lower` can sit above `p` for a prior near 1 with little strength. A stored config that sets `bound` is read by 0.8 and later; 0.7 throws a `TypeError` on the unknown key, so drop the option before rolling a deploy back. The state version is unchanged. The quantile costs 0.54 kB: the core goes from 6.65 kB to 7.19 kB minified and gzipped, past the 7 kB ceiling the stability page set, so the ceiling moves once, to 7.9 kB (the shipped size plus about 10%), and the quickstart budget from 3.9 kB to 4.5 kB; the stability page records the decision.
8
+
3
9
  ## 0.7.1
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -140,7 +140,7 @@ Learned counts never decay unless you set `learning.halfLife`.
140
140
 
141
141
  ### Reveal only when confident
142
142
 
143
- `shouldReveal` is true when p ≥ threshold **and** p − z·sd ≥ threshold, where sd is the Beta standard deviation and z defaults to 1.28 (a one-sided 90% bound). Thin evidence with a high mean does not fire; it takes a few consistent signals. After you `markRevealed`, the intent stays revealed for a `cooldown` (default: its half-life) so you don't fire on every event. The same cooldown runs after the intent's outcome event. `predict` reports both: `revealed` is true inside a reveal cooldown, and `suppressedUntil` is when the later of the two cooldowns ends, or null when `shouldReveal` is free to fire.
143
+ `shouldReveal` is true when p ≥ threshold **and** p − z·sd ≥ threshold, where sd is the Beta standard deviation and z defaults to 1.28 (a one-sided 90% bound). Thin evidence with a high mean does not fire; it takes a few consistent signals. That lower bound is a normal approximation; `bound: 'exact'` swaps in the Beta distribution's own quantile at the same level, which asks for a little more on thin evidence. After you `markRevealed`, the intent stays revealed for a `cooldown` (default: its half-life) so you don't fire on every event. The same cooldown runs after the intent's outcome event. `predict` reports both: `revealed` is true inside a reveal cooldown, and `suppressedUntil` is when the later of the two cooldowns ends, or null when `shouldReveal` is free to fire.
144
144
 
145
145
  ### Outcomes reset the slate and pause reveals
146
146
 
@@ -299,6 +299,7 @@ interface RevealOptions {
299
299
  threshold?: number; // default 0.5
300
300
  z?: number; // default 1.28
301
301
  cooldown?: number; // default: the intent's halfLife
302
+ bound?: 'normal' | 'exact'; // default 'normal': p − z·sd; 'exact': the Beta quantile at Φ(−z)
302
303
  }
303
304
  ```
304
305
 
@@ -311,7 +312,7 @@ interface Prediction {
311
312
  intent: string;
312
313
  p: number; // α / (α + β)
313
314
  sd: number; // Beta standard deviation
314
- lower: number; // p − z·sd, clamped to [0, 1]; what shouldReveal compares to threshold
315
+ lower: number; // p − z·sd clamped to [0, 1], or the exact Beta quantile with bound: 'exact'; what shouldReveal compares to threshold
315
316
  confidence: number; // 1 − 2·sd, clamped to [0, 1]. A heuristic: 1 is a sharp estimate, 0 is as vague as a Beta gets
316
317
  evidence: number; // α + β, prior strength included
317
318
  revealed: boolean; // inside the cooldown after markRevealed
package/dist/index.cjs CHANGED
@@ -20,6 +20,62 @@ function clamp01(x) {
20
20
  if (x > 1) return 1;
21
21
  return x;
22
22
  }
23
+ function normalTail(z) {
24
+ const x = z / Math.SQRT2;
25
+ const t = 1 / (1 + 0.3275911 * x);
26
+ const poly = t * (0.254829592 + t * (-0.284496736 + t * (1.421413741 + t * (-1.453152027 + t * 1.061405429))));
27
+ return 0.5 * poly * Math.exp(-x * x);
28
+ }
29
+ function lgamma(x) {
30
+ let acc = 0;
31
+ let v = x;
32
+ while (v < 8) {
33
+ acc -= Math.log(v);
34
+ v += 1;
35
+ }
36
+ const r = 1 / v;
37
+ const r2 = r * r;
38
+ return acc + (v - 0.5) * Math.log(v) - v + 0.9189385332046728 + r * (1 / 12 - r2 * (1 / 360 - r2 * (1 / 1260 - r2 / 1680)));
39
+ }
40
+ function betacf(x, a, b) {
41
+ const tiny = 1e-300;
42
+ const guard = (v) => Math.abs(v) < tiny ? tiny : v;
43
+ let c = 1 / tiny;
44
+ let d = 1;
45
+ let h = 1;
46
+ for (let i = 1; i < 2e4; i++) {
47
+ const m = i >> 1;
48
+ const aa = (i & 1 ? -(a + m) * (a + b + m) : m * (b - m)) * x / ((a + i - 1) * (a + i));
49
+ d = 1 / guard(1 + aa * d);
50
+ c = guard(1 + aa / c);
51
+ const del = c * d;
52
+ h *= del;
53
+ if (Math.abs(del - 1) < 1e-12) break;
54
+ }
55
+ return h;
56
+ }
57
+ function incompleteBeta(x, a, b) {
58
+ if (x <= 0) return 0;
59
+ if (x >= 1) return 1;
60
+ const front = Math.exp(
61
+ lgamma(a + b) - lgamma(a) - lgamma(b) + a * Math.log(x) + b * Math.log(1 - x)
62
+ );
63
+ return x < (a + 1) / (a + b + 2) ? front * betacf(x, a, b) / a : 1 - front * betacf(1 - x, b, a) / b;
64
+ }
65
+ function betaQuantile(q, a, b) {
66
+ let lo = 0;
67
+ let hi = 1;
68
+ for (let i = 0; i < 40 && hi - lo > 1e-9; i++) {
69
+ const mid = (lo + hi) / 2;
70
+ if (incompleteBeta(mid, a, b) < q) lo = mid;
71
+ else hi = mid;
72
+ }
73
+ return (lo + hi) / 2;
74
+ }
75
+ function lowerBound(alpha, beta, z, bound) {
76
+ if (bound === "exact") return betaQuantile(normalTail(z), alpha, beta);
77
+ return clamp01(betaMean(alpha, beta) - z * betaSd(alpha, beta));
78
+ }
23
79
  function repeatFactor(n) {
24
80
  return Math.log1p(1 / n) / Math.LN2;
25
81
  }
@@ -200,7 +256,7 @@ var INTENT_KEYS = [
200
256
  "attribution",
201
257
  "reveal"
202
258
  ];
203
- var REVEAL_KEYS = ["threshold", "z", "cooldown"];
259
+ var REVEAL_KEYS = ["threshold", "z", "cooldown", "bound"];
204
260
  var LEARNING_KEYS = ["halfLife", "gain", "priorEpisodes"];
205
261
  var EXPLAIN_KEYS = ["nonZero", "limit"];
206
262
  function typeError(path, message) {
@@ -274,6 +330,12 @@ function normalizeRevealOptions(value, path) {
274
330
  if (record2.cooldown !== void 0) {
275
331
  out.cooldown = expectNonNegative(record2.cooldown, `${path}.cooldown`);
276
332
  }
333
+ if (record2.bound !== void 0) {
334
+ if (record2.bound !== "normal" && record2.bound !== "exact") {
335
+ typeError(`${path}.bound`, 'must be "normal" or "exact"');
336
+ }
337
+ out.bound = record2.bound;
338
+ }
277
339
  return out;
278
340
  }
279
341
  function normalizeLearning(value, path) {
@@ -394,7 +456,8 @@ function resolveReveal(config, intent, opts, normalized = false) {
394
456
  return {
395
457
  threshold: o.threshold ?? intent.reveal?.threshold ?? config?.reveal?.threshold ?? DEFAULT_THRESHOLD,
396
458
  z: o.z ?? intent.reveal?.z ?? config?.reveal?.z ?? DEFAULT_Z,
397
- cooldown: o.cooldown ?? intent.reveal?.cooldown ?? config?.reveal?.cooldown ?? intent.halfLife
459
+ cooldown: o.cooldown ?? intent.reveal?.cooldown ?? config?.reveal?.cooldown ?? intent.halfLife,
460
+ bound: o.bound ?? intent.reveal?.bound ?? config?.reveal?.bound ?? "normal"
398
461
  };
399
462
  }
400
463
  function resolveLearning(config) {
@@ -501,7 +564,7 @@ function buildPrediction(is, name, reveal, at) {
501
564
  intent: name,
502
565
  p,
503
566
  sd,
504
- lower: clamp01(p - reveal.z * sd),
567
+ lower: lowerBound(is.alpha, is.beta, reveal.z, reveal.bound),
505
568
  confidence: clamp01(1 - 2 * sd),
506
569
  evidence: is.alpha + is.beta,
507
570
  revealed: revealEnd !== null,
@@ -569,13 +632,9 @@ function inspect(state, at, opts) {
569
632
  // src/evidence.ts
570
633
  function evidenceToReveal(intent, opts) {
571
634
  const config = normalizeIntentConfig(intent, "intent");
572
- const { threshold, z } = resolveReveal(void 0, config, opts);
635
+ const { threshold, z, bound } = resolveReveal(void 0, config, opts);
573
636
  const { alpha0, beta0 } = priorCounts(config);
574
- const open = (w) => {
575
- const alpha = alpha0 + w;
576
- const p = betaMean(alpha, beta0);
577
- return p >= threshold && clamp01(p - z * betaSd(alpha, beta0)) >= threshold;
578
- };
637
+ const open = (w) => betaMean(alpha0 + w, beta0) >= threshold && lowerBound(alpha0 + w, beta0, z, bound) >= threshold;
579
638
  if (threshold >= 1) return Number.POSITIVE_INFINITY;
580
639
  if (open(0)) return 0;
581
640
  let lo = 0;