@jarenjs/core 0.49.2 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ARCHITECTURE.md CHANGED
@@ -161,6 +161,15 @@ flowchart TB
161
161
 
162
162
  subgraph FunctionModule["Function Utilities"]
163
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"]
164
173
  end
165
174
 
166
175
  subgraph StringModules["String &amp; Scanning"]
@@ -628,9 +637,9 @@ direction is *not* claimed, because WKT whitespace, the `M` measure and number
628
637
  spelling are normalized on the way through.
629
638
 
630
639
  Against [`wellknown`](https://www.npmjs.com/package/wellknown), the established
631
- 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-->):
632
641
 
633
- <!--bm:geo.wktTable-->
642
+ <!--fact:geo.wktTable-->
634
643
  | scenario | Jaren | rival | ratio |
635
644
  |---|---|---|---|
636
645
  | wkt parse (POINT) | 431.1 ns | 1.31 µs (wellknown) | **3.1×** |
@@ -638,7 +647,7 @@ WKT↔GeoJSON converter (`npm run benchmark:geo`, Node <!--bm:geo.node-->v24.19.
638
647
  | wkt stringify (2000-vertex polygon) | 163.40 µs | 229.70 µs (wellknown) | **1.4×** |
639
648
  | wkt validate (POINT) | 242.7 ns | 1.28 µs (wellknown) | **5.3×** |
640
649
  | wkt validate (2000-vertex polygon) | 496.00 µs | 1.90 ms (wellknown) | **3.8×** |
641
- <!--/bm-->
650
+ <!--/fact-->
642
651
 
643
652
  The last two rows are the ones the sink exists for: `wellknown` has no
644
653
  predicate, so validating means parsing and throwing the geometry away. And the
@@ -677,7 +686,7 @@ RFC 7946 removed coordinate-reference-system support and mandates WGS 84, so
677
686
  there is no SRID table and no reprojection: conformance removes the need rather
678
687
  than an omission hiding it.
679
688
 
680
- **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
681
690
  scenario asserts result equivalence before any timing, and the harness refuses
682
691
  to print a table if the engines disagree: distance, area, length and bounding
683
692
  box come out *bit-identical* to Turf, containment agrees on a 400-point sweep,
@@ -685,7 +694,7 @@ and the index returns exactly Flatbush's answer on 200 queries. Ratios are the
685
694
  rival's time over this kernel's, so above 1 means Jaren is faster; the rows
686
695
  below derive from the committed measurement and are refreshed with it.
687
696
 
688
- <!--bm:geo.table-->
697
+ <!--fact:geo.table-->
689
698
  | scenario | Jaren | rival | ratio |
690
699
  |---|---|---|---|
691
700
  | distance (two positions) | 28.3 ns | 111.0 ns (turf) | **3.9×** |
@@ -700,11 +709,11 @@ below derive from the committed measurement and are refreshed with it.
700
709
  | index build (100k boxes) | 12.70 ms | 10.62 ms (flatbush) | 0.8× |
701
710
  | index probe (100k boxes) | 602.3 ns | 668.9 ns (flatbush) | **1.1×** |
702
711
  | linear scan (100k boxes, no index) | 141.50 µs | — | — |
703
- <!--/bm-->
712
+ <!--/fact-->
704
713
 
705
- 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-->.
706
715
  **Point-in-polygon on a large ring runs at about half Turf's speed**
707
- (<!--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
708
717
  matter goes through the exact orientation predicate, where Turf uses naive
709
718
  floating-point arithmetic. That is the trade this module exists to make — it
710
719
  is the difference between a containment test that is right on near-collinear
@@ -713,7 +722,7 @@ predicate on edges that cannot affect the answer, which took this from 0.2×
713
722
  to about 0.5×; the rest is the predicate itself, and the campaign that made
714
723
  geography reachable across the suite never traded it away. The bounding box
715
724
  of the same ring and the index build against Flatbush sit within a few tenths
716
- 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
717
726
  between runs and Node versions; they are published as measured rather than
718
727
  rounded to a win.
719
728
 
package/README.md CHANGED
@@ -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 |
@@ -98,7 +101,7 @@ Four design decisions carry the module:
98
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).
99
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.
100
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).
101
- - **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-->.
102
105
 
103
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).
104
107
 
@@ -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[]>;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @file The suite's one seeded generator, and the three draws built on
3
+ * it. Every seeded corpus, oracle and property test in this repository
4
+ * needs the same thing: a stream of numbers that is identical on every
5
+ * host for a given seed, so that a benchmark can state a delta and a
6
+ * failing property test can be replayed. Before this file that stream
7
+ * was written ten times; a generator that exists once is one whose
8
+ * sequence can be pinned once.
9
+ *
10
+ * The algorithm is mulberry32 — a 32-bit state, one multiply-xorshift
11
+ * round per draw, a period of 2^32. It is named by its algorithm rather
12
+ * than by its role because the SEQUENCE is the contract: a corpus
13
+ * generated from seed 20260825 must regenerate byte-for-byte, and a
14
+ * "better" generator under the same name would silently change every
15
+ * fixture that trusts it. A second algorithm gets a second name.
16
+ *
17
+ * Nothing here is cryptographic, and nothing here reads `Math.random`.
18
+ */
19
+ /**
20
+ * A seeded generator: uniform in `[0, 1)`, identical on every host for
21
+ * the same seed.
22
+ *
23
+ * The seed is taken as an unsigned 32-bit integer (`seed >>> 0`, the
24
+ * ToUint32 conversion): `1.5` seeds as `1`, `-1` as `4294967295`,
25
+ * `2^32 + 5` as `5`, and `NaN` as `0`. Two seeds that agree modulo 2^32
26
+ * are one stream — say so wherever a seed is published.
27
+ * @param {number} seed
28
+ * @returns {() => number} the stream; each call is the next draw
29
+ */
30
+ export declare function mulberry32(seed: number): () => number;
31
+ /**
32
+ * One integer draw over the half-open range `[min, max)`: `min +
33
+ * floor(random() * (max - min))`.
34
+ *
35
+ * This is the floor draw and deliberately so. Every committed corpus in
36
+ * the suite was generated with exactly this arithmetic, and a generator
37
+ * whose integer draw changed would regenerate every one of them
38
+ * differently. It is uniform up to a bias bounded by `(max - min) /
39
+ * 2^32` — under one part in a million for a span of four thousand, and
40
+ * far below anything a benchmark row can resolve. A draw that rejects
41
+ * to remove even that bias would be a different function under a
42
+ * different name, not a change to this one.
43
+ * @param {() => number} random - the stream, from {@link mulberry32}
44
+ * @param {number} min - inclusive integer lower bound
45
+ * @param {number} max - exclusive integer upper bound; must exceed `min`
46
+ * @returns {number} an integer in `[min, max)`
47
+ * @throws {RangeError} when a bound is not an integer or `max <= min`
48
+ */
49
+ export declare function randomInt(random: () => number, min: number, max: number): number;
50
+ /**
51
+ * Fisher–Yates, in place, from the given stream: for `i` from the last
52
+ * index down to 1, swap `i` with a uniform `j` in `[0, i]`. Returns the
53
+ * same array. An empty or one-element list draws nothing.
54
+ *
55
+ * The descending form is the one the seeded corpora were generated
56
+ * with; the ascending form is a different permutation of the same
57
+ * stream and must not be substituted.
58
+ * @template T
59
+ * @param {() => number} random - the stream, from {@link mulberry32}
60
+ * @param {T[]} list - reordered in place
61
+ * @returns {T[]} `list`
62
+ */
63
+ export declare function shuffle<T>(random: () => number, list: T[]): T[];
64
+ /**
65
+ * `k` distinct indices from `[0, n)`, uniformly, as a partial forward
66
+ * Fisher–Yates over a fresh index pool: the first `k` positions of a
67
+ * shuffle, without paying for the rest. Asked for more than `n` it
68
+ * answers `n` — a draw cannot invent a member the population does not
69
+ * hold; asked for nothing, or from nothing, it answers `[]`.
70
+ *
71
+ * The stream is the caller's, so one stream can serve many draws
72
+ * (a policy that draws per question from one seeded closure stays
73
+ * reproducible across the whole run).
74
+ * @param {() => number} random - the stream, from {@link mulberry32}
75
+ * @param {number} n - the population size (a non-negative integer)
76
+ * @param {number} k - how many to draw (a non-negative integer)
77
+ * @returns {number[]} `min(k, n)` distinct indices, in draw order
78
+ * @throws {RangeError} when `n` or `k` is not a non-negative integer
79
+ */
80
+ export declare function drawDistinct(random: () => number, n: number, k: number): number[];
@@ -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/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
 
package/docs/SERIES.md CHANGED
@@ -311,16 +311,16 @@ downsampleSeries(readings, { target: 2000 }); // → { points, sourceCount, re
311
311
 
312
312
  ## What it costs
313
313
 
314
- Measured by `benchmark/series.js` over <!--bm:series.corpus-->100,000 samples at 1-second spacing, Node v24.19.0<!--/bm-->,
314
+ Measured by `benchmark/series.js` over <!--fact:series.corpus-->100,000 samples at 1-second spacing, Node v24.19.0<!--/fact-->,
315
315
  which gates every timing on equivalence first: no number below is printed
316
316
  unless the kernel answered the identical rows the references did.
317
317
 
318
318
  The kernel is not the ceiling and does not claim to be. A one-pass loop
319
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-->.
320
+ returns a bare pair. Against those loops the kernel costs <!--fact:series.kernelVsCeiling-->3.4× the one-pass bucket loop and 6.2× the one-pass ring sum<!--/fact-->,
321
+ and against the vocabulary a consumer had instead it is <!--fact:series.kernelVsQuery-->73.6× faster than the generic query bucket and 81.2× faster than the labelled count window<!--/fact-->.
322
322
 
323
- <!--bm:series.kernelTable-->
323
+ <!--fact:series.kernelTable-->
324
324
  | operation | median | rows | against | what that is | ratio |
325
325
  |---|---:|---:|---:|---|---:|
326
326
  | `resampleSeries`, 60 s buckets | 1.5 ms | 1,667 | 0.45 ms | one-pass loop | 3.4× |
@@ -328,11 +328,11 @@ and against the vocabulary a consumer had instead it is <!--bm:series.kernelVsQu
328
328
  | `rollingSeries`, 60 s window | 7.2 ms | 100,000 | 1.2 ms | one-pass ring sum | 6.2× |
329
329
  | `asOfJoin`, one left row per 100 | 1.7 ms | 1,000 | 2.2 ms | one index read per row | 0.8× |
330
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-->
331
+ <!--/fact-->
332
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-->
333
+ A row that loses stays in, and the two shapes of the same join are published side by side rather than the flattering one alone. <!--fact:series.asofShape-->The as-of join costs 14.7× a handful of index reads, and beats them by 1.3× once there is one left row per hundred right ones. The reason is the shape rather than the engine: a b-tree pays per probe, and a sorted walk pays for the whole right side whether it was asked one question or a thousand.<!--/fact-->
334
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-->
335
+ And the seam has a price that this corpus cannot charge it. <!--fact:series.zoneCost-->Walking every boundary through an injected zone provider costs 1.0× the integer ladder over an identical answer — near parity because it is near nothing, since the benchmark corpus spans 28 hours and holds two daily boundaries. What the suite gates instead is that the provider is consulted per boundary rather than per sample.<!--/fact-->
336
336
 
337
337
  ## Not here
338
338
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/core",
3
3
  "private": false,
4
- "version": "0.49.2",
4
+ "version": "0.56.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./dist/types/index.d.ts",
@@ -40,6 +40,10 @@
40
40
  "types": "./dist/types/array.d.ts",
41
41
  "default": "./src/array.js"
42
42
  },
43
+ "./async": {
44
+ "types": "./dist/types/async.d.ts",
45
+ "default": "./src/async.js"
46
+ },
43
47
  "./bigint": {
44
48
  "types": "./dist/types/bigint.d.ts",
45
49
  "default": "./src/bigint.js"
@@ -104,6 +108,10 @@
104
108
  "types": "./dist/types/object.d.ts",
105
109
  "default": "./src/object.js"
106
110
  },
111
+ "./random": {
112
+ "types": "./dist/types/random.d.ts",
113
+ "default": "./src/random.js"
114
+ },
107
115
  "./scan": {
108
116
  "types": "./dist/types/scan.d.ts",
109
117
  "default": "./src/scan.js"
@@ -120,6 +128,10 @@
120
128
  "types": "./dist/types/series/*.d.ts",
121
129
  "default": "./src/series/*.js"
122
130
  },
131
+ "./stats": {
132
+ "types": "./dist/types/stats.d.ts",
133
+ "default": "./src/stats.js"
134
+ },
123
135
  "./string": {
124
136
  "types": "./dist/types/string.d.ts",
125
137
  "default": "./src/string.js"
package/src/async.js ADDED
@@ -0,0 +1,78 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The bounded ordered asynchronous map: run a worker over a list
4
+ * with never more than `limit` calls in flight, and answer the results
5
+ * in the list's order. Before this file the same twelve lines lived in
6
+ * the AI package's program runner and in the benchmark harness, and a
7
+ * downstream consumer had written them a third time; a pool that exists
8
+ * once is one whose edge behavior can be pinned once.
9
+ *
10
+ * The contract, in full:
11
+ *
12
+ * - results are in INPUT order, whatever order the workers finish in;
13
+ * - never more than `limit` workers are in flight; `limit` must be a
14
+ * number of at least 1 (`Infinity` is allowed and means unbounded) —
15
+ * anything else is a `TypeError`, never a silent clamp, because a
16
+ * limit of 0 is a bug in the caller and "sequential" is spelled 1;
17
+ * - a worker rejection stops dispatch: no item starts after it, the
18
+ * workers already in flight are awaited, and only then does the map
19
+ * reject with that first rejection. A worker that throws
20
+ * synchronously is a rejection;
21
+ * - an abort does the same, rejecting with the signal's reason; a signal
22
+ * that is already aborted rejects before any worker runs;
23
+ * - so when the returned promise settles, NO worker is still running —
24
+ * the caller can close whatever the workers were using.
25
+ *
26
+ * The map does not retry, rate-limit, delay per origin or know anything
27
+ * about what the worker does; those are the caller's policies around it.
28
+ */
29
+
30
+ /**
31
+ * @template T, R
32
+ * @param {readonly T[]} items
33
+ * @param {number} limit - workers in flight at once; a number >= 1, `Infinity` for unbounded
34
+ * @param {(item: T, index: number) => Promise<R> | R} worker
35
+ * @param {{ signal?: AbortSignal }} [options]
36
+ * @returns {Promise<R[]>} the results, in input order
37
+ * @throws {TypeError} (as a rejection) when `limit` is not a number >= 1
38
+ */
39
+ export async function mapConcurrent(items, limit, worker, options = {}) {
40
+ if (typeof limit !== 'number' || !(limit >= 1))
41
+ throw new TypeError(`mapConcurrent needs a limit of at least 1, got ${String(limit)}`);
42
+ const { signal } = options;
43
+ if (signal?.aborted) throw signal.reason;
44
+ const count = items.length;
45
+ /** @type {R[]} */
46
+ const results = new Array(count);
47
+ if (count === 0) return results;
48
+
49
+ let next = 0;
50
+ // the first failure or abort, kept as a one-element list: the lanes
51
+ // stop dispatching the moment it is set and drain what they hold
52
+ /** @type {unknown[]} */
53
+ const stop = [];
54
+ /** @type {(() => void) | undefined} */
55
+ let onAbort;
56
+ if (signal !== undefined) {
57
+ onAbort = () => { if (stop.length === 0) stop.push(signal.reason); };
58
+ signal.addEventListener('abort', onAbort, { once: true });
59
+ }
60
+
61
+ const lane = async () => {
62
+ while (stop.length === 0) {
63
+ const index = next++;
64
+ if (index >= count) return;
65
+ try {
66
+ results[index] = await worker(items[index], index);
67
+ }
68
+ catch (error) {
69
+ if (stop.length === 0) stop.push(error);
70
+ }
71
+ }
72
+ };
73
+ const lanes = Array.from({ length: Math.min(limit, count) }, lane);
74
+ await Promise.all(lanes);
75
+ if (onAbort !== undefined) signal?.removeEventListener('abort', onAbort);
76
+ if (stop.length > 0) throw stop[0];
77
+ return results;
78
+ }
package/src/random.js ADDED
@@ -0,0 +1,125 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The suite's one seeded generator, and the three draws built on
4
+ * it. Every seeded corpus, oracle and property test in this repository
5
+ * needs the same thing: a stream of numbers that is identical on every
6
+ * host for a given seed, so that a benchmark can state a delta and a
7
+ * failing property test can be replayed. Before this file that stream
8
+ * was written ten times; a generator that exists once is one whose
9
+ * sequence can be pinned once.
10
+ *
11
+ * The algorithm is mulberry32 — a 32-bit state, one multiply-xorshift
12
+ * round per draw, a period of 2^32. It is named by its algorithm rather
13
+ * than by its role because the SEQUENCE is the contract: a corpus
14
+ * generated from seed 20260825 must regenerate byte-for-byte, and a
15
+ * "better" generator under the same name would silently change every
16
+ * fixture that trusts it. A second algorithm gets a second name.
17
+ *
18
+ * Nothing here is cryptographic, and nothing here reads `Math.random`.
19
+ */
20
+
21
+ /**
22
+ * A seeded generator: uniform in `[0, 1)`, identical on every host for
23
+ * the same seed.
24
+ *
25
+ * The seed is taken as an unsigned 32-bit integer (`seed >>> 0`, the
26
+ * ToUint32 conversion): `1.5` seeds as `1`, `-1` as `4294967295`,
27
+ * `2^32 + 5` as `5`, and `NaN` as `0`. Two seeds that agree modulo 2^32
28
+ * are one stream — say so wherever a seed is published.
29
+ * @param {number} seed
30
+ * @returns {() => number} the stream; each call is the next draw
31
+ */
32
+ export function mulberry32(seed) {
33
+ let a = seed >>> 0;
34
+ return function random() {
35
+ a = (a + 0x6D2B79F5) >>> 0;
36
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
37
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
38
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
39
+ };
40
+ }
41
+
42
+ /**
43
+ * @param {number} value
44
+ * @param {string} what
45
+ */
46
+ function assertInteger(value, what) {
47
+ if (!Number.isInteger(value))
48
+ throw new RangeError(`${what} must be an integer, got ${String(value)}`);
49
+ }
50
+
51
+ /**
52
+ * One integer draw over the half-open range `[min, max)`: `min +
53
+ * floor(random() * (max - min))`.
54
+ *
55
+ * This is the floor draw and deliberately so. Every committed corpus in
56
+ * the suite was generated with exactly this arithmetic, and a generator
57
+ * whose integer draw changed would regenerate every one of them
58
+ * differently. It is uniform up to a bias bounded by `(max - min) /
59
+ * 2^32` — under one part in a million for a span of four thousand, and
60
+ * far below anything a benchmark row can resolve. A draw that rejects
61
+ * to remove even that bias would be a different function under a
62
+ * different name, not a change to this one.
63
+ * @param {() => number} random - the stream, from {@link mulberry32}
64
+ * @param {number} min - inclusive integer lower bound
65
+ * @param {number} max - exclusive integer upper bound; must exceed `min`
66
+ * @returns {number} an integer in `[min, max)`
67
+ * @throws {RangeError} when a bound is not an integer or `max <= min`
68
+ */
69
+ export function randomInt(random, min, max) {
70
+ assertInteger(min, 'min');
71
+ assertInteger(max, 'max');
72
+ if (max <= min) throw new RangeError(`randomInt needs max > min, got [${min}, ${max})`);
73
+ return min + Math.floor(random() * (max - min));
74
+ }
75
+
76
+ /**
77
+ * Fisher–Yates, in place, from the given stream: for `i` from the last
78
+ * index down to 1, swap `i` with a uniform `j` in `[0, i]`. Returns the
79
+ * same array. An empty or one-element list draws nothing.
80
+ *
81
+ * The descending form is the one the seeded corpora were generated
82
+ * with; the ascending form is a different permutation of the same
83
+ * stream and must not be substituted.
84
+ * @template T
85
+ * @param {() => number} random - the stream, from {@link mulberry32}
86
+ * @param {T[]} list - reordered in place
87
+ * @returns {T[]} `list`
88
+ */
89
+ export function shuffle(random, list) {
90
+ for (let i = list.length - 1; i > 0; i--) {
91
+ const j = Math.floor(random() * (i + 1));
92
+ [list[i], list[j]] = [list[j], list[i]];
93
+ }
94
+ return list;
95
+ }
96
+
97
+ /**
98
+ * `k` distinct indices from `[0, n)`, uniformly, as a partial forward
99
+ * Fisher–Yates over a fresh index pool: the first `k` positions of a
100
+ * shuffle, without paying for the rest. Asked for more than `n` it
101
+ * answers `n` — a draw cannot invent a member the population does not
102
+ * hold; asked for nothing, or from nothing, it answers `[]`.
103
+ *
104
+ * The stream is the caller's, so one stream can serve many draws
105
+ * (a policy that draws per question from one seeded closure stays
106
+ * reproducible across the whole run).
107
+ * @param {() => number} random - the stream, from {@link mulberry32}
108
+ * @param {number} n - the population size (a non-negative integer)
109
+ * @param {number} k - how many to draw (a non-negative integer)
110
+ * @returns {number[]} `min(k, n)` distinct indices, in draw order
111
+ * @throws {RangeError} when `n` or `k` is not a non-negative integer
112
+ */
113
+ export function drawDistinct(random, n, k) {
114
+ assertInteger(n, 'n');
115
+ assertInteger(k, 'k');
116
+ if (n < 0 || k < 0) throw new RangeError(`drawDistinct needs n >= 0 and k >= 0, got n=${n} k=${k}`);
117
+ const count = Math.min(k, n);
118
+ if (count === 0) return [];
119
+ const pool = Array.from({ length: n }, (_, i) => i);
120
+ for (let i = 0; i < count; i++) {
121
+ const j = i + Math.floor(random() * (pool.length - i));
122
+ [pool[i], pool[j]] = [pool[j], pool[i]];
123
+ }
124
+ return pool.slice(0, count);
125
+ }
package/src/stats.js ADDED
@@ -0,0 +1,133 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Descriptive statistics over a sample of numbers: the mean, the
4
+ * sample variance and its root, the median, and a quantile that will
5
+ * not answer until told which quantile it is being asked for.
6
+ *
7
+ * Before this file the suite computed these in two places with two
8
+ * quantile rules — linear interpolation in the query layer's statistics
9
+ * pack, nearest rank in the benchmark harness — and each was the right
10
+ * rule for its consumer: an interpolated percentile is what an analyst
11
+ * expects of `$percentile`, while a benchmark row of eleven readings
12
+ * should not publish a latency nobody measured. Both rules stay; the
13
+ * definitions move here so that there is one of each, and `quantile`
14
+ * makes the caller name its method, because "the 95th percentile" of a
15
+ * small sample is a different number under every one of the seven
16
+ * common definitions and a default would decide silently.
17
+ *
18
+ * Every function answers `undefined` for a sample it cannot summarize —
19
+ * an empty one, or fewer than two values for a variance — rather than
20
+ * `NaN` or `0`: a number that was not measured must not format as one.
21
+ * The caller's array is never reordered; a quantile sorts a copy.
22
+ * Values are numbers by contract and are not checked one by one.
23
+ */
24
+
25
+ /**
26
+ * The arithmetic mean.
27
+ * @param {readonly number[]} values
28
+ * @returns {number | undefined} `undefined` for an empty sample
29
+ */
30
+ export function mean(values) {
31
+ if (values.length === 0) return undefined;
32
+ let sum = 0;
33
+ for (const value of values) sum += value;
34
+ return sum / values.length;
35
+ }
36
+
37
+ /**
38
+ * The SAMPLE variance, with Bessel's correction (`n − 1`): the sample is
39
+ * taken as drawn from a population it did not enumerate, which is what a
40
+ * benchmark's rounds and a query's rows both are.
41
+ * @param {readonly number[]} values
42
+ * @returns {number | undefined} `undefined` for fewer than two values
43
+ */
44
+ export function variance(values) {
45
+ if (values.length < 2) return undefined;
46
+ const m = /** @type {number} */ (mean(values));
47
+ let sum = 0;
48
+ for (const value of values) sum += (value - m) * (value - m);
49
+ return sum / (values.length - 1);
50
+ }
51
+
52
+ /**
53
+ * The sample standard deviation — the root of {@link variance}.
54
+ * @param {readonly number[]} values
55
+ * @returns {number | undefined} `undefined` where the variance is
56
+ */
57
+ export function stddev(values) {
58
+ const v = variance(values);
59
+ return v === undefined ? undefined : Math.sqrt(v);
60
+ }
61
+
62
+ /**
63
+ * @param {readonly number[]} values
64
+ * @returns {number[]} an ascending copy
65
+ */
66
+ function ascending(values) {
67
+ return [...values].sort((a, b) => a - b);
68
+ }
69
+
70
+ /**
71
+ * The median: the middle value, or the mean of the two middle values
72
+ * when the sample has an even count.
73
+ *
74
+ * This is NOT `quantile(values, 0.5, …)` under either method, and on an
75
+ * even count the three disagree: for `[1, 2, 3, 4]` the median is `2.5`,
76
+ * the nearest-rank p50 is `2`, and the linear p50 is `2.5` only because
77
+ * that sample happens to be evenly spaced. A consumer publishing a "p50"
78
+ * beside a "p95" wants `quantile` with its method named; a consumer
79
+ * asking for the median wants this.
80
+ * @param {readonly number[]} values
81
+ * @returns {number | undefined} `undefined` for an empty sample
82
+ */
83
+ export function median(values) {
84
+ if (values.length === 0) return undefined;
85
+ const sorted = ascending(values);
86
+ const mid = sorted.length >> 1;
87
+ return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
88
+ }
89
+
90
+ /**
91
+ * The quantile methods this module knows.
92
+ *
93
+ * - `'nearest-rank'`: the value at rank `max(1, ceil(p · n))` of the
94
+ * ascending sample — always a value that was measured, never one
95
+ * invented between two. The benchmark rule.
96
+ * - `'linear'`: rank `(n − 1) · p`, interpolated linearly between the
97
+ * values at `floor` and `ceil` of it (Hyndman–Fan type 7, the default
98
+ * of R, NumPy and spreadsheets). The analyst's rule.
99
+ * @typedef {'nearest-rank' | 'linear'} QuantileMethod
100
+ */
101
+
102
+ /** @type {readonly QuantileMethod[]} */
103
+ const METHODS = ['nearest-rank', 'linear'];
104
+
105
+ /**
106
+ * The `p`-quantile of a sample, `p` on `[0, 1]`, under a NAMED method.
107
+ *
108
+ * The method is required, not defaulted: on a small sample the common
109
+ * definitions disagree by whole values, and a caller who did not say
110
+ * which one it wanted has published a number it cannot explain.
111
+ * @param {readonly number[]} values
112
+ * @param {number} p - the probability, `0` (the minimum) to `1` (the maximum)
113
+ * @param {{ method: QuantileMethod }} options
114
+ * @returns {number | undefined} `undefined` for an empty sample
115
+ * @throws {TypeError} when `method` is absent or not one of {@link QuantileMethod}
116
+ * @throws {RangeError} when `p` is not a number in `[0, 1]`
117
+ */
118
+ export function quantile(values, p, options) {
119
+ const method = options?.method;
120
+ if (!METHODS.includes(/** @type {any} */ (method)))
121
+ throw new TypeError(`quantile needs { method: 'nearest-rank' | 'linear' }, got ${JSON.stringify(method)}`);
122
+ if (typeof p !== 'number' || !(p >= 0 && p <= 1))
123
+ throw new RangeError(`quantile needs p in [0, 1], got ${String(p)}`);
124
+ const n = values.length;
125
+ if (n === 0) return undefined;
126
+ const sorted = ascending(values);
127
+ if (method === 'nearest-rank') return sorted[Math.max(1, Math.ceil(p * n)) - 1];
128
+ const rank = (n - 1) * p;
129
+ const lo = Math.floor(rank);
130
+ const hi = Math.ceil(rank);
131
+ if (lo === hi) return sorted[lo];
132
+ return sorted[lo] + (sorted[hi] - sorted[lo]) * (rank - lo);
133
+ }