@mailwoman/resolver-wof-sqlite 9.0.0 → 9.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -9
- package/address-point-interpolation.ts +18 -8
- package/address-point-schema.ts +18 -6
- package/address-point.ts +111 -18
- package/ancestry.ts +9 -6
- package/build-candidate.ts +287 -157
- package/build-slim.ts +3 -3
- package/candidate/alias-bags.ts +54 -0
- package/candidate/ancestors-sidecar.ts +206 -0
- package/candidate/country-display-names.ts +79 -0
- package/candidate/name-roles.ts +237 -0
- package/candidate/own-name.ts +146 -0
- package/candidate/place-attrs.ts +44 -0
- package/candidate/shard-fold.ts +137 -0
- package/candidate-ancestors-schema.ts +195 -0
- package/candidate-fts.ts +4 -2
- package/candidate-importance.ts +228 -0
- package/candidate-lookup.ts +564 -174
- package/candidate-schema.ts +60 -3
- package/candidate-scoring.ts +268 -0
- package/capital-schema.ts +90 -0
- package/capitals.ts +148 -0
- package/coincident-roles.ts +69 -10
- package/convention-schema.ts +72 -0
- package/convention.ts +2 -2
- package/coverage-manifest-schema.ts +7 -7
- package/currency-backfill.ts +249 -0
- package/exact-match.ts +104 -0
- package/fst-autocomplete.ts +105 -122
- package/fst-builder.ts +39 -47
- package/fst-deserialize-web.ts +43 -7
- package/fst-freshness.ts +2 -2
- package/fst-serialize.ts +68 -12
- package/fst-types.ts +35 -1
- package/fts-query.ts +1 -1
- package/fts.ts +16 -4
- package/geonames-postal.ts +2 -2
- package/index.ts +26 -14
- package/interpolation.ts +113 -19
- package/lookup.ts +118 -560
- package/name-score.ts +6 -4
- package/out/address-point-interpolation.d.ts.map +1 -1
- package/out/address-point-interpolation.js +13 -7
- package/out/address-point-interpolation.js.map +1 -1
- package/out/address-point-schema.d.ts +16 -6
- package/out/address-point-schema.d.ts.map +1 -1
- package/out/address-point-schema.js.map +1 -1
- package/out/address-point.d.ts.map +1 -1
- package/out/address-point.js +70 -14
- package/out/address-point.js.map +1 -1
- package/out/ancestry.d.ts +2 -2
- package/out/ancestry.d.ts.map +1 -1
- package/out/ancestry.js +5 -6
- package/out/ancestry.js.map +1 -1
- package/out/build-candidate.d.ts +108 -0
- package/out/build-candidate.d.ts.map +1 -1
- package/out/build-candidate.js +151 -120
- package/out/build-candidate.js.map +1 -1
- package/out/build-slim.d.ts +1 -1
- package/out/build-slim.js +3 -3
- package/out/build-slim.js.map +1 -1
- package/out/candidate/alias-bags.d.ts +17 -0
- package/out/candidate/alias-bags.d.ts.map +1 -0
- package/out/candidate/alias-bags.js +39 -0
- package/out/candidate/alias-bags.js.map +1 -0
- package/out/candidate/ancestors-sidecar.d.ts +33 -0
- package/out/candidate/ancestors-sidecar.d.ts.map +1 -0
- package/out/candidate/ancestors-sidecar.js +140 -0
- package/out/candidate/ancestors-sidecar.js.map +1 -0
- package/out/candidate/country-display-names.d.ts +35 -0
- package/out/candidate/country-display-names.d.ts.map +1 -0
- package/out/candidate/country-display-names.js +59 -0
- package/out/candidate/country-display-names.js.map +1 -0
- package/out/candidate/name-roles.d.ts +55 -0
- package/out/candidate/name-roles.d.ts.map +1 -0
- package/out/candidate/name-roles.js +165 -0
- package/out/candidate/name-roles.js.map +1 -0
- package/out/candidate/own-name.d.ts +50 -0
- package/out/candidate/own-name.d.ts.map +1 -0
- package/out/candidate/own-name.js +132 -0
- package/out/candidate/own-name.js.map +1 -0
- package/out/candidate/place-attrs.d.ts +43 -0
- package/out/candidate/place-attrs.d.ts.map +1 -0
- package/out/candidate/place-attrs.js +15 -0
- package/out/candidate/place-attrs.js.map +1 -0
- package/out/candidate/shard-fold.d.ts +31 -0
- package/out/candidate/shard-fold.d.ts.map +1 -0
- package/out/candidate/shard-fold.js +104 -0
- package/out/candidate/shard-fold.js.map +1 -0
- package/out/candidate-ancestors-schema.d.ts +150 -0
- package/out/candidate-ancestors-schema.d.ts.map +1 -0
- package/out/candidate-ancestors-schema.js +123 -0
- package/out/candidate-ancestors-schema.js.map +1 -0
- package/out/candidate-fts.d.ts +4 -2
- package/out/candidate-fts.d.ts.map +1 -1
- package/out/candidate-fts.js +4 -2
- package/out/candidate-fts.js.map +1 -1
- package/out/candidate-importance.d.ts +132 -0
- package/out/candidate-importance.d.ts.map +1 -0
- package/out/candidate-importance.js +174 -0
- package/out/candidate-importance.js.map +1 -0
- package/out/candidate-lookup.d.ts +22 -37
- package/out/candidate-lookup.d.ts.map +1 -1
- package/out/candidate-lookup.js +446 -132
- package/out/candidate-lookup.js.map +1 -1
- package/out/candidate-schema.d.ts +52 -4
- package/out/candidate-schema.d.ts.map +1 -1
- package/out/candidate-schema.js +8 -0
- package/out/candidate-schema.js.map +1 -1
- package/out/candidate-scoring.d.ts +34 -0
- package/out/candidate-scoring.d.ts.map +1 -0
- package/out/candidate-scoring.js +200 -0
- package/out/candidate-scoring.js.map +1 -0
- package/out/capital-schema.d.ts +51 -0
- package/out/capital-schema.d.ts.map +1 -0
- package/out/capital-schema.js +63 -0
- package/out/capital-schema.js.map +1 -0
- package/out/capitals.d.ts +69 -0
- package/out/capitals.d.ts.map +1 -0
- package/out/capitals.js +98 -0
- package/out/capitals.js.map +1 -0
- package/out/coincident-roles.d.ts +7 -0
- package/out/coincident-roles.d.ts.map +1 -1
- package/out/coincident-roles.js +42 -8
- package/out/coincident-roles.js.map +1 -1
- package/out/convention-schema.d.ts +51 -0
- package/out/convention-schema.d.ts.map +1 -0
- package/out/convention-schema.js +34 -0
- package/out/convention-schema.js.map +1 -0
- package/out/convention.d.ts +1 -1
- package/out/convention.js +2 -2
- package/out/coverage-manifest-schema.js +3 -7
- package/out/coverage-manifest-schema.js.map +1 -1
- package/out/currency-backfill.d.ts +46 -0
- package/out/currency-backfill.d.ts.map +1 -0
- package/out/currency-backfill.js +180 -0
- package/out/currency-backfill.js.map +1 -0
- package/out/exact-match.d.ts +25 -0
- package/out/exact-match.d.ts.map +1 -0
- package/out/exact-match.js +89 -0
- package/out/exact-match.js.map +1 -0
- package/out/fst-autocomplete.d.ts +24 -14
- package/out/fst-autocomplete.d.ts.map +1 -1
- package/out/fst-autocomplete.js +84 -100
- package/out/fst-autocomplete.js.map +1 -1
- package/out/fst-builder.d.ts.map +1 -1
- package/out/fst-builder.js +32 -40
- package/out/fst-builder.js.map +1 -1
- package/out/fst-deserialize-web.d.ts.map +1 -1
- package/out/fst-deserialize-web.js +36 -7
- package/out/fst-deserialize-web.js.map +1 -1
- package/out/fst-freshness.d.ts +2 -2
- package/out/fst-freshness.js +2 -2
- package/out/fst-serialize.d.ts +14 -4
- package/out/fst-serialize.d.ts.map +1 -1
- package/out/fst-serialize.js +60 -12
- package/out/fst-serialize.js.map +1 -1
- package/out/fst-types.d.ts +35 -1
- package/out/fst-types.d.ts.map +1 -1
- package/out/fts-query.js +1 -1
- package/out/fts-query.js.map +1 -1
- package/out/fts.d.ts +15 -4
- package/out/fts.d.ts.map +1 -1
- package/out/fts.js +15 -4
- package/out/fts.js.map +1 -1
- package/out/geonames-postal.d.ts +2 -2
- package/out/geonames-postal.js +2 -2
- package/out/index.d.ts +4 -2
- package/out/index.d.ts.map +1 -1
- package/out/index.js +3 -2
- package/out/index.js.map +1 -1
- package/out/interpolation.d.ts +8 -0
- package/out/interpolation.d.ts.map +1 -1
- package/out/interpolation.js +91 -19
- package/out/interpolation.js.map +1 -1
- package/out/lookup.d.ts +4 -5
- package/out/lookup.d.ts.map +1 -1
- package/out/lookup.js +102 -444
- package/out/lookup.js.map +1 -1
- package/out/name-score.d.ts +0 -10
- package/out/name-score.d.ts.map +1 -1
- package/out/name-score.js +6 -4
- package/out/name-score.js.map +1 -1
- package/out/place-importance-schema.d.ts +226 -0
- package/out/place-importance-schema.d.ts.map +1 -0
- package/out/place-importance-schema.js +288 -0
- package/out/place-importance-schema.js.map +1 -0
- package/out/poi-lookup.d.ts +1 -1
- package/out/poi-lookup.d.ts.map +1 -1
- package/out/poi-lookup.js +12 -13
- package/out/poi-lookup.js.map +1 -1
- package/out/poi-schema.d.ts +7 -3
- package/out/poi-schema.d.ts.map +1 -1
- package/out/poi-schema.js.map +1 -1
- package/out/polygon-schema.d.ts +37 -0
- package/out/polygon-schema.d.ts.map +1 -0
- package/out/polygon-schema.js +23 -0
- package/out/polygon-schema.js.map +1 -0
- package/out/postal-city-alias-lookup.d.ts +1 -1
- package/out/postal-city-alias-lookup.js +1 -1
- package/out/postal-city-candidate-schema.d.ts +2 -1
- package/out/postal-city-candidate-schema.d.ts.map +1 -1
- package/out/postal-city-candidate-schema.js.map +1 -1
- package/out/postcode-point-lookup.d.ts +1 -1
- package/out/postcode-point-lookup.js +1 -1
- package/out/primary-preference.d.ts +125 -0
- package/out/primary-preference.d.ts.map +1 -0
- package/out/primary-preference.js +138 -0
- package/out/primary-preference.js.map +1 -0
- package/out/proximity-rerank.d.ts +77 -0
- package/out/proximity-rerank.d.ts.map +1 -0
- package/out/proximity-rerank.js +86 -0
- package/out/proximity-rerank.js.map +1 -0
- package/out/region-keys.d.ts +47 -0
- package/out/region-keys.d.ts.map +1 -0
- package/out/region-keys.js +121 -0
- package/out/region-keys.js.map +1 -0
- package/out/reverse.d.ts.map +1 -1
- package/out/reverse.js +6 -9
- package/out/reverse.js.map +1 -1
- package/out/schema.d.ts +1 -1
- package/out/search-fetch.d.ts +57 -0
- package/out/search-fetch.d.ts.map +1 -0
- package/out/search-fetch.js +183 -0
- package/out/search-fetch.js.map +1 -0
- package/out/sharding.d.ts +3 -3
- package/out/sharding.js +1 -1
- package/out/sqlite-convention-source.d.ts +1 -1
- package/out/sqlite-convention-source.js +1 -1
- package/out/sqlite-utils.d.ts +31 -1
- package/out/sqlite-utils.d.ts.map +1 -1
- package/out/sqlite-utils.js +38 -0
- package/out/sqlite-utils.js.map +1 -1
- package/out/street-centroid-schema.d.ts +7 -2
- package/out/street-centroid-schema.d.ts.map +1 -1
- package/out/street-centroid-schema.js.map +1 -1
- package/out/street-centroid.d.ts.map +1 -1
- package/out/street-centroid.js +7 -7
- package/out/street-centroid.js.map +1 -1
- package/out/street-morphology-fst-builder.d.ts.map +1 -1
- package/out/street-morphology-fst-builder.js +5 -4
- package/out/street-morphology-fst-builder.js.map +1 -1
- package/out/street-normalize.d.ts +83 -9
- package/out/street-normalize.d.ts.map +1 -1
- package/out/street-normalize.js +177 -10
- package/out/street-normalize.js.map +1 -1
- package/out/street-segment-schema.d.ts +6 -2
- package/out/street-segment-schema.d.ts.map +1 -1
- package/out/street-segment-schema.js.map +1 -1
- package/out/types.d.ts +74 -1
- package/out/types.d.ts.map +1 -1
- package/out/unified-schema.d.ts +1 -1
- package/out/unified-schema.js +1 -1
- package/out/uprn-lookup.d.ts +85 -0
- package/out/uprn-lookup.d.ts.map +1 -0
- package/out/uprn-lookup.js +152 -0
- package/out/uprn-lookup.js.map +1 -0
- package/out/uprn-schema.d.ts +93 -0
- package/out/uprn-schema.d.ts.map +1 -0
- package/out/uprn-schema.js +78 -0
- package/out/uprn-schema.js.map +1 -0
- package/out/weights-overlay-linker.d.ts +141 -0
- package/out/weights-overlay-linker.d.ts.map +1 -0
- package/out/weights-overlay-linker.js +259 -0
- package/out/weights-overlay-linker.js.map +1 -0
- package/package.json +296 -16
- package/place-importance-schema.ts +402 -0
- package/poi-lookup.ts +12 -13
- package/poi-schema.ts +8 -3
- package/polygon-schema.ts +47 -0
- package/postal-city-alias-lookup.ts +1 -1
- package/postal-city-candidate-schema.ts +3 -1
- package/postcode-point-lookup.ts +1 -1
- package/primary-preference.ts +207 -0
- package/proximity-rerank.ts +120 -0
- package/region-keys.ts +144 -0
- package/reverse.ts +17 -16
- package/schema.ts +1 -1
- package/search-fetch.ts +256 -0
- package/sharding.ts +3 -3
- package/sqlite-convention-source.ts +1 -1
- package/sqlite-utils.ts +63 -1
- package/street-centroid-schema.ts +8 -2
- package/street-centroid.ts +13 -8
- package/street-morphology-fst-builder.ts +5 -4
- package/street-normalize.ts +254 -24
- package/street-segment-schema.ts +7 -2
- package/types.ts +74 -1
- package/unified-schema.ts +1 -1
- package/uprn-lookup.ts +210 -0
- package/uprn-schema.ts +124 -0
- package/weights-overlay-linker.ts +377 -0
- package/geo.ts +0 -121
- package/out/geo.d.ts +0 -74
- package/out/geo.d.ts.map +0 -1
- package/out/geo.js +0 -71
- package/out/geo.js.map +0 -1
package/fst-serialize.ts
CHANGED
|
@@ -18,9 +18,19 @@
|
|
|
18
18
|
*
|
|
19
19
|
* EDGE TABLE [edgeCount × 8 bytes] stringIdx u32 index into string table targetState u32
|
|
20
20
|
*
|
|
21
|
-
* PLACE TABLE [placeCount × 56
|
|
22
|
-
* u8 0..8
|
|
23
|
-
* (
|
|
21
|
+
* PLACE TABLE [placeCount × 60 bytes at V5, 56 below] wofID u32 placetypeIdx u8 index into
|
|
22
|
+
* PLACETYPE_ORDER chainLen u8 0..8 crossCountryBranches u8 (header flags bit0 gates the read)
|
|
23
|
+
* placeFlags u8 (V5; bit0 = encyclopedic present) nameIdx u32 index into string table referential f32
|
|
24
|
+
* population-anchored likelihood [0,1] — was the conflated `importance` (V2–V4), was population u32
|
|
25
|
+
* (V1) lat f32 lon f32 chain [u32; 8] parent chain (unused slots = 0) encyclopedic f32 (V5 only;
|
|
26
|
+
* read only when placeFlags bit0 is set)
|
|
27
|
+
*
|
|
28
|
+
* THE V5 BUMP IS THE TWO-SCORE SPLIT (ROAD_TO_V9 §2 R1). Through V4 one float carried whichever score
|
|
29
|
+
* the source database held, and nothing in the bytes said which — so a reader could not tell a
|
|
30
|
+
* population proxy from a Wikipedia score. V5 names the ranking score `referential` and gives the
|
|
31
|
+
* encyclopedic signal its own slot plus a PER-PLACE presence bit, because most places have no
|
|
32
|
+
* Wikipedia article and a 0 there would be a fact nobody recorded. `fst-freshness.ts` reports every
|
|
33
|
+
* V4-and-below artifact as format-stale for exactly this reason: its single float is unattributable.
|
|
24
34
|
*/
|
|
25
35
|
|
|
26
36
|
import { tryParsingJSON } from "@mailwoman/core/objects"
|
|
@@ -50,6 +60,34 @@ const NARROW_STATE_ENTRY_SIZE = 12
|
|
|
50
60
|
*/
|
|
51
61
|
const VERSION_WITH_METADATA = 3
|
|
52
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Format version that split the single `importance` float into `referential` + `encyclopedic`, growing the place entry
|
|
65
|
+
* from 56 to 60 bytes and claiming the previously-reserved `pp+7` byte as {@link PLACE_FLAG_HAS_ENCYCLOPEDIC}.
|
|
66
|
+
*/
|
|
67
|
+
const VERSION_TWO_SCORE_SPLIT = 5
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Place-entry size in bytes at or above {@link VERSION_TWO_SCORE_SPLIT}.
|
|
71
|
+
*/
|
|
72
|
+
const SPLIT_PLACE_ENTRY_SIZE = 60
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Place-entry size in bytes below {@link VERSION_TWO_SCORE_SPLIT}.
|
|
76
|
+
*/
|
|
77
|
+
const LEGACY_PLACE_ENTRY_SIZE = 56
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Byte offset of the encyclopedic float inside a v5 place entry — immediately after the 8-slot parent chain.
|
|
81
|
+
*/
|
|
82
|
+
const ENCYCLOPEDIC_OFFSET = 56
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `placeFlags` bit 0 (byte `pp+7`, v5+): this place carries an encyclopedic score. Per-PLACE rather than per-file
|
|
86
|
+
* because absence is the common case — roughly 89% of the 2026-08-05 gazetteer has no Wikipedia article — and a
|
|
87
|
+
* file-level flag would force every one of those rows to claim a 0 it never had.
|
|
88
|
+
*/
|
|
89
|
+
const PLACE_FLAG_HAS_ENCYCLOPEDIC = 1
|
|
90
|
+
|
|
53
91
|
/**
|
|
54
92
|
* File magic. A reader rejects anything not starting with these four bytes before parsing further.
|
|
55
93
|
*/
|
|
@@ -58,7 +96,7 @@ const MAGIC = Buffer.from("FST\0", "ascii")
|
|
|
58
96
|
/**
|
|
59
97
|
* Format version this serializer emits. See {@link VERSION_WIDE_STATE_COUNTERS} for what each bump changed.
|
|
60
98
|
*/
|
|
61
|
-
const VERSION =
|
|
99
|
+
const VERSION = 5
|
|
62
100
|
|
|
63
101
|
/**
|
|
64
102
|
* The format version this tree WRITES, published so a freshness guard can call an older artifact format-stale without
|
|
@@ -83,9 +121,9 @@ const STATE_ENTRY_SIZE = 16
|
|
|
83
121
|
const EDGE_ENTRY_SIZE = 8
|
|
84
122
|
|
|
85
123
|
/**
|
|
86
|
-
* Place-table entry: the place id, its placetype, coordinates, and
|
|
124
|
+
* Place-table entry at the version this tree WRITES: the place id, its placetype, coordinates, and both scores.
|
|
87
125
|
*/
|
|
88
|
-
const PLACE_ENTRY_SIZE =
|
|
126
|
+
const PLACE_ENTRY_SIZE = SPLIT_PLACE_ENTRY_SIZE
|
|
89
127
|
|
|
90
128
|
/**
|
|
91
129
|
* Longest ancestry chain stored per place. Deeper hierarchies are truncated at the leaf end, since the specific end of
|
|
@@ -246,11 +284,14 @@ export function serializeFST(matcher: FSTMatcher, provenance?: FSTProvenance): B
|
|
|
246
284
|
buf.writeUInt32LE(place.wofID, pp)
|
|
247
285
|
buf.writeUInt8(placetypeToIdx.get(place.placetype) ?? 0, pp + 4)
|
|
248
286
|
buf.writeUInt8(chainLen, pp + 5)
|
|
249
|
-
// Former _pad: byte 0 = crossCountryBranches (header flags bit0 gates the read), byte 1
|
|
287
|
+
// Former _pad: byte 0 = crossCountryBranches (header flags bit0 gates the read), byte 1 = v5 placeFlags.
|
|
250
288
|
buf.writeUInt8(hasAmbiguity ? Math.min(place.crossCountryBranches ?? 0, 255) : 0, pp + 6)
|
|
251
|
-
|
|
289
|
+
// An absent encyclopedic score writes flag 0 AND a 0.0 float. The float is unread in that
|
|
290
|
+
// state, so absence can never surface as a score — the meaning-of-zero rule, in bytes.
|
|
291
|
+
const hasEncyclopedic = place.encyclopedic !== undefined
|
|
292
|
+
buf.writeUInt8(hasEncyclopedic ? PLACE_FLAG_HAS_ENCYCLOPEDIC : 0, pp + 7)
|
|
252
293
|
buf.writeUInt32LE(intern(place.name), pp + 8)
|
|
253
|
-
buf.writeFloatLE(place.
|
|
294
|
+
buf.writeFloatLE(place.referential, pp + 12)
|
|
254
295
|
buf.writeFloatLE(place.lat, pp + 16)
|
|
255
296
|
buf.writeFloatLE(place.lon, pp + 20)
|
|
256
297
|
|
|
@@ -258,6 +299,8 @@ export function serializeFST(matcher: FSTMatcher, provenance?: FSTProvenance): B
|
|
|
258
299
|
buf.writeUInt32LE(ci < chainLen ? validChain[ci]! : 0, pp + 24 + ci * 4)
|
|
259
300
|
}
|
|
260
301
|
|
|
302
|
+
buf.writeFloatLE(hasEncyclopedic ? place.encyclopedic! : 0, pp + ENCYCLOPEDIC_OFFSET)
|
|
303
|
+
|
|
261
304
|
placeIdx++
|
|
262
305
|
}
|
|
263
306
|
}
|
|
@@ -280,6 +323,7 @@ export function deserializeFST(buf: Buffer): FSTMatcher {
|
|
|
280
323
|
|
|
281
324
|
if (version < 1 || version > VERSION) throw new Error(`FST version ${version} unsupported (expected 1..${VERSION})`)
|
|
282
325
|
const isV2 = version >= 2
|
|
326
|
+
const isSplit = version >= VERSION_TWO_SCORE_SPLIT
|
|
283
327
|
// flags bit0 (survey #4): place rows carry surface-ambiguity data in the former _pad byte.
|
|
284
328
|
const hasAmbiguity = (buf.readUInt16LE(6) & 1) === 1
|
|
285
329
|
|
|
@@ -312,6 +356,8 @@ export function deserializeFST(buf: Buffer): FSTMatcher {
|
|
|
312
356
|
|
|
313
357
|
// --- State table ---
|
|
314
358
|
const stateEntrySize = version >= VERSION_WIDE_STATE_COUNTERS ? WIDE_STATE_ENTRY_SIZE : NARROW_STATE_ENTRY_SIZE
|
|
359
|
+
// v5 grew the place entry by the encyclopedic float; v4-and-below files are read at the old stride.
|
|
360
|
+
const placeEntrySize = version >= VERSION_TWO_SCORE_SPLIT ? SPLIT_PLACE_ENTRY_SIZE : LEGACY_PLACE_ENTRY_SIZE
|
|
315
361
|
const stateTableStart = pos
|
|
316
362
|
const edgeTableStart = stateTableStart + stateCount * stateEntrySize
|
|
317
363
|
const placeTableStart = edgeTableStart + edgeCount * EDGE_ENTRY_SIZE
|
|
@@ -341,7 +387,7 @@ export function deserializeFST(buf: Buffer): FSTMatcher {
|
|
|
341
387
|
const places: PlaceEntry[] = new Array(placeCountForState)
|
|
342
388
|
|
|
343
389
|
for (let pi = 0; pi < placeCountForState; pi++) {
|
|
344
|
-
const pp = placeTableStart + (placeStart + pi) *
|
|
390
|
+
const pp = placeTableStart + (placeStart + pi) * placeEntrySize
|
|
345
391
|
const chainLen = buf.readUInt8(pp + 5)
|
|
346
392
|
const parentChain: number[] = []
|
|
347
393
|
|
|
@@ -349,20 +395,30 @@ export function deserializeFST(buf: Buffer): FSTMatcher {
|
|
|
349
395
|
parentChain.push(buf.readUInt32LE(pp + 24 + ci * 4))
|
|
350
396
|
}
|
|
351
397
|
|
|
352
|
-
|
|
398
|
+
// v1 stored a raw population u32 here; v2–v4 the conflated `importance` float; v5 the
|
|
399
|
+
// referential score. A v1 file's population is mapped through the SAME curve
|
|
400
|
+
// `referentialFromPopulation` uses, so its value is genuinely referential — the only
|
|
401
|
+
// generation of this format for which that can be said without reading the source database.
|
|
402
|
+
const referential = isV2
|
|
353
403
|
? buf.readFloatLE(pp + 12)
|
|
354
404
|
: Math.min(1, Math.log2(1 + buf.readUInt32LE(pp + 12) / 1000) / 14)
|
|
355
405
|
|
|
406
|
+
// Per-place presence bit (v5+). A v4-and-below file has no encyclopedic channel at all, so
|
|
407
|
+
// the field stays undefined rather than reading the reserved byte as a flag.
|
|
408
|
+
const hasEncyclopedic =
|
|
409
|
+
isSplit && (buf.readUInt8(pp + 7) & PLACE_FLAG_HAS_ENCYCLOPEDIC) === PLACE_FLAG_HAS_ENCYCLOPEDIC
|
|
410
|
+
|
|
356
411
|
places[pi] = {
|
|
357
412
|
wofID: buf.readUInt32LE(pp),
|
|
358
413
|
placetype: PLACETYPE_ORDER[buf.readUInt8(pp + 4)] ?? "locality",
|
|
359
414
|
name: strings[buf.readUInt32LE(pp + 8)]!,
|
|
360
|
-
|
|
415
|
+
referential,
|
|
361
416
|
lat: buf.readFloatLE(pp + 16),
|
|
362
417
|
lon: buf.readFloatLE(pp + 20),
|
|
363
418
|
parentChain,
|
|
364
419
|
// Header flags bit0 gates the read (survey #4): pre-ambiguity artifacts expose undefined.
|
|
365
420
|
...(hasAmbiguity ? { crossCountryBranches: buf.readUInt8(pp + 6) } : {}),
|
|
421
|
+
...(hasEncyclopedic ? { encyclopedic: buf.readFloatLE(pp + ENCYCLOPEDIC_OFFSET) } : {}),
|
|
366
422
|
}
|
|
367
423
|
}
|
|
368
424
|
|
package/fst-types.ts
CHANGED
|
@@ -13,7 +13,25 @@ export interface PlaceEntry {
|
|
|
13
13
|
placetype: PlacetypeID
|
|
14
14
|
name: string
|
|
15
15
|
parentChain: number[]
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* REFERENTIAL likelihood in [0, 1] — population-anchored (`referentialFromPopulation`), and the ONLY score the
|
|
18
|
+
* decoder bias is allowed to read (ROAD_TO_V9 §2, ratified 2026-08-06).
|
|
19
|
+
*
|
|
20
|
+
* This field was called `importance` through format v4, where it carried whichever score the source database happened
|
|
21
|
+
* to hold: the population proxy on a database with no `place_importance` table (which is every shipped
|
|
22
|
+
* `fst-per-locale` binary), the Wikipedia score where the concordance join landed otherwise. The rename is the point
|
|
23
|
+
* — a v4 artifact's value is a CONFLATION that cannot be told apart after the fact, which is why `fst-freshness.ts`
|
|
24
|
+
* reports anything below {@link FST_FORMAT_VERSION} as format-stale rather than reading it as referential.
|
|
25
|
+
*/
|
|
26
|
+
referential: number
|
|
27
|
+
/**
|
|
28
|
+
* Encyclopedic (Wikipedia) importance in [0, 1], fan-out-guarded per #1497. Carried for consumers that want to
|
|
29
|
+
* DISPLAY salience; never read by the decoder or by any ranking.
|
|
30
|
+
*
|
|
31
|
+
* `undefined` = this place has no encyclopedic signal, or the artifact predates format 5. NEVER conflate either with
|
|
32
|
+
* 0 — the meaning-of-zero rule; roughly 89% of the 2026-08-05 gazetteer's rows have no Wikipedia article at all.
|
|
33
|
+
*/
|
|
34
|
+
encyclopedic?: number
|
|
17
35
|
lat: number
|
|
18
36
|
lon: number
|
|
19
37
|
/**
|
|
@@ -64,7 +82,23 @@ export interface FSTProvenance {
|
|
|
64
82
|
placeCount: number
|
|
65
83
|
edgeCount: number
|
|
66
84
|
nameInsertions: number
|
|
85
|
+
/**
|
|
86
|
+
* How many places carried a non-zero REFERENTIAL score at build time. Named `importanceMatches` for stamp
|
|
87
|
+
* compatibility with every artifact written before the two-score split — renaming the JSON key would make every
|
|
88
|
+
* existing stamp unreadable, and the number means the same thing it always did on a population-built artifact.
|
|
89
|
+
*/
|
|
67
90
|
importanceMatches: number
|
|
91
|
+
/**
|
|
92
|
+
* How many places carried an ENCYCLOPEDIC score at build time. `undefined` on a pre-split build — which is not the
|
|
93
|
+
* same as 0 (a v5 build against a population-only database), so the freshness report says the two in different
|
|
94
|
+
* words.
|
|
95
|
+
*/
|
|
96
|
+
encyclopedicMatches?: number
|
|
97
|
+
/**
|
|
98
|
+
* How the builder obtained its two scores — an {@link ImportanceSplitSource}. Recorded so an artifact states, in its
|
|
99
|
+
* own provenance, whether its encyclopedic channel is real, reconstructed from a legacy conflated column, or absent.
|
|
100
|
+
*/
|
|
101
|
+
importanceSource?: string
|
|
68
102
|
sourceDB?: string
|
|
69
103
|
/**
|
|
70
104
|
* MD5 of the source database's bytes at build time — the artifact's link to the gazetteer it is a projection of.
|
package/fts-query.ts
CHANGED
|
@@ -67,7 +67,7 @@ export function sanitizeFTSQuery(text: string, opts?: { fuseTokens?: boolean }):
|
|
|
67
67
|
// fusing "Thiron-Gardais" into the unmatchable single term `ThironGardais` while the FTS
|
|
68
68
|
// doc holds two terms (#945 — the entire hyphenated-name class missed at the raw lookup;
|
|
69
69
|
// masked for years because pre-splice tokenizers never emitted hyphen-preserved values).
|
|
70
|
-
const parts = trimmed.split(/[^\p{L}\p{N}]+/u).filter(
|
|
70
|
+
const parts = trimmed.split(/[^\p{L}\p{N}]+/u).filter((part) => part.length > 0)
|
|
71
71
|
|
|
72
72
|
if (!parts.length) continue
|
|
73
73
|
|
package/fts.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* FTS5 index lifecycle for the WOF SQLite distribution.
|
|
7
7
|
*
|
|
8
|
-
* Shared by `
|
|
8
|
+
* Shared by `WOFSQLitePlaceLookup` (lazy build via `buildFTS: true`) and the operator-side
|
|
9
9
|
* `mailwoman gazetteer build fts` CLI (ahead-of-time build to avoid first-open latency in production).
|
|
10
10
|
*
|
|
11
11
|
* Upstream WOF SQLite distributions do NOT ship FTS5. The index lives in a `place_search` virtual
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import type { DatabaseSync } from "node:sqlite"
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
* Name of the FTS5 virtual table this module owns. Centralized so `
|
|
20
|
+
* Name of the FTS5 virtual table this module owns. Centralized so `WOFSQLitePlaceLookup` and the CLI can't drift apart.
|
|
21
21
|
*/
|
|
22
22
|
export const PLACE_SEARCH_TABLE = "place_search"
|
|
23
23
|
|
|
@@ -73,7 +73,7 @@ const ALIAS_SEPARATOR_CODEPOINT = ALIAS_SEPARATOR.codePointAt(0) as number
|
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
75
|
* Does any alias in an `alt_names` bag exactly equal the (already-normalized) query? The single shared implementation
|
|
76
|
-
* of the exact-tier alias check for every consumer of the bag — the Node resolver's `#
|
|
76
|
+
* of the exact-tier alias check for every consumer of the bag — the Node resolver's `#exactMatchIDs` fallback, the WASM
|
|
77
77
|
* resolver, and the demo's httpvfs resolver — so the bag format and its parsers can't drift.
|
|
78
78
|
*
|
|
79
79
|
* Two formats exist in the wild:
|
|
@@ -123,6 +123,18 @@ export const PLACE_BBOX_TABLE = "place_bbox"
|
|
|
123
123
|
*/
|
|
124
124
|
export const PLACE_POPULATION_TABLE = "place_population"
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Name of the auxiliary table holding the two salience scores per place — `referential` (population-anchored, the
|
|
128
|
+
* ranking backbone) and `encyclopedic` (the Wikipedia join, NULL when there is no article). Built by `mailwoman
|
|
129
|
+
* gazetteer importance`; schema and derivations in `place-importance-schema.ts`.
|
|
130
|
+
*
|
|
131
|
+
* The lookup reads ONLY `encyclopedic` from it, and only to CARRY the value onto the result — referential is derived
|
|
132
|
+
* from the population already joined, and no ORDER BY anywhere touches this table (ROAD_TO_V9 §2). Sparse and
|
|
133
|
+
* schema-versioned: a pre-split gazetteer has this table with a single conflated `importance` column instead, which the
|
|
134
|
+
* lookup's column probe deliberately refuses to read.
|
|
135
|
+
*/
|
|
136
|
+
export const PLACE_IMPORTANCE_TABLE = "place_importance"
|
|
137
|
+
|
|
126
138
|
/**
|
|
127
139
|
* Counters for a single `buildPlaceSearchFTS` run. Exposed so callers (CLI, lazy-build) can render progress to the
|
|
128
140
|
* user.
|
|
@@ -314,7 +326,7 @@ export function buildPlaceSearchFTS(db: DatabaseSync, opts: BuildPlaceSearchFTSO
|
|
|
314
326
|
}
|
|
315
327
|
|
|
316
328
|
/**
|
|
317
|
-
* Returns true iff the `place_search` table exists in the connected DB. Used by `
|
|
329
|
+
* Returns true iff the `place_search` table exists in the connected DB. Used by `WOFSQLitePlaceLookup` for its "FTS
|
|
318
330
|
* missing — pass buildFTS:true or run the CLI" guard.
|
|
319
331
|
*/
|
|
320
332
|
export function placeSearchFTSExists(db: DatabaseSync): boolean {
|
package/geonames-postal.ts
CHANGED
|
@@ -54,7 +54,7 @@ const GEONAMES_POSTAL_COLUMNS = 11
|
|
|
54
54
|
export const GEONAMES_POSTAL_ID_BASE = 9_500_000_000_000
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
|
-
* The #920 name law: reduce a
|
|
57
|
+
* The #920 name law: reduce a postcode to the sanitized-query token shape — strip every non-letter/number — so the
|
|
58
58
|
* stored name matches what `sanitizeFTSQuery` produces from the parsed postcode token. `"110 00"` → `"11000"`,
|
|
59
59
|
* `"11-041"` → `"11041"`, `"AD500"` → `"AD500"`.
|
|
60
60
|
*/
|
|
@@ -132,7 +132,7 @@ export interface GeonamesPostalIngestResult {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
/**
|
|
135
|
-
* Fold GeoNames
|
|
135
|
+
* Fold GeoNames postcodes for `countries` into an open unified/postcode ingest DB: one `spr` row per distinct
|
|
136
136
|
* normalized postcode (placetype `postalcode`, medoid centroid, degenerate bbox), the normalized form as `name`, and
|
|
137
137
|
* the display form as an extra `names` row when it differs. The caller owns the FTS rebuild (rows ride the standard
|
|
138
138
|
* freeze phase).
|
package/index.ts
CHANGED
|
@@ -19,9 +19,34 @@ export type {
|
|
|
19
19
|
WOFDatabase,
|
|
20
20
|
} from "./schema.ts"
|
|
21
21
|
|
|
22
|
-
export {
|
|
22
|
+
export { WOFSQLitePlaceLookup, type RankingWeights, type WOFSQLitePlaceLookupOpts } from "./lookup.ts"
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
CANDIDATE_ANCESTOR_COLUMNS,
|
|
26
|
+
CANDIDATE_ANCESTOR_TABLE,
|
|
27
|
+
CANDIDATE_INTERVAL_TABLE,
|
|
28
|
+
createCandidateAncestorTable,
|
|
29
|
+
createCandidateIntervalTable,
|
|
30
|
+
intervalContains,
|
|
31
|
+
MAX_ANCESTOR_DEPTH,
|
|
32
|
+
} from "./candidate-ancestors-schema.ts"
|
|
33
|
+
|
|
34
|
+
export type {
|
|
35
|
+
CandidateAncestorsDatabase,
|
|
36
|
+
CandidateAncestorTable,
|
|
37
|
+
CandidateIntervalTable,
|
|
38
|
+
IntervalLabel,
|
|
39
|
+
} from "./candidate-ancestors-schema.ts"
|
|
23
40
|
|
|
24
41
|
export { CANDIDATE_FTS_TABLE, createCandidateFTS } from "./candidate-fts.ts"
|
|
42
|
+
|
|
43
|
+
export {
|
|
44
|
+
ImportanceIndex,
|
|
45
|
+
IMPORTANCE_JOIN_GATE_KM,
|
|
46
|
+
type ImportanceIndexStats,
|
|
47
|
+
loadImportanceIndex,
|
|
48
|
+
} from "./candidate-importance.ts"
|
|
49
|
+
|
|
25
50
|
export { WOFCandidateTableLookup, type WOFCandidateTableLookupOpts } from "./candidate-lookup.ts"
|
|
26
51
|
|
|
27
52
|
export {
|
|
@@ -92,19 +117,6 @@ export {
|
|
|
92
117
|
type BuildPlaceSearchFTSResult,
|
|
93
118
|
} from "./fts.ts"
|
|
94
119
|
|
|
95
|
-
export {
|
|
96
|
-
bboxAround,
|
|
97
|
-
geometryContains,
|
|
98
|
-
haversineKm,
|
|
99
|
-
pointInPolygonRings,
|
|
100
|
-
pointInRing,
|
|
101
|
-
type Bbox,
|
|
102
|
-
type GeojsonGeometry,
|
|
103
|
-
type GeojsonMultiPolygon,
|
|
104
|
-
type GeojsonPolygon,
|
|
105
|
-
type GeojsonPosition,
|
|
106
|
-
} from "./geo.ts"
|
|
107
|
-
|
|
108
120
|
export { PLACETYPE_DEPTH, ancestorLineage, placetypeDepth, type AncestorPlaceRow } from "./ancestry.ts"
|
|
109
121
|
|
|
110
122
|
export {
|
package/interpolation.ts
CHANGED
|
@@ -30,11 +30,10 @@ import { DatabaseSync } from "node:sqlite"
|
|
|
30
30
|
|
|
31
31
|
import { parseJSONStrict } from "@mailwoman/core/objects"
|
|
32
32
|
import type { InterpolationLookup } from "@mailwoman/resolver"
|
|
33
|
-
import { clampFraction, pointAlong } from "@mailwoman/spatial"
|
|
33
|
+
import { clampFraction, haversineKm, pointAlong } from "@mailwoman/spatial"
|
|
34
34
|
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
37
|
-
import { canonicalizeRouteKey, normalizeStreetForKey } from "./street-normalize.ts"
|
|
35
|
+
import { hasTable, prepareAll, type PreparedAll } from "./sqlite-utils.ts"
|
|
36
|
+
import { canonicalizeRouteKey, type RouteKey, streetKeyVariants } from "./street-normalize.ts"
|
|
38
37
|
|
|
39
38
|
/**
|
|
40
39
|
* How an interpolated answer was computed (#483 Method 2):
|
|
@@ -92,8 +91,27 @@ export interface InterpolationQuery {
|
|
|
92
91
|
* ZIP scope — strongly preferred; without it common street names abstain (see module doc).
|
|
93
92
|
*/
|
|
94
93
|
postcode?: string
|
|
94
|
+
/**
|
|
95
|
+
* The resolved locality's coordinate — the tie-breaker when no postcode was given and the parity-preferred covering
|
|
96
|
+
* ranges still span several postcodes. See {@link NEAR_MAX_KM} for the acceptance geometry.
|
|
97
|
+
*/
|
|
98
|
+
near?: { lat: number; lon: number }
|
|
95
99
|
}
|
|
96
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Acceptance geometry for the `near` tie-break: the winning postcode group's closest segment must sit within this many
|
|
103
|
+
* kilometres of `near`, AND the runner-up group must be at least {@link NEAR_DOMINANCE} times farther. Both measured on
|
|
104
|
+
* the two live failures: Brooklyn's `st pauls place` 11226 segment is ~2 km from the Brooklyn centroid with Great
|
|
105
|
+
* Neck's 11021 at ~24 km (12×); Fraser's `east 13 mile road` 48026 is ~2 km with Mecosta's namesake ~190 km away. A
|
|
106
|
+
* near-tie between groups is genuine ambiguity and stays an abstention.
|
|
107
|
+
*/
|
|
108
|
+
const NEAR_MAX_KM = 25
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* See {@link NEAR_MAX_KM}.
|
|
112
|
+
*/
|
|
113
|
+
const NEAR_DOMINANCE = 2
|
|
114
|
+
|
|
97
115
|
interface SegmentRow {
|
|
98
116
|
from_hn: number
|
|
99
117
|
to_hn: number
|
|
@@ -106,11 +124,55 @@ interface SegmentRow {
|
|
|
106
124
|
release: string
|
|
107
125
|
}
|
|
108
126
|
|
|
127
|
+
/**
|
|
128
|
+
* The postcode group nearest `near`, under the {@link NEAR_MAX_KM} dominance geometry — or null when no group qualifies
|
|
129
|
+
* (out of range, or the runner-up is too close to call). A group's distance is its closest segment's first polyline
|
|
130
|
+
* vertex; a segment whose geometry fails to parse prices as unreachable rather than aborting the tie-break.
|
|
131
|
+
*/
|
|
132
|
+
function nearestPostcodeGroup(pool: readonly SegmentRow[], near: { lat: number; lon: number }): SegmentRow[] | null {
|
|
133
|
+
const groups = new Map<string, { rows: SegmentRow[]; km: number }>()
|
|
134
|
+
|
|
135
|
+
for (const row of pool) {
|
|
136
|
+
const key = row.postcode ?? ""
|
|
137
|
+
let km = Number.POSITIVE_INFINITY
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const [firstVertex] = parseJSONStrict<[number, number][]>(row.geometry)
|
|
141
|
+
|
|
142
|
+
if (firstVertex) {
|
|
143
|
+
km = haversineKm(near.lat, near.lon, firstVertex[1], firstVertex[0])
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
// Unparseable geometry: this row cannot be sited, so it cannot win the tie-break.
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const group = groups.get(key)
|
|
150
|
+
|
|
151
|
+
if (group) {
|
|
152
|
+
group.rows.push(row)
|
|
153
|
+
group.km = Math.min(group.km, km)
|
|
154
|
+
} else {
|
|
155
|
+
groups.set(key, { rows: [row], km })
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const ranked = [...groups.values()].toSorted((a, b) => a.km - b.km)
|
|
160
|
+
const [winner, runnerUp] = ranked
|
|
161
|
+
|
|
162
|
+
if (!winner || winner.km > NEAR_MAX_KM) return null
|
|
163
|
+
|
|
164
|
+
if (runnerUp && runnerUp.km < winner.km * NEAR_DOMINANCE) return null
|
|
165
|
+
|
|
166
|
+
return winner.rows
|
|
167
|
+
}
|
|
168
|
+
|
|
109
169
|
export class StreetInterpolator implements InterpolationLookup {
|
|
110
170
|
readonly #db: DatabaseSync
|
|
111
171
|
readonly #ownsDB: boolean
|
|
112
|
-
readonly #byPostcode:
|
|
113
|
-
|
|
172
|
+
readonly #byPostcode:
|
|
173
|
+
| PreparedAll<[postcode: string, street: RouteKey, minNumber: number, maxNumber: number], SegmentRow>
|
|
174
|
+
| undefined
|
|
175
|
+
readonly #byStreet: PreparedAll<[street: RouteKey, minNumber: number, maxNumber: number], SegmentRow> | undefined
|
|
114
176
|
readonly #radiusCalibration: number | undefined
|
|
115
177
|
|
|
116
178
|
constructor(opts: { dbPath?: string; database?: DatabaseSync }) {
|
|
@@ -129,12 +191,14 @@ export class StreetInterpolator implements InterpolationLookup {
|
|
|
129
191
|
if (hasTable(this.#db, "street_segment")) {
|
|
130
192
|
const columns = `from_hn, to_hn, min_hn, max_hn, parity, postcode, geometry, source, release`
|
|
131
193
|
|
|
132
|
-
this.#byPostcode =
|
|
194
|
+
this.#byPostcode = prepareAll(
|
|
195
|
+
this.#db,
|
|
133
196
|
`SELECT ${columns} FROM street_segment
|
|
134
197
|
WHERE postcode = ? AND street_norm = ? AND min_hn <= ? AND max_hn >= ?`
|
|
135
198
|
)
|
|
136
199
|
|
|
137
|
-
this.#byStreet =
|
|
200
|
+
this.#byStreet = prepareAll(
|
|
201
|
+
this.#db,
|
|
138
202
|
`SELECT ${columns} FROM street_segment
|
|
139
203
|
WHERE street_norm = ? AND min_hn <= ? AND max_hn >= ?`
|
|
140
204
|
)
|
|
@@ -169,29 +233,41 @@ export class StreetInterpolator implements InterpolationLookup {
|
|
|
169
233
|
|
|
170
234
|
find(query: InterpolationQuery): InterpolatedHit | null {
|
|
171
235
|
if (!this.#byPostcode || !this.#byStreet) return null
|
|
172
|
-
const streetNorm = canonicalizeRouteKey(normalizeStreetForKey(query.street))
|
|
173
236
|
const numberRaw = query.number.trim()
|
|
174
237
|
|
|
175
238
|
// Strictly-numeric house numbers only — this tier estimates, it doesn't guess at
|
|
176
239
|
// hyphenated/alphanumeric schemes the ranges don't model.
|
|
177
|
-
if (
|
|
240
|
+
if (!/^\d+$/.test(numberRaw)) return null
|
|
178
241
|
const n = Number(numberRaw)
|
|
179
242
|
|
|
180
|
-
|
|
243
|
+
// Key-variant ladder (see `streetKeyVariants`): the literal key first, then the doubled-type
|
|
244
|
+
// collapse and the saint↔st register swap. A variant advances the ladder when it produces no
|
|
245
|
+
// ANSWER, not merely no rows — a wrong-register key can cover the number in far-away towns and
|
|
246
|
+
// then fail the ambiguity gate ("saint pauls place" reaches Nassau's rows; the Brooklyn answer
|
|
247
|
+
// lives under "st pauls place"), and stopping at rows would eclipse the right variant.
|
|
248
|
+
for (const variant of streetKeyVariants(query.street)) {
|
|
249
|
+
const streetNorm = canonicalizeRouteKey(variant)
|
|
181
250
|
|
|
182
|
-
if (query.postcode) {
|
|
183
251
|
// A given ZIP that scopes to nothing is a MISS, not a statewide guess: the retry was
|
|
184
252
|
// measured (2026-06-11 VT eval) at +2.3pp coverage for a poisoned tail (p99 1.0 → 20.8
|
|
185
253
|
// km, max 204 km — a unique name statewide can live in a far-away town).
|
|
186
|
-
rows =
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const
|
|
254
|
+
const rows = query.postcode
|
|
255
|
+
? this.#byPostcode(query.postcode.trim(), streetNorm, n, n)
|
|
256
|
+
: this.#byStreet(streetNorm, n, n)
|
|
257
|
+
|
|
258
|
+
const hit = this.#answerFromRows(rows, n, query)
|
|
191
259
|
|
|
192
|
-
if (
|
|
260
|
+
if (hit) return hit
|
|
193
261
|
}
|
|
194
262
|
|
|
263
|
+
return null
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Resolve one key variant's covering rows to an answer, or null when they cannot honestly give one — the
|
|
268
|
+
* parity/ambiguity/tightest-range pipeline the module doc describes.
|
|
269
|
+
*/
|
|
270
|
+
#answerFromRows(rows: SegmentRow[], n: number, query: InterpolationQuery): InterpolatedHit | null {
|
|
195
271
|
if (!rows.length) return null
|
|
196
272
|
|
|
197
273
|
// Parity preference: exact side first, then 'mixed' (matches either), then the
|
|
@@ -200,9 +276,27 @@ export class StreetInterpolator implements InterpolationLookup {
|
|
|
200
276
|
const exact = rows.filter((r) => r.parity === (wantOdd ? "odd" : "even"))
|
|
201
277
|
const mixed = rows.filter((r) => r.parity === "mixed")
|
|
202
278
|
const preferred = exact.length ? exact : mixed
|
|
203
|
-
|
|
279
|
+
let pool = preferred.length ? preferred : rows
|
|
204
280
|
const parityMatched = preferred.length > 0
|
|
205
281
|
|
|
282
|
+
// No scope given: the covering ranges must agree on ONE postcode or the lookup abstains — a
|
|
283
|
+
// name spanning towns is ambiguity, not an answer. Counted over the PARITY pool, not all
|
|
284
|
+
// rows: a section-line boundary road carries a different ZIP per side ("east 13 mile road"
|
|
285
|
+
// is Fraser 48026 odd / Roseville 48066 even), and the opposite side can never hold the
|
|
286
|
+
// number it would otherwise veto. When several postcodes survive parity, the caller's
|
|
287
|
+
// resolved-locality coordinate breaks the tie by segment proximity under the dominance
|
|
288
|
+
// geometry of {@link NEAR_MAX_KM} — a near-tie stays an abstention.
|
|
289
|
+
if (!query.postcode) {
|
|
290
|
+
const postcodes = new Set(pool.map((r) => r.postcode ?? ""))
|
|
291
|
+
|
|
292
|
+
if (postcodes.size > 1) {
|
|
293
|
+
const scoped = query.near ? nearestPostcodeGroup(pool, query.near) : null
|
|
294
|
+
|
|
295
|
+
if (!scoped) return null
|
|
296
|
+
pool = scoped
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
206
300
|
// Tightest range wins — the most specific claim about where this number lives.
|
|
207
301
|
let best = pool[0]!
|
|
208
302
|
|