oracular 0.11.0 → 0.12.1
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 +22 -0
- package/README.md +3 -3
- package/dist/cli.js +16 -3
- package/dist/index.cjs +77 -20
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +34 -10
- package/dist/index.d.ts +34 -10
- package/dist/index.js +77 -20
- package/dist/index.js.map +1 -1
- package/dist/presets.cjs.map +1 -1
- package/dist/presets.d.cts +25 -3
- package/dist/presets.d.ts +25 -3
- package/dist/presets.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.12.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#64](https://github.com/Yuribenjamin/oracular/pull/64) [`c108ca9`](https://github.com/Yuribenjamin/oracular/commit/c108ca92b4b66b90749664379bc676a230462726) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **`replay` shows the reveals it could not score.** A reveal whose window runs past the end of the log is not scored for precision, since its verdict is not in yet, but an outcome it preceded still counts for recall, so a short log printed `reveals 0` beside `recall 100.0% of 1`, and `--json` showed `reveals: 0, caught: 1, censored: 1`. Both numbers were right and the table did not say why. The text table now shows such reveals in the row (`0 (+1 censored)`) with a footnote that says what recall still counts, and a log shorter than an intent's scoring window, in which every reveal and point is censored, gets a warning on stderr that points at `--window`. The `--json` report is unchanged: `censored` was already there. Found by the second production integration on a one-minute log with the default one-hour window.
|
|
8
|
+
|
|
9
|
+
## 0.12.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- [#62](https://github.com/Yuribenjamin/oracular/pull/62) [`0502b78`](https://github.com/Yuribenjamin/oracular/commit/0502b789049cb8ef8643af587ec94b78ce0c39b1) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **The clock rule: one glitch cannot move the engine.** 0.10.0 made every write trust the latest event's time, so that one event stamped in the future could no longer hold an intent until the clock caught up. A production re-test found the cost: a clock that steps *backwards* (a phone that wakes up in 1970 before it syncs, a seconds-for-milliseconds bug) pulled every anchor back with it, and the next event decayed the whole gap again and closed the session, recording a miss nobody earned. The two cases are one ambiguity: after one backward step the engine cannot tell whether the new stamp or the old anchor is the wrong clock. It now waits one event. The engine keeps a clock, the latest event time it trusts; an event stamped behind it counts now, at the clock's time, and its stamp is held. If the next event is also behind the clock, the clock was corrected: every anchor ahead of the held stamp (evidence, learned counts, reveals, outcomes, the open episode, the episodes waiting under `attribution`) moves back to it, and decay and cooldowns resume from there. If the next event is at or after the clock, the held stamp was a glitch, and nothing moved. Monotone clocks see no difference at all, and every number in the docs is unchanged. What changes: a clock that was ahead heals two events after it is corrected rather than one, and heals every intent at once rather than only the ones later events touch; a reveal stamped ahead of the clock is pinned by the next event on any intent; a single backward stamp wipes nothing and closes nothing.
|
|
14
|
+
|
|
15
|
+
Two costs, stated. The first event after a correction is processed at the uncorrected time, so the intents it touches are decayed to that time and their episodes waiting under `attribution` can settle as misses before the second event moves everything back. And two bad stamps in a row read as a correction, which costs exactly what 0.10 paid on every backward step: the anchors move to the bad time and the next good event decays the gap again. One case no rule covers: a device that boots in 1970 and syncs forward later makes one monotone jump of decades, indistinguishable from a long absence, so hold `observe` until the device clock is trusted.
|
|
16
|
+
|
|
17
|
+
**State version 4.** The clock is stored, so `STATE_VERSION` is 4 and version 3 states (0.6.0 to 0.11.x) migrate on load: the clock is set to the latest time the stored anchors were written at, nothing is held, and no estimate changes. Under rule 5 of the stability policy, 0.11 reads a version 4 state as its own; what it loses is the clock: a backward stamp awaiting confirmation is forgotten, and the next event is trusted the way 0.11 trusted every event.
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- [#62](https://github.com/Yuribenjamin/oracular/pull/62) [`0502b78`](https://github.com/Yuribenjamin/oracular/commit/0502b789049cb8ef8643af587ec94b78ce0c39b1) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **Size.** The whole `oracular` entry goes from 7.72 kB to 8.09 kB minified and gzipped with the clock rule and its state version, the quickstart import (`createState`, `observe`, `shouldReveal`, `markRevealed`) from 4.63 kB to 4.85 kB. That crosses the 7.9 kB ceiling the stability page set in 0.8, so the ceiling moves as it did then, to the shipped size plus about 10%: 8.9 kB, with the quickstart budget at 5 kB.
|
|
22
|
+
|
|
23
|
+
- [#62](https://github.com/Yuribenjamin/oracular/pull/62) [`0502b78`](https://github.com/Yuribenjamin/oracular/commit/0502b789049cb8ef8643af587ec94b78ce0c39b1) Thanks [@Yuribenjamin](https://github.com/Yuribenjamin)! - **Presets are read-only in the types too.** `presets` and `presetSources` are `DeepReadonly<...>`, matching the frozen objects: assigning into a preset is a type error, spreading one is unchanged.
|
|
24
|
+
|
|
3
25
|
## 0.11.0
|
|
4
26
|
|
|
5
27
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -270,7 +270,7 @@ serialize(state): string
|
|
|
270
270
|
deserialize(json: string, opts?: { config?: EngineConfig; migrate?: Migrate }): EngineState
|
|
271
271
|
validateConfig(config: unknown): EngineConfig // the checks createState runs, without a state; returns the normalized copy
|
|
272
272
|
ms.seconds(n), ms.minutes(n), ms.hours(n), ms.days(n), ms.weeks(n) // durations in milliseconds
|
|
273
|
-
STATE_VERSION //
|
|
273
|
+
STATE_VERSION // 4
|
|
274
274
|
RECORD_VERSION // 1
|
|
275
275
|
```
|
|
276
276
|
|
|
@@ -340,7 +340,7 @@ interface SignalExplanation {
|
|
|
340
340
|
|
|
341
341
|
### Time
|
|
342
342
|
|
|
343
|
-
`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.
|
|
343
|
+
`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. The engine keeps a clock, the latest event time it trusts, and resolves every stamp against it. An event stamped behind the clock is tolerated: it counts now, at the clock's time, with no decay and no error, and its stamp is held. If the next event is also behind the clock, the clock was corrected (a device whose time was ahead caught up): every anchor ahead of the held stamp moves back to it, and decay and cooldowns resume from the corrected time. If the next event is at or after the clock, the held stamp was one glitch (a phone that woke up in 1970 before it synced) and nothing moved. So one event stamped years ahead holds an intent for at most the two events that follow it, and one stamped years behind wipes nothing and closes no session. A reveal or outcome stamped ahead of the clock counts as having happened now, and the next event pins it to its own time. A device that boots in 1970 and syncs forward later makes one monotone jump that no rule can tell from a long absence, so hold `observe` until its clock is trusted. When `at` is in milliseconds, `ms.days(7)`, `ms.hours(3)` and `ms.minutes(30)` spell the durations; they are plain multiplications.
|
|
344
344
|
|
|
345
345
|
## Persistence
|
|
346
346
|
|
|
@@ -358,7 +358,7 @@ const state = deserialize(saved, { config });
|
|
|
358
358
|
|
|
359
359
|
The new config is validated and swapped in. Intents and signals you no longer declare are dropped (α and β are recomputed from what remains), newly declared intents start at their prior, and everything learned about signals that still exist is kept.
|
|
360
360
|
|
|
361
|
-
States saved by an older version of this library are upgraded on load: `deserialize` migrates the state shapes written by oracular 0.1.0 to 0.
|
|
361
|
+
States saved by an older version of this library are upgraded on load: `deserialize` migrates the state shapes written by oracular 0.1.0 to 0.11.x (`STATE_VERSION` 1 to 3) to the current one on its own. A `migrate` function is consulted only for a version the library does not know, such as a state written by a newer oracular before a rollback; without one, that throws a `RangeError`:
|
|
362
362
|
|
|
363
363
|
```ts
|
|
364
364
|
deserialize(saved, {
|
package/dist/cli.js
CHANGED
|
@@ -363,7 +363,7 @@ function formatIntent(intent) {
|
|
|
363
363
|
["threshold", "reveals", "per user", "precision", "recall", "median lead time"],
|
|
364
364
|
intent.thresholds.map((t) => [
|
|
365
365
|
`${t.threshold}${t.configured ? " *" : ""}`,
|
|
366
|
-
String(t.reveals),
|
|
366
|
+
t.censored > 0 ? `${t.reveals} (+${t.censored} censored)` : String(t.reveals),
|
|
367
367
|
num(t.revealsPerUser, 2),
|
|
368
368
|
pct(t.precision),
|
|
369
369
|
`${pct(t.recall)} of ${t.outcomes}`,
|
|
@@ -371,9 +371,9 @@ function formatIntent(intent) {
|
|
|
371
371
|
])
|
|
372
372
|
)
|
|
373
373
|
);
|
|
374
|
-
const censored = intent.thresholds.
|
|
374
|
+
const censored = intent.thresholds.some((t) => t.censored > 0);
|
|
375
375
|
out.push(
|
|
376
|
-
`* the threshold your config resolves for this intent.${censored
|
|
376
|
+
`* the threshold your config resolves for this intent.${censored ? " A reveal whose window runs past the end of the log (the +n) is not scored for precision, since its verdict is not in yet; an outcome it preceded still counts as caught for recall." : ""}`
|
|
377
377
|
);
|
|
378
378
|
out.push("");
|
|
379
379
|
out.push(
|
|
@@ -1310,9 +1310,22 @@ async function replayCommand(argv, io) {
|
|
|
1310
1310
|
if (error instanceof RangeError) throw new UsageError(error.message);
|
|
1311
1311
|
throw error;
|
|
1312
1312
|
}
|
|
1313
|
+
warnShortLog(report, io);
|
|
1313
1314
|
io.stdout(args.json ? formatJson(report) : formatText(report));
|
|
1314
1315
|
return 0;
|
|
1315
1316
|
}
|
|
1317
|
+
function warnShortLog(report, io) {
|
|
1318
|
+
if (report.start === null || report.end === null) return;
|
|
1319
|
+
const span = report.end - report.start;
|
|
1320
|
+
for (const intent of report.intents) {
|
|
1321
|
+
if (span < intent.window) {
|
|
1322
|
+
io.stderr(
|
|
1323
|
+
`oracular: warning: ${intent.intent}: the log spans ${formatDuration(span)}, less than its ${formatDuration(intent.window)} scoring window, so every reveal and point is censored and only recall can be counted. Pass --window with a duration shorter than the log, or record for longer than the window.
|
|
1324
|
+
`
|
|
1325
|
+
);
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1316
1329
|
async function tuneCommand(argv, io) {
|
|
1317
1330
|
const args = parseTuneArgs(argv);
|
|
1318
1331
|
if (args.help) {
|
package/dist/index.cjs
CHANGED
|
@@ -138,8 +138,9 @@ 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
|
+
if (!(at > state.learnedAt)) return state;
|
|
141
142
|
const d = learning.halfLife === null ? 1 : decayFactor(at - state.learnedAt, learning.halfLife);
|
|
142
|
-
if (d === 1) return
|
|
143
|
+
if (d === 1) return { ...state, learnedAt: at };
|
|
143
144
|
return {
|
|
144
145
|
...state,
|
|
145
146
|
learnedAt: at,
|
|
@@ -251,8 +252,9 @@ function withContributions(state, intent, contributions) {
|
|
|
251
252
|
}
|
|
252
253
|
function decayIntent(state, intent, at) {
|
|
253
254
|
if (state.updatedAt === null) return { ...state, updatedAt: at };
|
|
255
|
+
if (!(at > state.updatedAt)) return state;
|
|
254
256
|
const d = decayFactor(at - state.updatedAt, intent.halfLife);
|
|
255
|
-
if (d === 1) return
|
|
257
|
+
if (d === 1) return { ...state, updatedAt: at };
|
|
256
258
|
const contributions = mapRecord(state.contributions, (c) => ({
|
|
257
259
|
alpha: c.alpha * d,
|
|
258
260
|
beta: c.beta * d
|
|
@@ -570,7 +572,7 @@ function markResolved(episode, intent) {
|
|
|
570
572
|
}
|
|
571
573
|
|
|
572
574
|
// src/version.ts
|
|
573
|
-
var STATE_VERSION =
|
|
575
|
+
var STATE_VERSION = 4;
|
|
574
576
|
var RECORD_VERSION = 1;
|
|
575
577
|
|
|
576
578
|
// src/state.ts
|
|
@@ -580,10 +582,16 @@ function createState(config) {
|
|
|
580
582
|
for (const [name, intent] of Object.entries(normalized.intents)) {
|
|
581
583
|
intents[name] = initIntent(intent);
|
|
582
584
|
}
|
|
583
|
-
return {
|
|
585
|
+
return {
|
|
586
|
+
version: STATE_VERSION,
|
|
587
|
+
config: normalized,
|
|
588
|
+
intents,
|
|
589
|
+
episode: emptyEpisode(),
|
|
590
|
+
clock: { at: null, behindAt: null }
|
|
591
|
+
};
|
|
584
592
|
}
|
|
585
593
|
function requireState(state) {
|
|
586
|
-
if (!isRecord(state) || state.version !== STATE_VERSION || !isRecord(state.config) || !isRecord(state.intents) || !isRecord(state.episode)) {
|
|
594
|
+
if (!isRecord(state) || state.version !== STATE_VERSION || !isRecord(state.config) || !isRecord(state.intents) || !isRecord(state.episode) || !isRecord(state.clock)) {
|
|
587
595
|
throw new TypeError("oracular: state must be an EngineState from createState or deserialize");
|
|
588
596
|
}
|
|
589
597
|
return state;
|
|
@@ -720,8 +728,10 @@ function explain(state, intent, at, opts) {
|
|
|
720
728
|
|
|
721
729
|
// src/observe.ts
|
|
722
730
|
function observe(state, event) {
|
|
723
|
-
|
|
724
|
-
const
|
|
731
|
+
const { name, at: stamp } = validateEvent(event);
|
|
732
|
+
const synced = syncClock(requireState(state), stamp);
|
|
733
|
+
let s = synced.state;
|
|
734
|
+
const at = synced.at;
|
|
725
735
|
const closeAt = timedOutAt(s.episode, s.config.episodeTimeout, at);
|
|
726
736
|
if (closeAt !== null) s = closeEpisode(s, closeAt);
|
|
727
737
|
const learning = resolveLearning(s.config);
|
|
@@ -734,7 +744,6 @@ function observe(state, event) {
|
|
|
734
744
|
let is = getIntentState(s, intentName, intent);
|
|
735
745
|
is = decayIntent(is, intent, at);
|
|
736
746
|
is = settlePending(is, intent, learning, at);
|
|
737
|
-
is = pinAnchors(is, at);
|
|
738
747
|
if (isSignal) {
|
|
739
748
|
const { effective } = signalWeights(is, intent, learning, name);
|
|
740
749
|
const n = occurrences(episode, intentName, name) + 1;
|
|
@@ -755,15 +764,36 @@ function observe(state, event) {
|
|
|
755
764
|
}
|
|
756
765
|
return { ...s, intents, episode };
|
|
757
766
|
}
|
|
758
|
-
function
|
|
759
|
-
const
|
|
760
|
-
|
|
761
|
-
if (
|
|
762
|
-
return {
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
+
function syncClock(state, stamp) {
|
|
768
|
+
const { at, behindAt } = state.clock;
|
|
769
|
+
if (at === null || stamp >= at) return { state: pinTo(state, stamp), at: stamp };
|
|
770
|
+
if (behindAt !== null) return syncClock(pinTo(state, behindAt), stamp);
|
|
771
|
+
return { state: { ...state, clock: { at, behindAt: stamp } }, at };
|
|
772
|
+
}
|
|
773
|
+
var INTENT_ANCHORS = ["updatedAt", "learnedAt", "lastRevealedAt", "lastOutcomeAt"];
|
|
774
|
+
var EPISODE_ANCHORS = ["startedAt", "lastEventAt"];
|
|
775
|
+
function pinTo(state, t) {
|
|
776
|
+
let intents = state.intents;
|
|
777
|
+
for (const [name, is] of Object.entries(state.intents)) {
|
|
778
|
+
let pinned = pinKeys(is, INTENT_ANCHORS, t);
|
|
779
|
+
if (is.pending.some((p) => p.endedAt > t)) {
|
|
780
|
+
pinned = {
|
|
781
|
+
...pinned,
|
|
782
|
+
pending: is.pending.map((p) => p.endedAt > t ? { ...p, endedAt: t } : p)
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
if (pinned !== is) intents = { ...intents, [name]: pinned };
|
|
786
|
+
}
|
|
787
|
+
const episode = pinKeys(state.episode, EPISODE_ANCHORS, t);
|
|
788
|
+
return { ...state, intents, episode, clock: { at: t, behindAt: null } };
|
|
789
|
+
}
|
|
790
|
+
function pinKeys(obj, keys, t) {
|
|
791
|
+
let out = obj;
|
|
792
|
+
for (const key of keys) {
|
|
793
|
+
const value = obj[key];
|
|
794
|
+
if (typeof value === "number" && value > t) out = { ...out, [key]: t };
|
|
795
|
+
}
|
|
796
|
+
return out;
|
|
767
797
|
}
|
|
768
798
|
function endEpisode(state, at) {
|
|
769
799
|
const s = requireState(state);
|
|
@@ -889,7 +919,8 @@ function createEndRecord(at, opts) {
|
|
|
889
919
|
// src/migrate.ts
|
|
890
920
|
var MIGRATIONS = {
|
|
891
921
|
1: fromV1,
|
|
892
|
-
2: fromV2
|
|
922
|
+
2: fromV2,
|
|
923
|
+
3: fromV3
|
|
893
924
|
};
|
|
894
925
|
function canMigrate(version) {
|
|
895
926
|
return hasOwn(MIGRATIONS, String(version));
|
|
@@ -930,6 +961,9 @@ function fromV2(stored) {
|
|
|
930
961
|
}
|
|
931
962
|
return { ...stored, version: 3, intents };
|
|
932
963
|
}
|
|
964
|
+
function fromV3(stored) {
|
|
965
|
+
return { ...stored, version: 4, clock: { at: null, behindAt: null } };
|
|
966
|
+
}
|
|
933
967
|
|
|
934
968
|
// src/serialize.ts
|
|
935
969
|
function serialize(state) {
|
|
@@ -1075,6 +1109,13 @@ function episodeState(value, path) {
|
|
|
1075
1109
|
intents: mapOwn(r.intents, `${path}.intents`, episodeIntent)
|
|
1076
1110
|
};
|
|
1077
1111
|
}
|
|
1112
|
+
function clockState(value, path) {
|
|
1113
|
+
const r = record(value, path);
|
|
1114
|
+
return {
|
|
1115
|
+
at: nullOrFinite(r.at, `${path}.at`),
|
|
1116
|
+
behindAt: nullOrFinite(r.behindAt, `${path}.behindAt`)
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1078
1119
|
function parseState(value) {
|
|
1079
1120
|
const r = record(value, "state");
|
|
1080
1121
|
if (r.version !== STATE_VERSION) {
|
|
@@ -1086,7 +1127,8 @@ function parseState(value) {
|
|
|
1086
1127
|
// when it is only stored, and the config passed in replaces it anyway.
|
|
1087
1128
|
config: normalizeConfig(r.config, false),
|
|
1088
1129
|
intents: mapOwn(r.intents, "state.intents", intentState),
|
|
1089
|
-
episode: episodeState(r.episode, "state.episode")
|
|
1130
|
+
episode: episodeState(r.episode, "state.episode"),
|
|
1131
|
+
clock: clockState(r.clock, "state.clock")
|
|
1090
1132
|
};
|
|
1091
1133
|
}
|
|
1092
1134
|
function requireComputable(state) {
|
|
@@ -1160,9 +1202,24 @@ function reconcile(raw, config) {
|
|
|
1160
1202
|
startedAt: raw.episode.startedAt,
|
|
1161
1203
|
lastEventAt: raw.episode.startedAt === null ? null : raw.episode.lastEventAt,
|
|
1162
1204
|
intents: episodeIntents
|
|
1163
|
-
}
|
|
1205
|
+
},
|
|
1206
|
+
clock: trustedClock(raw.clock, intents, raw.episode)
|
|
1164
1207
|
};
|
|
1165
1208
|
}
|
|
1209
|
+
function trustedClock(clock, intents, episode) {
|
|
1210
|
+
let at = clock.at;
|
|
1211
|
+
const see = (value) => {
|
|
1212
|
+
if (value !== null && (at === null || value > at)) at = value;
|
|
1213
|
+
};
|
|
1214
|
+
see(episode.startedAt);
|
|
1215
|
+
see(episode.lastEventAt);
|
|
1216
|
+
for (const is of Object.values(intents)) {
|
|
1217
|
+
see(is.updatedAt);
|
|
1218
|
+
see(is.lastOutcomeAt);
|
|
1219
|
+
}
|
|
1220
|
+
const behindAt = clock.behindAt !== null && at !== null && clock.behindAt < at ? clock.behindAt : null;
|
|
1221
|
+
return at === clock.at && behindAt === clock.behindAt ? clock : { at, behindAt };
|
|
1222
|
+
}
|
|
1166
1223
|
|
|
1167
1224
|
// src/units.ts
|
|
1168
1225
|
var ms = {
|