@mailwoman/resolver-wof-sqlite 9.1.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 (278) 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 +200 -163
  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 +2 -1
  18. package/candidate-lookup.ts +439 -186
  19. package/candidate-schema.ts +33 -5
  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 +91 -119
  30. package/fst-builder.ts +14 -12
  31. package/fst-freshness.ts +2 -2
  32. package/fts-query.ts +1 -1
  33. package/fts.ts +4 -4
  34. package/geonames-postal.ts +2 -2
  35. package/index.ts +18 -14
  36. package/interpolation.ts +113 -19
  37. package/lookup.ts +110 -591
  38. package/name-score.ts +6 -4
  39. package/out/address-point-interpolation.d.ts.map +1 -1
  40. package/out/address-point-interpolation.js +13 -7
  41. package/out/address-point-interpolation.js.map +1 -1
  42. package/out/address-point-schema.d.ts +16 -6
  43. package/out/address-point-schema.d.ts.map +1 -1
  44. package/out/address-point-schema.js.map +1 -1
  45. package/out/address-point.d.ts.map +1 -1
  46. package/out/address-point.js +70 -14
  47. package/out/address-point.js.map +1 -1
  48. package/out/ancestry.d.ts +2 -2
  49. package/out/ancestry.d.ts.map +1 -1
  50. package/out/ancestry.js +5 -6
  51. package/out/ancestry.js.map +1 -1
  52. package/out/build-candidate.d.ts +75 -0
  53. package/out/build-candidate.d.ts.map +1 -1
  54. package/out/build-candidate.js +120 -122
  55. package/out/build-candidate.js.map +1 -1
  56. package/out/build-slim.d.ts +1 -1
  57. package/out/build-slim.js +3 -3
  58. package/out/build-slim.js.map +1 -1
  59. package/out/candidate/alias-bags.d.ts +17 -0
  60. package/out/candidate/alias-bags.d.ts.map +1 -0
  61. package/out/candidate/alias-bags.js +39 -0
  62. package/out/candidate/alias-bags.js.map +1 -0
  63. package/out/candidate/ancestors-sidecar.d.ts +33 -0
  64. package/out/candidate/ancestors-sidecar.d.ts.map +1 -0
  65. package/out/candidate/ancestors-sidecar.js +140 -0
  66. package/out/candidate/ancestors-sidecar.js.map +1 -0
  67. package/out/candidate/country-display-names.d.ts +35 -0
  68. package/out/candidate/country-display-names.d.ts.map +1 -0
  69. package/out/candidate/country-display-names.js +59 -0
  70. package/out/candidate/country-display-names.js.map +1 -0
  71. package/out/candidate/name-roles.d.ts +55 -0
  72. package/out/candidate/name-roles.d.ts.map +1 -0
  73. package/out/candidate/name-roles.js +165 -0
  74. package/out/candidate/name-roles.js.map +1 -0
  75. package/out/candidate/own-name.d.ts +50 -0
  76. package/out/candidate/own-name.d.ts.map +1 -0
  77. package/out/candidate/own-name.js +132 -0
  78. package/out/candidate/own-name.js.map +1 -0
  79. package/out/candidate/place-attrs.d.ts +43 -0
  80. package/out/candidate/place-attrs.d.ts.map +1 -0
  81. package/out/candidate/place-attrs.js +15 -0
  82. package/out/candidate/place-attrs.js.map +1 -0
  83. package/out/candidate/shard-fold.d.ts +31 -0
  84. package/out/candidate/shard-fold.d.ts.map +1 -0
  85. package/out/candidate/shard-fold.js +104 -0
  86. package/out/candidate/shard-fold.js.map +1 -0
  87. package/out/candidate-ancestors-schema.d.ts +150 -0
  88. package/out/candidate-ancestors-schema.d.ts.map +1 -0
  89. package/out/candidate-ancestors-schema.js +123 -0
  90. package/out/candidate-ancestors-schema.js.map +1 -0
  91. package/out/candidate-fts.d.ts +4 -2
  92. package/out/candidate-fts.d.ts.map +1 -1
  93. package/out/candidate-fts.js +4 -2
  94. package/out/candidate-fts.js.map +1 -1
  95. package/out/candidate-importance.d.ts.map +1 -1
  96. package/out/candidate-importance.js +1 -1
  97. package/out/candidate-importance.js.map +1 -1
  98. package/out/candidate-lookup.d.ts +22 -45
  99. package/out/candidate-lookup.d.ts.map +1 -1
  100. package/out/candidate-lookup.js +340 -135
  101. package/out/candidate-lookup.js.map +1 -1
  102. package/out/candidate-schema.d.ts +30 -6
  103. package/out/candidate-schema.d.ts.map +1 -1
  104. package/out/candidate-schema.js +3 -0
  105. package/out/candidate-schema.js.map +1 -1
  106. package/out/candidate-scoring.d.ts +34 -0
  107. package/out/candidate-scoring.d.ts.map +1 -0
  108. package/out/candidate-scoring.js +200 -0
  109. package/out/candidate-scoring.js.map +1 -0
  110. package/out/capital-schema.d.ts +51 -0
  111. package/out/capital-schema.d.ts.map +1 -0
  112. package/out/capital-schema.js +63 -0
  113. package/out/capital-schema.js.map +1 -0
  114. package/out/capitals.d.ts +69 -0
  115. package/out/capitals.d.ts.map +1 -0
  116. package/out/capitals.js +98 -0
  117. package/out/capitals.js.map +1 -0
  118. package/out/coincident-roles.d.ts +7 -0
  119. package/out/coincident-roles.d.ts.map +1 -1
  120. package/out/coincident-roles.js +42 -8
  121. package/out/coincident-roles.js.map +1 -1
  122. package/out/convention-schema.d.ts +51 -0
  123. package/out/convention-schema.d.ts.map +1 -0
  124. package/out/convention-schema.js +34 -0
  125. package/out/convention-schema.js.map +1 -0
  126. package/out/convention.d.ts +1 -1
  127. package/out/convention.js +2 -2
  128. package/out/coverage-manifest-schema.js +3 -7
  129. package/out/coverage-manifest-schema.js.map +1 -1
  130. package/out/currency-backfill.d.ts +46 -0
  131. package/out/currency-backfill.d.ts.map +1 -0
  132. package/out/currency-backfill.js +180 -0
  133. package/out/currency-backfill.js.map +1 -0
  134. package/out/exact-match.d.ts +25 -0
  135. package/out/exact-match.d.ts.map +1 -0
  136. package/out/exact-match.js +89 -0
  137. package/out/exact-match.js.map +1 -0
  138. package/out/fst-autocomplete.d.ts +11 -11
  139. package/out/fst-autocomplete.d.ts.map +1 -1
  140. package/out/fst-autocomplete.js +82 -99
  141. package/out/fst-autocomplete.js.map +1 -1
  142. package/out/fst-builder.d.ts.map +1 -1
  143. package/out/fst-builder.js +11 -12
  144. package/out/fst-builder.js.map +1 -1
  145. package/out/fst-freshness.d.ts +2 -2
  146. package/out/fst-freshness.js +2 -2
  147. package/out/fts-query.js +1 -1
  148. package/out/fts-query.js.map +1 -1
  149. package/out/fts.d.ts +4 -4
  150. package/out/fts.js +4 -4
  151. package/out/geonames-postal.d.ts +2 -2
  152. package/out/geonames-postal.js +2 -2
  153. package/out/index.d.ts +3 -2
  154. package/out/index.d.ts.map +1 -1
  155. package/out/index.js +2 -2
  156. package/out/index.js.map +1 -1
  157. package/out/interpolation.d.ts +8 -0
  158. package/out/interpolation.d.ts.map +1 -1
  159. package/out/interpolation.js +91 -19
  160. package/out/interpolation.js.map +1 -1
  161. package/out/lookup.d.ts +4 -5
  162. package/out/lookup.d.ts.map +1 -1
  163. package/out/lookup.js +94 -468
  164. package/out/lookup.js.map +1 -1
  165. package/out/name-score.d.ts +0 -10
  166. package/out/name-score.d.ts.map +1 -1
  167. package/out/name-score.js +6 -4
  168. package/out/name-score.js.map +1 -1
  169. package/out/place-importance-schema.d.ts +42 -5
  170. package/out/place-importance-schema.d.ts.map +1 -1
  171. package/out/place-importance-schema.js +54 -8
  172. package/out/place-importance-schema.js.map +1 -1
  173. package/out/poi-lookup.d.ts +1 -1
  174. package/out/poi-lookup.d.ts.map +1 -1
  175. package/out/poi-lookup.js +12 -13
  176. package/out/poi-lookup.js.map +1 -1
  177. package/out/poi-schema.d.ts +7 -3
  178. package/out/poi-schema.d.ts.map +1 -1
  179. package/out/poi-schema.js.map +1 -1
  180. package/out/polygon-schema.d.ts +37 -0
  181. package/out/polygon-schema.d.ts.map +1 -0
  182. package/out/polygon-schema.js +23 -0
  183. package/out/polygon-schema.js.map +1 -0
  184. package/out/postal-city-alias-lookup.d.ts +1 -1
  185. package/out/postal-city-alias-lookup.js +1 -1
  186. package/out/postal-city-candidate-schema.d.ts +2 -1
  187. package/out/postal-city-candidate-schema.d.ts.map +1 -1
  188. package/out/postal-city-candidate-schema.js.map +1 -1
  189. package/out/postcode-point-lookup.d.ts +1 -1
  190. package/out/postcode-point-lookup.js +1 -1
  191. package/out/primary-preference.d.ts +125 -0
  192. package/out/primary-preference.d.ts.map +1 -0
  193. package/out/primary-preference.js +138 -0
  194. package/out/primary-preference.js.map +1 -0
  195. package/out/proximity-rerank.d.ts +77 -0
  196. package/out/proximity-rerank.d.ts.map +1 -0
  197. package/out/proximity-rerank.js +86 -0
  198. package/out/proximity-rerank.js.map +1 -0
  199. package/out/region-keys.d.ts +47 -0
  200. package/out/region-keys.d.ts.map +1 -0
  201. package/out/region-keys.js +121 -0
  202. package/out/region-keys.js.map +1 -0
  203. package/out/reverse.d.ts.map +1 -1
  204. package/out/reverse.js +6 -9
  205. package/out/reverse.js.map +1 -1
  206. package/out/schema.d.ts +1 -1
  207. package/out/search-fetch.d.ts +57 -0
  208. package/out/search-fetch.d.ts.map +1 -0
  209. package/out/search-fetch.js +183 -0
  210. package/out/search-fetch.js.map +1 -0
  211. package/out/sharding.d.ts +3 -3
  212. package/out/sharding.js +1 -1
  213. package/out/sqlite-convention-source.d.ts +1 -1
  214. package/out/sqlite-convention-source.js +1 -1
  215. package/out/sqlite-utils.d.ts +19 -1
  216. package/out/sqlite-utils.d.ts.map +1 -1
  217. package/out/sqlite-utils.js +19 -1
  218. package/out/sqlite-utils.js.map +1 -1
  219. package/out/street-centroid-schema.d.ts +7 -2
  220. package/out/street-centroid-schema.d.ts.map +1 -1
  221. package/out/street-centroid-schema.js.map +1 -1
  222. package/out/street-centroid.d.ts.map +1 -1
  223. package/out/street-centroid.js +7 -7
  224. package/out/street-centroid.js.map +1 -1
  225. package/out/street-normalize.d.ts +82 -9
  226. package/out/street-normalize.d.ts.map +1 -1
  227. package/out/street-normalize.js +175 -9
  228. package/out/street-normalize.js.map +1 -1
  229. package/out/street-segment-schema.d.ts +6 -2
  230. package/out/street-segment-schema.d.ts.map +1 -1
  231. package/out/street-segment-schema.js.map +1 -1
  232. package/out/types.d.ts +35 -1
  233. package/out/types.d.ts.map +1 -1
  234. package/out/unified-schema.d.ts +1 -1
  235. package/out/unified-schema.js +1 -1
  236. package/out/uprn-lookup.d.ts +85 -0
  237. package/out/uprn-lookup.d.ts.map +1 -0
  238. package/out/uprn-lookup.js +152 -0
  239. package/out/uprn-lookup.js.map +1 -0
  240. package/out/uprn-schema.d.ts +93 -0
  241. package/out/uprn-schema.d.ts.map +1 -0
  242. package/out/uprn-schema.js +78 -0
  243. package/out/uprn-schema.js.map +1 -0
  244. package/out/weights-overlay-linker.d.ts +141 -0
  245. package/out/weights-overlay-linker.d.ts.map +1 -0
  246. package/out/weights-overlay-linker.js +259 -0
  247. package/out/weights-overlay-linker.js.map +1 -0
  248. package/package.json +288 -16
  249. package/place-importance-schema.ts +64 -15
  250. package/poi-lookup.ts +12 -13
  251. package/poi-schema.ts +8 -3
  252. package/polygon-schema.ts +47 -0
  253. package/postal-city-alias-lookup.ts +1 -1
  254. package/postal-city-candidate-schema.ts +3 -1
  255. package/postcode-point-lookup.ts +1 -1
  256. package/primary-preference.ts +207 -0
  257. package/proximity-rerank.ts +120 -0
  258. package/region-keys.ts +144 -0
  259. package/reverse.ts +17 -16
  260. package/schema.ts +1 -1
  261. package/search-fetch.ts +256 -0
  262. package/sharding.ts +3 -3
  263. package/sqlite-convention-source.ts +1 -1
  264. package/sqlite-utils.ts +43 -2
  265. package/street-centroid-schema.ts +8 -2
  266. package/street-centroid.ts +13 -8
  267. package/street-normalize.ts +252 -23
  268. package/street-segment-schema.ts +7 -2
  269. package/types.ts +35 -1
  270. package/unified-schema.ts +1 -1
  271. package/uprn-lookup.ts +210 -0
  272. package/uprn-schema.ts +124 -0
  273. package/weights-overlay-linker.ts +377 -0
  274. package/geo.ts +0 -121
  275. package/out/geo.d.ts +0 -74
  276. package/out/geo.d.ts.map +0 -1
  277. package/out/geo.js +0 -71
  278. package/out/geo.js.map +0 -1
@@ -23,6 +23,37 @@
23
23
  */
24
24
 
25
25
  import { AbbreviationToDirectional, US_STREET_SUFFIX_LOOKUP } from "@mailwoman/codex/us"
26
+ import type { Tagged } from "type-fest"
27
+
28
+ /**
29
+ * A place name folded by {@link normalizeLocalityForKey} — the value stored in, and required to probe, every
30
+ * `name_key`-style column: the candidate gazetteer, the postal-city side-index, the street-centroid shard, an ancestor
31
+ * chain's `parent_name_key`, and the address-point locality scope.
32
+ *
33
+ * The brand is here because the fold is applied at BUILD time and is therefore mandatory at QUERY time, while a
34
+ * near-miss approximation of it (`toLowerCase()`, `trim()`) is still a `string`: it binds to the parameter, returns
35
+ * fewer rows, and the shortfall reads as a coverage gap in the data rather than a defect in the probe. Requiring the
36
+ * brand at the seam turns that silent under-match into a compile error. Mint one only by calling the fold.
37
+ */
38
+ export type NameKey = Tagged<string, "NameKey">
39
+
40
+ /**
41
+ * A street name folded by {@link normalizeStreetForKey} / {@link normalizeStreetForKeyLocale} — the value stored in a
42
+ * `street_norm` address-point column and required to probe it.
43
+ *
44
+ * Distinct from {@link RouteKey}: the route fold is a FURTHER canonicalization applied to some columns and not others,
45
+ * so the two are not interchangeable even though both are folded streets.
46
+ */
47
+ export type StreetKey = Tagged<string, "StreetKey">
48
+
49
+ /**
50
+ * A street key that has additionally passed {@link canonicalizeRouteKey} — the numbered-route canonical form.
51
+ *
52
+ * A separate brand from {@link StreetKey} because it identifies a DIFFERENT set of columns, and the two folds disagree
53
+ * exactly on the rows that motivate the route fold: binding a plain street key to a route-folded column silently misses
54
+ * every row whose source spelled the route differently, which is the miss class the fold exists to close.
55
+ */
56
+ export type RouteKey = Tagged<string, "RouteKey">
26
57
 
27
58
  /**
28
59
  * Token count a street must exceed before its trailing pair is merged. At or below it the pair IS the whole street
@@ -86,10 +117,10 @@ function fold(input: string): string {
86
117
  * Normalize a street name for address-point keying. Same function at build time and lookup time — see module docstring
87
118
  * for the contract.
88
119
  */
89
- export function normalizeStreetForKey(street: string): string {
120
+ export function normalizeStreetForKey(street: string): StreetKey {
90
121
  const tokens = fold(street).split(" ")
91
122
 
92
- if (!tokens.length) return ""
123
+ if (!tokens.length) return "" as StreetKey
93
124
 
94
125
  // Spelled-ordinal street names → digit form when a street suffix follows ("Tenth Street" →
95
126
  // "10th street", #723). Gated on the next token being a suffix so ordinal-WORD names are untouched.
@@ -151,7 +182,7 @@ export function normalizeStreetForKey(street: string): string {
151
182
  }
152
183
  }
153
184
 
154
- return tokens.join(" ")
185
+ return tokens.join(" ") as StreetKey
155
186
  }
156
187
 
157
188
  /**
@@ -162,7 +193,7 @@ export function normalizeStreetForKey(street: string): string {
162
193
  * article, so no salient-token / multi-key index is built yet (deferred until probing shows the normalizer can't absorb
163
194
  * the false-negatives).
164
195
  */
165
- export type StreetLocale = "us" | "en" | "fr" | "de" | "nl"
196
+ export type StreetLocale = "us" | "en" | "fr" | "de" | "nl" | "pl" | "vn" | "id"
166
197
 
167
198
  /**
168
199
  * French street-type abbreviations → canonical full form, applied per token after {@link fold}. French address types
@@ -191,6 +222,25 @@ const FR_STREET_ABBREV = new Map<string, string>([
191
222
  ["sts", "saints"],
192
223
  ])
193
224
 
225
+ /**
226
+ * Polish leading street-type tokens, STRIPPED rather than expanded. Measured on the 5.56M-row PL OSM shard
227
+ * (2026-08-19): OSM Poland tags `addr:street` BARE — `ulica%` covers 22 rows and `ul.%` eight, so an EXPANSION rule
228
+ * makes a typed query ("ul. Świętokrzyska" → "ulica swietokrzyska") miss the 3,846 bare "swietokrzyska" rows. Stripping
229
+ * the leading type on BOTH sides keys typed and bare surfaces identically. The full words are stripped too: the
230
+ * aleja/plac/osiedle populations (48,597 / 26,815 / 39,552 rows) spell the word out, and "Plac Zamkowy" must key the
231
+ * same as a query's "plac zamkowy" or bare "Zamkowy". Never stripped when it is the only token.
232
+ */
233
+ const PL_LEADING_TYPE = new Set(["ul", "ulica", "al", "aleja", "aleje", "pl", "plac", "os", "osiedle"])
234
+
235
+ /**
236
+ * Indonesian street-type abbreviations → canonical full form, leading position ("Jl. Thamrin", "Gg. Waru").
237
+ */
238
+ const ID_STREET_ABBREV = new Map<string, string>([
239
+ ["jl", "jalan"],
240
+ ["jln", "jalan"],
241
+ ["gg", "gang"],
242
+ ])
243
+
194
244
  /**
195
245
  * Normalize a street name for the address-point key in a non-US locale. Same function build-side and probe-side (the
196
246
  * one-function discipline). US delegates to {@link normalizeStreetForKey}.
@@ -200,17 +250,32 @@ const FR_STREET_ABBREV = new Map<string, string>([
200
250
  * - **de** — fold + ß→ss + canonicalize the GLUED `-str(.)` suffix to `-strasse` ("Lindenstr." → "lindenstrasse",
201
251
  * "Lindenstraße" → "lindenstrasse"); an already-full "-strasse" is left intact.
202
252
  * - **nl** — fold + canonicalize the glued `-str` suffix to `-straat` ("Kerkstr." → "kerkstraat").
253
+ * - **pl** — fold + ł→l (Ł does not NFKD-decompose, so the generic fold keeps it and an undiacritized query would miss;
254
+ * every other Polish diacritic is a combining form the fold already strips) + STRIP the leading type token — see
255
+ * {@link PL_LEADING_TYPE} for the measured reason expansion was wrong for this source.
256
+ * - **vn** — fold + đ→d AND ð→d (both non-decomposing, and OSM mixes the two codepoints inside single values — see the
257
+ * branch comment). Deliberately NO type-abbreviation map yet: the common abbreviation is the single letter "Đ." for
258
+ * Đường, and expanding a bare folded "d" token would rewrite initials — measure the miss rate on the built shard
259
+ * before adding anything.
260
+ * - **id** — fold + expand leading type abbreviations (jl/jln→jalan, gg→gang); Indonesian street surfaces are otherwise
261
+ * ASCII-clean.
203
262
  */
204
- export function normalizeStreetForKeyLocale(street: string, locale: StreetLocale): string {
263
+ export function normalizeStreetForKeyLocale(street: string, locale: StreetLocale): StreetKey {
205
264
  if (locale === "us" || locale === "en") return normalizeStreetForKey(street)
206
265
 
207
266
  // Hyphen → space so a compound name keys the same whether the source or the query writes the
208
267
  // hyphen ("Champs-Élysées", "St-Honoré") or a space — both sides fold identically, so this is pure
209
268
  // robustness. It also splits a hyphenated abbreviation ("St-Honoré" → "st honore") into tokens the
210
- // per-locale type/Saint map can see.
211
- const tokens = fold(street).replaceAll("ß", "ss").replaceAll("-", " ").split(/\s+/).filter(Boolean)
269
+ // per-locale type/Saint map can see. Letter maps for non-decomposing letters (ß, ł, đ) live in the
270
+ // per-locale branches, NEVER here: widening the shared pipeline would silently change keys under
271
+ // every already-built shard of the other locales.
272
+ const tokens = fold(street)
273
+ .replaceAll("ß", "ss")
274
+ .replaceAll("-", " ")
275
+ .split(/\s+/)
276
+ .filter((value) => value.length > 0)
212
277
 
213
- if (!tokens.length) return ""
278
+ if (!tokens.length) return "" as StreetKey
214
279
 
215
280
  switch (locale) {
216
281
  case "fr":
@@ -236,16 +301,69 @@ export function normalizeStreetForKeyLocale(street: string, locale: StreetLocale
236
301
  }
237
302
  }
238
303
  break
304
+ case "pl":
305
+ for (let i = 0; i < tokens.length; i++) {
306
+ tokens[i] = tokens[i]!.replaceAll("ł", "l")
307
+ }
308
+
309
+ if (tokens.length > 1 && PL_LEADING_TYPE.has(tokens[0]!)) {
310
+ tokens.shift()
311
+ }
312
+ break
313
+ case "id":
314
+ for (let i = 0; i < tokens.length; i++) {
315
+ tokens[i] = ID_STREET_ABBREV.get(tokens[i]!) ?? tokens[i]!
316
+ }
317
+ break
318
+ case "vn":
319
+ // đ (U+0111, d-with-stroke) AND ð (U+00F0, eth): OSM Vietnamese data mixes the two visually
320
+ // identical codepoints inside single values — the 2026-08-19 shard measured `Đường Trần Hưng
321
+ // Đạo` carrying d-with-stroke at the front and ETH in `Đạo`, splitting the country's most
322
+ // common street into two keys with the MAJORITY variant (630 vs 421 rows) unreachable by a
323
+ // correctly-typed query. No abbreviation map — see the locale table.
324
+ for (let i = 0; i < tokens.length; i++) {
325
+ tokens[i] = tokens[i]!.replaceAll("đ", "d").replaceAll("ð", "d")
326
+ }
327
+ break
239
328
  }
240
329
 
241
- return tokens.join(" ")
330
+ return tokens.join(" ") as StreetKey
242
331
  }
243
332
 
244
333
  /**
245
334
  * Normalize a locality name for address-point keying (fold only — no street semantics).
246
335
  */
247
- export function normalizeLocalityForKey(locality: string): string {
248
- return fold(locality)
336
+ export function normalizeLocalityForKey(locality: string): NameKey {
337
+ return fold(locality) as NameKey
338
+ }
339
+
340
+ /**
341
+ * Leading French street-type token. French types LEAD the name ("avenue du Parc", "boul Saint-Laurent") while English
342
+ * types TRAIL ("Fifth Avenue", "Grosvenor Place"), so a leading type-token is the discriminating signal — "1 Avenue NE"
343
+ * starts with a digit and never matches. The separator lookahead is explicit rather than `\b` because a word boundary
344
+ * after an accented final letter ("carré") is not one to an ASCII-word `\b`.
345
+ */
346
+ const FRENCH_LEAD_TYPE =
347
+ /^(?:rue|ruelle|av|ave|avenue|boul|bd|boulevard|ch|che|chemin|all[ée]e|imp|impasse|mont[ée]e|c[ôo]te|pl|place|prom|promenade|rang|rte|route|autoroute|carr[eé]|croissant|terrasse|sentier)(?=[\s.-]|$)/i
348
+
349
+ /**
350
+ * Surface-driven street-locale ROUTER for bilingual shards — the Québec finishing move on the CA rooftop shard.
351
+ *
352
+ * A country registers ONE street locale, but Canada's street surfaces are two languages: under the `en` rules a French
353
+ * surface passes through mostly unchanged (both sides fold identically, so those rows stay reachable), and what breaks
354
+ * is abbreviation variance — the `en` rules cannot fold "boul"/"Ste-" to the full French word, so an abbreviated query
355
+ * misses a full-word row and vice versa. Routing on the SURFACE (not the province) also carries bilingual NB and the
356
+ * French street names outside Québec for free.
357
+ *
358
+ * ONE function, called by the shard BUILDER and the query PROBE alike — the #861 discipline: routing is part of the
359
+ * fold contract, and two transcriptions of this predicate would diverge exactly where it matters. Only an `en` base
360
+ * re-routes: a `fr`/`de`/`nl` shard already speaks its own rules, and the US pipeline stays untouched.
361
+ *
362
+ * Measured basis (CA shard, 2026-08-19): 183,963 distinct surfaces, 43,762 French-lead; 29,682 fold differently under
363
+ * fr-vs-en (888,265 rows), and the non-French half of those are English surfaces this router keeps on `en` unchanged.
364
+ */
365
+ export function streetLocaleForSurface(street: string, base: StreetLocale): StreetLocale {
366
+ return base === "en" && FRENCH_LEAD_TYPE.test(street.trimStart()) ? "fr" : base
249
367
  }
250
368
 
251
369
  /**
@@ -254,12 +372,13 @@ export function normalizeLocalityForKey(locality: string): string {
254
372
  * only French communes subdivided into _arrondissements municipaux_; a national register (BAN) names each row per
255
373
  * arrondissement, but a query names the base commune ("Place Bellecour, Lyon", never "…, Lyon 2e"). Applied on BOTH
256
374
  * sides of the #1042 street-centroid key — build-side (deriving the `locality_base` column) and query-side (folding the
257
- * probe commune) — so the two agree by construction (the one-function discipline). Input must already be folded
258
- * (lower-case, diacritic-stripped); a no-op for every other commune. Returns the input unchanged if the strip would
259
- * empty it.
375
+ * probe commune) — so the two agree by construction (the one-function discipline). The {@link NameKey} parameter is the
376
+ * enforcement of "must already be folded": stripping an arrondissement off an unfolded surface yields a key neither
377
+ * side stores. A no-op for every other commune, and the strip leaves a fold fixed point, so the result is still a
378
+ * {@link NameKey}. Returns the input unchanged if the strip would empty it.
260
379
  */
261
- export function stripArrondissement(localityNorm: string): string {
262
- const stripped = localityNorm.replace(/\s+\d+(?:er|e)\s+arrondissement$/, "").trim()
380
+ export function stripArrondissement(localityNorm: NameKey): NameKey {
381
+ const stripped = localityNorm.replace(/\s+\d+(?:er|e)\s+arrondissement$/, "").trim() as NameKey
263
382
 
264
383
  return stripped || localityNorm
265
384
  }
@@ -284,10 +403,43 @@ export function stripLocalityQualifier(locality: string): string {
284
403
  s = s.split("/")[0]!.trim()
285
404
  }
286
405
 
287
- // "Kraubath/Mur", "St.Kanzian/Klopeiner See"
288
- s = s.replace(/\s+[a-zà-ÿ]\.\s*\S.*$/iu, "") // abbreviated " b.Graz" / " o.Bleiburg" / " a.d. …"
289
- s = s.replace(/\s+(im|an der|ob|bei|in der|unter|vor)\s+\S.*$/iu, "") // " im Simmental", " bei Graz"
290
- s = s.replace(/\s+(S|N|E|W|V|Ø|Sø|Fyn|Thy|Sjælland|Jylland|[A-ZÅÄÖ]{2})$/u, "") // " S", " VD", " Thy"
406
+ const words = [...s.matchAll(/\S+/gu)].map((match) => ({ value: match[0], index: match.index }))
407
+ let qualifierStart: number | undefined
408
+
409
+ for (let index = 1; index < words.length; index++) {
410
+ const word = words[index]!
411
+ const first = word.value[0] ?? ""
412
+ const abbreviated = /[a-zà-ÿ]/iu.test(first) && word.value[1] === "." && word.value.length > 2
413
+ const oneWordQualifier = ["im", "ob", "bei", "unter", "vor"].includes(word.value.toLowerCase())
414
+
415
+ const twoWordQualifier =
416
+ ["an", "in"].includes(word.value.toLowerCase()) && words[index + 1]?.value.toLowerCase() === "der"
417
+
418
+ const hasQualifierValue = oneWordQualifier ? index + 1 < words.length : index + 2 < words.length
419
+
420
+ if (abbreviated || ((oneWordQualifier || twoWordQualifier) && hasQualifierValue)) {
421
+ qualifierStart = word.index
422
+
423
+ break
424
+ }
425
+ }
426
+
427
+ const suffix = words.at(-1)
428
+
429
+ if (
430
+ qualifierStart === undefined &&
431
+ suffix &&
432
+ words.length > 1 &&
433
+ (["S", "N", "E", "W", "V", "Ø", "Sø", "Fyn", "Thy", "Sjælland", "Jylland"].includes(suffix.value) ||
434
+ /^[A-ZÅÄÖ]{2}$/u.test(suffix.value))
435
+ ) {
436
+ qualifierStart = suffix.index
437
+ }
438
+
439
+ if (qualifierStart !== undefined) {
440
+ s = s.slice(0, qualifierStart).trimEnd()
441
+ }
442
+
291
443
  s = s.trim()
292
444
 
293
445
  return s === locality.trim() ? "" : s
@@ -309,10 +461,87 @@ export function stripLocalityQualifier(locality: string): string {
309
461
  * is ambiguous (designator unknown) and it stays unfolded — a bare-route query therefore misses rather than guessing a
310
462
  * designator.
311
463
  */
312
- export function canonicalizeRouteKey(streetNorm: string): string {
464
+ export function canonicalizeRouteKey(streetNorm: StreetKey): RouteKey {
313
465
  const match = /^(us|state|[a-z]{2}) (?:route|rte|rt|highway|hwy) (\d.*)$/.exec(streetNorm)
314
466
 
315
- if (!match) return streetNorm
467
+ // A street naming no route is already its own route key. {@link StreetKey} and {@link RouteKey} are
468
+ // SIBLING brands rather than nested ones — precisely so neither column accepts the other's key — so
469
+ // re-minting here is an explicit hop through the unbranded string.
470
+ if (!match) return streetNorm as string as RouteKey
471
+
472
+ return `${match[1] === "us" ? "us" : "state"} route ${match[2]}` as RouteKey
473
+ }
474
+
475
+ /**
476
+ * The canonical street-type words (lowercase) — the VALUE side of the codex suffix table. Membership means "this
477
+ * normalized token is a fully-spelled street type" ("place", "street", "road" …), which is how the doubled-type
478
+ * collapse below recognizes its shape without any parse-tree knowledge.
479
+ */
480
+ const CANONICAL_TYPE_WORDS: ReadonlySet<string> = new Set(
481
+ [...US_STREET_SUFFIX_LOOKUP.values()].map((v) => v.toLowerCase())
482
+ )
483
+
484
+ /**
485
+ * Ordered lookup-key variants for a US/EN street span — the primary normalized key first, then the null-only recovery
486
+ * forms a reader may probe when the primary misses. Two register mismatches motivate them, both measured on live
487
+ * queries (2026-08-14):
488
+ *
489
+ * - **Doubled type** — a user types the type twice ("Saint Pauls PL St"), and the normalizer canonicalizes only the LAST
490
+ * type token, leaving `saint pauls pl street`, which matches nothing anywhere. The signature is visible in the key
491
+ * itself: a canonical type word in last position DIRECTLY after an uncanonicalized type abbreviation. The variant
492
+ * drops the trailing word and canonicalizes what remains (`saint pauls place`). A street genuinely named with two
493
+ * types keys identically on both sides and is caught by the primary probe first.
494
+ * - **Saint↔St register split** — the artifacts preserve each SOURCE's spelling (NYC situs keys `st pauls place`, Nassau
495
+ * keys `saint pauls place`), and a query arrives in whichever register the user typed. A leading `saint` or `st`
496
+ * token is swapped for its sibling; leading position only — a leading `st` is always the hagionym in US street names,
497
+ * and interior tokens ("Mount Saint Helens Dr") are out of scope until measured.
498
+ *
499
+ * Deduplicated and ordered most-literal-first, so probing the list in order preserves the primary key's precedence.
500
+ */
501
+ export function streetKeyVariants(street: string, locale: StreetLocale = "us"): StreetKey[] {
502
+ const primary = locale === "us" ? normalizeStreetForKey(street) : normalizeStreetForKeyLocale(street, locale)
503
+ const variants: StreetKey[] = primary ? [primary] : []
504
+
505
+ if (!primary || (locale !== "us" && locale !== "en")) return variants
506
+
507
+ const tokens = primary.split(" ")
508
+ const last = tokens.at(-1)
509
+ const secondLast = tokens.at(-2)
510
+
511
+ if (
512
+ tokens.length > 2 &&
513
+ last &&
514
+ secondLast &&
515
+ CANONICAL_TYPE_WORDS.has(last) &&
516
+ !CANONICAL_TYPE_WORDS.has(secondLast) &&
517
+ US_STREET_SUFFIX_LOOKUP.has(secondLast)
518
+ ) {
519
+ const collapsed = normalizeStreetForKey(tokens.slice(0, -1).join(" "))
520
+
521
+ if (collapsed && !variants.includes(collapsed)) {
522
+ variants.push(collapsed)
523
+ }
524
+ }
525
+
526
+ // Snapshot before the swap pass — it appends while reading, and iterating the live array would
527
+ // re-visit its own additions.
528
+ const preSwap = variants.slice()
529
+
530
+ for (const variant of preSwap) {
531
+ const [head, ...rest] = variant.split(" ")
532
+ const swapped = head === "saint" ? "st" : head === "st" ? "saint" : null
533
+
534
+ if (swapped && rest.length) {
535
+ // Substituting a leading hagionym token in an already-normalized key leaves a fold fixed point —
536
+ // the suffix pass never rewrites position 0 — so the swap yields a {@link StreetKey} without
537
+ // re-folding.
538
+ const candidate = [swapped, ...rest].join(" ") as StreetKey
539
+
540
+ if (!variants.includes(candidate)) {
541
+ variants.push(candidate)
542
+ }
543
+ }
544
+ }
316
545
 
317
- return `${match[1] === "us" ? "us" : "state"} route ${match[2]}`
546
+ return variants
318
547
  }
@@ -17,6 +17,8 @@
17
17
 
18
18
  import type { Kysely } from "kysely"
19
19
 
20
+ import type { RouteKey } from "./street-normalize.ts"
21
+
20
22
  /**
21
23
  * One TIGER street-segment edge: a `(from_hn, to_hn)` house-number range on one `side` of a named street, with the
22
24
  * geometry the interpolator walks. `min_hn`/`max_hn` are the sorted bounds (the probe filters on them); `parity` is
@@ -24,9 +26,12 @@ import type { Kysely } from "kysely"
24
26
  */
25
27
  export interface StreetSegmentTable {
26
28
  /**
27
- * Shared {@link normalizeStreetForKey} of the street — the build/query-consistent probe key.
29
+ * `canonicalizeRouteKey(normalizeStreetForKey(street))` — the build/query-consistent probe key. The column NAME says
30
+ * `street_norm`, but the value carries the route fold on top of the street fold, which is why the brand is
31
+ * {@link RouteKey}: builder and probe both apply both folds, and a plain street key bound here misses every
32
+ * numbered-route row.
28
33
  */
29
- street_norm: string
34
+ street_norm: RouteKey
30
35
  /**
31
36
  * `L` or `R` — the TIGER side the address range sits on.
32
37
  */
package/types.ts CHANGED
@@ -39,7 +39,7 @@ export type WOFPlacetype =
39
39
  * treat it as ordinal, not absolute.
40
40
  *
41
41
  * `id` is the WOF place id. It's named generically (not `wof_id`) so the shape stays structurally compatible with
42
- * `@mailwoman/resolver`'s `ResolvedPlace` — `WOFSqlitePlaceLookup` satisfies the generic `ResolverBackend` contract
42
+ * `@mailwoman/resolver`'s `ResolvedPlace` — `WOFSQLitePlaceLookup` satisfies the generic `ResolverBackend` contract
43
43
  * without an adapter shim.
44
44
  *
45
45
  * `distanceKm` is populated only when the query carried `near` (and the place has a centroid). Useful for downstream
@@ -122,6 +122,14 @@ export interface PlaceCandidate {
122
122
  * the falsehood-detection differentiator.
123
123
  */
124
124
  mismatch?: boolean
125
+ /**
126
+ * Admin-containment stamp (#1717 stage 2) — TRI-STATE, mirroring `ResolvedPlace.containedByQualifier` in
127
+ * `@mailwoman/core`: `true` = the ancestors sidecar vouches this candidate sits under the query's
128
+ * {@link FindPlaceQuery.regionQualifier}; `false` = evaluated and not vouched for; absent = never evaluated (no
129
+ * qualifier on the query, or an artifact without the sidecar). Absence is load-bearing — the resolver walk reads it
130
+ * as `unavailable`, never as "not contained".
131
+ */
132
+ containedByQualifier?: boolean
125
133
  }
126
134
 
127
135
  /**
@@ -163,6 +171,24 @@ export interface FindPlaceQuery {
163
171
  * ISO 3166-1 alpha-2 — narrows to one country.
164
172
  */
165
173
  country?: string
174
+ /**
175
+ * ISO 3166-1 alpha-2 — narrows the TYPO-FUZZY tier only (#1585). Exact and qualifier-strip probes stay worldwide (a
176
+ * locale hint is a prior, never a hard filter on exact matches), but a typo CORRECTION into a different country's
177
+ * namespace is nearly always a scrape, so the corrected-key probes honor this scope and a scoped-empty ABSTAINS
178
+ * rather than falling through to a world-fuzzy candidate. Ignored when `country` is set (already narrower).
179
+ */
180
+ fuzzyCountry?: string
181
+ /**
182
+ * Restrict name matching to PRIMARY-keyed rows (#1632) — set by probes whose surface is a RE-READING (a token cut out
183
+ * of a longer classified span), which never named an alias. See the ResolverBackend contract in
184
+ * `@mailwoman/core/resolver`.
185
+ */
186
+ primaryOnly?: boolean
187
+ /**
188
+ * Alias-row NAME ROLES the probe refuses to answer through (#1730) — the bare-toponym side races pass `abbr`/`gloss`.
189
+ * Role-NULL alias rows (the exonym tier) stay open; backends/artifacts without a role column ignore it.
190
+ */
191
+ excludeNameRoles?: readonly string[]
166
192
  /**
167
193
  * WOF place id — narrows to descendants of this place.
168
194
  */
@@ -181,6 +207,14 @@ export interface FindPlaceQuery {
181
207
  * → the population-first order is untouched (byte-identical).
182
208
  */
183
209
  postcodeContainmentCoherence?: boolean
210
+ /**
211
+ * The tree's parsed REGION qualifier (#1717 stage 2) — set by the resolver on locality lookups when
212
+ * `ResolveOpts.adminContainmentRerank` is on. See the `ResolverBackend` contract in `@mailwoman/core/resolver`: a
213
+ * capable backend stamps `containedByQualifier`, ranks contained candidates first, and may ADD contained same-key
214
+ * candidates a country scope hid — additive only, never a filter. Ignored on an artifact without the ancestors
215
+ * sidecar (candidates then carry no stamp).
216
+ */
217
+ regionQualifier?: string
184
218
  /**
185
219
  * Proximity hint — candidates close to this point get a ranking boost.
186
220
  */
package/unified-schema.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * (`scripts/build-unified-wof.ts`). This is the CANONICAL gazetteer — we never use the
8
8
  * off-the-shelf geocode.earth prebuilt dumps (they assign different WOF ids to the same place;
9
9
  * see the `feedback-custom-wof-db-only` memory). The table/column names match the resolver's
10
- * expectations (`lookup.ts`) so `WOFSqlitePlaceLookup` works unchanged, INCLUDING the `ancestors`
10
+ * expectations (`lookup.ts`) so `WOFSQLitePlaceLookup` works unchanged, INCLUDING the `ancestors`
11
11
  * table (which lookup.ts's parent-constraint subquery needs) — see `populateAncestors`. The
12
12
  * `place_search` FTS5 + `place_bbox` R*Tree are built separately by `build-fts` (fts.ts).
13
13
  */
package/uprn-lookup.ts ADDED
@@ -0,0 +1,210 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Node reader for `uprn.db` — the OS Open UPRN layer (`uprn-schema.ts`). Two probes:
7
+ *
8
+ * - **`coordinateOf(uprn)`**: rowid B-tree hit on the `uprn` integer PK.
9
+ * - **`nearestUPRN(lat, lon, radiusM)`**: bounded nearest-point search over the res-9 `h3_cell`
10
+ * index — ring-by-ring `gridDisk` expansion with chunked `IN` probes and haversine ranking.
11
+ * Rings stop as soon as geometry proves no unprobed cell could beat the best hit — a distance
12
+ * bound, not POILookup's row-count accumulation, so the early exit can never strand a nearer
13
+ * point in an unprobed ring.
14
+ *
15
+ * ## `null` is a claim, scoped by coverage
16
+ *
17
+ * OS designates Open UPRN complete for GB (every UPRN in AddressBase Premium with geometry), and
18
+ * the builder writes `layer_coverage` with basis `designated` for every cell the product touches.
19
+ * So a `null` from either probe inside a covered cell is evidence of absence — "no such published
20
+ * GB UPRN" / "no UPRN within the radius". Outside coverage (Northern Ireland, the Isle of Man, the
21
+ * Channel Islands, open water) it is UNKNOWN, per the meaning-of-zero rule — callers building
22
+ * negative evidence must consult `readLayerCoverage`, not this reader alone.
23
+ *
24
+ * `latLngToCell`/`gridDisk` come from `h3-js`; the 48-bit short-cell packing is
25
+ * `@mailwoman/spatial`'s `shortCellToInt` via `uprnFullCell` — never reimplemented here.
26
+ */
27
+
28
+ import { DatabaseSync } from "node:sqlite"
29
+
30
+ import { haversineKm, shortCellToInt, type H3Cell } from "@mailwoman/spatial"
31
+ import { gridDisk } from "h3-js"
32
+
33
+ import { allRows } from "./sqlite-utils.ts"
34
+ import { uprnFullCell } from "./uprn-schema.ts"
35
+
36
+ /**
37
+ * Conservative FLOOR on how much CENTRE distance one unit of res-9 GRID distance buys, metres. Adjacent centres sit √3
38
+ * × edge apart (avg edge 174.4 m → ≈302 m); the worst bearing across a ring costs a further ×0.866, and H3's projection
39
+ * distortion shrinks edges by well under the slack this leaves (the true worst is ≈217 m per grid step). Dividing a
40
+ * radius by this over-counts rings and can never miss a cell; multiplying a grid distance by it under-states reach and
41
+ * can never end the ring walk early.
42
+ */
43
+ const RES9_CENTER_SPACING_FLOOR_M = 150
44
+
45
+ /**
46
+ * Conservative CEILING on a res-9 cell's centre-to-vertex distance, metres (avg edge 174.4 m; distortion stays well
47
+ * under this). A point within `radiusM` of the query sits in a cell whose CENTRE is within `radiusM` + this.
48
+ */
49
+ const RES9_CELL_RADIUS_CEILING_M = 300
50
+
51
+ /**
52
+ * Hard ceiling on `radiusM`. Keeps the probe bounded (10 km → ~72 rings ≈ 15.8k cells ≈ 18 `IN` chunks); a caller who
53
+ * wants a wider search than "which property is this coordinate" has outgrown this reader and should say so loudly.
54
+ */
55
+ export const UPRN_MAX_NEAREST_RADIUS_M = 10_000
56
+
57
+ /**
58
+ * `IN`-list chunk size for the cell probe — far under SQLite's 32,766 bound-variable ceiling.
59
+ */
60
+ const CELL_PROBE_CHUNK = 900
61
+
62
+ export interface UPRNCoordinate {
63
+ latitude: number
64
+ longitude: number
65
+ }
66
+
67
+ export interface UPRNNearestHit {
68
+ uprn: number
69
+ latitude: number
70
+ longitude: number
71
+ /**
72
+ * Haversine distance from the query point, metres.
73
+ */
74
+ distanceM: number
75
+ }
76
+
77
+ export interface UPRNLookupOpts {
78
+ /**
79
+ * Path to a `uprn.db` built by `mailwoman`'s gazetteer pipeline. Opened read-only.
80
+ */
81
+ databasePath?: string
82
+ /**
83
+ * Pre-opened handle (tests / shared connections). Mutually exclusive with `databasePath`.
84
+ */
85
+ database?: DatabaseSync
86
+ }
87
+
88
+ interface UPRNRow {
89
+ uprn: number
90
+ lat: number
91
+ lon: number
92
+ }
93
+
94
+ /**
95
+ * Node reader over `uprn.db`. `implements Disposable` so callers can `using lookup = new UPRNLookup(...)` — the same
96
+ * precedent as {@link POILookup}.
97
+ */
98
+ export class UPRNLookup implements Disposable {
99
+ #db: DatabaseSync
100
+ #ownsDB: boolean
101
+
102
+ /**
103
+ * `uprn` → its point (rowid-alias PK hit).
104
+ */
105
+ readonly #coordinateProbe: ReturnType<DatabaseSync["prepare"]>
106
+
107
+ constructor(opts: UPRNLookupOpts) {
108
+ if (opts.database) {
109
+ this.#db = opts.database
110
+ this.#ownsDB = false
111
+ } else if (opts.databasePath) {
112
+ this.#db = new DatabaseSync(opts.databasePath, { readOnly: true })
113
+ this.#ownsDB = true
114
+ } else {
115
+ throw new Error("UPRNLookup needs `databasePath` or `database`")
116
+ }
117
+
118
+ this.#coordinateProbe = this.#db.prepare("SELECT lat, lon FROM uprn WHERE uprn = ?")
119
+ }
120
+
121
+ /**
122
+ * The WGS84 point OS publishes for `uprn`, or `null` when the layer holds no such UPRN (see the module docstring for
123
+ * what that `null` claims).
124
+ */
125
+ coordinateOf(uprn: number): UPRNCoordinate | null {
126
+ const row = this.#coordinateProbe.get(uprn) as { lat: number; lon: number } | undefined
127
+
128
+ return row ? { latitude: row.lat, longitude: row.lon } : null
129
+ }
130
+
131
+ /**
132
+ * The single nearest UPRN within `radiusM` metres of the query point, or `null` when no UPRN lies inside the radius.
133
+ *
134
+ * Bounded two ways: `radiusM` is capped at {@link UPRN_MAX_NEAREST_RADIUS_M}, and rings expand outward only until no
135
+ * unprobed cell could beat the best hit found so far (or the radius, when nothing has been found). The stop rule is
136
+ * geometric — a cell at grid distance `g` holds no point nearer than `g` × spacing floor − cell radius, using the
137
+ * same conservative constants the reach math uses — so unlike POILookup's row-count accumulation there is no
138
+ * early-exit ambiguity: a break can never strand a nearer point in an unprobed ring. This is what keeps a
139
+ * capped-radius call over dense ground at milliseconds instead of a full-disk fetch (measured 6.4 s → 13 ms for a 10
140
+ * km radius over central London, 41.6M-row layer; an empty-sea miss at the cap runs the full expansion, 74 ms).
141
+ *
142
+ * @throws {RangeError} When `radiusM` is not a positive finite number, or exceeds the cap.
143
+ */
144
+ nearestUPRN(latitude: number, longitude: number, radiusM: number): UPRNNearestHit | null {
145
+ if (!Number.isFinite(radiusM) || radiusM <= 0) {
146
+ throw new RangeError(`nearestUPRN: radiusM must be a positive finite number, received ${radiusM}`)
147
+ }
148
+
149
+ if (radiusM > UPRN_MAX_NEAREST_RADIUS_M) {
150
+ throw new RangeError(`nearestUPRN: radiusM ${radiusM} exceeds the ${UPRN_MAX_NEAREST_RADIUS_M} m cap`)
151
+ }
152
+
153
+ const origin = uprnFullCell(latitude, longitude)
154
+ const seenCells = new Set<string>()
155
+ let best: UPRNNearestHit | null = null
156
+
157
+ // `ring` is H3 grid distance; the loop terminates because the break bound is at most radiusM, which the
158
+ // RangeError above caps.
159
+ for (let ring = 0; ; ring++) {
160
+ // A cell at grid distance `ring` holds no point nearer than this. Once it exceeds what could still
161
+ // win — the best hit so far, or the radius itself — further rings cannot improve the answer.
162
+ const closestPossibleM = ring * RES9_CENTER_SPACING_FLOOR_M - RES9_CELL_RADIUS_CEILING_M
163
+
164
+ if (closestPossibleM > Math.min(radiusM, best?.distanceM ?? radiusM)) break
165
+
166
+ // gridDisk(origin, ring) returns the WHOLE disk out to `ring`; diffing against what's already been
167
+ // probed derives just this ring's new cells (the POILookup pattern).
168
+ const diskCells = gridDisk(origin, ring) as string[]
169
+ const newCells: number[] = []
170
+
171
+ for (const cell of diskCells) {
172
+ if (!seenCells.has(cell)) {
173
+ seenCells.add(cell)
174
+ newCells.push(shortCellToInt(cell as H3Cell))
175
+ }
176
+ }
177
+
178
+ for (let i = 0; i < newCells.length; i += CELL_PROBE_CHUNK) {
179
+ const chunk = newCells.slice(i, i + CELL_PROBE_CHUNK)
180
+ const placeholders = chunk.map(() => "?").join(", ")
181
+
182
+ // Prepared fresh per chunk arity — a cold, per-call path, same posture as POILookup's batched hydration.
183
+ const rows = allRows<UPRNRow>(
184
+ this.#db.prepare(`SELECT uprn, lat, lon FROM uprn WHERE h3_cell IN (${placeholders})`),
185
+ ...chunk
186
+ )
187
+
188
+ for (const row of rows) {
189
+ const distanceM = haversineKm(latitude, longitude, row.lat, row.lon) * 1000
190
+
191
+ if (distanceM <= radiusM && (best === null || distanceM < best.distanceM)) {
192
+ best = { uprn: row.uprn, latitude: row.lat, longitude: row.lon, distanceM }
193
+ }
194
+ }
195
+ }
196
+ }
197
+
198
+ return best
199
+ }
200
+
201
+ close(): void {
202
+ if (this.#ownsDB) {
203
+ this.#db.close()
204
+ }
205
+ }
206
+
207
+ [Symbol.dispose](): void {
208
+ this.close()
209
+ }
210
+ }