@mailwoman/resolver-wof-sqlite 8.1.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 (217) 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 +36 -14
  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 +49 -16
  14. package/fst-autocomplete.ts +15 -9
  15. package/fst-builder.ts +29 -5
  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 +26 -3
  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 +10 -0
  26. package/interpolation.ts +33 -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 +9 -3
  57. package/out/candidate-lookup.d.ts.map +1 -1
  58. package/out/candidate-lookup.js +16 -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 +45 -14
  71. package/out/coverage-manifest-schema.d.ts.map +1 -1
  72. package/out/coverage-manifest-schema.js +14 -5
  73. package/out/coverage-manifest-schema.js.map +1 -1
  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 +15 -5
  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 +26 -3
  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.map +1 -1
  114. package/out/index.js.map +1 -1
  115. package/out/interpolation.d.ts +18 -6
  116. package/out/interpolation.d.ts.map +1 -1
  117. package/out/interpolation.js +11 -40
  118. package/out/interpolation.js.map +1 -1
  119. package/out/lookup.d.ts +3 -97
  120. package/out/lookup.d.ts.map +1 -1
  121. package/out/lookup.js +52 -184
  122. package/out/lookup.js.map +1 -1
  123. package/out/name-score.d.ts +28 -0
  124. package/out/name-score.d.ts.map +1 -0
  125. package/out/name-score.js +67 -0
  126. package/out/name-score.js.map +1 -0
  127. package/out/poi-lookup.d.ts +24 -8
  128. package/out/poi-lookup.d.ts.map +1 -1
  129. package/out/poi-lookup.js +27 -13
  130. package/out/poi-lookup.js.map +1 -1
  131. package/out/poi-schema.d.ts +42 -13
  132. package/out/poi-schema.d.ts.map +1 -1
  133. package/out/poi-schema.js +12 -3
  134. package/out/poi-schema.js.map +1 -1
  135. package/out/postal-city-alias-lookup.d.ts +18 -6
  136. package/out/postal-city-alias-lookup.d.ts.map +1 -1
  137. package/out/postal-city-alias-lookup.js.map +1 -1
  138. package/out/postal-city-alias-schema.d.ts +27 -9
  139. package/out/postal-city-alias-schema.d.ts.map +1 -1
  140. package/out/postal-city-alias-schema.js +3 -1
  141. package/out/postal-city-alias-schema.js.map +1 -1
  142. package/out/postal-city-candidate-schema.d.ts +15 -5
  143. package/out/postal-city-candidate-schema.d.ts.map +1 -1
  144. package/out/postal-city-candidate-schema.js +3 -1
  145. package/out/postal-city-candidate-schema.js.map +1 -1
  146. package/out/postcode-point-lookup.d.ts +6 -2
  147. package/out/postcode-point-lookup.d.ts.map +1 -1
  148. package/out/postcode-point-lookup.js +6 -2
  149. package/out/postcode-point-lookup.js.map +1 -1
  150. package/out/ranking-weights.d.ts +118 -0
  151. package/out/ranking-weights.d.ts.map +1 -0
  152. package/out/ranking-weights.js +44 -0
  153. package/out/ranking-weights.js.map +1 -0
  154. package/out/reverse.d.ts +9 -3
  155. package/out/reverse.d.ts.map +1 -1
  156. package/out/reverse.js +20 -6
  157. package/out/reverse.js.map +1 -1
  158. package/out/sharding.d.ts +3 -1
  159. package/out/sharding.d.ts.map +1 -1
  160. package/out/sharding.js +7 -5
  161. package/out/sharding.js.map +1 -1
  162. package/out/sqlite-convention-source.d.ts.map +1 -1
  163. package/out/sqlite-convention-source.js +3 -1
  164. package/out/sqlite-convention-source.js.map +1 -1
  165. package/out/street-centroid-schema.d.ts +33 -11
  166. package/out/street-centroid-schema.d.ts.map +1 -1
  167. package/out/street-centroid-schema.js +3 -1
  168. package/out/street-centroid-schema.js.map +1 -1
  169. package/out/street-centroid.d.ts.map +1 -1
  170. package/out/street-centroid.js +6 -2
  171. package/out/street-centroid.js.map +1 -1
  172. package/out/street-morphology-fst-builder.d.ts +6 -2
  173. package/out/street-morphology-fst-builder.d.ts.map +1 -1
  174. package/out/street-morphology-fst-builder.js +8 -7
  175. package/out/street-morphology-fst-builder.js.map +1 -1
  176. package/out/street-morphology-fst-loader.d.ts +24 -8
  177. package/out/street-morphology-fst-loader.d.ts.map +1 -1
  178. package/out/street-morphology-fst-loader.js +11 -5
  179. package/out/street-morphology-fst-loader.js.map +1 -1
  180. package/out/street-name-lookup.d.ts +9 -3
  181. package/out/street-name-lookup.d.ts.map +1 -1
  182. package/out/street-name-lookup.js +9 -7
  183. package/out/street-name-lookup.js.map +1 -1
  184. package/out/street-normalize.d.ts +3 -1
  185. package/out/street-normalize.d.ts.map +1 -1
  186. package/out/street-normalize.js +23 -13
  187. package/out/street-normalize.js.map +1 -1
  188. package/out/street-segment-schema.d.ts +48 -16
  189. package/out/street-segment-schema.d.ts.map +1 -1
  190. package/out/street-segment-schema.js +6 -2
  191. package/out/street-segment-schema.js.map +1 -1
  192. package/out/types.d.ts +18 -6
  193. package/out/types.d.ts.map +1 -1
  194. package/out/unified-schema.d.ts +1 -1
  195. package/out/unified-schema.d.ts.map +1 -1
  196. package/out/unified-schema.js +2 -2
  197. package/out/unified-schema.js.map +1 -1
  198. package/package.json +5 -5
  199. package/poi-lookup.ts +53 -21
  200. package/poi-schema.ts +43 -13
  201. package/postal-city-alias-lookup.ts +20 -6
  202. package/postal-city-alias-schema.ts +28 -9
  203. package/postal-city-candidate-schema.ts +15 -5
  204. package/postcode-point-lookup.ts +6 -2
  205. package/ranking-weights.ts +148 -0
  206. package/reverse.ts +47 -10
  207. package/sharding.ts +13 -6
  208. package/sqlite-convention-source.ts +4 -1
  209. package/street-centroid-schema.ts +35 -11
  210. package/street-centroid.ts +10 -3
  211. package/street-morphology-fst-builder.ts +25 -9
  212. package/street-morphology-fst-loader.ts +26 -10
  213. package/street-name-lookup.ts +19 -7
  214. package/street-normalize.ts +28 -13
  215. package/street-segment-schema.ts +50 -16
  216. package/types.ts +18 -6
  217. package/unified-schema.ts +11 -2
@@ -38,9 +38,13 @@ import { normalizeLocalityForKey, stripLocalityQualifier } from "./street-normal
38
38
  import type { FindPlaceQuery, PlaceCandidate, PlaceLookup, WOFPlacetype } from "./types.ts"
39
39
 
40
40
  export interface WOFCandidateTableLookupOpts {
41
- /** Path to a `candidate.db` built by `build-candidate.ts`. Opened read-only. */
41
+ /**
42
+ * Path to a `candidate.db` built by `build-candidate.ts`. Opened read-only.
43
+ */
42
44
  databasePath?: string
43
- /** Pre-opened handle (tests / shared connections). Mutually exclusive with `databasePath`. */
45
+ /**
46
+ * Pre-opened handle (tests / shared connections). Mutually exclusive with `databasePath`.
47
+ */
44
48
  database?: DatabaseSync
45
49
  }
46
50
 
@@ -92,7 +96,7 @@ const FUZZY_MIN = 0.34
92
96
  * ("Los Angeles" over La, Ghana — gap 1.6; "Las Vegas" over Vegas, Cuba — gap 2.4) while a near-tie coincidental
93
97
  * collision defers to the primary (Cancún over Changchun — gap 0.7).
94
98
  */
95
- const PRIMARY_PREFERENCE_LOG10 = 1.0
99
+ const PRIMARY_PREFERENCE_LOG10 = 1
96
100
 
97
101
  /**
98
102
  * Over-fetch cap for {@link rankByPrimaryPreference}: the candidate rows for one `name_key` (all same-name places
@@ -102,7 +106,9 @@ const PRIMARY_PREFERENCE_LOG10 = 1.0
102
106
  */
103
107
  const RERANK_FETCH = 64
104
108
 
105
- /** A candidate row annotated with the {@link rankByPrimaryPreference} effective rank + the exact-tier demotion flag. */
109
+ /**
110
+ * A candidate row annotated with the {@link rankByPrimaryPreference} effective rank + the exact-tier demotion flag.
111
+ */
106
112
  export type RankedRow<R> = R & {
107
113
  /**
108
114
  * `neg_rank` plus the bounded cross-country alias penalty — the value the row is ORDERED by, and the base the emitted
@@ -144,10 +150,12 @@ export function rankByPrimaryPreference<R extends Pick<CandidateRow, "neg_rank"
144
150
  }
145
151
 
146
152
  const topCountry = topPrimary?.country_id
153
+
147
154
  // A cross-country alias (different country than the top primary) is penalized; it is DEMOTED when even after — i.e.
148
155
  // the penalty leaves its effective rank behind the primary's raw rank (it lost the bounded population contest).
149
156
  const isCrossCountryAlias = (r: R): boolean =>
150
157
  topCountry !== undefined && r.is_primary !== 1 && r.country_id !== topCountry
158
+
151
159
  const annotate = (r: R): RankedRow<R> => {
152
160
  const penalized = isCrossCountryAlias(r)
153
161
  const effectiveNegRank = r.neg_rank + (penalized ? delta : 0)
@@ -159,6 +167,7 @@ export function rankByPrimaryPreference<R extends Pick<CandidateRow, "neg_rank"
159
167
  rows
160
168
  .map((r, i) => ({ row: annotate(r), i }))
161
169
  // Effective rank ASC; ties keep population order, then original index (stable).
170
+ // oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
162
171
  .sort((a, b) => a.row.effectiveNegRank - b.row.effectiveNegRank || a.row.neg_rank - b.row.neg_rank || a.i - b.i)
163
172
  .slice(0, limit)
164
173
  .map((x) => x.row)
@@ -260,6 +269,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
260
269
  this.#ftsProbe = this.#db.prepare(
261
270
  `SELECT name_key FROM ${CANDIDATE_FTS_TABLE} WHERE ${CANDIDATE_FTS_TABLE} MATCH ? ORDER BY bm25(${CANDIDATE_FTS_TABLE}) LIMIT ?`
262
271
  )
272
+
263
273
  this.#nameKeyExistsProbe = this.#db.prepare("SELECT 1 FROM candidate WHERE name_key = ? LIMIT 1")
264
274
  }
265
275
 
@@ -269,7 +279,9 @@ export class WOFCandidateTableLookup implements PlaceLookup {
269
279
  this.artifactCoverage = readGazetteerCoverageManifest(this.#db)
270
280
  }
271
281
 
272
- /** Does this query want a locality-tier place? Postal-city aliases (#741) are all localities. */
282
+ /**
283
+ * Does this query want a locality-tier place? Postal-city aliases (#741) are all localities.
284
+ */
273
285
  #wantsLocality(placetype: FindPlaceQuery["placetype"]): boolean {
274
286
  if (!placetype) return true
275
287
  const want = Array.isArray(placetype) ? placetype : [placetype]
@@ -286,8 +298,9 @@ export class WOFCandidateTableLookup implements PlaceLookup {
286
298
  // form at build (the GeoNames fold normalizes '624 66' → '62466'), so a postcode-typed query
287
299
  // strips internal whitespace before keying. Postcode-only — locality names keep their spaces.
288
300
  if ([query.placetype].flat().includes("postalcode")) {
289
- text = text.replace(/\s+/g, "")
301
+ text = text.replaceAll(/\s+/g, "")
290
302
  }
303
+
291
304
  const nameKey = normalizeLocalityForKey(text)
292
305
 
293
306
  if (!nameKey) return []
@@ -337,11 +350,12 @@ export class WOFCandidateTableLookup implements PlaceLookup {
337
350
  // Shared placetype-equivalence expansion (a `locality` query must also reach borough /
338
351
  // localadmin). `postalcode` maps to no admin placetype here → empty → no rows.
339
352
  const want = Array.isArray(query.placetype) ? query.placetype : [query.placetype]
353
+
340
354
  const ids = expandPlacetypeFilter(want as readonly string[])
341
355
  .map((t) => this.#placetypeToID.get(t))
342
356
  .filter((v): v is number => v !== undefined)
343
357
 
344
- if (ids.length === 0) return []
358
+ if (!ids.length) return []
345
359
  filters.push(`placetype_id IN (${ids.map(() => "?").join(",")})`)
346
360
  filterParams.push(...ids)
347
361
  }
@@ -360,7 +374,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
360
374
  // Kept OUT of the shared `filters` so a region MISS falls back to the unscoped cascade below: a
361
375
  // country/non-region parent (no `region_id` match), a `region_id=0` row (place with no region
362
376
  // ancestor), or a wrong parent degrades to today's behavior — never worse, recall-safe by construction.
363
- const regionParentID = query.parentID ? query.parentID : undefined
377
+ const regionParentID = query.parentID || undefined
364
378
 
365
379
  const probe = (nk: string, regionID: number | undefined): Array<RankedRow<CandidateRow>> => {
366
380
  const conds = ["name_key = ?", ...filters]
@@ -379,6 +393,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
379
393
  const sql =
380
394
  "SELECT spr_id, name, country_id, placetype_id, latitude, longitude, min_lat, min_lon, max_lat, max_lon, neg_rank, is_primary " +
381
395
  `FROM candidate WHERE ${conds.join(" AND ")} ORDER BY neg_rank ASC LIMIT ?`
396
+
382
397
  const fetched = this.#db.prepare(sql).all(...params, Math.max(limit, RERANK_FETCH)) as unknown as CandidateRow[]
383
398
 
384
399
  return rankByPrimaryPreference(fetched, limit)
@@ -390,7 +405,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
390
405
  const cascade = (regionID: number | undefined): Array<RankedRow<CandidateRow>> => {
391
406
  let rows = probe(nameKey, regionID)
392
407
 
393
- if (rows.length === 0) {
408
+ if (!rows.length) {
394
409
  // Query-side qualifier-strip fallback: an OA locality with a qualifier the gazetteer's
395
410
  // canonical name omits ("Lenk im Simmental" → "Lenk", "Roche VD"). Tried ONLY on an exact
396
411
  // miss; the cascade's region bbox disambiguates any base-name ambiguity.
@@ -412,15 +427,18 @@ export class WOFCandidateTableLookup implements PlaceLookup {
412
427
  // — fuzzing it scrapes an unrelated same-filter place ("Vienna, Austria" misrouted to IT would
413
428
  // pull a tiny Italian name_key near Siena) and masks the cascade's country-agnostic retry that
414
429
  // correctly lands population-first Vienna AT. The exact/strip probes already covered the real name.
415
- if (rows.length === 0 && this.#ftsProbe && this.#nameKeyExistsProbe && !this.#nameKeyExistsProbe.get(nameKey)) {
430
+ if (!rows.length && this.#ftsProbe && this.#nameKeyExistsProbe && !this.#nameKeyExistsProbe.get(nameKey)) {
416
431
  const match = ftsTrigramQuery(nameKey)
417
432
 
418
433
  if (match) {
419
434
  const hits = this.#ftsProbe.all(match, FUZZY_FETCH) as unknown as Array<{ name_key: string }>
435
+
420
436
  const ranked = hits
421
437
  .map((h) => ({ nk: String(h.name_key), s: trigramJaccard(nameKey, String(h.name_key)) }))
422
438
  .filter((h) => h.s >= FUZZY_MIN)
439
+ // oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
423
440
  .sort((a, b) => b.s - a.s)
441
+
424
442
  const seen = new Set<string>()
425
443
 
426
444
  for (const h of ranked) {
@@ -430,6 +448,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
430
448
 
431
449
  if (rows.length >= limit) break
432
450
  }
451
+
433
452
  rows = rows.slice(0, limit)
434
453
  }
435
454
  }
@@ -442,7 +461,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
442
461
  // Region-scope fallback: if scoping to the parent region found nothing across the whole cascade, retry
443
462
  // unscoped so a place with no in-region row (missing ancestry, or a country/non-region parent) still
444
463
  // resolves exactly as it does today. Only when a region scope was actually applied.
445
- if (rows.length === 0 && regionParentID !== undefined) {
464
+ if (!rows.length && regionParentID !== undefined) {
446
465
  rows = cascade(undefined)
447
466
  }
448
467
 
@@ -492,9 +511,9 @@ export class WOFCandidateTableLookup implements PlaceLookup {
492
511
  // log10(population + 1), so popTerm is the server formula read straight off it. Constants MIRROR
493
512
  // lookup.ts's DEFAULT_WEIGHTS (biasBoost 4, populationBoost 4, populationScaleLog10 6,
494
513
  // proximityScaleKm 100) — the #861 server↔demo parity contract; keep them in lockstep.
495
- if (query.bias && query.bias.length > 0) {
496
- const BIAS_BOOST = 4.0
497
- const POP_BOOST = 4.0
514
+ if (query.bias && query.bias.length) {
515
+ const BIAS_BOOST = 4
516
+ const POP_BOOST = 4
498
517
  const POP_SCALE_LOG10 = 6
499
518
  // SHARPER than lookup.ts's 100 km on purpose: this backend's `score` is log-population ALONE
500
519
  // (no bm25 document term), so the population signal is weaker relative to the bias and the
@@ -503,6 +522,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
503
522
  // candidates the user is actually LOOKING at — an in-view namesake still wins (Dublin, OH from
504
523
  // an Ohio view), a distant one no longer does (Paris stays FR from a Michigan view).
505
524
  const PROX_SCALE_KM = 30
525
+
506
526
  const combinedProminence = (c: PlaceCandidate): number => {
507
527
  // Population base is the PENALIZED `prominence` (set above = -effectiveNegRank), not raw `score`, so
508
528
  // the cross-country primary preference carries into the bias-weighted order too — a coincidental
@@ -524,6 +544,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
524
544
 
525
545
  return popTerm + proxTerm
526
546
  }
547
+
527
548
  // Persist the combined value into `prominence` so the resolver walk's `prominence ?? score` sort (and any
528
549
  // other node consumer) honors the bias order — then sort. Stable within equal prominence (preserves the
529
550
  // population order the B-tree already gave).
@@ -533,6 +554,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
533
554
 
534
555
  return { c, i, p: c.prominence }
535
556
  })
557
+ // oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
536
558
  .sort((a, b) => b.p - a.p || a.i - b.i)
537
559
  .forEach((x, j) => (candidates[j] = x.c))
538
560
  }
@@ -23,19 +23,29 @@ import { sql, type Kysely } from "kysely"
23
23
  * level (a postcode shard row may lack a bbox).
24
24
  */
25
25
  export interface CandidateTable {
26
- /** The shared {@link normalizeLocalityForKey} of the name/alias — the probe key. */
26
+ /**
27
+ * The shared {@link normalizeLocalityForKey} of the name/alias — the probe key.
28
+ */
27
29
  name_key: string
28
- /** Small int from {@link CountryCodeTable} (shrinks the clustered key). */
30
+ /**
31
+ * Small int from {@link CountryCodeTable} (shrinks the clustered key).
32
+ */
29
33
  country_id: number
30
- /** The place's region-tier ancestor id, or 0 (carried for the future region 2-step). */
34
+ /**
35
+ * The place's region-tier ancestor id, or 0 (carried for the future region 2-step).
36
+ */
31
37
  region_id: number
32
- /** Small int from {@link PlacetypeCodeTable}. */
38
+ /**
39
+ * Small int from {@link PlacetypeCodeTable}.
40
+ */
33
41
  placetype_id: number
34
42
  /**
35
43
  * `-log10(population + 1)` — ASC order = highest-population first. 0 for postcodes (no population).
36
44
  */
37
45
  neg_rank: number
38
- /** WOF id of the place this row resolves to. */
46
+ /**
47
+ * WOF id of the place this row resolves to.
48
+ */
39
49
  spr_id: number
40
50
  name: string | null
41
51
  latitude: number | null
@@ -45,27 +55,39 @@ export interface CandidateTable {
45
55
  max_lat: number | null
46
56
  max_lon: number | null
47
57
  population: number | null
48
- /** 1 when the row is the place's canonical name (vs an alias/abbrev). */
58
+ /**
59
+ * 1 when the row is the place's canonical name (vs an alias/abbrev).
60
+ */
49
61
  is_primary: number | null
50
62
  }
51
63
 
52
- /** `(id → ISO country code)` dictionary. */
64
+ /**
65
+ * `(id → ISO country code)` dictionary.
66
+ */
53
67
  export interface CountryCodeTable {
54
68
  id: number
55
69
  code: string
56
70
  }
57
71
 
58
- /** `(id → placetype)` dictionary. */
72
+ /**
73
+ * `(id → placetype)` dictionary.
74
+ */
59
75
  export interface PlacetypeCodeTable {
60
76
  id: number
61
77
  placetype: string
62
78
  }
63
79
 
64
- /** The candidate database schema for `new DatabaseClient<CandidateDatabase>(...)`. */
80
+ /**
81
+ * The candidate database schema for `new DatabaseClient<CandidateDatabase>(...)`.
82
+ */
65
83
  export interface CandidateDatabase {
66
- /** The clustered `WITHOUT ROWID` lookup table the reader probes. */
84
+ /**
85
+ * The clustered `WITHOUT ROWID` lookup table the reader probes.
86
+ */
67
87
  candidate: CandidateTable
68
- /** Transient staging table (same columns); dropped once `candidate` is materialized. */
88
+ /**
89
+ * Transient staging table (same columns); dropped once `candidate` is materialized.
90
+ */
69
91
  cand_stage: CandidateTable
70
92
  country_codes: CountryCodeTable
71
93
  placetype_codes: PlacetypeCodeTable
@@ -105,11 +127,13 @@ export async function createCandidateStagingTables(db: Kysely<CandidateDatabase>
105
127
  .addColumn("id", "integer", (c) => c.primaryKey())
106
128
  .addColumn("code", "text", (c) => c.unique())
107
129
  .execute()
130
+
108
131
  await db.schema
109
132
  .createTable("placetype_codes")
110
133
  .addColumn("id", "integer", (c) => c.primaryKey())
111
134
  .addColumn("placetype", "text", (c) => c.unique())
112
135
  .execute()
136
+
113
137
  await db.schema
114
138
  .createTable("cand_stage")
115
139
  .addColumn("name_key", "text")
@@ -42,9 +42,15 @@ import type { DatabaseSync } from "node:sqlite"
42
42
 
43
43
  import { haversineKm } from "@mailwoman/spatial"
44
44
 
45
+ /**
46
+ * Table of places that hold more than one admin role — a locality that is also its county seat. Written by the
47
+ * gazetteer build, read by the resolver when a coincident locality has to be chosen.
48
+ */
45
49
  export const COINCIDENT_ROLES_TABLE = "coincident_roles"
46
50
 
47
- /** A place that plays multiple admin roles — one row of the relation, keyed by `admin_id`. */
51
+ /**
52
+ * A place that plays multiple admin roles — one row of the relation, keyed by `admin_id`.
53
+ */
48
54
  export interface CoincidentRole {
49
55
  localityID: number
50
56
  relationshipType: "city-state" | "capital-seat" | "consolidated-county"
@@ -54,13 +60,17 @@ export interface CoincidentRole {
54
60
  }
55
61
 
56
62
  export interface BuildCoincidentRolesOpts {
57
- /** Drop + rebuild the table if it already exists. Default true (the build is cheap + idempotent). */
63
+ /**
64
+ * Drop + rebuild the table if it already exists. Default true (the build is cheap + idempotent).
65
+ */
58
66
  drop?: boolean
59
67
  /**
60
68
  * Relative tolerance: a pair is kept when centroid distance ≤ `toleranceFraction × bbox-diagonal`. Default 0.15.
61
69
  */
62
70
  toleranceFraction?: number
63
- /** Floor (km) under the relative tolerance, so small-bbox city-states still qualify. Default 12. */
71
+ /**
72
+ * Floor (km) under the relative tolerance, so small-bbox city-states still qualify. Default 12.
73
+ */
64
74
  minToleranceKm?: number
65
75
  /**
66
76
  * Centroid distance (km) below which a region-tier pair is classed `city-state` (metadata only). Default 2.
@@ -115,7 +125,9 @@ export function buildCoincidentRoles(
115
125
  onProgress("dropping", COINCIDENT_ROLES_TABLE)
116
126
  db.exec(`DROP TABLE ${COINCIDENT_ROLES_TABLE}`)
117
127
  }
128
+
118
129
  onProgress("creating", COINCIDENT_ROLES_TABLE)
130
+
119
131
  // Raw DDL by design: this is a sync builder consumed by a sync CLI (build-coincident-roles-cli) and
120
132
  // 6 sync unit tests, so routing one table through async Kysely would cascade async through all of
121
133
  // them for no real gain. See AGENTS.md "Database / inline SQL". (The SELECT + INSERT loop below are
@@ -133,6 +145,7 @@ export function buildCoincidentRoles(
133
145
  `)
134
146
 
135
147
  onProgress("scanning")
148
+
136
149
  // Admin (region/county tier) ⋈ same-name DESCENDANT locality. `place_population` is optional (LEFT
137
150
  // JOIN → 0 when absent). The relative-tolerance filter + relationship classification happen in JS so
138
151
  // the SQL stays a plain join. `spr` exposes the bbox columns we need for the diagonal.
@@ -153,11 +166,13 @@ export function buildCoincidentRoles(
153
166
  .all() as unknown as CandidateRow[]
154
167
 
155
168
  onProgress("filtering", `${candidates.length} candidates`)
169
+
156
170
  const insert = db.prepare(
157
171
  `INSERT OR REPLACE INTO ${COINCIDENT_ROLES_TABLE}
158
172
  (admin_id, locality_id, relationship_type, admin_placetype, distance_km, locality_population)
159
173
  VALUES (?, ?, ?, ?, ?, ?)`
160
174
  )
175
+
161
176
  const byCountry: Record<string, number> = {}
162
177
  let rowCount = 0
163
178
  db.exec("BEGIN")
@@ -176,14 +191,17 @@ export function buildCoincidentRoles(
176
191
  // dominated by French cantons / JP counties that don't hit the parser-drops-locality failure.
177
192
  const relationshipType = dist <= cityStateMaxKm ? "city-state" : "capital-seat"
178
193
  insert.run(c.admin_id, c.locality_id, relationshipType, c.admin_placetype, dist, c.pop)
194
+
179
195
  rowCount++
180
196
  byCountry[c.country] = (byCountry[c.country] ?? 0) + 1
181
197
  }
198
+
182
199
  db.exec("COMMIT")
183
- } catch (err) {
200
+ } catch (error) {
184
201
  db.exec("ROLLBACK")
185
- throw err
202
+ throw error
186
203
  }
204
+
187
205
  db.exec(`CREATE INDEX IF NOT EXISTS coincident_roles_by_admin ON ${COINCIDENT_ROLES_TABLE} (admin_id)`)
188
206
 
189
207
  onProgress("done", `${rowCount} coincident-role rows`)
@@ -191,7 +209,9 @@ export function buildCoincidentRoles(
191
209
  return { created: true, rowCount, byCountry, durationMs: Date.now() - start }
192
210
  }
193
211
 
194
- /** True iff the relation table exists. Used by the resolver to decide whether completion can run. */
212
+ /**
213
+ * True iff the relation table exists. Used by the resolver to decide whether completion can run.
214
+ */
195
215
  export function coincidentRolesExists(db: DatabaseSync): boolean {
196
216
  return tableExists(db, COINCIDENT_ROLES_TABLE)
197
217
  }
@@ -205,6 +225,7 @@ export function loadCoincidentRoles(db: DatabaseSync): Map<number, CoincidentRol
205
225
  const map = new Map<number, CoincidentRole[]>()
206
226
 
207
227
  if (!coincidentRolesExists(db)) return map
228
+
208
229
  const rows = db
209
230
  .prepare(
210
231
  `SELECT admin_id, locality_id, relationship_type, admin_placetype, distance_km, locality_population
@@ -227,6 +248,7 @@ export function loadCoincidentRoles(db: DatabaseSync): Map<number, CoincidentRol
227
248
  distanceKm: r.distance_km,
228
249
  population: r.locality_population,
229
250
  }
251
+
230
252
  const list = map.get(r.admin_id)
231
253
 
232
254
  if (list) {
package/convention.ts CHANGED
@@ -38,7 +38,9 @@ export interface ScoringWeights {
38
38
  * `tokenNormalization`, etc.
39
39
  */
40
40
  export interface Convention {
41
- /** Ordered strategy names the dispatcher runs; the first to return a non-null result wins. */
41
+ /**
42
+ * Ordered strategy names the dispatcher runs; the first to return a non-null result wins.
43
+ */
42
44
  candidateStrategies?: string[]
43
45
  /**
44
46
  * Weights for `postcode_area_resolution`'s soft-score. Partial — a layer may nudge one weight and inherit the rest
@@ -35,45 +35,74 @@ import { sql, type Kysely } from "kysely"
35
35
 
36
36
  import { hasTable } from "./sqlite-utils.ts"
37
37
 
38
- /** One country's hard-filter coverage measurement — the storage form of {@link CountryCoverageFact}. */
38
+ /**
39
+ * One country's hard-filter coverage measurement — the storage form of {@link CountryCoverageFact}.
40
+ */
39
41
  export interface CountryCoverageTable {
40
- /** ISO 3166-1 alpha-2, uppercase (PK). */
42
+ /**
43
+ * ISO 3166-1 alpha-2, uppercase (PK).
44
+ */
41
45
  country: string
42
- /** 0/1 — the promote-gate verdict (a verdict column, NOT re-derived from the rate; see the fact type's docstring). */
46
+ /**
47
+ * 0/1 — the promote-gate verdict (a verdict column, NOT re-derived from the rate; see the fact type's docstring).
48
+ */
43
49
  hard_filter_safe: number
44
- /** Measured hard-resolve rate 0..1 on the panel named in `source`; NULL when the receipt recorded none. */
50
+ /**
51
+ * Measured hard-resolve rate 0..1 on the panel named in `source`; NULL when the receipt recorded none.
52
+ */
45
53
  hard_resolve_rate: number | null
46
- /** Panel size behind `hard_resolve_rate`; NULL when unrecorded. */
54
+ /**
55
+ * Panel size behind `hard_resolve_rate`; NULL when unrecorded.
56
+ */
47
57
  sample_size: number | null
48
- /** ISO-8601 date of the measurement / promote gate. */
58
+ /**
59
+ * ISO-8601 date of the measurement / promote gate.
60
+ */
49
61
  measured_at: string
50
- /** The receipt: which panel/gate produced this row. */
62
+ /**
63
+ * The receipt: which panel/gate produced this row.
64
+ */
51
65
  source: string
52
66
  }
53
67
 
54
- /** One country's coarse guard-B bounding box — the storage form of {@link CountryBBoxFact}. */
68
+ /**
69
+ * One country's coarse guard-B bounding box — the storage form of {@link CountryBBoxFact}.
70
+ */
55
71
  export interface CountryBBoxTable {
56
- /** ISO 3166-1 alpha-2, uppercase (PK). */
72
+ /**
73
+ * ISO 3166-1 alpha-2, uppercase (PK).
74
+ */
57
75
  country: string
58
76
  lat_min: number
59
77
  lat_max: number
60
78
  lon_min: number
61
79
  lon_max: number
62
- /** Provenance of the box (harness + date). */
80
+ /**
81
+ * Provenance of the box (harness + date).
82
+ */
63
83
  source: string
64
84
  }
65
85
 
66
- /** The coverage-manifest schema for `new DatabaseClient<GazetteerCoverageDatabase>(...)`. */
86
+ /**
87
+ * The coverage-manifest schema for `new DatabaseClient<GazetteerCoverageDatabase>(...)`.
88
+ */
67
89
  export interface GazetteerCoverageDatabase {
68
90
  country_coverage: CountryCoverageTable
69
91
  country_bbox: CountryBBoxTable
70
92
  }
71
93
 
72
- /** Table names the lookup probes (existence-gated, so a candidate.db built before the manifest is byte-stable). */
94
+ /**
95
+ * Table names the lookup probes (existence-gated, so a candidate.db built before the manifest is byte-stable).
96
+ */
73
97
  export const COUNTRY_COVERAGE_TABLE = "country_coverage"
98
+ /**
99
+ * Table of per-country bounding boxes, used to reject a placement that fell outside its own country.
100
+ */
74
101
  export const COUNTRY_BBOX_TABLE = "country_bbox"
75
102
 
76
- /** Create `country_coverage` — a handful of small PK-probed rows, the WITHOUT ROWID sweet spot. */
103
+ /**
104
+ * Create `country_coverage` — a handful of small PK-probed rows, the WITHOUT ROWID sweet spot.
105
+ */
77
106
  export async function createCountryCoverageTable(db: Kysely<GazetteerCoverageDatabase>): Promise<void> {
78
107
  await db.schema
79
108
  .createTable(COUNTRY_COVERAGE_TABLE)
@@ -89,7 +118,9 @@ export async function createCountryCoverageTable(db: Kysely<GazetteerCoverageDat
89
118
  .execute()
90
119
  }
91
120
 
92
- /** Create `country_bbox` — same shape discipline as {@link createCountryCoverageTable}. */
121
+ /**
122
+ * Create `country_bbox` — same shape discipline as {@link createCountryCoverageTable}.
123
+ */
93
124
  export async function createCountryBBoxTable(db: Kysely<GazetteerCoverageDatabase>): Promise<void> {
94
125
  await db.schema
95
126
  .createTable(COUNTRY_BBOX_TABLE)
@@ -115,7 +146,7 @@ export async function writeGazetteerCoverageManifest(
115
146
  await createCountryCoverageTable(db)
116
147
  await createCountryBBoxTable(db)
117
148
 
118
- if (facts.coverage.length > 0) {
149
+ if (facts.coverage.length) {
119
150
  await db
120
151
  .insertInto(COUNTRY_COVERAGE_TABLE)
121
152
  .values(
@@ -131,7 +162,7 @@ export async function writeGazetteerCoverageManifest(
131
162
  .execute()
132
163
  }
133
164
 
134
- if (facts.bboxes.length > 0) {
165
+ if (facts.bboxes.length) {
135
166
  await db
136
167
  .insertInto(COUNTRY_BBOX_TABLE)
137
168
  .values(
@@ -171,6 +202,7 @@ export function readGazetteerCoverageManifest(db: DatabaseSync): GazetteerArtifa
171
202
 
172
203
  for (const row of rows) {
173
204
  const country = String(row.country).toUpperCase()
205
+
174
206
  countryCoverage.set(country, {
175
207
  country,
176
208
  hardFilterSafe: Number(row.hard_filter_safe) !== 0,
@@ -191,6 +223,7 @@ export function readGazetteerCoverageManifest(db: DatabaseSync): GazetteerArtifa
191
223
 
192
224
  for (const row of rows) {
193
225
  const country = String(row.country).toUpperCase()
226
+
194
227
  countryBBoxes.set(country, {
195
228
  country,
196
229
  latMin: Number(row.lat_min),
@@ -17,7 +17,8 @@
17
17
  * needs; without it "new yor" returns nothing useful. (#587)
18
18
  */
19
19
 
20
- import { FSTMatcher, normalizeTokens } from "./fst-matcher.ts"
20
+ import type { FSTMatcher } from "./fst-matcher.ts"
21
+ import { normalizeTokens } from "./fst-matcher.ts"
21
22
  import type { PlaceEntry } from "./fst-types.ts"
22
23
 
23
24
  export interface AutocompleteResult {
@@ -54,7 +55,9 @@ interface BfsItem {
54
55
  tokens: string[]
55
56
  }
56
57
 
57
- /** Max accepting entries collected per BFS branch — keeps one dense branch from starving the search. */
58
+ /**
59
+ * Max accepting entries collected per BFS branch — keeps one dense branch from starving the search.
60
+ */
58
61
  const PER_BRANCH = 4
59
62
 
60
63
  /**
@@ -63,7 +66,7 @@ const PER_BRANCH = 4
63
66
  function topByImportance(entries: readonly PlaceEntry[], k: number): PlaceEntry[] {
64
67
  if (entries.length <= k) return [...entries]
65
68
 
66
- return [...entries].sort((a, b) => b.importance - a.importance).slice(0, k)
69
+ return [...entries].toSorted((a, b) => b.importance - a.importance).slice(0, k)
67
70
  }
68
71
 
69
72
  /**
@@ -74,13 +77,13 @@ export function autocomplete(fst: FSTMatcher, query: string, opts: AutocompleteO
74
77
  const maxExpansionDepth = opts.maxExpansionDepth ?? 2
75
78
  const normalizedTokens = normalizeTokens(query)
76
79
 
77
- if (normalizedTokens.length === 0) {
80
+ if (!normalizedTokens.length) {
78
81
  return { query, normalizedTokens: [], depth: 0, suggestions: [] }
79
82
  }
80
83
 
81
84
  const seen = new Map<number, AutocompleteSuggestion>()
82
85
  const queue: BfsItem[] = []
83
- let depth = 0
86
+ let depth: number
84
87
 
85
88
  const match = fst.walk(normalizedTokens)
86
89
 
@@ -98,12 +101,13 @@ export function autocomplete(fst: FSTMatcher, query: string, opts: AutocompleteO
98
101
  } else {
99
102
  // PARTIAL last token — walk the complete prefix, complete the partial by prefix-filtering edges.
100
103
  const complete = normalizedTokens.slice(0, -1)
101
- const partial = normalizedTokens[normalizedTokens.length - 1]!
102
- const prefixState = complete.length === 0 ? 0 : (fst.walk(complete)?.stateID ?? undefined)
104
+ const partial = normalizedTokens.at(-1)!
105
+ const prefixState = !complete.length ? 0 : (fst.walk(complete)?.stateID ?? undefined)
103
106
 
104
107
  if (prefixState === undefined) {
105
108
  return { query, normalizedTokens, depth: 0, suggestions: [] }
106
109
  }
110
+
107
111
  depth = complete.length
108
112
 
109
113
  for (const cont of fst.continuations(prefixState)) {
@@ -113,6 +117,7 @@ export function autocomplete(fst: FSTMatcher, query: string, opts: AutocompleteO
113
117
  for (const entry of topByImportance(fst.accepting(cont.targetState), PER_BRANCH)) {
114
118
  addSuggestion(seen, entry, complete.length + 1, [cont.token])
115
119
  }
120
+
116
121
  // BFS a little past it too (multi-token completions: "new yor" → "New York Mills").
117
122
  queue.push({ stateID: cont.targetState, depth: 1, tokens: [cont.token] })
118
123
  }
@@ -122,7 +127,7 @@ export function autocomplete(fst: FSTMatcher, query: string, opts: AutocompleteO
122
127
  // branch contributes only its top PER_BRANCH places: a state like "new london" has dozens of
123
128
  // accepting entries and would otherwise blow the budget before the BFS ever reaches "new york"
124
129
  // (the "new" state has 311 continuations). Per-branch capping keeps the search broad. (#587)
125
- while (queue.length > 0 && seen.size < maxSuggestions * 4) {
130
+ while (queue.length && seen.size < maxSuggestions * 4) {
126
131
  const item = queue.shift()!
127
132
 
128
133
  if (item.depth > maxExpansionDepth) continue
@@ -138,7 +143,7 @@ export function autocomplete(fst: FSTMatcher, query: string, opts: AutocompleteO
138
143
  }
139
144
  }
140
145
 
141
- let suggestions = [...seen.values()].sort((a, b) => b.importance - a.importance)
146
+ let suggestions = [...seen.values()].toSorted((a, b) => b.importance - a.importance)
142
147
 
143
148
  if (opts.dedupeByName) {
144
149
  suggestions = dedupeByName(suggestions)
@@ -156,6 +161,7 @@ function addSuggestion(
156
161
  const existing = seen.get(entry.wofID)
157
162
 
158
163
  if (existing && existing.matchDepth <= matchDepth) return
164
+
159
165
  seen.set(entry.wofID, {
160
166
  name: entry.name,
161
167
  placetype: entry.placetype,