@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
@@ -25,10 +25,11 @@
25
25
 
26
26
  import { DatabaseSync } from "node:sqlite"
27
27
 
28
- import { expandPlacetypeFilter } from "@mailwoman/resolver"
28
+ import { expandPlacetypeFilter, type GazetteerArtifactCoverage } from "@mailwoman/resolver"
29
29
 
30
30
  import { CANDIDATE_FTS_TABLE } from "./candidate-fts.ts"
31
31
  import type { CandidateTable, CountryCodeTable, PlacetypeCodeTable } from "./candidate-schema.ts"
32
+ import { readGazetteerCoverageManifest } from "./coverage-manifest-schema.ts"
32
33
  import { haversineKm } from "./geo.ts"
33
34
  import { trigramJaccard } from "./lookup.ts"
34
35
  import { POSTAL_CITY_CANDIDATE_TABLE, type PostalCityCandidateTable } from "./postal-city-candidate-schema.ts"
@@ -37,9 +38,13 @@ import { normalizeLocalityForKey, stripLocalityQualifier } from "./street-normal
37
38
  import type { FindPlaceQuery, PlaceCandidate, PlaceLookup, WOFPlacetype } from "./types.ts"
38
39
 
39
40
  export interface WOFCandidateTableLookupOpts {
40
- /** Path to a `candidate.db` built by `build-candidate.ts`. Opened read-only. */
41
+ /**
42
+ * Path to a `candidate.db` built by `build-candidate.ts`. Opened read-only.
43
+ */
41
44
  databasePath?: string
42
- /** Pre-opened handle (tests / shared connections). Mutually exclusive with `databasePath`. */
45
+ /**
46
+ * Pre-opened handle (tests / shared connections). Mutually exclusive with `databasePath`.
47
+ */
43
48
  database?: DatabaseSync
44
49
  }
45
50
 
@@ -91,7 +96,7 @@ const FUZZY_MIN = 0.34
91
96
  * ("Los Angeles" over La, Ghana — gap 1.6; "Las Vegas" over Vegas, Cuba — gap 2.4) while a near-tie coincidental
92
97
  * collision defers to the primary (Cancún over Changchun — gap 0.7).
93
98
  */
94
- const PRIMARY_PREFERENCE_LOG10 = 1.0
99
+ const PRIMARY_PREFERENCE_LOG10 = 1
95
100
 
96
101
  /**
97
102
  * Over-fetch cap for {@link rankByPrimaryPreference}: the candidate rows for one `name_key` (all same-name places
@@ -101,7 +106,9 @@ const PRIMARY_PREFERENCE_LOG10 = 1.0
101
106
  */
102
107
  const RERANK_FETCH = 64
103
108
 
104
- /** A candidate row annotated with the {@link rankByPrimaryPreference} effective rank + the exact-tier demotion flag. */
109
+ /**
110
+ * A candidate row annotated with the {@link rankByPrimaryPreference} effective rank + the exact-tier demotion flag.
111
+ */
105
112
  export type RankedRow<R> = R & {
106
113
  /**
107
114
  * `neg_rank` plus the bounded cross-country alias penalty — the value the row is ORDERED by, and the base the emitted
@@ -143,10 +150,12 @@ export function rankByPrimaryPreference<R extends Pick<CandidateRow, "neg_rank"
143
150
  }
144
151
 
145
152
  const topCountry = topPrimary?.country_id
153
+
146
154
  // A cross-country alias (different country than the top primary) is penalized; it is DEMOTED when even after — i.e.
147
155
  // the penalty leaves its effective rank behind the primary's raw rank (it lost the bounded population contest).
148
156
  const isCrossCountryAlias = (r: R): boolean =>
149
157
  topCountry !== undefined && r.is_primary !== 1 && r.country_id !== topCountry
158
+
150
159
  const annotate = (r: R): RankedRow<R> => {
151
160
  const penalized = isCrossCountryAlias(r)
152
161
  const effectiveNegRank = r.neg_rank + (penalized ? delta : 0)
@@ -158,6 +167,7 @@ export function rankByPrimaryPreference<R extends Pick<CandidateRow, "neg_rank"
158
167
  rows
159
168
  .map((r, i) => ({ row: annotate(r), i }))
160
169
  // Effective rank ASC; ties keep population order, then original index (stable).
170
+ // oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
161
171
  .sort((a, b) => a.row.effectiveNegRank - b.row.effectiveNegRank || a.row.neg_rank - b.row.neg_rank || a.i - b.i)
162
172
  .slice(0, limit)
163
173
  .map((x) => x.row)
@@ -212,6 +222,12 @@ export class WOFCandidateTableLookup implements PlaceLookup {
212
222
  * country-agnostic retry. Prepared only alongside `#ftsProbe`.
213
223
  */
214
224
  readonly #nameKeyExistsProbe: ReturnType<DatabaseSync["prepare"]> | undefined
225
+ /**
226
+ * Facts this candidate DB declares about itself — the coverage manifest (`country_coverage` + `country_bbox`) the
227
+ * gazetteer build emits, read once at open. `undefined` when the artifact predates the manifest, so every consumer
228
+ * (the hard-country coverage gate, guard-B plausibility) falls back to its code constants byte-identically.
229
+ */
230
+ readonly artifactCoverage: GazetteerArtifactCoverage | undefined
215
231
 
216
232
  constructor(opts: WOFCandidateTableLookupOpts) {
217
233
  if (opts.database) {
@@ -253,11 +269,19 @@ export class WOFCandidateTableLookup implements PlaceLookup {
253
269
  this.#ftsProbe = this.#db.prepare(
254
270
  `SELECT name_key FROM ${CANDIDATE_FTS_TABLE} WHERE ${CANDIDATE_FTS_TABLE} MATCH ? ORDER BY bm25(${CANDIDATE_FTS_TABLE}) LIMIT ?`
255
271
  )
272
+
256
273
  this.#nameKeyExistsProbe = this.#db.prepare("SELECT 1 FROM candidate WHERE name_key = ? LIMIT 1")
257
274
  }
275
+
276
+ // Coverage manifest (survey candidate #2): the artifact's own coverage facts, existence-gated like
277
+ // the probes above — a candidate.db built before the manifest reads `undefined` and consumers keep
278
+ // their code-constant fallbacks byte-identically.
279
+ this.artifactCoverage = readGazetteerCoverageManifest(this.#db)
258
280
  }
259
281
 
260
- /** Does this query want a locality-tier place? Postal-city aliases (#741) are all localities. */
282
+ /**
283
+ * Does this query want a locality-tier place? Postal-city aliases (#741) are all localities.
284
+ */
261
285
  #wantsLocality(placetype: FindPlaceQuery["placetype"]): boolean {
262
286
  if (!placetype) return true
263
287
  const want = Array.isArray(placetype) ? placetype : [placetype]
@@ -274,8 +298,9 @@ export class WOFCandidateTableLookup implements PlaceLookup {
274
298
  // form at build (the GeoNames fold normalizes '624 66' → '62466'), so a postcode-typed query
275
299
  // strips internal whitespace before keying. Postcode-only — locality names keep their spaces.
276
300
  if ([query.placetype].flat().includes("postalcode")) {
277
- text = text.replace(/\s+/g, "")
301
+ text = text.replaceAll(/\s+/g, "")
278
302
  }
303
+
279
304
  const nameKey = normalizeLocalityForKey(text)
280
305
 
281
306
  if (!nameKey) return []
@@ -325,11 +350,12 @@ export class WOFCandidateTableLookup implements PlaceLookup {
325
350
  // Shared placetype-equivalence expansion (a `locality` query must also reach borough /
326
351
  // localadmin). `postalcode` maps to no admin placetype here → empty → no rows.
327
352
  const want = Array.isArray(query.placetype) ? query.placetype : [query.placetype]
353
+
328
354
  const ids = expandPlacetypeFilter(want as readonly string[])
329
355
  .map((t) => this.#placetypeToID.get(t))
330
356
  .filter((v): v is number => v !== undefined)
331
357
 
332
- if (ids.length === 0) return []
358
+ if (!ids.length) return []
333
359
  filters.push(`placetype_id IN (${ids.map(() => "?").join(",")})`)
334
360
  filterParams.push(...ids)
335
361
  }
@@ -348,7 +374,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
348
374
  // Kept OUT of the shared `filters` so a region MISS falls back to the unscoped cascade below: a
349
375
  // country/non-region parent (no `region_id` match), a `region_id=0` row (place with no region
350
376
  // ancestor), or a wrong parent degrades to today's behavior — never worse, recall-safe by construction.
351
- const regionParentID = query.parentID ? query.parentID : undefined
377
+ const regionParentID = query.parentID || undefined
352
378
 
353
379
  const probe = (nk: string, regionID: number | undefined): Array<RankedRow<CandidateRow>> => {
354
380
  const conds = ["name_key = ?", ...filters]
@@ -367,6 +393,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
367
393
  const sql =
368
394
  "SELECT spr_id, name, country_id, placetype_id, latitude, longitude, min_lat, min_lon, max_lat, max_lon, neg_rank, is_primary " +
369
395
  `FROM candidate WHERE ${conds.join(" AND ")} ORDER BY neg_rank ASC LIMIT ?`
396
+
370
397
  const fetched = this.#db.prepare(sql).all(...params, Math.max(limit, RERANK_FETCH)) as unknown as CandidateRow[]
371
398
 
372
399
  return rankByPrimaryPreference(fetched, limit)
@@ -378,7 +405,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
378
405
  const cascade = (regionID: number | undefined): Array<RankedRow<CandidateRow>> => {
379
406
  let rows = probe(nameKey, regionID)
380
407
 
381
- if (rows.length === 0) {
408
+ if (!rows.length) {
382
409
  // Query-side qualifier-strip fallback: an OA locality with a qualifier the gazetteer's
383
410
  // canonical name omits ("Lenk im Simmental" → "Lenk", "Roche VD"). Tried ONLY on an exact
384
411
  // miss; the cascade's region bbox disambiguates any base-name ambiguity.
@@ -400,15 +427,18 @@ export class WOFCandidateTableLookup implements PlaceLookup {
400
427
  // — fuzzing it scrapes an unrelated same-filter place ("Vienna, Austria" misrouted to IT would
401
428
  // pull a tiny Italian name_key near Siena) and masks the cascade's country-agnostic retry that
402
429
  // correctly lands population-first Vienna AT. The exact/strip probes already covered the real name.
403
- if (rows.length === 0 && this.#ftsProbe && this.#nameKeyExistsProbe && !this.#nameKeyExistsProbe.get(nameKey)) {
430
+ if (!rows.length && this.#ftsProbe && this.#nameKeyExistsProbe && !this.#nameKeyExistsProbe.get(nameKey)) {
404
431
  const match = ftsTrigramQuery(nameKey)
405
432
 
406
433
  if (match) {
407
434
  const hits = this.#ftsProbe.all(match, FUZZY_FETCH) as unknown as Array<{ name_key: string }>
435
+
408
436
  const ranked = hits
409
437
  .map((h) => ({ nk: String(h.name_key), s: trigramJaccard(nameKey, String(h.name_key)) }))
410
438
  .filter((h) => h.s >= FUZZY_MIN)
439
+ // oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
411
440
  .sort((a, b) => b.s - a.s)
441
+
412
442
  const seen = new Set<string>()
413
443
 
414
444
  for (const h of ranked) {
@@ -418,6 +448,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
418
448
 
419
449
  if (rows.length >= limit) break
420
450
  }
451
+
421
452
  rows = rows.slice(0, limit)
422
453
  }
423
454
  }
@@ -430,7 +461,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
430
461
  // Region-scope fallback: if scoping to the parent region found nothing across the whole cascade, retry
431
462
  // unscoped so a place with no in-region row (missing ancestry, or a country/non-region parent) still
432
463
  // resolves exactly as it does today. Only when a region scope was actually applied.
433
- if (rows.length === 0 && regionParentID !== undefined) {
464
+ if (!rows.length && regionParentID !== undefined) {
434
465
  rows = cascade(undefined)
435
466
  }
436
467
 
@@ -480,9 +511,9 @@ export class WOFCandidateTableLookup implements PlaceLookup {
480
511
  // log10(population + 1), so popTerm is the server formula read straight off it. Constants MIRROR
481
512
  // lookup.ts's DEFAULT_WEIGHTS (biasBoost 4, populationBoost 4, populationScaleLog10 6,
482
513
  // proximityScaleKm 100) — the #861 server↔demo parity contract; keep them in lockstep.
483
- if (query.bias && query.bias.length > 0) {
484
- const BIAS_BOOST = 4.0
485
- const POP_BOOST = 4.0
514
+ if (query.bias && query.bias.length) {
515
+ const BIAS_BOOST = 4
516
+ const POP_BOOST = 4
486
517
  const POP_SCALE_LOG10 = 6
487
518
  // SHARPER than lookup.ts's 100 km on purpose: this backend's `score` is log-population ALONE
488
519
  // (no bm25 document term), so the population signal is weaker relative to the bias and the
@@ -491,6 +522,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
491
522
  // candidates the user is actually LOOKING at — an in-view namesake still wins (Dublin, OH from
492
523
  // an Ohio view), a distant one no longer does (Paris stays FR from a Michigan view).
493
524
  const PROX_SCALE_KM = 30
525
+
494
526
  const combinedProminence = (c: PlaceCandidate): number => {
495
527
  // Population base is the PENALIZED `prominence` (set above = -effectiveNegRank), not raw `score`, so
496
528
  // the cross-country primary preference carries into the bias-weighted order too — a coincidental
@@ -512,6 +544,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
512
544
 
513
545
  return popTerm + proxTerm
514
546
  }
547
+
515
548
  // Persist the combined value into `prominence` so the resolver walk's `prominence ?? score` sort (and any
516
549
  // other node consumer) honors the bias order — then sort. Stable within equal prominence (preserves the
517
550
  // population order the B-tree already gave).
@@ -521,6 +554,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
521
554
 
522
555
  return { c, i, p: c.prominence }
523
556
  })
557
+ // oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
524
558
  .sort((a, b) => b.p - a.p || a.i - b.i)
525
559
  .forEach((x, j) => (candidates[j] = x.c))
526
560
  }
@@ -23,19 +23,29 @@ import { sql, type Kysely } from "kysely"
23
23
  * level (a postcode shard row may lack a bbox).
24
24
  */
25
25
  export interface CandidateTable {
26
- /** The shared {@link normalizeLocalityForKey} of the name/alias — the probe key. */
26
+ /**
27
+ * The shared {@link normalizeLocalityForKey} of the name/alias — the probe key.
28
+ */
27
29
  name_key: string
28
- /** Small int from {@link CountryCodeTable} (shrinks the clustered key). */
30
+ /**
31
+ * Small int from {@link CountryCodeTable} (shrinks the clustered key).
32
+ */
29
33
  country_id: number
30
- /** The place's region-tier ancestor id, or 0 (carried for the future region 2-step). */
34
+ /**
35
+ * The place's region-tier ancestor id, or 0 (carried for the future region 2-step).
36
+ */
31
37
  region_id: number
32
- /** Small int from {@link PlacetypeCodeTable}. */
38
+ /**
39
+ * Small int from {@link PlacetypeCodeTable}.
40
+ */
33
41
  placetype_id: number
34
42
  /**
35
43
  * `-log10(population + 1)` — ASC order = highest-population first. 0 for postcodes (no population).
36
44
  */
37
45
  neg_rank: number
38
- /** WOF id of the place this row resolves to. */
46
+ /**
47
+ * WOF id of the place this row resolves to.
48
+ */
39
49
  spr_id: number
40
50
  name: string | null
41
51
  latitude: number | null
@@ -45,27 +55,39 @@ export interface CandidateTable {
45
55
  max_lat: number | null
46
56
  max_lon: number | null
47
57
  population: number | null
48
- /** 1 when the row is the place's canonical name (vs an alias/abbrev). */
58
+ /**
59
+ * 1 when the row is the place's canonical name (vs an alias/abbrev).
60
+ */
49
61
  is_primary: number | null
50
62
  }
51
63
 
52
- /** `(id → ISO country code)` dictionary. */
64
+ /**
65
+ * `(id → ISO country code)` dictionary.
66
+ */
53
67
  export interface CountryCodeTable {
54
68
  id: number
55
69
  code: string
56
70
  }
57
71
 
58
- /** `(id → placetype)` dictionary. */
72
+ /**
73
+ * `(id → placetype)` dictionary.
74
+ */
59
75
  export interface PlacetypeCodeTable {
60
76
  id: number
61
77
  placetype: string
62
78
  }
63
79
 
64
- /** The candidate database schema for `new DatabaseClient<CandidateDatabase>(...)`. */
80
+ /**
81
+ * The candidate database schema for `new DatabaseClient<CandidateDatabase>(...)`.
82
+ */
65
83
  export interface CandidateDatabase {
66
- /** The clustered `WITHOUT ROWID` lookup table the reader probes. */
84
+ /**
85
+ * The clustered `WITHOUT ROWID` lookup table the reader probes.
86
+ */
67
87
  candidate: CandidateTable
68
- /** Transient staging table (same columns); dropped once `candidate` is materialized. */
88
+ /**
89
+ * Transient staging table (same columns); dropped once `candidate` is materialized.
90
+ */
69
91
  cand_stage: CandidateTable
70
92
  country_codes: CountryCodeTable
71
93
  placetype_codes: PlacetypeCodeTable
@@ -105,11 +127,13 @@ export async function createCandidateStagingTables(db: Kysely<CandidateDatabase>
105
127
  .addColumn("id", "integer", (c) => c.primaryKey())
106
128
  .addColumn("code", "text", (c) => c.unique())
107
129
  .execute()
130
+
108
131
  await db.schema
109
132
  .createTable("placetype_codes")
110
133
  .addColumn("id", "integer", (c) => c.primaryKey())
111
134
  .addColumn("placetype", "text", (c) => c.unique())
112
135
  .execute()
136
+
113
137
  await db.schema
114
138
  .createTable("cand_stage")
115
139
  .addColumn("name_key", "text")
@@ -42,9 +42,15 @@ import type { DatabaseSync } from "node:sqlite"
42
42
 
43
43
  import { haversineKm } from "@mailwoman/spatial"
44
44
 
45
+ /**
46
+ * Table of places that hold more than one admin role — a locality that is also its county seat. Written by the
47
+ * gazetteer build, read by the resolver when a coincident locality has to be chosen.
48
+ */
45
49
  export const COINCIDENT_ROLES_TABLE = "coincident_roles"
46
50
 
47
- /** A place that plays multiple admin roles — one row of the relation, keyed by `admin_id`. */
51
+ /**
52
+ * A place that plays multiple admin roles — one row of the relation, keyed by `admin_id`.
53
+ */
48
54
  export interface CoincidentRole {
49
55
  localityID: number
50
56
  relationshipType: "city-state" | "capital-seat" | "consolidated-county"
@@ -54,13 +60,17 @@ export interface CoincidentRole {
54
60
  }
55
61
 
56
62
  export interface BuildCoincidentRolesOpts {
57
- /** Drop + rebuild the table if it already exists. Default true (the build is cheap + idempotent). */
63
+ /**
64
+ * Drop + rebuild the table if it already exists. Default true (the build is cheap + idempotent).
65
+ */
58
66
  drop?: boolean
59
67
  /**
60
68
  * Relative tolerance: a pair is kept when centroid distance ≤ `toleranceFraction × bbox-diagonal`. Default 0.15.
61
69
  */
62
70
  toleranceFraction?: number
63
- /** Floor (km) under the relative tolerance, so small-bbox city-states still qualify. Default 12. */
71
+ /**
72
+ * Floor (km) under the relative tolerance, so small-bbox city-states still qualify. Default 12.
73
+ */
64
74
  minToleranceKm?: number
65
75
  /**
66
76
  * Centroid distance (km) below which a region-tier pair is classed `city-state` (metadata only). Default 2.
@@ -115,7 +125,9 @@ export function buildCoincidentRoles(
115
125
  onProgress("dropping", COINCIDENT_ROLES_TABLE)
116
126
  db.exec(`DROP TABLE ${COINCIDENT_ROLES_TABLE}`)
117
127
  }
128
+
118
129
  onProgress("creating", COINCIDENT_ROLES_TABLE)
130
+
119
131
  // Raw DDL by design: this is a sync builder consumed by a sync CLI (build-coincident-roles-cli) and
120
132
  // 6 sync unit tests, so routing one table through async Kysely would cascade async through all of
121
133
  // them for no real gain. See AGENTS.md "Database / inline SQL". (The SELECT + INSERT loop below are
@@ -133,6 +145,7 @@ export function buildCoincidentRoles(
133
145
  `)
134
146
 
135
147
  onProgress("scanning")
148
+
136
149
  // Admin (region/county tier) ⋈ same-name DESCENDANT locality. `place_population` is optional (LEFT
137
150
  // JOIN → 0 when absent). The relative-tolerance filter + relationship classification happen in JS so
138
151
  // the SQL stays a plain join. `spr` exposes the bbox columns we need for the diagonal.
@@ -153,11 +166,13 @@ export function buildCoincidentRoles(
153
166
  .all() as unknown as CandidateRow[]
154
167
 
155
168
  onProgress("filtering", `${candidates.length} candidates`)
169
+
156
170
  const insert = db.prepare(
157
171
  `INSERT OR REPLACE INTO ${COINCIDENT_ROLES_TABLE}
158
172
  (admin_id, locality_id, relationship_type, admin_placetype, distance_km, locality_population)
159
173
  VALUES (?, ?, ?, ?, ?, ?)`
160
174
  )
175
+
161
176
  const byCountry: Record<string, number> = {}
162
177
  let rowCount = 0
163
178
  db.exec("BEGIN")
@@ -176,14 +191,17 @@ export function buildCoincidentRoles(
176
191
  // dominated by French cantons / JP counties that don't hit the parser-drops-locality failure.
177
192
  const relationshipType = dist <= cityStateMaxKm ? "city-state" : "capital-seat"
178
193
  insert.run(c.admin_id, c.locality_id, relationshipType, c.admin_placetype, dist, c.pop)
194
+
179
195
  rowCount++
180
196
  byCountry[c.country] = (byCountry[c.country] ?? 0) + 1
181
197
  }
198
+
182
199
  db.exec("COMMIT")
183
- } catch (err) {
200
+ } catch (error) {
184
201
  db.exec("ROLLBACK")
185
- throw err
202
+ throw error
186
203
  }
204
+
187
205
  db.exec(`CREATE INDEX IF NOT EXISTS coincident_roles_by_admin ON ${COINCIDENT_ROLES_TABLE} (admin_id)`)
188
206
 
189
207
  onProgress("done", `${rowCount} coincident-role rows`)
@@ -191,7 +209,9 @@ export function buildCoincidentRoles(
191
209
  return { created: true, rowCount, byCountry, durationMs: Date.now() - start }
192
210
  }
193
211
 
194
- /** True iff the relation table exists. Used by the resolver to decide whether completion can run. */
212
+ /**
213
+ * True iff the relation table exists. Used by the resolver to decide whether completion can run.
214
+ */
195
215
  export function coincidentRolesExists(db: DatabaseSync): boolean {
196
216
  return tableExists(db, COINCIDENT_ROLES_TABLE)
197
217
  }
@@ -205,6 +225,7 @@ export function loadCoincidentRoles(db: DatabaseSync): Map<number, CoincidentRol
205
225
  const map = new Map<number, CoincidentRole[]>()
206
226
 
207
227
  if (!coincidentRolesExists(db)) return map
228
+
208
229
  const rows = db
209
230
  .prepare(
210
231
  `SELECT admin_id, locality_id, relationship_type, admin_placetype, distance_km, locality_population
@@ -227,6 +248,7 @@ export function loadCoincidentRoles(db: DatabaseSync): Map<number, CoincidentRol
227
248
  distanceKm: r.distance_km,
228
249
  population: r.locality_population,
229
250
  }
251
+
230
252
  const list = map.get(r.admin_id)
231
253
 
232
254
  if (list) {
package/convention.ts CHANGED
@@ -38,7 +38,9 @@ export interface ScoringWeights {
38
38
  * `tokenNormalization`, etc.
39
39
  */
40
40
  export interface Convention {
41
- /** Ordered strategy names the dispatcher runs; the first to return a non-null result wins. */
41
+ /**
42
+ * Ordered strategy names the dispatcher runs; the first to return a non-null result wins.
43
+ */
42
44
  candidateStrategies?: string[]
43
45
  /**
44
46
  * Weights for `postcode_area_resolution`'s soft-score. Partial — a layer may nudge one weight and inherit the rest
@@ -0,0 +1,243 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Typed schema + read/write helpers for the candidate gazetteer's COVERAGE MANIFEST — the two
7
+ * country-keyed tables through which the artifact declares facts about ITSELF, so those facts are
8
+ * updated at gazetteer REBUILD, not by a hand-edited code PR after someone remembers:
9
+ *
10
+ * - `country_coverage`: the hard-country-filter coverage record (#743/#194) — per-country
11
+ * promote-gate verdicts + the measured hard-resolve rates that used to live in a code comment on
12
+ * `HARD_PLACE_COUNTRY_SAFELIST`. Presence = measured; `hard_filter_safe = 0` = measured and
13
+ * FAILED the gate (FI 69.5%, PL 77.8%) — distinguishable from a country never measured at all
14
+ * (the meaning-of-zero rule, `docs/articles/plan/reference/layer-contract.mdx`).
15
+ * - `country_bbox`: the coarse guard-B plausibility boxes that used to live in
16
+ * `resolver/plausibility.ts`'s `COUNTRY_BBOX`. An absent row fails open (never trips the guard),
17
+ * exactly like an absent key in the constant.
18
+ *
19
+ * Emission happens at build time (`mailwoman/gazetteer-pipeline/coverage-manifest.ts` owns the
20
+ * measured record and calls {@link writeGazetteerCoverageManifest} before the DB is sealed — never
21
+ * patch a shipped DB, rebuild). The read happens at open time ({@link WOFCandidateTableLookup}
22
+ * calls {@link readGazetteerCoverageManifest} in its constructor); an artifact that predates the
23
+ * manifest returns `undefined` and every consumer falls back to the code constants byte-identically.
24
+ */
25
+
26
+ import type { DatabaseSync } from "node:sqlite"
27
+
28
+ import {
29
+ hardCountrySafelistFromCoverage,
30
+ type CountryBBoxFact,
31
+ type CountryCoverageFact,
32
+ type GazetteerArtifactCoverage,
33
+ } from "@mailwoman/core/resolver"
34
+ import { sql, type Kysely } from "kysely"
35
+
36
+ import { hasTable } from "./sqlite-utils.ts"
37
+
38
+ /**
39
+ * One country's hard-filter coverage measurement — the storage form of {@link CountryCoverageFact}.
40
+ */
41
+ export interface CountryCoverageTable {
42
+ /**
43
+ * ISO 3166-1 alpha-2, uppercase (PK).
44
+ */
45
+ country: string
46
+ /**
47
+ * 0/1 — the promote-gate verdict (a verdict column, NOT re-derived from the rate; see the fact type's docstring).
48
+ */
49
+ hard_filter_safe: number
50
+ /**
51
+ * Measured hard-resolve rate 0..1 on the panel named in `source`; NULL when the receipt recorded none.
52
+ */
53
+ hard_resolve_rate: number | null
54
+ /**
55
+ * Panel size behind `hard_resolve_rate`; NULL when unrecorded.
56
+ */
57
+ sample_size: number | null
58
+ /**
59
+ * ISO-8601 date of the measurement / promote gate.
60
+ */
61
+ measured_at: string
62
+ /**
63
+ * The receipt: which panel/gate produced this row.
64
+ */
65
+ source: string
66
+ }
67
+
68
+ /**
69
+ * One country's coarse guard-B bounding box — the storage form of {@link CountryBBoxFact}.
70
+ */
71
+ export interface CountryBBoxTable {
72
+ /**
73
+ * ISO 3166-1 alpha-2, uppercase (PK).
74
+ */
75
+ country: string
76
+ lat_min: number
77
+ lat_max: number
78
+ lon_min: number
79
+ lon_max: number
80
+ /**
81
+ * Provenance of the box (harness + date).
82
+ */
83
+ source: string
84
+ }
85
+
86
+ /**
87
+ * The coverage-manifest schema for `new DatabaseClient<GazetteerCoverageDatabase>(...)`.
88
+ */
89
+ export interface GazetteerCoverageDatabase {
90
+ country_coverage: CountryCoverageTable
91
+ country_bbox: CountryBBoxTable
92
+ }
93
+
94
+ /**
95
+ * Table names the lookup probes (existence-gated, so a candidate.db built before the manifest is byte-stable).
96
+ */
97
+ export const COUNTRY_COVERAGE_TABLE = "country_coverage"
98
+ /**
99
+ * Table of per-country bounding boxes, used to reject a placement that fell outside its own country.
100
+ */
101
+ export const COUNTRY_BBOX_TABLE = "country_bbox"
102
+
103
+ /**
104
+ * Create `country_coverage` — a handful of small PK-probed rows, the WITHOUT ROWID sweet spot.
105
+ */
106
+ export async function createCountryCoverageTable(db: Kysely<GazetteerCoverageDatabase>): Promise<void> {
107
+ await db.schema
108
+ .createTable(COUNTRY_COVERAGE_TABLE)
109
+ .ifNotExists()
110
+ .addColumn("country", "text", (c) => c.primaryKey())
111
+ .addColumn("hard_filter_safe", "integer", (c) => c.notNull())
112
+ .addColumn("hard_resolve_rate", "real")
113
+ .addColumn("sample_size", "integer")
114
+ .addColumn("measured_at", "text", (c) => c.notNull())
115
+ .addColumn("source", "text", (c) => c.notNull())
116
+ // `WITHOUT ROWID` has no first-class builder; the raw modifier is the idiomatic fallback.
117
+ .modifyEnd(sql`without rowid`)
118
+ .execute()
119
+ }
120
+
121
+ /**
122
+ * Create `country_bbox` — same shape discipline as {@link createCountryCoverageTable}.
123
+ */
124
+ export async function createCountryBBoxTable(db: Kysely<GazetteerCoverageDatabase>): Promise<void> {
125
+ await db.schema
126
+ .createTable(COUNTRY_BBOX_TABLE)
127
+ .ifNotExists()
128
+ .addColumn("country", "text", (c) => c.primaryKey())
129
+ .addColumn("lat_min", "real", (c) => c.notNull())
130
+ .addColumn("lat_max", "real", (c) => c.notNull())
131
+ .addColumn("lon_min", "real", (c) => c.notNull())
132
+ .addColumn("lon_max", "real", (c) => c.notNull())
133
+ .addColumn("source", "text", (c) => c.notNull())
134
+ .modifyEnd(sql`without rowid`)
135
+ .execute()
136
+ }
137
+
138
+ /**
139
+ * Write the coverage manifest into a candidate DB UNDER CONSTRUCTION (pre-seal — a shipped DB is never patched, rebuild
140
+ * instead). Creates both tables and inserts the facts; call exactly once, from the gazetteer build.
141
+ */
142
+ export async function writeGazetteerCoverageManifest(
143
+ db: Kysely<GazetteerCoverageDatabase>,
144
+ facts: { coverage: readonly CountryCoverageFact[]; bboxes: readonly CountryBBoxFact[] }
145
+ ): Promise<void> {
146
+ await createCountryCoverageTable(db)
147
+ await createCountryBBoxTable(db)
148
+
149
+ if (facts.coverage.length) {
150
+ await db
151
+ .insertInto(COUNTRY_COVERAGE_TABLE)
152
+ .values(
153
+ facts.coverage.map((f) => ({
154
+ country: f.country.toUpperCase(),
155
+ hard_filter_safe: f.hardFilterSafe ? 1 : 0,
156
+ hard_resolve_rate: f.hardResolveRate ?? null,
157
+ sample_size: f.sampleSize ?? null,
158
+ measured_at: f.measuredAt,
159
+ source: f.source,
160
+ }))
161
+ )
162
+ .execute()
163
+ }
164
+
165
+ if (facts.bboxes.length) {
166
+ await db
167
+ .insertInto(COUNTRY_BBOX_TABLE)
168
+ .values(
169
+ facts.bboxes.map((f) => ({
170
+ country: f.country.toUpperCase(),
171
+ lat_min: f.latMin,
172
+ lat_max: f.latMax,
173
+ lon_min: f.lonMin,
174
+ lon_max: f.lonMax,
175
+ source: f.source,
176
+ }))
177
+ )
178
+ .execute()
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Read the coverage manifest from an OPEN candidate DB, or `undefined` when the artifact predates it (neither table
184
+ * present) — the signal for consumers to fall back to the code constants byte-identically. Synchronous raw reads on
185
+ * purpose: this runs inside {@link WOFCandidateTableLookup}'s synchronous constructor (the sync-reader carve-out in
186
+ * `AGENTS.md`), and the tables are a few dozen rows read once per open.
187
+ */
188
+ export function readGazetteerCoverageManifest(db: DatabaseSync): GazetteerArtifactCoverage | undefined {
189
+ const hasCoverage = hasTable(db, COUNTRY_COVERAGE_TABLE)
190
+ const hasBBox = hasTable(db, COUNTRY_BBOX_TABLE)
191
+
192
+ if (!hasCoverage && !hasBBox) return undefined
193
+
194
+ const countryCoverage = new Map<string, CountryCoverageFact>()
195
+
196
+ if (hasCoverage) {
197
+ const rows = db
198
+ .prepare(
199
+ `SELECT country, hard_filter_safe, hard_resolve_rate, sample_size, measured_at, source FROM ${COUNTRY_COVERAGE_TABLE}`
200
+ )
201
+ .all() as unknown as CountryCoverageTable[]
202
+
203
+ for (const row of rows) {
204
+ const country = String(row.country).toUpperCase()
205
+
206
+ countryCoverage.set(country, {
207
+ country,
208
+ hardFilterSafe: Number(row.hard_filter_safe) !== 0,
209
+ ...(row.hard_resolve_rate === null ? {} : { hardResolveRate: Number(row.hard_resolve_rate) }),
210
+ ...(row.sample_size === null ? {} : { sampleSize: Number(row.sample_size) }),
211
+ measuredAt: String(row.measured_at),
212
+ source: String(row.source),
213
+ })
214
+ }
215
+ }
216
+
217
+ const countryBBoxes = new Map<string, CountryBBoxFact>()
218
+
219
+ if (hasBBox) {
220
+ const rows = db
221
+ .prepare(`SELECT country, lat_min, lat_max, lon_min, lon_max, source FROM ${COUNTRY_BBOX_TABLE}`)
222
+ .all() as unknown as CountryBBoxTable[]
223
+
224
+ for (const row of rows) {
225
+ const country = String(row.country).toUpperCase()
226
+
227
+ countryBBoxes.set(country, {
228
+ country,
229
+ latMin: Number(row.lat_min),
230
+ latMax: Number(row.lat_max),
231
+ lonMin: Number(row.lon_min),
232
+ lonMax: Number(row.lon_max),
233
+ source: String(row.source),
234
+ })
235
+ }
236
+ }
237
+
238
+ return {
239
+ countryCoverage,
240
+ countryBBoxes,
241
+ hardCountrySafelist: hardCountrySafelistFromCoverage(countryCoverage.values()),
242
+ }
243
+ }