@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/poi-lookup.ts CHANGED
@@ -32,7 +32,9 @@ import { gridDisk, latLngToCell } from "h3-js"
32
32
 
33
33
  import type { POICategoryCodeTable, POITable } from "./poi-schema.ts"
34
34
 
35
- /** Resolution the `poi` table's `h3_cell` column is keyed at — matches the builder (spec §3.4). */
35
+ /**
36
+ * Resolution the `poi` table's `h3_cell` column is keyed at — matches the builder (spec §3.4).
37
+ */
36
38
  export const POI_H3_RESOLUTION = 9
37
39
 
38
40
  /**
@@ -57,11 +59,15 @@ const DEFAULT_MAX_RINGS = 16
57
59
  */
58
60
  const BRAND_MAX_DISTANCE_KM = 500
59
61
 
60
- /** Row-count default when a query doesn't specify `limit`. */
62
+ /**
63
+ * Row-count default when a query doesn't specify `limit`.
64
+ */
61
65
  const DEFAULT_LIMIT = 20
62
66
 
63
67
  export interface POISearchQuery {
64
- /** Poi-taxonomy category id (string side of the dictionary). Ignored when `brandWikidata` is also set — brand wins. */
68
+ /**
69
+ * Poi-taxonomy category id (string side of the dictionary). Ignored when `brandWikidata` is also set — brand wins.
70
+ */
65
71
  categoryID?: string
66
72
  /**
67
73
  * Fan-out category ids — the Overture `taxonomy.primary` leaves a single canonical category rolls up into (e.g.
@@ -70,11 +76,17 @@ export interface POISearchQuery {
70
76
  * one-element list `[categoryID]` when this is absent). Ignored when `brandWikidata` is set — brand wins.
71
77
  */
72
78
  categoryIDs?: string[]
73
- /** Wikidata QID for brand-exact search. */
79
+ /**
80
+ * Wikidata QID for brand-exact search.
81
+ */
74
82
  brandWikidata?: string
75
- /** Free-text name (FTS5). */
83
+ /**
84
+ * Free-text name (FTS5).
85
+ */
76
86
  name?: string
77
- /** Search center. Required for category/brand queries (k-ring expansion). */
87
+ /**
88
+ * Search center. Required for category/brand queries (k-ring expansion).
89
+ */
78
90
  center?: { latitude: number; longitude: number }
79
91
  /**
80
92
  * Ring budget: how many res-9 k-rings to expand before giving up (default 16 ≈ ~5.4 km). Counts ring 0, so k reaches
@@ -92,19 +104,27 @@ export interface POISearchHit {
92
104
  longitude: number
93
105
  country: string
94
106
  confidence: number
95
- /** Overture GERS id — nullable METADATA ONLY, never a key (the #470 rule; see `POITable.gers_id`). */
107
+ /**
108
+ * Overture GERS id — nullable METADATA ONLY, never a key (the #470 rule; see `POITable.gers_id`).
109
+ */
96
110
  gersID: string | null
97
111
  distanceM?: number
98
112
  }
99
113
 
100
114
  export interface POILookupOpts {
101
- /** Path to a `poi.db` built by the (future) POI builder. Opened read-only. */
115
+ /**
116
+ * Path to a `poi.db` built by the (future) POI builder. Opened read-only.
117
+ */
102
118
  databasePath?: string
103
- /** Pre-opened handle (tests / shared connections). Mutually exclusive with `databasePath`. */
119
+ /**
120
+ * Pre-opened handle (tests / shared connections). Mutually exclusive with `databasePath`.
121
+ */
104
122
  database?: DatabaseSync
105
123
  }
106
124
 
107
- /** The `poi` columns every search mode hydrates — a typed projection of the SHARED {@link POITable}. */
125
+ /**
126
+ * The `poi` columns every search mode hydrates — a typed projection of the SHARED {@link POITable}.
127
+ */
108
128
  type POIRow = Pick<
109
129
  POITable,
110
130
  | "name"
@@ -129,11 +149,17 @@ export class POILookup implements Disposable {
129
149
  readonly #categoryToID = new Map<string, number>()
130
150
  readonly #idToCategory = new Map<number, string>()
131
151
 
132
- /** `(h3_cell, category_id)` → the cell's category-clustered range, most-confident-first. */
152
+ /**
153
+ * `(h3_cell, category_id)` → the cell's category-clustered range, most-confident-first.
154
+ */
133
155
  readonly #categoryCellProbe: ReturnType<DatabaseSync["prepare"]>
134
- /** `brand_wikidata` → ALL of a brand's rows globally (partial-index range-scan); distance-sorted in JS, not SQL. */
156
+ /**
157
+ * `brand_wikidata` → ALL of a brand's rows globally (partial-index range-scan); distance-sorted in JS, not SQL.
158
+ */
135
159
  readonly #brandProbe: ReturnType<DatabaseSync["prepare"]>
136
- /** FTS5 `MATCH` over `poi_search`, returning candidate `name_key`s to hydrate. */
160
+ /**
161
+ * FTS5 `MATCH` over `poi_search`, returning candidate `name_key`s to hydrate.
162
+ */
137
163
  readonly #nameFTSProbe: ReturnType<DatabaseSync["prepare"]>
138
164
 
139
165
  constructor(opts: POILookupOpts) {
@@ -161,7 +187,9 @@ export class POILookup implements Disposable {
161
187
  this.#categoryCellProbe = this.#db.prepare(
162
188
  `SELECT ${columns} FROM poi WHERE h3_cell = ? AND category_id = ? ORDER BY neg_rank ASC LIMIT ?`
163
189
  )
190
+
164
191
  this.#brandProbe = this.#db.prepare(`SELECT ${columns} FROM poi WHERE brand_wikidata = ?`)
192
+
165
193
  this.#nameFTSProbe = this.#db.prepare(
166
194
  "SELECT name_key FROM poi_search WHERE poi_search MATCH ? ORDER BY bm25(poi_search) LIMIT ?"
167
195
  )
@@ -174,7 +202,7 @@ export class POILookup implements Disposable {
174
202
  return this.#searchByName(query.name, limit, query.center)
175
203
  }
176
204
 
177
- if (query.categoryID || (query.categoryIDs && query.categoryIDs.length > 0) || query.brandWikidata) {
205
+ if (query.categoryID || (query.categoryIDs && query.categoryIDs.length) || query.brandWikidata) {
178
206
  if (!query.center) {
179
207
  throw new Error("POILookup.search: category/brand search requires a `center`")
180
208
  }
@@ -208,7 +236,9 @@ export class POILookup implements Disposable {
208
236
  .map((row) => toHit(row, this.#idToCategory, center))
209
237
  }
210
238
 
211
- /** Category path: k-ring expansion from `query.center`'s res-9 cell, probing each new ring's cells. */
239
+ /**
240
+ * Category path: k-ring expansion from `query.center`'s res-9 cell, probing each new ring's cells.
241
+ */
212
242
  #searchKRing(query: POISearchQuery, limit: number): POISearchHit[] {
213
243
  const center = query.center!
214
244
  const maxRings = query.maxRings ?? DEFAULT_MAX_RINGS
@@ -227,7 +257,7 @@ export class POILookup implements Disposable {
227
257
  }
228
258
 
229
259
  // No resolvable leaf (every id unknown to the dictionary) can't have rows — a clean miss, not a throw.
230
- if (categoryIds.length === 0) return []
260
+ if (!categoryIds.length) return []
231
261
 
232
262
  const origin = latLngToCell(center.latitude, center.longitude, POI_H3_RESOLUTION) as H3Cell
233
263
  const seenCells = new Set<string>()
@@ -281,7 +311,7 @@ export class POILookup implements Disposable {
281
311
  uniqueKeys.push(hit.name_key)
282
312
  }
283
313
 
284
- if (uniqueKeys.length === 0) return []
314
+ if (!uniqueKeys.length) return []
285
315
 
286
316
  const hydrated = this.#hydrateByNameKeys(uniqueKeys)
287
317
  const rowsByKey = new Map<string, POIRow[]>()
@@ -334,13 +364,15 @@ export class POILookup implements Disposable {
334
364
  }
335
365
  }
336
366
 
337
- /** `poi.h3_cell` is the SHORTENED (48-bit) cell — never the full h3-js cell string. */
367
+ /**
368
+ * `poi.h3_cell` is the SHORTENED (48-bit) cell — never the full h3-js cell string.
369
+ */
338
370
  function h3CellToInt(cell: H3Cell): number {
339
371
  return Number(BigInt(`0x${shortenH3Cell(cell)}`))
340
372
  }
341
373
 
342
374
  function sortByDistance(rows: POIRow[], center: { latitude: number; longitude: number }): POIRow[] {
343
- return [...rows].sort(
375
+ return [...rows].toSorted(
344
376
  (a, b) =>
345
377
  haversineKm(center.latitude, center.longitude, a.latitude, a.longitude) -
346
378
  haversineKm(center.latitude, center.longitude, b.latitude, b.longitude)
@@ -378,10 +410,10 @@ function toHit(
378
410
  */
379
411
  function sanitizePOINameQuery(text: string): string {
380
412
  return text
381
- .replace(/["*:]/g, "")
413
+ .replaceAll(/["*:]/g, "")
382
414
  .trim()
383
415
  .split(/\s+/u)
384
416
  .filter(Boolean)
385
- .map((token) => `"${token.replace(/"/g, '""')}"`)
417
+ .map((token) => `"${token.replaceAll('"', '""')}"`)
386
418
  .join(" ")
387
419
  }
package/poi-schema.ts CHANGED
@@ -17,27 +17,45 @@ import type { DatabaseSync } from "node:sqlite"
17
17
  import type { LayerContractDatabase } from "@mailwoman/core/layers"
18
18
  import { sql, type Kysely } from "kysely"
19
19
 
20
- /** One POI row. Clustered PK: h3_cell → category_id → neg_rank → rowid_key. */
20
+ /**
21
+ * One POI row. Clustered PK: h3_cell → category_id → neg_rank → rowid_key.
22
+ */
21
23
  export interface POITable {
22
- /** 48-bit short H3 cell at res 9 (`latLngToCell` → `shortenH3Cell`). */
24
+ /**
25
+ * 48-bit short H3 cell at res 9 (`latLngToCell` → `shortenH3Cell`).
26
+ */
23
27
  h3_cell: number
24
- /** Small int from {@link POICategoryCodeTable}; 0 = uncategorized. */
28
+ /**
29
+ * Small int from {@link POICategoryCodeTable}; 0 = uncategorized.
30
+ */
25
31
  category_id: number
26
- /** `-log10(confidence + epsilon)` so ASC = most-confident-first within a cell+category. */
32
+ /**
33
+ * `-log10(confidence + epsilon)` so ASC = most-confident-first within a cell+category.
34
+ */
27
35
  neg_rank: number
28
- /** Uniquifier within the clustered key (builder-assigned monotonic int). */
36
+ /**
37
+ * Uniquifier within the clustered key (builder-assigned monotonic int).
38
+ */
29
39
  rowid_key: number
30
40
  name: string | null
31
- /** Lowercased, diacritic-flattened probe key for exact name lookups. */
41
+ /**
42
+ * Lowercased, diacritic-flattened probe key for exact name lookups.
43
+ */
32
44
  name_key: string | null
33
45
  brand_wikidata: string | null
34
46
  latitude: number
35
47
  longitude: number
36
- /** ISO 3166-1 alpha-2 (from the Overture partition). */
48
+ /**
49
+ * ISO 3166-1 alpha-2 (from the Overture partition).
50
+ */
37
51
  country: string
38
- /** Overture existence confidence (already filtered ≥ 0.85 at build). */
52
+ /**
53
+ * Overture existence confidence (already filtered ≥ 0.85 at build).
54
+ */
39
55
  confidence: number
40
- /** GERS id — nullable METADATA ONLY, never a key (the #470 rule). */
56
+ /**
57
+ * GERS id — nullable METADATA ONLY, never a key (the #470 rule).
58
+ */
41
59
  gers_id: string | null
42
60
  }
43
61
 
@@ -60,7 +78,9 @@ export interface POIStageTable {
60
78
  gers_id: string | null
61
79
  }
62
80
 
63
- /** `(id → poi-taxonomy category id)` dictionary, e.g. `3 → "cafe"`. */
81
+ /**
82
+ * `(id → poi-taxonomy category id)` dictionary, e.g. `3 → "cafe"`.
83
+ */
64
84
  export interface POICategoryCodeTable {
65
85
  id: number
66
86
  category: string
@@ -72,7 +92,9 @@ export interface POIDatabase extends LayerContractDatabase {
72
92
  poi_category_codes: POICategoryCodeTable
73
93
  }
74
94
 
75
- /** Clustered-key-order column list shared by builder + `INSERT INTO poi SELECT … FROM poi_stage`. */
95
+ /**
96
+ * Clustered-key-order column list shared by builder + `INSERT INTO poi SELECT … FROM poi_stage`.
97
+ */
76
98
  export const POI_COLUMNS = [
77
99
  "h3_cell",
78
100
  "category_id",
@@ -94,6 +116,7 @@ export async function createPOIStagingTables(db: Kysely<POIDatabase>): Promise<v
94
116
  .addColumn("id", "integer", (c) => c.primaryKey())
95
117
  .addColumn("category", "text", (c) => c.unique())
96
118
  .execute()
119
+
97
120
  await db.schema
98
121
  .createTable("poi_stage")
99
122
  .addColumn("h3_cell", "integer")
@@ -132,7 +155,9 @@ export async function createPOITable(db: Kysely<POIDatabase>): Promise<void> {
132
155
  .execute()
133
156
  }
134
157
 
135
- /** Secondary index for the FTS-hydration path. Builders call this AFTER the bulk materialize (index-after-load). */
158
+ /**
159
+ * Secondary index for the FTS-hydration path. Builders call this AFTER the bulk materialize (index-after-load).
160
+ */
136
161
  export async function createPOINameKeyIndex(db: Kysely<POIDatabase>): Promise<void> {
137
162
  await db.schema.createIndex("poi_name_key").on("poi").column("name_key").execute()
138
163
  }
@@ -154,9 +179,14 @@ export async function createPOIBrandIndex(db: Kysely<POIDatabase>): Promise<void
154
179
  .execute()
155
180
  }
156
181
 
182
+ /**
183
+ * FTS5 virtual table backing POI name search in poi.db.
184
+ */
157
185
  export const POI_FTS_TABLE = "poi_search"
158
186
 
159
- /** FTS5 stays raw SQL by project rule (Kysely can't express virtual tables). Content-keyed by name_key. */
187
+ /**
188
+ * FTS5 stays raw SQL by project rule (Kysely can't express virtual tables). Content-keyed by name_key.
189
+ */
160
190
  export function createPOISearchFTS(db: DatabaseSync): void {
161
191
  db.exec(
162
192
  `CREATE VIRTUAL TABLE ${POI_FTS_TABLE} USING fts5(name, name_key UNINDEXED, h3_cell UNINDEXED, tokenize = 'unicode61')`
@@ -23,19 +23,31 @@ import { DatabaseClient } from "@mailwoman/core/kysley/client"
23
23
  import type { PostalCityAliasDatabase } from "./postal-city-alias-schema.ts"
24
24
 
25
25
  export interface WOFPostalCityAliasLookupOpts {
26
- /** Path to a `postal-city-alias-<cc>.db` built by `build-postal-city-alias.ts`. Opened read-only. */
26
+ /**
27
+ * Path to a `postal-city-alias-<cc>.db` built by `build-postal-city-alias.ts`. Opened read-only.
28
+ */
27
29
  databasePath?: string
28
- /** Pre-opened handle (tests / shared connections). Mutually exclusive with `databasePath`. */
30
+ /**
31
+ * Pre-opened handle (tests / shared connections). Mutually exclusive with `databasePath`.
32
+ */
29
33
  database?: DatabaseSync
30
34
  }
31
35
 
32
- /** One divergent alias edge: the postal-system name and the geographic locality it maps to. */
36
+ /**
37
+ * One divergent alias edge: the postal-system name and the geographic locality it maps to.
38
+ */
33
39
  export interface PostalCityAlias {
34
- /** The postal-system surface (what a user types). */
40
+ /**
41
+ * The postal-system surface (what a user types).
42
+ */
35
43
  postalCity: string
36
- /** The geographic locality name the postcode sits in (≈ the gazetteer's canonical name). */
44
+ /**
45
+ * The geographic locality name the postcode sits in (≈ the gazetteer's canonical name).
46
+ */
37
47
  geoLocality: string
38
- /** Observed usage count — the evidence weight. */
48
+ /**
49
+ * Observed usage count — the evidence weight.
50
+ */
39
51
  n: number
40
52
  }
41
53
 
@@ -59,6 +71,7 @@ export class WOFPostalCityAliasLookup {
59
71
  } else {
60
72
  throw new Error("WOFPostalCityAliasLookup needs `databasePath` or `database`")
61
73
  }
74
+
62
75
  // `#kdb` wraps `#db` for the typed query; close() owns the raw handle directly (sync).
63
76
  this.#kdb = new DatabaseClient<PostalCityAliasDatabase>({ database: this.#db })
64
77
  }
@@ -71,6 +84,7 @@ export class WOFPostalCityAliasLookup {
71
84
  const pc = postcode.trim()
72
85
 
73
86
  if (!pc) return []
87
+
74
88
  const rows = await this.#kdb
75
89
  .selectFrom("postal_city_alias")
76
90
  .select(["postal_city", "geo_locality", "n"])
@@ -23,28 +23,46 @@ import type { Kysely } from "kysely"
23
23
  * a `MIN_COUNT` floor on `n`, so every row is a non-trivial usage.
24
24
  */
25
25
  export interface PostalCityAliasTable {
26
- /** The postcode the aggregate is scoped to (the resolver probes by this). */
26
+ /**
27
+ * The postcode the aggregate is scoped to (the resolver probes by this).
28
+ */
27
29
  postcode: string
28
- /** What the postal system calls the place (the surface a user is likely to type). */
30
+ /**
31
+ * What the postal system calls the place (the surface a user is likely to type).
32
+ */
29
33
  postal_city: string
30
- /** The geographic locality name the postcode actually sits in (≈ the gazetteer's canonical name). */
34
+ /**
35
+ * The geographic locality name the postcode actually sits in (≈ the gazetteer's canonical name).
36
+ */
31
37
  geo_locality: string
32
- /** Observed row count — the evidence weight behind this alias. */
38
+ /**
39
+ * Observed row count — the evidence weight behind this alias.
40
+ */
33
41
  n: number
34
- /** 1 when `postal_city != geo_locality` (the alias signal); 0 when they agree. */
42
+ /**
43
+ * 1 when `postal_city != geo_locality` (the alias signal); 0 when they agree.
44
+ */
35
45
  divergent: number
36
- /** Provenance: the dataset this aggregate came from (e.g. `overture:US`). */
46
+ /**
47
+ * Provenance: the dataset this aggregate came from (e.g. `overture:US`).
48
+ */
37
49
  source: string
38
- /** The pinned data release the aggregate was computed from. */
50
+ /**
51
+ * The pinned data release the aggregate was computed from.
52
+ */
39
53
  release: string
40
54
  }
41
55
 
42
- /** The postal-city-alias database schema for `new DatabaseClient<PostalCityAliasDatabase>(...)`. */
56
+ /**
57
+ * The postal-city-alias database schema for `new DatabaseClient<PostalCityAliasDatabase>(...)`.
58
+ */
43
59
  export interface PostalCityAliasDatabase {
44
60
  postal_city_alias: PostalCityAliasTable
45
61
  }
46
62
 
47
- /** The `postal_city_alias` column order — the builder's INSERT derives its column list from this. */
63
+ /**
64
+ * The `postal_city_alias` column order — the builder's INSERT derives its column list from this.
65
+ */
48
66
  export const POSTAL_CITY_ALIAS_COLUMNS = [
49
67
  "postcode",
50
68
  "postal_city",
@@ -70,6 +88,7 @@ export async function createPostalCityAliasTable(db: Kysely<PostalCityAliasDatab
70
88
  .addColumn("source", "text", (c) => c.notNull())
71
89
  .addColumn("release", "text", (c) => c.notNull())
72
90
  .execute()
91
+
73
92
  await db.schema.createIndex("idx_pca_postcode").on("postal_city_alias").column("postcode").execute()
74
93
  await db.schema.createIndex("idx_pca_pair").on("postal_city_alias").columns(["postal_city", "geo_locality"]).execute()
75
94
  }
@@ -25,13 +25,21 @@ import { sql, type Kysely } from "kysely"
25
25
  * locality directly; the denormalized name/coord avoid a join back to `candidate`.
26
26
  */
27
27
  export interface PostalCityCandidateTable {
28
- /** {@link normalizeLocalityForKey} of the postal-city name — the build/query-consistent probe key. */
28
+ /**
29
+ * {@link normalizeLocalityForKey} of the postal-city name — the build/query-consistent probe key.
30
+ */
29
31
  name_key: string
30
- /** The postcode the alias is scoped to (the second half of the exact key). */
32
+ /**
33
+ * The postcode the alias is scoped to (the second half of the exact key).
34
+ */
31
35
  postcode: string
32
- /** WOF id of the geographic locality the postcode sits in (the resolve target). */
36
+ /**
37
+ * WOF id of the geographic locality the postcode sits in (the resolve target).
38
+ */
33
39
  spr_id: number
34
- /** The geographic locality's display name. */
40
+ /**
41
+ * The geographic locality's display name.
42
+ */
35
43
  name: string
36
44
  latitude: number
37
45
  longitude: number
@@ -49,7 +57,9 @@ export interface PostalCityCandidateDatabase {
49
57
  */
50
58
  export const POSTAL_CITY_CANDIDATE_TABLE = "postal_city_candidate"
51
59
 
52
- /** Column order for the builder's positional INSERT. */
60
+ /**
61
+ * Column order for the builder's positional INSERT.
62
+ */
53
63
  export const POSTAL_CITY_CANDIDATE_COLUMNS = [
54
64
  "name_key",
55
65
  "postcode",
@@ -37,13 +37,17 @@ export class WOFPostcodeLookup {
37
37
  readonly #dbs: DatabaseSync[]
38
38
  readonly #stmts: ReturnType<DatabaseSync["prepare"]>[]
39
39
 
40
- /** Open each shard read-only and prepare its exact-match statement. */
40
+ /**
41
+ * Open each shard read-only and prepare its exact-match statement.
42
+ */
41
43
  constructor(dbPaths: readonly string[]) {
42
44
  this.#dbs = dbPaths.map((p) => new DatabaseSync(p, { readOnly: true }))
43
45
  this.#stmts = this.#dbs.map((db) => db.prepare(LOOKUP_SQL))
44
46
  }
45
47
 
46
- /** Exact-match the postcode across every shard and union the rows. */
48
+ /**
49
+ * Exact-match the postcode across every shard and union the rows.
50
+ */
47
51
  lookup(postcode: string): PostcodePlace[] {
48
52
  const out: PostcodePlace[] = []
49
53
 
@@ -0,0 +1,148 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * The locality-ranking weights and their shipped defaults. Its own module because every value here is
7
+ * a measured tuning decision with its rationale attached — the block reads as a reference table, not
8
+ * as part of the lookup's control flow, and the tests import it directly to pin one lever at a time.
9
+ */
10
+
11
+ /**
12
+ * Ranking weights for `findPlace`. Tweakable per-instance but defaults match the values declared in the Phase 4.2 plan
13
+ * doc.
14
+ */
15
+ export interface RankingWeights {
16
+ /**
17
+ * Boost when the candidate's placetype matches an explicit `placetype` filter.
18
+ */
19
+ placetypeMatchBoost: number
20
+ /**
21
+ * Boost when the candidate is a locality and no explicit placetype was requested.
22
+ */
23
+ localityImplicitBoost: number
24
+ /**
25
+ * Boost when the candidate's country matches an explicit `country` filter.
26
+ */
27
+ countryMatchBoost: number
28
+ /**
29
+ * Boost when the candidate is a direct child of the requested `parentID`.
30
+ */
31
+ directChildBoost: number
32
+ /**
33
+ * Boost when the candidate is a transitive descendant of the requested `parentID`.
34
+ */
35
+ descendantBoost: number
36
+ /**
37
+ * Multiplier on the length-penalty term (penalizes much-longer-than-query names).
38
+ */
39
+ lengthPenaltyWeight: number
40
+ /**
41
+ * Magnitude of the proximity boost when the query carries `near`. The contribution is `proximityBoost / (1 +
42
+ * distanceKm / proximityScaleKm)` — at distance 0 the boost is full magnitude, at `proximityScaleKm` it's half,
43
+ * decaying further with distance. Default tuned so proximity can overcome a typical FTS rank tie but not dominate a
44
+ * strong text match.
45
+ */
46
+ proximityBoost: number
47
+ /**
48
+ * Magnitude of the bias-hint term inside the exact-tier PROMINENCE sort (the `bias`/viewport path). Deliberately
49
+ * population-scale (default = populationBoost) so a candidate near the map view / the user beats a distant-but-bigger
50
+ * namesake — "the map view wins" is the feature; same-region ties (all candidates far from every hint) still fall to
51
+ * population.
52
+ */
53
+ biasBoost: number
54
+ /**
55
+ * Distance (km) at which the proximity boost halves. Tune to the typical query radius.
56
+ */
57
+ proximityScaleKm: number
58
+ /**
59
+ * Magnitude of the population boost when the candidate has a known `wof:population`. The contribution is
60
+ * `populationBoost * log10(1 + population) / populationScaleLog10`, capped at `populationBoost`. WOF only carries
61
+ * population for ~15% of localities (mostly larger ones); places without it get +0 (never a penalty). Default tuned
62
+ * so the famous Springfield, IL (pop ~112k) gets ~0.42 boost — enough to nudge past tiny same-name peers.
63
+ */
64
+ populationBoost: number
65
+ /**
66
+ * Population (in log10) at which the boost reaches its full magnitude. Default 6 — i.e. a population of 1,000,000
67
+ * gives `populationBoost` exactly. Larger populations cap at the same value (no compounding effect for megacities).
68
+ */
69
+ populationScaleLog10: number
70
+ /**
71
+ * Tier candidates with an EXACT name/alias match above candidates that only match partially, BEFORE the weighted-sum
72
+ * score is consulted. Default true.
73
+ *
74
+ * Why this is needed (and why it ALIGNS with — rather than overrides — the population/importance signal): the
75
+ * weighted sum adds population as a large additive boost (`populationBoost`, up to +4) so that famous places surface
76
+ * for unambiguous full-name queries. But population is a _prominence prior_ — its job is to break ties among
77
+ * candidates that match the query EQUALLY WELL (e.g. "Springfield" → Springfield IL over Springfield MA, both exact
78
+ * name matches). It was never meant to promote a place that matches the query WORSE. For a 2-letter region
79
+ * abbreviation that backfires: querying "ME" returns Maine (which has the exact alias `ME`) AND Missouri/
80
+ * Michigan/etc. (which do not), and Missouri's larger population (+4) overcomes Maine's bm25 edge — so "Portland, ME"
81
+ * resolves its region to Missouri and the locality then cascades to the wrong state. Tiering restores the intended
82
+ * ordering: **match quality is the primary key, prominence (population) the secondary key WITHIN a tier.**
83
+ * Springfield-IL-over-MA still works (both exact → same tier → population decides); ME→Maine now works (only Maine is
84
+ * exact → higher tier → population never gets to override it). See
85
+ * docs/articles/evals/resolver-geo/2026-05-30-resolver-exact-match.md.
86
+ *
87
+ * Note: tiering re-ranks within the over-fetched candidate window (`limit * 4`); a pathological exact match that
88
+ * falls outside that window is not rescued. For the region-abbrev case the window is comfortably sufficient (a
89
+ * handful of states match a 2-letter query).
90
+ */
91
+ exactMatchTiering: boolean
92
+ /**
93
+ * #936 option 3 — official-language names ARE names. When true, a candidate holding the query as an OFFICIAL name
94
+ * (`names.official = 1`: a preferred-form name in an official language of its country, stamped at ingest) joins the
95
+ * NAME-exact sub-tier rather than the alias-exact one, provided its population clears {@link officialNameExactFloor}.
96
+ * Fixes unscoped "Åbo" → Turku (its official Swedish name) over a hamlet literally named Åbo; population still orders
97
+ * within the sub-tier, so Paris → Paris FR is untouched.
98
+ *
99
+ * Default true (operator-promoted 2026-07-03 after the pre-registered gate battery: four intended exonym flips —
100
+ * Berne→Bern, Bruges→Brugge, Roma→Rome, Åbo→Turku — with the namesake/abbreviation rows and the US/FI panels
101
+ * byte-identical). Requires a gazetteer carrying the #940 ingest bit — on older DBs without the `official` column the
102
+ * probe fails soft and behavior is identical to the flag being off.
103
+ */
104
+ officialNameExact: boolean
105
+ /**
106
+ * Minimum population for a candidate's official names to join the name-exact sub-tier. The #936 review's no-floor
107
+ * census measured the boundary: ≥100k holders are the famous-exonym class (757 flips, intent-correct; 7 collisions,
108
+ * none harmful) while 10k–100k holders are junk-dominated (3,481 flips led by short-form mis-tags — Villeneuve-Loubet
109
+ * carrying "villeneuve" would bury five real villages of that name). Rank-time knob: tunable without re-ingest;
110
+ * below-floor official names simply stay alias-tier (today's behavior).
111
+ */
112
+ officialNameExactFloor: number
113
+ }
114
+
115
+ /**
116
+ * The shipped weights. Every value is a measured decision — change one and re-run the resolver eval; the per-field docs
117
+ * on {@link RankingWeights} say what each lever moves and what motivated its current value.
118
+ */
119
+ export const DEFAULT_WEIGHTS: RankingWeights = {
120
+ placetypeMatchBoost: 0.5,
121
+ localityImplicitBoost: 0.2,
122
+ countryMatchBoost: 0.3,
123
+ directChildBoost: 0.5,
124
+ descendantBoost: 0.2,
125
+ lengthPenaltyWeight: 0.1,
126
+ proximityBoost: 0.8,
127
+ proximityScaleKm: 100,
128
+ biasBoost: 4,
129
+ // populationBoost is intentionally large — empirical tuning against real WOF showed BM25 gaps
130
+ // of 1.5-3.0 between famous places and tiny same-name peers (because the famous ones have
131
+ // hundreds of alt-name entries that hurt their FTS document score). To consistently surface
132
+ // "the famous one" for unambiguous queries like "New York" or "Chicago", the population signal
133
+ // needs to dominate. Callers wanting a more conservative balance can drop this in the
134
+ // RankingWeights override.
135
+ //
136
+ // Note: this resolver uses `place_population` directly. The separate `place_importance` table
137
+ // (Wikipedia-derived) is consumed by the FST layer, not here. See
138
+ // docs/articles/concepts/importance-vs-population.md for the two-signal contract.
139
+ populationBoost: 4,
140
+ populationScaleLog10: 6,
141
+ // Exact name/alias match outranks partial match before the weighted sum (incl. population) is
142
+ // consulted — keeps population as an intra-tier prominence tiebreaker, not a cross-tier promoter.
143
+ // Fixes the 2-letter-region-abbrev bug ("ME" → Maine, not the more-populous Missouri).
144
+ exactMatchTiering: true,
145
+ // #936 option 3 — promoted default-ON 2026-07-03 (gate battery PASS; see the RankingWeights docstring).
146
+ officialNameExact: true,
147
+ officialNameExactFloor: 100_000,
148
+ }