@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
package/docs/SERIES.md
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# `@jarenjs/core/series`
|
|
2
|
+
|
|
3
|
+
The temporal kernel: one meaning for an instant, one meaning for an
|
|
4
|
+
interval, and the set algebra over them. A roster, a calendar, an event
|
|
5
|
+
log, an availability view and a telemetry graph each rebuild the same
|
|
6
|
+
loops today — "does this overlap", "where is the free time", "what is
|
|
7
|
+
covered", "which events are live right now" — and this is the module
|
|
8
|
+
that answers them once.
|
|
9
|
+
|
|
10
|
+
Two constraints shape it, the same two the calendar kernel is built on.
|
|
11
|
+
**There is no type**: an instant is epoch milliseconds or an RFC 3339
|
|
12
|
+
string, a sample is `{ at, value }`, an interval is `{ start, end }` —
|
|
13
|
+
all of them JSON already, so they survive a patch, a schema, a pointer,
|
|
14
|
+
a stored document and a wire reply unchanged. And **there is no `now`**:
|
|
15
|
+
every bound is data, and an operation that needs a window and was given
|
|
16
|
+
none derives it from its own input, never from the clock. Import the
|
|
17
|
+
barrel (`@jarenjs/core/series`) or a single module.
|
|
18
|
+
|
|
19
|
+
**Every specification is closed.** `resampleSeries`, `rollingSeries`,
|
|
20
|
+
`asOfJoin`, `downsampleSeries`, `findSlots` and `mergeIntervals` each
|
|
21
|
+
publish the members they admit — `RESAMPLE_MEMBERS`, `ROLLING_MEMBERS`,
|
|
22
|
+
`ASOF_MEMBERS`, `DOWNSAMPLE_MEMBERS`, `SLOTS_MEMBERS`, `MERGE_MEMBERS`
|
|
23
|
+
— and anything else is a `TypeError` naming the near miss. `minPeriod`
|
|
24
|
+
for `minPeriods` accepted and ignored is a window with no minimum and a
|
|
25
|
+
plausible number; `timezone` for `zone` is the quiet fall back to UTC
|
|
26
|
+
that the clock's own refusal exists to prevent. The query language
|
|
27
|
+
reads these same lists (§8.16), minus `provider`, which is a pair of
|
|
28
|
+
functions and therefore not something a document can carry.
|
|
29
|
+
|
|
30
|
+
## Normalization — `normalize.js`
|
|
31
|
+
|
|
32
|
+
`toEpoch(value)` is the one door: a finite number passes through, a
|
|
33
|
+
valid RFC 3339 string becomes the instant it names. A full-date reads as
|
|
34
|
+
UTC midnight. Three things are `TypeError` rather than a guess — a
|
|
35
|
+
full-time (`'09:30:00Z'` names no day), an offset-less date-time
|
|
36
|
+
(`'2026-01-01T09:30:00'` names no instant without a zone, and there is
|
|
37
|
+
no implicit machine zone here), and a `Date` object.
|
|
38
|
+
|
|
39
|
+
`normalizeSeries(rows, {at, value}?)` and
|
|
40
|
+
`normalizeIntervals(rows, {start, end}?)` convert a whole collection
|
|
41
|
+
once and sort it. Both take a **selector** per member — a property name
|
|
42
|
+
or a function — so rows spelled `on`, `recorded_at` or `from`/`to` are
|
|
43
|
+
read where they are rather than rewritten first. Each result is a
|
|
44
|
+
shallow copy of its source row with the canonical members written over
|
|
45
|
+
it, so nothing a caller attached is lost.
|
|
46
|
+
|
|
47
|
+
Three rules make the result safe to build on:
|
|
48
|
+
|
|
49
|
+
- **A row is never dropped.** A member that cannot become a finite
|
|
50
|
+
instant is a refusal naming the row (`row 4172, at: …`), not a
|
|
51
|
+
silently shorter answer.
|
|
52
|
+
- **The sort is stable.** Rows sharing an instant come out in input
|
|
53
|
+
order, and *all* of them are present: a duplicate instant is two
|
|
54
|
+
readings in the same millisecond, not a key collision.
|
|
55
|
+
- **A value may be absent, but only explicitly.** `null` is a measured
|
|
56
|
+
gap and survives; `undefined`, a string or a `NaN` is a defect.
|
|
57
|
+
|
|
58
|
+
`lowerBoundTime(rows, at, key?)` and `upperBoundTime(rows, at, key?)`
|
|
59
|
+
are the binary cuts a normalized array is then read through — together
|
|
60
|
+
they bracket the rows *at* an instant, which is what a duplicate-tolerant
|
|
61
|
+
as-of has to read.
|
|
62
|
+
|
|
63
|
+
## Interval algebra — `interval.js`
|
|
64
|
+
|
|
65
|
+
Every interval is **half-open**, `[start, end)`: it holds its start and
|
|
66
|
+
not its end. So a day ends exactly where the next begins, nothing is
|
|
67
|
+
counted twice at a boundary, and two intervals that touch do **not**
|
|
68
|
+
overlap — back-to-back bookings are not a double booking. An interval
|
|
69
|
+
that is empty (`[t, t)`), reversed or not finite is refused at the point
|
|
70
|
+
it was written.
|
|
71
|
+
|
|
72
|
+
| Function | Answers |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `containsInstant(interval, at)` | is this instant inside — `start <= at < end` |
|
|
75
|
+
| `overlapsInterval(a, b)` | do they share an instant (touching does not) |
|
|
76
|
+
| `intersectInterval(a, b)` | the span they share, or `null` |
|
|
77
|
+
| `mergeIntervals(list, {adjacent}?)` | the union, as the fewest disjoint spans |
|
|
78
|
+
| `subtractIntervals(from, remove)` | the set difference; a cut through the middle splits |
|
|
79
|
+
| `gapsWithin(list, window?)` | the uncovered spans |
|
|
80
|
+
| `coverageOf(list, window?)` | milliseconds covered, an instant counted once |
|
|
81
|
+
| `findSlots(availability, spec)` | where a fixed-width span fits |
|
|
82
|
+
|
|
83
|
+
Merging is the one place "touching does not overlap" is not the answer
|
|
84
|
+
a caller wants, which is why `mergeIntervals` **joins touching spans by
|
|
85
|
+
default**: availability asks whether there is continuous cover, and
|
|
86
|
+
09:00–13:00 plus 13:00–17:00 is continuous cover. The other reading is
|
|
87
|
+
real too — a handover is two shifts, not one — and it is spelled
|
|
88
|
+
`{ adjacent: false }`, so nothing has to guess. Overlapping spans join
|
|
89
|
+
under both settings.
|
|
90
|
+
|
|
91
|
+
`gapsWithin` and `coverageOf` take an optional window. Without one they
|
|
92
|
+
work inside the hull of the intervals themselves, because the only other
|
|
93
|
+
default would be a clock. Passing the window explicitly is what reports a
|
|
94
|
+
missing *edge*: an empty morning is only a gap once the caller says the
|
|
95
|
+
day starts at nine. `coverageOf` returns milliseconds, so the ratio is
|
|
96
|
+
the caller's own division:
|
|
97
|
+
`coverageOf(shifts, day) / (day.end - day.start)`.
|
|
98
|
+
|
|
99
|
+
`findSlots(availability, { duration, step? })` merges availability, then
|
|
100
|
+
walks each window from its own start in `step` increments (default: back
|
|
101
|
+
to back), keeping every span that still ends inside. `duration` and
|
|
102
|
+
`step` are fixed widths — milliseconds, or a fixed ISO 8601 duration
|
|
103
|
+
(`'PT30M'`); a calendar duration (`P1M`) is refused rather than called
|
|
104
|
+
thirty days. It is enumeration, not scheduling: choosing among the
|
|
105
|
+
answers, weighing preferences and assigning people is a solver's job,
|
|
106
|
+
deliberately not this one.
|
|
107
|
+
|
|
108
|
+
Merge, subtract, gaps and slots return bare `{ start, end }` records. A
|
|
109
|
+
span welded out of three source rows belongs to none of them, and
|
|
110
|
+
carrying one of their identities forward would be a claim the data does
|
|
111
|
+
not support.
|
|
112
|
+
|
|
113
|
+
## The index — `interval-index.js`
|
|
114
|
+
|
|
115
|
+
`createIntervalIndex(items, selectors?)` builds once and answers many:
|
|
116
|
+
`index.at(instant)` for the spans holding an instant,
|
|
117
|
+
`index.overlapping(start, end)` for the spans sharing one with a window.
|
|
118
|
+
Results are the caller's **own rows**, ascending by start and — for rows
|
|
119
|
+
sharing a start — in the order they arrived, in a fresh array each time.
|
|
120
|
+
|
|
121
|
+
Sorting by start is not enough, and the reason is the whole design. A
|
|
122
|
+
binary search finds where a query falls among the starts, but a span that
|
|
123
|
+
began a year earlier and has not ended yet sits far to the *left* of that
|
|
124
|
+
neighbourhood and still overlaps — a conference week among hourly
|
|
125
|
+
meetings is exactly that span, and an index that merely cuts around the
|
|
126
|
+
query loses it while looking plausible. So the index carries a second
|
|
127
|
+
array: the prefix maximum end, non-decreasing by construction and
|
|
128
|
+
therefore binary-searchable too. The first position where it passes the
|
|
129
|
+
query's start is the first position where anything can still be live.
|
|
130
|
+
|
|
131
|
+
A query is two binary cuts and a walk between them — O(log n + k) — with
|
|
132
|
+
no pass over the array and no per-query sort. It is **static**: the
|
|
133
|
+
bounds are copied into flat typed arrays at build time, so a query reads
|
|
134
|
+
no source object at all, and a row mutated afterwards cannot change what
|
|
135
|
+
the index answers.
|
|
136
|
+
|
|
137
|
+
```javascript
|
|
138
|
+
import { createIntervalIndex, mergeIntervals, gapsWithin, findSlots } from '@jarenjs/core/series';
|
|
139
|
+
|
|
140
|
+
const shifts = [
|
|
141
|
+
{ from: '2026-03-02T09:00:00Z', to: '2026-03-02T13:00:00Z', who: 'ada' },
|
|
142
|
+
{ from: '2026-03-02T13:00:00Z', to: '2026-03-02T17:00:00Z', who: 'grace' },
|
|
143
|
+
];
|
|
144
|
+
const index = createIntervalIndex(shifts, { start: 'from', end: 'to' });
|
|
145
|
+
index.at('2026-03-02T13:00:00Z'); // [grace] — half-open: the handover belongs to one shift
|
|
146
|
+
|
|
147
|
+
const cover = shifts.map((s) => ({ start: s.from, end: s.to }));
|
|
148
|
+
mergeIntervals(cover); // one span, 09:00–17:00: touching is continuous cover
|
|
149
|
+
gapsWithin(cover, { start: '2026-03-02T08:00:00Z', end: '2026-03-02T18:00:00Z' });
|
|
150
|
+
// the hour before and the hour after
|
|
151
|
+
findSlots(cover, { duration: 'PT30M' }); // sixteen half-hour slots, one straddling the handover
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## The clock — `zone.js`
|
|
155
|
+
|
|
156
|
+
Where a calendar boundary falls depends on a wall clock, and a wall
|
|
157
|
+
clock that is not UTC is data this suite refuses to bundle: a tzdb is
|
|
158
|
+
megabytes that go stale on a government's timetable, a Temporal
|
|
159
|
+
polyfill is a runtime dependency, and reading the host's zone is the
|
|
160
|
+
hidden clock this kernel exists without. So `resolveClock(options)` is a
|
|
161
|
+
**seam**:
|
|
162
|
+
|
|
163
|
+
| options | the clock |
|
|
164
|
+
|---|---|
|
|
165
|
+
| *(nothing)* | UTC. Nothing to configure, and no local time is ever ambiguous |
|
|
166
|
+
| `{ offset: -300 }` | minutes east of UTC, constant. Exact integer arithmetic |
|
|
167
|
+
| `{ zone, provider }` | the caller's tzdb, in whatever form they already have one |
|
|
168
|
+
|
|
169
|
+
A provider answers two questions, and the second is the hard one:
|
|
170
|
+
`toParts(epoch, zone)` is the wall clock at an instant, and
|
|
171
|
+
`toEpoch(parts, zone, disambiguation)` is the instant at a wall clock —
|
|
172
|
+
hard because a local time is not a function of the clock. On a
|
|
173
|
+
spring-forward day 02:30 never happens; on a fall-back day it happens
|
|
174
|
+
twice. `disambiguation` is `'reject'` (the default: an error, not an
|
|
175
|
+
hour nobody notices), `'earlier'` or `'later'`. Asking for a named zone
|
|
176
|
+
with no provider is a refusal, never a quiet fall back to UTC — which is
|
|
177
|
+
right for Amsterdam for none of the year and *looks* right for eight
|
|
178
|
+
months of it.
|
|
179
|
+
|
|
180
|
+
## Buckets, resampling and fill — `bucket.js`
|
|
181
|
+
|
|
182
|
+
Two questions that are always asked together and are not the same
|
|
183
|
+
question. **Bucketing** is "which span does this instant fall in", and
|
|
184
|
+
it is arithmetic. **Filling** is "what does a span with no readings
|
|
185
|
+
say", and it is a policy. An average over an empty hour is not zero, and
|
|
186
|
+
it is not yesterday's average, and it is not nothing.
|
|
187
|
+
|
|
188
|
+
`compileBuckets(spec, options?)` validates a ladder once —
|
|
189
|
+
`compileBuckets('PT15M')`, or `{ every, origin }` — and returns
|
|
190
|
+
`floor`, `startOf`, `indexOf` and the shape it resolved to. Boundaries
|
|
191
|
+
come in two flavours, and the difference is physical:
|
|
192
|
+
|
|
193
|
+
- **fixed** — `PT15M`, `PT1H`, `P1D`, or a number of milliseconds. The
|
|
194
|
+
boundary is `origin + k × width`, integer arithmetic all the way down,
|
|
195
|
+
and it *floors*, so an instant before 1970 lands in its own bucket
|
|
196
|
+
rather than the one after it.
|
|
197
|
+
- **calendar** — `P1M`, `P1Y`, and a whole number of days *on a named
|
|
198
|
+
zone*. A month has no width, so the boundary is walked by the calendar
|
|
199
|
+
kernel from the anchor; on a named zone a day that the clock changed
|
|
200
|
+
on is 23 or 25 hours long, and a ladder multiplying by 86,400,000
|
|
201
|
+
would drift off local midnight for the rest of the year.
|
|
202
|
+
|
|
203
|
+
A width that mixes the two families (`P1MT1H`) is refused: a month and
|
|
204
|
+
an hour share no boundary. The default `origin` is local
|
|
205
|
+
`1970-01-01T00:00:00` **on the clock**, so a daily bucket in `+02:00`
|
|
206
|
+
falls on local midnight rather than on UTC's.
|
|
207
|
+
|
|
208
|
+
`resampleSeries(rows, spec)` returns ascending `{ at, value, count }`
|
|
209
|
+
labelled at each bucket's **start**. `count` is the number of source
|
|
210
|
+
rows — duplicates and measured gaps included — so it is the honest
|
|
211
|
+
denominator of what was *seen*. All six value aggregates (`sum`, `mean`,
|
|
212
|
+
`min`, `max`, `first`, `last`) skip `null` readings, so `value` is
|
|
213
|
+
`null` exactly when there was nothing to measure and `count` says
|
|
214
|
+
whether that was because nobody reported or because everybody reported a
|
|
215
|
+
gap. `aggregate: 'count'` returns that row count as the value.
|
|
216
|
+
|
|
217
|
+
With no `start`/`end` the window is the data's own — the bucket holding
|
|
218
|
+
the first sample through the bucket holding the last — because the only
|
|
219
|
+
other default would be a clock. Pass them when an empty *edge* matters.
|
|
220
|
+
|
|
221
|
+
The five fill policies decide what an **empty** bucket says, and nothing
|
|
222
|
+
else; a bucket that held rows and no numbers reports `null`, because
|
|
223
|
+
that is a measurement:
|
|
224
|
+
|
|
225
|
+
| fill | an empty bucket |
|
|
226
|
+
|---|---|
|
|
227
|
+
| `omit` | is not emitted — the default: a gap is not a row |
|
|
228
|
+
| `null` | is emitted as `null` |
|
|
229
|
+
| `zero` | is emitted as `0` |
|
|
230
|
+
| `locf` | repeats the last value before it |
|
|
231
|
+
| `linear` | is interpolated between its two neighbours |
|
|
232
|
+
|
|
233
|
+
Neither `locf` nor `linear` invents a value at the leading edge, and
|
|
234
|
+
`linear` needs a value on **both** sides. To seed one, widen the window
|
|
235
|
+
until the earlier reading falls inside it: the seed is then a bucket
|
|
236
|
+
with data, which is the only anchor either policy will extrapolate from.
|
|
237
|
+
|
|
238
|
+
## Rolling windows — `rolling.js`
|
|
239
|
+
|
|
240
|
+
`rollingSeries(rows, spec)` aggregates over a *duration* rather than a
|
|
241
|
+
count of rows, which is the whole point: sixty rows of a sensor
|
|
242
|
+
reporting every second is a minute, and sixty rows of a sensor that
|
|
243
|
+
dropped half its readings is two minutes. One of those is a
|
|
244
|
+
specification.
|
|
245
|
+
|
|
246
|
+
The window is `(at − width, at]` — exactly `width` wide, holding the
|
|
247
|
+
current instant and not the one a full width behind it. Two samples at
|
|
248
|
+
one instant share that window and therefore share an answer: a span of
|
|
249
|
+
time is a function of the instant it ends at, not of which simultaneous
|
|
250
|
+
reading arrived first. `minPeriods` is how many source rows the window
|
|
251
|
+
must hold before a value is reported at all.
|
|
252
|
+
|
|
253
|
+
The complexity is per aggregate and is structural rather than hopeful:
|
|
254
|
+
`sum`/`mean`/`count` carry a running total, `min`/`max` use a monotone
|
|
255
|
+
deque, and `first`/`last` are two pointers that only move forward. A
|
|
256
|
+
carried total is not a fresh sum in the last bits when the values are
|
|
257
|
+
not exactly representable — that is what carrying one costs, and the
|
|
258
|
+
suite's corpora use exact binary fractions so the difference is zero and
|
|
259
|
+
equality is the check.
|
|
260
|
+
|
|
261
|
+
## As-of joins — `asof.js`
|
|
262
|
+
|
|
263
|
+
`asOfJoin(left, right, spec?)` answers "what was the price when this
|
|
264
|
+
trade printed" for two series that share a timeline and nothing else.
|
|
265
|
+
One record per left row, in the left series' order, unmatched included
|
|
266
|
+
as `{ left, right: null, distance: null }` — a join that quietly returns
|
|
267
|
+
fewer rows than it was given is how a report loses the events nothing
|
|
268
|
+
explained.
|
|
269
|
+
|
|
270
|
+
| direction | the right row chosen |
|
|
271
|
+
|---|---|
|
|
272
|
+
| `backward` | the last one at or before the left instant (default) |
|
|
273
|
+
| `forward` | the last one at or after it |
|
|
274
|
+
| `nearest` | whichever is closer; a tie chooses `backward` |
|
|
275
|
+
|
|
276
|
+
At an equal instant the **last** right-side row wins in every direction,
|
|
277
|
+
because duplicates are two readings and "as of" means the later one. A
|
|
278
|
+
`nearest` tie chooses backward because a value already observed is
|
|
279
|
+
evidence and one that has not been is a forecast. `tolerance` is the
|
|
280
|
+
furthest a match may be; beyond it there is no match, not a distant one.
|
|
281
|
+
`key` joins within groups — the right side is partitioned **once**, and
|
|
282
|
+
no left row ever filters it.
|
|
283
|
+
|
|
284
|
+
## Downsampling — `downsample.js`
|
|
285
|
+
|
|
286
|
+
A hundred thousand points on a line eight hundred pixels wide is a
|
|
287
|
+
hundred and twenty five points per pixel. `downsampleSeries(rows, spec)`
|
|
288
|
+
supplies `lttb` (largest-triangle-three-buckets: keeps the shape a
|
|
289
|
+
reader recognizes) and `minmax` (keeps the envelope exactly), and
|
|
290
|
+
reports `{ points, sourceCount, renderedCount, method }` so a consumer
|
|
291
|
+
can always say how much of the data it is looking at.
|
|
292
|
+
|
|
293
|
+
Three rules stop either from lying. **A gap is never bridged** — the
|
|
294
|
+
series is cut at every run of `null`s, each run keeps a marker, and each
|
|
295
|
+
segment is sampled on its own budget. **The ends stay** — and a series
|
|
296
|
+
ending in gaps keeps its last instant as that run's marker, so the
|
|
297
|
+
rendered domain still reaches the end of the data. **An impossible
|
|
298
|
+
target is refused** — those markers and endpoints are the minimum a
|
|
299
|
+
faithful picture needs, and a prettier lie is worse than a `RangeError`
|
|
300
|
+
naming the number.
|
|
301
|
+
|
|
302
|
+
```javascript
|
|
303
|
+
import { resampleSeries, rollingSeries, asOfJoin, downsampleSeries } from '@jarenjs/core/series';
|
|
304
|
+
|
|
305
|
+
resampleSeries(readings, { every: 'PT1H', aggregate: 'mean', fill: 'linear' });
|
|
306
|
+
resampleSeries(readings, { every: 'P1M', zone: 'Europe/Amsterdam', provider });
|
|
307
|
+
rollingSeries(readings, { width: 'PT5M', aggregate: 'max', minPeriods: 3 });
|
|
308
|
+
asOfJoin(trades, quotes, { direction: 'nearest', tolerance: 'PT1S', key: 'symbol' });
|
|
309
|
+
downsampleSeries(readings, { target: 2000 }); // → { points, sourceCount, renderedCount, method }
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
## What it costs
|
|
313
|
+
|
|
314
|
+
Measured by `benchmark/series.js` over <!--fact:series.corpus-->100,000 samples at 1-second spacing, Node v24.19.0<!--/fact-->,
|
|
315
|
+
which gates every timing on equivalence first: no number below is printed
|
|
316
|
+
unless the kernel answered the identical rows the references did.
|
|
317
|
+
|
|
318
|
+
The kernel is not the ceiling and does not claim to be. A one-pass loop
|
|
319
|
+
written for one question validates nothing, normalizes nothing and
|
|
320
|
+
returns a bare pair. Against those loops the kernel costs <!--fact:series.kernelVsCeiling-->3.4× the one-pass bucket loop and 6.2× the one-pass ring sum<!--/fact-->,
|
|
321
|
+
and against the vocabulary a consumer had instead it is <!--fact:series.kernelVsQuery-->73.6× faster than the generic query bucket and 81.2× faster than the labelled count window<!--/fact-->.
|
|
322
|
+
|
|
323
|
+
<!--fact:series.kernelTable-->
|
|
324
|
+
| operation | median | rows | against | what that is | ratio |
|
|
325
|
+
|---|---:|---:|---:|---|---:|
|
|
326
|
+
| `resampleSeries`, 60 s buckets | 1.5 ms | 1,667 | 0.45 ms | one-pass loop | 3.4× |
|
|
327
|
+
| `resampleSeries`, + linear fill | 1.1 ms | 1,657 | 1 ms | the same buckets, omitting | 1.0× |
|
|
328
|
+
| `rollingSeries`, 60 s window | 7.2 ms | 100,000 | 1.2 ms | one-pass ring sum | 6.2× |
|
|
329
|
+
| `asOfJoin`, one left row per 100 | 1.7 ms | 1,000 | 2.2 ms | one index read per row | 0.8× |
|
|
330
|
+
| `downsampleSeries`, lttb, gap corpus | 1.6 ms | 2,000 | 1.8 ms | the same line with no holes in it | 0.9× |
|
|
331
|
+
<!--/fact-->
|
|
332
|
+
|
|
333
|
+
A row that loses stays in, and the two shapes of the same join are published side by side rather than the flattering one alone. <!--fact:series.asofShape-->The as-of join costs 14.7× a handful of index reads, and beats them by 1.3× once there is one left row per hundred right ones. The reason is the shape rather than the engine: a b-tree pays per probe, and a sorted walk pays for the whole right side whether it was asked one question or a thousand.<!--/fact-->
|
|
334
|
+
|
|
335
|
+
And the seam has a price that this corpus cannot charge it. <!--fact:series.zoneCost-->Walking every boundary through an injected zone provider costs 1.0× the integer ladder over an identical answer — near parity because it is near nothing, since the benchmark corpus spans 28 hours and holds two daily boundaries. What the suite gates instead is that the provider is consulted per boundary rather than per sample.<!--/fact-->
|
|
336
|
+
|
|
337
|
+
## Not here
|
|
338
|
+
|
|
339
|
+
Named time zones are injected, never bundled; recurrence grammars
|
|
340
|
+
(RRULE, iCalendar) and any kind of scheduling solver are somebody else's
|
|
341
|
+
layer. This module supplies the algebra those are built from, and
|
|
342
|
+
nothing that needs a clock, a locale or a zone database to be correct.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jarenjs/core",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.56.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"types": "./dist/types/index.d.ts",
|
|
@@ -40,6 +40,10 @@
|
|
|
40
40
|
"types": "./dist/types/array.d.ts",
|
|
41
41
|
"default": "./src/array.js"
|
|
42
42
|
},
|
|
43
|
+
"./async": {
|
|
44
|
+
"types": "./dist/types/async.d.ts",
|
|
45
|
+
"default": "./src/async.js"
|
|
46
|
+
},
|
|
43
47
|
"./bigint": {
|
|
44
48
|
"types": "./dist/types/bigint.d.ts",
|
|
45
49
|
"default": "./src/bigint.js"
|
|
@@ -104,6 +108,10 @@
|
|
|
104
108
|
"types": "./dist/types/object.d.ts",
|
|
105
109
|
"default": "./src/object.js"
|
|
106
110
|
},
|
|
111
|
+
"./random": {
|
|
112
|
+
"types": "./dist/types/random.d.ts",
|
|
113
|
+
"default": "./src/random.js"
|
|
114
|
+
},
|
|
107
115
|
"./scan": {
|
|
108
116
|
"types": "./dist/types/scan.d.ts",
|
|
109
117
|
"default": "./src/scan.js"
|
|
@@ -112,6 +120,18 @@
|
|
|
112
120
|
"types": "./dist/types/schema.d.ts",
|
|
113
121
|
"default": "./src/schema.js"
|
|
114
122
|
},
|
|
123
|
+
"./series": {
|
|
124
|
+
"types": "./dist/types/series/index.d.ts",
|
|
125
|
+
"default": "./src/series/index.js"
|
|
126
|
+
},
|
|
127
|
+
"./series/*": {
|
|
128
|
+
"types": "./dist/types/series/*.d.ts",
|
|
129
|
+
"default": "./src/series/*.js"
|
|
130
|
+
},
|
|
131
|
+
"./stats": {
|
|
132
|
+
"types": "./dist/types/stats.d.ts",
|
|
133
|
+
"default": "./src/stats.js"
|
|
134
|
+
},
|
|
115
135
|
"./string": {
|
|
116
136
|
"types": "./dist/types/string.d.ts",
|
|
117
137
|
"default": "./src/string.js"
|
package/src/async.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The bounded ordered asynchronous map: run a worker over a list
|
|
4
|
+
* with never more than `limit` calls in flight, and answer the results
|
|
5
|
+
* in the list's order. Before this file the same twelve lines lived in
|
|
6
|
+
* the AI package's program runner and in the benchmark harness, and a
|
|
7
|
+
* downstream consumer had written them a third time; a pool that exists
|
|
8
|
+
* once is one whose edge behavior can be pinned once.
|
|
9
|
+
*
|
|
10
|
+
* The contract, in full:
|
|
11
|
+
*
|
|
12
|
+
* - results are in INPUT order, whatever order the workers finish in;
|
|
13
|
+
* - never more than `limit` workers are in flight; `limit` must be a
|
|
14
|
+
* number of at least 1 (`Infinity` is allowed and means unbounded) —
|
|
15
|
+
* anything else is a `TypeError`, never a silent clamp, because a
|
|
16
|
+
* limit of 0 is a bug in the caller and "sequential" is spelled 1;
|
|
17
|
+
* - a worker rejection stops dispatch: no item starts after it, the
|
|
18
|
+
* workers already in flight are awaited, and only then does the map
|
|
19
|
+
* reject with that first rejection. A worker that throws
|
|
20
|
+
* synchronously is a rejection;
|
|
21
|
+
* - an abort does the same, rejecting with the signal's reason; a signal
|
|
22
|
+
* that is already aborted rejects before any worker runs;
|
|
23
|
+
* - so when the returned promise settles, NO worker is still running —
|
|
24
|
+
* the caller can close whatever the workers were using.
|
|
25
|
+
*
|
|
26
|
+
* The map does not retry, rate-limit, delay per origin or know anything
|
|
27
|
+
* about what the worker does; those are the caller's policies around it.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @template T, R
|
|
32
|
+
* @param {readonly T[]} items
|
|
33
|
+
* @param {number} limit - workers in flight at once; a number >= 1, `Infinity` for unbounded
|
|
34
|
+
* @param {(item: T, index: number) => Promise<R> | R} worker
|
|
35
|
+
* @param {{ signal?: AbortSignal }} [options]
|
|
36
|
+
* @returns {Promise<R[]>} the results, in input order
|
|
37
|
+
* @throws {TypeError} (as a rejection) when `limit` is not a number >= 1
|
|
38
|
+
*/
|
|
39
|
+
export async function mapConcurrent(items, limit, worker, options = {}) {
|
|
40
|
+
if (typeof limit !== 'number' || !(limit >= 1))
|
|
41
|
+
throw new TypeError(`mapConcurrent needs a limit of at least 1, got ${String(limit)}`);
|
|
42
|
+
const { signal } = options;
|
|
43
|
+
if (signal?.aborted) throw signal.reason;
|
|
44
|
+
const count = items.length;
|
|
45
|
+
/** @type {R[]} */
|
|
46
|
+
const results = new Array(count);
|
|
47
|
+
if (count === 0) return results;
|
|
48
|
+
|
|
49
|
+
let next = 0;
|
|
50
|
+
// the first failure or abort, kept as a one-element list: the lanes
|
|
51
|
+
// stop dispatching the moment it is set and drain what they hold
|
|
52
|
+
/** @type {unknown[]} */
|
|
53
|
+
const stop = [];
|
|
54
|
+
/** @type {(() => void) | undefined} */
|
|
55
|
+
let onAbort;
|
|
56
|
+
if (signal !== undefined) {
|
|
57
|
+
onAbort = () => { if (stop.length === 0) stop.push(signal.reason); };
|
|
58
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const lane = async () => {
|
|
62
|
+
while (stop.length === 0) {
|
|
63
|
+
const index = next++;
|
|
64
|
+
if (index >= count) return;
|
|
65
|
+
try {
|
|
66
|
+
results[index] = await worker(items[index], index);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (stop.length === 0) stop.push(error);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const lanes = Array.from({ length: Math.min(limit, count) }, lane);
|
|
74
|
+
await Promise.all(lanes);
|
|
75
|
+
if (onAbort !== undefined) signal?.removeEventListener('abort', onAbort);
|
|
76
|
+
if (stop.length > 0) throw stop[0];
|
|
77
|
+
return results;
|
|
78
|
+
}
|