@jarenjs/core 0.46.5 → 0.49.2

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.
@@ -0,0 +1,229 @@
1
+ //@ts-check
2
+
3
+ //#region Rolling windows of time
4
+ // A rolling aggregate over a *duration* rather than over a count of
5
+ // rows. The distinction is the whole reason this module exists: a
6
+ // sixty-row window over a sensor that reports every second is a minute,
7
+ // and over a sensor that dropped half its readings it is two minutes.
8
+ // One of those is a specification and the other is an accident.
9
+ //
10
+ // The window is `(at − width, at]`: exactly `width` milliseconds wide,
11
+ // holding the current instant and not the one a full width behind it.
12
+ // That is the mirror of the `[start, end)` rule the rest of this kernel
13
+ // runs on — one endpoint in, one out, so consecutive windows neither
14
+ // overlap at a boundary nor drop it — turned around because a trailing
15
+ // window is anchored at its newest point rather than its oldest.
16
+ //
17
+ // Every output is one row, in the input's order, so the result lines up
18
+ // with the series it came from. Rows sharing an instant therefore share
19
+ // a window and share an answer: a span of time is a function of the
20
+ // instant it ends at, not of which of two simultaneous readings the
21
+ // loop happened to reach first.
22
+ //
23
+ // The complexity claim is per aggregate, and it is a claim about
24
+ // structure rather than about a stopwatch:
25
+ //
26
+ // sum, mean, count a running total. The arriving row is added and
27
+ // the departing row subtracted, so the window is
28
+ // never re-read.
29
+ // min, max a monotone deque. A row that arrives smaller
30
+ // than the rows behind it makes them unreachable,
31
+ // so they leave; each row is pushed and popped at
32
+ // most once.
33
+ // first, last the window's own ends, tracked by two pointers
34
+ // that only ever move forward.
35
+ //
36
+ // A carried total is not a fresh sum in the last bits when the values
37
+ // are not exactly representable — that is what carrying one costs, and
38
+ // the suite's corpora use exact binary fractions so the difference is
39
+ // zero and equality is the check rather than a tolerance.
40
+
41
+ import { addToParts } from '../dates/civil.js';
42
+ import { canonicalSeries } from './normalize.js';
43
+ import { compileSpan } from './bucket.js';
44
+ import { requireSpecMembers } from './selector.js';
45
+ import { CLOCK_MEMBERS } from './zone.js';
46
+
47
+ /** @typedef {import('./normalize.js').Sample} Sample */
48
+
49
+ /** What a window's rows can be reduced to — the seven of D5. */
50
+ const AGGREGATES = Object.freeze(['sum', 'mean', 'min', 'max', 'first', 'last', 'count']);
51
+
52
+ /**
53
+ * `rollingSeries`' closed specification: a window measured in time,
54
+ * how much of one counts, the clock a calendar width walks, and where
55
+ * a row keeps its instant and its reading.
56
+ */
57
+ export const ROLLING_MEMBERS = Object.freeze([
58
+ 'width', 'aggregate', 'minPeriods', 'at', 'value', ...CLOCK_MEMBERS,
59
+ ]);
60
+
61
+ /**
62
+ * A rolling aggregate over a time-width window.
63
+ *
64
+ * Returns one `{ at, value, count }` per input sample, labelled at that
65
+ * sample's own instant, where the window is the half-open span
66
+ * `(at − width, at]`. Two samples at one instant share that window, and
67
+ * so report the same value and count.
68
+ *
69
+ * `count` is the number of **source rows** in the window — duplicates
70
+ * and measured gaps included — and the six value aggregates skip `null`
71
+ * readings, so `value` is `null` exactly when the window had nothing to
72
+ * measure. `aggregate: 'count'` returns that row count as the value.
73
+ *
74
+ * `minPeriods` is the number of source rows the window must hold before
75
+ * a value is reported at all; below it the row is `null` with its real
76
+ * `count`. It counts rows rather than readings, so a window full of
77
+ * measured gaps satisfies it and still reports `null` — which is the
78
+ * honest answer: readings were due, and none of them carried a number.
79
+ *
80
+ * `width` is a fixed duration (`'PT1H'`, `86400000`) or, with a clock,
81
+ * a calendar one (`'P1M'`, and `'P1D'` on a named zone where a day may
82
+ * be 23 or 25 hours long). A calendar window's start is computed by the
83
+ * calendar kernel per output, which is O(1) each — it is never a scan
84
+ * back through the window.
85
+ *
86
+ * @param {any[]} rows - the samples, in any order
87
+ * @param {Object} spec
88
+ * @param {number | string} spec.width - the window's width
89
+ * @param {'sum'|'mean'|'min'|'max'|'first'|'last'|'count'} [spec.aggregate]
90
+ * default `'mean'`
91
+ * @param {number} [spec.minPeriods] - default 1
92
+ * @param {string} [spec.zone] - a named zone, needing `provider`
93
+ * @param {number} [spec.offset] - minutes east of UTC
94
+ * @param {import('./zone.js').ZoneProvider} [spec.provider]
95
+ * @param {'reject'|'earlier'|'later'} [spec.disambiguation]
96
+ * @param {string | ((item: any, index: number) => any)} [spec.at]
97
+ * @param {string | ((item: any, index: number) => any)} [spec.value]
98
+ * @returns {{ at: number, value: number | null, count: number }[]}
99
+ * @throws {TypeError} for a width that is not one positive whole span,
100
+ * an unknown aggregate, a `minPeriods` that is not a positive whole
101
+ * number, or a row that is not a canonical sample
102
+ * @example
103
+ * rollingSeries(readings, { width: 'PT5M', aggregate: 'mean' });
104
+ * rollingSeries(readings, { width: 'PT1M', minPeriods: 60 });
105
+ */
106
+ export function rollingSeries(rows, spec) {
107
+ requireSpecMembers(spec, ROLLING_MEMBERS, 'rollingSeries',
108
+ 'a rolling spec is an object with a \'width\'');
109
+ const samples = canonicalSeries(rows, spec);
110
+ const span = compileSpan(spec.width, spec);
111
+ const aggregate = spec.aggregate ?? 'mean';
112
+ if (typeof aggregate !== 'string' || !AGGREGATES.includes(aggregate)) {
113
+ throw new TypeError(`aggregate is ${AGGREGATES.map((a) => `'${a}'`).join(', ')}, not ${
114
+ JSON.stringify(aggregate)}`);
115
+ }
116
+ const minPeriods = spec.minPeriods ?? 1;
117
+ if (!Number.isInteger(minPeriods) || minPeriods < 1)
118
+ throw new TypeError(`minPeriods is a positive whole number, not ${minPeriods}`);
119
+
120
+ const n = samples.length;
121
+ /** @type {{ at: number, value: number | null, count: number }[]} */
122
+ const out = new Array(n);
123
+
124
+ // where the window holding `at` opens — exclusively, so a sample
125
+ // exactly one width behind the current one has already left
126
+ const opensAt = span.calendar
127
+ ? (/** @type {number} */ at) =>
128
+ span.clock.epochOf(addToParts(span.clock.partsAt(at), -span.amount, span.unit))
129
+ : (/** @type {number} */ at) => at - span.width;
130
+
131
+ const wantsExtremum = aggregate === 'min' || aggregate === 'max';
132
+ const sign = aggregate === 'min' ? 1 : -1;
133
+ /** The monotone deque, as indices into `samples`; `head`..`tail-1` is live. */
134
+ const deque = wantsExtremum ? new Int32Array(n) : null;
135
+ let head = 0;
136
+ let tail = 0;
137
+
138
+ let lo = 0;
139
+ let sum = 0;
140
+ let measured = 0;
141
+ let firstAt = 0;
142
+ let lastAt = -1;
143
+
144
+ // rows are admitted a whole instant at a time. A window is a span of
145
+ // time, so two readings in the same millisecond are both inside every
146
+ // window that holds either of them — and the two output rows, sharing
147
+ // an instant, therefore share an answer. Letting the loop admit one
148
+ // and then report before admitting the other would make the aggregate
149
+ // a function of arrival order rather than of the instant it is
150
+ // labelled at.
151
+ let i = 0;
152
+ while (i < n) {
153
+ const at = samples[i].at;
154
+ let group = i;
155
+ while (group + 1 < n && samples[group + 1].at === at)
156
+ group++;
157
+
158
+ const opens = opensAt(at);
159
+ while (lo < i && samples[lo].at <= opens) {
160
+ const leaving = samples[lo].value;
161
+ if (leaving !== null) {
162
+ sum -= leaving;
163
+ measured--;
164
+ }
165
+ lo++;
166
+ }
167
+ for (let k = i; k <= group; k++) {
168
+ const value = samples[k].value;
169
+ if (value !== null) {
170
+ sum += value;
171
+ measured++;
172
+ lastAt = k;
173
+ if (deque !== null) {
174
+ while (tail > head
175
+ && sign * (/** @type {number} */(samples[deque[tail - 1]].value) - value) >= 0)
176
+ tail--;
177
+ deque[tail++] = k;
178
+ }
179
+ }
180
+ }
181
+ if (deque !== null) {
182
+ while (tail > head && deque[head] < lo)
183
+ head++;
184
+ }
185
+ if (firstAt < lo)
186
+ firstAt = lo;
187
+ while (firstAt <= group && samples[firstAt].value === null)
188
+ firstAt++;
189
+
190
+ const count = group - lo + 1;
191
+ const value = count < minPeriods ? null
192
+ : reduce(aggregate, count, measured, sum, samples, deque, head,
193
+ firstAt <= group ? firstAt : -1, lastAt >= lo ? lastAt : -1);
194
+ for (let k = i; k <= group; k++)
195
+ out[k] = { at, count, value };
196
+ i = group + 1;
197
+ }
198
+ return out;
199
+ }
200
+
201
+ /**
202
+ * One window's rows, reduced from the state the loop already carries.
203
+ * @param {string} aggregate
204
+ * @param {number} count - source rows, gaps included
205
+ * @param {number} measured - rows carrying a number
206
+ * @param {number} sum
207
+ * @param {Sample[]} samples
208
+ * @param {Int32Array | null} deque
209
+ * @param {number} head
210
+ * @param {number} first - index of the window's first reading, or -1
211
+ * @param {number} last - index of the window's last reading, or -1
212
+ * @returns {number | null}
213
+ */
214
+ function reduce(aggregate, count, measured, sum, samples, deque, head, first, last) {
215
+ if (aggregate === 'count')
216
+ return count;
217
+ if (measured === 0)
218
+ return null;
219
+ switch (aggregate) {
220
+ case 'sum': return sum;
221
+ case 'mean': return sum / measured;
222
+ case 'min':
223
+ case 'max': return /** @type {number} */ (samples[/** @type {Int32Array} */(deque)[head]].value);
224
+ case 'first': return first < 0 ? null : samples[first].value;
225
+ default: return last < 0 ? null : samples[last].value;
226
+ }
227
+ }
228
+
229
+ //#endregion
@@ -0,0 +1,104 @@
1
+ //@ts-check
2
+
3
+ //#region Reading a source row
4
+ // Where a member lives in a caller's row, and nothing else.
5
+ //
6
+ // Timestamped rows almost never arrive spelled `{ at, value }` or
7
+ // `{ start, end }`. They come out of a document as `on`, out of a
8
+ // database as `recorded_at`, out of a booking system as `from`/`to`. A
9
+ // selector is either that member's name or a function of the row, so
10
+ // nothing has to be rewritten into the canonical shape before it can be
11
+ // normalized or indexed.
12
+ //
13
+ // This module imports nothing on purpose: it is the leaf both the
14
+ // normalizer and the index stand on.
15
+
16
+ /**
17
+ * A reader for one member of a source row.
18
+ * @param {string | ((item: any, index: number) => any)} spec - a
19
+ * property name, or a function of the row and its position
20
+ * @param {string} role - the member's name, for the message
21
+ * @returns {(item: any, index: number) => any}
22
+ * @throws {TypeError} when `spec` is neither
23
+ */
24
+ export function selectorOf(spec, role) {
25
+ if (typeof spec === 'string')
26
+ return (item) => item[spec];
27
+ if (typeof spec === 'function')
28
+ return spec;
29
+ throw new TypeError(`the '${role}' selector is a property name or a function`);
30
+ }
31
+
32
+ /**
33
+ * The row at `index`, confirmed to be something with members to read.
34
+ * @param {any} item
35
+ * @param {number} index
36
+ * @returns {any}
37
+ * @throws {TypeError} when it is not an object
38
+ */
39
+ export function requireRow(item, index) {
40
+ if (item === null || typeof item !== 'object')
41
+ throw new TypeError(`row ${index} is not an object`);
42
+ return item;
43
+ }
44
+
45
+ //#endregion
46
+
47
+ //#region Reading a specification
48
+ // A specification is closed, and a member nobody admitted is a refusal.
49
+ //
50
+ // `minPeriod` for `minPeriods` silently ignored is the bug that takes
51
+ // an afternoon: the window still answers, the number is still
52
+ // plausible, and it was computed from a specification nobody wrote.
53
+ // The zone members are worse — a named zone with NO provider refuses
54
+ // (`zone.js`), so the only way left to reach a quiet UTC ladder is to
55
+ // misspell the member that refusal keys on, and UTC is right for
56
+ // Amsterdam for none of the year while looking right for eight months
57
+ // of it.
58
+ //
59
+ // The near miss is named because that is the whole cost of the bug:
60
+ // same first letter and a length within two, or a case-folded match.
61
+ // It never guesses when nothing is close.
62
+
63
+ /**
64
+ * The nearest admitted member to a misspelling.
65
+ * @param {string} name
66
+ * @param {readonly string[]} allowed
67
+ * @returns {string} `''`, or ` (did you mean 'x'?)`
68
+ */
69
+ function nearMiss(name, allowed) {
70
+ const lower = name.toLowerCase();
71
+ for (const candidate of allowed) {
72
+ const other = candidate.toLowerCase();
73
+ if (other === lower
74
+ || (other.startsWith(lower) && other.length - lower.length <= 2)
75
+ || (lower.startsWith(other) && lower.length - other.length <= 2))
76
+ return ` (did you mean '${candidate}'?)`;
77
+ }
78
+ return '';
79
+ }
80
+
81
+ /**
82
+ * One specification, confirmed to name only admitted members.
83
+ * @param {any} spec - the caller's specification object
84
+ * @param {readonly string[]} allowed - the closed member list
85
+ * @param {string} kernel - the function's name, for the message
86
+ * @param {string} shape - what the specification is, for the message
87
+ * when it is not an object at all
88
+ * @returns {any} the same object
89
+ * @throws {TypeError} when it is not an object, or names a member the
90
+ * kernel does not admit
91
+ */
92
+ export function requireSpecMembers(spec, allowed, kernel, shape) {
93
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec))
94
+ throw new TypeError(shape);
95
+ for (const name of Object.keys(spec)) {
96
+ if (!allowed.includes(name)) {
97
+ throw new TypeError(`${kernel} has no specification member '${name}'${
98
+ nearMiss(name, allowed)}; it admits ${allowed.map((m) => `'${m}'`).join(', ')}`);
99
+ }
100
+ }
101
+ return spec;
102
+ }
103
+
104
+ //#endregion
@@ -0,0 +1,188 @@
1
+ //@ts-check
2
+
3
+ //#region The zone seam
4
+ // Where a calendar boundary falls depends on a wall clock, and a wall
5
+ // clock that is not UTC is data this suite refuses to bundle. A tzdb is
6
+ // megabytes that go stale on a government's timetable; a Temporal
7
+ // polyfill is a runtime dependency; reading the host's zone is the
8
+ // hidden clock D7 exists to forbid. So this module is a **seam**, not an
9
+ // implementation.
10
+ //
11
+ // Three clocks come out of it:
12
+ //
13
+ // UTC the default. Nothing to configure, nothing to
14
+ // install, and no local time is ever ambiguous.
15
+ // a fixed offset `{ offset: -300 }` — minutes east of UTC, constant.
16
+ // Exact integer arithmetic, still unambiguous.
17
+ // a named zone `{ zone: 'Europe/Amsterdam', provider }` — the
18
+ // caller supplies the tzdb, in whatever form they
19
+ // already have one (Intl, Temporal, a table of their
20
+ // own). This module only says what it must answer.
21
+ //
22
+ // A provider answers two questions, and the second is the hard one:
23
+ //
24
+ // toParts(epoch, zone) → the wall clock at an instant
25
+ // toEpoch(parts, zone, disambiguation) → the instant at a wall clock
26
+ //
27
+ // The instant is hard because a local time is not a function of the
28
+ // clock. On a spring-forward day 02:30 never happens, and on a
29
+ // fall-back day 02:30 happens twice. `disambiguation` says which answer
30
+ // the caller wants — `'reject'` (the default: neither, it is an error),
31
+ // `'earlier'` or `'later'` — and a provider that cannot produce one says
32
+ // so by returning a non-finite number or throwing, which this module
33
+ // turns into a refusal naming the local time rather than a silent hour.
34
+
35
+ import { partsFromEpoch } from '../dates/civil.js';
36
+ import { epochOfRFC3339Parts } from '../dates/rfc3339.js';
37
+
38
+ /**
39
+ * A wall clock the caller supplies, for zones this suite does not carry.
40
+ * @typedef {Object} ZoneProvider
41
+ * @property {(epoch: number, zone: string) => any} toParts - the wall
42
+ * clock at an instant, as a parts record (`year`, `month` 1-12, `day`,
43
+ * `hours`, `minutes`, `seconds`, and `offset` in minutes east if known)
44
+ * @property {(parts: any, zone: string, disambiguation: string) => number}
45
+ * toEpoch - the instant a wall clock names, in epoch milliseconds;
46
+ * non-finite (or a throw) when the local time is ambiguous or does not
47
+ * exist and `disambiguation` does not resolve it
48
+ */
49
+
50
+ /**
51
+ * A resolved wall clock: the two directions, already bound to a zone.
52
+ * @typedef {Object} Clock
53
+ * @property {string} zone - what the clock is called, for messages
54
+ * @property {(epoch: number) => any} partsAt
55
+ * @property {(parts: any) => number} epochOf
56
+ */
57
+
58
+ /** What a caller may ask for when a local time is ambiguous or absent. */
59
+ const DISAMBIGUATION = Object.freeze(['reject', 'earlier', 'later']);
60
+
61
+ /**
62
+ * The four members every calendar-aware specification carries, so
63
+ * a bucket ladder, a rolling window and a query document all spell
64
+ * the clock the same way.
65
+ */
66
+ export const CLOCK_MEMBERS = Object.freeze(['zone', 'offset', 'provider', 'disambiguation']);
67
+
68
+ /**
69
+ * The clock an operation reads its calendar boundaries on.
70
+ *
71
+ * Defaults to UTC, which needs nothing: no provider, no zone name, no
72
+ * ambiguity. `{ offset }` is a constant number of minutes east and is
73
+ * exact integer arithmetic. `{ zone }` is a name, and a name means
74
+ * nothing without a `provider` — asking for `'Europe/Amsterdam'` with no
75
+ * tzdb is a refusal, never a silent fall back to UTC that is right for
76
+ * eight months of the year.
77
+ *
78
+ * @param {Object} [options]
79
+ * @param {string} [options.zone] - an IANA name; `'UTC'` needs no provider
80
+ * @param {number} [options.offset] - minutes east of UTC, for a fixed offset
81
+ * @param {ZoneProvider} [options.provider] - the tzdb, for a named zone
82
+ * @param {'reject' | 'earlier' | 'later'} [options.disambiguation]
83
+ * what a local time that happens twice, or never, resolves to
84
+ * (default `'reject'`)
85
+ * @returns {Clock}
86
+ * @throws {TypeError} for a named zone with no provider, a non-finite
87
+ * offset, an unknown disambiguation, or both a zone and an offset
88
+ * @example
89
+ * resolveClock(); // UTC
90
+ * resolveClock({ offset: 330 }); // +05:30, constant
91
+ * resolveClock({ zone: 'Europe/Amsterdam', provider, disambiguation: 'later' });
92
+ */
93
+ export function resolveClock(options = {}) {
94
+ if (options === null || typeof options !== 'object')
95
+ throw new TypeError('clock options are an object');
96
+ const disambiguation = options.disambiguation ?? 'reject';
97
+ if (!DISAMBIGUATION.includes(disambiguation)) {
98
+ throw new TypeError(`disambiguation is ${
99
+ DISAMBIGUATION.map((d) => `'${d}'`).join(', ')}, not '${disambiguation}'`);
100
+ }
101
+ const named = options.zone !== undefined && options.zone !== 'UTC';
102
+ if (named && options.offset !== undefined)
103
+ throw new TypeError(`a clock is a zone or an offset, not both ('${options.zone}' and ${options.offset})`);
104
+ if (named)
105
+ return providerClock(/** @type {string} */(options.zone), options.provider, disambiguation);
106
+ const offset = options.offset ?? 0;
107
+ if (typeof offset !== 'number' || !Number.isFinite(offset))
108
+ throw new TypeError('an offset is a finite number of minutes east of UTC');
109
+ return offsetClock(offset);
110
+ }
111
+
112
+ /**
113
+ * The UTC-or-fixed-offset clock: a constant shift, so every local time
114
+ * exists exactly once and `disambiguation` never has anything to decide.
115
+ * @param {number} offset - minutes east of UTC
116
+ * @returns {Clock}
117
+ */
118
+ function offsetClock(offset) {
119
+ return {
120
+ zone: offset === 0 ? 'UTC' : formatOffset(offset),
121
+ partsAt: (epoch) => partsFromEpoch(epoch, offset),
122
+ epochOf: (parts) => {
123
+ const ms = epochOfRFC3339Parts({ ...parts, offset });
124
+ if (!Number.isFinite(ms))
125
+ throw new TypeError(`${describe(parts)} names no instant at ${formatOffset(offset)}`);
126
+ return ms;
127
+ },
128
+ };
129
+ }
130
+
131
+ /**
132
+ * The named-zone clock: every question goes to the caller's provider,
133
+ * and an answer that is not a finite instant becomes a refusal naming
134
+ * the local time that has none.
135
+ * @param {string} zone
136
+ * @param {ZoneProvider | undefined} provider
137
+ * @param {string} disambiguation
138
+ * @returns {Clock}
139
+ */
140
+ function providerClock(zone, provider, disambiguation) {
141
+ if (provider === null || typeof provider !== 'object'
142
+ || typeof provider.toParts !== 'function' || typeof provider.toEpoch !== 'function') {
143
+ throw new TypeError(`the zone '${zone}' needs a provider with toParts(epoch, zone) and`
144
+ + ' toEpoch(parts, zone, disambiguation); this suite bundles no time-zone database');
145
+ }
146
+ return {
147
+ zone,
148
+ partsAt: (epoch) => {
149
+ const parts = provider.toParts(epoch, zone);
150
+ if (parts === null || typeof parts !== 'object')
151
+ throw new TypeError(`the provider gave no wall clock for ${epoch} in '${zone}'`);
152
+ return parts;
153
+ },
154
+ epochOf: (parts) => {
155
+ let ms;
156
+ try {
157
+ ms = provider.toEpoch(parts, zone, disambiguation);
158
+ }
159
+ catch (error) {
160
+ throw new TypeError(`${describe(parts)} in '${zone}': ${
161
+ error instanceof Error ? error.message : String(error)}`);
162
+ }
163
+ if (typeof ms !== 'number' || !Number.isFinite(ms)) {
164
+ throw new TypeError(`${describe(parts)} in '${zone}' is ambiguous or does not exist,`
165
+ + ` and disambiguation is '${disambiguation}'`);
166
+ }
167
+ return ms;
168
+ },
169
+ };
170
+ }
171
+
172
+ /** `+02:00` / `-05:30`, for a message. @param {number} minutes @returns {string} */
173
+ function formatOffset(minutes) {
174
+ const sign = minutes < 0 ? '-' : '+';
175
+ const abs = Math.abs(minutes);
176
+ const pad = (n) => String(n).padStart(2, '0');
177
+ return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
178
+ }
179
+
180
+ /** A local time, for a message. @param {any} parts @returns {string} */
181
+ function describe(parts) {
182
+ const pad = (n) => String(n).padStart(2, '0');
183
+ return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}T${
184
+ pad(Math.max(parts.hours, 0))}:${pad(Math.max(parts.minutes, 0))}:${
185
+ pad(Math.floor(Math.max(parts.seconds, 0)))}`;
186
+ }
187
+
188
+ //#endregion