@jarenjs/core 0.46.5 → 0.49.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,67 @@
1
+ export type Sample = import('./normalize.js').Sample;
2
+ /**
3
+ * `rollingSeries`' closed specification: a window measured in time,
4
+ * how much of one counts, the clock a calendar width walks, and where
5
+ * a row keeps its instant and its reading.
6
+ */
7
+ export declare const ROLLING_MEMBERS: readonly string[];
8
+ /**
9
+ * A rolling aggregate over a time-width window.
10
+ *
11
+ * Returns one `{ at, value, count }` per input sample, labelled at that
12
+ * sample's own instant, where the window is the half-open span
13
+ * `(at − width, at]`. Two samples at one instant share that window, and
14
+ * so report the same value and count.
15
+ *
16
+ * `count` is the number of **source rows** in the window — duplicates
17
+ * and measured gaps included — and the six value aggregates skip `null`
18
+ * readings, so `value` is `null` exactly when the window had nothing to
19
+ * measure. `aggregate: 'count'` returns that row count as the value.
20
+ *
21
+ * `minPeriods` is the number of source rows the window must hold before
22
+ * a value is reported at all; below it the row is `null` with its real
23
+ * `count`. It counts rows rather than readings, so a window full of
24
+ * measured gaps satisfies it and still reports `null` — which is the
25
+ * honest answer: readings were due, and none of them carried a number.
26
+ *
27
+ * `width` is a fixed duration (`'PT1H'`, `86400000`) or, with a clock,
28
+ * a calendar one (`'P1M'`, and `'P1D'` on a named zone where a day may
29
+ * be 23 or 25 hours long). A calendar window's start is computed by the
30
+ * calendar kernel per output, which is O(1) each — it is never a scan
31
+ * back through the window.
32
+ *
33
+ * @param {any[]} rows - the samples, in any order
34
+ * @param {Object} spec
35
+ * @param {number | string} spec.width - the window's width
36
+ * @param {'sum'|'mean'|'min'|'max'|'first'|'last'|'count'} [spec.aggregate]
37
+ * default `'mean'`
38
+ * @param {number} [spec.minPeriods] - default 1
39
+ * @param {string} [spec.zone] - a named zone, needing `provider`
40
+ * @param {number} [spec.offset] - minutes east of UTC
41
+ * @param {import('./zone.js').ZoneProvider} [spec.provider]
42
+ * @param {'reject'|'earlier'|'later'} [spec.disambiguation]
43
+ * @param {string | ((item: any, index: number) => any)} [spec.at]
44
+ * @param {string | ((item: any, index: number) => any)} [spec.value]
45
+ * @returns {{ at: number, value: number | null, count: number }[]}
46
+ * @throws {TypeError} for a width that is not one positive whole span,
47
+ * an unknown aggregate, a `minPeriods` that is not a positive whole
48
+ * number, or a row that is not a canonical sample
49
+ * @example
50
+ * rollingSeries(readings, { width: 'PT5M', aggregate: 'mean' });
51
+ * rollingSeries(readings, { width: 'PT1M', minPeriods: 60 });
52
+ */
53
+ export declare function rollingSeries(rows: any[], spec: {
54
+ width: number | string;
55
+ aggregate?: 'sum' | 'mean' | 'min' | 'max' | 'first' | 'last' | 'count';
56
+ minPeriods?: number;
57
+ zone?: string;
58
+ offset?: number;
59
+ provider?: import('./zone.js').ZoneProvider;
60
+ disambiguation?: 'reject' | 'earlier' | 'later';
61
+ at?: string | ((item: any, index: number) => any);
62
+ value?: string | ((item: any, index: number) => any);
63
+ }): {
64
+ at: number;
65
+ value: number | null;
66
+ count: number;
67
+ }[];
@@ -0,0 +1,29 @@
1
+ /**
2
+ * A reader for one member of a source row.
3
+ * @param {string | ((item: any, index: number) => any)} spec - a
4
+ * property name, or a function of the row and its position
5
+ * @param {string} role - the member's name, for the message
6
+ * @returns {(item: any, index: number) => any}
7
+ * @throws {TypeError} when `spec` is neither
8
+ */
9
+ export declare function selectorOf(spec: string | ((item: any, index: number) => any), role: string): (item: any, index: number) => any;
10
+ /**
11
+ * The row at `index`, confirmed to be something with members to read.
12
+ * @param {any} item
13
+ * @param {number} index
14
+ * @returns {any}
15
+ * @throws {TypeError} when it is not an object
16
+ */
17
+ export declare function requireRow(item: any, index: number): any;
18
+ /**
19
+ * One specification, confirmed to name only admitted members.
20
+ * @param {any} spec - the caller's specification object
21
+ * @param {readonly string[]} allowed - the closed member list
22
+ * @param {string} kernel - the function's name, for the message
23
+ * @param {string} shape - what the specification is, for the message
24
+ * when it is not an object at all
25
+ * @returns {any} the same object
26
+ * @throws {TypeError} when it is not an object, or names a member the
27
+ * kernel does not admit
28
+ */
29
+ export declare function requireSpecMembers(spec: any, allowed: readonly string[], kernel: string, shape: string): any;
@@ -0,0 +1,59 @@
1
+ export type ZoneProvider = {
2
+ /**
3
+ * - the wall
4
+ * clock at an instant, as a parts record (`year`, `month` 1-12, `day`,
5
+ * `hours`, `minutes`, `seconds`, and `offset` in minutes east if known)
6
+ */
7
+ toParts: (epoch: number, zone: string) => any;
8
+ /**
9
+ * - the instant a wall clock names, in epoch milliseconds;
10
+ * non-finite (or a throw) when the local time is ambiguous or does not
11
+ * exist and `disambiguation` does not resolve it
12
+ */
13
+ toEpoch: (parts: any, zone: string, disambiguation: string) => number;
14
+ };
15
+ export type Clock = {
16
+ /**
17
+ * - what the clock is called, for messages
18
+ */
19
+ zone: string;
20
+ partsAt: (epoch: number) => any;
21
+ epochOf: (parts: any) => number;
22
+ };
23
+ /**
24
+ * The four members every calendar-aware specification carries, so
25
+ * a bucket ladder, a rolling window and a query document all spell
26
+ * the clock the same way.
27
+ */
28
+ export declare const CLOCK_MEMBERS: readonly string[];
29
+ /**
30
+ * The clock an operation reads its calendar boundaries on.
31
+ *
32
+ * Defaults to UTC, which needs nothing: no provider, no zone name, no
33
+ * ambiguity. `{ offset }` is a constant number of minutes east and is
34
+ * exact integer arithmetic. `{ zone }` is a name, and a name means
35
+ * nothing without a `provider` — asking for `'Europe/Amsterdam'` with no
36
+ * tzdb is a refusal, never a silent fall back to UTC that is right for
37
+ * eight months of the year.
38
+ *
39
+ * @param {Object} [options]
40
+ * @param {string} [options.zone] - an IANA name; `'UTC'` needs no provider
41
+ * @param {number} [options.offset] - minutes east of UTC, for a fixed offset
42
+ * @param {ZoneProvider} [options.provider] - the tzdb, for a named zone
43
+ * @param {'reject' | 'earlier' | 'later'} [options.disambiguation]
44
+ * what a local time that happens twice, or never, resolves to
45
+ * (default `'reject'`)
46
+ * @returns {Clock}
47
+ * @throws {TypeError} for a named zone with no provider, a non-finite
48
+ * offset, an unknown disambiguation, or both a zone and an offset
49
+ * @example
50
+ * resolveClock(); // UTC
51
+ * resolveClock({ offset: 330 }); // +05:30, constant
52
+ * resolveClock({ zone: 'Europe/Amsterdam', provider, disambiguation: 'later' });
53
+ */
54
+ export declare function resolveClock(options?: {
55
+ zone?: string;
56
+ offset?: number;
57
+ provider?: ZoneProvider;
58
+ disambiguation?: 'reject' | 'earlier' | 'later';
59
+ }): Clock;
package/docs/DATES.md CHANGED
@@ -42,6 +42,43 @@ start Monday, per ISO). **Month math clamps**: 31 Jan + 1 month =
42
42
  28 Feb, which is what makes add and diff behave as inverses in the
43
43
  query operators built on this.
44
44
 
45
+ Two refusals keep those answers honest, both `TypeError`:
46
+
47
+ - **A unit reads a half, and a value may not carry it.** `year` through
48
+ `day` read the calendar; `hour` through `millisecond` read the clock.
49
+ Adding hours to a full-date, adding a day to a full-time, or
50
+ truncating either to the unit it has not got has no answer to give.
51
+ `day` and coarser truncations are the boundary both forms have, so
52
+ `startOfParts` / `endOfParts` of a `day` on a full-time is midnight and
53
+ `23:59:59.999`.
54
+ - **A fraction is a quantity only where it converts exactly.** A
55
+ fractional fixed-width amount becomes whole milliseconds — `1.5 day` is
56
+ thirty-six hours, and needs a value with a clock to land on. A
57
+ fractional calendar unit is refused unless it lands on a whole month:
58
+ half a year is six months, half a month is nothing.
59
+
60
+ ## Time-axis ticks — `ticks.js`
61
+
62
+ `niceTimeStep(span, count)` picks the `[unit, amount]` a domain should be
63
+ read in — from 1/5/15/30 seconds and minutes through 1/3/6/12 hours, 1/2
64
+ days, 1/2 weeks and 1/3/6 months, then whole years on the 1/2/5 × 10^k
65
+ ladder, so a millennial domain steps by centuries rather than running out
66
+ of axis. `axisTicksTime(min, max, count)` lays the boundaries down: each
67
+ tick is a multiple of the step's own amount, so an axis reads
68
+ `1850 1900 1950 2000` rather than offsets from wherever the data began.
69
+ Below a second the calendar has nothing to say and the numeric ladder
70
+ answers instead. The chart component re-exports both rather than
71
+ carrying its own copy.
72
+
73
+ `timeTicksEvery(min, max, unit, amount?, options?)` is the same boundary
74
+ rule with the step supplied instead of chosen — what a document that
75
+ declares its own interval needs (Mermaid's Gantt `tickInterval 1week` is
76
+ the one in this repo). `options.weekStart` is an ISO weekday, so a
77
+ document whose weeks begin on Sunday gets Sundays; `options.limit` caps
78
+ the count. `axisTicksTime` is now this function with the ladder in
79
+ front of it, so a chart axis and a timeline cannot disagree about where
80
+ a tick falls.
81
+
45
82
  ## Durations — `duration.js`
46
83
 
47
84
  `parseDuration('P3DT4H')` — ISO 8601 duration decomposition into
@@ -65,6 +102,43 @@ ISO tokens work bare; name tokens (`MMMM`, `EEE`, `a`) require a
65
102
  `names` provider, which is where `@jarenjs/locales` plugs in — the
66
103
  kernel ships no month names, so server-rendered output stays
67
104
  byte-stable across Node/ICU versions.
105
+ `compileDateLocale(pack).names` is that provider: five arrays (12 months
106
+ wide and short, 7 weekdays wide and short from Sunday, the two day-period
107
+ markers), read once out of a locale pack's own msgids and frozen, so a
108
+ pattern compiled against them costs no lookup per date.
109
+
110
+ ## Parsing — `parse.js`
111
+
112
+ `compileDateParser(pattern, names?)` is the formatter's strict inverse
113
+ over the same LDML vocabulary, compiled the same way: the pattern is
114
+ scanned once into a chain of readers, and no regular expression is built
115
+ per compile or per parse. It returns a function from text to the parts
116
+ record `parseRFC3339Parts` produces, so `epochOfRFC3339Parts`,
117
+ `addToParts` and `formatRFC3339Parts` all take its output unchanged, and
118
+ the lexical family follows the pattern — date tokens only give a
119
+ full-date, time tokens only a full-time.
120
+
121
+ Strict means three things, and each of them is the difference between an
122
+ answer and a plausible wrong answer:
123
+
124
+ 1. **Full consumption.** `yyyy-MM-dd` does not read `2026-01-02T03:04`.
125
+ 2. **Impossible civil dates fail.** `31-02-2014` is `null`, not 3 March.
126
+ 3. **Fixed-width tokens are fixed width.** `MM` reads exactly two digits.
127
+
128
+ Malformed *text* is data and comes back `null`; a malformed *pattern* is
129
+ the programmer's error and throws at compile time. The tokens that only
130
+ ever format — `EEEE`, `DDD`, `ww`, `Q` are derived *from* a date rather
131
+ than fields *of* one — throw with a message naming the token and the
132
+ reason, rather than reading nothing in silence. Two tokens are
133
+ documented rather than refused: `yy` reads 00–68 as 2000–2068 and 69–99
134
+ as 1969–1999 (the POSIX pivot), and `h`/`hh` with no `a` in the same
135
+ pattern read as the morning.
136
+
137
+ A consumer whose patterns are **not** LDML adapts its own tokens onto
138
+ this vocabulary rather than handing its spelling over. `@jarenjs/mermaid`
139
+ does exactly that for a Gantt's `dateFormat` (moment's grammar) and
140
+ `axisFormat` (strftime): LDML's `YYYY` is the *week-numbering* year, so
141
+ reading `YYYY-MM-DD` as LDML answers 2019 for 2018-12-31.
68
142
 
69
143
  ## Not here
70
144
 
@@ -72,7 +146,9 @@ byte-stable across Node/ICU versions.
72
146
  Node ≥ 24 baseline); the string/number representation is exactly what
73
147
  `Temporal.Instant.from()` consumes, so the kernel can delegate
74
148
  internally later without a surface change. Relative-time phrasing and
75
- month-name catalogs are `@jarenjs/locales`' job; `formatMinimum`/
149
+ month-name catalogs are `@jarenjs/locales`' job — including the decision
150
+ that a relative phrase is handed its signed amount rather than working
151
+ one out, which is the same "no `now`" rule this kernel keeps; `formatMinimum`/
76
152
  `formatMaximum` bound comparison lives in `@jarenjs/formats`; time
77
153
  *axes* (charts) and date *controls* (forms) consume this kernel from
78
154
  their own packages.
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 <!--bm:series.corpus-->100,000 samples at 1-second spacing, Node v24.19.0<!--/bm-->,
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 <!--bm:series.kernelVsCeiling-->3.4× the one-pass bucket loop and 6.2× the one-pass ring sum<!--/bm-->,
321
+ and against the vocabulary a consumer had instead it is <!--bm:series.kernelVsQuery-->73.6× faster than the generic query bucket and 81.2× faster than the labelled count window<!--/bm-->.
322
+
323
+ <!--bm: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
+ <!--/bm-->
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. <!--bm: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.<!--/bm-->
334
+
335
+ And the seam has a price that this corpus cannot charge it. <!--bm: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.<!--/bm-->
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.46.5",
4
+ "version": "0.49.2",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./dist/types/index.d.ts",
@@ -112,6 +112,14 @@
112
112
  "types": "./dist/types/schema.d.ts",
113
113
  "default": "./src/schema.js"
114
114
  },
115
+ "./series": {
116
+ "types": "./dist/types/series/index.d.ts",
117
+ "default": "./src/series/index.js"
118
+ },
119
+ "./series/*": {
120
+ "types": "./dist/types/series/*.d.ts",
121
+ "default": "./src/series/*.js"
122
+ },
115
123
  "./string": {
116
124
  "types": "./dist/types/string.d.ts",
117
125
  "default": "./src/string.js"