@mailwoman/resolver-wof-sqlite 9.4.0 → 10.1.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 (548) hide show
  1. package/README.md +8 -8
  2. package/lib/address/{point-interpolation.ts → point/interpolation.ts} +40 -38
  3. package/lib/address/point/schema.ts +132 -0
  4. package/lib/address/point.ts +68 -81
  5. package/lib/{address/index.ts → address.ts} +2 -2
  6. package/lib/ancestry/backfill.ts +24 -88
  7. package/lib/{ancestry/index.ts → ancestry.ts} +19 -15
  8. package/lib/build/candidate.ts +608 -0
  9. package/lib/build/population-corrections.ts +64 -0
  10. package/lib/build/slim.ts +338 -0
  11. package/lib/candidate/alias-bags.ts +74 -14
  12. package/lib/candidate/ancestors/schema.ts +211 -0
  13. package/lib/candidate/{ancestors-sidecar.ts → ancestors/sidecar.ts} +17 -24
  14. package/lib/candidate/country-display-names.ts +6 -20
  15. package/lib/candidate/extract-fold.ts +12 -57
  16. package/lib/candidate/fts.ts +28 -0
  17. package/lib/candidate/importance.ts +221 -0
  18. package/lib/candidate/lookup.ts +669 -0
  19. package/lib/candidate/name-roles.ts +16 -34
  20. package/lib/candidate/own-name.ts +34 -31
  21. package/lib/candidate/place-attrs.ts +20 -13
  22. package/lib/candidate/schema.ts +211 -0
  23. package/lib/candidate/scoring.ts +222 -0
  24. package/lib/capital-schema.ts +21 -17
  25. package/lib/capitals.ts +26 -24
  26. package/lib/coincident-roles.ts +74 -55
  27. package/lib/convention/schema.ts +13 -7
  28. package/lib/convention.ts +144 -0
  29. package/lib/coverage-manifest-schema.ts +34 -39
  30. package/lib/currency-backfill.ts +24 -69
  31. package/lib/env.ts +1 -1
  32. package/lib/exact-match.ts +31 -20
  33. package/lib/extracts.ts +96 -73
  34. package/lib/fst/autocomplete.ts +52 -35
  35. package/lib/fst/builder.ts +30 -36
  36. package/lib/fst/deserialize-web.ts +8 -9
  37. package/lib/fst/format.ts +41 -23
  38. package/lib/fst/freshness.ts +17 -95
  39. package/lib/fst/matcher.ts +2 -2
  40. package/lib/fst/serialize.ts +45 -60
  41. package/lib/fst/types.ts +71 -59
  42. package/lib/fts/query.ts +5 -36
  43. package/lib/fts.ts +280 -0
  44. package/lib/geonames/aliases.ts +72 -149
  45. package/lib/geonames/postal.ts +162 -77
  46. package/lib/index.ts +20 -27
  47. package/lib/interpolation.ts +43 -96
  48. package/lib/lookup.ts +137 -257
  49. package/lib/name-score.ts +9 -7
  50. package/lib/nsul/lookup.ts +36 -53
  51. package/lib/nsul/schema.ts +16 -43
  52. package/lib/paths.ts +159 -0
  53. package/lib/place-importance-schema.ts +57 -164
  54. package/lib/poi/lookup.ts +100 -109
  55. package/lib/poi/schema.ts +42 -26
  56. package/lib/polygon-schema.ts +10 -6
  57. package/lib/postal/city/alias/lookup.ts +79 -0
  58. package/lib/postal/{city-alias-schema.ts → city/alias/schema.ts} +14 -10
  59. package/lib/postal/{city-candidate-schema.ts → city/candidate-schema.ts} +26 -20
  60. package/lib/postal.ts +9 -0
  61. package/lib/postcode-point-lookup.ts +9 -19
  62. package/lib/primary-preference.ts +45 -125
  63. package/lib/proximity-rerank.ts +43 -32
  64. package/lib/ranking-weights.ts +98 -76
  65. package/lib/region-keys.ts +68 -40
  66. package/lib/reverse.ts +108 -72
  67. package/lib/schema.ts +74 -42
  68. package/lib/search-fetch.ts +90 -68
  69. package/lib/sqlite/convention-source.ts +57 -0
  70. package/lib/sqlite/utils.ts +79 -0
  71. package/lib/street/centroid/schema.ts +149 -0
  72. package/lib/street/centroid.ts +16 -25
  73. package/lib/street/{morphology-fst-builder.ts → morphology/fst/builder.ts} +56 -41
  74. package/lib/street/{morphology-fst-loader.ts → morphology/fst/loader.ts} +36 -29
  75. package/lib/street/name-lookup.ts +43 -35
  76. package/lib/street/normalize.ts +85 -216
  77. package/lib/street/segment-schema.ts +116 -43
  78. package/lib/{street/index.ts → street.ts} +3 -3
  79. package/lib/types.ts +94 -140
  80. package/lib/unified-schema.ts +29 -29
  81. package/lib/uprn/existence.ts +30 -19
  82. package/lib/uprn/lookup.ts +28 -67
  83. package/lib/uprn/schema.ts +18 -40
  84. package/lib/weights-overlay-linker.ts +204 -232
  85. package/out/address/point/interpolation.d.ts +45 -0
  86. package/out/address/point/interpolation.d.ts.map +1 -0
  87. package/out/address/{point-interpolation.js → point/interpolation.js} +38 -38
  88. package/out/address/point/interpolation.js.map +1 -0
  89. package/out/address/point/schema.d.ts +74 -0
  90. package/out/address/point/schema.d.ts.map +1 -0
  91. package/out/address/{point-schema.js → point/schema.js} +4 -20
  92. package/out/address/point/schema.js.map +1 -0
  93. package/out/address/point.d.ts +24 -21
  94. package/out/address/point.d.ts.map +1 -1
  95. package/out/address/point.js +60 -77
  96. package/out/address/point.js.map +1 -1
  97. package/out/{address/index.js → address.d.ts} +3 -3
  98. package/out/address.d.ts.map +1 -0
  99. package/out/{address/index.d.ts → address.js} +3 -3
  100. package/out/address.js.map +1 -0
  101. package/out/ancestry/backfill.d.ts +11 -64
  102. package/out/ancestry/backfill.d.ts.map +1 -1
  103. package/out/ancestry/backfill.js +18 -76
  104. package/out/ancestry/backfill.js.map +1 -1
  105. package/out/ancestry.d.ts +49 -0
  106. package/out/ancestry.d.ts.map +1 -0
  107. package/out/ancestry.js +59 -0
  108. package/out/ancestry.js.map +1 -0
  109. package/out/build/candidate.d.ts +170 -0
  110. package/out/build/candidate.d.ts.map +1 -0
  111. package/out/{build-candidate.js → build/candidate.js} +33 -102
  112. package/out/build/candidate.js.map +1 -0
  113. package/out/build/population-corrections.d.ts +52 -0
  114. package/out/build/population-corrections.d.ts.map +1 -0
  115. package/out/build/population-corrections.js +40 -0
  116. package/out/build/population-corrections.js.map +1 -0
  117. package/out/build/slim.d.ts +44 -0
  118. package/out/build/slim.d.ts.map +1 -0
  119. package/out/{build-slim.js → build/slim.js} +33 -94
  120. package/out/build/slim.js.map +1 -0
  121. package/out/candidate/alias-bags.d.ts +13 -5
  122. package/out/candidate/alias-bags.d.ts.map +1 -1
  123. package/out/candidate/alias-bags.js +55 -12
  124. package/out/candidate/alias-bags.js.map +1 -1
  125. package/out/candidate/ancestors/schema.d.ts +166 -0
  126. package/out/candidate/ancestors/schema.d.ts.map +1 -0
  127. package/out/candidate/ancestors/schema.js +131 -0
  128. package/out/candidate/ancestors/schema.js.map +1 -0
  129. package/out/candidate/ancestors/sidecar.d.ts +27 -0
  130. package/out/candidate/ancestors/sidecar.d.ts.map +1 -0
  131. package/out/candidate/{ancestors-sidecar.js → ancestors/sidecar.js} +17 -24
  132. package/out/candidate/ancestors/sidecar.js.map +1 -0
  133. package/out/candidate/country-display-names.d.ts +5 -15
  134. package/out/candidate/country-display-names.d.ts.map +1 -1
  135. package/out/candidate/country-display-names.js +6 -20
  136. package/out/candidate/country-display-names.js.map +1 -1
  137. package/out/candidate/extract-fold.d.ts +8 -16
  138. package/out/candidate/extract-fold.d.ts.map +1 -1
  139. package/out/candidate/extract-fold.js +6 -48
  140. package/out/candidate/extract-fold.js.map +1 -1
  141. package/out/candidate/fts.d.ts +19 -0
  142. package/out/candidate/fts.d.ts.map +1 -0
  143. package/out/candidate/fts.js +22 -0
  144. package/out/candidate/fts.js.map +1 -0
  145. package/out/candidate/importance.d.ts +127 -0
  146. package/out/candidate/importance.d.ts.map +1 -0
  147. package/out/candidate/importance.js +162 -0
  148. package/out/candidate/importance.js.map +1 -0
  149. package/out/candidate/lookup.d.ts +36 -0
  150. package/out/candidate/lookup.d.ts.map +1 -0
  151. package/out/candidate/lookup.js +456 -0
  152. package/out/candidate/lookup.js.map +1 -0
  153. package/out/candidate/name-roles.d.ts +10 -22
  154. package/out/candidate/name-roles.d.ts.map +1 -1
  155. package/out/candidate/name-roles.js +15 -33
  156. package/out/candidate/name-roles.js.map +1 -1
  157. package/out/candidate/own-name.d.ts +23 -25
  158. package/out/candidate/own-name.d.ts.map +1 -1
  159. package/out/candidate/own-name.js +34 -31
  160. package/out/candidate/own-name.js.map +1 -1
  161. package/out/candidate/place-attrs.d.ts +20 -13
  162. package/out/candidate/place-attrs.d.ts.map +1 -1
  163. package/out/candidate/place-attrs.js +4 -5
  164. package/out/candidate/place-attrs.js.map +1 -1
  165. package/out/candidate/schema.d.ts +120 -0
  166. package/out/candidate/schema.d.ts.map +1 -0
  167. package/out/{candidate-schema.js → candidate/schema.js} +11 -23
  168. package/out/candidate/schema.js.map +1 -0
  169. package/out/{candidate-scoring.d.ts → candidate/scoring.d.ts} +8 -9
  170. package/out/candidate/scoring.d.ts.map +1 -0
  171. package/out/candidate/scoring.js +157 -0
  172. package/out/candidate/scoring.js.map +1 -0
  173. package/out/capital-schema.d.ts +21 -17
  174. package/out/capital-schema.d.ts.map +1 -1
  175. package/out/capital-schema.js +17 -15
  176. package/out/capital-schema.js.map +1 -1
  177. package/out/capitals.d.ts +26 -24
  178. package/out/capitals.d.ts.map +1 -1
  179. package/out/capitals.js +23 -21
  180. package/out/capitals.js.map +1 -1
  181. package/out/coincident-roles.d.ts +60 -43
  182. package/out/coincident-roles.d.ts.map +1 -1
  183. package/out/coincident-roles.js +61 -50
  184. package/out/coincident-roles.js.map +1 -1
  185. package/out/convention/schema.d.ts +12 -6
  186. package/out/convention/schema.d.ts.map +1 -1
  187. package/out/convention/schema.js +6 -4
  188. package/out/convention/schema.js.map +1 -1
  189. package/out/convention.d.ts +97 -0
  190. package/out/convention.d.ts.map +1 -0
  191. package/out/convention.js +81 -0
  192. package/out/convention.js.map +1 -0
  193. package/out/coverage-manifest-schema.d.ts +33 -38
  194. package/out/coverage-manifest-schema.d.ts.map +1 -1
  195. package/out/coverage-manifest-schema.js +20 -29
  196. package/out/coverage-manifest-schema.js.map +1 -1
  197. package/out/currency-backfill.d.ts +13 -35
  198. package/out/currency-backfill.d.ts.map +1 -1
  199. package/out/currency-backfill.js +12 -39
  200. package/out/currency-backfill.js.map +1 -1
  201. package/out/env.d.ts +2 -2
  202. package/out/env.js +1 -1
  203. package/out/exact-match.d.ts +19 -10
  204. package/out/exact-match.d.ts.map +1 -1
  205. package/out/exact-match.js +31 -20
  206. package/out/exact-match.js.map +1 -1
  207. package/out/extracts.d.ts +65 -54
  208. package/out/extracts.d.ts.map +1 -1
  209. package/out/extracts.js +63 -55
  210. package/out/extracts.js.map +1 -1
  211. package/out/fst/autocomplete.d.ts +28 -20
  212. package/out/fst/autocomplete.d.ts.map +1 -1
  213. package/out/fst/autocomplete.js +38 -27
  214. package/out/fst/autocomplete.js.map +1 -1
  215. package/out/fst/builder.d.ts.map +1 -1
  216. package/out/fst/builder.js +30 -36
  217. package/out/fst/builder.js.map +1 -1
  218. package/out/fst/deserialize-web.d.ts +3 -3
  219. package/out/fst/deserialize-web.d.ts.map +1 -1
  220. package/out/fst/deserialize-web.js +8 -9
  221. package/out/fst/deserialize-web.js.map +1 -1
  222. package/out/fst/format.d.ts +41 -23
  223. package/out/fst/format.d.ts.map +1 -1
  224. package/out/fst/format.js +41 -23
  225. package/out/fst/format.js.map +1 -1
  226. package/out/fst/freshness.d.ts +11 -73
  227. package/out/fst/freshness.d.ts.map +1 -1
  228. package/out/fst/freshness.js +12 -76
  229. package/out/fst/freshness.js.map +1 -1
  230. package/out/fst/matcher.d.ts +1 -1
  231. package/out/fst/matcher.d.ts.map +1 -1
  232. package/out/fst/matcher.js +2 -2
  233. package/out/fst/matcher.js.map +1 -1
  234. package/out/fst/serialize.d.ts +30 -26
  235. package/out/fst/serialize.d.ts.map +1 -1
  236. package/out/fst/serialize.js +44 -58
  237. package/out/fst/serialize.js.map +1 -1
  238. package/out/fst/types.d.ts +71 -59
  239. package/out/fst/types.d.ts.map +1 -1
  240. package/out/fst/types.js +1 -4
  241. package/out/fst/types.js.map +1 -1
  242. package/out/{fst/index.js → fst.d.ts} +1 -1
  243. package/out/fst.d.ts.map +1 -0
  244. package/out/{fst/index.d.ts → fst.js} +1 -1
  245. package/out/fst.js.map +1 -0
  246. package/out/fts/query.d.ts +2 -27
  247. package/out/fts/query.d.ts.map +1 -1
  248. package/out/fts/query.js +5 -36
  249. package/out/fts/query.js.map +1 -1
  250. package/out/fts.d.ts +126 -0
  251. package/out/fts.d.ts.map +1 -0
  252. package/out/fts.js +188 -0
  253. package/out/fts.js.map +1 -0
  254. package/out/geonames/aliases.d.ts +26 -67
  255. package/out/geonames/aliases.d.ts.map +1 -1
  256. package/out/geonames/aliases.js +44 -121
  257. package/out/geonames/aliases.js.map +1 -1
  258. package/out/geonames/postal.d.ts +84 -50
  259. package/out/geonames/postal.d.ts.map +1 -1
  260. package/out/geonames/postal.js +115 -78
  261. package/out/geonames/postal.js.map +1 -1
  262. package/out/{geonames/index.js → geonames.d.ts} +1 -1
  263. package/out/geonames.d.ts.map +1 -0
  264. package/out/{geonames/index.d.ts → geonames.js} +1 -1
  265. package/out/geonames.js.map +1 -0
  266. package/out/index.d.ts +20 -21
  267. package/out/index.d.ts.map +1 -1
  268. package/out/index.js +14 -15
  269. package/out/index.js.map +1 -1
  270. package/out/interpolation.d.ts +15 -52
  271. package/out/interpolation.d.ts.map +1 -1
  272. package/out/interpolation.js +25 -68
  273. package/out/interpolation.js.map +1 -1
  274. package/out/lookup.d.ts +24 -53
  275. package/out/lookup.d.ts.map +1 -1
  276. package/out/lookup.js +117 -223
  277. package/out/lookup.js.map +1 -1
  278. package/out/name-score.d.ts +2 -2
  279. package/out/name-score.d.ts.map +1 -1
  280. package/out/name-score.js +9 -7
  281. package/out/name-score.js.map +1 -1
  282. package/out/nsul/lookup.d.ts +33 -34
  283. package/out/nsul/lookup.d.ts.map +1 -1
  284. package/out/nsul/lookup.js +26 -40
  285. package/out/nsul/lookup.js.map +1 -1
  286. package/out/nsul/schema.d.ts +16 -43
  287. package/out/nsul/schema.d.ts.map +1 -1
  288. package/out/nsul/schema.js +9 -35
  289. package/out/nsul/schema.js.map +1 -1
  290. package/out/{nsul/index.d.ts → nsul.d.ts} +1 -1
  291. package/out/nsul.d.ts.map +1 -0
  292. package/out/{nsul/index.js → nsul.js} +1 -1
  293. package/out/nsul.js.map +1 -0
  294. package/out/paths.d.ts +124 -0
  295. package/out/paths.d.ts.map +1 -0
  296. package/out/paths.js +104 -0
  297. package/out/paths.js.map +1 -0
  298. package/out/place-importance-schema.d.ts +49 -141
  299. package/out/place-importance-schema.d.ts.map +1 -1
  300. package/out/place-importance-schema.js +25 -140
  301. package/out/place-importance-schema.js.map +1 -1
  302. package/out/poi/lookup.d.ts +38 -42
  303. package/out/poi/lookup.d.ts.map +1 -1
  304. package/out/poi/lookup.js +67 -90
  305. package/out/poi/lookup.js.map +1 -1
  306. package/out/poi/schema.d.ts +41 -25
  307. package/out/poi/schema.d.ts.map +1 -1
  308. package/out/poi/schema.js +25 -17
  309. package/out/poi/schema.js.map +1 -1
  310. package/out/{poi/index.js → poi.d.ts} +1 -1
  311. package/out/poi.d.ts.map +1 -0
  312. package/out/{poi/index.d.ts → poi.js} +1 -1
  313. package/out/poi.js.map +1 -0
  314. package/out/polygon-schema.d.ts +10 -6
  315. package/out/polygon-schema.d.ts.map +1 -1
  316. package/out/polygon-schema.js +2 -2
  317. package/out/polygon-schema.js.map +1 -1
  318. package/out/postal/{city-alias-lookup.d.ts → city/alias/lookup.d.ts} +22 -25
  319. package/out/postal/city/alias/lookup.d.ts.map +1 -0
  320. package/out/postal/city/alias/lookup.js +49 -0
  321. package/out/postal/city/alias/lookup.js.map +1 -0
  322. package/out/postal/{city-alias-schema.d.ts → city/alias/schema.d.ts} +15 -11
  323. package/out/postal/city/alias/schema.d.ts.map +1 -0
  324. package/out/postal/{city-alias-schema.js → city/alias/schema.js} +11 -9
  325. package/out/postal/city/alias/schema.js.map +1 -0
  326. package/out/postal/city/candidate-schema.d.ts +72 -0
  327. package/out/postal/city/candidate-schema.d.ts.map +1 -0
  328. package/out/postal/city/candidate-schema.js +60 -0
  329. package/out/postal/city/candidate-schema.js.map +1 -0
  330. package/out/postal.d.ts +9 -0
  331. package/out/postal.d.ts.map +1 -0
  332. package/out/postal.js +9 -0
  333. package/out/postal.js.map +1 -0
  334. package/out/postcode-point-lookup.d.ts +9 -19
  335. package/out/postcode-point-lookup.d.ts.map +1 -1
  336. package/out/postcode-point-lookup.js +5 -17
  337. package/out/postcode-point-lookup.js.map +1 -1
  338. package/out/primary-preference.d.ts +39 -93
  339. package/out/primary-preference.d.ts.map +1 -1
  340. package/out/primary-preference.js +13 -97
  341. package/out/primary-preference.js.map +1 -1
  342. package/out/proximity-rerank.d.ts +40 -29
  343. package/out/proximity-rerank.d.ts.map +1 -1
  344. package/out/proximity-rerank.js +38 -29
  345. package/out/proximity-rerank.js.map +1 -1
  346. package/out/ranking-weights.d.ts +91 -62
  347. package/out/ranking-weights.d.ts.map +1 -1
  348. package/out/ranking-weights.js +26 -25
  349. package/out/ranking-weights.js.map +1 -1
  350. package/out/region-keys.d.ts +48 -29
  351. package/out/region-keys.d.ts.map +1 -1
  352. package/out/region-keys.js +68 -40
  353. package/out/region-keys.js.map +1 -1
  354. package/out/reverse.d.ts +75 -50
  355. package/out/reverse.d.ts.map +1 -1
  356. package/out/reverse.js +61 -49
  357. package/out/reverse.js.map +1 -1
  358. package/out/schema.d.ts +74 -42
  359. package/out/schema.d.ts.map +1 -1
  360. package/out/schema.js +4 -5
  361. package/out/schema.js.map +1 -1
  362. package/out/search-fetch.d.ts +16 -12
  363. package/out/search-fetch.d.ts.map +1 -1
  364. package/out/search-fetch.js +83 -63
  365. package/out/search-fetch.js.map +1 -1
  366. package/out/sqlite/convention-source.d.ts +25 -0
  367. package/out/sqlite/convention-source.d.ts.map +1 -0
  368. package/out/sqlite/convention-source.js +48 -0
  369. package/out/sqlite/convention-source.js.map +1 -0
  370. package/out/sqlite/utils.d.ts +45 -0
  371. package/out/sqlite/utils.d.ts.map +1 -0
  372. package/out/sqlite/utils.js +56 -0
  373. package/out/sqlite/utils.js.map +1 -0
  374. package/out/street/centroid/schema.d.ts +100 -0
  375. package/out/street/centroid/schema.d.ts.map +1 -0
  376. package/out/street/centroid/schema.js +66 -0
  377. package/out/street/centroid/schema.js.map +1 -0
  378. package/out/street/centroid.d.ts +9 -19
  379. package/out/street/centroid.d.ts.map +1 -1
  380. package/out/street/centroid.js +11 -22
  381. package/out/street/centroid.js.map +1 -1
  382. package/out/street/{morphology-fst-builder.d.ts → morphology/fst/builder.d.ts} +21 -11
  383. package/out/street/morphology/fst/builder.d.ts.map +1 -0
  384. package/out/street/{morphology-fst-builder.js → morphology/fst/builder.js} +40 -34
  385. package/out/street/morphology/fst/builder.js.map +1 -0
  386. package/out/street/morphology/fst/loader.d.ts +74 -0
  387. package/out/street/morphology/fst/loader.d.ts.map +1 -0
  388. package/out/street/{morphology-fst-loader.js → morphology/fst/loader.js} +21 -21
  389. package/out/street/morphology/fst/loader.js.map +1 -0
  390. package/out/street/name-lookup.d.ts +24 -18
  391. package/out/street/name-lookup.d.ts.map +1 -1
  392. package/out/street/name-lookup.js +32 -31
  393. package/out/street/name-lookup.js.map +1 -1
  394. package/out/street/normalize.d.ts +35 -136
  395. package/out/street/normalize.d.ts.map +1 -1
  396. package/out/street/normalize.js +74 -191
  397. package/out/street/normalize.js.map +1 -1
  398. package/out/street/segment-schema.d.ts +79 -39
  399. package/out/street/segment-schema.d.ts.map +1 -1
  400. package/out/street/segment-schema.js +71 -21
  401. package/out/street/segment-schema.js.map +1 -1
  402. package/out/{street/index.d.ts → street.d.ts} +4 -4
  403. package/out/street.d.ts.map +1 -0
  404. package/out/{street/index.js → street.js} +4 -4
  405. package/out/street.js.map +1 -0
  406. package/out/types.d.ts +69 -138
  407. package/out/types.d.ts.map +1 -1
  408. package/out/types.js +0 -6
  409. package/out/types.js.map +1 -1
  410. package/out/unified-schema.d.ts +8 -12
  411. package/out/unified-schema.d.ts.map +1 -1
  412. package/out/unified-schema.js +29 -29
  413. package/out/unified-schema.js.map +1 -1
  414. package/out/uprn/existence.d.ts +28 -17
  415. package/out/uprn/existence.d.ts.map +1 -1
  416. package/out/uprn/existence.js +26 -16
  417. package/out/uprn/existence.js.map +1 -1
  418. package/out/uprn/lookup.d.ts +14 -44
  419. package/out/uprn/lookup.d.ts.map +1 -1
  420. package/out/uprn/lookup.js +20 -56
  421. package/out/uprn/lookup.js.map +1 -1
  422. package/out/uprn/schema.d.ts +18 -40
  423. package/out/uprn/schema.d.ts.map +1 -1
  424. package/out/uprn/schema.js +10 -32
  425. package/out/uprn/schema.js.map +1 -1
  426. package/out/{uprn/index.d.ts → uprn.d.ts} +1 -1
  427. package/out/uprn.d.ts.map +1 -0
  428. package/out/{uprn/index.js → uprn.js} +1 -1
  429. package/out/uprn.js.map +1 -0
  430. package/out/weights-overlay-linker.d.ts +132 -141
  431. package/out/weights-overlay-linker.d.ts.map +1 -1
  432. package/out/weights-overlay-linker.js +86 -154
  433. package/out/weights-overlay-linker.js.map +1 -1
  434. package/package.json +94 -599
  435. package/lib/address/point-schema.ts +0 -155
  436. package/lib/build-candidate.ts +0 -632
  437. package/lib/build-slim.ts +0 -408
  438. package/lib/candidate-ancestors-schema.ts +0 -195
  439. package/lib/candidate-fts.ts +0 -46
  440. package/lib/candidate-importance.ts +0 -224
  441. package/lib/candidate-lookup.ts +0 -977
  442. package/lib/candidate-schema.ts +0 -247
  443. package/lib/candidate-scoring.ts +0 -261
  444. package/lib/convention/index.ts +0 -154
  445. package/lib/fts/index.ts +0 -361
  446. package/lib/postal/city-alias-lookup.ts +0 -97
  447. package/lib/postal/index.ts +0 -9
  448. package/lib/sqlite-convention-source.ts +0 -65
  449. package/lib/sqlite-utils.ts +0 -82
  450. package/lib/street/centroid-schema.ts +0 -154
  451. package/out/address/index.d.ts.map +0 -1
  452. package/out/address/index.js.map +0 -1
  453. package/out/address/point-interpolation.d.ts +0 -49
  454. package/out/address/point-interpolation.d.ts.map +0 -1
  455. package/out/address/point-interpolation.js.map +0 -1
  456. package/out/address/point-schema.d.ts +0 -97
  457. package/out/address/point-schema.d.ts.map +0 -1
  458. package/out/address/point-schema.js.map +0 -1
  459. package/out/ancestry/index.d.ts +0 -45
  460. package/out/ancestry/index.d.ts.map +0 -1
  461. package/out/ancestry/index.js +0 -55
  462. package/out/ancestry/index.js.map +0 -1
  463. package/out/build-candidate.d.ts +0 -184
  464. package/out/build-candidate.d.ts.map +0 -1
  465. package/out/build-candidate.js.map +0 -1
  466. package/out/build-slim.d.ts +0 -80
  467. package/out/build-slim.d.ts.map +0 -1
  468. package/out/build-slim.js.map +0 -1
  469. package/out/candidate/ancestors-sidecar.d.ts +0 -32
  470. package/out/candidate/ancestors-sidecar.d.ts.map +0 -1
  471. package/out/candidate/ancestors-sidecar.js.map +0 -1
  472. package/out/candidate-ancestors-schema.d.ts +0 -150
  473. package/out/candidate-ancestors-schema.d.ts.map +0 -1
  474. package/out/candidate-ancestors-schema.js +0 -123
  475. package/out/candidate-ancestors-schema.js.map +0 -1
  476. package/out/candidate-fts.d.ts +0 -37
  477. package/out/candidate-fts.d.ts.map +0 -1
  478. package/out/candidate-fts.js +0 -40
  479. package/out/candidate-fts.js.map +0 -1
  480. package/out/candidate-importance.d.ts +0 -132
  481. package/out/candidate-importance.d.ts.map +0 -1
  482. package/out/candidate-importance.js +0 -169
  483. package/out/candidate-importance.js.map +0 -1
  484. package/out/candidate-lookup.d.ts +0 -76
  485. package/out/candidate-lookup.d.ts.map +0 -1
  486. package/out/candidate-lookup.js +0 -768
  487. package/out/candidate-lookup.js.map +0 -1
  488. package/out/candidate-schema.d.ts +0 -155
  489. package/out/candidate-schema.d.ts.map +0 -1
  490. package/out/candidate-schema.js.map +0 -1
  491. package/out/candidate-scoring.d.ts.map +0 -1
  492. package/out/candidate-scoring.js +0 -196
  493. package/out/candidate-scoring.js.map +0 -1
  494. package/out/convention/index.d.ts +0 -106
  495. package/out/convention/index.d.ts.map +0 -1
  496. package/out/convention/index.js +0 -93
  497. package/out/convention/index.js.map +0 -1
  498. package/out/fst/index.d.ts.map +0 -1
  499. package/out/fst/index.js.map +0 -1
  500. package/out/fts/index.d.ts +0 -184
  501. package/out/fts/index.d.ts.map +0 -1
  502. package/out/fts/index.js +0 -277
  503. package/out/fts/index.js.map +0 -1
  504. package/out/geonames/index.d.ts.map +0 -1
  505. package/out/geonames/index.js.map +0 -1
  506. package/out/nsul/index.d.ts.map +0 -1
  507. package/out/nsul/index.js.map +0 -1
  508. package/out/poi/index.d.ts.map +0 -1
  509. package/out/poi/index.js.map +0 -1
  510. package/out/postal/city-alias-lookup.d.ts.map +0 -1
  511. package/out/postal/city-alias-lookup.js +0 -62
  512. package/out/postal/city-alias-lookup.js.map +0 -1
  513. package/out/postal/city-alias-schema.d.ts.map +0 -1
  514. package/out/postal/city-alias-schema.js.map +0 -1
  515. package/out/postal/city-candidate-schema.d.ts +0 -66
  516. package/out/postal/city-candidate-schema.d.ts.map +0 -1
  517. package/out/postal/city-candidate-schema.js +0 -56
  518. package/out/postal/city-candidate-schema.js.map +0 -1
  519. package/out/postal/index.d.ts +0 -9
  520. package/out/postal/index.d.ts.map +0 -1
  521. package/out/postal/index.js +0 -9
  522. package/out/postal/index.js.map +0 -1
  523. package/out/sqlite-convention-source.d.ts +0 -29
  524. package/out/sqlite-convention-source.d.ts.map +0 -1
  525. package/out/sqlite-convention-source.js +0 -57
  526. package/out/sqlite-convention-source.js.map +0 -1
  527. package/out/sqlite-utils.d.ts +0 -45
  528. package/out/sqlite-utils.d.ts.map +0 -1
  529. package/out/sqlite-utils.js +0 -57
  530. package/out/sqlite-utils.js.map +0 -1
  531. package/out/street/centroid-schema.d.ts +0 -105
  532. package/out/street/centroid-schema.d.ts.map +0 -1
  533. package/out/street/centroid-schema.js +0 -78
  534. package/out/street/centroid-schema.js.map +0 -1
  535. package/out/street/index.d.ts.map +0 -1
  536. package/out/street/index.js.map +0 -1
  537. package/out/street/morphology-fst-builder.d.ts.map +0 -1
  538. package/out/street/morphology-fst-builder.js.map +0 -1
  539. package/out/street/morphology-fst-loader.d.ts +0 -67
  540. package/out/street/morphology-fst-loader.d.ts.map +0 -1
  541. package/out/street/morphology-fst-loader.js.map +0 -1
  542. package/out/uprn/index.d.ts.map +0 -1
  543. package/out/uprn/index.js.map +0 -1
  544. /package/lib/{fst/index.ts → fst.ts} +0 -0
  545. /package/lib/{geonames/index.ts → geonames.ts} +0 -0
  546. /package/lib/{nsul/index.ts → nsul.ts} +0 -0
  547. /package/lib/{poi/index.ts → poi.ts} +0 -0
  548. /package/lib/{uprn/index.ts → uprn.ts} +0 -0
@@ -1,977 +0,0 @@
1
- /**
2
- * @copyright Sister Software
3
- * @license AGPL-3.0
4
- * @author Teffen Ellis, et al.
5
- *
6
- * Node-side {@link PlaceLookup} over the byte-range CANDIDATE table (`build-candidate.ts`) — the
7
- * SAME gazetteer the browser runtime resolves against ({@link WOFCandidateTableLookup} in
8
- * `packages/resolver-wof-wasm/lib/httpvfs/resolver.ts`), but reading a LOCAL `candidate.db` via
9
- * `node:sqlite` instead of sql.js-httpvfs. This is what makes the server/CLI resolver match the
10
- * browser: one lookup surface, one artifact, one ranking.
11
- *
12
- * The query is a single contiguous probe on the `WITHOUT ROWID` B-tree keyed `(name_key,
13
- * country_id, region_id, placetype_id, neg_rank, spr_id)`. `name_key` is the SHARED
14
- * {@link normalizeLocalityForKey} (build- and query-consistent), each row is denormalized (display
15
- * `name`, centroid, bbox), and population rank is precomputed into `neg_rank` — so the result is
16
- * POPULATION-FIRST and COUNTRY-AGNOSTIC (when no `country` filter is given), exactly like the
17
- * demo. That's the deliberate divergence from {@link WOFSQLitePlaceLookup}'s FTS/bm25 ranking: a
18
- * bare "Moscow" resolves to the 10.4 M-pop Russian city, not whichever same-name US township bm25
19
- * floats to the top.
20
- *
21
- * Disambiguation rides the same mechanism the cascade already uses: a parsed region resolves to its
22
- * stored bbox and the locality query is point-in-bbox-filtered on the candidate centroid (the
23
- * `bbox` field on {@link FindPlaceQuery}).
24
- */
25
-
26
- import {
27
- expandPlacetypeFilter,
28
- type Ancestor,
29
- type GazetteerArtifactCoverage,
30
- referentialFromPopulation,
31
- } from "@mailwoman/core/resolver"
32
- import { allRows } from "@mailwoman/core/utils"
33
- import { jaroWinkler, levenshteinSimilarity } from "@mailwoman/match/comparators"
34
- import { partitionByContainment } from "@mailwoman/resolver"
35
- import { haversineKm } from "@mailwoman/spatial"
36
- import { DatabaseClient } from "@mailwoman/sqlite/client"
37
- import type { PathBuilderLike } from "path-ts"
38
-
39
- import {
40
- CANDIDATE_ANCESTOR_TABLE,
41
- CANDIDATE_INTERVAL_TABLE,
42
- intervalContains,
43
- type CandidateAncestorTable,
44
- type IntervalLabel,
45
- } from "#candidate-ancestors-schema"
46
- import { CANDIDATE_FTS_TABLE } from "#candidate-fts"
47
- import type { CandidateDatabase, CandidateTable, CountryCodeTable, PlacetypeCodeTable } from "#candidate-schema"
48
- import { readGazetteerCoverageManifest } from "#coverage-manifest-schema"
49
- import { POSTAL_CITY_CANDIDATE_TABLE, type PostalCityCandidateTable } from "#postal/city-candidate-schema"
50
- import { rankByPrimaryPreference, type RankedRow, RERANK_FETCH } from "#primary-preference"
51
- import { applyProximityRerank } from "#proximity-rerank"
52
- import { REGION_CLASS_PLACETYPES, regionQualifierProbeKeys } from "#region-keys"
53
- import { hasColumn, hasTable } from "#sqlite-utils"
54
- import { type NameKey, normalizeLocalityForKey, stripLocalityQualifier } from "#street/normalize"
55
- import type { FindPlaceQuery, PlaceCandidate, PlaceLookup, WOFPlacetype } from "#types"
56
-
57
- export { rankByPrimaryPreference } from "#primary-preference"
58
- export type { RankedRow } from "#primary-preference"
59
-
60
- export interface WOFCandidateTableLookupOpts {
61
- /**
62
- * Path to a `candidate.db` built by `build-candidate.ts`. Opened read-only.
63
- */
64
- databasePath?: PathBuilderLike
65
- /**
66
- * Pre-opened handle (tests / shared connections). Mutually exclusive with `databasePath`.
67
- */
68
- database?: DatabaseClient<CandidateDatabase>
69
- /**
70
- * #1882 opt-in: exempt `name_role = 'variant'` aliases — the holder's own primary name in another orthography,
71
- * stamped by the build's own-name detector — from the cross-country primary-preference penalty. No-ops on an artifact
72
- * without the role column. Default OFF (D-rule).
73
- */
74
- variantAliasExemption?: boolean
75
- }
76
-
77
- /**
78
- * The candidate columns this lookup probes — a typed projection of the SHARED {@link CandidateTable}, so a column rename
79
- * in `build-candidate` (the writer) is a compile error here (the reader).
80
- */
81
- type CandidateRow = Pick<
82
- CandidateTable,
83
- | "spr_id"
84
- | "name"
85
- | "country_id"
86
- | "placetype_id"
87
- | "latitude"
88
- | "longitude"
89
- | "min_lat"
90
- | "min_lon"
91
- | "max_lat"
92
- | "max_lon"
93
- | "neg_rank"
94
- | "is_primary"
95
- | "population"
96
- > &
97
- // `importance` is OPTIONAL on the row rather than `number | null`, because whether the SELECT names it
98
- // depends on the artifact: a candidate.db built before #28 has no such column and the probe leaves it
99
- // out (see `#importanceSelect`). `undefined` therefore means "this build cannot tell you", which is the
100
- // same answer as `null`'s "the score source had no measurement" — both are UNMEASURED, and the emit
101
- // below collapses them into the one thing the consumer understands: no `importance` field at all.
102
- Partial<Pick<CandidateTable, "importance">>
103
-
104
- /**
105
- * FTS5-trigram over-fetch before the WORD-LEVEL re-rank. The trigram index stays the candidate GENERATOR (it is what
106
- * the artifact carries); the scoring moved off trigram-Jaccard on 2026-08-12 (#1614), which the aucklnad receipts
107
- * falsified as a typo measure: it scored the true transposition correction 'auckland' at 0.333 — below its own 0.34 bar
108
- * — while 'auckley' scored 0.375 and the 'gore bay' scrape 0.455, because shared generic suffixes count as trigram
109
- * evidence and transpositions count against it.
110
- */
111
- const FUZZY_FETCH = 40
112
-
113
- /**
114
- * Minimum WORD-LEVEL similarity (max of Jaro-Winkler and normalized edit similarity — the `match/comparators`
115
- * primitives, deliberately WITHOUT `nameSimilarity`'s token-subset floor, which is a person-name rule that would hand
116
- * 'stanmore bay' to a place named 'Bay') for a fuzzy correction to count. Measured on the #1614 receipts:
117
- * 'aucklnad'→'auckland' 0.975 (in), →'auckley' 0.868 (in, but outranked), 'stanmore bay'→'gore bay' ~0.70 (out),
118
- * 'sacremento'→'sacramento' ~0.97 (in).
119
- */
120
- const WORD_FUZZY_MIN = 0.85
121
-
122
- /**
123
- * The word-level correction similarity — see {@link WORD_FUZZY_MIN}.
124
- */
125
- function wordFuzzySimilarity(a: string, b: string): number {
126
- return Math.max(jaroWinkler(a, b), levenshteinSimilarity(a, b))
127
- }
128
-
129
- /**
130
- * Postcode-containment re-rank check radius (km) — the SAME value the resolver's country pass measures at
131
- * (`POSTCODE_COUNTRY_COHERENCE_THRESHOLD_KM`, resolver/postcode-country-coherence.ts): a locality within this distance
132
- * of the postcode's own centroid counts as "containing" it. One number, two passes — a divergence here would make the
133
- * two mechanisms disagree about what is proximal.
134
- */
135
- const POSTCODE_CONTAINMENT_THRESHOLD_KM = 25
136
-
137
- /**
138
- * Unpadded character-trigrams of `s`, OR'd into an FTS5 trigram MATCH query (each quoted so FTS treats it as a literal
139
- * term). Returns "" when `s` is shorter than a trigram or yields no clean grams — the caller then skips the fuzzy
140
- * probe.
141
- */
142
- function ftsTrigramQuery(s: string): string {
143
- const grams = new Set<string>()
144
-
145
- for (let i = 0; i + 3 <= s.length; i++) {
146
- const g = s.slice(i, i + 3)
147
-
148
- if (/^[\p{L}\p{N} ]{3}$/u.test(g)) {
149
- grams.add(g)
150
- }
151
- }
152
-
153
- return [...grams].map((g) => `"${g}"`).join(" OR ")
154
- }
155
-
156
- /**
157
- * Node {@link PlaceLookup} over `candidate.db`. Drop-in for {@link WOFSQLitePlaceLookup} in `createWOFResolver(backend)`
158
- * — same `findPlace` contract, population-first ranking.
159
- */
160
- export class WOFCandidateTableLookup implements PlaceLookup, Disposable {
161
- #db: DatabaseClient<CandidateDatabase>
162
- /**
163
- * Resources this instance opened. A connection handed in by a caller is NOT in here, so disposal cannot reach it —
164
- * ownership is membership rather than a flag a later branch has to check.
165
- */
166
- readonly #resources = new DisposableStack()
167
- readonly #countryToID = new Map<string, number>()
168
- readonly #idToCountry = new Map<number, string>()
169
- readonly #placetypeToID = new Map<string, number>()
170
- readonly #idToPlacetype = new Map<number, string>()
171
- /**
172
- * Prepared `(name_key, postcode)` probe for the #741 postal-city side-index — `undefined` when the
173
- * `postal_city_candidate` table isn't present, so a candidate.db built without it is byte-stable.
174
- */
175
- readonly #postalCityProbe: ReturnType<DatabaseClient["prepare"]> | undefined
176
- /**
177
- * Prepared FTS5-trigram MATCH probe for the typo-tolerant fallback — `undefined` when the `candidate_fts` index isn't
178
- * present, so a candidate.db built without it is byte-stable (the fuzzy path is skipped, exactly like today).
179
- */
180
- readonly #ftsProbe: ReturnType<DatabaseClient["prepare"]> | undefined
181
- /**
182
- * Prepared UNFILTERED existence probe (`name_key` present anywhere, ignoring country/placetype/bbox). Checks the
183
- * fuzzy fallback: fuzzy is a TYPO corrector, so it engages only when the name doesn't exist in the gazetteer at all.
184
- * A name that DOES exist but missed under the active filter is a filter miss (e.g. a placer misroute "Vienna,
185
- * Austria"→IT), not a spelling miss — fuzzing it would scrape an unrelated same-country place and defeat the
186
- * cascade's country-agnostic retry. Prepared only alongside `#ftsProbe`.
187
- */
188
- readonly #nameKeyExistsProbe: ReturnType<DatabaseClient["prepare"]> | undefined
189
- /**
190
- * Facts this candidate DB declares about itself — the coverage manifest (`country_coverage` + `country_bbox`) the
191
- * gazetteer build emits, read once at open. `undefined` when the artifact predates the manifest, so every consumer
192
- * (the hard-country coverage check, guard-B plausibility) falls back to its code constants byte-identically.
193
- */
194
- readonly artifactCoverage: GazetteerArtifactCoverage | undefined
195
- /**
196
- * `", importance"` when this artifact carries the #28 fame column, `""` when it does not — spliced into the probe's
197
- * SELECT list. Existence-restricted exactly like `#ftsProbe` and `#postalCityProbe` above, and for the same reason: a
198
- * candidate.db built before the column is a valid artifact, and naming a column it lacks would turn a stale gazetteer
199
- * into `no such column` on the first keystroke rather than into "no fame signal", which is what it is.
200
- */
201
- readonly #importanceSelect: string
202
- /**
203
- * Whether the artifact carries the #1730 `name_role` column — absent on pre-role builds, where the `excludeNameRoles`
204
- * filter degrades to a no-op rather than erroring on a missing column.
205
- */
206
- readonly #hasNameRole: boolean
207
- readonly #variantAliasExemption: boolean
208
- /**
209
- * `", name_role"` when the artifact carries the column — the probe SELECT rides it so the #1882 exemption can read
210
- * the stamp off the row; empty on a pre-role build.
211
- */
212
- readonly #roleSelect: string
213
- /**
214
- * Prepared chain probe over the `candidate_ancestor` sidecar — `undefined` when the artifact predates it.
215
- */
216
- readonly #ancestorsProbe: ReturnType<DatabaseClient["prepare"]> | undefined
217
- readonly #ancestorsCache = new Map<number, Ancestor[]>()
218
- /**
219
- * Prepared interval-label probe over `candidate_interval` — `undefined` when the artifact predates the sidecar, which
220
- * is what makes the admin-containment re-rank (#1717 stage 2) capability-restricted: without it,
221
- * `FindPlaceQuery.regionQualifier` is ignored, no candidate carries a `containedByQualifier` stamp, and the resolver
222
- * walk reports the change `unavailable` instead of silently dead.
223
- */
224
- readonly #intervalProbe: ReturnType<DatabaseClient["prepare"]> | undefined
225
- readonly #intervalCache = new Map<number, IntervalLabel | null>()
226
- /**
227
- * Prepared qualifier probe: the region-band rows (plus `country` — the region SLOT can hold a mislabeled country
228
- * name, "Moscow, Russia" parses region="Russia") for one folded qualifier key. `undefined` when the artifact lacks
229
- * the sidecar or the placetype dictionary lacks the band entirely.
230
- */
231
- readonly #qualifierProbe: ReturnType<DatabaseClient["prepare"]> | undefined
232
- /**
233
- * The ancestor lineage of a resolved place — nearest-first (locality-tier → county → region → … → country), the same
234
- * order the FTS backend's `ancestorLineage` serves, read from the `candidate_ancestor` sidecar in one clustered
235
- * probe. Backs `ResolveOpts.includeAncestors` (#404) on this backend, which is what puts region-class ancestry in
236
- * front of the admin-coherence check (#1717).
237
- *
238
- * A PROPERTY, not a method, and assigned only when the artifact carries the sidecar: capability probes (`typeof
239
- * backend.ancestors === "function"` — the resolver's gap report) then read the ARTIFACT truthfully. A candidate.db
240
- * built before the sidecar reports the capability absent instead of presenting a method that answers `[]` for every
241
- * place, which would be an absence dressed as a negative answer.
242
- */
243
- readonly ancestors: ((id: number | string) => Ancestor[]) | undefined
244
-
245
- constructor(opts: WOFCandidateTableLookupOpts) {
246
- if (opts.database) {
247
- this.#db = opts.database
248
- } else if (opts.databasePath) {
249
- this.#db = this.#resources.use(new DatabaseClient<CandidateDatabase>(opts.databasePath, { readOnly: true }))
250
- } else {
251
- throw new Error("WOFCandidateTableLookup needs `databasePath` or `database`")
252
- }
253
-
254
- // The code tables are tiny (country/placetype dictionaries) — load them once at construction so
255
- // `findPlace` is a single B-tree probe with no dictionary round-trip.
256
- for (const r of allRows<CountryCodeTable>(this.#db.prepare("SELECT id, code FROM country_codes"))) {
257
- const code = String(r.code).toUpperCase()
258
- this.#countryToID.set(code, Number(r.id))
259
- this.#idToCountry.set(Number(r.id), code)
260
- }
261
-
262
- for (const r of allRows<PlacetypeCodeTable>(this.#db.prepare("SELECT id, placetype FROM placetype_codes"))) {
263
- this.#placetypeToID.set(String(r.placetype), Number(r.id))
264
- this.#idToPlacetype.set(Number(r.id), String(r.placetype))
265
- }
266
-
267
- // #741 postal-city side-index: prepare the exact probe only if the table is present. Absent →
268
- // `#postalCityProbe` stays undefined → findPlace skips the postal-city path → byte-stable.
269
- if (hasTable(this.#db, POSTAL_CITY_CANDIDATE_TABLE)) {
270
- this.#postalCityProbe = this.#db.prepare(
271
- `SELECT spr_id, name, latitude, longitude FROM ${POSTAL_CITY_CANDIDATE_TABLE} WHERE name_key = ? AND postcode = ? LIMIT 1`
272
- )
273
- }
274
-
275
- // FTS5-trigram fuzzy fallback: prepare the MATCH probe only if the index is present (the unified
276
- // gazetteer carries it; an older candidate.db doesn't → the fuzzy path is skipped, byte-stable).
277
- if (hasTable(this.#db, CANDIDATE_FTS_TABLE)) {
278
- this.#ftsProbe = this.#db.prepare(
279
- `SELECT name_key FROM ${CANDIDATE_FTS_TABLE} WHERE ${CANDIDATE_FTS_TABLE} MATCH ? ORDER BY bm25(${CANDIDATE_FTS_TABLE}) LIMIT ?`
280
- )
281
-
282
- this.#nameKeyExistsProbe = this.#db.prepare("SELECT 1 FROM candidate WHERE name_key = ? LIMIT 1")
283
- }
284
-
285
- // #28 fame column: probed ONCE here (it runs a PRAGMA, and `findPlace` is per-keystroke hot).
286
- this.#importanceSelect = hasColumn(this.#db, "candidate", "importance") ? ", importance" : ""
287
- this.#hasNameRole = hasColumn(this.#db, "candidate", "name_role")
288
- this.#variantAliasExemption = opts.variantAliasExemption === true
289
- this.#roleSelect = this.#hasNameRole ? ", name_role" : ""
290
-
291
- // Ancestors sidecar (#1717): existence-restricted like the probes above, and the CAPABILITY checks with
292
- // it — see the `ancestors` property doc for why an older artifact must read as "no ancestors()"
293
- // rather than as a method that answers [] everywhere.
294
- if (hasTable(this.#db, CANDIDATE_ANCESTOR_TABLE)) {
295
- this.#ancestorsProbe = this.#db.prepare(
296
- `SELECT parent_spr_id, parent_placetype_id, parent_name FROM ${CANDIDATE_ANCESTOR_TABLE}` +
297
- " WHERE spr_id = ? ORDER BY depth ASC"
298
- )
299
-
300
- this.ancestors = (id) => this.#ancestorLineage(id)
301
- }
302
-
303
- // Admin-containment re-rank (#1717 stage 2): conditioned on the interval half of the sidecar (built in
304
- // the same pass as the closure rows; probed separately so a hand-degraded artifact degrades
305
- // truthfully) AND on the placetype dictionary carrying the qualifier band at all.
306
- if (this.#ancestorsProbe && hasTable(this.#db, CANDIDATE_INTERVAL_TABLE)) {
307
- this.#intervalProbe = this.#db.prepare(`SELECT pre, post FROM ${CANDIDATE_INTERVAL_TABLE} WHERE spr_id = ?`)
308
-
309
- const bandIDs = [...REGION_CLASS_PLACETYPES, "country"]
310
- .map((placetype) => this.#placetypeToID.get(placetype))
311
- .filter((id): id is number => id !== undefined)
312
-
313
- if (bandIDs.length) {
314
- this.#qualifierProbe = this.#db.prepare(
315
- `SELECT DISTINCT spr_id FROM candidate WHERE name_key = ? AND placetype_id IN (${bandIDs.join(",")}) LIMIT 8`
316
- )
317
- }
318
- }
319
-
320
- // Coverage manifest (survey candidate #2): the artifact's own coverage facts, existence-restricted like
321
- // the probes above — a candidate.db built before the manifest reads `undefined` and consumers keep
322
- // their code-constant fallbacks byte-identically.
323
- this.artifactCoverage = readGazetteerCoverageManifest(this.#db)
324
- }
325
-
326
- /**
327
- * The memoized chain read behind {@link ancestors}. Sync raw `.prepare()` on purpose — the backend contract's
328
- * `ancestors()` is synchronous (the sync-by-interface resolver-reader rule), and the sidecar row already carries the
329
- * parent's name and placetype, so this is one clustered probe with no join.
330
- */
331
- #ancestorLineage(id: number | string): Ancestor[] {
332
- const pid = typeof id === "number" ? id : Number(id)
333
-
334
- if (!Number.isFinite(pid) || !this.#ancestorsProbe) return []
335
-
336
- const cached = this.#ancestorsCache.get(pid)
337
-
338
- if (cached) return cached
339
-
340
- const rows = allRows<Pick<CandidateAncestorTable, "parent_spr_id" | "parent_placetype_id" | "parent_name">>(
341
- this.#ancestorsProbe,
342
- pid
343
- )
344
-
345
- const lineage: Ancestor[] = rows.map((r) => ({
346
- id: Number(r.parent_spr_id),
347
- placetype: this.#idToPlacetype.get(Number(r.parent_placetype_id)) ?? "",
348
- name: String(r.parent_name ?? ""),
349
- }))
350
-
351
- this.#ancestorsCache.set(pid, lineage)
352
-
353
- return lineage
354
- }
355
-
356
- /**
357
- * The interval label for one place, memoized. `null` is a real answer — the place has no recorded ancestry in the
358
- * source (absence semantics: UNVERIFIABLE, never a containment verdict) — and is cached as such.
359
- */
360
- #intervalLabel(sprID: number): IntervalLabel | null {
361
- if (!this.#intervalProbe) return null
362
-
363
- const cached = this.#intervalCache.get(sprID)
364
-
365
- if (cached !== undefined) return cached
366
-
367
- const row = this.#intervalProbe.get(sprID) as { pre: number; post: number } | undefined
368
- const label = row ? { pre: Number(row.pre), post: Number(row.post) } : null
369
-
370
- this.#intervalCache.set(sprID, label)
371
-
372
- return label
373
- }
374
-
375
- /**
376
- * The qualifier's own rows in the candidate table: every region-band (+ country) place whose `name_key` matches one
377
- * of the qualifier's {@link regionQualifierProbeKeys} expansions. Alias keys participate — `Thüringen` finds the row
378
- * stored as `Thuringia` through the artifact's own alias keying, which is precisely the variant-form bridge the
379
- * admin-coherence verdicts' fold-equality bound cannot offer (its stated v1 bound). Empty = the qualifier names
380
- * nothing the artifact knows; the caller then stamps `false` everywhere and reorders nothing.
381
- */
382
- #qualifierRegionIDs(qualifier: string, country: string | undefined): Set<number> {
383
- const ids = new Set<number>()
384
-
385
- if (!this.#qualifierProbe) return ids
386
-
387
- for (const key of regionQualifierProbeKeys(qualifier, country)) {
388
- if (!key) continue
389
-
390
- for (const row of allRows<{ spr_id: number }>(this.#qualifierProbe, key)) {
391
- ids.add(Number(row.spr_id))
392
- }
393
- }
394
-
395
- return ids
396
- }
397
-
398
- /**
399
- * Is `sprID` contained by ANY of the qualifier's rows? Interval first — {@link intervalContains}, O(1), reflexive —
400
- * then the closure rows where intervals abstain: the interval forest encodes only the CANONICAL parent per place, so
401
- * a `false` there means "not contained along the canonical hierarchy", and the chain probe (one clustered read of
402
- * ≤{@link MAX_ANCESTOR_DEPTH} rows) is the complete record that determines the result.
403
- */
404
- #containedByQualifier(sprID: number, qualifierIDs: ReadonlySet<number>, qualifierLabels: IntervalLabel[]): boolean {
405
- if (qualifierIDs.has(sprID)) return true
406
-
407
- const label = this.#intervalLabel(sprID)
408
-
409
- if (label && qualifierLabels.some((outer) => intervalContains(outer, label))) return true
410
-
411
- return this.#ancestorLineage(sprID).some((ancestor) => qualifierIDs.has(Number(ancestor.id)))
412
- }
413
-
414
- /**
415
- * The #1717 stage-2 re-rank over one lookup's final row set. Three steps, each additive:
416
- *
417
- * 1. Resolve the qualifier to its region-band rows ({@link #qualifierRegionIDs}) and stamp every existing row's
418
- * `containedByQualifier` — the stamp is the trace surface, written even when nothing reorders.
419
- * 2. INJECT contained same-key candidates the country scope hid: the deciding-site measurement (2026-08-18, the #1729
420
- * lesson re-confirmed) showed `Weimar, Thüringen` under the en-US locale probes `country_id = US`, so the DE row
421
- * is not IN the list and no reorder of the list can reach it. The injection probe runs the same exact fold (and,
422
- * on a contained-miss, the qualifier-strip variant restricted to primary keys — the #1626 alias-scrape guard)
423
- * under the SHAPE conds only, appends contained rows not already present, and never removes anything — recall can
424
- * only widen. The typo-fuzzy tier is deliberately not probed: a qualifier cannot vouch for a name the gazetteer
425
- * does not carry.
426
- * 3. Partition contained-first — the SHARED {@link partitionByContainment} (tier-safe, stable; the resolver walk runs
427
- * the same function after its fame re-rank, one function at both deciding sites per the #861 rule) — then
428
- * re-window to `limit`.
429
- *
430
- * A qualifier that matches nothing stamps `false` everywhere and reorders nothing — byte-identical answers, and the
431
- * walk's verdict reads `no_contained_candidate` rather than `unavailable` (the question WAS asked).
432
- */
433
- #applyAdminContainment(
434
- rows: Array<RankedRow<CandidateRow>>,
435
- qualifier: string,
436
- country: string | undefined,
437
- opts: {
438
- nameKey: NameKey
439
- strippedKey: NameKey
440
- shapeFilters: string[]
441
- shapeParams: Array<string | number>
442
- limit: number
443
- }
444
- ): Array<RankedRow<CandidateRow>> {
445
- const qualifierIDs = this.#qualifierRegionIDs(qualifier, country)
446
-
447
- if (!qualifierIDs.size) {
448
- for (const row of rows) {
449
- row.containedByQualifier = false
450
- }
451
-
452
- return rows
453
- }
454
-
455
- const qualifierLabels = [...qualifierIDs]
456
- .map((id) => this.#intervalLabel(id))
457
- .filter((label): label is IntervalLabel => label !== null)
458
-
459
- const contained = (sprID: number): boolean => this.#containedByQualifier(sprID, qualifierIDs, qualifierLabels)
460
-
461
- for (const row of rows) {
462
- row.containedByQualifier = contained(Number(row.spr_id))
463
- }
464
-
465
- const present = new Set(rows.map((row) => Number(row.spr_id)))
466
- const injected: Array<RankedRow<CandidateRow>> = []
467
-
468
- const injectSQL = (primaryOnly: boolean): string =>
469
- "SELECT spr_id, name, country_id, placetype_id, latitude, longitude, min_lat, min_lon, max_lat, max_lon, neg_rank, is_primary, population" +
470
- `${this.#importanceSelect} FROM candidate WHERE ${["name_key = ?", ...opts.shapeFilters, ...(primaryOnly ? ["is_primary = 1"] : [])].join(" AND ")} ` +
471
- "ORDER BY neg_rank ASC LIMIT ?"
472
-
473
- const injectFrom = (key: string, primaryOnly: boolean): void => {
474
- const fetched = allRows<CandidateRow>(
475
- this.#db.prepare(injectSQL(primaryOnly)),
476
- key,
477
- ...opts.shapeParams,
478
- RERANK_FETCH
479
- )
480
-
481
- for (const row of fetched) {
482
- const sprID = Number(row.spr_id)
483
-
484
- if (present.has(sprID) || !contained(sprID)) continue
485
- present.add(sprID)
486
-
487
- injected.push({ ...row, effectiveNegRank: row.neg_rank, demoted: false, containedByQualifier: true })
488
- }
489
- }
490
-
491
- injectFrom(opts.nameKey, false)
492
-
493
- // #1731: the dependent-locality band. A locality query's filter group (locality/borough/localadmin)
494
- // cannot reach a neighbourhood-tier namesake, so a CONTAINED one is structurally invisible no matter
495
- // how the list reorders — the Astoria class: Queens' Astoria is a WOF neighbourhood, and the walk
496
- // answered the Oregon locality under `qualifier="NY"` because nothing in the pool sat under NY. The
497
- // widening is injection-only and triple-conditioned: the band is explicit (neighbourhood/macrohood/microhood
498
- // — never region or country tiers), admission still requires the sidecar's containment proof, and only
499
- // primary-keyed rows enter (an alias-keyed neighbourhood is the #1626 scrape class). Recall can only
500
- // widen, and only toward rows the qualifier vouches for. `opts.shapeFilters`' bbox clause is
501
- // deliberately not carried: containment is the stronger constraint, and the two co-occurring is not a
502
- // measured shape.
503
- const bandIDs = ["neighbourhood", "macrohood", "microhood"]
504
- .map((placetype) => this.#placetypeToID.get(placetype))
505
- .filter((id): id is number => id !== undefined)
506
-
507
- if (bandIDs.length) {
508
- const bandSQL =
509
- "SELECT spr_id, name, country_id, placetype_id, latitude, longitude, min_lat, min_lon, max_lat, max_lon, neg_rank, is_primary, population" +
510
- `${this.#importanceSelect} FROM candidate WHERE name_key = ? AND placetype_id IN (${bandIDs.map(() => "?").join(",")}) AND is_primary = 1 ` +
511
- "ORDER BY neg_rank ASC LIMIT ?"
512
-
513
- const fetched = allRows<CandidateRow>(this.#db.prepare(bandSQL), opts.nameKey, ...bandIDs, RERANK_FETCH)
514
-
515
- for (const row of fetched) {
516
- const sprID = Number(row.spr_id)
517
-
518
- if (present.has(sprID) || !contained(sprID)) continue
519
- present.add(sprID)
520
-
521
- injected.push({ ...row, effectiveNegRank: row.neg_rank, demoted: false, containedByQualifier: true })
522
- }
523
- }
524
-
525
- // The strip variant mirrors the cascade's discipline: tried only when the exact fold vouched for
526
- // nothing, and primary-keyed only (a stripped surface never named an alias — #1626).
527
- if (
528
- !injected.length &&
529
- !rows.some((row) => row.containedByQualifier) &&
530
- opts.strippedKey &&
531
- opts.strippedKey !== opts.nameKey
532
- ) {
533
- injectFrom(opts.strippedKey, true)
534
- }
535
-
536
- if (!injected.length && !rows.some((row) => row.containedByQualifier)) return rows
537
-
538
- return partitionByContainment(
539
- [...rows, ...injected],
540
- (row) => row.containedByQualifier === true,
541
- (row) => !row.demoted && !row.fuzzy
542
- ).slice(0, opts.limit)
543
- }
544
-
545
- /**
546
- * Does this query want a locality-tier place? Postal-city aliases (#741) are all localities.
547
- */
548
- #wantsLocality(placetype: FindPlaceQuery["placetype"]): boolean {
549
- if (!placetype) return true
550
- const want = Array.isArray(placetype) ? placetype : [placetype]
551
-
552
- return expandPlacetypeFilter(want as readonly string[]).includes("locality")
553
- }
554
-
555
- /**
556
- * The postcode-containment anchor: the postcode's own centroid row in the candidate table, keyed whitespace-stripped
557
- * (#920 — the same fold the build applies to postcode rows), country-scoped when the query is, first
558
- * coordinate-bearing row wins. null when the candidate table carries no such postcode — the re-rank then abstains,
559
- * because a recall gap is not evidence for the name match. Meaning-of-zero: a 0,0 row is the build's unlocated
560
- * sentinel, never a real centroid.
561
- */
562
- #postcodeAnchor(postcode: string, country?: string): { lat: number; lon: number } | null {
563
- const placetypeID = this.#placetypeToID.get("postalcode")
564
-
565
- if (placetypeID === undefined) return null
566
-
567
- const conds = ["name_key = ?", "placetype_id = ?"]
568
- const params: Array<string | number> = [postcode.replaceAll(/\s+/g, ""), placetypeID]
569
-
570
- if (country) {
571
- const countryID = this.#countryToID.get(country.toUpperCase())
572
-
573
- if (countryID === undefined) return null // a country the candidate table doesn't carry
574
- conds.push("country_id = ?")
575
- params.push(countryID)
576
- }
577
-
578
- const row = this.#db
579
- .prepare(`SELECT latitude, longitude FROM candidate WHERE ${conds.join(" AND ")} ORDER BY neg_rank ASC LIMIT 1`)
580
- .get(...params) as { latitude: number; longitude: number } | undefined
581
-
582
- if (!row || (Number(row.latitude) === 0 && Number(row.longitude) === 0)) return null
583
-
584
- return { lat: Number(row.latitude), lon: Number(row.longitude) }
585
- }
586
-
587
- async findPlace(query: FindPlaceQuery): Promise<PlaceCandidate[]> {
588
- let text = (query.text ?? "").trim()
589
-
590
- if (!text) return []
591
-
592
- // #920 name law, candidate-key edition: postcode rows are keyed by their whitespace-stripped
593
- // form at build (the GeoNames fold normalizes '624 66' → '62466'), so a postcode-typed query
594
- // strips internal whitespace before keying. Postcode-only — locality names keep their spaces.
595
- const wantsPostcode = [query.placetype].flat().includes("postalcode")
596
-
597
- if (wantsPostcode) {
598
- text = text.replaceAll(/\s+/g, "")
599
- }
600
-
601
- const nameKey = normalizeLocalityForKey(text)
602
-
603
- if (!nameKey) return []
604
-
605
- // #741: postcode-keyed postal-city alias. An exact `(name_key, postcode)` hit resolves a
606
- // user-typed POSTAL city ("Antioch", 37013) to the geographic locality the postcode sits in
607
- // ("Nashville"), bypassing the population/region ranking that can't see the postcode. Conditioned on
608
- // the side-index being present, a postcode in the query, and a locality-tier request — so the
609
- // common (no-postcode / non-locality) path is untouched. A hit short-circuits: the postcode is
610
- // an exact, high-confidence disambiguator, so we return the single geographic locality.
611
- if (query.postcode && this.#postalCityProbe && this.#wantsLocality(query.placetype)) {
612
- const hit = this.#postalCityProbe.get(nameKey, query.postcode.trim()) as
613
- | Pick<PostalCityCandidateTable, "spr_id" | "name" | "latitude" | "longitude">
614
- | undefined
615
-
616
- if (hit) {
617
- return [
618
- {
619
- id: Number(hit.spr_id),
620
- name: String(hit.name ?? ""),
621
- placetype: "locality" as WOFPlacetype,
622
- country: query.country?.toUpperCase() ?? "",
623
- lat: Number(hit.latitude),
624
- lon: Number(hit.longitude),
625
- score: 1,
626
- exactMatch: true,
627
- },
628
- ]
629
- }
630
- }
631
-
632
- const limit = Math.max(1, query.limit ?? 10)
633
-
634
- // Filter conds shared by the exact-key + strip-fallback probes (everything but name_key). The
635
- // SHAPE subset (placetype/bbox/primary — everything but the country scope) is kept separately
636
- // because the admin-containment injection probe (#1717 stage 2) runs under the shape conds
637
- // WITHOUT the country: bypassing a locale-inferred country scope for a qualifier-vouched
638
- // candidate is the change's whole point and the one filter injection may cross.
639
- const filters: string[] = []
640
- const filterParams: Array<string | number> = []
641
- const shapeFilters: string[] = []
642
- const shapeParams: Array<string | number> = []
643
-
644
- if (query.country) {
645
- const cid = this.#countryToID.get(query.country.toUpperCase())
646
-
647
- if (cid === undefined) return [] // a country the candidate table doesn't carry
648
- filters.push("country_id = ?")
649
- filterParams.push(cid)
650
- }
651
-
652
- if (query.placetype) {
653
- // Shared placetype-equivalence expansion (a `locality` query must also reach borough /
654
- // localadmin). `postalcode` maps to no admin placetype here → empty → no rows.
655
- const want = Array.isArray(query.placetype) ? query.placetype : [query.placetype]
656
-
657
- const ids = expandPlacetypeFilter(want as readonly string[])
658
- .map((t) => this.#placetypeToID.get(t))
659
- .filter((v): v is number => v !== undefined)
660
-
661
- if (!ids.length) return []
662
- shapeFilters.push(`placetype_id IN (${ids.map(() => "?").join(",")})`)
663
- shapeParams.push(...ids)
664
- }
665
-
666
- if (query.bbox) {
667
- const b = query.bbox
668
- shapeFilters.push("latitude BETWEEN ? AND ? AND longitude BETWEEN ? AND ?")
669
- shapeParams.push(b.minLat, b.maxLat, b.minLon, b.maxLon)
670
- }
671
-
672
- // The re-reading guard (#1632, the #1626 rationale generalized to the caller): a probe whose surface
673
- // is a token taken out of a longer classified span never NAMED an alias, so alias-keyed rows must not
674
- // answer it — 'Savile Row''s token 'Row' resolved Rhu, Scotland (585 km) through the village's
675
- // historical-name alias key. Whole-input bare probes never set this, keeping the exonym recall the
676
- // #1546 note protects (Москва's alias rows answer 'Moscow').
677
- if (query.primaryOnly) {
678
- shapeFilters.push("is_primary = 1")
679
- }
680
-
681
- // The role guard (#1730): a probe may refuse abbreviation/gloss alias rows while keeping the
682
- // role-NULL exonym tier open — the distinction `primaryOnly` cannot express. Degrades to a no-op
683
- // on an artifact without the column.
684
- if (query.excludeNameRoles?.length && this.#hasNameRole) {
685
- shapeFilters.push(`(name_role IS NULL OR name_role NOT IN (${query.excludeNameRoles.map(() => "?").join(",")}))`)
686
- shapeParams.push(...query.excludeNameRoles)
687
- }
688
-
689
- // The main-probe conds are country-then-shape, exactly the order they have always been.
690
- filters.push(...shapeFilters)
691
- filterParams.push(...shapeParams)
692
-
693
- // Region scope: when the cascade resolves a region and passes it down as `parentID` (the walk sets
694
- // `query.parentID = parentResolved.id`), the candidate build stamps each place's region-tier ancestor
695
- // id into `region_id` (build-candidate.ts `regionOf`), and that id equals the resolved region's WOF id
696
- // — so `region_id = parentID` scopes the probe to in-region rows. Without it a bare same-name probe is
697
- // population-first and "Springfield, IL" (parentID = Illinois) drops to the larger Springfield, MO.
698
- // Kept OUT of the shared `filters` so a region MISS falls back to the unscoped cascade below: a
699
- // country/non-region parent (no `region_id` match), a `region_id=0` row (place with no region
700
- // ancestor), or a wrong parent degrades to today's behavior — never worse, recall-safe by construction.
701
- const regionParentID = query.parentID || undefined
702
-
703
- const probe = (nk: string, regionID: number | undefined, countryID?: number): Array<RankedRow<CandidateRow>> => {
704
- const conds = ["name_key = ?", ...filters]
705
- const params: Array<string | number> = [nk, ...filterParams]
706
-
707
- if (regionID !== undefined) {
708
- conds.push("region_id = ?")
709
- params.push(regionID)
710
- }
711
-
712
- // #1585: the fuzzy tier's country scope — only the corrected-key probes pass this.
713
- if (typeof countryID === "number") {
714
- conds.push("country_id = ?")
715
- params.push(countryID)
716
- }
717
-
718
- // Fetch population-ordered (the clustered-key order — a cheap ordered scan), over-fetching to
719
- // RERANK_FETCH so the bounded cross-country primary-preference re-rank (below) can promote the
720
- // intended primary even when a cluster of more-populous foreign aliases sits ahead of it. `is_primary`
721
- // + `country_id` feed that re-rank. A single-country probe (a country filter, or all rows same
722
- // country) re-ranks to the identical population order, so the common path is untouched.
723
- // `population` rides along for the REFERENTIAL score on the result (ROAD_TO_V9 §2) — one more
724
- // column off a clustered row the probe already reads, and it is NOT what the probe orders by:
725
- // `neg_rank` remains the sort key, so this changes no ordering, only what the result reports.
726
- // `importance` (#28) rides along on the same terms, and there is deliberately NO `ORDER BY` on it:
727
- // the fame prior is applied by the RESOLVER (`resolver/toponym-prior.ts`), which alone knows
728
- // whether the query was bare enough to deserve it. A backend that pre-sorted by fame would apply
729
- // it to every lookup, including the qualified addresses the D-rule guard exists to protect.
730
- const sql =
731
- "SELECT spr_id, name, country_id, placetype_id, latitude, longitude, min_lat, min_lon, max_lat, max_lon, neg_rank, is_primary, population" +
732
- `${this.#importanceSelect}${this.#roleSelect} FROM candidate WHERE ${conds.join(" AND ")} ORDER BY neg_rank ASC LIMIT ?`
733
-
734
- const fetched = allRows<CandidateRow>(this.#db.prepare(sql), ...params, Math.max(limit, RERANK_FETCH))
735
-
736
- return rankByPrimaryPreference(fetched, limit, undefined, this.#idToPlacetype, this.#variantAliasExemption)
737
- }
738
-
739
- // The exact → qualifier-strip → typo-fuzzy probe cascade, run at a fixed region scope. Region scoping
740
- // only tightens an already-population-first pick, so a region MISS re-runs the whole cascade unscoped
741
- // (below) rather than dropping a place that has no in-region row.
742
- const cascade = (regionID: number | undefined): Array<RankedRow<CandidateRow>> => {
743
- let rows = probe(nameKey, regionID)
744
-
745
- if (!rows.length) {
746
- // Query-side qualifier-strip fallback: an OA locality with a qualifier the gazetteer's
747
- // canonical name omits ("Lenk im Simmental" → "Lenk", "Roche VD"). Tried ONLY on an exact
748
- // miss; the cascade's region bbox disambiguates any base-name ambiguity.
749
- const strippedKey = normalizeLocalityForKey(stripLocalityQualifier(text))
750
-
751
- if (strippedKey && strippedKey !== nameKey) {
752
- // #1626: a stripped probe may answer only through a NON-PRIMARY alias key, which is a
753
- // scrape, not a qualifier match — 'Savile Row' stripped to 'row' resolved Rhu, Scotland
754
- // (585 km) through the village's historical-name alias. The legitimate qualifier class
755
- // matches the place's own primary key ('Lenk im Simmental' → the Lenk row keyed 'lenk',
756
- // is_primary=1), so refusing alias-keyed rows keeps every intended case and kills the
757
- // scrape. The alias tier remains fully available to EXACT queries — only the stripped
758
- // RETRY loses it, because the query's own surface never named the alias.
759
- rows = probe(strippedKey, regionID).filter((r) => r.is_primary === 1)
760
- }
761
- }
762
-
763
- // Typo-tolerant fallback (the unified gazetteer's fuzzy mode): an exact + strip miss may be a
764
- // misspelling the normalized key can't reach. FTS5-trigram fetches a loose set; we re-rank by
765
- // trigram-Jaccard (the admin backend's measure) and probe the best name_keys, so a typo resolves
766
- // the same on either backend. The country/placetype/bbox/region filters still apply via `probe`.
767
- // Skipped when the index is absent (byte-stable for an older candidate.db).
768
- //
769
- // Condition: only when the name doesn't exist in the gazetteer AT ALL (unfiltered). A name that exists
770
- // but missed under the active country/placetype/bbox filter is a FILTER miss, not a spelling miss
771
- // — fuzzing it scrapes an unrelated same-filter place ("Vienna, Austria" misrouted to IT would
772
- // pull a tiny Italian name_key near Siena) and masks the cascade's country-agnostic retry that
773
- // correctly lands population-first Vienna AT. The exact/strip probes already covered the real name.
774
- //
775
- // NEVER for postcodes: fuzzy is a typo corrector for place NAMES, and a "corrected" postcode is a
776
- // DIFFERENT postcode. The 2026-08-05 Code-Point swap exposed the trap at scale: Northern Ireland's
777
- // `BT3 9QQ` (absent — no permissive NI source) trigram-matched Sheffield's `S3 9QQ` (Jaccard 0.4
778
- // on {39q, 9qq}) and resolved 200+ km wrong with full confidence. An unknown postcode must abstain.
779
- // #1585: a locale HINT scopes the typo tier to its country. Only when no hard `country`
780
- // filter is active (that is already narrower); a scope naming a country the table doesn't
781
- // carry is a SCOPED-EMPTY — the fuzzy tier abstains rather than falling through worldwide.
782
- const fuzzyCountryID =
783
- !query.country && query.fuzzyCountry ? this.#countryToID.get(query.fuzzyCountry.toUpperCase()) : undefined
784
-
785
- const fuzzyScopedOut = !query.country && !!query.fuzzyCountry && typeof fuzzyCountryID !== "number"
786
-
787
- if (
788
- !rows.length &&
789
- !wantsPostcode &&
790
- !fuzzyScopedOut &&
791
- this.#ftsProbe &&
792
- this.#nameKeyExistsProbe &&
793
- !this.#nameKeyExistsProbe.get(nameKey)
794
- ) {
795
- const match = ftsTrigramQuery(nameKey)
796
-
797
- if (match) {
798
- const hits = allRows<{ name_key: string }>(this.#ftsProbe, match, FUZZY_FETCH)
799
-
800
- const ranked = hits
801
- .map((h) => ({ nk: String(h.name_key), s: wordFuzzySimilarity(nameKey, String(h.name_key)) }))
802
- .filter((h) => h.s >= WORD_FUZZY_MIN)
803
- // oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
804
- .sort((a, b) => b.s - a.s)
805
-
806
- const seen = new Set<string>()
807
-
808
- for (const h of ranked) {
809
- if (seen.has(h.nk)) continue
810
- seen.add(h.nk)
811
- // #17: stamp the tier. These rows answer a name the gazetteer does not carry, so they are
812
- // fuzzy matches and `exactMatch` below must say so — see `RankedRow.fuzzy`.
813
- rows.push(...probe(h.nk, regionID, fuzzyCountryID).map((r) => ({ ...r, fuzzy: true })))
814
-
815
- if (rows.length >= limit) break
816
- }
817
-
818
- rows = rows.slice(0, limit)
819
- }
820
- }
821
-
822
- return rows
823
- }
824
-
825
- let rows = cascade(regionParentID)
826
- // #1731: whether the rows the caller receives came from the UNSCOPED fallback below — the backend's
827
- // interior check the resolver-side trace (#1721) cannot otherwise see. Stamped onto every returned
828
- // place, because the re-admission path is exactly where a wrong-instance namesake enters.
829
- let regionScopeMiss = false
830
-
831
- // Region-scope fallback: if scoping to the parent region found nothing across the whole cascade, retry
832
- // unscoped so a place with no in-region row (missing ancestry, or a country/non-region parent) still
833
- // resolves exactly as it does today. Only when a region scope was actually applied.
834
- if (!rows.length && regionParentID !== undefined) {
835
- rows = cascade(undefined)
836
- regionScopeMiss = rows.length > 0
837
- }
838
-
839
- // Postcode-containment coherence (#31, Mechanism 2): re-rank the rows by proximity to the postcode's
840
- // own centroid, so the locality that CONTAINS the postcode wins the name-match tie (the "Paris" that
841
- // holds 75001, not the one that holds a 75001-free namesake). The resolver sends this flag on locality
842
- // lookups when `ResolveOpts.postcodeContainmentCoherence` is on. Strictly beneath the #741 postal-city
843
- // short-circuit above — an exact (name, postcode) hit IS the answer and outranks any re-rank — and after
844
- // the region-scope fallback, so it sees the final row set. Rows within the radius sort by distance first;
845
- // the out-of-radius tail keeps its original population-first order. No in-radius row, or no postcode row in
846
- // the candidate table → unchanged (byte-identical to the flag-off path).
847
- if (
848
- query.postcode &&
849
- query.postcodeContainmentCoherence === true &&
850
- this.#wantsLocality(query.placetype) &&
851
- rows.length > 1
852
- ) {
853
- const anchor = this.#postcodeAnchor(query.postcode, query.country)
854
-
855
- if (anchor) {
856
- const insideThreshold: Array<{ row: RankedRow<CandidateRow>; distanceKm: number }> = []
857
- const outsideThreshold: RankedRow<CandidateRow>[] = []
858
-
859
- for (const row of rows) {
860
- const distanceKm = haversineKm(anchor.lat, anchor.lon, Number(row.latitude), Number(row.longitude))
861
-
862
- if (distanceKm <= POSTCODE_CONTAINMENT_THRESHOLD_KM) {
863
- insideThreshold.push({ row, distanceKm })
864
- } else {
865
- outsideThreshold.push(row)
866
- }
867
- }
868
-
869
- if (insideThreshold.length) {
870
- // oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
871
- insideThreshold.sort((a, b) => a.distanceKm - b.distanceKm)
872
- rows = [...insideThreshold.map(({ row }) => row), ...outsideThreshold]
873
- }
874
- }
875
- }
876
-
877
- // Admin-containment re-rank (#1717 stage 2): LAST, on the final row set — the qualifier is the
878
- // address's outermost explicit statement, so its partition outranks the postcode-proximity order
879
- // above (contained rows keep that order among themselves). Capability-conditioned on the sidecar
880
- // (`#qualifierProbe`); when the artifact predates it, `regionQualifier` is ignored, no stamp is
881
- // written, and the resolver walk reports the change `unavailable`.
882
- if (query.regionQualifier?.trim() && this.#qualifierProbe && this.#wantsLocality(query.placetype)) {
883
- rows = this.#applyAdminContainment(rows, query.regionQualifier.trim(), query.country, {
884
- nameKey,
885
- strippedKey: normalizeLocalityForKey(stripLocalityQualifier(text)),
886
- shapeFilters,
887
- shapeParams,
888
- limit,
889
- })
890
- }
891
-
892
- const candidates = rows.map((row): PlaceCandidate => {
893
- const hasBbox = row.min_lat != null && row.max_lat != null && row.min_lon != null && row.max_lon != null
894
-
895
- return {
896
- id: Number(row.spr_id),
897
- name: String(row.name ?? ""),
898
- placetype: (this.#idToPlacetype.get(Number(row.placetype_id)) ?? "") as WOFPlacetype,
899
- // Surfaced so the cascade can country-restrict a postcode by the resolved locality (an ambiguous
900
- // international postcode like 10115 = Berlin DE AND New York US must not out-resolve the city).
901
- country: this.#idToCountry.get(Number(row.country_id)) ?? "",
902
- lat: Number(row.latitude),
903
- lon: Number(row.longitude),
904
- // `score` stays the RAW population rank (`-neg_rank`) — it feeds the resolver walk's absolute
905
- // `minWinningScore` floor (`resolve.ts`), which must see real prominence, never a penalized value.
906
- score: -Number(row.neg_rank),
907
- // `prominence` carries the bounded cross-country primary preference (the effective, penalty-adjusted
908
- // rank). The walk ORDERS candidates by `prominence ?? score` (`resolve.ts`), so this is what makes the
909
- // re-rank actually stick through resolution — without it the walk re-sorts by raw `score` and a
910
- // more-populous foreign alias (Changchun for "Cancun") wins back the node. Equals `score` for every
911
- // un-penalized row (primaries + same-country aliases), so the common ordering is unchanged.
912
- prominence: -Number(row.effectiveNegRank),
913
- // Every candidate row IS an exact normalized-name (or alias/abbrev) match — the cascade's exact tier
914
- // accepts alias-exact hits ("New York City" → New York) the same as canonical — EXCEPT a cross-country
915
- // alias that lost the bounded contest to a same-key primary (`demoted`): it drops to the partial tier so
916
- // the walk's country posterior can't cross back over the primary (see `RankedRow.demoted`) — and
917
- // EXCEPT a row the typo-corrector produced (`fuzzy`), which by definition answers a name the
918
- // gazetteer does not carry (see `RankedRow.fuzzy`).
919
- exactMatch: !row.demoted && !row.fuzzy,
920
- // #1731: emitted ONLY when a region scope was applied, missed, and the unscoped fallback
921
- // produced this row — the re-admission path. Absence means the question never arose.
922
- ...(regionScopeMiss ? { regionScopeMiss: true } : {}),
923
- // #1717 stage 2 — the containment stamp, tri-state: emitted ONLY when the question was
924
- // asked (a `regionQualifier` query over a sidecar-bearing artifact); its absence is what
925
- // the resolver walk reports as `unavailable` (meaning-of-zero).
926
- ...(row.containedByQualifier === undefined ? {} : { containedByQualifier: row.containedByQualifier }),
927
- // #1893 — the exemption's firing mark, carried only when the ranker actually spared this row
928
- // the cross-country penalty (see RankedRow.variantExempted).
929
- ...(row.variantExempted ? { variantAliasExempted: true as const } : {}),
930
- // The two-score split's carry (ROAD_TO_V9 §2). `referential` names the prominence this
931
- // backend has always ordered by — `neg_rank` IS `-log10(population + 1)`, so the score and
932
- // the sort key are two readings of the same number.
933
- ...(row.population === null || row.population <= 0
934
- ? {}
935
- : { population: row.population, referential: referentialFromPopulation(row.population) }),
936
- // #28: the fame prior, from the `importance` column the candidate build joins in. Emitted ONLY
937
- // when the artifact measured this place — an absent field is what `rankByImportance` reads as
938
- // "does not participate", and a 0 would be a claim nobody made (meaning-of-zero).
939
- //
940
- // The field name matches the column because they hold the same thing: the score source's
941
- // BLENDED prior — the concordance's encyclopedia-derived channel where a concordance matched, a
942
- // population-derived proxy everywhere else. It is NOT the strict `encyclopedic` channel
943
- // `place-importance-schema.ts` defines, and it deliberately does not land in that field: the
944
- // strict channel was measured on 2026-08-10 and covers eleven countries, none of them CA/AU/RU,
945
- // which makes it inert on three of the four homonym contests the prior exists to settle.
946
- // `PlaceCandidate.encyclopedic` stays reserved for a strict-channel source (the FTS backend's
947
- // clauses are strict and today emit NULL for everything — no shipped admin DB has the split
948
- // table at all). See `candidate-schema.ts` → {@link CandidateTable.importance}.
949
- ...(typeof row.importance === "number" && Number.isFinite(row.importance)
950
- ? { importance: row.importance }
951
- : {}),
952
- ...(hasBbox
953
- ? {
954
- bbox: {
955
- minLat: Number(row.min_lat),
956
- maxLat: Number(row.max_lat),
957
- minLon: Number(row.min_lon),
958
- maxLon: Number(row.max_lon),
959
- },
960
- }
961
- : {}),
962
- }
963
- })
964
-
965
- // Proximity re-rank (#938) — the shared implementation, so the browser byte-range twin runs the same
966
- // code rather than the same constants. See proximity-rerank.ts for why that distinction mattered.
967
- if (query.bias && query.bias.length) {
968
- applyProximityRerank(candidates, query.bias)
969
- }
970
-
971
- return candidates
972
- }
973
-
974
- [Symbol.dispose](): void {
975
- this.#resources[Symbol.dispose]()
976
- }
977
- }