@mailwoman/resolver-wof-sqlite 8.6.0 → 9.1.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 (121) hide show
  1. package/ancestry-backfill.ts +103 -64
  2. package/build-candidate.ts +230 -29
  3. package/candidate-importance.ts +227 -0
  4. package/candidate-lookup.ts +159 -9
  5. package/candidate-schema.ts +29 -0
  6. package/coverage-manifest-schema.ts +1 -1
  7. package/fst-autocomplete.ts +22 -11
  8. package/fst-builder.ts +76 -50
  9. package/fst-deserialize-web.ts +46 -8
  10. package/fst-freshness.ts +333 -0
  11. package/fst-serialize.ts +78 -13
  12. package/fst-types.ts +58 -1
  13. package/fts.ts +12 -0
  14. package/geo.ts +16 -41
  15. package/geonames-aliases.ts +105 -37
  16. package/geonames-postal.ts +65 -21
  17. package/index.ts +16 -1
  18. package/interpolation.ts +2 -1
  19. package/lookup.ts +44 -5
  20. package/out/ancestry-backfill.d.ts +37 -18
  21. package/out/ancestry-backfill.d.ts.map +1 -1
  22. package/out/ancestry-backfill.js +82 -52
  23. package/out/ancestry-backfill.js.map +1 -1
  24. package/out/build-candidate.d.ts +42 -0
  25. package/out/build-candidate.d.ts.map +1 -1
  26. package/out/build-candidate.js +141 -13
  27. package/out/build-candidate.js.map +1 -1
  28. package/out/candidate-importance.d.ts +132 -0
  29. package/out/candidate-importance.d.ts.map +1 -0
  30. package/out/candidate-importance.js +174 -0
  31. package/out/candidate-importance.js.map +1 -0
  32. package/out/candidate-lookup.d.ts +9 -1
  33. package/out/candidate-lookup.d.ts.map +1 -1
  34. package/out/candidate-lookup.js +127 -8
  35. package/out/candidate-lookup.js.map +1 -1
  36. package/out/candidate-schema.d.ts +25 -1
  37. package/out/candidate-schema.d.ts.map +1 -1
  38. package/out/candidate-schema.js +5 -0
  39. package/out/candidate-schema.js.map +1 -1
  40. package/out/coverage-manifest-schema.d.ts +1 -1
  41. package/out/coverage-manifest-schema.js +1 -1
  42. package/out/fst-autocomplete.d.ts +13 -3
  43. package/out/fst-autocomplete.d.ts.map +1 -1
  44. package/out/fst-autocomplete.js +10 -9
  45. package/out/fst-autocomplete.js.map +1 -1
  46. package/out/fst-builder.d.ts.map +1 -1
  47. package/out/fst-builder.js +64 -40
  48. package/out/fst-builder.js.map +1 -1
  49. package/out/fst-deserialize-web.d.ts.map +1 -1
  50. package/out/fst-deserialize-web.js +38 -8
  51. package/out/fst-deserialize-web.js.map +1 -1
  52. package/out/fst-freshness.d.ts +138 -0
  53. package/out/fst-freshness.d.ts.map +1 -0
  54. package/out/fst-freshness.js +238 -0
  55. package/out/fst-freshness.js.map +1 -0
  56. package/out/fst-serialize.d.ts +19 -3
  57. package/out/fst-serialize.d.ts.map +1 -1
  58. package/out/fst-serialize.js +68 -13
  59. package/out/fst-serialize.js.map +1 -1
  60. package/out/fst-types.d.ts +61 -1
  61. package/out/fst-types.d.ts.map +1 -1
  62. package/out/fts.d.ts +11 -0
  63. package/out/fts.d.ts.map +1 -1
  64. package/out/fts.js +11 -0
  65. package/out/fts.js.map +1 -1
  66. package/out/geo.d.ts +10 -13
  67. package/out/geo.d.ts.map +1 -1
  68. package/out/geo.js +15 -35
  69. package/out/geo.js.map +1 -1
  70. package/out/geonames-aliases.d.ts +23 -1
  71. package/out/geonames-aliases.d.ts.map +1 -1
  72. package/out/geonames-aliases.js +88 -33
  73. package/out/geonames-aliases.js.map +1 -1
  74. package/out/geonames-postal.d.ts +22 -1
  75. package/out/geonames-postal.d.ts.map +1 -1
  76. package/out/geonames-postal.js +50 -16
  77. package/out/geonames-postal.js.map +1 -1
  78. package/out/index.d.ts +3 -1
  79. package/out/index.d.ts.map +1 -1
  80. package/out/index.js +3 -1
  81. package/out/index.js.map +1 -1
  82. package/out/interpolation.d.ts.map +1 -1
  83. package/out/interpolation.js +2 -1
  84. package/out/interpolation.js.map +1 -1
  85. package/out/lookup.d.ts.map +1 -1
  86. package/out/lookup.js +37 -5
  87. package/out/lookup.js.map +1 -1
  88. package/out/place-importance-schema.d.ts +189 -0
  89. package/out/place-importance-schema.d.ts.map +1 -0
  90. package/out/place-importance-schema.js +242 -0
  91. package/out/place-importance-schema.js.map +1 -0
  92. package/out/poi-lookup.d.ts +1 -1
  93. package/out/poi-lookup.js +3 -3
  94. package/out/reverse.d.ts.map +1 -1
  95. package/out/reverse.js +3 -9
  96. package/out/reverse.js.map +1 -1
  97. package/out/sqlite-convention-source.d.ts.map +1 -1
  98. package/out/sqlite-convention-source.js +4 -3
  99. package/out/sqlite-convention-source.js.map +1 -1
  100. package/out/sqlite-utils.d.ts +12 -0
  101. package/out/sqlite-utils.d.ts.map +1 -1
  102. package/out/sqlite-utils.js +20 -0
  103. package/out/sqlite-utils.js.map +1 -1
  104. package/out/street-morphology-fst-builder.d.ts.map +1 -1
  105. package/out/street-morphology-fst-builder.js +7 -5
  106. package/out/street-morphology-fst-builder.js.map +1 -1
  107. package/out/street-normalize.d.ts +2 -1
  108. package/out/street-normalize.d.ts.map +1 -1
  109. package/out/street-normalize.js +2 -1
  110. package/out/street-normalize.js.map +1 -1
  111. package/out/types.d.ts +39 -0
  112. package/out/types.d.ts.map +1 -1
  113. package/package.json +24 -6
  114. package/place-importance-schema.ts +353 -0
  115. package/poi-lookup.ts +3 -3
  116. package/reverse.ts +4 -9
  117. package/sqlite-convention-source.ts +5 -3
  118. package/sqlite-utils.ts +21 -0
  119. package/street-morphology-fst-builder.ts +8 -5
  120. package/street-normalize.ts +3 -2
  121. package/types.ts +39 -0
package/fst-serialize.ts CHANGED
@@ -18,11 +18,23 @@
18
18
  *
19
19
  * EDGE TABLE [edgeCount × 8 bytes] stringIdx u32 index into string table targetState u32
20
20
  *
21
- * PLACE TABLE [placeCount × 56 bytes] wofID u32 placetypeIdx u8 index into PLACETYPE_ORDER chainLen
22
- * u8 0..8 _pad u16 nameIdx u32 index into string table importance f32 Wikipedia importance [0,1]
23
- * (V2); was population u32 (V1) lat f32 lon f32 chain [u32; 8] parent chain (unused slots = 0)
21
+ * PLACE TABLE [placeCount × 60 bytes at V5, 56 below] wofID u32 placetypeIdx u8 index into
22
+ * PLACETYPE_ORDER chainLen u8 0..8 crossCountryBranches u8 (header flags bit0 gates the read)
23
+ * placeFlags u8 (V5; bit0 = encyclopedic present) nameIdx u32 index into string table referential f32
24
+ * population-anchored likelihood [0,1] — was the conflated `importance` (V2–V4), was population u32
25
+ * (V1) lat f32 lon f32 chain [u32; 8] parent chain (unused slots = 0) encyclopedic f32 (V5 only;
26
+ * read only when placeFlags bit0 is set)
27
+ *
28
+ * THE V5 BUMP IS THE TWO-SCORE SPLIT (ROAD_TO_V9 §2 R1). Through V4 one float carried whichever score
29
+ * the source database held, and nothing in the bytes said which — so a reader could not tell a
30
+ * population proxy from a Wikipedia score. V5 names the ranking score `referential` and gives the
31
+ * encyclopedic signal its own slot plus a PER-PLACE presence bit, because most places have no
32
+ * Wikipedia article and a 0 there would be a fact nobody recorded. `fst-freshness.ts` reports every
33
+ * V4-and-below artifact as format-stale for exactly this reason: its single float is unattributable.
24
34
  */
25
35
 
36
+ import { tryParsingJSON } from "@mailwoman/core/objects"
37
+
26
38
  import type { FSTNode } from "./fst-matcher.ts"
27
39
  import { FSTMatcher } from "./fst-matcher.ts"
28
40
  import type { FSTProvenance, PlaceEntry, PlacetypeID } from "./fst-types.ts"
@@ -48,6 +60,34 @@ const NARROW_STATE_ENTRY_SIZE = 12
48
60
  */
49
61
  const VERSION_WITH_METADATA = 3
50
62
 
63
+ /**
64
+ * Format version that split the single `importance` float into `referential` + `encyclopedic`, growing the place entry
65
+ * from 56 to 60 bytes and claiming the previously-reserved `pp+7` byte as {@link PLACE_FLAG_HAS_ENCYCLOPEDIC}.
66
+ */
67
+ const VERSION_TWO_SCORE_SPLIT = 5
68
+
69
+ /**
70
+ * Place-entry size in bytes at or above {@link VERSION_TWO_SCORE_SPLIT}.
71
+ */
72
+ const SPLIT_PLACE_ENTRY_SIZE = 60
73
+
74
+ /**
75
+ * Place-entry size in bytes below {@link VERSION_TWO_SCORE_SPLIT}.
76
+ */
77
+ const LEGACY_PLACE_ENTRY_SIZE = 56
78
+
79
+ /**
80
+ * Byte offset of the encyclopedic float inside a v5 place entry — immediately after the 8-slot parent chain.
81
+ */
82
+ const ENCYCLOPEDIC_OFFSET = 56
83
+
84
+ /**
85
+ * `placeFlags` bit 0 (byte `pp+7`, v5+): this place carries an encyclopedic score. Per-PLACE rather than per-file
86
+ * because absence is the common case — roughly 89% of the 2026-08-05 gazetteer has no Wikipedia article — and a
87
+ * file-level flag would force every one of those rows to claim a 0 it never had.
88
+ */
89
+ const PLACE_FLAG_HAS_ENCYCLOPEDIC = 1
90
+
51
91
  /**
52
92
  * File magic. A reader rejects anything not starting with these four bytes before parsing further.
53
93
  */
@@ -56,7 +96,14 @@ const MAGIC = Buffer.from("FST\0", "ascii")
56
96
  /**
57
97
  * Format version this serializer emits. See {@link VERSION_WIDE_STATE_COUNTERS} for what each bump changed.
58
98
  */
59
- const VERSION = 4
99
+ const VERSION = 5
100
+
101
+ /**
102
+ * The format version this tree WRITES, published so a freshness guard can call an older artifact format-stale without
103
+ * re-typing the number. Mirrors `REQUIRED_PAIR_INDEX_SCHEMA`'s role for PIX1: one constant, so a version bump cannot be
104
+ * noticed by some checkers and not others. See `fst-freshness.ts`.
105
+ */
106
+ export const FST_FORMAT_VERSION = VERSION
60
107
 
61
108
  /**
62
109
  * Fixed header size in bytes: magic, version, and the section offsets that follow it.
@@ -74,9 +121,9 @@ const STATE_ENTRY_SIZE = 16
74
121
  const EDGE_ENTRY_SIZE = 8
75
122
 
76
123
  /**
77
- * Place-table entry: the place id, its placetype, coordinates, and importance.
124
+ * Place-table entry at the version this tree WRITES: the place id, its placetype, coordinates, and both scores.
78
125
  */
79
- const PLACE_ENTRY_SIZE = 56
126
+ const PLACE_ENTRY_SIZE = SPLIT_PLACE_ENTRY_SIZE
80
127
 
81
128
  /**
82
129
  * Longest ancestry chain stored per place. Deeper hierarchies are truncated at the leaf end, since the specific end of
@@ -237,11 +284,14 @@ export function serializeFST(matcher: FSTMatcher, provenance?: FSTProvenance): B
237
284
  buf.writeUInt32LE(place.wofID, pp)
238
285
  buf.writeUInt8(placetypeToIdx.get(place.placetype) ?? 0, pp + 4)
239
286
  buf.writeUInt8(chainLen, pp + 5)
240
- // Former _pad: byte 0 = crossCountryBranches (header flags bit0 gates the read), byte 1 reserved.
287
+ // Former _pad: byte 0 = crossCountryBranches (header flags bit0 gates the read), byte 1 = v5 placeFlags.
241
288
  buf.writeUInt8(hasAmbiguity ? Math.min(place.crossCountryBranches ?? 0, 255) : 0, pp + 6)
242
- buf.writeUInt8(0, pp + 7)
289
+ // An absent encyclopedic score writes flag 0 AND a 0.0 float. The float is unread in that
290
+ // state, so absence can never surface as a score — the meaning-of-zero rule, in bytes.
291
+ const hasEncyclopedic = place.encyclopedic !== undefined
292
+ buf.writeUInt8(hasEncyclopedic ? PLACE_FLAG_HAS_ENCYCLOPEDIC : 0, pp + 7)
243
293
  buf.writeUInt32LE(intern(place.name), pp + 8)
244
- buf.writeFloatLE(place.importance, pp + 12)
294
+ buf.writeFloatLE(place.referential, pp + 12)
245
295
  buf.writeFloatLE(place.lat, pp + 16)
246
296
  buf.writeFloatLE(place.lon, pp + 20)
247
297
 
@@ -249,6 +299,8 @@ export function serializeFST(matcher: FSTMatcher, provenance?: FSTProvenance): B
249
299
  buf.writeUInt32LE(ci < chainLen ? validChain[ci]! : 0, pp + 24 + ci * 4)
250
300
  }
251
301
 
302
+ buf.writeFloatLE(hasEncyclopedic ? place.encyclopedic! : 0, pp + ENCYCLOPEDIC_OFFSET)
303
+
252
304
  placeIdx++
253
305
  }
254
306
  }
@@ -271,6 +323,7 @@ export function deserializeFST(buf: Buffer): FSTMatcher {
271
323
 
272
324
  if (version < 1 || version > VERSION) throw new Error(`FST version ${version} unsupported (expected 1..${VERSION})`)
273
325
  const isV2 = version >= 2
326
+ const isSplit = version >= VERSION_TWO_SCORE_SPLIT
274
327
  // flags bit0 (survey #4): place rows carry surface-ambiguity data in the former _pad byte.
275
328
  const hasAmbiguity = (buf.readUInt16LE(6) & 1) === 1
276
329
 
@@ -303,6 +356,8 @@ export function deserializeFST(buf: Buffer): FSTMatcher {
303
356
 
304
357
  // --- State table ---
305
358
  const stateEntrySize = version >= VERSION_WIDE_STATE_COUNTERS ? WIDE_STATE_ENTRY_SIZE : NARROW_STATE_ENTRY_SIZE
359
+ // v5 grew the place entry by the encyclopedic float; v4-and-below files are read at the old stride.
360
+ const placeEntrySize = version >= VERSION_TWO_SCORE_SPLIT ? SPLIT_PLACE_ENTRY_SIZE : LEGACY_PLACE_ENTRY_SIZE
306
361
  const stateTableStart = pos
307
362
  const edgeTableStart = stateTableStart + stateCount * stateEntrySize
308
363
  const placeTableStart = edgeTableStart + edgeCount * EDGE_ENTRY_SIZE
@@ -332,7 +387,7 @@ export function deserializeFST(buf: Buffer): FSTMatcher {
332
387
  const places: PlaceEntry[] = new Array(placeCountForState)
333
388
 
334
389
  for (let pi = 0; pi < placeCountForState; pi++) {
335
- const pp = placeTableStart + (placeStart + pi) * PLACE_ENTRY_SIZE
390
+ const pp = placeTableStart + (placeStart + pi) * placeEntrySize
336
391
  const chainLen = buf.readUInt8(pp + 5)
337
392
  const parentChain: number[] = []
338
393
 
@@ -340,20 +395,30 @@ export function deserializeFST(buf: Buffer): FSTMatcher {
340
395
  parentChain.push(buf.readUInt32LE(pp + 24 + ci * 4))
341
396
  }
342
397
 
343
- const rawImportance = isV2
398
+ // v1 stored a raw population u32 here; v2–v4 the conflated `importance` float; v5 the
399
+ // referential score. A v1 file's population is mapped through the SAME curve
400
+ // `referentialFromPopulation` uses, so its value is genuinely referential — the only
401
+ // generation of this format for which that can be said without reading the source database.
402
+ const referential = isV2
344
403
  ? buf.readFloatLE(pp + 12)
345
404
  : Math.min(1, Math.log2(1 + buf.readUInt32LE(pp + 12) / 1000) / 14)
346
405
 
406
+ // Per-place presence bit (v5+). A v4-and-below file has no encyclopedic channel at all, so
407
+ // the field stays undefined rather than reading the reserved byte as a flag.
408
+ const hasEncyclopedic =
409
+ isSplit && (buf.readUInt8(pp + 7) & PLACE_FLAG_HAS_ENCYCLOPEDIC) === PLACE_FLAG_HAS_ENCYCLOPEDIC
410
+
347
411
  places[pi] = {
348
412
  wofID: buf.readUInt32LE(pp),
349
413
  placetype: PLACETYPE_ORDER[buf.readUInt8(pp + 4)] ?? "locality",
350
414
  name: strings[buf.readUInt32LE(pp + 8)]!,
351
- importance: rawImportance,
415
+ referential,
352
416
  lat: buf.readFloatLE(pp + 16),
353
417
  lon: buf.readFloatLE(pp + 20),
354
418
  parentChain,
355
419
  // Header flags bit0 gates the read (survey #4): pre-ambiguity artifacts expose undefined.
356
420
  ...(hasAmbiguity ? { crossCountryBranches: buf.readUInt8(pp + 6) } : {}),
421
+ ...(hasEncyclopedic ? { encyclopedic: buf.readFloatLE(pp + ENCYCLOPEDIC_OFFSET) } : {}),
357
422
  }
358
423
  }
359
424
 
@@ -378,7 +443,7 @@ export function readFSTProvenance(buf: Buffer): FSTProvenance | undefined {
378
443
  const jsonLen = buf.readUInt32LE(provenanceOffset)
379
444
  const jsonStr = buf.toString("utf8", provenanceOffset + 4, provenanceOffset + 4 + jsonLen)
380
445
 
381
- return JSON.parse(jsonStr) as FSTProvenance
446
+ return tryParsingJSON<FSTProvenance>(jsonStr) ?? undefined
382
447
  } catch {
383
448
  return undefined
384
449
  }
package/fst-types.ts CHANGED
@@ -13,7 +13,25 @@ export interface PlaceEntry {
13
13
  placetype: PlacetypeID
14
14
  name: string
15
15
  parentChain: number[]
16
- importance: number
16
+ /**
17
+ * REFERENTIAL likelihood in [0, 1] — population-anchored (`referentialFromPopulation`), and the ONLY score the
18
+ * decoder bias is allowed to read (ROAD_TO_V9 §2, ratified 2026-08-06).
19
+ *
20
+ * This field was called `importance` through format v4, where it carried whichever score the source database happened
21
+ * to hold: the population proxy on a database with no `place_importance` table (which is every shipped
22
+ * `fst-per-locale` binary), the Wikipedia score where the concordance join landed otherwise. The rename is the point
23
+ * — a v4 artifact's value is a CONFLATION that cannot be told apart after the fact, which is why `fst-freshness.ts`
24
+ * reports anything below {@link FST_FORMAT_VERSION} as format-stale rather than reading it as referential.
25
+ */
26
+ referential: number
27
+ /**
28
+ * Encyclopedic (Wikipedia) importance in [0, 1], fan-out-guarded per #1497. Carried for consumers that want to
29
+ * DISPLAY salience; never read by the decoder or by any ranking.
30
+ *
31
+ * `undefined` = this place has no encyclopedic signal, or the artifact predates format 5. NEVER conflate either with
32
+ * 0 — the meaning-of-zero rule; roughly 89% of the 2026-08-05 gazetteer's rows have no Wikipedia article at all.
33
+ */
34
+ encyclopedic?: number
17
35
  lat: number
18
36
  lon: number
19
37
  /**
@@ -64,8 +82,41 @@ export interface FSTProvenance {
64
82
  placeCount: number
65
83
  edgeCount: number
66
84
  nameInsertions: number
85
+ /**
86
+ * How many places carried a non-zero REFERENTIAL score at build time. Named `importanceMatches` for stamp
87
+ * compatibility with every artifact written before the two-score split — renaming the JSON key would make every
88
+ * existing stamp unreadable, and the number means the same thing it always did on a population-built artifact.
89
+ */
67
90
  importanceMatches: number
91
+ /**
92
+ * How many places carried an ENCYCLOPEDIC score at build time. `undefined` on a pre-split build — which is not the
93
+ * same as 0 (a v5 build against a population-only database), so the freshness report says the two in different
94
+ * words.
95
+ */
96
+ encyclopedicMatches?: number
97
+ /**
98
+ * How the builder obtained its two scores — an {@link ImportanceSplitSource}. Recorded so an artifact states, in its
99
+ * own provenance, whether its encyclopedic channel is real, reconstructed from a legacy conflated column, or absent.
100
+ */
101
+ importanceSource?: string
68
102
  sourceDB?: string
103
+ /**
104
+ * MD5 of the source database's bytes at build time — the artifact's link to the gazetteer it is a projection of.
105
+ *
106
+ * `sourceDB` records the PATH, which is exactly the field that cannot change when the bytes behind it do: the admin
107
+ * DB is a sealed readonly artifact that a rebuild REPLACES in place, so every FST built before the 2026-08-04 swap
108
+ * still names the current file and none of them was built from it. Compared by `fst-freshness.ts`.
109
+ *
110
+ * `undefined` = built before the stamp existed (every artifact predating 2026-08-05). NEVER conflate that with "built
111
+ * from a database whose md5 is unknown" — the freshness check reports the two in different words.
112
+ */
113
+ sourceDBMD5?: string
114
+ /**
115
+ * Byte size of the source database at build time. Not redundant with {@link FSTProvenance.sourceDBMD5}: it survives a
116
+ * truncated source and it is what makes a staleness warning legible — "5,273,722,880 → 5,372,076,032" names the
117
+ * rebuild, a hex delta does not.
118
+ */
119
+ sourceDBBytes?: number
69
120
  modelCardVersion?: string
70
121
  /**
71
122
  * Degenerate-surface curation policy applied at build time (absent = uncurated build).
@@ -79,6 +130,12 @@ export interface FSTProvenance {
79
130
 
80
131
  export interface BuildFSTOpts {
81
132
  dbPath: string
133
+ /**
134
+ * Pre-computed identity of `dbPath` to stamp into provenance. Omit and the builder reads it via
135
+ * `readWOFSourceIdentity` (sidecar-cached). Supply it when the digest is already in hand, or when the caller is
136
+ * building from something whose identity it defines itself.
137
+ */
138
+ sourceIdentity?: { md5: string; bytes: number }
82
139
  countries?: string[]
83
140
  placetypes?: PlacetypeID[]
84
141
  languages?: string[]
package/fts.ts CHANGED
@@ -123,6 +123,18 @@ export const PLACE_BBOX_TABLE = "place_bbox"
123
123
  */
124
124
  export const PLACE_POPULATION_TABLE = "place_population"
125
125
 
126
+ /**
127
+ * Name of the auxiliary table holding the two salience scores per place — `referential` (population-anchored, the
128
+ * ranking backbone) and `encyclopedic` (the Wikipedia join, NULL when there is no article). Built by `mailwoman
129
+ * gazetteer importance`; schema and derivations in `place-importance-schema.ts`.
130
+ *
131
+ * The lookup reads ONLY `encyclopedic` from it, and only to CARRY the value onto the result — referential is derived
132
+ * from the population already joined, and no ORDER BY anywhere touches this table (ROAD_TO_V9 §2). Sparse and
133
+ * schema-versioned: a pre-split gazetteer has this table with a single conflated `importance` column instead, which the
134
+ * lookup's column probe deliberately refuses to read.
135
+ */
136
+ export const PLACE_IMPORTANCE_TABLE = "place_importance"
137
+
126
138
  /**
127
139
  * Counters for a single `buildPlaceSearchFTS` run. Exposed so callers (CLI, lazy-build) can render progress to the
128
140
  * user.
package/geo.ts CHANGED
@@ -10,17 +10,20 @@
10
10
  * ray-cast PIP a ~30-line one — both plenty fast for the post-fetch passes (we operate on ≤ a few
11
11
  * hundred candidates per query, not the whole 142k-row corpus).
12
12
  *
13
- * The PIP implementation here is the CANONICAL port of the even-odd ray cast that previously lived
14
- * only in Python (`scripts/eval/pip-containment.py`, with a second copy in
15
- * `scripts/build-postcode-locality.ts`). Keep the three in sync if the algorithm ever changes —
16
- * the eval-side Python copies grade the same containment truth this one resolves with.
13
+ * The even-odd ray cast lives in `@mailwoman/spatial` and is re-exported below, so the resolver,
14
+ * the reverse-geocoder and the gazetteer pipeline share one definition. `scripts/eval/pip-containment.py`
15
+ * grades the same containment truth and has to be matched BY HAND if the algorithm changes — it is
16
+ * the one copy no import can reach.
17
17
  *
18
18
  * The R*Tree index name + schema are centralized in `fts.ts` (alongside the FTS5 build).
19
19
  */
20
20
 
21
- // haversineKm is the canonical implementation in @mailwoman/spatial; re-exported so this package's
22
- // readers keep importing it from "./geo.ts" (the spatial dep is transitive via @mailwoman/resolver).
23
- export { haversineKm } from "@mailwoman/spatial"
21
+ import { pointInPolygon } from "@mailwoman/spatial"
22
+
23
+ // haversineKm and the point-in-polygon ray cast are implemented in @mailwoman/spatial, the math
24
+ // home; re-exported so this package's readers keep importing them from "./geo.ts" (the spatial dep
25
+ // is transitive via @mailwoman/resolver).
26
+ export { haversineKm, pointInRing } from "@mailwoman/spatial"
24
27
 
25
28
  /**
26
29
  * WGS-84 degrees → radians.
@@ -82,42 +85,14 @@ export interface GeojsonMultiPolygon {
82
85
  export type GeojsonGeometry = GeojsonPolygon | GeojsonMultiPolygon | { type: string; coordinates?: unknown }
83
86
 
84
87
  /**
85
- * Ray-cast a point against ONE linear ring. Standard even-odd crossing count: shoot a ray along +lon and toggle on
86
- * every edge crossing. Points exactly on an edge are implementation-defined (either side is acceptable for geocoding —
87
- * admin boundaries are DP-simplified anyway).
88
- */
89
- export function pointInRing(lon: number, lat: number, ring: readonly GeojsonPosition[]): boolean {
90
- let inside = false
91
- const n = ring.length
92
-
93
- for (let i = 0, j = n - 1; i < n; j = i++) {
94
- const xi = ring[i]![0]
95
- const yi = ring[i]![1]
96
- const xj = ring[j]![0]
97
- const yj = ring[j]![1]
98
-
99
- if (yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) {
100
- inside = !inside
101
- }
102
- }
103
-
104
- return inside
105
- }
106
-
107
- /**
108
- * Even-odd containment over a polygon's ring list (`[outer, hole1, …]`) — being inside an odd number of rings means
109
- * inside the polygon, which handles holes without ring-orientation rules.
88
+ * Even-odd containment over a polygon's ring list (`[outer, hole1, …]`).
89
+ *
90
+ * Named alias for `@mailwoman/spatial`'s {@link pointInPolygon}, kept because this package's readers and the gazetteer
91
+ * pipeline import `pointInPolygonRings` by name and the "rings" spelling is what makes the ring-list argument obvious
92
+ * at those call sites.
110
93
  */
111
94
  export function pointInPolygonRings(lon: number, lat: number, rings: readonly GeojsonPosition[][]): boolean {
112
- let inside = false
113
-
114
- for (const ring of rings) {
115
- if (pointInRing(lon, lat, ring)) {
116
- inside = !inside
117
- }
118
- }
119
-
120
- return inside
95
+ return pointInPolygon(lon, lat, rings)
121
96
  }
122
97
 
123
98
  /**
@@ -24,11 +24,14 @@
24
24
  * (TSV).
25
25
  */
26
26
 
27
- import { existsSync, readFileSync } from "node:fs"
28
- import { join } from "node:path"
27
+ import { existsSync } from "node:fs"
29
28
  import type { DatabaseSync } from "node:sqlite"
30
29
 
31
30
  import { isOfficialLanguage } from "@mailwoman/codex/country"
31
+ import { join } from "path-ts"
32
+ import { TSVSpliterator } from "spliterator"
33
+
34
+ import { GEONAMES_POSTAL_ID_BASE } from "./geonames-postal.ts"
32
35
 
33
36
  /**
34
37
  * Synthetic id base for GeoNames-sourced rows (#743/#193) — above Overture's 8e12 so the three sources (WOF real ids,
@@ -36,6 +39,47 @@ import { isOfficialLanguage } from "@mailwoman/codex/country"
36
39
  */
37
40
  export const GEONAMES_ID_BASE = 9_000_000_000_000
38
41
 
42
+ /**
43
+ * The four tables the alias fold writes into the range it owns. The purge and the ingest must agree on this list — a
44
+ * table written but not purged is exactly the #1514 defect.
45
+ */
46
+ const FOLD_OWNED_TABLES = ["spr", "names", "place_population", "ancestors"] as const
47
+
48
+ /**
49
+ * Clear everything the alias fold owns — `[GEONAMES_ID_BASE, GEONAMES_POSTAL_ID_BASE)` across {@link
50
+ * FOLD_OWNED_TABLES}
51
+ * — so a fold's output is a function of its country list and dumps ALONE, never of what the DB happened to hold first.
52
+ * Returns the rows removed per table.
53
+ *
54
+ * This exists because the synthetic id is a POSITION (`GEONAMES_ID_BASE + n`, counted across the country list in
55
+ * order), not a derivation from the geonameid: change the list, its order, or a dump's contents and every subsequent id
56
+ * shifts. Without the purge, a second fold overwrote the `spr` PREFIX it reached (`INSERT OR REPLACE`) while its
57
+ * `names`/`ancestors` rows merely appended (bare `INSERT`) and `place_population` skipped unpopulated places entirely —
58
+ * so the previous run's names, ancestry and populations stayed bound to ids now describing other places.
59
+ *
60
+ * Measured on the live 2026-08-05 artifact: a 14-country re-fold over the 161-country fold baked into
61
+ * `admin-global-priority.db` put 522,184 of 2,110,096 name rows in the range (24.7 %) on a place from a different
62
+ * country. Gaborone/BW at id 9000000121151 became Aichegg/AT and kept all 26 of its names; Kinshasa's 16,000,000
63
+ * population landed on a Lithuanian hamlet.
64
+ *
65
+ * The upper bound is NOT the end of the id space. Each later shard owns a range above this one — GeoNames-postal @
66
+ * 9.5e12, NL-PC6 @ 9.6e12, Code-Point @ 9.7e12, NI @ 9.8e12 — and a purge that ran past {@link GEONAMES_POSTAL_ID_BASE}
67
+ * would delete them.
68
+ */
69
+ export function purgeGeonamesAliasRange(db: DatabaseSync): Record<string, number> {
70
+ const removed: Record<string, number> = {}
71
+
72
+ for (const table of FOLD_OWNED_TABLES) {
73
+ const result = db
74
+ .prepare(`DELETE FROM ${table} WHERE id >= ? AND id < ?`)
75
+ .run(GEONAMES_ID_BASE, GEONAMES_POSTAL_ID_BASE)
76
+
77
+ removed[table] = Number(result.changes)
78
+ }
79
+
80
+ return removed
81
+ }
82
+
39
83
  /**
40
84
  * Per-country progress for the ingest — one event per country dump processed (or skipped).
41
85
  */
@@ -65,19 +109,13 @@ interface V2Alias {
65
109
 
66
110
  /**
67
111
  * Parse a country's alternateNamesV2 file into `geonameid -> spelling -> attribution`, restricted to the populated
68
- * places (`P`) present in `lines`.
112
+ * places (`P`) in `wanted`.
69
113
  */
70
- function parseAlternateNamesV2(v2File: string, cc: string, lines: string[]): Map<number, Map<string, V2Alias>> {
71
- const wanted = new Set<number>()
72
-
73
- for (const line of lines) {
74
- const f = line.split("\t")
75
-
76
- if (f[6] === "P") {
77
- wanted.add(Number(f[0]))
78
- }
79
- }
80
-
114
+ async function parseAlternateNamesV2(
115
+ v2File: string,
116
+ cc: string,
117
+ wanted: ReadonlySet<number>
118
+ ): Promise<Map<number, Map<string, V2Alias>>> {
81
119
  const v2 = new Map<number, Map<string, V2Alias>>()
82
120
 
83
121
  // V2 columns (0-indexed): 1 geonameid, 2 isolanguage, 3 name, 4 isPreferredName, 5 isShortName,
@@ -89,13 +127,13 @@ function parseAlternateNamesV2(v2File: string, cc: string, lines: string[]): Map
89
127
  // colonial-era name sails through on the language-tagged row (the #936 review's Malabo finding).
90
128
  // Do NOT gate on isPreferredName instead — it's sparse annotation, not a signal (Turku's sv "Åbo"
91
129
  // is unflagged; FI has 1,746 flags across the whole dump).
92
- const v2Lines = readFileSync(v2File, "utf8").split("\n")
130
+ //
131
+ // Both passes STREAM the file rather than sharing one materialized array: NO's V2 dump is 33 MB,
132
+ // and a second read off the page cache costs less than holding half a million line strings.
133
+ // `header: false` — the dump is headerless.
93
134
  const historicNames = new Set<string>()
94
135
 
95
- for (const line of v2Lines) {
96
- if (!line) continue
97
- const f = line.split("\t")
98
-
136
+ for await (const f of TSVSpliterator.fromAsync(v2File, { header: false })) {
99
137
  if (f[6] === "1" || f[7] === "1" || (f[9] ?? "").trim() !== "") {
100
138
  const alt = (f[3] ?? "").trim()
101
139
 
@@ -105,9 +143,7 @@ function parseAlternateNamesV2(v2File: string, cc: string, lines: string[]): Map
105
143
  }
106
144
  }
107
145
 
108
- for (const line of v2Lines) {
109
- if (!line) continue
110
- const f = line.split("\t")
146
+ for await (const f of TSVSpliterator.fromAsync(v2File, { header: false })) {
111
147
  const gid = Number(f[1])
112
148
 
113
149
  if (!wanted.has(gid)) continue
@@ -153,7 +189,7 @@ function parseAlternateNamesV2(v2File: string, cc: string, lines: string[]): Map
153
189
  * caller MUST rebuild `place_search` afterward (`buildPlaceSearchFTS(db, { drop: true })`) for the new names to reach
154
190
  * the candidate build's alias pass.
155
191
  */
156
- export function ingestGeonamesAliases(
192
+ export async function ingestGeonamesAliases(
157
193
  db: DatabaseSync,
158
194
  countries: string[],
159
195
  geonamesDir: string,
@@ -177,7 +213,7 @@ export function ingestGeonamesAliases(
177
213
  */
178
214
  alternateDir?: string
179
215
  }
180
- ): number {
216
+ ): Promise<number> {
181
217
  // Latin-only, no bracket/paren noise GeoNames packs into `alternatenames` ("(( Karis Landskommun ))",
182
218
  // airport codes), 2–60 chars, at least one letter (drops bare postcodes/numbers).
183
219
  const LATIN_NAME = /^[\p{Script=Latin}\p{M}\s\-'.]{2,60}$/u
@@ -222,6 +258,10 @@ export function ingestGeonamesAliases(
222
258
  let total = 0
223
259
  db.exec("BEGIN")
224
260
 
261
+ // #1514: clear the range before writing it. Inside the transaction, so a fold that throws leaves the
262
+ // DB as it found it rather than half-purged. See purgeGeonamesAliasRange for why the ids demand this.
263
+ purgeGeonamesAliasRange(db)
264
+
225
265
  for (const cc of countries) {
226
266
  const file = join(geonamesDir, `${cc}.txt`)
227
267
 
@@ -234,15 +274,43 @@ export function ingestGeonamesAliases(
234
274
  let nc = 0
235
275
  // #267: add A-class admin + ancestry only for the gap countries this country is in (never the EU set).
236
276
  const addAdmin = opts?.adminForCountries?.has(cc) ?? false
277
+ const v2File = opts?.alternateDir ? join(opts.alternateDir, `${cc}.txt`) : undefined
278
+ const readV2 = Boolean(v2File && existsSync(v2File))
279
+
280
+ // Survey pass. The dump is STREAMED — NO's is 71 MB and only the caller knows which country
281
+ // comes next — so what a later pass needs has to be collected here rather than re-scanned off a
282
+ // materialized array. Two things qualify, and both are small: the P-class geonameid set the V2
283
+ // decoration is restricted to, and the handful of A-class rows the admin pre-pass writes (one
284
+ // PCL* + the ADM1s). Everything else is re-read in the emit pass below. Neither is wanted
285
+ // without V2 tags or admin, and then the pass is skipped rather than read for nothing.
286
+ //
237
287
  // GeoNames dump columns (0-indexed): 0 geonameid, 1 name, 2 asciiname, 3 alternatenames, 4 lat, 5 lon,
238
288
  // 6 feature_class, 7 feature_code, 10 admin1 code, 14 pop.
239
- const lines = readFileSync(file, "utf8").split("\n")
289
+ //
290
+ // `header: false` — the dump is headerless, so row 1 is a place, not column names.
291
+ const wanted = new Set<number>()
292
+ const adminRows: string[][] = []
293
+
294
+ if (readV2 || addAdmin) {
295
+ for await (const f of TSVSpliterator.fromAsync(file, { header: false })) {
296
+ if (f[6] === "P") {
297
+ if (readV2) {
298
+ wanted.add(Number(f[0]))
299
+ }
300
+
301
+ continue
302
+ }
303
+
304
+ if (addAdmin && f[6] === "A" && (f[7]?.startsWith("PCL") || (f[7] === "ADM1" && f[10]))) {
305
+ adminRows.push(f)
306
+ }
307
+ }
308
+ }
240
309
 
241
310
  // #936: V2 tags for this country's P-class rows — geonameid → exact alias spelling → tag. The V2
242
311
  // dump repeats one spelling under several languages ("Åbo" sv/da/no); the merged tag is official /
243
312
  // preferred if ANY qualifying row is.
244
- const v2File = opts?.alternateDir ? join(opts.alternateDir, `${cc}.txt`) : undefined
245
- const v2 = v2File && existsSync(v2File) ? parseAlternateNamesV2(v2File, cc, lines) : undefined
313
+ const v2 = readV2 ? await parseAlternateNamesV2(v2File!, cc, wanted) : undefined
246
314
 
247
315
  // #267 admin pre-pass (gap countries): fold the country (PCLI) + regions (ADM1), self+ancestry them, and
248
316
  // build the admin1→region map the localities link through. Point bbox (GeoNames gives a centroid only).
@@ -250,10 +318,7 @@ export function ingestGeonamesAliases(
250
318
  const adminMap = new Map<string, number>()
251
319
 
252
320
  if (addAdmin) {
253
- for (const line of lines) {
254
- const f = line.split("\t")
255
-
256
- if (f[6] !== "A") continue
321
+ for (const f of adminRows) {
257
322
  const aname = clean(f[2] ?? "") ?? clean(f[1] ?? "")
258
323
 
259
324
  if (!aname) continue
@@ -287,10 +352,8 @@ export function ingestGeonamesAliases(
287
352
  }
288
353
  }
289
354
 
290
- for (const line of lines) {
291
- if (!line) continue
292
- const f = line.split("\t")
293
-
355
+ // Emit pass — a second stream over the same file, off the page cache the survey pass just warmed.
356
+ for await (const f of TSVSpliterator.fromAsync(file, { header: false })) {
294
357
  if (f[6] !== "P") continue // populated places only
295
358
  const lat = Number(f[4])
296
359
  const lon = Number(f[5])
@@ -308,9 +371,14 @@ export function ingestGeonamesAliases(
308
371
  sprInsert.run(nid, parentID, name, "locality", cc, lat, lon, lat, lon, lat, lon, 1, 0, 0, 0, 0, 0)
309
372
  namesInsert.run(nid, name, "locality", cc, "", "", 0, 0)
310
373
 
311
- if (addAdmin) {
312
- ancestorInsert.run(nid, nid, "locality")
374
+ // The SELF row is unconditional (#1514). `populateAncestors` writes one for every spr row it
375
+ // sees, so a full build's freeze phase produces it either way — but the freeze does not run on
376
+ // the fold-on-copy path, and since the purge now clears this range the fold has to emit
377
+ // everything the closure would. Measured on the 161-country fold: 212,993 self rows, exactly the
378
+ // non-gap countries' localities, which is what the base artifact carried and a fold-on-copy lost.
379
+ ancestorInsert.run(nid, nid, "locality")
313
380
 
381
+ if (addAdmin) {
314
382
  if (regionID >= 0) {
315
383
  ancestorInsert.run(nid, regionID, "region")
316
384
  }