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