@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.
Files changed (54) hide show
  1. package/lib/address/point.ts +44 -1
  2. package/lib/build/slim.ts +0 -19
  3. package/lib/candidate/lookup.ts +9 -4
  4. package/lib/index.ts +0 -1
  5. package/lib/landmark/rows.ts +91 -0
  6. package/lib/landmark.ts +94 -0
  7. package/lib/lookup.ts +1 -21
  8. package/lib/primary-preference.ts +13 -20
  9. package/lib/schema.ts +0 -18
  10. package/lib/street/normalize.ts +91 -0
  11. package/lib/types.ts +7 -2
  12. package/out/address/point.d.ts.map +1 -1
  13. package/out/address/point.js +36 -1
  14. package/out/address/point.js.map +1 -1
  15. package/out/build/slim.d.ts +1 -1
  16. package/out/build/slim.d.ts.map +1 -1
  17. package/out/build/slim.js +0 -12
  18. package/out/build/slim.js.map +1 -1
  19. package/out/candidate/lookup.d.ts.map +1 -1
  20. package/out/candidate/lookup.js +8 -4
  21. package/out/candidate/lookup.js.map +1 -1
  22. package/out/index.d.ts +1 -1
  23. package/out/index.d.ts.map +1 -1
  24. package/out/index.js.map +1 -1
  25. package/out/landmark/rows.d.ts +63 -0
  26. package/out/landmark/rows.d.ts.map +1 -0
  27. package/out/landmark/rows.js +48 -0
  28. package/out/landmark/rows.js.map +1 -0
  29. package/out/landmark.d.ts +40 -0
  30. package/out/landmark.d.ts.map +1 -0
  31. package/out/landmark.js +78 -0
  32. package/out/landmark.js.map +1 -0
  33. package/out/lookup.d.ts +1 -5
  34. package/out/lookup.d.ts.map +1 -1
  35. package/out/lookup.js +0 -17
  36. package/out/lookup.js.map +1 -1
  37. package/out/primary-preference.d.ts +10 -10
  38. package/out/primary-preference.d.ts.map +1 -1
  39. package/out/primary-preference.js +8 -9
  40. package/out/primary-preference.js.map +1 -1
  41. package/out/schema.d.ts +0 -17
  42. package/out/schema.d.ts.map +1 -1
  43. package/out/street/normalize.d.ts +7 -0
  44. package/out/street/normalize.d.ts.map +1 -1
  45. package/out/street/normalize.js +75 -0
  46. package/out/street/normalize.js.map +1 -1
  47. package/out/types.d.ts +6 -2
  48. package/out/types.d.ts.map +1 -1
  49. package/package.json +12 -12
  50. package/lib/coincident-roles.ts +0 -347
  51. package/out/coincident-roles.d.ts +0 -121
  52. package/out/coincident-roles.d.ts.map +0 -1
  53. package/out/coincident-roles.js +0 -222
  54. package/out/coincident-roles.js.map +0 -1
@@ -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"}
@@ -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