@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.
- package/ARCHITECTURE.md +104 -0
- package/README.md +46 -2
- 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/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/docs/DATES.md +77 -1
- package/docs/SERIES.md +342 -0
- package/package.json +9 -1
- 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/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
|
@@ -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
|
|
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.
|
|
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"
|