@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 +18 -9
- package/README.md +4 -1
- package/dist/types/async.d.ts +39 -0
- package/dist/types/random.d.ts +80 -0
- package/dist/types/stats.d.ts +73 -0
- package/docs/GEO.md +2 -2
- package/docs/SERIES.md +7 -7
- package/package.json +13 -1
- package/src/async.js +78 -0
- package/src/random.js +125 -0
- package/src/stats.js +133 -0
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 & 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 <!--
|
|
640
|
+
WKT↔GeoJSON converter (`npm run benchmark:geo`, Node <!--fact:geo.node-->v24.19.0<!--/fact-->):
|
|
632
641
|
|
|
633
|
-
<!--
|
|
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
|
-
<!--/
|
|
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 <!--
|
|
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
|
-
<!--
|
|
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
|
-
<!--/
|
|
712
|
+
<!--/fact-->
|
|
704
713
|
|
|
705
|
-
The losses are kept, and named by the same measurement: on the committed run <!--
|
|
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
|
-
(<!--
|
|
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 (<!--
|
|
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 <!--
|
|
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 (<!--
|
|
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: <!--
|
|
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 <!--
|
|
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 <!--
|
|
321
|
-
and against the vocabulary a consumer had instead it is <!--
|
|
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
|
-
<!--
|
|
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
|
-
<!--/
|
|
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. <!--
|
|
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. <!--
|
|
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.
|
|
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
|
+
}
|