@jarenjs/core 0.43.3 → 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 +72 -28
- package/README.md +26 -3
- package/dist/types/geo/geohash.d.ts +3 -2
- package/dist/types/vector/index.d.ts +98 -0
- package/docs/GEO.md +45 -13
- package/package.json +5 -1
- package/src/geo/geohash.js +3 -2
- package/src/geo/wkt.js +27 -2
- package/src/vector/index.js +266 -0
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
|
-
|
|
583
|
+
<!--bm:geo.wktTable-->
|
|
584
|
+
| scenario | Jaren | rival | ratio |
|
|
579
585
|
|---|---|---|---|
|
|
580
|
-
| parse
|
|
581
|
-
| parse
|
|
582
|
-
|
|
|
583
|
-
| validate
|
|
584
|
-
| validate
|
|
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
|
|
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
|
|
633
|
-
| distance vs
|
|
634
|
-
|
|
|
635
|
-
| point in polygon
|
|
636
|
-
|
|
|
637
|
-
| polygon area
|
|
638
|
-
|
|
|
639
|
-
|
|
|
640
|
-
|
|
|
641
|
-
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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.5× |
|
|
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.8× |
|
|
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
|
|
653
|
-
|
|
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.
|
|
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,
|
|
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
|
|
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
|
|
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:
|
|
162
|
-
is
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
182
|
-
`$
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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.
|
|
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"
|
package/src/geo/geohash.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|