@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.
- package/ancestry-backfill.ts +103 -64
- package/build-candidate.ts +230 -29
- package/candidate-importance.ts +227 -0
- package/candidate-lookup.ts +159 -9
- package/candidate-schema.ts +29 -0
- package/coverage-manifest-schema.ts +1 -1
- package/fst-autocomplete.ts +22 -11
- package/fst-builder.ts +76 -50
- package/fst-deserialize-web.ts +46 -8
- package/fst-freshness.ts +333 -0
- package/fst-serialize.ts +78 -13
- package/fst-types.ts +58 -1
- package/fts.ts +12 -0
- package/geo.ts +16 -41
- package/geonames-aliases.ts +105 -37
- package/geonames-postal.ts +65 -21
- package/index.ts +16 -1
- package/interpolation.ts +2 -1
- package/lookup.ts +44 -5
- package/out/ancestry-backfill.d.ts +37 -18
- package/out/ancestry-backfill.d.ts.map +1 -1
- package/out/ancestry-backfill.js +82 -52
- package/out/ancestry-backfill.js.map +1 -1
- package/out/build-candidate.d.ts +42 -0
- package/out/build-candidate.d.ts.map +1 -1
- package/out/build-candidate.js +141 -13
- package/out/build-candidate.js.map +1 -1
- package/out/candidate-importance.d.ts +132 -0
- package/out/candidate-importance.d.ts.map +1 -0
- package/out/candidate-importance.js +174 -0
- package/out/candidate-importance.js.map +1 -0
- package/out/candidate-lookup.d.ts +9 -1
- package/out/candidate-lookup.d.ts.map +1 -1
- package/out/candidate-lookup.js +127 -8
- package/out/candidate-lookup.js.map +1 -1
- package/out/candidate-schema.d.ts +25 -1
- package/out/candidate-schema.d.ts.map +1 -1
- package/out/candidate-schema.js +5 -0
- package/out/candidate-schema.js.map +1 -1
- package/out/coverage-manifest-schema.d.ts +1 -1
- package/out/coverage-manifest-schema.js +1 -1
- package/out/fst-autocomplete.d.ts +13 -3
- package/out/fst-autocomplete.d.ts.map +1 -1
- package/out/fst-autocomplete.js +10 -9
- package/out/fst-autocomplete.js.map +1 -1
- package/out/fst-builder.d.ts.map +1 -1
- package/out/fst-builder.js +64 -40
- package/out/fst-builder.js.map +1 -1
- package/out/fst-deserialize-web.d.ts.map +1 -1
- package/out/fst-deserialize-web.js +38 -8
- package/out/fst-deserialize-web.js.map +1 -1
- package/out/fst-freshness.d.ts +138 -0
- package/out/fst-freshness.d.ts.map +1 -0
- package/out/fst-freshness.js +238 -0
- package/out/fst-freshness.js.map +1 -0
- package/out/fst-serialize.d.ts +19 -3
- package/out/fst-serialize.d.ts.map +1 -1
- package/out/fst-serialize.js +68 -13
- package/out/fst-serialize.js.map +1 -1
- package/out/fst-types.d.ts +61 -1
- package/out/fst-types.d.ts.map +1 -1
- package/out/fts.d.ts +11 -0
- package/out/fts.d.ts.map +1 -1
- package/out/fts.js +11 -0
- package/out/fts.js.map +1 -1
- package/out/geo.d.ts +10 -13
- package/out/geo.d.ts.map +1 -1
- package/out/geo.js +15 -35
- package/out/geo.js.map +1 -1
- package/out/geonames-aliases.d.ts +23 -1
- package/out/geonames-aliases.d.ts.map +1 -1
- package/out/geonames-aliases.js +88 -33
- package/out/geonames-aliases.js.map +1 -1
- package/out/geonames-postal.d.ts +22 -1
- package/out/geonames-postal.d.ts.map +1 -1
- package/out/geonames-postal.js +50 -16
- package/out/geonames-postal.js.map +1 -1
- package/out/index.d.ts +3 -1
- package/out/index.d.ts.map +1 -1
- package/out/index.js +3 -1
- package/out/index.js.map +1 -1
- package/out/interpolation.d.ts.map +1 -1
- package/out/interpolation.js +2 -1
- package/out/interpolation.js.map +1 -1
- package/out/lookup.d.ts.map +1 -1
- package/out/lookup.js +37 -5
- package/out/lookup.js.map +1 -1
- package/out/place-importance-schema.d.ts +189 -0
- package/out/place-importance-schema.d.ts.map +1 -0
- package/out/place-importance-schema.js +242 -0
- package/out/place-importance-schema.js.map +1 -0
- package/out/poi-lookup.d.ts +1 -1
- package/out/poi-lookup.js +3 -3
- package/out/reverse.d.ts.map +1 -1
- package/out/reverse.js +3 -9
- package/out/reverse.js.map +1 -1
- package/out/sqlite-convention-source.d.ts.map +1 -1
- package/out/sqlite-convention-source.js +4 -3
- package/out/sqlite-convention-source.js.map +1 -1
- package/out/sqlite-utils.d.ts +12 -0
- package/out/sqlite-utils.d.ts.map +1 -1
- package/out/sqlite-utils.js +20 -0
- package/out/sqlite-utils.js.map +1 -1
- package/out/street-morphology-fst-builder.d.ts.map +1 -1
- package/out/street-morphology-fst-builder.js +7 -5
- package/out/street-morphology-fst-builder.js.map +1 -1
- package/out/street-normalize.d.ts +2 -1
- package/out/street-normalize.d.ts.map +1 -1
- package/out/street-normalize.js +2 -1
- package/out/street-normalize.js.map +1 -1
- package/out/types.d.ts +39 -0
- package/out/types.d.ts.map +1 -1
- package/package.json +24 -6
- package/place-importance-schema.ts +353 -0
- package/poi-lookup.ts +3 -3
- package/reverse.ts +4 -9
- package/sqlite-convention-source.ts +5 -3
- package/sqlite-utils.ts +21 -0
- package/street-morphology-fst-builder.ts +8 -5
- package/street-normalize.ts +3 -2
- 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
|
|
22
|
-
* u8 0..8
|
|
23
|
-
* (
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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.
|
|
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) *
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* the
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/geonames-aliases.ts
CHANGED
|
@@ -24,11 +24,14 @@
|
|
|
24
24
|
* (TSV).
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import { existsSync
|
|
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`)
|
|
112
|
+
* places (`P`) in `wanted`.
|
|
69
113
|
*/
|
|
70
|
-
function parseAlternateNamesV2(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
291
|
-
|
|
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
|
-
|
|
312
|
-
|
|
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
|
}
|