@jarenjs/core 0.46.5 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/ARCHITECTURE.md +122 -9
  2. package/README.md +50 -3
  3. package/dist/types/async.d.ts +39 -0
  4. package/dist/types/dates/civil.d.ts +24 -5
  5. package/dist/types/dates/index.d.ts +2 -0
  6. package/dist/types/dates/parse.d.ts +47 -0
  7. package/dist/types/dates/ticks.d.ts +59 -0
  8. package/dist/types/math/float64.d.ts +13 -0
  9. package/dist/types/random.d.ts +80 -0
  10. package/dist/types/series/asof.d.ts +88 -0
  11. package/dist/types/series/bucket.d.ts +206 -0
  12. package/dist/types/series/downsample.d.ts +53 -0
  13. package/dist/types/series/index.d.ts +8 -0
  14. package/dist/types/series/interval-index.d.ts +47 -0
  15. package/dist/types/series/interval.d.ts +170 -0
  16. package/dist/types/series/normalize.d.ts +190 -0
  17. package/dist/types/series/rolling.d.ts +67 -0
  18. package/dist/types/series/selector.d.ts +29 -0
  19. package/dist/types/series/zone.d.ts +59 -0
  20. package/dist/types/stats.d.ts +73 -0
  21. package/docs/DATES.md +77 -1
  22. package/docs/GEO.md +2 -2
  23. package/docs/SERIES.md +342 -0
  24. package/package.json +21 -1
  25. package/src/async.js +78 -0
  26. package/src/dates/civil.js +136 -41
  27. package/src/dates/index.js +4 -0
  28. package/src/dates/parse.js +410 -0
  29. package/src/dates/ticks.js +184 -0
  30. package/src/math/float64.js +29 -0
  31. package/src/random.js +125 -0
  32. package/src/series/asof.js +276 -0
  33. package/src/series/bucket.js +542 -0
  34. package/src/series/downsample.js +343 -0
  35. package/src/series/index.js +86 -0
  36. package/src/series/interval-index.js +181 -0
  37. package/src/series/interval.js +374 -0
  38. package/src/series/normalize.js +330 -0
  39. package/src/series/rolling.js +229 -0
  40. package/src/series/selector.js +104 -0
  41. package/src/series/zone.js +188 -0
  42. package/src/stats.js +133 -0
@@ -0,0 +1,190 @@
1
+ export type Sample = {
2
+ /**
3
+ * - Unix epoch milliseconds
4
+ */
5
+ at: number;
6
+ /**
7
+ * - the reading, or `null` for a
8
+ * measured gap
9
+ */
10
+ value: number | null;
11
+ };
12
+ export type Interval = {
13
+ /**
14
+ * - inclusive lower bound, epoch milliseconds
15
+ */
16
+ start: number;
17
+ /**
18
+ * - exclusive upper bound, epoch milliseconds
19
+ */
20
+ end: number;
21
+ };
22
+ /**
23
+ * @typedef {Object} Sample
24
+ * @property {number} at - Unix epoch milliseconds
25
+ * @property {number | null} value - the reading, or `null` for a
26
+ * measured gap
27
+ */
28
+ /**
29
+ * @typedef {Object} Interval
30
+ * @property {number} start - inclusive lower bound, epoch milliseconds
31
+ * @property {number} end - exclusive upper bound, epoch milliseconds
32
+ */
33
+ /**
34
+ * The instant `value` names, in Unix epoch milliseconds.
35
+ *
36
+ * Accepts the two forms a date has in this suite: a finite **number**
37
+ * (already epoch milliseconds, returned unchanged) and a valid **RFC
38
+ * 3339 string**. A full-date reads as UTC midnight, an offset shifts to
39
+ * the instant it names, and `Z` is UTC.
40
+ *
41
+ * Refused, all as `TypeError`: a full-time (`'09:30:00Z'` names no day,
42
+ * and inventing one would be a hidden clock), a date-time without an
43
+ * offset (`'2026-01-01T09:30:00'` names no instant without a zone),
44
+ * `NaN`/`Infinity`, a `Date` object (dates are strings or numbers here,
45
+ * never a wrapper), and anything else.
46
+ *
47
+ * @param {number | string} value
48
+ * @returns {number} epoch milliseconds
49
+ * @throws {TypeError} when `value` names no instant
50
+ * @example
51
+ * toEpoch(0); // 0
52
+ * toEpoch('1970-01-01'); // 0
53
+ * toEpoch('1970-01-01T01:00:00+01:00'); // 0
54
+ */
55
+ export declare function toEpoch(value: number | string): number;
56
+ /**
57
+ * One instant read out of a row, with the row named when it is not one.
58
+ * "not an RFC 3339 instant" is a puzzle when ten thousand rows were
59
+ * handed in; "row 4172, at: …" is a defect someone can go and look at.
60
+ *
61
+ * @param {any} value - the raw member
62
+ * @param {string} role - the member's name, for the message
63
+ * @param {number} index - the row's position
64
+ * @returns {number} epoch milliseconds
65
+ * @throws {TypeError} when `value` names no instant
66
+ */
67
+ export declare function epochAt(value: any, role: string, index: number): number;
68
+ /**
69
+ * Sorted canonical samples: every row's instant converted once, its
70
+ * value checked, its other members kept, and the whole ascending by
71
+ * instant.
72
+ *
73
+ * The sort is **stable**, so rows sharing an instant come out in input
74
+ * order — and both are present, because a duplicate instant is two
75
+ * readings, not one row written twice.
76
+ *
77
+ * Each result is a shallow copy of its source row with canonical `at`
78
+ * and `value` members written over it, so a source that spelled its
79
+ * instant `on` keeps `on` too and nothing a caller attached is lost.
80
+ *
81
+ * @param {any[]} rows - the source rows
82
+ * @param {Object} [options]
83
+ * @param {string | ((item: any, index: number) => any)} [options.at]
84
+ * where the instant lives (default `'at'`)
85
+ * @param {string | ((item: any, index: number) => any)} [options.value]
86
+ * where the reading lives (default `'value'`)
87
+ * @returns {(Sample & Record<string, any>)[]} a new array of new records
88
+ * @throws {TypeError} for a non-array, a row that is not an object, an
89
+ * instant that names none, or a value that is neither a finite number
90
+ * nor `null`
91
+ * @example
92
+ * normalizeSeries([{ on: '2026-01-01T00:00:01Z', v: 2 },
93
+ * { on: '2026-01-01T00:00:00Z', v: 1 }],
94
+ * { at: 'on', value: 'v' });
95
+ * // [{ on: '…:00Z', v: 1, at: 1767225600000, value: 1 },
96
+ * // { on: '…:01Z', v: 2, at: 1767225601000, value: 2 }]
97
+ */
98
+ export declare function normalizeSeries(rows: any[], options?: {
99
+ at?: string | ((item: any, index: number) => any);
100
+ value?: string | ((item: any, index: number) => any);
101
+ }): (Sample & Record<string, any>)[];
102
+ /**
103
+ * The same canonical samples, without the copy when there is nothing to
104
+ * convert.
105
+ *
106
+ * {@link normalizeSeries} always builds a new array of new records,
107
+ * which is the right answer at the door and the wrong one three kernels
108
+ * later: bucketing, rolling and joining all take a series that a caller
109
+ * usually normalized once already, and re-copying a hundred thousand
110
+ * rows per operation costs more than the operation. So this checks
111
+ * instead of converting — one pass, no allocation — and hands the
112
+ * caller's own array straight back when every row is already
113
+ * `{ at: <finite number>, value: <number | null> }` and ascending.
114
+ *
115
+ * Nothing is trusted: a row that fails the check sends the whole array
116
+ * through {@link normalizeSeries}, which refuses it there with the row
117
+ * named. The fast path is a measurement, not a promise.
118
+ *
119
+ * @param {any[]} rows
120
+ * @param {Object} [options]
121
+ * @param {string | ((item: any, index: number) => any)} [options.at]
122
+ * @param {string | ((item: any, index: number) => any)} [options.value]
123
+ * @returns {(Sample & Record<string, any>)[]} `rows` itself, or a new
124
+ * normalized array
125
+ * @throws {TypeError} exactly where {@link normalizeSeries} does
126
+ */
127
+ export declare function canonicalSeries(rows: any[], options?: {
128
+ at?: string | ((item: any, index: number) => any);
129
+ value?: string | ((item: any, index: number) => any);
130
+ }): (Sample & Record<string, any>)[];
131
+ /**
132
+ * Sorted canonical intervals: every bound converted once, the direction
133
+ * checked, other members kept, and the whole ascending by start.
134
+ *
135
+ * Half-open `[start, end)` with `start < end` is the contract the whole
136
+ * algebra rests on, so an empty (`start === end`), reversed or
137
+ * non-finite interval is refused here rather than producing an answer
138
+ * later that no reader could predict. Duplicates survive — two bookings
139
+ * of the same slot are two bookings — and rows sharing a start keep
140
+ * their input order.
141
+ *
142
+ * @param {any[]} rows - the source rows
143
+ * @param {Object} [options]
144
+ * @param {string | ((item: any, index: number) => any)} [options.start]
145
+ * where the lower bound lives (default `'start'`)
146
+ * @param {string | ((item: any, index: number) => any)} [options.end]
147
+ * where the upper bound lives (default `'end'`)
148
+ * @returns {(Interval & Record<string, any>)[]} a new array of new records
149
+ * @throws {TypeError} for a non-array, a row that is not an object, a
150
+ * bound that names no instant, or `end <= start`
151
+ * @example
152
+ * normalizeIntervals([{ start: '2026-01-01', end: '2026-01-02' }]);
153
+ * // [{ start: 1767225600000, end: 1767312000000 }]
154
+ */
155
+ export declare function normalizeIntervals(rows: any[], options?: {
156
+ start?: string | ((item: any, index: number) => any);
157
+ end?: string | ((item: any, index: number) => any);
158
+ }): (Interval & Record<string, any>)[];
159
+ /**
160
+ * The index of the first row at or after `at` — the lower end of a
161
+ * half-open cut. `rows` must already be ascending on `key`.
162
+ *
163
+ * This is the whole reason a series is normalized once: a range over a
164
+ * sorted array is two binary searches and a slice, not a pass over
165
+ * everything.
166
+ *
167
+ * @param {any[]} rows - ascending on `key`
168
+ * @param {number} at - epoch milliseconds
169
+ * @param {string} [key] - the member holding the instant (default `'at'`)
170
+ * @returns {number} an index in `[0, rows.length]`
171
+ * @example
172
+ * const lo = lowerBoundTime(samples, start);
173
+ * const hi = lowerBoundTime(samples, end);
174
+ * samples.slice(lo, hi); // every sample in [start, end)
175
+ */
176
+ export declare function lowerBoundTime(rows: any[], at: number, key?: string): number;
177
+ /**
178
+ * The index of the first row strictly after `at`. `rows` must already
179
+ * be ascending on `key`.
180
+ *
181
+ * The twin of {@link lowerBoundTime}: together they bracket the rows AT
182
+ * an instant (`[lowerBoundTime(rows, t), upperBoundTime(rows, t))`),
183
+ * which is what a duplicate-tolerant as-of has to read.
184
+ *
185
+ * @param {any[]} rows - ascending on `key`
186
+ * @param {number} at - epoch milliseconds
187
+ * @param {string} [key] - the member holding the instant (default `'at'`)
188
+ * @returns {number} an index in `[0, rows.length]`
189
+ */
190
+ export declare function upperBoundTime(rows: any[], at: number, key?: string): number;
@@ -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;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @file Descriptive statistics over a sample of numbers: the mean, the
3
+ * sample variance and its root, the median, and a quantile that will
4
+ * not answer until told which quantile it is being asked for.
5
+ *
6
+ * Before this file the suite computed these in two places with two
7
+ * quantile rules — linear interpolation in the query layer's statistics
8
+ * pack, nearest rank in the benchmark harness — and each was the right
9
+ * rule for its consumer: an interpolated percentile is what an analyst
10
+ * expects of `$percentile`, while a benchmark row of eleven readings
11
+ * should not publish a latency nobody measured. Both rules stay; the
12
+ * definitions move here so that there is one of each, and `quantile`
13
+ * makes the caller name its method, because "the 95th percentile" of a
14
+ * small sample is a different number under every one of the seven
15
+ * common definitions and a default would decide silently.
16
+ *
17
+ * Every function answers `undefined` for a sample it cannot summarize —
18
+ * an empty one, or fewer than two values for a variance — rather than
19
+ * `NaN` or `0`: a number that was not measured must not format as one.
20
+ * The caller's array is never reordered; a quantile sorts a copy.
21
+ * Values are numbers by contract and are not checked one by one.
22
+ */
23
+ /**
24
+ * The arithmetic mean.
25
+ * @param {readonly number[]} values
26
+ * @returns {number | undefined} `undefined` for an empty sample
27
+ */
28
+ export declare function mean(values: readonly number[]): number | undefined;
29
+ /**
30
+ * The SAMPLE variance, with Bessel's correction (`n − 1`): the sample is
31
+ * taken as drawn from a population it did not enumerate, which is what a
32
+ * benchmark's rounds and a query's rows both are.
33
+ * @param {readonly number[]} values
34
+ * @returns {number | undefined} `undefined` for fewer than two values
35
+ */
36
+ export declare function variance(values: readonly number[]): number | undefined;
37
+ /**
38
+ * The sample standard deviation — the root of {@link variance}.
39
+ * @param {readonly number[]} values
40
+ * @returns {number | undefined} `undefined` where the variance is
41
+ */
42
+ export declare function stddev(values: readonly number[]): number | undefined;
43
+ /**
44
+ * The median: the middle value, or the mean of the two middle values
45
+ * when the sample has an even count.
46
+ *
47
+ * This is NOT `quantile(values, 0.5, …)` under either method, and on an
48
+ * even count the three disagree: for `[1, 2, 3, 4]` the median is `2.5`,
49
+ * the nearest-rank p50 is `2`, and the linear p50 is `2.5` only because
50
+ * that sample happens to be evenly spaced. A consumer publishing a "p50"
51
+ * beside a "p95" wants `quantile` with its method named; a consumer
52
+ * asking for the median wants this.
53
+ * @param {readonly number[]} values
54
+ * @returns {number | undefined} `undefined` for an empty sample
55
+ */
56
+ export declare function median(values: readonly number[]): number | undefined;
57
+ export type QuantileMethod = 'nearest-rank' | 'linear';
58
+ /**
59
+ * The `p`-quantile of a sample, `p` on `[0, 1]`, under a NAMED method.
60
+ *
61
+ * The method is required, not defaulted: on a small sample the common
62
+ * definitions disagree by whole values, and a caller who did not say
63
+ * which one it wanted has published a number it cannot explain.
64
+ * @param {readonly number[]} values
65
+ * @param {number} p - the probability, `0` (the minimum) to `1` (the maximum)
66
+ * @param {{ method: QuantileMethod }} options
67
+ * @returns {number | undefined} `undefined` for an empty sample
68
+ * @throws {TypeError} when `method` is absent or not one of {@link QuantileMethod}
69
+ * @throws {RangeError} when `p` is not a number in `[0, 1]`
70
+ */
71
+ export declare function quantile(values: readonly number[], p: number, options: {
72
+ method: QuantileMethod;
73
+ }): number | undefined;
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/GEO.md CHANGED
@@ -22,10 +22,10 @@ the *wrong sign* on near-collinear input, which makes containment
22
22
  contradict itself. `orient2dFast` is the naive form, exported for
23
23
  callers that provably do not care. Every winding and containment answer
24
24
  in this module rests on this sign; its cost is the deliberate
25
- point-in-polygon loss on the benchmark page (<!--bm:geo.pip2000-->0.5×<!--/bm--> against
25
+ point-in-polygon loss on the benchmark page (<!--fact:geo.pip2000-->0.5×<!--/fact--> against
26
26
  Turf at 2000 vertices, kept on purpose). Every loss the kernel carries is
27
27
  published in [ARCHITECTURE](../ARCHITECTURE.md), derived from the committed
28
- measurement: <!--bm:geo.losses-->three rows lose to a rival: point in polygon (2000-vertex) at 0.5× (turf), bounding box (2000-vertex) at 0.9× (turf), index build (100k boxes) at 0.8× (flatbush)<!--/bm-->.
28
+ measurement: <!--fact:geo.losses-->three rows lose to a rival: point in polygon (2000-vertex) at 0.5× (turf), bounding box (2000-vertex) at 0.9× (turf), index build (100k boxes) at 0.8× (flatbush)<!--/fact-->.
29
29
 
30
30
  ## Distance — `distance.js`
31
31