@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.
Files changed (297) hide show
  1. package/README.md +28 -9
  2. package/address-point-interpolation.ts +18 -8
  3. package/address-point-schema.ts +18 -6
  4. package/address-point.ts +111 -18
  5. package/ancestry.ts +9 -6
  6. package/build-candidate.ts +287 -157
  7. package/build-slim.ts +3 -3
  8. package/candidate/alias-bags.ts +54 -0
  9. package/candidate/ancestors-sidecar.ts +206 -0
  10. package/candidate/country-display-names.ts +79 -0
  11. package/candidate/name-roles.ts +237 -0
  12. package/candidate/own-name.ts +146 -0
  13. package/candidate/place-attrs.ts +44 -0
  14. package/candidate/shard-fold.ts +137 -0
  15. package/candidate-ancestors-schema.ts +195 -0
  16. package/candidate-fts.ts +4 -2
  17. package/candidate-importance.ts +228 -0
  18. package/candidate-lookup.ts +564 -174
  19. package/candidate-schema.ts +60 -3
  20. package/candidate-scoring.ts +268 -0
  21. package/capital-schema.ts +90 -0
  22. package/capitals.ts +148 -0
  23. package/coincident-roles.ts +69 -10
  24. package/convention-schema.ts +72 -0
  25. package/convention.ts +2 -2
  26. package/coverage-manifest-schema.ts +7 -7
  27. package/currency-backfill.ts +249 -0
  28. package/exact-match.ts +104 -0
  29. package/fst-autocomplete.ts +105 -122
  30. package/fst-builder.ts +39 -47
  31. package/fst-deserialize-web.ts +43 -7
  32. package/fst-freshness.ts +2 -2
  33. package/fst-serialize.ts +68 -12
  34. package/fst-types.ts +35 -1
  35. package/fts-query.ts +1 -1
  36. package/fts.ts +16 -4
  37. package/geonames-postal.ts +2 -2
  38. package/index.ts +26 -14
  39. package/interpolation.ts +113 -19
  40. package/lookup.ts +118 -560
  41. package/name-score.ts +6 -4
  42. package/out/address-point-interpolation.d.ts.map +1 -1
  43. package/out/address-point-interpolation.js +13 -7
  44. package/out/address-point-interpolation.js.map +1 -1
  45. package/out/address-point-schema.d.ts +16 -6
  46. package/out/address-point-schema.d.ts.map +1 -1
  47. package/out/address-point-schema.js.map +1 -1
  48. package/out/address-point.d.ts.map +1 -1
  49. package/out/address-point.js +70 -14
  50. package/out/address-point.js.map +1 -1
  51. package/out/ancestry.d.ts +2 -2
  52. package/out/ancestry.d.ts.map +1 -1
  53. package/out/ancestry.js +5 -6
  54. package/out/ancestry.js.map +1 -1
  55. package/out/build-candidate.d.ts +108 -0
  56. package/out/build-candidate.d.ts.map +1 -1
  57. package/out/build-candidate.js +151 -120
  58. package/out/build-candidate.js.map +1 -1
  59. package/out/build-slim.d.ts +1 -1
  60. package/out/build-slim.js +3 -3
  61. package/out/build-slim.js.map +1 -1
  62. package/out/candidate/alias-bags.d.ts +17 -0
  63. package/out/candidate/alias-bags.d.ts.map +1 -0
  64. package/out/candidate/alias-bags.js +39 -0
  65. package/out/candidate/alias-bags.js.map +1 -0
  66. package/out/candidate/ancestors-sidecar.d.ts +33 -0
  67. package/out/candidate/ancestors-sidecar.d.ts.map +1 -0
  68. package/out/candidate/ancestors-sidecar.js +140 -0
  69. package/out/candidate/ancestors-sidecar.js.map +1 -0
  70. package/out/candidate/country-display-names.d.ts +35 -0
  71. package/out/candidate/country-display-names.d.ts.map +1 -0
  72. package/out/candidate/country-display-names.js +59 -0
  73. package/out/candidate/country-display-names.js.map +1 -0
  74. package/out/candidate/name-roles.d.ts +55 -0
  75. package/out/candidate/name-roles.d.ts.map +1 -0
  76. package/out/candidate/name-roles.js +165 -0
  77. package/out/candidate/name-roles.js.map +1 -0
  78. package/out/candidate/own-name.d.ts +50 -0
  79. package/out/candidate/own-name.d.ts.map +1 -0
  80. package/out/candidate/own-name.js +132 -0
  81. package/out/candidate/own-name.js.map +1 -0
  82. package/out/candidate/place-attrs.d.ts +43 -0
  83. package/out/candidate/place-attrs.d.ts.map +1 -0
  84. package/out/candidate/place-attrs.js +15 -0
  85. package/out/candidate/place-attrs.js.map +1 -0
  86. package/out/candidate/shard-fold.d.ts +31 -0
  87. package/out/candidate/shard-fold.d.ts.map +1 -0
  88. package/out/candidate/shard-fold.js +104 -0
  89. package/out/candidate/shard-fold.js.map +1 -0
  90. package/out/candidate-ancestors-schema.d.ts +150 -0
  91. package/out/candidate-ancestors-schema.d.ts.map +1 -0
  92. package/out/candidate-ancestors-schema.js +123 -0
  93. package/out/candidate-ancestors-schema.js.map +1 -0
  94. package/out/candidate-fts.d.ts +4 -2
  95. package/out/candidate-fts.d.ts.map +1 -1
  96. package/out/candidate-fts.js +4 -2
  97. package/out/candidate-fts.js.map +1 -1
  98. package/out/candidate-importance.d.ts +132 -0
  99. package/out/candidate-importance.d.ts.map +1 -0
  100. package/out/candidate-importance.js +174 -0
  101. package/out/candidate-importance.js.map +1 -0
  102. package/out/candidate-lookup.d.ts +22 -37
  103. package/out/candidate-lookup.d.ts.map +1 -1
  104. package/out/candidate-lookup.js +446 -132
  105. package/out/candidate-lookup.js.map +1 -1
  106. package/out/candidate-schema.d.ts +52 -4
  107. package/out/candidate-schema.d.ts.map +1 -1
  108. package/out/candidate-schema.js +8 -0
  109. package/out/candidate-schema.js.map +1 -1
  110. package/out/candidate-scoring.d.ts +34 -0
  111. package/out/candidate-scoring.d.ts.map +1 -0
  112. package/out/candidate-scoring.js +200 -0
  113. package/out/candidate-scoring.js.map +1 -0
  114. package/out/capital-schema.d.ts +51 -0
  115. package/out/capital-schema.d.ts.map +1 -0
  116. package/out/capital-schema.js +63 -0
  117. package/out/capital-schema.js.map +1 -0
  118. package/out/capitals.d.ts +69 -0
  119. package/out/capitals.d.ts.map +1 -0
  120. package/out/capitals.js +98 -0
  121. package/out/capitals.js.map +1 -0
  122. package/out/coincident-roles.d.ts +7 -0
  123. package/out/coincident-roles.d.ts.map +1 -1
  124. package/out/coincident-roles.js +42 -8
  125. package/out/coincident-roles.js.map +1 -1
  126. package/out/convention-schema.d.ts +51 -0
  127. package/out/convention-schema.d.ts.map +1 -0
  128. package/out/convention-schema.js +34 -0
  129. package/out/convention-schema.js.map +1 -0
  130. package/out/convention.d.ts +1 -1
  131. package/out/convention.js +2 -2
  132. package/out/coverage-manifest-schema.js +3 -7
  133. package/out/coverage-manifest-schema.js.map +1 -1
  134. package/out/currency-backfill.d.ts +46 -0
  135. package/out/currency-backfill.d.ts.map +1 -0
  136. package/out/currency-backfill.js +180 -0
  137. package/out/currency-backfill.js.map +1 -0
  138. package/out/exact-match.d.ts +25 -0
  139. package/out/exact-match.d.ts.map +1 -0
  140. package/out/exact-match.js +89 -0
  141. package/out/exact-match.js.map +1 -0
  142. package/out/fst-autocomplete.d.ts +24 -14
  143. package/out/fst-autocomplete.d.ts.map +1 -1
  144. package/out/fst-autocomplete.js +84 -100
  145. package/out/fst-autocomplete.js.map +1 -1
  146. package/out/fst-builder.d.ts.map +1 -1
  147. package/out/fst-builder.js +32 -40
  148. package/out/fst-builder.js.map +1 -1
  149. package/out/fst-deserialize-web.d.ts.map +1 -1
  150. package/out/fst-deserialize-web.js +36 -7
  151. package/out/fst-deserialize-web.js.map +1 -1
  152. package/out/fst-freshness.d.ts +2 -2
  153. package/out/fst-freshness.js +2 -2
  154. package/out/fst-serialize.d.ts +14 -4
  155. package/out/fst-serialize.d.ts.map +1 -1
  156. package/out/fst-serialize.js +60 -12
  157. package/out/fst-serialize.js.map +1 -1
  158. package/out/fst-types.d.ts +35 -1
  159. package/out/fst-types.d.ts.map +1 -1
  160. package/out/fts-query.js +1 -1
  161. package/out/fts-query.js.map +1 -1
  162. package/out/fts.d.ts +15 -4
  163. package/out/fts.d.ts.map +1 -1
  164. package/out/fts.js +15 -4
  165. package/out/fts.js.map +1 -1
  166. package/out/geonames-postal.d.ts +2 -2
  167. package/out/geonames-postal.js +2 -2
  168. package/out/index.d.ts +4 -2
  169. package/out/index.d.ts.map +1 -1
  170. package/out/index.js +3 -2
  171. package/out/index.js.map +1 -1
  172. package/out/interpolation.d.ts +8 -0
  173. package/out/interpolation.d.ts.map +1 -1
  174. package/out/interpolation.js +91 -19
  175. package/out/interpolation.js.map +1 -1
  176. package/out/lookup.d.ts +4 -5
  177. package/out/lookup.d.ts.map +1 -1
  178. package/out/lookup.js +102 -444
  179. package/out/lookup.js.map +1 -1
  180. package/out/name-score.d.ts +0 -10
  181. package/out/name-score.d.ts.map +1 -1
  182. package/out/name-score.js +6 -4
  183. package/out/name-score.js.map +1 -1
  184. package/out/place-importance-schema.d.ts +226 -0
  185. package/out/place-importance-schema.d.ts.map +1 -0
  186. package/out/place-importance-schema.js +288 -0
  187. package/out/place-importance-schema.js.map +1 -0
  188. package/out/poi-lookup.d.ts +1 -1
  189. package/out/poi-lookup.d.ts.map +1 -1
  190. package/out/poi-lookup.js +12 -13
  191. package/out/poi-lookup.js.map +1 -1
  192. package/out/poi-schema.d.ts +7 -3
  193. package/out/poi-schema.d.ts.map +1 -1
  194. package/out/poi-schema.js.map +1 -1
  195. package/out/polygon-schema.d.ts +37 -0
  196. package/out/polygon-schema.d.ts.map +1 -0
  197. package/out/polygon-schema.js +23 -0
  198. package/out/polygon-schema.js.map +1 -0
  199. package/out/postal-city-alias-lookup.d.ts +1 -1
  200. package/out/postal-city-alias-lookup.js +1 -1
  201. package/out/postal-city-candidate-schema.d.ts +2 -1
  202. package/out/postal-city-candidate-schema.d.ts.map +1 -1
  203. package/out/postal-city-candidate-schema.js.map +1 -1
  204. package/out/postcode-point-lookup.d.ts +1 -1
  205. package/out/postcode-point-lookup.js +1 -1
  206. package/out/primary-preference.d.ts +125 -0
  207. package/out/primary-preference.d.ts.map +1 -0
  208. package/out/primary-preference.js +138 -0
  209. package/out/primary-preference.js.map +1 -0
  210. package/out/proximity-rerank.d.ts +77 -0
  211. package/out/proximity-rerank.d.ts.map +1 -0
  212. package/out/proximity-rerank.js +86 -0
  213. package/out/proximity-rerank.js.map +1 -0
  214. package/out/region-keys.d.ts +47 -0
  215. package/out/region-keys.d.ts.map +1 -0
  216. package/out/region-keys.js +121 -0
  217. package/out/region-keys.js.map +1 -0
  218. package/out/reverse.d.ts.map +1 -1
  219. package/out/reverse.js +6 -9
  220. package/out/reverse.js.map +1 -1
  221. package/out/schema.d.ts +1 -1
  222. package/out/search-fetch.d.ts +57 -0
  223. package/out/search-fetch.d.ts.map +1 -0
  224. package/out/search-fetch.js +183 -0
  225. package/out/search-fetch.js.map +1 -0
  226. package/out/sharding.d.ts +3 -3
  227. package/out/sharding.js +1 -1
  228. package/out/sqlite-convention-source.d.ts +1 -1
  229. package/out/sqlite-convention-source.js +1 -1
  230. package/out/sqlite-utils.d.ts +31 -1
  231. package/out/sqlite-utils.d.ts.map +1 -1
  232. package/out/sqlite-utils.js +38 -0
  233. package/out/sqlite-utils.js.map +1 -1
  234. package/out/street-centroid-schema.d.ts +7 -2
  235. package/out/street-centroid-schema.d.ts.map +1 -1
  236. package/out/street-centroid-schema.js.map +1 -1
  237. package/out/street-centroid.d.ts.map +1 -1
  238. package/out/street-centroid.js +7 -7
  239. package/out/street-centroid.js.map +1 -1
  240. package/out/street-morphology-fst-builder.d.ts.map +1 -1
  241. package/out/street-morphology-fst-builder.js +5 -4
  242. package/out/street-morphology-fst-builder.js.map +1 -1
  243. package/out/street-normalize.d.ts +83 -9
  244. package/out/street-normalize.d.ts.map +1 -1
  245. package/out/street-normalize.js +177 -10
  246. package/out/street-normalize.js.map +1 -1
  247. package/out/street-segment-schema.d.ts +6 -2
  248. package/out/street-segment-schema.d.ts.map +1 -1
  249. package/out/street-segment-schema.js.map +1 -1
  250. package/out/types.d.ts +74 -1
  251. package/out/types.d.ts.map +1 -1
  252. package/out/unified-schema.d.ts +1 -1
  253. package/out/unified-schema.js +1 -1
  254. package/out/uprn-lookup.d.ts +85 -0
  255. package/out/uprn-lookup.d.ts.map +1 -0
  256. package/out/uprn-lookup.js +152 -0
  257. package/out/uprn-lookup.js.map +1 -0
  258. package/out/uprn-schema.d.ts +93 -0
  259. package/out/uprn-schema.d.ts.map +1 -0
  260. package/out/uprn-schema.js +78 -0
  261. package/out/uprn-schema.js.map +1 -0
  262. package/out/weights-overlay-linker.d.ts +141 -0
  263. package/out/weights-overlay-linker.d.ts.map +1 -0
  264. package/out/weights-overlay-linker.js +259 -0
  265. package/out/weights-overlay-linker.js.map +1 -0
  266. package/package.json +296 -16
  267. package/place-importance-schema.ts +402 -0
  268. package/poi-lookup.ts +12 -13
  269. package/poi-schema.ts +8 -3
  270. package/polygon-schema.ts +47 -0
  271. package/postal-city-alias-lookup.ts +1 -1
  272. package/postal-city-candidate-schema.ts +3 -1
  273. package/postcode-point-lookup.ts +1 -1
  274. package/primary-preference.ts +207 -0
  275. package/proximity-rerank.ts +120 -0
  276. package/region-keys.ts +144 -0
  277. package/reverse.ts +17 -16
  278. package/schema.ts +1 -1
  279. package/search-fetch.ts +256 -0
  280. package/sharding.ts +3 -3
  281. package/sqlite-convention-source.ts +1 -1
  282. package/sqlite-utils.ts +63 -1
  283. package/street-centroid-schema.ts +8 -2
  284. package/street-centroid.ts +13 -8
  285. package/street-morphology-fst-builder.ts +5 -4
  286. package/street-normalize.ts +254 -24
  287. package/street-segment-schema.ts +7 -2
  288. package/types.ts +74 -1
  289. package/unified-schema.ts +1 -1
  290. package/uprn-lookup.ts +210 -0
  291. package/uprn-schema.ts +124 -0
  292. package/weights-overlay-linker.ts +377 -0
  293. package/geo.ts +0 -121
  294. package/out/geo.d.ts +0 -74
  295. package/out/geo.d.ts.map +0 -1
  296. package/out/geo.js +0 -71
  297. 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 bytes] wofID u32 placetypeIdx u8 index into PLACETYPE_ORDER chainLen
22
- * u8 0..8 _pad u16 nameIdx u32 index into string table importance f32 Wikipedia importance [0,1]
23
- * (V2); was population u32 (V1) lat f32 lon f32 chain [u32; 8] parent chain (unused slots = 0)
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 = 4
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 importance.
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 = 56
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 reserved.
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
- buf.writeUInt8(0, pp + 7)
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.importance, pp + 12)
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) * PLACE_ENTRY_SIZE
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
- const rawImportance = isV2
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
- importance: rawImportance,
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
- importance: number
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(Boolean)
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 `WOFSqlitePlaceLookup` (lazy build via `buildFTS: true`) and the operator-side
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 `WOFSqlitePlaceLookup` and the CLI can't drift apart.
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 `#exactMatchIds` fallback, the WASM
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 `WOFSqlitePlaceLookup` for its "FTS
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 {
@@ -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 postal code to the sanitized-query token shape — strip every non-letter/number — so the
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 postal codes for `countries` into an open unified/postcode ingest DB: one `spr` row per distinct
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 { WOFSqlitePlaceLookup, type RankingWeights, type WOFSqlitePlaceLookupOpts } from "./lookup.ts"
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 { haversineKm } from "./geo.ts"
36
- import { hasTable } from "./sqlite-utils.ts"
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: ReturnType<DatabaseSync["prepare"]> | undefined
113
- readonly #byStreet: ReturnType<DatabaseSync["prepare"]> | undefined
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 = this.#db.prepare(
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 = this.#db.prepare(
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 (!streetNorm || !/^\d+$/.test(numberRaw)) return null
240
+ if (!/^\d+$/.test(numberRaw)) return null
178
241
  const n = Number(numberRaw)
179
242
 
180
- let rows: SegmentRow[]
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 = this.#byPostcode.all(query.postcode.trim(), streetNorm, n, n) as unknown as SegmentRow[]
187
- } else {
188
- // No scope given: a name matching ranges across several ZIPs is ambiguous — abstain.
189
- rows = this.#byStreet.all(streetNorm, n, n) as unknown as SegmentRow[]
190
- const postcodes = new Set(rows.map((r) => r.postcode ?? ""))
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 (postcodes.size > 1) return null
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
- const pool = preferred.length ? preferred : rows
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