@mailwoman/resolver-wof-sqlite 8.0.0 → 8.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 (219) hide show
  1. package/address-point-interpolation.ts +9 -3
  2. package/address-point-schema.ts +32 -10
  3. package/address-point.ts +3 -0
  4. package/ancestry-backfill.ts +18 -5
  5. package/ancestry.ts +7 -2
  6. package/build-candidate.ts +29 -6
  7. package/build-slim.ts +40 -11
  8. package/candidate-fts.ts +1 -0
  9. package/candidate-lookup.ts +49 -15
  10. package/candidate-schema.ts +35 -11
  11. package/coincident-roles.ts +28 -6
  12. package/convention.ts +3 -1
  13. package/coverage-manifest-schema.ts +243 -0
  14. package/fst-autocomplete.ts +15 -9
  15. package/fst-builder.ts +65 -9
  16. package/fst-deserialize-web.ts +46 -9
  17. package/fst-matcher.ts +12 -5
  18. package/fst-serialize.ts +83 -9
  19. package/fst-types.ts +43 -0
  20. package/fts-query.ts +84 -0
  21. package/fts.ts +35 -9
  22. package/geo.ts +9 -3
  23. package/geonames-aliases.ts +116 -79
  24. package/geonames-postal.ts +25 -5
  25. package/index.ts +19 -0
  26. package/interpolation.ts +59 -55
  27. package/lookup.ts +103 -292
  28. package/name-score.ts +76 -0
  29. package/out/address-point-interpolation.d.ts.map +1 -1
  30. package/out/address-point-interpolation.js +4 -2
  31. package/out/address-point-interpolation.js.map +1 -1
  32. package/out/address-point-schema.d.ts +30 -10
  33. package/out/address-point-schema.d.ts.map +1 -1
  34. package/out/address-point-schema.js +6 -2
  35. package/out/address-point-schema.js.map +1 -1
  36. package/out/address-point.d.ts.map +1 -1
  37. package/out/address-point.js.map +1 -1
  38. package/out/ancestry-backfill.d.ts +6 -2
  39. package/out/ancestry-backfill.d.ts.map +1 -1
  40. package/out/ancestry-backfill.js +7 -3
  41. package/out/ancestry-backfill.js.map +1 -1
  42. package/out/ancestry.d.ts +6 -2
  43. package/out/ancestry.d.ts.map +1 -1
  44. package/out/ancestry.js +3 -1
  45. package/out/ancestry.js.map +1 -1
  46. package/out/build-candidate.d.ts +9 -3
  47. package/out/build-candidate.d.ts.map +1 -1
  48. package/out/build-candidate.js +5 -3
  49. package/out/build-candidate.js.map +1 -1
  50. package/out/build-slim.d.ts +15 -5
  51. package/out/build-slim.d.ts.map +1 -1
  52. package/out/build-slim.js +11 -5
  53. package/out/build-slim.js.map +1 -1
  54. package/out/candidate-fts.d.ts.map +1 -1
  55. package/out/candidate-fts.js.map +1 -1
  56. package/out/candidate-lookup.d.ts +16 -3
  57. package/out/candidate-lookup.d.ts.map +1 -1
  58. package/out/candidate-lookup.js +27 -11
  59. package/out/candidate-lookup.js.map +1 -1
  60. package/out/candidate-schema.d.ts +33 -11
  61. package/out/candidate-schema.d.ts.map +1 -1
  62. package/out/candidate-schema.js.map +1 -1
  63. package/out/coincident-roles.d.ts +16 -4
  64. package/out/coincident-roles.d.ts.map +1 -1
  65. package/out/coincident-roles.js +9 -3
  66. package/out/coincident-roles.js.map +1 -1
  67. package/out/convention.d.ts +3 -1
  68. package/out/convention.d.ts.map +1 -1
  69. package/out/convention.js.map +1 -1
  70. package/out/coverage-manifest-schema.d.ts +112 -0
  71. package/out/coverage-manifest-schema.d.ts.map +1 -0
  72. package/out/coverage-manifest-schema.js +154 -0
  73. package/out/coverage-manifest-schema.js.map +1 -0
  74. package/out/fst-autocomplete.d.ts +1 -1
  75. package/out/fst-autocomplete.d.ts.map +1 -1
  76. package/out/fst-autocomplete.js +11 -9
  77. package/out/fst-autocomplete.js.map +1 -1
  78. package/out/fst-builder.d.ts.map +1 -1
  79. package/out/fst-builder.js +42 -9
  80. package/out/fst-builder.js.map +1 -1
  81. package/out/fst-deserialize-web.d.ts.map +1 -1
  82. package/out/fst-deserialize-web.js +34 -9
  83. package/out/fst-deserialize-web.js.map +1 -1
  84. package/out/fst-matcher.d.ts +6 -2
  85. package/out/fst-matcher.d.ts.map +1 -1
  86. package/out/fst-matcher.js +9 -5
  87. package/out/fst-matcher.js.map +1 -1
  88. package/out/fst-serialize.d.ts.map +1 -1
  89. package/out/fst-serialize.js +62 -9
  90. package/out/fst-serialize.js.map +1 -1
  91. package/out/fst-types.d.ts +43 -0
  92. package/out/fst-types.d.ts.map +1 -1
  93. package/out/fts-query.d.ts +41 -0
  94. package/out/fts-query.d.ts.map +1 -0
  95. package/out/fts-query.js +75 -0
  96. package/out/fts-query.js.map +1 -0
  97. package/out/fts.d.ts +21 -7
  98. package/out/fts.d.ts.map +1 -1
  99. package/out/fts.js +10 -4
  100. package/out/fts.js.map +1 -1
  101. package/out/geo.d.ts +6 -2
  102. package/out/geo.d.ts.map +1 -1
  103. package/out/geo.js +3 -1
  104. package/out/geo.js.map +1 -1
  105. package/out/geonames-aliases.d.ts +12 -4
  106. package/out/geonames-aliases.d.ts.map +1 -1
  107. package/out/geonames-aliases.js +72 -67
  108. package/out/geonames-aliases.js.map +1 -1
  109. package/out/geonames-postal.d.ts +9 -3
  110. package/out/geonames-postal.d.ts.map +1 -1
  111. package/out/geonames-postal.js +7 -2
  112. package/out/geonames-postal.js.map +1 -1
  113. package/out/index.d.ts +2 -0
  114. package/out/index.d.ts.map +1 -1
  115. package/out/index.js +1 -0
  116. package/out/index.js.map +1 -1
  117. package/out/interpolation.d.ts +24 -6
  118. package/out/interpolation.d.ts.map +1 -1
  119. package/out/interpolation.js +32 -40
  120. package/out/interpolation.js.map +1 -1
  121. package/out/lookup.d.ts +3 -97
  122. package/out/lookup.d.ts.map +1 -1
  123. package/out/lookup.js +52 -184
  124. package/out/lookup.js.map +1 -1
  125. package/out/name-score.d.ts +28 -0
  126. package/out/name-score.d.ts.map +1 -0
  127. package/out/name-score.js +67 -0
  128. package/out/name-score.js.map +1 -0
  129. package/out/poi-lookup.d.ts +24 -8
  130. package/out/poi-lookup.d.ts.map +1 -1
  131. package/out/poi-lookup.js +27 -13
  132. package/out/poi-lookup.js.map +1 -1
  133. package/out/poi-schema.d.ts +42 -13
  134. package/out/poi-schema.d.ts.map +1 -1
  135. package/out/poi-schema.js +12 -3
  136. package/out/poi-schema.js.map +1 -1
  137. package/out/postal-city-alias-lookup.d.ts +18 -6
  138. package/out/postal-city-alias-lookup.d.ts.map +1 -1
  139. package/out/postal-city-alias-lookup.js.map +1 -1
  140. package/out/postal-city-alias-schema.d.ts +27 -9
  141. package/out/postal-city-alias-schema.d.ts.map +1 -1
  142. package/out/postal-city-alias-schema.js +3 -1
  143. package/out/postal-city-alias-schema.js.map +1 -1
  144. package/out/postal-city-candidate-schema.d.ts +15 -5
  145. package/out/postal-city-candidate-schema.d.ts.map +1 -1
  146. package/out/postal-city-candidate-schema.js +3 -1
  147. package/out/postal-city-candidate-schema.js.map +1 -1
  148. package/out/postcode-point-lookup.d.ts +6 -2
  149. package/out/postcode-point-lookup.d.ts.map +1 -1
  150. package/out/postcode-point-lookup.js +6 -2
  151. package/out/postcode-point-lookup.js.map +1 -1
  152. package/out/ranking-weights.d.ts +118 -0
  153. package/out/ranking-weights.d.ts.map +1 -0
  154. package/out/ranking-weights.js +44 -0
  155. package/out/ranking-weights.js.map +1 -0
  156. package/out/reverse.d.ts +9 -3
  157. package/out/reverse.d.ts.map +1 -1
  158. package/out/reverse.js +20 -6
  159. package/out/reverse.js.map +1 -1
  160. package/out/sharding.d.ts +3 -1
  161. package/out/sharding.d.ts.map +1 -1
  162. package/out/sharding.js +7 -5
  163. package/out/sharding.js.map +1 -1
  164. package/out/sqlite-convention-source.d.ts.map +1 -1
  165. package/out/sqlite-convention-source.js +3 -1
  166. package/out/sqlite-convention-source.js.map +1 -1
  167. package/out/street-centroid-schema.d.ts +33 -11
  168. package/out/street-centroid-schema.d.ts.map +1 -1
  169. package/out/street-centroid-schema.js +3 -1
  170. package/out/street-centroid-schema.js.map +1 -1
  171. package/out/street-centroid.d.ts.map +1 -1
  172. package/out/street-centroid.js +6 -2
  173. package/out/street-centroid.js.map +1 -1
  174. package/out/street-morphology-fst-builder.d.ts +6 -2
  175. package/out/street-morphology-fst-builder.d.ts.map +1 -1
  176. package/out/street-morphology-fst-builder.js +8 -7
  177. package/out/street-morphology-fst-builder.js.map +1 -1
  178. package/out/street-morphology-fst-loader.d.ts +67 -0
  179. package/out/street-morphology-fst-loader.d.ts.map +1 -0
  180. package/out/street-morphology-fst-loader.js +59 -0
  181. package/out/street-morphology-fst-loader.js.map +1 -0
  182. package/out/street-name-lookup.d.ts +9 -3
  183. package/out/street-name-lookup.d.ts.map +1 -1
  184. package/out/street-name-lookup.js +9 -7
  185. package/out/street-name-lookup.js.map +1 -1
  186. package/out/street-normalize.d.ts +3 -1
  187. package/out/street-normalize.d.ts.map +1 -1
  188. package/out/street-normalize.js +23 -13
  189. package/out/street-normalize.js.map +1 -1
  190. package/out/street-segment-schema.d.ts +68 -13
  191. package/out/street-segment-schema.d.ts.map +1 -1
  192. package/out/street-segment-schema.js +21 -2
  193. package/out/street-segment-schema.js.map +1 -1
  194. package/out/types.d.ts +18 -6
  195. package/out/types.d.ts.map +1 -1
  196. package/out/unified-schema.d.ts +1 -1
  197. package/out/unified-schema.d.ts.map +1 -1
  198. package/out/unified-schema.js +2 -2
  199. package/out/unified-schema.js.map +1 -1
  200. package/package.json +13 -5
  201. package/poi-lookup.ts +53 -21
  202. package/poi-schema.ts +43 -13
  203. package/postal-city-alias-lookup.ts +20 -6
  204. package/postal-city-alias-schema.ts +28 -9
  205. package/postal-city-candidate-schema.ts +15 -5
  206. package/postcode-point-lookup.ts +6 -2
  207. package/ranking-weights.ts +148 -0
  208. package/reverse.ts +47 -10
  209. package/sharding.ts +13 -6
  210. package/sqlite-convention-source.ts +4 -1
  211. package/street-centroid-schema.ts +35 -11
  212. package/street-centroid.ts +10 -3
  213. package/street-morphology-fst-builder.ts +25 -9
  214. package/street-morphology-fst-loader.ts +103 -0
  215. package/street-name-lookup.ts +19 -7
  216. package/street-normalize.ts +28 -13
  217. package/street-segment-schema.ts +83 -13
  218. package/types.ts +18 -6
  219. package/unified-schema.ts +11 -2
package/lookup.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  type ResolvedConvention,
28
28
  type Strategy,
29
29
  } from "./convention.ts"
30
+ import { normalizePlacetypes, sanitizeFTSQuery } from "./fts-query.ts"
30
31
  import {
31
32
  aliasBagExactMatch,
32
33
  buildPlaceSearchFTS,
@@ -37,7 +38,9 @@ import {
37
38
  placeSearchFTSExists,
38
39
  } from "./fts.ts"
39
40
  import { bboxAround, haversineKm } from "./geo.ts"
41
+ import { cfNormalize, softNameScore, trigramJaccard } from "./name-score.ts"
40
42
  import type { WOFPostalCityAliasLookup } from "./postal-city-alias-lookup.ts"
43
+ import { DEFAULT_WEIGHTS, type RankingWeights } from "./ranking-weights.ts"
41
44
  import type { WOFDatabase } from "./schema.ts"
42
45
  import {
43
46
  pickShardForPlacetype,
@@ -49,6 +52,13 @@ import {
49
52
  import { SqliteConventionSource } from "./sqlite-convention-source.ts"
50
53
  import type { FindPlaceQuery, PlaceCandidate, PlaceLookup, WOFPlacetype } from "./types.ts"
51
54
 
55
+ /**
56
+ * Query length at or below which the FTS window is widened. A two- or three-character query is almost always a region
57
+ * abbreviation, where the exact match can otherwise fall outside the window behind higher-bm25 partial hits — "NY"
58
+ * losing to "New York".
59
+ */
60
+ const SHORT_QUERY_MAX_LENGTH = 3
61
+
52
62
  export interface WOFSqlitePlaceLookupOpts {
53
63
  /**
54
64
  * Path to the WOF SQLite distribution on disk. Mutually exclusive with `database`.
@@ -96,127 +106,6 @@ export interface WOFSqlitePlaceLookupOpts {
96
106
  postalCityAliases?: WOFPostalCityAliasLookup
97
107
  }
98
108
 
99
- /**
100
- * Ranking weights for `findPlace`. Tweakable per-instance but defaults match the values declared in the Phase 4.2 plan
101
- * doc.
102
- */
103
- export interface RankingWeights {
104
- /** Boost when the candidate's placetype matches an explicit `placetype` filter. */
105
- placetypeMatchBoost: number
106
- /** Boost when the candidate is a locality and no explicit placetype was requested. */
107
- localityImplicitBoost: number
108
- /** Boost when the candidate's country matches an explicit `country` filter. */
109
- countryMatchBoost: number
110
- /** Boost when the candidate is a direct child of the requested `parentID`. */
111
- directChildBoost: number
112
- /** Boost when the candidate is a transitive descendant of the requested `parentID`. */
113
- descendantBoost: number
114
- /** Multiplier on the length-penalty term (penalizes much-longer-than-query names). */
115
- lengthPenaltyWeight: number
116
- /**
117
- * Magnitude of the proximity boost when the query carries `near`. The contribution is `proximityBoost / (1 +
118
- * distanceKm / proximityScaleKm)` — at distance 0 the boost is full magnitude, at `proximityScaleKm` it's half,
119
- * decaying further with distance. Default tuned so proximity can overcome a typical FTS rank tie but not dominate a
120
- * strong text match.
121
- */
122
- proximityBoost: number
123
- /**
124
- * Magnitude of the bias-hint term inside the exact-tier PROMINENCE sort (the `bias`/viewport path). Deliberately
125
- * population-scale (default = populationBoost) so a candidate near the map view / the user beats a distant-but-bigger
126
- * namesake — "the map view wins" is the feature; same-region ties (all candidates far from every hint) still fall to
127
- * population.
128
- */
129
- biasBoost: number
130
- /** Distance (km) at which the proximity boost halves. Tune to the typical query radius. */
131
- proximityScaleKm: number
132
- /**
133
- * Magnitude of the population boost when the candidate has a known `wof:population`. The contribution is
134
- * `populationBoost * log10(1 + population) / populationScaleLog10`, capped at `populationBoost`. WOF only carries
135
- * population for ~15% of localities (mostly larger ones); places without it get +0 (never a penalty). Default tuned
136
- * so the famous Springfield, IL (pop ~112k) gets ~0.42 boost — enough to nudge past tiny same-name peers.
137
- */
138
- populationBoost: number
139
- /**
140
- * Population (in log10) at which the boost reaches its full magnitude. Default 6 — i.e. a population of 1,000,000
141
- * gives `populationBoost` exactly. Larger populations cap at the same value (no compounding effect for megacities).
142
- */
143
- populationScaleLog10: number
144
- /**
145
- * Tier candidates with an EXACT name/alias match above candidates that only match partially, BEFORE the weighted-sum
146
- * score is consulted. Default true.
147
- *
148
- * Why this is needed (and why it ALIGNS with — rather than overrides — the population/importance signal): the
149
- * weighted sum adds population as a large additive boost (`populationBoost`, up to +4) so that famous places surface
150
- * for unambiguous full-name queries. But population is a _prominence prior_ — its job is to break ties among
151
- * candidates that match the query EQUALLY WELL (e.g. "Springfield" → Springfield IL over Springfield MA, both exact
152
- * name matches). It was never meant to promote a place that matches the query WORSE. For a 2-letter region
153
- * abbreviation that backfires: querying "ME" returns Maine (which has the exact alias `ME`) AND Missouri/
154
- * Michigan/etc. (which do not), and Missouri's larger population (+4) overcomes Maine's bm25 edge — so "Portland, ME"
155
- * resolves its region to Missouri and the locality then cascades to the wrong state. Tiering restores the intended
156
- * ordering: **match quality is the primary key, prominence (population) the secondary key WITHIN a tier.**
157
- * Springfield-IL-over-MA still works (both exact → same tier → population decides); ME→Maine now works (only Maine is
158
- * exact → higher tier → population never gets to override it). See
159
- * docs/articles/evals/resolver-geo/2026-05-30-resolver-exact-match.md.
160
- *
161
- * Note: tiering re-ranks within the over-fetched candidate window (`limit * 4`); a pathological exact match that
162
- * falls outside that window is not rescued. For the region-abbrev case the window is comfortably sufficient (a
163
- * handful of states match a 2-letter query).
164
- */
165
- exactMatchTiering: boolean
166
- /**
167
- * #936 option 3 — official-language names ARE names. When true, a candidate holding the query as an OFFICIAL name
168
- * (`names.official = 1`: a preferred-form name in an official language of its country, stamped at ingest) joins the
169
- * NAME-exact sub-tier rather than the alias-exact one, provided its population clears {@link officialNameExactFloor}.
170
- * Fixes unscoped "Åbo" → Turku (its official Swedish name) over a hamlet literally named Åbo; population still orders
171
- * within the sub-tier, so Paris → Paris FR is untouched.
172
- *
173
- * Default true (operator-promoted 2026-07-03 after the pre-registered gate battery: four intended exonym flips —
174
- * Berne→Bern, Bruges→Brugge, Roma→Rome, Åbo→Turku — with the namesake/abbreviation rows and the US/FI panels
175
- * byte-identical). Requires a gazetteer carrying the #940 ingest bit — on older DBs without the `official` column the
176
- * probe fails soft and behavior is identical to the flag being off.
177
- */
178
- officialNameExact: boolean
179
- /**
180
- * Minimum population for a candidate's official names to join the name-exact sub-tier. The #936 review's no-floor
181
- * census measured the boundary: ≥100k holders are the famous-exonym class (757 flips, intent-correct; 7 collisions,
182
- * none harmful) while 10k–100k holders are junk-dominated (3,481 flips led by short-form mis-tags — Villeneuve-Loubet
183
- * carrying "villeneuve" would bury five real villages of that name). Rank-time knob: tunable without re-ingest;
184
- * below-floor official names simply stay alias-tier (today's behavior).
185
- */
186
- officialNameExactFloor: number
187
- }
188
-
189
- const DEFAULT_WEIGHTS: RankingWeights = {
190
- placetypeMatchBoost: 0.5,
191
- localityImplicitBoost: 0.2,
192
- countryMatchBoost: 0.3,
193
- directChildBoost: 0.5,
194
- descendantBoost: 0.2,
195
- lengthPenaltyWeight: 0.1,
196
- proximityBoost: 0.8,
197
- proximityScaleKm: 100,
198
- biasBoost: 4.0,
199
- // populationBoost is intentionally large — empirical tuning against real WOF showed BM25 gaps
200
- // of 1.5-3.0 between famous places and tiny same-name peers (because the famous ones have
201
- // hundreds of alt-name entries that hurt their FTS document score). To consistently surface
202
- // "the famous one" for unambiguous queries like "New York" or "Chicago", the population signal
203
- // needs to dominate. Callers wanting a more conservative balance can drop this in the
204
- // RankingWeights override.
205
- //
206
- // Note: this resolver uses `place_population` directly. The separate `place_importance` table
207
- // (Wikipedia-derived) is consumed by the FST layer, not here. See
208
- // docs/articles/concepts/importance-vs-population.md for the two-signal contract.
209
- populationBoost: 4.0,
210
- populationScaleLog10: 6,
211
- // Exact name/alias match outranks partial match before the weighted sum (incl. population) is
212
- // consulted — keeps population as an intra-tier prominence tiebreaker, not a cross-tier promoter.
213
- // Fixes the 2-letter-region-abbrev bug ("ME" → Maine, not the more-populous Missouri).
214
- exactMatchTiering: true,
215
- // #936 option 3 — promoted default-ON 2026-07-03 (gate battery PASS; see the RankingWeights docstring).
216
- officialNameExact: true,
217
- officialNameExactFloor: 100_000,
218
- }
219
-
220
109
  /**
221
110
  * Over-fetch floor for SHORT (≤3-char) queries — region abbreviations like "NY"/"VT". An exact-abbrev holder's BM25 is
222
111
  * poor (long multilingual alt-name document), so the normal `limit * 4` window can drop it before `exactMatchTiering`
@@ -272,67 +161,6 @@ const CF_PC_DECAY_KM = 8
272
161
  const CF_MISMATCH_KM = 50
273
162
  const CF_MISMATCH_DELTA = 0.5
274
163
 
275
- /** Case-fold + strip diacritics + collapse punctuation — for the coord-first soft name match. */
276
- function cfNormalize(s: string): string {
277
- return s
278
- .toLowerCase()
279
- .normalize("NFD")
280
- .replace(/[\u0300-\u036f]/g, "") // combining diacritical marks
281
- .replace(/[^a-z0-9]+/g, " ")
282
- .trim()
283
- }
284
-
285
- /** Padded character-trigram set (a leading/trailing space pads short tokens). */
286
- export function trigrams(s: string): Set<string> {
287
- const t = ` ${s} `
288
- const out = new Set<string>()
289
-
290
- for (let i = 0; i + 3 <= t.length; i++) {
291
- out.add(t.slice(i, i + 3))
292
- }
293
-
294
- return out
295
- }
296
-
297
- /**
298
- * Character-trigram Jaccard ∈ [0,1] — tolerant of the swallowed-leading-char fragments ("auen" vs "plauen") and minor
299
- * misspellings without a heavyweight edit-distance pass. Shared with the candidate backend's FTS5-trigram fuzzy
300
- * fallback so both lookups rank typos identically.
301
- */
302
- export function trigramJaccard(a: string, b: string): number {
303
- const A = trigrams(a)
304
- const B = trigrams(b)
305
-
306
- if (A.size === 0 || B.size === 0) return 0
307
- let inter = 0
308
-
309
- for (const x of A)
310
- if (B.has(x)) {
311
- inter++
312
- }
313
-
314
- return inter / (A.size + B.size - inter)
315
- }
316
-
317
- /** Soft name-match score ∈ [0,1]: exact (normalized) name/alias → 1, else best trigram-Jaccard. */
318
- function softNameScore(text: string, name: string, aliases: readonly string[]): number {
319
- const q = cfNormalize(text)
320
-
321
- if (!q) return 0
322
- let best = 0
323
-
324
- for (const raw of [name, ...aliases]) {
325
- const n = cfNormalize(raw)
326
-
327
- if (!n) continue
328
-
329
- if (n === q) return 1
330
- best = Math.max(best, trigramJaccard(q, n))
331
- }
332
-
333
- return best
334
- }
335
-
336
164
  export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
337
165
  readonly #db: DatabaseSync
338
166
  readonly #ownsDB: boolean
@@ -362,7 +190,9 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
362
190
  * override) schema names.
363
191
  */
364
192
  readonly #shards: ResolvedShard[]
365
- /** #920: per-schema probed country sets for country-aware shard routing (non-main shards only). */
193
+ /**
194
+ * #920: per-schema probed country sets for country-aware shard routing (non-main shards only).
195
+ */
366
196
  readonly #shardCountries: Map<string, ReadonlySet<string>>
367
197
  /**
368
198
  * The Geographic Rule Engine (Direction E, #289). `#conventionSource` supplies per-WOF-polygon resolution profiles;
@@ -374,13 +204,17 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
374
204
  readonly #conventionSource: ConventionSource
375
205
  readonly #strategies: Map<string, Strategy>
376
206
  readonly #countryWOFIdCache = new Map<string, number | null>()
377
- /** Strategy names already warned about — so an unknown name surfaces once, not once per query. */
207
+ /**
208
+ * Strategy names already warned about — so an unknown name surfaces once, not once per query.
209
+ */
378
210
  readonly #warnedUnknownStrategies = new Set<string>()
379
211
  /**
380
212
  * Lazily-built `admin_id → coincident localities` map from the #403 relation (null until first use).
381
213
  */
382
214
  #coincidentRolesCache: Map<number, CoincidentLocality[]> | null = null
383
- /** Per-id memoized ancestor lineages (#404) — a hot chain is queried once. */
215
+ /**
216
+ * Per-id memoized ancestor lineages (#404) — a hot chain is queried once.
217
+ */
384
218
  readonly #ancestorsCache = new Map<number, Ancestor[]>()
385
219
  /**
386
220
  * Opt-in postal-city alias reader (#475). `null` unless `opts.postalCityAliases` was supplied — every alias code path
@@ -416,7 +250,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
416
250
  // ATTACH each non-main shard. Schema names were validated by resolveShards, so safe to
417
251
  // interpolate directly (SQLite ATTACH doesn't accept parameters for the schema name).
418
252
  for (const s of shards.slice(1)) {
419
- this.#db.exec(`ATTACH DATABASE '${s.path.replace(/'/g, "''")}' AS ${s.schemaName}`)
253
+ this.#db.exec(`ATTACH DATABASE '${s.path.replaceAll("'", "''")}' AS ${s.schemaName}`)
420
254
  }
421
255
  }
422
256
 
@@ -432,6 +266,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
432
266
  this.#kysely = new Kysely<WOFDatabase>({
433
267
  dialect: new SqliteDialect({ database: this.#db }),
434
268
  })
269
+
435
270
  this.#weights = { ...DEFAULT_WEIGHTS, ...weights }
436
271
 
437
272
  // Probe each shard's aux-table presence — driven by per-shard table existence in
@@ -443,6 +278,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
443
278
  this.#hasBboxIndex.set(s.schemaName, this.#shardHasTable(s.schemaName, PLACE_BBOX_TABLE))
444
279
  this.#hasPopulationIndex.set(s.schemaName, this.#shardHasTable(s.schemaName, PLACE_POPULATION_TABLE))
445
280
  }
281
+
446
282
  // #920 country-aware shard routing: probe each NON-MAIN shard's country set once at
447
283
  // construction (they're small, purpose-built shards — postcode/locality slices; main is the
448
284
  // multi-GB admin DB and is the fallback anyway, so it is deliberately NOT scanned). Feeds
@@ -457,6 +293,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
457
293
  const rows = this.#db
458
294
  .prepare(`SELECT DISTINCT country FROM ${sh.schemaName}.spr WHERE country != ''`)
459
295
  .all() as Array<{ country: string }>
296
+
460
297
  this.#shardCountries.set(sh.schemaName, new Set(rows.map((r) => r.country)))
461
298
  } catch {
462
299
  // A shard without spr (or an attach oddity) just doesn't participate in country routing.
@@ -480,6 +317,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
480
317
  // strategy is registering it here.
481
318
  const conventionShard =
482
319
  this.#shards.find((s) => this.#shardHasTable(s.schemaName, ADDRESS_CONVENTION_TABLE))?.schemaName ?? null
320
+
483
321
  this.#conventionSource = opts.conventions
484
322
  ? "get" in opts.conventions && typeof opts.conventions.get === "function"
485
323
  ? opts.conventions
@@ -487,6 +325,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
487
325
  : conventionShard
488
326
  ? new SqliteConventionSource(this.#db, conventionShard)
489
327
  : new SeedConventionSource()
328
+
490
329
  this.#strategies = new Map<string, Strategy>([
491
330
  ["postcode_area_resolution", (q, c) => this.#postcodeAreaResolution(q, c)],
492
331
  ["fallback_fuzzy_name_match", (q) => this.#fuzzyNameMatch(q)],
@@ -501,6 +340,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
501
340
 
502
341
  if (tableName === PLACE_POPULATION_TABLE) return placePopulationExists(this.#db)
503
342
  }
343
+
504
344
  const row = this.#db
505
345
  .prepare(`SELECT name FROM ${schemaName}.sqlite_master WHERE type = 'table' AND name = ?`)
506
346
  .get(tableName) as { name: string } | undefined
@@ -524,17 +364,20 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
524
364
 
525
365
  if (!strategy) {
526
366
  this.#warnUnknownStrategy(name)
367
+
527
368
  continue
528
369
  }
370
+
529
371
  const result = await strategy(query, convention)
530
372
 
531
373
  if (result !== null) {
532
374
  outcome = result
375
+
533
376
  break
534
377
  }
535
378
  }
536
379
 
537
- if (outcome.length > 0) return outcome
380
+ if (outcome.length) return outcome
538
381
 
539
382
  // #924: NL postcode retry ladder. The WOF NL postalcode repo stores full codes UNSPACED
540
383
  // ('1012LG') plus 4-digit stems ('1012'), while Dutch addresses carry the spaced form
@@ -550,13 +393,14 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
550
393
  /^\d{4}\s?[A-Za-z]{2}$/.test(query.text.trim())
551
394
  ) {
552
395
  const trimmed = query.text.trim()
553
- const joined = trimmed.replace(/\s+/g, "")
396
+ const joined = trimmed.replaceAll(/\s+/g, "")
554
397
 
555
398
  if (joined !== trimmed) {
556
399
  const full = await this.findPlace({ ...query, text: joined })
557
400
 
558
- if (full.length > 0) return full
401
+ if (full.length) return full
559
402
  }
403
+
560
404
  const stem = trimmed.slice(0, 4)
561
405
 
562
406
  if (stem !== trimmed) return this.findPlace({ ...query, text: stem })
@@ -613,6 +457,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
613
457
  population: r.population,
614
458
  distanceKm: r.distanceKm,
615
459
  }
460
+
616
461
  const list = map.get(r.adminID)
617
462
 
618
463
  if (list) {
@@ -622,6 +467,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
622
467
  }
623
468
  }
624
469
  }
470
+
625
471
  this.#coincidentRolesCache = map
626
472
  }
627
473
 
@@ -644,11 +490,13 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
644
490
  const cached = this.#ancestorsCache.get(pid)
645
491
 
646
492
  if (cached) return cached
493
+
647
494
  const lineage: Ancestor[] = ancestorLineage(this.#db, pid).map((r) => ({
648
495
  id: r.id,
649
496
  placetype: r.placetype,
650
497
  name: r.name,
651
498
  }))
499
+
652
500
  this.#ancestorsCache.set(pid, lineage)
653
501
 
654
502
  return lineage
@@ -663,6 +511,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
663
511
  #warnUnknownStrategy(name: string): void {
664
512
  if (this.#warnedUnknownStrategies.has(name)) return
665
513
  this.#warnedUnknownStrategies.add(name)
514
+
666
515
  console.warn(
667
516
  `WOFSqlitePlaceLookup: a convention names strategy "${name}", which this build does not register ` +
668
517
  `(known: ${[...this.#strategies.keys()].join(", ")}). Skipping it. If the convention asset was built ` +
@@ -690,6 +539,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
690
539
  */
691
540
  async #fuzzyNameMatch(query: FindPlaceQuery, forceShard?: ResolvedShard): Promise<PlaceCandidate[]> {
692
541
  const limit = query.limit ?? 10
542
+
693
543
  // Over-fetch so post-scoring + exact-match tiering have room to re-rank. SHORT queries (a 2–3-char
694
544
  // region abbreviation like "NY"/"VT") are the danger case the `exactMatchTiering` docstring flags:
695
545
  // the exact-abbrev holder's BM25 is poor (its long multilingual alt-name document tanks the score),
@@ -699,7 +549,8 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
699
549
  // (Cross-country abbrev collisions — "VT" is BOTH Vermont and Viterbo — still need a country/
700
550
  // postcode signal to disambiguate; this only rescues the window-drop class, not genuine ambiguity.
701
551
  // With a `country` hint every abbrev resolves; bare + no-context lifts 7→10/15 US states.)
702
- const ftsLimit = query.text.trim().length <= 3 ? Math.max(limit * 4, SHORT_QUERY_OVERFETCH) : limit * 4
552
+ const ftsLimit =
553
+ query.text.trim().length <= SHORT_QUERY_MAX_LENGTH ? Math.max(limit * 4, SHORT_QUERY_OVERFETCH) : limit * 4
703
554
 
704
555
  // Expand the placetype filter through the shared equivalence table (core/resolver): a
705
556
  // `locality` query must also reach `borough` / `localadmin` rows — Brooklyn-the-borough
@@ -734,6 +585,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
734
585
  for (const sh of matching) {
735
586
  pools.push(await this.#fuzzyNameMatch(query, sh))
736
587
  }
588
+
737
589
  const byID = new Map<PlaceCandidate["id"], PlaceCandidate>()
738
590
 
739
591
  for (const c of pools.flat()) {
@@ -741,7 +593,9 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
741
593
  byID.set(c.id, c)
742
594
  }
743
595
  }
596
+
744
597
  const merged = [...byID.values()]
598
+
745
599
  merged.sort(
746
600
  (a, b) =>
747
601
  Number(b.exactMatch ?? false) - Number(a.exactMatch ?? false) ||
@@ -752,12 +606,14 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
752
606
  return merged.slice(0, limit)
753
607
  }
754
608
  }
609
+
755
610
  const shard =
756
611
  forceShard ??
757
612
  pickShardForPlacetype(this.#shards, firstPlacetype, {
758
613
  country: query.country,
759
614
  countriesBySchema: this.#shardCountries,
760
615
  })
616
+
761
617
  const sch = shard.schemaName // bare schema name; safe to interpolate (validated at construction)
762
618
 
763
619
  // Filter out historical / superseded / deprecated places by default — they live in the same
@@ -768,7 +624,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
768
624
  const where: string[] = ["place_search MATCH ?", "spr.is_current != 0", "spr.is_deprecated = 0"]
769
625
  const params: SQLInputValue[] = [ftsQuery]
770
626
 
771
- if (placetypes && placetypes.length > 0) {
627
+ if (placetypes && placetypes.length) {
772
628
  where.push(`spr.placetype IN (${placetypes.map(() => "?").join(", ")})`)
773
629
  params.push(...placetypes)
774
630
  }
@@ -793,9 +649,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
793
649
  if (useBboxJoin) {
794
650
  joinClause += ` JOIN ${sch}.${PLACE_BBOX_TABLE} bbox ON bbox.id = spr.id`
795
651
  // AABB intersection — both bbox sides must overlap. R*Tree handles this in O(log n).
796
- const filterBox = query.bbox
797
- ? query.bbox
798
- : bboxAround(query.near!.lat, query.near!.lon, query.near!.maxDistanceKm!)
652
+ const filterBox = query.bbox || bboxAround(query.near!.lat, query.near!.lon, query.near!.maxDistanceKm!)
799
653
  where.push("bbox.min_lat <= ? AND bbox.max_lat >= ?", "bbox.min_lon <= ? AND bbox.max_lon >= ?")
800
654
  params.push(filterBox.maxLat, filterBox.minLat, filterBox.maxLon, filterBox.minLon)
801
655
  }
@@ -803,9 +657,11 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
803
657
  // LEFT JOIN the population aux table when present. Missing-on-this-shard means the SELECT
804
658
  // just doesn't include the population column; the post-scoring loop treats it as 0.
805
659
  const shardHasPopulation = this.#hasPopulationIndex.get(sch) === true
660
+
806
661
  const populationSelect = shardHasPopulation
807
662
  ? `${PLACE_POPULATION_TABLE}.population AS population`
808
663
  : `NULL AS population`
664
+
809
665
  const populationJoin = shardHasPopulation
810
666
  ? `LEFT JOIN ${sch}.${PLACE_POPULATION_TABLE} ON ${PLACE_POPULATION_TABLE}.id = spr.id`
811
667
  : ""
@@ -853,6 +709,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
853
709
  if (shardHasPopulation) {
854
710
  params.push(this.#weights.populationBoost, this.#weights.populationScaleLog10)
855
711
  }
712
+
856
713
  params.push(ftsLimit)
857
714
 
858
715
  const rawRows = stmt.all(...params) as unknown as RawSearchRow[]
@@ -883,7 +740,8 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
883
740
  ORDER BY COALESCE(${PLACE_POPULATION_TABLE}.population, 0) DESC
884
741
  LIMIT ?
885
742
  `)
886
- const popParams = params.slice(0, params.length - 3) // drop the two boost params + ftsLimit
743
+
744
+ const popParams = params.slice(0, -3) // drop the two boost params + ftsLimit
887
745
  const seen = new Set(rawRows.map((r) => r.id))
888
746
 
889
747
  for (const row of popStmt.all(...popParams, POPULATION_FETCH_LIMIT) as unknown as RawSearchRow[]) {
@@ -894,12 +752,13 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
894
752
  }
895
753
 
896
754
  const queryLen = query.text.length
755
+
897
756
  const candidates = rawRows.map((row): PlaceCandidate => {
898
757
  // SQLite's bm25() returns a lower-is-better score (negative for matches). Negate so we
899
758
  // start from a higher-is-better baseline.
900
759
  let score = -row.rank
901
760
 
902
- if (placetypes && placetypes.length > 0 && placetypes.includes(row.placetype as WOFPlacetype)) {
761
+ if (placetypes && placetypes.length && placetypes.includes(row.placetype as WOFPlacetype)) {
903
762
  score += this.#weights.placetypeMatchBoost
904
763
  }
905
764
 
@@ -912,12 +771,9 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
912
771
  }
913
772
 
914
773
  if (query.parentID !== undefined) {
915
- if (row.parent_id === query.parentID) {
916
- score += this.#weights.directChildBoost
917
- } else {
918
- score += this.#weights.descendantBoost
919
- }
774
+ score += row.parent_id === query.parentID ? this.#weights.directChildBoost : this.#weights.descendantBoost
920
775
  }
776
+
921
777
  const extraLen = Math.max(0, row.name.length - queryLen - 3)
922
778
  score -= (this.#weights.lengthPenaltyWeight * extraLen) / 10
923
779
 
@@ -954,6 +810,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
954
810
  scoreTerm = decay * this.#weights.proximityBoost
955
811
  }
956
812
  }
813
+
957
814
  score += scoreTerm
958
815
  }
959
816
 
@@ -967,6 +824,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
967
824
  popTerm = this.#weights.populationBoost * popFraction
968
825
  score += popTerm
969
826
  }
827
+
970
828
  // Combined prominence for the exact-tier sort when proximity hints are present: population
971
829
  // and nearness in the SAME additive units, so the map view / the user's location can win a
972
830
  // cross-country postcode tie without a hard filter.
@@ -1020,7 +878,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
1020
878
  // Runs even for a SINGLE candidate so `exactMatch` is stamped consistently (parity with the
1021
879
  // WASM lookup) — a sole alias hit ("New York City" → New York) must still carry the flag the
1022
880
  // demo cascade / #369 re-rank read.
1023
- if (this.#weights.exactMatchTiering && candidates.length > 0) {
881
+ if (this.#weights.exactMatchTiering && candidates.length) {
1024
882
  const exactIds = this.#exactMatchIds(
1025
883
  sch,
1026
884
  candidates.map((c) => c.id as number),
@@ -1034,7 +892,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
1034
892
  c.exactMatch = exactIds.has(c.id as number)
1035
893
  }
1036
894
 
1037
- if (exactIds.size > 0) {
895
+ if (exactIds.size) {
1038
896
  // #905: WITHIN the exact tier, population is the PRIMARY key and the weighted score
1039
897
  // only breaks population ties. Exactness saturates text relevance, and the bm25
1040
898
  // residue inside `score` is length-noise (see the fetch-site comment), so letting it
@@ -1048,8 +906,9 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
1048
906
  // widen recall, not to tie primaries. ME→Maine is untouched: 'ME' name-exact-matches
1049
907
  // nothing, so the alias sub-tier still decides there. Population orders within each
1050
908
  // sub-tier as before.
1051
- const norm = (v: string): string => v.toLowerCase().trim().replace(/\s+/g, " ")
909
+ const norm = (v: string): string => v.toLowerCase().trim().replaceAll(/\s+/g, " ")
1052
910
  const needle = norm(query.text)
911
+
1053
912
  // #936 option 3: an OFFICIAL name (preferred form in an official language of the place's
1054
913
  // country, `names.official = 1`) counts as the place's own name for the sub-tier — "Åbo" is
1055
914
  // Turku's name, not merely its alias. Floor-gated on the holder's population (see the
@@ -1066,6 +925,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
1066
925
  query.text
1067
926
  )
1068
927
  : undefined
928
+
1069
929
  const kind = (c: PlaceCandidate): number => {
1070
930
  if (!exactIds.has(c.id as number)) return 0
1071
931
 
@@ -1073,11 +933,13 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
1073
933
 
1074
934
  return officialIds?.has(c.id as number) ? 2 : 1
1075
935
  }
936
+
1076
937
  // With proximity hints (near/bias), prominence (population + nearness, same units)
1077
938
  // replaces raw population as the within-tier key — the 48026 rule: the map view or
1078
939
  // the user's location breaks a cross-country postcode tie. Without hints, population
1079
940
  // ordering is byte-identical to before.
1080
941
  const hasHints = !!query.near || (query.bias?.length ?? 0) > 0
942
+
1081
943
  candidates.sort((a, b) => {
1082
944
  const ax = kind(a)
1083
945
  const bx = kind(b)
@@ -1093,13 +955,13 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
1093
955
  return b.score - a.score
1094
956
  })
1095
957
 
1096
- return Promise.resolve(candidates.slice(0, limit))
958
+ return candidates.slice(0, limit)
1097
959
  }
1098
960
  }
1099
961
 
1100
962
  candidates.sort((a, b) => b.score - a.score)
1101
963
 
1102
- return Promise.resolve(candidates.slice(0, limit))
964
+ return candidates.slice(0, limit)
1103
965
  }
1104
966
 
1105
967
  #isLocalityQuery(query: FindPlaceQuery): boolean {
@@ -1137,16 +999,18 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
1137
999
  const cached = this.#countryWOFIdCache.get(code)
1138
1000
 
1139
1001
  if (cached !== undefined) return cached
1140
- let id: number | null = null
1002
+ let id: number | null
1141
1003
 
1142
1004
  try {
1143
1005
  const row = this.#db
1144
1006
  .prepare(`SELECT id FROM main.spr WHERE placetype = 'country' AND country = ? AND is_current != 0 LIMIT 1`)
1145
1007
  .get(code) as { id: number } | undefined
1008
+
1146
1009
  id = row?.id ?? null
1147
1010
  } catch {
1148
1011
  id = null
1149
1012
  }
1013
+
1150
1014
  this.#countryWOFIdCache.set(code, id)
1151
1015
 
1152
1016
  return id
@@ -1169,6 +1033,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
1169
1033
  const pc = query.postcode!.trim()
1170
1034
  const pcWhere = query.country ? "postcode = ? AND country = ?" : "postcode = ?"
1171
1035
  const pcParams: SQLInputValue[] = query.country ? [pc, query.country] : [pc]
1036
+
1172
1037
  const pcRows = this.#db
1173
1038
  .prepare(
1174
1039
  `SELECT locality_id AS id, aliases, distance_km AS dist, is_containing AS containing
@@ -1176,7 +1041,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
1176
1041
  )
1177
1042
  .all(...pcParams) as unknown as Array<{ id: number; aliases: string | null; dist: number; containing: number }>
1178
1043
 
1179
- if (pcRows.length === 0) return null
1044
+ if (!pcRows.length) return null
1180
1045
 
1181
1046
  const limit = query.limit ?? 10
1182
1047
  // Name-match candidates via the normal FTS path (postcode cleared → no recursion).
@@ -1214,6 +1079,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
1214
1079
  for (const c of ftsCands) {
1215
1080
  merged.set(c.id as number, c)
1216
1081
  }
1082
+
1217
1083
  const missing = [...pcInfo.keys()].filter((id) => !merged.has(id))
1218
1084
 
1219
1085
  for (const row of this.#fetchLocalitiesByID(missing)) {
@@ -1229,14 +1095,16 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
1229
1095
  // (#475). `postalAliasByGeo` is empty unless the opt-in reader was supplied, so when off this
1230
1096
  // reduces to the original `info?.aliases ?? []` and the score is unchanged.
1231
1097
  const wofAliases = info?.aliases ?? []
1232
- const aliases =
1233
- postalAliasByGeo.size > 0
1234
- ? [...wofAliases, ...(postalAliasByGeo.get(cfNormalize(cand.name)) ?? [])]
1235
- : wofAliases
1098
+
1099
+ const aliases = postalAliasByGeo.size
1100
+ ? [...wofAliases, ...(postalAliasByGeo.get(cfNormalize(cand.name)) ?? [])]
1101
+ : wofAliases
1102
+
1236
1103
  const sName = softNameScore(query.text, cand.name, aliases)
1237
1104
  const sPop = cand.population && cand.population > 0 ? Math.min(1, Math.log10(1 + cand.population) / 6) : 0
1238
1105
  scored.push({ ...cand, score: w.pc * sPc + w.name * sName + w.pop * sPop, exact: sName >= 1 })
1239
1106
  }
1107
+
1240
1108
  // Exact-name tiering (same philosophy as the FTS path): an EXACT name/alias match tiers above
1241
1109
  // coordinate-only candidates, with the soft-score breaking ties WITHIN a tier. This keeps an
1242
1110
  // unambiguous city ("Berlin", exact + huge population) ahead of the fine-grained Ortsteil its
@@ -1256,13 +1124,17 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
1256
1124
  // every locality polygon still anchor to the closest town.
1257
1125
  const anchorRow = pcRows
1258
1126
  .filter((r) => merged.has(r.id))
1127
+ // oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
1259
1128
  .sort((a, b) => b.containing - a.containing || a.dist - b.dist)[0]
1129
+
1260
1130
  const anchor = anchorRow ? merged.get(anchorRow.id) : undefined
1261
1131
 
1262
- if (anchor && (top.id as number) !== anchorRow!.id) {
1263
- if (haversineKm(top.lat, top.lon, anchor.lat, anchor.lon) > CF_MISMATCH_KM) {
1264
- top.mismatch = true
1265
- }
1132
+ if (
1133
+ anchor &&
1134
+ (top.id as number) !== anchorRow!.id &&
1135
+ haversineKm(top.lat, top.lon, anchor.lat, anchor.lon) > CF_MISMATCH_KM
1136
+ ) {
1137
+ top.mismatch = true
1266
1138
  }
1267
1139
  }
1268
1140
 
@@ -1273,13 +1145,16 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
1273
1145
  })
1274
1146
  }
1275
1147
 
1276
- /** Fetch locality spr rows (from main) for the postcode-injected candidate ids the FTS set missed. */
1148
+ /**
1149
+ * Fetch locality spr rows (from main) for the postcode-injected candidate ids the FTS set missed.
1150
+ */
1277
1151
  #fetchLocalitiesByID(ids: number[]): PlaceCandidate[] {
1278
- if (ids.length === 0) return []
1152
+ if (!ids.length) return []
1279
1153
  const hasPop = this.#hasPopulationIndex.get("main") === true
1280
1154
  const popSelect = hasPop ? `pp.population AS population` : `NULL AS population`
1281
1155
  const popJoin = hasPop ? `LEFT JOIN main.${PLACE_POPULATION_TABLE} pp ON pp.id = s.id` : ""
1282
1156
  const ph = ids.map(() => "?").join(", ")
1157
+
1283
1158
  const rows = this.#db
1284
1159
  .prepare(
1285
1160
  `SELECT s.id AS id, s.name AS name, s.country AS country, s.parent_id AS parent_id,
@@ -1320,7 +1195,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
1320
1195
  const out = new Set<number>()
1321
1196
  const trimmed = text.trim()
1322
1197
 
1323
- if (ids.length === 0 || !trimmed) return out
1198
+ if (!ids.length || !trimmed) return out
1324
1199
  const placeholders = ids.map(() => "?").join(", ")
1325
1200
 
1326
1201
  try {
@@ -1345,7 +1220,8 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
1345
1220
  `SELECT wof_id AS id, name, alt_names FROM ${schemaName}.place_search WHERE wof_id IN (${placeholders})`
1346
1221
  )
1347
1222
  .all(...ids) as Array<{ id: number; name: string | null; alt_names: string | null }>
1348
- const norm = (s: string): string => s.toLowerCase().trim().replace(/\s+/g, " ")
1223
+
1224
+ const norm = (s: string): string => s.toLowerCase().trim().replaceAll(/\s+/g, " ")
1349
1225
  const needle = norm(trimmed)
1350
1226
 
1351
1227
  for (const r of rows) {
@@ -1353,6 +1229,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
1353
1229
  out.add(r.id)
1354
1230
  }
1355
1231
  }
1232
+
1356
1233
  // Alias pass via the shared bag parser (#523). Separated bags (built since #523) get a true
1357
1234
  // per-alias equality check, ungated — matching the `names`-table branch above, where an
1358
1235
  // alias match counts as exact regardless of other candidates. Legacy bags (no separator)
@@ -1383,7 +1260,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
1383
1260
  const out = new Set<number>()
1384
1261
  const trimmed = text.trim()
1385
1262
 
1386
- if (ids.length === 0 || !trimmed) return out
1263
+ if (!ids.length || !trimmed) return out
1387
1264
  const placeholders = ids.map(() => "?").join(", ")
1388
1265
 
1389
1266
  try {
@@ -1417,7 +1294,9 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
1417
1294
  this.close()
1418
1295
  }
1419
1296
 
1420
- /** Build the FTS5 virtual table from the `names` + `places` tables. */
1297
+ /**
1298
+ * Build the FTS5 virtual table from the `names` + `places` tables.
1299
+ */
1421
1300
  #ensureFTS(): void {
1422
1301
  buildPlaceSearchFTS(this.#db)
1423
1302
  }
@@ -1431,74 +1310,6 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
1431
1310
  }
1432
1311
  }
1433
1312
 
1434
- function normalizePlacetypes(p: FindPlaceQuery["placetype"]): WOFPlacetype[] | null {
1435
- if (!p) return null
1436
-
1437
- return Array.isArray(p) ? p : [p]
1438
- }
1439
-
1440
- /**
1441
- * Make an arbitrary user-typed string safe for FTS5 MATCH.
1442
- *
1443
- * FTS5 has its own query syntax (`"phrase"`, `term1 OR term2`, `prefix*`, NEAR/N, etc.). Letting raw user input through
1444
- * means a user typing `Paris's` or `St. (Petersburg)` causes a syntax error.
1445
- *
1446
- * Per-token rules:
1447
- *
1448
- * - Strip all punctuation except trailing `*` from each whitespace-separated token.
1449
- * - **Trailing `*`** is preserved as FTS5 **prefix syntax** — `627*` becomes the literal `627*` (unquoted). The caller
1450
- * signaled they want a prefix; respect that.
1451
- * - All other tokens are wrapped in `"..."` as a single-word phrase. Conservative — handles apostrophes, parens, accented
1452
- * input, etc. safely.
1453
- * - Multiple tokens join with implicit AND.
1454
- *
1455
- * Examples:
1456
- *
1457
- * - `"Paris"` → `"Paris"` (phrase)
1458
- * - `"627*"` → `627*` (prefix)
1459
- * - `"St. (Petersburg)"` → `"St" "Petersburg"` (two phrases, AND-joined)
1460
- * - `"Thiron-Gardais"` → `"Thiron" "Gardais"` (intra-token punctuation SPLITS — #945; fusing to `ThironGardais` matched
1461
- * nothing because the FTS doc tokenizes the hyphenated name as two terms)
1462
- * - `"110 00"` with `fuseTokens` (postcode-typed) → `"110" "00"` per-token fused — the #920 name law
1463
- * - `"Pari* TX"` → `Pari* "TX"` (mixed prefix + phrase)
1464
- * - `"*"` alone → `""` (no body → drop)
1465
- */
1466
- function sanitizeFTSQuery(text: string, opts?: { fuseTokens?: boolean }): string {
1467
- const out: string[] = []
1468
-
1469
- for (const rawToken of text.normalize("NFKC").split(/\s+/u)) {
1470
- const trimmed = rawToken.trim()
1471
-
1472
- if (!trimmed) continue
1473
- const hasPrefixStar = trimmed.endsWith("*")
1474
-
1475
- // #920 name law (postcode-typed queries ONLY): delete intra-token punctuation and FUSE the
1476
- // remainder — postal names are stored in this collapsed shape ("SW1A" stays one term).
1477
- if (opts?.fuseTokens) {
1478
- const body = trimmed.replace(/[^\p{L}\p{N}]/gu, "")
1479
-
1480
- if (!body) continue
1481
- out.push(hasPrefixStar ? `${body}*` : `"${body.replace(/"/g, '""')}"`)
1482
- continue
1483
- }
1484
-
1485
- // Everything else SPLITS on intra-token punctuation — the behavior the docstring always
1486
- // promised ("St. (Petersburg)" → two phrases). The old code DELETED punctuation instead,
1487
- // fusing "Thiron-Gardais" into the unmatchable single term `ThironGardais` while the FTS
1488
- // doc holds two terms (#945 — the entire hyphenated-name class missed at the raw lookup;
1489
- // masked for years because pre-splice tokenizers never emitted hyphen-preserved values).
1490
- const parts = trimmed.split(/[^\p{L}\p{N}]+/u).filter(Boolean)
1491
-
1492
- if (parts.length === 0) continue
1493
-
1494
- for (let i = 0; i < parts.length; i++) {
1495
- const body = parts[i]!.replace(/\*/g, "")
1313
+ export { trigramJaccard, trigrams } from "./name-score.ts"
1496
1314
 
1497
- if (!body) continue
1498
- // The caller's trailing `*` applies to the FINAL part ("Thiron-Gard*" → "Thiron" Gard*).
1499
- out.push(hasPrefixStar && i === parts.length - 1 ? `${body}*` : `"${body.replace(/"/g, '""')}"`)
1500
- }
1501
- }
1502
-
1503
- return out.join(" ")
1504
- }
1315
+ export type { RankingWeights } from "./ranking-weights.ts"