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/CHANGELOG.md +16 -0
- package/README.md +7 -5
- package/dist/index.cjs +20 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +29 -7
- package/dist/index.d.ts +29 -7
- package/dist/index.js +20 -5
- package/dist/index.js.map +1 -1
- package/dist/inspect.d.cts +2 -2
- package/dist/inspect.d.ts +2 -2
- package/dist/presets.d.cts +19 -0
- package/dist/presets.d.ts +19 -0
- package/package.json +1 -1
- package/schema/config.schema.json +10 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.15.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#73](https://github.com/Yuribenjamin/oracular/pull/73) [`3363311`](https://github.com/Yuribenjamin/oracular/commit/3363311a66fa2f3208a3be43f71523ac93979101) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **An episode per outcome, for intents shaped as attempts.** The episode is the session, shared by every intent, so for an upload that fails, completes, and completes again in one session the engine counted one episode: the second completion, with nothing seen before it, was a duplicate under 0.10's rule, and a second attempt that failed and never completed stayed neutral at the close. The failure signal never saw an outcome without it, which is the contrast learning needs, and `tune` printed "1 counted episode" where the app saw two. `IntentConfig.episodePerOutcome: true` makes each outcome an episode of its own for that intent: a later outcome in the same session counts as another episode even with nothing seen since, and signals seen after an outcome take a miss at the session's close, or wait under `attribution`. Everything else is as before, `resetOnOutcome` is independent, and `tune` follows the engine's counts. Off unless set: every state and every number without it is exactly what it was. With it set, a duplicated outcome event counts twice, so dedupe such events in the app for these intents. The JSON Schema lists it. Found by the second production integration, whose workaround, `endEpisode` on the outcome, ends the session for every other intent too.
|
|
8
|
+
|
|
9
|
+
**Rollback.** A stored config that sets `episodePerOutcome` loads in 0.13 and later, which do not read the stored snapshot when the app passes its config; 0.12 and earlier throw a `TypeError` on the unknown key, so remove it from the saved config before rolling a deploy back that far. The state version is unchanged.
|
|
10
|
+
|
|
11
|
+
## 0.14.0
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- [#71](https://github.com/Yuribenjamin/oracular/pull/71) [`d9e9769`](https://github.com/Yuribenjamin/oracular/commit/d9e9769e922c3bc4251f4585a6cc1c7697468958) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **A cooldown of its own after the outcome.** The outcome event paused reveals for the reveal `cooldown`: after a "stuck" intent's outcome, help was suppressed for the hour the cooldown named, and the next attempt that went wrong, exactly when the help should be back, got nothing; the only way out, `cooldown: 0`, disabled the reveal cooldown too. `reveal.outcomeCooldown` is the pause after the outcome, resolved per call → intent → engine like the other reveal options, 0 allowed; it defaults to the resolved `cooldown`, so nothing moves unless it is set. The JSON Schema lists it. Found by the second production integration.
|
|
16
|
+
|
|
17
|
+
**Rollback.** A stored config that sets `outcomeCooldown` loads in 0.13 and later, which do not read the stored snapshot when the app passes its config; 0.12 and earlier throw a `TypeError` on the unknown key, so remove it from the saved config before rolling a deploy back that far. The state version is unchanged.
|
|
18
|
+
|
|
3
19
|
## 0.13.0
|
|
4
20
|
|
|
5
21
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -186,7 +186,7 @@ Before any update or reading, the evidence decays by 2^(−Δt / halfLife), wher
|
|
|
186
186
|
|
|
187
187
|
### The user's history corrects your weights
|
|
188
188
|
|
|
189
|
-
Events happen inside **episodes** (sessions). An episode ends when you call `endEpisode`, or on its own after `episodeTimeout` of inactivity. Signals seen in the episode are remembered per intent. When the intent's `outcome` event arrives, each remembered signal gets a **hit**; when the episode ends without it, each gets a **miss**, or, with `attribution` set on the intent, the episode waits and takes the share `2^(−age / halfLife)` of a hit if the outcome arrives inside the window. The engine also counts, per intent, how many episodes ended with and without the outcome: the intent's **base rate**. Signals seen after an outcome in the same episode count only if another outcome follows them; a repeated outcome with nothing seen since counts nothing.
|
|
189
|
+
Events happen inside **episodes** (sessions). An episode ends when you call `endEpisode`, or on its own after `episodeTimeout` of inactivity. Signals seen in the episode are remembered per intent. When the intent's `outcome` event arrives, each remembered signal gets a **hit**; when the episode ends without it, each gets a **miss**, or, with `attribution` set on the intent, the episode waits and takes the share `2^(−age / halfLife)` of a hit if the outcome arrives inside the window. The engine also counts, per intent, how many episodes ended with and without the outcome: the intent's **base rate**. Signals seen after an outcome in the same episode count only if another outcome follows them; a repeated outcome with nothing seen since counts nothing. For an intent shaped as attempts (an upload that fails, completes, and completes again), set `episodePerOutcome` and each outcome is an episode of its own: the second completion counts, with nothing seen, as the contrast learning needs, and failures after a completion take a miss when the session ends.
|
|
190
190
|
|
|
191
191
|
A signal's weight is then a blend of your prior and the learned estimate:
|
|
192
192
|
|
|
@@ -224,7 +224,7 @@ Learned counts never decay unless you set `learning.halfLife`.
|
|
|
224
224
|
|
|
225
225
|
When the outcome event arrives, the intent's evidence returns to the prior (α, β and contributions), while everything learned is kept. The user already upgraded; stop predicting that they will. Set `resetOnOutcome: false` for intents where the outcome can repeat inside one episode.
|
|
226
226
|
|
|
227
|
-
The outcome also starts the
|
|
227
|
+
The outcome also starts a cooldown, `outcomeCooldown`, by default the same `cooldown` a reveal starts: `shouldReveal` stays false until it ends, however strong the fresh evidence, so a user who just bought is not immediately pitched again. Set it on its own, 0 included, for an intent whose outcome recurs within minutes, or a "stuck" intent whose help should be back at the next attempt: the outcome then pauses nothing while a reveal still starts its cooldown. The time of the last outcome is kept on the state as `lastOutcomeAt`.
|
|
228
228
|
|
|
229
229
|
### Every answer comes with its reasons
|
|
230
230
|
|
|
@@ -297,14 +297,16 @@ interface IntentConfig {
|
|
|
297
297
|
outcome?: string; // event that confirms the intent
|
|
298
298
|
resetOnOutcome?: boolean; // default true
|
|
299
299
|
saturate?: boolean; // repeats within an episode add less; default: episodeTimeout is set
|
|
300
|
+
episodePerOutcome?: boolean; // each outcome is an episode of its own for this intent; default false: the session is
|
|
300
301
|
attribution?: number; // how long a closed episode waits for the outcome before it is a miss; off if omitted
|
|
301
302
|
reveal?: RevealOptions;
|
|
302
303
|
}
|
|
303
304
|
|
|
304
305
|
interface RevealOptions {
|
|
305
|
-
threshold?: number;
|
|
306
|
-
z?: number;
|
|
307
|
-
cooldown?: number;
|
|
306
|
+
threshold?: number; // default 0.5
|
|
307
|
+
z?: number; // default 1.28
|
|
308
|
+
cooldown?: number; // after markRevealed; default: the intent's halfLife
|
|
309
|
+
outcomeCooldown?: number; // after the outcome event; default: cooldown. 0 lets the outcome pause nothing
|
|
308
310
|
bound?: 'normal' | 'exact'; // default 'normal': p − z·sd; 'exact': the Beta quantile at Φ(−z)
|
|
309
311
|
}
|
|
310
312
|
```
|
package/dist/index.cjs
CHANGED
|
@@ -286,10 +286,11 @@ var INTENT_KEYS = [
|
|
|
286
286
|
"outcome",
|
|
287
287
|
"resetOnOutcome",
|
|
288
288
|
"saturate",
|
|
289
|
+
"episodePerOutcome",
|
|
289
290
|
"attribution",
|
|
290
291
|
"reveal"
|
|
291
292
|
];
|
|
292
|
-
var REVEAL_KEYS = ["threshold", "z", "cooldown", "bound"];
|
|
293
|
+
var REVEAL_KEYS = ["threshold", "z", "cooldown", "outcomeCooldown", "bound"];
|
|
293
294
|
var LEARNING_KEYS = ["halfLife", "gain", "priorEpisodes", "lift"];
|
|
294
295
|
var LIFT_TYPES = [
|
|
295
296
|
"absolute-difference",
|
|
@@ -375,6 +376,9 @@ function normalizeRevealOptions(value, path, strict = true) {
|
|
|
375
376
|
if (record2.cooldown !== void 0) {
|
|
376
377
|
out.cooldown = expectNonNegative(record2.cooldown, `${path}.cooldown`);
|
|
377
378
|
}
|
|
379
|
+
if (record2.outcomeCooldown !== void 0) {
|
|
380
|
+
out.outcomeCooldown = expectNonNegative(record2.outcomeCooldown, `${path}.outcomeCooldown`);
|
|
381
|
+
}
|
|
378
382
|
if (record2.bound !== void 0) {
|
|
379
383
|
if (record2.bound !== "normal" && record2.bound !== "exact") {
|
|
380
384
|
typeError(`${path}.bound`, 'must be "normal" or "exact"');
|
|
@@ -440,6 +444,12 @@ function normalizeIntentConfig(value, path, strict = true) {
|
|
|
440
444
|
if (typeof record2.saturate !== "boolean") typeError(`${path}.saturate`, "must be a boolean");
|
|
441
445
|
out.saturate = record2.saturate;
|
|
442
446
|
}
|
|
447
|
+
if (record2.episodePerOutcome !== void 0) {
|
|
448
|
+
if (typeof record2.episodePerOutcome !== "boolean") {
|
|
449
|
+
typeError(`${path}.episodePerOutcome`, "must be a boolean");
|
|
450
|
+
}
|
|
451
|
+
out.episodePerOutcome = record2.episodePerOutcome;
|
|
452
|
+
}
|
|
443
453
|
if (record2.attribution !== void 0) {
|
|
444
454
|
out.attribution = expectPositive(record2.attribution, `${path}.attribution`);
|
|
445
455
|
}
|
|
@@ -513,10 +523,12 @@ function splitEvaluateOptions(value, path) {
|
|
|
513
523
|
}
|
|
514
524
|
function resolveReveal(config, intent, opts, normalized = false) {
|
|
515
525
|
const o = opts === void 0 ? {} : normalized ? opts : normalizeRevealOptions(opts, "options", false);
|
|
526
|
+
const cooldown = o.cooldown ?? intent.reveal?.cooldown ?? config?.reveal?.cooldown ?? intent.halfLife;
|
|
516
527
|
return {
|
|
517
528
|
threshold: o.threshold ?? intent.reveal?.threshold ?? config?.reveal?.threshold ?? DEFAULT_THRESHOLD,
|
|
518
529
|
z: o.z ?? intent.reveal?.z ?? config?.reveal?.z ?? DEFAULT_Z,
|
|
519
|
-
cooldown
|
|
530
|
+
cooldown,
|
|
531
|
+
outcomeCooldown: o.outcomeCooldown ?? intent.reveal?.outcomeCooldown ?? config?.reveal?.outcomeCooldown ?? cooldown,
|
|
520
532
|
bound: o.bound ?? intent.reveal?.bound ?? config?.reveal?.bound ?? "normal"
|
|
521
533
|
};
|
|
522
534
|
}
|
|
@@ -621,7 +633,7 @@ function buildPrediction(is, name, reveal, at) {
|
|
|
621
633
|
const p = betaMean(is.alpha, is.beta);
|
|
622
634
|
const sd = betaSd(is.alpha, is.beta);
|
|
623
635
|
const revealEnd = cooldownEnd(is.lastRevealedAt, reveal.cooldown, at);
|
|
624
|
-
const outcomeEnd = cooldownEnd(is.lastOutcomeAt, reveal.
|
|
636
|
+
const outcomeEnd = cooldownEnd(is.lastOutcomeAt, reveal.outcomeCooldown, at);
|
|
625
637
|
const suppressedUntil = revealEnd === null ? outcomeEnd : outcomeEnd === null ? revealEnd : Math.max(revealEnd, outcomeEnd);
|
|
626
638
|
return {
|
|
627
639
|
intent: name,
|
|
@@ -754,7 +766,9 @@ function observe(state, event) {
|
|
|
754
766
|
}
|
|
755
767
|
if (isOutcome) {
|
|
756
768
|
const record2 = episodeRecord(episode, intentName);
|
|
757
|
-
if (!record2.resolved || record2.seen.length > 0
|
|
769
|
+
if (!record2.resolved || record2.seen.length > 0 || intent.episodePerOutcome === true) {
|
|
770
|
+
is = recordEpisode(is, record2.seen, 1);
|
|
771
|
+
}
|
|
758
772
|
is = creditPending(is, intent, at);
|
|
759
773
|
episode = markResolved(episode, intentName);
|
|
760
774
|
is = { ...is, lastOutcomeAt: at };
|
|
@@ -807,7 +821,8 @@ function closeEpisode(state, at) {
|
|
|
807
821
|
const intents = { ...state.intents };
|
|
808
822
|
for (const [intentName, intent] of Object.entries(state.config.intents)) {
|
|
809
823
|
const record2 = episodeRecord(state.episode, intentName);
|
|
810
|
-
if (record2.resolved)
|
|
824
|
+
if (record2.resolved && (intent.episodePerOutcome !== true || record2.seen.length === 0))
|
|
825
|
+
continue;
|
|
811
826
|
let is = getIntentState(state, intentName, intent);
|
|
812
827
|
is = settlePending(is, intent, learning, at);
|
|
813
828
|
is = intent.attribution === void 0 ? recordEpisode(is, record2.seen, 0) : deferEpisode(is, record2.seen, at);
|