@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.
Files changed (42) hide show
  1. package/ARCHITECTURE.md +122 -9
  2. package/README.md +50 -3
  3. package/dist/types/async.d.ts +39 -0
  4. package/dist/types/dates/civil.d.ts +24 -5
  5. package/dist/types/dates/index.d.ts +2 -0
  6. package/dist/types/dates/parse.d.ts +47 -0
  7. package/dist/types/dates/ticks.d.ts +59 -0
  8. package/dist/types/math/float64.d.ts +13 -0
  9. package/dist/types/random.d.ts +80 -0
  10. package/dist/types/series/asof.d.ts +88 -0
  11. package/dist/types/series/bucket.d.ts +206 -0
  12. package/dist/types/series/downsample.d.ts +53 -0
  13. package/dist/types/series/index.d.ts +8 -0
  14. package/dist/types/series/interval-index.d.ts +47 -0
  15. package/dist/types/series/interval.d.ts +170 -0
  16. package/dist/types/series/normalize.d.ts +190 -0
  17. package/dist/types/series/rolling.d.ts +67 -0
  18. package/dist/types/series/selector.d.ts +29 -0
  19. package/dist/types/series/zone.d.ts +59 -0
  20. package/dist/types/stats.d.ts +73 -0
  21. package/docs/DATES.md +77 -1
  22. package/docs/GEO.md +2 -2
  23. package/docs/SERIES.md +342 -0
  24. package/package.json +21 -1
  25. package/src/async.js +78 -0
  26. package/src/dates/civil.js +136 -41
  27. package/src/dates/index.js +4 -0
  28. package/src/dates/parse.js +410 -0
  29. package/src/dates/ticks.js +184 -0
  30. package/src/math/float64.js +29 -0
  31. package/src/random.js +125 -0
  32. package/src/series/asof.js +276 -0
  33. package/src/series/bucket.js +542 -0
  34. package/src/series/downsample.js +343 -0
  35. package/src/series/index.js +86 -0
  36. package/src/series/interval-index.js +181 -0
  37. package/src/series/interval.js +374 -0
  38. package/src/series/normalize.js +330 -0
  39. package/src/series/rolling.js +229 -0
  40. package/src/series/selector.js +104 -0
  41. package/src/series/zone.js +188 -0
  42. 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
+ }