@mailwoman/resolver-wof-sqlite 10.8.0 → 11.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/lib/address/point.ts +44 -1
- package/lib/build/slim.ts +0 -19
- package/lib/candidate/lookup.ts +9 -4
- package/lib/index.ts +0 -1
- package/lib/landmark/rows.ts +91 -0
- package/lib/landmark.ts +94 -0
- package/lib/lookup.ts +1 -21
- package/lib/primary-preference.ts +13 -20
- package/lib/schema.ts +0 -18
- package/lib/street/normalize.ts +91 -0
- package/lib/types.ts +7 -2
- package/out/address/point.d.ts.map +1 -1
- package/out/address/point.js +36 -1
- package/out/address/point.js.map +1 -1
- package/out/build/slim.d.ts +1 -1
- package/out/build/slim.d.ts.map +1 -1
- package/out/build/slim.js +0 -12
- package/out/build/slim.js.map +1 -1
- package/out/candidate/lookup.d.ts.map +1 -1
- package/out/candidate/lookup.js +8 -4
- package/out/candidate/lookup.js.map +1 -1
- package/out/index.d.ts +1 -1
- package/out/index.d.ts.map +1 -1
- package/out/index.js.map +1 -1
- package/out/landmark/rows.d.ts +63 -0
- package/out/landmark/rows.d.ts.map +1 -0
- package/out/landmark/rows.js +48 -0
- package/out/landmark/rows.js.map +1 -0
- package/out/landmark.d.ts +40 -0
- package/out/landmark.d.ts.map +1 -0
- package/out/landmark.js +78 -0
- package/out/landmark.js.map +1 -0
- package/out/lookup.d.ts +1 -5
- package/out/lookup.d.ts.map +1 -1
- package/out/lookup.js +0 -17
- package/out/lookup.js.map +1 -1
- package/out/primary-preference.d.ts +10 -10
- package/out/primary-preference.d.ts.map +1 -1
- package/out/primary-preference.js +8 -9
- package/out/primary-preference.js.map +1 -1
- package/out/schema.d.ts +0 -17
- package/out/schema.d.ts.map +1 -1
- package/out/street/normalize.d.ts +7 -0
- package/out/street/normalize.d.ts.map +1 -1
- package/out/street/normalize.js +75 -0
- package/out/street/normalize.js.map +1 -1
- package/out/types.d.ts +6 -2
- package/out/types.d.ts.map +1 -1
- package/package.json +12 -12
- package/lib/coincident-roles.ts +0 -347
- package/out/coincident-roles.d.ts +0 -121
- package/out/coincident-roles.d.ts.map +0 -1
- package/out/coincident-roles.js +0 -222
- package/out/coincident-roles.js.map +0 -1
package/lib/coincident-roles.ts
DELETED
|
@@ -1,347 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @copyright Sister Software
|
|
3
|
-
* @license AGPL-3.0
|
|
4
|
-
* @author Teffen Ellis, et al.
|
|
5
|
-
*
|
|
6
|
-
* `buildCoincidentRoles` derives the coincident-roles relation into the unified gazetteer.
|
|
7
|
-
*
|
|
8
|
-
* Some places occupy multiple admin tiers under one name: German city-states (Berlin, Hamburg,
|
|
9
|
-
* Bremen), Italian provinces whose names match their capital (Milano, Varese), Spanish
|
|
10
|
-
* provinces-after-capitals, UK unitary authorities, JP prefectures, NL province-capitals (Utrecht,
|
|
11
|
-
* Groningen), and Shanghai. When an address surfaces only the admin role because the parser
|
|
12
|
-
* dropped the locality span, the resolver has no locality to place. The hierarchy-completion step
|
|
13
|
-
* consults this relation. The table preserves the gazetteer's own structure, so the runtime is an
|
|
14
|
-
* O(1) membership lookup with no distance math.
|
|
15
|
-
*
|
|
16
|
-
* V1 is region-tier only (admin.placetype = `region`), the ~124 places matching the census across
|
|
17
|
-
* nine countries. County-tier same-name coincidences are excluded because they are dominated by
|
|
18
|
-
* French cantons and JP counties, admin subdivisions whose names match a seat town that do not hit the
|
|
19
|
-
* parser-drops-locality failure. Consolidated city-counties (US SF, Denver) are a separate
|
|
20
|
-
* follow-up needing a relative-size filter.
|
|
21
|
-
*
|
|
22
|
-
* A pair `(admin, locality)` is recorded when the names match case-insensitively, the locality descends from the admin in `ancestors`,
|
|
23
|
-
* and their centroids fall within a
|
|
24
|
-
* relative tolerance of `toleranceFraction × admin-bbox-diagonal`, floored at `minToleranceKm`.
|
|
25
|
-
* The relative term lets a large Italian province admit a city tens of km from its centroid while
|
|
26
|
-
* a tiny city-state stays tight. The floor catches city-states whose bbox is small (Bremen's
|
|
27
|
-
* centroids sit 9.3 km apart). The tolerance lives only here at build time and never enters the
|
|
28
|
-
* resolver hot path.
|
|
29
|
-
*
|
|
30
|
-
* `relationship_type` is recorded for debuggability and deferred per-type behavior. V1 completion
|
|
31
|
-
* is uniform. It is a coarse classification.
|
|
32
|
-
*
|
|
33
|
-
* This mirrors the derived-table builder pattern in `fts.ts` (`buildPlaceSearchFTS`). It runs
|
|
34
|
-
* incrementally against an existing `admin-global-priority.db` via
|
|
35
|
-
* `build-coincident-roles-cli.ts`, and it should also be wired as a post-step of the main
|
|
36
|
-
* `scripts/build-unified-wof.ts`.
|
|
37
|
-
*/
|
|
38
|
-
|
|
39
|
-
import type { CoincidentLocality } from "@mailwoman/core/resolver"
|
|
40
|
-
import { allRows } from "@mailwoman/core/utils"
|
|
41
|
-
import { haversine } from "@mailwoman/spatial/geometries/point/distance"
|
|
42
|
-
import type { DatabaseClient } from "@mailwoman/sqlite/client"
|
|
43
|
-
import { tableExists } from "@mailwoman/sqlite/introspection"
|
|
44
|
-
|
|
45
|
-
import type { WOFDatabase } from "#schema"
|
|
46
|
-
/**
|
|
47
|
-
* Table of places that hold more than one admin role.
|
|
48
|
-
* A locality that is also its county seat.
|
|
49
|
-
*
|
|
50
|
-
* Written by the gazetteer build, read by the resolver when a coincident locality has to be chosen.
|
|
51
|
-
*/
|
|
52
|
-
export const COINCIDENT_ROLES_TABLE = "coincident_roles"
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* A place that plays multiple admin roles, one row of the relation keyed by `admin_id`.
|
|
56
|
-
*/
|
|
57
|
-
export interface CoincidentRole {
|
|
58
|
-
localityID: number
|
|
59
|
-
relationshipType: "city-state" | "capital-seat" | "consolidated-county"
|
|
60
|
-
adminPlacetype: string
|
|
61
|
-
distanceKm: number
|
|
62
|
-
population: number
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export interface BuildCoincidentRolesOpts {
|
|
66
|
-
/**
|
|
67
|
-
* Drop + rebuild the table if it already exists.
|
|
68
|
-
*
|
|
69
|
-
* @defaultValue `true` (the build is cheap + idempotent)
|
|
70
|
-
*/
|
|
71
|
-
drop?: boolean
|
|
72
|
-
/**
|
|
73
|
-
* Relative tolerance: a pair is kept when centroid distance ≤ `toleranceFraction × bbox-diagonal`.
|
|
74
|
-
*
|
|
75
|
-
* @defaultValue `0.15`
|
|
76
|
-
*/
|
|
77
|
-
toleranceFraction?: number
|
|
78
|
-
/**
|
|
79
|
-
* Floor (km) under the relative tolerance, so small-bbox city-states still qualify.
|
|
80
|
-
*
|
|
81
|
-
* @defaultValue `12`
|
|
82
|
-
*/
|
|
83
|
-
minToleranceKm?: number
|
|
84
|
-
/**
|
|
85
|
-
* Centroid distance (km) below which a region-tier pair is classed `city-state` (metadata only).
|
|
86
|
-
*
|
|
87
|
-
* @defaultValue `2`
|
|
88
|
-
*/
|
|
89
|
-
cityStateMaxKm?: number
|
|
90
|
-
onProgress?: (phase: string, detail?: string) => void
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export interface BuildCoincidentRolesResult {
|
|
94
|
-
created: boolean
|
|
95
|
-
rowCount: number
|
|
96
|
-
byCountry: Record<string, number>
|
|
97
|
-
durationMs: number
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
interface CandidateRow {
|
|
101
|
-
admin_id: number
|
|
102
|
-
admin_placetype: string
|
|
103
|
-
country: string
|
|
104
|
-
locality_id: number
|
|
105
|
-
rlat: number
|
|
106
|
-
rlon: number
|
|
107
|
-
llat: number
|
|
108
|
-
llon: number
|
|
109
|
-
min_latitude: number
|
|
110
|
-
min_longitude: number
|
|
111
|
-
max_latitude: number
|
|
112
|
-
max_longitude: number
|
|
113
|
-
pop: number
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Derive the coincident-roles relation into `db`.
|
|
118
|
-
*
|
|
119
|
-
* Additive, only creating or replacing the `coincident_roles` table.
|
|
120
|
-
* It never touches `spr`, `names`, or `ancestors`.
|
|
121
|
-
* Idempotent.
|
|
122
|
-
*/
|
|
123
|
-
export function buildCoincidentRoles(
|
|
124
|
-
db: DatabaseClient<WOFDatabase>,
|
|
125
|
-
opts: BuildCoincidentRolesOpts = {}
|
|
126
|
-
): BuildCoincidentRolesResult {
|
|
127
|
-
const start = Date.now()
|
|
128
|
-
const drop = opts.drop ?? true
|
|
129
|
-
const toleranceFraction = opts.toleranceFraction ?? 0.15
|
|
130
|
-
const minToleranceKm = opts.minToleranceKm ?? 12
|
|
131
|
-
const cityStateMaxKm = opts.cityStateMaxKm ?? 2
|
|
132
|
-
const onProgress = opts.onProgress ?? (() => {})
|
|
133
|
-
|
|
134
|
-
if (tableExists(db, COINCIDENT_ROLES_TABLE) && drop) {
|
|
135
|
-
onProgress("dropping", COINCIDENT_ROLES_TABLE)
|
|
136
|
-
db.exec(`DROP TABLE ${COINCIDENT_ROLES_TABLE}`)
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
onProgress("creating", COINCIDENT_ROLES_TABLE)
|
|
140
|
-
|
|
141
|
-
// Raw DDL by design: this is a sync builder consumed by a sync CLI and six sync unit tests,
|
|
142
|
-
// so routing one table through async Kysely would cascade async through all of them.
|
|
143
|
-
// See agents.md "Database / inline SQL".
|
|
144
|
-
db.exec(`
|
|
145
|
-
CREATE TABLE IF NOT EXISTS ${COINCIDENT_ROLES_TABLE} (
|
|
146
|
-
admin_id INTEGER NOT NULL,
|
|
147
|
-
locality_id INTEGER NOT NULL,
|
|
148
|
-
relationship_type TEXT NOT NULL,
|
|
149
|
-
admin_placetype TEXT NOT NULL,
|
|
150
|
-
distance_km REAL NOT NULL,
|
|
151
|
-
locality_population INTEGER NOT NULL DEFAULT 0,
|
|
152
|
-
PRIMARY KEY (admin_id, locality_id)
|
|
153
|
-
)
|
|
154
|
-
`)
|
|
155
|
-
|
|
156
|
-
onProgress("scanning")
|
|
157
|
-
|
|
158
|
-
// Admin (region or county tier) joined to a same-name descendant locality.
|
|
159
|
-
// `place_population` is optional, so the left join yields 0 when absent.
|
|
160
|
-
// JavaScript applies the relative-tolerance filter and classifies the relationship.
|
|
161
|
-
// SQL stays a plain join.
|
|
162
|
-
// `spr` exposes the bbox columns needed for the diagonal.
|
|
163
|
-
const candidates = allRows<CandidateRow>(
|
|
164
|
-
db.prepare(
|
|
165
|
-
`SELECT r.id AS admin_id, r.placetype AS admin_placetype, r.country AS country, l.id AS locality_id,
|
|
166
|
-
r.latitude AS rlat, r.longitude AS rlon, l.latitude AS llat, l.longitude AS llon,
|
|
167
|
-
r.min_latitude, r.min_longitude, r.max_latitude, r.max_longitude,
|
|
168
|
-
COALESCE(p.population, 0) AS pop
|
|
169
|
-
FROM spr r
|
|
170
|
-
JOIN spr l ON lower(l.name) = lower(r.name) AND l.placetype = 'locality'
|
|
171
|
-
AND l.is_current != 0 AND l.is_deprecated = 0
|
|
172
|
-
JOIN ${"ancestors"} a ON a.id = l.id AND a.ancestor_id = r.id
|
|
173
|
-
LEFT JOIN place_population p ON p.id = l.id
|
|
174
|
-
WHERE r.placetype = 'region'
|
|
175
|
-
AND r.is_current != 0 AND r.is_deprecated = 0`
|
|
176
|
-
)
|
|
177
|
-
)
|
|
178
|
-
|
|
179
|
-
onProgress("filtering", `${candidates.length} candidates`)
|
|
180
|
-
|
|
181
|
-
const insert = db.prepare(
|
|
182
|
-
`INSERT OR REPLACE INTO ${COINCIDENT_ROLES_TABLE}
|
|
183
|
-
(admin_id, locality_id, relationship_type, admin_placetype, distance_km, locality_population)
|
|
184
|
-
VALUES (?, ?, ?, ?, ?, ?)`
|
|
185
|
-
)
|
|
186
|
-
|
|
187
|
-
const byCountry: Record<string, number> = {}
|
|
188
|
-
let rowCount = 0
|
|
189
|
-
db.exec("BEGIN")
|
|
190
|
-
|
|
191
|
-
try {
|
|
192
|
-
for (const c of candidates) {
|
|
193
|
-
const dist = haversine(c.rlat, c.rlon, c.llat, c.llon)
|
|
194
|
-
const diag = haversine(c.min_latitude, c.min_longitude, c.max_latitude, c.max_longitude)
|
|
195
|
-
const tolerance = Math.max(toleranceFraction * diag, minToleranceKm)
|
|
196
|
-
|
|
197
|
-
if (dist > tolerance) continue
|
|
198
|
-
// v1 is region-tier only: a place is `city-state` when its centroid coincides with the
|
|
199
|
-
// region's (Berlin, Hamburg), otherwise `capital-seat` (a region whose name matches its
|
|
200
|
-
// principal city, such as Milano province and Milano comune). `consolidated-county` is
|
|
201
|
-
// reserved for a future county-tier pass (US SF, Denver), excluded from v1 because
|
|
202
|
-
// county-tier same-name coincidences are dominated by French cantons and JP counties
|
|
203
|
-
// that do not hit the parser-drops-locality failure.
|
|
204
|
-
const relationshipType = dist <= cityStateMaxKm ? "city-state" : "capital-seat"
|
|
205
|
-
insert.run(c.admin_id, c.locality_id, relationshipType, c.admin_placetype, dist, c.pop)
|
|
206
|
-
|
|
207
|
-
rowCount++
|
|
208
|
-
byCountry[c.country] = (byCountry[c.country] ?? 0) + 1
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
db.exec("COMMIT")
|
|
212
|
-
} catch (error) {
|
|
213
|
-
db.exec("ROLLBACK")
|
|
214
|
-
throw error
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
db.exec(`CREATE INDEX IF NOT EXISTS coincident_roles_by_admin ON ${COINCIDENT_ROLES_TABLE} (admin_id)`)
|
|
218
|
-
|
|
219
|
-
onProgress("done", `${rowCount} coincident-role rows`)
|
|
220
|
-
|
|
221
|
-
return { created: true, rowCount, byCountry, durationMs: Date.now() - start }
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* True iff the relation table exists.
|
|
226
|
-
*
|
|
227
|
-
* Used by the resolver to decide whether completion can run.
|
|
228
|
-
*/
|
|
229
|
-
export function coincidentRolesExists<DB>(db: DatabaseClient<DB>): boolean {
|
|
230
|
-
return tableExists(db, COINCIDENT_ROLES_TABLE)
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Load the relation into an in-memory map keyed by `admin_id` for an O(1) runtime lookup.
|
|
235
|
-
*
|
|
236
|
-
* Each admin may map to multiple same-name descendants.
|
|
237
|
-
* The consumer disambiguates by minimum distance, then population, then abstains.
|
|
238
|
-
* Returns an empty map when the table is absent.
|
|
239
|
-
*/
|
|
240
|
-
export function loadCoincidentRoles<DB>(db: DatabaseClient<DB>): Map<number, CoincidentRole[]> {
|
|
241
|
-
const map = new Map<number, CoincidentRole[]>()
|
|
242
|
-
|
|
243
|
-
if (!coincidentRolesExists(db)) return map
|
|
244
|
-
|
|
245
|
-
const rows = allRows<{
|
|
246
|
-
admin_id: number
|
|
247
|
-
locality_id: number
|
|
248
|
-
relationship_type: CoincidentRole["relationshipType"]
|
|
249
|
-
admin_placetype: string
|
|
250
|
-
distance_km: number
|
|
251
|
-
locality_population: number
|
|
252
|
-
}>(
|
|
253
|
-
db.prepare(
|
|
254
|
-
`SELECT admin_id, locality_id, relationship_type, admin_placetype, distance_km, locality_population
|
|
255
|
-
FROM ${COINCIDENT_ROLES_TABLE}`
|
|
256
|
-
)
|
|
257
|
-
)
|
|
258
|
-
|
|
259
|
-
for (const r of rows) {
|
|
260
|
-
const entry: CoincidentRole = {
|
|
261
|
-
localityID: r.locality_id,
|
|
262
|
-
relationshipType: r.relationship_type,
|
|
263
|
-
adminPlacetype: r.admin_placetype,
|
|
264
|
-
distanceKm: r.distance_km,
|
|
265
|
-
population: r.locality_population,
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
const list = map.get(r.admin_id)
|
|
269
|
-
|
|
270
|
-
if (list) {
|
|
271
|
-
list.push(entry)
|
|
272
|
-
} else {
|
|
273
|
-
map.set(r.admin_id, [entry])
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
return map
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* The relation joined with `spr`, as an in-memory map keyed by `admin_id`.
|
|
282
|
-
*
|
|
283
|
-
* The resolver-facing view of {@link loadCoincidentRoles}, carrying the canonical name
|
|
284
|
-
* and coordinates each coincident locality resolves to.
|
|
285
|
-
* Returns an empty map when the relation table is absent.
|
|
286
|
-
*/
|
|
287
|
-
export function loadCoincidentLocalities<DB>(db: DatabaseClient<DB>): Map<number, CoincidentLocality[]> {
|
|
288
|
-
const map = new Map<number, CoincidentLocality[]>()
|
|
289
|
-
|
|
290
|
-
if (coincidentRolesExists(db)) {
|
|
291
|
-
const rows = allRows<{
|
|
292
|
-
adminID: number
|
|
293
|
-
id: number
|
|
294
|
-
name: string
|
|
295
|
-
country: string
|
|
296
|
-
lat: number
|
|
297
|
-
lon: number
|
|
298
|
-
relationshipType: string
|
|
299
|
-
population: number
|
|
300
|
-
distanceKm: number
|
|
301
|
-
}>(
|
|
302
|
-
db.prepare(
|
|
303
|
-
`SELECT cr.admin_id AS adminID, s.id AS id, s.name AS name, s.country AS country,
|
|
304
|
-
s.latitude AS lat, s.longitude AS lon,
|
|
305
|
-
cr.relationship_type AS relationshipType, cr.locality_population AS population,
|
|
306
|
-
cr.distance_km AS distanceKm
|
|
307
|
-
FROM ${COINCIDENT_ROLES_TABLE} cr JOIN spr s ON s.id = cr.locality_id`
|
|
308
|
-
)
|
|
309
|
-
)
|
|
310
|
-
|
|
311
|
-
for (const r of rows) {
|
|
312
|
-
const candidate: CoincidentLocality = {
|
|
313
|
-
id: r.id,
|
|
314
|
-
name: r.name,
|
|
315
|
-
placetype: "locality",
|
|
316
|
-
country: r.country,
|
|
317
|
-
lat: r.lat,
|
|
318
|
-
lon: r.lon,
|
|
319
|
-
score: 0,
|
|
320
|
-
relationshipType: r.relationshipType,
|
|
321
|
-
population: r.population,
|
|
322
|
-
distanceKm: r.distanceKm,
|
|
323
|
-
parent_id: null,
|
|
324
|
-
prominence: null,
|
|
325
|
-
referential: null,
|
|
326
|
-
encyclopedic: null,
|
|
327
|
-
importance: null,
|
|
328
|
-
exactMatch: null,
|
|
329
|
-
mismatch: null,
|
|
330
|
-
resolutionQuality: null,
|
|
331
|
-
containedByQualifier: null,
|
|
332
|
-
regionScopeMiss: null,
|
|
333
|
-
variantAliasExempted: null,
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
const list = map.get(r.adminID)
|
|
337
|
-
|
|
338
|
-
if (list) {
|
|
339
|
-
list.push(candidate)
|
|
340
|
-
} else {
|
|
341
|
-
map.set(r.adminID, [candidate])
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
return map
|
|
347
|
-
}
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @copyright Sister Software
|
|
3
|
-
* @license AGPL-3.0
|
|
4
|
-
* @author Teffen Ellis, et al.
|
|
5
|
-
*
|
|
6
|
-
* `buildCoincidentRoles` derives the coincident-roles relation into the unified gazetteer.
|
|
7
|
-
*
|
|
8
|
-
* Some places occupy multiple admin tiers under one name: German city-states (Berlin, Hamburg,
|
|
9
|
-
* Bremen), Italian provinces whose names match their capital (Milano, Varese), Spanish
|
|
10
|
-
* provinces-after-capitals, UK unitary authorities, JP prefectures, NL province-capitals (Utrecht,
|
|
11
|
-
* Groningen), and Shanghai. When an address surfaces only the admin role because the parser
|
|
12
|
-
* dropped the locality span, the resolver has no locality to place. The hierarchy-completion step
|
|
13
|
-
* consults this relation. The table preserves the gazetteer's own structure, so the runtime is an
|
|
14
|
-
* O(1) membership lookup with no distance math.
|
|
15
|
-
*
|
|
16
|
-
* V1 is region-tier only (admin.placetype = `region`), the ~124 places matching the census across
|
|
17
|
-
* nine countries. County-tier same-name coincidences are excluded because they are dominated by
|
|
18
|
-
* French cantons and JP counties, admin subdivisions whose names match a seat town that do not hit the
|
|
19
|
-
* parser-drops-locality failure. Consolidated city-counties (US SF, Denver) are a separate
|
|
20
|
-
* follow-up needing a relative-size filter.
|
|
21
|
-
*
|
|
22
|
-
* A pair `(admin, locality)` is recorded when the names match case-insensitively, the locality descends from the admin in `ancestors`,
|
|
23
|
-
* and their centroids fall within a
|
|
24
|
-
* relative tolerance of `toleranceFraction × admin-bbox-diagonal`, floored at `minToleranceKm`.
|
|
25
|
-
* The relative term lets a large Italian province admit a city tens of km from its centroid while
|
|
26
|
-
* a tiny city-state stays tight. The floor catches city-states whose bbox is small (Bremen's
|
|
27
|
-
* centroids sit 9.3 km apart). The tolerance lives only here at build time and never enters the
|
|
28
|
-
* resolver hot path.
|
|
29
|
-
*
|
|
30
|
-
* `relationship_type` is recorded for debuggability and deferred per-type behavior. V1 completion
|
|
31
|
-
* is uniform. It is a coarse classification.
|
|
32
|
-
*
|
|
33
|
-
* This mirrors the derived-table builder pattern in `fts.ts` (`buildPlaceSearchFTS`). It runs
|
|
34
|
-
* incrementally against an existing `admin-global-priority.db` via
|
|
35
|
-
* `build-coincident-roles-cli.ts`, and it should also be wired as a post-step of the main
|
|
36
|
-
* `scripts/build-unified-wof.ts`.
|
|
37
|
-
*/
|
|
38
|
-
import type { CoincidentLocality } from "@mailwoman/core/resolver";
|
|
39
|
-
import type { DatabaseClient } from "@mailwoman/sqlite/client";
|
|
40
|
-
import type { WOFDatabase } from "#schema";
|
|
41
|
-
/**
|
|
42
|
-
* Table of places that hold more than one admin role.
|
|
43
|
-
* A locality that is also its county seat.
|
|
44
|
-
*
|
|
45
|
-
* Written by the gazetteer build, read by the resolver when a coincident locality has to be chosen.
|
|
46
|
-
*/
|
|
47
|
-
export declare const COINCIDENT_ROLES_TABLE = "coincident_roles";
|
|
48
|
-
/**
|
|
49
|
-
* A place that plays multiple admin roles, one row of the relation keyed by `admin_id`.
|
|
50
|
-
*/
|
|
51
|
-
export interface CoincidentRole {
|
|
52
|
-
localityID: number;
|
|
53
|
-
relationshipType: "city-state" | "capital-seat" | "consolidated-county";
|
|
54
|
-
adminPlacetype: string;
|
|
55
|
-
distanceKm: number;
|
|
56
|
-
population: number;
|
|
57
|
-
}
|
|
58
|
-
export interface BuildCoincidentRolesOpts {
|
|
59
|
-
/**
|
|
60
|
-
* Drop + rebuild the table if it already exists.
|
|
61
|
-
*
|
|
62
|
-
* @defaultValue `true` (the build is cheap + idempotent)
|
|
63
|
-
*/
|
|
64
|
-
drop?: boolean;
|
|
65
|
-
/**
|
|
66
|
-
* Relative tolerance: a pair is kept when centroid distance ≤ `toleranceFraction × bbox-diagonal`.
|
|
67
|
-
*
|
|
68
|
-
* @defaultValue `0.15`
|
|
69
|
-
*/
|
|
70
|
-
toleranceFraction?: number;
|
|
71
|
-
/**
|
|
72
|
-
* Floor (km) under the relative tolerance, so small-bbox city-states still qualify.
|
|
73
|
-
*
|
|
74
|
-
* @defaultValue `12`
|
|
75
|
-
*/
|
|
76
|
-
minToleranceKm?: number;
|
|
77
|
-
/**
|
|
78
|
-
* Centroid distance (km) below which a region-tier pair is classed `city-state` (metadata only).
|
|
79
|
-
*
|
|
80
|
-
* @defaultValue `2`
|
|
81
|
-
*/
|
|
82
|
-
cityStateMaxKm?: number;
|
|
83
|
-
onProgress?: (phase: string, detail?: string) => void;
|
|
84
|
-
}
|
|
85
|
-
export interface BuildCoincidentRolesResult {
|
|
86
|
-
created: boolean;
|
|
87
|
-
rowCount: number;
|
|
88
|
-
byCountry: Record<string, number>;
|
|
89
|
-
durationMs: number;
|
|
90
|
-
}
|
|
91
|
-
/**
|
|
92
|
-
* Derive the coincident-roles relation into `db`.
|
|
93
|
-
*
|
|
94
|
-
* Additive, only creating or replacing the `coincident_roles` table.
|
|
95
|
-
* It never touches `spr`, `names`, or `ancestors`.
|
|
96
|
-
* Idempotent.
|
|
97
|
-
*/
|
|
98
|
-
export declare function buildCoincidentRoles(db: DatabaseClient<WOFDatabase>, opts?: BuildCoincidentRolesOpts): BuildCoincidentRolesResult;
|
|
99
|
-
/**
|
|
100
|
-
* True iff the relation table exists.
|
|
101
|
-
*
|
|
102
|
-
* Used by the resolver to decide whether completion can run.
|
|
103
|
-
*/
|
|
104
|
-
export declare function coincidentRolesExists<DB>(db: DatabaseClient<DB>): boolean;
|
|
105
|
-
/**
|
|
106
|
-
* Load the relation into an in-memory map keyed by `admin_id` for an O(1) runtime lookup.
|
|
107
|
-
*
|
|
108
|
-
* Each admin may map to multiple same-name descendants.
|
|
109
|
-
* The consumer disambiguates by minimum distance, then population, then abstains.
|
|
110
|
-
* Returns an empty map when the table is absent.
|
|
111
|
-
*/
|
|
112
|
-
export declare function loadCoincidentRoles<DB>(db: DatabaseClient<DB>): Map<number, CoincidentRole[]>;
|
|
113
|
-
/**
|
|
114
|
-
* The relation joined with `spr`, as an in-memory map keyed by `admin_id`.
|
|
115
|
-
*
|
|
116
|
-
* The resolver-facing view of {@link loadCoincidentRoles}, carrying the canonical name
|
|
117
|
-
* and coordinates each coincident locality resolves to.
|
|
118
|
-
* Returns an empty map when the relation table is absent.
|
|
119
|
-
*/
|
|
120
|
-
export declare function loadCoincidentLocalities<DB>(db: DatabaseClient<DB>): Map<number, CoincidentLocality[]>;
|
|
121
|
-
//# sourceMappingURL=coincident-roles.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"coincident-roles.d.ts","sourceRoot":"","sources":["../lib/coincident-roles.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAA;AAGlE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAA;AAG9D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAA;AAC1C;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,qBAAqB,CAAA;AAExD;;GAEG;AACH,MAAM,WAAW,cAAc;IAC9B,UAAU,EAAE,MAAM,CAAA;IAClB,gBAAgB,EAAE,YAAY,GAAG,cAAc,GAAG,qBAAqB,CAAA;IACvE,cAAc,EAAE,MAAM,CAAA;IACtB,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,wBAAwB;IACxC;;;;OAIG;IACH,IAAI,CAAC,EAAE,OAAO,CAAA;IACd;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;CACrD;AAED,MAAM,WAAW,0BAA0B;IAC1C,OAAO,EAAE,OAAO,CAAA;IAChB,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACjC,UAAU,EAAE,MAAM,CAAA;CAClB;AAkBD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CACnC,EAAE,EAAE,cAAc,CAAC,WAAW,CAAC,EAC/B,IAAI,GAAE,wBAA6B,GACjC,0BAA0B,CAgG5B;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,EAAE,EAAE,EAAE,EAAE,cAAc,CAAC,EAAE,CAAC,GAAG,OAAO,CAEzE;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,EAAE,EAAE,EAAE,EAAE,cAAc,CAAC,EAAE,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,cAAc,EAAE,CAAC,CAsC7F;AAED;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CAAC,EAAE,EAAE,EAAE,EAAE,cAAc,CAAC,EAAE,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,kBAAkB,EAAE,CAAC,CA4DtG"}
|
package/out/coincident-roles.js
DELETED
|
@@ -1,222 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @copyright Sister Software
|
|
3
|
-
* @license AGPL-3.0
|
|
4
|
-
* @author Teffen Ellis, et al.
|
|
5
|
-
*
|
|
6
|
-
* `buildCoincidentRoles` derives the coincident-roles relation into the unified gazetteer.
|
|
7
|
-
*
|
|
8
|
-
* Some places occupy multiple admin tiers under one name: German city-states (Berlin, Hamburg,
|
|
9
|
-
* Bremen), Italian provinces whose names match their capital (Milano, Varese), Spanish
|
|
10
|
-
* provinces-after-capitals, UK unitary authorities, JP prefectures, NL province-capitals (Utrecht,
|
|
11
|
-
* Groningen), and Shanghai. When an address surfaces only the admin role because the parser
|
|
12
|
-
* dropped the locality span, the resolver has no locality to place. The hierarchy-completion step
|
|
13
|
-
* consults this relation. The table preserves the gazetteer's own structure, so the runtime is an
|
|
14
|
-
* O(1) membership lookup with no distance math.
|
|
15
|
-
*
|
|
16
|
-
* V1 is region-tier only (admin.placetype = `region`), the ~124 places matching the census across
|
|
17
|
-
* nine countries. County-tier same-name coincidences are excluded because they are dominated by
|
|
18
|
-
* French cantons and JP counties, admin subdivisions whose names match a seat town that do not hit the
|
|
19
|
-
* parser-drops-locality failure. Consolidated city-counties (US SF, Denver) are a separate
|
|
20
|
-
* follow-up needing a relative-size filter.
|
|
21
|
-
*
|
|
22
|
-
* A pair `(admin, locality)` is recorded when the names match case-insensitively, the locality descends from the admin in `ancestors`,
|
|
23
|
-
* and their centroids fall within a
|
|
24
|
-
* relative tolerance of `toleranceFraction × admin-bbox-diagonal`, floored at `minToleranceKm`.
|
|
25
|
-
* The relative term lets a large Italian province admit a city tens of km from its centroid while
|
|
26
|
-
* a tiny city-state stays tight. The floor catches city-states whose bbox is small (Bremen's
|
|
27
|
-
* centroids sit 9.3 km apart). The tolerance lives only here at build time and never enters the
|
|
28
|
-
* resolver hot path.
|
|
29
|
-
*
|
|
30
|
-
* `relationship_type` is recorded for debuggability and deferred per-type behavior. V1 completion
|
|
31
|
-
* is uniform. It is a coarse classification.
|
|
32
|
-
*
|
|
33
|
-
* This mirrors the derived-table builder pattern in `fts.ts` (`buildPlaceSearchFTS`). It runs
|
|
34
|
-
* incrementally against an existing `admin-global-priority.db` via
|
|
35
|
-
* `build-coincident-roles-cli.ts`, and it should also be wired as a post-step of the main
|
|
36
|
-
* `scripts/build-unified-wof.ts`.
|
|
37
|
-
*/
|
|
38
|
-
import { allRows } from "@mailwoman/core/utils";
|
|
39
|
-
import { haversine } from "@mailwoman/spatial/geometries/point/distance";
|
|
40
|
-
import { tableExists } from "@mailwoman/sqlite/introspection";
|
|
41
|
-
/**
|
|
42
|
-
* Table of places that hold more than one admin role.
|
|
43
|
-
* A locality that is also its county seat.
|
|
44
|
-
*
|
|
45
|
-
* Written by the gazetteer build, read by the resolver when a coincident locality has to be chosen.
|
|
46
|
-
*/
|
|
47
|
-
export const COINCIDENT_ROLES_TABLE = "coincident_roles";
|
|
48
|
-
/**
|
|
49
|
-
* Derive the coincident-roles relation into `db`.
|
|
50
|
-
*
|
|
51
|
-
* Additive, only creating or replacing the `coincident_roles` table.
|
|
52
|
-
* It never touches `spr`, `names`, or `ancestors`.
|
|
53
|
-
* Idempotent.
|
|
54
|
-
*/
|
|
55
|
-
export function buildCoincidentRoles(db, opts = {}) {
|
|
56
|
-
const start = Date.now();
|
|
57
|
-
const drop = opts.drop ?? true;
|
|
58
|
-
const toleranceFraction = opts.toleranceFraction ?? 0.15;
|
|
59
|
-
const minToleranceKm = opts.minToleranceKm ?? 12;
|
|
60
|
-
const cityStateMaxKm = opts.cityStateMaxKm ?? 2;
|
|
61
|
-
const onProgress = opts.onProgress ?? (() => { });
|
|
62
|
-
if (tableExists(db, COINCIDENT_ROLES_TABLE) && drop) {
|
|
63
|
-
onProgress("dropping", COINCIDENT_ROLES_TABLE);
|
|
64
|
-
db.exec(`DROP TABLE ${COINCIDENT_ROLES_TABLE}`);
|
|
65
|
-
}
|
|
66
|
-
onProgress("creating", COINCIDENT_ROLES_TABLE);
|
|
67
|
-
// Raw DDL by design: this is a sync builder consumed by a sync CLI and six sync unit tests,
|
|
68
|
-
// so routing one table through async Kysely would cascade async through all of them.
|
|
69
|
-
// See agents.md "Database / inline SQL".
|
|
70
|
-
db.exec(`
|
|
71
|
-
CREATE TABLE IF NOT EXISTS ${COINCIDENT_ROLES_TABLE} (
|
|
72
|
-
admin_id INTEGER NOT NULL,
|
|
73
|
-
locality_id INTEGER NOT NULL,
|
|
74
|
-
relationship_type TEXT NOT NULL,
|
|
75
|
-
admin_placetype TEXT NOT NULL,
|
|
76
|
-
distance_km REAL NOT NULL,
|
|
77
|
-
locality_population INTEGER NOT NULL DEFAULT 0,
|
|
78
|
-
PRIMARY KEY (admin_id, locality_id)
|
|
79
|
-
)
|
|
80
|
-
`);
|
|
81
|
-
onProgress("scanning");
|
|
82
|
-
// Admin (region or county tier) joined to a same-name descendant locality.
|
|
83
|
-
// `place_population` is optional, so the left join yields 0 when absent.
|
|
84
|
-
// JavaScript applies the relative-tolerance filter and classifies the relationship.
|
|
85
|
-
// SQL stays a plain join.
|
|
86
|
-
// `spr` exposes the bbox columns needed for the diagonal.
|
|
87
|
-
const candidates = allRows(db.prepare(`SELECT r.id AS admin_id, r.placetype AS admin_placetype, r.country AS country, l.id AS locality_id,
|
|
88
|
-
r.latitude AS rlat, r.longitude AS rlon, l.latitude AS llat, l.longitude AS llon,
|
|
89
|
-
r.min_latitude, r.min_longitude, r.max_latitude, r.max_longitude,
|
|
90
|
-
COALESCE(p.population, 0) AS pop
|
|
91
|
-
FROM spr r
|
|
92
|
-
JOIN spr l ON lower(l.name) = lower(r.name) AND l.placetype = 'locality'
|
|
93
|
-
AND l.is_current != 0 AND l.is_deprecated = 0
|
|
94
|
-
JOIN ${"ancestors"} a ON a.id = l.id AND a.ancestor_id = r.id
|
|
95
|
-
LEFT JOIN place_population p ON p.id = l.id
|
|
96
|
-
WHERE r.placetype = 'region'
|
|
97
|
-
AND r.is_current != 0 AND r.is_deprecated = 0`));
|
|
98
|
-
onProgress("filtering", `${candidates.length} candidates`);
|
|
99
|
-
const insert = db.prepare(`INSERT OR REPLACE INTO ${COINCIDENT_ROLES_TABLE}
|
|
100
|
-
(admin_id, locality_id, relationship_type, admin_placetype, distance_km, locality_population)
|
|
101
|
-
VALUES (?, ?, ?, ?, ?, ?)`);
|
|
102
|
-
const byCountry = {};
|
|
103
|
-
let rowCount = 0;
|
|
104
|
-
db.exec("BEGIN");
|
|
105
|
-
try {
|
|
106
|
-
for (const c of candidates) {
|
|
107
|
-
const dist = haversine(c.rlat, c.rlon, c.llat, c.llon);
|
|
108
|
-
const diag = haversine(c.min_latitude, c.min_longitude, c.max_latitude, c.max_longitude);
|
|
109
|
-
const tolerance = Math.max(toleranceFraction * diag, minToleranceKm);
|
|
110
|
-
if (dist > tolerance)
|
|
111
|
-
continue;
|
|
112
|
-
// v1 is region-tier only: a place is `city-state` when its centroid coincides with the
|
|
113
|
-
// region's (Berlin, Hamburg), otherwise `capital-seat` (a region whose name matches its
|
|
114
|
-
// principal city, such as Milano province and Milano comune). `consolidated-county` is
|
|
115
|
-
// reserved for a future county-tier pass (US SF, Denver), excluded from v1 because
|
|
116
|
-
// county-tier same-name coincidences are dominated by French cantons and JP counties
|
|
117
|
-
// that do not hit the parser-drops-locality failure.
|
|
118
|
-
const relationshipType = dist <= cityStateMaxKm ? "city-state" : "capital-seat";
|
|
119
|
-
insert.run(c.admin_id, c.locality_id, relationshipType, c.admin_placetype, dist, c.pop);
|
|
120
|
-
rowCount++;
|
|
121
|
-
byCountry[c.country] = (byCountry[c.country] ?? 0) + 1;
|
|
122
|
-
}
|
|
123
|
-
db.exec("COMMIT");
|
|
124
|
-
}
|
|
125
|
-
catch (error) {
|
|
126
|
-
db.exec("ROLLBACK");
|
|
127
|
-
throw error;
|
|
128
|
-
}
|
|
129
|
-
db.exec(`CREATE INDEX IF NOT EXISTS coincident_roles_by_admin ON ${COINCIDENT_ROLES_TABLE} (admin_id)`);
|
|
130
|
-
onProgress("done", `${rowCount} coincident-role rows`);
|
|
131
|
-
return { created: true, rowCount, byCountry, durationMs: Date.now() - start };
|
|
132
|
-
}
|
|
133
|
-
/**
|
|
134
|
-
* True iff the relation table exists.
|
|
135
|
-
*
|
|
136
|
-
* Used by the resolver to decide whether completion can run.
|
|
137
|
-
*/
|
|
138
|
-
export function coincidentRolesExists(db) {
|
|
139
|
-
return tableExists(db, COINCIDENT_ROLES_TABLE);
|
|
140
|
-
}
|
|
141
|
-
/**
|
|
142
|
-
* Load the relation into an in-memory map keyed by `admin_id` for an O(1) runtime lookup.
|
|
143
|
-
*
|
|
144
|
-
* Each admin may map to multiple same-name descendants.
|
|
145
|
-
* The consumer disambiguates by minimum distance, then population, then abstains.
|
|
146
|
-
* Returns an empty map when the table is absent.
|
|
147
|
-
*/
|
|
148
|
-
export function loadCoincidentRoles(db) {
|
|
149
|
-
const map = new Map();
|
|
150
|
-
if (!coincidentRolesExists(db))
|
|
151
|
-
return map;
|
|
152
|
-
const rows = allRows(db.prepare(`SELECT admin_id, locality_id, relationship_type, admin_placetype, distance_km, locality_population
|
|
153
|
-
FROM ${COINCIDENT_ROLES_TABLE}`));
|
|
154
|
-
for (const r of rows) {
|
|
155
|
-
const entry = {
|
|
156
|
-
localityID: r.locality_id,
|
|
157
|
-
relationshipType: r.relationship_type,
|
|
158
|
-
adminPlacetype: r.admin_placetype,
|
|
159
|
-
distanceKm: r.distance_km,
|
|
160
|
-
population: r.locality_population,
|
|
161
|
-
};
|
|
162
|
-
const list = map.get(r.admin_id);
|
|
163
|
-
if (list) {
|
|
164
|
-
list.push(entry);
|
|
165
|
-
}
|
|
166
|
-
else {
|
|
167
|
-
map.set(r.admin_id, [entry]);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
return map;
|
|
171
|
-
}
|
|
172
|
-
/**
|
|
173
|
-
* The relation joined with `spr`, as an in-memory map keyed by `admin_id`.
|
|
174
|
-
*
|
|
175
|
-
* The resolver-facing view of {@link loadCoincidentRoles}, carrying the canonical name
|
|
176
|
-
* and coordinates each coincident locality resolves to.
|
|
177
|
-
* Returns an empty map when the relation table is absent.
|
|
178
|
-
*/
|
|
179
|
-
export function loadCoincidentLocalities(db) {
|
|
180
|
-
const map = new Map();
|
|
181
|
-
if (coincidentRolesExists(db)) {
|
|
182
|
-
const rows = allRows(db.prepare(`SELECT cr.admin_id AS adminID, s.id AS id, s.name AS name, s.country AS country,
|
|
183
|
-
s.latitude AS lat, s.longitude AS lon,
|
|
184
|
-
cr.relationship_type AS relationshipType, cr.locality_population AS population,
|
|
185
|
-
cr.distance_km AS distanceKm
|
|
186
|
-
FROM ${COINCIDENT_ROLES_TABLE} cr JOIN spr s ON s.id = cr.locality_id`));
|
|
187
|
-
for (const r of rows) {
|
|
188
|
-
const candidate = {
|
|
189
|
-
id: r.id,
|
|
190
|
-
name: r.name,
|
|
191
|
-
placetype: "locality",
|
|
192
|
-
country: r.country,
|
|
193
|
-
lat: r.lat,
|
|
194
|
-
lon: r.lon,
|
|
195
|
-
score: 0,
|
|
196
|
-
relationshipType: r.relationshipType,
|
|
197
|
-
population: r.population,
|
|
198
|
-
distanceKm: r.distanceKm,
|
|
199
|
-
parent_id: null,
|
|
200
|
-
prominence: null,
|
|
201
|
-
referential: null,
|
|
202
|
-
encyclopedic: null,
|
|
203
|
-
importance: null,
|
|
204
|
-
exactMatch: null,
|
|
205
|
-
mismatch: null,
|
|
206
|
-
resolutionQuality: null,
|
|
207
|
-
containedByQualifier: null,
|
|
208
|
-
regionScopeMiss: null,
|
|
209
|
-
variantAliasExempted: null,
|
|
210
|
-
};
|
|
211
|
-
const list = map.get(r.adminID);
|
|
212
|
-
if (list) {
|
|
213
|
-
list.push(candidate);
|
|
214
|
-
}
|
|
215
|
-
else {
|
|
216
|
-
map.set(r.adminID, [candidate]);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
return map;
|
|
221
|
-
}
|
|
222
|
-
//# sourceMappingURL=coincident-roles.js.map
|