@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
package/ARCHITECTURE.md CHANGED
@@ -109,7 +109,9 @@ flowchart TB
109
109
  DatesRfc["rfc3339.js<br/>RFC 3339 / ISO 8601"]
110
110
  DatesCivil["civil.js<br/>Gregorian day-number math"]
111
111
  DatesFormat["format.js<br/>LDML pattern compiler"]
112
+ DatesParse["parse.js<br/>LDML pattern parser"]
112
113
  DatesDuration["duration.js<br/>ISO 8601 durations"]
114
+ DatesTicks["ticks.js<br/>Time-axis step ladder"]
113
115
  end
114
116
 
115
117
  subgraph GeoModule["Spatial"]
@@ -130,6 +132,14 @@ flowchart TB
130
132
  VectorIndex["vector/index.js<br/>Similarity kernels, normalization, packed form"]
131
133
  end
132
134
 
135
+ subgraph SeriesModule["Temporal"]
136
+ SeriesIndex["series/index.js"]
137
+ SeriesSelector["selector.js<br/>Where a member lives in a row"]
138
+ SeriesNormalize["normalize.js<br/>Instants, stable sort, binary bounds"]
139
+ SeriesInterval["interval.js<br/>Half-open set algebra"]
140
+ SeriesTree["interval-index.js<br/>Prefix-max-end interval index"]
141
+ end
142
+
133
143
  subgraph MathModules["Mathematics"]
134
144
  MathIndex["math/index.js"]
135
145
  Int32Math["int32.js<br/>Fixed-point math"]
@@ -151,6 +161,15 @@ flowchart TB
151
161
 
152
162
  subgraph FunctionModule["Function Utilities"]
153
163
  FunctionUtil["function.js<br/>trueThat, falseThat"]
164
+ AsyncUtil["async.js<br/>Bounded ordered asynchronous map"]
165
+ end
166
+
167
+ subgraph RandomModule["Randomness"]
168
+ RandomUtil["random.js<br/>Seeded generator, integer draw, shuffle, distinct draw"]
169
+ end
170
+
171
+ subgraph StatsModule["Statistics"]
172
+ StatsUtil["stats.js<br/>Mean, sample variance, median, named quantile"]
154
173
  end
155
174
 
156
175
  subgraph StringModules["String &amp; Scanning"]
@@ -191,6 +210,7 @@ flowchart TB
191
210
  DatesIndex --> DatesCivil
192
211
  DatesIndex --> DatesFormat
193
212
  DatesIndex --> DatesDuration
213
+ DatesIndex --> DatesTicks
194
214
 
195
215
  GeoIndex --> GeoPred
196
216
  GeoIndex --> GeoDist
@@ -462,7 +482,9 @@ intermediate and is plain data, never an opaque handle.
462
482
  | `rfc3339.js` | validation, lexical decomposition, epoch conversion |
463
483
  | `civil.js` | proleptic Gregorian arithmetic over integers |
464
484
  | `format.js` | LDML pattern → compiled formatter |
485
+ | `parse.js` | LDML pattern → compiled strict parser (the formatter's inverse) |
465
486
  | `duration.js` | ISO 8601 duration decomposition and date arithmetic |
487
+ | `ticks.js` | the time-axis step ladder and its calendar boundaries |
466
488
 
467
489
  Calendar math goes through day numbers (`daysFromCivil`/`civilFromDays`),
468
490
  never through `Date`: the conversions are ~10 integer operations and allocate
@@ -475,6 +497,23 @@ allocates nothing per value. Formatting is the two-stage compiler again — a
475
497
  pattern is scanned once into a chain of appenders, which measured ~4.5×
476
498
  against re-scanning it per call.
477
499
 
500
+ Parsing is the same compiler run backwards. `compileDateParser` scans a
501
+ pattern once into a chain of readers and returns a function from text to the
502
+ same parts record `parseRFC3339Parts` produces, so every calendar function
503
+ takes its output unchanged. It is strict in three ways that a permissive
504
+ reader is not: trailing input fails, an impossible civil date (31 February,
505
+ hour 24) fails rather than rolling forward into the next month, and a
506
+ fixed-width token reads exactly its width. Failure is `null` — malformed
507
+ *text* is data — while a malformed *pattern* throws at compile time. The
508
+ tokens that only ever format, because they are derived from a date rather
509
+ than fields of one (`EEEE`, `DDD`, `ww`, `Q`), are compile errors with a
510
+ message saying which and why, rather than tokens that quietly read nothing.
511
+ A consumer whose patterns are not LDML — Mermaid's Gantt `dateFormat` is
512
+ moment's grammar and its `axisFormat` is strftime — adapts its own tokens
513
+ onto this vocabulary rather than handing its spelling to this compiler:
514
+ LDML's `YYYY` is the week-numbering year, so `YYYY-MM-DD` read as LDML
515
+ answers 2019 for 2018-12-31.
516
+
478
517
  Locale-dependent presentation (month and weekday names, meridiem, relative
479
518
  phrasing) is deliberately **not** here, so this package stays zero-dependency
480
519
  and free of data that drifts per language: `compileDateFormat` takes a names
@@ -532,11 +571,31 @@ import {
532
571
  const parts = parseRFC3339Parts('2026-01-31');
533
572
  addToParts(parts, 1, 'month'); // 2026-02-28 — month math clamps
534
573
  startOfParts(parts, 'week'); // the Monday of that week (ISO 8601)
574
+ addToParts(parts, 3, 'hour'); // TypeError — a full-date has no clock
535
575
 
536
576
  const fmt = compileDateFormat("yyyy-'W'ww"); // compile once…
537
577
  fmt(parts); // …call many: '2026-W05'
578
+
579
+ const read = compileDateParser('dd-MM-yyyy');
580
+ read('06-01-2014'); // { year: 2014, month: 1, day: 6, … }
581
+ read('06-01-2014x'); // null — trailing input
582
+ read('31-02-2014'); // null — February has no 31st
583
+ ```
584
+
585
+ **Ticks a caller chose, or ticks this module chose:**
586
+
587
+ ```javascript
588
+ import { axisTicksTime, timeTicksEvery } from '@jarenjs/core/dates';
589
+
590
+ axisTicksTime(from, to, 4); // the ladder picks the step
591
+ timeTicksEvery(from, to, 'week', 1, { weekStart: 7 }); // Sundays
538
592
  ```
539
593
 
594
+ `axisTicksTime` is the chart axis's planner; `timeTicksEvery` is the same
595
+ boundary rule with the step supplied, which is what a document declaring its
596
+ own interval (Mermaid's `tickInterval 1week`) needs. One ladder, so a chart
597
+ and a timeline cannot disagree about where a tick falls.
598
+
540
599
  ### 5b. Geo Module (`geo/`)
541
600
 
542
601
  The spatial kernel. As with dates, **there is no geometry type**: the
@@ -578,9 +637,9 @@ direction is *not* claimed, because WKT whitespace, the `M` measure and number
578
637
  spelling are normalized on the way through.
579
638
 
580
639
  Against [`wellknown`](https://www.npmjs.com/package/wellknown), the established
581
- WKT↔GeoJSON converter (`npm run benchmark:geo`, Node <!--bm:geo.node-->v24.19.0<!--/bm-->):
640
+ WKT↔GeoJSON converter (`npm run benchmark:geo`, Node <!--fact:geo.node-->v24.19.0<!--/fact-->):
582
641
 
583
- <!--bm:geo.wktTable-->
642
+ <!--fact:geo.wktTable-->
584
643
  | scenario | Jaren | rival | ratio |
585
644
  |---|---|---|---|
586
645
  | wkt parse (POINT) | 431.1 ns | 1.31 µs (wellknown) | **3.1×** |
@@ -588,7 +647,7 @@ WKT↔GeoJSON converter (`npm run benchmark:geo`, Node <!--bm:geo.node-->v24.19.
588
647
  | wkt stringify (2000-vertex polygon) | 163.40 µs | 229.70 µs (wellknown) | **1.4×** |
589
648
  | wkt validate (POINT) | 242.7 ns | 1.28 µs (wellknown) | **5.3×** |
590
649
  | wkt validate (2000-vertex polygon) | 496.00 µs | 1.90 ms (wellknown) | **3.8×** |
591
- <!--/bm-->
650
+ <!--/fact-->
592
651
 
593
652
  The last two rows are the ones the sink exists for: `wellknown` has no
594
653
  predicate, so validating means parsing and throwing the geometry away. And the
@@ -627,7 +686,7 @@ RFC 7946 removed coordinate-reference-system support and mandates WGS 84, so
627
686
  there is no SRID table and no reprojection: conformance removes the need rather
628
687
  than an omission hiding it.
629
688
 
630
- **Measured against the field** (`npm run benchmark:geo`, Node <!--bm:geo.node-->v24.19.0<!--/bm-->). Every
689
+ **Measured against the field** (`npm run benchmark:geo`, Node <!--fact:geo.node-->v24.19.0<!--/fact-->). Every
631
690
  scenario asserts result equivalence before any timing, and the harness refuses
632
691
  to print a table if the engines disagree: distance, area, length and bounding
633
692
  box come out *bit-identical* to Turf, containment agrees on a 400-point sweep,
@@ -635,7 +694,7 @@ and the index returns exactly Flatbush's answer on 200 queries. Ratios are the
635
694
  rival's time over this kernel's, so above 1 means Jaren is faster; the rows
636
695
  below derive from the committed measurement and are refreshed with it.
637
696
 
638
- <!--bm:geo.table-->
697
+ <!--fact:geo.table-->
639
698
  | scenario | Jaren | rival | ratio |
640
699
  |---|---|---|---|
641
700
  | distance (two positions) | 28.3 ns | 111.0 ns (turf) | **3.9×** |
@@ -650,11 +709,11 @@ below derive from the committed measurement and are refreshed with it.
650
709
  | index build (100k boxes) | 12.70 ms | 10.62 ms (flatbush) | 0.8× |
651
710
  | index probe (100k boxes) | 602.3 ns | 668.9 ns (flatbush) | **1.1×** |
652
711
  | linear scan (100k boxes, no index) | 141.50 µs | — | — |
653
- <!--/bm-->
712
+ <!--/fact-->
654
713
 
655
- The losses are kept, and named by the same measurement: on the committed run <!--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-->.
714
+ The losses are kept, and named by the same measurement: on the committed run <!--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-->.
656
715
  **Point-in-polygon on a large ring runs at about half Turf's speed**
657
- (<!--bm:geo.pip2000-->0.5×<!--/bm--> at 2000 vertices) because every edge that could
716
+ (<!--fact:geo.pip2000-->0.5×<!--/fact--> at 2000 vertices) because every edge that could
658
717
  matter goes through the exact orientation predicate, where Turf uses naive
659
718
  floating-point arithmetic. That is the trade this module exists to make — it
660
719
  is the difference between a containment test that is right on near-collinear
@@ -663,7 +722,7 @@ predicate on edges that cannot affect the answer, which took this from 0.2×
663
722
  to about 0.5×; the rest is the predicate itself, and the campaign that made
664
723
  geography reachable across the suite never traded it away. The bounding box
665
724
  of the same ring and the index build against Flatbush sit within a few tenths
666
- of level (<!--bm:geo.bbox2000-->0.9×<!--/bm--> and <!--bm:geo.indexBuild-->0.8×<!--/bm-->) and move
725
+ of level (<!--fact:geo.bbox2000-->0.9×<!--/fact--> and <!--fact:geo.indexBuild-->0.8×<!--/fact-->) and move
667
726
  between runs and Node versions; they are published as measured rather than
668
727
  rounded to a win.
669
728
 
@@ -721,6 +780,60 @@ their reason to exist; they guarantee a finite answer. The fixed-arity vector
721
780
  classes in `math/` (`Vec2f64`, `Vec3f64`) are 2D/3D geometry with a
722
781
  different convention and a different job, and they stay where they are.
723
782
 
783
+ ### 5d. Series Module (`series/`)
784
+
785
+ The temporal kernel: one meaning for an instant, one for a sorted series of
786
+ readings, one for an interval, and the set algebra over them. As with dates,
787
+ **there is no type** — an instant is epoch milliseconds or an RFC 3339 string,
788
+ a sample is `{ at, value }`, an interval is `{ start, end }` — and, as with
789
+ dates, **there is no `now`**: every bound is data, so every answer here is
790
+ reproducible and cacheable.
791
+
792
+ | File | Owns |
793
+ |---|---|
794
+ | `selector.js` | where a member lives in a caller's row — a property name or a function, so `on`/`recorded_at`/`from`/`to` are read where they are |
795
+ | `normalize.js` | `toEpoch`, `normalizeSeries`, `normalizeIntervals` and the `lowerBoundTime`/`upperBoundTime` cuts a sorted array is read through |
796
+ | `interval.js` | `containsInstant`, `overlapsInterval`, `intersectInterval`, `mergeIntervals`, `subtractIntervals`, `gapsWithin`, `coverageOf`, `findSlots` |
797
+ | `interval-index.js` | `createIntervalIndex` — build once, query many, by point or by range |
798
+
799
+ Four decisions carry it. **Half-open `[start, end)`**: an interval holds its
800
+ start and not its end, so touching intervals neither overlap nor double-count
801
+ and a boundary instant belongs to exactly one of them — while `mergeIntervals`
802
+ *joins* touching spans by default, because availability means continuous cover,
803
+ with `{ adjacent: false }` as the explicit other answer. **Nothing is dropped**:
804
+ a member that names no instant, or an interval that is empty, reversed or
805
+ non-finite, is a refusal naming the row rather than a silently shorter result,
806
+ and the sort is stable so duplicate instants keep their order and both count.
807
+ **No clock**: `gapsWithin` and `coverageOf` derive their window from the input's
808
+ own hull when given none, because the only other default would be "now".
809
+
810
+ And **the index cuts on both ends**. Sorting by start alone is the bug: a span
811
+ that began long before a query sits far to the left of the query's own
812
+ neighbourhood and still overlaps it, so a binary search around the query loses
813
+ it while looking plausible. `createIntervalIndex` therefore carries a prefix
814
+ maximum end beside the starts — non-decreasing by construction, hence
815
+ binary-searchable — and the first position where it passes the query's start is
816
+ the first position where anything can still be live. A query is two binary cuts
817
+ and a walk between them, O(log n + k), returning the caller's own rows in a
818
+ fresh array. Like the packed-Hilbert box index it is **static**: bounds are
819
+ copied into flat typed arrays at build time, so a query reads no source object
820
+ and a row mutated afterwards cannot change what the index answers.
821
+
822
+ Buckets, resampling, rolling windows and as-of joins are the layer above this
823
+ one; named zones, recurrence grammars and scheduling solvers are outside it.
824
+ `findSlots` enumerates where a fixed-width span fits — choosing among the
825
+ answers is a solver's job, deliberately not this kernel's.
826
+
827
+ ```javascript
828
+ import { createIntervalIndex, mergeIntervals, findSlots } from '@jarenjs/core/series';
829
+
830
+ const shifts = [{ start: 0, end: 4 * 3600_000 }, { start: 4 * 3600_000, end: 8 * 3600_000 }];
831
+ mergeIntervals(shifts); // one span — touching IS continuous cover
832
+ mergeIntervals(shifts, { adjacent: false }); // two — a handover is two shifts
833
+ findSlots(shifts, { duration: 'PT30M' }).length; // 16
834
+ createIntervalIndex(shifts).at(4 * 3600_000); // the second shift only: [start, end)
835
+ ```
836
+
724
837
  ### 6. Text Module (`text/`)
725
838
 
726
839
  Comprehensive string format validation organized by domain.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @jarenjs/core
2
2
 
3
- The zero-dependency foundation of [Jaren](https://github.com/jklarenbeek/jarenjs). Everything the rest of the suite is built on lives here — type guards, Unicode-aware string handling, a large text-validation toolbox, number range helpers, fixed-point and vector math, the calendar kernel, the spatial kernel, the vector kernel, the message-catalog compiler, unit/currency conversion and a finance library.
3
+ The zero-dependency foundation of [Jaren](https://github.com/jklarenbeek/jarenjs). Everything the rest of the suite is built on lives here — type guards, Unicode-aware string handling, a large text-validation toolbox, number range helpers, fixed-point and vector math, the calendar kernel, the spatial kernel, the vector kernel, the interval kernel, the message-catalog compiler, unit/currency conversion and a finance library.
4
4
 
5
5
  None of it depends on JSON Schema: every module can be used standalone in any JavaScript project.
6
6
 
@@ -13,6 +13,9 @@ None of it depends on JSON Schema: every module can be used standalone in any Ja
13
13
  | `@jarenjs/core/object` | deep equality (`equalsDeep`, JSON-only `equalsJson`), the `isJsonObject` and deep `isJsonValue` predicates, `__proto__`-safe `setObjectMember`, `deepFreeze`, map/set merging |
14
14
  | `@jarenjs/core/string` | Unicode string helpers (`countCodePoints`, `compareCodePoints`, ...), cached regex compilation, the suite's one content hash (`fnv1a` and the `hashContent` fingerprint over it) and `kebabCase` |
15
15
  | `@jarenjs/core/cache` | the bounded LRU (`createBoundedCache`), the reference-keyed `createWeakCache`, and `createSemanticCache` — keyed by what a value IS, for caches whose entries decide a result |
16
+ | `@jarenjs/core/random` | the suite's one seeded generator (`mulberry32`, pinned sequence, ToUint32 seed) and the draws built on it: `randomInt` over a half-open range, in-place Fisher–Yates `shuffle`, and `drawDistinct` — `k` distinct indices from one stream |
17
+ | `@jarenjs/core/stats` | descriptive statistics over a sample: `mean`, sample `variance`/`stddev`, the midpoint `median`, and `quantile(values, p, { method })` — `p` on 0..1 under a NAMED rule, `'nearest-rank'` or `'linear'`, because a default would decide silently; an empty sample answers `undefined`, never `0` |
18
+ | `@jarenjs/core/async` | `mapConcurrent(items, limit, worker, { signal })` — the bounded ordered asynchronous map: never more than `limit` workers in flight, results in input order, a rejection or an abort stops dispatch and drains the lanes before the map rejects, so nothing is still running when it settles |
16
19
  | `@jarenjs/core/chunk` | cutting a value down to size: `sizeOf` (the suite's one size rule — a string is its length, anything else its JSON), `excerpt`, `truncate`, and `chunkText` by size, line or separator with offsets that locate a piece in its source |
17
20
  | `@jarenjs/core/scan` | char-code constants and predicates for recursive-descent parsers |
18
21
  | `@jarenjs/core/message` | the message template/catalog compiler shared by the validator and the form layer |
@@ -24,6 +27,7 @@ None of it depends on JSON Schema: every module can be used standalone in any Ja
24
27
  | `@jarenjs/core/dates` | RFC 3339 / ISO 8601 validation, plus the calendar kernel: integer date arithmetic, compiled formatting, durations |
25
28
  | `@jarenjs/core/geo` | the spatial kernel over GeoJSON: robust orientation, great-circle measurement, rings, bounding boxes, geohash, GeoJSON/WKT validity, a packed-Hilbert box index, Web Mercator and Douglas-Peucker simplification |
26
29
  | `@jarenjs/core/vector` | the vector kernel over plain arrays: dot, cosine and Euclidean similarity (higher-is-better; a malformed pair scores 0), l2 normalization, the packed little-endian Float32 form and the one shape guard (`isVector`) |
30
+ | `@jarenjs/core/series` | the temporal kernel over plain records: instant/sample/interval normalization with a stable sort, half-open `[start, end)` set algebra (overlap, merge, subtract, gaps, coverage, slot enumeration), a static interval index, and the series operations built on them — fixed and calendar buckets with five fill policies, time-width rolling aggregates, as-of joins and gap-aware downsampling, on an injected zone seam |
27
31
  | `@jarenjs/core/text` | text validators: emails, hostnames, IPs, URIs/IRIs, UUIDs, punycode, ... |
28
32
  | `@jarenjs/core/math` | int32/float64 math and 2D/3D vector classes; the linear `remap` and unit-interval `clamp01` |
29
33
  | `@jarenjs/core/finance` | zero-dependency finance/trading formulas: TVM, cash flow, amortization, interest, depreciation, bonds, technical indicators, returns/risk |
@@ -65,7 +69,7 @@ Grouped by file: `email` (RFC 5321 + internationalized addresses), `host` (hostn
65
69
 
66
70
  ## Dates, numbers and math
67
71
 
68
- - `dates` — RFC 3339 date/time/date-time validation and parsing (leap years and month lengths included), the more lenient ISO date-time forms, and the suite's calendar kernel: proleptic Gregorian arithmetic over integer day numbers (`addToParts`, `startOfParts`, `endOfParts` — month math clamps, so 31 Jan plus a month is 28 Feb), ISO 8601 duration decomposition, and `compileDateFormat`, which turns an LDML pattern into a formatter once instead of re-scanning it per call. Dates stay JSON: an RFC 3339 string or epoch milliseconds, never a wrapper object. Locale names live in `@jarenjs/locales`, so a pattern needing `MMMM` takes a names provider. The full module reference is [docs/DATES.md](./docs/DATES.md).
72
+ - `dates` — RFC 3339 date/time/date-time validation and parsing (leap years and month lengths included), the more lenient ISO date-time forms, and the suite's calendar kernel: proleptic Gregorian arithmetic over integer day numbers (`addToParts`, `startOfParts`, `endOfParts` — month math clamps, so 31 Jan plus a month is 28 Feb, and an operation reading a half the value has not got is refused rather than guessed), ISO 8601 duration decomposition, the locale-free time-axis step ladder (`niceTimeStep`, `axisTicksTime`, and `timeTicksEvery` for a step the caller declares) a chart and a timeline both read, and the two-stage pattern compilers `compileDateFormat` and its strict inverse `compileDateParser`, which turn an LDML pattern into a formatter or a parser once instead of re-scanning it per call — the parser refuses trailing input, an impossible civil date, and the tokens that only format because they are derived from a date rather than fields of one. Dates stay JSON: an RFC 3339 string or epoch milliseconds, never a wrapper object. Locale names live in `@jarenjs/locales`, so a pattern needing `MMMM` takes a names provider. The full module reference is [docs/DATES.md](./docs/DATES.md); the interval algebra built on top of it is [`series`](#intervals-and-series).
69
73
  - `integer`/`float`/`bigint` — range constants and validators for every fixed-width type from `int8` to `uint64` and `float16` to `float64`, including float increment/decrement in representable steps.
70
74
  - `math` — asm.js-style typed math (`Int32`, `Float64`) and vector classes (`Vec2i32`, `Vec2f64`, `Vec3f64`) with a fast integer sine approximation; reference in [docs/MATH.md](./docs/MATH.md).
71
75
 
@@ -97,7 +101,7 @@ Four design decisions carry the module:
97
101
  - **Orientation is computed exactly** (Shewchuk's adaptive predicates): a naive floating-point determinant returns the *wrong sign* on near-collinear input, which makes containment contradict itself. Every ring winding and point-in-polygon answer rests on this sign, and the deliberate cost is on the [benchmark page](https://jklarenbeek.github.io/jarenjs/#/benchmarks?suite=geo).
98
102
  - **Measurement is spherical, drawing is projected, and the two never mix.** A Euclidean norm on raw degrees is 64% wrong over 1 km at Dutch latitudes, so `haversineDistance`/`sphericalRingArea` work on the sphere (`equirectDistance` is the cheap screening form for rejecting candidates first), while `projectMercator`/`fitMercator` and `simplifyLine`/`simplifyRing` exist for renderers — never measure on a projected coordinate.
99
103
  - **Validity is a separate concern from traversal, and a box is refused rather than made too small.** `eachPosition`, `bboxOf`, `centroidOf` and friends measure without judging (a ring is read as closed whether or not it is); a box over any non-finite coordinate is `null`, never a plausible box that misses its input; and boxes never cross the antimeridian — cut the geometry at ±180° as RFC 7946 §3.1.9 asks, and every containment test and index probe is right. `isValidGeoJson` (structure plus the ring closure a JSON Schema provably cannot express), `isValidWkt` and `isValidGeohash` are the one-call judgments that back the `geoFormats` group in [`@jarenjs/formats`](../formats), next to the full GeoJSON meta-schema artifacts in [`@jarenjs/json`](../json).
100
- - **WKT is one grammar walk with two entry points.** `isValidWkt` and `wktToGeoJson` run the *same* scan, parameterized by a sink that is absent for the predicate and present for the parser — so the `wkt` format tester (which runs per value in the validator and per keystroke in the form layer) allocates nothing, and the two cannot drift apart. A committed corpus asserts `isValidWkt(s) === (wktToGeoJson(s) !== null)` for all 181 entries, malformed half included. `geoJsonToWkt` writes the string back, and `wktToGeoJson(geoJsonToWkt(g))` returns `g`; the text direction is *not* claimed, because whitespace, the `M` measure and number spelling are normalized. Against [`wellknown`](https://www.npmjs.com/package/wellknown) the parse is <!--bm:geo.wktParsePoint-->3.1×<!--/bm--> faster on a `POINT` and the yes-or-no answer <!--bm:geo.wktValidatePoint-->5.3×<!--/bm--> — and wellknown *validates less* while being slower, which is the honest framing; the table and every language difference are in [ARCHITECTURE](./ARCHITECTURE.md). The kernel's own losses are published there too, derived from the same run: <!--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-->.
104
+ - **WKT is one grammar walk with two entry points.** `isValidWkt` and `wktToGeoJson` run the *same* scan, parameterized by a sink that is absent for the predicate and present for the parser — so the `wkt` format tester (which runs per value in the validator and per keystroke in the form layer) allocates nothing, and the two cannot drift apart. A committed corpus asserts `isValidWkt(s) === (wktToGeoJson(s) !== null)` for all 181 entries, malformed half included. `geoJsonToWkt` writes the string back, and `wktToGeoJson(geoJsonToWkt(g))` returns `g`; the text direction is *not* claimed, because whitespace, the `M` measure and number spelling are normalized. Against [`wellknown`](https://www.npmjs.com/package/wellknown) the parse is <!--fact:geo.wktParsePoint-->3.1×<!--/fact--> faster on a `POINT` and the yes-or-no answer <!--fact:geo.wktValidatePoint-->5.3×<!--/fact--> — and wellknown *validates less* while being slower, which is the honest framing; the table and every language difference are in [ARCHITECTURE](./ARCHITECTURE.md). The kernel's own losses are published there too, derived from the same run: <!--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-->.
101
105
 
102
106
  The spatial query operators (`$distance`, `$within`, `$geohash`, spatial joins over the box index) live in the query engine in [`@jarenjs/json`](../json); the streaming map chart that draws a FeatureCollection with bounded memory lives in [`@jarenjs/charts`](../../components/charts). The full module reference is [docs/GEO.md](./docs/GEO.md).
103
107
 
@@ -123,6 +127,49 @@ Three rules, kept by every function so that no caller has to check them again:
123
127
 
124
128
  The packed form is `4·d` bytes of little-endian binary32 — the value a database column stores; components round to `Math.fround` and come back exactly. Unpacking aligned bytes on a little-endian host is a *view*, not a copy, which is what a sweep over ten thousand fetched rows is paid for by; misaligned bytes (a pooled `Buffer`, an odd offset into a record) and big-endian hosts take the copy path to the same values. The client that produces embeddings — and a deterministic reference embedder for tests — lives in [`@jarenjs/ai`](../ai/README.md#embeddings).
125
129
 
130
+ ## Intervals and series
131
+
132
+ `@jarenjs/core/series` is the suite's temporal kernel: one meaning for an interval, one meaning for a sorted series of timestamped readings, the set algebra over them, and the five operations every consumer of a timeline otherwise rebuilds by hand — bucket, fill, roll, join as-of, downsample. As with dates, there is no type — an instant is epoch milliseconds or an RFC 3339 string, a sample is `{ at, value }`, an interval is `{ start, end }` — so every value stays a plain JSON item, and nothing here reads a clock.
133
+
134
+ ```javascript
135
+ import { createIntervalIndex, mergeIntervals, gapsWithin, findSlots } from '@jarenjs/core/series';
136
+
137
+ const shifts = [
138
+ { from: '2026-03-02T09:00:00Z', to: '2026-03-02T13:00:00Z', who: 'ada' },
139
+ { from: '2026-03-02T13:00:00Z', to: '2026-03-02T17:00:00Z', who: 'grace' },
140
+ ];
141
+ const index = createIntervalIndex(shifts, { start: 'from', end: 'to' });
142
+ index.at('2026-03-02T13:00:00Z'); // [grace] — half-open, so the handover belongs to one shift
143
+
144
+ const cover = shifts.map((s) => ({ start: s.from, end: s.to }));
145
+ mergeIntervals(cover); // one span, 09:00–17:00 — touching IS continuous cover
146
+ findSlots(cover, { duration: 'PT30M' }); // sixteen half-hour slots, one straddling the handover
147
+ ```
148
+
149
+ ```javascript
150
+ import { resampleSeries, rollingSeries, asOfJoin, downsampleSeries } from '@jarenjs/core/series';
151
+
152
+ resampleSeries(readings, { every: 'PT1H', aggregate: 'mean', fill: 'linear' });
153
+ resampleSeries(readings, { every: 'P1M', zone: 'Europe/Amsterdam', provider });
154
+ rollingSeries(readings, { width: 'PT5M', aggregate: 'max', minPeriods: 3 });
155
+ asOfJoin(trades, quotes, { direction: 'nearest', tolerance: 'PT1S', key: 'symbol' });
156
+ downsampleSeries(readings, { target: 2000 }); // → { points, sourceCount, renderedCount, method }
157
+ ```
158
+
159
+ Seven decisions carry the module:
160
+
161
+ - **Half-open, everywhere.** `[start, end)` holds its start and not its end, so a day ends exactly where the next begins, a boundary instant belongs to exactly one of two touching intervals, and nothing is counted twice. Touching intervals therefore do *not* overlap — back-to-back bookings are not a double booking — while `mergeIntervals` joins them by default, because availability asks whether there is continuous cover. `{ adjacent: false }` is the other answer, spelled out rather than guessed.
162
+ - **A row is never dropped, and a duplicate is never merged away.** A member that cannot become a finite instant is a refusal naming the row, not a silently shorter result; an empty (`[t, t)`), reversed or non-finite interval is refused at the point it was written. The sort is stable, so two readings in the same millisecond keep their input order and both count.
163
+ - **Nothing reads a clock.** `gapsWithin` and `coverageOf` derive their window from the input's own hull when given none, because the only other default would be "now" — and an operation that read the clock could not be cached, reproduced or run in a test twice.
164
+ - **The index cuts on both ends, and cannot lose a long span.** Sorting by start alone is the bug: a conference week that began before an hourly meeting sits far to the left of that meeting's neighbourhood and still overlaps it. `createIntervalIndex` carries a prefix maximum end beside the starts — non-decreasing, so binary-searchable — and a query becomes two binary cuts and a walk between them, O(log n + k), returning the caller's own rows. It is static: bounds are copied at build time, so a query reads no source object and a row mutated afterwards changes nothing.
165
+
166
+ - **Bucketing is arithmetic; filling is a policy.** An average over an empty hour is not zero, and it is not yesterday's average, and it is not nothing — it is whichever of `omit | null | zero | locf | linear` the caller asked for, and the aggregate had no opinion. `count` reports source rows (duplicates and measured gaps included) beside every value, so `null` and "nobody reported" are distinguishable. Neither `locf` nor `linear` invents a value at the leading edge.
167
+ - **A rolling window is a duration, not a row count.** Sixty rows of a sensor reporting every second is a minute; sixty rows of a sensor that dropped half its readings is two minutes. The window is `(at − width, at]`, so two readings in the same millisecond share it and therefore share an answer. `sum`/`mean`/`count` carry a running total, `min`/`max` use a monotone deque, `first`/`last` are forward-only pointers — the complexity is structural, and the tests count the reads rather than the milliseconds.
168
+ - **Time zones are injected, never bundled.** UTC and fixed offsets work with no setup; a named zone takes the caller's own `provider`, because a bundled tzdb is megabytes that go stale on a government's timetable. A local time that never happened, or happened twice, is a refusal unless `disambiguation` says `earlier` or `later`. A sampler never bridges a gap, never moves an end, and refuses a target too small to hold both rather than drawing a prettier line.
169
+ - **A specification is closed.** Every kernel that takes one publishes the members it admits, and anything else is a refusal naming the near miss — because `minPeriod` for `minPeriods` quietly ignored is a window with no minimum and a plausible number to show for it, and `timezone` for `zone` is precisely the silent fall back to UTC the injected clock exists to prevent. The query language reads the same lists, minus the `provider`, which is a pair of functions and therefore not something a document can carry.
170
+
171
+ The kernel is measured against one-pass loops written for one question and against the query vocabulary a consumer had instead, and both ratios are published: it costs about twice a hand-written loop and answers about a hundred times faster than the generic route. Named time zones' data, recurrence grammars and scheduling solvers are deliberately outside it. The full module reference is [docs/SERIES.md](./docs/SERIES.md).
172
+
126
173
  ## Messages
127
174
 
128
175
  `@jarenjs/core/message` is the template/catalog compiler behind the validator's uniform error messages and the form layer's labels — msgid plus parameters in, rendered text out, so every message is translatable by swapping a catalog ([`@jarenjs/locales`](../locales) ships eleven language packs over it):
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @file The bounded ordered asynchronous map: run a worker over a list
3
+ * with never more than `limit` calls in flight, and answer the results
4
+ * in the list's order. Before this file the same twelve lines lived in
5
+ * the AI package's program runner and in the benchmark harness, and a
6
+ * downstream consumer had written them a third time; a pool that exists
7
+ * once is one whose edge behavior can be pinned once.
8
+ *
9
+ * The contract, in full:
10
+ *
11
+ * - results are in INPUT order, whatever order the workers finish in;
12
+ * - never more than `limit` workers are in flight; `limit` must be a
13
+ * number of at least 1 (`Infinity` is allowed and means unbounded) —
14
+ * anything else is a `TypeError`, never a silent clamp, because a
15
+ * limit of 0 is a bug in the caller and "sequential" is spelled 1;
16
+ * - a worker rejection stops dispatch: no item starts after it, the
17
+ * workers already in flight are awaited, and only then does the map
18
+ * reject with that first rejection. A worker that throws
19
+ * synchronously is a rejection;
20
+ * - an abort does the same, rejecting with the signal's reason; a signal
21
+ * that is already aborted rejects before any worker runs;
22
+ * - so when the returned promise settles, NO worker is still running —
23
+ * the caller can close whatever the workers were using.
24
+ *
25
+ * The map does not retry, rate-limit, delay per origin or know anything
26
+ * about what the worker does; those are the caller's policies around it.
27
+ */
28
+ /**
29
+ * @template T, R
30
+ * @param {readonly T[]} items
31
+ * @param {number} limit - workers in flight at once; a number >= 1, `Infinity` for unbounded
32
+ * @param {(item: T, index: number) => Promise<R> | R} worker
33
+ * @param {{ signal?: AbortSignal }} [options]
34
+ * @returns {Promise<R[]>} the results, in input order
35
+ * @throws {TypeError} (as a rejection) when `limit` is not a number >= 1
36
+ */
37
+ export declare function mapConcurrent<T, R>(items: readonly T[], limit: number, worker: (item: T, index: number) => Promise<R> | R, options?: {
38
+ signal?: AbortSignal;
39
+ }): Promise<R[]>;
@@ -111,36 +111,55 @@ export declare function fixedUnitMs(unit: string): number;
111
111
  /**
112
112
  * Add a signed amount of calendar units to a parts record, returning a
113
113
  * new one. The input is never mutated and its lexical shape is kept: a
114
- * full-date stays a full-date, and a value keeps its own UTC offset
115
- * rather than being normalized.
114
+ * full-date stays a full-date, a full-time stays a full-time, and a
115
+ * value keeps its own UTC offset rather than being normalized.
116
116
  *
117
117
  * Month and year arithmetic **clamps** to the end of the target month —
118
118
  * 2026-01-31 plus one month is 2026-02-28 — which is the rule every
119
119
  * mainstream date library uses, because the alternative (overflowing
120
120
  * into March) makes `add(1, 'month')` non-monotonic.
121
121
  *
122
+ * A fraction is a quantity only where the unit has an exact conversion.
123
+ * A fixed-width fraction becomes whole milliseconds, so `1.5 day` is
124
+ * thirty-six hours; a calendar fraction is refused unless it lands on a
125
+ * whole month, because half of January is not a length. The clock those
126
+ * milliseconds land on has to exist: a full-date can be moved by whole
127
+ * days but not by half of one.
128
+ *
122
129
  * @param {object} parts - a parts record from `parseRFC3339Parts`
123
- * @param {number} amount - signed count, may be fractional only for
124
- * fixed-width units (a fractional month has no meaning)
130
+ * @param {number} amount - signed count, fractional only where the unit
131
+ * converts exactly and the value has the half to carry it
125
132
  * @param {string} unit - a {@link DATE_UNITS} member
126
133
  * @returns {object} a new parts record
134
+ * @throws {TypeError} for an unknown unit, a fraction with no exact
135
+ * conversion, or a value missing a half the operation reads
127
136
  */
128
137
  export declare function addToParts(parts: object, amount: number, unit: string): object;
129
138
  /**
130
139
  * Truncate a parts record to the start of a calendar unit, returning a
131
140
  * new one. `week` starts on Monday (ISO 8601).
141
+ *
142
+ * `day` and coarser name a boundary the calendar has, so the start of
143
+ * the day of a full-time is midnight. A sub-day unit names a boundary
144
+ * inside a day, which a value with no clock does not have, and is
145
+ * refused rather than answered from a clock that is not there.
146
+ *
132
147
  * @param {object} parts - a parts record
133
148
  * @param {string} unit - a {@link DATE_UNITS} member
134
149
  * @returns {object} a new parts record
150
+ * @throws {TypeError} for an unknown unit, or a value missing a half
151
+ * the truncation reads
135
152
  */
136
153
  export declare function startOfParts(parts: object, unit: string): object;
137
154
  /**
138
155
  * The last representable instant inside a calendar unit: the start of
139
156
  * the next unit less one millisecond. A value with no time half is
140
157
  * truncated to the unit's last *day* instead, so a full-date stays a
141
- * full-date.
158
+ * full-date, and it refuses the same sub-day units `startOfParts` does.
142
159
  * @param {object} parts - a parts record
143
160
  * @param {string} unit - a {@link DATE_UNITS} member
144
161
  * @returns {object} a new parts record
162
+ * @throws {TypeError} for an unknown unit, or a value missing a half
163
+ * the truncation reads
145
164
  */
146
165
  export declare function endOfParts(parts: object, unit: string): object;
@@ -1,4 +1,6 @@
1
1
  export * from './rfc3339.js';
2
2
  export * from './civil.js';
3
3
  export * from './format.js';
4
+ export * from './parse.js';
4
5
  export * from './duration.js';
6
+ export * from './ticks.js';
@@ -0,0 +1,47 @@
1
+ export type Reader = (text: string, i: number, out: any) => number;
2
+ /**
3
+ * Compile an LDML date pattern into a strict parser.
4
+ *
5
+ * The returned function takes a string and returns a parts record in
6
+ * exactly the shape `parseRFC3339Parts` produces — so
7
+ * `epochOfRFC3339Parts`, `formatRFC3339Parts`, `addToParts` and every
8
+ * other calendar function accept it unchanged — or `null` when the text
9
+ * does not match. Failure is `null`, never a throw and never a partial
10
+ * record: a malformed *pattern* is the programmer's error and throws at
11
+ * compile time; malformed *text* is data and is reported as no match.
12
+ *
13
+ * Strict means three things:
14
+ *
15
+ * 1. **Full consumption.** Trailing text fails; `yyyy-MM-dd` does not
16
+ * read `2026-01-02T03:04`.
17
+ * 2. **Impossible civil dates fail.** 31 February and hour 24 are not
18
+ * silently rolled forward into March and the next day.
19
+ * 3. **Fixed-width tokens are fixed width.** `MM` reads exactly two
20
+ * digits, `M` one or two, and neither reads three.
21
+ *
22
+ * The lexical family of the result follows the pattern: a pattern with
23
+ * only date tokens yields a full-date (`hours`/`minutes`/`seconds` are
24
+ * the contract's `-1`), one with only time tokens a full-time
25
+ * (`year`/`month`/`day` are `-1`), and one with both a date-time. A
26
+ * pattern with no field token at all is a compile error — it could only
27
+ * ever return the same empty record.
28
+ *
29
+ * Two tokens are not strict inverses of an arbitrary value and are
30
+ * documented rather than refused: `yy` reads 00–68 as 2000–2068 and
31
+ * 69–99 as 1969–1999 (the POSIX pivot moment uses), and `h`/`hh`
32
+ * without an `a` in the same pattern read as the morning, so 12 is
33
+ * midnight. Both round-trip their own spelling exactly.
34
+ *
35
+ * @param {string} pattern - an LDML pattern, e.g. `'yyyy-MM-dd'`
36
+ * @param {import('./format.js').DateNames} [names] - locale names, required only if the
37
+ * pattern uses `MMM`/`MMMM`/`a`
38
+ * @returns {(text: string) => object | null} the compiled parser
39
+ * @throws {TypeError} on an unterminated quote, a derived token, a name
40
+ * token with no provider, or a pattern that reads no field
41
+ * @example
42
+ * const read = compileDateParser('dd-MM-yyyy');
43
+ * read('06-01-2014'); // { year: 2014, month: 1, day: 6, hours: -1, … }
44
+ * read('06-01-2014x'); // null — trailing input
45
+ * read('31-02-2014'); // null — February has no 31st
46
+ */
47
+ export declare function compileDateParser(pattern: string, names?: import('./format.js').DateNames): (text: string) => object | null;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Choose the calendar step closest to covering `span` in `count` ticks.
3
+ *
4
+ * A year is the coarsest unit a calendar has, so above one year the
5
+ * ladder continues as whole years on the same 1/2/5×10^k steps — 2, 5,
6
+ * 10, 50, 1000 years. Without that continuation a millennial domain
7
+ * asks for one tick per year and runs out of axis long before it runs
8
+ * out of domain.
9
+ *
10
+ * @param {number} span - Domain width in milliseconds
11
+ * @param {number} count - Desired tick count
12
+ * @returns {[string, number]} a [unit, amount] pair
13
+ */
14
+ export declare function niceTimeStep(span: number, count: number): [string, number];
15
+ /**
16
+ * Time ticks on calendar boundaries: the first tick is the start of a
17
+ * `[unit, amount]` step at or after `min`, and each following one is a
18
+ * whole step later. A multi-unit step lands on a multiple of its own
19
+ * amount — months on 1, 4, 7, 10 and years on 1900, 1950, 2000 — so an
20
+ * axis reads as a calendar rather than as offsets from wherever the
21
+ * data happened to start.
22
+ *
23
+ * @param {number} min - Domain minimum, epoch milliseconds
24
+ * @param {number} max - Domain maximum, epoch milliseconds
25
+ * @param {number} [count] - Desired tick count (approximate)
26
+ * @returns {number[]} tick values in epoch milliseconds
27
+ */
28
+ export declare function axisTicksTime(min: number, max: number, count?: number): number[];
29
+ /**
30
+ * Time ticks on a step the CALLER chose, rather than one this module
31
+ * picked for a target count: the first tick is the start of the
32
+ * `amount`-wide `unit` at or after `min`, and each following one is a
33
+ * whole step later. This is the boundary rule {@link axisTicksTime}
34
+ * uses once it has decided a step, exposed on its own because a
35
+ * consumer whose document declares its own interval — Mermaid's Gantt
36
+ * `tickInterval 1week` is the one in this repo — needs the same
37
+ * boundaries without the ladder choosing for it.
38
+ *
39
+ * A multi-unit step lands on a multiple of its own amount, so months
40
+ * fall on 1, 4, 7, 10 and years on 1900, 1950, 2000. A `week` step
41
+ * starts on Monday unless `weekStart` names another ISO weekday, which
42
+ * is what a document that says "weeks begin on Sunday" means.
43
+ *
44
+ * @param {number} min - Domain minimum, epoch milliseconds
45
+ * @param {number} max - Domain maximum, epoch milliseconds
46
+ * @param {string} unit - a `DATE_UNITS` member (`'day'`, `'week'`, …)
47
+ * @param {number} [amount] - whole steps per tick, at least 1
48
+ * @param {{ weekStart?: number, limit?: number }} [options] -
49
+ * `weekStart` is an ISO weekday, 1 (Monday) to 7 (Sunday);
50
+ * `limit` caps the tick count (default 1000)
51
+ * @returns {number[]} tick values in epoch milliseconds
52
+ * @example
53
+ * timeTicksEvery(Date.UTC(2024, 0, 3), Date.UTC(2024, 0, 20), 'week', 1);
54
+ * // the Mondays of 2024-01-08 and 2024-01-15
55
+ */
56
+ export declare function timeTicksEvery(min: number, max: number, unit: string, amount?: number, options?: {
57
+ weekStart?: number;
58
+ limit?: number;
59
+ }): number[];
@@ -222,6 +222,19 @@ export declare function remap(v: number, smin: number, smax: number, dmin: numbe
222
222
  * @returns {number}
223
223
  */
224
224
  export declare function niceStep(span: number, count?: number): number;
225
+ /**
226
+ * Ticks on the {@link niceStep} ladder inside `[min, max]`: every whole
227
+ * multiple of the step the domain contains, rounded to the step's own
228
+ * precision so an axis never shows `0.30000000000000004`. A degenerate
229
+ * domain yields a single tick, a reversed one reads the same as its
230
+ * forward twin, and a non-finite bound yields nothing.
231
+ *
232
+ * @param {number} min - Domain minimum
233
+ * @param {number} max - Domain maximum
234
+ * @param {number} [count] - Desired tick count (approximate)
235
+ * @returns {number[]}
236
+ */
237
+ export declare function axisTicksLinear(min: number, max: number, count?: number): number[];
225
238
  /**
226
239
  * Clamp a value into the unit interval `[0, 1]` — the fraction every
227
240
  * unit-space geometry stage emits. `NaN` passes through as `NaN` rather