@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-builder.ts
CHANGED
|
@@ -13,9 +13,11 @@
|
|
|
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"
|
|
20
|
+
import { loadImportanceSplit } from "./place-importance-schema.ts"
|
|
19
21
|
|
|
20
22
|
const DEFAULT_PLACETYPES: PlacetypeID[] = [
|
|
21
23
|
"country",
|
|
@@ -29,6 +31,11 @@ const DEFAULT_PLACETYPES: PlacetypeID[] = [
|
|
|
29
31
|
|
|
30
32
|
const DEFAULT_COUNTRIES = ["US"]
|
|
31
33
|
const DEFAULT_LANGUAGES = ["eng", ""]
|
|
34
|
+
/**
|
|
35
|
+
* Ids per `IN (…)` batch. SQLITE_MAX_VARIABLE_NUMBER defaults to 32,766; 500 matches the name-load batch a few phases
|
|
36
|
+
* down, so both read paths bind the same shape.
|
|
37
|
+
*/
|
|
38
|
+
const ANCESTOR_CHUNK = 500
|
|
32
39
|
|
|
33
40
|
interface SprRow {
|
|
34
41
|
id: number
|
|
@@ -46,11 +53,6 @@ interface NameRow {
|
|
|
46
53
|
privateuse: string
|
|
47
54
|
}
|
|
48
55
|
|
|
49
|
-
interface PopulationRow {
|
|
50
|
-
id: number
|
|
51
|
-
population: number
|
|
52
|
-
}
|
|
53
|
-
|
|
54
56
|
export function buildFSTFromWOF(opts: BuildFSTOpts): {
|
|
55
57
|
matcher: FSTMatcher
|
|
56
58
|
provenance: FSTProvenance
|
|
@@ -89,19 +91,40 @@ export function buildFSTFromWOF(opts: BuildFSTOpts): {
|
|
|
89
91
|
// Also load parent rows that might be outside our placetype filter (e.g., country for region).
|
|
90
92
|
const parentStmt = db.prepare("SELECT id, name, placetype, parent_id, latitude, longitude FROM spr WHERE id = ?")
|
|
91
93
|
|
|
92
|
-
// Fallback
|
|
93
|
-
|
|
94
|
+
// Fallback for a sentinel parent_id (-1, -4, …): the ancestors table. Read in chunked `IN (…)`
|
|
95
|
+
// batches ONCE — the point-query version fired per orphan row, and on a global build the orphans
|
|
96
|
+
// run to six figures. Ordering is county → region → country, preserved by the same CASE the
|
|
97
|
+
// per-row query used, with `id` leading so one pass groups the rows.
|
|
98
|
+
const ancestorsByID = new Map<number, number[]>()
|
|
94
99
|
|
|
95
100
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
101
|
+
const orphanIDs = sprRows.filter((row) => row.parent_id <= 0).map((row) => row.id)
|
|
102
|
+
|
|
103
|
+
for (let i = 0; i < orphanIDs.length; i += ANCESTOR_CHUNK) {
|
|
104
|
+
const chunk = orphanIDs.slice(i, i + ANCESTOR_CHUNK)
|
|
105
|
+
|
|
106
|
+
const rows = db
|
|
107
|
+
.prepare(
|
|
108
|
+
`SELECT DISTINCT id, ancestor_id FROM ancestors
|
|
109
|
+
WHERE id IN (${chunk.map(() => "?").join(",")}) AND ancestor_placetype IN ('country', 'region', 'county')
|
|
110
|
+
ORDER BY id, CASE ancestor_placetype
|
|
111
|
+
WHEN 'county' THEN 1
|
|
112
|
+
WHEN 'region' THEN 2
|
|
113
|
+
WHEN 'country' THEN 3
|
|
114
|
+
END`
|
|
115
|
+
)
|
|
116
|
+
.all(...chunk) as unknown as Array<{ id: number; ancestor_id: number }>
|
|
117
|
+
|
|
118
|
+
for (const row of rows) {
|
|
119
|
+
let chain = ancestorsByID.get(row.id)
|
|
120
|
+
|
|
121
|
+
if (!chain) {
|
|
122
|
+
ancestorsByID.set(row.id, (chain = []))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
chain.push(row.ancestor_id)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
105
128
|
} catch {
|
|
106
129
|
progress("ancestors", "No ancestors table — sentinel parent_ids will produce empty chains")
|
|
107
130
|
}
|
|
@@ -112,10 +135,8 @@ export function buildFSTFromWOF(opts: BuildFSTOpts): {
|
|
|
112
135
|
if (!row) return []
|
|
113
136
|
|
|
114
137
|
// If parent_id is a sentinel (≤ 0), use ancestors table.
|
|
115
|
-
if (row.parent_id <= 0
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
return ancestors.map((a) => a.ancestor_id).filter((aid) => aid !== id)
|
|
138
|
+
if (row.parent_id <= 0) {
|
|
139
|
+
return (ancestorsByID.get(id) ?? []).filter((ancestorID) => ancestorID !== id)
|
|
119
140
|
}
|
|
120
141
|
|
|
121
142
|
// Normal case: walk parent_id chain.
|
|
@@ -146,35 +167,22 @@ export function buildFSTFromWOF(opts: BuildFSTOpts): {
|
|
|
146
167
|
return chain
|
|
147
168
|
}
|
|
148
169
|
|
|
149
|
-
// Phase 3: Load
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
for (const row of impRows) {
|
|
159
|
-
importanceMap.set(row.id, row.importance)
|
|
160
|
-
}
|
|
170
|
+
// Phase 3: Load BOTH scores (ROAD_TO_V9 §2 R1, the two-score split).
|
|
171
|
+
//
|
|
172
|
+
// Referential is ALWAYS population-anchored and never read out of a legacy `place_importance`
|
|
173
|
+
// column, because a legacy row that got a Wikipedia score overwrote whatever population would have
|
|
174
|
+
// said and the two are indistinguishable afterwards. Encyclopedic rides along for consumers and is
|
|
175
|
+
// never handed to the decoder. `loadImportanceSplit` handles all four schema generations; the
|
|
176
|
+
// source it reports is stamped into provenance so an artifact says which one it read.
|
|
177
|
+
progress("importance", "Loading referential + encyclopedic scores")
|
|
178
|
+
const split = loadImportanceSplit(db)
|
|
161
179
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const popRows = popStmt.all() as unknown as PopulationRow[]
|
|
169
|
-
|
|
170
|
-
for (const row of popRows) {
|
|
171
|
-
const normalized = row.population > 0 ? Math.min(1, Math.log2(1 + row.population / 1000) / 14) : 0
|
|
172
|
-
importanceMap.set(row.id, normalized)
|
|
173
|
-
}
|
|
174
|
-
} catch {
|
|
175
|
-
progress("importance", "No place_population either — using 0 for all")
|
|
176
|
-
}
|
|
177
|
-
}
|
|
180
|
+
progress(
|
|
181
|
+
"importance",
|
|
182
|
+
`${split.referential.size} referential, ${split.encyclopedic.size} encyclopedic (source: ${split.source}` +
|
|
183
|
+
(split.legacyFallbackRows ? `, ${split.legacyFallbackRows} legacy rows attributed to the population pass` : "") +
|
|
184
|
+
")"
|
|
185
|
+
)
|
|
178
186
|
|
|
179
187
|
// Phase 4: Load names for matching places.
|
|
180
188
|
progress("names", "Loading name variants")
|
|
@@ -279,12 +287,17 @@ export function buildFSTFromWOF(opts: BuildFSTOpts): {
|
|
|
279
287
|
for (const row of sprRows) {
|
|
280
288
|
const parentChain = resolveParentChain(row.id)
|
|
281
289
|
|
|
290
|
+
const encyclopedic = split.encyclopedic.get(row.id)
|
|
291
|
+
|
|
282
292
|
const entry: PlaceEntry = {
|
|
283
293
|
wofID: row.id,
|
|
284
294
|
placetype: row.placetype as PlacetypeID,
|
|
285
295
|
name: row.name,
|
|
286
296
|
parentChain,
|
|
287
|
-
|
|
297
|
+
referential: split.referential.get(row.id) ?? 0,
|
|
298
|
+
// Spread rather than assigned: a place with no Wikipedia article must carry NO field, not a
|
|
299
|
+
// zero. The serializer's per-place presence bit reads `!== undefined`.
|
|
300
|
+
...(encyclopedic === undefined ? {} : { encyclopedic }),
|
|
288
301
|
lat: row.latitude,
|
|
289
302
|
lon: row.longitude,
|
|
290
303
|
}
|
|
@@ -320,6 +333,15 @@ export function buildFSTFromWOF(opts: BuildFSTOpts): {
|
|
|
320
333
|
const edgeCount = nodes.reduce((sum, n) => sum + n.edges.size, 0)
|
|
321
334
|
const matcher = FSTMatcher.fromNodes(nodes)
|
|
322
335
|
|
|
336
|
+
// The build stamp (2026-08-05). `sourceDB` alone was never enough to tell a reader whether this
|
|
337
|
+
// artifact matches the database at that path — the admin DB is sealed and REPLACED by a rebuild, so
|
|
338
|
+
// the path is constant across every generation of it. Hashing costs 7.3 s for the 5.27 GB admin DB
|
|
339
|
+
// and is free whenever the `.md5` sidecar is current, which the admin build already writes.
|
|
340
|
+
// `sourceIdentity` lets a caller that already knows the digest (or is building from something that
|
|
341
|
+
// is not a file at all) supply it instead.
|
|
342
|
+
progress("stamp", `Reading source identity for ${opts.dbPath}`)
|
|
343
|
+
const source = opts.sourceIdentity ?? readWOFSourceIdentity(opts.dbPath)
|
|
344
|
+
|
|
323
345
|
const provenance: FSTProvenance = {
|
|
324
346
|
builtAt: new Date().toISOString(),
|
|
325
347
|
countries,
|
|
@@ -327,8 +349,12 @@ export function buildFSTFromWOF(opts: BuildFSTOpts): {
|
|
|
327
349
|
placeCount: sprRows.length,
|
|
328
350
|
edgeCount,
|
|
329
351
|
nameInsertions: insertCount,
|
|
330
|
-
importanceMatches:
|
|
352
|
+
importanceMatches: split.referential.size,
|
|
353
|
+
encyclopedicMatches: split.encyclopedic.size,
|
|
354
|
+
importanceSource: split.source,
|
|
331
355
|
sourceDB: opts.dbPath,
|
|
356
|
+
sourceDBMD5: source.md5,
|
|
357
|
+
sourceDBBytes: source.bytes,
|
|
332
358
|
...(excludeSurfaces !== undefined || excludeAllTokensOf !== undefined
|
|
333
359
|
? { exclusionPolicy: opts.exclusionPolicy ?? "unspecified", excludedInsertions: excludedCount }
|
|
334
360
|
: {}),
|
package/fst-deserialize-web.ts
CHANGED
|
@@ -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"
|
|
@@ -37,17 +39,44 @@ const VERSION_WITH_METADATA = 3
|
|
|
37
39
|
|
|
38
40
|
const HEADER_SIZE = 32
|
|
39
41
|
const EDGE_ENTRY_SIZE = 8
|
|
40
|
-
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Format version that split the single `importance` float into `referential` + `encyclopedic` (ROAD_TO_V9 §2 R1),
|
|
45
|
+
* growing the place entry from 56 to 60 bytes. Mirrors `fst-serialize.ts`'s constant of the same name.
|
|
46
|
+
*/
|
|
47
|
+
const VERSION_TWO_SCORE_SPLIT = 5
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Place-entry size in bytes at or above {@link VERSION_TWO_SCORE_SPLIT}.
|
|
51
|
+
*/
|
|
52
|
+
const SPLIT_PLACE_ENTRY_SIZE = 60
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Place-entry size in bytes below {@link VERSION_TWO_SCORE_SPLIT}.
|
|
56
|
+
*/
|
|
57
|
+
const LEGACY_PLACE_ENTRY_SIZE = 56
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Byte offset of the encyclopedic float inside a v5 place entry — immediately after the 8-slot parent chain.
|
|
61
|
+
*/
|
|
62
|
+
const ENCYCLOPEDIC_OFFSET = 56
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `placeFlags` bit 0 (byte `pp+7`, v5+): this place carries an encyclopedic score. Per-place because absence is the
|
|
66
|
+
* common case; see `fst-serialize.ts`.
|
|
67
|
+
*/
|
|
68
|
+
const PLACE_FLAG_HAS_ENCYCLOPEDIC = 1
|
|
41
69
|
/**
|
|
42
70
|
* "FST\0".
|
|
43
71
|
*/
|
|
44
72
|
const MAGIC_BYTES = [0x46, 0x53, 0x54, 0x00]
|
|
45
73
|
/**
|
|
46
|
-
* Must track the serializer's VERSION (fst-serialize.ts, currently
|
|
74
|
+
* Must track the serializer's VERSION (fst-serialize.ts, currently 5). The v3 provenance + v4 16-byte-state/u32-count
|
|
47
75
|
* layout logic below already matches the Node deserializer; only this gate was left stale at 2, so the browser FST
|
|
48
|
-
* loader rejected every real (v4) artifact.
|
|
76
|
+
* loader rejected every real (v4) artifact. It was left stale again at 4 by the v5 two-score split until this line
|
|
77
|
+
* moved with it — the gate is a SEPARATE number from the layout branches, which is exactly why it keeps drifting.
|
|
49
78
|
*/
|
|
50
|
-
const MAX_VERSION =
|
|
79
|
+
const MAX_VERSION = 5
|
|
51
80
|
|
|
52
81
|
const PLACETYPE_ORDER: readonly PlacetypeID[] = [
|
|
53
82
|
"country",
|
|
@@ -86,6 +115,7 @@ export function deserializeFSTWeb(input: ArrayBuffer | Uint8Array): FSTMatcher {
|
|
|
86
115
|
}
|
|
87
116
|
|
|
88
117
|
const isV2 = version >= 2
|
|
118
|
+
const isSplit = version >= VERSION_TWO_SCORE_SPLIT
|
|
89
119
|
// flags bit0 (survey #4, mirrors fst-serialize.ts): place rows carry surface-ambiguity data.
|
|
90
120
|
const hasAmbiguity = (view.getUint16(6, true) & 1) === 1
|
|
91
121
|
|
|
@@ -118,6 +148,8 @@ export function deserializeFSTWeb(input: ArrayBuffer | Uint8Array): FSTMatcher {
|
|
|
118
148
|
|
|
119
149
|
// --- State table ---
|
|
120
150
|
const stateEntrySize = version >= VERSION_WIDE_STATE_COUNTERS ? WIDE_STATE_ENTRY_SIZE : NARROW_STATE_ENTRY_SIZE
|
|
151
|
+
// v5 grew the place entry by the encyclopedic float; v4-and-below files are read at the old stride.
|
|
152
|
+
const placeEntrySize = isSplit ? SPLIT_PLACE_ENTRY_SIZE : LEGACY_PLACE_ENTRY_SIZE
|
|
121
153
|
const stateTableStart = pos
|
|
122
154
|
const edgeTableStart = stateTableStart + stateCount * stateEntrySize
|
|
123
155
|
const placeTableStart = edgeTableStart + edgeCount * EDGE_ENTRY_SIZE
|
|
@@ -147,7 +179,7 @@ export function deserializeFSTWeb(input: ArrayBuffer | Uint8Array): FSTMatcher {
|
|
|
147
179
|
const places: PlaceEntry[] = new Array(placeCountForState)
|
|
148
180
|
|
|
149
181
|
for (let pi = 0; pi < placeCountForState; pi++) {
|
|
150
|
-
const pp = placeTableStart + (placeStart + pi) *
|
|
182
|
+
const pp = placeTableStart + (placeStart + pi) * placeEntrySize
|
|
151
183
|
const chainLen = view.getUint8(pp + 5)
|
|
152
184
|
const parentChain: number[] = []
|
|
153
185
|
|
|
@@ -155,19 +187,25 @@ export function deserializeFSTWeb(input: ArrayBuffer | Uint8Array): FSTMatcher {
|
|
|
155
187
|
parentChain.push(view.getUint32(pp + 24 + ci * 4, true))
|
|
156
188
|
}
|
|
157
189
|
|
|
158
|
-
|
|
190
|
+
// v1 stored a raw population u32 here; v2-v4 the conflated `importance` float; v5 the
|
|
191
|
+
// referential score. See the Node deserializer for why a v1 value is genuinely referential.
|
|
192
|
+
const referential = isV2
|
|
159
193
|
? view.getFloat32(pp + 12, true)
|
|
160
194
|
: Math.min(1, Math.log2(1 + view.getUint32(pp + 12, true) / 1000) / 14)
|
|
161
195
|
|
|
196
|
+
const hasEncyclopedic =
|
|
197
|
+
isSplit && (view.getUint8(pp + 7) & PLACE_FLAG_HAS_ENCYCLOPEDIC) === PLACE_FLAG_HAS_ENCYCLOPEDIC
|
|
198
|
+
|
|
162
199
|
places[pi] = {
|
|
163
200
|
wofID: view.getUint32(pp, true),
|
|
164
201
|
placetype: PLACETYPE_ORDER[view.getUint8(pp + 4)] ?? "locality",
|
|
165
202
|
name: strings[view.getUint32(pp + 8, true)]!,
|
|
166
|
-
|
|
203
|
+
referential,
|
|
167
204
|
lat: view.getFloat32(pp + 16, true),
|
|
168
205
|
lon: view.getFloat32(pp + 20, true),
|
|
169
206
|
parentChain,
|
|
170
207
|
...(hasAmbiguity ? { crossCountryBranches: view.getUint8(pp + 6) } : {}),
|
|
208
|
+
...(hasEncyclopedic ? { encyclopedic: view.getFloat32(pp + ENCYCLOPEDIC_OFFSET, true) } : {}),
|
|
171
209
|
}
|
|
172
210
|
}
|
|
173
211
|
|
|
@@ -194,7 +232,7 @@ export function readFSTProvenanceWeb(input: ArrayBuffer | Uint8Array): FSTProven
|
|
|
194
232
|
const jsonLen = view.getUint32(provenanceOffset, true)
|
|
195
233
|
const jsonStr = decoder.decode(bytes.subarray(provenanceOffset + 4, provenanceOffset + 4 + jsonLen))
|
|
196
234
|
|
|
197
|
-
return
|
|
235
|
+
return tryParsingJSON<FSTProvenance>(jsonStr) ?? undefined
|
|
198
236
|
} catch {
|
|
199
237
|
return undefined
|
|
200
238
|
}
|
package/fst-freshness.ts
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Does an `fst-*.bin` still match the gazetteer it was built from?
|
|
7
|
+
*
|
|
8
|
+
* WHY THIS EXISTS. Every FST artifact is a projection of one WOF admin database, and the admin
|
|
9
|
+
* database is a sealed readonly artifact that a rebuild REPLACES. Nothing mechanically tied the two
|
|
10
|
+
* together: the 2026-08-04 admin swap (4.87M rows, ancestry repaired, macrohood/microhood ingested)
|
|
11
|
+
* left `fst-global-priority.bin` at its 2026-05-28 build and the per-locale set at 2026-07-26, and
|
|
12
|
+
* the only way to notice was to compare mtimes by hand. The artifacts kept loading, kept answering
|
|
13
|
+
* queries, and answered them from a gazetteer that no longer exists.
|
|
14
|
+
*
|
|
15
|
+
* `FSTProvenance` already recorded `sourceDB` — the source's PATH, which is exactly the field that
|
|
16
|
+
* cannot change when the bytes behind it do. So the stamp gains the source's IDENTITY (md5 + byte
|
|
17
|
+
* size) and this module compares it. The shape deliberately mirrors
|
|
18
|
+
* `scripts/weights-overlay-linker.ts`'s `pairIndexStaleReason`: one function returning a reason
|
|
19
|
+
* string or `undefined`, so a fact added to the stamp cannot be checked by some callers and not
|
|
20
|
+
* others — which is how three of the four base linkers ended up unable to notice a PIX1 schema bump.
|
|
21
|
+
*
|
|
22
|
+
* FORMAT IS PART OF FRESHNESS. The check compares the serializer version too, not just the source
|
|
23
|
+
* md5. A guard that checks only the source reads a format-obsolete binary as "current" (the R5
|
|
24
|
+
* freshness-guard lesson, format edition), and a file below {@link MIN_STAMPED_FORMAT_VERSION}
|
|
25
|
+
* cannot carry a stamp at all — reported as its own reason rather than silently passing.
|
|
26
|
+
*
|
|
27
|
+
* STALE IS A WARNING, NOT A FAULT. A dev tree with an old FST must still run; the artifact is a
|
|
28
|
+
* decode-time bias list, not a correctness dependency. Callers print {@link formatFSTStaleWarning}
|
|
29
|
+
* and continue.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { createHash } from "node:crypto"
|
|
33
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync, statSync, writeFileSync } from "node:fs"
|
|
34
|
+
|
|
35
|
+
import { tryParsingJSON } from "@mailwoman/core/objects"
|
|
36
|
+
|
|
37
|
+
import { FST_FORMAT_VERSION } from "./fst-serialize.ts"
|
|
38
|
+
import type { FSTProvenance } from "./fst-types.ts"
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Fixed header size in bytes — mirrors `fst-serialize.ts`'s `HEADER_SIZE`. Duplicated rather than exported across
|
|
42
|
+
* because this module reads the header by SEEK (never buffering the file), and the serializer's constant is private to
|
|
43
|
+
* its own read/write pair.
|
|
44
|
+
*/
|
|
45
|
+
const HEADER_SIZE = 32
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `"FST\0"` little-endian, the four magic bytes every artifact opens with.
|
|
49
|
+
*/
|
|
50
|
+
const MAGIC = 0x00_54_53_46
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Byte offset of the u32 trailer offset inside the header (the last of its eight fields).
|
|
54
|
+
*/
|
|
55
|
+
const PROVENANCE_OFFSET_FIELD = 28
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* First serializer version carrying the trailing provenance block. Below this a file has no place to put a stamp, so
|
|
59
|
+
* "unstamped" is a statement about the FORMAT, not about the builder.
|
|
60
|
+
*/
|
|
61
|
+
export const MIN_STAMPED_FORMAT_VERSION = 3
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Hex characters in an md5 digest.
|
|
65
|
+
*/
|
|
66
|
+
const MD5_HEX_LENGTH = 32
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Bytes per read in {@link md5FileSync}. 8 MiB measured at 7.3 s for the 5.27 GB admin DB on the lab playpen — the same
|
|
70
|
+
* throughput as `md5sum(1)`, so the sidecar below is what makes the check cheap, not the chunk size.
|
|
71
|
+
*/
|
|
72
|
+
const MD5_CHUNK_BYTES = 8 * 1024 * 1024
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* What an FST was built from, recorded so a later reader can tell whether that thing still exists.
|
|
76
|
+
*
|
|
77
|
+
* `bytes` is not redundant with `md5` — it is the field that survives a truncated or half-written source and makes the
|
|
78
|
+
* mismatch legible in the warning ("5,273,722,880 → 5,372,076,032" names the rebuild; a hex delta does not).
|
|
79
|
+
*/
|
|
80
|
+
export interface FSTSourceIdentity {
|
|
81
|
+
md5: string
|
|
82
|
+
bytes: number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The stamp fields a freshness guard reads off an artifact, plus the format version it was written at.
|
|
87
|
+
*
|
|
88
|
+
* Every field is optional because every one of them can be legitimately absent on an artifact that predates it, and the
|
|
89
|
+
* meaning-of-zero rule applies to all of them: absent is a distinct state from "matches", and the reasons below say so
|
|
90
|
+
* in different words.
|
|
91
|
+
*/
|
|
92
|
+
export interface FSTStampFields {
|
|
93
|
+
formatVersion: number
|
|
94
|
+
provenance: FSTProvenance | undefined
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* What a caller expects the artifact to have been built from.
|
|
99
|
+
*
|
|
100
|
+
* `exclusionPolicy` is optional and caller-supplied on purpose. The policy id lives in
|
|
101
|
+
* `mailwoman/gazetteer-pipeline/fst.ts` (which depends on this package, not the other way round), so only the caller
|
|
102
|
+
* knows which policy it means — the same split as the pair-index guard, where the format+magnitude half is shared and
|
|
103
|
+
* the source-md5 half stays with the script that knows its sources.
|
|
104
|
+
*/
|
|
105
|
+
export interface FSTExpectation {
|
|
106
|
+
source: FSTSourceIdentity
|
|
107
|
+
/**
|
|
108
|
+
* Serializer version to require. Defaults to {@link FST_FORMAT_VERSION} — the version this tree writes — so a caller
|
|
109
|
+
* cannot forget the format half of the comparison. Override only to check against a specific older floor.
|
|
110
|
+
*/
|
|
111
|
+
formatVersion?: number
|
|
112
|
+
exclusionPolicy?: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Read an artifact's stamp WITHOUT deserializing it — a header seek plus the trailer, three reads totalling a few
|
|
117
|
+
* kilobytes. The distinction matters: `fst-global-priority.bin` is 317 MB and this runs on every `yarn test` via the
|
|
118
|
+
* weights linkers, so `readFileSync` + `readFSTProvenance` would trade a freshness guard for a slower test suite and
|
|
119
|
+
* nobody would keep it.
|
|
120
|
+
*
|
|
121
|
+
* Returns `undefined` for a file that is absent, too small, or not an FST at all — none of which is this function's
|
|
122
|
+
* business to diagnose.
|
|
123
|
+
*/
|
|
124
|
+
export function peekFSTStampFields(path: string): FSTStampFields | undefined {
|
|
125
|
+
if (!existsSync(path)) return undefined
|
|
126
|
+
const size = statSync(path).size
|
|
127
|
+
|
|
128
|
+
if (size < HEADER_SIZE) return undefined
|
|
129
|
+
const fd = openSync(path, "r")
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const header = Buffer.alloc(HEADER_SIZE)
|
|
133
|
+
readSync(fd, header, 0, HEADER_SIZE, 0)
|
|
134
|
+
|
|
135
|
+
if (header.readUInt32LE(0) !== MAGIC) return undefined
|
|
136
|
+
const formatVersion = header.readUInt16LE(4)
|
|
137
|
+
|
|
138
|
+
if (formatVersion < MIN_STAMPED_FORMAT_VERSION) return { formatVersion, provenance: undefined }
|
|
139
|
+
const trailerStart = header.readUInt32LE(PROVENANCE_OFFSET_FIELD)
|
|
140
|
+
|
|
141
|
+
// 0 = "this build wrote no trailer" (the serializer's own encoding); anything past EOF is a
|
|
142
|
+
// truncated file. Both read as "no stamp", which is what the caller does with them anyway.
|
|
143
|
+
if (trailerStart === 0 || trailerStart + 4 > size) return { formatVersion, provenance: undefined }
|
|
144
|
+
|
|
145
|
+
const lengthBytes = Buffer.alloc(4)
|
|
146
|
+
readSync(fd, lengthBytes, 0, 4, trailerStart)
|
|
147
|
+
const jsonLength = lengthBytes.readUInt32LE(0)
|
|
148
|
+
|
|
149
|
+
if (jsonLength === 0 || trailerStart + 4 + jsonLength > size) return { formatVersion, provenance: undefined }
|
|
150
|
+
|
|
151
|
+
const json = Buffer.alloc(jsonLength)
|
|
152
|
+
readSync(fd, json, 0, jsonLength, trailerStart + 4)
|
|
153
|
+
|
|
154
|
+
return { formatVersion, provenance: tryParsingJSON<FSTProvenance>(json.toString("utf8")) ?? undefined }
|
|
155
|
+
} finally {
|
|
156
|
+
closeSync(fd)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Streaming MD5 of a file, SYNCHRONOUS.
|
|
162
|
+
*
|
|
163
|
+
* The async `md5File` in `@mailwoman/core/utils` is the one to reach for anywhere else. This exists because the FST
|
|
164
|
+
* builder and its whole call chain are synchronous by design (`buildFSTFromWOF` → `buildLocaleFSTs`), and making them
|
|
165
|
+
* async to stamp a checksum would cascade through the Pastel commands and the tests for one hash. It reads in
|
|
166
|
+
* {@link MD5_CHUNK_BYTES} chunks rather than `readFileSync` — the source is a multi-gigabyte database.
|
|
167
|
+
*/
|
|
168
|
+
export function md5FileSync(path: string): string {
|
|
169
|
+
const hash = createHash("md5")
|
|
170
|
+
const fd = openSync(path, "r")
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const chunk = Buffer.alloc(MD5_CHUNK_BYTES)
|
|
174
|
+
|
|
175
|
+
for (;;) {
|
|
176
|
+
const read = readSync(fd, chunk, 0, MD5_CHUNK_BYTES, null)
|
|
177
|
+
|
|
178
|
+
if (read <= 0) break
|
|
179
|
+
hash.update(chunk.subarray(0, read))
|
|
180
|
+
}
|
|
181
|
+
} finally {
|
|
182
|
+
closeSync(fd)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return hash.digest("hex")
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The source identity an FST build should stamp, or a check should compare against.
|
|
190
|
+
*
|
|
191
|
+
* Uses the `.md5` sidecar convention the weights linkers already established on this exact file: `<path>.md5` in
|
|
192
|
+
* md5sum(1) format (`<hash> <filename>`), trusted only while its mtime is at least the source's. An older sidecar is
|
|
193
|
+
* recomputed. Without it the admin DB costs 7.3 s per call and the guard would be quietly disabled by whoever noticed
|
|
194
|
+
* `yarn test` got slower.
|
|
195
|
+
*
|
|
196
|
+
* `refreshSidecar` writes the recomputed digest back. Best-effort: a sealed data root or a read-only mount fails the
|
|
197
|
+
* write and the caller still gets its answer, because refusing to check freshness on a read-only tree would be the
|
|
198
|
+
* wrong trade.
|
|
199
|
+
*/
|
|
200
|
+
export function readWOFSourceIdentity(path: string, { refreshSidecar = true } = {}): FSTSourceIdentity {
|
|
201
|
+
const stats = statSync(path)
|
|
202
|
+
const memoKey = `${path}\0${stats.mtimeMs}\0${stats.size}`
|
|
203
|
+
const hit = sourceIdentityMemo.get(memoKey)
|
|
204
|
+
|
|
205
|
+
if (hit) return hit
|
|
206
|
+
const sidecarPath = `${path}.md5`
|
|
207
|
+
let md5: string | undefined
|
|
208
|
+
|
|
209
|
+
if (existsSync(sidecarPath)) {
|
|
210
|
+
const sidecarStats = statSync(sidecarPath)
|
|
211
|
+
|
|
212
|
+
if (sidecarStats.mtimeMs >= stats.mtimeMs) {
|
|
213
|
+
const [hash] = readFileSync(sidecarPath, "utf8").trim().split(/\s+/)
|
|
214
|
+
|
|
215
|
+
if (hash && hash.length === MD5_HEX_LENGTH) {
|
|
216
|
+
md5 = hash
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!md5) {
|
|
222
|
+
md5 = md5FileSync(path)
|
|
223
|
+
|
|
224
|
+
if (refreshSidecar) {
|
|
225
|
+
try {
|
|
226
|
+
writeFileSync(sidecarPath, `${md5} ${path.split("/").pop()}\n`)
|
|
227
|
+
} catch {
|
|
228
|
+
// Read-only data root — the digest is still correct, it just isn't cached.
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const identity: FSTSourceIdentity = { md5, bytes: stats.size }
|
|
234
|
+
sourceIdentityMemo.set(memoKey, identity)
|
|
235
|
+
|
|
236
|
+
return identity
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Memo for {@link readWOFSourceIdentity}, keyed on (path, mtimeMs, size) — NOT on path alone, for the same reason
|
|
241
|
+
* `computeSurfaceCountryCounts` isn't: the admin DB is a sealed artifact that a rebuild REPLACES, so a path-only memo
|
|
242
|
+
* would serve a stale digest against a new file for the life of the process.
|
|
243
|
+
*/
|
|
244
|
+
const sourceIdentityMemo = new Map<string, FSTSourceIdentity>()
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Why an FST artifact is stale against `expected`, or `undefined` when it still matches.
|
|
248
|
+
*
|
|
249
|
+
* The order is deliberate: FORMAT first (a version-obsolete file is stale whatever its source says), then the presence
|
|
250
|
+
* of a stamp, then the source identity, then the build policy. Each returns prose a reader can act on — the reasons are
|
|
251
|
+
* printed verbatim into {@link formatFSTStaleWarning}.
|
|
252
|
+
*/
|
|
253
|
+
export function fstStaleReason(fields: FSTStampFields | undefined, expected: FSTExpectation): string | undefined {
|
|
254
|
+
if (!fields) return "unreadable or not an FST artifact"
|
|
255
|
+
const requiredFormat = expected.formatVersion ?? FST_FORMAT_VERSION
|
|
256
|
+
|
|
257
|
+
if (fields.formatVersion < requiredFormat) {
|
|
258
|
+
return `format v${fields.formatVersion} → v${requiredFormat}`
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (fields.formatVersion < MIN_STAMPED_FORMAT_VERSION) {
|
|
262
|
+
return `format v${fields.formatVersion} predates the build stamp (needs v${MIN_STAMPED_FORMAT_VERSION}+)`
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const provenance = fields.provenance
|
|
266
|
+
|
|
267
|
+
if (!provenance) return "carries no build stamp"
|
|
268
|
+
|
|
269
|
+
if (!provenance.sourceDBMD5) {
|
|
270
|
+
return `built ${provenance.builtAt} with no source checksum — rebuild to stamp one`
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (provenance.sourceDBMD5 !== expected.source.md5) {
|
|
274
|
+
return `source db ${provenance.sourceDBMD5.slice(0, 8)} → ${expected.source.md5.slice(0, 8)} (built ${provenance.builtAt})`
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Reached only when the md5s agree, so a size disagreement means one of the two was recorded
|
|
278
|
+
// against a different file than it was hashed from. Cheap to check, and it never fires by accident.
|
|
279
|
+
if (provenance.sourceDBBytes !== undefined && provenance.sourceDBBytes !== expected.source.bytes) {
|
|
280
|
+
return `source db size ${provenance.sourceDBBytes} → ${expected.source.bytes} at a matching md5 — one of the two is misrecorded`
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (expected.exclusionPolicy !== undefined && provenance.exclusionPolicy !== expected.exclusionPolicy) {
|
|
284
|
+
return `exclusion policy ${provenance.exclusionPolicy ?? "(none)"} → ${expected.exclusionPolicy}`
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return undefined
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The whole check, for a caller that has a path and a source DB and wants a warning string or nothing.
|
|
292
|
+
*
|
|
293
|
+
* Returns `undefined` when the artifact is current OR when it is absent — an absent artifact is a different problem
|
|
294
|
+
* with a different message, and every existing caller already reports it in place.
|
|
295
|
+
*/
|
|
296
|
+
export function fstFreshnessWarning({
|
|
297
|
+
fstPath,
|
|
298
|
+
sourceDBPath,
|
|
299
|
+
formatVersion,
|
|
300
|
+
exclusionPolicy,
|
|
301
|
+
rebuildCommand,
|
|
302
|
+
}: {
|
|
303
|
+
fstPath: string
|
|
304
|
+
sourceDBPath: string
|
|
305
|
+
formatVersion?: number
|
|
306
|
+
exclusionPolicy?: string
|
|
307
|
+
rebuildCommand: string
|
|
308
|
+
}): string | undefined {
|
|
309
|
+
if (!existsSync(fstPath) || !existsSync(sourceDBPath)) return undefined
|
|
310
|
+
|
|
311
|
+
const reason = fstStaleReason(peekFSTStampFields(fstPath), {
|
|
312
|
+
source: readWOFSourceIdentity(sourceDBPath),
|
|
313
|
+
...(formatVersion === undefined ? {} : { formatVersion }),
|
|
314
|
+
...(exclusionPolicy === undefined ? {} : { exclusionPolicy }),
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
return reason === undefined ? undefined : formatFSTStaleWarning({ fstPath, reason, rebuildCommand })
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The one warning format, so `grep -r "FST STALE"` finds every site that can emit one.
|
|
322
|
+
*/
|
|
323
|
+
export function formatFSTStaleWarning({
|
|
324
|
+
fstPath,
|
|
325
|
+
reason,
|
|
326
|
+
rebuildCommand,
|
|
327
|
+
}: {
|
|
328
|
+
fstPath: string
|
|
329
|
+
reason: string
|
|
330
|
+
rebuildCommand: string
|
|
331
|
+
}): string {
|
|
332
|
+
return `WARNING: FST STALE — ${fstPath}: ${reason}. Rebuild with: ${rebuildCommand}`
|
|
333
|
+
}
|