@mailwoman/resolver-wof-sqlite 7.2.0 → 7.3.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 (57) hide show
  1. package/address-point-interpolation.ts +207 -0
  2. package/address-point-schema.ts +107 -0
  3. package/address-point.ts +122 -0
  4. package/ancestry-backfill.ts +205 -0
  5. package/ancestry.ts +70 -0
  6. package/build-candidate.ts +351 -0
  7. package/build-slim.ts +394 -0
  8. package/candidate-fts.ts +43 -0
  9. package/candidate-lookup.ts +382 -0
  10. package/candidate-schema.ts +166 -0
  11. package/coincident-roles.ts +240 -0
  12. package/convention.ts +152 -0
  13. package/fst-autocomplete.ts +187 -0
  14. package/fst-builder.ts +291 -0
  15. package/fst-deserialize-web.ts +164 -0
  16. package/fst-matcher.ts +150 -0
  17. package/fst-serialize.ts +311 -0
  18. package/fst-types.ts +78 -0
  19. package/fts.ts +318 -0
  20. package/geo.ts +140 -0
  21. package/geonames-aliases.ts +317 -0
  22. package/geonames-postal.ts +150 -0
  23. package/index.ts +117 -0
  24. package/interpolation.ts +232 -0
  25. package/lookup.ts +1498 -0
  26. package/out/poi-lookup.d.ts +14 -2
  27. package/out/poi-lookup.d.ts.map +1 -1
  28. package/out/poi-lookup.js +55 -21
  29. package/out/poi-lookup.js.map +1 -1
  30. package/out/poi-schema.d.ts +9 -0
  31. package/out/poi-schema.d.ts.map +1 -1
  32. package/out/poi-schema.js +16 -0
  33. package/out/poi-schema.js.map +1 -1
  34. package/out/reverse.d.ts +8 -1
  35. package/out/reverse.d.ts.map +1 -1
  36. package/out/reverse.js +10 -1
  37. package/out/reverse.js.map +1 -1
  38. package/package.json +168 -82
  39. package/poi-lookup.ts +375 -0
  40. package/poi-schema.ts +164 -0
  41. package/postal-city-alias-lookup.ts +89 -0
  42. package/postal-city-alias-schema.ts +75 -0
  43. package/postal-city-candidate-schema.ts +81 -0
  44. package/postcode-point-lookup.ts +64 -0
  45. package/reverse.ts +439 -0
  46. package/schema.ts +176 -0
  47. package/sharding.ts +235 -0
  48. package/sqlite-convention-source.ts +61 -0
  49. package/sqlite-utils.ts +25 -0
  50. package/street-centroid-schema.ts +124 -0
  51. package/street-centroid.ts +124 -0
  52. package/street-morphology-fst-builder.ts +230 -0
  53. package/street-name-lookup.ts +101 -0
  54. package/street-normalize.ts +302 -0
  55. package/street-segment-schema.ts +104 -0
  56. package/types.ts +164 -0
  57. package/unified-schema.ts +171 -0
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Typed schema for the POSTAL-CITY ALIAS table (`postal-city-alias-<cc>.db`, built by
7
+ * `scripts/build-postal-city-alias.ts`) — the single source of truth for the columns shared by
8
+ * the BUILDER and the READER ({@link WOFPostalCityAliasLookup}). Like {@link CandidateTable}, the
9
+ * contract is a Kysely `Database` interface plus the table DDL as a string, so a column rename in
10
+ * the builder is a compile error in the reader.
11
+ *
12
+ * Provenance discipline (provenance-first): this is a SIBLING table to the PIP-derived
13
+ * `postcode_locality` data, never mixed into it — one table, one provenance class. Each row is an
14
+ * OBSERVED `(postcode, postal_city, geo_locality)` aggregate from Overture's `postal_city` field
15
+ * with a usage count `n`; `divergent = 1` exactly when `postal_city != geo_locality` (the alias
16
+ * signal — the only rows the resolver consumes).
17
+ */
18
+
19
+ import type { Kysely } from "kysely"
20
+
21
+ /**
22
+ * One observed postal-city aggregate. The natural key is `(postcode, postal_city, geo_locality)`; the builder enforces
23
+ * a `MIN_COUNT` floor on `n`, so every row is a non-trivial usage.
24
+ */
25
+ export interface PostalCityAliasTable {
26
+ /** The postcode the aggregate is scoped to (the resolver probes by this). */
27
+ postcode: string
28
+ /** What the postal system calls the place (the surface a user is likely to type). */
29
+ postal_city: string
30
+ /** The geographic locality name the postcode actually sits in (≈ the gazetteer's canonical name). */
31
+ geo_locality: string
32
+ /** Observed row count — the evidence weight behind this alias. */
33
+ n: number
34
+ /** 1 when `postal_city != geo_locality` (the alias signal); 0 when they agree. */
35
+ divergent: number
36
+ /** Provenance: the dataset this aggregate came from (e.g. `overture:US`). */
37
+ source: string
38
+ /** The pinned data release the aggregate was computed from. */
39
+ release: string
40
+ }
41
+
42
+ /** The postal-city-alias database schema for `new DatabaseClient<PostalCityAliasDatabase>(...)`. */
43
+ export interface PostalCityAliasDatabase {
44
+ postal_city_alias: PostalCityAliasTable
45
+ }
46
+
47
+ /** The `postal_city_alias` column order — the builder's INSERT derives its column list from this. */
48
+ export const POSTAL_CITY_ALIAS_COLUMNS = [
49
+ "postcode",
50
+ "postal_city",
51
+ "geo_locality",
52
+ "n",
53
+ "divergent",
54
+ "source",
55
+ "release",
56
+ ] as const
57
+
58
+ /**
59
+ * Create the `postal_city_alias` table + its two probe indexes. Kept here (not only in the builder) so tests can stand
60
+ * up a fixture DB with the exact production shape. Pass a {@link DatabaseClient} (or any `Kysely`) over the alias DB.
61
+ */
62
+ export async function createPostalCityAliasTable(db: Kysely<PostalCityAliasDatabase>): Promise<void> {
63
+ await db.schema
64
+ .createTable("postal_city_alias")
65
+ .addColumn("postcode", "text", (c) => c.notNull())
66
+ .addColumn("postal_city", "text", (c) => c.notNull())
67
+ .addColumn("geo_locality", "text", (c) => c.notNull())
68
+ .addColumn("n", "integer", (c) => c.notNull())
69
+ .addColumn("divergent", "integer", (c) => c.notNull())
70
+ .addColumn("source", "text", (c) => c.notNull())
71
+ .addColumn("release", "text", (c) => c.notNull())
72
+ .execute()
73
+ await db.schema.createIndex("idx_pca_postcode").on("postal_city_alias").column("postcode").execute()
74
+ await db.schema.createIndex("idx_pca_pair").on("postal_city_alias").columns(["postal_city", "geo_locality"]).execute()
75
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Typed schema for the POSTAL-CITY CANDIDATE side-index (#741 / #475) — a small `(name_key,
7
+ * postcode) → geo-locality` table that lives alongside the byte-range `candidate` table so the
8
+ * candidate-backend resolver (the demo/CLI default) can do what the FTS coordinate-first scorer
9
+ * does: resolve a user-typed POSTAL city ("Antioch", 37013) to the geographic locality the
10
+ * postcode sits in ("Nashville").
11
+ *
12
+ * Why a SIDE-INDEX, not cloned `candidate` rows: the `candidate` B-tree is keyed `(name_key,
13
+ * country_id, region_id, placetype_id, …)` and ranked population-first — it has no postcode
14
+ * dimension. A cloned alias row was tested (#741) and falsified: a sentinel rank is
15
+ * bare-name-safe but then loses to any in-region homonym, and there is no single rank that is
16
+ * both. The fix is an EXACT `(name_key, postcode)` probe that bypasses population/region ranking
17
+ * entirely — consulted only when the query carries a postcode, so the common no-postcode path is
18
+ * untouched.
19
+ */
20
+
21
+ import { sql, type Kysely } from "kysely"
22
+
23
+ /**
24
+ * One postal-city → geo-locality edge, keyed exactly by `(name_key, postcode)`. The probe returns the geographic
25
+ * locality directly; the denormalized name/coord avoid a join back to `candidate`.
26
+ */
27
+ export interface PostalCityCandidateTable {
28
+ /** {@link normalizeLocalityForKey} of the postal-city name — the build/query-consistent probe key. */
29
+ name_key: string
30
+ /** The postcode the alias is scoped to (the second half of the exact key). */
31
+ postcode: string
32
+ /** WOF id of the geographic locality the postcode sits in (the resolve target). */
33
+ spr_id: number
34
+ /** The geographic locality's display name. */
35
+ name: string
36
+ latitude: number
37
+ longitude: number
38
+ }
39
+
40
+ /**
41
+ * The postal-city-candidate database schema for `new DatabaseClient<PostalCityCandidateDatabase>(...)`.
42
+ */
43
+ export interface PostalCityCandidateDatabase {
44
+ postal_city_candidate: PostalCityCandidateTable
45
+ }
46
+
47
+ /**
48
+ * The table name the lookup probes (existence-gated, so an old candidate.db without it is byte-stable).
49
+ */
50
+ export const POSTAL_CITY_CANDIDATE_TABLE = "postal_city_candidate"
51
+
52
+ /** Column order for the builder's positional INSERT. */
53
+ export const POSTAL_CITY_CANDIDATE_COLUMNS = [
54
+ "name_key",
55
+ "postcode",
56
+ "spr_id",
57
+ "name",
58
+ "latitude",
59
+ "longitude",
60
+ ] as const
61
+
62
+ /**
63
+ * Create the side-index — a clustered `WITHOUT ROWID` B-tree on `(name_key, postcode)` so the resolve is a single exact
64
+ * probe. Idempotent (`IF NOT EXISTS`); pass a {@link DatabaseClient} (or any `Kysely`) over the candidate DB. The
65
+ * Kysely schema-builder is the house idiom for table creation — see `AGENTS.md` (inline-SQL → Kysely).
66
+ */
67
+ export async function createPostalCityCandidateTable(db: Kysely<PostalCityCandidateDatabase>): Promise<void> {
68
+ await db.schema
69
+ .createTable(POSTAL_CITY_CANDIDATE_TABLE)
70
+ .ifNotExists()
71
+ .addColumn("name_key", "text", (c) => c.notNull())
72
+ .addColumn("postcode", "text", (c) => c.notNull())
73
+ .addColumn("spr_id", "integer", (c) => c.notNull())
74
+ .addColumn("name", "text", (c) => c.notNull())
75
+ .addColumn("latitude", "real", (c) => c.notNull())
76
+ .addColumn("longitude", "real", (c) => c.notNull())
77
+ .addPrimaryKeyConstraint("postal_city_candidate_pk", ["name_key", "postcode"])
78
+ // `WITHOUT ROWID` has no first-class builder; the raw modifier is the idiomatic fallback.
79
+ .modifyEnd(sql`without rowid`)
80
+ .execute()
81
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * SQLite-backed postcode lookup for the postcode anchor (#240). A thin exact-match resolver over
7
+ * one or more `postalcode-*.db` shards (the `spr` schema built by `build-unified-wof --placetypes
8
+ * postalcode`, then centroid-backfilled by `scripts/backfill-postcode-centroids.ts`).
9
+ *
10
+ * This is the production implementation of the `PostcodeResolver` interface consumed by
11
+ * `@mailwoman/neural`'s `extractPostcodeAnchors`. It is deliberately dumb: an indexed exact-match
12
+ * on the postcode string across every shard, unioned. No FTS, no ranking, no proximity — the
13
+ * anchor only needs "does this string exist as a postcode, in which countries, near where". A
14
+ * future WASM build swaps this for an FST-backed resolver behind the same `lookup()` seam.
15
+ *
16
+ * Why multiple shards instead of the multi-shard `WOFSqlitePlaceLookup`: that resolver routes a
17
+ * query to ONE shard by placetype, but every postcode shard shares `placetype='postalcode'`, so a
18
+ * single query could only ever hit one country's shard. The anchor needs the union across
19
+ * countries to build its country posterior, so it queries each shard directly.
20
+ */
21
+
22
+ import { DatabaseSync } from "node:sqlite"
23
+
24
+ /**
25
+ * A gazetteer hit. `lat`/`lon` of 0 means the postcode is known but has no centroid (no admin parent).
26
+ */
27
+ export interface PostcodePlace {
28
+ country: string
29
+ lat: number
30
+ lon: number
31
+ }
32
+
33
+ const LOOKUP_SQL =
34
+ "SELECT country, latitude AS lat, longitude AS lon FROM spr WHERE name = ? AND placetype = 'postalcode' AND is_current != 0"
35
+
36
+ export class WOFPostcodeLookup {
37
+ readonly #dbs: DatabaseSync[]
38
+ readonly #stmts: ReturnType<DatabaseSync["prepare"]>[]
39
+
40
+ /** Open each shard read-only and prepare its exact-match statement. */
41
+ constructor(dbPaths: readonly string[]) {
42
+ this.#dbs = dbPaths.map((p) => new DatabaseSync(p, { readOnly: true }))
43
+ this.#stmts = this.#dbs.map((db) => db.prepare(LOOKUP_SQL))
44
+ }
45
+
46
+ /** Exact-match the postcode across every shard and union the rows. */
47
+ lookup(postcode: string): PostcodePlace[] {
48
+ const out: PostcodePlace[] = []
49
+
50
+ for (const stmt of this.#stmts) {
51
+ for (const row of stmt.all(postcode)) {
52
+ out.push({ country: String(row.country), lat: Number(row.lat), lon: Number(row.lon) })
53
+ }
54
+ }
55
+
56
+ return out
57
+ }
58
+
59
+ close(): void {
60
+ for (const db of this.#dbs) {
61
+ db.close()
62
+ }
63
+ }
64
+ }
package/reverse.ts ADDED
@@ -0,0 +1,439 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Reverse geocoding (#484): `(lat, lon)` → the containing admin hierarchy. Assembly over existing
7
+ * machinery, per the 2026-06-11 scoping notes:
8
+ *
9
+ * 1. **Candidate fetch** — the admin DB's `place_bbox` R*Tree (built by `fts.ts`) for places whose
10
+ * bbox contains the point, smallest-area-first (so the FIRST polygon confirmation is the
11
+ * deepest).
12
+ * 2. **PIP confirmation** — ray-cast (geo.ts, the canonical TS port of
13
+ * `scripts/eval/pip-containment.py`) against the polygon sidecar DB (`wof-polygons.db`,
14
+ * `polygons(id, geom)` with GeoJSON text — built by `scripts/build-wof-polygons.mjs` for the
15
+ * demo map). A candidate whose polygon EXISTS but rejects the point is a bbox false positive
16
+ * and is dropped entirely; a candidate with no polygon row stays eligible for the
17
+ * approximate fallback.
18
+ * 3. **Approximate descent** — WOF carries point geometry for most localities (#292: ~99% of JP
19
+ * municipalities; ~half of US localities have degenerate bboxes too), so the polygon walk
20
+ * usually bottoms out at county level. We then descend tier-by-tier (county → localadmin →
21
+ * locality → …) through the winner's DESCENDANTS (the `ancestors` table, reversed), taking
22
+ * the PIP-confirmed child when a polygon exists and the nearest-centroid child otherwise —
23
+ * the latter flagged `containment: "approximate"`, the demo's honesty convention.
24
+ * 4. **Hierarchy assembly** — the deepest place's ancestor chain via the SAME walk forward resolution
25
+ * uses (`ancestry.ts`, #404), so consumers get a symmetric tree.
26
+ *
27
+ * Reverse quality is country-dependent (polygon coverage: see the #292 JP finding); `containment`
28
+ * says so per result rather than pretending.
29
+ */
30
+
31
+ import { DatabaseSync } from "node:sqlite"
32
+
33
+ import { ancestorLineage, placetypeDepth } from "./ancestry.ts"
34
+ import { PLACE_BBOX_TABLE } from "./fts.ts"
35
+ import { geometryContains, haversineKm, type GeojsonGeometry } from "./geo.ts"
36
+ import type { PlaceCandidate, WOFPlacetype } from "./types.ts"
37
+
38
+ /**
39
+ * How the deepest returned place was confirmed:
40
+ *
41
+ * - `"polygon"` — the point ray-cast INSIDE the place's real (DP-simplified) admin boundary.
42
+ * - `"approximate"` — the place has no polygon on record; it won by nearest-centroid among the candidates whose bbox (or
43
+ * parent) contains the point. The same honesty convention as the demo's approximate circles — country-dependent data
44
+ * reality, surfaced instead of hidden.
45
+ */
46
+ export type ContainmentKind = "polygon" | "approximate"
47
+
48
+ export interface ReverseGeocodeResult {
49
+ /**
50
+ * The containment chain, DEEPEST-FIRST (`[0]` is the winning place, then its ancestors up to country) — the same tree
51
+ * shape forward resolution attaches via `includeAncestors`. Empty when no candidate's bbox contains the point (open
52
+ * ocean, or outside the gazetteer's coverage).
53
+ */
54
+ hierarchy: PlaceCandidate[]
55
+ /** Containment kind of the DEEPEST place in `hierarchy` (see {@link ContainmentKind}). */
56
+ containment: ContainmentKind
57
+ }
58
+
59
+ export interface WOFReverseGeocoderOpts {
60
+ /**
61
+ * Path to the admin gazetteer DB (e.g. `admin-global-priority.db`) — must carry `spr`, `ancestors`, and the
62
+ * package-built `place_bbox` R*Tree (`mailwoman gazetteer build fts`). Mutually exclusive with `adminDatabase`.
63
+ */
64
+ adminDBPath?: string
65
+ /** Pre-opened admin DB — primarily for tests against an inline fixture. */
66
+ adminDatabase?: DatabaseSync
67
+ /**
68
+ * Path to the polygon sidecar DB (`wof-polygons.db`, table `polygons(id, geom)`). OPTIONAL — without it every result
69
+ * is `containment: "approximate"` (centroid-only mode). Mutually exclusive with `polygonDatabase`.
70
+ */
71
+ polygonDBPath?: string
72
+ /** Pre-opened polygon DB — primarily for tests. */
73
+ polygonDatabase?: DatabaseSync
74
+ }
75
+
76
+ export interface ReverseGeocodeOpts {
77
+ /**
78
+ * Restrict the hierarchy to these placetypes (both the bbox candidates and the descent tiers). Default: every admin
79
+ * placetype the gazetteer carries. E.g. `["region", "county", "locality"]` to skip the neighbourhood grain.
80
+ */
81
+ placetypes?: WOFPlacetype[]
82
+ /**
83
+ * Cap on the bbox candidate fetch. Default 128 — comfortably covers a dense metro (the most bbox-overlapping point
84
+ * we've measured is a few dozen neighbourhoods + the admin chain).
85
+ */
86
+ maxCandidates?: number
87
+ /**
88
+ * Approximate (nearest-centroid) steps further than this from the query point are not taken — keeps a sparse
89
+ * gazetteer from "refining" to a far-away sibling. Polygon-confirmed steps ignore it (containment is exact regardless
90
+ * of centroid distance). Default 25 km.
91
+ */
92
+ maxApproximateKm?: number
93
+ }
94
+
95
+ const DEFAULT_MAX_CANDIDATES = 128
96
+ const DEFAULT_MAX_APPROXIMATE_KM = 25
97
+
98
+ /**
99
+ * The tier ladder for the approximate descent, coarsest-first. Each tier is attempted among the CURRENT winner's
100
+ * descendants; a tier with no rows is skipped (e.g. counties without localadmins jump straight to locality).
101
+ */
102
+ const DESCENT_TIERS: readonly WOFPlacetype[] = [
103
+ "county",
104
+ "localadmin",
105
+ "locality",
106
+ "borough",
107
+ "neighbourhood",
108
+ "microhood",
109
+ ]
110
+
111
+ /** Internal candidate row off `spr` (+ optional bbox area / centroid distance bookkeeping). */
112
+ interface CandidateRow {
113
+ id: number
114
+ name: string
115
+ placetype: string
116
+ country: string | null
117
+ parent_id: number | null
118
+ lat: number
119
+ lon: number
120
+ }
121
+
122
+ function toPlaceCandidate(row: CandidateRow, distanceKm?: number): PlaceCandidate {
123
+ const c: PlaceCandidate = {
124
+ id: row.id,
125
+ name: row.name,
126
+ placetype: row.placetype as WOFPlacetype,
127
+ country: row.country ?? "",
128
+ lat: row.lat,
129
+ lon: row.lon,
130
+ parent_id: row.parent_id ?? undefined,
131
+ score: 0,
132
+ }
133
+
134
+ if (distanceKm !== undefined) {
135
+ c.distanceKm = distanceKm
136
+ }
137
+
138
+ return c
139
+ }
140
+
141
+ export class WOFReverseGeocoder implements Disposable {
142
+ readonly #admin: DatabaseSync
143
+ readonly #ownsAdmin: boolean
144
+ readonly #polygons: DatabaseSync | null
145
+ readonly #ownsPolygons: boolean
146
+ /**
147
+ * Parsed-geometry cache. Reverse queries cluster geographically (an eval run hits the same ~15 county polygons 1400
148
+ * times), so caching the JSON.parse pays for itself immediately. Bounded — cleared wholesale at the cap rather than
149
+ * LRU-tracked; the polygons are DP-simplified and small, the cap exists only to keep a long-lived server process
150
+ * honest.
151
+ */
152
+ readonly #geometryCache = new Map<number, GeojsonGeometry | null>()
153
+ static readonly #GEOMETRY_CACHE_CAP = 4096
154
+
155
+ constructor(opts: WOFReverseGeocoderOpts) {
156
+ if (opts.adminDatabase && opts.adminDBPath) {
157
+ throw new Error("WOFReverseGeocoder: pass either `adminDatabase` or `adminDBPath`, not both")
158
+ }
159
+
160
+ if (!opts.adminDatabase && !opts.adminDBPath) {
161
+ throw new Error("WOFReverseGeocoder: one of `adminDatabase` or `adminDBPath` is required")
162
+ }
163
+
164
+ if (opts.polygonDatabase && opts.polygonDBPath) {
165
+ throw new Error("WOFReverseGeocoder: pass either `polygonDatabase` or `polygonDBPath`, not both")
166
+ }
167
+
168
+ this.#admin = opts.adminDatabase ?? new DatabaseSync(opts.adminDBPath!, { readOnly: true })
169
+ this.#ownsAdmin = !opts.adminDatabase
170
+ this.#polygons =
171
+ opts.polygonDatabase ?? (opts.polygonDBPath ? new DatabaseSync(opts.polygonDBPath, { readOnly: true }) : null)
172
+ this.#ownsPolygons = !opts.polygonDatabase && Boolean(opts.polygonDBPath)
173
+
174
+ // Fail loudly up front — the R*Tree is a build artifact, not part of the upstream WOF
175
+ // distribution, and a missing index would otherwise surface as an opaque SQL error per query.
176
+ const hasBbox = this.#admin
177
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
178
+ .get(PLACE_BBOX_TABLE)
179
+
180
+ if (!hasBbox) {
181
+ throw new Error(
182
+ `WOFReverseGeocoder: the admin DB has no \`${PLACE_BBOX_TABLE}\` R*Tree. Build it with ` +
183
+ "`mailwoman gazetteer build fts <path-to-wof.db>` (see resolver-wof-sqlite/README.md)."
184
+ )
185
+ }
186
+
187
+ if (this.#polygons) {
188
+ const hasPolygons = this.#polygons
189
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'polygons'`)
190
+ .get()
191
+
192
+ if (!hasPolygons) {
193
+ throw new Error(
194
+ "WOFReverseGeocoder: the polygon DB has no `polygons` table. Expected a `wof-polygons.db` " +
195
+ "built by scripts/build-wof-polygons.mjs."
196
+ )
197
+ }
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Resolve a WGS-84 point to its containing admin hierarchy. Async for symmetry with `PlaceLookup.findPlace` (the work
203
+ * is sync `node:sqlite` underneath — same convention). Thin wrapper over {@link reverseGeocodeSync}.
204
+ */
205
+ async reverseGeocode(lat: number, lon: number, opts: ReverseGeocodeOpts = {}): Promise<ReverseGeocodeResult> {
206
+ return this.reverseGeocodeSync(lat, lon, opts)
207
+ }
208
+
209
+ /**
210
+ * Synchronous core of {@link reverseGeocode} — every step underneath is already sync `node:sqlite`, so this is the
211
+ * REAL implementation; the async method above exists only for call-site symmetry with `PlaceLookup.findPlace`.
212
+ * Exposed directly for callers that can't await mid-call (e.g. `mailwoman/poi-executor.ts`'s `createPOIExecutor`,
213
+ * whose `POIIntentOutcome` return type is synchronous by contract — see `poi-intent.ts`'s `deps.execute`).
214
+ */
215
+ reverseGeocodeSync(lat: number, lon: number, opts: ReverseGeocodeOpts = {}): ReverseGeocodeResult {
216
+ if (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180) {
217
+ throw new RangeError(`WOFReverseGeocoder.reverseGeocode: (${lat}, ${lon}) is not a WGS-84 coordinate`)
218
+ }
219
+ const maxApproximateKm = opts.maxApproximateKm ?? DEFAULT_MAX_APPROXIMATE_KM
220
+ const candidates = this.#bboxCandidates(lat, lon, opts)
221
+
222
+ // PIP walk, smallest-bbox-first: the first polygon that contains the point is the deepest
223
+ // polygon-confirmable place. Polygon-rejected candidates are bbox false positives — dropped.
224
+ let winner: CandidateRow | null = null
225
+ let winnerConfirmed = false
226
+ const pointOnly: CandidateRow[] = []
227
+
228
+ for (const c of candidates) {
229
+ const contains = geometryContains(this.#geometry(c.id), lon, lat)
230
+
231
+ if (contains === true) {
232
+ winner = c
233
+ winnerConfirmed = true
234
+ break
235
+ }
236
+
237
+ if (contains === null) {
238
+ pointOnly.push(c)
239
+ }
240
+ }
241
+
242
+ if (!winner) {
243
+ // No polygon confirmed anywhere — nearest centroid among the polygon-less bbox candidates.
244
+ let bestKm = Infinity
245
+
246
+ for (const c of pointOnly) {
247
+ const km = haversineKm(lat, lon, c.lat, c.lon)
248
+
249
+ if (km < bestKm) {
250
+ bestKm = km
251
+ winner = c
252
+ }
253
+ }
254
+
255
+ if (!winner) return { hierarchy: [], containment: "approximate" }
256
+ }
257
+
258
+ // Approximate descent into finer tiers than the winner.
259
+ let current = winner
260
+ let currentConfirmed = winnerConfirmed
261
+ let currentDistanceKm = currentConfirmed ? undefined : haversineKm(lat, lon, current.lat, current.lon)
262
+
263
+ for (const tier of DESCENT_TIERS) {
264
+ if (placetypeDepth(tier) <= placetypeDepth(current.placetype)) continue
265
+
266
+ if (opts.placetypes && !opts.placetypes.includes(tier)) continue
267
+ const kids = this.#descendants(current.id, tier, lat, lon, maxApproximateKm)
268
+ let next: CandidateRow | null = null
269
+ let nextConfirmed = false
270
+ let nextKm: number | undefined
271
+ let bestKm = Infinity
272
+
273
+ for (const k of kids) {
274
+ const contains = geometryContains(this.#geometry(k.id), lon, lat)
275
+
276
+ if (contains === true) {
277
+ next = k
278
+ nextConfirmed = true
279
+ nextKm = undefined
280
+ break
281
+ }
282
+
283
+ if (contains === false) continue // known not-here — polygon rejected
284
+ const km = haversineKm(lat, lon, k.lat, k.lon)
285
+
286
+ if (km <= maxApproximateKm && km < bestKm) {
287
+ bestKm = km
288
+ next = k
289
+ nextConfirmed = false
290
+ nextKm = km
291
+ }
292
+ }
293
+
294
+ if (next) {
295
+ current = next
296
+ currentConfirmed = nextConfirmed
297
+ currentDistanceKm = nextKm
298
+ }
299
+ // An empty tier is NOT terminal — counties without localadmins jump straight to locality.
300
+ }
301
+
302
+ // Hierarchy assembly via the shared ancestor walk. If the descent crossed an ancestry gap
303
+ // (the deepest place's recorded lineage misses the PIP root), merge the root's own chain so
304
+ // region/country are always present when a polygon confirmed them.
305
+ const byID = new Map<number, PlaceCandidate>()
306
+ byID.set(current.id, toPlaceCandidate(current, currentDistanceKm))
307
+
308
+ for (const a of ancestorLineage(this.#admin, current.id)) {
309
+ if (!byID.has(a.id)) {
310
+ byID.set(a.id, { ...a, placetype: a.placetype as WOFPlacetype, country: a.country ?? "", score: 0 })
311
+ }
312
+ }
313
+
314
+ if (!byID.has(winner.id)) {
315
+ byID.set(winner.id, toPlaceCandidate(winner))
316
+
317
+ for (const a of ancestorLineage(this.#admin, winner.id)) {
318
+ if (!byID.has(a.id)) {
319
+ byID.set(a.id, { ...a, placetype: a.placetype as WOFPlacetype, country: a.country ?? "", score: 0 })
320
+ }
321
+ }
322
+ }
323
+ const hierarchy = [...byID.values()]
324
+
325
+ if (opts.placetypes) {
326
+ const allowed = new Set<string>(opts.placetypes)
327
+
328
+ for (let i = hierarchy.length - 1; i >= 0; i--) {
329
+ if (!allowed.has(hierarchy[i]!.placetype)) {
330
+ hierarchy.splice(i, 1)
331
+ }
332
+ }
333
+ }
334
+ hierarchy.sort((a, b) => placetypeDepth(b.placetype) - placetypeDepth(a.placetype))
335
+
336
+ return { hierarchy, containment: currentConfirmed ? "polygon" : "approximate" }
337
+ }
338
+
339
+ /** Bbox candidates containing the point, smallest-area-first, via the `place_bbox` R*Tree. */
340
+ #bboxCandidates(lat: number, lon: number, opts: ReverseGeocodeOpts): CandidateRow[] {
341
+ const where: string[] = [
342
+ "bbox.min_lat <= ?",
343
+ "bbox.max_lat >= ?",
344
+ "bbox.min_lon <= ?",
345
+ "bbox.max_lon >= ?",
346
+ "spr.is_current != 0",
347
+ "spr.is_deprecated = 0",
348
+ ]
349
+ const params: Array<number | string> = [lat, lat, lon, lon]
350
+
351
+ if (opts.placetypes && opts.placetypes.length > 0) {
352
+ where.push(`spr.placetype IN (${opts.placetypes.map(() => "?").join(", ")})`)
353
+ params.push(...opts.placetypes)
354
+ }
355
+ params.push(opts.maxCandidates ?? DEFAULT_MAX_CANDIDATES)
356
+
357
+ return this.#admin
358
+ .prepare(
359
+ `SELECT spr.id AS id, spr.name AS name, spr.placetype AS placetype, spr.country AS country,
360
+ spr.parent_id AS parent_id, spr.latitude AS lat, spr.longitude AS lon
361
+ FROM ${PLACE_BBOX_TABLE} bbox JOIN spr ON spr.id = bbox.id
362
+ WHERE ${where.join(" AND ")}
363
+ ORDER BY (bbox.max_lat - bbox.min_lat) * (bbox.max_lon - bbox.min_lon) ASC
364
+ LIMIT ?`
365
+ )
366
+ .all(...params) as unknown as CandidateRow[]
367
+ }
368
+
369
+ /**
370
+ * Descendants of `parentID` at one placetype tier, pre-filtered to a centroid window around the query point (a
371
+ * generous 4× the approximate cap — polygon-holding children may legitimately have far centroids, e.g. a sprawling
372
+ * consolidated city; the precise cap is applied per-candidate in the caller, and only to centroid-fallback steps).
373
+ */
374
+ #descendants(
375
+ parentID: number,
376
+ placetype: string,
377
+ lat: number,
378
+ lon: number,
379
+ maxApproximateKm: number
380
+ ): CandidateRow[] {
381
+ const windowDeg = (maxApproximateKm * 4) / 111
382
+
383
+ return this.#admin
384
+ .prepare(
385
+ `SELECT s.id AS id, s.name AS name, s.placetype AS placetype, s.country AS country,
386
+ s.parent_id AS parent_id, s.latitude AS lat, s.longitude AS lon
387
+ FROM ancestors a JOIN spr s ON s.id = a.id
388
+ WHERE a.ancestor_id = ? AND s.placetype = ? AND s.is_current != 0 AND s.is_deprecated = 0
389
+ AND s.latitude BETWEEN ? AND ? AND s.longitude BETWEEN ? AND ?`
390
+ )
391
+ .all(
392
+ parentID,
393
+ placetype,
394
+ lat - windowDeg,
395
+ lat + windowDeg,
396
+ lon - windowDeg,
397
+ lon + windowDeg
398
+ ) as unknown as CandidateRow[]
399
+ }
400
+
401
+ /** Parsed GeoJSON geometry for a WOF id, or null when absent / unparseable / no polygon DB. */
402
+ #geometry(id: number): GeojsonGeometry | null {
403
+ if (!this.#polygons) return null
404
+ const cached = this.#geometryCache.get(id)
405
+
406
+ if (cached !== undefined) return cached
407
+
408
+ if (this.#geometryCache.size >= WOFReverseGeocoder.#GEOMETRY_CACHE_CAP) {
409
+ this.#geometryCache.clear()
410
+ }
411
+ const row = this.#polygons.prepare(`SELECT geom FROM polygons WHERE id = ?`).get(id) as { geom: string } | undefined
412
+ let geometry: GeojsonGeometry | null = null
413
+
414
+ if (row) {
415
+ try {
416
+ geometry = JSON.parse(row.geom) as GeojsonGeometry
417
+ } catch {
418
+ geometry = null // malformed row — treat as no-polygon rather than failing the query
419
+ }
420
+ }
421
+ this.#geometryCache.set(id, geometry)
422
+
423
+ return geometry
424
+ }
425
+
426
+ close(): void {
427
+ if (this.#ownsAdmin) {
428
+ this.#admin.close()
429
+ }
430
+
431
+ if (this.#ownsPolygons) {
432
+ this.#polygons?.close()
433
+ }
434
+ }
435
+
436
+ [Symbol.dispose](): void {
437
+ this.close()
438
+ }
439
+ }