@mailwoman/resolver-wof-sqlite 8.1.0 → 8.3.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
@@ -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.
@@ -30,10 +30,14 @@ import { deserializeFST, readFSTProvenance } from "./fst-serialize.ts"
30
30
  import type { FSTProvenance } from "./fst-types.ts"
31
31
  import { buildStreetMorphologyFST } from "./street-morphology-fst-builder.ts"
32
32
 
33
- /** The sealed artifact's canonical filename — identical in the data-root staging dir and as a weights-package sibling. */
33
+ /**
34
+ * The sealed artifact's canonical filename — identical in the data-root staging dir and as a weights-package sibling.
35
+ */
34
36
  export const STREET_MORPHOLOGY_ARTIFACT_FILENAME = "fst-street-morphology.bin"
35
37
 
36
- /** The staged artifact's default location: `$MAILWOMAN_DATA_ROOT/wof/fst-street-morphology.bin`. */
38
+ /**
39
+ * The staged artifact's default location: `$MAILWOMAN_DATA_ROOT/wof/fst-street-morphology.bin`.
40
+ */
37
41
  export function defaultStreetMorphologyArtifactPath(): string {
38
42
  return String(dataRootPath("wof", STREET_MORPHOLOGY_ARTIFACT_FILENAME))
39
43
  }
@@ -44,23 +48,35 @@ export interface LoadStreetMorphologyFSTOpts {
44
48
  * unreadable degrades straight to the dictionary build, never a throw.
45
49
  */
46
50
  artifactPath?: string
47
- /** Dictionaries dir for the build fallback. Defaults to core's bundled libpostal dictionaries. */
51
+ /**
52
+ * Dictionaries dir for the build fallback. Defaults to core's bundled libpostal dictionaries.
53
+ */
48
54
  dictionariesDir?: string
49
- /** Unreadable-artifact diagnostics. Defaults to silent (the caller owns its warn channel). */
55
+ /**
56
+ * Unreadable-artifact diagnostics. Defaults to silent (the caller owns its warn channel).
57
+ */
50
58
  onWarn?: (message: string) => void
51
59
  }
52
60
 
53
61
  export interface LoadedStreetMorphologyFST {
54
62
  matcher: FSTMatcher
55
- /** Which rung produced the matcher: a sealed-artifact deserialize, or the per-process dictionary build. */
63
+ /**
64
+ * Which rung produced the matcher: a sealed-artifact deserialize, or the per-process dictionary build.
65
+ */
56
66
  source: "artifact" | "built"
57
- /** The artifact path when `source === "artifact"`. */
67
+ /**
68
+ * The artifact path when `source === "artifact"`.
69
+ */
58
70
  path?: string
59
- /** Build provenance — read from the artifact trailer, or carried fresh off the fallback build. */
71
+ /**
72
+ * Build provenance — read from the artifact trailer, or carried fresh off the fallback build.
73
+ */
60
74
  provenance?: FSTProvenance
61
75
  }
62
76
 
63
- /** Load the street-morphology matcher: sealed artifact first, per-process dictionary build as the degrade path. */
77
+ /**
78
+ * Load the street-morphology matcher: sealed artifact first, per-process dictionary build as the degrade path.
79
+ */
64
80
  export function loadStreetMorphologyFST(opts: LoadStreetMorphologyFSTOpts = {}): LoadedStreetMorphologyFST {
65
81
  const warn = opts.onWarn ?? (() => {})
66
82
  const artifactPath = opts.artifactPath ?? defaultStreetMorphologyArtifactPath()
@@ -72,9 +88,9 @@ export function loadStreetMorphologyFST(opts: LoadStreetMorphologyFSTOpts = {}):
72
88
  const provenance = readFSTProvenance(buf)
73
89
 
74
90
  return { matcher, source: "artifact", path: artifactPath, ...(provenance ? { provenance } : {}) }
75
- } catch (err) {
91
+ } catch (error) {
76
92
  warn(
77
- `street-morphology artifact at ${artifactPath} unreadable (${(err as Error).message}) — falling back to the dictionary build`
93
+ `street-morphology artifact at ${artifactPath} unreadable (${(error as Error).message}) — falling back to the dictionary build`
78
94
  )
79
95
  }
80
96
  }
@@ -39,9 +39,13 @@ function hasColumn(db: DatabaseSync, table: string, column: string): boolean {
39
39
  }
40
40
 
41
41
  export interface SQLiteStreetNameLookupOpts {
42
- /** ISO-2 (upper-case) countries this index answers for. Default `["FR"]` (the BAN street-centroids instance). */
42
+ /**
43
+ * ISO-2 (upper-case) countries this index answers for. Default `["FR"]` (the BAN street-centroids instance).
44
+ */
43
45
  countries?: Iterable<string>
44
- /** Table name. Default `street_centroid`. */
46
+ /**
47
+ * Table name. Default `street_centroid`.
48
+ */
45
49
  table?: string
46
50
  }
47
51
 
@@ -68,9 +72,11 @@ export class SQLiteStreetNameLookup implements StreetLocalityEvidence {
68
72
  // `name_key` MUST match `foldStreetSurface` here (the fold-parity contract).
69
73
  const keyCol = hasColumn(this.#db, table, "name_key") ? "name_key" : "street_norm"
70
74
  this.#byName = this.#db.prepare(`SELECT 1 FROM ${table} WHERE ${keyCol} = ? LIMIT 1`)
75
+
71
76
  this.#byNameLocality = this.#db.prepare(
72
77
  `SELECT 1 FROM ${table} WHERE ${keyCol} = ? AND locality_base = ? LIMIT 1`
73
78
  )
79
+
74
80
  this.#byNamePostcode = this.#db.prepare(`SELECT 1 FROM ${table} WHERE ${keyCol} = ? AND postcode = ? LIMIT 1`)
75
81
  }
76
82
  }
@@ -83,18 +89,24 @@ export class SQLiteStreetNameLookup implements StreetLocalityEvidence {
83
89
 
84
90
  // Scoped lookups tighten precision when the hypothesis carries a locality/postcode; a scoped MISS falls back to the
85
91
  // unscoped probe (index incompleteness in the scope column is not evidence of absence — positive-evidence rule).
86
- if (scope?.locality && this.#byNameLocality) {
87
- if (this.#byNameLocality.get(norm, foldStreetSurface(scope.locality)) !== undefined) return true
92
+ if (
93
+ scope?.locality &&
94
+ this.#byNameLocality &&
95
+ this.#byNameLocality.get(norm, foldStreetSurface(scope.locality)) !== undefined
96
+ ) {
97
+ return true
88
98
  }
89
99
 
90
- if (scope?.postcode && this.#byNamePostcode) {
91
- if (this.#byNamePostcode.get(norm, scope.postcode) !== undefined) return true
100
+ if (scope?.postcode && this.#byNamePostcode && this.#byNamePostcode.get(norm, scope.postcode) !== undefined) {
101
+ return true
92
102
  }
93
103
 
94
104
  return this.#byName.get(norm) !== undefined
95
105
  }
96
106
 
97
- /** Close the underlying handle. */
107
+ /**
108
+ * Close the underlying handle.
109
+ */
98
110
  close(): void {
99
111
  this.#db.close()
100
112
  }
@@ -24,6 +24,12 @@
24
24
 
25
25
  import { AbbreviationToDirectional, US_STREET_SUFFIX_LOOKUP } from "@mailwoman/codex/us"
26
26
 
27
+ /**
28
+ * Token count a street must exceed before its trailing pair is merged. At or below it the pair IS the whole street
29
+ * name, and merging would leave nothing to match on.
30
+ */
31
+ const MIN_TOKENS_FOR_TAIL_MERGE = 3
32
+
27
33
  /**
28
34
  * Spelled ordinal street names → their digit-ordinal form ("tenth" → "10th"), applied ONLY when a street-type suffix
29
35
  * follows (#723 admin-tail) — so the ordinal cross-streets common in grid cities ("Tenth Street", "Fifth Avenue") match
@@ -63,14 +69,16 @@ const SPELLED_ORDINAL_TO_DIGIT = new Map<string, string>([
63
69
  ["hundredth", "100th"],
64
70
  ])
65
71
 
66
- /** Lowercase + diacritic-fold + punctuation strip + whitespace collapse. */
72
+ /**
73
+ * Lowercase + diacritic-fold + punctuation strip + whitespace collapse.
74
+ */
67
75
  function fold(input: string): string {
68
76
  return input
69
77
  .normalize("NFKD")
70
- .replace(/[̀-ͯ]/g, "")
78
+ .replaceAll(/[̀-ͯ]/g, "")
71
79
  .toLowerCase()
72
- .replace(/[.,'’]/g, "")
73
- .replace(/\s+/g, " ")
80
+ .replaceAll(/[.,'’]/g, "")
81
+ .replaceAll(/\s+/g, " ")
74
82
  .trim()
75
83
  }
76
84
 
@@ -81,7 +89,7 @@ function fold(input: string): string {
81
89
  export function normalizeStreetForKey(street: string): string {
82
90
  const tokens = fold(street).split(" ")
83
91
 
84
- if (tokens.length === 0) return ""
92
+ if (!tokens.length) return ""
85
93
 
86
94
  // Spelled-ordinal street names → digit form when a street suffix follows ("Tenth Street" →
87
95
  // "10th street", #723). Gated on the next token being a suffix so ordinal-WORD names are untouched.
@@ -99,6 +107,7 @@ export function normalizeStreetForKey(street: string): string {
99
107
  // ("southeast"), and also merge an already-written two-token pair ("South East …").
100
108
  const edgeDirectional = (raw: string) =>
101
109
  AbbreviationToDirectional.get(raw.toUpperCase())?.toLowerCase().replace(" ", "")
110
+
102
111
  const mergePair = (a?: string, b?: string) =>
103
112
  a && b && /^(north|south)$/.test(a) && /^(east|west)$/.test(b) ? a + b : undefined
104
113
 
@@ -107,20 +116,21 @@ export function normalizeStreetForKey(street: string): string {
107
116
  if (leadPair && tokens.length > 2) {
108
117
  tokens.splice(0, 2, leadPair)
109
118
  }
119
+
110
120
  const first = edgeDirectional(tokens[0]!)
111
121
 
112
122
  if (first && tokens.length > 1) {
113
123
  tokens[0] = first
114
124
  }
115
125
 
116
- const tailPair = mergePair(tokens[tokens.length - 2], tokens[tokens.length - 1])
126
+ const tailPair = mergePair(tokens.at(-2), tokens.at(-1))
117
127
 
118
- if (tailPair && tokens.length > 3) {
119
- tokens.splice(tokens.length - 2, 2, tailPair)
128
+ if (tailPair && tokens.length > MIN_TOKENS_FOR_TAIL_MERGE) {
129
+ tokens.splice(-2, 2, tailPair)
120
130
  }
121
131
 
122
132
  if (tokens.length > 2) {
123
- const last = edgeDirectional(tokens[tokens.length - 1]!)
133
+ const last = edgeDirectional(tokens.at(-1)!)
124
134
 
125
135
  if (last) {
126
136
  tokens[tokens.length - 1] = last
@@ -136,6 +146,7 @@ export function normalizeStreetForKey(street: string): string {
136
146
 
137
147
  if (canonical) {
138
148
  tokens[at] = canonical.toLowerCase()
149
+
139
150
  break
140
151
  }
141
152
  }
@@ -196,9 +207,9 @@ export function normalizeStreetForKeyLocale(street: string, locale: StreetLocale
196
207
  // hyphen ("Champs-Élysées", "St-Honoré") or a space — both sides fold identically, so this is pure
197
208
  // robustness. It also splits a hyphenated abbreviation ("St-Honoré" → "st honore") into tokens the
198
209
  // per-locale type/Saint map can see.
199
- const tokens = fold(street).replace(/ß/g, "ss").replace(/-/g, " ").split(/\s+/).filter(Boolean)
210
+ const tokens = fold(street).replaceAll("ß", "ss").replaceAll("-", " ").split(/\s+/).filter(Boolean)
200
211
 
201
- if (tokens.length === 0) return ""
212
+ if (!tokens.length) return ""
202
213
 
203
214
  switch (locale) {
204
215
  case "fr":
@@ -229,7 +240,9 @@ export function normalizeStreetForKeyLocale(street: string, locale: StreetLocale
229
240
  return tokens.join(" ")
230
241
  }
231
242
 
232
- /** Normalize a locality name for address-point keying (fold only — no street semantics). */
243
+ /**
244
+ * Normalize a locality name for address-point keying (fold only — no street semantics).
245
+ */
233
246
  export function normalizeLocalityForKey(locality: string): string {
234
247
  return fold(locality)
235
248
  }
@@ -268,7 +281,9 @@ export function stripLocalityQualifier(locality: string): string {
268
281
 
269
282
  if (s.includes("/")) {
270
283
  s = s.split("/")[0]!.trim()
271
- } // "Kraubath/Mur", "St.Kanzian/Klopeiner See"
284
+ }
285
+
286
+ // "Kraubath/Mur", "St.Kanzian/Klopeiner See"
272
287
  s = s.replace(/\s+[a-zà-ÿ]\.\s*\S.*$/iu, "") // abbreviated " b.Graz" / " o.Bleiburg" / " a.d. …"
273
288
  s = s.replace(/\s+(im|an der|ob|bei|in der|unter|vor)\s+\S.*$/iu, "") // " im Simmental", " bei Graz"
274
289
  s = s.replace(/\s+(S|N|E|W|V|Ø|Sø|Fyn|Thy|Sjælland|Jylland|[A-ZÅÄÖ]{2})$/u, "") // " S", " VD", " Thy"
@@ -23,28 +23,48 @@ import type { Kysely } from "kysely"
23
23
  * `odd`/`even`/`mixed`.
24
24
  */
25
25
  export interface StreetSegmentTable {
26
- /** Shared {@link normalizeStreetForKey} of the street — the build/query-consistent probe key. */
26
+ /**
27
+ * Shared {@link normalizeStreetForKey} of the street — the build/query-consistent probe key.
28
+ */
27
29
  street_norm: string
28
- /** `L` or `R` — the TIGER side the address range sits on. */
30
+ /**
31
+ * `L` or `R` — the TIGER side the address range sits on.
32
+ */
29
33
  side: string
30
34
  from_hn: number
31
35
  to_hn: number
32
- /** Sorted lower bound of `(from_hn, to_hn)` — the probe filters `min_hn <= n <= max_hn`. */
36
+ /**
37
+ * Sorted lower bound of `(from_hn, to_hn)` — the probe filters `min_hn <= n <= max_hn`.
38
+ */
33
39
  min_hn: number
34
- /** Sorted upper bound of `(from_hn, to_hn)`. */
40
+ /**
41
+ * Sorted upper bound of `(from_hn, to_hn)`.
42
+ */
35
43
  max_hn: number
36
- /** `odd` | `even` | `mixed` — the house-number parity along the range. */
44
+ /**
45
+ * `odd` | `even` | `mixed` — the house-number parity along the range.
46
+ */
37
47
  parity: string
38
48
  postcode: string | null
39
- /** 5-digit state+county FIPS the edge came from. */
49
+ /**
50
+ * 5-digit state+county FIPS the edge came from.
51
+ */
40
52
  county_fips: string
41
- /** The street as it appeared in TIGER (kept for display / debugging). */
53
+ /**
54
+ * The street as it appeared in TIGER (kept for display / debugging).
55
+ */
42
56
  street_raw: string
43
- /** GeoJSON LineString text (no SpatiaLite — read back with `JSON.parse`). */
57
+ /**
58
+ * GeoJSON LineString text (no SpatiaLite — read back with `JSON.parse`).
59
+ */
44
60
  geometry: string
45
- /** Provenance: the dataset this edge came from (e.g. `tiger:edges`). */
61
+ /**
62
+ * Provenance: the dataset this edge came from (e.g. `tiger:edges`).
63
+ */
46
64
  source: string
47
- /** The pinned TIGER release the edge was ingested from. */
65
+ /**
66
+ * The pinned TIGER release the edge was ingested from.
67
+ */
48
68
  release: string
49
69
  }
50
70
 
@@ -56,15 +76,23 @@ export interface StreetSegmentTable {
56
76
  * and callers fall back to the in-code per-region table (never patch shipped DBs — rebuild).
57
77
  */
58
78
  export interface InterpCalibrationRow {
59
- /** Conformal multiplier for the raw half-segment `uncertainty_m` radius (#374/#584) — ×Q̂ for a ~90% bound. */
79
+ /**
80
+ * Conformal multiplier for the raw half-segment `uncertainty_m` radius (#374/#584) — ×Q̂ for a ~90% bound.
81
+ */
60
82
  radius_multiplier: number
61
- /** Provenance of the multiplier (e.g. `split-conformal:2026-06-14`). */
83
+ /**
84
+ * Provenance of the multiplier (e.g. `split-conformal:2026-06-14`).
85
+ */
62
86
  method: string
63
- /** The calibration-table key the multiplier was selected by (a USPS region code, or `default` for unmeasured). */
87
+ /**
88
+ * The calibration-table key the multiplier was selected by (a USPS region code, or `default` for unmeasured).
89
+ */
64
90
  region: string
65
91
  }
66
92
 
67
- /** The street-segment database schema for `new DatabaseClient<StreetSegmentDatabase>(...)`. */
93
+ /**
94
+ * The street-segment database schema for `new DatabaseClient<StreetSegmentDatabase>(...)`.
95
+ */
68
96
  export interface StreetSegmentDatabase {
69
97
  street_segment: StreetSegmentTable
70
98
  interp_calibration: InterpCalibrationRow
@@ -90,7 +118,9 @@ export const STREET_SEGMENT_COLUMNS = [
90
118
  "release",
91
119
  ] as const
92
120
 
93
- /** Create the `street_segment` table — called before the streaming bulk load. */
121
+ /**
122
+ * Create the `street_segment` table — called before the streaming bulk load.
123
+ */
94
124
  export async function createStreetSegmentTable(db: Kysely<StreetSegmentDatabase>): Promise<void> {
95
125
  await db.schema
96
126
  .createTable("street_segment")
@@ -126,15 +156,19 @@ export async function writeInterpCalibration(
126
156
  .addColumn("method", "text", (c) => c.notNull())
127
157
  .addColumn("region", "text", (c) => c.notNull())
128
158
  .execute()
159
+
129
160
  await db.insertInto("interp_calibration").values(row).execute()
130
161
  }
131
162
 
132
- /** Create the two probe indexes the reader relies on (postcode-scope, street-scope). */
163
+ /**
164
+ * Create the two probe indexes the reader relies on (postcode-scope, street-scope).
165
+ */
133
166
  export async function createStreetSegmentIndexes(db: Kysely<StreetSegmentDatabase>): Promise<void> {
134
167
  await db.schema
135
168
  .createIndex("idx_seg_postcode")
136
169
  .on("street_segment")
137
170
  .columns(["postcode", "street_norm", "min_hn"])
138
171
  .execute()
172
+
139
173
  await db.schema.createIndex("idx_seg_street").on("street_segment").columns(["street_norm", "min_hn"]).execute()
140
174
  }
package/types.ts CHANGED
@@ -49,7 +49,9 @@ export interface PlaceCandidate {
49
49
  id: number
50
50
  name: string
51
51
  placetype: WOFPlacetype
52
- /** ISO 3166-1 alpha-2 country code. */
52
+ /**
53
+ * ISO 3166-1 alpha-2 country code.
54
+ */
53
55
  country: string
54
56
  lat: number
55
57
  lon: number
@@ -125,9 +127,13 @@ export interface GeoBbox {
125
127
  export interface FindPlaceQuery {
126
128
  text: string
127
129
  placetype?: WOFPlacetype | WOFPlacetype[]
128
- /** ISO 3166-1 alpha-2 — narrows to one country. */
130
+ /**
131
+ * ISO 3166-1 alpha-2 — narrows to one country.
132
+ */
129
133
  country?: string
130
- /** WOF place id — narrows to descendants of this place. */
134
+ /**
135
+ * WOF place id — narrows to descendants of this place.
136
+ */
131
137
  parentID?: number
132
138
  /**
133
139
  * Sibling postcode. When set on a `locality` query AND a `postcode_locality` table is present, triggers the
@@ -136,7 +142,9 @@ export interface FindPlaceQuery {
136
142
  * postcode_locality shard is present.
137
143
  */
138
144
  postcode?: string
139
- /** Proximity hint — candidates close to this point get a ranking boost. */
145
+ /**
146
+ * Proximity hint — candidates close to this point get a ranking boost.
147
+ */
140
148
  near?: GeoPoint & { maxDistanceKm?: number }
141
149
  /**
142
150
  * Ordered proximity-bias points (viewport center, user location, …), each optionally weighted (default 1.0, first
@@ -147,9 +155,13 @@ export interface FindPlaceQuery {
147
155
  * back-compat.
148
156
  */
149
157
  bias?: Array<GeoPoint & { weight?: number }>
150
- /** Bounding-box filter — only candidates whose bbox intersects this box are returned. */
158
+ /**
159
+ * Bounding-box filter — only candidates whose bbox intersects this box are returned.
160
+ */
151
161
  bbox?: GeoBbox
152
- /** Default 10. */
162
+ /**
163
+ * Default 10.
164
+ */
153
165
  limit?: number
154
166
  }
155
167
 
package/unified-schema.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  * `place_search` FTS5 + `place_bbox` R*Tree are built separately by `build-fts` (fts.ts).
13
13
  */
14
14
 
15
- import { DatabaseSync } from "node:sqlite"
15
+ import type { DatabaseSync } from "node:sqlite"
16
16
 
17
17
  import { DatabaseClient } from "@mailwoman/core/kysley/client"
18
18
 
@@ -109,11 +109,13 @@ export async function createUnifiedSchema(db: DatabaseSync): Promise<void> {
109
109
  */
110
110
  export function populateAncestors(db: DatabaseSync): number {
111
111
  db.exec("DELETE FROM ancestors")
112
+
112
113
  const rows = db.prepare("SELECT id, parent_id, placetype FROM spr").all() as Array<{
113
114
  id: number
114
115
  parent_id: number
115
116
  placetype: string
116
117
  }>
118
+
117
119
  const byID = new Map<number, { parent: number; placetype: string }>()
118
120
 
119
121
  for (const r of rows) {
@@ -125,7 +127,9 @@ export function populateAncestors(db: DatabaseSync): number {
125
127
  let count = 0
126
128
 
127
129
  for (const r of rows) {
128
- insert.run(r.id, r.id, r.placetype) // self
130
+ insert.run(r.id, r.id, r.placetype)
131
+
132
+ // self
129
133
  count++
130
134
  const seen = new Set<number>([r.id])
131
135
  let cur = r.parent_id
@@ -135,11 +139,13 @@ export function populateAncestors(db: DatabaseSync): number {
135
139
 
136
140
  if (!node) break
137
141
  insert.run(r.id, cur, node.placetype)
142
+
138
143
  count++
139
144
  seen.add(cur)
140
145
  cur = node.parent
141
146
  }
142
147
  }
148
+
143
149
  db.exec("COMMIT")
144
150
 
145
151
  return count
@@ -152,18 +158,21 @@ export async function createUnifiedIndexes(db: DatabaseSync): Promise<void> {
152
158
  await kdb.schema.createIndex("spr_by_parent").ifNotExists().on("spr").column("parent_id").execute()
153
159
  await kdb.schema.createIndex("names_by_id").ifNotExists().on("names").column("id").execute()
154
160
  await kdb.schema.createIndex("names_by_name").ifNotExists().on("names").column("name").execute()
161
+
155
162
  await kdb.schema
156
163
  .createIndex("concordances_by_id")
157
164
  .ifNotExists()
158
165
  .on("concordances")
159
166
  .columns(["id", "lastmodified"])
160
167
  .execute()
168
+
161
169
  await kdb.schema
162
170
  .createIndex("concordances_by_other_id")
163
171
  .ifNotExists()
164
172
  .on("concordances")
165
173
  .columns(["other_source", "other_id"])
166
174
  .execute()
175
+
167
176
  // ancestor_id is the hot column (parent-constraint queries `WHERE ancestor_id = ?`); id supports
168
177
  // the reverse lookup.
169
178
  await kdb.schema.createIndex("ancestors_by_ancestor").ifNotExists().on("ancestors").column("ancestor_id").execute()