@mailwoman/resolver-wof-sqlite 8.5.0 → 9.0.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 (78) hide show
  1. package/ancestry-backfill.ts +103 -64
  2. package/build-candidate.ts +133 -25
  3. package/candidate-lookup.ts +15 -2
  4. package/coverage-manifest-schema.ts +1 -1
  5. package/fst-builder.ts +51 -15
  6. package/fst-deserialize-web.ts +3 -1
  7. package/fst-freshness.ts +333 -0
  8. package/fst-serialize.ts +10 -1
  9. package/fst-types.ts +23 -0
  10. package/geo.ts +16 -41
  11. package/geonames-aliases.ts +105 -37
  12. package/geonames-postal.ts +65 -21
  13. package/index.ts +8 -1
  14. package/interpolation.ts +2 -1
  15. package/out/ancestry-backfill.d.ts +37 -18
  16. package/out/ancestry-backfill.d.ts.map +1 -1
  17. package/out/ancestry-backfill.js +82 -52
  18. package/out/ancestry-backfill.js.map +1 -1
  19. package/out/build-candidate.d.ts +9 -0
  20. package/out/build-candidate.d.ts.map +1 -1
  21. package/out/build-candidate.js +104 -9
  22. package/out/build-candidate.js.map +1 -1
  23. package/out/candidate-lookup.d.ts.map +1 -1
  24. package/out/candidate-lookup.js +12 -2
  25. package/out/candidate-lookup.js.map +1 -1
  26. package/out/coverage-manifest-schema.d.ts +1 -1
  27. package/out/coverage-manifest-schema.js +1 -1
  28. package/out/fst-builder.d.ts.map +1 -1
  29. package/out/fst-builder.js +43 -12
  30. package/out/fst-builder.js.map +1 -1
  31. package/out/fst-deserialize-web.d.ts.map +1 -1
  32. package/out/fst-deserialize-web.js +2 -1
  33. package/out/fst-deserialize-web.js.map +1 -1
  34. package/out/fst-freshness.d.ts +138 -0
  35. package/out/fst-freshness.d.ts.map +1 -0
  36. package/out/fst-freshness.js +238 -0
  37. package/out/fst-freshness.js.map +1 -0
  38. package/out/fst-serialize.d.ts +6 -0
  39. package/out/fst-serialize.d.ts.map +1 -1
  40. package/out/fst-serialize.js +8 -1
  41. package/out/fst-serialize.js.map +1 -1
  42. package/out/fst-types.d.ts +26 -0
  43. package/out/fst-types.d.ts.map +1 -1
  44. package/out/geo.d.ts +10 -13
  45. package/out/geo.d.ts.map +1 -1
  46. package/out/geo.js +15 -35
  47. package/out/geo.js.map +1 -1
  48. package/out/geonames-aliases.d.ts +23 -1
  49. package/out/geonames-aliases.d.ts.map +1 -1
  50. package/out/geonames-aliases.js +88 -33
  51. package/out/geonames-aliases.js.map +1 -1
  52. package/out/geonames-postal.d.ts +22 -1
  53. package/out/geonames-postal.d.ts.map +1 -1
  54. package/out/geonames-postal.js +50 -16
  55. package/out/geonames-postal.js.map +1 -1
  56. package/out/index.d.ts +2 -1
  57. package/out/index.d.ts.map +1 -1
  58. package/out/index.js +2 -1
  59. package/out/index.js.map +1 -1
  60. package/out/interpolation.d.ts.map +1 -1
  61. package/out/interpolation.js +2 -1
  62. package/out/interpolation.js.map +1 -1
  63. package/out/poi-lookup.d.ts +1 -1
  64. package/out/poi-lookup.js +3 -3
  65. package/out/reverse.d.ts.map +1 -1
  66. package/out/reverse.js +3 -9
  67. package/out/reverse.js.map +1 -1
  68. package/out/sqlite-convention-source.d.ts.map +1 -1
  69. package/out/sqlite-convention-source.js +4 -3
  70. package/out/sqlite-convention-source.js.map +1 -1
  71. package/out/street-morphology-fst-builder.d.ts.map +1 -1
  72. package/out/street-morphology-fst-builder.js +2 -1
  73. package/out/street-morphology-fst-builder.js.map +1 -1
  74. package/package.json +16 -6
  75. package/poi-lookup.ts +3 -3
  76. package/reverse.ts +4 -9
  77. package/sqlite-convention-source.ts +5 -3
  78. package/street-morphology-fst-builder.ts +3 -1
@@ -3,9 +3,8 @@
3
3
  * @license AGPL-3.0
4
4
  * @author Teffen Ellis, et al.
5
5
  *
6
- * Repair the only-self ancestry that {@link populateAncestors} (the parent_id closure in
7
- * unified-schema.ts) leaves for places whose `wof:parent_id` is the WOF `-4` "ambiguous /
8
- * multi-parent" sentinel.
6
+ * Repair the truncated ancestry that {@link populateAncestors} (the parent_id closure in
7
+ * unified-schema.ts) leaves wherever the closure dead-ends before reaching the top.
9
8
  *
10
9
  * Root cause (#440 / #832): a place that straddles multiple parents — New York City spans five
11
10
  * counties (its boroughs), London 30+ — carries `wof:parent_id = -4`, so the parent_id closure
@@ -14,22 +13,47 @@
14
13
  * a correctly-parented namesake ("New York Mills", pop 3,190) wins over NYC's 8.8M. The same defect
15
14
  * orphans London, Singapore, and ~2,850 other localities — the most demo-visible queries.
16
15
  *
16
+ * **The dead end is inherited by children (#1445).** Repairing the `-4` place itself does not repair
17
+ * anything BELOW it: the closure walks Brooklyn → New York and stops, because New York's own
18
+ * `parent_id` is `-4`. Brooklyn-the-borough (pop 2.5M) therefore carried exactly two ancestor rows —
19
+ * itself and New York — with no county, region or country, and the only US locality-tier place named
20
+ * "Brooklyn" that survived a New-York-State descendant filter was Pillar Point, a Jefferson County
21
+ * hamlet 411 km away carrying "Brooklyn" as an alternate name. All five NYC boroughs, every London
22
+ * borough and Hyderabad's zones were in the same state.
23
+ *
24
+ * So the candidate test is NOT "has only a self ancestor" — that misses every child of a repaired
25
+ * place, which by construction has two. It is **"has no `country`-tier ancestor"**. Country is the
26
+ * universal terminal for every non-{@link TOP_PLACETYPES} placetype, so its absence is exactly the
27
+ * signal that the chain dead-ended somewhere, at whatever depth. On a wide-coverage build this
28
+ * selects ~24k places against 2.55M rows.
29
+ *
30
+ * A place whose `wof:hierarchy` genuinely stops short is NOT a candidate and needs no repair: the
31
+ * source is the authority on what a place should have. American Samoa's localities, for instance,
32
+ * have `{country_id, locality_id}` and no region in WOF itself — the artifact matching that is
33
+ * correct, not truncated.
34
+ *
17
35
  * The authoritative hierarchy IS in the source geojson: `wof:hierarchy` is an array of branches,
18
36
  * each a `<placetype>_id` → id map (region_id, county_id, country_id, …), fully populated even when
19
- * parent_id is -4. This reads it for every only-self place and inserts the missing ancestor rows
20
- * (one per distinct ancestor across branches).
37
+ * parent_id is -4. This reads it for every candidate and inserts the missing ancestor rows (one per
38
+ * distinct ancestor across branches).
21
39
  *
22
40
  * MUST run AFTER populateAncestors and BEFORE the build freezes (VACUUM INTO), so the rows land in
23
41
  * the shipped artifact — `scripts/build-unified-wof.ts` Phase 3 calls it inline. The standalone
24
42
  * `scripts/backfill-ancestors-from-hierarchy.ts` is a thin CLI over the same function for ad-hoc
25
- * repair of an already-built DB. Idempotent: only touches places with <= 1 ancestor row (self), and
26
- * inserts each (id, ancestor_id) at most once.
43
+ * repair of an already-built DB. Idempotent by the per-pair existence check, not by the candidate
44
+ * test: each (id, ancestor_id) is inserted at most once, so a second run over the same DB adds
45
+ * nothing.
27
46
  */
28
47
 
29
- import { existsSync, readdirSync, readFileSync } from "node:fs"
30
- import { join } from "node:path"
48
+ import { readdirSync } from "node:fs"
31
49
  import type { DatabaseSync } from "node:sqlite"
32
50
 
51
+ import { DatabaseClient } from "@mailwoman/core/kysley/client"
52
+ import { readWOFFeature } from "@mailwoman/core/resources/whosonfirst"
53
+ import { join } from "path-ts"
54
+
55
+ import type { WOFDatabase } from "./schema.ts"
56
+
33
57
  /**
34
58
  * Genuinely top-level placetypes — they never have (or need) an ancestor, so skip them.
35
59
  */
@@ -45,8 +69,8 @@ export interface AncestryBackfillResult {
45
69
  */
46
70
  rowsAdded: number
47
71
  /**
48
- * Only-self candidates whose source geojson could not be found (non-WOF backfilled places, or repos not present
49
- * locally) — skipped, not an error.
72
+ * Candidates whose source geojson could not be found (non-WOF backfilled places, or repos not present locally) —
73
+ * skipped, not an error.
50
74
  */
51
75
  noGeojson: number
52
76
  }
@@ -89,34 +113,6 @@ export function discoverAdminDataRoots(reposRoot: string): string[] {
89
113
  return roots
90
114
  }
91
115
 
92
- /**
93
- * WOF geojson lives sharded: an id resolves to `<3-char chunks>/<id>.geojson` under each data root.
94
- */
95
- function geojsonForID(id: number, roots: readonly string[]): Record<string, unknown> | null {
96
- const s = String(id)
97
- const chunks: string[] = []
98
-
99
- for (let i = 0; i < s.length; i += 3) {
100
- chunks.push(s.slice(i, i + 3))
101
- }
102
-
103
- const rel = join(chunks.join("/"), `${s}.geojson`)
104
-
105
- for (const root of roots) {
106
- const fp = join(root, rel)
107
-
108
- if (existsSync(fp)) {
109
- try {
110
- return JSON.parse(readFileSync(fp, "utf8")) as Record<string, unknown>
111
- } catch {
112
- return null
113
- }
114
- }
115
- }
116
-
117
- return null
118
- }
119
-
120
116
  // `<placetype>_id` key → ancestor placetype. WOF hierarchy keys are e.g. region_id, county_id. Self
121
117
  // is filtered downstream by the `aid === id` check, so we do NOT special-case locality here: for a
122
118
  // locality candidate `locality_id` IS self (dropped by aid===id), but for a neighbourhood candidate
@@ -128,48 +124,82 @@ function placetypeFromKey(key: string): string | null {
128
124
  }
129
125
 
130
126
  /**
131
- * Insert missing ancestor rows for only-self places by reading `wof:hierarchy` from their source geojson under
132
- * `geojsonRoots` (see {@link discoverAdminDataRoots}). Runs inside a single transaction; caller owns connection
133
- * lifecycle (open, WAL checkpoint, close).
127
+ * Insert missing ancestor rows for every place whose ancestry chain dead-ended before reaching a country, by reading
128
+ * `wof:hierarchy` from its source geojson under `geojsonRoots` (see {@link discoverAdminDataRoots}). Runs inside a
129
+ * single transaction; caller owns connection lifecycle (open, WAL checkpoint, close).
134
130
  *
135
- * `opts.maxId` bounds the candidate scan to ids BELOW it — pass the synthetic-id base (`OVERTURE_ID_BASE`, 8e12) so the
131
+ * `opts.maxID` bounds the candidate scan to ids BELOW it — pass the synthetic-id base (`OVERTURE_ID_BASE`, 8e12) so the
136
132
  * backfill considers only real WOF places. Overture/GeoNames rows carry synthetic ids and have NO `wof:hierarchy`
137
- * geojson, so probing them is pure waste: on a wide-coverage DB the only-self set is millions of Overture/GeoNames leaf
138
- * localities, and the per-candidate geojson probe across every repo root turns a seconds-long WOF-only pass into a
133
+ * geojson, so probing them is pure waste: on a wide-coverage DB the country-less set is millions of Overture/GeoNames
134
+ * leaf localities, and the per-candidate geojson probe across every repo root turns a seconds-long WOF-only pass into a
139
135
  * ~40-minute one (their ancestry comes from the parent_id closure, not this backfill). Correctness-preserving — the
140
- * skipped rows would have `noGeojson`-skipped anyway. Omit `maxId` (default) for the legacy WOF-only DBs.
136
+ * skipped rows would have `noGeojson`-skipped anyway. Omit `maxID` (default) for the legacy WOF-only DBs.
141
137
  */
142
- export function backfillAncestorsFromHierarchy(
138
+ export async function backfillAncestorsFromHierarchy(
143
139
  db: DatabaseSync,
144
140
  geojsonRoots: readonly string[],
145
- opts: { maxId?: number } = {}
146
- ): AncestryBackfillResult {
147
- const maxId = opts.maxId ?? Number.MAX_SAFE_INTEGER
148
-
149
- // `s.id < ?` first lets SQLite prune by the PK index before the correlated only-self subquery runs at all.
150
- const candidates = db
151
- .prepare(
152
- `SELECT s.id AS id, s.placetype AS placetype FROM spr s
153
- WHERE s.id < ? AND (SELECT count(*) FROM ancestors a WHERE a.id = s.id) <= 1`
141
+ opts: { maxID?: number } = {}
142
+ ): Promise<AncestryBackfillResult> {
143
+ const maxID = opts.maxID ?? Number.MAX_SAFE_INTEGER
144
+ const kdb = new DatabaseClient<WOFDatabase>({ database: db })
145
+
146
+ // "No country-tier ancestor" is the dead-end signal at any depth — see the module docstring. The
147
+ // earlier "<= 1 ancestor row" test only caught the dead end's origin, never the children that
148
+ // inherit it (a child of a repaired -4 place has two rows: itself and that parent) (#1445).
149
+ // The id bound is stated first so SQLite prunes by the PK index before the NOT EXISTS runs at all.
150
+ const candidateBase = kdb
151
+ .selectFrom("spr")
152
+ .where("id", "<", maxID)
153
+ .where((eb) =>
154
+ eb.not(
155
+ eb.exists(
156
+ eb
157
+ .selectFrom("ancestors as a")
158
+ .select("a.id")
159
+ .whereRef("a.id", "=", "spr.id")
160
+ .where("a.ancestor_placetype", "=", "country")
161
+ )
162
+ )
154
163
  )
155
- .all(maxId) as Array<{ id: number; placetype: string }>
164
+
165
+ const candidates = await candidateBase.select(["id", "placetype"]).execute()
166
+
167
+ // Every candidate's existing ancestors in ONE query rather than an indexed read each. The widened
168
+ // candidate test made that per-candidate read the dominant cost of the pass, and the set is bounded:
169
+ // a candidate reaching this point has a handful of rows at most. The candidate set rides in as the
170
+ // SAME predicate re-issued as a subquery, never a materialized `IN (?, ?, …)` list — node:sqlite
171
+ // caps a statement at 32,766 bound variables and the wide-coverage build has 67,521 candidates
172
+ // (measured 2026-08-04).
173
+ const alreadyPresent = new Map<number, Set<number>>()
174
+
175
+ for (const row of await kdb
176
+ .selectFrom("ancestors")
177
+ .select(["id", "ancestor_id"])
178
+ .where("id", "in", candidateBase.select("spr.id"))
179
+ .execute()) {
180
+ let set = alreadyPresent.get(row.id)
181
+
182
+ if (!set) {
183
+ set = new Set()
184
+ alreadyPresent.set(row.id, set)
185
+ }
186
+
187
+ set.add(Number(row.ancestor_id))
188
+ }
156
189
 
157
190
  const insert = db.prepare(
158
191
  "INSERT INTO ancestors (id, ancestor_id, ancestor_placetype, lastmodified) VALUES (?, ?, ?, 0)"
159
192
  )
160
193
 
161
- const hasRow = db.prepare("SELECT 1 FROM ancestors WHERE id = ? AND ancestor_id = ? LIMIT 1")
162
-
163
194
  let placesFixed = 0
164
195
  let rowsAdded = 0
165
196
  let noGeojson = 0
166
197
  db.exec("BEGIN")
167
198
 
168
199
  for (const { id, placetype } of candidates) {
169
- if (TOP_PLACETYPES.has(placetype)) continue
170
- const gj = geojsonForID(id, geojsonRoots)
171
- const props = (gj?.["properties"] ?? null) as Record<string, unknown> | null
172
- const hierarchy = (props?.["wof:hierarchy"] ?? null) as Array<Record<string, number>> | null
200
+ if (placetype && TOP_PLACETYPES.has(placetype)) continue
201
+ const gj = readWOFFeature(id, geojsonRoots)
202
+ const hierarchy = gj?.properties?.["wof:hierarchy"]
173
203
 
174
204
  if (!hierarchy || !hierarchy.length) {
175
205
  if (!gj) {
@@ -197,11 +227,20 @@ export function backfillAncestorsFromHierarchy(
197
227
  }
198
228
  }
199
229
 
230
+ let present = alreadyPresent.get(id)
231
+
232
+ if (!present) {
233
+ present = new Set()
234
+ alreadyPresent.set(id, present)
235
+ }
236
+
200
237
  let added = 0
201
238
 
202
239
  for (const [aid, pt] of seen) {
203
- if (hasRow.get(id, aid)) continue
240
+ if (present.has(aid)) continue
241
+
204
242
  insert.run(id, aid, pt)
243
+ present.add(aid)
205
244
 
206
245
  added++
207
246
  }
@@ -60,6 +60,9 @@ export interface BuildCandidateOptions {
60
60
  * Optional postcode shards (`spr` rows with `placetype='postalcode'` + real coords, e.g. postalcode-us.db) — folded
61
61
  * in as `postalcode` candidate rows so `findPlace(postalcode)` resolves a ZIP directly (the demo's primary postcode
62
62
  * path; the postcode-*.bin anchor stays the fallback). Matches the slim wof-hot.db, which took one such postcode DB.
63
+ *
64
+ * Each shard's `names` table is folded in too (#1495) — that's where the GeoNames delivery-city names live
65
+ * ("Brooklyn" for 11201), and they were previously reachable only through FTS.
63
66
  */
64
67
  postcodes?: string[]
65
68
  /**
@@ -75,6 +78,12 @@ export interface BuildCandidateResult {
75
78
  aliases: number
76
79
  abbrevs: number
77
80
  postcodes: number
81
+ /**
82
+ * Delivery-city (and other `names`-table) aliases folded onto postcode rows — #1495. Zero here means the shards
83
+ * carried no alias names, NOT that the pass was skipped: a shard with no `names` table reports that separately
84
+ * through `onProgress`.
85
+ */
86
+ postcodeAliases: number
78
87
  }
79
88
 
80
89
  interface PlaceAttrs {
@@ -140,12 +149,33 @@ export async function buildCandidateTable(opts: BuildCandidateOptions): Promise<
140
149
  // --- region_id per place (its region-tier ancestor) for same-name disambiguation ---
141
150
  progress("region", "loading region ancestry")
142
151
  const regionOf = new Map<number, number>()
143
-
144
- for (const r of src.prepare("SELECT id, ancestor_id FROM ancestors WHERE ancestor_placetype='region'").iterate()) {
152
+ let multiRegion = 0
153
+
154
+ // A place can carry more than one region-tier ancestor — 59 do on the 2026-08-03 artifact (42
155
+ // localities, mostly Chinese places on an ambiguous boundary). One `region_id` column holds one of
156
+ // them, and which one it ought to be is a real question this is not the place to answer.
157
+ //
158
+ // MIN is arbitrary but STABLE: an unordered pick lets the stamp for those places differ between two
159
+ // builds of the same source. The count is logged because the number is expected to grow, and should
160
+ // be visible rather than inferred.
161
+ for (const r of src
162
+ .prepare(
163
+ "SELECT id, MIN(ancestor_id) AS ancestor_id, COUNT(DISTINCT ancestor_id) AS n" +
164
+ " FROM ancestors WHERE ancestor_placetype='region' GROUP BY id"
165
+ )
166
+ .iterate()) {
145
167
  regionOf.set(Number(r.id), Number(r.ancestor_id))
168
+
169
+ if (Number(r.n) > 1) {
170
+ multiRegion++
171
+ }
146
172
  }
147
173
 
148
- progress("region", `${regionOf.size.toLocaleString()} places carry a region`)
174
+ progress(
175
+ "region",
176
+ `${regionOf.size.toLocaleString()} places carry a region` +
177
+ (multiRegion ? ` (${multiRegion.toLocaleString()} carry more than one; stamped with the lowest id)` : "")
178
+ )
149
179
 
150
180
  // The hot path — millions of clustered rows. Kept a single positional prepared statement (the fastest
151
181
  // node:sqlite insert) rather than a per-row query builder. Placeholders come from CANDIDATE_COLUMNS so
@@ -270,13 +300,25 @@ export async function buildCandidateTable(opts: BuildCandidateOptions): Promise<
270
300
  out.exec("COMMIT")
271
301
  progress("abbrevs", `${nAbbr.toLocaleString()} abbrevs`)
272
302
 
273
- // --- pass 4: postcodes (separate shards: spr placetype='postalcode' with real coords) ---
274
- let nPostcode = 0
275
-
276
- for (const pcDB of opts.postcodes ?? []) {
303
+ /**
304
+ * Pass 4 — fold ONE postcode shard (`spr` rows with `placetype='postalcode'`) in, then pass 4b: the delivery-city
305
+ * aliases hanging off the same shard's `names` table.
306
+ *
307
+ * Extracted rather than inlined because the shard loop is self-contained — it shares only the staging statement and
308
+ * the code dictionaries with the passes above, and nothing after it reads anything it produces except the two
309
+ * counters it returns.
310
+ */
311
+ const foldPostcodeShard = (pcDB: string): { primaries: number; aliases: number } => {
277
312
  progress("postcodes", `reading ${pcDB}`)
313
+
278
314
  const pc = new DatabaseSync(pcDB, { readOnly: true })
279
315
  const pcPtid = ptID("postalcode")
316
+ // Per-shard, not the admin `attrs` map: pass 1 only ever sees the admin DB, so the alias pass
317
+ // below has nothing to join against unless this primary loop records what it staged.
318
+ const pcAttrs = new Map<number, PlaceAttrs>()
319
+ let primaries = 0
320
+ let aliases = 0
321
+
280
322
  out.exec("BEGIN")
281
323
 
282
324
  for (const r of pc
@@ -290,38 +332,96 @@ export async function buildCandidateTable(opts: BuildCandidateOptions): Promise<
290
332
  const key = normalizeLocalityForKey(name)
291
333
 
292
334
  if (!key) continue
335
+
293
336
  const lat = r.latitude as number
294
337
  const lon = r.longitude as number
295
338
 
296
339
  // region_id 0 (a postcode is unique by name+country — no same-name disambiguation); neg_rank 0
297
340
  // (no population). bbox = the postcode's own min/max (falls back to the centroid point).
298
- insStage.run(
299
- key,
300
- ccID(r.country as string | null),
301
- 0,
302
- pcPtid,
303
- 0,
304
- Number(r.id),
341
+ const a: PlaceAttrs = {
342
+ cid: ccID(r.country as string | null),
343
+ rid: 0,
344
+ ptid: pcPtid,
305
345
  name,
306
346
  lat,
307
347
  lon,
308
- (r.mnlat as number) || lat,
309
- (r.mnlon as number) || lon,
310
- (r.mxlat as number) || lat,
311
- (r.mxlon as number) || lon,
312
- 0,
313
- 1
314
- )
315
-
316
- nPostcode++
348
+ mnLat: (r.mnlat as number) || lat,
349
+ mnLon: (r.mnlon as number) || lon,
350
+ mxLat: (r.mxlat as number) || lat,
351
+ mxLon: (r.mxlon as number) || lon,
352
+ pop: 0,
353
+ neg: 0,
354
+ pkey: key,
355
+ }
356
+
357
+ pcAttrs.set(Number(r.id), a)
358
+ stageRow(key, a, Number(r.id), 1)
359
+
360
+ primaries++
317
361
  }
318
362
 
319
363
  out.exec("COMMIT")
364
+
365
+ // --- pass 4b: postcode ALIAS names (#1495) ---
366
+ //
367
+ // The delivery-city names GeoNames supplies for a ZIP ("Brooklyn" for 11201) are written into
368
+ // the shard's `names` table by `postcode/centroid-fills.ts`'s `geonamesNameFill`. Everything
369
+ // downstream of `names` picked them up EXCEPT this build: `fts.ts` unions `spr.name` with every
370
+ // `names` row into `place_search.alt_names`, so the FTS backend resolved "Brooklyn" → 11201
371
+ // while the candidate backend — whose every row IS an exact-tier row — had no key for it at
372
+ // all. Pass 2 does the equivalent fold for admin places, but reads the ADMIN `place_search`,
373
+ // and `attrs` holds admin ids only, so a postcode shard could never reach it.
374
+ //
375
+ // Same discipline as pass 2: `is_primary = 0` (so `rankByPrimaryPreference` treats it as an
376
+ // alias, not a canonical postcode name), the row stays denormalized onto the POSTCODE's own
377
+ // spr_id/coords/bbox, and the display `name` stays the postcode — resolving "brooklyn" answers
378
+ // with place 11201, it does not rename the place to its delivery city.
379
+ const hasNames = pc.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='names'").get() !== undefined
380
+
381
+ if (hasNames) {
382
+ out.exec("BEGIN")
383
+
384
+ for (const r of pc.prepare("SELECT id, name FROM names").iterate()) {
385
+ const a = pcAttrs.get(Number(r.id))
386
+
387
+ if (!a) continue
388
+
389
+ const k = normalizeLocalityForKey(String(r.name ?? ""))
390
+
391
+ // The postcode's own key is already staged as the primary; `INSERT OR IGNORE` at
392
+ // materialization dedupes repeats, so this only skips the obvious self-alias.
393
+ if (!k || k === a.pkey) continue
394
+
395
+ stageRow(k, a, Number(r.id), 0)
396
+
397
+ aliases++
398
+ }
399
+
400
+ out.exec("COMMIT")
401
+ } else {
402
+ // Never a silent zero: real shards come from `createUnifiedSchema`, which always creates
403
+ // `names`. A shard without it has no alias surface to lose, but say so rather than reporting
404
+ // "0 aliases" from a table that was never read.
405
+ progress("postcode-aliases", `${pcDB} has no \`names\` table — no delivery-city aliases to fold`)
406
+ }
407
+
320
408
  pc.close()
409
+
410
+ return { primaries, aliases }
411
+ }
412
+
413
+ let nPostcode = 0
414
+ let nPostcodeAlias = 0
415
+
416
+ for (const pcDB of opts.postcodes ?? []) {
417
+ const folded = foldPostcodeShard(pcDB)
418
+
419
+ nPostcode += folded.primaries
420
+ nPostcodeAlias += folded.aliases
321
421
  }
322
422
 
323
423
  if (nPostcode > 0) {
324
- progress("postcodes", `${nPostcode.toLocaleString()} postcodes`)
424
+ progress("postcodes", `${nPostcode.toLocaleString()} postcodes; ${nPostcodeAlias.toLocaleString()} aliases`)
325
425
  }
326
426
 
327
427
  // --- code dictionaries: typed batch inserts via kdb (a few hundred rows — Kysely is clean here) ---
@@ -370,5 +470,13 @@ export async function buildCandidateTable(opts: BuildCandidateOptions): Promise<
370
470
  await kdb.destroy()
371
471
 
372
472
  // closes the underlying `out` connection
373
- return { rows, places: attrs.size, primaries: nPrim, aliases: nAlias, abbrevs: nAbbr, postcodes: nPostcode }
473
+ return {
474
+ rows,
475
+ places: attrs.size,
476
+ primaries: nPrim,
477
+ aliases: nAlias,
478
+ abbrevs: nAbbr,
479
+ postcodes: nPostcode,
480
+ postcodeAliases: nPostcodeAlias,
481
+ }
374
482
  }
@@ -297,7 +297,9 @@ export class WOFCandidateTableLookup implements PlaceLookup {
297
297
  // #920 name law, candidate-key edition: postcode rows are keyed by their whitespace-stripped
298
298
  // form at build (the GeoNames fold normalizes '624 66' → '62466'), so a postcode-typed query
299
299
  // strips internal whitespace before keying. Postcode-only — locality names keep their spaces.
300
- if ([query.placetype].flat().includes("postalcode")) {
300
+ const wantsPostcode = [query.placetype].flat().includes("postalcode")
301
+
302
+ if (wantsPostcode) {
301
303
  text = text.replaceAll(/\s+/g, "")
302
304
  }
303
305
 
@@ -427,7 +429,18 @@ export class WOFCandidateTableLookup implements PlaceLookup {
427
429
  // — fuzzing it scrapes an unrelated same-filter place ("Vienna, Austria" misrouted to IT would
428
430
  // pull a tiny Italian name_key near Siena) and masks the cascade's country-agnostic retry that
429
431
  // correctly lands population-first Vienna AT. The exact/strip probes already covered the real name.
430
- if (!rows.length && this.#ftsProbe && this.#nameKeyExistsProbe && !this.#nameKeyExistsProbe.get(nameKey)) {
432
+ //
433
+ // NEVER for postcodes: fuzzy is a typo corrector for place NAMES, and a "corrected" postcode is a
434
+ // DIFFERENT postcode. The 2026-08-05 Code-Point swap exposed the trap at scale: Northern Ireland's
435
+ // `BT3 9QQ` (absent — no permissive NI source) trigram-matched Sheffield's `S3 9QQ` (Jaccard 0.4
436
+ // on {39q, 9qq}) and resolved 200+ km wrong with full confidence. An unknown postcode must abstain.
437
+ if (
438
+ !rows.length &&
439
+ !wantsPostcode &&
440
+ this.#ftsProbe &&
441
+ this.#nameKeyExistsProbe &&
442
+ !this.#nameKeyExistsProbe.get(nameKey)
443
+ ) {
431
444
  const match = ftsTrigramQuery(nameKey)
432
445
 
433
446
  if (match) {
@@ -11,7 +11,7 @@
11
11
  * promote-gate verdicts + the measured hard-resolve rates that used to live in a code comment on
12
12
  * `HARD_PLACE_COUNTRY_SAFELIST`. Presence = measured; `hard_filter_safe = 0` = measured and
13
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`).
14
+ * (the meaning-of-zero rule, `docs/engineering/reference/layer-contract.mdx`).
15
15
  * - `country_bbox`: the coarse guard-B plausibility boxes that used to live in
16
16
  * `resolver/plausibility.ts`'s `COUNTRY_BBOX`. An absent row fails open (never trips the guard),
17
17
  * exactly like an absent key in the constant.
package/fst-builder.ts CHANGED
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { DatabaseSync } from "node:sqlite"
15
15
 
16
+ import { readWOFSourceIdentity } from "./fst-freshness.ts"
16
17
  import type { FSTNode } from "./fst-matcher.ts"
17
18
  import { FSTMatcher, normalizeTokens } from "./fst-matcher.ts"
18
19
  import type { BuildFSTOpts, BuildFSTResult, FSTProvenance, PlaceEntry, PlacetypeID } from "./fst-types.ts"
@@ -29,6 +30,11 @@ const DEFAULT_PLACETYPES: PlacetypeID[] = [
29
30
 
30
31
  const DEFAULT_COUNTRIES = ["US"]
31
32
  const DEFAULT_LANGUAGES = ["eng", ""]
33
+ /**
34
+ * Ids per `IN (…)` batch. SQLITE_MAX_VARIABLE_NUMBER defaults to 32,766; 500 matches the name-load batch a few phases
35
+ * down, so both read paths bind the same shape.
36
+ */
37
+ const ANCESTOR_CHUNK = 500
32
38
 
33
39
  interface SprRow {
34
40
  id: number
@@ -89,19 +95,40 @@ export function buildFSTFromWOF(opts: BuildFSTOpts): {
89
95
  // Also load parent rows that might be outside our placetype filter (e.g., country for region).
90
96
  const parentStmt = db.prepare("SELECT id, name, placetype, parent_id, latitude, longitude FROM spr WHERE id = ?")
91
97
 
92
- // Fallback: use ancestors table when parent_id is a sentinel (-1, -4, etc.).
93
- let ancestorStmt: ReturnType<typeof db.prepare> | null = null
98
+ // Fallback for a sentinel parent_id (-1, -4, …): the ancestors table. Read in chunked `IN (…)`
99
+ // batches ONCE the point-query version fired per orphan row, and on a global build the orphans
100
+ // run to six figures. Ordering is county → region → country, preserved by the same CASE the
101
+ // per-row query used, with `id` leading so one pass groups the rows.
102
+ const ancestorsByID = new Map<number, number[]>()
94
103
 
95
104
  try {
96
- ancestorStmt = db.prepare(
97
- `SELECT DISTINCT ancestor_id FROM ancestors
98
- WHERE id = ? AND ancestor_placetype IN ('country', 'region', 'county')
99
- ORDER BY CASE ancestor_placetype
100
- WHEN 'county' THEN 1
101
- WHEN 'region' THEN 2
102
- WHEN 'country' THEN 3
103
- END`
104
- )
105
+ const orphanIDs = sprRows.filter((row) => row.parent_id <= 0).map((row) => row.id)
106
+
107
+ for (let i = 0; i < orphanIDs.length; i += ANCESTOR_CHUNK) {
108
+ const chunk = orphanIDs.slice(i, i + ANCESTOR_CHUNK)
109
+
110
+ const rows = db
111
+ .prepare(
112
+ `SELECT DISTINCT id, ancestor_id FROM ancestors
113
+ WHERE id IN (${chunk.map(() => "?").join(",")}) AND ancestor_placetype IN ('country', 'region', 'county')
114
+ ORDER BY id, CASE ancestor_placetype
115
+ WHEN 'county' THEN 1
116
+ WHEN 'region' THEN 2
117
+ WHEN 'country' THEN 3
118
+ END`
119
+ )
120
+ .all(...chunk) as unknown as Array<{ id: number; ancestor_id: number }>
121
+
122
+ for (const row of rows) {
123
+ let chain = ancestorsByID.get(row.id)
124
+
125
+ if (!chain) {
126
+ ancestorsByID.set(row.id, (chain = []))
127
+ }
128
+
129
+ chain.push(row.ancestor_id)
130
+ }
131
+ }
105
132
  } catch {
106
133
  progress("ancestors", "No ancestors table — sentinel parent_ids will produce empty chains")
107
134
  }
@@ -112,10 +139,8 @@ export function buildFSTFromWOF(opts: BuildFSTOpts): {
112
139
  if (!row) return []
113
140
 
114
141
  // If parent_id is a sentinel (≤ 0), use ancestors table.
115
- if (row.parent_id <= 0 && ancestorStmt) {
116
- const ancestors = ancestorStmt.all(id) as unknown as Array<{ ancestor_id: number }>
117
-
118
- return ancestors.map((a) => a.ancestor_id).filter((aid) => aid !== id)
142
+ if (row.parent_id <= 0) {
143
+ return (ancestorsByID.get(id) ?? []).filter((ancestorID) => ancestorID !== id)
119
144
  }
120
145
 
121
146
  // Normal case: walk parent_id chain.
@@ -320,6 +345,15 @@ export function buildFSTFromWOF(opts: BuildFSTOpts): {
320
345
  const edgeCount = nodes.reduce((sum, n) => sum + n.edges.size, 0)
321
346
  const matcher = FSTMatcher.fromNodes(nodes)
322
347
 
348
+ // The build stamp (2026-08-05). `sourceDB` alone was never enough to tell a reader whether this
349
+ // artifact matches the database at that path — the admin DB is sealed and REPLACED by a rebuild, so
350
+ // the path is constant across every generation of it. Hashing costs 7.3 s for the 5.27 GB admin DB
351
+ // and is free whenever the `.md5` sidecar is current, which the admin build already writes.
352
+ // `sourceIdentity` lets a caller that already knows the digest (or is building from something that
353
+ // is not a file at all) supply it instead.
354
+ progress("stamp", `Reading source identity for ${opts.dbPath}`)
355
+ const source = opts.sourceIdentity ?? readWOFSourceIdentity(opts.dbPath)
356
+
323
357
  const provenance: FSTProvenance = {
324
358
  builtAt: new Date().toISOString(),
325
359
  countries,
@@ -329,6 +363,8 @@ export function buildFSTFromWOF(opts: BuildFSTOpts): {
329
363
  nameInsertions: insertCount,
330
364
  importanceMatches: importanceMap.size,
331
365
  sourceDB: opts.dbPath,
366
+ sourceDBMD5: source.md5,
367
+ sourceDBBytes: source.bytes,
332
368
  ...(excludeSurfaces !== undefined || excludeAllTokensOf !== undefined
333
369
  ? { exclusionPolicy: opts.exclusionPolicy ?? "unspecified", excludedInsertions: excludedCount }
334
370
  : {}),
@@ -10,6 +10,8 @@
10
10
  * build-time operation).
11
11
  */
12
12
 
13
+ import { tryParsingJSON } from "@mailwoman/core/objects"
14
+
13
15
  import type { FSTNode } from "./fst-matcher.ts"
14
16
  import { FSTMatcher } from "./fst-matcher.ts"
15
17
  import type { FSTProvenance, PlaceEntry, PlacetypeID } from "./fst-types.ts"
@@ -194,7 +196,7 @@ export function readFSTProvenanceWeb(input: ArrayBuffer | Uint8Array): FSTProven
194
196
  const jsonLen = view.getUint32(provenanceOffset, true)
195
197
  const jsonStr = decoder.decode(bytes.subarray(provenanceOffset + 4, provenanceOffset + 4 + jsonLen))
196
198
 
197
- return JSON.parse(jsonStr) as FSTProvenance
199
+ return tryParsingJSON<FSTProvenance>(jsonStr) ?? undefined
198
200
  } catch {
199
201
  return undefined
200
202
  }