@jarenjs/core 0.43.1 → 0.46.4

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
@@ -126,6 +126,10 @@ flowchart TB
126
126
  GeoMerc["mercator.js + simplify.js<br/>Projection out, simplification"]
127
127
  end
128
128
 
129
+ subgraph VectorModule["Vectors"]
130
+ VectorIndex["vector/index.js<br/>Similarity kernels, normalization, packed form"]
131
+ end
132
+
129
133
  subgraph MathModules["Mathematics"]
130
134
  MathIndex["math/index.js"]
131
135
  Int32Math["int32.js<br/>Fixed-point math"]
@@ -205,6 +209,7 @@ flowchart TB
205
209
  style MathModules fill:#ffccbc
206
210
  style DateModule fill:#fff9c4
207
211
  style GeoModule fill:#b2dfdb
212
+ style VectorModule fill:#b2dfdb
208
213
  ```
209
214
 
210
215
  ---
@@ -573,15 +578,17 @@ direction is *not* claimed, because WKT whitespace, the `M` measure and number
573
578
  spelling are normalized on the way through.
574
579
 
575
580
  Against [`wellknown`](https://www.npmjs.com/package/wellknown), the established
576
- WKT↔GeoJSON converter (`npm run benchmark:geo`, Node v24.19.0):
581
+ WKT↔GeoJSON converter (`npm run benchmark:geo`, Node <!--bm:geo.node-->v24.19.0<!--/bm-->):
577
582
 
578
- | scenario | Jaren | wellknown | ratio |
583
+ <!--bm:geo.wktTable-->
584
+ | scenario | Jaren | rival | ratio |
579
585
  |---|---|---|---|
580
- | parse a `POINT` | 439 ns | 1.34 µs | **3.1×** |
581
- | parse a 2000-vertex `POLYGON` | 820 µs | 2.20 ms | **2.7×** |
582
- | write that polygon | 160 µs | 213 µs | **1.3×** |
583
- | validate a `POINT` | 241 ns | 1.29 µs | **5.4×** |
584
- | validate that polygon | 493 µs | 1.94 ms | **3.9×** |
586
+ | wkt parse (POINT) | 431.1 ns | 1.31 µs (wellknown) | **3.1×** |
587
+ | wkt parse (2000-vertex polygon) | 843.80 µs | 1.98 ms (wellknown) | **2.3×** |
588
+ | wkt stringify (2000-vertex polygon) | 163.40 µs | 229.70 µs (wellknown) | **1.4×** |
589
+ | wkt validate (POINT) | 242.7 ns | 1.28 µs (wellknown) | **5.3×** |
590
+ | wkt validate (2000-vertex polygon) | 496.00 µs | 1.90 ms (wellknown) | **3.8×** |
591
+ <!--/bm-->
585
592
 
586
593
  The last two rows are the ones the sink exists for: `wellknown` has no
587
594
  predicate, so validating means parsing and throwing the geometry away. And the
@@ -620,37 +627,48 @@ RFC 7946 removed coordinate-reference-system support and mandates WGS 84, so
620
627
  there is no SRID table and no reprojection: conformance removes the need rather
621
628
  than an omission hiding it.
622
629
 
623
- **Measured against the field** (`npm run benchmark:geo`, Node v22.22.2). Every
630
+ **Measured against the field** (`npm run benchmark:geo`, Node <!--bm:geo.node-->v24.19.0<!--/bm-->). Every
624
631
  scenario asserts result equivalence before any timing, and the harness refuses
625
632
  to print a table if the engines disagree: distance, area, length and bounding
626
633
  box come out *bit-identical* to Turf, containment agrees on a 400-point sweep,
627
634
  and the index returns exactly Flatbush's answer on 200 queries. Ratios are the
628
- rival's time over this kernel's, so above 1 means Jaren is faster.
635
+ rival's time over this kernel's, so above 1 means Jaren is faster; the rows
636
+ below derive from the committed measurement and are refreshed with it.
629
637
 
638
+ <!--bm:geo.table-->
630
639
  | scenario | Jaren | rival | ratio |
631
640
  |---|---|---|---|
632
- | distance, two positions | 26.2 ns | 128.5 ns (turf) | **4.9×** |
633
- | distance vs an ellipsoidal library | 52.3 ns | 648.4 ns (geolib) | **12.4×** |
634
- | line length, 500 positions | 20.5 µs | 63.6 µs (turf) | **3.1×** |
635
- | point in polygon, 12 vertices | 228 ns | 327 ns (turf) | **1.4×** |
636
- | bounding box, 2000 vertices | 15.3 µs | 18.1 µs (turf) | **1.2×** |
637
- | polygon area, 2000 vertices | 18.8 µs | 20.4 µs (turf) | **1.1×** |
638
- | centroid, 2000 vertices | 18.4 µs | 19.1 µs (turf) | 1. |
639
- | index build, 100k boxes | 12.2 ms | 12.4 ms (flatbush) | 1.0× |
640
- | index probe, 100k boxes | 529 ns | 529 ns (flatbush) | 1. |
641
- | point in polygon, 2000 vertices | 8.2 µs | 3.7 µs (turf) | **0.5×** |
642
-
643
- One row is a loss, and it is kept on purpose. **Point-in-polygon on a large
644
- ring runs at about half Turf's speed** because every edge that could matter
645
- goes through the exact orientation predicate, where Turf uses naive
641
+ | distance (two positions) | 28.3 ns | 111.0 ns (turf) | **3.9×** |
642
+ | distance (vs geolib, ellipsoidal) | 50.7 ns | 609.1 ns (geolib) | **12.0×** |
643
+ | distance (equirectangular screen) | 26.4 ns | | |
644
+ | point in polygon (12-vertex) | 72.9 ns | 226.4 ns (turf) | **3.1×** |
645
+ | point in polygon (2000-vertex) | 8.23 µs | 3.71 µs (turf) | 0. |
646
+ | polygon area (2000-vertex) | 17.65 µs | 18.45 µs (turf) | **1.0×** |
647
+ | line length (500 positions) | 26.65 µs | 63.47 µs (turf) | **2.4×** |
648
+ | bounding box (2000-vertex) | 21.08 µs | 18.65 µs (turf) | 0.9× |
649
+ | centroid (2000-vertex) | 16.70 µs | 23.18 µs (turf) | **1.4×** |
650
+ | index build (100k boxes) | 12.70 ms | 10.62 ms (flatbush) | 0. |
651
+ | index probe (100k boxes) | 602.3 ns | 668.9 ns (flatbush) | **1.1×** |
652
+ | linear scan (100k boxes, no index) | 141.50 µs | | |
653
+ <!--/bm-->
654
+
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-->.
656
+ **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
658
+ matter goes through the exact orientation predicate, where Turf uses naive
646
659
  floating-point arithmetic. That is the trade this module exists to make — it
647
660
  is the difference between a containment test that is right on near-collinear
648
661
  input and one that is merely fast. Cheap straddle/span tests already skip the
649
662
  predicate on edges that cannot affect the answer, which took this from 0.2×
650
- to 0.5×; the rest is the predicate itself.
663
+ to about 0.5×; the rest is the predicate itself, and the campaign that made
664
+ geography reachable across the suite never traded it away. The bounding box
665
+ 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
667
+ between runs and Node versions; they are published as measured rather than
668
+ rounded to a win.
651
669
 
652
- Two former losses closed, and both closures carry a lesson about profiling
653
- before fixing:
670
+ Two former losses were closed by profiling before fixing, and both closures
671
+ carry the lesson:
654
672
 
655
673
  - **Index build** was 2.6× behind Flatbush, and the assumed cause — the leaf
656
674
  sort permuting the four-wide bounds rows on every swap — turned out to be
@@ -660,7 +678,8 @@ before fixing:
660
678
  (identical values, verified exhaustively at the corners and over 200k
661
679
  pseudo-random points), the sort orders a `Uint32Array` permutation with an
662
680
  insertion-sort cutoff, and the bounds are written once, already in leaf
663
- order. Build and probe are both level with Flatbush now.
681
+ order. Whether the row is level or a few tenths behind on a given run is
682
+ what the derived table above says.
664
683
  - **Centroid** walked the same `eachPosition` as `bboxOf` yet lost where
665
684
  `bboxOf` won. The difference was the callback body: accumulating doubles
666
685
  into *closure variables* writes a boxed heap number per `+=` — two per
@@ -674,9 +693,34 @@ import { orient2d, haversineDistance, ringWinding, geohashEncode } from '@jarenj
674
693
  orient2d(0, 0, 1, 0, 0, 1); // > 0 — counter-clockwise, exactly
675
694
  haversineDistance(4.9041, 52.3676, 2.3522, 48.8566); // 429_862 m
676
695
  ringWinding([[0,0],[1,0],[1,1],[0,1],[0,0]]); // 1 — RFC 7946 exterior ring
677
- geohashEncode(4.9041, 52.3676, 5); // 'u173z' — a string, so $starts-with is proximity
696
+ geohashEncode(4.9041, 52.3676, 5); // 'u173z' — a string: a prefix buckets, geohashNeighbours is proximity
678
697
  ```
679
698
 
699
+ ### 5c. Vector Module (`vector/`)
700
+
701
+ The suite's one home for n-dimensional vector arithmetic — what an embedding
702
+ is compared, normalized and stored with. As with geo, **there is no vector
703
+ type**: a vector is a plain array of finite numbers (`number[]` out of JSON, a
704
+ `Float32Array` out of a packed column), so it survives every JSON boundary
705
+ it travels through. One file, seven functions, three rules every consumer
706
+ relies on: similarity is *higher-is-better* in every metric; a malformed
707
+ comparison (mismatched lengths, empty, null, non-finite) *scores 0 and never
708
+ throws*; and the constructors (`packVector`, `l2Normalize`) *refuse with
709
+ null* rather than truncate, pad or zero-fill.
710
+
711
+ | Function | Owns |
712
+ |---|---|
713
+ | `isVector(v, dims?)` | the one shape guard — array or float typed array, all finite, optionally an exact width |
714
+ | `dotProduct`, `cosineSimilarity`, `euclideanSimilarity` | the similarity kernels; cosine is the one the suite ranks by |
715
+ | `l2Normalize` | the unit vector, as a new `Float32Array` — the stored form, so cosine, dot and Euclidean agree in rank |
716
+ | `packVector`, `unpackVector` | the little-endian binary32 column form; an aligned unpack is a view, a misaligned one a copy |
717
+
718
+ The comparison kernels do not re-check components — that is the gate's job,
719
+ once, at the boundary — and they keep their loops tight for the sweep that is
720
+ their reason to exist; they guarantee a finite answer. The fixed-arity vector
721
+ classes in `math/` (`Vec2f64`, `Vec3f64`) are 2D/3D geometry with a
722
+ different convention and a different job, and they stay where they are.
723
+
680
724
  ### 6. Text Module (`text/`)
681
725
 
682
726
  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 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 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
 
@@ -23,6 +23,7 @@ None of it depends on JSON Schema: every module can be used standalone in any Ja
23
23
  | `@jarenjs/core/bigint` | bigint helpers (`BigInt_min`, `BigInt_MinMax`, ...) |
24
24
  | `@jarenjs/core/dates` | RFC 3339 / ISO 8601 validation, plus the calendar kernel: integer date arithmetic, compiled formatting, durations |
25
25
  | `@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
+ | `@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`) |
26
27
  | `@jarenjs/core/text` | text validators: emails, hostnames, IPs, URIs/IRIs, UUIDs, punycode, ... |
27
28
  | `@jarenjs/core/math` | int32/float64 math and 2D/3D vector classes; the linear `remap` and unit-interval `clamp01` |
28
29
  | `@jarenjs/core/finance` | zero-dependency finance/trading formulas: TVM, cash flow, amortization, interest, depreciation, bonds, technical indicators, returns/risk |
@@ -95,11 +96,33 @@ Four design decisions carry the module:
95
96
 
96
97
  - **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).
97
98
  - **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.
98
- - **Validity is a separate concern from traversal.** `eachPosition`, `bboxOf`, `centroidOf` and friends measure without judging; `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).
99
- - **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 3.1× faster on a `POINT` and the yes-or-no answer 5. — and wellknown *validates less* while being slower, which is the honest framing; the table and every language difference are in [ARCHITECTURE](./ARCHITECTURE.md).
99
+ - **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-->.
100
101
 
101
102
  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).
102
103
 
104
+ ## Vectors
105
+
106
+ `@jarenjs/core/vector` is the suite's one home for n-dimensional vector arithmetic — the kernels an embedding is compared, normalized and stored with. As with GeoJSON positions, there is no vector type: a vector is a plain array of finite numbers, `number[]` straight out of JSON or a `Float32Array` straight out of a packed column, so it survives a ledger record, a document store and a wire reply unchanged.
107
+
108
+ ```javascript
109
+ import { cosineSimilarity, l2Normalize, packVector, unpackVector, isVector } from '@jarenjs/core/vector';
110
+
111
+ cosineSimilarity([1, 0], [1, 1]); // 0.7071… — higher is better, in [-1, 1]
112
+ cosineSimilarity([1, 0], [1, 0, 0]); // 0 — a malformed pair scores 0, never throws
113
+ const bytes = packVector(l2Normalize([3, 4])); // Uint8Array of 8 bytes: little-endian binary32
114
+ unpackVector(bytes, 2); // Float32Array [0.6, 0.8]
115
+ isVector([0.1, NaN]); // false — the shape guard every consumer shares
116
+ ```
117
+
118
+ Three rules, kept by every function so that no caller has to check them again:
119
+
120
+ - **Higher is better, in every metric.** Cosine answers in [-1, 1], the dot product is unbounded, Euclidean similarity is `1 / (1 + distance)` in (0, 1] — so one descending sort ranks any of them, and nobody remembers which way a metric sorts.
121
+ - **A malformed comparison scores 0 and never throws.** Mismatched lengths, an empty vector, a null or a non-finite component answer 0: one bad vector among ten thousand loses the comparison, it does not kill the sweep, and it never poisons a ranking with `NaN`.
122
+ - **Refuse, never fix.** `packVector` and `l2Normalize` answer `null` for anything `isVector` refuses, the way a bounding box refuses a position it cannot bound; nothing truncates, pads or zero-fills a vector into the shape it was supposed to have.
123
+
124
+ 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
+
103
126
  ## Messages
104
127
 
105
128
  `@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):
@@ -53,9 +53,10 @@ export declare function geohashCellSize(precision: number): number[] | null;
53
53
  * nine-element array. Cells that would fall off the poles are omitted,
54
54
  * so the result may be shorter.
55
55
  *
56
- * This is what makes a prefix search safe near a boundary: two points
56
+ * This is the proximity probe, and a single cell is not one: two points
57
57
  * metres apart can sit in different cells, so a proximity query tests
58
- * the neighbourhood rather than the single cell.
58
+ * the neighbourhood rather than the single cell — a prefix buckets, it
59
+ * does not find neighbours.
59
60
  *
60
61
  * @param {string} hash
61
62
  * @returns {string[]}
@@ -0,0 +1,98 @@
1
+ export type Vector = number[] | Float32Array | Float64Array;
2
+ /**
3
+ * Whether `v` is a vector: a non-empty `Array`, `Float32Array` or
4
+ * `Float64Array` whose every component is a finite number — and, when
5
+ * `dims` is given, exactly that many of them. The one shape guard the
6
+ * suite shares, so a ledger, an operator and a store refuse the same
7
+ * inputs.
8
+ * @param {unknown} v
9
+ * @param {number} [dims] - the exact length required
10
+ * @returns {v is Vector}
11
+ * @example
12
+ * isVector([0.1, 0.2, 0.3]); // true
13
+ * isVector([0.1, 0.2, 0.3], 4); // false — wrong width
14
+ * isVector([0.1, NaN]); // false — not finite
15
+ * isVector(new Uint8Array(4)); // false — bytes are not a vector; unpack them first
16
+ */
17
+ export declare function isVector(v: unknown, dims?: number): v is Vector;
18
+ /**
19
+ * The dot product. Unbounded; equal to the cosine for unit vectors, so
20
+ * a sweep over l2-normalized vectors can take this cheaper form.
21
+ * @param {Vector | null | undefined} a
22
+ * @param {Vector | null | undefined} b
23
+ * @returns {number} the product — or 0 for a malformed pair (mismatched
24
+ * lengths, empty, null, or a result that is not finite)
25
+ */
26
+ export declare function dotProduct(a: Vector | null | undefined, b: Vector | null | undefined): number;
27
+ /**
28
+ * Cosine similarity, in [-1, 1]: 1 for the same direction, 0 for
29
+ * orthogonal, -1 for opposite. The one metric the suite ranks by —
30
+ * scale-free, so vectors from a model that does not normalize compare
31
+ * the same as vectors from one that does.
32
+ * @param {Vector | null | undefined} a
33
+ * @param {Vector | null | undefined} b
34
+ * @returns {number} the similarity — or 0 for a malformed pair, and 0
35
+ * when either vector has no direction (all zeros)
36
+ * @example
37
+ * cosineSimilarity([1, 0], [1, 0]); // 1
38
+ * cosineSimilarity([1, 0], [0, 1]); // 0
39
+ * cosineSimilarity([1, 0], [-2, 0]); // -1
40
+ * cosineSimilarity([1, 0], [1]); // 0 — mismatched, never a throw
41
+ */
42
+ export declare function cosineSimilarity(a: Vector | null | undefined, b: Vector | null | undefined): number;
43
+ /**
44
+ * Euclidean similarity, `1 / (1 + distance)`, in (0, 1]: 1 for equal
45
+ * vectors, falling towards 0 as they move apart. The distance turned
46
+ * into the same higher-is-better direction as the other kernels, so a
47
+ * caller never has to remember which way a metric sorts.
48
+ * @param {Vector | null | undefined} a
49
+ * @param {Vector | null | undefined} b
50
+ * @returns {number} the similarity — or 0 for a malformed pair
51
+ */
52
+ export declare function euclideanSimilarity(a: Vector | null | undefined, b: Vector | null | undefined): number;
53
+ /**
54
+ * The unit vector in `a`'s direction, as a new `Float32Array` — the
55
+ * form the packed column stores, so that cosine, dot and Euclidean
56
+ * then agree in rank. The input is never modified.
57
+ * @param {Vector | null | undefined} a
58
+ * @returns {Float32Array | null} the normalized copy; the zero vector
59
+ * stays zero (it has no direction, and scores 0 against everything);
60
+ * null for anything `isVector` refuses, or a norm that overflows
61
+ */
62
+ export declare function l2Normalize(a: Vector | null | undefined): Float32Array | null;
63
+ /**
64
+ * The packed form: `4·d` bytes of little-endian binary32, the value a
65
+ * database column stores. Components round to their nearest binary32
66
+ * (`Math.fround`); `unpackVector` returns exactly those.
67
+ *
68
+ * A component too large for binary32 has no nearest one — it rounds to
69
+ * an infinity — so such a vector is REFUSED rather than stored as bytes
70
+ * that unpack into something `isVector` would reject. Normalized
71
+ * vectors, which is what a column holds, can never meet it.
72
+ * @param {Vector | null | undefined} a
73
+ * @returns {Uint8Array | null} the bytes — null for anything `isVector`
74
+ * refuses (empty, non-finite) and for a component that overflows
75
+ * binary32, never a partial or zero-filled record
76
+ * @example
77
+ * packVector([1]); // Uint8Array [0, 0, 128, 63]
78
+ * packVector([1, NaN]); // null
79
+ * packVector([1e39]); // null — no finite binary32 is nearest
80
+ */
81
+ export declare function packVector(a: Vector | null | undefined): Uint8Array | null;
82
+ /**
83
+ * The vector packed in `bytes`, read as `dims` little-endian binary32
84
+ * components. The width is the caller's to state — a column declares
85
+ * it — and bytes of any other length are refused rather than partially
86
+ * read.
87
+ *
88
+ * On a little-endian host, 4-byte-aligned bytes come back as a VIEW over
89
+ * the same buffer — no copy, which is what a sweep over ten thousand
90
+ * fetched rows is paid for by; writing into the result writes into the
91
+ * bytes. Misaligned bytes (a pooled `Buffer`, a slice at an odd offset)
92
+ * and big-endian hosts take the copy path and answer the same values.
93
+ * @param {Uint8Array | null | undefined} bytes
94
+ * @param {number} dims - the width the bytes must hold
95
+ * @returns {Float32Array | null} the vector — null when `bytes` is not a
96
+ * `Uint8Array` of exactly `4·dims` bytes
97
+ */
98
+ export declare function unpackVector(bytes: Uint8Array | null | undefined, dims: number): Float32Array | null;
package/docs/GEO.md CHANGED
@@ -22,7 +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 (<!--bm:geo.pip2000-->0.5×<!--/bm--> against
26
+ Turf at 2000 vertices, kept on purpose). Every loss the kernel carries is
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-->.
26
29
 
27
30
  ## Distance — `distance.js`
28
31
 
@@ -79,6 +82,25 @@ plausible box that does not contain its input, and a candidate filter
79
82
  built on such a box loses matching entries with nothing to show for it.
80
83
  `null` is the answer every caller already handles.
81
84
 
85
+ **Boxes do not cross the antimeridian.** RFC 7946 §3.1.9 tells producers
86
+ to cut a geometry at ±180° rather than let it span the line, and the
87
+ kernel follows that posture instead of re-joining what a producer split:
88
+ `bboxOf` of an uncut geometry with positions either side of the line
89
+ returns a box spanning the globe the *wrong* way (`[-179, 0, 179, 0]` for
90
+ two points four degrees apart), and `circleBounds` answers a west below
91
+ −180 rather than wrapping, which is how a caller detects the case. Cut
92
+ the geometry as the RFC asks and every box, containment test and index
93
+ probe is right; there is no flag, because a box that meant "the short way
94
+ round" for some values and "the long way" for others would be worse than
95
+ one rule.
96
+
97
+ **Traversal is not validation.** `containsPosition`, `geometryArea` and
98
+ the rest walk whatever rings they are given and treat a ring as closed
99
+ whether or not its last position repeats its first — the same reading
100
+ every implementation makes. A ring `isValidGeoJson` refuses (unclosed,
101
+ too short) therefore still measures; judgment is one call away and is not
102
+ duplicated inside every walk.
103
+
82
104
  ## GeoJSON traversal — `geojson.js`
83
105
 
84
106
  The one layer that reads the `type` discriminator; every function takes
@@ -158,11 +180,16 @@ spelling are all normalized.
158
180
 
159
181
  `geohashEncode(lon, lat, precision)`, `geohashDecode`, `geohashBounds`,
160
182
  `geohashCellSize`, `geohashNeighbours`. A geohash is a **string**, so it
161
- needs no new vocabulary anywhere: proximity is a prefix test, bucketing
162
- is grouping on a substring, and a sorted index over the hash is a
163
- spatial index. Cells are not equal-area and neighbours can straddle a
164
- cell edge use `geohashNeighbours` for boundary-safe proximity, and
165
- never as a distance.
183
+ needs no new vocabulary anywhere: bucketing is grouping on a substring,
184
+ tiling is a prefix, and a sorted index over the hash is a spatial index.
185
+ **A prefix is bucketing, not proximity.** Two points ten metres apart can
186
+ differ in the *first* character of their cell, so a single-prefix "near
187
+ here" misses a neighbour at every cell boundary; the proximity probe is
188
+ `geohashNeighbours` — the cell and its eight neighbours, in reading order
189
+ (north-west first, the cell itself in the middle, fewer past a pole) —
190
+ followed by the exact distance on what survives. Cells are not equal-area
191
+ (2.4× between the equatorial and polar bands), and a cell is never a
192
+ distance.
166
193
 
167
194
  ## Drawing — `mercator.js` + `simplify.js`
168
195
 
@@ -178,10 +205,15 @@ degenerate.
178
205
 
179
206
  ## Not here
180
207
 
181
- Spatial *query operators* (`$distance`, `$within`, `$bbox-intersects`,
182
- `$centroid`, `$geohash`, index-screened spatial joins) live in the
183
- query engine of `@jarenjs/json` (QUERY-FORMAT §8.14); the GeoJSON
184
- meta-schema artifacts live in `@jarenjs/json/schemas`; the map chart
185
- and its bounded-memory streaming accumulator live in `@jarenjs/charts`.
186
- Overlay operations (union/intersection/buffer) are deliberately absent
187
- see the ROADMAP.
208
+ The thirteen spatial *query operators* the eight measurements and
209
+ predicates (`$bbox`, `$area`, `$length`, `$centroid`, `$distance`,
210
+ `$within`, `$bbox-intersects`, `$geohash`) and the five conversions
211
+ (`$geo-parse`, `$geo-text`, `$geohash-bounds`, `$geohash-neighbours`,
212
+ `$geo-simplify`), plus index-screened spatial joins live in the query
213
+ engine of `@jarenjs/json` (QUERY-FORMAT §8.14), and `@jarenjs/linq`
214
+ spells every one of them; the GeoJSON meta-schema artifacts live in
215
+ `@jarenjs/json/schemas`; derived spatial index columns and the two-stage
216
+ pushdown live in `@jarenjs/db`; the map chart and its bounded-memory
217
+ streaming accumulator live in `@jarenjs/charts`. Overlay operations
218
+ (union/intersection/difference/buffer) are deliberately absent — see the
219
+ ROADMAP.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/core",
3
3
  "private": false,
4
- "version": "0.43.1",
4
+ "version": "0.46.4",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./dist/types/index.d.ts",
@@ -80,6 +80,10 @@
80
80
  "types": "./dist/types/geo/*.d.ts",
81
81
  "default": "./src/geo/*.js"
82
82
  },
83
+ "./vector": {
84
+ "types": "./dist/types/vector/index.d.ts",
85
+ "default": "./src/vector/index.js"
86
+ },
83
87
  "./function": {
84
88
  "types": "./dist/types/function.d.ts",
85
89
  "default": "./src/function.js"
@@ -184,9 +184,10 @@ export function geohashCellSize(precision) {
184
184
  * nine-element array. Cells that would fall off the poles are omitted,
185
185
  * so the result may be shorter.
186
186
  *
187
- * This is what makes a prefix search safe near a boundary: two points
187
+ * This is the proximity probe, and a single cell is not one: two points
188
188
  * metres apart can sit in different cells, so a proximity query tests
189
- * the neighbourhood rather than the single cell.
189
+ * the neighbourhood rather than the single cell — a prefix buckets, it
190
+ * does not find neighbours.
190
191
  *
191
192
  * @param {string} hash
192
193
  * @returns {string[]}
package/src/geo/wkt.js CHANGED
@@ -26,7 +26,9 @@
26
26
  // point must match. Polygon rings must close and carry at least four
27
27
  // points, as the spec requires. No leading or trailing text is
28
28
  // tolerated: a format that trims would accept strings a consumer then
29
- // fails on.
29
+ // fails on. A GEOMETRYCOLLECTION nests at most eight deep — the bound
30
+ // the GeoJSON value gate applies — so hostile nesting is refused, never
31
+ // recursed into.
30
32
  //
31
33
  // Parsing answers what the text says and judges nothing else: a
32
34
  // coordinate outside the WGS 84 bounds parses, and `isValidGeoJson` is
@@ -260,9 +262,31 @@ function scanPolygonBody(text, at, dim, out) {
260
262
  return scanNested(text, at, dim, 1, scanRing, out);
261
263
  }
262
264
 
265
+ /**
266
+ * How deep a `GEOMETRYCOLLECTION` may nest. A nesting guard, not a spec
267
+ * rule, and the same bound `isValidGeoJson` applies to a GeoJSON
268
+ * `GeometryCollection`: unbounded recursion on hostile input is a
269
+ * different problem than an unusual document, and a WKT string nested
270
+ * deeper than the value gate admits could never become a valid value
271
+ * anyway. Past the bound the walk answers "not WKT" — the same answer
272
+ * both entry points give for any other malformed text — rather than
273
+ * overflowing the stack.
274
+ */
275
+ const MAX_COLLECTION_DEPTH = 8;
276
+
277
+ /** The collection nesting the walk is currently inside (one walk at a
278
+ * time; reset at the entry point, so an aborted walk cannot poison the
279
+ * next). */
280
+ let collectionDepth = 0;
281
+
263
282
  /** A collection member is a whole tagged geometry, modifier and all. */
264
283
  function scanCollectionItem(text, at, dim, out) {
265
- return scanGeometry(text, at, out);
284
+ if (collectionDepth >= MAX_COLLECTION_DEPTH)
285
+ return -1;
286
+ collectionDepth += 1;
287
+ const end = scanGeometry(text, at, out);
288
+ collectionDepth -= 1;
289
+ return end;
266
290
  }
267
291
 
268
292
  /**
@@ -349,6 +373,7 @@ function scanGeometry(text, at, out) {
349
373
  function scanWkt(text, out) {
350
374
  if (typeof text !== 'string' || text.length === 0 || isWhitespaceCode(text.charCodeAt(0)))
351
375
  return false;
376
+ collectionDepth = 0;
352
377
  return scanGeometry(text, 0, out) === text.length;
353
378
  }
354
379
 
@@ -0,0 +1,266 @@
1
+ //@ts-check
2
+
3
+ //#region @jarenjs/core/vector
4
+ // The suite's one home for n-dimensional vector arithmetic: the kernels
5
+ // an embedding is compared, normalized and stored with. There is no
6
+ // vector type. A vector is a plain array of finite numbers — `number[]`
7
+ // straight out of JSON, or a `Float32Array` straight out of a packed
8
+ // column — the convention the spatial kernel keeps for GeoJSON
9
+ // positions, and for the same reason: an embedding travels through a
10
+ // ledger record, a jsonb document and a wire reply, and a wrapper class
11
+ // survives none of those boundaries.
12
+ //
13
+ // Three rules every function here keeps, so that every caller — a
14
+ // ranked recall, a query operator, a store's fetch-and-rank plan — can
15
+ // rely on them without checking again:
16
+ //
17
+ // higher is better every similarity answers in the same direction:
18
+ // cosine in [-1, 1], the dot product unbounded,
19
+ // Euclidean as 1 / (1 + distance) in (0, 1] — so
20
+ // one descending sort ranks any of them
21
+ // malformed scores 0 a comparison over mismatched lengths, an empty
22
+ // vector, a null, or a non-finite component answers
23
+ // 0 and never throws: one bad vector among ten
24
+ // thousand loses the comparison, it does not kill
25
+ // the sweep, and it never poisons a ranking with NaN
26
+ // refuse, never fix the constructors — `packVector`, `l2Normalize` —
27
+ // answer null for anything `isVector` refuses, the
28
+ // way a bounding box refuses a position it cannot
29
+ // bound; nothing here truncates, pads or zero-fills
30
+ // a vector into the shape it was supposed to have
31
+ //
32
+ // `isVector` is the one definition of "a vector" the ledger, the query
33
+ // operators and the store share, so they refuse the same inputs. The
34
+ // comparison kernels do not re-check components (that is the gate's
35
+ // job, once, at the boundary); they guarantee a finite answer.
36
+ //
37
+ // The packed form is little-endian IEEE 754 binary32, 4·d bytes for d
38
+ // dimensions — the bytes a database column stores. Unpacking an aligned
39
+ // buffer on a little-endian host is a view, not a copy, which is what
40
+ // makes ranking over a fetched column fast; a misaligned buffer (a slice
41
+ // of a pooled allocation, an offset into a larger record) or a
42
+ // big-endian host takes the copy path. Alignment is an observation about
43
+ // one driver on one day, not a contract any driver signed.
44
+
45
+ /**
46
+ * A vector as this module accepts it: plain numbers, in a plain array or
47
+ * a float typed array. Never a class.
48
+ * @typedef {number[] | Float32Array | Float64Array} Vector
49
+ */
50
+
51
+ /** Whether this host stores a Float32 in the packed (little-endian) byte order. */
52
+ const LITTLE_ENDIAN = new Uint8Array(new Uint16Array([1]).buffer)[0] === 1;
53
+
54
+ /**
55
+ * Whether `v` is a vector: a non-empty `Array`, `Float32Array` or
56
+ * `Float64Array` whose every component is a finite number — and, when
57
+ * `dims` is given, exactly that many of them. The one shape guard the
58
+ * suite shares, so a ledger, an operator and a store refuse the same
59
+ * inputs.
60
+ * @param {unknown} v
61
+ * @param {number} [dims] - the exact length required
62
+ * @returns {v is Vector}
63
+ * @example
64
+ * isVector([0.1, 0.2, 0.3]); // true
65
+ * isVector([0.1, 0.2, 0.3], 4); // false — wrong width
66
+ * isVector([0.1, NaN]); // false — not finite
67
+ * isVector(new Uint8Array(4)); // false — bytes are not a vector; unpack them first
68
+ */
69
+ export function isVector(v, dims) {
70
+ if (!(Array.isArray(v) || v instanceof Float32Array || v instanceof Float64Array))
71
+ return false;
72
+ const n = v.length;
73
+ if (n === 0 || (dims !== undefined && n !== dims))
74
+ return false;
75
+ for (let i = 0; i < n; i++) {
76
+ if (!Number.isFinite(v[i]))
77
+ return false;
78
+ }
79
+ return true;
80
+ }
81
+
82
+ /**
83
+ * The dot product. Unbounded; equal to the cosine for unit vectors, so
84
+ * a sweep over l2-normalized vectors can take this cheaper form.
85
+ * @param {Vector | null | undefined} a
86
+ * @param {Vector | null | undefined} b
87
+ * @returns {number} the product — or 0 for a malformed pair (mismatched
88
+ * lengths, empty, null, or a result that is not finite)
89
+ */
90
+ export function dotProduct(a, b) {
91
+ if (a == null || b == null)
92
+ return 0;
93
+ const n = a.length;
94
+ if (n === 0 || n !== b.length)
95
+ return 0;
96
+ let sum = 0;
97
+ for (let i = 0; i < n; i++)
98
+ sum += a[i] * b[i];
99
+ return Number.isFinite(sum) ? sum : 0;
100
+ }
101
+
102
+ /**
103
+ * Cosine similarity, in [-1, 1]: 1 for the same direction, 0 for
104
+ * orthogonal, -1 for opposite. The one metric the suite ranks by —
105
+ * scale-free, so vectors from a model that does not normalize compare
106
+ * the same as vectors from one that does.
107
+ * @param {Vector | null | undefined} a
108
+ * @param {Vector | null | undefined} b
109
+ * @returns {number} the similarity — or 0 for a malformed pair, and 0
110
+ * when either vector has no direction (all zeros)
111
+ * @example
112
+ * cosineSimilarity([1, 0], [1, 0]); // 1
113
+ * cosineSimilarity([1, 0], [0, 1]); // 0
114
+ * cosineSimilarity([1, 0], [-2, 0]); // -1
115
+ * cosineSimilarity([1, 0], [1]); // 0 — mismatched, never a throw
116
+ */
117
+ export function cosineSimilarity(a, b) {
118
+ if (a == null || b == null)
119
+ return 0;
120
+ const n = a.length;
121
+ if (n === 0 || n !== b.length)
122
+ return 0;
123
+ let dot = 0;
124
+ let na = 0;
125
+ let nb = 0;
126
+ for (let i = 0; i < n; i++) {
127
+ const x = a[i];
128
+ const y = b[i];
129
+ dot += x * y;
130
+ na += x * x;
131
+ nb += y * y;
132
+ }
133
+ // the two roots separately: their product can overflow where neither does
134
+ const denominator = Math.sqrt(na) * Math.sqrt(nb);
135
+ if (!(denominator > 0) || !Number.isFinite(denominator) || !Number.isFinite(dot))
136
+ return 0;
137
+ const r = dot / denominator;
138
+ // rounding can land a hair outside the interval; the contract is [-1, 1]
139
+ return r > 1 ? 1 : r < -1 ? -1 : r;
140
+ }
141
+
142
+ /**
143
+ * Euclidean similarity, `1 / (1 + distance)`, in (0, 1]: 1 for equal
144
+ * vectors, falling towards 0 as they move apart. The distance turned
145
+ * into the same higher-is-better direction as the other kernels, so a
146
+ * caller never has to remember which way a metric sorts.
147
+ * @param {Vector | null | undefined} a
148
+ * @param {Vector | null | undefined} b
149
+ * @returns {number} the similarity — or 0 for a malformed pair
150
+ */
151
+ export function euclideanSimilarity(a, b) {
152
+ if (a == null || b == null)
153
+ return 0;
154
+ const n = a.length;
155
+ if (n === 0 || n !== b.length)
156
+ return 0;
157
+ let sum = 0;
158
+ for (let i = 0; i < n; i++) {
159
+ const d = a[i] - b[i];
160
+ sum += d * d;
161
+ }
162
+ return Number.isFinite(sum) ? 1 / (1 + Math.sqrt(sum)) : 0;
163
+ }
164
+
165
+ /**
166
+ * The unit vector in `a`'s direction, as a new `Float32Array` — the
167
+ * form the packed column stores, so that cosine, dot and Euclidean
168
+ * then agree in rank. The input is never modified.
169
+ * @param {Vector | null | undefined} a
170
+ * @returns {Float32Array | null} the normalized copy; the zero vector
171
+ * stays zero (it has no direction, and scores 0 against everything);
172
+ * null for anything `isVector` refuses, or a norm that overflows
173
+ */
174
+ export function l2Normalize(a) {
175
+ if (!isVector(a))
176
+ return null;
177
+ const n = a.length;
178
+ let sum = 0;
179
+ for (let i = 0; i < n; i++)
180
+ sum += a[i] * a[i];
181
+ if (!Number.isFinite(sum))
182
+ return null;
183
+ const out = new Float32Array(n);
184
+ if (sum === 0)
185
+ return out;
186
+ const norm = Math.sqrt(sum);
187
+ for (let i = 0; i < n; i++)
188
+ out[i] = a[i] / norm;
189
+ return out;
190
+ }
191
+
192
+ /**
193
+ * The packed form: `4·d` bytes of little-endian binary32, the value a
194
+ * database column stores. Components round to their nearest binary32
195
+ * (`Math.fround`); `unpackVector` returns exactly those.
196
+ *
197
+ * A component too large for binary32 has no nearest one — it rounds to
198
+ * an infinity — so such a vector is REFUSED rather than stored as bytes
199
+ * that unpack into something `isVector` would reject. Normalized
200
+ * vectors, which is what a column holds, can never meet it.
201
+ * @param {Vector | null | undefined} a
202
+ * @returns {Uint8Array | null} the bytes — null for anything `isVector`
203
+ * refuses (empty, non-finite) and for a component that overflows
204
+ * binary32, never a partial or zero-filled record
205
+ * @example
206
+ * packVector([1]); // Uint8Array [0, 0, 128, 63]
207
+ * packVector([1, NaN]); // null
208
+ * packVector([1e39]); // null — no finite binary32 is nearest
209
+ */
210
+ export function packVector(a) {
211
+ if (!isVector(a))
212
+ return null;
213
+ const n = a.length;
214
+ const bytes = new Uint8Array(n * 4);
215
+ if (LITTLE_ENDIAN) {
216
+ // a fresh buffer is aligned, and the host's byte order is the packed
217
+ // one — so the rounded components are readable back through the view
218
+ const floats = new Float32Array(bytes.buffer);
219
+ floats.set(a);
220
+ for (let i = 0; i < n; i++) {
221
+ if (!Number.isFinite(floats[i]))
222
+ return null;
223
+ }
224
+ }
225
+ else {
226
+ const view = new DataView(bytes.buffer);
227
+ for (let i = 0; i < n; i++) {
228
+ const rounded = Math.fround(a[i]);
229
+ if (!Number.isFinite(rounded))
230
+ return null;
231
+ view.setFloat32(i * 4, rounded, true);
232
+ }
233
+ }
234
+ return bytes;
235
+ }
236
+
237
+ /**
238
+ * The vector packed in `bytes`, read as `dims` little-endian binary32
239
+ * components. The width is the caller's to state — a column declares
240
+ * it — and bytes of any other length are refused rather than partially
241
+ * read.
242
+ *
243
+ * On a little-endian host, 4-byte-aligned bytes come back as a VIEW over
244
+ * the same buffer — no copy, which is what a sweep over ten thousand
245
+ * fetched rows is paid for by; writing into the result writes into the
246
+ * bytes. Misaligned bytes (a pooled `Buffer`, a slice at an odd offset)
247
+ * and big-endian hosts take the copy path and answer the same values.
248
+ * @param {Uint8Array | null | undefined} bytes
249
+ * @param {number} dims - the width the bytes must hold
250
+ * @returns {Float32Array | null} the vector — null when `bytes` is not a
251
+ * `Uint8Array` of exactly `4·dims` bytes
252
+ */
253
+ export function unpackVector(bytes, dims) {
254
+ if (!(bytes instanceof Uint8Array) || !Number.isInteger(dims) || dims < 1
255
+ || bytes.byteLength !== dims * 4)
256
+ return null;
257
+ if (LITTLE_ENDIAN && bytes.byteOffset % 4 === 0)
258
+ return new Float32Array(bytes.buffer, bytes.byteOffset, dims);
259
+ const out = new Float32Array(dims);
260
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
261
+ for (let i = 0; i < dims; i++)
262
+ out[i] = view.getFloat32(i * 4, true);
263
+ return out;
264
+ }
265
+
266
+ //#endregion