@mailwoman/resolver-wof-sqlite 8.0.0 → 8.2.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/address-point-interpolation.ts +9 -3
- package/address-point-schema.ts +32 -10
- package/address-point.ts +3 -0
- package/ancestry-backfill.ts +18 -5
- package/ancestry.ts +7 -2
- package/build-candidate.ts +29 -6
- package/build-slim.ts +40 -11
- package/candidate-fts.ts +1 -0
- package/candidate-lookup.ts +49 -15
- package/candidate-schema.ts +35 -11
- package/coincident-roles.ts +28 -6
- package/convention.ts +3 -1
- package/coverage-manifest-schema.ts +243 -0
- package/fst-autocomplete.ts +15 -9
- package/fst-builder.ts +65 -9
- package/fst-deserialize-web.ts +46 -9
- package/fst-matcher.ts +12 -5
- package/fst-serialize.ts +83 -9
- package/fst-types.ts +43 -0
- package/fts-query.ts +84 -0
- package/fts.ts +35 -9
- package/geo.ts +9 -3
- package/geonames-aliases.ts +116 -79
- package/geonames-postal.ts +25 -5
- package/index.ts +19 -0
- package/interpolation.ts +59 -55
- package/lookup.ts +103 -292
- package/name-score.ts +76 -0
- package/out/address-point-interpolation.d.ts.map +1 -1
- package/out/address-point-interpolation.js +4 -2
- package/out/address-point-interpolation.js.map +1 -1
- package/out/address-point-schema.d.ts +30 -10
- package/out/address-point-schema.d.ts.map +1 -1
- package/out/address-point-schema.js +6 -2
- package/out/address-point-schema.js.map +1 -1
- package/out/address-point.d.ts.map +1 -1
- package/out/address-point.js.map +1 -1
- package/out/ancestry-backfill.d.ts +6 -2
- package/out/ancestry-backfill.d.ts.map +1 -1
- package/out/ancestry-backfill.js +7 -3
- package/out/ancestry-backfill.js.map +1 -1
- package/out/ancestry.d.ts +6 -2
- package/out/ancestry.d.ts.map +1 -1
- package/out/ancestry.js +3 -1
- package/out/ancestry.js.map +1 -1
- package/out/build-candidate.d.ts +9 -3
- package/out/build-candidate.d.ts.map +1 -1
- package/out/build-candidate.js +5 -3
- package/out/build-candidate.js.map +1 -1
- package/out/build-slim.d.ts +15 -5
- package/out/build-slim.d.ts.map +1 -1
- package/out/build-slim.js +11 -5
- package/out/build-slim.js.map +1 -1
- package/out/candidate-fts.d.ts.map +1 -1
- package/out/candidate-fts.js.map +1 -1
- package/out/candidate-lookup.d.ts +16 -3
- package/out/candidate-lookup.d.ts.map +1 -1
- package/out/candidate-lookup.js +27 -11
- package/out/candidate-lookup.js.map +1 -1
- package/out/candidate-schema.d.ts +33 -11
- package/out/candidate-schema.d.ts.map +1 -1
- package/out/candidate-schema.js.map +1 -1
- package/out/coincident-roles.d.ts +16 -4
- package/out/coincident-roles.d.ts.map +1 -1
- package/out/coincident-roles.js +9 -3
- package/out/coincident-roles.js.map +1 -1
- package/out/convention.d.ts +3 -1
- package/out/convention.d.ts.map +1 -1
- package/out/convention.js.map +1 -1
- package/out/coverage-manifest-schema.d.ts +112 -0
- package/out/coverage-manifest-schema.d.ts.map +1 -0
- package/out/coverage-manifest-schema.js +154 -0
- package/out/coverage-manifest-schema.js.map +1 -0
- package/out/fst-autocomplete.d.ts +1 -1
- package/out/fst-autocomplete.d.ts.map +1 -1
- package/out/fst-autocomplete.js +11 -9
- package/out/fst-autocomplete.js.map +1 -1
- package/out/fst-builder.d.ts.map +1 -1
- package/out/fst-builder.js +42 -9
- package/out/fst-builder.js.map +1 -1
- package/out/fst-deserialize-web.d.ts.map +1 -1
- package/out/fst-deserialize-web.js +34 -9
- package/out/fst-deserialize-web.js.map +1 -1
- package/out/fst-matcher.d.ts +6 -2
- package/out/fst-matcher.d.ts.map +1 -1
- package/out/fst-matcher.js +9 -5
- package/out/fst-matcher.js.map +1 -1
- package/out/fst-serialize.d.ts.map +1 -1
- package/out/fst-serialize.js +62 -9
- package/out/fst-serialize.js.map +1 -1
- package/out/fst-types.d.ts +43 -0
- package/out/fst-types.d.ts.map +1 -1
- package/out/fts-query.d.ts +41 -0
- package/out/fts-query.d.ts.map +1 -0
- package/out/fts-query.js +75 -0
- package/out/fts-query.js.map +1 -0
- package/out/fts.d.ts +21 -7
- package/out/fts.d.ts.map +1 -1
- package/out/fts.js +10 -4
- package/out/fts.js.map +1 -1
- package/out/geo.d.ts +6 -2
- package/out/geo.d.ts.map +1 -1
- package/out/geo.js +3 -1
- package/out/geo.js.map +1 -1
- package/out/geonames-aliases.d.ts +12 -4
- package/out/geonames-aliases.d.ts.map +1 -1
- package/out/geonames-aliases.js +72 -67
- package/out/geonames-aliases.js.map +1 -1
- package/out/geonames-postal.d.ts +9 -3
- package/out/geonames-postal.d.ts.map +1 -1
- package/out/geonames-postal.js +7 -2
- package/out/geonames-postal.js.map +1 -1
- package/out/index.d.ts +2 -0
- package/out/index.d.ts.map +1 -1
- package/out/index.js +1 -0
- package/out/index.js.map +1 -1
- package/out/interpolation.d.ts +24 -6
- package/out/interpolation.d.ts.map +1 -1
- package/out/interpolation.js +32 -40
- package/out/interpolation.js.map +1 -1
- package/out/lookup.d.ts +3 -97
- package/out/lookup.d.ts.map +1 -1
- package/out/lookup.js +52 -184
- package/out/lookup.js.map +1 -1
- package/out/name-score.d.ts +28 -0
- package/out/name-score.d.ts.map +1 -0
- package/out/name-score.js +67 -0
- package/out/name-score.js.map +1 -0
- package/out/poi-lookup.d.ts +24 -8
- package/out/poi-lookup.d.ts.map +1 -1
- package/out/poi-lookup.js +27 -13
- package/out/poi-lookup.js.map +1 -1
- package/out/poi-schema.d.ts +42 -13
- package/out/poi-schema.d.ts.map +1 -1
- package/out/poi-schema.js +12 -3
- package/out/poi-schema.js.map +1 -1
- package/out/postal-city-alias-lookup.d.ts +18 -6
- package/out/postal-city-alias-lookup.d.ts.map +1 -1
- package/out/postal-city-alias-lookup.js.map +1 -1
- package/out/postal-city-alias-schema.d.ts +27 -9
- package/out/postal-city-alias-schema.d.ts.map +1 -1
- package/out/postal-city-alias-schema.js +3 -1
- package/out/postal-city-alias-schema.js.map +1 -1
- package/out/postal-city-candidate-schema.d.ts +15 -5
- package/out/postal-city-candidate-schema.d.ts.map +1 -1
- package/out/postal-city-candidate-schema.js +3 -1
- package/out/postal-city-candidate-schema.js.map +1 -1
- package/out/postcode-point-lookup.d.ts +6 -2
- package/out/postcode-point-lookup.d.ts.map +1 -1
- package/out/postcode-point-lookup.js +6 -2
- package/out/postcode-point-lookup.js.map +1 -1
- package/out/ranking-weights.d.ts +118 -0
- package/out/ranking-weights.d.ts.map +1 -0
- package/out/ranking-weights.js +44 -0
- package/out/ranking-weights.js.map +1 -0
- package/out/reverse.d.ts +9 -3
- package/out/reverse.d.ts.map +1 -1
- package/out/reverse.js +20 -6
- package/out/reverse.js.map +1 -1
- package/out/sharding.d.ts +3 -1
- package/out/sharding.d.ts.map +1 -1
- package/out/sharding.js +7 -5
- package/out/sharding.js.map +1 -1
- package/out/sqlite-convention-source.d.ts.map +1 -1
- package/out/sqlite-convention-source.js +3 -1
- package/out/sqlite-convention-source.js.map +1 -1
- package/out/street-centroid-schema.d.ts +33 -11
- package/out/street-centroid-schema.d.ts.map +1 -1
- package/out/street-centroid-schema.js +3 -1
- package/out/street-centroid-schema.js.map +1 -1
- package/out/street-centroid.d.ts.map +1 -1
- package/out/street-centroid.js +6 -2
- package/out/street-centroid.js.map +1 -1
- package/out/street-morphology-fst-builder.d.ts +6 -2
- package/out/street-morphology-fst-builder.d.ts.map +1 -1
- package/out/street-morphology-fst-builder.js +8 -7
- package/out/street-morphology-fst-builder.js.map +1 -1
- package/out/street-morphology-fst-loader.d.ts +67 -0
- package/out/street-morphology-fst-loader.d.ts.map +1 -0
- package/out/street-morphology-fst-loader.js +59 -0
- package/out/street-morphology-fst-loader.js.map +1 -0
- package/out/street-name-lookup.d.ts +9 -3
- package/out/street-name-lookup.d.ts.map +1 -1
- package/out/street-name-lookup.js +9 -7
- package/out/street-name-lookup.js.map +1 -1
- package/out/street-normalize.d.ts +3 -1
- package/out/street-normalize.d.ts.map +1 -1
- package/out/street-normalize.js +23 -13
- package/out/street-normalize.js.map +1 -1
- package/out/street-segment-schema.d.ts +68 -13
- package/out/street-segment-schema.d.ts.map +1 -1
- package/out/street-segment-schema.js +21 -2
- package/out/street-segment-schema.js.map +1 -1
- package/out/types.d.ts +18 -6
- package/out/types.d.ts.map +1 -1
- package/out/unified-schema.d.ts +1 -1
- package/out/unified-schema.d.ts.map +1 -1
- package/out/unified-schema.js +2 -2
- package/out/unified-schema.js.map +1 -1
- package/package.json +13 -5
- package/poi-lookup.ts +53 -21
- package/poi-schema.ts +43 -13
- package/postal-city-alias-lookup.ts +20 -6
- package/postal-city-alias-schema.ts +28 -9
- package/postal-city-candidate-schema.ts +15 -5
- package/postcode-point-lookup.ts +6 -2
- package/ranking-weights.ts +148 -0
- package/reverse.ts +47 -10
- package/sharding.ts +13 -6
- package/sqlite-convention-source.ts +4 -1
- package/street-centroid-schema.ts +35 -11
- package/street-centroid.ts +10 -3
- package/street-morphology-fst-builder.ts +25 -9
- package/street-morphology-fst-loader.ts +103 -0
- package/street-name-lookup.ts +19 -7
- package/street-normalize.ts +28 -13
- package/street-segment-schema.ts +83 -13
- package/types.ts +18 -6
- package/unified-schema.ts +11 -2
package/candidate-lookup.ts
CHANGED
|
@@ -25,10 +25,11 @@
|
|
|
25
25
|
|
|
26
26
|
import { DatabaseSync } from "node:sqlite"
|
|
27
27
|
|
|
28
|
-
import { expandPlacetypeFilter } from "@mailwoman/resolver"
|
|
28
|
+
import { expandPlacetypeFilter, type GazetteerArtifactCoverage } from "@mailwoman/resolver"
|
|
29
29
|
|
|
30
30
|
import { CANDIDATE_FTS_TABLE } from "./candidate-fts.ts"
|
|
31
31
|
import type { CandidateTable, CountryCodeTable, PlacetypeCodeTable } from "./candidate-schema.ts"
|
|
32
|
+
import { readGazetteerCoverageManifest } from "./coverage-manifest-schema.ts"
|
|
32
33
|
import { haversineKm } from "./geo.ts"
|
|
33
34
|
import { trigramJaccard } from "./lookup.ts"
|
|
34
35
|
import { POSTAL_CITY_CANDIDATE_TABLE, type PostalCityCandidateTable } from "./postal-city-candidate-schema.ts"
|
|
@@ -37,9 +38,13 @@ import { normalizeLocalityForKey, stripLocalityQualifier } from "./street-normal
|
|
|
37
38
|
import type { FindPlaceQuery, PlaceCandidate, PlaceLookup, WOFPlacetype } from "./types.ts"
|
|
38
39
|
|
|
39
40
|
export interface WOFCandidateTableLookupOpts {
|
|
40
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Path to a `candidate.db` built by `build-candidate.ts`. Opened read-only.
|
|
43
|
+
*/
|
|
41
44
|
databasePath?: string
|
|
42
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* Pre-opened handle (tests / shared connections). Mutually exclusive with `databasePath`.
|
|
47
|
+
*/
|
|
43
48
|
database?: DatabaseSync
|
|
44
49
|
}
|
|
45
50
|
|
|
@@ -91,7 +96,7 @@ const FUZZY_MIN = 0.34
|
|
|
91
96
|
* ("Los Angeles" over La, Ghana — gap 1.6; "Las Vegas" over Vegas, Cuba — gap 2.4) while a near-tie coincidental
|
|
92
97
|
* collision defers to the primary (Cancún over Changchun — gap 0.7).
|
|
93
98
|
*/
|
|
94
|
-
const PRIMARY_PREFERENCE_LOG10 = 1
|
|
99
|
+
const PRIMARY_PREFERENCE_LOG10 = 1
|
|
95
100
|
|
|
96
101
|
/**
|
|
97
102
|
* Over-fetch cap for {@link rankByPrimaryPreference}: the candidate rows for one `name_key` (all same-name places
|
|
@@ -101,7 +106,9 @@ const PRIMARY_PREFERENCE_LOG10 = 1.0
|
|
|
101
106
|
*/
|
|
102
107
|
const RERANK_FETCH = 64
|
|
103
108
|
|
|
104
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* A candidate row annotated with the {@link rankByPrimaryPreference} effective rank + the exact-tier demotion flag.
|
|
111
|
+
*/
|
|
105
112
|
export type RankedRow<R> = R & {
|
|
106
113
|
/**
|
|
107
114
|
* `neg_rank` plus the bounded cross-country alias penalty — the value the row is ORDERED by, and the base the emitted
|
|
@@ -143,10 +150,12 @@ export function rankByPrimaryPreference<R extends Pick<CandidateRow, "neg_rank"
|
|
|
143
150
|
}
|
|
144
151
|
|
|
145
152
|
const topCountry = topPrimary?.country_id
|
|
153
|
+
|
|
146
154
|
// A cross-country alias (different country than the top primary) is penalized; it is DEMOTED when even after — i.e.
|
|
147
155
|
// the penalty leaves its effective rank behind the primary's raw rank (it lost the bounded population contest).
|
|
148
156
|
const isCrossCountryAlias = (r: R): boolean =>
|
|
149
157
|
topCountry !== undefined && r.is_primary !== 1 && r.country_id !== topCountry
|
|
158
|
+
|
|
150
159
|
const annotate = (r: R): RankedRow<R> => {
|
|
151
160
|
const penalized = isCrossCountryAlias(r)
|
|
152
161
|
const effectiveNegRank = r.neg_rank + (penalized ? delta : 0)
|
|
@@ -158,6 +167,7 @@ export function rankByPrimaryPreference<R extends Pick<CandidateRow, "neg_rank"
|
|
|
158
167
|
rows
|
|
159
168
|
.map((r, i) => ({ row: annotate(r), i }))
|
|
160
169
|
// Effective rank ASC; ties keep population order, then original index (stable).
|
|
170
|
+
// oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
|
|
161
171
|
.sort((a, b) => a.row.effectiveNegRank - b.row.effectiveNegRank || a.row.neg_rank - b.row.neg_rank || a.i - b.i)
|
|
162
172
|
.slice(0, limit)
|
|
163
173
|
.map((x) => x.row)
|
|
@@ -212,6 +222,12 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
212
222
|
* country-agnostic retry. Prepared only alongside `#ftsProbe`.
|
|
213
223
|
*/
|
|
214
224
|
readonly #nameKeyExistsProbe: ReturnType<DatabaseSync["prepare"]> | undefined
|
|
225
|
+
/**
|
|
226
|
+
* Facts this candidate DB declares about itself — the coverage manifest (`country_coverage` + `country_bbox`) the
|
|
227
|
+
* gazetteer build emits, read once at open. `undefined` when the artifact predates the manifest, so every consumer
|
|
228
|
+
* (the hard-country coverage gate, guard-B plausibility) falls back to its code constants byte-identically.
|
|
229
|
+
*/
|
|
230
|
+
readonly artifactCoverage: GazetteerArtifactCoverage | undefined
|
|
215
231
|
|
|
216
232
|
constructor(opts: WOFCandidateTableLookupOpts) {
|
|
217
233
|
if (opts.database) {
|
|
@@ -253,11 +269,19 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
253
269
|
this.#ftsProbe = this.#db.prepare(
|
|
254
270
|
`SELECT name_key FROM ${CANDIDATE_FTS_TABLE} WHERE ${CANDIDATE_FTS_TABLE} MATCH ? ORDER BY bm25(${CANDIDATE_FTS_TABLE}) LIMIT ?`
|
|
255
271
|
)
|
|
272
|
+
|
|
256
273
|
this.#nameKeyExistsProbe = this.#db.prepare("SELECT 1 FROM candidate WHERE name_key = ? LIMIT 1")
|
|
257
274
|
}
|
|
275
|
+
|
|
276
|
+
// Coverage manifest (survey candidate #2): the artifact's own coverage facts, existence-gated like
|
|
277
|
+
// the probes above — a candidate.db built before the manifest reads `undefined` and consumers keep
|
|
278
|
+
// their code-constant fallbacks byte-identically.
|
|
279
|
+
this.artifactCoverage = readGazetteerCoverageManifest(this.#db)
|
|
258
280
|
}
|
|
259
281
|
|
|
260
|
-
/**
|
|
282
|
+
/**
|
|
283
|
+
* Does this query want a locality-tier place? Postal-city aliases (#741) are all localities.
|
|
284
|
+
*/
|
|
261
285
|
#wantsLocality(placetype: FindPlaceQuery["placetype"]): boolean {
|
|
262
286
|
if (!placetype) return true
|
|
263
287
|
const want = Array.isArray(placetype) ? placetype : [placetype]
|
|
@@ -274,8 +298,9 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
274
298
|
// form at build (the GeoNames fold normalizes '624 66' → '62466'), so a postcode-typed query
|
|
275
299
|
// strips internal whitespace before keying. Postcode-only — locality names keep their spaces.
|
|
276
300
|
if ([query.placetype].flat().includes("postalcode")) {
|
|
277
|
-
text = text.
|
|
301
|
+
text = text.replaceAll(/\s+/g, "")
|
|
278
302
|
}
|
|
303
|
+
|
|
279
304
|
const nameKey = normalizeLocalityForKey(text)
|
|
280
305
|
|
|
281
306
|
if (!nameKey) return []
|
|
@@ -325,11 +350,12 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
325
350
|
// Shared placetype-equivalence expansion (a `locality` query must also reach borough /
|
|
326
351
|
// localadmin). `postalcode` maps to no admin placetype here → empty → no rows.
|
|
327
352
|
const want = Array.isArray(query.placetype) ? query.placetype : [query.placetype]
|
|
353
|
+
|
|
328
354
|
const ids = expandPlacetypeFilter(want as readonly string[])
|
|
329
355
|
.map((t) => this.#placetypeToID.get(t))
|
|
330
356
|
.filter((v): v is number => v !== undefined)
|
|
331
357
|
|
|
332
|
-
if (ids.length
|
|
358
|
+
if (!ids.length) return []
|
|
333
359
|
filters.push(`placetype_id IN (${ids.map(() => "?").join(",")})`)
|
|
334
360
|
filterParams.push(...ids)
|
|
335
361
|
}
|
|
@@ -348,7 +374,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
348
374
|
// Kept OUT of the shared `filters` so a region MISS falls back to the unscoped cascade below: a
|
|
349
375
|
// country/non-region parent (no `region_id` match), a `region_id=0` row (place with no region
|
|
350
376
|
// ancestor), or a wrong parent degrades to today's behavior — never worse, recall-safe by construction.
|
|
351
|
-
const regionParentID = query.parentID
|
|
377
|
+
const regionParentID = query.parentID || undefined
|
|
352
378
|
|
|
353
379
|
const probe = (nk: string, regionID: number | undefined): Array<RankedRow<CandidateRow>> => {
|
|
354
380
|
const conds = ["name_key = ?", ...filters]
|
|
@@ -367,6 +393,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
367
393
|
const sql =
|
|
368
394
|
"SELECT spr_id, name, country_id, placetype_id, latitude, longitude, min_lat, min_lon, max_lat, max_lon, neg_rank, is_primary " +
|
|
369
395
|
`FROM candidate WHERE ${conds.join(" AND ")} ORDER BY neg_rank ASC LIMIT ?`
|
|
396
|
+
|
|
370
397
|
const fetched = this.#db.prepare(sql).all(...params, Math.max(limit, RERANK_FETCH)) as unknown as CandidateRow[]
|
|
371
398
|
|
|
372
399
|
return rankByPrimaryPreference(fetched, limit)
|
|
@@ -378,7 +405,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
378
405
|
const cascade = (regionID: number | undefined): Array<RankedRow<CandidateRow>> => {
|
|
379
406
|
let rows = probe(nameKey, regionID)
|
|
380
407
|
|
|
381
|
-
if (rows.length
|
|
408
|
+
if (!rows.length) {
|
|
382
409
|
// Query-side qualifier-strip fallback: an OA locality with a qualifier the gazetteer's
|
|
383
410
|
// canonical name omits ("Lenk im Simmental" → "Lenk", "Roche VD"). Tried ONLY on an exact
|
|
384
411
|
// miss; the cascade's region bbox disambiguates any base-name ambiguity.
|
|
@@ -400,15 +427,18 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
400
427
|
// — fuzzing it scrapes an unrelated same-filter place ("Vienna, Austria" misrouted to IT would
|
|
401
428
|
// pull a tiny Italian name_key near Siena) and masks the cascade's country-agnostic retry that
|
|
402
429
|
// correctly lands population-first Vienna AT. The exact/strip probes already covered the real name.
|
|
403
|
-
if (rows.length
|
|
430
|
+
if (!rows.length && this.#ftsProbe && this.#nameKeyExistsProbe && !this.#nameKeyExistsProbe.get(nameKey)) {
|
|
404
431
|
const match = ftsTrigramQuery(nameKey)
|
|
405
432
|
|
|
406
433
|
if (match) {
|
|
407
434
|
const hits = this.#ftsProbe.all(match, FUZZY_FETCH) as unknown as Array<{ name_key: string }>
|
|
435
|
+
|
|
408
436
|
const ranked = hits
|
|
409
437
|
.map((h) => ({ nk: String(h.name_key), s: trigramJaccard(nameKey, String(h.name_key)) }))
|
|
410
438
|
.filter((h) => h.s >= FUZZY_MIN)
|
|
439
|
+
// oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
|
|
411
440
|
.sort((a, b) => b.s - a.s)
|
|
441
|
+
|
|
412
442
|
const seen = new Set<string>()
|
|
413
443
|
|
|
414
444
|
for (const h of ranked) {
|
|
@@ -418,6 +448,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
418
448
|
|
|
419
449
|
if (rows.length >= limit) break
|
|
420
450
|
}
|
|
451
|
+
|
|
421
452
|
rows = rows.slice(0, limit)
|
|
422
453
|
}
|
|
423
454
|
}
|
|
@@ -430,7 +461,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
430
461
|
// Region-scope fallback: if scoping to the parent region found nothing across the whole cascade, retry
|
|
431
462
|
// unscoped so a place with no in-region row (missing ancestry, or a country/non-region parent) still
|
|
432
463
|
// resolves exactly as it does today. Only when a region scope was actually applied.
|
|
433
|
-
if (rows.length
|
|
464
|
+
if (!rows.length && regionParentID !== undefined) {
|
|
434
465
|
rows = cascade(undefined)
|
|
435
466
|
}
|
|
436
467
|
|
|
@@ -480,9 +511,9 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
480
511
|
// log10(population + 1), so popTerm is the server formula read straight off it. Constants MIRROR
|
|
481
512
|
// lookup.ts's DEFAULT_WEIGHTS (biasBoost 4, populationBoost 4, populationScaleLog10 6,
|
|
482
513
|
// proximityScaleKm 100) — the #861 server↔demo parity contract; keep them in lockstep.
|
|
483
|
-
if (query.bias && query.bias.length
|
|
484
|
-
const BIAS_BOOST = 4
|
|
485
|
-
const POP_BOOST = 4
|
|
514
|
+
if (query.bias && query.bias.length) {
|
|
515
|
+
const BIAS_BOOST = 4
|
|
516
|
+
const POP_BOOST = 4
|
|
486
517
|
const POP_SCALE_LOG10 = 6
|
|
487
518
|
// SHARPER than lookup.ts's 100 km on purpose: this backend's `score` is log-population ALONE
|
|
488
519
|
// (no bm25 document term), so the population signal is weaker relative to the bias and the
|
|
@@ -491,6 +522,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
491
522
|
// candidates the user is actually LOOKING at — an in-view namesake still wins (Dublin, OH from
|
|
492
523
|
// an Ohio view), a distant one no longer does (Paris stays FR from a Michigan view).
|
|
493
524
|
const PROX_SCALE_KM = 30
|
|
525
|
+
|
|
494
526
|
const combinedProminence = (c: PlaceCandidate): number => {
|
|
495
527
|
// Population base is the PENALIZED `prominence` (set above = -effectiveNegRank), not raw `score`, so
|
|
496
528
|
// the cross-country primary preference carries into the bias-weighted order too — a coincidental
|
|
@@ -512,6 +544,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
512
544
|
|
|
513
545
|
return popTerm + proxTerm
|
|
514
546
|
}
|
|
547
|
+
|
|
515
548
|
// Persist the combined value into `prominence` so the resolver walk's `prominence ?? score` sort (and any
|
|
516
549
|
// other node consumer) honors the bias order — then sort. Stable within equal prominence (preserves the
|
|
517
550
|
// population order the B-tree already gave).
|
|
@@ -521,6 +554,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
521
554
|
|
|
522
555
|
return { c, i, p: c.prominence }
|
|
523
556
|
})
|
|
557
|
+
// oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
|
|
524
558
|
.sort((a, b) => b.p - a.p || a.i - b.i)
|
|
525
559
|
.forEach((x, j) => (candidates[j] = x.c))
|
|
526
560
|
}
|
package/candidate-schema.ts
CHANGED
|
@@ -23,19 +23,29 @@ import { sql, type Kysely } from "kysely"
|
|
|
23
23
|
* level (a postcode shard row may lack a bbox).
|
|
24
24
|
*/
|
|
25
25
|
export interface CandidateTable {
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* The shared {@link normalizeLocalityForKey} of the name/alias — the probe key.
|
|
28
|
+
*/
|
|
27
29
|
name_key: string
|
|
28
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Small int from {@link CountryCodeTable} (shrinks the clustered key).
|
|
32
|
+
*/
|
|
29
33
|
country_id: number
|
|
30
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* The place's region-tier ancestor id, or 0 (carried for the future region 2-step).
|
|
36
|
+
*/
|
|
31
37
|
region_id: number
|
|
32
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Small int from {@link PlacetypeCodeTable}.
|
|
40
|
+
*/
|
|
33
41
|
placetype_id: number
|
|
34
42
|
/**
|
|
35
43
|
* `-log10(population + 1)` — ASC order = highest-population first. 0 for postcodes (no population).
|
|
36
44
|
*/
|
|
37
45
|
neg_rank: number
|
|
38
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* WOF id of the place this row resolves to.
|
|
48
|
+
*/
|
|
39
49
|
spr_id: number
|
|
40
50
|
name: string | null
|
|
41
51
|
latitude: number | null
|
|
@@ -45,27 +55,39 @@ export interface CandidateTable {
|
|
|
45
55
|
max_lat: number | null
|
|
46
56
|
max_lon: number | null
|
|
47
57
|
population: number | null
|
|
48
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* 1 when the row is the place's canonical name (vs an alias/abbrev).
|
|
60
|
+
*/
|
|
49
61
|
is_primary: number | null
|
|
50
62
|
}
|
|
51
63
|
|
|
52
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* `(id → ISO country code)` dictionary.
|
|
66
|
+
*/
|
|
53
67
|
export interface CountryCodeTable {
|
|
54
68
|
id: number
|
|
55
69
|
code: string
|
|
56
70
|
}
|
|
57
71
|
|
|
58
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* `(id → placetype)` dictionary.
|
|
74
|
+
*/
|
|
59
75
|
export interface PlacetypeCodeTable {
|
|
60
76
|
id: number
|
|
61
77
|
placetype: string
|
|
62
78
|
}
|
|
63
79
|
|
|
64
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* The candidate database schema for `new DatabaseClient<CandidateDatabase>(...)`.
|
|
82
|
+
*/
|
|
65
83
|
export interface CandidateDatabase {
|
|
66
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* The clustered `WITHOUT ROWID` lookup table the reader probes.
|
|
86
|
+
*/
|
|
67
87
|
candidate: CandidateTable
|
|
68
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* Transient staging table (same columns); dropped once `candidate` is materialized.
|
|
90
|
+
*/
|
|
69
91
|
cand_stage: CandidateTable
|
|
70
92
|
country_codes: CountryCodeTable
|
|
71
93
|
placetype_codes: PlacetypeCodeTable
|
|
@@ -105,11 +127,13 @@ export async function createCandidateStagingTables(db: Kysely<CandidateDatabase>
|
|
|
105
127
|
.addColumn("id", "integer", (c) => c.primaryKey())
|
|
106
128
|
.addColumn("code", "text", (c) => c.unique())
|
|
107
129
|
.execute()
|
|
130
|
+
|
|
108
131
|
await db.schema
|
|
109
132
|
.createTable("placetype_codes")
|
|
110
133
|
.addColumn("id", "integer", (c) => c.primaryKey())
|
|
111
134
|
.addColumn("placetype", "text", (c) => c.unique())
|
|
112
135
|
.execute()
|
|
136
|
+
|
|
113
137
|
await db.schema
|
|
114
138
|
.createTable("cand_stage")
|
|
115
139
|
.addColumn("name_key", "text")
|
package/coincident-roles.ts
CHANGED
|
@@ -42,9 +42,15 @@ import type { DatabaseSync } from "node:sqlite"
|
|
|
42
42
|
|
|
43
43
|
import { haversineKm } from "@mailwoman/spatial"
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Table of places that hold more than one admin role — a locality that is also its county seat. Written by the
|
|
47
|
+
* gazetteer build, read by the resolver when a coincident locality has to be chosen.
|
|
48
|
+
*/
|
|
45
49
|
export const COINCIDENT_ROLES_TABLE = "coincident_roles"
|
|
46
50
|
|
|
47
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* A place that plays multiple admin roles — one row of the relation, keyed by `admin_id`.
|
|
53
|
+
*/
|
|
48
54
|
export interface CoincidentRole {
|
|
49
55
|
localityID: number
|
|
50
56
|
relationshipType: "city-state" | "capital-seat" | "consolidated-county"
|
|
@@ -54,13 +60,17 @@ export interface CoincidentRole {
|
|
|
54
60
|
}
|
|
55
61
|
|
|
56
62
|
export interface BuildCoincidentRolesOpts {
|
|
57
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Drop + rebuild the table if it already exists. Default true (the build is cheap + idempotent).
|
|
65
|
+
*/
|
|
58
66
|
drop?: boolean
|
|
59
67
|
/**
|
|
60
68
|
* Relative tolerance: a pair is kept when centroid distance ≤ `toleranceFraction × bbox-diagonal`. Default 0.15.
|
|
61
69
|
*/
|
|
62
70
|
toleranceFraction?: number
|
|
63
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* Floor (km) under the relative tolerance, so small-bbox city-states still qualify. Default 12.
|
|
73
|
+
*/
|
|
64
74
|
minToleranceKm?: number
|
|
65
75
|
/**
|
|
66
76
|
* Centroid distance (km) below which a region-tier pair is classed `city-state` (metadata only). Default 2.
|
|
@@ -115,7 +125,9 @@ export function buildCoincidentRoles(
|
|
|
115
125
|
onProgress("dropping", COINCIDENT_ROLES_TABLE)
|
|
116
126
|
db.exec(`DROP TABLE ${COINCIDENT_ROLES_TABLE}`)
|
|
117
127
|
}
|
|
128
|
+
|
|
118
129
|
onProgress("creating", COINCIDENT_ROLES_TABLE)
|
|
130
|
+
|
|
119
131
|
// Raw DDL by design: this is a sync builder consumed by a sync CLI (build-coincident-roles-cli) and
|
|
120
132
|
// 6 sync unit tests, so routing one table through async Kysely would cascade async through all of
|
|
121
133
|
// them for no real gain. See AGENTS.md "Database / inline SQL". (The SELECT + INSERT loop below are
|
|
@@ -133,6 +145,7 @@ export function buildCoincidentRoles(
|
|
|
133
145
|
`)
|
|
134
146
|
|
|
135
147
|
onProgress("scanning")
|
|
148
|
+
|
|
136
149
|
// Admin (region/county tier) ⋈ same-name DESCENDANT locality. `place_population` is optional (LEFT
|
|
137
150
|
// JOIN → 0 when absent). The relative-tolerance filter + relationship classification happen in JS so
|
|
138
151
|
// the SQL stays a plain join. `spr` exposes the bbox columns we need for the diagonal.
|
|
@@ -153,11 +166,13 @@ export function buildCoincidentRoles(
|
|
|
153
166
|
.all() as unknown as CandidateRow[]
|
|
154
167
|
|
|
155
168
|
onProgress("filtering", `${candidates.length} candidates`)
|
|
169
|
+
|
|
156
170
|
const insert = db.prepare(
|
|
157
171
|
`INSERT OR REPLACE INTO ${COINCIDENT_ROLES_TABLE}
|
|
158
172
|
(admin_id, locality_id, relationship_type, admin_placetype, distance_km, locality_population)
|
|
159
173
|
VALUES (?, ?, ?, ?, ?, ?)`
|
|
160
174
|
)
|
|
175
|
+
|
|
161
176
|
const byCountry: Record<string, number> = {}
|
|
162
177
|
let rowCount = 0
|
|
163
178
|
db.exec("BEGIN")
|
|
@@ -176,14 +191,17 @@ export function buildCoincidentRoles(
|
|
|
176
191
|
// dominated by French cantons / JP counties that don't hit the parser-drops-locality failure.
|
|
177
192
|
const relationshipType = dist <= cityStateMaxKm ? "city-state" : "capital-seat"
|
|
178
193
|
insert.run(c.admin_id, c.locality_id, relationshipType, c.admin_placetype, dist, c.pop)
|
|
194
|
+
|
|
179
195
|
rowCount++
|
|
180
196
|
byCountry[c.country] = (byCountry[c.country] ?? 0) + 1
|
|
181
197
|
}
|
|
198
|
+
|
|
182
199
|
db.exec("COMMIT")
|
|
183
|
-
} catch (
|
|
200
|
+
} catch (error) {
|
|
184
201
|
db.exec("ROLLBACK")
|
|
185
|
-
throw
|
|
202
|
+
throw error
|
|
186
203
|
}
|
|
204
|
+
|
|
187
205
|
db.exec(`CREATE INDEX IF NOT EXISTS coincident_roles_by_admin ON ${COINCIDENT_ROLES_TABLE} (admin_id)`)
|
|
188
206
|
|
|
189
207
|
onProgress("done", `${rowCount} coincident-role rows`)
|
|
@@ -191,7 +209,9 @@ export function buildCoincidentRoles(
|
|
|
191
209
|
return { created: true, rowCount, byCountry, durationMs: Date.now() - start }
|
|
192
210
|
}
|
|
193
211
|
|
|
194
|
-
/**
|
|
212
|
+
/**
|
|
213
|
+
* True iff the relation table exists. Used by the resolver to decide whether completion can run.
|
|
214
|
+
*/
|
|
195
215
|
export function coincidentRolesExists(db: DatabaseSync): boolean {
|
|
196
216
|
return tableExists(db, COINCIDENT_ROLES_TABLE)
|
|
197
217
|
}
|
|
@@ -205,6 +225,7 @@ export function loadCoincidentRoles(db: DatabaseSync): Map<number, CoincidentRol
|
|
|
205
225
|
const map = new Map<number, CoincidentRole[]>()
|
|
206
226
|
|
|
207
227
|
if (!coincidentRolesExists(db)) return map
|
|
228
|
+
|
|
208
229
|
const rows = db
|
|
209
230
|
.prepare(
|
|
210
231
|
`SELECT admin_id, locality_id, relationship_type, admin_placetype, distance_km, locality_population
|
|
@@ -227,6 +248,7 @@ export function loadCoincidentRoles(db: DatabaseSync): Map<number, CoincidentRol
|
|
|
227
248
|
distanceKm: r.distance_km,
|
|
228
249
|
population: r.locality_population,
|
|
229
250
|
}
|
|
251
|
+
|
|
230
252
|
const list = map.get(r.admin_id)
|
|
231
253
|
|
|
232
254
|
if (list) {
|
package/convention.ts
CHANGED
|
@@ -38,7 +38,9 @@ export interface ScoringWeights {
|
|
|
38
38
|
* `tokenNormalization`, etc.
|
|
39
39
|
*/
|
|
40
40
|
export interface Convention {
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Ordered strategy names the dispatcher runs; the first to return a non-null result wins.
|
|
43
|
+
*/
|
|
42
44
|
candidateStrategies?: string[]
|
|
43
45
|
/**
|
|
44
46
|
* Weights for `postcode_area_resolution`'s soft-score. Partial — a layer may nudge one weight and inherit the rest
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Typed schema + read/write helpers for the candidate gazetteer's COVERAGE MANIFEST — the two
|
|
7
|
+
* country-keyed tables through which the artifact declares facts about ITSELF, so those facts are
|
|
8
|
+
* updated at gazetteer REBUILD, not by a hand-edited code PR after someone remembers:
|
|
9
|
+
*
|
|
10
|
+
* - `country_coverage`: the hard-country-filter coverage record (#743/#194) — per-country
|
|
11
|
+
* promote-gate verdicts + the measured hard-resolve rates that used to live in a code comment on
|
|
12
|
+
* `HARD_PLACE_COUNTRY_SAFELIST`. Presence = measured; `hard_filter_safe = 0` = measured and
|
|
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/articles/plan/reference/layer-contract.mdx`).
|
|
15
|
+
* - `country_bbox`: the coarse guard-B plausibility boxes that used to live in
|
|
16
|
+
* `resolver/plausibility.ts`'s `COUNTRY_BBOX`. An absent row fails open (never trips the guard),
|
|
17
|
+
* exactly like an absent key in the constant.
|
|
18
|
+
*
|
|
19
|
+
* Emission happens at build time (`mailwoman/gazetteer-pipeline/coverage-manifest.ts` owns the
|
|
20
|
+
* measured record and calls {@link writeGazetteerCoverageManifest} before the DB is sealed — never
|
|
21
|
+
* patch a shipped DB, rebuild). The read happens at open time ({@link WOFCandidateTableLookup}
|
|
22
|
+
* calls {@link readGazetteerCoverageManifest} in its constructor); an artifact that predates the
|
|
23
|
+
* manifest returns `undefined` and every consumer falls back to the code constants byte-identically.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { DatabaseSync } from "node:sqlite"
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
hardCountrySafelistFromCoverage,
|
|
30
|
+
type CountryBBoxFact,
|
|
31
|
+
type CountryCoverageFact,
|
|
32
|
+
type GazetteerArtifactCoverage,
|
|
33
|
+
} from "@mailwoman/core/resolver"
|
|
34
|
+
import { sql, type Kysely } from "kysely"
|
|
35
|
+
|
|
36
|
+
import { hasTable } from "./sqlite-utils.ts"
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One country's hard-filter coverage measurement — the storage form of {@link CountryCoverageFact}.
|
|
40
|
+
*/
|
|
41
|
+
export interface CountryCoverageTable {
|
|
42
|
+
/**
|
|
43
|
+
* ISO 3166-1 alpha-2, uppercase (PK).
|
|
44
|
+
*/
|
|
45
|
+
country: string
|
|
46
|
+
/**
|
|
47
|
+
* 0/1 — the promote-gate verdict (a verdict column, NOT re-derived from the rate; see the fact type's docstring).
|
|
48
|
+
*/
|
|
49
|
+
hard_filter_safe: number
|
|
50
|
+
/**
|
|
51
|
+
* Measured hard-resolve rate 0..1 on the panel named in `source`; NULL when the receipt recorded none.
|
|
52
|
+
*/
|
|
53
|
+
hard_resolve_rate: number | null
|
|
54
|
+
/**
|
|
55
|
+
* Panel size behind `hard_resolve_rate`; NULL when unrecorded.
|
|
56
|
+
*/
|
|
57
|
+
sample_size: number | null
|
|
58
|
+
/**
|
|
59
|
+
* ISO-8601 date of the measurement / promote gate.
|
|
60
|
+
*/
|
|
61
|
+
measured_at: string
|
|
62
|
+
/**
|
|
63
|
+
* The receipt: which panel/gate produced this row.
|
|
64
|
+
*/
|
|
65
|
+
source: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* One country's coarse guard-B bounding box — the storage form of {@link CountryBBoxFact}.
|
|
70
|
+
*/
|
|
71
|
+
export interface CountryBBoxTable {
|
|
72
|
+
/**
|
|
73
|
+
* ISO 3166-1 alpha-2, uppercase (PK).
|
|
74
|
+
*/
|
|
75
|
+
country: string
|
|
76
|
+
lat_min: number
|
|
77
|
+
lat_max: number
|
|
78
|
+
lon_min: number
|
|
79
|
+
lon_max: number
|
|
80
|
+
/**
|
|
81
|
+
* Provenance of the box (harness + date).
|
|
82
|
+
*/
|
|
83
|
+
source: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The coverage-manifest schema for `new DatabaseClient<GazetteerCoverageDatabase>(...)`.
|
|
88
|
+
*/
|
|
89
|
+
export interface GazetteerCoverageDatabase {
|
|
90
|
+
country_coverage: CountryCoverageTable
|
|
91
|
+
country_bbox: CountryBBoxTable
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Table names the lookup probes (existence-gated, so a candidate.db built before the manifest is byte-stable).
|
|
96
|
+
*/
|
|
97
|
+
export const COUNTRY_COVERAGE_TABLE = "country_coverage"
|
|
98
|
+
/**
|
|
99
|
+
* Table of per-country bounding boxes, used to reject a placement that fell outside its own country.
|
|
100
|
+
*/
|
|
101
|
+
export const COUNTRY_BBOX_TABLE = "country_bbox"
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Create `country_coverage` — a handful of small PK-probed rows, the WITHOUT ROWID sweet spot.
|
|
105
|
+
*/
|
|
106
|
+
export async function createCountryCoverageTable(db: Kysely<GazetteerCoverageDatabase>): Promise<void> {
|
|
107
|
+
await db.schema
|
|
108
|
+
.createTable(COUNTRY_COVERAGE_TABLE)
|
|
109
|
+
.ifNotExists()
|
|
110
|
+
.addColumn("country", "text", (c) => c.primaryKey())
|
|
111
|
+
.addColumn("hard_filter_safe", "integer", (c) => c.notNull())
|
|
112
|
+
.addColumn("hard_resolve_rate", "real")
|
|
113
|
+
.addColumn("sample_size", "integer")
|
|
114
|
+
.addColumn("measured_at", "text", (c) => c.notNull())
|
|
115
|
+
.addColumn("source", "text", (c) => c.notNull())
|
|
116
|
+
// `WITHOUT ROWID` has no first-class builder; the raw modifier is the idiomatic fallback.
|
|
117
|
+
.modifyEnd(sql`without rowid`)
|
|
118
|
+
.execute()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Create `country_bbox` — same shape discipline as {@link createCountryCoverageTable}.
|
|
123
|
+
*/
|
|
124
|
+
export async function createCountryBBoxTable(db: Kysely<GazetteerCoverageDatabase>): Promise<void> {
|
|
125
|
+
await db.schema
|
|
126
|
+
.createTable(COUNTRY_BBOX_TABLE)
|
|
127
|
+
.ifNotExists()
|
|
128
|
+
.addColumn("country", "text", (c) => c.primaryKey())
|
|
129
|
+
.addColumn("lat_min", "real", (c) => c.notNull())
|
|
130
|
+
.addColumn("lat_max", "real", (c) => c.notNull())
|
|
131
|
+
.addColumn("lon_min", "real", (c) => c.notNull())
|
|
132
|
+
.addColumn("lon_max", "real", (c) => c.notNull())
|
|
133
|
+
.addColumn("source", "text", (c) => c.notNull())
|
|
134
|
+
.modifyEnd(sql`without rowid`)
|
|
135
|
+
.execute()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Write the coverage manifest into a candidate DB UNDER CONSTRUCTION (pre-seal — a shipped DB is never patched, rebuild
|
|
140
|
+
* instead). Creates both tables and inserts the facts; call exactly once, from the gazetteer build.
|
|
141
|
+
*/
|
|
142
|
+
export async function writeGazetteerCoverageManifest(
|
|
143
|
+
db: Kysely<GazetteerCoverageDatabase>,
|
|
144
|
+
facts: { coverage: readonly CountryCoverageFact[]; bboxes: readonly CountryBBoxFact[] }
|
|
145
|
+
): Promise<void> {
|
|
146
|
+
await createCountryCoverageTable(db)
|
|
147
|
+
await createCountryBBoxTable(db)
|
|
148
|
+
|
|
149
|
+
if (facts.coverage.length) {
|
|
150
|
+
await db
|
|
151
|
+
.insertInto(COUNTRY_COVERAGE_TABLE)
|
|
152
|
+
.values(
|
|
153
|
+
facts.coverage.map((f) => ({
|
|
154
|
+
country: f.country.toUpperCase(),
|
|
155
|
+
hard_filter_safe: f.hardFilterSafe ? 1 : 0,
|
|
156
|
+
hard_resolve_rate: f.hardResolveRate ?? null,
|
|
157
|
+
sample_size: f.sampleSize ?? null,
|
|
158
|
+
measured_at: f.measuredAt,
|
|
159
|
+
source: f.source,
|
|
160
|
+
}))
|
|
161
|
+
)
|
|
162
|
+
.execute()
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (facts.bboxes.length) {
|
|
166
|
+
await db
|
|
167
|
+
.insertInto(COUNTRY_BBOX_TABLE)
|
|
168
|
+
.values(
|
|
169
|
+
facts.bboxes.map((f) => ({
|
|
170
|
+
country: f.country.toUpperCase(),
|
|
171
|
+
lat_min: f.latMin,
|
|
172
|
+
lat_max: f.latMax,
|
|
173
|
+
lon_min: f.lonMin,
|
|
174
|
+
lon_max: f.lonMax,
|
|
175
|
+
source: f.source,
|
|
176
|
+
}))
|
|
177
|
+
)
|
|
178
|
+
.execute()
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Read the coverage manifest from an OPEN candidate DB, or `undefined` when the artifact predates it (neither table
|
|
184
|
+
* present) — the signal for consumers to fall back to the code constants byte-identically. Synchronous raw reads on
|
|
185
|
+
* purpose: this runs inside {@link WOFCandidateTableLookup}'s synchronous constructor (the sync-reader carve-out in
|
|
186
|
+
* `AGENTS.md`), and the tables are a few dozen rows read once per open.
|
|
187
|
+
*/
|
|
188
|
+
export function readGazetteerCoverageManifest(db: DatabaseSync): GazetteerArtifactCoverage | undefined {
|
|
189
|
+
const hasCoverage = hasTable(db, COUNTRY_COVERAGE_TABLE)
|
|
190
|
+
const hasBBox = hasTable(db, COUNTRY_BBOX_TABLE)
|
|
191
|
+
|
|
192
|
+
if (!hasCoverage && !hasBBox) return undefined
|
|
193
|
+
|
|
194
|
+
const countryCoverage = new Map<string, CountryCoverageFact>()
|
|
195
|
+
|
|
196
|
+
if (hasCoverage) {
|
|
197
|
+
const rows = db
|
|
198
|
+
.prepare(
|
|
199
|
+
`SELECT country, hard_filter_safe, hard_resolve_rate, sample_size, measured_at, source FROM ${COUNTRY_COVERAGE_TABLE}`
|
|
200
|
+
)
|
|
201
|
+
.all() as unknown as CountryCoverageTable[]
|
|
202
|
+
|
|
203
|
+
for (const row of rows) {
|
|
204
|
+
const country = String(row.country).toUpperCase()
|
|
205
|
+
|
|
206
|
+
countryCoverage.set(country, {
|
|
207
|
+
country,
|
|
208
|
+
hardFilterSafe: Number(row.hard_filter_safe) !== 0,
|
|
209
|
+
...(row.hard_resolve_rate === null ? {} : { hardResolveRate: Number(row.hard_resolve_rate) }),
|
|
210
|
+
...(row.sample_size === null ? {} : { sampleSize: Number(row.sample_size) }),
|
|
211
|
+
measuredAt: String(row.measured_at),
|
|
212
|
+
source: String(row.source),
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const countryBBoxes = new Map<string, CountryBBoxFact>()
|
|
218
|
+
|
|
219
|
+
if (hasBBox) {
|
|
220
|
+
const rows = db
|
|
221
|
+
.prepare(`SELECT country, lat_min, lat_max, lon_min, lon_max, source FROM ${COUNTRY_BBOX_TABLE}`)
|
|
222
|
+
.all() as unknown as CountryBBoxTable[]
|
|
223
|
+
|
|
224
|
+
for (const row of rows) {
|
|
225
|
+
const country = String(row.country).toUpperCase()
|
|
226
|
+
|
|
227
|
+
countryBBoxes.set(country, {
|
|
228
|
+
country,
|
|
229
|
+
latMin: Number(row.lat_min),
|
|
230
|
+
latMax: Number(row.lat_max),
|
|
231
|
+
lonMin: Number(row.lon_min),
|
|
232
|
+
lonMax: Number(row.lon_max),
|
|
233
|
+
source: String(row.source),
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
countryCoverage,
|
|
240
|
+
countryBBoxes,
|
|
241
|
+
hardCountrySafelist: hardCountrySafelistFromCoverage(countryCoverage.values()),
|
|
242
|
+
}
|
|
243
|
+
}
|