@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.
- package/ancestry-backfill.ts +103 -64
- package/build-candidate.ts +133 -25
- package/candidate-lookup.ts +15 -2
- package/coverage-manifest-schema.ts +1 -1
- package/fst-builder.ts +51 -15
- package/fst-deserialize-web.ts +3 -1
- package/fst-freshness.ts +333 -0
- package/fst-serialize.ts +10 -1
- package/fst-types.ts +23 -0
- package/geo.ts +16 -41
- package/geonames-aliases.ts +105 -37
- package/geonames-postal.ts +65 -21
- package/index.ts +8 -1
- package/interpolation.ts +2 -1
- 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 +9 -0
- package/out/build-candidate.d.ts.map +1 -1
- package/out/build-candidate.js +104 -9
- package/out/build-candidate.js.map +1 -1
- package/out/candidate-lookup.d.ts.map +1 -1
- package/out/candidate-lookup.js +12 -2
- package/out/candidate-lookup.js.map +1 -1
- package/out/coverage-manifest-schema.d.ts +1 -1
- package/out/coverage-manifest-schema.js +1 -1
- package/out/fst-builder.d.ts.map +1 -1
- package/out/fst-builder.js +43 -12
- package/out/fst-builder.js.map +1 -1
- package/out/fst-deserialize-web.d.ts.map +1 -1
- package/out/fst-deserialize-web.js +2 -1
- 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 +6 -0
- package/out/fst-serialize.d.ts.map +1 -1
- package/out/fst-serialize.js +8 -1
- package/out/fst-serialize.js.map +1 -1
- package/out/fst-types.d.ts +26 -0
- package/out/fst-types.d.ts.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 +2 -1
- package/out/index.d.ts.map +1 -1
- package/out/index.js +2 -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/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/street-morphology-fst-builder.d.ts.map +1 -1
- package/out/street-morphology-fst-builder.js +2 -1
- package/out/street-morphology-fst-builder.js.map +1 -1
- package/package.json +16 -6
- package/poi-lookup.ts +3 -3
- package/reverse.ts +4 -9
- package/sqlite-convention-source.ts +5 -3
- package/street-morphology-fst-builder.ts +3 -1
package/ancestry-backfill.ts
CHANGED
|
@@ -3,9 +3,8 @@
|
|
|
3
3
|
* @license AGPL-3.0
|
|
4
4
|
* @author Teffen Ellis, et al.
|
|
5
5
|
*
|
|
6
|
-
* Repair the
|
|
7
|
-
* unified-schema.ts) leaves
|
|
8
|
-
* multi-parent" sentinel.
|
|
6
|
+
* Repair the truncated ancestry that {@link populateAncestors} (the parent_id closure in
|
|
7
|
+
* unified-schema.ts) leaves wherever the closure dead-ends before reaching the top.
|
|
9
8
|
*
|
|
10
9
|
* Root cause (#440 / #832): a place that straddles multiple parents — New York City spans five
|
|
11
10
|
* counties (its boroughs), London 30+ — carries `wof:parent_id = -4`, so the parent_id closure
|
|
@@ -14,22 +13,47 @@
|
|
|
14
13
|
* a correctly-parented namesake ("New York Mills", pop 3,190) wins over NYC's 8.8M. The same defect
|
|
15
14
|
* orphans London, Singapore, and ~2,850 other localities — the most demo-visible queries.
|
|
16
15
|
*
|
|
16
|
+
* **The dead end is inherited by children (#1445).** Repairing the `-4` place itself does not repair
|
|
17
|
+
* anything BELOW it: the closure walks Brooklyn → New York and stops, because New York's own
|
|
18
|
+
* `parent_id` is `-4`. Brooklyn-the-borough (pop 2.5M) therefore carried exactly two ancestor rows —
|
|
19
|
+
* itself and New York — with no county, region or country, and the only US locality-tier place named
|
|
20
|
+
* "Brooklyn" that survived a New-York-State descendant filter was Pillar Point, a Jefferson County
|
|
21
|
+
* hamlet 411 km away carrying "Brooklyn" as an alternate name. All five NYC boroughs, every London
|
|
22
|
+
* borough and Hyderabad's zones were in the same state.
|
|
23
|
+
*
|
|
24
|
+
* So the candidate test is NOT "has only a self ancestor" — that misses every child of a repaired
|
|
25
|
+
* place, which by construction has two. It is **"has no `country`-tier ancestor"**. Country is the
|
|
26
|
+
* universal terminal for every non-{@link TOP_PLACETYPES} placetype, so its absence is exactly the
|
|
27
|
+
* signal that the chain dead-ended somewhere, at whatever depth. On a wide-coverage build this
|
|
28
|
+
* selects ~24k places against 2.55M rows.
|
|
29
|
+
*
|
|
30
|
+
* A place whose `wof:hierarchy` genuinely stops short is NOT a candidate and needs no repair: the
|
|
31
|
+
* source is the authority on what a place should have. American Samoa's localities, for instance,
|
|
32
|
+
* have `{country_id, locality_id}` and no region in WOF itself — the artifact matching that is
|
|
33
|
+
* correct, not truncated.
|
|
34
|
+
*
|
|
17
35
|
* The authoritative hierarchy IS in the source geojson: `wof:hierarchy` is an array of branches,
|
|
18
36
|
* each a `<placetype>_id` → id map (region_id, county_id, country_id, …), fully populated even when
|
|
19
|
-
* parent_id is -4. This reads it for every
|
|
20
|
-
*
|
|
37
|
+
* parent_id is -4. This reads it for every candidate and inserts the missing ancestor rows (one per
|
|
38
|
+
* distinct ancestor across branches).
|
|
21
39
|
*
|
|
22
40
|
* MUST run AFTER populateAncestors and BEFORE the build freezes (VACUUM INTO), so the rows land in
|
|
23
41
|
* the shipped artifact — `scripts/build-unified-wof.ts` Phase 3 calls it inline. The standalone
|
|
24
42
|
* `scripts/backfill-ancestors-from-hierarchy.ts` is a thin CLI over the same function for ad-hoc
|
|
25
|
-
* repair of an already-built DB. Idempotent
|
|
26
|
-
*
|
|
43
|
+
* repair of an already-built DB. Idempotent by the per-pair existence check, not by the candidate
|
|
44
|
+
* test: each (id, ancestor_id) is inserted at most once, so a second run over the same DB adds
|
|
45
|
+
* nothing.
|
|
27
46
|
*/
|
|
28
47
|
|
|
29
|
-
import {
|
|
30
|
-
import { join } from "node:path"
|
|
48
|
+
import { readdirSync } from "node:fs"
|
|
31
49
|
import type { DatabaseSync } from "node:sqlite"
|
|
32
50
|
|
|
51
|
+
import { DatabaseClient } from "@mailwoman/core/kysley/client"
|
|
52
|
+
import { readWOFFeature } from "@mailwoman/core/resources/whosonfirst"
|
|
53
|
+
import { join } from "path-ts"
|
|
54
|
+
|
|
55
|
+
import type { WOFDatabase } from "./schema.ts"
|
|
56
|
+
|
|
33
57
|
/**
|
|
34
58
|
* Genuinely top-level placetypes — they never have (or need) an ancestor, so skip them.
|
|
35
59
|
*/
|
|
@@ -45,8 +69,8 @@ export interface AncestryBackfillResult {
|
|
|
45
69
|
*/
|
|
46
70
|
rowsAdded: number
|
|
47
71
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
72
|
+
* Candidates whose source geojson could not be found (non-WOF backfilled places, or repos not present locally) —
|
|
73
|
+
* skipped, not an error.
|
|
50
74
|
*/
|
|
51
75
|
noGeojson: number
|
|
52
76
|
}
|
|
@@ -89,34 +113,6 @@ export function discoverAdminDataRoots(reposRoot: string): string[] {
|
|
|
89
113
|
return roots
|
|
90
114
|
}
|
|
91
115
|
|
|
92
|
-
/**
|
|
93
|
-
* WOF geojson lives sharded: an id resolves to `<3-char chunks>/<id>.geojson` under each data root.
|
|
94
|
-
*/
|
|
95
|
-
function geojsonForID(id: number, roots: readonly string[]): Record<string, unknown> | null {
|
|
96
|
-
const s = String(id)
|
|
97
|
-
const chunks: string[] = []
|
|
98
|
-
|
|
99
|
-
for (let i = 0; i < s.length; i += 3) {
|
|
100
|
-
chunks.push(s.slice(i, i + 3))
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const rel = join(chunks.join("/"), `${s}.geojson`)
|
|
104
|
-
|
|
105
|
-
for (const root of roots) {
|
|
106
|
-
const fp = join(root, rel)
|
|
107
|
-
|
|
108
|
-
if (existsSync(fp)) {
|
|
109
|
-
try {
|
|
110
|
-
return JSON.parse(readFileSync(fp, "utf8")) as Record<string, unknown>
|
|
111
|
-
} catch {
|
|
112
|
-
return null
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
return null
|
|
118
|
-
}
|
|
119
|
-
|
|
120
116
|
// `<placetype>_id` key → ancestor placetype. WOF hierarchy keys are e.g. region_id, county_id. Self
|
|
121
117
|
// is filtered downstream by the `aid === id` check, so we do NOT special-case locality here: for a
|
|
122
118
|
// locality candidate `locality_id` IS self (dropped by aid===id), but for a neighbourhood candidate
|
|
@@ -128,48 +124,82 @@ function placetypeFromKey(key: string): string | null {
|
|
|
128
124
|
}
|
|
129
125
|
|
|
130
126
|
/**
|
|
131
|
-
* Insert missing ancestor rows for
|
|
132
|
-
* `geojsonRoots` (see {@link discoverAdminDataRoots}). Runs inside a
|
|
133
|
-
* lifecycle (open, WAL checkpoint, close).
|
|
127
|
+
* Insert missing ancestor rows for every place whose ancestry chain dead-ended before reaching a country, by reading
|
|
128
|
+
* `wof:hierarchy` from its source geojson under `geojsonRoots` (see {@link discoverAdminDataRoots}). Runs inside a
|
|
129
|
+
* single transaction; caller owns connection lifecycle (open, WAL checkpoint, close).
|
|
134
130
|
*
|
|
135
|
-
* `opts.
|
|
131
|
+
* `opts.maxID` bounds the candidate scan to ids BELOW it — pass the synthetic-id base (`OVERTURE_ID_BASE`, 8e12) so the
|
|
136
132
|
* backfill considers only real WOF places. Overture/GeoNames rows carry synthetic ids and have NO `wof:hierarchy`
|
|
137
|
-
* geojson, so probing them is pure waste: on a wide-coverage DB the
|
|
138
|
-
* localities, and the per-candidate geojson probe across every repo root turns a seconds-long WOF-only pass into a
|
|
133
|
+
* geojson, so probing them is pure waste: on a wide-coverage DB the country-less set is millions of Overture/GeoNames
|
|
134
|
+
* leaf localities, and the per-candidate geojson probe across every repo root turns a seconds-long WOF-only pass into a
|
|
139
135
|
* ~40-minute one (their ancestry comes from the parent_id closure, not this backfill). Correctness-preserving — the
|
|
140
|
-
* skipped rows would have `noGeojson`-skipped anyway. Omit `
|
|
136
|
+
* skipped rows would have `noGeojson`-skipped anyway. Omit `maxID` (default) for the legacy WOF-only DBs.
|
|
141
137
|
*/
|
|
142
|
-
export function backfillAncestorsFromHierarchy(
|
|
138
|
+
export async function backfillAncestorsFromHierarchy(
|
|
143
139
|
db: DatabaseSync,
|
|
144
140
|
geojsonRoots: readonly string[],
|
|
145
|
-
opts: {
|
|
146
|
-
): AncestryBackfillResult {
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
141
|
+
opts: { maxID?: number } = {}
|
|
142
|
+
): Promise<AncestryBackfillResult> {
|
|
143
|
+
const maxID = opts.maxID ?? Number.MAX_SAFE_INTEGER
|
|
144
|
+
const kdb = new DatabaseClient<WOFDatabase>({ database: db })
|
|
145
|
+
|
|
146
|
+
// "No country-tier ancestor" is the dead-end signal at any depth — see the module docstring. The
|
|
147
|
+
// earlier "<= 1 ancestor row" test only caught the dead end's origin, never the children that
|
|
148
|
+
// inherit it (a child of a repaired -4 place has two rows: itself and that parent) (#1445).
|
|
149
|
+
// The id bound is stated first so SQLite prunes by the PK index before the NOT EXISTS runs at all.
|
|
150
|
+
const candidateBase = kdb
|
|
151
|
+
.selectFrom("spr")
|
|
152
|
+
.where("id", "<", maxID)
|
|
153
|
+
.where((eb) =>
|
|
154
|
+
eb.not(
|
|
155
|
+
eb.exists(
|
|
156
|
+
eb
|
|
157
|
+
.selectFrom("ancestors as a")
|
|
158
|
+
.select("a.id")
|
|
159
|
+
.whereRef("a.id", "=", "spr.id")
|
|
160
|
+
.where("a.ancestor_placetype", "=", "country")
|
|
161
|
+
)
|
|
162
|
+
)
|
|
154
163
|
)
|
|
155
|
-
|
|
164
|
+
|
|
165
|
+
const candidates = await candidateBase.select(["id", "placetype"]).execute()
|
|
166
|
+
|
|
167
|
+
// Every candidate's existing ancestors in ONE query rather than an indexed read each. The widened
|
|
168
|
+
// candidate test made that per-candidate read the dominant cost of the pass, and the set is bounded:
|
|
169
|
+
// a candidate reaching this point has a handful of rows at most. The candidate set rides in as the
|
|
170
|
+
// SAME predicate re-issued as a subquery, never a materialized `IN (?, ?, …)` list — node:sqlite
|
|
171
|
+
// caps a statement at 32,766 bound variables and the wide-coverage build has 67,521 candidates
|
|
172
|
+
// (measured 2026-08-04).
|
|
173
|
+
const alreadyPresent = new Map<number, Set<number>>()
|
|
174
|
+
|
|
175
|
+
for (const row of await kdb
|
|
176
|
+
.selectFrom("ancestors")
|
|
177
|
+
.select(["id", "ancestor_id"])
|
|
178
|
+
.where("id", "in", candidateBase.select("spr.id"))
|
|
179
|
+
.execute()) {
|
|
180
|
+
let set = alreadyPresent.get(row.id)
|
|
181
|
+
|
|
182
|
+
if (!set) {
|
|
183
|
+
set = new Set()
|
|
184
|
+
alreadyPresent.set(row.id, set)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
set.add(Number(row.ancestor_id))
|
|
188
|
+
}
|
|
156
189
|
|
|
157
190
|
const insert = db.prepare(
|
|
158
191
|
"INSERT INTO ancestors (id, ancestor_id, ancestor_placetype, lastmodified) VALUES (?, ?, ?, 0)"
|
|
159
192
|
)
|
|
160
193
|
|
|
161
|
-
const hasRow = db.prepare("SELECT 1 FROM ancestors WHERE id = ? AND ancestor_id = ? LIMIT 1")
|
|
162
|
-
|
|
163
194
|
let placesFixed = 0
|
|
164
195
|
let rowsAdded = 0
|
|
165
196
|
let noGeojson = 0
|
|
166
197
|
db.exec("BEGIN")
|
|
167
198
|
|
|
168
199
|
for (const { id, placetype } of candidates) {
|
|
169
|
-
if (TOP_PLACETYPES.has(placetype)) continue
|
|
170
|
-
const gj =
|
|
171
|
-
const
|
|
172
|
-
const hierarchy = (props?.["wof:hierarchy"] ?? null) as Array<Record<string, number>> | null
|
|
200
|
+
if (placetype && TOP_PLACETYPES.has(placetype)) continue
|
|
201
|
+
const gj = readWOFFeature(id, geojsonRoots)
|
|
202
|
+
const hierarchy = gj?.properties?.["wof:hierarchy"]
|
|
173
203
|
|
|
174
204
|
if (!hierarchy || !hierarchy.length) {
|
|
175
205
|
if (!gj) {
|
|
@@ -197,11 +227,20 @@ export function backfillAncestorsFromHierarchy(
|
|
|
197
227
|
}
|
|
198
228
|
}
|
|
199
229
|
|
|
230
|
+
let present = alreadyPresent.get(id)
|
|
231
|
+
|
|
232
|
+
if (!present) {
|
|
233
|
+
present = new Set()
|
|
234
|
+
alreadyPresent.set(id, present)
|
|
235
|
+
}
|
|
236
|
+
|
|
200
237
|
let added = 0
|
|
201
238
|
|
|
202
239
|
for (const [aid, pt] of seen) {
|
|
203
|
-
if (
|
|
240
|
+
if (present.has(aid)) continue
|
|
241
|
+
|
|
204
242
|
insert.run(id, aid, pt)
|
|
243
|
+
present.add(aid)
|
|
205
244
|
|
|
206
245
|
added++
|
|
207
246
|
}
|
package/build-candidate.ts
CHANGED
|
@@ -60,6 +60,9 @@ export interface BuildCandidateOptions {
|
|
|
60
60
|
* Optional postcode shards (`spr` rows with `placetype='postalcode'` + real coords, e.g. postalcode-us.db) — folded
|
|
61
61
|
* in as `postalcode` candidate rows so `findPlace(postalcode)` resolves a ZIP directly (the demo's primary postcode
|
|
62
62
|
* path; the postcode-*.bin anchor stays the fallback). Matches the slim wof-hot.db, which took one such postcode DB.
|
|
63
|
+
*
|
|
64
|
+
* Each shard's `names` table is folded in too (#1495) — that's where the GeoNames delivery-city names live
|
|
65
|
+
* ("Brooklyn" for 11201), and they were previously reachable only through FTS.
|
|
63
66
|
*/
|
|
64
67
|
postcodes?: string[]
|
|
65
68
|
/**
|
|
@@ -75,6 +78,12 @@ export interface BuildCandidateResult {
|
|
|
75
78
|
aliases: number
|
|
76
79
|
abbrevs: number
|
|
77
80
|
postcodes: number
|
|
81
|
+
/**
|
|
82
|
+
* Delivery-city (and other `names`-table) aliases folded onto postcode rows — #1495. Zero here means the shards
|
|
83
|
+
* carried no alias names, NOT that the pass was skipped: a shard with no `names` table reports that separately
|
|
84
|
+
* through `onProgress`.
|
|
85
|
+
*/
|
|
86
|
+
postcodeAliases: number
|
|
78
87
|
}
|
|
79
88
|
|
|
80
89
|
interface PlaceAttrs {
|
|
@@ -140,12 +149,33 @@ export async function buildCandidateTable(opts: BuildCandidateOptions): Promise<
|
|
|
140
149
|
// --- region_id per place (its region-tier ancestor) for same-name disambiguation ---
|
|
141
150
|
progress("region", "loading region ancestry")
|
|
142
151
|
const regionOf = new Map<number, number>()
|
|
143
|
-
|
|
144
|
-
|
|
152
|
+
let multiRegion = 0
|
|
153
|
+
|
|
154
|
+
// A place can carry more than one region-tier ancestor — 59 do on the 2026-08-03 artifact (42
|
|
155
|
+
// localities, mostly Chinese places on an ambiguous boundary). One `region_id` column holds one of
|
|
156
|
+
// them, and which one it ought to be is a real question this is not the place to answer.
|
|
157
|
+
//
|
|
158
|
+
// MIN is arbitrary but STABLE: an unordered pick lets the stamp for those places differ between two
|
|
159
|
+
// builds of the same source. The count is logged because the number is expected to grow, and should
|
|
160
|
+
// be visible rather than inferred.
|
|
161
|
+
for (const r of src
|
|
162
|
+
.prepare(
|
|
163
|
+
"SELECT id, MIN(ancestor_id) AS ancestor_id, COUNT(DISTINCT ancestor_id) AS n" +
|
|
164
|
+
" FROM ancestors WHERE ancestor_placetype='region' GROUP BY id"
|
|
165
|
+
)
|
|
166
|
+
.iterate()) {
|
|
145
167
|
regionOf.set(Number(r.id), Number(r.ancestor_id))
|
|
168
|
+
|
|
169
|
+
if (Number(r.n) > 1) {
|
|
170
|
+
multiRegion++
|
|
171
|
+
}
|
|
146
172
|
}
|
|
147
173
|
|
|
148
|
-
progress(
|
|
174
|
+
progress(
|
|
175
|
+
"region",
|
|
176
|
+
`${regionOf.size.toLocaleString()} places carry a region` +
|
|
177
|
+
(multiRegion ? ` (${multiRegion.toLocaleString()} carry more than one; stamped with the lowest id)` : "")
|
|
178
|
+
)
|
|
149
179
|
|
|
150
180
|
// The hot path — millions of clustered rows. Kept a single positional prepared statement (the fastest
|
|
151
181
|
// node:sqlite insert) rather than a per-row query builder. Placeholders come from CANDIDATE_COLUMNS so
|
|
@@ -270,13 +300,25 @@ export async function buildCandidateTable(opts: BuildCandidateOptions): Promise<
|
|
|
270
300
|
out.exec("COMMIT")
|
|
271
301
|
progress("abbrevs", `${nAbbr.toLocaleString()} abbrevs`)
|
|
272
302
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
303
|
+
/**
|
|
304
|
+
* Pass 4 — fold ONE postcode shard (`spr` rows with `placetype='postalcode'`) in, then pass 4b: the delivery-city
|
|
305
|
+
* aliases hanging off the same shard's `names` table.
|
|
306
|
+
*
|
|
307
|
+
* Extracted rather than inlined because the shard loop is self-contained — it shares only the staging statement and
|
|
308
|
+
* the code dictionaries with the passes above, and nothing after it reads anything it produces except the two
|
|
309
|
+
* counters it returns.
|
|
310
|
+
*/
|
|
311
|
+
const foldPostcodeShard = (pcDB: string): { primaries: number; aliases: number } => {
|
|
277
312
|
progress("postcodes", `reading ${pcDB}`)
|
|
313
|
+
|
|
278
314
|
const pc = new DatabaseSync(pcDB, { readOnly: true })
|
|
279
315
|
const pcPtid = ptID("postalcode")
|
|
316
|
+
// Per-shard, not the admin `attrs` map: pass 1 only ever sees the admin DB, so the alias pass
|
|
317
|
+
// below has nothing to join against unless this primary loop records what it staged.
|
|
318
|
+
const pcAttrs = new Map<number, PlaceAttrs>()
|
|
319
|
+
let primaries = 0
|
|
320
|
+
let aliases = 0
|
|
321
|
+
|
|
280
322
|
out.exec("BEGIN")
|
|
281
323
|
|
|
282
324
|
for (const r of pc
|
|
@@ -290,38 +332,96 @@ export async function buildCandidateTable(opts: BuildCandidateOptions): Promise<
|
|
|
290
332
|
const key = normalizeLocalityForKey(name)
|
|
291
333
|
|
|
292
334
|
if (!key) continue
|
|
335
|
+
|
|
293
336
|
const lat = r.latitude as number
|
|
294
337
|
const lon = r.longitude as number
|
|
295
338
|
|
|
296
339
|
// region_id 0 (a postcode is unique by name+country — no same-name disambiguation); neg_rank 0
|
|
297
340
|
// (no population). bbox = the postcode's own min/max (falls back to the centroid point).
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
pcPtid,
|
|
303
|
-
0,
|
|
304
|
-
Number(r.id),
|
|
341
|
+
const a: PlaceAttrs = {
|
|
342
|
+
cid: ccID(r.country as string | null),
|
|
343
|
+
rid: 0,
|
|
344
|
+
ptid: pcPtid,
|
|
305
345
|
name,
|
|
306
346
|
lat,
|
|
307
347
|
lon,
|
|
308
|
-
(r.mnlat as number) || lat,
|
|
309
|
-
(r.mnlon as number) || lon,
|
|
310
|
-
(r.mxlat as number) || lat,
|
|
311
|
-
(r.mxlon as number) || lon,
|
|
312
|
-
0,
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
348
|
+
mnLat: (r.mnlat as number) || lat,
|
|
349
|
+
mnLon: (r.mnlon as number) || lon,
|
|
350
|
+
mxLat: (r.mxlat as number) || lat,
|
|
351
|
+
mxLon: (r.mxlon as number) || lon,
|
|
352
|
+
pop: 0,
|
|
353
|
+
neg: 0,
|
|
354
|
+
pkey: key,
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
pcAttrs.set(Number(r.id), a)
|
|
358
|
+
stageRow(key, a, Number(r.id), 1)
|
|
359
|
+
|
|
360
|
+
primaries++
|
|
317
361
|
}
|
|
318
362
|
|
|
319
363
|
out.exec("COMMIT")
|
|
364
|
+
|
|
365
|
+
// --- pass 4b: postcode ALIAS names (#1495) ---
|
|
366
|
+
//
|
|
367
|
+
// The delivery-city names GeoNames supplies for a ZIP ("Brooklyn" for 11201) are written into
|
|
368
|
+
// the shard's `names` table by `postcode/centroid-fills.ts`'s `geonamesNameFill`. Everything
|
|
369
|
+
// downstream of `names` picked them up EXCEPT this build: `fts.ts` unions `spr.name` with every
|
|
370
|
+
// `names` row into `place_search.alt_names`, so the FTS backend resolved "Brooklyn" → 11201
|
|
371
|
+
// while the candidate backend — whose every row IS an exact-tier row — had no key for it at
|
|
372
|
+
// all. Pass 2 does the equivalent fold for admin places, but reads the ADMIN `place_search`,
|
|
373
|
+
// and `attrs` holds admin ids only, so a postcode shard could never reach it.
|
|
374
|
+
//
|
|
375
|
+
// Same discipline as pass 2: `is_primary = 0` (so `rankByPrimaryPreference` treats it as an
|
|
376
|
+
// alias, not a canonical postcode name), the row stays denormalized onto the POSTCODE's own
|
|
377
|
+
// spr_id/coords/bbox, and the display `name` stays the postcode — resolving "brooklyn" answers
|
|
378
|
+
// with place 11201, it does not rename the place to its delivery city.
|
|
379
|
+
const hasNames = pc.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='names'").get() !== undefined
|
|
380
|
+
|
|
381
|
+
if (hasNames) {
|
|
382
|
+
out.exec("BEGIN")
|
|
383
|
+
|
|
384
|
+
for (const r of pc.prepare("SELECT id, name FROM names").iterate()) {
|
|
385
|
+
const a = pcAttrs.get(Number(r.id))
|
|
386
|
+
|
|
387
|
+
if (!a) continue
|
|
388
|
+
|
|
389
|
+
const k = normalizeLocalityForKey(String(r.name ?? ""))
|
|
390
|
+
|
|
391
|
+
// The postcode's own key is already staged as the primary; `INSERT OR IGNORE` at
|
|
392
|
+
// materialization dedupes repeats, so this only skips the obvious self-alias.
|
|
393
|
+
if (!k || k === a.pkey) continue
|
|
394
|
+
|
|
395
|
+
stageRow(k, a, Number(r.id), 0)
|
|
396
|
+
|
|
397
|
+
aliases++
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
out.exec("COMMIT")
|
|
401
|
+
} else {
|
|
402
|
+
// Never a silent zero: real shards come from `createUnifiedSchema`, which always creates
|
|
403
|
+
// `names`. A shard without it has no alias surface to lose, but say so rather than reporting
|
|
404
|
+
// "0 aliases" from a table that was never read.
|
|
405
|
+
progress("postcode-aliases", `${pcDB} has no \`names\` table — no delivery-city aliases to fold`)
|
|
406
|
+
}
|
|
407
|
+
|
|
320
408
|
pc.close()
|
|
409
|
+
|
|
410
|
+
return { primaries, aliases }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
let nPostcode = 0
|
|
414
|
+
let nPostcodeAlias = 0
|
|
415
|
+
|
|
416
|
+
for (const pcDB of opts.postcodes ?? []) {
|
|
417
|
+
const folded = foldPostcodeShard(pcDB)
|
|
418
|
+
|
|
419
|
+
nPostcode += folded.primaries
|
|
420
|
+
nPostcodeAlias += folded.aliases
|
|
321
421
|
}
|
|
322
422
|
|
|
323
423
|
if (nPostcode > 0) {
|
|
324
|
-
progress("postcodes", `${nPostcode.toLocaleString()} postcodes`)
|
|
424
|
+
progress("postcodes", `${nPostcode.toLocaleString()} postcodes; ${nPostcodeAlias.toLocaleString()} aliases`)
|
|
325
425
|
}
|
|
326
426
|
|
|
327
427
|
// --- code dictionaries: typed batch inserts via kdb (a few hundred rows — Kysely is clean here) ---
|
|
@@ -370,5 +470,13 @@ export async function buildCandidateTable(opts: BuildCandidateOptions): Promise<
|
|
|
370
470
|
await kdb.destroy()
|
|
371
471
|
|
|
372
472
|
// closes the underlying `out` connection
|
|
373
|
-
return {
|
|
473
|
+
return {
|
|
474
|
+
rows,
|
|
475
|
+
places: attrs.size,
|
|
476
|
+
primaries: nPrim,
|
|
477
|
+
aliases: nAlias,
|
|
478
|
+
abbrevs: nAbbr,
|
|
479
|
+
postcodes: nPostcode,
|
|
480
|
+
postcodeAliases: nPostcodeAlias,
|
|
481
|
+
}
|
|
374
482
|
}
|
package/candidate-lookup.ts
CHANGED
|
@@ -297,7 +297,9 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
297
297
|
// #920 name law, candidate-key edition: postcode rows are keyed by their whitespace-stripped
|
|
298
298
|
// form at build (the GeoNames fold normalizes '624 66' → '62466'), so a postcode-typed query
|
|
299
299
|
// strips internal whitespace before keying. Postcode-only — locality names keep their spaces.
|
|
300
|
-
|
|
300
|
+
const wantsPostcode = [query.placetype].flat().includes("postalcode")
|
|
301
|
+
|
|
302
|
+
if (wantsPostcode) {
|
|
301
303
|
text = text.replaceAll(/\s+/g, "")
|
|
302
304
|
}
|
|
303
305
|
|
|
@@ -427,7 +429,18 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
427
429
|
// — fuzzing it scrapes an unrelated same-filter place ("Vienna, Austria" misrouted to IT would
|
|
428
430
|
// pull a tiny Italian name_key near Siena) and masks the cascade's country-agnostic retry that
|
|
429
431
|
// correctly lands population-first Vienna AT. The exact/strip probes already covered the real name.
|
|
430
|
-
|
|
432
|
+
//
|
|
433
|
+
// NEVER for postcodes: fuzzy is a typo corrector for place NAMES, and a "corrected" postcode is a
|
|
434
|
+
// DIFFERENT postcode. The 2026-08-05 Code-Point swap exposed the trap at scale: Northern Ireland's
|
|
435
|
+
// `BT3 9QQ` (absent — no permissive NI source) trigram-matched Sheffield's `S3 9QQ` (Jaccard 0.4
|
|
436
|
+
// on {39q, 9qq}) and resolved 200+ km wrong with full confidence. An unknown postcode must abstain.
|
|
437
|
+
if (
|
|
438
|
+
!rows.length &&
|
|
439
|
+
!wantsPostcode &&
|
|
440
|
+
this.#ftsProbe &&
|
|
441
|
+
this.#nameKeyExistsProbe &&
|
|
442
|
+
!this.#nameKeyExistsProbe.get(nameKey)
|
|
443
|
+
) {
|
|
431
444
|
const match = ftsTrigramQuery(nameKey)
|
|
432
445
|
|
|
433
446
|
if (match) {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* promote-gate verdicts + the measured hard-resolve rates that used to live in a code comment on
|
|
12
12
|
* `HARD_PLACE_COUNTRY_SAFELIST`. Presence = measured; `hard_filter_safe = 0` = measured and
|
|
13
13
|
* FAILED the gate (FI 69.5%, PL 77.8%) — distinguishable from a country never measured at all
|
|
14
|
-
* (the meaning-of-zero rule, `docs/
|
|
14
|
+
* (the meaning-of-zero rule, `docs/engineering/reference/layer-contract.mdx`).
|
|
15
15
|
* - `country_bbox`: the coarse guard-B plausibility boxes that used to live in
|
|
16
16
|
* `resolver/plausibility.ts`'s `COUNTRY_BBOX`. An absent row fails open (never trips the guard),
|
|
17
17
|
* exactly like an absent key in the constant.
|
package/fst-builder.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
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"
|
|
@@ -29,6 +30,11 @@ const DEFAULT_PLACETYPES: PlacetypeID[] = [
|
|
|
29
30
|
|
|
30
31
|
const DEFAULT_COUNTRIES = ["US"]
|
|
31
32
|
const DEFAULT_LANGUAGES = ["eng", ""]
|
|
33
|
+
/**
|
|
34
|
+
* Ids per `IN (…)` batch. SQLITE_MAX_VARIABLE_NUMBER defaults to 32,766; 500 matches the name-load batch a few phases
|
|
35
|
+
* down, so both read paths bind the same shape.
|
|
36
|
+
*/
|
|
37
|
+
const ANCESTOR_CHUNK = 500
|
|
32
38
|
|
|
33
39
|
interface SprRow {
|
|
34
40
|
id: number
|
|
@@ -89,19 +95,40 @@ export function buildFSTFromWOF(opts: BuildFSTOpts): {
|
|
|
89
95
|
// Also load parent rows that might be outside our placetype filter (e.g., country for region).
|
|
90
96
|
const parentStmt = db.prepare("SELECT id, name, placetype, parent_id, latitude, longitude FROM spr WHERE id = ?")
|
|
91
97
|
|
|
92
|
-
// Fallback
|
|
93
|
-
|
|
98
|
+
// Fallback for a sentinel parent_id (-1, -4, …): the ancestors table. Read in chunked `IN (…)`
|
|
99
|
+
// batches ONCE — the point-query version fired per orphan row, and on a global build the orphans
|
|
100
|
+
// run to six figures. Ordering is county → region → country, preserved by the same CASE the
|
|
101
|
+
// per-row query used, with `id` leading so one pass groups the rows.
|
|
102
|
+
const ancestorsByID = new Map<number, number[]>()
|
|
94
103
|
|
|
95
104
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
+
const orphanIDs = sprRows.filter((row) => row.parent_id <= 0).map((row) => row.id)
|
|
106
|
+
|
|
107
|
+
for (let i = 0; i < orphanIDs.length; i += ANCESTOR_CHUNK) {
|
|
108
|
+
const chunk = orphanIDs.slice(i, i + ANCESTOR_CHUNK)
|
|
109
|
+
|
|
110
|
+
const rows = db
|
|
111
|
+
.prepare(
|
|
112
|
+
`SELECT DISTINCT id, ancestor_id FROM ancestors
|
|
113
|
+
WHERE id IN (${chunk.map(() => "?").join(",")}) AND ancestor_placetype IN ('country', 'region', 'county')
|
|
114
|
+
ORDER BY id, CASE ancestor_placetype
|
|
115
|
+
WHEN 'county' THEN 1
|
|
116
|
+
WHEN 'region' THEN 2
|
|
117
|
+
WHEN 'country' THEN 3
|
|
118
|
+
END`
|
|
119
|
+
)
|
|
120
|
+
.all(...chunk) as unknown as Array<{ id: number; ancestor_id: number }>
|
|
121
|
+
|
|
122
|
+
for (const row of rows) {
|
|
123
|
+
let chain = ancestorsByID.get(row.id)
|
|
124
|
+
|
|
125
|
+
if (!chain) {
|
|
126
|
+
ancestorsByID.set(row.id, (chain = []))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
chain.push(row.ancestor_id)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
105
132
|
} catch {
|
|
106
133
|
progress("ancestors", "No ancestors table — sentinel parent_ids will produce empty chains")
|
|
107
134
|
}
|
|
@@ -112,10 +139,8 @@ export function buildFSTFromWOF(opts: BuildFSTOpts): {
|
|
|
112
139
|
if (!row) return []
|
|
113
140
|
|
|
114
141
|
// 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)
|
|
142
|
+
if (row.parent_id <= 0) {
|
|
143
|
+
return (ancestorsByID.get(id) ?? []).filter((ancestorID) => ancestorID !== id)
|
|
119
144
|
}
|
|
120
145
|
|
|
121
146
|
// Normal case: walk parent_id chain.
|
|
@@ -320,6 +345,15 @@ export function buildFSTFromWOF(opts: BuildFSTOpts): {
|
|
|
320
345
|
const edgeCount = nodes.reduce((sum, n) => sum + n.edges.size, 0)
|
|
321
346
|
const matcher = FSTMatcher.fromNodes(nodes)
|
|
322
347
|
|
|
348
|
+
// The build stamp (2026-08-05). `sourceDB` alone was never enough to tell a reader whether this
|
|
349
|
+
// artifact matches the database at that path — the admin DB is sealed and REPLACED by a rebuild, so
|
|
350
|
+
// the path is constant across every generation of it. Hashing costs 7.3 s for the 5.27 GB admin DB
|
|
351
|
+
// and is free whenever the `.md5` sidecar is current, which the admin build already writes.
|
|
352
|
+
// `sourceIdentity` lets a caller that already knows the digest (or is building from something that
|
|
353
|
+
// is not a file at all) supply it instead.
|
|
354
|
+
progress("stamp", `Reading source identity for ${opts.dbPath}`)
|
|
355
|
+
const source = opts.sourceIdentity ?? readWOFSourceIdentity(opts.dbPath)
|
|
356
|
+
|
|
323
357
|
const provenance: FSTProvenance = {
|
|
324
358
|
builtAt: new Date().toISOString(),
|
|
325
359
|
countries,
|
|
@@ -329,6 +363,8 @@ export function buildFSTFromWOF(opts: BuildFSTOpts): {
|
|
|
329
363
|
nameInsertions: insertCount,
|
|
330
364
|
importanceMatches: importanceMap.size,
|
|
331
365
|
sourceDB: opts.dbPath,
|
|
366
|
+
sourceDBMD5: source.md5,
|
|
367
|
+
sourceDBBytes: source.bytes,
|
|
332
368
|
...(excludeSurfaces !== undefined || excludeAllTokensOf !== undefined
|
|
333
369
|
? { exclusionPolicy: opts.exclusionPolicy ?? "unspecified", excludedInsertions: excludedCount }
|
|
334
370
|
: {}),
|
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"
|
|
@@ -194,7 +196,7 @@ export function readFSTProvenanceWeb(input: ArrayBuffer | Uint8Array): FSTProven
|
|
|
194
196
|
const jsonLen = view.getUint32(provenanceOffset, true)
|
|
195
197
|
const jsonStr = decoder.decode(bytes.subarray(provenanceOffset + 4, provenanceOffset + 4 + jsonLen))
|
|
196
198
|
|
|
197
|
-
return
|
|
199
|
+
return tryParsingJSON<FSTProvenance>(jsonStr) ?? undefined
|
|
198
200
|
} catch {
|
|
199
201
|
return undefined
|
|
200
202
|
}
|