@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/out/lookup.js CHANGED
@@ -3,53 +3,46 @@
3
3
  * @license AGPL-3.0
4
4
  * @author Teffen Ellis, et al.
5
5
  *
6
- * `WOFSqlitePlaceLookup` — the resolver implementation backed by `node:sqlite` + a Kysely-typed
6
+ * `WOFSQLitePlaceLookup` — the resolver implementation backed by `node:sqlite` + a Kysely-typed
7
7
  * query layer where the queries are non-trivial, and raw SQL where they aren't (FTS5 MATCH, the
8
8
  * FTS index build).
9
9
  *
10
10
  * See `docs/plan/phases/PHASE_4_2_wof_sqlite.md` for the design rationale.
11
11
  */
12
- import { DatabaseSync } from "node:sqlite";
13
- import { SqliteDialect } from "@mailwoman/core/kysley/dialect";
14
12
  import { expandPlacetypeFilter } from "@mailwoman/resolver";
15
- import { Kysely } from "kysely";
16
- import { ancestorLineage } from "./ancestry.js";
17
- import { COINCIDENT_ROLES_TABLE, coincidentRolesExists } from "./coincident-roles.js";
18
- import { ADDRESS_CONVENTION_TABLE, resolveConvention, SeedConventionSource, } from "./convention.js";
19
- import { normalizePlacetypes, sanitizeFTSQuery } from "./fts-query.js";
20
- import { aliasBagExactMatch, buildPlaceSearchFTS, PLACE_BBOX_TABLE, PLACE_POPULATION_TABLE, placeBboxExists, placePopulationExists, placeSearchFTSExists, } from "./fts.js";
21
- import { bboxAround, haversineKm } from "./geo.js";
22
- import { cfNormalize, softNameScore, trigramJaccard } from "./name-score.js";
23
- import { compareReferential, encyclopedicClauses, referentialFromPopulation } from "./place-importance-schema.js";
24
- import { DEFAULT_WEIGHTS } from "./ranking-weights.js";
25
- import { pickShardForPlacetype, pickShardsForPlacetype, resolveShards, } from "./sharding.js";
26
- import { SqliteConventionSource } from "./sqlite-convention-source.js";
27
- /**
28
- * Query length at or below which the FTS window is widened. A two- or three-character query is almost always a region
29
- * abbreviation, where the exact match can otherwise fall outside the window behind higher-bm25 partial hits — "NY"
30
- * losing to "New York".
31
- */
32
- const SHORT_QUERY_MAX_LENGTH = 3;
33
- /**
34
- * Over-fetch floor for SHORT (≤3-char) queries — region abbreviations like "NY"/"VT". An exact-abbrev holder's BM25 is
35
- * poor (long multilingual alt-name document), so the normal `limit * 4` window can drop it before `exactMatchTiering`
36
- * promotes it. 200 comfortably covers every same-abbrev region across the 12-country gazetteer (a 2-letter token
37
- * matches a few dozen regions at most) while staying a cheap region-placetype fetch. See the `#fuzzyNameMatch`
38
- * over-fetch comment.
39
- */
40
- const SHORT_QUERY_OVERFETCH = 200;
41
- /**
42
- * How many rows the population-ordered companion fetch (#905) adds to the candidate pool. Small on purpose: its only
43
- * job is to guarantee the FAMOUS holders of a name enter the pool at all — for "Paris"-class floods the bm25 window is
44
- * saturated by thousands of tiny same-name rows and no boost inside the bm25-based ORDER BY can rescue a candidate
45
- * whose bm25 is length-poisoned by ~15 points (see the fetch-site comment).
46
- */
47
- const POPULATION_FETCH_LIMIT = 15;
13
+ import { haversineKm } from "@mailwoman/spatial";
14
+ import { DatabaseClient } from "@mailwoman/sqlite/client";
15
+ import { ancestorLineage } from "#ancestry/index";
16
+ import { candidateFromSearchRow, rankCandidates } from "#candidate-scoring";
17
+ import { loadCoincidentLocalities } from "#coincident-roles";
18
+ import { ADDRESS_CONVENTION_TABLE, resolveConvention, SeedConventionSource, } from "#convention/index";
19
+ import { pickExtractForPlacetype, pickExtractsForPlacetype, resolveExtracts, } from "#extracts";
20
+ import { buildPlaceSearchFTS, PLACE_BBOX_TABLE, PLACE_POPULATION_TABLE, PLACE_SEARCH_TABLE, placeBboxExists, placePopulationExists, placeSearchFTSExists, } from "#fts/index";
21
+ import { normalizePlacetypes, sanitizeFTSQuery } from "#fts/query";
22
+ import { cfNormalize, softNameScore } from "#name-score";
23
+ import { encyclopedicClauses } from "#place-importance-schema";
24
+ import { DEFAULT_WEIGHTS, populationScaleTerm } from "#ranking-weights";
25
+ import { fetchSearchRows } from "#search-fetch";
26
+ import { SqliteConventionSource } from "#sqlite-convention-source";
27
+ import { allRows } from "#sqlite-utils";
48
28
  /**
49
29
  * The coordinate-first candidate table (scripts/build-postcode-locality.ts): postcode → containing
50
30
  *
51
31
  * - Nearby localities with WOF alt-name aliases.
52
32
  */
33
+ /**
34
+ * The placetypes `pickExtractsForPlacetype`'s substring rule can route by name. Not every WOF placetype — only the ones
35
+ * a purpose-built extract is ever named for — so the diagnostic below can say "this name routes nowhere" without
36
+ * claiming to enumerate the gazetteer.
37
+ */
38
+ const KNOWN_ROUTED_PLACETYPES = [
39
+ "postalcode",
40
+ "locality",
41
+ "region",
42
+ "county",
43
+ "country",
44
+ "venue",
45
+ ];
53
46
  const POSTCODE_LOCALITY_TABLE = "postcode_locality";
54
47
  /**
55
48
  * Tunables for the coordinate-first locality soft-score `Score = pc·S_pc + name·S_name + pop·S_pop` (each S in [0,1]).
@@ -64,46 +57,48 @@ const CF_PC_DECAY_KM = 8;
64
57
  * flagged, tight enough to catch a wrong city (hundreds of km).
65
58
  */
66
59
  const CF_MISMATCH_KM = 50;
67
- const CF_MISMATCH_DELTA = 0.5;
68
- export class WOFSqlitePlaceLookup {
60
+ export class WOFSQLitePlaceLookup {
69
61
  #db;
70
- #ownsDB;
71
- #kysely;
62
+ /**
63
+ * Resources this instance opened. A connection handed in by a caller is NOT in here, so disposal cannot reach it —
64
+ * ownership is membership rather than a flag a later branch has to check.
65
+ */
66
+ #resources = new DisposableStack();
72
67
  #weights;
73
68
  /**
74
69
  * Cached at construction so we don't `sqlite_master` query on every findPlace call. Bbox + near- with-radius queries
75
70
  * fall back to no-filter when this is false, preserving compatibility with DBs that were FTS-built before the R*Tree
76
71
  * shipped.
77
72
  *
78
- * Per-shard: a shard is only considered to have the bbox index if its own R*Tree table exists.
73
+ * Per-extract: a extract is only considered to have the bbox index if its own R*Tree table exists.
79
74
  */
80
75
  #hasBboxIndex;
81
76
  /**
82
- * Per-shard probe for the `place_population` aux table. When false, the LEFT JOIN is omitted from the SELECT and
77
+ * Per-extract probe for the `place_population` aux table. When false, the LEFT JOIN is omitted from the SELECT and
83
78
  * population boost is 0 for every row — preserves compatibility with DBs built before this feature shipped.
84
79
  */
85
80
  #hasPopulationIndex;
86
81
  /**
87
- * Per-shard SELECT term + LEFT JOIN for the two-score split's `encyclopedic` carry (ROAD_TO_V9 §2 R1), probed and
88
- * built once at construction. Degrades to `NULL AS encyclopedic` with no join on a pre-split shard — every shipped
89
- * shard today. See {@link encyclopedicClauses} for why the probe is a column and not a table.
82
+ * Per-extract SELECT term + LEFT JOIN for the two-score split's `encyclopedic` carry (ROAD_TO_V9 §2 R1), probed and
83
+ * built once at construction. Degrades to `NULL AS encyclopedic` with no join on a pre-split extract — every shipped
84
+ * extract today. See {@link encyclopedicClauses} for why the probe is a column and not a table.
90
85
  */
91
86
  #encyclopedicClauses;
92
87
  /**
93
- * Per-shard probe for the `postcode_locality` table (the coordinate-first candidate table, built by
88
+ * Per-extract probe for the `postcode_locality` table (the coordinate-first candidate table, built by
94
89
  * scripts/build-postcode-locality.ts). Cached at construction; null'd out when absent so the coord-first path
95
90
  * silently no-ops on a deployment that didn't ship the table.
96
91
  */
97
- #postcodeLocalityShard;
92
+ #postcodeLocalityExtract;
98
93
  /**
99
- * Resolved shard list. Always at least one entry; first is `main`. Multi-shard adds extras with their own derived (or
100
- * override) schema names.
94
+ * Resolved extract list. Always at least one entry; first is `main`. Multi-extract adds extras with their own derived
95
+ * (or override) schema names.
101
96
  */
102
- #shards;
97
+ #extracts;
103
98
  /**
104
- * #920: per-schema probed country sets for country-aware shard routing (non-main shards only).
99
+ * #920: per-schema probed country sets for country-aware extract routing (non-main extracts only).
105
100
  */
106
- #shardCountries;
101
+ #extractCountries;
107
102
  /**
108
103
  * The Geographic Rule Engine (Direction E, #289). `#conventionSource` supplies per-WOF-polygon resolution profiles;
109
104
  * `#strategies` is the named-primitive registry the merged convention dispatches. Empty source → every query resolves
@@ -128,35 +123,33 @@ export class WOFSqlitePlaceLookup {
128
123
  #ancestorsCache = new Map();
129
124
  /**
130
125
  * Opt-in postal-city alias reader (#475). `null` unless `opts.postalCityAliases` was supplied — every alias code path
131
- * is gated on this, so the default resolver is byte-identical.
126
+ * is conditioned on this, so the default resolver is byte-identical.
132
127
  */
133
128
  #postalCityAliases;
134
129
  constructor(opts, weights) {
135
130
  if (opts.database && opts.databasePath) {
136
- throw new Error("WOFSqlitePlaceLookup: pass either `database` or `databasePath`, not both");
131
+ throw new Error("WOFSQLitePlaceLookup: pass either `database` or `databasePath`, not both");
137
132
  }
138
133
  if (!opts.database && !opts.databasePath) {
139
- throw new Error("WOFSqlitePlaceLookup: one of `database` or `databasePath` is required");
134
+ throw new Error("WOFSQLitePlaceLookup: one of `database` or `databasePath` is required");
140
135
  }
141
136
  if (opts.database) {
142
137
  this.#db = opts.database;
143
- this.#ownsDB = false;
144
- this.#shards = [{ path: ":memory:", schemaName: "main", placetypes: [] }];
138
+ this.#extracts = [{ path: ":memory:", schemaName: "main", placetypes: [] }];
145
139
  }
146
140
  else {
147
- const shards = resolveShards(opts.databasePath);
148
- this.#shards = shards;
149
- // Read-only by default — shipped gazetteer shards are sealed 0444 and Docker `:ro` mounts
141
+ const extracts = resolveExtracts(opts.databasePath);
142
+ this.#extracts = extracts;
143
+ // Read-only by default — shipped gazetteer extracts are sealed 0444 and Docker `:ro` mounts
150
144
  // forbid write-mode opens, so a writable open fails there. The only code path that writes to
151
- // the main shard is `#ensureFTS()` (FTS5 index build), gated on `opts.buildFTS`; open writable
145
+ // the main extract is `#ensureFTS()` (FTS5 index build), conditioned on `opts.buildFTS`; open writable
152
146
  // ONLY when that build was explicitly requested. Every read query (FTS5 MATCH, the aux-table
153
147
  // SELECTs, ATTACH, and the `busy_timeout` PRAGMA) works read-only. See the docker read-only
154
148
  // mount limitation (#1213).
155
- this.#db = new DatabaseSync(shards[0].path, { readOnly: !opts.buildFTS });
156
- this.#ownsDB = true;
157
- // ATTACH each non-main shard. Schema names were validated by resolveShards, so safe to
149
+ this.#db = this.#resources.use(new DatabaseClient(extracts[0].path, { readOnly: !opts.buildFTS }));
150
+ // ATTACH each non-main extract. Schema names were validated by resolveExtracts, so safe to
158
151
  // interpolate directly (SQLite ATTACH doesn't accept parameters for the schema name).
159
- for (const s of shards.slice(1)) {
152
+ for (const s of extracts.slice(1)) {
160
153
  this.#db.exec(`ATTACH DATABASE '${s.path.replaceAll("'", "''")}' AS ${s.schemaName}`);
161
154
  }
162
155
  }
@@ -168,67 +161,100 @@ export class WOFSqlitePlaceLookup {
168
161
  else {
169
162
  this.#assertFTSExists();
170
163
  }
171
- this.#kysely = new Kysely({
172
- dialect: new SqliteDialect({ database: this.#db }),
173
- });
174
164
  this.#weights = { ...DEFAULT_WEIGHTS, ...weights };
175
- // Probe each shard's aux-table presence — driven by per-shard table existence in
165
+ // Probe each extract's aux-table presence — driven by per-extract table existence in
176
166
  // sqlite_master. Cached at construction so findPlace doesn't query sqlite_master per call.
177
167
  this.#hasBboxIndex = new Map();
178
168
  this.#hasPopulationIndex = new Map();
179
169
  this.#encyclopedicClauses = new Map();
180
- for (const s of this.#shards) {
181
- this.#hasBboxIndex.set(s.schemaName, this.#shardHasTable(s.schemaName, PLACE_BBOX_TABLE));
182
- this.#hasPopulationIndex.set(s.schemaName, this.#shardHasTable(s.schemaName, PLACE_POPULATION_TABLE));
170
+ for (const s of this.#extracts) {
171
+ this.#hasBboxIndex.set(s.schemaName, this.#extractHasTable(s.schemaName, PLACE_BBOX_TABLE));
172
+ this.#hasPopulationIndex.set(s.schemaName, this.#extractHasTable(s.schemaName, PLACE_POPULATION_TABLE));
183
173
  this.#encyclopedicClauses.set(s.schemaName, encyclopedicClauses(this.#db, s.schemaName));
184
174
  }
185
- // #920 country-aware shard routing: probe each NON-MAIN shard's country set once at
186
- // construction (they're small, purpose-built shards — postcode/locality slices; main is the
175
+ // Every lookup path here reaches `place_search`, and a extract without it fails in one of two ways
176
+ // that are both hard to read: an unroutable name returns zero hits (indistinguishable from "this
177
+ // country has no places") and a routable one throws mid-query from deep inside a SELECT. The
178
+ // unroutable half is the worse of the two — a extract reaches routing only through the name
179
+ // `deriveSchemaName` derives from its FILENAME, so a file spelled one letter off the placetype it
180
+ // serves answers with nothing while holding every row that was asked for.
181
+ //
182
+ // Two independent things bring a extract under the guard, and it needs both. Carrying `spr` is a
183
+ // CLAIM to be a place extract. Carrying a name that routes is an INVITATION to be queried as one, and
184
+ // it is made by the filename alone — so a database with no tables at all still gets picked, still
185
+ // answers no query, and still dies inside a SELECT. Testing only the claim lets an empty or
186
+ // truncated file past construction; testing only the name would exempt a correctly-named build
187
+ // input. A extract needs to fail neither test to be exempt.
188
+ //
189
+ // Exempt by design: `postcode-locality-<cc>.db` carries a relation table and nothing else, matches
190
+ // no routed placetype, and is part of the documented default extract list.
191
+ for (const s of this.#extracts) {
192
+ if (s.schemaName === "main")
193
+ continue;
194
+ const routes = KNOWN_ROUTED_PLACETYPES.some((pt) => s.schemaName === pt || s.schemaName.startsWith(`${pt}_`) || s.schemaName.endsWith(`_${pt}`));
195
+ const claimsPlaceExtract = this.#extractHasTable(s.schemaName, "spr");
196
+ if (!routes && !claimsPlaceExtract)
197
+ continue;
198
+ if (this.#extractHasTable(s.schemaName, PLACE_SEARCH_TABLE))
199
+ continue;
200
+ throw new Error(`WOFSQLitePlaceLookup: ${s.path} ` +
201
+ (claimsPlaceExtract
202
+ ? `carries "spr" but no "${PLACE_SEARCH_TABLE}" table, so it cannot serve a lookup.`
203
+ : `is named for a routed placetype but carries neither "spr" nor "${PLACE_SEARCH_TABLE}", so every ` +
204
+ `query routed to it would die mid-SELECT. An empty or truncated file reads exactly like this.`) +
205
+ ` Build it with the FTS index, or leave it out — it is usable as a BUILD input either way.` +
206
+ (routes
207
+ ? ""
208
+ : ` Its schema name "${s.schemaName}" also matches no routed placetype (${KNOWN_ROUTED_PLACETYPES.join(", ")}), ` +
209
+ `so it would never have been queried even with the table — check the filename's spelling.`));
210
+ }
211
+ // #920 country-aware extract routing: probe each NON-MAIN extract's country set once at
212
+ // construction (they're small, purpose-built extracts — postcode/locality slices; main is the
187
213
  // multi-GB admin DB and is the fallback anyway, so it is deliberately NOT scanned). Feeds
188
- // pickShardForPlacetype so two postcode shards (postalcode-us + postalcode-geonames-tail)
189
- // route by the query's country instead of first-match starving the second shard.
190
- this.#shardCountries = new Map();
191
- for (const sh of this.#shards) {
214
+ // pickExtractForPlacetype so two postcode extracts (postalcode-us + postalcode-geonames-tail)
215
+ // route by the query's country instead of first-match starving the second extract.
216
+ this.#extractCountries = new Map();
217
+ for (const sh of this.#extracts) {
192
218
  if (sh.schemaName === "main")
193
219
  continue;
194
220
  try {
195
221
  const rows = this.#db
196
222
  .prepare(`SELECT DISTINCT country FROM ${sh.schemaName}.spr WHERE country != ''`)
197
223
  .all();
198
- this.#shardCountries.set(sh.schemaName, new Set(rows.map((r) => r.country)));
224
+ this.#extractCountries.set(sh.schemaName, new Set(rows.map((r) => r.country)));
199
225
  }
200
226
  catch {
201
- // A shard without spr (or an attach oddity) just doesn't participate in country routing.
227
+ // A extract without spr (or an attach oddity) just doesn't participate in country routing.
202
228
  }
203
229
  }
204
- // The postcode_locality table can live on any attached shard (typically its own
205
- // `postcode-locality-<cc>.db`). Find the first shard that has it; null = coord-first disabled.
206
- this.#postcodeLocalityShard =
207
- this.#shards.find((s) => this.#shardHasTable(s.schemaName, POSTCODE_LOCALITY_TABLE))?.schemaName ?? null;
208
- // Opt-in postal-city alias reader (#475). Construction-time present-or-not is the gate: null
230
+ // The postcode_locality table can live on any attached extract (typically its own
231
+ // `postcode-locality-<cc>.db`). Find the first extract that has it; null = coord-first disabled.
232
+ this.#postcodeLocalityExtract =
233
+ this.#extracts.find((s) => this.#extractHasTable(s.schemaName, POSTCODE_LOCALITY_TABLE))?.schemaName ?? null;
234
+ // Opt-in postal-city alias reader (#475). Construction-time present-or-not is the check: null
209
235
  // keeps the coordinate-first scorer byte-identical to pre-#475.
210
236
  this.#postalCityAliases = opts.postalCityAliases ?? null;
211
237
  // The Geographic Rule Engine convention source. Precedence: an explicit `opts.conventions`
212
238
  // (a ready source or a seed map) wins; else the build-from-source convention asset if one is
213
- // attached (auto-detected, like the postcode_locality shard — adding conventions.db to
239
+ // attached (auto-detected, like the postcode_locality extract — adding conventions.db to
214
240
  // databasePath enables it; queried on demand, not paged into memory); else empty, so EU rides
215
241
  // WORLD_DEFAULT. The registry binds strategy NAMES to the SQL-bound primitives — adding a
216
242
  // strategy is registering it here.
217
- const conventionShard = this.#shards.find((s) => this.#shardHasTable(s.schemaName, ADDRESS_CONVENTION_TABLE))?.schemaName ?? null;
243
+ const conventionExtract = this.#extracts.find((s) => this.#extractHasTable(s.schemaName, ADDRESS_CONVENTION_TABLE))?.schemaName ?? null;
218
244
  this.#conventionSource = opts.conventions
219
245
  ? "get" in opts.conventions && typeof opts.conventions.get === "function"
220
246
  ? opts.conventions
221
247
  : new SeedConventionSource(opts.conventions)
222
- : conventionShard
223
- ? new SqliteConventionSource(this.#db, conventionShard)
248
+ : conventionExtract
249
+ ? new SqliteConventionSource(this.#db, conventionExtract)
224
250
  : new SeedConventionSource();
225
251
  this.#strategies = new Map([
226
252
  ["postcode_area_resolution", (q, c) => this.#postcodeAreaResolution(q, c)],
227
253
  ["fallback_fuzzy_name_match", (q) => this.#fuzzyNameMatch(q)],
228
254
  ]);
229
255
  }
230
- #shardHasTable(schemaName, tableName) {
231
- // For main, the existing helpers work directly. For attached shards we have to ask via the
256
+ #extractHasTable(schemaName, tableName) {
257
+ // For main, the existing helpers work directly. For attached extracts we have to ask via the
232
258
  // schema-qualified `sqlite_master` view.
233
259
  if (schemaName === "main") {
234
260
  if (tableName === PLACE_BBOX_TABLE)
@@ -267,9 +293,9 @@ export class WOFSqlitePlaceLookup {
267
293
  // #924: NL postcode retry ladder. The WOF NL postalcode repo stores full codes UNSPACED
268
294
  // ('1012LG') plus 4-digit stems ('1012'), while Dutch addresses carry the spaced form
269
295
  // ('1012 LG') — two FTS tokens that can never match the one-token doc (the #920 name law,
270
- // resurfacing in a WOF-built shard). On a postcode-typed NL-shape miss, retry ONCE with the
296
+ // resurfacing in a WOF-built extract). On a postcode-typed NL-shape miss, retry ONCE with the
271
297
  // whitespace-joined form (block-level precision when the full-code row exists), then the
272
- // 4-digit stem (area-level). Country-gated to NL — the same digits+letters shape elsewhere
298
+ // 4-digit stem (area-level). Country-restricted to NL — the same digits+letters shape elsewhere
273
299
  // must not silently coarsen to a different system's code. Each retry only fires when its
274
300
  // text differs from the current one, so the ladder terminates by construction.
275
301
  if (query.country?.toUpperCase() === "NL" &&
@@ -299,38 +325,7 @@ export class WOFSqlitePlaceLookup {
299
325
  if (!Number.isFinite(id))
300
326
  return [];
301
327
  if (!this.#coincidentRolesCache) {
302
- const map = new Map();
303
- if (coincidentRolesExists(this.#db)) {
304
- const rows = this.#db
305
- .prepare(`SELECT cr.admin_id AS adminID, s.id AS id, s.name AS name, s.country AS country,
306
- s.latitude AS lat, s.longitude AS lon,
307
- cr.relationship_type AS relationshipType, cr.locality_population AS population,
308
- cr.distance_km AS distanceKm
309
- FROM ${COINCIDENT_ROLES_TABLE} cr JOIN spr s ON s.id = cr.locality_id`)
310
- .all();
311
- for (const r of rows) {
312
- const candidate = {
313
- id: r.id,
314
- name: r.name,
315
- placetype: "locality",
316
- country: r.country,
317
- lat: r.lat,
318
- lon: r.lon,
319
- score: 0,
320
- relationshipType: r.relationshipType,
321
- population: r.population,
322
- distanceKm: r.distanceKm,
323
- };
324
- const list = map.get(r.adminID);
325
- if (list) {
326
- list.push(candidate);
327
- }
328
- else {
329
- map.set(r.adminID, [candidate]);
330
- }
331
- }
332
- }
333
- this.#coincidentRolesCache = map;
328
+ this.#coincidentRolesCache = loadCoincidentLocalities(this.#db);
334
329
  }
335
330
  return this.#coincidentRolesCache.get(id) ?? [];
336
331
  }
@@ -368,61 +363,51 @@ export class WOFSqlitePlaceLookup {
368
363
  if (this.#warnedUnknownStrategies.has(name))
369
364
  return;
370
365
  this.#warnedUnknownStrategies.add(name);
371
- console.warn(`WOFSqlitePlaceLookup: a convention names strategy "${name}", which this build does not register ` +
366
+ console.warn(`WOFSQLitePlaceLookup: a convention names strategy "${name}", which this build does not register ` +
372
367
  `(known: ${[...this.#strategies.keys()].join(", ")}). Skipping it. If the convention asset was built ` +
373
368
  `against a newer code revision, rebuild the asset for this one.`);
374
369
  }
375
370
  /**
376
- * Strategy `postcode_area_resolution` — the coordinate-first locality path, strictly gated (a sibling postcode AND a
377
- * postcode_locality table AND a locality query). Returns `null` — so the dispatcher falls through to the next
378
- * strategy — when the gate is unmet or the postcode isn't in the table; otherwise the soft-scored postcode∪name
371
+ * Strategy `postcode_area_resolution` — the coordinate-first locality path, strictly conditioned (a sibling postcode
372
+ * AND a postcode_locality table AND a locality query). Returns `null` — so the dispatcher falls through to the next
373
+ * strategy — when the condition is unmet or the postcode isn't in the table; otherwise the soft-scored postcode∪name
379
374
  * candidate set.
380
375
  */
381
376
  #postcodeAreaResolution(query, convention) {
382
- if (!(query.postcode && this.#postcodeLocalityShard && this.#isLocalityQuery(query))) {
377
+ if (!(query.postcode && this.#postcodeLocalityExtract && this.#isLocalityQuery(query))) {
383
378
  return Promise.resolve(null);
384
379
  }
385
- return this.#findLocalityCoordFirst(query, this.#postcodeLocalityShard, convention);
380
+ return this.#findLocalityCoordFirst(query, this.#postcodeLocalityExtract, convention);
386
381
  }
387
382
  /**
388
383
  * Strategy `fallback_fuzzy_name_match` — the BM25 FTS name-match over the gazetteer, the universal fallback. Always
389
384
  * returns an array (never null), so it terminates the dispatch chain.
390
385
  */
391
- async #fuzzyNameMatch(query, forceShard) {
386
+ async #fuzzyNameMatch(query, forceExtract) {
392
387
  const limit = query.limit ?? 10;
393
- // Over-fetch so post-scoring + exact-match tiering have room to re-rank. SHORT queries (a 2–3-char
394
- // region abbreviation like "NY"/"VT") are the danger case the `exactMatchTiering` docstring flags:
395
- // the exact-abbrev holder's BM25 is poor (its long multilingual alt-name document tanks the score),
396
- // so under the normal `limit * 4` window it drops OUT of the candidate pool BEFORE tiering can
397
- // promote it — "NY" then resolves to a token-matching foreign region (Highland, GB) instead of New
398
- // York. Widen the window for short queries so the exact match is always present to be tiered.
399
- // (Cross-country abbrev collisions — "VT" is BOTH Vermont and Viterbo — still need a country/
400
- // postcode signal to disambiguate; this only rescues the window-drop class, not genuine ambiguity.
401
- // With a `country` hint every abbrev resolves; bare + no-context lifts 7→10/15 US states.)
402
- const ftsLimit = query.text.trim().length <= SHORT_QUERY_MAX_LENGTH ? Math.max(limit * 4, SHORT_QUERY_OVERFETCH) : limit * 4;
403
388
  // Expand the placetype filter through the shared equivalence table (core/resolver): a
404
389
  // `locality` query must also reach `borough` / `localadmin` rows — Brooklyn-the-borough
405
390
  // (pop 2.5M) is a borough, not a locality, and a strict filter made it unreachable so the
406
391
  // fuzzy "Brooklyn Park, MN" won instead. Order-preserving: the FIRST entry stays the
407
- // requested placetype, which is what shard routing keys off below.
392
+ // requested placetype, which is what extract routing keys off below.
408
393
  const placetypes = expandPlacetypeFilter(normalizePlacetypes(query.placetype));
409
394
  // Postcode-typed queries keep the #920 fused name-law shape; everything else splits on
410
395
  // intra-token punctuation so hyphenated names reach the FTS as their real terms (#945).
411
396
  const ftsQuery = sanitizeFTSQuery(query.text, { fuseTokens: placetypes?.includes("postalcode") ?? false });
412
397
  if (!ftsQuery)
413
398
  return [];
414
- // Pick the shard for this query. Multi-shard routing is placetype-driven; a query without
415
- // `placetype` always goes to main. (Mixed-placetype queries with multiple shards aren't
399
+ // Pick the extract for this query. Multi-extract routing is placetype-driven; a query without
400
+ // `placetype` always goes to main. (Mixed-placetype queries with multiple extracts aren't
416
401
  // supported in v1 — caller can issue two findPlace calls and merge in TS if needed.)
417
402
  const firstPlacetype = placetypes?.[0];
418
403
  // Bias fan-out (#58/proximity-bias): a country-less query WITH proximity hints must see the
419
- // cross-shard ambiguity the hints exist to resolve — "48026" lives in postalcode-us AND
420
- // postalcode-intl, and single-shard routing would hide one side. Query every matching shard
421
- // (self-recursion with a shard pin), merge by id, and re-sort by the same (exact, prominence)
422
- // keys the per-shard tier sort used. Bounded: hints + no country + >1 matching shard only.
404
+ // cross-extract ambiguity the hints exist to resolve — "48026" lives in postalcode-us AND
405
+ // postalcode-intl, and single-extract routing would hide one side. Query every matching extract
406
+ // (self-recursion with a extract pin), merge by id, and re-sort by the same (exact, prominence)
407
+ // keys the per-extract tier sort used. Bounded: hints + no country + >1 matching extract only.
423
408
  const hasBiasHints = !!query.near || (query.bias?.length ?? 0) > 0;
424
- if (!forceShard && hasBiasHints && !query.country) {
425
- const matching = pickShardsForPlacetype(this.#shards, firstPlacetype);
409
+ if (!forceExtract && hasBiasHints && !query.country) {
410
+ const matching = pickExtractsForPlacetype(this.#extracts, firstPlacetype);
426
411
  if (matching.length > 1) {
427
412
  const pools = [];
428
413
  for (const sh of matching) {
@@ -441,317 +426,38 @@ export class WOFSqlitePlaceLookup {
441
426
  return merged.slice(0, limit);
442
427
  }
443
428
  }
444
- const shard = forceShard ??
445
- pickShardForPlacetype(this.#shards, firstPlacetype, {
429
+ const extract = forceExtract ??
430
+ pickExtractForPlacetype(this.#extracts, firstPlacetype, {
446
431
  country: query.country,
447
- countriesBySchema: this.#shardCountries,
432
+ countriesBySchema: this.#extractCountries,
448
433
  });
449
- const sch = shard.schemaName; // bare schema name; safe to interpolate (validated at construction)
450
- // Filter out historical / superseded / deprecated places by default — they live in the same
451
- // spr table but should never win a contemporary lookup. `is_current = 0` is the only WOF
452
- // value that means "not current"; both `-1` (modern) and `1` (legacy) mean current. See #91.
453
- // Note: with schema-qualified FROM the bare `place_search` reference in MATCH resolves to
454
- // the FROM table — required by FTS5 parser, see sharding.ts header comment.
455
- const where = ["place_search MATCH ?", "spr.is_current != 0", "spr.is_deprecated = 0"];
456
- const params = [ftsQuery];
457
- if (placetypes && placetypes.length) {
458
- where.push(`spr.placetype IN (${placetypes.map(() => "?").join(", ")})`);
459
- params.push(...placetypes);
460
- }
461
- if (query.country) {
462
- where.push("spr.country = ?");
463
- params.push(query.country);
464
- }
465
- if (query.parentID !== undefined) {
466
- where.push(`(spr.parent_id = ? OR spr.id IN (SELECT id FROM ${sch}.ancestors WHERE ancestor_id = ?))`);
467
- params.push(query.parentID, query.parentID);
468
- }
469
- // Bbox + near-with-radius are SQL-level filters via the R*Tree. We only emit the JOIN when
470
- // the active shard has the R*Tree; missing-but-requested is silently treated as no-bbox-
471
- // filter so legacy DBs / shards-without-bbox don't crash.
472
- const shardHasBbox = this.#hasBboxIndex.get(sch) === true;
473
- const useBboxJoin = (query.bbox || query.near?.maxDistanceKm !== undefined) && shardHasBbox;
474
- let joinClause = `JOIN ${sch}.spr ON spr.id = place_search.wof_id`;
475
- if (useBboxJoin) {
476
- joinClause += ` JOIN ${sch}.${PLACE_BBOX_TABLE} bbox ON bbox.id = spr.id`;
477
- // AABB intersection — both bbox sides must overlap. R*Tree handles this in O(log n).
478
- const filterBox = query.bbox || bboxAround(query.near.lat, query.near.lon, query.near.maxDistanceKm);
479
- where.push("bbox.min_lat <= ? AND bbox.max_lat >= ?", "bbox.min_lon <= ? AND bbox.max_lon >= ?");
480
- params.push(filterBox.maxLat, filterBox.minLat, filterBox.maxLon, filterBox.minLon);
481
- }
482
- // LEFT JOIN the population aux table when present. Missing-on-this-shard means the SELECT
483
- // just doesn't include the population column; the post-scoring loop treats it as 0.
484
- const shardHasPopulation = this.#hasPopulationIndex.get(sch) === true;
485
- const populationSelect = shardHasPopulation
486
- ? `${PLACE_POPULATION_TABLE}.population AS population`
487
- : `NULL AS population`;
488
- const populationJoin = shardHasPopulation
489
- ? `LEFT JOIN ${sch}.${PLACE_POPULATION_TABLE} ON ${PLACE_POPULATION_TABLE}.id = spr.id`
490
- : "";
491
- // The encyclopedic score is CARRIED, never ranked on (ROAD_TO_V9 §2, ratified 2026-08-06) — it
492
- // appears in the SELECT and in no ORDER BY, here or in the companion fetch below. Gated on the
493
- // split column, so a pre-split shard emits a literal NULL and builds no join at all.
494
- const { select: encyclopedicSelect, join: encyclopedicJoin } = this.#encyclopedicClauses.get(sch);
495
- // Push the population boost into the ORDER BY when the index is available, so famous places
496
- // (whose long alt-name lists hurt BM25) actually make it into the over-fetch window. The TS
497
- // post-scoring will still compute the same boost for the final score; this just ensures the
498
- // candidate set is right.
499
- //
500
- // Formula: rank_adjusted = bm25 - populationBoost * min(1.0, log10(1 + pop) / scaleLog10)
501
- // Lower rank_adjusted = better (matches SQLite's bm25 convention of "more negative = better").
502
- //
503
- // #905 — do NOT reach for bm25 column weights here. Measured falsification (2026-07-02): FTS5's
504
- // bm25 length normalization is polluted by the row's TOTAL document size, so identical 1-token
505
- // `name` docs read −16.0 (empty alt_names) vs −0.43 (2.7 KB alt_names) EVEN with the alt_names
506
- // column weighted to zero — no weighting isolates name relevance in this schema. The famous-
507
- // holder guarantee lives in the population-ordered companion fetch below instead, and the
508
- // exact tier breaks ties by population in the post-scoring sort.
509
- const orderByExpr = shardHasPopulation
510
- ? `(bm25(place_search) - ? * MIN(1.0, COALESCE(log10(1.0 + ${PLACE_POPULATION_TABLE}.population), 0) / ?))`
511
- : "bm25(place_search)";
512
- // Schema-qualified FROM with bare-name MATCH — required syntax for FTS5 on attached schemas.
513
- // See sharding.ts header for the gotcha that drove this design.
514
- const stmt = this.#db.prepare(`
515
- SELECT
516
- spr.id AS id,
517
- spr.name,
518
- spr.placetype,
519
- spr.country,
520
- spr.parent_id,
521
- bm25(place_search) AS rank,
522
- spr.latitude AS lat,
523
- spr.longitude AS lon,
524
- spr.min_latitude, spr.max_latitude, spr.min_longitude, spr.max_longitude,
525
- ${populationSelect},
526
- ${encyclopedicSelect}
527
- FROM ${sch}.place_search
528
- ${joinClause}
529
- ${populationJoin}
530
- ${encyclopedicJoin}
531
- WHERE ${where.join(" AND ")}
532
- ORDER BY ${orderByExpr} ASC
533
- LIMIT ?
534
- `);
535
- if (shardHasPopulation) {
536
- params.push(this.#weights.populationBoost, this.#weights.populationScaleLog10);
537
- }
538
- params.push(ftsLimit);
539
- const rawRows = stmt.all(...params);
540
- // #905 companion fetch: the same MATCH, ordered by population alone. For name floods
541
- // ("Paris" matches thousands of gap-fill villages) the bm25-based window above cannot admit
542
- // the famous holder — its bm25 is length-poisoned by the row's alias bulk (measured ~15 pts,
543
- // vs a +4.0 boost cap), so FR Paris never even reaches post-scoring. This fetch makes the
544
- // prominent holders of a name pool-complete BY CONSTRUCTION; the exact-tier sort below
545
- // decides whether they win. Skipped without a population index (nothing to order by).
546
- if (shardHasPopulation) {
547
- const popStmt = this.#db.prepare(`
548
- SELECT
549
- spr.id AS id,
550
- spr.name,
551
- spr.placetype,
552
- spr.country,
553
- spr.parent_id,
554
- bm25(place_search) AS rank,
555
- spr.latitude AS lat,
556
- spr.longitude AS lon,
557
- spr.min_latitude, spr.max_latitude, spr.min_longitude, spr.max_longitude,
558
- ${populationSelect},
559
- ${encyclopedicSelect}
560
- FROM ${sch}.place_search
561
- ${joinClause}
562
- ${populationJoin}
563
- ${encyclopedicJoin}
564
- WHERE ${where.join(" AND ")}
565
- ORDER BY COALESCE(${PLACE_POPULATION_TABLE}.population, 0) DESC
566
- LIMIT ?
567
- `);
568
- const popParams = params.slice(0, -3); // drop the two boost params + ftsLimit
569
- const seen = new Set(rawRows.map((r) => r.id));
570
- for (const row of popStmt.all(...popParams, POPULATION_FETCH_LIMIT)) {
571
- if (!seen.has(row.id)) {
572
- rawRows.push(row);
573
- }
574
- }
575
- }
576
- const queryLen = query.text.length;
577
- const candidates = rawRows.map((row) => {
578
- // SQLite's bm25() returns a lower-is-better score (negative for matches). Negate so we
579
- // start from a higher-is-better baseline.
580
- let score = -row.rank;
581
- if (placetypes && placetypes.length && placetypes.includes(row.placetype)) {
582
- score += this.#weights.placetypeMatchBoost;
583
- }
584
- if (!placetypes && row.placetype === "locality") {
585
- score += this.#weights.localityImplicitBoost;
586
- }
587
- if (query.country && row.country === query.country) {
588
- score += this.#weights.countryMatchBoost;
589
- }
590
- if (query.parentID !== undefined) {
591
- score += row.parent_id === query.parentID ? this.#weights.directChildBoost : this.#weights.descendantBoost;
592
- }
593
- const extraLen = Math.max(0, row.name.length - queryLen - 3);
594
- score -= (this.#weights.lengthPenaltyWeight * extraLen) / 10;
595
- // Proximity boost: only applied when the query carries `near` AND the candidate has real
596
- // coordinates. The formula decays smoothly with distance so close-but-not-exact hits
597
- // still benefit; tunable via proximityBoost + proximityScaleKm.
598
- let distanceKm;
599
- // The best decayed-distance term over `near` + every `bias` point (each point's term is
600
- // scaled by its weight; the MAX wins — a candidate near ANY hint is "nearby"). Carried
601
- // into the exact-tier prominence sort below when hints are present.
602
- let proximityTerm = 0;
603
- if (row.lat !== null && row.lon !== null && !(row.lat === 0 && row.lon === 0)) {
604
- const hints = [];
605
- if (query.near) {
606
- hints.push({ lat: query.near.lat, lon: query.near.lon, weight: 1 });
607
- }
608
- for (const b of query.bias ?? []) {
609
- hints.push({ lat: b.lat, lon: b.lon, weight: b.weight ?? 1 });
610
- }
611
- let scoreTerm = 0;
612
- for (const h of hints) {
613
- const d = haversineKm(h.lat, h.lon, row.lat, row.lon);
614
- const decay = h.weight / (1 + d / this.#weights.proximityScaleKm);
615
- const prom = decay * this.#weights.biasBoost;
616
- if (prom > proximityTerm) {
617
- proximityTerm = prom;
618
- distanceKm = d;
619
- scoreTerm = decay * this.#weights.proximityBoost;
620
- }
621
- }
622
- score += scoreTerm;
623
- }
624
- // Population boost: capped at `populationBoost` magnitude at `10^populationScaleLog10`
625
- // people. Missing population → no contribution. Never penalizes.
626
- let popTerm = 0;
627
- if (row.population !== null && row.population > 0 && this.#weights.populationScaleLog10 > 0) {
628
- const popLog = Math.log10(1 + row.population);
629
- const popFraction = Math.min(1, popLog / this.#weights.populationScaleLog10);
630
- popTerm = this.#weights.populationBoost * popFraction;
631
- score += popTerm;
632
- }
633
- // Combined prominence for the exact-tier sort when proximity hints are present: population
634
- // and nearness in the SAME additive units, so the map view / the user's location can win a
635
- // cross-country postcode tie without a hard filter.
636
- const prominence = popTerm + proximityTerm;
637
- const candidate = {
638
- id: row.id,
639
- prominence,
640
- name: row.name,
641
- placetype: row.placetype,
642
- country: row.country ?? "",
643
- lat: row.lat ?? 0,
644
- lon: row.lon ?? 0,
645
- parent_id: row.parent_id ?? undefined,
646
- score,
647
- };
648
- if (distanceKm !== undefined) {
649
- candidate.distanceKm = distanceKm;
650
- }
651
- if (row.population !== null && row.population > 0) {
652
- candidate.population = row.population;
653
- // The named ranking key (ROAD_TO_V9 §2). DERIVED, not stored — a pure function of the
654
- // population already on this row, so it cannot drift from what the ordering uses.
655
- candidate.referential = referentialFromPopulation(row.population);
656
- }
657
- // Carried for consumers (annotations / API surfaces). No ranking site reads it.
658
- if (row.encyclopedic !== null) {
659
- candidate.encyclopedic = row.encyclopedic;
660
- }
661
- // Candidate bbox — parity with the WASM lookup (resolver-wof-wasm/lookup.ts), whose
662
- // consumers (the demo cascade's region constraint) read it. Without this the Node
663
- // backend's region→bbox constraint is dead and disambiguation falls to population
664
- // ranking (the Springfield-IL→MO failure the #524 smoke eval caught).
665
- if (row.min_latitude != null &&
666
- row.max_latitude != null &&
667
- row.min_longitude != null &&
668
- row.max_longitude != null) {
669
- candidate.bbox = {
670
- minLat: row.min_latitude,
671
- maxLat: row.max_latitude,
672
- minLon: row.min_longitude,
673
- maxLon: row.max_longitude,
674
- };
675
- }
676
- return candidate;
434
+ // bare schema name; safe to interpolate (validated at construction)
435
+ const sch = extract.schemaName;
436
+ const rawRows = fetchSearchRows({
437
+ db: this.#db,
438
+ schemaName: sch,
439
+ query,
440
+ placetypes,
441
+ ftsQuery,
442
+ limit,
443
+ hasBboxIndex: this.#hasBboxIndex,
444
+ hasPopulationIndex: this.#hasPopulationIndex,
445
+ encyclopedicClauses: this.#encyclopedicClauses,
446
+ weights: this.#weights,
447
+ });
448
+ const scoring = {
449
+ query,
450
+ placetypes,
451
+ queryLen: query.text.length,
452
+ weights: this.#weights,
453
+ };
454
+ const candidates = rawRows.map((row) => candidateFromSearchRow(row, scoring));
455
+ rankCandidates(candidates, {
456
+ db: this.#db,
457
+ schemaName: sch,
458
+ query,
459
+ weights: this.#weights,
677
460
  });
678
- // Exact-match tiering: a candidate whose name OR any alias equals the query text (case-folded)
679
- // ranks above any partial match, with the weighted-sum score (incl. population) breaking ties
680
- // WITHIN a tier. See the RankingWeights.exactMatchTiering docstring for why this aligns the
681
- // population prior rather than overriding it. One cheap indexed lookup over the candidate ids.
682
- // Runs even for a SINGLE candidate so `exactMatch` is stamped consistently (parity with the
683
- // WASM lookup) — a sole alias hit ("New York City" → New York) must still carry the flag the
684
- // demo cascade / #369 re-rank read.
685
- if (this.#weights.exactMatchTiering && candidates.length) {
686
- const exactIds = this.#exactMatchIds(sch, candidates.map((c) => c.id), query.text);
687
- // Stamp the tier onto every candidate (not just when the tiering sort fires) so a downstream
688
- // re-rank — #369's postcode-anchor country pin in `resolveTree` — can keep the country pin from
689
- // crossing the exact/partial boundary ("ME" → Maine, not the more-populous Missouri).
690
- for (const c of candidates) {
691
- c.exactMatch = exactIds.has(c.id);
692
- }
693
- if (exactIds.size) {
694
- // #905: WITHIN the exact tier, population is the PRIMARY key and the weighted score
695
- // only breaks population ties. Exactness saturates text relevance, and the bm25
696
- // residue inside `score` is length-noise (see the fetch-site comment), so letting it
697
- // order the tier is what sent unscoped "Paris" to an Ohio township. The partial tier
698
- // keeps score order — text relevance still means something there. This makes the
699
- // exactMatchTiering docstring literal: match quality primary, prominence within.
700
- //
701
- // #912 sub-tier: a NAME-exact candidate (spr.name equals the query) outranks an
702
- // ALIAS-exact one ('Paris' the place beats 'Paris Township' held via alias 'Paris').
703
- // The place's own name is a stronger identity claim than an alias — aliases exist to
704
- // widen recall, not to tie primaries. ME→Maine is untouched: 'ME' name-exact-matches
705
- // nothing, so the alias sub-tier still decides there. Population orders within each
706
- // sub-tier as before.
707
- const norm = (v) => v.toLowerCase().trim().replaceAll(/\s+/g, " ");
708
- const needle = norm(query.text);
709
- // #936 option 3: an OFFICIAL name (preferred form in an official language of the place's
710
- // country, `names.official = 1`) counts as the place's own name for the sub-tier — "Åbo" is
711
- // Turku's name, not merely its alias. Floor-gated on the holder's population (see the
712
- // RankingWeights docstring for the measured 100k boundary). officialIds ⊆ exactIds by
713
- // construction (official rows are names rows), so only the sub-tier KIND changes.
714
- const officialIds = this.#weights.officialNameExact
715
- ? this.#officialNameIds(sch, candidates
716
- .filter((c) => exactIds.has(c.id) && (c.population ?? 0) >= this.#weights.officialNameExactFloor)
717
- .map((c) => c.id), query.text)
718
- : undefined;
719
- const kind = (c) => {
720
- if (!exactIds.has(c.id))
721
- return 0;
722
- if (norm(String(c.name ?? "")) === needle)
723
- return 2;
724
- return officialIds?.has(c.id) ? 2 : 1;
725
- };
726
- // With proximity hints (near/bias), prominence (population + nearness, same units)
727
- // replaces raw population as the within-tier key — the 48026 rule: the map view or
728
- // the user's location breaks a cross-country postcode tie. Without hints, REFERENTIAL
729
- // ordering decides.
730
- //
731
- // ROAD_TO_V9 §2: this is the site that "orders namesakes", so it is the site that has to
732
- // say what it orders by. `compareReferential` is referential DESC with raw population as
733
- // the tiebreak, which is provably the SAME ORDER as the `(b.population ?? 0) - (a.population ?? 0)`
734
- // it replaces — referential is strictly increasing in population below saturation and
735
- // constant above it, and the tiebreak restores the order in the saturated tail. Measured
736
- // zero-delta, not assumed: see `place-importance-schema.test.ts` and `resolver-referential-ranking.test.ts`.
737
- // Encyclopedic importance is not, and must not become, an input here.
738
- const hasHints = !!query.near || (query.bias?.length ?? 0) > 0;
739
- candidates.sort((a, b) => {
740
- const ax = kind(a);
741
- const bx = kind(b);
742
- if (bx !== ax)
743
- return bx - ax;
744
- if (ax >= 1) {
745
- if (hasHints)
746
- return (b.prominence ?? 0) - (a.prominence ?? 0) || b.score - a.score;
747
- return compareReferential(a, b) || b.score - a.score;
748
- }
749
- return b.score - a.score;
750
- });
751
- return candidates.slice(0, limit);
752
- }
753
- }
754
- candidates.sort((a, b) => b.score - a.score);
755
461
  return candidates.slice(0, limit);
756
462
  }
757
463
  #isLocalityQuery(query) {
@@ -809,10 +515,8 @@ export class WOFSqlitePlaceLookup {
809
515
  const pc = query.postcode.trim();
810
516
  const pcWhere = query.country ? "postcode = ? AND country = ?" : "postcode = ?";
811
517
  const pcParams = query.country ? [pc, query.country] : [pc];
812
- const pcRows = this.#db
813
- .prepare(`SELECT locality_id AS id, aliases, distance_km AS dist, is_containing AS containing
814
- FROM ${sch}.${POSTCODE_LOCALITY_TABLE} WHERE ${pcWhere}`)
815
- .all(...pcParams);
518
+ const pcRows = allRows(this.#db.prepare(`SELECT locality_id AS id, aliases, distance_km AS dist, is_containing AS containing
519
+ FROM ${sch}.${POSTCODE_LOCALITY_TABLE} WHERE ${pcWhere}`), ...pcParams);
816
520
  if (!pcRows.length)
817
521
  return null;
818
522
  const limit = query.limit ?? 10;
@@ -861,7 +565,7 @@ export class WOFSqlitePlaceLookup {
861
565
  ? [...wofAliases, ...(postalAliasByGeo.get(cfNormalize(cand.name)) ?? [])]
862
566
  : wofAliases;
863
567
  const sName = softNameScore(query.text, cand.name, aliases);
864
- const sPop = cand.population && cand.population > 0 ? Math.min(1, Math.log10(1 + cand.population) / 6) : 0;
568
+ const sPop = populationScaleTerm(cand.population, this.#weights);
865
569
  scored.push({ ...cand, score: w.pc * sPc + w.name * sName + w.pop * sPop, exact: sName >= 1 });
866
570
  }
867
571
  // Exact-name tiering (same philosophy as the FTS path): an EXACT name/alias match tiers above
@@ -905,12 +609,10 @@ export class WOFSqlitePlaceLookup {
905
609
  const popSelect = hasPop ? `pp.population AS population` : `NULL AS population`;
906
610
  const popJoin = hasPop ? `LEFT JOIN main.${PLACE_POPULATION_TABLE} pp ON pp.id = s.id` : "";
907
611
  const ph = ids.map(() => "?").join(", ");
908
- const rows = this.#db
909
- .prepare(`SELECT s.id AS id, s.name AS name, s.country AS country, s.parent_id AS parent_id,
612
+ const rows = allRows(this.#db.prepare(`SELECT s.id AS id, s.name AS name, s.country AS country, s.parent_id AS parent_id,
910
613
  s.latitude AS lat, s.longitude AS lon, s.placetype AS placetype, ${popSelect}
911
614
  FROM main.spr s ${popJoin}
912
- WHERE s.id IN (${ph}) AND s.is_current != 0`)
913
- .all(...ids);
615
+ WHERE s.id IN (${ph}) AND s.is_current != 0`), ...ids);
914
616
  return rows.map((row) => {
915
617
  const c = {
916
618
  id: row.id,
@@ -928,95 +630,10 @@ export class WOFSqlitePlaceLookup {
928
630
  return c;
929
631
  });
930
632
  }
931
- /**
932
- * Among `ids`, return the subset whose name OR any alias equals `text` case-insensitively — the exact-match tier for
933
- * ranking. One indexed query over `<schema>.names`. When the shard has no `names` table (a slim DB built with
934
- * `dropNames`, or a postcode-only shard), fall back to the self-contained `place_search` FTS content: its `alt_names`
935
- * column is the same alias set joined on the boundary-preserving `ALIAS_SEPARATOR` (#523), so `aliasBagExactMatch`
936
- * recovers the exact alias tier ("New York City" → New York) that the dropped `names` table used to provide.
937
- */
938
- #exactMatchIds(schemaName, ids, text) {
939
- const out = new Set();
940
- const trimmed = text.trim();
941
- if (!ids.length || !trimmed)
942
- return out;
943
- const placeholders = ids.map(() => "?").join(", ");
944
- try {
945
- const rows = this.#db
946
- .prepare(`SELECT DISTINCT id FROM ${schemaName}.names WHERE id IN (${placeholders}) AND name = ? COLLATE NOCASE`)
947
- .all(...ids, trimmed);
948
- for (const r of rows) {
949
- out.add(r.id);
950
- }
951
- return out;
952
- }
953
- catch {
954
- // No `names` table on this shard — fall through to the place_search alias bag.
955
- }
956
- try {
957
- const rows = this.#db
958
- .prepare(`SELECT wof_id AS id, name, alt_names FROM ${schemaName}.place_search WHERE wof_id IN (${placeholders})`)
959
- .all(...ids);
960
- const norm = (s) => s.toLowerCase().trim().replaceAll(/\s+/g, " ");
961
- const needle = norm(trimmed);
962
- for (const r of rows) {
963
- if (r.name !== null && norm(r.name) === needle) {
964
- out.add(r.id);
965
- }
966
- }
967
- // Alias pass via the shared bag parser (#523). Separated bags (built since #523) get a true
968
- // per-alias equality check, ungated — matching the `names`-table branch above, where an
969
- // alias match counts as exact regardless of other candidates. Legacy bags (no separator)
970
- // fall back to padded containment, gated on "no canonical exact in the pool" because their
971
- // lost boundaries would otherwise false-promote interior fragments ("York" inside the alias
972
- // "New York City") or cross-alias fragments ("York New" across "…York" + "New City…").
973
- const anyCanonicalExact = out.size > 0;
974
- for (const r of rows) {
975
- if (aliasBagExactMatch(r.alt_names, needle, anyCanonicalExact)) {
976
- out.add(r.id);
977
- }
978
- }
979
- }
980
- catch {
981
- // Shard without place_search either → no exact-match tier. Falls back to weighted-sum order.
982
- }
983
- return out;
984
- }
985
- /**
986
- * Among `ids` (already known exact matches), the subset holding `text` as an OFFICIAL name (`names.official = 1`, the
987
- * #940 ingest bit). Same COLLATE NOCASE semantics as {@link WOFSqlitePlaceLookup.#exactMatchIds} so the two probes
988
- * agree on what "equals the query" means. Fails soft on gazetteers built before #940 (no `official` column) — the
989
- * sub-tier then behaves exactly as if `officialNameExact` were off.
990
- */
991
- #officialNameIds(schemaName, ids, text) {
992
- const out = new Set();
993
- const trimmed = text.trim();
994
- if (!ids.length || !trimmed)
995
- return out;
996
- const placeholders = ids.map(() => "?").join(", ");
997
- try {
998
- const rows = this.#db
999
- .prepare(`SELECT DISTINCT id FROM ${schemaName}.names WHERE id IN (${placeholders}) AND official = 1 AND name = ? COLLATE NOCASE`)
1000
- .all(...ids, trimmed);
1001
- for (const r of rows) {
1002
- out.add(r.id);
1003
- }
1004
- }
1005
- catch {
1006
- // Pre-#940 gazetteer (no `official` column) or a names-less slim shard — feature inert.
1007
- }
1008
- return out;
1009
- }
1010
- close() {
1011
- // Destroying the Kysely instance closes the underlying connection IF we own it. If the caller
1012
- // passed in a pre-opened DatabaseSync (test fixture), respect their ownership.
1013
- void this.#kysely.destroy();
1014
- if (this.#ownsDB) {
1015
- this.#db.close();
1016
- }
1017
- }
1018
633
  [Symbol.dispose]() {
1019
- this.close();
634
+ // Only when we opened it. A caller who passed a pre-opened client keeps using it after this returns — the FTS
635
+ // build this lookup performed lives on their connection, and closing it would take that with us.
636
+ this.#resources[Symbol.dispose]();
1020
637
  }
1021
638
  /**
1022
639
  * Build the FTS5 virtual table from the `names` + `places` tables.
@@ -1026,9 +643,8 @@ export class WOFSqlitePlaceLookup {
1026
643
  }
1027
644
  #assertFTSExists() {
1028
645
  if (!placeSearchFTSExists(this.#db)) {
1029
- throw new Error("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).");
646
+ throw new Error("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).");
1030
647
  }
1031
648
  }
1032
649
  }
1033
- export { trigramJaccard, trigrams } from "./name-score.js";
1034
650
  //# sourceMappingURL=lookup.js.map