@mailwoman/resolver-wof-sqlite 10.0.0 → 10.2.0

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