@mailwoman/resolver-wof-sqlite 8.1.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 +36 -14
- package/candidate-schema.ts +35 -11
- package/coincident-roles.ts +28 -6
- package/convention.ts +3 -1
- package/coverage-manifest-schema.ts +49 -16
- package/fst-autocomplete.ts +15 -9
- package/fst-builder.ts +29 -5
- package/fst-deserialize-web.ts +46 -9
- package/fst-matcher.ts +12 -5
- package/fst-serialize.ts +83 -9
- package/fst-types.ts +26 -3
- 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 +10 -0
- package/interpolation.ts +33 -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 +9 -3
- package/out/candidate-lookup.d.ts.map +1 -1
- package/out/candidate-lookup.js +16 -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 +45 -14
- package/out/coverage-manifest-schema.d.ts.map +1 -1
- package/out/coverage-manifest-schema.js +14 -5
- package/out/coverage-manifest-schema.js.map +1 -1
- 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 +15 -5
- 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 +26 -3
- 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.map +1 -1
- package/out/index.js.map +1 -1
- package/out/interpolation.d.ts +18 -6
- package/out/interpolation.d.ts.map +1 -1
- package/out/interpolation.js +11 -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 +24 -8
- package/out/street-morphology-fst-loader.d.ts.map +1 -1
- package/out/street-morphology-fst-loader.js +11 -5
- package/out/street-morphology-fst-loader.js.map +1 -1
- 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 +48 -16
- package/out/street-segment-schema.d.ts.map +1 -1
- package/out/street-segment-schema.js +6 -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 +5 -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 +26 -10
- package/street-name-lookup.ts +19 -7
- package/street-normalize.ts +28 -13
- package/street-segment-schema.ts +50 -16
- package/types.ts +18 -6
- package/unified-schema.ts +11 -2
package/candidate-lookup.ts
CHANGED
|
@@ -38,9 +38,13 @@ import { normalizeLocalityForKey, stripLocalityQualifier } from "./street-normal
|
|
|
38
38
|
import type { FindPlaceQuery, PlaceCandidate, PlaceLookup, WOFPlacetype } from "./types.ts"
|
|
39
39
|
|
|
40
40
|
export interface WOFCandidateTableLookupOpts {
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Path to a `candidate.db` built by `build-candidate.ts`. Opened read-only.
|
|
43
|
+
*/
|
|
42
44
|
databasePath?: string
|
|
43
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* Pre-opened handle (tests / shared connections). Mutually exclusive with `databasePath`.
|
|
47
|
+
*/
|
|
44
48
|
database?: DatabaseSync
|
|
45
49
|
}
|
|
46
50
|
|
|
@@ -92,7 +96,7 @@ const FUZZY_MIN = 0.34
|
|
|
92
96
|
* ("Los Angeles" over La, Ghana — gap 1.6; "Las Vegas" over Vegas, Cuba — gap 2.4) while a near-tie coincidental
|
|
93
97
|
* collision defers to the primary (Cancún over Changchun — gap 0.7).
|
|
94
98
|
*/
|
|
95
|
-
const PRIMARY_PREFERENCE_LOG10 = 1
|
|
99
|
+
const PRIMARY_PREFERENCE_LOG10 = 1
|
|
96
100
|
|
|
97
101
|
/**
|
|
98
102
|
* Over-fetch cap for {@link rankByPrimaryPreference}: the candidate rows for one `name_key` (all same-name places
|
|
@@ -102,7 +106,9 @@ const PRIMARY_PREFERENCE_LOG10 = 1.0
|
|
|
102
106
|
*/
|
|
103
107
|
const RERANK_FETCH = 64
|
|
104
108
|
|
|
105
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* A candidate row annotated with the {@link rankByPrimaryPreference} effective rank + the exact-tier demotion flag.
|
|
111
|
+
*/
|
|
106
112
|
export type RankedRow<R> = R & {
|
|
107
113
|
/**
|
|
108
114
|
* `neg_rank` plus the bounded cross-country alias penalty — the value the row is ORDERED by, and the base the emitted
|
|
@@ -144,10 +150,12 @@ export function rankByPrimaryPreference<R extends Pick<CandidateRow, "neg_rank"
|
|
|
144
150
|
}
|
|
145
151
|
|
|
146
152
|
const topCountry = topPrimary?.country_id
|
|
153
|
+
|
|
147
154
|
// A cross-country alias (different country than the top primary) is penalized; it is DEMOTED when even after — i.e.
|
|
148
155
|
// the penalty leaves its effective rank behind the primary's raw rank (it lost the bounded population contest).
|
|
149
156
|
const isCrossCountryAlias = (r: R): boolean =>
|
|
150
157
|
topCountry !== undefined && r.is_primary !== 1 && r.country_id !== topCountry
|
|
158
|
+
|
|
151
159
|
const annotate = (r: R): RankedRow<R> => {
|
|
152
160
|
const penalized = isCrossCountryAlias(r)
|
|
153
161
|
const effectiveNegRank = r.neg_rank + (penalized ? delta : 0)
|
|
@@ -159,6 +167,7 @@ export function rankByPrimaryPreference<R extends Pick<CandidateRow, "neg_rank"
|
|
|
159
167
|
rows
|
|
160
168
|
.map((r, i) => ({ row: annotate(r), i }))
|
|
161
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
|
|
162
171
|
.sort((a, b) => a.row.effectiveNegRank - b.row.effectiveNegRank || a.row.neg_rank - b.row.neg_rank || a.i - b.i)
|
|
163
172
|
.slice(0, limit)
|
|
164
173
|
.map((x) => x.row)
|
|
@@ -260,6 +269,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
260
269
|
this.#ftsProbe = this.#db.prepare(
|
|
261
270
|
`SELECT name_key FROM ${CANDIDATE_FTS_TABLE} WHERE ${CANDIDATE_FTS_TABLE} MATCH ? ORDER BY bm25(${CANDIDATE_FTS_TABLE}) LIMIT ?`
|
|
262
271
|
)
|
|
272
|
+
|
|
263
273
|
this.#nameKeyExistsProbe = this.#db.prepare("SELECT 1 FROM candidate WHERE name_key = ? LIMIT 1")
|
|
264
274
|
}
|
|
265
275
|
|
|
@@ -269,7 +279,9 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
269
279
|
this.artifactCoverage = readGazetteerCoverageManifest(this.#db)
|
|
270
280
|
}
|
|
271
281
|
|
|
272
|
-
/**
|
|
282
|
+
/**
|
|
283
|
+
* Does this query want a locality-tier place? Postal-city aliases (#741) are all localities.
|
|
284
|
+
*/
|
|
273
285
|
#wantsLocality(placetype: FindPlaceQuery["placetype"]): boolean {
|
|
274
286
|
if (!placetype) return true
|
|
275
287
|
const want = Array.isArray(placetype) ? placetype : [placetype]
|
|
@@ -286,8 +298,9 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
286
298
|
// form at build (the GeoNames fold normalizes '624 66' → '62466'), so a postcode-typed query
|
|
287
299
|
// strips internal whitespace before keying. Postcode-only — locality names keep their spaces.
|
|
288
300
|
if ([query.placetype].flat().includes("postalcode")) {
|
|
289
|
-
text = text.
|
|
301
|
+
text = text.replaceAll(/\s+/g, "")
|
|
290
302
|
}
|
|
303
|
+
|
|
291
304
|
const nameKey = normalizeLocalityForKey(text)
|
|
292
305
|
|
|
293
306
|
if (!nameKey) return []
|
|
@@ -337,11 +350,12 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
337
350
|
// Shared placetype-equivalence expansion (a `locality` query must also reach borough /
|
|
338
351
|
// localadmin). `postalcode` maps to no admin placetype here → empty → no rows.
|
|
339
352
|
const want = Array.isArray(query.placetype) ? query.placetype : [query.placetype]
|
|
353
|
+
|
|
340
354
|
const ids = expandPlacetypeFilter(want as readonly string[])
|
|
341
355
|
.map((t) => this.#placetypeToID.get(t))
|
|
342
356
|
.filter((v): v is number => v !== undefined)
|
|
343
357
|
|
|
344
|
-
if (ids.length
|
|
358
|
+
if (!ids.length) return []
|
|
345
359
|
filters.push(`placetype_id IN (${ids.map(() => "?").join(",")})`)
|
|
346
360
|
filterParams.push(...ids)
|
|
347
361
|
}
|
|
@@ -360,7 +374,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
360
374
|
// Kept OUT of the shared `filters` so a region MISS falls back to the unscoped cascade below: a
|
|
361
375
|
// country/non-region parent (no `region_id` match), a `region_id=0` row (place with no region
|
|
362
376
|
// ancestor), or a wrong parent degrades to today's behavior — never worse, recall-safe by construction.
|
|
363
|
-
const regionParentID = query.parentID
|
|
377
|
+
const regionParentID = query.parentID || undefined
|
|
364
378
|
|
|
365
379
|
const probe = (nk: string, regionID: number | undefined): Array<RankedRow<CandidateRow>> => {
|
|
366
380
|
const conds = ["name_key = ?", ...filters]
|
|
@@ -379,6 +393,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
379
393
|
const sql =
|
|
380
394
|
"SELECT spr_id, name, country_id, placetype_id, latitude, longitude, min_lat, min_lon, max_lat, max_lon, neg_rank, is_primary " +
|
|
381
395
|
`FROM candidate WHERE ${conds.join(" AND ")} ORDER BY neg_rank ASC LIMIT ?`
|
|
396
|
+
|
|
382
397
|
const fetched = this.#db.prepare(sql).all(...params, Math.max(limit, RERANK_FETCH)) as unknown as CandidateRow[]
|
|
383
398
|
|
|
384
399
|
return rankByPrimaryPreference(fetched, limit)
|
|
@@ -390,7 +405,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
390
405
|
const cascade = (regionID: number | undefined): Array<RankedRow<CandidateRow>> => {
|
|
391
406
|
let rows = probe(nameKey, regionID)
|
|
392
407
|
|
|
393
|
-
if (rows.length
|
|
408
|
+
if (!rows.length) {
|
|
394
409
|
// Query-side qualifier-strip fallback: an OA locality with a qualifier the gazetteer's
|
|
395
410
|
// canonical name omits ("Lenk im Simmental" → "Lenk", "Roche VD"). Tried ONLY on an exact
|
|
396
411
|
// miss; the cascade's region bbox disambiguates any base-name ambiguity.
|
|
@@ -412,15 +427,18 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
412
427
|
// — fuzzing it scrapes an unrelated same-filter place ("Vienna, Austria" misrouted to IT would
|
|
413
428
|
// pull a tiny Italian name_key near Siena) and masks the cascade's country-agnostic retry that
|
|
414
429
|
// correctly lands population-first Vienna AT. The exact/strip probes already covered the real name.
|
|
415
|
-
if (rows.length
|
|
430
|
+
if (!rows.length && this.#ftsProbe && this.#nameKeyExistsProbe && !this.#nameKeyExistsProbe.get(nameKey)) {
|
|
416
431
|
const match = ftsTrigramQuery(nameKey)
|
|
417
432
|
|
|
418
433
|
if (match) {
|
|
419
434
|
const hits = this.#ftsProbe.all(match, FUZZY_FETCH) as unknown as Array<{ name_key: string }>
|
|
435
|
+
|
|
420
436
|
const ranked = hits
|
|
421
437
|
.map((h) => ({ nk: String(h.name_key), s: trigramJaccard(nameKey, String(h.name_key)) }))
|
|
422
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
|
|
423
440
|
.sort((a, b) => b.s - a.s)
|
|
441
|
+
|
|
424
442
|
const seen = new Set<string>()
|
|
425
443
|
|
|
426
444
|
for (const h of ranked) {
|
|
@@ -430,6 +448,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
430
448
|
|
|
431
449
|
if (rows.length >= limit) break
|
|
432
450
|
}
|
|
451
|
+
|
|
433
452
|
rows = rows.slice(0, limit)
|
|
434
453
|
}
|
|
435
454
|
}
|
|
@@ -442,7 +461,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
442
461
|
// Region-scope fallback: if scoping to the parent region found nothing across the whole cascade, retry
|
|
443
462
|
// unscoped so a place with no in-region row (missing ancestry, or a country/non-region parent) still
|
|
444
463
|
// resolves exactly as it does today. Only when a region scope was actually applied.
|
|
445
|
-
if (rows.length
|
|
464
|
+
if (!rows.length && regionParentID !== undefined) {
|
|
446
465
|
rows = cascade(undefined)
|
|
447
466
|
}
|
|
448
467
|
|
|
@@ -492,9 +511,9 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
492
511
|
// log10(population + 1), so popTerm is the server formula read straight off it. Constants MIRROR
|
|
493
512
|
// lookup.ts's DEFAULT_WEIGHTS (biasBoost 4, populationBoost 4, populationScaleLog10 6,
|
|
494
513
|
// proximityScaleKm 100) — the #861 server↔demo parity contract; keep them in lockstep.
|
|
495
|
-
if (query.bias && query.bias.length
|
|
496
|
-
const BIAS_BOOST = 4
|
|
497
|
-
const POP_BOOST = 4
|
|
514
|
+
if (query.bias && query.bias.length) {
|
|
515
|
+
const BIAS_BOOST = 4
|
|
516
|
+
const POP_BOOST = 4
|
|
498
517
|
const POP_SCALE_LOG10 = 6
|
|
499
518
|
// SHARPER than lookup.ts's 100 km on purpose: this backend's `score` is log-population ALONE
|
|
500
519
|
// (no bm25 document term), so the population signal is weaker relative to the bias and the
|
|
@@ -503,6 +522,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
503
522
|
// candidates the user is actually LOOKING at — an in-view namesake still wins (Dublin, OH from
|
|
504
523
|
// an Ohio view), a distant one no longer does (Paris stays FR from a Michigan view).
|
|
505
524
|
const PROX_SCALE_KM = 30
|
|
525
|
+
|
|
506
526
|
const combinedProminence = (c: PlaceCandidate): number => {
|
|
507
527
|
// Population base is the PENALIZED `prominence` (set above = -effectiveNegRank), not raw `score`, so
|
|
508
528
|
// the cross-country primary preference carries into the bias-weighted order too — a coincidental
|
|
@@ -524,6 +544,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
524
544
|
|
|
525
545
|
return popTerm + proxTerm
|
|
526
546
|
}
|
|
547
|
+
|
|
527
548
|
// Persist the combined value into `prominence` so the resolver walk's `prominence ?? score` sort (and any
|
|
528
549
|
// other node consumer) honors the bias order — then sort. Stable within equal prominence (preserves the
|
|
529
550
|
// population order the B-tree already gave).
|
|
@@ -533,6 +554,7 @@ export class WOFCandidateTableLookup implements PlaceLookup {
|
|
|
533
554
|
|
|
534
555
|
return { c, i, p: c.prominence }
|
|
535
556
|
})
|
|
557
|
+
// oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
|
|
536
558
|
.sort((a, b) => b.p - a.p || a.i - b.i)
|
|
537
559
|
.forEach((x, j) => (candidates[j] = x.c))
|
|
538
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
|
|
@@ -35,45 +35,74 @@ import { sql, type Kysely } from "kysely"
|
|
|
35
35
|
|
|
36
36
|
import { hasTable } from "./sqlite-utils.ts"
|
|
37
37
|
|
|
38
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* One country's hard-filter coverage measurement — the storage form of {@link CountryCoverageFact}.
|
|
40
|
+
*/
|
|
39
41
|
export interface CountryCoverageTable {
|
|
40
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* ISO 3166-1 alpha-2, uppercase (PK).
|
|
44
|
+
*/
|
|
41
45
|
country: string
|
|
42
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* 0/1 — the promote-gate verdict (a verdict column, NOT re-derived from the rate; see the fact type's docstring).
|
|
48
|
+
*/
|
|
43
49
|
hard_filter_safe: number
|
|
44
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Measured hard-resolve rate 0..1 on the panel named in `source`; NULL when the receipt recorded none.
|
|
52
|
+
*/
|
|
45
53
|
hard_resolve_rate: number | null
|
|
46
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Panel size behind `hard_resolve_rate`; NULL when unrecorded.
|
|
56
|
+
*/
|
|
47
57
|
sample_size: number | null
|
|
48
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* ISO-8601 date of the measurement / promote gate.
|
|
60
|
+
*/
|
|
49
61
|
measured_at: string
|
|
50
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* The receipt: which panel/gate produced this row.
|
|
64
|
+
*/
|
|
51
65
|
source: string
|
|
52
66
|
}
|
|
53
67
|
|
|
54
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* One country's coarse guard-B bounding box — the storage form of {@link CountryBBoxFact}.
|
|
70
|
+
*/
|
|
55
71
|
export interface CountryBBoxTable {
|
|
56
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* ISO 3166-1 alpha-2, uppercase (PK).
|
|
74
|
+
*/
|
|
57
75
|
country: string
|
|
58
76
|
lat_min: number
|
|
59
77
|
lat_max: number
|
|
60
78
|
lon_min: number
|
|
61
79
|
lon_max: number
|
|
62
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* Provenance of the box (harness + date).
|
|
82
|
+
*/
|
|
63
83
|
source: string
|
|
64
84
|
}
|
|
65
85
|
|
|
66
|
-
/**
|
|
86
|
+
/**
|
|
87
|
+
* The coverage-manifest schema for `new DatabaseClient<GazetteerCoverageDatabase>(...)`.
|
|
88
|
+
*/
|
|
67
89
|
export interface GazetteerCoverageDatabase {
|
|
68
90
|
country_coverage: CountryCoverageTable
|
|
69
91
|
country_bbox: CountryBBoxTable
|
|
70
92
|
}
|
|
71
93
|
|
|
72
|
-
/**
|
|
94
|
+
/**
|
|
95
|
+
* Table names the lookup probes (existence-gated, so a candidate.db built before the manifest is byte-stable).
|
|
96
|
+
*/
|
|
73
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
|
+
*/
|
|
74
101
|
export const COUNTRY_BBOX_TABLE = "country_bbox"
|
|
75
102
|
|
|
76
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* Create `country_coverage` — a handful of small PK-probed rows, the WITHOUT ROWID sweet spot.
|
|
105
|
+
*/
|
|
77
106
|
export async function createCountryCoverageTable(db: Kysely<GazetteerCoverageDatabase>): Promise<void> {
|
|
78
107
|
await db.schema
|
|
79
108
|
.createTable(COUNTRY_COVERAGE_TABLE)
|
|
@@ -89,7 +118,9 @@ export async function createCountryCoverageTable(db: Kysely<GazetteerCoverageDat
|
|
|
89
118
|
.execute()
|
|
90
119
|
}
|
|
91
120
|
|
|
92
|
-
/**
|
|
121
|
+
/**
|
|
122
|
+
* Create `country_bbox` — same shape discipline as {@link createCountryCoverageTable}.
|
|
123
|
+
*/
|
|
93
124
|
export async function createCountryBBoxTable(db: Kysely<GazetteerCoverageDatabase>): Promise<void> {
|
|
94
125
|
await db.schema
|
|
95
126
|
.createTable(COUNTRY_BBOX_TABLE)
|
|
@@ -115,7 +146,7 @@ export async function writeGazetteerCoverageManifest(
|
|
|
115
146
|
await createCountryCoverageTable(db)
|
|
116
147
|
await createCountryBBoxTable(db)
|
|
117
148
|
|
|
118
|
-
if (facts.coverage.length
|
|
149
|
+
if (facts.coverage.length) {
|
|
119
150
|
await db
|
|
120
151
|
.insertInto(COUNTRY_COVERAGE_TABLE)
|
|
121
152
|
.values(
|
|
@@ -131,7 +162,7 @@ export async function writeGazetteerCoverageManifest(
|
|
|
131
162
|
.execute()
|
|
132
163
|
}
|
|
133
164
|
|
|
134
|
-
if (facts.bboxes.length
|
|
165
|
+
if (facts.bboxes.length) {
|
|
135
166
|
await db
|
|
136
167
|
.insertInto(COUNTRY_BBOX_TABLE)
|
|
137
168
|
.values(
|
|
@@ -171,6 +202,7 @@ export function readGazetteerCoverageManifest(db: DatabaseSync): GazetteerArtifa
|
|
|
171
202
|
|
|
172
203
|
for (const row of rows) {
|
|
173
204
|
const country = String(row.country).toUpperCase()
|
|
205
|
+
|
|
174
206
|
countryCoverage.set(country, {
|
|
175
207
|
country,
|
|
176
208
|
hardFilterSafe: Number(row.hard_filter_safe) !== 0,
|
|
@@ -191,6 +223,7 @@ export function readGazetteerCoverageManifest(db: DatabaseSync): GazetteerArtifa
|
|
|
191
223
|
|
|
192
224
|
for (const row of rows) {
|
|
193
225
|
const country = String(row.country).toUpperCase()
|
|
226
|
+
|
|
194
227
|
countryBBoxes.set(country, {
|
|
195
228
|
country,
|
|
196
229
|
latMin: Number(row.lat_min),
|
package/fst-autocomplete.ts
CHANGED
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
* needs; without it "new yor" returns nothing useful. (#587)
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { FSTMatcher
|
|
20
|
+
import type { FSTMatcher } from "./fst-matcher.ts"
|
|
21
|
+
import { normalizeTokens } from "./fst-matcher.ts"
|
|
21
22
|
import type { PlaceEntry } from "./fst-types.ts"
|
|
22
23
|
|
|
23
24
|
export interface AutocompleteResult {
|
|
@@ -54,7 +55,9 @@ interface BfsItem {
|
|
|
54
55
|
tokens: string[]
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Max accepting entries collected per BFS branch — keeps one dense branch from starving the search.
|
|
60
|
+
*/
|
|
58
61
|
const PER_BRANCH = 4
|
|
59
62
|
|
|
60
63
|
/**
|
|
@@ -63,7 +66,7 @@ const PER_BRANCH = 4
|
|
|
63
66
|
function topByImportance(entries: readonly PlaceEntry[], k: number): PlaceEntry[] {
|
|
64
67
|
if (entries.length <= k) return [...entries]
|
|
65
68
|
|
|
66
|
-
return [...entries].
|
|
69
|
+
return [...entries].toSorted((a, b) => b.importance - a.importance).slice(0, k)
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
/**
|
|
@@ -74,13 +77,13 @@ export function autocomplete(fst: FSTMatcher, query: string, opts: AutocompleteO
|
|
|
74
77
|
const maxExpansionDepth = opts.maxExpansionDepth ?? 2
|
|
75
78
|
const normalizedTokens = normalizeTokens(query)
|
|
76
79
|
|
|
77
|
-
if (normalizedTokens.length
|
|
80
|
+
if (!normalizedTokens.length) {
|
|
78
81
|
return { query, normalizedTokens: [], depth: 0, suggestions: [] }
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
const seen = new Map<number, AutocompleteSuggestion>()
|
|
82
85
|
const queue: BfsItem[] = []
|
|
83
|
-
let depth
|
|
86
|
+
let depth: number
|
|
84
87
|
|
|
85
88
|
const match = fst.walk(normalizedTokens)
|
|
86
89
|
|
|
@@ -98,12 +101,13 @@ export function autocomplete(fst: FSTMatcher, query: string, opts: AutocompleteO
|
|
|
98
101
|
} else {
|
|
99
102
|
// PARTIAL last token — walk the complete prefix, complete the partial by prefix-filtering edges.
|
|
100
103
|
const complete = normalizedTokens.slice(0, -1)
|
|
101
|
-
const partial = normalizedTokens
|
|
102
|
-
const prefixState = complete.length
|
|
104
|
+
const partial = normalizedTokens.at(-1)!
|
|
105
|
+
const prefixState = !complete.length ? 0 : (fst.walk(complete)?.stateID ?? undefined)
|
|
103
106
|
|
|
104
107
|
if (prefixState === undefined) {
|
|
105
108
|
return { query, normalizedTokens, depth: 0, suggestions: [] }
|
|
106
109
|
}
|
|
110
|
+
|
|
107
111
|
depth = complete.length
|
|
108
112
|
|
|
109
113
|
for (const cont of fst.continuations(prefixState)) {
|
|
@@ -113,6 +117,7 @@ export function autocomplete(fst: FSTMatcher, query: string, opts: AutocompleteO
|
|
|
113
117
|
for (const entry of topByImportance(fst.accepting(cont.targetState), PER_BRANCH)) {
|
|
114
118
|
addSuggestion(seen, entry, complete.length + 1, [cont.token])
|
|
115
119
|
}
|
|
120
|
+
|
|
116
121
|
// BFS a little past it too (multi-token completions: "new yor" → "New York Mills").
|
|
117
122
|
queue.push({ stateID: cont.targetState, depth: 1, tokens: [cont.token] })
|
|
118
123
|
}
|
|
@@ -122,7 +127,7 @@ export function autocomplete(fst: FSTMatcher, query: string, opts: AutocompleteO
|
|
|
122
127
|
// branch contributes only its top PER_BRANCH places: a state like "new london" has dozens of
|
|
123
128
|
// accepting entries and would otherwise blow the budget before the BFS ever reaches "new york"
|
|
124
129
|
// (the "new" state has 311 continuations). Per-branch capping keeps the search broad. (#587)
|
|
125
|
-
while (queue.length
|
|
130
|
+
while (queue.length && seen.size < maxSuggestions * 4) {
|
|
126
131
|
const item = queue.shift()!
|
|
127
132
|
|
|
128
133
|
if (item.depth > maxExpansionDepth) continue
|
|
@@ -138,7 +143,7 @@ export function autocomplete(fst: FSTMatcher, query: string, opts: AutocompleteO
|
|
|
138
143
|
}
|
|
139
144
|
}
|
|
140
145
|
|
|
141
|
-
let suggestions = [...seen.values()].
|
|
146
|
+
let suggestions = [...seen.values()].toSorted((a, b) => b.importance - a.importance)
|
|
142
147
|
|
|
143
148
|
if (opts.dedupeByName) {
|
|
144
149
|
suggestions = dedupeByName(suggestions)
|
|
@@ -156,6 +161,7 @@ function addSuggestion(
|
|
|
156
161
|
const existing = seen.get(entry.wofID)
|
|
157
162
|
|
|
158
163
|
if (existing && existing.matchDepth <= matchDepth) return
|
|
164
|
+
|
|
159
165
|
seen.set(entry.wofID, {
|
|
160
166
|
name: entry.name,
|
|
161
167
|
placetype: entry.placetype,
|