@mailwoman/resolver-wof-sqlite 9.1.0 → 9.3.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 (501) hide show
  1. package/README.md +36 -20
  2. package/lib/address/index.ts +9 -0
  3. package/{address-point-interpolation.ts → lib/address/point-interpolation.ts} +40 -28
  4. package/{address-point-schema.ts → lib/address/point-schema.ts} +34 -8
  5. package/lib/address/point.ts +280 -0
  6. package/{ancestry-backfill.ts → lib/ancestry/backfill.ts} +14 -19
  7. package/{ancestry.ts → lib/ancestry/index.ts} +13 -8
  8. package/{build-candidate.ts → lib/build-candidate.ts} +243 -199
  9. package/{build-slim.ts → lib/build-slim.ts} +153 -168
  10. package/lib/candidate/alias-bags.ts +56 -0
  11. package/lib/candidate/ancestors-sidecar.ts +204 -0
  12. package/lib/candidate/country-display-names.ts +79 -0
  13. package/lib/candidate/extract-fold.ts +138 -0
  14. package/lib/candidate/name-roles.ts +238 -0
  15. package/lib/candidate/own-name.ts +146 -0
  16. package/lib/candidate/place-attrs.ts +44 -0
  17. package/lib/candidate-ancestors-schema.ts +195 -0
  18. package/{candidate-fts.ts → lib/candidate-fts.ts} +7 -5
  19. package/{candidate-importance.ts → lib/candidate-importance.ts} +54 -57
  20. package/lib/candidate-lookup.ts +976 -0
  21. package/{candidate-schema.ts → lib/candidate-schema.ts} +34 -6
  22. package/lib/candidate-scoring.ts +261 -0
  23. package/lib/capital-schema.ts +89 -0
  24. package/lib/capitals.ts +148 -0
  25. package/{coincident-roles.ts → lib/coincident-roles.ts} +75 -19
  26. package/{convention.ts → lib/convention/index.ts} +5 -5
  27. package/lib/convention/schema.ts +72 -0
  28. package/{coverage-manifest-schema.ts → lib/coverage-manifest-schema.ts} +15 -16
  29. package/lib/currency-backfill.ts +334 -0
  30. package/lib/env.ts +50 -0
  31. package/lib/exact-match.ts +113 -0
  32. package/{sharding.ts → lib/extracts.ts} +63 -60
  33. package/lib/fst/autocomplete.ts +176 -0
  34. package/{fst-builder.ts → lib/fst/builder.ts} +30 -27
  35. package/{fst-deserialize-web.ts → lib/fst/deserialize-web.ts} +27 -87
  36. package/lib/fst/format.ts +106 -0
  37. package/{fst-freshness.ts → lib/fst/freshness.ts} +42 -75
  38. package/lib/fst/index.ts +14 -0
  39. package/{fst-matcher.ts → lib/fst/matcher.ts} +1 -1
  40. package/{fst-serialize.ts → lib/fst/serialize.ts} +37 -115
  41. package/{fst-types.ts → lib/fst/types.ts} +4 -2
  42. package/{fts.ts → lib/fts/index.ts} +28 -23
  43. package/{fts-query.ts → lib/fts/query.ts} +2 -2
  44. package/{geonames-aliases.ts → lib/geonames/aliases.ts} +13 -13
  45. package/lib/geonames/index.ts +8 -0
  46. package/{geonames-postal.ts → lib/geonames/postal.ts} +16 -15
  47. package/{index.ts → lib/index.ts} +52 -48
  48. package/{interpolation.ts → lib/interpolation.ts} +137 -41
  49. package/lib/lookup.ts +861 -0
  50. package/{name-score.ts → lib/name-score.ts} +6 -4
  51. package/lib/nsul/index.ts +8 -0
  52. package/lib/nsul/lookup.ts +144 -0
  53. package/lib/nsul/schema.ts +130 -0
  54. package/{place-importance-schema.ts → lib/place-importance-schema.ts} +70 -22
  55. package/lib/poi/index.ts +8 -0
  56. package/{poi-lookup.ts → lib/poi/lookup.ts} +32 -39
  57. package/{poi-schema.ts → lib/poi/schema.ts} +10 -6
  58. package/lib/polygon-schema.ts +47 -0
  59. package/{postal-city-alias-lookup.ts → lib/postal/city-alias-lookup.ts} +15 -21
  60. package/{postal-city-candidate-schema.ts → lib/postal/city-candidate-schema.ts} +4 -2
  61. package/lib/postal/index.ts +9 -0
  62. package/{postcode-point-lookup.ts → lib/postcode-point-lookup.ts} +17 -15
  63. package/lib/primary-preference.ts +226 -0
  64. package/lib/proximity-rerank.ts +120 -0
  65. package/{ranking-weights.ts → lib/ranking-weights.ts} +30 -4
  66. package/lib/region-keys.ts +144 -0
  67. package/{reverse.ts → lib/reverse.ts} +45 -56
  68. package/{schema.ts → lib/schema.ts} +11 -1
  69. package/lib/search-fetch.ts +257 -0
  70. package/{sqlite-convention-source.ts → lib/sqlite-convention-source.ts} +9 -10
  71. package/lib/sqlite-utils.ts +83 -0
  72. package/{street-centroid-schema.ts → lib/street/centroid-schema.ts} +12 -6
  73. package/{street-centroid.ts → lib/street/centroid.ts} +26 -28
  74. package/lib/street/index.ts +13 -0
  75. package/{street-morphology-fst-builder.ts → lib/street/morphology-fst-builder.ts} +24 -14
  76. package/{street-morphology-fst-loader.ts → lib/street/morphology-fst-loader.ts} +14 -12
  77. package/{street-name-lookup.ts → lib/street/name-lookup.ts} +14 -30
  78. package/lib/street/normalize.ts +572 -0
  79. package/{street-segment-schema.ts → lib/street/segment-schema.ts} +13 -8
  80. package/{types.ts → lib/types.ts} +49 -9
  81. package/{unified-schema.ts → lib/unified-schema.ts} +21 -25
  82. package/lib/uprn/existence.ts +84 -0
  83. package/lib/uprn/index.ts +9 -0
  84. package/lib/uprn/lookup.ts +206 -0
  85. package/lib/uprn/schema.ts +124 -0
  86. package/lib/weights-overlay-linker.ts +878 -0
  87. package/out/address/index.d.ts +9 -0
  88. package/out/address/index.d.ts.map +1 -0
  89. package/out/address/index.js +9 -0
  90. package/out/address/index.js.map +1 -0
  91. package/out/{address-point-interpolation.d.ts → address/point-interpolation.d.ts} +11 -10
  92. package/out/address/point-interpolation.d.ts.map +1 -0
  93. package/out/{address-point-interpolation.js → address/point-interpolation.js} +29 -23
  94. package/out/address/point-interpolation.js.map +1 -0
  95. package/out/{address-point-schema.d.ts → address/point-schema.d.ts} +30 -10
  96. package/out/address/point-schema.d.ts.map +1 -0
  97. package/out/{address-point-schema.js → address/point-schema.js} +7 -3
  98. package/out/address/point-schema.js.map +1 -0
  99. package/out/{address-point.d.ts → address/point.d.ts} +16 -8
  100. package/out/address/point.d.ts.map +1 -0
  101. package/out/address/point.js +191 -0
  102. package/out/address/point.js.map +1 -0
  103. package/out/{ancestry-backfill.d.ts → ancestry/backfill.d.ts} +7 -5
  104. package/out/ancestry/backfill.d.ts.map +1 -0
  105. package/out/{ancestry-backfill.js → ancestry/backfill.js} +12 -16
  106. package/out/ancestry/backfill.js.map +1 -0
  107. package/out/{ancestry.d.ts → ancestry/index.d.ts} +6 -5
  108. package/out/ancestry/index.d.ts.map +1 -0
  109. package/out/{ancestry.js → ancestry/index.js} +7 -7
  110. package/out/ancestry/index.js.map +1 -0
  111. package/out/build-candidate.d.ts +88 -7
  112. package/out/build-candidate.d.ts.map +1 -1
  113. package/out/build-candidate.js +145 -149
  114. package/out/build-candidate.js.map +1 -1
  115. package/out/build-slim.d.ts +5 -5
  116. package/out/build-slim.d.ts.map +1 -1
  117. package/out/build-slim.js +127 -142
  118. package/out/build-slim.js.map +1 -1
  119. package/out/candidate/alias-bags.d.ts +19 -0
  120. package/out/candidate/alias-bags.d.ts.map +1 -0
  121. package/out/candidate/alias-bags.js +39 -0
  122. package/out/candidate/alias-bags.js.map +1 -0
  123. package/out/candidate/ancestors-sidecar.d.ts +32 -0
  124. package/out/candidate/ancestors-sidecar.d.ts.map +1 -0
  125. package/out/candidate/ancestors-sidecar.js +140 -0
  126. package/out/candidate/ancestors-sidecar.js.map +1 -0
  127. package/out/candidate/country-display-names.d.ts +35 -0
  128. package/out/candidate/country-display-names.d.ts.map +1 -0
  129. package/out/candidate/country-display-names.js +59 -0
  130. package/out/candidate/country-display-names.js.map +1 -0
  131. package/out/candidate/extract-fold.d.ts +32 -0
  132. package/out/candidate/extract-fold.d.ts.map +1 -0
  133. package/out/candidate/extract-fold.js +104 -0
  134. package/out/candidate/extract-fold.js.map +1 -0
  135. package/out/candidate/name-roles.d.ts +57 -0
  136. package/out/candidate/name-roles.d.ts.map +1 -0
  137. package/out/candidate/name-roles.js +166 -0
  138. package/out/candidate/name-roles.js.map +1 -0
  139. package/out/candidate/own-name.d.ts +50 -0
  140. package/out/candidate/own-name.d.ts.map +1 -0
  141. package/out/candidate/own-name.js +132 -0
  142. package/out/candidate/own-name.js.map +1 -0
  143. package/out/candidate/place-attrs.d.ts +43 -0
  144. package/out/candidate/place-attrs.d.ts.map +1 -0
  145. package/out/candidate/place-attrs.js +15 -0
  146. package/out/candidate/place-attrs.js.map +1 -0
  147. package/out/candidate-ancestors-schema.d.ts +150 -0
  148. package/out/candidate-ancestors-schema.d.ts.map +1 -0
  149. package/out/candidate-ancestors-schema.js +123 -0
  150. package/out/candidate-ancestors-schema.js.map +1 -0
  151. package/out/candidate-fts.d.ts +7 -5
  152. package/out/candidate-fts.d.ts.map +1 -1
  153. package/out/candidate-fts.js +5 -3
  154. package/out/candidate-fts.js.map +1 -1
  155. package/out/candidate-importance.d.ts +16 -16
  156. package/out/candidate-importance.d.ts.map +1 -1
  157. package/out/candidate-importance.js +47 -52
  158. package/out/candidate-importance.js.map +1 -1
  159. package/out/candidate-lookup.d.ts +31 -52
  160. package/out/candidate-lookup.d.ts.map +1 -1
  161. package/out/candidate-lookup.js +380 -172
  162. package/out/candidate-lookup.js.map +1 -1
  163. package/out/candidate-schema.d.ts +31 -7
  164. package/out/candidate-schema.d.ts.map +1 -1
  165. package/out/candidate-schema.js +3 -0
  166. package/out/candidate-schema.js.map +1 -1
  167. package/out/candidate-scoring.d.ts +34 -0
  168. package/out/candidate-scoring.d.ts.map +1 -0
  169. package/out/candidate-scoring.js +196 -0
  170. package/out/candidate-scoring.js.map +1 -0
  171. package/out/capital-schema.d.ts +51 -0
  172. package/out/capital-schema.d.ts.map +1 -0
  173. package/out/capital-schema.js +63 -0
  174. package/out/capital-schema.js.map +1 -0
  175. package/out/capitals.d.ts +69 -0
  176. package/out/capitals.d.ts.map +1 -0
  177. package/out/capitals.js +98 -0
  178. package/out/capitals.js.map +1 -0
  179. package/out/coincident-roles.d.ts +12 -4
  180. package/out/coincident-roles.d.ts.map +1 -1
  181. package/out/coincident-roles.js +43 -11
  182. package/out/coincident-roles.js.map +1 -1
  183. package/out/{convention.d.ts → convention/index.d.ts} +5 -5
  184. package/out/convention/index.d.ts.map +1 -0
  185. package/out/{convention.js → convention/index.js} +4 -4
  186. package/out/convention/index.js.map +1 -0
  187. package/out/convention/schema.d.ts +51 -0
  188. package/out/convention/schema.d.ts.map +1 -0
  189. package/out/convention/schema.js +34 -0
  190. package/out/convention/schema.js.map +1 -0
  191. package/out/coverage-manifest-schema.d.ts +8 -8
  192. package/out/coverage-manifest-schema.d.ts.map +1 -1
  193. package/out/coverage-manifest-schema.js +6 -10
  194. package/out/coverage-manifest-schema.js.map +1 -1
  195. package/out/currency-backfill.d.ts +90 -0
  196. package/out/currency-backfill.d.ts.map +1 -0
  197. package/out/currency-backfill.js +196 -0
  198. package/out/currency-backfill.js.map +1 -0
  199. package/out/env.d.ts +43 -0
  200. package/out/env.d.ts.map +1 -0
  201. package/out/env.js +48 -0
  202. package/out/env.js.map +1 -0
  203. package/out/exact-match.d.ts +25 -0
  204. package/out/exact-match.d.ts.map +1 -0
  205. package/out/exact-match.js +88 -0
  206. package/out/exact-match.js.map +1 -0
  207. package/out/extracts.d.ts +114 -0
  208. package/out/extracts.d.ts.map +1 -0
  209. package/out/{sharding.js → extracts.js} +38 -38
  210. package/out/extracts.js.map +1 -0
  211. package/out/{fst-autocomplete.d.ts → fst/autocomplete.d.ts} +13 -13
  212. package/out/fst/autocomplete.d.ts.map +1 -0
  213. package/out/fst/autocomplete.js +113 -0
  214. package/out/fst/autocomplete.js.map +1 -0
  215. package/out/{fst-builder.d.ts → fst/builder.d.ts} +5 -5
  216. package/out/fst/builder.d.ts.map +1 -0
  217. package/out/{fst-builder.js → fst/builder.js} +24 -24
  218. package/out/fst/builder.js.map +1 -0
  219. package/out/{fst-deserialize-web.d.ts → fst/deserialize-web.d.ts} +3 -3
  220. package/out/fst/deserialize-web.d.ts.map +1 -0
  221. package/out/{fst-deserialize-web.js → fst/deserialize-web.js} +10 -74
  222. package/out/fst/deserialize-web.js.map +1 -0
  223. package/out/fst/format.d.ts +80 -0
  224. package/out/fst/format.d.ts.map +1 -0
  225. package/out/fst/format.js +91 -0
  226. package/out/fst/format.js.map +1 -0
  227. package/out/{fst-freshness.d.ts → fst/freshness.d.ts} +11 -14
  228. package/out/fst/freshness.d.ts.map +1 -0
  229. package/out/{fst-freshness.js → fst/freshness.js} +42 -74
  230. package/out/fst/freshness.js.map +1 -0
  231. package/out/fst/index.d.ts +14 -0
  232. package/out/fst/index.d.ts.map +1 -0
  233. package/out/fst/index.js +14 -0
  234. package/out/fst/index.js.map +1 -0
  235. package/out/{fst-matcher.d.ts → fst/matcher.d.ts} +2 -2
  236. package/out/fst/matcher.d.ts.map +1 -0
  237. package/out/{fst-matcher.js → fst/matcher.js} +1 -1
  238. package/out/fst/matcher.js.map +1 -0
  239. package/out/{fst-serialize.d.ts → fst/serialize.d.ts} +5 -10
  240. package/out/fst/serialize.d.ts.map +1 -0
  241. package/out/{fst-serialize.js → fst/serialize.js} +18 -98
  242. package/out/fst/serialize.js.map +1 -0
  243. package/out/{fst-types.d.ts → fst/types.d.ts} +4 -3
  244. package/out/fst/types.d.ts.map +1 -0
  245. package/out/{fst-types.js → fst/types.js} +1 -1
  246. package/out/fst/types.js.map +1 -0
  247. package/out/{fts.d.ts → fts/index.d.ts} +20 -13
  248. package/out/fts/index.d.ts.map +1 -0
  249. package/out/{fts.js → fts/index.js} +20 -15
  250. package/out/fts/index.js.map +1 -0
  251. package/out/{fts-query.d.ts → fts/query.d.ts} +2 -2
  252. package/out/fts/query.d.ts.map +1 -0
  253. package/out/{fts-query.js → fts/query.js} +2 -2
  254. package/out/fts/query.js.map +1 -0
  255. package/out/{geonames-aliases.d.ts → geonames/aliases.d.ts} +8 -6
  256. package/out/geonames/aliases.d.ts.map +1 -0
  257. package/out/{geonames-aliases.js → geonames/aliases.js} +7 -7
  258. package/out/geonames/aliases.js.map +1 -0
  259. package/out/geonames/index.d.ts +8 -0
  260. package/out/geonames/index.d.ts.map +1 -0
  261. package/out/geonames/index.js +8 -0
  262. package/out/geonames/index.js.map +1 -0
  263. package/out/{geonames-postal.d.ts → geonames/postal.d.ts} +12 -10
  264. package/out/geonames/postal.d.ts.map +1 -0
  265. package/out/{geonames-postal.js → geonames/postal.js} +11 -11
  266. package/out/geonames/postal.js.map +1 -0
  267. package/out/index.d.ts +32 -31
  268. package/out/index.d.ts.map +1 -1
  269. package/out/index.js +24 -24
  270. package/out/index.js.map +1 -1
  271. package/out/interpolation.d.ts +18 -9
  272. package/out/interpolation.d.ts.map +1 -1
  273. package/out/interpolation.js +108 -36
  274. package/out/interpolation.js.map +1 -1
  275. package/out/lookup.d.ts +25 -25
  276. package/out/lookup.d.ts.map +1 -1
  277. package/out/lookup.js +174 -558
  278. package/out/lookup.js.map +1 -1
  279. package/out/name-score.d.ts +0 -10
  280. package/out/name-score.d.ts.map +1 -1
  281. package/out/name-score.js +6 -4
  282. package/out/name-score.js.map +1 -1
  283. package/out/nsul/index.d.ts +8 -0
  284. package/out/nsul/index.d.ts.map +1 -0
  285. package/out/nsul/index.js +8 -0
  286. package/out/nsul/index.js.map +1 -0
  287. package/out/nsul/lookup.d.ts +83 -0
  288. package/out/nsul/lookup.d.ts.map +1 -0
  289. package/out/nsul/lookup.js +80 -0
  290. package/out/nsul/lookup.js.map +1 -0
  291. package/out/nsul/schema.d.ts +100 -0
  292. package/out/nsul/schema.d.ts.map +1 -0
  293. package/out/nsul/schema.js +78 -0
  294. package/out/nsul/schema.js.map +1 -0
  295. package/out/place-importance-schema.d.ts +47 -10
  296. package/out/place-importance-schema.d.ts.map +1 -1
  297. package/out/place-importance-schema.js +56 -10
  298. package/out/place-importance-schema.js.map +1 -1
  299. package/out/poi/index.d.ts +8 -0
  300. package/out/poi/index.d.ts.map +1 -0
  301. package/out/poi/index.js +8 -0
  302. package/out/poi/index.js.map +1 -0
  303. package/out/{poi-lookup.d.ts → poi/lookup.d.ts} +8 -10
  304. package/out/poi/lookup.d.ts.map +1 -0
  305. package/out/{poi-lookup.js → poi/lookup.js} +24 -29
  306. package/out/poi/lookup.js.map +1 -0
  307. package/out/{poi-schema.d.ts → poi/schema.d.ts} +10 -6
  308. package/out/poi/schema.d.ts.map +1 -0
  309. package/out/{poi-schema.js → poi/schema.js} +1 -1
  310. package/out/poi/schema.js.map +1 -0
  311. package/out/polygon-schema.d.ts +37 -0
  312. package/out/polygon-schema.d.ts.map +1 -0
  313. package/out/polygon-schema.js +23 -0
  314. package/out/polygon-schema.js.map +1 -0
  315. package/out/{postal-city-alias-lookup.d.ts → postal/city-alias-lookup.d.ts} +7 -6
  316. package/out/postal/city-alias-lookup.d.ts.map +1 -0
  317. package/out/{postal-city-alias-lookup.js → postal/city-alias-lookup.js} +12 -16
  318. package/out/postal/city-alias-lookup.js.map +1 -0
  319. package/out/{postal-city-alias-schema.d.ts → postal/city-alias-schema.d.ts} +1 -1
  320. package/out/{postal-city-alias-schema.d.ts.map → postal/city-alias-schema.d.ts.map} +1 -1
  321. package/out/{postal-city-alias-schema.js → postal/city-alias-schema.js} +1 -1
  322. package/out/{postal-city-alias-schema.js.map → postal/city-alias-schema.js.map} +1 -1
  323. package/out/{postal-city-candidate-schema.d.ts → postal/city-candidate-schema.d.ts} +4 -3
  324. package/out/postal/city-candidate-schema.d.ts.map +1 -0
  325. package/out/{postal-city-candidate-schema.js → postal/city-candidate-schema.js} +2 -2
  326. package/out/{postal-city-candidate-schema.js.map → postal/city-candidate-schema.js.map} +1 -1
  327. package/out/postal/index.d.ts +9 -0
  328. package/out/postal/index.d.ts.map +1 -0
  329. package/out/postal/index.js +9 -0
  330. package/out/postal/index.js.map +1 -0
  331. package/out/postcode-point-lookup.d.ts +10 -10
  332. package/out/postcode-point-lookup.d.ts.map +1 -1
  333. package/out/postcode-point-lookup.js +13 -13
  334. package/out/postcode-point-lookup.js.map +1 -1
  335. package/out/primary-preference.d.ts +133 -0
  336. package/out/primary-preference.d.ts.map +1 -0
  337. package/out/primary-preference.js +146 -0
  338. package/out/primary-preference.js.map +1 -0
  339. package/out/proximity-rerank.d.ts +77 -0
  340. package/out/proximity-rerank.d.ts.map +1 -0
  341. package/out/proximity-rerank.js +86 -0
  342. package/out/proximity-rerank.js.map +1 -0
  343. package/out/ranking-weights.d.ts +15 -3
  344. package/out/ranking-weights.d.ts.map +1 -1
  345. package/out/ranking-weights.js +21 -3
  346. package/out/ranking-weights.js.map +1 -1
  347. package/out/region-keys.d.ts +47 -0
  348. package/out/region-keys.d.ts.map +1 -0
  349. package/out/region-keys.js +121 -0
  350. package/out/region-keys.js.map +1 -0
  351. package/out/reverse.d.ts +6 -6
  352. package/out/reverse.d.ts.map +1 -1
  353. package/out/reverse.js +23 -35
  354. package/out/reverse.js.map +1 -1
  355. package/out/schema.d.ts +10 -1
  356. package/out/schema.d.ts.map +1 -1
  357. package/out/schema.js.map +1 -1
  358. package/out/search-fetch.d.ts +59 -0
  359. package/out/search-fetch.d.ts.map +1 -0
  360. package/out/search-fetch.js +184 -0
  361. package/out/search-fetch.js.map +1 -0
  362. package/out/sqlite-convention-source.d.ts +7 -7
  363. package/out/sqlite-convention-source.d.ts.map +1 -1
  364. package/out/sqlite-convention-source.js +5 -5
  365. package/out/sqlite-convention-source.js.map +1 -1
  366. package/out/sqlite-utils.d.ts +24 -6
  367. package/out/sqlite-utils.d.ts.map +1 -1
  368. package/out/sqlite-utils.js +24 -7
  369. package/out/sqlite-utils.js.map +1 -1
  370. package/out/{street-centroid-schema.d.ts → street/centroid-schema.d.ts} +12 -7
  371. package/out/street/centroid-schema.d.ts.map +1 -0
  372. package/out/{street-centroid-schema.js → street/centroid-schema.js} +5 -5
  373. package/out/{street-centroid-schema.js.map → street/centroid-schema.js.map} +1 -1
  374. package/out/{street-centroid.d.ts → street/centroid.d.ts} +6 -6
  375. package/out/street/centroid.d.ts.map +1 -0
  376. package/out/{street-centroid.js → street/centroid.js} +18 -24
  377. package/out/street/centroid.js.map +1 -0
  378. package/out/street/index.d.ts +13 -0
  379. package/out/street/index.d.ts.map +1 -0
  380. package/out/street/index.js +13 -0
  381. package/out/street/index.js.map +1 -0
  382. package/out/{street-morphology-fst-builder.d.ts → street/morphology-fst-builder.d.ts} +4 -4
  383. package/out/street/morphology-fst-builder.d.ts.map +1 -0
  384. package/out/{street-morphology-fst-builder.js → street/morphology-fst-builder.js} +18 -13
  385. package/out/street/morphology-fst-builder.js.map +1 -0
  386. package/out/{street-morphology-fst-loader.d.ts → street/morphology-fst-loader.d.ts} +5 -5
  387. package/out/street/morphology-fst-loader.d.ts.map +1 -0
  388. package/out/{street-morphology-fst-loader.js → street/morphology-fst-loader.js} +11 -10
  389. package/out/street/morphology-fst-loader.js.map +1 -0
  390. package/out/{street-name-lookup.d.ts → street/name-lookup.d.ts} +4 -7
  391. package/out/street/name-lookup.d.ts.map +1 -0
  392. package/out/{street-name-lookup.js → street/name-lookup.js} +9 -23
  393. package/out/street/name-lookup.js.map +1 -0
  394. package/out/street/normalize.d.ts +179 -0
  395. package/out/street/normalize.d.ts.map +1 -0
  396. package/out/street/normalize.js +457 -0
  397. package/out/street/normalize.js.map +1 -0
  398. package/out/{street-segment-schema.d.ts → street/segment-schema.d.ts} +13 -9
  399. package/out/street/segment-schema.d.ts.map +1 -0
  400. package/out/{street-segment-schema.js → street/segment-schema.js} +4 -4
  401. package/out/{street-segment-schema.js.map → street/segment-schema.js.map} +1 -1
  402. package/out/types.d.ts +49 -9
  403. package/out/types.d.ts.map +1 -1
  404. package/out/types.js.map +1 -1
  405. package/out/unified-schema.d.ts +6 -5
  406. package/out/unified-schema.d.ts.map +1 -1
  407. package/out/unified-schema.js +16 -19
  408. package/out/unified-schema.js.map +1 -1
  409. package/out/uprn/existence.d.ts +53 -0
  410. package/out/uprn/existence.d.ts.map +1 -0
  411. package/out/uprn/existence.js +61 -0
  412. package/out/uprn/existence.js.map +1 -0
  413. package/out/uprn/index.d.ts +9 -0
  414. package/out/uprn/index.d.ts.map +1 -0
  415. package/out/uprn/index.js +9 -0
  416. package/out/uprn/index.js.map +1 -0
  417. package/out/uprn/lookup.d.ts +85 -0
  418. package/out/uprn/lookup.d.ts.map +1 -0
  419. package/out/uprn/lookup.js +149 -0
  420. package/out/uprn/lookup.js.map +1 -0
  421. package/out/uprn/schema.d.ts +93 -0
  422. package/out/uprn/schema.d.ts.map +1 -0
  423. package/out/uprn/schema.js +78 -0
  424. package/out/uprn/schema.js.map +1 -0
  425. package/out/weights-overlay-linker.d.ts +299 -0
  426. package/out/weights-overlay-linker.d.ts.map +1 -0
  427. package/out/weights-overlay-linker.js +511 -0
  428. package/out/weights-overlay-linker.js.map +1 -0
  429. package/package.json +622 -154
  430. package/address-point.ts +0 -125
  431. package/candidate-lookup.ts +0 -720
  432. package/fst-autocomplete.ts +0 -204
  433. package/geo.ts +0 -121
  434. package/lookup.ts +0 -1354
  435. package/out/address-point-interpolation.d.ts.map +0 -1
  436. package/out/address-point-interpolation.js.map +0 -1
  437. package/out/address-point-schema.d.ts.map +0 -1
  438. package/out/address-point-schema.js.map +0 -1
  439. package/out/address-point.d.ts.map +0 -1
  440. package/out/address-point.js +0 -84
  441. package/out/address-point.js.map +0 -1
  442. package/out/ancestry-backfill.d.ts.map +0 -1
  443. package/out/ancestry-backfill.js.map +0 -1
  444. package/out/ancestry.d.ts.map +0 -1
  445. package/out/ancestry.js.map +0 -1
  446. package/out/convention.d.ts.map +0 -1
  447. package/out/convention.js.map +0 -1
  448. package/out/fst-autocomplete.d.ts.map +0 -1
  449. package/out/fst-autocomplete.js +0 -130
  450. package/out/fst-autocomplete.js.map +0 -1
  451. package/out/fst-builder.d.ts.map +0 -1
  452. package/out/fst-builder.js.map +0 -1
  453. package/out/fst-deserialize-web.d.ts.map +0 -1
  454. package/out/fst-deserialize-web.js.map +0 -1
  455. package/out/fst-freshness.d.ts.map +0 -1
  456. package/out/fst-freshness.js.map +0 -1
  457. package/out/fst-matcher.d.ts.map +0 -1
  458. package/out/fst-matcher.js.map +0 -1
  459. package/out/fst-serialize.d.ts.map +0 -1
  460. package/out/fst-serialize.js.map +0 -1
  461. package/out/fst-types.d.ts.map +0 -1
  462. package/out/fst-types.js.map +0 -1
  463. package/out/fts-query.d.ts.map +0 -1
  464. package/out/fts-query.js.map +0 -1
  465. package/out/fts.d.ts.map +0 -1
  466. package/out/fts.js.map +0 -1
  467. package/out/geo.d.ts +0 -74
  468. package/out/geo.d.ts.map +0 -1
  469. package/out/geo.js +0 -71
  470. package/out/geo.js.map +0 -1
  471. package/out/geonames-aliases.d.ts.map +0 -1
  472. package/out/geonames-aliases.js.map +0 -1
  473. package/out/geonames-postal.d.ts.map +0 -1
  474. package/out/geonames-postal.js.map +0 -1
  475. package/out/poi-lookup.d.ts.map +0 -1
  476. package/out/poi-lookup.js.map +0 -1
  477. package/out/poi-schema.d.ts.map +0 -1
  478. package/out/poi-schema.js.map +0 -1
  479. package/out/postal-city-alias-lookup.d.ts.map +0 -1
  480. package/out/postal-city-alias-lookup.js.map +0 -1
  481. package/out/postal-city-candidate-schema.d.ts.map +0 -1
  482. package/out/sharding.d.ts +0 -114
  483. package/out/sharding.d.ts.map +0 -1
  484. package/out/sharding.js.map +0 -1
  485. package/out/street-centroid-schema.d.ts.map +0 -1
  486. package/out/street-centroid.d.ts.map +0 -1
  487. package/out/street-centroid.js.map +0 -1
  488. package/out/street-morphology-fst-builder.d.ts.map +0 -1
  489. package/out/street-morphology-fst-builder.js.map +0 -1
  490. package/out/street-morphology-fst-loader.d.ts.map +0 -1
  491. package/out/street-morphology-fst-loader.js.map +0 -1
  492. package/out/street-name-lookup.d.ts.map +0 -1
  493. package/out/street-name-lookup.js.map +0 -1
  494. package/out/street-normalize.d.ts +0 -95
  495. package/out/street-normalize.d.ts.map +0 -1
  496. package/out/street-normalize.js +0 -272
  497. package/out/street-normalize.js.map +0 -1
  498. package/out/street-segment-schema.d.ts.map +0 -1
  499. package/sqlite-utils.ts +0 -46
  500. package/street-normalize.ts +0 -318
  501. /package/{postal-city-alias-schema.ts → lib/postal/city-alias-schema.ts} +0 -0
package/lookup.ts DELETED
@@ -1,1354 +0,0 @@
1
- /**
2
- * @copyright Sister Software
3
- * @license AGPL-3.0
4
- * @author Teffen Ellis, et al.
5
- *
6
- * `WOFSqlitePlaceLookup` — the resolver implementation backed by `node:sqlite` + a Kysely-typed
7
- * query layer where the queries are non-trivial, and raw SQL where they aren't (FTS5 MATCH, the
8
- * FTS index build).
9
- *
10
- * See `docs/plan/phases/PHASE_4_2_wof_sqlite.md` for the design rationale.
11
- */
12
-
13
- import { DatabaseSync, type SQLInputValue } from "node:sqlite"
14
-
15
- import { SqliteDialect } from "@mailwoman/core/kysley/dialect"
16
- import { expandPlacetypeFilter, type Ancestor, type CoincidentLocality } from "@mailwoman/resolver"
17
- import { Kysely } from "kysely"
18
-
19
- import { ancestorLineage } from "./ancestry.ts"
20
- import { COINCIDENT_ROLES_TABLE, coincidentRolesExists } from "./coincident-roles.ts"
21
- import {
22
- ADDRESS_CONVENTION_TABLE,
23
- resolveConvention,
24
- SeedConventionSource,
25
- type Convention,
26
- type ConventionSource,
27
- type ResolvedConvention,
28
- type Strategy,
29
- } from "./convention.ts"
30
- import { normalizePlacetypes, sanitizeFTSQuery } from "./fts-query.ts"
31
- import {
32
- aliasBagExactMatch,
33
- buildPlaceSearchFTS,
34
- PLACE_BBOX_TABLE,
35
- PLACE_POPULATION_TABLE,
36
- placeBboxExists,
37
- placePopulationExists,
38
- placeSearchFTSExists,
39
- } from "./fts.ts"
40
- import { bboxAround, haversineKm } from "./geo.ts"
41
- import { cfNormalize, softNameScore, trigramJaccard } from "./name-score.ts"
42
- import { compareReferential, encyclopedicClauses, referentialFromPopulation } from "./place-importance-schema.ts"
43
- import type { WOFPostalCityAliasLookup } from "./postal-city-alias-lookup.ts"
44
- import { DEFAULT_WEIGHTS, type RankingWeights } from "./ranking-weights.ts"
45
- import type { WOFDatabase } from "./schema.ts"
46
- import {
47
- pickShardForPlacetype,
48
- pickShardsForPlacetype,
49
- resolveShards,
50
- type ResolvedShard,
51
- type ShardConfig,
52
- } from "./sharding.ts"
53
- import { SqliteConventionSource } from "./sqlite-convention-source.ts"
54
- import type { FindPlaceQuery, PlaceCandidate, PlaceLookup, WOFPlacetype } from "./types.ts"
55
-
56
- /**
57
- * Query length at or below which the FTS window is widened. A two- or three-character query is almost always a region
58
- * abbreviation, where the exact match can otherwise fall outside the window behind higher-bm25 partial hits — "NY"
59
- * losing to "New York".
60
- */
61
- const SHORT_QUERY_MAX_LENGTH = 3
62
-
63
- export interface WOFSqlitePlaceLookupOpts {
64
- /**
65
- * Path to the WOF SQLite distribution on disk. Mutually exclusive with `database`.
66
- *
67
- * **Single string** — opens that one DB as the main shard.
68
- *
69
- * **Array** — opens the first entry as main, then ATTACHes each subsequent entry as a separate SQLite schema. Schema
70
- * names are derived from the filename (`whosonfirst-data-postalcode- us-latest.db` → `postalcode_us`); override with
71
- * `ShardConfig.schemaName` when the filename doesn't follow WOF convention. See `sharding.ts` for the derivation
72
- * rules.
73
- *
74
- * Routing: queries with a `placetype` matching a shard's name (or explicit `placetypes` hint) are sent to that shard;
75
- * everything else hits main. Cross-shard UNION is NOT done — BM25 isn't comparable across separately-indexed
76
- * corpora.
77
- */
78
- databasePath?: string | ReadonlyArray<string | ShardConfig>
79
- /**
80
- * Pre-opened DatabaseSync — primarily for tests against an inline fixture DB. Mutually exclusive with `databasePath`.
81
- * Multi-shard requires `databasePath` (so the lookup owns the ATTACH).
82
- */
83
- database?: DatabaseSync
84
- /**
85
- * If true, build the FTS5 `place_search` virtual table on construction if it doesn't already exist. The upstream WOF
86
- * distribution does NOT ship FTS5, so callers either set this once on first open or pre-build it via the
87
- * operator-side CLI documented in the README. Default false — the resolver assumes the index already exists and
88
- * errors loudly if it doesn't.
89
- *
90
- * With multi-shard, `buildFTS: true` builds the index on the **main** shard only. Other shards must be pre-built via
91
- * `mailwoman gazetteer build fts` — operator script for predictable cost.
92
- */
93
- buildFTS?: boolean
94
- /**
95
- * Geographic Rule Engine convention source (Direction E, #289). Per-WOF-polygon resolution profiles, either as a
96
- * ready `ConventionSource` or a plain `{ wofID: Convention }` seed map. Default empty — every query rides
97
- * `WORLD_DEFAULT` (the EU coordinate-first behavior). JP/KR/TW add rows; #290 wires a build-from-source sqlite-backed
98
- * source here.
99
- */
100
- conventions?: ConventionSource | Record<number, Convention>
101
- /**
102
- * Opt-in postal-city alias reader (#475). When supplied, the coordinate-first locality scorer treats an observed
103
- * `postal_city` ("Antioch", postcode 37013) as a name-match alias for the geographic locality the postcode sits in
104
- * ("Nashville"), recovering the chronic postal-vs- geographic-city mismatch. Absent (the default), the resolver is
105
- * byte-identical — every alias code path is gated on this being non-null, so an unprovided reader changes no score.
106
- */
107
- postalCityAliases?: WOFPostalCityAliasLookup
108
- }
109
-
110
- /**
111
- * Over-fetch floor for SHORT (≤3-char) queries — region abbreviations like "NY"/"VT". An exact-abbrev holder's BM25 is
112
- * poor (long multilingual alt-name document), so the normal `limit * 4` window can drop it before `exactMatchTiering`
113
- * promotes it. 200 comfortably covers every same-abbrev region across the 12-country gazetteer (a 2-letter token
114
- * matches a few dozen regions at most) while staying a cheap region-placetype fetch. See the `#fuzzyNameMatch`
115
- * over-fetch comment.
116
- */
117
- const SHORT_QUERY_OVERFETCH = 200
118
-
119
- /**
120
- * How many rows the population-ordered companion fetch (#905) adds to the candidate pool. Small on purpose: its only
121
- * job is to guarantee the FAMOUS holders of a name enter the pool at all — for "Paris"-class floods the bm25 window is
122
- * saturated by thousands of tiny same-name rows and no boost inside the bm25-based ORDER BY can rescue a candidate
123
- * whose bm25 is length-poisoned by ~15 points (see the fetch-site comment).
124
- */
125
- const POPULATION_FETCH_LIMIT = 15
126
-
127
- interface RawSearchRow {
128
- id: number
129
- name: string
130
- placetype: string
131
- country: string | null
132
- parent_id: number | null
133
- rank: number // BM25 (lower = better in SQLite); we negate to get higher-is-better
134
- lat: number | null
135
- lon: number | null
136
- min_latitude: number | null
137
- max_latitude: number | null
138
- min_longitude: number | null
139
- max_longitude: number | null
140
- population: number | null // from the place_population aux table; null when missing
141
- /**
142
- * From `place_importance.encyclopedic` when the shard's table carries the two-score split columns. NULL means the
143
- * place has no Wikipedia article, or the shard predates the split — absence either way, and never 0 (ROAD_TO_V9 §2).
144
- */
145
- encyclopedic: number | null
146
- }
147
-
148
- /**
149
- * The coordinate-first candidate table (scripts/build-postcode-locality.ts): postcode → containing
150
- *
151
- * - Nearby localities with WOF alt-name aliases.
152
- */
153
- const POSTCODE_LOCALITY_TABLE = "postcode_locality"
154
-
155
- /**
156
- * Tunables for the coordinate-first locality soft-score `Score = pc·S_pc + name·S_name + pop·S_pop` (each S in [0,1]).
157
- * The pc/name/pop WEIGHTS now come from the resolved convention's `scoringWeights` (`WORLD_DEFAULT` = 0.6/0.3/0.1 — the
158
- * EU values), so a locale can retune them as data. PC_DECAY_KM sets how fast S_pc falls with distance.
159
- */
160
- const CF_PC_DECAY_KM = 8
161
- /**
162
- * The chosen locality must be within this distance of the postcode's containing locality, else the postcode and the
163
- * parsed city name are judged to disagree (a transposed / wrong-for-the-city postcode) and the `mismatch` flag fires.
164
- * Generous enough that a city-state Ortsteil (~15km from the city centroid) and an abutting town (~few km) are NOT
165
- * flagged, tight enough to catch a wrong city (hundreds of km).
166
- */
167
- const CF_MISMATCH_KM = 50
168
- const CF_MISMATCH_DELTA = 0.5
169
-
170
- export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
171
- readonly #db: DatabaseSync
172
- readonly #ownsDB: boolean
173
- readonly #kysely: Kysely<WOFDatabase>
174
- readonly #weights: RankingWeights
175
- /**
176
- * Cached at construction so we don't `sqlite_master` query on every findPlace call. Bbox + near- with-radius queries
177
- * fall back to no-filter when this is false, preserving compatibility with DBs that were FTS-built before the R*Tree
178
- * shipped.
179
- *
180
- * Per-shard: a shard is only considered to have the bbox index if its own R*Tree table exists.
181
- */
182
- readonly #hasBboxIndex: Map<string, boolean>
183
- /**
184
- * Per-shard probe for the `place_population` aux table. When false, the LEFT JOIN is omitted from the SELECT and
185
- * population boost is 0 for every row — preserves compatibility with DBs built before this feature shipped.
186
- */
187
- readonly #hasPopulationIndex: Map<string, boolean>
188
- /**
189
- * Per-shard SELECT term + LEFT JOIN for the two-score split's `encyclopedic` carry (ROAD_TO_V9 §2 R1), probed and
190
- * built once at construction. Degrades to `NULL AS encyclopedic` with no join on a pre-split shard — every shipped
191
- * shard today. See {@link encyclopedicClauses} for why the probe is a column and not a table.
192
- */
193
- readonly #encyclopedicClauses: Map<string, { select: string; join: string }>
194
- /**
195
- * Per-shard probe for the `postcode_locality` table (the coordinate-first candidate table, built by
196
- * scripts/build-postcode-locality.ts). Cached at construction; null'd out when absent so the coord-first path
197
- * silently no-ops on a deployment that didn't ship the table.
198
- */
199
- readonly #postcodeLocalityShard: string | null
200
- /**
201
- * Resolved shard list. Always at least one entry; first is `main`. Multi-shard adds extras with their own derived (or
202
- * override) schema names.
203
- */
204
- readonly #shards: ResolvedShard[]
205
- /**
206
- * #920: per-schema probed country sets for country-aware shard routing (non-main shards only).
207
- */
208
- readonly #shardCountries: Map<string, ReadonlySet<string>>
209
- /**
210
- * The Geographic Rule Engine (Direction E, #289). `#conventionSource` supplies per-WOF-polygon resolution profiles;
211
- * `#strategies` is the named-primitive registry the merged convention dispatches. Empty source → every query resolves
212
- * to `WORLD_DEFAULT` → byte-identical to the pre-engine coordinate-first path. `#countryWOFIdCache` memoizes the
213
- * country-code → country-WOF-id lookup that seeds the convention ancestor chain (one query per country, then
214
- * cached).
215
- */
216
- readonly #conventionSource: ConventionSource
217
- readonly #strategies: Map<string, Strategy>
218
- readonly #countryWOFIdCache = new Map<string, number | null>()
219
- /**
220
- * Strategy names already warned about — so an unknown name surfaces once, not once per query.
221
- */
222
- readonly #warnedUnknownStrategies = new Set<string>()
223
- /**
224
- * Lazily-built `admin_id → coincident localities` map from the #403 relation (null until first use).
225
- */
226
- #coincidentRolesCache: Map<number, CoincidentLocality[]> | null = null
227
- /**
228
- * Per-id memoized ancestor lineages (#404) — a hot chain is queried once.
229
- */
230
- readonly #ancestorsCache = new Map<number, Ancestor[]>()
231
- /**
232
- * Opt-in postal-city alias reader (#475). `null` unless `opts.postalCityAliases` was supplied — every alias code path
233
- * is gated on this, so the default resolver is byte-identical.
234
- */
235
- readonly #postalCityAliases: WOFPostalCityAliasLookup | null
236
-
237
- constructor(opts: WOFSqlitePlaceLookupOpts, weights?: Partial<RankingWeights>) {
238
- if (opts.database && opts.databasePath) {
239
- throw new Error("WOFSqlitePlaceLookup: pass either `database` or `databasePath`, not both")
240
- }
241
-
242
- if (!opts.database && !opts.databasePath) {
243
- throw new Error("WOFSqlitePlaceLookup: one of `database` or `databasePath` is required")
244
- }
245
-
246
- if (opts.database) {
247
- this.#db = opts.database
248
- this.#ownsDB = false
249
- this.#shards = [{ path: ":memory:", schemaName: "main", placetypes: [] }]
250
- } else {
251
- const shards = resolveShards(opts.databasePath!)
252
- this.#shards = shards
253
- // Read-only by default — shipped gazetteer shards are sealed 0444 and Docker `:ro` mounts
254
- // forbid write-mode opens, so a writable open fails there. The only code path that writes to
255
- // the main shard is `#ensureFTS()` (FTS5 index build), gated on `opts.buildFTS`; open writable
256
- // ONLY when that build was explicitly requested. Every read query (FTS5 MATCH, the aux-table
257
- // SELECTs, ATTACH, and the `busy_timeout` PRAGMA) works read-only. See the docker read-only
258
- // mount limitation (#1213).
259
- this.#db = new DatabaseSync(shards[0]!.path, { readOnly: !opts.buildFTS })
260
- this.#ownsDB = true
261
-
262
- // ATTACH each non-main shard. Schema names were validated by resolveShards, so safe to
263
- // interpolate directly (SQLite ATTACH doesn't accept parameters for the schema name).
264
- for (const s of shards.slice(1)) {
265
- this.#db.exec(`ATTACH DATABASE '${s.path.replaceAll("'", "''")}' AS ${s.schemaName}`)
266
- }
267
- }
268
-
269
- // node:sqlite has no .pragma() helper; pragmas are executed as plain SQL.
270
- this.#db.exec("PRAGMA busy_timeout = 5000")
271
-
272
- if (opts.buildFTS) {
273
- this.#ensureFTS()
274
- } else {
275
- this.#assertFTSExists()
276
- }
277
-
278
- this.#kysely = new Kysely<WOFDatabase>({
279
- dialect: new SqliteDialect({ database: this.#db }),
280
- })
281
-
282
- this.#weights = { ...DEFAULT_WEIGHTS, ...weights }
283
-
284
- // Probe each shard's aux-table presence — driven by per-shard table existence in
285
- // sqlite_master. Cached at construction so findPlace doesn't query sqlite_master per call.
286
- this.#hasBboxIndex = new Map()
287
- this.#hasPopulationIndex = new Map()
288
- this.#encyclopedicClauses = new Map()
289
-
290
- for (const s of this.#shards) {
291
- this.#hasBboxIndex.set(s.schemaName, this.#shardHasTable(s.schemaName, PLACE_BBOX_TABLE))
292
- this.#hasPopulationIndex.set(s.schemaName, this.#shardHasTable(s.schemaName, PLACE_POPULATION_TABLE))
293
- this.#encyclopedicClauses.set(s.schemaName, encyclopedicClauses(this.#db, s.schemaName))
294
- }
295
-
296
- // #920 country-aware shard routing: probe each NON-MAIN shard's country set once at
297
- // construction (they're small, purpose-built shards — postcode/locality slices; main is the
298
- // multi-GB admin DB and is the fallback anyway, so it is deliberately NOT scanned). Feeds
299
- // pickShardForPlacetype so two postcode shards (postalcode-us + postalcode-geonames-tail)
300
- // route by the query's country instead of first-match starving the second shard.
301
- this.#shardCountries = new Map()
302
-
303
- for (const sh of this.#shards) {
304
- if (sh.schemaName === "main") continue
305
-
306
- try {
307
- const rows = this.#db
308
- .prepare(`SELECT DISTINCT country FROM ${sh.schemaName}.spr WHERE country != ''`)
309
- .all() as Array<{ country: string }>
310
-
311
- this.#shardCountries.set(sh.schemaName, new Set(rows.map((r) => r.country)))
312
- } catch {
313
- // A shard without spr (or an attach oddity) just doesn't participate in country routing.
314
- }
315
- }
316
-
317
- // The postcode_locality table can live on any attached shard (typically its own
318
- // `postcode-locality-<cc>.db`). Find the first shard that has it; null = coord-first disabled.
319
- this.#postcodeLocalityShard =
320
- this.#shards.find((s) => this.#shardHasTable(s.schemaName, POSTCODE_LOCALITY_TABLE))?.schemaName ?? null
321
-
322
- // Opt-in postal-city alias reader (#475). Construction-time present-or-not is the gate: null
323
- // keeps the coordinate-first scorer byte-identical to pre-#475.
324
- this.#postalCityAliases = opts.postalCityAliases ?? null
325
-
326
- // The Geographic Rule Engine convention source. Precedence: an explicit `opts.conventions`
327
- // (a ready source or a seed map) wins; else the build-from-source convention asset if one is
328
- // attached (auto-detected, like the postcode_locality shard — adding conventions.db to
329
- // databasePath enables it; queried on demand, not paged into memory); else empty, so EU rides
330
- // WORLD_DEFAULT. The registry binds strategy NAMES to the SQL-bound primitives — adding a
331
- // strategy is registering it here.
332
- const conventionShard =
333
- this.#shards.find((s) => this.#shardHasTable(s.schemaName, ADDRESS_CONVENTION_TABLE))?.schemaName ?? null
334
-
335
- this.#conventionSource = opts.conventions
336
- ? "get" in opts.conventions && typeof opts.conventions.get === "function"
337
- ? opts.conventions
338
- : new SeedConventionSource(opts.conventions as Record<number, Convention>)
339
- : conventionShard
340
- ? new SqliteConventionSource(this.#db, conventionShard)
341
- : new SeedConventionSource()
342
-
343
- this.#strategies = new Map<string, Strategy>([
344
- ["postcode_area_resolution", (q, c) => this.#postcodeAreaResolution(q, c)],
345
- ["fallback_fuzzy_name_match", (q) => this.#fuzzyNameMatch(q)],
346
- ])
347
- }
348
-
349
- #shardHasTable(schemaName: string, tableName: string): boolean {
350
- // For main, the existing helpers work directly. For attached shards we have to ask via the
351
- // schema-qualified `sqlite_master` view.
352
- if (schemaName === "main") {
353
- if (tableName === PLACE_BBOX_TABLE) return placeBboxExists(this.#db)
354
-
355
- if (tableName === PLACE_POPULATION_TABLE) return placePopulationExists(this.#db)
356
- }
357
-
358
- const row = this.#db
359
- .prepare(`SELECT name FROM ${schemaName}.sqlite_master WHERE type = 'table' AND name = ?`)
360
- .get(tableName) as { name: string } | undefined
361
-
362
- return Boolean(row)
363
- }
364
-
365
- async findPlace(query: FindPlaceQuery): Promise<PlaceCandidate[]> {
366
- // Geographic Rule Engine dispatch (#289). Resolve the effective convention for this query
367
- // (WORLD_DEFAULT for the EU locales — the seed source is empty) and run its candidate strategies
368
- // in order; the first to return a non-null result wins. The default list,
369
- // [postcode_area_resolution, fallback_fuzzy_name_match], reproduces the pre-engine coordinate-
370
- // first → FTS fall-through exactly. Unknown strategy names are skipped, so a convention may name
371
- // a primitive a future phase will register.
372
- const convention = this.#conventionFor(query)
373
-
374
- let outcome: PlaceCandidate[] = []
375
-
376
- for (const name of convention.candidateStrategies) {
377
- const strategy = this.#strategies.get(name)
378
-
379
- if (!strategy) {
380
- this.#warnUnknownStrategy(name)
381
-
382
- continue
383
- }
384
-
385
- const result = await strategy(query, convention)
386
-
387
- if (result !== null) {
388
- outcome = result
389
-
390
- break
391
- }
392
- }
393
-
394
- if (outcome.length) return outcome
395
-
396
- // #924: NL postcode retry ladder. The WOF NL postalcode repo stores full codes UNSPACED
397
- // ('1012LG') plus 4-digit stems ('1012'), while Dutch addresses carry the spaced form
398
- // ('1012 LG') — two FTS tokens that can never match the one-token doc (the #920 name law,
399
- // resurfacing in a WOF-built shard). On a postcode-typed NL-shape miss, retry ONCE with the
400
- // whitespace-joined form (block-level precision when the full-code row exists), then the
401
- // 4-digit stem (area-level). Country-gated to NL — the same digits+letters shape elsewhere
402
- // must not silently coarsen to a different system's code. Each retry only fires when its
403
- // text differs from the current one, so the ladder terminates by construction.
404
- if (
405
- query.country?.toUpperCase() === "NL" &&
406
- (normalizePlacetypes(query.placetype)?.includes("postalcode") ?? false) &&
407
- /^\d{4}\s?[A-Za-z]{2}$/.test(query.text.trim())
408
- ) {
409
- const trimmed = query.text.trim()
410
- const joined = trimmed.replaceAll(/\s+/g, "")
411
-
412
- if (joined !== trimmed) {
413
- const full = await this.findPlace({ ...query, text: joined })
414
-
415
- if (full.length) return full
416
- }
417
-
418
- const stem = trimmed.slice(0, 4)
419
-
420
- if (stem !== trimmed) return this.findPlace({ ...query, text: stem })
421
- }
422
-
423
- return outcome
424
- }
425
-
426
- /**
427
- * Dual-role localities coincident with an admin id, from the precomputed `coincident_roles` relation (#403). Backs
428
- * {@link ResolveOpts.hierarchyCompletion} (#405): O(1) once the relation is loaded. Returns `[]` when the relation
429
- * table is absent (older DB) or the admin isn't a dual-role place, so completion degrades gracefully. The relation +
430
- * `spr` join is loaded once and memoized.
431
- */
432
- coincidentLocalitiesFor(adminID: number | string): CoincidentLocality[] {
433
- const id = typeof adminID === "number" ? adminID : Number(adminID)
434
-
435
- if (!Number.isFinite(id)) return []
436
-
437
- if (!this.#coincidentRolesCache) {
438
- const map = new Map<number, CoincidentLocality[]>()
439
-
440
- if (coincidentRolesExists(this.#db)) {
441
- const rows = this.#db
442
- .prepare(
443
- `SELECT cr.admin_id AS adminID, s.id AS id, s.name AS name, s.country AS country,
444
- s.latitude AS lat, s.longitude AS lon,
445
- cr.relationship_type AS relationshipType, cr.locality_population AS population,
446
- cr.distance_km AS distanceKm
447
- FROM ${COINCIDENT_ROLES_TABLE} cr JOIN spr s ON s.id = cr.locality_id`
448
- )
449
- .all() as unknown as Array<{
450
- adminID: number
451
- id: number
452
- name: string
453
- country: string
454
- lat: number
455
- lon: number
456
- relationshipType: string
457
- population: number
458
- distanceKm: number
459
- }>
460
-
461
- for (const r of rows) {
462
- const candidate: CoincidentLocality = {
463
- id: r.id,
464
- name: r.name,
465
- placetype: "locality",
466
- country: r.country,
467
- lat: r.lat,
468
- lon: r.lon,
469
- score: 0,
470
- relationshipType: r.relationshipType,
471
- population: r.population,
472
- distanceKm: r.distanceKm,
473
- }
474
-
475
- const list = map.get(r.adminID)
476
-
477
- if (list) {
478
- list.push(candidate)
479
- } else {
480
- map.set(r.adminID, [candidate])
481
- }
482
- }
483
- }
484
-
485
- this.#coincidentRolesCache = map
486
- }
487
-
488
- return this.#coincidentRolesCache.get(id) ?? []
489
- }
490
-
491
- /**
492
- * The ancestor lineage of a place — its containment chain joined with `spr` for canonical names, ordered
493
- * NEAREST-FIRST (localadmin → county → region → … → country). Backs {@link ResolveOpts.includeAncestors} (#404). Self
494
- * is excluded; memoized per id. Returns `[]` when the place has no recorded ancestry.
495
- *
496
- * The walk itself lives in `ancestry.ts` (shared with the reverse geocoder, #484); the ordering is its
497
- * `PLACETYPE_DEPTH` table — same ranking as the previous inline SQL CASE, extended below `localadmin` so
498
- * locality/neighbourhood ancestors order correctly instead of sorting last.
499
- */
500
- ancestors(id: number | string): Ancestor[] {
501
- const pid = typeof id === "number" ? id : Number(id)
502
-
503
- if (!Number.isFinite(pid)) return []
504
- const cached = this.#ancestorsCache.get(pid)
505
-
506
- if (cached) return cached
507
-
508
- const lineage: Ancestor[] = ancestorLineage(this.#db, pid).map((r) => ({
509
- id: r.id,
510
- placetype: r.placetype,
511
- name: r.name,
512
- }))
513
-
514
- this.#ancestorsCache.set(pid, lineage)
515
-
516
- return lineage
517
- }
518
-
519
- /**
520
- * Surface an unknown strategy name LOUDLY (once per name) rather than swallowing it silently — an invisible no-op is
521
- * exactly the hidden-dependency failure mode we avoid (see the provenance-first design value). We warn rather than
522
- * throw so a convention asset built against a newer code revision (one that adds a strategy) degrades gracefully on
523
- * an older build instead of taking down resolution.
524
- */
525
- #warnUnknownStrategy(name: string): void {
526
- if (this.#warnedUnknownStrategies.has(name)) return
527
- this.#warnedUnknownStrategies.add(name)
528
-
529
- console.warn(
530
- `WOFSqlitePlaceLookup: a convention names strategy "${name}", which this build does not register ` +
531
- `(known: ${[...this.#strategies.keys()].join(", ")}). Skipping it. If the convention asset was built ` +
532
- `against a newer code revision, rebuild the asset for this one.`
533
- )
534
- }
535
-
536
- /**
537
- * Strategy `postcode_area_resolution` — the coordinate-first locality path, strictly gated (a sibling postcode AND a
538
- * postcode_locality table AND a locality query). Returns `null` — so the dispatcher falls through to the next
539
- * strategy — when the gate is unmet or the postcode isn't in the table; otherwise the soft-scored postcode∪name
540
- * candidate set.
541
- */
542
- #postcodeAreaResolution(query: FindPlaceQuery, convention: ResolvedConvention): Promise<PlaceCandidate[] | null> {
543
- if (!(query.postcode && this.#postcodeLocalityShard && this.#isLocalityQuery(query))) {
544
- return Promise.resolve(null)
545
- }
546
-
547
- return this.#findLocalityCoordFirst(query, this.#postcodeLocalityShard, convention)
548
- }
549
-
550
- /**
551
- * Strategy `fallback_fuzzy_name_match` — the BM25 FTS name-match over the gazetteer, the universal fallback. Always
552
- * returns an array (never null), so it terminates the dispatch chain.
553
- */
554
- async #fuzzyNameMatch(query: FindPlaceQuery, forceShard?: ResolvedShard): Promise<PlaceCandidate[]> {
555
- const limit = query.limit ?? 10
556
-
557
- // Over-fetch so post-scoring + exact-match tiering have room to re-rank. SHORT queries (a 2–3-char
558
- // region abbreviation like "NY"/"VT") are the danger case the `exactMatchTiering` docstring flags:
559
- // the exact-abbrev holder's BM25 is poor (its long multilingual alt-name document tanks the score),
560
- // so under the normal `limit * 4` window it drops OUT of the candidate pool BEFORE tiering can
561
- // promote it — "NY" then resolves to a token-matching foreign region (Highland, GB) instead of New
562
- // York. Widen the window for short queries so the exact match is always present to be tiered.
563
- // (Cross-country abbrev collisions — "VT" is BOTH Vermont and Viterbo — still need a country/
564
- // postcode signal to disambiguate; this only rescues the window-drop class, not genuine ambiguity.
565
- // With a `country` hint every abbrev resolves; bare + no-context lifts 7→10/15 US states.)
566
- const ftsLimit =
567
- query.text.trim().length <= SHORT_QUERY_MAX_LENGTH ? Math.max(limit * 4, SHORT_QUERY_OVERFETCH) : limit * 4
568
-
569
- // Expand the placetype filter through the shared equivalence table (core/resolver): a
570
- // `locality` query must also reach `borough` / `localadmin` rows — Brooklyn-the-borough
571
- // (pop 2.5M) is a borough, not a locality, and a strict filter made it unreachable so the
572
- // fuzzy "Brooklyn Park, MN" won instead. Order-preserving: the FIRST entry stays the
573
- // requested placetype, which is what shard routing keys off below.
574
- const placetypes = expandPlacetypeFilter(normalizePlacetypes(query.placetype)) as WOFPlacetype[] | null
575
- // Postcode-typed queries keep the #920 fused name-law shape; everything else splits on
576
- // intra-token punctuation so hyphenated names reach the FTS as their real terms (#945).
577
- const ftsQuery = sanitizeFTSQuery(query.text, { fuseTokens: placetypes?.includes("postalcode") ?? false })
578
-
579
- if (!ftsQuery) return []
580
-
581
- // Pick the shard for this query. Multi-shard routing is placetype-driven; a query without
582
- // `placetype` always goes to main. (Mixed-placetype queries with multiple shards aren't
583
- // supported in v1 — caller can issue two findPlace calls and merge in TS if needed.)
584
- const firstPlacetype = placetypes?.[0]
585
-
586
- // Bias fan-out (#58/proximity-bias): a country-less query WITH proximity hints must see the
587
- // cross-shard ambiguity the hints exist to resolve — "48026" lives in postalcode-us AND
588
- // postalcode-intl, and single-shard routing would hide one side. Query every matching shard
589
- // (self-recursion with a shard pin), merge by id, and re-sort by the same (exact, prominence)
590
- // keys the per-shard tier sort used. Bounded: hints + no country + >1 matching shard only.
591
- const hasBiasHints = !!query.near || (query.bias?.length ?? 0) > 0
592
-
593
- if (!forceShard && hasBiasHints && !query.country) {
594
- const matching = pickShardsForPlacetype(this.#shards, firstPlacetype)
595
-
596
- if (matching.length > 1) {
597
- const pools: PlaceCandidate[][] = []
598
-
599
- for (const sh of matching) {
600
- pools.push(await this.#fuzzyNameMatch(query, sh))
601
- }
602
-
603
- const byID = new Map<PlaceCandidate["id"], PlaceCandidate>()
604
-
605
- for (const c of pools.flat()) {
606
- if (!byID.has(c.id)) {
607
- byID.set(c.id, c)
608
- }
609
- }
610
-
611
- const merged = [...byID.values()]
612
-
613
- merged.sort(
614
- (a, b) =>
615
- Number(b.exactMatch ?? false) - Number(a.exactMatch ?? false) ||
616
- (b.prominence ?? 0) - (a.prominence ?? 0) ||
617
- b.score - a.score
618
- )
619
-
620
- return merged.slice(0, limit)
621
- }
622
- }
623
-
624
- const shard =
625
- forceShard ??
626
- pickShardForPlacetype(this.#shards, firstPlacetype, {
627
- country: query.country,
628
- countriesBySchema: this.#shardCountries,
629
- })
630
-
631
- const sch = shard.schemaName // bare schema name; safe to interpolate (validated at construction)
632
-
633
- // Filter out historical / superseded / deprecated places by default — they live in the same
634
- // spr table but should never win a contemporary lookup. `is_current = 0` is the only WOF
635
- // value that means "not current"; both `-1` (modern) and `1` (legacy) mean current. See #91.
636
- // Note: with schema-qualified FROM the bare `place_search` reference in MATCH resolves to
637
- // the FROM table — required by FTS5 parser, see sharding.ts header comment.
638
- const where: string[] = ["place_search MATCH ?", "spr.is_current != 0", "spr.is_deprecated = 0"]
639
- const params: SQLInputValue[] = [ftsQuery]
640
-
641
- if (placetypes && placetypes.length) {
642
- where.push(`spr.placetype IN (${placetypes.map(() => "?").join(", ")})`)
643
- params.push(...placetypes)
644
- }
645
-
646
- if (query.country) {
647
- where.push("spr.country = ?")
648
- params.push(query.country)
649
- }
650
-
651
- if (query.parentID !== undefined) {
652
- where.push(`(spr.parent_id = ? OR spr.id IN (SELECT id FROM ${sch}.ancestors WHERE ancestor_id = ?))`)
653
- params.push(query.parentID, query.parentID)
654
- }
655
-
656
- // Bbox + near-with-radius are SQL-level filters via the R*Tree. We only emit the JOIN when
657
- // the active shard has the R*Tree; missing-but-requested is silently treated as no-bbox-
658
- // filter so legacy DBs / shards-without-bbox don't crash.
659
- const shardHasBbox = this.#hasBboxIndex.get(sch) === true
660
- const useBboxJoin = (query.bbox || query.near?.maxDistanceKm !== undefined) && shardHasBbox
661
- let joinClause = `JOIN ${sch}.spr ON spr.id = place_search.wof_id`
662
-
663
- if (useBboxJoin) {
664
- joinClause += ` JOIN ${sch}.${PLACE_BBOX_TABLE} bbox ON bbox.id = spr.id`
665
- // AABB intersection — both bbox sides must overlap. R*Tree handles this in O(log n).
666
- const filterBox = query.bbox || bboxAround(query.near!.lat, query.near!.lon, query.near!.maxDistanceKm!)
667
- where.push("bbox.min_lat <= ? AND bbox.max_lat >= ?", "bbox.min_lon <= ? AND bbox.max_lon >= ?")
668
- params.push(filterBox.maxLat, filterBox.minLat, filterBox.maxLon, filterBox.minLon)
669
- }
670
-
671
- // LEFT JOIN the population aux table when present. Missing-on-this-shard means the SELECT
672
- // just doesn't include the population column; the post-scoring loop treats it as 0.
673
- const shardHasPopulation = this.#hasPopulationIndex.get(sch) === true
674
-
675
- const populationSelect = shardHasPopulation
676
- ? `${PLACE_POPULATION_TABLE}.population AS population`
677
- : `NULL AS population`
678
-
679
- const populationJoin = shardHasPopulation
680
- ? `LEFT JOIN ${sch}.${PLACE_POPULATION_TABLE} ON ${PLACE_POPULATION_TABLE}.id = spr.id`
681
- : ""
682
-
683
- // The encyclopedic score is CARRIED, never ranked on (ROAD_TO_V9 §2, ratified 2026-08-06) — it
684
- // appears in the SELECT and in no ORDER BY, here or in the companion fetch below. Gated on the
685
- // split column, so a pre-split shard emits a literal NULL and builds no join at all.
686
- const { select: encyclopedicSelect, join: encyclopedicJoin } = this.#encyclopedicClauses.get(sch)!
687
-
688
- // Push the population boost into the ORDER BY when the index is available, so famous places
689
- // (whose long alt-name lists hurt BM25) actually make it into the over-fetch window. The TS
690
- // post-scoring will still compute the same boost for the final score; this just ensures the
691
- // candidate set is right.
692
- //
693
- // Formula: rank_adjusted = bm25 - populationBoost * min(1.0, log10(1 + pop) / scaleLog10)
694
- // Lower rank_adjusted = better (matches SQLite's bm25 convention of "more negative = better").
695
- //
696
- // #905 — do NOT reach for bm25 column weights here. Measured falsification (2026-07-02): FTS5's
697
- // bm25 length normalization is polluted by the row's TOTAL document size, so identical 1-token
698
- // `name` docs read −16.0 (empty alt_names) vs −0.43 (2.7 KB alt_names) EVEN with the alt_names
699
- // column weighted to zero — no weighting isolates name relevance in this schema. The famous-
700
- // holder guarantee lives in the population-ordered companion fetch below instead, and the
701
- // exact tier breaks ties by population in the post-scoring sort.
702
- const orderByExpr = shardHasPopulation
703
- ? `(bm25(place_search) - ? * MIN(1.0, COALESCE(log10(1.0 + ${PLACE_POPULATION_TABLE}.population), 0) / ?))`
704
- : "bm25(place_search)"
705
-
706
- // Schema-qualified FROM with bare-name MATCH — required syntax for FTS5 on attached schemas.
707
- // See sharding.ts header for the gotcha that drove this design.
708
- const stmt = this.#db.prepare(`
709
- SELECT
710
- spr.id AS id,
711
- spr.name,
712
- spr.placetype,
713
- spr.country,
714
- spr.parent_id,
715
- bm25(place_search) AS rank,
716
- spr.latitude AS lat,
717
- spr.longitude AS lon,
718
- spr.min_latitude, spr.max_latitude, spr.min_longitude, spr.max_longitude,
719
- ${populationSelect},
720
- ${encyclopedicSelect}
721
- FROM ${sch}.place_search
722
- ${joinClause}
723
- ${populationJoin}
724
- ${encyclopedicJoin}
725
- WHERE ${where.join(" AND ")}
726
- ORDER BY ${orderByExpr} ASC
727
- LIMIT ?
728
- `)
729
-
730
- if (shardHasPopulation) {
731
- params.push(this.#weights.populationBoost, this.#weights.populationScaleLog10)
732
- }
733
-
734
- params.push(ftsLimit)
735
-
736
- const rawRows = stmt.all(...params) as unknown as RawSearchRow[]
737
-
738
- // #905 companion fetch: the same MATCH, ordered by population alone. For name floods
739
- // ("Paris" matches thousands of gap-fill villages) the bm25-based window above cannot admit
740
- // the famous holder — its bm25 is length-poisoned by the row's alias bulk (measured ~15 pts,
741
- // vs a +4.0 boost cap), so FR Paris never even reaches post-scoring. This fetch makes the
742
- // prominent holders of a name pool-complete BY CONSTRUCTION; the exact-tier sort below
743
- // decides whether they win. Skipped without a population index (nothing to order by).
744
- if (shardHasPopulation) {
745
- const popStmt = this.#db.prepare(`
746
- SELECT
747
- spr.id AS id,
748
- spr.name,
749
- spr.placetype,
750
- spr.country,
751
- spr.parent_id,
752
- bm25(place_search) AS rank,
753
- spr.latitude AS lat,
754
- spr.longitude AS lon,
755
- spr.min_latitude, spr.max_latitude, spr.min_longitude, spr.max_longitude,
756
- ${populationSelect},
757
- ${encyclopedicSelect}
758
- FROM ${sch}.place_search
759
- ${joinClause}
760
- ${populationJoin}
761
- ${encyclopedicJoin}
762
- WHERE ${where.join(" AND ")}
763
- ORDER BY COALESCE(${PLACE_POPULATION_TABLE}.population, 0) DESC
764
- LIMIT ?
765
- `)
766
-
767
- const popParams = params.slice(0, -3) // drop the two boost params + ftsLimit
768
- const seen = new Set(rawRows.map((r) => r.id))
769
-
770
- for (const row of popStmt.all(...popParams, POPULATION_FETCH_LIMIT) as unknown as RawSearchRow[]) {
771
- if (!seen.has(row.id)) {
772
- rawRows.push(row)
773
- }
774
- }
775
- }
776
-
777
- const queryLen = query.text.length
778
-
779
- const candidates = rawRows.map((row): PlaceCandidate => {
780
- // SQLite's bm25() returns a lower-is-better score (negative for matches). Negate so we
781
- // start from a higher-is-better baseline.
782
- let score = -row.rank
783
-
784
- if (placetypes && placetypes.length && placetypes.includes(row.placetype as WOFPlacetype)) {
785
- score += this.#weights.placetypeMatchBoost
786
- }
787
-
788
- if (!placetypes && row.placetype === "locality") {
789
- score += this.#weights.localityImplicitBoost
790
- }
791
-
792
- if (query.country && row.country === query.country) {
793
- score += this.#weights.countryMatchBoost
794
- }
795
-
796
- if (query.parentID !== undefined) {
797
- score += row.parent_id === query.parentID ? this.#weights.directChildBoost : this.#weights.descendantBoost
798
- }
799
-
800
- const extraLen = Math.max(0, row.name.length - queryLen - 3)
801
- score -= (this.#weights.lengthPenaltyWeight * extraLen) / 10
802
-
803
- // Proximity boost: only applied when the query carries `near` AND the candidate has real
804
- // coordinates. The formula decays smoothly with distance so close-but-not-exact hits
805
- // still benefit; tunable via proximityBoost + proximityScaleKm.
806
- let distanceKm: number | undefined
807
- // The best decayed-distance term over `near` + every `bias` point (each point's term is
808
- // scaled by its weight; the MAX wins — a candidate near ANY hint is "nearby"). Carried
809
- // into the exact-tier prominence sort below when hints are present.
810
- let proximityTerm = 0
811
-
812
- if (row.lat !== null && row.lon !== null && !(row.lat === 0 && row.lon === 0)) {
813
- const hints: Array<{ lat: number; lon: number; weight: number }> = []
814
-
815
- if (query.near) {
816
- hints.push({ lat: query.near.lat, lon: query.near.lon, weight: 1 })
817
- }
818
-
819
- for (const b of query.bias ?? []) {
820
- hints.push({ lat: b.lat, lon: b.lon, weight: b.weight ?? 1 })
821
- }
822
-
823
- let scoreTerm = 0
824
-
825
- for (const h of hints) {
826
- const d = haversineKm(h.lat, h.lon, row.lat, row.lon)
827
- const decay = h.weight / (1 + d / this.#weights.proximityScaleKm)
828
- const prom = decay * this.#weights.biasBoost
829
-
830
- if (prom > proximityTerm) {
831
- proximityTerm = prom
832
- distanceKm = d
833
- scoreTerm = decay * this.#weights.proximityBoost
834
- }
835
- }
836
-
837
- score += scoreTerm
838
- }
839
-
840
- // Population boost: capped at `populationBoost` magnitude at `10^populationScaleLog10`
841
- // people. Missing population → no contribution. Never penalizes.
842
- let popTerm = 0
843
-
844
- if (row.population !== null && row.population > 0 && this.#weights.populationScaleLog10 > 0) {
845
- const popLog = Math.log10(1 + row.population)
846
- const popFraction = Math.min(1, popLog / this.#weights.populationScaleLog10)
847
- popTerm = this.#weights.populationBoost * popFraction
848
- score += popTerm
849
- }
850
-
851
- // Combined prominence for the exact-tier sort when proximity hints are present: population
852
- // and nearness in the SAME additive units, so the map view / the user's location can win a
853
- // cross-country postcode tie without a hard filter.
854
- const prominence = popTerm + proximityTerm
855
-
856
- const candidate: PlaceCandidate = {
857
- id: row.id,
858
- prominence,
859
- name: row.name,
860
- placetype: row.placetype as WOFPlacetype,
861
- country: row.country ?? "",
862
- lat: row.lat ?? 0,
863
- lon: row.lon ?? 0,
864
- parent_id: row.parent_id ?? undefined,
865
- score,
866
- }
867
-
868
- if (distanceKm !== undefined) {
869
- candidate.distanceKm = distanceKm
870
- }
871
-
872
- if (row.population !== null && row.population > 0) {
873
- candidate.population = row.population
874
- // The named ranking key (ROAD_TO_V9 §2). DERIVED, not stored — a pure function of the
875
- // population already on this row, so it cannot drift from what the ordering uses.
876
- candidate.referential = referentialFromPopulation(row.population)
877
- }
878
-
879
- // Carried for consumers (annotations / API surfaces). No ranking site reads it.
880
- if (row.encyclopedic !== null) {
881
- candidate.encyclopedic = row.encyclopedic
882
- }
883
-
884
- // Candidate bbox — parity with the WASM lookup (resolver-wof-wasm/lookup.ts), whose
885
- // consumers (the demo cascade's region constraint) read it. Without this the Node
886
- // backend's region→bbox constraint is dead and disambiguation falls to population
887
- // ranking (the Springfield-IL→MO failure the #524 smoke eval caught).
888
- if (
889
- row.min_latitude != null &&
890
- row.max_latitude != null &&
891
- row.min_longitude != null &&
892
- row.max_longitude != null
893
- ) {
894
- candidate.bbox = {
895
- minLat: row.min_latitude,
896
- maxLat: row.max_latitude,
897
- minLon: row.min_longitude,
898
- maxLon: row.max_longitude,
899
- }
900
- }
901
-
902
- return candidate
903
- })
904
-
905
- // Exact-match tiering: a candidate whose name OR any alias equals the query text (case-folded)
906
- // ranks above any partial match, with the weighted-sum score (incl. population) breaking ties
907
- // WITHIN a tier. See the RankingWeights.exactMatchTiering docstring for why this aligns the
908
- // population prior rather than overriding it. One cheap indexed lookup over the candidate ids.
909
- // Runs even for a SINGLE candidate so `exactMatch` is stamped consistently (parity with the
910
- // WASM lookup) — a sole alias hit ("New York City" → New York) must still carry the flag the
911
- // demo cascade / #369 re-rank read.
912
- if (this.#weights.exactMatchTiering && candidates.length) {
913
- const exactIds = this.#exactMatchIds(
914
- sch,
915
- candidates.map((c) => c.id as number),
916
- query.text
917
- )
918
-
919
- // Stamp the tier onto every candidate (not just when the tiering sort fires) so a downstream
920
- // re-rank — #369's postcode-anchor country pin in `resolveTree` — can keep the country pin from
921
- // crossing the exact/partial boundary ("ME" → Maine, not the more-populous Missouri).
922
- for (const c of candidates) {
923
- c.exactMatch = exactIds.has(c.id as number)
924
- }
925
-
926
- if (exactIds.size) {
927
- // #905: WITHIN the exact tier, population is the PRIMARY key and the weighted score
928
- // only breaks population ties. Exactness saturates text relevance, and the bm25
929
- // residue inside `score` is length-noise (see the fetch-site comment), so letting it
930
- // order the tier is what sent unscoped "Paris" to an Ohio township. The partial tier
931
- // keeps score order — text relevance still means something there. This makes the
932
- // exactMatchTiering docstring literal: match quality primary, prominence within.
933
- //
934
- // #912 sub-tier: a NAME-exact candidate (spr.name equals the query) outranks an
935
- // ALIAS-exact one ('Paris' the place beats 'Paris Township' held via alias 'Paris').
936
- // The place's own name is a stronger identity claim than an alias — aliases exist to
937
- // widen recall, not to tie primaries. ME→Maine is untouched: 'ME' name-exact-matches
938
- // nothing, so the alias sub-tier still decides there. Population orders within each
939
- // sub-tier as before.
940
- const norm = (v: string): string => v.toLowerCase().trim().replaceAll(/\s+/g, " ")
941
- const needle = norm(query.text)
942
-
943
- // #936 option 3: an OFFICIAL name (preferred form in an official language of the place's
944
- // country, `names.official = 1`) counts as the place's own name for the sub-tier — "Åbo" is
945
- // Turku's name, not merely its alias. Floor-gated on the holder's population (see the
946
- // RankingWeights docstring for the measured 100k boundary). officialIds ⊆ exactIds by
947
- // construction (official rows are names rows), so only the sub-tier KIND changes.
948
- const officialIds = this.#weights.officialNameExact
949
- ? this.#officialNameIds(
950
- sch,
951
- candidates
952
- .filter(
953
- (c) => exactIds.has(c.id as number) && (c.population ?? 0) >= this.#weights.officialNameExactFloor
954
- )
955
- .map((c) => c.id as number),
956
- query.text
957
- )
958
- : undefined
959
-
960
- const kind = (c: PlaceCandidate): number => {
961
- if (!exactIds.has(c.id as number)) return 0
962
-
963
- if (norm(String(c.name ?? "")) === needle) return 2
964
-
965
- return officialIds?.has(c.id as number) ? 2 : 1
966
- }
967
-
968
- // With proximity hints (near/bias), prominence (population + nearness, same units)
969
- // replaces raw population as the within-tier key — the 48026 rule: the map view or
970
- // the user's location breaks a cross-country postcode tie. Without hints, REFERENTIAL
971
- // ordering decides.
972
- //
973
- // ROAD_TO_V9 §2: this is the site that "orders namesakes", so it is the site that has to
974
- // say what it orders by. `compareReferential` is referential DESC with raw population as
975
- // the tiebreak, which is provably the SAME ORDER as the `(b.population ?? 0) - (a.population ?? 0)`
976
- // it replaces — referential is strictly increasing in population below saturation and
977
- // constant above it, and the tiebreak restores the order in the saturated tail. Measured
978
- // zero-delta, not assumed: see `place-importance-schema.test.ts` and `resolver-referential-ranking.test.ts`.
979
- // Encyclopedic importance is not, and must not become, an input here.
980
- const hasHints = !!query.near || (query.bias?.length ?? 0) > 0
981
-
982
- candidates.sort((a, b) => {
983
- const ax = kind(a)
984
- const bx = kind(b)
985
-
986
- if (bx !== ax) return bx - ax
987
-
988
- if (ax >= 1) {
989
- if (hasHints) return (b.prominence ?? 0) - (a.prominence ?? 0) || b.score - a.score
990
-
991
- return compareReferential(a, b) || b.score - a.score
992
- }
993
-
994
- return b.score - a.score
995
- })
996
-
997
- return candidates.slice(0, limit)
998
- }
999
- }
1000
-
1001
- candidates.sort((a, b) => b.score - a.score)
1002
-
1003
- return candidates.slice(0, limit)
1004
- }
1005
-
1006
- #isLocalityQuery(query: FindPlaceQuery): boolean {
1007
- const pts = normalizePlacetypes(query.placetype)
1008
-
1009
- return !pts || pts.includes("locality")
1010
- }
1011
-
1012
- /**
1013
- * Resolve the effective convention for a query (the Geographic Rule Engine entry point). The ancestor chain is keyed
1014
- * by WOF polygon id; for #289 it carries just the country level — resolved from `query.country` via the cached
1015
- * code→WOF-id lookup — so the EU locales, which have no override rows, resolve to `WORLD_DEFAULT` and dispatch is
1016
- * byte-identical to the pre-engine path. E4 (JP) extends the chain with the resolved locality's `ancestors` row, so a
1017
- * region/locality-level convention (e.g. Sapporo's grid) deep-merges over the country one.
1018
- */
1019
- #conventionFor(query: FindPlaceQuery): ResolvedConvention {
1020
- const chain: number[] = []
1021
-
1022
- if (query.country) {
1023
- const cid = this.#countryWOFId(query.country)
1024
-
1025
- if (cid !== null) {
1026
- chain.push(cid)
1027
- }
1028
- }
1029
-
1030
- return resolveConvention(this.#conventionSource, chain)
1031
- }
1032
-
1033
- /**
1034
- * Country ISO code → its WOF polygon id (the coarsest convention key). Cached — one indexed `spr` query per distinct
1035
- * country, then memoized (including a not-found `null`) so findPlace never pays for it twice.
1036
- */
1037
- #countryWOFId(code: string): number | null {
1038
- const cached = this.#countryWOFIdCache.get(code)
1039
-
1040
- if (cached !== undefined) return cached
1041
- let id: number | null
1042
-
1043
- try {
1044
- const row = this.#db
1045
- .prepare(`SELECT id FROM main.spr WHERE placetype = 'country' AND country = ? AND is_current != 0 LIMIT 1`)
1046
- .get(code) as { id: number } | undefined
1047
-
1048
- id = row?.id ?? null
1049
- } catch {
1050
- id = null
1051
- }
1052
-
1053
- this.#countryWOFIdCache.set(code, id)
1054
-
1055
- return id
1056
- }
1057
-
1058
- /**
1059
- * Coordinate-first locality resolution. The postcode_locality table maps the sibling postcode to the locality whose
1060
- * polygon contains the postcode centroid (+ a few nearby ones for the abutting- postcode case). We union those
1061
- * COORDINATE candidates with the FTS NAME candidates and soft-score the union `0.6·S_pc + 0.3·S_name + 0.1·S_pop` —
1062
- * so a small town the name-match never finds is recovered by the postcode, while an unambiguous name (Berlin) still
1063
- * wins on name + population. Returns null when the postcode isn't in the table (→ caller falls back to the FTS
1064
- * path).
1065
- */
1066
- async #findLocalityCoordFirst(
1067
- query: FindPlaceQuery,
1068
- sch: string,
1069
- convention: ResolvedConvention
1070
- ): Promise<PlaceCandidate[] | null> {
1071
- const w = convention.scoringWeights
1072
- const pc = query.postcode!.trim()
1073
- const pcWhere = query.country ? "postcode = ? AND country = ?" : "postcode = ?"
1074
- const pcParams: SQLInputValue[] = query.country ? [pc, query.country] : [pc]
1075
-
1076
- const pcRows = this.#db
1077
- .prepare(
1078
- `SELECT locality_id AS id, aliases, distance_km AS dist, is_containing AS containing
1079
- FROM ${sch}.${POSTCODE_LOCALITY_TABLE} WHERE ${pcWhere}`
1080
- )
1081
- .all(...pcParams) as unknown as Array<{ id: number; aliases: string | null; dist: number; containing: number }>
1082
-
1083
- if (!pcRows.length) return null
1084
-
1085
- const limit = query.limit ?? 10
1086
- // Name-match candidates via the normal FTS path (postcode cleared → no recursion).
1087
- const ftsCands = await this.findPlace({ ...query, postcode: undefined, limit: Math.max(limit, 10) })
1088
-
1089
- const pcInfo = new Map<number, { dist: number; containing: boolean; aliases: string[] }>()
1090
-
1091
- for (const r of pcRows) {
1092
- pcInfo.set(r.id, { dist: r.dist, containing: r.containing === 1, aliases: r.aliases ? r.aliases.split("|") : [] })
1093
- }
1094
-
1095
- // #475 (opt-in): observed postal-city aliases for this postcode, keyed by the geographic
1096
- // locality name they map to. A user-typed postal city ("Antioch", 37013) becomes a name-match
1097
- // alias for the geographic locality the postcode sits in ("Nashville"). Empty when the reader
1098
- // isn't supplied → the scoring loop below is byte-identical to pre-#475.
1099
- const postalAliasByGeo = new Map<string, string[]>()
1100
-
1101
- if (this.#postalCityAliases) {
1102
- for (const a of await this.#postalCityAliases.getDivergentAliases(pc)) {
1103
- const key = cfNormalize(a.geoLocality)
1104
-
1105
- if (!key) continue
1106
- const bag = postalAliasByGeo.get(key)
1107
-
1108
- if (bag) {
1109
- bag.push(a.postalCity)
1110
- } else {
1111
- postalAliasByGeo.set(key, [a.postalCity])
1112
- }
1113
- }
1114
- }
1115
-
1116
- const merged = new Map<number, PlaceCandidate>()
1117
-
1118
- for (const c of ftsCands) {
1119
- merged.set(c.id as number, c)
1120
- }
1121
-
1122
- const missing = [...pcInfo.keys()].filter((id) => !merged.has(id))
1123
-
1124
- for (const row of this.#fetchLocalitiesByID(missing)) {
1125
- merged.set(row.id, row)
1126
- }
1127
-
1128
- const scored: Array<PlaceCandidate & { exact: boolean }> = []
1129
-
1130
- for (const cand of merged.values()) {
1131
- const info = pcInfo.get(cand.id as number)
1132
- const sPc = info ? (info.containing ? 1 : Math.exp(-info.dist / CF_PC_DECAY_KM)) : 0
1133
- // Fold any postal-city aliases for this candidate's geographic name into the soft name match
1134
- // (#475). `postalAliasByGeo` is empty unless the opt-in reader was supplied, so when off this
1135
- // reduces to the original `info?.aliases ?? []` and the score is unchanged.
1136
- const wofAliases = info?.aliases ?? []
1137
-
1138
- const aliases = postalAliasByGeo.size
1139
- ? [...wofAliases, ...(postalAliasByGeo.get(cfNormalize(cand.name)) ?? [])]
1140
- : wofAliases
1141
-
1142
- const sName = softNameScore(query.text, cand.name, aliases)
1143
- const sPop = cand.population && cand.population > 0 ? Math.min(1, Math.log10(1 + cand.population) / 6) : 0
1144
- scored.push({ ...cand, score: w.pc * sPc + w.name * sName + w.pop * sPop, exact: sName >= 1 })
1145
- }
1146
-
1147
- // Exact-name tiering (same philosophy as the FTS path): an EXACT name/alias match tiers above
1148
- // coordinate-only candidates, with the soft-score breaking ties WITHIN a tier. This keeps an
1149
- // unambiguous city ("Berlin", exact + huge population) ahead of the fine-grained Ortsteil its
1150
- // postcode centroid lands in, while a small town the name-match never finds (no exact tier) is
1151
- // still recovered by its postcode's containing locality.
1152
- scored.sort((a, b) => Number(b.exact) - Number(a.exact) || b.score - a.score)
1153
-
1154
- // Conflict flag: if the chosen locality is NOT the postcode's containing locality and sits far
1155
- // from it, the postcode and the city name disagree (a transposed / wrong-for-the-city postcode).
1156
- // We keep the name-chosen locality but flag it — the falsehood signal a BM25 geocoder can't give.
1157
- const top = scored[0]
1158
-
1159
- if (top) {
1160
- // The postcode's geographic anchor: among the postcode's candidate localities that actually
1161
- // resolved (some — e.g. unnamed Ortsteile — are in the postcode table but not the admin DB),
1162
- // prefer the containing one, else the nearest. Postcodes whose centroid falls just outside
1163
- // every locality polygon still anchor to the closest town.
1164
- const anchorRow = pcRows
1165
- .filter((r) => merged.has(r.id))
1166
- // oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
1167
- .sort((a, b) => b.containing - a.containing || a.dist - b.dist)[0]
1168
-
1169
- const anchor = anchorRow ? merged.get(anchorRow.id) : undefined
1170
-
1171
- if (
1172
- anchor &&
1173
- (top.id as number) !== anchorRow!.id &&
1174
- haversineKm(top.lat, top.lon, anchor.lat, anchor.lon) > CF_MISMATCH_KM
1175
- ) {
1176
- top.mismatch = true
1177
- }
1178
- }
1179
-
1180
- return scored.slice(0, limit).map(({ exact, ...c }) => {
1181
- void exact
1182
-
1183
- return c
1184
- })
1185
- }
1186
-
1187
- /**
1188
- * Fetch locality spr rows (from main) for the postcode-injected candidate ids the FTS set missed.
1189
- */
1190
- #fetchLocalitiesByID(ids: number[]): PlaceCandidate[] {
1191
- if (!ids.length) return []
1192
- const hasPop = this.#hasPopulationIndex.get("main") === true
1193
- const popSelect = hasPop ? `pp.population AS population` : `NULL AS population`
1194
- const popJoin = hasPop ? `LEFT JOIN main.${PLACE_POPULATION_TABLE} pp ON pp.id = s.id` : ""
1195
- const ph = ids.map(() => "?").join(", ")
1196
-
1197
- const rows = this.#db
1198
- .prepare(
1199
- `SELECT s.id AS id, s.name AS name, s.country AS country, s.parent_id AS parent_id,
1200
- s.latitude AS lat, s.longitude AS lon, s.placetype AS placetype, ${popSelect}
1201
- FROM main.spr s ${popJoin}
1202
- WHERE s.id IN (${ph}) AND s.is_current != 0`
1203
- )
1204
- .all(...ids) as unknown as Array<RawSearchRow>
1205
-
1206
- return rows.map((row) => {
1207
- const c: PlaceCandidate = {
1208
- id: row.id,
1209
- name: row.name,
1210
- placetype: row.placetype as WOFPlacetype,
1211
- country: row.country ?? "",
1212
- lat: row.lat ?? 0,
1213
- lon: row.lon ?? 0,
1214
- parent_id: row.parent_id ?? undefined,
1215
- score: 0,
1216
- }
1217
-
1218
- if (row.population !== null && row.population > 0) {
1219
- c.population = row.population
1220
- }
1221
-
1222
- return c
1223
- })
1224
- }
1225
-
1226
- /**
1227
- * Among `ids`, return the subset whose name OR any alias equals `text` case-insensitively — the exact-match tier for
1228
- * ranking. One indexed query over `<schema>.names`. When the shard has no `names` table (a slim DB built with
1229
- * `dropNames`, or a postcode-only shard), fall back to the self-contained `place_search` FTS content: its `alt_names`
1230
- * column is the same alias set joined on the boundary-preserving `ALIAS_SEPARATOR` (#523), so `aliasBagExactMatch`
1231
- * recovers the exact alias tier ("New York City" → New York) that the dropped `names` table used to provide.
1232
- */
1233
- #exactMatchIds(schemaName: string, ids: number[], text: string): Set<number> {
1234
- const out = new Set<number>()
1235
- const trimmed = text.trim()
1236
-
1237
- if (!ids.length || !trimmed) return out
1238
- const placeholders = ids.map(() => "?").join(", ")
1239
-
1240
- try {
1241
- const rows = this.#db
1242
- .prepare(
1243
- `SELECT DISTINCT id FROM ${schemaName}.names WHERE id IN (${placeholders}) AND name = ? COLLATE NOCASE`
1244
- )
1245
- .all(...ids, trimmed) as Array<{ id: number }>
1246
-
1247
- for (const r of rows) {
1248
- out.add(r.id)
1249
- }
1250
-
1251
- return out
1252
- } catch {
1253
- // No `names` table on this shard — fall through to the place_search alias bag.
1254
- }
1255
-
1256
- try {
1257
- const rows = this.#db
1258
- .prepare(
1259
- `SELECT wof_id AS id, name, alt_names FROM ${schemaName}.place_search WHERE wof_id IN (${placeholders})`
1260
- )
1261
- .all(...ids) as Array<{ id: number; name: string | null; alt_names: string | null }>
1262
-
1263
- const norm = (s: string): string => s.toLowerCase().trim().replaceAll(/\s+/g, " ")
1264
- const needle = norm(trimmed)
1265
-
1266
- for (const r of rows) {
1267
- if (r.name !== null && norm(r.name) === needle) {
1268
- out.add(r.id)
1269
- }
1270
- }
1271
-
1272
- // Alias pass via the shared bag parser (#523). Separated bags (built since #523) get a true
1273
- // per-alias equality check, ungated — matching the `names`-table branch above, where an
1274
- // alias match counts as exact regardless of other candidates. Legacy bags (no separator)
1275
- // fall back to padded containment, gated on "no canonical exact in the pool" because their
1276
- // lost boundaries would otherwise false-promote interior fragments ("York" inside the alias
1277
- // "New York City") or cross-alias fragments ("York New" across "…York" + "New City…").
1278
- const anyCanonicalExact = out.size > 0
1279
-
1280
- for (const r of rows) {
1281
- if (aliasBagExactMatch(r.alt_names, needle, anyCanonicalExact)) {
1282
- out.add(r.id)
1283
- }
1284
- }
1285
- } catch {
1286
- // Shard without place_search either → no exact-match tier. Falls back to weighted-sum order.
1287
- }
1288
-
1289
- return out
1290
- }
1291
-
1292
- /**
1293
- * Among `ids` (already known exact matches), the subset holding `text` as an OFFICIAL name (`names.official = 1`, the
1294
- * #940 ingest bit). Same COLLATE NOCASE semantics as {@link WOFSqlitePlaceLookup.#exactMatchIds} so the two probes
1295
- * agree on what "equals the query" means. Fails soft on gazetteers built before #940 (no `official` column) — the
1296
- * sub-tier then behaves exactly as if `officialNameExact` were off.
1297
- */
1298
- #officialNameIds(schemaName: string, ids: number[], text: string): Set<number> {
1299
- const out = new Set<number>()
1300
- const trimmed = text.trim()
1301
-
1302
- if (!ids.length || !trimmed) return out
1303
- const placeholders = ids.map(() => "?").join(", ")
1304
-
1305
- try {
1306
- const rows = this.#db
1307
- .prepare(
1308
- `SELECT DISTINCT id FROM ${schemaName}.names WHERE id IN (${placeholders}) AND official = 1 AND name = ? COLLATE NOCASE`
1309
- )
1310
- .all(...ids, trimmed) as Array<{ id: number }>
1311
-
1312
- for (const r of rows) {
1313
- out.add(r.id)
1314
- }
1315
- } catch {
1316
- // Pre-#940 gazetteer (no `official` column) or a names-less slim shard — feature inert.
1317
- }
1318
-
1319
- return out
1320
- }
1321
-
1322
- close(): void {
1323
- // Destroying the Kysely instance closes the underlying connection IF we own it. If the caller
1324
- // passed in a pre-opened DatabaseSync (test fixture), respect their ownership.
1325
- void this.#kysely.destroy()
1326
-
1327
- if (this.#ownsDB) {
1328
- this.#db.close()
1329
- }
1330
- }
1331
-
1332
- [Symbol.dispose](): void {
1333
- this.close()
1334
- }
1335
-
1336
- /**
1337
- * Build the FTS5 virtual table from the `names` + `places` tables.
1338
- */
1339
- #ensureFTS(): void {
1340
- buildPlaceSearchFTS(this.#db)
1341
- }
1342
-
1343
- #assertFTSExists(): void {
1344
- if (!placeSearchFTSExists(this.#db)) {
1345
- throw new Error(
1346
- "WOFSqlitePlaceLookup: `place_search` FTS5 table is missing. Pass `buildFTS: true` to build it on open, or run `mailwoman gazetteer build fts <path-to-wof.db>` ahead of time (see resolver-wof-sqlite/README.md)."
1347
- )
1348
- }
1349
- }
1350
- }
1351
-
1352
- export { trigramJaccard, trigrams } from "./name-score.ts"
1353
-
1354
- export type { RankingWeights } from "./ranking-weights.ts"