@mailwoman/resolver-wof-sqlite 8.6.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/ancestry-backfill.ts +103 -64
  2. package/build-candidate.ts +133 -25
  3. package/candidate-lookup.ts +15 -2
  4. package/coverage-manifest-schema.ts +1 -1
  5. package/fst-builder.ts +51 -15
  6. package/fst-deserialize-web.ts +3 -1
  7. package/fst-freshness.ts +333 -0
  8. package/fst-serialize.ts +10 -1
  9. package/fst-types.ts +23 -0
  10. package/geo.ts +16 -41
  11. package/geonames-aliases.ts +105 -37
  12. package/geonames-postal.ts +65 -21
  13. package/index.ts +8 -1
  14. package/interpolation.ts +2 -1
  15. package/out/ancestry-backfill.d.ts +37 -18
  16. package/out/ancestry-backfill.d.ts.map +1 -1
  17. package/out/ancestry-backfill.js +82 -52
  18. package/out/ancestry-backfill.js.map +1 -1
  19. package/out/build-candidate.d.ts +9 -0
  20. package/out/build-candidate.d.ts.map +1 -1
  21. package/out/build-candidate.js +104 -9
  22. package/out/build-candidate.js.map +1 -1
  23. package/out/candidate-lookup.d.ts.map +1 -1
  24. package/out/candidate-lookup.js +12 -2
  25. package/out/candidate-lookup.js.map +1 -1
  26. package/out/coverage-manifest-schema.d.ts +1 -1
  27. package/out/coverage-manifest-schema.js +1 -1
  28. package/out/fst-builder.d.ts.map +1 -1
  29. package/out/fst-builder.js +43 -12
  30. package/out/fst-builder.js.map +1 -1
  31. package/out/fst-deserialize-web.d.ts.map +1 -1
  32. package/out/fst-deserialize-web.js +2 -1
  33. package/out/fst-deserialize-web.js.map +1 -1
  34. package/out/fst-freshness.d.ts +138 -0
  35. package/out/fst-freshness.d.ts.map +1 -0
  36. package/out/fst-freshness.js +238 -0
  37. package/out/fst-freshness.js.map +1 -0
  38. package/out/fst-serialize.d.ts +6 -0
  39. package/out/fst-serialize.d.ts.map +1 -1
  40. package/out/fst-serialize.js +8 -1
  41. package/out/fst-serialize.js.map +1 -1
  42. package/out/fst-types.d.ts +26 -0
  43. package/out/fst-types.d.ts.map +1 -1
  44. package/out/geo.d.ts +10 -13
  45. package/out/geo.d.ts.map +1 -1
  46. package/out/geo.js +15 -35
  47. package/out/geo.js.map +1 -1
  48. package/out/geonames-aliases.d.ts +23 -1
  49. package/out/geonames-aliases.d.ts.map +1 -1
  50. package/out/geonames-aliases.js +88 -33
  51. package/out/geonames-aliases.js.map +1 -1
  52. package/out/geonames-postal.d.ts +22 -1
  53. package/out/geonames-postal.d.ts.map +1 -1
  54. package/out/geonames-postal.js +50 -16
  55. package/out/geonames-postal.js.map +1 -1
  56. package/out/index.d.ts +2 -1
  57. package/out/index.d.ts.map +1 -1
  58. package/out/index.js +2 -1
  59. package/out/index.js.map +1 -1
  60. package/out/interpolation.d.ts.map +1 -1
  61. package/out/interpolation.js +2 -1
  62. package/out/interpolation.js.map +1 -1
  63. package/out/poi-lookup.d.ts +1 -1
  64. package/out/poi-lookup.js +3 -3
  65. package/out/reverse.d.ts.map +1 -1
  66. package/out/reverse.js +3 -9
  67. package/out/reverse.js.map +1 -1
  68. package/out/sqlite-convention-source.d.ts.map +1 -1
  69. package/out/sqlite-convention-source.js +4 -3
  70. package/out/sqlite-convention-source.js.map +1 -1
  71. package/out/street-morphology-fst-builder.d.ts.map +1 -1
  72. package/out/street-morphology-fst-builder.js +2 -1
  73. package/out/street-morphology-fst-builder.js.map +1 -1
  74. package/package.json +16 -6
  75. package/poi-lookup.ts +3 -3
  76. package/reverse.ts +4 -9
  77. package/sqlite-convention-source.ts +5 -3
  78. package/street-morphology-fst-builder.ts +3 -1
@@ -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
+ }
package/fst-serialize.ts CHANGED
@@ -23,6 +23,8 @@
23
23
  * (V2); was population u32 (V1) lat f32 lon f32 chain [u32; 8] parent chain (unused slots = 0)
24
24
  */
25
25
 
26
+ import { tryParsingJSON } from "@mailwoman/core/objects"
27
+
26
28
  import type { FSTNode } from "./fst-matcher.ts"
27
29
  import { FSTMatcher } from "./fst-matcher.ts"
28
30
  import type { FSTProvenance, PlaceEntry, PlacetypeID } from "./fst-types.ts"
@@ -58,6 +60,13 @@ const MAGIC = Buffer.from("FST\0", "ascii")
58
60
  */
59
61
  const VERSION = 4
60
62
 
63
+ /**
64
+ * The format version this tree WRITES, published so a freshness guard can call an older artifact format-stale without
65
+ * re-typing the number. Mirrors `REQUIRED_PAIR_INDEX_SCHEMA`'s role for PIX1: one constant, so a version bump cannot be
66
+ * noticed by some checkers and not others. See `fst-freshness.ts`.
67
+ */
68
+ export const FST_FORMAT_VERSION = VERSION
69
+
61
70
  /**
62
71
  * Fixed header size in bytes: magic, version, and the section offsets that follow it.
63
72
  */
@@ -378,7 +387,7 @@ export function readFSTProvenance(buf: Buffer): FSTProvenance | undefined {
378
387
  const jsonLen = buf.readUInt32LE(provenanceOffset)
379
388
  const jsonStr = buf.toString("utf8", provenanceOffset + 4, provenanceOffset + 4 + jsonLen)
380
389
 
381
- return JSON.parse(jsonStr) as FSTProvenance
390
+ return tryParsingJSON<FSTProvenance>(jsonStr) ?? undefined
382
391
  } catch {
383
392
  return undefined
384
393
  }
package/fst-types.ts CHANGED
@@ -66,6 +66,23 @@ export interface FSTProvenance {
66
66
  nameInsertions: number
67
67
  importanceMatches: number
68
68
  sourceDB?: string
69
+ /**
70
+ * MD5 of the source database's bytes at build time — the artifact's link to the gazetteer it is a projection of.
71
+ *
72
+ * `sourceDB` records the PATH, which is exactly the field that cannot change when the bytes behind it do: the admin
73
+ * DB is a sealed readonly artifact that a rebuild REPLACES in place, so every FST built before the 2026-08-04 swap
74
+ * still names the current file and none of them was built from it. Compared by `fst-freshness.ts`.
75
+ *
76
+ * `undefined` = built before the stamp existed (every artifact predating 2026-08-05). NEVER conflate that with "built
77
+ * from a database whose md5 is unknown" — the freshness check reports the two in different words.
78
+ */
79
+ sourceDBMD5?: string
80
+ /**
81
+ * Byte size of the source database at build time. Not redundant with {@link FSTProvenance.sourceDBMD5}: it survives a
82
+ * truncated source and it is what makes a staleness warning legible — "5,273,722,880 → 5,372,076,032" names the
83
+ * rebuild, a hex delta does not.
84
+ */
85
+ sourceDBBytes?: number
69
86
  modelCardVersion?: string
70
87
  /**
71
88
  * Degenerate-surface curation policy applied at build time (absent = uncurated build).
@@ -79,6 +96,12 @@ export interface FSTProvenance {
79
96
 
80
97
  export interface BuildFSTOpts {
81
98
  dbPath: string
99
+ /**
100
+ * Pre-computed identity of `dbPath` to stamp into provenance. Omit and the builder reads it via
101
+ * `readWOFSourceIdentity` (sidecar-cached). Supply it when the digest is already in hand, or when the caller is
102
+ * building from something whose identity it defines itself.
103
+ */
104
+ sourceIdentity?: { md5: string; bytes: number }
82
105
  countries?: string[]
83
106
  placetypes?: PlacetypeID[]
84
107
  languages?: string[]
package/geo.ts CHANGED
@@ -10,17 +10,20 @@
10
10
  * ray-cast PIP a ~30-line one — both plenty fast for the post-fetch passes (we operate on ≤ a few
11
11
  * hundred candidates per query, not the whole 142k-row corpus).
12
12
  *
13
- * The PIP implementation here is the CANONICAL port of the even-odd ray cast that previously lived
14
- * only in Python (`scripts/eval/pip-containment.py`, with a second copy in
15
- * `scripts/build-postcode-locality.ts`). Keep the three in sync if the algorithm ever changes —
16
- * the eval-side Python copies grade the same containment truth this one resolves with.
13
+ * The even-odd ray cast lives in `@mailwoman/spatial` and is re-exported below, so the resolver,
14
+ * the reverse-geocoder and the gazetteer pipeline share one definition. `scripts/eval/pip-containment.py`
15
+ * grades the same containment truth and has to be matched BY HAND if the algorithm changes — it is
16
+ * the one copy no import can reach.
17
17
  *
18
18
  * The R*Tree index name + schema are centralized in `fts.ts` (alongside the FTS5 build).
19
19
  */
20
20
 
21
- // haversineKm is the canonical implementation in @mailwoman/spatial; re-exported so this package's
22
- // readers keep importing it from "./geo.ts" (the spatial dep is transitive via @mailwoman/resolver).
23
- export { haversineKm } from "@mailwoman/spatial"
21
+ import { pointInPolygon } from "@mailwoman/spatial"
22
+
23
+ // haversineKm and the point-in-polygon ray cast are implemented in @mailwoman/spatial, the math
24
+ // home; re-exported so this package's readers keep importing them from "./geo.ts" (the spatial dep
25
+ // is transitive via @mailwoman/resolver).
26
+ export { haversineKm, pointInRing } from "@mailwoman/spatial"
24
27
 
25
28
  /**
26
29
  * WGS-84 degrees → radians.
@@ -82,42 +85,14 @@ export interface GeojsonMultiPolygon {
82
85
  export type GeojsonGeometry = GeojsonPolygon | GeojsonMultiPolygon | { type: string; coordinates?: unknown }
83
86
 
84
87
  /**
85
- * Ray-cast a point against ONE linear ring. Standard even-odd crossing count: shoot a ray along +lon and toggle on
86
- * every edge crossing. Points exactly on an edge are implementation-defined (either side is acceptable for geocoding —
87
- * admin boundaries are DP-simplified anyway).
88
- */
89
- export function pointInRing(lon: number, lat: number, ring: readonly GeojsonPosition[]): boolean {
90
- let inside = false
91
- const n = ring.length
92
-
93
- for (let i = 0, j = n - 1; i < n; j = i++) {
94
- const xi = ring[i]![0]
95
- const yi = ring[i]![1]
96
- const xj = ring[j]![0]
97
- const yj = ring[j]![1]
98
-
99
- if (yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) {
100
- inside = !inside
101
- }
102
- }
103
-
104
- return inside
105
- }
106
-
107
- /**
108
- * Even-odd containment over a polygon's ring list (`[outer, hole1, …]`) — being inside an odd number of rings means
109
- * inside the polygon, which handles holes without ring-orientation rules.
88
+ * Even-odd containment over a polygon's ring list (`[outer, hole1, …]`).
89
+ *
90
+ * Named alias for `@mailwoman/spatial`'s {@link pointInPolygon}, kept because this package's readers and the gazetteer
91
+ * pipeline import `pointInPolygonRings` by name and the "rings" spelling is what makes the ring-list argument obvious
92
+ * at those call sites.
110
93
  */
111
94
  export function pointInPolygonRings(lon: number, lat: number, rings: readonly GeojsonPosition[][]): boolean {
112
- let inside = false
113
-
114
- for (const ring of rings) {
115
- if (pointInRing(lon, lat, ring)) {
116
- inside = !inside
117
- }
118
- }
119
-
120
- return inside
95
+ return pointInPolygon(lon, lat, rings)
121
96
  }
122
97
 
123
98
  /**