@mailwoman/resolver-wof-sqlite 9.1.0 → 9.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 (278) hide show
  1. package/README.md +28 -9
  2. package/address-point-interpolation.ts +18 -8
  3. package/address-point-schema.ts +18 -6
  4. package/address-point.ts +111 -18
  5. package/ancestry.ts +9 -6
  6. package/build-candidate.ts +200 -163
  7. package/build-slim.ts +3 -3
  8. package/candidate/alias-bags.ts +54 -0
  9. package/candidate/ancestors-sidecar.ts +206 -0
  10. package/candidate/country-display-names.ts +79 -0
  11. package/candidate/name-roles.ts +237 -0
  12. package/candidate/own-name.ts +146 -0
  13. package/candidate/place-attrs.ts +44 -0
  14. package/candidate/shard-fold.ts +137 -0
  15. package/candidate-ancestors-schema.ts +195 -0
  16. package/candidate-fts.ts +4 -2
  17. package/candidate-importance.ts +2 -1
  18. package/candidate-lookup.ts +439 -186
  19. package/candidate-schema.ts +33 -5
  20. package/candidate-scoring.ts +268 -0
  21. package/capital-schema.ts +90 -0
  22. package/capitals.ts +148 -0
  23. package/coincident-roles.ts +69 -10
  24. package/convention-schema.ts +72 -0
  25. package/convention.ts +2 -2
  26. package/coverage-manifest-schema.ts +7 -7
  27. package/currency-backfill.ts +249 -0
  28. package/exact-match.ts +104 -0
  29. package/fst-autocomplete.ts +91 -119
  30. package/fst-builder.ts +14 -12
  31. package/fst-freshness.ts +2 -2
  32. package/fts-query.ts +1 -1
  33. package/fts.ts +4 -4
  34. package/geonames-postal.ts +2 -2
  35. package/index.ts +18 -14
  36. package/interpolation.ts +113 -19
  37. package/lookup.ts +110 -591
  38. package/name-score.ts +6 -4
  39. package/out/address-point-interpolation.d.ts.map +1 -1
  40. package/out/address-point-interpolation.js +13 -7
  41. package/out/address-point-interpolation.js.map +1 -1
  42. package/out/address-point-schema.d.ts +16 -6
  43. package/out/address-point-schema.d.ts.map +1 -1
  44. package/out/address-point-schema.js.map +1 -1
  45. package/out/address-point.d.ts.map +1 -1
  46. package/out/address-point.js +70 -14
  47. package/out/address-point.js.map +1 -1
  48. package/out/ancestry.d.ts +2 -2
  49. package/out/ancestry.d.ts.map +1 -1
  50. package/out/ancestry.js +5 -6
  51. package/out/ancestry.js.map +1 -1
  52. package/out/build-candidate.d.ts +75 -0
  53. package/out/build-candidate.d.ts.map +1 -1
  54. package/out/build-candidate.js +120 -122
  55. package/out/build-candidate.js.map +1 -1
  56. package/out/build-slim.d.ts +1 -1
  57. package/out/build-slim.js +3 -3
  58. package/out/build-slim.js.map +1 -1
  59. package/out/candidate/alias-bags.d.ts +17 -0
  60. package/out/candidate/alias-bags.d.ts.map +1 -0
  61. package/out/candidate/alias-bags.js +39 -0
  62. package/out/candidate/alias-bags.js.map +1 -0
  63. package/out/candidate/ancestors-sidecar.d.ts +33 -0
  64. package/out/candidate/ancestors-sidecar.d.ts.map +1 -0
  65. package/out/candidate/ancestors-sidecar.js +140 -0
  66. package/out/candidate/ancestors-sidecar.js.map +1 -0
  67. package/out/candidate/country-display-names.d.ts +35 -0
  68. package/out/candidate/country-display-names.d.ts.map +1 -0
  69. package/out/candidate/country-display-names.js +59 -0
  70. package/out/candidate/country-display-names.js.map +1 -0
  71. package/out/candidate/name-roles.d.ts +55 -0
  72. package/out/candidate/name-roles.d.ts.map +1 -0
  73. package/out/candidate/name-roles.js +165 -0
  74. package/out/candidate/name-roles.js.map +1 -0
  75. package/out/candidate/own-name.d.ts +50 -0
  76. package/out/candidate/own-name.d.ts.map +1 -0
  77. package/out/candidate/own-name.js +132 -0
  78. package/out/candidate/own-name.js.map +1 -0
  79. package/out/candidate/place-attrs.d.ts +43 -0
  80. package/out/candidate/place-attrs.d.ts.map +1 -0
  81. package/out/candidate/place-attrs.js +15 -0
  82. package/out/candidate/place-attrs.js.map +1 -0
  83. package/out/candidate/shard-fold.d.ts +31 -0
  84. package/out/candidate/shard-fold.d.ts.map +1 -0
  85. package/out/candidate/shard-fold.js +104 -0
  86. package/out/candidate/shard-fold.js.map +1 -0
  87. package/out/candidate-ancestors-schema.d.ts +150 -0
  88. package/out/candidate-ancestors-schema.d.ts.map +1 -0
  89. package/out/candidate-ancestors-schema.js +123 -0
  90. package/out/candidate-ancestors-schema.js.map +1 -0
  91. package/out/candidate-fts.d.ts +4 -2
  92. package/out/candidate-fts.d.ts.map +1 -1
  93. package/out/candidate-fts.js +4 -2
  94. package/out/candidate-fts.js.map +1 -1
  95. package/out/candidate-importance.d.ts.map +1 -1
  96. package/out/candidate-importance.js +1 -1
  97. package/out/candidate-importance.js.map +1 -1
  98. package/out/candidate-lookup.d.ts +22 -45
  99. package/out/candidate-lookup.d.ts.map +1 -1
  100. package/out/candidate-lookup.js +340 -135
  101. package/out/candidate-lookup.js.map +1 -1
  102. package/out/candidate-schema.d.ts +30 -6
  103. package/out/candidate-schema.d.ts.map +1 -1
  104. package/out/candidate-schema.js +3 -0
  105. package/out/candidate-schema.js.map +1 -1
  106. package/out/candidate-scoring.d.ts +34 -0
  107. package/out/candidate-scoring.d.ts.map +1 -0
  108. package/out/candidate-scoring.js +200 -0
  109. package/out/candidate-scoring.js.map +1 -0
  110. package/out/capital-schema.d.ts +51 -0
  111. package/out/capital-schema.d.ts.map +1 -0
  112. package/out/capital-schema.js +63 -0
  113. package/out/capital-schema.js.map +1 -0
  114. package/out/capitals.d.ts +69 -0
  115. package/out/capitals.d.ts.map +1 -0
  116. package/out/capitals.js +98 -0
  117. package/out/capitals.js.map +1 -0
  118. package/out/coincident-roles.d.ts +7 -0
  119. package/out/coincident-roles.d.ts.map +1 -1
  120. package/out/coincident-roles.js +42 -8
  121. package/out/coincident-roles.js.map +1 -1
  122. package/out/convention-schema.d.ts +51 -0
  123. package/out/convention-schema.d.ts.map +1 -0
  124. package/out/convention-schema.js +34 -0
  125. package/out/convention-schema.js.map +1 -0
  126. package/out/convention.d.ts +1 -1
  127. package/out/convention.js +2 -2
  128. package/out/coverage-manifest-schema.js +3 -7
  129. package/out/coverage-manifest-schema.js.map +1 -1
  130. package/out/currency-backfill.d.ts +46 -0
  131. package/out/currency-backfill.d.ts.map +1 -0
  132. package/out/currency-backfill.js +180 -0
  133. package/out/currency-backfill.js.map +1 -0
  134. package/out/exact-match.d.ts +25 -0
  135. package/out/exact-match.d.ts.map +1 -0
  136. package/out/exact-match.js +89 -0
  137. package/out/exact-match.js.map +1 -0
  138. package/out/fst-autocomplete.d.ts +11 -11
  139. package/out/fst-autocomplete.d.ts.map +1 -1
  140. package/out/fst-autocomplete.js +82 -99
  141. package/out/fst-autocomplete.js.map +1 -1
  142. package/out/fst-builder.d.ts.map +1 -1
  143. package/out/fst-builder.js +11 -12
  144. package/out/fst-builder.js.map +1 -1
  145. package/out/fst-freshness.d.ts +2 -2
  146. package/out/fst-freshness.js +2 -2
  147. package/out/fts-query.js +1 -1
  148. package/out/fts-query.js.map +1 -1
  149. package/out/fts.d.ts +4 -4
  150. package/out/fts.js +4 -4
  151. package/out/geonames-postal.d.ts +2 -2
  152. package/out/geonames-postal.js +2 -2
  153. package/out/index.d.ts +3 -2
  154. package/out/index.d.ts.map +1 -1
  155. package/out/index.js +2 -2
  156. package/out/index.js.map +1 -1
  157. package/out/interpolation.d.ts +8 -0
  158. package/out/interpolation.d.ts.map +1 -1
  159. package/out/interpolation.js +91 -19
  160. package/out/interpolation.js.map +1 -1
  161. package/out/lookup.d.ts +4 -5
  162. package/out/lookup.d.ts.map +1 -1
  163. package/out/lookup.js +94 -468
  164. package/out/lookup.js.map +1 -1
  165. package/out/name-score.d.ts +0 -10
  166. package/out/name-score.d.ts.map +1 -1
  167. package/out/name-score.js +6 -4
  168. package/out/name-score.js.map +1 -1
  169. package/out/place-importance-schema.d.ts +42 -5
  170. package/out/place-importance-schema.d.ts.map +1 -1
  171. package/out/place-importance-schema.js +54 -8
  172. package/out/place-importance-schema.js.map +1 -1
  173. package/out/poi-lookup.d.ts +1 -1
  174. package/out/poi-lookup.d.ts.map +1 -1
  175. package/out/poi-lookup.js +12 -13
  176. package/out/poi-lookup.js.map +1 -1
  177. package/out/poi-schema.d.ts +7 -3
  178. package/out/poi-schema.d.ts.map +1 -1
  179. package/out/poi-schema.js.map +1 -1
  180. package/out/polygon-schema.d.ts +37 -0
  181. package/out/polygon-schema.d.ts.map +1 -0
  182. package/out/polygon-schema.js +23 -0
  183. package/out/polygon-schema.js.map +1 -0
  184. package/out/postal-city-alias-lookup.d.ts +1 -1
  185. package/out/postal-city-alias-lookup.js +1 -1
  186. package/out/postal-city-candidate-schema.d.ts +2 -1
  187. package/out/postal-city-candidate-schema.d.ts.map +1 -1
  188. package/out/postal-city-candidate-schema.js.map +1 -1
  189. package/out/postcode-point-lookup.d.ts +1 -1
  190. package/out/postcode-point-lookup.js +1 -1
  191. package/out/primary-preference.d.ts +125 -0
  192. package/out/primary-preference.d.ts.map +1 -0
  193. package/out/primary-preference.js +138 -0
  194. package/out/primary-preference.js.map +1 -0
  195. package/out/proximity-rerank.d.ts +77 -0
  196. package/out/proximity-rerank.d.ts.map +1 -0
  197. package/out/proximity-rerank.js +86 -0
  198. package/out/proximity-rerank.js.map +1 -0
  199. package/out/region-keys.d.ts +47 -0
  200. package/out/region-keys.d.ts.map +1 -0
  201. package/out/region-keys.js +121 -0
  202. package/out/region-keys.js.map +1 -0
  203. package/out/reverse.d.ts.map +1 -1
  204. package/out/reverse.js +6 -9
  205. package/out/reverse.js.map +1 -1
  206. package/out/schema.d.ts +1 -1
  207. package/out/search-fetch.d.ts +57 -0
  208. package/out/search-fetch.d.ts.map +1 -0
  209. package/out/search-fetch.js +183 -0
  210. package/out/search-fetch.js.map +1 -0
  211. package/out/sharding.d.ts +3 -3
  212. package/out/sharding.js +1 -1
  213. package/out/sqlite-convention-source.d.ts +1 -1
  214. package/out/sqlite-convention-source.js +1 -1
  215. package/out/sqlite-utils.d.ts +19 -1
  216. package/out/sqlite-utils.d.ts.map +1 -1
  217. package/out/sqlite-utils.js +19 -1
  218. package/out/sqlite-utils.js.map +1 -1
  219. package/out/street-centroid-schema.d.ts +7 -2
  220. package/out/street-centroid-schema.d.ts.map +1 -1
  221. package/out/street-centroid-schema.js.map +1 -1
  222. package/out/street-centroid.d.ts.map +1 -1
  223. package/out/street-centroid.js +7 -7
  224. package/out/street-centroid.js.map +1 -1
  225. package/out/street-normalize.d.ts +82 -9
  226. package/out/street-normalize.d.ts.map +1 -1
  227. package/out/street-normalize.js +175 -9
  228. package/out/street-normalize.js.map +1 -1
  229. package/out/street-segment-schema.d.ts +6 -2
  230. package/out/street-segment-schema.d.ts.map +1 -1
  231. package/out/street-segment-schema.js.map +1 -1
  232. package/out/types.d.ts +35 -1
  233. package/out/types.d.ts.map +1 -1
  234. package/out/unified-schema.d.ts +1 -1
  235. package/out/unified-schema.js +1 -1
  236. package/out/uprn-lookup.d.ts +85 -0
  237. package/out/uprn-lookup.d.ts.map +1 -0
  238. package/out/uprn-lookup.js +152 -0
  239. package/out/uprn-lookup.js.map +1 -0
  240. package/out/uprn-schema.d.ts +93 -0
  241. package/out/uprn-schema.d.ts.map +1 -0
  242. package/out/uprn-schema.js +78 -0
  243. package/out/uprn-schema.js.map +1 -0
  244. package/out/weights-overlay-linker.d.ts +141 -0
  245. package/out/weights-overlay-linker.d.ts.map +1 -0
  246. package/out/weights-overlay-linker.js +259 -0
  247. package/out/weights-overlay-linker.js.map +1 -0
  248. package/package.json +288 -16
  249. package/place-importance-schema.ts +64 -15
  250. package/poi-lookup.ts +12 -13
  251. package/poi-schema.ts +8 -3
  252. package/polygon-schema.ts +47 -0
  253. package/postal-city-alias-lookup.ts +1 -1
  254. package/postal-city-candidate-schema.ts +3 -1
  255. package/postcode-point-lookup.ts +1 -1
  256. package/primary-preference.ts +207 -0
  257. package/proximity-rerank.ts +120 -0
  258. package/region-keys.ts +144 -0
  259. package/reverse.ts +17 -16
  260. package/schema.ts +1 -1
  261. package/search-fetch.ts +256 -0
  262. package/sharding.ts +3 -3
  263. package/sqlite-convention-source.ts +1 -1
  264. package/sqlite-utils.ts +43 -2
  265. package/street-centroid-schema.ts +8 -2
  266. package/street-centroid.ts +13 -8
  267. package/street-normalize.ts +252 -23
  268. package/street-segment-schema.ts +7 -2
  269. package/types.ts +35 -1
  270. package/unified-schema.ts +1 -1
  271. package/uprn-lookup.ts +210 -0
  272. package/uprn-schema.ts +124 -0
  273. package/weights-overlay-linker.ts +377 -0
  274. package/geo.ts +0 -121
  275. package/out/geo.d.ts +0 -74
  276. package/out/geo.d.ts.map +0 -1
  277. package/out/geo.js +0 -71
  278. package/out/geo.js.map +0 -1
@@ -40,8 +40,11 @@
40
40
 
41
41
  import type { DatabaseSync } from "node:sqlite"
42
42
 
43
+ import type { CoincidentLocality } from "@mailwoman/resolver"
43
44
  import { haversineKm } from "@mailwoman/spatial"
44
45
 
46
+ import { allRows } from "./sqlite-utils.ts"
47
+
45
48
  /**
46
49
  * Table of places that hold more than one admin role — a locality that is also its county seat. Written by the
47
50
  * gazetteer build, read by the resolver when a coincident locality has to be chosen.
@@ -149,8 +152,8 @@ export function buildCoincidentRoles(
149
152
  // Admin (region/county tier) ⋈ same-name DESCENDANT locality. `place_population` is optional (LEFT
150
153
  // JOIN → 0 when absent). The relative-tolerance filter + relationship classification happen in JS so
151
154
  // the SQL stays a plain join. `spr` exposes the bbox columns we need for the diagonal.
152
- const candidates = db
153
- .prepare(
155
+ const candidates = allRows<CandidateRow>(
156
+ db.prepare(
154
157
  `SELECT r.id AS admin_id, r.placetype AS admin_placetype, r.country AS country, l.id AS locality_id,
155
158
  r.latitude AS rlat, r.longitude AS rlon, l.latitude AS llat, l.longitude AS llon,
156
159
  r.min_latitude, r.min_longitude, r.max_latitude, r.max_longitude,
@@ -163,7 +166,7 @@ export function buildCoincidentRoles(
163
166
  WHERE r.placetype = 'region'
164
167
  AND r.is_current != 0 AND r.is_deprecated = 0`
165
168
  )
166
- .all() as unknown as CandidateRow[]
169
+ )
167
170
 
168
171
  onProgress("filtering", `${candidates.length} candidates`)
169
172
 
@@ -226,19 +229,19 @@ export function loadCoincidentRoles(db: DatabaseSync): Map<number, CoincidentRol
226
229
 
227
230
  if (!coincidentRolesExists(db)) return map
228
231
 
229
- const rows = db
230
- .prepare(
231
- `SELECT admin_id, locality_id, relationship_type, admin_placetype, distance_km, locality_population
232
- FROM ${COINCIDENT_ROLES_TABLE}`
233
- )
234
- .all() as unknown as Array<{
232
+ const rows = allRows<{
235
233
  admin_id: number
236
234
  locality_id: number
237
235
  relationship_type: CoincidentRole["relationshipType"]
238
236
  admin_placetype: string
239
237
  distance_km: number
240
238
  locality_population: number
241
- }>
239
+ }>(
240
+ db.prepare(
241
+ `SELECT admin_id, locality_id, relationship_type, admin_placetype, distance_km, locality_population
242
+ FROM ${COINCIDENT_ROLES_TABLE}`
243
+ )
244
+ )
242
245
 
243
246
  for (const r of rows) {
244
247
  const entry: CoincidentRole = {
@@ -260,3 +263,59 @@ export function loadCoincidentRoles(db: DatabaseSync): Map<number, CoincidentRol
260
263
 
261
264
  return map
262
265
  }
266
+
267
+ /**
268
+ * The relation joined with `spr`, as an in-memory map keyed by `admin_id` — the resolver-facing view of
269
+ * {@link loadCoincidentRoles}, carrying the canonical name and coordinates each coincident locality resolves to.
270
+ * Returns an empty map when the relation table is absent.
271
+ */
272
+ export function loadCoincidentLocalities(db: DatabaseSync): Map<number, CoincidentLocality[]> {
273
+ const map = new Map<number, CoincidentLocality[]>()
274
+
275
+ if (coincidentRolesExists(db)) {
276
+ const rows = allRows<{
277
+ adminID: number
278
+ id: number
279
+ name: string
280
+ country: string
281
+ lat: number
282
+ lon: number
283
+ relationshipType: string
284
+ population: number
285
+ distanceKm: number
286
+ }>(
287
+ db.prepare(
288
+ `SELECT cr.admin_id AS adminID, s.id AS id, s.name AS name, s.country AS country,
289
+ s.latitude AS lat, s.longitude AS lon,
290
+ cr.relationship_type AS relationshipType, cr.locality_population AS population,
291
+ cr.distance_km AS distanceKm
292
+ FROM ${COINCIDENT_ROLES_TABLE} cr JOIN spr s ON s.id = cr.locality_id`
293
+ )
294
+ )
295
+
296
+ for (const r of rows) {
297
+ const candidate: CoincidentLocality = {
298
+ id: r.id,
299
+ name: r.name,
300
+ placetype: "locality",
301
+ country: r.country,
302
+ lat: r.lat,
303
+ lon: r.lon,
304
+ score: 0,
305
+ relationshipType: r.relationshipType,
306
+ population: r.population,
307
+ distanceKm: r.distanceKm,
308
+ }
309
+
310
+ const list = map.get(r.adminID)
311
+
312
+ if (list) {
313
+ list.push(candidate)
314
+ } else {
315
+ map.set(r.adminID, [candidate])
316
+ }
317
+ }
318
+ }
319
+
320
+ return map
321
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Typed schema for the convention asset — the `address_convention` table `SqliteConventionSource` probes, plus the
7
+ * `meta` provenance row every sealed artifact here carries. The interface is the read/write contract and
8
+ * {@link createAddressConventionTable} creates the table, so a column added to one is a compile error against the
9
+ * other.
10
+ */
11
+
12
+ import type { Kysely } from "kysely"
13
+
14
+ import { ADDRESS_CONVENTION_TABLE } from "./convention.ts"
15
+
16
+ /**
17
+ * One convention profile, keyed by the WOF admin polygon it attaches to.
18
+ */
19
+ export interface AddressConventionTable {
20
+ wof_id: number
21
+ /**
22
+ * The `Convention` record, JSON-encoded. Parsed at read time by `SqliteConventionSource`.
23
+ */
24
+ convention: string
25
+ /**
26
+ * Provenance: why this row exists and where it came from.
27
+ */
28
+ source: string
29
+ }
30
+
31
+ /**
32
+ * Key/value provenance for the sealed artifact.
33
+ */
34
+ export interface ConventionMetaTable {
35
+ key: string
36
+ value: string | null
37
+ }
38
+
39
+ export interface ConventionDatabase {
40
+ address_convention: AddressConventionTable
41
+ meta: ConventionMetaTable
42
+ }
43
+
44
+ /**
45
+ * The slice of a Kysely handle the convention DDL touches. Kysely is invariant in its schema parameter, so naming only
46
+ * `schema` lets a builder holding a wider handle pass it without a cast.
47
+ */
48
+ export type ConventionSchemaHandle = Pick<Kysely<ConventionDatabase>, "schema">
49
+
50
+ /**
51
+ * Create `address_convention`. The table name comes from {@link ADDRESS_CONVENTION_TABLE} so the build script, the
52
+ * runtime source, and the shard auto-detect cannot drift apart.
53
+ */
54
+ export async function createAddressConventionTable(db: ConventionSchemaHandle): Promise<void> {
55
+ await db.schema
56
+ .createTable(ADDRESS_CONVENTION_TABLE)
57
+ .addColumn("wof_id", "integer", (column) => column.primaryKey())
58
+ .addColumn("convention", "text", (column) => column.notNull())
59
+ .addColumn("source", "text", (column) => column.notNull())
60
+ .execute()
61
+ }
62
+
63
+ /**
64
+ * Create the artifact's `meta` table.
65
+ */
66
+ export async function createConventionMetaTable(db: ConventionSchemaHandle): Promise<void> {
67
+ await db.schema
68
+ .createTable("meta")
69
+ .addColumn("key", "text", (column) => column.primaryKey())
70
+ .addColumn("value", "text")
71
+ .execute()
72
+ }
package/convention.ts CHANGED
@@ -142,8 +142,8 @@ export function mergeConventions(base: Convention, ...overrides: Array<Conventio
142
142
  * region, …, locality). Starts from `WORLD_DEFAULT` so every field is defined regardless of which (if any) ancestors
143
143
  * carry an override.
144
144
  */
145
- export function resolveConvention(source: ConventionSource, ancestorIds: readonly number[]): ResolvedConvention {
146
- const layers = ancestorIds.map((id) => source.get(id))
145
+ export function resolveConvention(source: ConventionSource, ancestorIDs: readonly number[]): ResolvedConvention {
146
+ const layers = ancestorIDs.map((id) => source.get(id))
147
147
  const merged = mergeConventions(WORLD_DEFAULT, ...layers)
148
148
 
149
149
  return {
@@ -33,7 +33,7 @@ import {
33
33
  } from "@mailwoman/core/resolver"
34
34
  import { sql, type Kysely } from "kysely"
35
35
 
36
- import { hasTable } from "./sqlite-utils.ts"
36
+ import { allRows, hasTable } from "./sqlite-utils.ts"
37
37
 
38
38
  /**
39
39
  * One country's hard-filter coverage measurement — the storage form of {@link CountryCoverageFact}.
@@ -194,11 +194,11 @@ export function readGazetteerCoverageManifest(db: DatabaseSync): GazetteerArtifa
194
194
  const countryCoverage = new Map<string, CountryCoverageFact>()
195
195
 
196
196
  if (hasCoverage) {
197
- const rows = db
198
- .prepare(
197
+ const rows = allRows<CountryCoverageTable>(
198
+ db.prepare(
199
199
  `SELECT country, hard_filter_safe, hard_resolve_rate, sample_size, measured_at, source FROM ${COUNTRY_COVERAGE_TABLE}`
200
200
  )
201
- .all() as unknown as CountryCoverageTable[]
201
+ )
202
202
 
203
203
  for (const row of rows) {
204
204
  const country = String(row.country).toUpperCase()
@@ -217,9 +217,9 @@ export function readGazetteerCoverageManifest(db: DatabaseSync): GazetteerArtifa
217
217
  const countryBBoxes = new Map<string, CountryBBoxFact>()
218
218
 
219
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[]
220
+ const rows = allRows<CountryBBoxTable>(
221
+ db.prepare(`SELECT country, lat_min, lat_max, lon_min, lon_max, source FROM ${COUNTRY_BBOX_TABLE}`)
222
+ )
223
223
 
224
224
  for (const row of rows) {
225
225
  const country = String(row.country).toUpperCase()
@@ -0,0 +1,249 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Pass 1c of the candidate build (#1737): resurrect deprecated-with-no-successor WOF localities that a second
7
+ * source independently attests. Extracted from `build-candidate.ts` as a cohesive unit — the gates, their measured
8
+ * constants, and the GeoNames dump reader live together here; the build calls {@link resurrectCurrencyHoles} once,
9
+ * between the primaries pass and the alias pass.
10
+ */
11
+
12
+ import { existsSync } from "node:fs"
13
+ import { resolve } from "node:path"
14
+ import type { DatabaseSync } from "node:sqlite"
15
+
16
+ import { isStrictlyFiner } from "@mailwoman/core/resources/whosonfirst"
17
+ import { haversineKm } from "@mailwoman/spatial"
18
+ import { TSVSpliterator } from "spliterator"
19
+
20
+ import type { loadImportanceIndex } from "./candidate-importance.ts"
21
+ import type { PlaceAttrs, StageRow } from "./candidate/place-attrs.ts"
22
+ import { normalizeLocalityForKey } from "./street-normalize.ts"
23
+
24
+ /**
25
+ * Corroboration radius for the currency backfill (#1737), km — both for the live-near blocker and the GeoNames
26
+ * attestation. Measured basis (2026-08-19 prototype, GB locality slice): at 10 km, 20 of 108 dead names resurrect —
27
+ * Rochester (Kent), Aldershot, Staines, Telford, Ebbw Vale among them — while the `Birmingham/Wolverhampton/…`
28
+ * conurbation blobs stay dead (no attestation) and Swansea/Wrexham stay out because a live same-name row already serves
29
+ * them within the radius.
30
+ */
31
+ const CURRENCY_BACKFILL_RADIUS_KM = 10
32
+
33
+ /**
34
+ * Minimum attestor population for a resurrection. The same prototype measured 44 of 108 dead GB names attested but
35
+ * under this floor — hamlet-scale ghosts whose absence from the index nobody has reported. A floor keeps the pass
36
+ * answering the measured defect (real settlements) rather than re-importing the tail WOF chose to prune.
37
+ */
38
+ const CURRENCY_BACKFILL_POP_FLOOR = 1000
39
+
40
+ /**
41
+ * Pass 1c (#1737): resurrect deprecated-with-no-successor localities that a second source independently attests.
42
+ *
43
+ * WOF deprecations WITH a successor need nothing — the successor indexes. A deprecation with `is_superseded = 0` on a
44
+ * populated place is the shape of an upstream mistake (Rochester Kent, Aldershot, Telford; 120 GB localities alone),
45
+ * and it is indistinguishable from a correct pruning at this layer without outside evidence. So every resurrection
46
+ * requires all three gates, positive evidence only:
47
+ *
48
+ * 1. NO live same-name spr row of any placetype within {@link CURRENCY_BACKFILL_RADIUS_KM} of the dead record — a live row
49
+ * means the place is alive (possibly under another placetype) and there is no hole. A DISTANT same-name row is a
50
+ * namesake and does not block.
51
+ * 2. A GeoNames feature-class-P attestation of the same folded name within the radius.
52
+ * 3. The attestor at or above {@link CURRENCY_BACKFILL_POP_FLOOR}.
53
+ *
54
+ * The staged row keeps the WOF identity — id, name, centroid, bbox, region ancestry — because the dead record's own
55
+ * data is not what is wrong with it. GeoNames contributes exactly two things: the attestation, and the population that
56
+ * lets the row stand in prominence races (the dead record's own population is absent). Each name is judged once per
57
+ * country; the resurrected place joins `attrs`, so the alias pass explodes its alt names like any primary's.
58
+ */
59
+ export async function resurrectCurrencyHoles(ctx: {
60
+ src: DatabaseSync
61
+ tx: DatabaseSync
62
+ geonamesDir: string
63
+ countries: readonly string[]
64
+ attrs: Map<number, PlaceAttrs>
65
+ ccID: (code: string | null) => number
66
+ ptID: (pt: string | null) => number
67
+ regionOf: Map<number, number>
68
+ importance: ReturnType<typeof loadImportanceIndex> | undefined
69
+ stageRow: StageRow
70
+ progress: (phase: string, message: string) => void
71
+ }): Promise<number> {
72
+ const deadStmt = ctx.src.prepare(
73
+ `SELECT id, name, placetype, latitude, longitude, min_latitude, min_longitude, max_latitude, max_longitude
74
+ FROM spr
75
+ WHERE country = ? AND placetype = 'locality'
76
+ AND is_current = 0 AND is_deprecated = 1 AND is_superseded = 0`
77
+ )
78
+
79
+ const liveStmt = ctx.src.prepare(
80
+ `SELECT latitude, longitude, placetype FROM spr WHERE country = ? AND name = ? AND is_current != 0`
81
+ )
82
+
83
+ let total = 0
84
+
85
+ for (const country of ctx.countries) {
86
+ const cc = country.toUpperCase()
87
+ const dumpPath = resolve(ctx.geonamesDir, `${cc}.txt`)
88
+
89
+ if (!existsSync(dumpPath)) {
90
+ ctx.progress("currency-backfill", `${cc}: no GeoNames dump at ${dumpPath} — holes stay dead`)
91
+
92
+ continue
93
+ }
94
+
95
+ // Dead rows FIRST: a country with no deprecated-no-successor localities needs no attestors at all, and
96
+ // loading a national dump to judge zero rows is pure heap pressure on a build already near its ceiling
97
+ // (the first live run OOM'd in a later pass with JP/KR dumps loaded for 0 dead names each).
98
+ const dead = deadStmt.all(cc)
99
+
100
+ if (!dead.length) {
101
+ ctx.progress("currency-backfill", `${cc}: 0 dead names — dump not loaded`)
102
+
103
+ continue
104
+ }
105
+
106
+ // Only the dead names' own folded keys can ever be probed, so only those keys are worth holding —
107
+ // the rest of the national dump streams through without residency.
108
+ const deadKeys = new Set<string>()
109
+
110
+ for (const d of dead) {
111
+ const k = normalizeLocalityForKey(String(d.name ?? ""))
112
+
113
+ if (k) {
114
+ deadKeys.add(k)
115
+ }
116
+ }
117
+
118
+ // Folded name → P-class attestors. GeoNames columns by index: 1 name, 2 ascii, 4 lat, 5 lon,
119
+ // 6 feature_class, 14 population.
120
+ const attestors = new Map<string, { lat: number; lon: number; pop: number }[]>()
121
+
122
+ for await (const f of TSVSpliterator.fromAsync(dumpPath, { header: false })) {
123
+ if (f[6] !== "P") continue
124
+
125
+ const keys = [normalizeLocalityForKey(String(f[1] ?? "")), normalizeLocalityForKey(String(f[2] ?? ""))].filter(
126
+ (key) => key && deadKeys.has(key)
127
+ )
128
+
129
+ if (!keys.length) continue
130
+
131
+ const row = { lat: Number(f[4]), lon: Number(f[5]), pop: Number(f[14]) || 0 }
132
+
133
+ for (const key of new Set(keys)) {
134
+ const bag = attestors.get(key)
135
+
136
+ if (bag) {
137
+ bag.push(row)
138
+ } else {
139
+ attestors.set(key, [row])
140
+ }
141
+ }
142
+ }
143
+
144
+ let judged = 0
145
+ let blocked = 0
146
+ let unattested = 0
147
+ let floored = 0
148
+ let resurrected = 0
149
+ const seen = new Set<string>()
150
+
151
+ ctx.tx.exec("BEGIN")
152
+
153
+ for (const d of dead) {
154
+ const name = String(d.name ?? "")
155
+ const pkey = normalizeLocalityForKey(name)
156
+
157
+ if (!pkey || seen.has(pkey)) continue
158
+ seen.add(pkey)
159
+
160
+ judged++
161
+
162
+ const dLat = Number(d.latitude)
163
+ const dLon = Number(d.longitude)
164
+ // The dead-row query is scoped to `locality` today; read it from the row anyway so widening that query
165
+ // cannot silently start comparing every candidate against a hardcoded rung.
166
+ const deadPlacetype = String(d.placetype ?? "locality")
167
+
168
+ // A live row blocks only when it is AT LEAST AS COARSE as the dead one. The original gate compared name and
169
+ // distance alone, on the premise that a nearby same-name row means "the place is alive under another
170
+ // placetype" — true for a place recorded twice, false for a placetype DEMOTION, which is the shape that
171
+ // actually occurs: WOF retired `Gillingham` the locality (pop 101,187) and kept `Gillingham` the
172
+ // neighbourhood 3.2 km away, and the gate read the surviving CHILD as covering its own dead parent.
173
+ // Sixteen of seventeen GB refusals had exactly that shape (#1746).
174
+ //
175
+ // An UNRANKED placetype blocks, which is the conservative direction: this gate's failure mode is inventing
176
+ // a place, so a row we cannot rank is treated as covering rather than waved through.
177
+ const liveNear = liveStmt.all(cc, name).some((row) => {
178
+ if (haversineKm(dLat, dLon, Number(row.latitude), Number(row.longitude)) > CURRENCY_BACKFILL_RADIUS_KM) {
179
+ return false
180
+ }
181
+
182
+ // Blocks UNLESS the live row is strictly finer. The equal rung must still block — a live `locality`
183
+ // covers a dead `locality` — and an unranked placetype blocks too, since this gate's failure mode
184
+ // is inventing a place.
185
+ return isStrictlyFiner(String(row.placetype ?? ""), deadPlacetype) !== true
186
+ })
187
+
188
+ if (liveNear) {
189
+ blocked++
190
+
191
+ continue
192
+ }
193
+
194
+ const near = (attestors.get(pkey) ?? []).filter(
195
+ (g) => haversineKm(dLat, dLon, g.lat, g.lon) <= CURRENCY_BACKFILL_RADIUS_KM
196
+ )
197
+
198
+ if (!near.length) {
199
+ unattested++
200
+
201
+ continue
202
+ }
203
+
204
+ const pop = Math.max(...near.map((g) => g.pop))
205
+
206
+ if (pop < CURRENCY_BACKFILL_POP_FLOOR) {
207
+ floored++
208
+
209
+ continue
210
+ }
211
+
212
+ const sid = Number(d.id)
213
+
214
+ const a: PlaceAttrs = {
215
+ cid: ctx.ccID(cc),
216
+ rid: ctx.regionOf.get(sid) ?? 0,
217
+ ptid: ctx.ptID("locality"),
218
+ name,
219
+ lat: dLat,
220
+ lon: dLon,
221
+ mnLat: Number(d.min_latitude),
222
+ mnLon: Number(d.min_longitude),
223
+ mxLat: Number(d.max_latitude),
224
+ mxLon: Number(d.max_longitude),
225
+ pop,
226
+ neg: -Math.log10(pop + 1),
227
+ pkey,
228
+ imp: ctx.importance?.find(name, cc, "locality", dLat, dLon) ?? null,
229
+ }
230
+
231
+ ctx.attrs.set(sid, a)
232
+ ctx.stageRow(pkey, a, sid, 1)
233
+
234
+ resurrected++
235
+ }
236
+
237
+ ctx.tx.exec("COMMIT")
238
+
239
+ ctx.progress(
240
+ "currency-backfill",
241
+ `${cc}: ${resurrected} resurrected of ${judged} dead names ` +
242
+ `(${blocked} blocked by a live near row, ${unattested} unattested, ${floored} under the population floor)`
243
+ )
244
+
245
+ total += resurrected
246
+ }
247
+
248
+ return total
249
+ }
package/exact-match.ts ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * The exact-match tier's name probes — the two case-folded equality lookups that decide whether a
7
+ * candidate holds the query text as its own name, an alias, or an official name.
8
+ */
9
+
10
+ import type { DatabaseSync } from "node:sqlite"
11
+
12
+ import { aliasBagExactMatch } from "./fts.ts"
13
+
14
+ /**
15
+ * Among `ids`, return the subset whose name OR any alias equals `text` case-insensitively — the exact-match tier for
16
+ * ranking. One indexed query over `<schema>.names`. When the shard has no `names` table (a slim DB built with
17
+ * `dropNames`, or a postcode-only shard), fall back to the self-contained `place_search` FTS content: its `alt_names`
18
+ * column is the same alias set joined on the boundary-preserving `ALIAS_SEPARATOR` (#523), so `aliasBagExactMatch`
19
+ * recovers the exact alias tier ("New York City" → New York) that the dropped `names` table used to provide.
20
+ */
21
+ export function exactMatchIDs(db: DatabaseSync, schemaName: string, ids: number[], text: string): Set<number> {
22
+ const out = new Set<number>()
23
+ const trimmed = text.trim()
24
+
25
+ if (!ids.length || !trimmed) return out
26
+ const placeholders = ids.map(() => "?").join(", ")
27
+
28
+ try {
29
+ const rows = db
30
+ .prepare(`SELECT DISTINCT id FROM ${schemaName}.names WHERE id IN (${placeholders}) AND name = ? COLLATE NOCASE`)
31
+ .all(...ids, trimmed) as Array<{ id: number }>
32
+
33
+ for (const r of rows) {
34
+ out.add(r.id)
35
+ }
36
+
37
+ return out
38
+ } catch {
39
+ // No `names` table on this shard — fall through to the place_search alias bag.
40
+ }
41
+
42
+ try {
43
+ const rows = db
44
+ .prepare(`SELECT wof_id AS id, name, alt_names FROM ${schemaName}.place_search WHERE wof_id IN (${placeholders})`)
45
+ .all(...ids) as Array<{ id: number; name: string | null; alt_names: string | null }>
46
+
47
+ const norm = (s: string): string => s.toLowerCase().trim().replaceAll(/\s+/g, " ")
48
+ const needle = norm(trimmed)
49
+
50
+ for (const r of rows) {
51
+ if (r.name !== null && norm(r.name) === needle) {
52
+ out.add(r.id)
53
+ }
54
+ }
55
+
56
+ // Alias pass via the shared bag parser (#523). Separated bags (built since #523) get a true
57
+ // per-alias equality check, ungated — matching the `names`-table branch above, where an
58
+ // alias match counts as exact regardless of other candidates. Legacy bags (no separator)
59
+ // fall back to padded containment, gated on "no canonical exact in the pool" because their
60
+ // lost boundaries would otherwise false-promote interior fragments ("York" inside the alias
61
+ // "New York City") or cross-alias fragments ("York New" across "…York" + "New City…").
62
+ const anyCanonicalExact = out.size > 0
63
+
64
+ for (const r of rows) {
65
+ if (aliasBagExactMatch(r.alt_names, needle, anyCanonicalExact)) {
66
+ out.add(r.id)
67
+ }
68
+ }
69
+ } catch {
70
+ // Shard without place_search either → no exact-match tier. Falls back to weighted-sum order.
71
+ }
72
+
73
+ return out
74
+ }
75
+
76
+ /**
77
+ * Among `ids` (already known exact matches), the subset holding `text` as an OFFICIAL name (`names.official = 1`, the
78
+ * #940 ingest bit). Same COLLATE NOCASE semantics as {@link WOFSQLitePlaceLookup.#exactMatchIDs} so the two probes
79
+ * agree on what "equals the query" means. Fails soft on gazetteers built before #940 (no `official` column) — the
80
+ * sub-tier then behaves exactly as if `officialNameExact` were off.
81
+ */
82
+ export function officialNameIDs(db: DatabaseSync, schemaName: string, ids: number[], text: string): Set<number> {
83
+ const out = new Set<number>()
84
+ const trimmed = text.trim()
85
+
86
+ if (!ids.length || !trimmed) return out
87
+ const placeholders = ids.map(() => "?").join(", ")
88
+
89
+ try {
90
+ const rows = db
91
+ .prepare(
92
+ `SELECT DISTINCT id FROM ${schemaName}.names WHERE id IN (${placeholders}) AND official = 1 AND name = ? COLLATE NOCASE`
93
+ )
94
+ .all(...ids, trimmed) as Array<{ id: number }>
95
+
96
+ for (const r of rows) {
97
+ out.add(r.id)
98
+ }
99
+ } catch {
100
+ // Pre-#940 gazetteer (no `official` column) or a names-less slim shard — feature inert.
101
+ }
102
+
103
+ return out
104
+ }