oracular 0.9.0 → 0.10.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 +18 -0
- package/README.md +3 -3
- package/dist/cli.js +56 -27
- package/dist/index.cjs +48 -27
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +16 -7
- package/dist/index.d.ts +16 -7
- package/dist/index.js +48 -27
- package/dist/index.js.map +1 -1
- package/dist/presets.cjs +11 -4
- package/dist/presets.cjs.map +1 -1
- package/dist/presets.d.cts +0 -17
- package/dist/presets.d.ts +0 -17
- package/dist/presets.js +11 -4
- package/dist/presets.js.map +1 -1
- package/package.json +1 -1
- package/schema/config.schema.json +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#56](https://github.com/Yuribenjamin/oracular/pull/56) [`1529d8e`](https://github.com/Yuribenjamin/oracular/commit/1529d8e1568d79959afc7573ba8fa6e7f3e7ff59) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **Configs that cannot work are refused.** `createState`, `validateConfig` and the `config` passed to `deserialize` now throw for a reveal `threshold` of 0 or 1 (`RangeError`, strictly between 0 and 1), a `z` above 10 (`RangeError`; 3.09 is already a one-sided 99.9% bound, and 128 is a misplaced 1.28), and an `outcome` that is also one of the intent's own signals while `resetOnOutcome` is on (`TypeError`: the reset wipes the evidence the signal just added; set `resetOnOutcome: false` to keep such a signal, which then reads as "did it once, will again"). The JSON Schema says the same. Per-call reveal options keep accepting 0 and 1, since a probe such as `evidenceToReveal` at those values is a legitimate question. A stored state whose config predates these checks still loads: `deserialize` reads the stored config without them and replaces it with the one you pass. An intent with no signals stays accepted; it is a prior-only gate. The presets in `oracular/presets` are now frozen through and through, so a change by one caller cannot reach every other; spread one to change a value, as the guide shows.
|
|
8
|
+
|
|
9
|
+
**Size.** The checks, the anchor pin and the duplicate-outcome rule add about 0.2 kB: the core goes from 7.5 kB to 7.72 kB minified and gzipped, under the 7.9 kB ceiling, and the quickstart entry (`createState`, `observe`, `shouldReveal`, `markRevealed`) from 4.43 kB to 4.63 kB, past its 4.5 kB budget, which moves to 4.7 kB.
|
|
10
|
+
|
|
11
|
+
- [#56](https://github.com/Yuribenjamin/oracular/pull/56) [`1529d8e`](https://github.com/Yuribenjamin/oracular/commit/1529d8e1568d79959afc7573ba8fa6e7f3e7ff59) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **A clock that was ahead no longer freezes an intent.** A production audit found that one event stamped in the future (a device clock that was wrong for a moment) held an intent until the clock caught up: decay stopped, because the anchor kept the later time; the open episode never timed out, so learning stopped; and a reveal or outcome stamped in the future kept the gate shut until that date plus the cooldown. Every write now moves its anchor to the event's own time, even when that is earlier: the evidence is held (no decay for a backwards step, as before) and decay resumes from the new time, for `updatedAt`, the learned counts' `learnedAt` and the episode's `lastEventAt`. A reveal or outcome stamped ahead of the clock counts as having happened now: `predict` reports `suppressedUntil` one cooldown after the current time rather than years ahead, and the next event pins the stored time to its own. `markRevealed` follows the same rule and trusts the time it is given, so an earlier `at` moves the cooldown start back. What moves for a clock that only ever runs forward: nothing. What moves for one that jumps backwards: the anchor now follows it, so a log replayed with timestamps out of order decays slightly more than before. `decayFactor` of an infinite gap is now 0, not 1. An episode held under `attribution` with a close stamped in the future is left as it was.
|
|
12
|
+
|
|
13
|
+
- [#56](https://github.com/Yuribenjamin/oracular/pull/56) [`1529d8e`](https://github.com/Yuribenjamin/oracular/commit/1529d8e1568d79959afc7573ba8fa6e7f3e7ff59) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **A repeated outcome counts once.** An outcome rewards the signals seen since the episode started or since the intent's outcome last arrived, and counts the episode toward the base rate. A second outcome event with no signal seen since the first (a duplicate event, or two outcomes back to back) counted another hit episode, so a session with two outcome events inflated the base rate; it now counts nothing, and only `lastOutcomeAt` and the reset of the evidence happen again. Signals seen after an outcome in the same episode are unchanged: they earn a hit if another outcome follows them, and stay neutral at the close, since what they would predict is a second outcome in the same session, which is not what the weight is for. `tune` counts the same way. The state format is unchanged.
|
|
14
|
+
|
|
15
|
+
- [#56](https://github.com/Yuribenjamin/oracular/pull/56) [`1529d8e`](https://github.com/Yuribenjamin/oracular/commit/1529d8e1568d79959afc7573ba8fa6e7f3e7ff59) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **`tune` no longer reports a lift with nothing to compare it to.** A signal seen in every counted episode had its outcome rate compared with a smoothed rate over zero episodes, a made-up 0.5, and the report printed a lift and a 90% interval from it. For such a signal `lift`, `score`, `low` and `high` are now `null` in `--json`, and the text report prints "no contrast" in place of the interval; the weight was already left out as ubiquitous.
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- [#56](https://github.com/Yuribenjamin/oracular/pull/56) [`1529d8e`](https://github.com/Yuribenjamin/oracular/commit/1529d8e1568d79959afc7573ba8fa6e7f3e7ff59) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **Two CLI fixes.** `replay` and `tune` no longer refuse a whole config because one intent has a reveal cooldown of 0: that intent is scored over its half-life, the text report says so, and the `--json` reports gain `windowSource` (`option`, `cooldown` or `halfLife`) next to the existing `windowIsCooldown`. `oracular replay --version` and `oracular tune --version` (or `-v`) print the version, as the help already claimed.
|
|
20
|
+
|
|
3
21
|
## 0.9.0
|
|
4
22
|
|
|
5
23
|
### 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**.
|
|
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.
|
|
190
190
|
|
|
191
191
|
A signal's weight is then a blend of your prior and the learned estimate:
|
|
192
192
|
|
|
@@ -308,7 +308,7 @@ interface RevealOptions {
|
|
|
308
308
|
}
|
|
309
309
|
```
|
|
310
310
|
|
|
311
|
-
Reveal options resolve per call → intent → engine → defaults. Invalid config throws a `TypeError` or `RangeError` with the offending path in the message. `validateConfig(config)` runs those checks without building a state and returns the normalized copy `createState` stores, so a bad weight fails at startup or in a test rather than on first use. For a config kept as JSON, the package ships a [JSON Schema](./schema/config.schema.json) as `oracular/config.schema.json`, also served at <https://yuribenjamin.github.io/oracular/config.schema.json>, for editor completion; `oracular replay` ignores the `$schema` key such a file carries, the engine does not.
|
|
311
|
+
Reveal options resolve per call → intent → engine → defaults. Invalid config throws a `TypeError` or `RangeError` with the offending path in the message, including a config that cannot work: a reveal `threshold` of 0 or 1, a `z` above 10, or an outcome that is also one of the intent's signals while `resetOnOutcome` is on (the reset would wipe what the signal just added; set it to `false` to keep such a signal). A stored state whose config predates these checks still loads. `validateConfig(config)` runs those checks without building a state and returns the normalized copy `createState` stores, so a bad weight fails at startup or in a test rather than on first use. For a config kept as JSON, the package ships a [JSON Schema](./schema/config.schema.json) as `oracular/config.schema.json`, also served at <https://yuribenjamin.github.io/oracular/config.schema.json>, for editor completion; `oracular replay` ignores the `$schema` key such a file carries, the engine does not.
|
|
312
312
|
|
|
313
313
|
### Results
|
|
314
314
|
|
|
@@ -339,7 +339,7 @@ interface SignalExplanation {
|
|
|
339
339
|
|
|
340
340
|
### Time
|
|
341
341
|
|
|
342
|
-
`at` is a timestamp. Every duration (`halfLife`, `cooldown`, `episodeTimeout`, `learning.halfLife`) is in the same unit as `at`. Milliseconds from `Date.now()` are the obvious choice, but the library never calls `Date.now()` itself: you pass time in, which keeps every function deterministic and makes tests trivial. A clock that jumps backwards is tolerated (no decay, no error). When `at` is in milliseconds, `ms.days(7)`, `ms.hours(3)` and `ms.minutes(30)` spell the durations; they are plain multiplications.
|
|
342
|
+
`at` is a timestamp. Every duration (`halfLife`, `cooldown`, `episodeTimeout`, `learning.halfLife`) is in the same unit as `at`. Milliseconds from `Date.now()` are the obvious choice, but the library never calls `Date.now()` itself: you pass time in, which keeps every function deterministic and makes tests trivial. A clock that jumps backwards is tolerated: the evidence is held (no decay, no error) and the anchor moves to the new time, so decay resumes from there. One event stamped ahead of the clock therefore cannot hold an intent until the clock catches up, and a reveal or outcome stamped ahead of it counts as having happened now: its cooldown runs from the current time until the next event pins it there. When `at` is in milliseconds, `ms.days(7)`, `ms.hours(3)` and `ms.minutes(30)` spell the durations; they are plain multiplications.
|
|
343
343
|
|
|
344
344
|
## Persistence
|
|
345
345
|
|
package/dist/cli.js
CHANGED
|
@@ -107,6 +107,14 @@ import {
|
|
|
107
107
|
} from "oracular";
|
|
108
108
|
var DEFAULT_THRESHOLDS = [0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9];
|
|
109
109
|
var DEFAULT_THRESHOLD = 0.5;
|
|
110
|
+
function scoringWindow(given, cooldown, halfLife) {
|
|
111
|
+
if (given !== void 0) {
|
|
112
|
+
if (!(given > 0)) throw new RangeError("the scoring window must be greater than 0");
|
|
113
|
+
return { window: given, windowSource: "option" };
|
|
114
|
+
}
|
|
115
|
+
if (cooldown > 0) return { window: cooldown, windowSource: "cooldown" };
|
|
116
|
+
return { window: halfLife, windowSource: "halfLife" };
|
|
117
|
+
}
|
|
110
118
|
function replay(entries, config, options = {}) {
|
|
111
119
|
const base = createState(config);
|
|
112
120
|
const normalized = base.config;
|
|
@@ -135,19 +143,15 @@ function replay(entries, config, options = {}) {
|
|
|
135
143
|
const list = options.thresholds ?? [...DEFAULT_THRESHOLDS, configured];
|
|
136
144
|
const thresholds = [...new Set(list)].sort((a, b) => a - b);
|
|
137
145
|
const cooldown = predict(markRevealed(base, name, 0), name, 0).suppressedUntil ?? 0;
|
|
138
|
-
const window = options.window
|
|
139
|
-
if (!(window > 0)) {
|
|
140
|
-
throw new RangeError(
|
|
141
|
-
options.window === void 0 ? `intent ${JSON.stringify(name)} has a reveal cooldown of 0, so there is no default scoring window; pass --window (for example --window 7d)` : "the scoring window must be greater than 0"
|
|
142
|
-
);
|
|
143
|
-
}
|
|
146
|
+
const { window, windowSource } = scoringWindow(options.window, cooldown, intent.halfLife);
|
|
144
147
|
return {
|
|
145
148
|
name,
|
|
146
149
|
outcome: intent.outcome,
|
|
147
150
|
configured,
|
|
148
151
|
thresholds,
|
|
149
152
|
window,
|
|
150
|
-
|
|
153
|
+
windowSource,
|
|
154
|
+
windowIsCooldown: windowSource === "cooldown"
|
|
151
155
|
};
|
|
152
156
|
});
|
|
153
157
|
const allThresholds = [...new Set(plans.flatMap((p) => p.thresholds))].sort((a, b) => a - b);
|
|
@@ -261,6 +265,7 @@ function replay(entries, config, options = {}) {
|
|
|
261
265
|
intent: plan.name,
|
|
262
266
|
outcome: plan.outcome,
|
|
263
267
|
window: plan.window,
|
|
268
|
+
windowSource: plan.windowSource,
|
|
264
269
|
windowIsCooldown: plan.windowIsCooldown,
|
|
265
270
|
installs,
|
|
266
271
|
thresholds: totals.map((s) => {
|
|
@@ -347,7 +352,7 @@ function table(headers, rows) {
|
|
|
347
352
|
}
|
|
348
353
|
function formatIntent(intent) {
|
|
349
354
|
const window = formatDuration(intent.window);
|
|
350
|
-
const source = intent.
|
|
355
|
+
const source = intent.windowSource === "cooldown" ? "the intent's reveal cooldown" : intent.windowSource === "halfLife" ? "the intent's half-life, since its reveal cooldown is 0" : "as given";
|
|
351
356
|
const out = [];
|
|
352
357
|
out.push(
|
|
353
358
|
`${intent.intent}: outcome ${intent.outcome}, window ${window} (${source}), ${intent.installs} install${intent.installs === 1 ? "" : "s"}`
|
|
@@ -526,11 +531,12 @@ function fitSignal(signal, current, tally, strength, priorEpisodes, liftType) {
|
|
|
526
531
|
const qWith = laplace(hits, nWith);
|
|
527
532
|
const qWithout = laplace(outcomesWithout, without);
|
|
528
533
|
const baseline = liftType === "association-rule" ? (hits + outcomesWithout + 2) / (nWith + without + 4) : qWithout;
|
|
529
|
-
const
|
|
530
|
-
const
|
|
534
|
+
const noContrast = without === 0;
|
|
535
|
+
const rawLift = noContrast ? null : nWith === 0 ? 0 : lift(qWith, baseline, liftType);
|
|
536
|
+
const score = rawLift === null ? null : nWith === 0 ? 0 : learningScore(rawLift, liftType);
|
|
531
537
|
const contrast = nWith + without === 0 ? 0 : nWith * without / (nWith + without);
|
|
532
538
|
const [qLow, qHigh] = wilson(hits, nWith, Z90);
|
|
533
|
-
const rounded = Math.round(strength * score * 100) / 100;
|
|
539
|
+
const rounded = score === null ? 0 : Math.round(strength * score * 100) / 100;
|
|
534
540
|
const share = tally.episodes === 0 ? 0 : nWith / tally.episodes;
|
|
535
541
|
const ubiquitous = nWith > UBIQUITY_SHARE * tally.episodes;
|
|
536
542
|
return {
|
|
@@ -547,8 +553,8 @@ function fitSignal(signal, current, tally, strength, priorEpisodes, liftType) {
|
|
|
547
553
|
weight: nWith === 0 || ubiquitous || rounded === 0 ? null : rounded,
|
|
548
554
|
share,
|
|
549
555
|
ubiquitous,
|
|
550
|
-
low: strength * learningScore(lift(qLow, baseline, liftType), liftType),
|
|
551
|
-
high: strength * learningScore(lift(qHigh, baseline, liftType), liftType),
|
|
556
|
+
low: noContrast ? null : strength * learningScore(lift(qLow, baseline, liftType), liftType),
|
|
557
|
+
high: noContrast ? null : strength * learningScore(lift(qHigh, baseline, liftType), liftType),
|
|
552
558
|
contrast,
|
|
553
559
|
thin: contrast < priorEpisodes
|
|
554
560
|
};
|
|
@@ -643,13 +649,8 @@ function tune(entries, config, options = {}) {
|
|
|
643
649
|
const grouped = groupByInstall(entries);
|
|
644
650
|
const windows = /* @__PURE__ */ new Map();
|
|
645
651
|
for (const name of fitting) {
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
throw new RangeError(
|
|
649
|
-
options.window === void 0 ? `intent ${JSON.stringify(name)} has a reveal cooldown of 0, so there is no scoring window for the half-life grid; pass --window (for example --window 7d)` : "the scoring window must be greater than 0"
|
|
650
|
-
);
|
|
651
|
-
}
|
|
652
|
-
windows.set(name, window);
|
|
652
|
+
const intent = normalized.intents[name];
|
|
653
|
+
windows.set(name, scoringWindow(options.window, cooldownOf(base, name), intent.halfLife));
|
|
653
654
|
}
|
|
654
655
|
const eventNames = /* @__PURE__ */ new Set();
|
|
655
656
|
for (const entry of entries) if (entry.kind === "event") eventNames.add(entry.name);
|
|
@@ -745,7 +746,7 @@ function tune(entries, config, options = {}) {
|
|
|
745
746
|
signals.filter((f) => f.weight !== null).map((f) => [f.signal, f.weight])
|
|
746
747
|
)
|
|
747
748
|
};
|
|
748
|
-
const window = windows.get(name);
|
|
749
|
+
const { window, windowSource } = windows.get(name);
|
|
749
750
|
const threshold = intent.reveal?.threshold ?? normalized.reveal?.threshold ?? DEFAULT_THRESHOLD;
|
|
750
751
|
const grid = [.../* @__PURE__ */ new Set([...options.halfLives ?? DEFAULT_HALF_LIVES, intent.halfLife])].sort(
|
|
751
752
|
(a, b) => a - b
|
|
@@ -772,6 +773,7 @@ function tune(entries, config, options = {}) {
|
|
|
772
773
|
source: "kept",
|
|
773
774
|
note: "kept: no point in the log could be scored, so the grid says nothing",
|
|
774
775
|
window,
|
|
776
|
+
windowSource,
|
|
775
777
|
curve,
|
|
776
778
|
brierBaseline
|
|
777
779
|
};
|
|
@@ -789,6 +791,7 @@ function tune(entries, config, options = {}) {
|
|
|
789
791
|
source: "kept",
|
|
790
792
|
note: `kept: the grid is flat, the best point (${formatDuration(chosen.halfLife)}, ${best.toFixed(4)}) improves on the current value (${atCurrent.brier.toFixed(4)}) by ${(gain * 100).toFixed(2)}%, less than ${FLAT_GRID_GAIN * 100}%${baseline}`,
|
|
791
793
|
window,
|
|
794
|
+
windowSource,
|
|
792
795
|
curve,
|
|
793
796
|
brierBaseline
|
|
794
797
|
};
|
|
@@ -799,6 +802,7 @@ function tune(entries, config, options = {}) {
|
|
|
799
802
|
source: "grid",
|
|
800
803
|
note: `lowest Brier score over the grid, ${best.toFixed(4)}${baseline}`,
|
|
801
804
|
window,
|
|
805
|
+
windowSource,
|
|
802
806
|
curve,
|
|
803
807
|
brierBaseline
|
|
804
808
|
};
|
|
@@ -874,9 +878,9 @@ function signalTable(fits, priorEpisodes, withCurrent) {
|
|
|
874
878
|
f.signal,
|
|
875
879
|
num(f.weight, 2),
|
|
876
880
|
...withCurrent ? [num(f.current, 2)] : [],
|
|
877
|
-
`${f.low.toFixed(2)} to ${f.high.toFixed(2)}`,
|
|
878
|
-
signed(f.lift, 2),
|
|
879
|
-
...showScore ? [signed(f.score, 2)] : [],
|
|
881
|
+
f.low === null || f.high === null ? "no contrast" : `${f.low.toFixed(2)} to ${f.high.toFixed(2)}`,
|
|
882
|
+
f.lift === null ? "" : signed(f.lift, 2),
|
|
883
|
+
...showScore ? [f.score === null ? "" : signed(f.score, 2)] : [],
|
|
880
884
|
`${f.hits} / ${f.misses}`,
|
|
881
885
|
`${f.outcomesWithout} / ${f.without}`,
|
|
882
886
|
note(f, priorEpisodes)
|
|
@@ -923,7 +927,7 @@ function formatIntent2(fit) {
|
|
|
923
927
|
}
|
|
924
928
|
out.push("");
|
|
925
929
|
out.push(
|
|
926
|
-
`Half-life grid, Brier score against "the outcome follows within ${formatDuration(fit.halfLife.window)}" (lower is better): ${fit.halfLife.curve.map(
|
|
930
|
+
`Half-life grid, Brier score against "the outcome follows within ${formatDuration(fit.halfLife.window)}"${fit.halfLife.windowSource === "halfLife" ? " (the intent's half-life, since its reveal cooldown is 0)" : ""} (lower is better): ${fit.halfLife.curve.map(
|
|
927
931
|
(p) => `${formatDuration(p.halfLife)} ${num(p.brier, 4)}${p.halfLife === fit.halfLife.value && p.brier !== null ? " *" : ""}`
|
|
928
932
|
).join(", ")}`
|
|
929
933
|
);
|
|
@@ -1052,6 +1056,7 @@ Options:
|
|
|
1052
1056
|
the signals
|
|
1053
1057
|
--json print the report as JSON
|
|
1054
1058
|
-h, --help show this help
|
|
1059
|
+
-v, --version print the version
|
|
1055
1060
|
|
|
1056
1061
|
Only shadow periods are counted by default: an episode in which any record was live is left out,
|
|
1057
1062
|
a log with live records gets a warning, and a log with nothing but live records is refused unless
|
|
@@ -1142,16 +1147,29 @@ function commonFlags(out) {
|
|
|
1142
1147
|
},
|
|
1143
1148
|
"-h": () => {
|
|
1144
1149
|
out.help = true;
|
|
1150
|
+
},
|
|
1151
|
+
"--version": (_value, noValue) => {
|
|
1152
|
+
noValue();
|
|
1153
|
+
out.version = true;
|
|
1154
|
+
},
|
|
1155
|
+
"-v": () => {
|
|
1156
|
+
out.version = true;
|
|
1145
1157
|
}
|
|
1146
1158
|
};
|
|
1147
1159
|
}
|
|
1148
1160
|
function requireLogsAndConfig(out, command) {
|
|
1149
|
-
if (out.help) return;
|
|
1161
|
+
if (out.help || out.version) return;
|
|
1150
1162
|
if (out.logs.length === 0) throw new UsageError(`${command} needs at least one log file`);
|
|
1151
1163
|
if (out.config === void 0) throw new UsageError(`${command} needs --config <file>`);
|
|
1152
1164
|
}
|
|
1153
1165
|
function parseReplayArgs(argv) {
|
|
1154
|
-
const out = {
|
|
1166
|
+
const out = {
|
|
1167
|
+
logs: [],
|
|
1168
|
+
includeLive: false,
|
|
1169
|
+
json: false,
|
|
1170
|
+
help: false,
|
|
1171
|
+
version: false
|
|
1172
|
+
};
|
|
1155
1173
|
parseFlags(
|
|
1156
1174
|
argv,
|
|
1157
1175
|
{
|
|
@@ -1171,6 +1189,7 @@ function parseTuneArgs(argv) {
|
|
|
1171
1189
|
includeLive: false,
|
|
1172
1190
|
json: false,
|
|
1173
1191
|
help: false,
|
|
1192
|
+
version: false,
|
|
1174
1193
|
allEvents: false
|
|
1175
1194
|
};
|
|
1176
1195
|
parseFlags(
|
|
@@ -1264,6 +1283,11 @@ async function replayCommand(argv, io) {
|
|
|
1264
1283
|
io.stdout(REPLAY_HELP);
|
|
1265
1284
|
return 0;
|
|
1266
1285
|
}
|
|
1286
|
+
if (args.version) {
|
|
1287
|
+
io.stdout(`${version()}
|
|
1288
|
+
`);
|
|
1289
|
+
return 0;
|
|
1290
|
+
}
|
|
1267
1291
|
const config = await loadCheckedConfig(args, io);
|
|
1268
1292
|
const entries = readLogs(args.logs, io);
|
|
1269
1293
|
checkModes(entries, args.includeLive, io, {
|
|
@@ -1291,6 +1315,11 @@ async function tuneCommand(argv, io) {
|
|
|
1291
1315
|
io.stdout(TUNE_HELP);
|
|
1292
1316
|
return 0;
|
|
1293
1317
|
}
|
|
1318
|
+
if (args.version) {
|
|
1319
|
+
io.stdout(`${version()}
|
|
1320
|
+
`);
|
|
1321
|
+
return 0;
|
|
1322
|
+
}
|
|
1294
1323
|
const config = await loadCheckedConfig(args, io);
|
|
1295
1324
|
const entries = readLogs(args.logs, io);
|
|
1296
1325
|
checkModes(entries, args.includeLive, io, {
|
package/dist/index.cjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/math.ts
|
|
4
4
|
function decayFactor(elapsed, halfLife) {
|
|
5
|
-
if (!(elapsed > 0)
|
|
5
|
+
if (!(elapsed > 0)) return 1;
|
|
6
6
|
return 2 ** (-elapsed / halfLife);
|
|
7
7
|
}
|
|
8
8
|
function betaMean(alpha, beta) {
|
|
@@ -138,12 +138,11 @@ function mapRecord(record2, fn) {
|
|
|
138
138
|
var NONE = { hits: 0, misses: 0 };
|
|
139
139
|
function decayLearned(state, learning, at) {
|
|
140
140
|
if (state.learnedAt === null) return { ...state, learnedAt: at };
|
|
141
|
-
const learnedAt = Math.max(state.learnedAt, at);
|
|
142
141
|
const d = learning.halfLife === null ? 1 : decayFactor(at - state.learnedAt, learning.halfLife);
|
|
143
|
-
if (d === 1) return
|
|
142
|
+
if (d === 1) return at === state.learnedAt ? state : { ...state, learnedAt: at };
|
|
144
143
|
return {
|
|
145
144
|
...state,
|
|
146
|
-
learnedAt,
|
|
145
|
+
learnedAt: at,
|
|
147
146
|
learned: mapRecord(state.learned, (r) => ({ hits: r.hits * d, misses: r.misses * d })),
|
|
148
147
|
episodes: { hits: state.episodes.hits * d, misses: state.episodes.misses * d }
|
|
149
148
|
};
|
|
@@ -252,13 +251,12 @@ function withContributions(state, intent, contributions) {
|
|
|
252
251
|
function decayIntent(state, intent, at) {
|
|
253
252
|
if (state.updatedAt === null) return { ...state, updatedAt: at };
|
|
254
253
|
const d = decayFactor(at - state.updatedAt, intent.halfLife);
|
|
255
|
-
|
|
256
|
-
if (d === 1) return updatedAt === state.updatedAt ? state : { ...state, updatedAt };
|
|
254
|
+
if (d === 1) return at === state.updatedAt ? state : { ...state, updatedAt: at };
|
|
257
255
|
const contributions = mapRecord(state.contributions, (c) => ({
|
|
258
256
|
alpha: c.alpha * d,
|
|
259
257
|
beta: c.beta * d
|
|
260
258
|
}));
|
|
261
|
-
return withContributions({ ...state, updatedAt }, intent, contributions);
|
|
259
|
+
return withContributions({ ...state, updatedAt: at }, intent, contributions);
|
|
262
260
|
}
|
|
263
261
|
function applySignal(state, intent, signal, weight) {
|
|
264
262
|
const prev = getOwn(state.contributions, signal) ?? { alpha: 0, beta: 0 };
|
|
@@ -275,6 +273,7 @@ var DEFAULT_Z = 1.28;
|
|
|
275
273
|
var DEFAULT_GAIN = 2;
|
|
276
274
|
var DEFAULT_PRIOR_EPISODES = 10;
|
|
277
275
|
var MAX_MAGNITUDE = 1e6;
|
|
276
|
+
var MAX_Z = 10;
|
|
278
277
|
var CONFIG_KEYS = ["intents", "episodeTimeout", "learning", "reveal"];
|
|
279
278
|
var INTENT_KEYS = [
|
|
280
279
|
"prior",
|
|
@@ -359,14 +358,17 @@ function expectKeyName(name, path) {
|
|
|
359
358
|
if (name === "__proto__") typeError(path, 'must not use the reserved name "__proto__"');
|
|
360
359
|
return name;
|
|
361
360
|
}
|
|
362
|
-
function normalizeRevealOptions(value, path) {
|
|
361
|
+
function normalizeRevealOptions(value, path, strict = true) {
|
|
363
362
|
const record2 = expectRecord(value, path);
|
|
364
363
|
expectKeys(record2, REVEAL_KEYS, path);
|
|
365
364
|
const out = {};
|
|
366
365
|
if (record2.threshold !== void 0) {
|
|
367
|
-
out.threshold = expectClosedUnit(record2.threshold, `${path}.threshold`);
|
|
366
|
+
out.threshold = strict ? expectOpenUnit(record2.threshold, `${path}.threshold`) : expectClosedUnit(record2.threshold, `${path}.threshold`);
|
|
367
|
+
}
|
|
368
|
+
if (record2.z !== void 0) {
|
|
369
|
+
out.z = expectNonNegative(record2.z, `${path}.z`);
|
|
370
|
+
if (strict && out.z > MAX_Z) rangeError(`${path}.z`, `must be at most ${MAX_Z}`);
|
|
368
371
|
}
|
|
369
|
-
if (record2.z !== void 0) out.z = expectNonNegative(record2.z, `${path}.z`);
|
|
370
372
|
if (record2.cooldown !== void 0) {
|
|
371
373
|
out.cooldown = expectNonNegative(record2.cooldown, `${path}.cooldown`);
|
|
372
374
|
}
|
|
@@ -396,7 +398,7 @@ function normalizeLearning(value, path) {
|
|
|
396
398
|
}
|
|
397
399
|
return out;
|
|
398
400
|
}
|
|
399
|
-
function normalizeIntentConfig(value, path) {
|
|
401
|
+
function normalizeIntentConfig(value, path, strict = true) {
|
|
400
402
|
const record2 = expectRecord(value, path);
|
|
401
403
|
expectKeys(record2, INTENT_KEYS, path);
|
|
402
404
|
const signalsRecord = expectRecord(record2.signals, `${path}.signals`);
|
|
@@ -438,11 +440,18 @@ function normalizeIntentConfig(value, path) {
|
|
|
438
440
|
if (record2.attribution !== void 0) {
|
|
439
441
|
out.attribution = expectPositive(record2.attribution, `${path}.attribution`);
|
|
440
442
|
}
|
|
441
|
-
if (record2.reveal !== void 0)
|
|
442
|
-
out.reveal = normalizeRevealOptions(record2.reveal, `${path}.reveal
|
|
443
|
+
if (record2.reveal !== void 0) {
|
|
444
|
+
out.reveal = normalizeRevealOptions(record2.reveal, `${path}.reveal`, strict);
|
|
445
|
+
}
|
|
446
|
+
if (strict && out.outcome !== void 0 && hasOwn(signals, out.outcome) && out.resetOnOutcome !== false) {
|
|
447
|
+
typeError(
|
|
448
|
+
`${path}.outcome`,
|
|
449
|
+
"is also one of its signals: the reset on outcome would wipe what it adds; set resetOnOutcome: false to keep it"
|
|
450
|
+
);
|
|
451
|
+
}
|
|
443
452
|
return out;
|
|
444
453
|
}
|
|
445
|
-
function normalizeConfig(value) {
|
|
454
|
+
function normalizeConfig(value, strict = true) {
|
|
446
455
|
const record2 = expectRecord(value, "config");
|
|
447
456
|
expectKeys(record2, CONFIG_KEYS, "config");
|
|
448
457
|
const intentsRecord = expectRecord(record2.intents, "config.intents");
|
|
@@ -453,7 +462,8 @@ function normalizeConfig(value) {
|
|
|
453
462
|
expectKeyName(name, "config.intents");
|
|
454
463
|
intents[name] = normalizeIntentConfig(
|
|
455
464
|
intentsRecord[name],
|
|
456
|
-
`config.intents[${JSON.stringify(name)}]
|
|
465
|
+
`config.intents[${JSON.stringify(name)}]`,
|
|
466
|
+
strict
|
|
457
467
|
);
|
|
458
468
|
}
|
|
459
469
|
const out = { intents };
|
|
@@ -462,8 +472,9 @@ function normalizeConfig(value) {
|
|
|
462
472
|
}
|
|
463
473
|
if (record2.learning !== void 0)
|
|
464
474
|
out.learning = normalizeLearning(record2.learning, "config.learning");
|
|
465
|
-
if (record2.reveal !== void 0)
|
|
466
|
-
out.reveal = normalizeRevealOptions(record2.reveal, "config.reveal");
|
|
475
|
+
if (record2.reveal !== void 0) {
|
|
476
|
+
out.reveal = normalizeRevealOptions(record2.reveal, "config.reveal", strict);
|
|
477
|
+
}
|
|
467
478
|
return out;
|
|
468
479
|
}
|
|
469
480
|
function normalizeExplainOptions(value, path) {
|
|
@@ -493,12 +504,12 @@ function splitEvaluateOptions(value, path) {
|
|
|
493
504
|
else explain2[key] = v;
|
|
494
505
|
}
|
|
495
506
|
return {
|
|
496
|
-
reveal: normalizeRevealOptions(reveal, path),
|
|
507
|
+
reveal: normalizeRevealOptions(reveal, path, false),
|
|
497
508
|
explain: normalizeExplainOptions(explain2, path)
|
|
498
509
|
};
|
|
499
510
|
}
|
|
500
511
|
function resolveReveal(config, intent, opts, normalized = false) {
|
|
501
|
-
const o = opts === void 0 ? {} : normalized ? opts : normalizeRevealOptions(opts, "options");
|
|
512
|
+
const o = opts === void 0 ? {} : normalized ? opts : normalizeRevealOptions(opts, "options", false);
|
|
502
513
|
return {
|
|
503
514
|
threshold: o.threshold ?? intent.reveal?.threshold ?? config?.reveal?.threshold ?? DEFAULT_THRESHOLD,
|
|
504
515
|
z: o.z ?? intent.reveal?.z ?? config?.reveal?.z ?? DEFAULT_Z,
|
|
@@ -536,11 +547,7 @@ function timedOutAt(episode, timeout, at) {
|
|
|
536
547
|
return at - episode.lastEventAt > timeout ? episode.lastEventAt + timeout : null;
|
|
537
548
|
}
|
|
538
549
|
function extendEpisode(episode, at) {
|
|
539
|
-
return {
|
|
540
|
-
startedAt: episode.startedAt ?? at,
|
|
541
|
-
lastEventAt: episode.lastEventAt === null ? at : Math.max(episode.lastEventAt, at),
|
|
542
|
-
intents: episode.intents
|
|
543
|
-
};
|
|
550
|
+
return { startedAt: episode.startedAt ?? at, lastEventAt: at, intents: episode.intents };
|
|
544
551
|
}
|
|
545
552
|
function episodeRecord(episode, intent) {
|
|
546
553
|
return getOwn(episode.intents, intent) ?? { seen: [], counts: {}, resolved: false };
|
|
@@ -598,7 +605,7 @@ function assessIntent(state, name, intent, learning, at) {
|
|
|
598
605
|
}
|
|
599
606
|
function cooldownEnd(since, cooldown, at) {
|
|
600
607
|
if (since === null) return null;
|
|
601
|
-
const end = since + cooldown;
|
|
608
|
+
const end = Math.min(since, at) + cooldown;
|
|
602
609
|
return at < end ? end : null;
|
|
603
610
|
}
|
|
604
611
|
function buildPrediction(is, name, reveal, at) {
|
|
@@ -726,6 +733,7 @@ function observe(state, event) {
|
|
|
726
733
|
let is = getIntentState(s, intentName, intent);
|
|
727
734
|
is = decayIntent(is, intent, at);
|
|
728
735
|
is = settlePending(is, intent, learning, at);
|
|
736
|
+
is = pinAnchors(is, at);
|
|
729
737
|
if (isSignal) {
|
|
730
738
|
const { effective } = signalWeights(is, intent, learning, name);
|
|
731
739
|
const n = occurrences(episode, intentName, name) + 1;
|
|
@@ -735,7 +743,8 @@ function observe(state, event) {
|
|
|
735
743
|
if (!isOutcome) episode = markSeen(episode, intentName, name);
|
|
736
744
|
}
|
|
737
745
|
if (isOutcome) {
|
|
738
|
-
|
|
746
|
+
const record2 = episodeRecord(episode, intentName);
|
|
747
|
+
if (!record2.resolved || record2.seen.length > 0) is = recordEpisode(is, record2.seen, 1);
|
|
739
748
|
is = creditPending(is, intent, at);
|
|
740
749
|
episode = markResolved(episode, intentName);
|
|
741
750
|
is = { ...is, lastOutcomeAt: at };
|
|
@@ -745,6 +754,16 @@ function observe(state, event) {
|
|
|
745
754
|
}
|
|
746
755
|
return { ...s, intents, episode };
|
|
747
756
|
}
|
|
757
|
+
function pinAnchors(is, at) {
|
|
758
|
+
const reveal = is.lastRevealedAt !== null && is.lastRevealedAt > at;
|
|
759
|
+
const outcome = is.lastOutcomeAt !== null && is.lastOutcomeAt > at;
|
|
760
|
+
if (!reveal && !outcome) return is;
|
|
761
|
+
return {
|
|
762
|
+
...is,
|
|
763
|
+
lastRevealedAt: reveal ? at : is.lastRevealedAt,
|
|
764
|
+
lastOutcomeAt: outcome ? at : is.lastOutcomeAt
|
|
765
|
+
};
|
|
766
|
+
}
|
|
748
767
|
function endEpisode(state, at) {
|
|
749
768
|
const s = requireState(state);
|
|
750
769
|
const t = expectTimestamp(at, "at");
|
|
@@ -1062,7 +1081,9 @@ function parseState(value) {
|
|
|
1062
1081
|
}
|
|
1063
1082
|
return {
|
|
1064
1083
|
version: STATE_VERSION,
|
|
1065
|
-
|
|
1084
|
+
// Without the strict checks: a config this release would refuse at createState still loads
|
|
1085
|
+
// when it is only stored, and the config passed in replaces it anyway.
|
|
1086
|
+
config: normalizeConfig(r.config, false),
|
|
1066
1087
|
intents: mapOwn(r.intents, "state.intents", intentState),
|
|
1067
1088
|
episode: episodeState(r.episode, "state.episode")
|
|
1068
1089
|
};
|