@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
@@ -14,7 +14,7 @@
14
14
  * {@link normalizeLocalityForKey} (build- and query-consistent), each row is denormalized (display
15
15
  * `name`, centroid, bbox), and population rank is precomputed into `neg_rank` — so the result is
16
16
  * POPULATION-FIRST and COUNTRY-AGNOSTIC (when no `country` filter is given), exactly like the
17
- * demo. That's the deliberate divergence from {@link WOFSqlitePlaceLookup}'s FTS/bm25 ranking: a
17
+ * demo. That's the deliberate divergence from {@link WOFSQLitePlaceLookup}'s FTS/bm25 ranking: a
18
18
  * bare "Moscow" resolves to the 10.4 M-pop Russian city, not whichever same-name US township bm25
19
19
  * floats to the top.
20
20
  *
@@ -22,91 +22,50 @@
22
22
  * stored bbox and the locality query is point-in-bbox-filtered on the candidate centroid (the
23
23
  * `bbox` field on {@link FindPlaceQuery}).
24
24
  */
25
- import { DatabaseSync } from "node:sqlite";
26
- import { expandPlacetypeFilter } from "@mailwoman/resolver";
27
- import { CANDIDATE_FTS_TABLE } from "./candidate-fts.js";
28
- import { readGazetteerCoverageManifest } from "./coverage-manifest-schema.js";
29
- import { haversineKm } from "./geo.js";
30
- import { trigramJaccard } from "./lookup.js";
31
- import { referentialFromPopulation } from "./place-importance-schema.js";
32
- import { POSTAL_CITY_CANDIDATE_TABLE } from "./postal-city-candidate-schema.js";
33
- import { hasColumn, hasTable } from "./sqlite-utils.js";
34
- import { normalizeLocalityForKey, stripLocalityQualifier } from "./street-normalize.js";
25
+ import { jaroWinkler, levenshteinSimilarity } from "@mailwoman/match/comparators";
26
+ import { expandPlacetypeFilter, partitionByContainment, } from "@mailwoman/resolver";
27
+ import { haversineKm } from "@mailwoman/spatial";
28
+ import { DatabaseClient } from "@mailwoman/sqlite/client";
29
+ import { CANDIDATE_ANCESTOR_TABLE, CANDIDATE_INTERVAL_TABLE, intervalContains, } from "#candidate-ancestors-schema";
30
+ import { CANDIDATE_FTS_TABLE } from "#candidate-fts";
31
+ import { readGazetteerCoverageManifest } from "#coverage-manifest-schema";
32
+ import { referentialFromPopulation } from "#place-importance-schema";
33
+ import { POSTAL_CITY_CANDIDATE_TABLE } from "#postal/city-candidate-schema";
34
+ import { rankByPrimaryPreference, RERANK_FETCH } from "#primary-preference";
35
+ import { applyProximityRerank } from "#proximity-rerank";
36
+ import { REGION_CLASS_PLACETYPES, regionQualifierProbeKeys } from "#region-keys";
37
+ import { allRows, hasColumn, hasTable } from "#sqlite-utils";
38
+ import { normalizeLocalityForKey, stripLocalityQualifier } from "#street/normalize";
39
+ export { rankByPrimaryPreference } from "#primary-preference";
35
40
  /**
36
- * FTS5-trigram over-fetch before the trigram-Jaccard re-rank, and the minimum similarity to count as a fuzzy hit (below
37
- * it the trigram overlap is noise, e.g. unrelated same-trigram names). Tunable.
41
+ * FTS5-trigram over-fetch before the WORD-LEVEL re-rank. The trigram index stays the candidate GENERATOR (it is what
42
+ * the artifact carries); the scoring moved off trigram-Jaccard on 2026-08-12 (#1614), which the aucklnad receipts
43
+ * falsified as a typo measure: it scored the true transposition correction 'auckland' at 0.333 — below its own 0.34 bar
44
+ * — while 'auckley' scored 0.375 and the 'gore bay' scrape 0.455, because shared generic suffixes count as trigram
45
+ * evidence and transpositions count against it.
38
46
  */
39
47
  const FUZZY_FETCH = 40;
40
- const FUZZY_MIN = 0.34;
41
48
  /**
42
- * Postcode-containment re-rank gate radius (km) — the SAME value the resolver's country pass measures at
43
- * (`POSTCODE_COUNTRY_COHERENCE_GATE_KM`, resolver/postcode-country-coherence.ts): a locality within this distance of
44
- * the postcode's own centroid counts as "containing" it. One number, two passes — a divergence here would make the two
45
- * mechanisms disagree about what is proximal.
49
+ * Minimum WORD-LEVEL similarity (max of Jaro-Winkler and normalized edit similarity — the `match/comparators`
50
+ * primitives, deliberately WITHOUT `nameSimilarity`'s token-subset floor, which is a person-name rule that would hand
51
+ * 'stanmore bay' to a place named 'Bay') for a fuzzy correction to count. Measured on the #1614 receipts:
52
+ * 'aucklnad'→'auckland' 0.975 (in), →'auckley' 0.868 (in, but outranked), 'stanmore bay'→'gore bay' ~0.70 (out),
53
+ * 'sacremento'→'sacramento' ~0.97 (in).
46
54
  */
47
- const POSTCODE_CONTAINMENT_GATE_KM = 25;
55
+ const WORD_FUZZY_MIN = 0.85;
48
56
  /**
49
- * Bounded PRIMARY-NAME preference across a CROSS-COUNTRY name collision (the `is_primary` ranking signal).
50
- *
51
- * The raw candidate order is population-first (`neg_rank ASC`) and treats an ALIAS row (a place's alt-name / exonym,
52
- * `is_primary=0`) and a PRIMARY-name row (`is_primary=1`) on equal footing. So a foreign place whose transliterated
53
- * exonym coincidentally normalizes to a query — Changchun CN stores the Turkish exonym "Çançun" (`name_key="cancun"`),
54
- * 4.19 M pop — outranks the PRIMARY-name place the query actually means (Cancún MX, 0.89 M pop). This penalty makes a
55
- * same-key alias have to clear a population MARGIN over a foreign primary before it wins.
56
- *
57
- * It is deliberately NOT a dominant sort key (no `ORDER BY is_primary DESC`, which would make every primary outrank
58
- * every alias and break the alt-names users depend on — "NYC"→New York, "LA"→Los Angeles, "Frisco"→San Francisco). Two
59
- * bounds keep it a soft prior:
60
- *
61
- * 1. **Cross-country only.** The penalty applies to an alias ONLY when the top-population primary sharing the key is in a
62
- * DIFFERENT country. A SAME-country nickname contest (San Francisco's alias "Frisco" vs the primary Frisco, TX —
63
- * both US) is left on pure population, so the legitimate alias still wins.
64
- * 2. **Population-bounded.** The penalty is {@link PRIMARY_PREFERENCE_LOG10} in log10-population units — an alias must be
65
- * at least 10x more populous than the foreign primary to still win. So a genuinely dominant alias keeps winning
66
- * ("Los Angeles" over La, Ghana — gap 1.6; "Las Vegas" over Vegas, Cuba — gap 2.4) while a near-tie coincidental
67
- * collision defers to the primary (Cancún over Changchun — gap 0.7).
68
- */
69
- const PRIMARY_PREFERENCE_LOG10 = 1;
70
- /**
71
- * Over-fetch cap for {@link rankByPrimaryPreference}: the candidate rows for one `name_key` (all same-name places
72
- * worldwide) are re-ranked in-process, so the probe fetches this many (population-ordered) before the re-rank rather
73
- * than the caller's small `limit`, ensuring the intended primary isn't cut below the fold by a cluster of more-populous
74
- * foreign aliases. Bounded and small — a single contiguous B-tree scan.
57
+ * The word-level correction similarity — see {@link WORD_FUZZY_MIN}.
75
58
  */
76
- const RERANK_FETCH = 64;
59
+ function wordFuzzySimilarity(a, b) {
60
+ return Math.max(jaroWinkler(a, b), levenshteinSimilarity(a, b));
61
+ }
77
62
  /**
78
- * Bounded cross-country primary-name preference (see {@link PRIMARY_PREFERENCE_LOG10}). Pure + total-ordered so
79
- * `candidate-lookup.test.ts` can exercise it on synthetic rows. `rows` arrive population-ordered (`neg_rank ASC`); an
80
- * alias (`is_primary=0`) is pushed back by `delta` in log10-population units ONLY when the top-population primary
81
- * sharing the key is in a different country, and is `demoted` out of the exact tier when that penalty leaves it BEHIND
82
- * the primary. Returns the top `limit` after the re-rank, each annotated.
63
+ * Postcode-containment re-rank check radius (km) — the SAME value the resolver's country pass measures at
64
+ * (`POSTCODE_COUNTRY_COHERENCE_THRESHOLD_KM`, resolver/postcode-country-coherence.ts): a locality within this distance
65
+ * of the postcode's own centroid counts as "containing" it. One number, two passes — a divergence here would make the
66
+ * two mechanisms disagree about what is proximal.
83
67
  */
84
- export function rankByPrimaryPreference(rows, limit, delta = PRIMARY_PREFERENCE_LOG10) {
85
- // The primary the alias actually competes with for the top slot: highest population (min neg_rank). Undefined
86
- // when the set has no primary → nothing to prefer, penalty is 0, order stays population-first (today's behavior).
87
- let topPrimary;
88
- for (const r of rows) {
89
- if (r.is_primary === 1 && (topPrimary === undefined || r.neg_rank < topPrimary.neg_rank)) {
90
- topPrimary = r;
91
- }
92
- }
93
- const topCountry = topPrimary?.country_id;
94
- // A cross-country alias (different country than the top primary) is penalized; it is DEMOTED when even after — i.e.
95
- // the penalty leaves its effective rank behind the primary's raw rank (it lost the bounded population contest).
96
- const isCrossCountryAlias = (r) => topCountry !== undefined && r.is_primary !== 1 && r.country_id !== topCountry;
97
- const annotate = (r) => {
98
- const penalized = isCrossCountryAlias(r);
99
- const effectiveNegRank = r.neg_rank + (penalized ? delta : 0);
100
- return { ...r, effectiveNegRank, demoted: penalized && effectiveNegRank > topPrimary.neg_rank };
101
- };
102
- return (rows
103
- .map((r, i) => ({ row: annotate(r), i }))
104
- // Effective rank ASC; ties keep population order, then original index (stable).
105
- // oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
106
- .sort((a, b) => a.row.effectiveNegRank - b.row.effectiveNegRank || a.row.neg_rank - b.row.neg_rank || a.i - b.i)
107
- .slice(0, limit)
108
- .map((x) => x.row));
109
- }
68
+ const POSTCODE_CONTAINMENT_THRESHOLD_KM = 25;
110
69
  /**
111
70
  * Unpadded character-trigrams of `s`, OR'd into an FTS5 trigram MATCH query (each quoted so FTS treats it as a literal
112
71
  * term). Returns "" when `s` is shorter than a trigram or yields no clean grams — the caller then skips the fuzzy
@@ -123,12 +82,16 @@ function ftsTrigramQuery(s) {
123
82
  return [...grams].map((g) => `"${g}"`).join(" OR ");
124
83
  }
125
84
  /**
126
- * Node {@link PlaceLookup} over `candidate.db`. Drop-in for {@link WOFSqlitePlaceLookup} in `createWOFResolver(backend)`
85
+ * Node {@link PlaceLookup} over `candidate.db`. Drop-in for {@link WOFSQLitePlaceLookup} in `createWOFResolver(backend)`
127
86
  * — same `findPlace` contract, population-first ranking.
128
87
  */
129
88
  export class WOFCandidateTableLookup {
130
89
  #db;
131
- #ownsDB;
90
+ /**
91
+ * Resources this instance opened. A connection handed in by a caller is NOT in here, so disposal cannot reach it —
92
+ * ownership is membership rather than a flag a later branch has to check.
93
+ */
94
+ #resources = new DisposableStack();
132
95
  #countryToID = new Map();
133
96
  #idToCountry = new Map();
134
97
  #placetypeToID = new Map();
@@ -144,48 +107,86 @@ export class WOFCandidateTableLookup {
144
107
  */
145
108
  #ftsProbe;
146
109
  /**
147
- * Prepared UNFILTERED existence probe (`name_key` present anywhere, ignoring country/placetype/bbox). Gates the fuzzy
148
- * fallback: fuzzy is a TYPO corrector, so it engages only when the name doesn't exist in the gazetteer at all. A name
149
- * that DOES exist but missed under the active filter is a filter miss (e.g. a placer misroute "Vienna, Austria"→IT),
150
- * not a spelling miss — fuzzing it would scrape an unrelated same-country place and defeat the cascade's
151
- * country-agnostic retry. Prepared only alongside `#ftsProbe`.
110
+ * Prepared UNFILTERED existence probe (`name_key` present anywhere, ignoring country/placetype/bbox). Checks the
111
+ * fuzzy fallback: fuzzy is a TYPO corrector, so it engages only when the name doesn't exist in the gazetteer at all.
112
+ * A name that DOES exist but missed under the active filter is a filter miss (e.g. a placer misroute "Vienna,
113
+ * Austria"→IT), not a spelling miss — fuzzing it would scrape an unrelated same-country place and defeat the
114
+ * cascade's country-agnostic retry. Prepared only alongside `#ftsProbe`.
152
115
  */
153
116
  #nameKeyExistsProbe;
154
117
  /**
155
118
  * Facts this candidate DB declares about itself — the coverage manifest (`country_coverage` + `country_bbox`) the
156
119
  * gazetteer build emits, read once at open. `undefined` when the artifact predates the manifest, so every consumer
157
- * (the hard-country coverage gate, guard-B plausibility) falls back to its code constants byte-identically.
120
+ * (the hard-country coverage check, guard-B plausibility) falls back to its code constants byte-identically.
158
121
  */
159
122
  artifactCoverage;
160
123
  /**
161
124
  * `", importance"` when this artifact carries the #28 fame column, `""` when it does not — spliced into the probe's
162
- * SELECT list. Existence-gated exactly like `#ftsProbe` and `#postalCityProbe` above, and for the same reason: a
125
+ * SELECT list. Existence-restricted exactly like `#ftsProbe` and `#postalCityProbe` above, and for the same reason: a
163
126
  * candidate.db built before the column is a valid artifact, and naming a column it lacks would turn a stale gazetteer
164
127
  * into `no such column` on the first keystroke rather than into "no fame signal", which is what it is.
165
128
  */
166
129
  #importanceSelect;
130
+ /**
131
+ * Whether the artifact carries the #1730 `name_role` column — absent on pre-role builds, where the `excludeNameRoles`
132
+ * filter degrades to a no-op rather than erroring on a missing column.
133
+ */
134
+ #hasNameRole;
135
+ #variantAliasExemption;
136
+ /**
137
+ * `", name_role"` when the artifact carries the column — the probe SELECT rides it so the #1882 exemption can read
138
+ * the stamp off the row; empty on a pre-role build.
139
+ */
140
+ #roleSelect;
141
+ /**
142
+ * Prepared chain probe over the `candidate_ancestor` sidecar — `undefined` when the artifact predates it.
143
+ */
144
+ #ancestorsProbe;
145
+ #ancestorsCache = new Map();
146
+ /**
147
+ * Prepared interval-label probe over `candidate_interval` — `undefined` when the artifact predates the sidecar, which
148
+ * is what makes the admin-containment re-rank (#1717 stage 2) capability-restricted: without it,
149
+ * `FindPlaceQuery.regionQualifier` is ignored, no candidate carries a `containedByQualifier` stamp, and the resolver
150
+ * walk reports the change `unavailable` instead of silently dead.
151
+ */
152
+ #intervalProbe;
153
+ #intervalCache = new Map();
154
+ /**
155
+ * Prepared qualifier probe: the region-band rows (plus `country` — the region SLOT can hold a mislabeled country
156
+ * name, "Moscow, Russia" parses region="Russia") for one folded qualifier key. `undefined` when the artifact lacks
157
+ * the sidecar or the placetype dictionary lacks the band entirely.
158
+ */
159
+ #qualifierProbe;
160
+ /**
161
+ * The ancestor lineage of a resolved place — nearest-first (locality-tier → county → region → … → country), the same
162
+ * order the FTS backend's `ancestorLineage` serves, read from the `candidate_ancestor` sidecar in one clustered
163
+ * probe. Backs `ResolveOpts.includeAncestors` (#404) on this backend, which is what puts region-class ancestry in
164
+ * front of the admin-coherence check (#1717).
165
+ *
166
+ * A PROPERTY, not a method, and assigned only when the artifact carries the sidecar: capability probes (`typeof
167
+ * backend.ancestors === "function"` — the resolver's gap report) then read the ARTIFACT truthfully. A candidate.db
168
+ * built before the sidecar reports the capability absent instead of presenting a method that answers `[]` for every
169
+ * place, which would be an absence dressed as a negative answer.
170
+ */
171
+ ancestors;
167
172
  constructor(opts) {
168
173
  if (opts.database) {
169
174
  this.#db = opts.database;
170
- this.#ownsDB = false;
171
175
  }
172
176
  else if (opts.databasePath) {
173
- this.#db = new DatabaseSync(opts.databasePath, { readOnly: true });
174
- this.#ownsDB = true;
177
+ this.#db = this.#resources.use(new DatabaseClient(opts.databasePath, { readOnly: true }));
175
178
  }
176
179
  else {
177
180
  throw new Error("WOFCandidateTableLookup needs `databasePath` or `database`");
178
181
  }
179
182
  // The code tables are tiny (country/placetype dictionaries) — load them once at construction so
180
183
  // `findPlace` is a single B-tree probe with no dictionary round-trip.
181
- for (const r of this.#db.prepare("SELECT id, code FROM country_codes").all()) {
184
+ for (const r of allRows(this.#db.prepare("SELECT id, code FROM country_codes"))) {
182
185
  const code = String(r.code).toUpperCase();
183
186
  this.#countryToID.set(code, Number(r.id));
184
187
  this.#idToCountry.set(Number(r.id), code);
185
188
  }
186
- for (const r of this.#db
187
- .prepare("SELECT id, placetype FROM placetype_codes")
188
- .all()) {
189
+ for (const r of allRows(this.#db.prepare("SELECT id, placetype FROM placetype_codes"))) {
189
190
  this.#placetypeToID.set(String(r.placetype), Number(r.id));
190
191
  this.#idToPlacetype.set(Number(r.id), String(r.placetype));
191
192
  }
@@ -202,11 +203,192 @@ export class WOFCandidateTableLookup {
202
203
  }
203
204
  // #28 fame column: probed ONCE here (it runs a PRAGMA, and `findPlace` is per-keystroke hot).
204
205
  this.#importanceSelect = hasColumn(this.#db, "candidate", "importance") ? ", importance" : "";
205
- // Coverage manifest (survey candidate #2): the artifact's own coverage facts, existence-gated like
206
+ this.#hasNameRole = hasColumn(this.#db, "candidate", "name_role");
207
+ this.#variantAliasExemption = opts.variantAliasExemption === true;
208
+ this.#roleSelect = this.#hasNameRole ? ", name_role" : "";
209
+ // Ancestors sidecar (#1717): existence-restricted like the probes above, and the CAPABILITY checks with
210
+ // it — see the `ancestors` property doc for why an older artifact must read as "no ancestors()"
211
+ // rather than as a method that answers [] everywhere.
212
+ if (hasTable(this.#db, CANDIDATE_ANCESTOR_TABLE)) {
213
+ this.#ancestorsProbe = this.#db.prepare(`SELECT parent_spr_id, parent_placetype_id, parent_name FROM ${CANDIDATE_ANCESTOR_TABLE}` +
214
+ " WHERE spr_id = ? ORDER BY depth ASC");
215
+ this.ancestors = (id) => this.#ancestorLineage(id);
216
+ }
217
+ // Admin-containment re-rank (#1717 stage 2): conditioned on the interval half of the sidecar (built in
218
+ // the same pass as the closure rows; probed separately so a hand-degraded artifact degrades
219
+ // truthfully) AND on the placetype dictionary carrying the qualifier band at all.
220
+ if (this.#ancestorsProbe && hasTable(this.#db, CANDIDATE_INTERVAL_TABLE)) {
221
+ this.#intervalProbe = this.#db.prepare(`SELECT pre, post FROM ${CANDIDATE_INTERVAL_TABLE} WHERE spr_id = ?`);
222
+ const bandIDs = [...REGION_CLASS_PLACETYPES, "country"]
223
+ .map((placetype) => this.#placetypeToID.get(placetype))
224
+ .filter((id) => id !== undefined);
225
+ if (bandIDs.length) {
226
+ this.#qualifierProbe = this.#db.prepare(`SELECT DISTINCT spr_id FROM candidate WHERE name_key = ? AND placetype_id IN (${bandIDs.join(",")}) LIMIT 8`);
227
+ }
228
+ }
229
+ // Coverage manifest (survey candidate #2): the artifact's own coverage facts, existence-restricted like
206
230
  // the probes above — a candidate.db built before the manifest reads `undefined` and consumers keep
207
231
  // their code-constant fallbacks byte-identically.
208
232
  this.artifactCoverage = readGazetteerCoverageManifest(this.#db);
209
233
  }
234
+ /**
235
+ * The memoized chain read behind {@link ancestors}. Sync raw `.prepare()` on purpose — the backend contract's
236
+ * `ancestors()` is synchronous (the sync-by-interface resolver-reader rule), and the sidecar row already carries the
237
+ * parent's name and placetype, so this is one clustered probe with no join.
238
+ */
239
+ #ancestorLineage(id) {
240
+ const pid = typeof id === "number" ? id : Number(id);
241
+ if (!Number.isFinite(pid) || !this.#ancestorsProbe)
242
+ return [];
243
+ const cached = this.#ancestorsCache.get(pid);
244
+ if (cached)
245
+ return cached;
246
+ const rows = allRows(this.#ancestorsProbe, pid);
247
+ const lineage = rows.map((r) => ({
248
+ id: Number(r.parent_spr_id),
249
+ placetype: this.#idToPlacetype.get(Number(r.parent_placetype_id)) ?? "",
250
+ name: String(r.parent_name ?? ""),
251
+ }));
252
+ this.#ancestorsCache.set(pid, lineage);
253
+ return lineage;
254
+ }
255
+ /**
256
+ * The interval label for one place, memoized. `null` is a real answer — the place has no recorded ancestry in the
257
+ * source (absence semantics: UNVERIFIABLE, never a containment verdict) — and is cached as such.
258
+ */
259
+ #intervalLabel(sprID) {
260
+ if (!this.#intervalProbe)
261
+ return null;
262
+ const cached = this.#intervalCache.get(sprID);
263
+ if (cached !== undefined)
264
+ return cached;
265
+ const row = this.#intervalProbe.get(sprID);
266
+ const label = row ? { pre: Number(row.pre), post: Number(row.post) } : null;
267
+ this.#intervalCache.set(sprID, label);
268
+ return label;
269
+ }
270
+ /**
271
+ * The qualifier's own rows in the candidate table: every region-band (+ country) place whose `name_key` matches one
272
+ * of the qualifier's {@link regionQualifierProbeKeys} expansions. Alias keys participate — `Thüringen` finds the row
273
+ * stored as `Thuringia` through the artifact's own alias keying, which is precisely the variant-form bridge the
274
+ * admin-coherence verdicts' fold-equality bound cannot offer (its stated v1 bound). Empty = the qualifier names
275
+ * nothing the artifact knows; the caller then stamps `false` everywhere and reorders nothing.
276
+ */
277
+ #qualifierRegionIDs(qualifier, country) {
278
+ const ids = new Set();
279
+ if (!this.#qualifierProbe)
280
+ return ids;
281
+ for (const key of regionQualifierProbeKeys(qualifier, country)) {
282
+ if (!key)
283
+ continue;
284
+ for (const row of allRows(this.#qualifierProbe, key)) {
285
+ ids.add(Number(row.spr_id));
286
+ }
287
+ }
288
+ return ids;
289
+ }
290
+ /**
291
+ * Is `sprID` contained by ANY of the qualifier's rows? Interval first — {@link intervalContains}, O(1), reflexive —
292
+ * then the closure rows where intervals abstain: the interval forest encodes only the CANONICAL parent per place, so
293
+ * a `false` there means "not contained along the canonical hierarchy", and the chain probe (one clustered read of
294
+ * ≤{@link MAX_ANCESTOR_DEPTH} rows) is the complete record that determines the result.
295
+ */
296
+ #containedByQualifier(sprID, qualifierIDs, qualifierLabels) {
297
+ if (qualifierIDs.has(sprID))
298
+ return true;
299
+ const label = this.#intervalLabel(sprID);
300
+ if (label && qualifierLabels.some((outer) => intervalContains(outer, label)))
301
+ return true;
302
+ return this.#ancestorLineage(sprID).some((ancestor) => qualifierIDs.has(Number(ancestor.id)));
303
+ }
304
+ /**
305
+ * The #1717 stage-2 re-rank over one lookup's final row set. Three steps, each additive:
306
+ *
307
+ * 1. Resolve the qualifier to its region-band rows ({@link #qualifierRegionIDs}) and stamp every existing row's
308
+ * `containedByQualifier` — the stamp is the trace surface, written even when nothing reorders.
309
+ * 2. INJECT contained same-key candidates the country scope hid: the deciding-site measurement (2026-08-18, the #1729
310
+ * lesson re-confirmed) showed `Weimar, Thüringen` under the en-US locale probes `country_id = US`, so the DE row
311
+ * is not IN the list and no reorder of the list can reach it. The injection probe runs the same exact fold (and,
312
+ * on a contained-miss, the qualifier-strip variant restricted to primary keys — the #1626 alias-scrape guard)
313
+ * under the SHAPE conds only, appends contained rows not already present, and never removes anything — recall can
314
+ * only widen. The typo-fuzzy tier is deliberately not probed: a qualifier cannot vouch for a name the gazetteer
315
+ * does not carry.
316
+ * 3. Partition contained-first — the SHARED {@link partitionByContainment} (tier-safe, stable; the resolver walk runs
317
+ * the same function after its fame re-rank, one function at both deciding sites per the #861 rule) — then
318
+ * re-window to `limit`.
319
+ *
320
+ * A qualifier that matches nothing stamps `false` everywhere and reorders nothing — byte-identical answers, and the
321
+ * walk's verdict reads `no_contained_candidate` rather than `unavailable` (the question WAS asked).
322
+ */
323
+ #applyAdminContainment(rows, qualifier, country, opts) {
324
+ const qualifierIDs = this.#qualifierRegionIDs(qualifier, country);
325
+ if (!qualifierIDs.size) {
326
+ for (const row of rows) {
327
+ row.containedByQualifier = false;
328
+ }
329
+ return rows;
330
+ }
331
+ const qualifierLabels = [...qualifierIDs]
332
+ .map((id) => this.#intervalLabel(id))
333
+ .filter((label) => label !== null);
334
+ const contained = (sprID) => this.#containedByQualifier(sprID, qualifierIDs, qualifierLabels);
335
+ for (const row of rows) {
336
+ row.containedByQualifier = contained(Number(row.spr_id));
337
+ }
338
+ const present = new Set(rows.map((row) => Number(row.spr_id)));
339
+ const injected = [];
340
+ const injectSQL = (primaryOnly) => "SELECT spr_id, name, country_id, placetype_id, latitude, longitude, min_lat, min_lon, max_lat, max_lon, neg_rank, is_primary, population" +
341
+ `${this.#importanceSelect} FROM candidate WHERE ${["name_key = ?", ...opts.shapeFilters, ...(primaryOnly ? ["is_primary = 1"] : [])].join(" AND ")} ` +
342
+ "ORDER BY neg_rank ASC LIMIT ?";
343
+ const injectFrom = (key, primaryOnly) => {
344
+ const fetched = allRows(this.#db.prepare(injectSQL(primaryOnly)), key, ...opts.shapeParams, RERANK_FETCH);
345
+ for (const row of fetched) {
346
+ const sprID = Number(row.spr_id);
347
+ if (present.has(sprID) || !contained(sprID))
348
+ continue;
349
+ present.add(sprID);
350
+ injected.push({ ...row, effectiveNegRank: row.neg_rank, demoted: false, containedByQualifier: true });
351
+ }
352
+ };
353
+ injectFrom(opts.nameKey, false);
354
+ // #1731: the dependent-locality band. A locality query's filter group (locality/borough/localadmin)
355
+ // cannot reach a neighbourhood-tier namesake, so a CONTAINED one is structurally invisible no matter
356
+ // how the list reorders — the Astoria class: Queens' Astoria is a WOF neighbourhood, and the walk
357
+ // answered the Oregon locality under `qualifier="NY"` because nothing in the pool sat under NY. The
358
+ // widening is injection-only and triple-conditioned: the band is explicit (neighbourhood/macrohood/microhood
359
+ // — never region or country tiers), admission still requires the sidecar's containment proof, and only
360
+ // primary-keyed rows enter (an alias-keyed neighbourhood is the #1626 scrape class). Recall can only
361
+ // widen, and only toward rows the qualifier vouches for. `opts.shapeFilters`' bbox clause is
362
+ // deliberately not carried: containment is the stronger constraint, and the two co-occurring is not a
363
+ // measured shape.
364
+ const bandIDs = ["neighbourhood", "macrohood", "microhood"]
365
+ .map((placetype) => this.#placetypeToID.get(placetype))
366
+ .filter((id) => id !== undefined);
367
+ if (bandIDs.length) {
368
+ const bandSQL = "SELECT spr_id, name, country_id, placetype_id, latitude, longitude, min_lat, min_lon, max_lat, max_lon, neg_rank, is_primary, population" +
369
+ `${this.#importanceSelect} FROM candidate WHERE name_key = ? AND placetype_id IN (${bandIDs.map(() => "?").join(",")}) AND is_primary = 1 ` +
370
+ "ORDER BY neg_rank ASC LIMIT ?";
371
+ const fetched = allRows(this.#db.prepare(bandSQL), opts.nameKey, ...bandIDs, RERANK_FETCH);
372
+ for (const row of fetched) {
373
+ const sprID = Number(row.spr_id);
374
+ if (present.has(sprID) || !contained(sprID))
375
+ continue;
376
+ present.add(sprID);
377
+ injected.push({ ...row, effectiveNegRank: row.neg_rank, demoted: false, containedByQualifier: true });
378
+ }
379
+ }
380
+ // The strip variant mirrors the cascade's discipline: tried only when the exact fold vouched for
381
+ // nothing, and primary-keyed only (a stripped surface never named an alias — #1626).
382
+ if (!injected.length &&
383
+ !rows.some((row) => row.containedByQualifier) &&
384
+ opts.strippedKey &&
385
+ opts.strippedKey !== opts.nameKey) {
386
+ injectFrom(opts.strippedKey, true);
387
+ }
388
+ if (!injected.length && !rows.some((row) => row.containedByQualifier))
389
+ return rows;
390
+ return partitionByContainment([...rows, ...injected], (row) => row.containedByQualifier === true, (row) => !row.demoted && !row.fuzzy).slice(0, opts.limit);
391
+ }
210
392
  /**
211
393
  * Does this query want a locality-tier place? Postal-city aliases (#741) are all localities.
212
394
  */
@@ -259,7 +441,7 @@ export class WOFCandidateTableLookup {
259
441
  return [];
260
442
  // #741: postcode-keyed postal-city alias. An exact `(name_key, postcode)` hit resolves a
261
443
  // user-typed POSTAL city ("Antioch", 37013) to the geographic locality the postcode sits in
262
- // ("Nashville"), bypassing the population/region ranking that can't see the postcode. Gated on
444
+ // ("Nashville"), bypassing the population/region ranking that can't see the postcode. Conditioned on
263
445
  // the side-index being present, a postcode in the query, and a locality-tier request — so the
264
446
  // common (no-postcode / non-locality) path is untouched. A hit short-circuits: the postcode is
265
447
  // an exact, high-confidence disambiguator, so we return the single geographic locality.
@@ -281,9 +463,15 @@ export class WOFCandidateTableLookup {
281
463
  }
282
464
  }
283
465
  const limit = Math.max(1, query.limit ?? 10);
284
- // Filter conds shared by the exact-key + strip-fallback probes (everything but name_key).
466
+ // Filter conds shared by the exact-key + strip-fallback probes (everything but name_key). The
467
+ // SHAPE subset (placetype/bbox/primary — everything but the country scope) is kept separately
468
+ // because the admin-containment injection probe (#1717 stage 2) runs under the shape conds
469
+ // WITHOUT the country: bypassing a locale-inferred country scope for a qualifier-vouched
470
+ // candidate is the change's whole point and the one filter injection may cross.
285
471
  const filters = [];
286
472
  const filterParams = [];
473
+ const shapeFilters = [];
474
+ const shapeParams = [];
287
475
  if (query.country) {
288
476
  const cid = this.#countryToID.get(query.country.toUpperCase());
289
477
  if (cid === undefined)
@@ -300,14 +488,32 @@ export class WOFCandidateTableLookup {
300
488
  .filter((v) => v !== undefined);
301
489
  if (!ids.length)
302
490
  return [];
303
- filters.push(`placetype_id IN (${ids.map(() => "?").join(",")})`);
304
- filterParams.push(...ids);
491
+ shapeFilters.push(`placetype_id IN (${ids.map(() => "?").join(",")})`);
492
+ shapeParams.push(...ids);
305
493
  }
306
494
  if (query.bbox) {
307
495
  const b = query.bbox;
308
- filters.push("latitude BETWEEN ? AND ? AND longitude BETWEEN ? AND ?");
309
- filterParams.push(b.minLat, b.maxLat, b.minLon, b.maxLon);
496
+ shapeFilters.push("latitude BETWEEN ? AND ? AND longitude BETWEEN ? AND ?");
497
+ shapeParams.push(b.minLat, b.maxLat, b.minLon, b.maxLon);
310
498
  }
499
+ // The re-reading guard (#1632, the #1626 rationale generalized to the caller): a probe whose surface
500
+ // is a token taken out of a longer classified span never NAMED an alias, so alias-keyed rows must not
501
+ // answer it — 'Savile Row''s token 'Row' resolved Rhu, Scotland (585 km) through the village's
502
+ // historical-name alias key. Whole-input bare probes never set this, keeping the exonym recall the
503
+ // #1546 note protects (Москва's alias rows answer 'Moscow').
504
+ if (query.primaryOnly) {
505
+ shapeFilters.push("is_primary = 1");
506
+ }
507
+ // The role guard (#1730): a probe may refuse abbreviation/gloss alias rows while keeping the
508
+ // role-NULL exonym tier open — the distinction `primaryOnly` cannot express. Degrades to a no-op
509
+ // on an artifact without the column.
510
+ if (query.excludeNameRoles?.length && this.#hasNameRole) {
511
+ shapeFilters.push(`(name_role IS NULL OR name_role NOT IN (${query.excludeNameRoles.map(() => "?").join(",")}))`);
512
+ shapeParams.push(...query.excludeNameRoles);
513
+ }
514
+ // The main-probe conds are country-then-shape, exactly the order they have always been.
515
+ filters.push(...shapeFilters);
516
+ filterParams.push(...shapeParams);
311
517
  // Region scope: when the cascade resolves a region and passes it down as `parentID` (the walk sets
312
518
  // `query.parentID = parentResolved.id`), the candidate build stamps each place's region-tier ancestor
313
519
  // id into `region_id` (build-candidate.ts `regionOf`), and that id equals the resolved region's WOF id
@@ -317,13 +523,18 @@ export class WOFCandidateTableLookup {
317
523
  // country/non-region parent (no `region_id` match), a `region_id=0` row (place with no region
318
524
  // ancestor), or a wrong parent degrades to today's behavior — never worse, recall-safe by construction.
319
525
  const regionParentID = query.parentID || undefined;
320
- const probe = (nk, regionID) => {
526
+ const probe = (nk, regionID, countryID) => {
321
527
  const conds = ["name_key = ?", ...filters];
322
528
  const params = [nk, ...filterParams];
323
529
  if (regionID !== undefined) {
324
530
  conds.push("region_id = ?");
325
531
  params.push(regionID);
326
532
  }
533
+ // #1585: the fuzzy tier's country scope — only the corrected-key probes pass this.
534
+ if (typeof countryID === "number") {
535
+ conds.push("country_id = ?");
536
+ params.push(countryID);
537
+ }
327
538
  // Fetch population-ordered (the clustered-key order — a cheap ordered scan), over-fetching to
328
539
  // RERANK_FETCH so the bounded cross-country primary-preference re-rank (below) can promote the
329
540
  // intended primary even when a cluster of more-populous foreign aliases sits ahead of it. `is_primary`
@@ -337,9 +548,9 @@ export class WOFCandidateTableLookup {
337
548
  // whether the query was bare enough to deserve it. A backend that pre-sorted by fame would apply
338
549
  // it to every lookup, including the qualified addresses the D-rule guard exists to protect.
339
550
  const sql = "SELECT spr_id, name, country_id, placetype_id, latitude, longitude, min_lat, min_lon, max_lat, max_lon, neg_rank, is_primary, population" +
340
- `${this.#importanceSelect} FROM candidate WHERE ${conds.join(" AND ")} ORDER BY neg_rank ASC LIMIT ?`;
341
- const fetched = this.#db.prepare(sql).all(...params, Math.max(limit, RERANK_FETCH));
342
- return rankByPrimaryPreference(fetched, limit);
551
+ `${this.#importanceSelect}${this.#roleSelect} FROM candidate WHERE ${conds.join(" AND ")} ORDER BY neg_rank ASC LIMIT ?`;
552
+ const fetched = allRows(this.#db.prepare(sql), ...params, Math.max(limit, RERANK_FETCH));
553
+ return rankByPrimaryPreference(fetched, limit, undefined, this.#idToPlacetype, this.#variantAliasExemption);
343
554
  };
344
555
  // The exact → qualifier-strip → typo-fuzzy probe cascade, run at a fixed region scope. Region scoping
345
556
  // only tightens an already-population-first pick, so a region MISS re-runs the whole cascade unscoped
@@ -352,7 +563,14 @@ export class WOFCandidateTableLookup {
352
563
  // miss; the cascade's region bbox disambiguates any base-name ambiguity.
353
564
  const strippedKey = normalizeLocalityForKey(stripLocalityQualifier(text));
354
565
  if (strippedKey && strippedKey !== nameKey) {
355
- rows = probe(strippedKey, regionID);
566
+ // #1626: a stripped probe may answer only through a NON-PRIMARY alias key, which is a
567
+ // scrape, not a qualifier match — 'Savile Row' stripped to 'row' resolved Rhu, Scotland
568
+ // (585 km) through the village's historical-name alias. The legitimate qualifier class
569
+ // matches the place's own primary key ('Lenk im Simmental' → the Lenk row keyed 'lenk',
570
+ // is_primary=1), so refusing alias-keyed rows keeps every intended case and kills the
571
+ // scrape. The alias tier remains fully available to EXACT queries — only the stripped
572
+ // RETRY loses it, because the query's own surface never named the alias.
573
+ rows = probe(strippedKey, regionID).filter((r) => r.is_primary === 1);
356
574
  }
357
575
  }
358
576
  // Typo-tolerant fallback (the unified gazetteer's fuzzy mode): an exact + strip miss may be a
@@ -361,7 +579,7 @@ export class WOFCandidateTableLookup {
361
579
  // the same on either backend. The country/placetype/bbox/region filters still apply via `probe`.
362
580
  // Skipped when the index is absent (byte-stable for an older candidate.db).
363
581
  //
364
- // Gate: only when the name doesn't exist in the gazetteer AT ALL (unfiltered). A name that exists
582
+ // Condition: only when the name doesn't exist in the gazetteer AT ALL (unfiltered). A name that exists
365
583
  // but missed under the active country/placetype/bbox filter is a FILTER miss, not a spelling miss
366
584
  // — fuzzing it scrapes an unrelated same-filter place ("Vienna, Austria" misrouted to IT would
367
585
  // pull a tiny Italian name_key near Siena) and masks the cascade's country-agnostic retry that
@@ -371,17 +589,23 @@ export class WOFCandidateTableLookup {
371
589
  // DIFFERENT postcode. The 2026-08-05 Code-Point swap exposed the trap at scale: Northern Ireland's
372
590
  // `BT3 9QQ` (absent — no permissive NI source) trigram-matched Sheffield's `S3 9QQ` (Jaccard 0.4
373
591
  // on {39q, 9qq}) and resolved 200+ km wrong with full confidence. An unknown postcode must abstain.
592
+ // #1585: a locale HINT scopes the typo tier to its country. Only when no hard `country`
593
+ // filter is active (that is already narrower); a scope naming a country the table doesn't
594
+ // carry is a SCOPED-EMPTY — the fuzzy tier abstains rather than falling through worldwide.
595
+ const fuzzyCountryID = !query.country && query.fuzzyCountry ? this.#countryToID.get(query.fuzzyCountry.toUpperCase()) : undefined;
596
+ const fuzzyScopedOut = !query.country && !!query.fuzzyCountry && typeof fuzzyCountryID !== "number";
374
597
  if (!rows.length &&
375
598
  !wantsPostcode &&
599
+ !fuzzyScopedOut &&
376
600
  this.#ftsProbe &&
377
601
  this.#nameKeyExistsProbe &&
378
602
  !this.#nameKeyExistsProbe.get(nameKey)) {
379
603
  const match = ftsTrigramQuery(nameKey);
380
604
  if (match) {
381
- const hits = this.#ftsProbe.all(match, FUZZY_FETCH);
605
+ const hits = allRows(this.#ftsProbe, match, FUZZY_FETCH);
382
606
  const ranked = hits
383
- .map((h) => ({ nk: String(h.name_key), s: trigramJaccard(nameKey, String(h.name_key)) }))
384
- .filter((h) => h.s >= FUZZY_MIN)
607
+ .map((h) => ({ nk: String(h.name_key), s: wordFuzzySimilarity(nameKey, String(h.name_key)) }))
608
+ .filter((h) => h.s >= WORD_FUZZY_MIN)
385
609
  // oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
386
610
  .sort((a, b) => b.s - a.s);
387
611
  const seen = new Set();
@@ -391,7 +615,7 @@ export class WOFCandidateTableLookup {
391
615
  seen.add(h.nk);
392
616
  // #17: stamp the tier. These rows answer a name the gazetteer does not carry, so they are
393
617
  // fuzzy matches and `exactMatch` below must say so — see `RankedRow.fuzzy`.
394
- rows.push(...probe(h.nk, regionID).map((r) => ({ ...r, fuzzy: true })));
618
+ rows.push(...probe(h.nk, regionID, fuzzyCountryID).map((r) => ({ ...r, fuzzy: true })));
395
619
  if (rows.length >= limit)
396
620
  break;
397
621
  }
@@ -401,19 +625,24 @@ export class WOFCandidateTableLookup {
401
625
  return rows;
402
626
  };
403
627
  let rows = cascade(regionParentID);
628
+ // #1731: whether the rows the caller receives came from the UNSCOPED fallback below — the backend's
629
+ // interior check the resolver-side trace (#1721) cannot otherwise see. Stamped onto every returned
630
+ // place, because the re-admission path is exactly where a wrong-instance namesake enters.
631
+ let regionScopeMiss = false;
404
632
  // Region-scope fallback: if scoping to the parent region found nothing across the whole cascade, retry
405
633
  // unscoped so a place with no in-region row (missing ancestry, or a country/non-region parent) still
406
634
  // resolves exactly as it does today. Only when a region scope was actually applied.
407
635
  if (!rows.length && regionParentID !== undefined) {
408
636
  rows = cascade(undefined);
637
+ regionScopeMiss = rows.length > 0;
409
638
  }
410
639
  // Postcode-containment coherence (#31, Mechanism 2): re-rank the rows by proximity to the postcode's
411
640
  // own centroid, so the locality that CONTAINS the postcode wins the name-match tie (the "Paris" that
412
641
  // holds 75001, not the one that holds a 75001-free namesake). The resolver sends this flag on locality
413
642
  // lookups when `ResolveOpts.postcodeContainmentCoherence` is on. Strictly beneath the #741 postal-city
414
643
  // short-circuit above — an exact (name, postcode) hit IS the answer and outranks any re-rank — and after
415
- // the region-scope fallback, so it sees the final row set. Rows within the gate sort by distance first;
416
- // the out-gate tail keeps its original population-first order. No in-gate row, or no postcode row in
644
+ // the region-scope fallback, so it sees the final row set. Rows within the radius sort by distance first;
645
+ // the out-of-radius tail keeps its original population-first order. No in-radius row, or no postcode row in
417
646
  // the candidate table → unchanged (byte-identical to the flag-off path).
418
647
  if (query.postcode &&
419
648
  query.postcodeContainmentCoherence === true &&
@@ -421,37 +650,51 @@ export class WOFCandidateTableLookup {
421
650
  rows.length > 1) {
422
651
  const anchor = this.#postcodeAnchor(query.postcode, query.country);
423
652
  if (anchor) {
424
- const inGate = [];
425
- const outGate = [];
653
+ const insideThreshold = [];
654
+ const outsideThreshold = [];
426
655
  for (const row of rows) {
427
656
  const distanceKm = haversineKm(anchor.lat, anchor.lon, Number(row.latitude), Number(row.longitude));
428
- if (distanceKm <= POSTCODE_CONTAINMENT_GATE_KM) {
429
- inGate.push({ row, distanceKm });
657
+ if (distanceKm <= POSTCODE_CONTAINMENT_THRESHOLD_KM) {
658
+ insideThreshold.push({ row, distanceKm });
430
659
  }
431
660
  else {
432
- outGate.push(row);
661
+ outsideThreshold.push(row);
433
662
  }
434
663
  }
435
- if (inGate.length) {
664
+ if (insideThreshold.length) {
436
665
  // oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
437
- inGate.sort((a, b) => a.distanceKm - b.distanceKm);
438
- rows = [...inGate.map(({ row }) => row), ...outGate];
666
+ insideThreshold.sort((a, b) => a.distanceKm - b.distanceKm);
667
+ rows = [...insideThreshold.map(({ row }) => row), ...outsideThreshold];
439
668
  }
440
669
  }
441
670
  }
671
+ // Admin-containment re-rank (#1717 stage 2): LAST, on the final row set — the qualifier is the
672
+ // address's outermost explicit statement, so its partition outranks the postcode-proximity order
673
+ // above (contained rows keep that order among themselves). Capability-conditioned on the sidecar
674
+ // (`#qualifierProbe`); when the artifact predates it, `regionQualifier` is ignored, no stamp is
675
+ // written, and the resolver walk reports the change `unavailable`.
676
+ if (query.regionQualifier?.trim() && this.#qualifierProbe && this.#wantsLocality(query.placetype)) {
677
+ rows = this.#applyAdminContainment(rows, query.regionQualifier.trim(), query.country, {
678
+ nameKey,
679
+ strippedKey: normalizeLocalityForKey(stripLocalityQualifier(text)),
680
+ shapeFilters,
681
+ shapeParams,
682
+ limit,
683
+ });
684
+ }
442
685
  const candidates = rows.map((row) => {
443
686
  const hasBbox = row.min_lat != null && row.max_lat != null && row.min_lon != null && row.max_lon != null;
444
687
  return {
445
688
  id: Number(row.spr_id),
446
689
  name: String(row.name ?? ""),
447
690
  placetype: (this.#idToPlacetype.get(Number(row.placetype_id)) ?? ""),
448
- // Surfaced so the cascade can country-gate a postcode by the resolved locality (an ambiguous
691
+ // Surfaced so the cascade can country-restrict a postcode by the resolved locality (an ambiguous
449
692
  // international postcode like 10115 = Berlin DE AND New York US must not out-resolve the city).
450
693
  country: this.#idToCountry.get(Number(row.country_id)) ?? "",
451
694
  lat: Number(row.latitude),
452
695
  lon: Number(row.longitude),
453
696
  // `score` stays the RAW population rank (`-neg_rank`) — it feeds the resolver walk's absolute
454
- // `minWinningScore` gate (`resolve.ts`), which must see real prominence, never a penalized value.
697
+ // `minWinningScore` floor (`resolve.ts`), which must see real prominence, never a penalized value.
455
698
  score: -Number(row.neg_rank),
456
699
  // `prominence` carries the bounded cross-country primary preference (the effective, penalty-adjusted
457
700
  // rank). The walk ORDERS candidates by `prominence ?? score` (`resolve.ts`), so this is what makes the
@@ -466,6 +709,16 @@ export class WOFCandidateTableLookup {
466
709
  // EXCEPT a row the typo-corrector produced (`fuzzy`), which by definition answers a name the
467
710
  // gazetteer does not carry (see `RankedRow.fuzzy`).
468
711
  exactMatch: !row.demoted && !row.fuzzy,
712
+ // #1731: emitted ONLY when a region scope was applied, missed, and the unscoped fallback
713
+ // produced this row — the re-admission path. Absence means the question never arose.
714
+ ...(regionScopeMiss ? { regionScopeMiss: true } : {}),
715
+ // #1717 stage 2 — the containment stamp, tri-state: emitted ONLY when the question was
716
+ // asked (a `regionQualifier` query over a sidecar-bearing artifact); its absence is what
717
+ // the resolver walk reports as `unavailable` (meaning-of-zero).
718
+ ...(row.containedByQualifier === undefined ? {} : { containedByQualifier: row.containedByQualifier }),
719
+ // #1893 — the exemption's firing mark, carried only when the ranker actually spared this row
720
+ // the cross-country penalty (see RankedRow.variantExempted).
721
+ ...(row.variantExempted ? { variantAliasExempted: true } : {}),
469
722
  // The two-score split's carry (ROAD_TO_V9 §2). `referential` names the prominence this
470
723
  // backend has always ordered by — `neg_rank` IS `-log10(population + 1)`, so the score and
471
724
  // the sort key are two readings of the same number.
@@ -500,60 +753,15 @@ export class WOFCandidateTableLookup {
500
753
  : {}),
501
754
  };
502
755
  });
503
- // Proximity re-rank (#938): with bias hints (the demo's map viewport / user location), re-sort the
504
- // exact-match candidates by the SAME prominence the FTS server uses (lookup.ts) — population and
505
- // nearness in one additive scale — so an in-view namesake wins a tie without a hard filter. Byte-
506
- // identical to the plain population order when no bias is passed. `score` here is -neg_rank =
507
- // log10(population + 1), so popTerm is the server formula read straight off it. Constants MIRROR
508
- // lookup.ts's DEFAULT_WEIGHTS (biasBoost 4, populationBoost 4, populationScaleLog10 6,
509
- // proximityScaleKm 100) — the #861 server↔demo parity contract; keep them in lockstep.
756
+ // Proximity re-rank (#938) — the shared implementation, so the browser byte-range twin runs the same
757
+ // code rather than the same constants. See proximity-rerank.ts for why that distinction mattered.
510
758
  if (query.bias && query.bias.length) {
511
- const BIAS_BOOST = 4;
512
- const POP_BOOST = 4;
513
- const POP_SCALE_LOG10 = 6;
514
- // SHARPER than lookup.ts's 100 km on purpose: this backend's `score` is log-population ALONE
515
- // (no bm25 document term), so the population signal is weaker relative to the bias and the
516
- // gentle 100 km decay let a 230 km-distant alias-exact township ("Paris Township", OH) edge
517
- // out a global city ("Paris", FR) from a nearby view. A ~30 km scale keeps the boost to
518
- // candidates the user is actually LOOKING at — an in-view namesake still wins (Dublin, OH from
519
- // an Ohio view), a distant one no longer does (Paris stays FR from a Michigan view).
520
- const PROX_SCALE_KM = 30;
521
- const combinedProminence = (c) => {
522
- // Population base is the PENALIZED `prominence` (set above = -effectiveNegRank), not raw `score`, so
523
- // the cross-country primary preference carries into the bias-weighted order too — a coincidental
524
- // foreign alias doesn't ride population back over a primary just because a viewport hint is present.
525
- const popBase = c.prominence ?? c.score;
526
- const popTerm = POP_BOOST * Math.min(1, Math.max(0, popBase) / POP_SCALE_LOG10);
527
- let proxTerm = 0;
528
- if (!(c.lat === 0 && c.lon === 0)) {
529
- for (const b of query.bias) {
530
- const d = haversineKm(b.lat, b.lon, c.lat, c.lon);
531
- const term = (BIAS_BOOST * (b.weight ?? 1)) / (1 + d / PROX_SCALE_KM);
532
- if (term > proxTerm) {
533
- proxTerm = term;
534
- }
535
- }
536
- }
537
- return popTerm + proxTerm;
538
- };
539
- // Persist the combined value into `prominence` so the resolver walk's `prominence ?? score` sort (and any
540
- // other node consumer) honors the bias order — then sort. Stable within equal prominence (preserves the
541
- // population order the B-tree already gave).
542
- candidates
543
- .map((c, i) => {
544
- c.prominence = combinedProminence(c);
545
- return { c, i, p: c.prominence };
546
- })
547
- // oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
548
- .sort((a, b) => b.p - a.p || a.i - b.i)
549
- .forEach((x, j) => (candidates[j] = x.c));
759
+ applyProximityRerank(candidates, query.bias);
550
760
  }
551
761
  return candidates;
552
762
  }
553
- close() {
554
- if (this.#ownsDB) {
555
- this.#db.close();
556
- }
763
+ [Symbol.dispose]() {
764
+ this.#resources[Symbol.dispose]();
557
765
  }
558
766
  }
559
767
  //# sourceMappingURL=candidate-lookup.js.map