@jarenjs/core 0.46.5 → 0.56.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/ARCHITECTURE.md +122 -9
- package/README.md +50 -3
- package/dist/types/async.d.ts +39 -0
- package/dist/types/dates/civil.d.ts +24 -5
- package/dist/types/dates/index.d.ts +2 -0
- package/dist/types/dates/parse.d.ts +47 -0
- package/dist/types/dates/ticks.d.ts +59 -0
- package/dist/types/math/float64.d.ts +13 -0
- package/dist/types/random.d.ts +80 -0
- package/dist/types/series/asof.d.ts +88 -0
- package/dist/types/series/bucket.d.ts +206 -0
- package/dist/types/series/downsample.d.ts +53 -0
- package/dist/types/series/index.d.ts +8 -0
- package/dist/types/series/interval-index.d.ts +47 -0
- package/dist/types/series/interval.d.ts +170 -0
- package/dist/types/series/normalize.d.ts +190 -0
- package/dist/types/series/rolling.d.ts +67 -0
- package/dist/types/series/selector.d.ts +29 -0
- package/dist/types/series/zone.d.ts +59 -0
- package/dist/types/stats.d.ts +73 -0
- package/docs/DATES.md +77 -1
- package/docs/GEO.md +2 -2
- package/docs/SERIES.md +342 -0
- package/package.json +21 -1
- package/src/async.js +78 -0
- package/src/dates/civil.js +136 -41
- package/src/dates/index.js +4 -0
- package/src/dates/parse.js +410 -0
- package/src/dates/ticks.js +184 -0
- package/src/math/float64.js +29 -0
- package/src/random.js +125 -0
- package/src/series/asof.js +276 -0
- package/src/series/bucket.js +542 -0
- package/src/series/downsample.js +343 -0
- package/src/series/index.js +86 -0
- package/src/series/interval-index.js +181 -0
- package/src/series/interval.js +374 -0
- package/src/series/normalize.js +330 -0
- package/src/series/rolling.js +229 -0
- package/src/series/selector.js +104 -0
- package/src/series/zone.js +188 -0
- package/src/stats.js +133 -0
|
@@ -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
|
package/src/stats.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Descriptive statistics over a sample of numbers: the mean, the
|
|
4
|
+
* sample variance and its root, the median, and a quantile that will
|
|
5
|
+
* not answer until told which quantile it is being asked for.
|
|
6
|
+
*
|
|
7
|
+
* Before this file the suite computed these in two places with two
|
|
8
|
+
* quantile rules — linear interpolation in the query layer's statistics
|
|
9
|
+
* pack, nearest rank in the benchmark harness — and each was the right
|
|
10
|
+
* rule for its consumer: an interpolated percentile is what an analyst
|
|
11
|
+
* expects of `$percentile`, while a benchmark row of eleven readings
|
|
12
|
+
* should not publish a latency nobody measured. Both rules stay; the
|
|
13
|
+
* definitions move here so that there is one of each, and `quantile`
|
|
14
|
+
* makes the caller name its method, because "the 95th percentile" of a
|
|
15
|
+
* small sample is a different number under every one of the seven
|
|
16
|
+
* common definitions and a default would decide silently.
|
|
17
|
+
*
|
|
18
|
+
* Every function answers `undefined` for a sample it cannot summarize —
|
|
19
|
+
* an empty one, or fewer than two values for a variance — rather than
|
|
20
|
+
* `NaN` or `0`: a number that was not measured must not format as one.
|
|
21
|
+
* The caller's array is never reordered; a quantile sorts a copy.
|
|
22
|
+
* Values are numbers by contract and are not checked one by one.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The arithmetic mean.
|
|
27
|
+
* @param {readonly number[]} values
|
|
28
|
+
* @returns {number | undefined} `undefined` for an empty sample
|
|
29
|
+
*/
|
|
30
|
+
export function mean(values) {
|
|
31
|
+
if (values.length === 0) return undefined;
|
|
32
|
+
let sum = 0;
|
|
33
|
+
for (const value of values) sum += value;
|
|
34
|
+
return sum / values.length;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The SAMPLE variance, with Bessel's correction (`n − 1`): the sample is
|
|
39
|
+
* taken as drawn from a population it did not enumerate, which is what a
|
|
40
|
+
* benchmark's rounds and a query's rows both are.
|
|
41
|
+
* @param {readonly number[]} values
|
|
42
|
+
* @returns {number | undefined} `undefined` for fewer than two values
|
|
43
|
+
*/
|
|
44
|
+
export function variance(values) {
|
|
45
|
+
if (values.length < 2) return undefined;
|
|
46
|
+
const m = /** @type {number} */ (mean(values));
|
|
47
|
+
let sum = 0;
|
|
48
|
+
for (const value of values) sum += (value - m) * (value - m);
|
|
49
|
+
return sum / (values.length - 1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The sample standard deviation — the root of {@link variance}.
|
|
54
|
+
* @param {readonly number[]} values
|
|
55
|
+
* @returns {number | undefined} `undefined` where the variance is
|
|
56
|
+
*/
|
|
57
|
+
export function stddev(values) {
|
|
58
|
+
const v = variance(values);
|
|
59
|
+
return v === undefined ? undefined : Math.sqrt(v);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {readonly number[]} values
|
|
64
|
+
* @returns {number[]} an ascending copy
|
|
65
|
+
*/
|
|
66
|
+
function ascending(values) {
|
|
67
|
+
return [...values].sort((a, b) => a - b);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The median: the middle value, or the mean of the two middle values
|
|
72
|
+
* when the sample has an even count.
|
|
73
|
+
*
|
|
74
|
+
* This is NOT `quantile(values, 0.5, …)` under either method, and on an
|
|
75
|
+
* even count the three disagree: for `[1, 2, 3, 4]` the median is `2.5`,
|
|
76
|
+
* the nearest-rank p50 is `2`, and the linear p50 is `2.5` only because
|
|
77
|
+
* that sample happens to be evenly spaced. A consumer publishing a "p50"
|
|
78
|
+
* beside a "p95" wants `quantile` with its method named; a consumer
|
|
79
|
+
* asking for the median wants this.
|
|
80
|
+
* @param {readonly number[]} values
|
|
81
|
+
* @returns {number | undefined} `undefined` for an empty sample
|
|
82
|
+
*/
|
|
83
|
+
export function median(values) {
|
|
84
|
+
if (values.length === 0) return undefined;
|
|
85
|
+
const sorted = ascending(values);
|
|
86
|
+
const mid = sorted.length >> 1;
|
|
87
|
+
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The quantile methods this module knows.
|
|
92
|
+
*
|
|
93
|
+
* - `'nearest-rank'`: the value at rank `max(1, ceil(p · n))` of the
|
|
94
|
+
* ascending sample — always a value that was measured, never one
|
|
95
|
+
* invented between two. The benchmark rule.
|
|
96
|
+
* - `'linear'`: rank `(n − 1) · p`, interpolated linearly between the
|
|
97
|
+
* values at `floor` and `ceil` of it (Hyndman–Fan type 7, the default
|
|
98
|
+
* of R, NumPy and spreadsheets). The analyst's rule.
|
|
99
|
+
* @typedef {'nearest-rank' | 'linear'} QuantileMethod
|
|
100
|
+
*/
|
|
101
|
+
|
|
102
|
+
/** @type {readonly QuantileMethod[]} */
|
|
103
|
+
const METHODS = ['nearest-rank', 'linear'];
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The `p`-quantile of a sample, `p` on `[0, 1]`, under a NAMED method.
|
|
107
|
+
*
|
|
108
|
+
* The method is required, not defaulted: on a small sample the common
|
|
109
|
+
* definitions disagree by whole values, and a caller who did not say
|
|
110
|
+
* which one it wanted has published a number it cannot explain.
|
|
111
|
+
* @param {readonly number[]} values
|
|
112
|
+
* @param {number} p - the probability, `0` (the minimum) to `1` (the maximum)
|
|
113
|
+
* @param {{ method: QuantileMethod }} options
|
|
114
|
+
* @returns {number | undefined} `undefined` for an empty sample
|
|
115
|
+
* @throws {TypeError} when `method` is absent or not one of {@link QuantileMethod}
|
|
116
|
+
* @throws {RangeError} when `p` is not a number in `[0, 1]`
|
|
117
|
+
*/
|
|
118
|
+
export function quantile(values, p, options) {
|
|
119
|
+
const method = options?.method;
|
|
120
|
+
if (!METHODS.includes(/** @type {any} */ (method)))
|
|
121
|
+
throw new TypeError(`quantile needs { method: 'nearest-rank' | 'linear' }, got ${JSON.stringify(method)}`);
|
|
122
|
+
if (typeof p !== 'number' || !(p >= 0 && p <= 1))
|
|
123
|
+
throw new RangeError(`quantile needs p in [0, 1], got ${String(p)}`);
|
|
124
|
+
const n = values.length;
|
|
125
|
+
if (n === 0) return undefined;
|
|
126
|
+
const sorted = ascending(values);
|
|
127
|
+
if (method === 'nearest-rank') return sorted[Math.max(1, Math.ceil(p * n)) - 1];
|
|
128
|
+
const rank = (n - 1) * p;
|
|
129
|
+
const lo = Math.floor(rank);
|
|
130
|
+
const hi = Math.ceil(rank);
|
|
131
|
+
if (lo === hi) return sorted[lo];
|
|
132
|
+
return sorted[lo] + (sorted[hi] - sorted[lo]) * (rank - lo);
|
|
133
|
+
}
|