@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-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: use ancestors table when parent_id is a sentinel (-1, -4, etc.).
93
- let ancestorStmt: ReturnType<typeof db.prepare> | null = null
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
- ancestorStmt = db.prepare(
97
- `SELECT DISTINCT ancestor_id FROM ancestors
98
- WHERE id = ? AND ancestor_placetype IN ('country', 'region', 'county')
99
- ORDER BY CASE ancestor_placetype
100
- WHEN 'county' THEN 1
101
- WHEN 'region' THEN 2
102
- WHEN 'country' THEN 3
103
- END`
104
- )
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 && ancestorStmt) {
116
- const ancestors = ancestorStmt.all(id) as unknown as Array<{ ancestor_id: number }>
117
-
118
- return ancestors.map((a) => a.ancestor_id).filter((aid) => aid !== id)
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 importance data (Wikipedia-based, falls back to population-scaled).
150
- // See docs/articles/concepts/importance-vs-population.md for the two-signal contract.
151
- progress("importance", "Loading importance data")
152
- const importanceMap = new Map<number, number>()
153
-
154
- try {
155
- const impStmt = db.prepare("SELECT id, importance FROM place_importance")
156
- const impRows = impStmt.all() as unknown as Array<{ id: number; importance: number }>
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
- progress("importance", `Loaded ${importanceMap.size} importance scores`)
163
- } catch {
164
- progress("importance", "No place_importance table — falling back to population")
165
-
166
- try {
167
- const popStmt = db.prepare("SELECT id, population FROM place_population")
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
- importance: importanceMap.get(row.id) ?? 0,
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: importanceMap.size,
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
  : {}),
@@ -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
- const PLACE_ENTRY_SIZE = 56
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 4). The v3 provenance + v4 16-byte-state/u32-count
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 = 4
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) * PLACE_ENTRY_SIZE
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
- const rawImportance = isV2
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
- importance: rawImportance,
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 JSON.parse(jsonStr) as FSTProvenance
235
+ return tryParsingJSON<FSTProvenance>(jsonStr) ?? undefined
198
236
  } catch {
199
237
  return undefined
200
238
  }
@@ -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
+ }