@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/reverse.ts CHANGED
@@ -35,6 +35,16 @@ import { PLACE_BBOX_TABLE } from "./fts.ts"
35
35
  import { geometryContains, haversineKm, type GeojsonGeometry } from "./geo.ts"
36
36
  import type { PlaceCandidate, WOFPlacetype } from "./types.ts"
37
37
 
38
+ /**
39
+ * Largest absolute latitude in WGS-84 degrees.
40
+ */
41
+ const MAX_ABS_LATITUDE = 90
42
+
43
+ /**
44
+ * Largest absolute longitude in WGS-84 degrees.
45
+ */
46
+ const MAX_ABS_LONGITUDE = 180
47
+
38
48
  /**
39
49
  * How the deepest returned place was confirmed:
40
50
  *
@@ -52,7 +62,9 @@ export interface ReverseGeocodeResult {
52
62
  * ocean, or outside the gazetteer's coverage).
53
63
  */
54
64
  hierarchy: PlaceCandidate[]
55
- /** Containment kind of the DEEPEST place in `hierarchy` (see {@link ContainmentKind}). */
65
+ /**
66
+ * Containment kind of the DEEPEST place in `hierarchy` (see {@link ContainmentKind}).
67
+ */
56
68
  containment: ContainmentKind
57
69
  }
58
70
 
@@ -62,14 +74,18 @@ export interface WOFReverseGeocoderOpts {
62
74
  * package-built `place_bbox` R*Tree (`mailwoman gazetteer build fts`). Mutually exclusive with `adminDatabase`.
63
75
  */
64
76
  adminDBPath?: string
65
- /** Pre-opened admin DB — primarily for tests against an inline fixture. */
77
+ /**
78
+ * Pre-opened admin DB — primarily for tests against an inline fixture.
79
+ */
66
80
  adminDatabase?: DatabaseSync
67
81
  /**
68
82
  * Path to the polygon sidecar DB (`wof-polygons.db`, table `polygons(id, geom)`). OPTIONAL — without it every result
69
83
  * is `containment: "approximate"` (centroid-only mode). Mutually exclusive with `polygonDatabase`.
70
84
  */
71
85
  polygonDBPath?: string
72
- /** Pre-opened polygon DB — primarily for tests. */
86
+ /**
87
+ * Pre-opened polygon DB — primarily for tests.
88
+ */
73
89
  polygonDatabase?: DatabaseSync
74
90
  }
75
91
 
@@ -108,7 +124,9 @@ const DESCENT_TIERS: readonly WOFPlacetype[] = [
108
124
  "microhood",
109
125
  ]
110
126
 
111
- /** Internal candidate row off `spr` (+ optional bbox area / centroid distance bookkeeping). */
127
+ /**
128
+ * Internal candidate row off `spr` (+ optional bbox area / centroid distance bookkeeping).
129
+ */
112
130
  interface CandidateRow {
113
131
  id: number
114
132
  name: string
@@ -167,8 +185,10 @@ export class WOFReverseGeocoder implements Disposable {
167
185
 
168
186
  this.#admin = opts.adminDatabase ?? new DatabaseSync(opts.adminDBPath!, { readOnly: true })
169
187
  this.#ownsAdmin = !opts.adminDatabase
188
+
170
189
  this.#polygons =
171
190
  opts.polygonDatabase ?? (opts.polygonDBPath ? new DatabaseSync(opts.polygonDBPath, { readOnly: true }) : null)
191
+
172
192
  this.#ownsPolygons = !opts.polygonDatabase && Boolean(opts.polygonDBPath)
173
193
 
174
194
  // Fail loudly up front — the R*Tree is a build artifact, not part of the upstream WOF
@@ -213,9 +233,15 @@ export class WOFReverseGeocoder implements Disposable {
213
233
  * whose `POIIntentOutcome` return type is synchronous by contract — see `poi-intent.ts`'s `deps.execute`).
214
234
  */
215
235
  reverseGeocodeSync(lat: number, lon: number, opts: ReverseGeocodeOpts = {}): ReverseGeocodeResult {
216
- if (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180) {
236
+ if (
237
+ !Number.isFinite(lat) ||
238
+ !Number.isFinite(lon) ||
239
+ Math.abs(lat) > MAX_ABS_LATITUDE ||
240
+ Math.abs(lon) > MAX_ABS_LONGITUDE
241
+ ) {
217
242
  throw new RangeError(`WOFReverseGeocoder.reverseGeocode: (${lat}, ${lon}) is not a WGS-84 coordinate`)
218
243
  }
244
+
219
245
  const maxApproximateKm = opts.maxApproximateKm ?? DEFAULT_MAX_APPROXIMATE_KM
220
246
  const candidates = this.#bboxCandidates(lat, lon, opts)
221
247
 
@@ -231,6 +257,7 @@ export class WOFReverseGeocoder implements Disposable {
231
257
  if (contains === true) {
232
258
  winner = c
233
259
  winnerConfirmed = true
260
+
234
261
  break
235
262
  }
236
263
 
@@ -277,6 +304,7 @@ export class WOFReverseGeocoder implements Disposable {
277
304
  next = k
278
305
  nextConfirmed = true
279
306
  nextKm = undefined
307
+
280
308
  break
281
309
  }
282
310
 
@@ -302,8 +330,7 @@ export class WOFReverseGeocoder implements Disposable {
302
330
  // Hierarchy assembly via the shared ancestor walk. If the descent crossed an ancestry gap
303
331
  // (the deepest place's recorded lineage misses the PIP root), merge the root's own chain so
304
332
  // region/country are always present when a polygon confirmed them.
305
- const byID = new Map<number, PlaceCandidate>()
306
- byID.set(current.id, toPlaceCandidate(current, currentDistanceKm))
333
+ const byID = new Map<number, PlaceCandidate>([[current.id, toPlaceCandidate(current, currentDistanceKm)]])
307
334
 
308
335
  for (const a of ancestorLineage(this.#admin, current.id)) {
309
336
  if (!byID.has(a.id)) {
@@ -320,6 +347,7 @@ export class WOFReverseGeocoder implements Disposable {
320
347
  }
321
348
  }
322
349
  }
350
+
323
351
  const hierarchy = [...byID.values()]
324
352
 
325
353
  if (opts.placetypes) {
@@ -331,12 +359,15 @@ export class WOFReverseGeocoder implements Disposable {
331
359
  }
332
360
  }
333
361
  }
362
+
334
363
  hierarchy.sort((a, b) => placetypeDepth(b.placetype) - placetypeDepth(a.placetype))
335
364
 
336
365
  return { hierarchy, containment: currentConfirmed ? "polygon" : "approximate" }
337
366
  }
338
367
 
339
- /** Bbox candidates containing the point, smallest-area-first, via the `place_bbox` R*Tree. */
368
+ /**
369
+ * Bbox candidates containing the point, smallest-area-first, via the `place_bbox` R*Tree.
370
+ */
340
371
  #bboxCandidates(lat: number, lon: number, opts: ReverseGeocodeOpts): CandidateRow[] {
341
372
  const where: string[] = [
342
373
  "bbox.min_lat <= ?",
@@ -346,12 +377,14 @@ export class WOFReverseGeocoder implements Disposable {
346
377
  "spr.is_current != 0",
347
378
  "spr.is_deprecated = 0",
348
379
  ]
380
+
349
381
  const params: Array<number | string> = [lat, lat, lon, lon]
350
382
 
351
- if (opts.placetypes && opts.placetypes.length > 0) {
383
+ if (opts.placetypes && opts.placetypes.length) {
352
384
  where.push(`spr.placetype IN (${opts.placetypes.map(() => "?").join(", ")})`)
353
385
  params.push(...opts.placetypes)
354
386
  }
387
+
355
388
  params.push(opts.maxCandidates ?? DEFAULT_MAX_CANDIDATES)
356
389
 
357
390
  return this.#admin
@@ -398,7 +431,9 @@ export class WOFReverseGeocoder implements Disposable {
398
431
  ) as unknown as CandidateRow[]
399
432
  }
400
433
 
401
- /** Parsed GeoJSON geometry for a WOF id, or null when absent / unparseable / no polygon DB. */
434
+ /**
435
+ * Parsed GeoJSON geometry for a WOF id, or null when absent / unparseable / no polygon DB.
436
+ */
402
437
  #geometry(id: number): GeojsonGeometry | null {
403
438
  if (!this.#polygons) return null
404
439
  const cached = this.#geometryCache.get(id)
@@ -408,6 +443,7 @@ export class WOFReverseGeocoder implements Disposable {
408
443
  if (this.#geometryCache.size >= WOFReverseGeocoder.#GEOMETRY_CACHE_CAP) {
409
444
  this.#geometryCache.clear()
410
445
  }
446
+
411
447
  const row = this.#polygons.prepare(`SELECT geom FROM polygons WHERE id = ?`).get(id) as { geom: string } | undefined
412
448
  let geometry: GeojsonGeometry | null = null
413
449
 
@@ -418,6 +454,7 @@ export class WOFReverseGeocoder implements Disposable {
418
454
  geometry = null // malformed row — treat as no-polygon rather than failing the query
419
455
  }
420
456
  }
457
+
421
458
  this.#geometryCache.set(id, geometry)
422
459
 
423
460
  return geometry
package/sharding.ts CHANGED
@@ -47,7 +47,7 @@ export function deriveSchemaName(path: string): string {
47
47
  .replace(/^whosonfirst-data-/u, "")
48
48
  .replace(/-latest\.db$/u, "")
49
49
  .replace(/\.db$/u, "")
50
- .replace(/[^a-zA-Z0-9_]/g, "_")
50
+ .replaceAll(/[^a-zA-Z0-9_]/g, "_")
51
51
 
52
52
  if (!stem) {
53
53
  throw new Error(`deriveSchemaName: could not derive a SQL schema name from path ${JSON.stringify(path)}`)
@@ -86,7 +86,9 @@ export interface ResolvedShard {
86
86
  placetypes: readonly string[]
87
87
  }
88
88
 
89
- /** SQLite identifier regex — `[A-Za-z_][A-Za-z0-9_]*`. */
89
+ /**
90
+ * SQLite identifier regex — `[A-Za-z_][A-Za-z0-9_]*`.
91
+ */
90
92
  const SQLITE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/u
91
93
 
92
94
  /**
@@ -99,7 +101,7 @@ const SQLITE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/u
99
101
  export function resolveShards(input: string | ReadonlyArray<string | ShardConfig>): ResolvedShard[] {
100
102
  const list = typeof input === "string" ? [input] : input
101
103
 
102
- if (list.length === 0) throw new Error("resolveShards: at least one shard is required")
104
+ if (!list.length) throw new Error("resolveShards: at least one shard is required")
103
105
 
104
106
  const seen = new Set<string>()
105
107
  const out: ResolvedShard[] = []
@@ -116,6 +118,7 @@ export function resolveShards(input: string | ReadonlyArray<string | ShardConfig
116
118
  `{ path, schemaName } to override.`
117
119
  )
118
120
  }
121
+
119
122
  // The first shard is always main per SQLite semantics — its derived name is informational
120
123
  // only. Subsequent shards must have unique non-main names.
121
124
  const schemaName = i === 0 ? "main" : derived
@@ -126,7 +129,9 @@ export function resolveShards(input: string | ReadonlyArray<string | ShardConfig
126
129
  `(either with "main" or another shard). Pass an explicit { path, schemaName }.`
127
130
  )
128
131
  }
132
+
129
133
  seen.add(schemaName)
134
+
130
135
  out.push({
131
136
  path: cfg.path,
132
137
  schemaName,
@@ -179,7 +184,7 @@ export function pickShardsForPlacetype(shards: ResolvedShard[], placetype: strin
179
184
  }
180
185
  }
181
186
 
182
- return matches.length > 0 ? matches : [shards[0]!]
187
+ return matches.length ? matches : [shards[0]!]
183
188
  }
184
189
 
185
190
  export function pickShardForPlacetype(
@@ -194,7 +199,9 @@ export function pickShardForPlacetype(
194
199
  * order remains the tiebreak when no shard claims the country (or none was probed).
195
200
  */
196
201
  country?: string
197
- /** Per-schema probed country sets (see `WOFSqlitePlaceLookup`'s construction probe). */
202
+ /**
203
+ * Per-schema probed country sets (see `WOFSqlitePlaceLookup`'s construction probe).
204
+ */
198
205
  countriesBySchema?: ReadonlyMap<string, ReadonlySet<string>>
199
206
  }
200
207
  ): ResolvedShard {
@@ -223,7 +230,7 @@ export function pickShardForPlacetype(
223
230
  }
224
231
  }
225
232
 
226
- if (matches.length === 0) return shards[0]!
233
+ if (!matches.length) return shards[0]!
227
234
 
228
235
  if (opts?.country && opts.countriesBySchema) {
229
236
  for (const s of matches) {
@@ -22,7 +22,9 @@ import { ADDRESS_CONVENTION_TABLE, type Convention, type ConventionSource } from
22
22
  export class SqliteConventionSource implements ConventionSource {
23
23
  readonly #db: DatabaseSync
24
24
  readonly #schema: string
25
- /** Memoize per-id lookups (including misses, as `null`) so a hot ancestor chain is queried once. */
25
+ /**
26
+ * Memoize per-id lookups (including misses, as `null`) so a hot ancestor chain is queried once.
27
+ */
26
28
  readonly #cache = new Map<number, Convention | null>()
27
29
 
28
30
  /**
@@ -54,6 +56,7 @@ export class SqliteConventionSource implements ConventionSource {
54
56
  // WORLD_DEFAULT). The build script validates structure, so this is purely defensive.
55
57
  value = null
56
58
  }
59
+
57
60
  this.#cache.set(wofID, value)
58
61
 
59
62
  return value ?? undefined
@@ -29,27 +29,45 @@ import type { Kysely } from "kysely"
29
29
  * bbox diagonal into an honest `uncertainty_m`).
30
30
  */
31
31
  export interface StreetCentroidTable {
32
- /** Shared `normalizeStreetForKeyLocale` of the street — the build/query-consistent probe key. */
32
+ /**
33
+ * Shared `normalizeStreetForKeyLocale` of the street — the build/query-consistent probe key.
34
+ */
33
35
  street_norm: string
34
- /** The 5-digit postcode of this group, or null when the source row carried none. */
36
+ /**
37
+ * The 5-digit postcode of this group, or null when the source row carried none.
38
+ */
35
39
  postcode: string | null
36
- /** Arrondissement-stripped commune (`stripArrondissement(normalizeLocalityForKey(commune))`) — the fallback scope. */
40
+ /**
41
+ * Arrondissement-stripped commune (`stripArrondissement(normalizeLocalityForKey(commune))`) — the fallback scope.
42
+ */
37
43
  locality_base: string
38
- /** Weighted-mean centroid latitude of the street's member points. */
44
+ /**
45
+ * Weighted-mean centroid latitude of the street's member points.
46
+ */
39
47
  lat: number
40
- /** Weighted-mean centroid longitude of the street's member points. */
48
+ /**
49
+ * Weighted-mean centroid longitude of the street's member points.
50
+ */
41
51
  lon: number
42
52
  min_lat: number
43
53
  max_lat: number
44
54
  min_lon: number
45
55
  max_lon: number
46
- /** Member address-point count — the weight for a cross-group centroid aggregate. */
56
+ /**
57
+ * Member address-point count — the weight for a cross-group centroid aggregate.
58
+ */
47
59
  point_count: number
48
- /** A representative street name as it appeared in the source (display / debugging). */
60
+ /**
61
+ * A representative street name as it appeared in the source (display / debugging).
62
+ */
49
63
  street_raw: string
50
- /** Provenance: the register this street was derived from (e.g. `ban:fr`). */
64
+ /**
65
+ * Provenance: the register this street was derived from (e.g. `ban:fr`).
66
+ */
51
67
  source: string
52
- /** The pinned data release the underlying points came from. */
68
+ /**
69
+ * The pinned data release the underlying points came from.
70
+ */
53
71
  release: string
54
72
  /**
55
73
  * #727 phase-4c: `foldStreetSurface(street_raw)` — the contract-fold street-NAME existence key for
@@ -61,7 +79,9 @@ export interface StreetCentroidTable {
61
79
  name_key: string
62
80
  }
63
81
 
64
- /** The street-centroid database schema for `new DatabaseClient<StreetCentroidDatabase>(...)`. */
82
+ /**
83
+ * The street-centroid database schema for `new DatabaseClient<StreetCentroidDatabase>(...)`.
84
+ */
65
85
  export interface StreetCentroidDatabase {
66
86
  street_centroid: StreetCentroidTable
67
87
  }
@@ -87,7 +107,9 @@ export const STREET_CENTROID_COLUMNS = [
87
107
  "name_key",
88
108
  ] as const
89
109
 
90
- /** Create the `street_centroid` table — called before the streaming bulk load. */
110
+ /**
111
+ * Create the `street_centroid` table — called before the streaming bulk load.
112
+ */
91
113
  export async function createStreetCentroidTable(db: Kysely<StreetCentroidDatabase>): Promise<void> {
92
114
  await db.schema
93
115
  .createTable("street_centroid")
@@ -115,10 +137,12 @@ export async function createStreetCentroidTable(db: Kysely<StreetCentroidDatabas
115
137
  */
116
138
  export async function createStreetCentroidIndexes(db: Kysely<StreetCentroidDatabase>): Promise<void> {
117
139
  await db.schema.createIndex("idx_sc_postcode").on("street_centroid").columns(["postcode", "street_norm"]).execute()
140
+
118
141
  await db.schema
119
142
  .createIndex("idx_sc_locality")
120
143
  .on("street_centroid")
121
144
  .columns(["locality_base", "street_norm"])
122
145
  .execute()
146
+
123
147
  await db.schema.createIndex("idx_sc_name").on("street_centroid").columns(["name_key"]).execute()
124
148
  }
@@ -31,7 +31,9 @@ import {
31
31
  stripArrondissement,
32
32
  } from "./street-normalize.ts"
33
33
 
34
- /** The weighted-centroid + extent + provenance an aggregate probe projects. `lat` is null when nothing matched. */
34
+ /**
35
+ * The weighted-centroid + extent + provenance an aggregate probe projects. `lat` is null when nothing matched.
36
+ */
35
37
  interface AggRow {
36
38
  lat: number | null
37
39
  lon: number | null
@@ -43,14 +45,18 @@ interface AggRow {
43
45
  release: string | null
44
46
  }
45
47
 
46
- /** Weighted-centroid aggregate over a WHERE-filtered set. `SUM(coord*n)/SUM(n)` reconstructs the grand centroid. */
48
+ /**
49
+ * Weighted-centroid aggregate over a WHERE-filtered set. `SUM(coord*n)/SUM(n)` reconstructs the grand centroid.
50
+ */
47
51
  const AGG_SELECT =
48
52
  "SUM(lat * point_count) / SUM(point_count) AS lat, " +
49
53
  "SUM(lon * point_count) / SUM(point_count) AS lon, " +
50
54
  "MIN(min_lat) AS min_lat, MAX(max_lat) AS max_lat, MIN(min_lon) AS min_lon, MAX(max_lon) AS max_lon, " +
51
55
  "MAX(source) AS source, MAX(release) AS release"
52
56
 
53
- /** Half the bbox diagonal, in METERS — an honest coarse radius for a street centroid. */
57
+ /**
58
+ * Half the bbox diagonal, in METERS — an honest coarse radius for a street centroid.
59
+ */
54
60
  function extentRadiusM(minLat: number, maxLat: number, minLon: number, maxLon: number): number {
55
61
  const R = 6_371_000
56
62
  const toRad = (d: number): number => (d * Math.PI) / 180
@@ -84,6 +90,7 @@ export class StreetCentroidSqliteLookup implements StreetCentroidLookup {
84
90
  this.#byPostcode = this.#db.prepare(
85
91
  `SELECT ${AGG_SELECT} FROM street_centroid WHERE postcode = ? AND street_norm = ?`
86
92
  )
93
+
87
94
  this.#byLocality = this.#db.prepare(
88
95
  `SELECT ${AGG_SELECT} FROM street_centroid WHERE locality_base = ? AND street_norm = ?`
89
96
  )
@@ -40,7 +40,9 @@ const STREET_AFFIX_WOFID_BASE = 1_900_000_000
40
40
  const STREET_TYPES_FILENAME = "street_types.txt"
41
41
 
42
42
  export interface BuildStreetMorphologyFSTOpts {
43
- /** Path to the `core/data/libpostal/dictionaries` directory containing per-locale subfolders. */
43
+ /**
44
+ * Path to the `core/data/libpostal/dictionaries` directory containing per-locale subfolders.
45
+ */
44
46
  dictionariesDir: string
45
47
  /**
46
48
  * Optional locale filter — only ingest these locale subfolders. Defaults to all that have a `street_types.txt`.
@@ -56,7 +58,9 @@ export interface BuildStreetMorphologyFSTOpts {
56
58
  * `rue`, `str`) while filtering out the noise.
57
59
  */
58
60
  minVariantLength?: number
59
- /** Optional progress callback. */
61
+ /**
62
+ * Optional progress callback.
63
+ */
60
64
  onProgress?: (phase: string, detail?: string) => void
61
65
  }
62
66
 
@@ -78,13 +82,14 @@ export interface BuildStreetMorphologyFSTResult {
78
82
  function parseLine(line: string): { canonical: string; variants: string[] } | null {
79
83
  const trimmed = line.trim()
80
84
 
81
- if (trimmed.length === 0 || trimmed.startsWith("#")) return null
85
+ if (!trimmed.length || trimmed.startsWith("#")) return null
86
+
82
87
  const parts = trimmed
83
88
  .split("|")
84
89
  .map((s) => s.trim())
85
90
  .filter((s) => s.length > 0)
86
91
 
87
- if (parts.length === 0) return null
92
+ if (!parts.length) return null
88
93
 
89
94
  return { canonical: parts[0]!, variants: parts }
90
95
  }
@@ -96,7 +101,8 @@ export function buildStreetMorphologyFST(opts: BuildStreetMorphologyFSTOpts): Bu
96
101
  // Discover locales — either provided explicitly, or all directories containing street_types.txt.
97
102
  let locales: string[]
98
103
 
99
- if (opts.locales && opts.locales.length > 0) {
104
+ // oxlint-disable-next-line unicorn/prefer-ternary -- the else branch is a multi-line directory scan
105
+ if (opts.locales && opts.locales.length) {
100
106
  locales = opts.locales
101
107
  } else {
102
108
  locales = readdirSync(opts.dictionariesDir).filter((entry) => {
@@ -113,6 +119,7 @@ export function buildStreetMorphologyFST(opts: BuildStreetMorphologyFSTOpts): Bu
113
119
  }
114
120
  })
115
121
  }
122
+
116
123
  progress("discover", `Found ${locales.length} locales with ${STREET_TYPES_FILENAME}`)
117
124
 
118
125
  // Collect canonical → set-of-variants across all locales. Same canonical form may appear in
@@ -132,13 +139,15 @@ export function buildStreetMorphologyFST(opts: BuildStreetMorphologyFSTOpts): Bu
132
139
  for (const variant of parsed.variants) {
133
140
  existing.add(variant)
134
141
  }
142
+
135
143
  canonicalToVariants.set(parsed.canonical, existing)
136
144
  }
137
145
  }
146
+
138
147
  progress("collect", `Collected ${canonicalToVariants.size} canonical affixes`)
139
148
 
140
149
  // Assign stable synthetic wofIDs. Sort canonicals for determinism.
141
- const sortedCanonicals = [...canonicalToVariants.keys()].sort()
150
+ const sortedCanonicals = [...canonicalToVariants.keys()].toSorted()
142
151
  const canonicalToWOFID = new Map<string, number>()
143
152
 
144
153
  for (let i = 0; i < sortedCanonicals.length; i++) {
@@ -150,7 +159,7 @@ export function buildStreetMorphologyFST(opts: BuildStreetMorphologyFSTOpts): Bu
150
159
  const nodes: FSTNode[] = [{ edges: new Map(), places: [] }]
151
160
 
152
161
  function insertName(tokens: string[], entry: PlaceEntry): void {
153
- if (tokens.length === 0) return
162
+ if (!tokens.length) return
154
163
  let stateID = 0
155
164
 
156
165
  for (const t of tokens) {
@@ -162,8 +171,10 @@ export function buildStreetMorphologyFST(opts: BuildStreetMorphologyFSTOpts): Bu
162
171
  nodes.push({ edges: new Map(), places: [] })
163
172
  node.edges.set(t, next)
164
173
  }
174
+
165
175
  stateID = next
166
176
  }
177
+
167
178
  const existing = nodes[stateID]!.places
168
179
 
169
180
  if (!existing.some((p) => p.wofID === entry.wofID && p.placetype === entry.placetype)) {
@@ -177,6 +188,7 @@ export function buildStreetMorphologyFST(opts: BuildStreetMorphologyFSTOpts): Bu
177
188
  for (const canonical of sortedCanonicals) {
178
189
  const variants = canonicalToVariants.get(canonical)!
179
190
  const wofID = canonicalToWOFID.get(canonical)!
191
+
180
192
  const entry: PlaceEntry = {
181
193
  wofID,
182
194
  placetype: "street_affix",
@@ -185,7 +197,7 @@ export function buildStreetMorphologyFST(opts: BuildStreetMorphologyFSTOpts): Bu
185
197
  // Fixed importance: street affixes are structurally unambiguous (Avenue is almost never
186
198
  // anything but street-typing). The morphology prior caps bias separately; this value
187
199
  // just feeds the cap formula `importance * cap`.
188
- importance: 1.0,
200
+ importance: 1,
189
201
  lat: 0,
190
202
  lon: 0,
191
203
  }
@@ -193,21 +205,25 @@ export function buildStreetMorphologyFST(opts: BuildStreetMorphologyFSTOpts): Bu
193
205
  for (const variant of variants) {
194
206
  const tokens = normalizeTokens(variant)
195
207
 
196
- if (tokens.length === 0) continue
208
+ if (!tokens.length) continue
197
209
  // Filter out collision-prone short surface forms — see `minVariantLength` docstring.
198
210
  // We measure against the joined token form (no spaces) since FST keys are token sequences.
199
211
  const joined = tokens.join("")
200
212
 
201
213
  if (joined.length < minVariantLength) continue
202
214
  insertName(tokens, entry)
215
+
203
216
  insertCount++
217
+
204
218
  variantCount++
205
219
  }
206
220
  }
221
+
207
222
  progress("trie", `Built trie: ${nodes.length} states, ${insertCount} variant insertions`)
208
223
 
209
224
  const edgeCount = nodes.reduce((sum, n) => sum + n.edges.size, 0)
210
225
  const matcher = FSTMatcher.fromNodes(nodes)
226
+
211
227
  const provenance: FSTProvenance = {
212
228
  builtAt: new Date().toISOString(),
213
229
  countries: locales, // Reuse `countries` slot for locale provenance — semantics differ from admin FST.
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Unified runtime loader for the street-morphology FST — the #1315 street-context gate's signal
7
+ * source, previously rebuilt from the libpostal dictionaries per process at three duplicate call
8
+ * sites (runtime pipeline / parity eval / neural harness). The sealed artifact
9
+ * (`fst-street-morphology.bin`, built by `mailwoman gazetteer build street-morphology`) replaces
10
+ * those per-process builds; this loader is the ONE resolution ladder every node call site shares:
11
+ *
12
+ * 1. An explicit `artifactPath` (e.g. the weights-package sibling surfaced by
13
+ * `NeuralAddressClassifier.streetMorphologyPath`). When given, it is the only artifact probed.
14
+ * 2. Otherwise the staged sealed artifact at `$MAILWOMAN_DATA_ROOT/wof/fst-street-morphology.bin`.
15
+ * 3. Build-from-dictionaries fallback: `buildStreetMorphologyFST` over core's bundled libpostal
16
+ * `street_types.txt` files — the pre-artifact behavior, kept so a missing artifact degrades to
17
+ * a per-process build rather than a crash.
18
+ *
19
+ * A present-but-unreadable artifact reports through `onWarn` and falls through to the build rung.
20
+ * Browsers can't take the fallback (no fs) — they deserialize the same artifact via
21
+ * `fst-deserialize-web.ts` (see the docs demo loader).
22
+ */
23
+
24
+ import { existsSync, readFileSync } from "node:fs"
25
+
26
+ import { dataRootPath, resourceDictionaryPath } from "@mailwoman/core/utils"
27
+
28
+ import type { FSTMatcher } from "./fst-matcher.ts"
29
+ import { deserializeFST, readFSTProvenance } from "./fst-serialize.ts"
30
+ import type { FSTProvenance } from "./fst-types.ts"
31
+ import { buildStreetMorphologyFST } from "./street-morphology-fst-builder.ts"
32
+
33
+ /**
34
+ * The sealed artifact's canonical filename — identical in the data-root staging dir and as a weights-package sibling.
35
+ */
36
+ export const STREET_MORPHOLOGY_ARTIFACT_FILENAME = "fst-street-morphology.bin"
37
+
38
+ /**
39
+ * The staged artifact's default location: `$MAILWOMAN_DATA_ROOT/wof/fst-street-morphology.bin`.
40
+ */
41
+ export function defaultStreetMorphologyArtifactPath(): string {
42
+ return String(dataRootPath("wof", STREET_MORPHOLOGY_ARTIFACT_FILENAME))
43
+ }
44
+
45
+ export interface LoadStreetMorphologyFSTOpts {
46
+ /**
47
+ * Explicit artifact path (e.g. a weights-package sibling). When given it is the ONLY artifact probed — missing or
48
+ * unreadable degrades straight to the dictionary build, never a throw.
49
+ */
50
+ artifactPath?: string
51
+ /**
52
+ * Dictionaries dir for the build fallback. Defaults to core's bundled libpostal dictionaries.
53
+ */
54
+ dictionariesDir?: string
55
+ /**
56
+ * Unreadable-artifact diagnostics. Defaults to silent (the caller owns its warn channel).
57
+ */
58
+ onWarn?: (message: string) => void
59
+ }
60
+
61
+ export interface LoadedStreetMorphologyFST {
62
+ matcher: FSTMatcher
63
+ /**
64
+ * Which rung produced the matcher: a sealed-artifact deserialize, or the per-process dictionary build.
65
+ */
66
+ source: "artifact" | "built"
67
+ /**
68
+ * The artifact path when `source === "artifact"`.
69
+ */
70
+ path?: string
71
+ /**
72
+ * Build provenance — read from the artifact trailer, or carried fresh off the fallback build.
73
+ */
74
+ provenance?: FSTProvenance
75
+ }
76
+
77
+ /**
78
+ * Load the street-morphology matcher: sealed artifact first, per-process dictionary build as the degrade path.
79
+ */
80
+ export function loadStreetMorphologyFST(opts: LoadStreetMorphologyFSTOpts = {}): LoadedStreetMorphologyFST {
81
+ const warn = opts.onWarn ?? (() => {})
82
+ const artifactPath = opts.artifactPath ?? defaultStreetMorphologyArtifactPath()
83
+
84
+ if (existsSync(artifactPath)) {
85
+ try {
86
+ const buf = readFileSync(artifactPath)
87
+ const matcher = deserializeFST(buf)
88
+ const provenance = readFSTProvenance(buf)
89
+
90
+ return { matcher, source: "artifact", path: artifactPath, ...(provenance ? { provenance } : {}) }
91
+ } catch (error) {
92
+ warn(
93
+ `street-morphology artifact at ${artifactPath} unreadable (${(error as Error).message}) — falling back to the dictionary build`
94
+ )
95
+ }
96
+ }
97
+
98
+ const built = buildStreetMorphologyFST({
99
+ dictionariesDir: opts.dictionariesDir ?? resourceDictionaryPath("libpostal"),
100
+ })
101
+
102
+ return { matcher: built.matcher, source: "built", provenance: built.provenance }
103
+ }