@mailwoman/resolver-wof-sqlite 9.1.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 (278) 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 +200 -163
  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 +2 -1
  18. package/candidate-lookup.ts +439 -186
  19. package/candidate-schema.ts +33 -5
  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 +91 -119
  30. package/fst-builder.ts +14 -12
  31. package/fst-freshness.ts +2 -2
  32. package/fts-query.ts +1 -1
  33. package/fts.ts +4 -4
  34. package/geonames-postal.ts +2 -2
  35. package/index.ts +18 -14
  36. package/interpolation.ts +113 -19
  37. package/lookup.ts +110 -591
  38. package/name-score.ts +6 -4
  39. package/out/address-point-interpolation.d.ts.map +1 -1
  40. package/out/address-point-interpolation.js +13 -7
  41. package/out/address-point-interpolation.js.map +1 -1
  42. package/out/address-point-schema.d.ts +16 -6
  43. package/out/address-point-schema.d.ts.map +1 -1
  44. package/out/address-point-schema.js.map +1 -1
  45. package/out/address-point.d.ts.map +1 -1
  46. package/out/address-point.js +70 -14
  47. package/out/address-point.js.map +1 -1
  48. package/out/ancestry.d.ts +2 -2
  49. package/out/ancestry.d.ts.map +1 -1
  50. package/out/ancestry.js +5 -6
  51. package/out/ancestry.js.map +1 -1
  52. package/out/build-candidate.d.ts +75 -0
  53. package/out/build-candidate.d.ts.map +1 -1
  54. package/out/build-candidate.js +120 -122
  55. package/out/build-candidate.js.map +1 -1
  56. package/out/build-slim.d.ts +1 -1
  57. package/out/build-slim.js +3 -3
  58. package/out/build-slim.js.map +1 -1
  59. package/out/candidate/alias-bags.d.ts +17 -0
  60. package/out/candidate/alias-bags.d.ts.map +1 -0
  61. package/out/candidate/alias-bags.js +39 -0
  62. package/out/candidate/alias-bags.js.map +1 -0
  63. package/out/candidate/ancestors-sidecar.d.ts +33 -0
  64. package/out/candidate/ancestors-sidecar.d.ts.map +1 -0
  65. package/out/candidate/ancestors-sidecar.js +140 -0
  66. package/out/candidate/ancestors-sidecar.js.map +1 -0
  67. package/out/candidate/country-display-names.d.ts +35 -0
  68. package/out/candidate/country-display-names.d.ts.map +1 -0
  69. package/out/candidate/country-display-names.js +59 -0
  70. package/out/candidate/country-display-names.js.map +1 -0
  71. package/out/candidate/name-roles.d.ts +55 -0
  72. package/out/candidate/name-roles.d.ts.map +1 -0
  73. package/out/candidate/name-roles.js +165 -0
  74. package/out/candidate/name-roles.js.map +1 -0
  75. package/out/candidate/own-name.d.ts +50 -0
  76. package/out/candidate/own-name.d.ts.map +1 -0
  77. package/out/candidate/own-name.js +132 -0
  78. package/out/candidate/own-name.js.map +1 -0
  79. package/out/candidate/place-attrs.d.ts +43 -0
  80. package/out/candidate/place-attrs.d.ts.map +1 -0
  81. package/out/candidate/place-attrs.js +15 -0
  82. package/out/candidate/place-attrs.js.map +1 -0
  83. package/out/candidate/shard-fold.d.ts +31 -0
  84. package/out/candidate/shard-fold.d.ts.map +1 -0
  85. package/out/candidate/shard-fold.js +104 -0
  86. package/out/candidate/shard-fold.js.map +1 -0
  87. package/out/candidate-ancestors-schema.d.ts +150 -0
  88. package/out/candidate-ancestors-schema.d.ts.map +1 -0
  89. package/out/candidate-ancestors-schema.js +123 -0
  90. package/out/candidate-ancestors-schema.js.map +1 -0
  91. package/out/candidate-fts.d.ts +4 -2
  92. package/out/candidate-fts.d.ts.map +1 -1
  93. package/out/candidate-fts.js +4 -2
  94. package/out/candidate-fts.js.map +1 -1
  95. package/out/candidate-importance.d.ts.map +1 -1
  96. package/out/candidate-importance.js +1 -1
  97. package/out/candidate-importance.js.map +1 -1
  98. package/out/candidate-lookup.d.ts +22 -45
  99. package/out/candidate-lookup.d.ts.map +1 -1
  100. package/out/candidate-lookup.js +340 -135
  101. package/out/candidate-lookup.js.map +1 -1
  102. package/out/candidate-schema.d.ts +30 -6
  103. package/out/candidate-schema.d.ts.map +1 -1
  104. package/out/candidate-schema.js +3 -0
  105. package/out/candidate-schema.js.map +1 -1
  106. package/out/candidate-scoring.d.ts +34 -0
  107. package/out/candidate-scoring.d.ts.map +1 -0
  108. package/out/candidate-scoring.js +200 -0
  109. package/out/candidate-scoring.js.map +1 -0
  110. package/out/capital-schema.d.ts +51 -0
  111. package/out/capital-schema.d.ts.map +1 -0
  112. package/out/capital-schema.js +63 -0
  113. package/out/capital-schema.js.map +1 -0
  114. package/out/capitals.d.ts +69 -0
  115. package/out/capitals.d.ts.map +1 -0
  116. package/out/capitals.js +98 -0
  117. package/out/capitals.js.map +1 -0
  118. package/out/coincident-roles.d.ts +7 -0
  119. package/out/coincident-roles.d.ts.map +1 -1
  120. package/out/coincident-roles.js +42 -8
  121. package/out/coincident-roles.js.map +1 -1
  122. package/out/convention-schema.d.ts +51 -0
  123. package/out/convention-schema.d.ts.map +1 -0
  124. package/out/convention-schema.js +34 -0
  125. package/out/convention-schema.js.map +1 -0
  126. package/out/convention.d.ts +1 -1
  127. package/out/convention.js +2 -2
  128. package/out/coverage-manifest-schema.js +3 -7
  129. package/out/coverage-manifest-schema.js.map +1 -1
  130. package/out/currency-backfill.d.ts +46 -0
  131. package/out/currency-backfill.d.ts.map +1 -0
  132. package/out/currency-backfill.js +180 -0
  133. package/out/currency-backfill.js.map +1 -0
  134. package/out/exact-match.d.ts +25 -0
  135. package/out/exact-match.d.ts.map +1 -0
  136. package/out/exact-match.js +89 -0
  137. package/out/exact-match.js.map +1 -0
  138. package/out/fst-autocomplete.d.ts +11 -11
  139. package/out/fst-autocomplete.d.ts.map +1 -1
  140. package/out/fst-autocomplete.js +82 -99
  141. package/out/fst-autocomplete.js.map +1 -1
  142. package/out/fst-builder.d.ts.map +1 -1
  143. package/out/fst-builder.js +11 -12
  144. package/out/fst-builder.js.map +1 -1
  145. package/out/fst-freshness.d.ts +2 -2
  146. package/out/fst-freshness.js +2 -2
  147. package/out/fts-query.js +1 -1
  148. package/out/fts-query.js.map +1 -1
  149. package/out/fts.d.ts +4 -4
  150. package/out/fts.js +4 -4
  151. package/out/geonames-postal.d.ts +2 -2
  152. package/out/geonames-postal.js +2 -2
  153. package/out/index.d.ts +3 -2
  154. package/out/index.d.ts.map +1 -1
  155. package/out/index.js +2 -2
  156. package/out/index.js.map +1 -1
  157. package/out/interpolation.d.ts +8 -0
  158. package/out/interpolation.d.ts.map +1 -1
  159. package/out/interpolation.js +91 -19
  160. package/out/interpolation.js.map +1 -1
  161. package/out/lookup.d.ts +4 -5
  162. package/out/lookup.d.ts.map +1 -1
  163. package/out/lookup.js +94 -468
  164. package/out/lookup.js.map +1 -1
  165. package/out/name-score.d.ts +0 -10
  166. package/out/name-score.d.ts.map +1 -1
  167. package/out/name-score.js +6 -4
  168. package/out/name-score.js.map +1 -1
  169. package/out/place-importance-schema.d.ts +42 -5
  170. package/out/place-importance-schema.d.ts.map +1 -1
  171. package/out/place-importance-schema.js +54 -8
  172. package/out/place-importance-schema.js.map +1 -1
  173. package/out/poi-lookup.d.ts +1 -1
  174. package/out/poi-lookup.d.ts.map +1 -1
  175. package/out/poi-lookup.js +12 -13
  176. package/out/poi-lookup.js.map +1 -1
  177. package/out/poi-schema.d.ts +7 -3
  178. package/out/poi-schema.d.ts.map +1 -1
  179. package/out/poi-schema.js.map +1 -1
  180. package/out/polygon-schema.d.ts +37 -0
  181. package/out/polygon-schema.d.ts.map +1 -0
  182. package/out/polygon-schema.js +23 -0
  183. package/out/polygon-schema.js.map +1 -0
  184. package/out/postal-city-alias-lookup.d.ts +1 -1
  185. package/out/postal-city-alias-lookup.js +1 -1
  186. package/out/postal-city-candidate-schema.d.ts +2 -1
  187. package/out/postal-city-candidate-schema.d.ts.map +1 -1
  188. package/out/postal-city-candidate-schema.js.map +1 -1
  189. package/out/postcode-point-lookup.d.ts +1 -1
  190. package/out/postcode-point-lookup.js +1 -1
  191. package/out/primary-preference.d.ts +125 -0
  192. package/out/primary-preference.d.ts.map +1 -0
  193. package/out/primary-preference.js +138 -0
  194. package/out/primary-preference.js.map +1 -0
  195. package/out/proximity-rerank.d.ts +77 -0
  196. package/out/proximity-rerank.d.ts.map +1 -0
  197. package/out/proximity-rerank.js +86 -0
  198. package/out/proximity-rerank.js.map +1 -0
  199. package/out/region-keys.d.ts +47 -0
  200. package/out/region-keys.d.ts.map +1 -0
  201. package/out/region-keys.js +121 -0
  202. package/out/region-keys.js.map +1 -0
  203. package/out/reverse.d.ts.map +1 -1
  204. package/out/reverse.js +6 -9
  205. package/out/reverse.js.map +1 -1
  206. package/out/schema.d.ts +1 -1
  207. package/out/search-fetch.d.ts +57 -0
  208. package/out/search-fetch.d.ts.map +1 -0
  209. package/out/search-fetch.js +183 -0
  210. package/out/search-fetch.js.map +1 -0
  211. package/out/sharding.d.ts +3 -3
  212. package/out/sharding.js +1 -1
  213. package/out/sqlite-convention-source.d.ts +1 -1
  214. package/out/sqlite-convention-source.js +1 -1
  215. package/out/sqlite-utils.d.ts +19 -1
  216. package/out/sqlite-utils.d.ts.map +1 -1
  217. package/out/sqlite-utils.js +19 -1
  218. package/out/sqlite-utils.js.map +1 -1
  219. package/out/street-centroid-schema.d.ts +7 -2
  220. package/out/street-centroid-schema.d.ts.map +1 -1
  221. package/out/street-centroid-schema.js.map +1 -1
  222. package/out/street-centroid.d.ts.map +1 -1
  223. package/out/street-centroid.js +7 -7
  224. package/out/street-centroid.js.map +1 -1
  225. package/out/street-normalize.d.ts +82 -9
  226. package/out/street-normalize.d.ts.map +1 -1
  227. package/out/street-normalize.js +175 -9
  228. package/out/street-normalize.js.map +1 -1
  229. package/out/street-segment-schema.d.ts +6 -2
  230. package/out/street-segment-schema.d.ts.map +1 -1
  231. package/out/street-segment-schema.js.map +1 -1
  232. package/out/types.d.ts +35 -1
  233. package/out/types.d.ts.map +1 -1
  234. package/out/unified-schema.d.ts +1 -1
  235. package/out/unified-schema.js +1 -1
  236. package/out/uprn-lookup.d.ts +85 -0
  237. package/out/uprn-lookup.d.ts.map +1 -0
  238. package/out/uprn-lookup.js +152 -0
  239. package/out/uprn-lookup.js.map +1 -0
  240. package/out/uprn-schema.d.ts +93 -0
  241. package/out/uprn-schema.d.ts.map +1 -0
  242. package/out/uprn-schema.js +78 -0
  243. package/out/uprn-schema.js.map +1 -0
  244. package/out/weights-overlay-linker.d.ts +141 -0
  245. package/out/weights-overlay-linker.d.ts.map +1 -0
  246. package/out/weights-overlay-linker.js +259 -0
  247. package/out/weights-overlay-linker.js.map +1 -0
  248. package/package.json +288 -16
  249. package/place-importance-schema.ts +64 -15
  250. package/poi-lookup.ts +12 -13
  251. package/poi-schema.ts +8 -3
  252. package/polygon-schema.ts +47 -0
  253. package/postal-city-alias-lookup.ts +1 -1
  254. package/postal-city-candidate-schema.ts +3 -1
  255. package/postcode-point-lookup.ts +1 -1
  256. package/primary-preference.ts +207 -0
  257. package/proximity-rerank.ts +120 -0
  258. package/region-keys.ts +144 -0
  259. package/reverse.ts +17 -16
  260. package/schema.ts +1 -1
  261. package/search-fetch.ts +256 -0
  262. package/sharding.ts +3 -3
  263. package/sqlite-convention-source.ts +1 -1
  264. package/sqlite-utils.ts +43 -2
  265. package/street-centroid-schema.ts +8 -2
  266. package/street-centroid.ts +13 -8
  267. package/street-normalize.ts +252 -23
  268. package/street-segment-schema.ts +7 -2
  269. package/types.ts +35 -1
  270. package/unified-schema.ts +1 -1
  271. package/uprn-lookup.ts +210 -0
  272. package/uprn-schema.ts +124 -0
  273. package/weights-overlay-linker.ts +377 -0
  274. package/geo.ts +0 -121
  275. package/out/geo.d.ts +0 -74
  276. package/out/geo.d.ts.map +0 -1
  277. package/out/geo.js +0 -71
  278. 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
  *
@@ -25,19 +25,37 @@
25
25
 
26
26
  import { DatabaseSync } from "node:sqlite"
27
27
 
28
- import { expandPlacetypeFilter, type GazetteerArtifactCoverage } from "@mailwoman/resolver"
29
-
28
+ import { jaroWinkler, levenshteinSimilarity } from "@mailwoman/match/comparators"
29
+ import {
30
+ expandPlacetypeFilter,
31
+ partitionByContainment,
32
+ type Ancestor,
33
+ type GazetteerArtifactCoverage,
34
+ } from "@mailwoman/resolver"
35
+ import { haversineKm } from "@mailwoman/spatial"
36
+
37
+ import {
38
+ CANDIDATE_ANCESTOR_TABLE,
39
+ CANDIDATE_INTERVAL_TABLE,
40
+ intervalContains,
41
+ type CandidateAncestorTable,
42
+ type IntervalLabel,
43
+ } from "./candidate-ancestors-schema.ts"
30
44
  import { CANDIDATE_FTS_TABLE } from "./candidate-fts.ts"
31
45
  import type { CandidateTable, CountryCodeTable, PlacetypeCodeTable } from "./candidate-schema.ts"
32
46
  import { readGazetteerCoverageManifest } from "./coverage-manifest-schema.ts"
33
- import { haversineKm } from "./geo.ts"
34
- import { trigramJaccard } from "./lookup.ts"
35
47
  import { referentialFromPopulation } from "./place-importance-schema.ts"
36
48
  import { POSTAL_CITY_CANDIDATE_TABLE, type PostalCityCandidateTable } from "./postal-city-candidate-schema.ts"
37
- import { hasColumn, hasTable } from "./sqlite-utils.ts"
38
- import { normalizeLocalityForKey, stripLocalityQualifier } from "./street-normalize.ts"
49
+ import { rankByPrimaryPreference, type RankedRow, RERANK_FETCH } from "./primary-preference.ts"
50
+ import { applyProximityRerank } from "./proximity-rerank.ts"
51
+ import { REGION_CLASS_PLACETYPES, regionQualifierProbeKeys } from "./region-keys.ts"
52
+ import { allRows, hasColumn, hasTable } from "./sqlite-utils.ts"
53
+ import { type NameKey, normalizeLocalityForKey, stripLocalityQualifier } from "./street-normalize.ts"
39
54
  import type { FindPlaceQuery, PlaceCandidate, PlaceLookup, WOFPlacetype } from "./types.ts"
40
55
 
56
+ export { rankByPrimaryPreference } from "./primary-preference.ts"
57
+ export type { RankedRow } from "./primary-preference.ts"
58
+
41
59
  export interface WOFCandidateTableLookupOpts {
42
60
  /**
43
61
  * Path to a `candidate.db` built by `build-candidate.ts`. Opened read-only.
@@ -47,6 +65,12 @@ export interface WOFCandidateTableLookupOpts {
47
65
  * Pre-opened handle (tests / shared connections). Mutually exclusive with `databasePath`.
48
66
  */
49
67
  database?: DatabaseSync
68
+ /**
69
+ * #1882 opt-in: exempt `name_role = 'variant'` aliases — the holder's own primary name in another orthography,
70
+ * stamped by the build's own-name detector — from the cross-country primary-preference penalty. No-ops on an artifact
71
+ * without the role column. Default OFF (D-rule).
72
+ */
73
+ variantAliasExemption?: boolean
50
74
  }
51
75
 
52
76
  /**
@@ -77,126 +101,37 @@ type CandidateRow = Pick<
77
101
  Partial<Pick<CandidateTable, "importance">>
78
102
 
79
103
  /**
80
- * FTS5-trigram over-fetch before the trigram-Jaccard re-rank, and the minimum similarity to count as a fuzzy hit (below
81
- * it the trigram overlap is noise, e.g. unrelated same-trigram names). Tunable.
104
+ * FTS5-trigram over-fetch before the WORD-LEVEL re-rank. The trigram index stays the candidate GENERATOR (it is what
105
+ * the artifact carries); the scoring moved off trigram-Jaccard on 2026-08-12 (#1614), which the aucklnad receipts
106
+ * falsified as a typo measure: it scored the true transposition correction 'auckland' at 0.333 — below its own 0.34 bar
107
+ * — while 'auckley' scored 0.375 and the 'gore bay' scrape 0.455, because shared generic suffixes count as trigram
108
+ * evidence and transpositions count against it.
82
109
  */
83
110
  const FUZZY_FETCH = 40
84
- const FUZZY_MIN = 0.34
85
111
 
86
112
  /**
87
- * Postcode-containment re-rank gate radius (km) — the SAME value the resolver's country pass measures at
88
- * (`POSTCODE_COUNTRY_COHERENCE_GATE_KM`, resolver/postcode-country-coherence.ts): a locality within this distance of
89
- * the postcode's own centroid counts as "containing" it. One number, two passes — a divergence here would make the two
90
- * mechanisms disagree about what is proximal.
113
+ * Minimum WORD-LEVEL similarity (max of Jaro-Winkler and normalized edit similarity — the `match/comparators`
114
+ * primitives, deliberately WITHOUT `nameSimilarity`'s token-subset floor, which is a person-name rule that would hand
115
+ * 'stanmore bay' to a place named 'Bay') for a fuzzy correction to count. Measured on the #1614 receipts:
116
+ * 'aucklnad'→'auckland' 0.975 (in), →'auckley' 0.868 (in, but outranked), 'stanmore bay'→'gore bay' ~0.70 (out),
117
+ * 'sacremento'→'sacramento' ~0.97 (in).
91
118
  */
92
- const POSTCODE_CONTAINMENT_GATE_KM = 25
119
+ const WORD_FUZZY_MIN = 0.85
93
120
 
94
121
  /**
95
- * Bounded PRIMARY-NAME preference across a CROSS-COUNTRY name collision (the `is_primary` ranking signal).
96
- *
97
- * The raw candidate order is population-first (`neg_rank ASC`) and treats an ALIAS row (a place's alt-name / exonym,
98
- * `is_primary=0`) and a PRIMARY-name row (`is_primary=1`) on equal footing. So a foreign place whose transliterated
99
- * exonym coincidentally normalizes to a query — Changchun CN stores the Turkish exonym "Çançun" (`name_key="cancun"`),
100
- * 4.19 M pop — outranks the PRIMARY-name place the query actually means (Cancún MX, 0.89 M pop). This penalty makes a
101
- * same-key alias have to clear a population MARGIN over a foreign primary before it wins.
102
- *
103
- * It is deliberately NOT a dominant sort key (no `ORDER BY is_primary DESC`, which would make every primary outrank
104
- * every alias and break the alt-names users depend on — "NYC"→New York, "LA"→Los Angeles, "Frisco"→San Francisco). Two
105
- * bounds keep it a soft prior:
106
- *
107
- * 1. **Cross-country only.** The penalty applies to an alias ONLY when the top-population primary sharing the key is in a
108
- * DIFFERENT country. A SAME-country nickname contest (San Francisco's alias "Frisco" vs the primary Frisco, TX —
109
- * both US) is left on pure population, so the legitimate alias still wins.
110
- * 2. **Population-bounded.** The penalty is {@link PRIMARY_PREFERENCE_LOG10} in log10-population units — an alias must be
111
- * at least 10x more populous than the foreign primary to still win. So a genuinely dominant alias keeps winning
112
- * ("Los Angeles" over La, Ghana — gap 1.6; "Las Vegas" over Vegas, Cuba — gap 2.4) while a near-tie coincidental
113
- * collision defers to the primary (Cancún over Changchun — gap 0.7).
114
- */
115
- const PRIMARY_PREFERENCE_LOG10 = 1
116
-
117
- /**
118
- * Over-fetch cap for {@link rankByPrimaryPreference}: the candidate rows for one `name_key` (all same-name places
119
- * worldwide) are re-ranked in-process, so the probe fetches this many (population-ordered) before the re-rank rather
120
- * than the caller's small `limit`, ensuring the intended primary isn't cut below the fold by a cluster of more-populous
121
- * foreign aliases. Bounded and small — a single contiguous B-tree scan.
122
+ * The word-level correction similarity — see {@link WORD_FUZZY_MIN}.
122
123
  */
123
- const RERANK_FETCH = 64
124
-
125
- /**
126
- * A candidate row annotated with the {@link rankByPrimaryPreference} effective rank + the exact-tier demotion flag.
127
- */
128
- export type RankedRow<R> = R & {
129
- /**
130
- * `neg_rank` plus the bounded cross-country alias penalty — the value the row is ORDERED by, and the base the emitted
131
- * `prominence` is derived from (so the resolver walk's `prominence ?? score` sort, `resolve.ts`, agrees with this
132
- * order; the raw `score`/`neg_rank` is left intact for the walk's `minWinningScore` gate).
133
- */
134
- effectiveNegRank: number
135
- /**
136
- * True when this row is a cross-country alias that LOST the bounded population contest to the same-key primary — a
137
- * coincidental foreign exonym (Changchun's "Çançun" for "Cancun"). Such a row is dropped out of the exact-match tier
138
- * (`exactMatch=false`) so the resolver walk's country pin — the model's `anchorPosterior`, which "never crosses the
139
- * exact/partial boundary" (`resolve.ts`) — can't ride a spurious posterior (CN 0.86 for "Cancun") back over the
140
- * primary. Only the LOSING foreign alias is demoted; a dominant alias (Los Angeles over La, Ghana) keeps its exact
141
- * tier, and a same-country nickname (San Francisco's "Frisco") is never touched.
142
- */
143
- demoted: boolean
144
- /**
145
- * True when this row came from the TYPO-CORRECTOR tier — the FTS5-trigram fallback that fires only after the exact
146
- * and qualifier-strip probes both missed. Such a row answers a query the gazetteer does not contain, so it is a fuzzy
147
- * match by construction and must not claim `exactMatch` (#17). Recall is unaffected: the row is still returned, still
148
- * ranked, still resolvable — it just stops asserting a match quality it does not have, which is what the FTS backend
149
- * has always done and what every `exactMatch`-filtering consumer assumed.
150
- */
151
- fuzzy?: boolean
124
+ function wordFuzzySimilarity(a: string, b: string): number {
125
+ return Math.max(jaroWinkler(a, b), levenshteinSimilarity(a, b))
152
126
  }
153
127
 
154
128
  /**
155
- * Bounded cross-country primary-name preference (see {@link PRIMARY_PREFERENCE_LOG10}). Pure + total-ordered so
156
- * `candidate-lookup.test.ts` can exercise it on synthetic rows. `rows` arrive population-ordered (`neg_rank ASC`); an
157
- * alias (`is_primary=0`) is pushed back by `delta` in log10-population units ONLY when the top-population primary
158
- * sharing the key is in a different country, and is `demoted` out of the exact tier when that penalty leaves it BEHIND
159
- * the primary. Returns the top `limit` after the re-rank, each annotated.
129
+ * Postcode-containment re-rank gate radius (km) — the SAME value the resolver's country pass measures at
130
+ * (`POSTCODE_COUNTRY_COHERENCE_GATE_KM`, resolver/postcode-country-coherence.ts): a locality within this distance of
131
+ * the postcode's own centroid counts as "containing" it. One number, two passes — a divergence here would make the two
132
+ * mechanisms disagree about what is proximal.
160
133
  */
161
- export function rankByPrimaryPreference<R extends Pick<CandidateRow, "neg_rank" | "is_primary" | "country_id">>(
162
- rows: readonly R[],
163
- limit: number,
164
- delta = PRIMARY_PREFERENCE_LOG10
165
- ): Array<RankedRow<R>> {
166
- // The primary the alias actually competes with for the top slot: highest population (min neg_rank). Undefined
167
- // when the set has no primary → nothing to prefer, penalty is 0, order stays population-first (today's behavior).
168
- let topPrimary: R | undefined
169
-
170
- for (const r of rows) {
171
- if (r.is_primary === 1 && (topPrimary === undefined || r.neg_rank < topPrimary.neg_rank)) {
172
- topPrimary = r
173
- }
174
- }
175
-
176
- const topCountry = topPrimary?.country_id
177
-
178
- // A cross-country alias (different country than the top primary) is penalized; it is DEMOTED when even after — i.e.
179
- // the penalty leaves its effective rank behind the primary's raw rank (it lost the bounded population contest).
180
- const isCrossCountryAlias = (r: R): boolean =>
181
- topCountry !== undefined && r.is_primary !== 1 && r.country_id !== topCountry
182
-
183
- const annotate = (r: R): RankedRow<R> => {
184
- const penalized = isCrossCountryAlias(r)
185
- const effectiveNegRank = r.neg_rank + (penalized ? delta : 0)
186
-
187
- return { ...r, effectiveNegRank, demoted: penalized && effectiveNegRank > topPrimary!.neg_rank }
188
- }
189
-
190
- return (
191
- rows
192
- .map((r, i) => ({ row: annotate(r), i }))
193
- // Effective rank ASC; ties keep population order, then original index (stable).
194
- // oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
195
- .sort((a, b) => a.row.effectiveNegRank - b.row.effectiveNegRank || a.row.neg_rank - b.row.neg_rank || a.i - b.i)
196
- .slice(0, limit)
197
- .map((x) => x.row)
198
- )
199
- }
134
+ const POSTCODE_CONTAINMENT_GATE_KM = 25
200
135
 
201
136
  /**
202
137
  * Unpadded character-trigrams of `s`, OR'd into an FTS5 trigram MATCH query (each quoted so FTS treats it as a literal
@@ -218,7 +153,7 @@ function ftsTrigramQuery(s: string): string {
218
153
  }
219
154
 
220
155
  /**
221
- * Node {@link PlaceLookup} over `candidate.db`. Drop-in for {@link WOFSqlitePlaceLookup} in `createWOFResolver(backend)`
156
+ * Node {@link PlaceLookup} over `candidate.db`. Drop-in for {@link WOFSQLitePlaceLookup} in `createWOFResolver(backend)`
222
157
  * — same `findPlace` contract, population-first ranking.
223
158
  */
224
159
  export class WOFCandidateTableLookup implements PlaceLookup {
@@ -259,6 +194,48 @@ export class WOFCandidateTableLookup implements PlaceLookup {
259
194
  * into `no such column` on the first keystroke rather than into "no fame signal", which is what it is.
260
195
  */
261
196
  readonly #importanceSelect: string
197
+ /**
198
+ * Whether the artifact carries the #1730 `name_role` column — absent on pre-role builds, where the `excludeNameRoles`
199
+ * filter degrades to a no-op rather than erroring on a missing column.
200
+ */
201
+ readonly #hasNameRole: boolean
202
+ readonly #variantAliasExemption: boolean
203
+ /**
204
+ * `", name_role"` when the artifact carries the column — the probe SELECT rides it so the #1882 exemption can read
205
+ * the stamp off the row; empty on a pre-role build.
206
+ */
207
+ readonly #roleSelect: string
208
+ /**
209
+ * Prepared chain probe over the `candidate_ancestor` sidecar — `undefined` when the artifact predates it.
210
+ */
211
+ readonly #ancestorsProbe: ReturnType<DatabaseSync["prepare"]> | undefined
212
+ readonly #ancestorsCache = new Map<number, Ancestor[]>()
213
+ /**
214
+ * Prepared interval-label probe over `candidate_interval` — `undefined` when the artifact predates the sidecar, which
215
+ * is what makes the admin-containment re-rank (#1717 stage 2) capability-gated: without it,
216
+ * `FindPlaceQuery.regionQualifier` is ignored, no candidate carries a `containedByQualifier` stamp, and the resolver
217
+ * walk reports the lever `unavailable` instead of silently dead.
218
+ */
219
+ readonly #intervalProbe: ReturnType<DatabaseSync["prepare"]> | undefined
220
+ readonly #intervalCache = new Map<number, IntervalLabel | null>()
221
+ /**
222
+ * Prepared qualifier probe: the region-band rows (plus `country` — the region SLOT can hold a mislabeled country
223
+ * name, "Moscow, Russia" parses region="Russia") for one folded qualifier key. `undefined` when the artifact lacks
224
+ * the sidecar or the placetype dictionary lacks the band entirely.
225
+ */
226
+ readonly #qualifierProbe: ReturnType<DatabaseSync["prepare"]> | undefined
227
+ /**
228
+ * The ancestor lineage of a resolved place — nearest-first (locality-tier → county → region → … → country), the same
229
+ * order the FTS backend's `ancestorLineage` serves, read from the `candidate_ancestor` sidecar in one clustered
230
+ * probe. Backs `ResolveOpts.includeAncestors` (#404) on this backend, which is what puts region-class ancestry in
231
+ * front of the admin-coherence check (#1717).
232
+ *
233
+ * A PROPERTY, not a method, and assigned only when the artifact carries the sidecar: capability probes (`typeof
234
+ * backend.ancestors === "function"` — the resolver's gap report) then read the ARTIFACT truthfully. A candidate.db
235
+ * built before the sidecar reports the capability absent instead of presenting a method that answers `[]` for every
236
+ * place, which would be an absence dressed as a negative answer.
237
+ */
238
+ readonly ancestors: ((id: number | string) => Ancestor[]) | undefined
262
239
 
263
240
  constructor(opts: WOFCandidateTableLookupOpts) {
264
241
  if (opts.database) {
@@ -273,15 +250,13 @@ export class WOFCandidateTableLookup implements PlaceLookup {
273
250
 
274
251
  // The code tables are tiny (country/placetype dictionaries) — load them once at construction so
275
252
  // `findPlace` is a single B-tree probe with no dictionary round-trip.
276
- for (const r of this.#db.prepare("SELECT id, code FROM country_codes").all() as unknown as CountryCodeTable[]) {
253
+ for (const r of allRows<CountryCodeTable>(this.#db.prepare("SELECT id, code FROM country_codes"))) {
277
254
  const code = String(r.code).toUpperCase()
278
255
  this.#countryToID.set(code, Number(r.id))
279
256
  this.#idToCountry.set(Number(r.id), code)
280
257
  }
281
258
 
282
- for (const r of this.#db
283
- .prepare("SELECT id, placetype FROM placetype_codes")
284
- .all() as unknown as PlacetypeCodeTable[]) {
259
+ for (const r of allRows<PlacetypeCodeTable>(this.#db.prepare("SELECT id, placetype FROM placetype_codes"))) {
285
260
  this.#placetypeToID.set(String(r.placetype), Number(r.id))
286
261
  this.#idToPlacetype.set(Number(r.id), String(r.placetype))
287
262
  }
@@ -306,6 +281,38 @@ export class WOFCandidateTableLookup implements PlaceLookup {
306
281
 
307
282
  // #28 fame column: probed ONCE here (it runs a PRAGMA, and `findPlace` is per-keystroke hot).
308
283
  this.#importanceSelect = hasColumn(this.#db, "candidate", "importance") ? ", importance" : ""
284
+ this.#hasNameRole = hasColumn(this.#db, "candidate", "name_role")
285
+ this.#variantAliasExemption = opts.variantAliasExemption === true
286
+ this.#roleSelect = this.#hasNameRole ? ", name_role" : ""
287
+
288
+ // Ancestors sidecar (#1717): existence-gated like the probes above, and the CAPABILITY gates with
289
+ // it — see the `ancestors` property doc for why an older artifact must read as "no ancestors()"
290
+ // rather than as a method that answers [] everywhere.
291
+ if (hasTable(this.#db, CANDIDATE_ANCESTOR_TABLE)) {
292
+ this.#ancestorsProbe = this.#db.prepare(
293
+ `SELECT parent_spr_id, parent_placetype_id, parent_name FROM ${CANDIDATE_ANCESTOR_TABLE}` +
294
+ " WHERE spr_id = ? ORDER BY depth ASC"
295
+ )
296
+
297
+ this.ancestors = (id) => this.#ancestorLineage(id)
298
+ }
299
+
300
+ // Admin-containment re-rank (#1717 stage 2): gated on the interval half of the sidecar (built in
301
+ // the same pass as the closure rows; probed separately so a hand-degraded artifact degrades
302
+ // truthfully) AND on the placetype dictionary carrying the qualifier band at all.
303
+ if (this.#ancestorsProbe && hasTable(this.#db, CANDIDATE_INTERVAL_TABLE)) {
304
+ this.#intervalProbe = this.#db.prepare(`SELECT pre, post FROM ${CANDIDATE_INTERVAL_TABLE} WHERE spr_id = ?`)
305
+
306
+ const bandIDs = [...REGION_CLASS_PLACETYPES, "country"]
307
+ .map((placetype) => this.#placetypeToID.get(placetype))
308
+ .filter((id): id is number => id !== undefined)
309
+
310
+ if (bandIDs.length) {
311
+ this.#qualifierProbe = this.#db.prepare(
312
+ `SELECT DISTINCT spr_id FROM candidate WHERE name_key = ? AND placetype_id IN (${bandIDs.join(",")}) LIMIT 8`
313
+ )
314
+ }
315
+ }
309
316
 
310
317
  // Coverage manifest (survey candidate #2): the artifact's own coverage facts, existence-gated like
311
318
  // the probes above — a candidate.db built before the manifest reads `undefined` and consumers keep
@@ -313,6 +320,225 @@ export class WOFCandidateTableLookup implements PlaceLookup {
313
320
  this.artifactCoverage = readGazetteerCoverageManifest(this.#db)
314
321
  }
315
322
 
323
+ /**
324
+ * The memoized chain read behind {@link ancestors}. Sync raw `.prepare()` on purpose — the backend contract's
325
+ * `ancestors()` is synchronous (the sync-by-interface resolver-reader rule), and the sidecar row already carries the
326
+ * parent's name and placetype, so this is one clustered probe with no join.
327
+ */
328
+ #ancestorLineage(id: number | string): Ancestor[] {
329
+ const pid = typeof id === "number" ? id : Number(id)
330
+
331
+ if (!Number.isFinite(pid) || !this.#ancestorsProbe) return []
332
+
333
+ const cached = this.#ancestorsCache.get(pid)
334
+
335
+ if (cached) return cached
336
+
337
+ const rows = allRows<Pick<CandidateAncestorTable, "parent_spr_id" | "parent_placetype_id" | "parent_name">>(
338
+ this.#ancestorsProbe,
339
+ pid
340
+ )
341
+
342
+ const lineage: Ancestor[] = rows.map((r) => ({
343
+ id: Number(r.parent_spr_id),
344
+ placetype: this.#idToPlacetype.get(Number(r.parent_placetype_id)) ?? "",
345
+ name: String(r.parent_name ?? ""),
346
+ }))
347
+
348
+ this.#ancestorsCache.set(pid, lineage)
349
+
350
+ return lineage
351
+ }
352
+
353
+ /**
354
+ * The interval label for one place, memoized. `null` is a real answer — the place has no recorded ancestry in the
355
+ * source (absence semantics: UNVERIFIABLE, never a containment verdict) — and is cached as such.
356
+ */
357
+ #intervalLabel(sprID: number): IntervalLabel | null {
358
+ if (!this.#intervalProbe) return null
359
+
360
+ const cached = this.#intervalCache.get(sprID)
361
+
362
+ if (cached !== undefined) return cached
363
+
364
+ const row = this.#intervalProbe.get(sprID) as { pre: number; post: number } | undefined
365
+ const label = row ? { pre: Number(row.pre), post: Number(row.post) } : null
366
+
367
+ this.#intervalCache.set(sprID, label)
368
+
369
+ return label
370
+ }
371
+
372
+ /**
373
+ * The qualifier's own rows in the candidate table: every region-band (+ country) place whose `name_key` matches one
374
+ * of the qualifier's {@link regionQualifierProbeKeys} expansions. Alias keys participate — `Thüringen` finds the row
375
+ * stored as `Thuringia` through the artifact's own alias keying, which is precisely the variant-form bridge the
376
+ * admin-coherence verdicts' fold-equality bound cannot offer (its stated v1 bound). Empty = the qualifier names
377
+ * nothing the artifact knows; the caller then stamps `false` everywhere and reorders nothing.
378
+ */
379
+ #qualifierRegionIDs(qualifier: string, country: string | undefined): Set<number> {
380
+ const ids = new Set<number>()
381
+
382
+ if (!this.#qualifierProbe) return ids
383
+
384
+ for (const key of regionQualifierProbeKeys(qualifier, country)) {
385
+ if (!key) continue
386
+
387
+ for (const row of allRows<{ spr_id: number }>(this.#qualifierProbe, key)) {
388
+ ids.add(Number(row.spr_id))
389
+ }
390
+ }
391
+
392
+ return ids
393
+ }
394
+
395
+ /**
396
+ * Is `sprID` contained by ANY of the qualifier's rows? Interval first — {@link intervalContains}, O(1), reflexive —
397
+ * then the closure rows where intervals abstain: the interval forest encodes only the CANONICAL parent per place, so
398
+ * a `false` there means "not contained along the canonical hierarchy", and the chain probe (one clustered read of
399
+ * ≤{@link MAX_ANCESTOR_DEPTH} rows) is the complete record that settles it.
400
+ */
401
+ #containedByQualifier(sprID: number, qualifierIDs: ReadonlySet<number>, qualifierLabels: IntervalLabel[]): boolean {
402
+ if (qualifierIDs.has(sprID)) return true
403
+
404
+ const label = this.#intervalLabel(sprID)
405
+
406
+ if (label && qualifierLabels.some((outer) => intervalContains(outer, label))) return true
407
+
408
+ return this.#ancestorLineage(sprID).some((ancestor) => qualifierIDs.has(Number(ancestor.id)))
409
+ }
410
+
411
+ /**
412
+ * The #1717 stage-2 re-rank over one lookup's final row set. Three steps, each additive:
413
+ *
414
+ * 1. Resolve the qualifier to its region-band rows ({@link #qualifierRegionIDs}) and stamp every existing row's
415
+ * `containedByQualifier` — the stamp is the trace surface, written even when nothing reorders.
416
+ * 2. INJECT contained same-key candidates the country scope hid: the deciding-site measurement (2026-08-18, the #1729
417
+ * lesson re-confirmed) showed `Weimar, Thüringen` under the en-US locale probes `country_id = US`, so the DE row
418
+ * is not IN the list and no reorder of the list can reach it. The injection probe runs the same exact fold (and,
419
+ * on a contained-miss, the qualifier-strip variant restricted to primary keys — the #1626 alias-scrape guard)
420
+ * under the SHAPE conds only, appends contained rows not already present, and never removes anything — recall can
421
+ * only widen. The typo-fuzzy tier is deliberately not probed: a qualifier cannot vouch for a name the gazetteer
422
+ * does not carry.
423
+ * 3. Partition contained-first — the SHARED {@link partitionByContainment} (tier-safe, stable; the resolver walk runs
424
+ * the same function after its fame re-rank, one function at both deciding sites per the #861 rule) — then
425
+ * re-window to `limit`.
426
+ *
427
+ * A qualifier that matches nothing stamps `false` everywhere and reorders nothing — byte-identical answers, and the
428
+ * walk's verdict reads `no_contained_candidate` rather than `unavailable` (the question WAS asked).
429
+ */
430
+ #applyAdminContainment(
431
+ rows: Array<RankedRow<CandidateRow>>,
432
+ qualifier: string,
433
+ country: string | undefined,
434
+ opts: {
435
+ nameKey: NameKey
436
+ strippedKey: NameKey
437
+ shapeFilters: string[]
438
+ shapeParams: Array<string | number>
439
+ limit: number
440
+ }
441
+ ): Array<RankedRow<CandidateRow>> {
442
+ const qualifierIDs = this.#qualifierRegionIDs(qualifier, country)
443
+
444
+ if (!qualifierIDs.size) {
445
+ for (const row of rows) {
446
+ row.containedByQualifier = false
447
+ }
448
+
449
+ return rows
450
+ }
451
+
452
+ const qualifierLabels = [...qualifierIDs]
453
+ .map((id) => this.#intervalLabel(id))
454
+ .filter((label): label is IntervalLabel => label !== null)
455
+
456
+ const contained = (sprID: number): boolean => this.#containedByQualifier(sprID, qualifierIDs, qualifierLabels)
457
+
458
+ for (const row of rows) {
459
+ row.containedByQualifier = contained(Number(row.spr_id))
460
+ }
461
+
462
+ const present = new Set(rows.map((row) => Number(row.spr_id)))
463
+ const injected: Array<RankedRow<CandidateRow>> = []
464
+
465
+ const injectSQL = (primaryOnly: boolean): string =>
466
+ "SELECT spr_id, name, country_id, placetype_id, latitude, longitude, min_lat, min_lon, max_lat, max_lon, neg_rank, is_primary, population" +
467
+ `${this.#importanceSelect} FROM candidate WHERE ${["name_key = ?", ...opts.shapeFilters, ...(primaryOnly ? ["is_primary = 1"] : [])].join(" AND ")} ` +
468
+ "ORDER BY neg_rank ASC LIMIT ?"
469
+
470
+ const injectFrom = (key: string, primaryOnly: boolean): void => {
471
+ const fetched = allRows<CandidateRow>(
472
+ this.#db.prepare(injectSQL(primaryOnly)),
473
+ key,
474
+ ...opts.shapeParams,
475
+ RERANK_FETCH
476
+ )
477
+
478
+ for (const row of fetched) {
479
+ const sprID = Number(row.spr_id)
480
+
481
+ if (present.has(sprID) || !contained(sprID)) continue
482
+ present.add(sprID)
483
+
484
+ injected.push({ ...row, effectiveNegRank: row.neg_rank, demoted: false, containedByQualifier: true })
485
+ }
486
+ }
487
+
488
+ injectFrom(opts.nameKey, false)
489
+
490
+ // #1731: the dependent-locality band. A locality query's filter group (locality/borough/localadmin)
491
+ // cannot reach a neighbourhood-tier namesake, so a CONTAINED one is structurally invisible no matter
492
+ // how the list reorders — the Astoria class: Queens' Astoria is a WOF neighbourhood, and the walk
493
+ // answered the Oregon locality under `qualifier="NY"` because nothing in the pool sat under NY. The
494
+ // widening is injection-only and triple-gated: the band is explicit (neighbourhood/macrohood/microhood
495
+ // — never region or country tiers), admission still requires the sidecar's containment proof, and only
496
+ // primary-keyed rows enter (an alias-keyed neighbourhood is the #1626 scrape class). Recall can only
497
+ // widen, and only toward rows the qualifier vouches for. `opts.shapeFilters`' bbox clause is
498
+ // deliberately not carried: containment is the stronger constraint, and the two co-occurring is not a
499
+ // measured shape.
500
+ const bandIDs = ["neighbourhood", "macrohood", "microhood"]
501
+ .map((placetype) => this.#placetypeToID.get(placetype))
502
+ .filter((id): id is number => id !== undefined)
503
+
504
+ if (bandIDs.length) {
505
+ const bandSQL =
506
+ "SELECT spr_id, name, country_id, placetype_id, latitude, longitude, min_lat, min_lon, max_lat, max_lon, neg_rank, is_primary, population" +
507
+ `${this.#importanceSelect} FROM candidate WHERE name_key = ? AND placetype_id IN (${bandIDs.map(() => "?").join(",")}) AND is_primary = 1 ` +
508
+ "ORDER BY neg_rank ASC LIMIT ?"
509
+
510
+ const fetched = allRows<CandidateRow>(this.#db.prepare(bandSQL), opts.nameKey, ...bandIDs, RERANK_FETCH)
511
+
512
+ for (const row of fetched) {
513
+ const sprID = Number(row.spr_id)
514
+
515
+ if (present.has(sprID) || !contained(sprID)) continue
516
+ present.add(sprID)
517
+
518
+ injected.push({ ...row, effectiveNegRank: row.neg_rank, demoted: false, containedByQualifier: true })
519
+ }
520
+ }
521
+
522
+ // The strip variant mirrors the cascade's discipline: tried only when the exact fold vouched for
523
+ // nothing, and primary-keyed only (a stripped surface never named an alias — #1626).
524
+ if (
525
+ !injected.length &&
526
+ !rows.some((row) => row.containedByQualifier) &&
527
+ opts.strippedKey &&
528
+ opts.strippedKey !== opts.nameKey
529
+ ) {
530
+ injectFrom(opts.strippedKey, true)
531
+ }
532
+
533
+ if (!injected.length && !rows.some((row) => row.containedByQualifier)) return rows
534
+
535
+ return partitionByContainment(
536
+ [...rows, ...injected],
537
+ (row) => row.containedByQualifier === true,
538
+ (row) => !row.demoted && !row.fuzzy
539
+ ).slice(0, opts.limit)
540
+ }
541
+
316
542
  /**
317
543
  * Does this query want a locality-tier place? Postal-city aliases (#741) are all localities.
318
544
  */
@@ -402,9 +628,15 @@ export class WOFCandidateTableLookup implements PlaceLookup {
402
628
 
403
629
  const limit = Math.max(1, query.limit ?? 10)
404
630
 
405
- // Filter conds shared by the exact-key + strip-fallback probes (everything but name_key).
631
+ // Filter conds shared by the exact-key + strip-fallback probes (everything but name_key). The
632
+ // SHAPE subset (placetype/bbox/primary — everything but the country scope) is kept separately
633
+ // because the admin-containment injection probe (#1717 stage 2) runs under the shape conds
634
+ // WITHOUT the country: bypassing a locale-inferred country scope for a qualifier-vouched
635
+ // candidate is the lever's whole point, and it is the one filter injection may cross.
406
636
  const filters: string[] = []
407
637
  const filterParams: Array<string | number> = []
638
+ const shapeFilters: string[] = []
639
+ const shapeParams: Array<string | number> = []
408
640
 
409
641
  if (query.country) {
410
642
  const cid = this.#countryToID.get(query.country.toUpperCase())
@@ -424,16 +656,37 @@ export class WOFCandidateTableLookup implements PlaceLookup {
424
656
  .filter((v): v is number => v !== undefined)
425
657
 
426
658
  if (!ids.length) return []
427
- filters.push(`placetype_id IN (${ids.map(() => "?").join(",")})`)
428
- filterParams.push(...ids)
659
+ shapeFilters.push(`placetype_id IN (${ids.map(() => "?").join(",")})`)
660
+ shapeParams.push(...ids)
429
661
  }
430
662
 
431
663
  if (query.bbox) {
432
664
  const b = query.bbox
433
- filters.push("latitude BETWEEN ? AND ? AND longitude BETWEEN ? AND ?")
434
- filterParams.push(b.minLat, b.maxLat, b.minLon, b.maxLon)
665
+ shapeFilters.push("latitude BETWEEN ? AND ? AND longitude BETWEEN ? AND ?")
666
+ shapeParams.push(b.minLat, b.maxLat, b.minLon, b.maxLon)
667
+ }
668
+
669
+ // The re-reading guard (#1632, the #1626 rationale generalized to the caller): a probe whose surface
670
+ // is a token cut out of a longer classified span never NAMED an alias, so alias-keyed rows must not
671
+ // answer it — 'Savile Row''s token 'Row' resolved Rhu, Scotland (585 km) through the village's
672
+ // historical-name alias key. Whole-input bare probes never set this, keeping the exonym recall the
673
+ // #1546 note protects (Москва's alias rows answer 'Moscow').
674
+ if (query.primaryOnly) {
675
+ shapeFilters.push("is_primary = 1")
676
+ }
677
+
678
+ // The role guard (#1730): a probe may refuse abbreviation/gloss alias rows while keeping the
679
+ // role-NULL exonym tier open — the distinction `primaryOnly` cannot express. Degrades to a no-op
680
+ // on an artifact without the column.
681
+ if (query.excludeNameRoles?.length && this.#hasNameRole) {
682
+ shapeFilters.push(`(name_role IS NULL OR name_role NOT IN (${query.excludeNameRoles.map(() => "?").join(",")}))`)
683
+ shapeParams.push(...query.excludeNameRoles)
435
684
  }
436
685
 
686
+ // The main-probe conds are country-then-shape, exactly the order they have always been.
687
+ filters.push(...shapeFilters)
688
+ filterParams.push(...shapeParams)
689
+
437
690
  // Region scope: when the cascade resolves a region and passes it down as `parentID` (the walk sets
438
691
  // `query.parentID = parentResolved.id`), the candidate build stamps each place's region-tier ancestor
439
692
  // id into `region_id` (build-candidate.ts `regionOf`), and that id equals the resolved region's WOF id
@@ -444,7 +697,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
444
697
  // ancestor), or a wrong parent degrades to today's behavior — never worse, recall-safe by construction.
445
698
  const regionParentID = query.parentID || undefined
446
699
 
447
- const probe = (nk: string, regionID: number | undefined): Array<RankedRow<CandidateRow>> => {
700
+ const probe = (nk: string, regionID: number | undefined, countryID?: number): Array<RankedRow<CandidateRow>> => {
448
701
  const conds = ["name_key = ?", ...filters]
449
702
  const params: Array<string | number> = [nk, ...filterParams]
450
703
 
@@ -453,6 +706,12 @@ export class WOFCandidateTableLookup implements PlaceLookup {
453
706
  params.push(regionID)
454
707
  }
455
708
 
709
+ // #1585: the fuzzy tier's country scope — only the corrected-key probes pass this.
710
+ if (typeof countryID === "number") {
711
+ conds.push("country_id = ?")
712
+ params.push(countryID)
713
+ }
714
+
456
715
  // Fetch population-ordered (the clustered-key order — a cheap ordered scan), over-fetching to
457
716
  // RERANK_FETCH so the bounded cross-country primary-preference re-rank (below) can promote the
458
717
  // intended primary even when a cluster of more-populous foreign aliases sits ahead of it. `is_primary`
@@ -467,11 +726,11 @@ export class WOFCandidateTableLookup implements PlaceLookup {
467
726
  // it to every lookup, including the qualified addresses the D-rule guard exists to protect.
468
727
  const sql =
469
728
  "SELECT spr_id, name, country_id, placetype_id, latitude, longitude, min_lat, min_lon, max_lat, max_lon, neg_rank, is_primary, population" +
470
- `${this.#importanceSelect} FROM candidate WHERE ${conds.join(" AND ")} ORDER BY neg_rank ASC LIMIT ?`
729
+ `${this.#importanceSelect}${this.#roleSelect} FROM candidate WHERE ${conds.join(" AND ")} ORDER BY neg_rank ASC LIMIT ?`
471
730
 
472
- const fetched = this.#db.prepare(sql).all(...params, Math.max(limit, RERANK_FETCH)) as unknown as CandidateRow[]
731
+ const fetched = allRows<CandidateRow>(this.#db.prepare(sql), ...params, Math.max(limit, RERANK_FETCH))
473
732
 
474
- return rankByPrimaryPreference(fetched, limit)
733
+ return rankByPrimaryPreference(fetched, limit, undefined, this.#idToPlacetype, this.#variantAliasExemption)
475
734
  }
476
735
 
477
736
  // The exact → qualifier-strip → typo-fuzzy probe cascade, run at a fixed region scope. Region scoping
@@ -487,7 +746,14 @@ export class WOFCandidateTableLookup implements PlaceLookup {
487
746
  const strippedKey = normalizeLocalityForKey(stripLocalityQualifier(text))
488
747
 
489
748
  if (strippedKey && strippedKey !== nameKey) {
490
- rows = probe(strippedKey, regionID)
749
+ // #1626: a stripped probe may answer only through a NON-PRIMARY alias key, which is a
750
+ // scrape, not a qualifier match — 'Savile Row' stripped to 'row' resolved Rhu, Scotland
751
+ // (585 km) through the village's historical-name alias. The legitimate qualifier class
752
+ // matches the place's own primary key ('Lenk im Simmental' → the Lenk row keyed 'lenk',
753
+ // is_primary=1), so refusing alias-keyed rows keeps every intended case and kills the
754
+ // scrape. The alias tier remains fully available to EXACT queries — only the stripped
755
+ // RETRY loses it, because the query's own surface never named the alias.
756
+ rows = probe(strippedKey, regionID).filter((r) => r.is_primary === 1)
491
757
  }
492
758
  }
493
759
 
@@ -507,9 +773,18 @@ export class WOFCandidateTableLookup implements PlaceLookup {
507
773
  // DIFFERENT postcode. The 2026-08-05 Code-Point swap exposed the trap at scale: Northern Ireland's
508
774
  // `BT3 9QQ` (absent — no permissive NI source) trigram-matched Sheffield's `S3 9QQ` (Jaccard 0.4
509
775
  // on {39q, 9qq}) and resolved 200+ km wrong with full confidence. An unknown postcode must abstain.
776
+ // #1585: a locale HINT scopes the typo tier to its country. Only when no hard `country`
777
+ // filter is active (that is already narrower); a scope naming a country the table doesn't
778
+ // carry is a SCOPED-EMPTY — the fuzzy tier abstains rather than falling through worldwide.
779
+ const fuzzyCountryID =
780
+ !query.country && query.fuzzyCountry ? this.#countryToID.get(query.fuzzyCountry.toUpperCase()) : undefined
781
+
782
+ const fuzzyScopedOut = !query.country && !!query.fuzzyCountry && typeof fuzzyCountryID !== "number"
783
+
510
784
  if (
511
785
  !rows.length &&
512
786
  !wantsPostcode &&
787
+ !fuzzyScopedOut &&
513
788
  this.#ftsProbe &&
514
789
  this.#nameKeyExistsProbe &&
515
790
  !this.#nameKeyExistsProbe.get(nameKey)
@@ -517,11 +792,11 @@ export class WOFCandidateTableLookup implements PlaceLookup {
517
792
  const match = ftsTrigramQuery(nameKey)
518
793
 
519
794
  if (match) {
520
- const hits = this.#ftsProbe.all(match, FUZZY_FETCH) as unknown as Array<{ name_key: string }>
795
+ const hits = allRows<{ name_key: string }>(this.#ftsProbe, match, FUZZY_FETCH)
521
796
 
522
797
  const ranked = hits
523
- .map((h) => ({ nk: String(h.name_key), s: trigramJaccard(nameKey, String(h.name_key)) }))
524
- .filter((h) => h.s >= FUZZY_MIN)
798
+ .map((h) => ({ nk: String(h.name_key), s: wordFuzzySimilarity(nameKey, String(h.name_key)) }))
799
+ .filter((h) => h.s >= WORD_FUZZY_MIN)
525
800
  // oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
526
801
  .sort((a, b) => b.s - a.s)
527
802
 
@@ -532,7 +807,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
532
807
  seen.add(h.nk)
533
808
  // #17: stamp the tier. These rows answer a name the gazetteer does not carry, so they are
534
809
  // fuzzy matches and `exactMatch` below must say so — see `RankedRow.fuzzy`.
535
- rows.push(...probe(h.nk, regionID).map((r) => ({ ...r, fuzzy: true })))
810
+ rows.push(...probe(h.nk, regionID, fuzzyCountryID).map((r) => ({ ...r, fuzzy: true })))
536
811
 
537
812
  if (rows.length >= limit) break
538
813
  }
@@ -545,12 +820,17 @@ export class WOFCandidateTableLookup implements PlaceLookup {
545
820
  }
546
821
 
547
822
  let rows = cascade(regionParentID)
823
+ // #1731: whether the rows the caller receives came from the UNSCOPED fallback below — the backend's
824
+ // interior gate the resolver-side trace (#1721) cannot otherwise see. Stamped onto every returned
825
+ // place, because the re-admission path is exactly where a wrong-instance namesake enters.
826
+ let regionScopeMiss = false
548
827
 
549
828
  // Region-scope fallback: if scoping to the parent region found nothing across the whole cascade, retry
550
829
  // unscoped so a place with no in-region row (missing ancestry, or a country/non-region parent) still
551
830
  // resolves exactly as it does today. Only when a region scope was actually applied.
552
831
  if (!rows.length && regionParentID !== undefined) {
553
832
  rows = cascade(undefined)
833
+ regionScopeMiss = rows.length > 0
554
834
  }
555
835
 
556
836
  // Postcode-containment coherence (#31, Mechanism 2): re-rank the rows by proximity to the postcode's
@@ -591,6 +871,21 @@ export class WOFCandidateTableLookup implements PlaceLookup {
591
871
  }
592
872
  }
593
873
 
874
+ // Admin-containment re-rank (#1717 stage 2): LAST, on the final row set — the qualifier is the
875
+ // address's outermost explicit statement, so its partition outranks the postcode-proximity order
876
+ // above (contained rows keep that order among themselves). Capability-gated on the sidecar
877
+ // (`#qualifierProbe`); when the artifact predates it, `regionQualifier` is ignored, no stamp is
878
+ // written, and the resolver walk reports the lever `unavailable`.
879
+ if (query.regionQualifier?.trim() && this.#qualifierProbe && this.#wantsLocality(query.placetype)) {
880
+ rows = this.#applyAdminContainment(rows, query.regionQualifier.trim(), query.country, {
881
+ nameKey,
882
+ strippedKey: normalizeLocalityForKey(stripLocalityQualifier(text)),
883
+ shapeFilters,
884
+ shapeParams,
885
+ limit,
886
+ })
887
+ }
888
+
594
889
  const candidates = rows.map((row): PlaceCandidate => {
595
890
  const hasBbox = row.min_lat != null && row.max_lat != null && row.min_lon != null && row.max_lon != null
596
891
 
@@ -619,6 +914,13 @@ export class WOFCandidateTableLookup implements PlaceLookup {
619
914
  // EXCEPT a row the typo-corrector produced (`fuzzy`), which by definition answers a name the
620
915
  // gazetteer does not carry (see `RankedRow.fuzzy`).
621
916
  exactMatch: !row.demoted && !row.fuzzy,
917
+ // #1731: emitted ONLY when a region scope was applied, missed, and the unscoped fallback
918
+ // produced this row — the re-admission path. Absence means the question never arose.
919
+ ...(regionScopeMiss ? { regionScopeMiss: true } : {}),
920
+ // #1717 stage 2 — the containment stamp, tri-state: emitted ONLY when the question was
921
+ // asked (a `regionQualifier` query over a sidecar-bearing artifact); its absence is what
922
+ // the resolver walk reports as `unavailable` (meaning-of-zero).
923
+ ...(row.containedByQualifier === undefined ? {} : { containedByQualifier: row.containedByQualifier }),
622
924
  // The two-score split's carry (ROAD_TO_V9 §2). `referential` names the prominence this
623
925
  // backend has always ordered by — `neg_rank` IS `-log10(population + 1)`, so the score and
624
926
  // the sort key are two readings of the same number.
@@ -654,59 +956,10 @@ export class WOFCandidateTableLookup implements PlaceLookup {
654
956
  }
655
957
  })
656
958
 
657
- // Proximity re-rank (#938): with bias hints (the demo's map viewport / user location), re-sort the
658
- // exact-match candidates by the SAME prominence the FTS server uses (lookup.ts) — population and
659
- // nearness in one additive scale — so an in-view namesake wins a tie without a hard filter. Byte-
660
- // identical to the plain population order when no bias is passed. `score` here is -neg_rank =
661
- // log10(population + 1), so popTerm is the server formula read straight off it. Constants MIRROR
662
- // lookup.ts's DEFAULT_WEIGHTS (biasBoost 4, populationBoost 4, populationScaleLog10 6,
663
- // proximityScaleKm 100) — the #861 server↔demo parity contract; keep them in lockstep.
959
+ // Proximity re-rank (#938) — the shared implementation, so the browser byte-range twin runs the same
960
+ // code rather than the same constants. See proximity-rerank.ts for why that distinction mattered.
664
961
  if (query.bias && query.bias.length) {
665
- const BIAS_BOOST = 4
666
- const POP_BOOST = 4
667
- const POP_SCALE_LOG10 = 6
668
- // SHARPER than lookup.ts's 100 km on purpose: this backend's `score` is log-population ALONE
669
- // (no bm25 document term), so the population signal is weaker relative to the bias and the
670
- // gentle 100 km decay let a 230 km-distant alias-exact township ("Paris Township", OH) edge
671
- // out a global city ("Paris", FR) from a nearby view. A ~30 km scale keeps the boost to
672
- // candidates the user is actually LOOKING at — an in-view namesake still wins (Dublin, OH from
673
- // an Ohio view), a distant one no longer does (Paris stays FR from a Michigan view).
674
- const PROX_SCALE_KM = 30
675
-
676
- const combinedProminence = (c: PlaceCandidate): number => {
677
- // Population base is the PENALIZED `prominence` (set above = -effectiveNegRank), not raw `score`, so
678
- // the cross-country primary preference carries into the bias-weighted order too — a coincidental
679
- // foreign alias doesn't ride population back over a primary just because a viewport hint is present.
680
- const popBase = c.prominence ?? c.score
681
- const popTerm = POP_BOOST * Math.min(1, Math.max(0, popBase) / POP_SCALE_LOG10)
682
- let proxTerm = 0
683
-
684
- if (!(c.lat === 0 && c.lon === 0)) {
685
- for (const b of query.bias!) {
686
- const d = haversineKm(b.lat, b.lon, c.lat, c.lon)
687
- const term = (BIAS_BOOST * (b.weight ?? 1)) / (1 + d / PROX_SCALE_KM)
688
-
689
- if (term > proxTerm) {
690
- proxTerm = term
691
- }
692
- }
693
- }
694
-
695
- return popTerm + proxTerm
696
- }
697
-
698
- // Persist the combined value into `prominence` so the resolver walk's `prominence ?? score` sort (and any
699
- // other node consumer) honors the bias order — then sort. Stable within equal prominence (preserves the
700
- // population order the B-tree already gave).
701
- candidates
702
- .map((c, i) => {
703
- c.prominence = combinedProminence(c)
704
-
705
- return { c, i, p: c.prominence }
706
- })
707
- // oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
708
- .sort((a, b) => b.p - a.p || a.i - b.i)
709
- .forEach((x, j) => (candidates[j] = x.c))
962
+ applyProximityRerank(candidates, query.bias)
710
963
  }
711
964
 
712
965
  return candidates