@mailwoman/resolver-wof-sqlite 7.2.0 → 7.2.1

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 (45) hide show
  1. package/address-point-interpolation.ts +207 -0
  2. package/address-point-schema.ts +107 -0
  3. package/address-point.ts +122 -0
  4. package/ancestry-backfill.ts +205 -0
  5. package/ancestry.ts +70 -0
  6. package/build-candidate.ts +351 -0
  7. package/build-slim.ts +394 -0
  8. package/candidate-fts.ts +43 -0
  9. package/candidate-lookup.ts +382 -0
  10. package/candidate-schema.ts +166 -0
  11. package/coincident-roles.ts +240 -0
  12. package/convention.ts +152 -0
  13. package/fst-autocomplete.ts +187 -0
  14. package/fst-builder.ts +291 -0
  15. package/fst-deserialize-web.ts +164 -0
  16. package/fst-matcher.ts +150 -0
  17. package/fst-serialize.ts +311 -0
  18. package/fst-types.ts +78 -0
  19. package/fts.ts +318 -0
  20. package/geo.ts +140 -0
  21. package/geonames-aliases.ts +317 -0
  22. package/geonames-postal.ts +150 -0
  23. package/index.ts +117 -0
  24. package/interpolation.ts +232 -0
  25. package/lookup.ts +1498 -0
  26. package/package.json +168 -82
  27. package/poi-lookup.ts +319 -0
  28. package/poi-schema.ts +147 -0
  29. package/postal-city-alias-lookup.ts +89 -0
  30. package/postal-city-alias-schema.ts +75 -0
  31. package/postal-city-candidate-schema.ts +81 -0
  32. package/postcode-point-lookup.ts +64 -0
  33. package/reverse.ts +429 -0
  34. package/schema.ts +176 -0
  35. package/sharding.ts +235 -0
  36. package/sqlite-convention-source.ts +61 -0
  37. package/sqlite-utils.ts +25 -0
  38. package/street-centroid-schema.ts +124 -0
  39. package/street-centroid.ts +124 -0
  40. package/street-morphology-fst-builder.ts +230 -0
  41. package/street-name-lookup.ts +101 -0
  42. package/street-normalize.ts +302 -0
  43. package/street-segment-schema.ts +104 -0
  44. package/types.ts +164 -0
  45. package/unified-schema.ts +171 -0
@@ -0,0 +1,240 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `buildCoincidentRoles` — derives the **coincident-roles relation** (#403, epic #402) into the
7
+ * unified gazetteer.
8
+ *
9
+ * Many places occupy MULTIPLE admin tiers under one name: German city-states (Berlin/Hamburg/Bremen
10
+ * = city == state), Italian provinces named after their capital (Milano, Varese…), Spanish
11
+ * provinces-after-capitals, UK unitary authorities, JP prefectures, NL province-capitals
12
+ * (Utrecht/Groningen), Shanghai. When an address surfaces only the admin role (the parser drops
13
+ * the locality span), the resolver has no locality to place. The hierarchy-completion step (#405)
14
+ * repairs that by consulting THIS relation; the table replaces #387's hardcoded 15 km constant
15
+ * with the gazetteer's own structure, so the runtime is an O(1) membership lookup with no
16
+ * distance math.
17
+ *
18
+ * V1 is REGION-tier only (admin.placetype = `region`): the ~124 places matching the census across 9
19
+ * countries (IT/ES/GB/JP/KR/FR/DE/NL/CN). County-tier same-name coincidences are deliberately
20
+ * excluded — they're dominated by French cantons and JP counties (admin subdivisions named after
21
+ * a seat town, not dual-role cities) that don't hit the parser-drops-locality failure; genuine
22
+ * consolidated city-counties (US SF/Denver) are a separate follow-up needing a relative-size
23
+ * filter.
24
+ *
25
+ * A pair `(admin, locality)` is recorded when all hold: same `name` (case-insensitive), the
26
+ * locality is a `descendant` of the admin (via the `ancestors` table), and their centroids are
27
+ * within a RELATIVE tolerance — `toleranceFraction × admin-bbox-diagonal`, floored at
28
+ * `minToleranceKm`. The relative term lets a large Italian province admit a city ~tens of km from
29
+ * its centroid while a tiny city-state stays tight; the floor catches city-states whose bbox is
30
+ * small (Bremen's centroids sit 9.3 km apart). The tolerance lives ONLY here at build time — it
31
+ * never enters the resolver hot path.
32
+ *
33
+ * `relationship_type` is recorded for debuggability / deferred per-type behavior; v1 completion is
34
+ * uniform (see #405). It's a coarse classification, not critical.
35
+ *
36
+ * Mirrors the derived-table builder pattern in `fts.ts` (`buildPlaceSearchFTS`). Run incrementally
37
+ * against an existing `admin-global-priority.db` via `build-coincident-roles-cli.ts`; should also
38
+ * be wired as a post-step of the main `scripts/build-unified-wof.ts`.
39
+ */
40
+
41
+ import type { DatabaseSync } from "node:sqlite"
42
+
43
+ import { haversineKm } from "@mailwoman/spatial"
44
+
45
+ export const COINCIDENT_ROLES_TABLE = "coincident_roles"
46
+
47
+ /** A place that plays multiple admin roles — one row of the relation, keyed by `admin_id`. */
48
+ export interface CoincidentRole {
49
+ localityID: number
50
+ relationshipType: "city-state" | "capital-seat" | "consolidated-county"
51
+ adminPlacetype: string
52
+ distanceKm: number
53
+ population: number
54
+ }
55
+
56
+ export interface BuildCoincidentRolesOpts {
57
+ /** Drop + rebuild the table if it already exists. Default true (the build is cheap + idempotent). */
58
+ drop?: boolean
59
+ /**
60
+ * Relative tolerance: a pair is kept when centroid distance ≤ `toleranceFraction × bbox-diagonal`. Default 0.15.
61
+ */
62
+ toleranceFraction?: number
63
+ /** Floor (km) under the relative tolerance, so small-bbox city-states still qualify. Default 12. */
64
+ minToleranceKm?: number
65
+ /**
66
+ * Centroid distance (km) below which a region-tier pair is classed `city-state` (metadata only). Default 2.
67
+ */
68
+ cityStateMaxKm?: number
69
+ onProgress?: (phase: string, detail?: string) => void
70
+ }
71
+
72
+ export interface BuildCoincidentRolesResult {
73
+ created: boolean
74
+ rowCount: number
75
+ byCountry: Record<string, number>
76
+ durationMs: number
77
+ }
78
+
79
+ interface CandidateRow {
80
+ admin_id: number
81
+ admin_placetype: string
82
+ country: string
83
+ locality_id: number
84
+ rlat: number
85
+ rlon: number
86
+ llat: number
87
+ llon: number
88
+ min_latitude: number
89
+ min_longitude: number
90
+ max_latitude: number
91
+ max_longitude: number
92
+ pop: number
93
+ }
94
+
95
+ function tableExists(db: DatabaseSync, name: string): boolean {
96
+ return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?").get(name)
97
+ }
98
+
99
+ /**
100
+ * Derive the coincident-roles relation into `db`. Additive — only creates/replaces the `coincident_roles` table; never
101
+ * touches `spr`/`names`/`ancestors`. Idempotent.
102
+ */
103
+ export function buildCoincidentRoles(
104
+ db: DatabaseSync,
105
+ opts: BuildCoincidentRolesOpts = {}
106
+ ): BuildCoincidentRolesResult {
107
+ const start = Date.now()
108
+ const drop = opts.drop ?? true
109
+ const toleranceFraction = opts.toleranceFraction ?? 0.15
110
+ const minToleranceKm = opts.minToleranceKm ?? 12
111
+ const cityStateMaxKm = opts.cityStateMaxKm ?? 2
112
+ const onProgress = opts.onProgress ?? (() => {})
113
+
114
+ if (tableExists(db, COINCIDENT_ROLES_TABLE) && drop) {
115
+ onProgress("dropping", COINCIDENT_ROLES_TABLE)
116
+ db.exec(`DROP TABLE ${COINCIDENT_ROLES_TABLE}`)
117
+ }
118
+ onProgress("creating", COINCIDENT_ROLES_TABLE)
119
+ // Raw DDL by design: this is a sync builder consumed by a sync CLI (build-coincident-roles-cli) and
120
+ // 6 sync unit tests, so routing one table through async Kysely would cascade async through all of
121
+ // them for no real gain. See AGENTS.md "Database / inline SQL". (The SELECT + INSERT loop below are
122
+ // likewise the raw hot path.)
123
+ db.exec(`
124
+ CREATE TABLE IF NOT EXISTS ${COINCIDENT_ROLES_TABLE} (
125
+ admin_id INTEGER NOT NULL,
126
+ locality_id INTEGER NOT NULL,
127
+ relationship_type TEXT NOT NULL,
128
+ admin_placetype TEXT NOT NULL,
129
+ distance_km REAL NOT NULL,
130
+ locality_population INTEGER NOT NULL DEFAULT 0,
131
+ PRIMARY KEY (admin_id, locality_id)
132
+ )
133
+ `)
134
+
135
+ onProgress("scanning")
136
+ // Admin (region/county tier) ⋈ same-name DESCENDANT locality. `place_population` is optional (LEFT
137
+ // JOIN → 0 when absent). The relative-tolerance filter + relationship classification happen in JS so
138
+ // the SQL stays a plain join. `spr` exposes the bbox columns we need for the diagonal.
139
+ const candidates = db
140
+ .prepare(
141
+ `SELECT r.id AS admin_id, r.placetype AS admin_placetype, r.country AS country, l.id AS locality_id,
142
+ r.latitude AS rlat, r.longitude AS rlon, l.latitude AS llat, l.longitude AS llon,
143
+ r.min_latitude, r.min_longitude, r.max_latitude, r.max_longitude,
144
+ COALESCE(p.population, 0) AS pop
145
+ FROM spr r
146
+ JOIN spr l ON lower(l.name) = lower(r.name) AND l.placetype = 'locality'
147
+ AND l.is_current != 0 AND l.is_deprecated = 0
148
+ JOIN ${"ancestors"} a ON a.id = l.id AND a.ancestor_id = r.id
149
+ LEFT JOIN place_population p ON p.id = l.id
150
+ WHERE r.placetype = 'region'
151
+ AND r.is_current != 0 AND r.is_deprecated = 0`
152
+ )
153
+ .all() as unknown as CandidateRow[]
154
+
155
+ onProgress("filtering", `${candidates.length} candidates`)
156
+ const insert = db.prepare(
157
+ `INSERT OR REPLACE INTO ${COINCIDENT_ROLES_TABLE}
158
+ (admin_id, locality_id, relationship_type, admin_placetype, distance_km, locality_population)
159
+ VALUES (?, ?, ?, ?, ?, ?)`
160
+ )
161
+ const byCountry: Record<string, number> = {}
162
+ let rowCount = 0
163
+ db.exec("BEGIN")
164
+
165
+ try {
166
+ for (const c of candidates) {
167
+ const dist = haversineKm(c.rlat, c.rlon, c.llat, c.llon)
168
+ const diag = haversineKm(c.min_latitude, c.min_longitude, c.max_latitude, c.max_longitude)
169
+ const tolerance = Math.max(toleranceFraction * diag, minToleranceKm)
170
+
171
+ if (dist > tolerance) continue
172
+ // v1 is region-tier only: a place is a `city-state` when its centroid coincides with the
173
+ // region's (Berlin/Hamburg), else `capital-seat` (a region named after its principal city, e.g.
174
+ // Milano province → Milano comune). `consolidated-county` is reserved for a future county-tier
175
+ // pass (US SF/Denver) — excluded from v1 because county-tier same-name coincidences are
176
+ // dominated by French cantons / JP counties that don't hit the parser-drops-locality failure.
177
+ const relationshipType = dist <= cityStateMaxKm ? "city-state" : "capital-seat"
178
+ insert.run(c.admin_id, c.locality_id, relationshipType, c.admin_placetype, dist, c.pop)
179
+ rowCount++
180
+ byCountry[c.country] = (byCountry[c.country] ?? 0) + 1
181
+ }
182
+ db.exec("COMMIT")
183
+ } catch (err) {
184
+ db.exec("ROLLBACK")
185
+ throw err
186
+ }
187
+ db.exec(`CREATE INDEX IF NOT EXISTS coincident_roles_by_admin ON ${COINCIDENT_ROLES_TABLE} (admin_id)`)
188
+
189
+ onProgress("done", `${rowCount} coincident-role rows`)
190
+
191
+ return { created: true, rowCount, byCountry, durationMs: Date.now() - start }
192
+ }
193
+
194
+ /** True iff the relation table exists. Used by the resolver to decide whether completion can run. */
195
+ export function coincidentRolesExists(db: DatabaseSync): boolean {
196
+ return tableExists(db, COINCIDENT_ROLES_TABLE)
197
+ }
198
+
199
+ /**
200
+ * Load the relation into an in-memory map keyed by `admin_id` for O(1) runtime lookup (#405). Each admin may map to
201
+ * MULTIPLE same-name descendants; the consumer disambiguates (min distance → population → abstain). Returns an empty
202
+ * map when the table is absent.
203
+ */
204
+ export function loadCoincidentRoles(db: DatabaseSync): Map<number, CoincidentRole[]> {
205
+ const map = new Map<number, CoincidentRole[]>()
206
+
207
+ if (!coincidentRolesExists(db)) return map
208
+ const rows = db
209
+ .prepare(
210
+ `SELECT admin_id, locality_id, relationship_type, admin_placetype, distance_km, locality_population
211
+ FROM ${COINCIDENT_ROLES_TABLE}`
212
+ )
213
+ .all() as unknown as Array<{
214
+ admin_id: number
215
+ locality_id: number
216
+ relationship_type: CoincidentRole["relationshipType"]
217
+ admin_placetype: string
218
+ distance_km: number
219
+ locality_population: number
220
+ }>
221
+
222
+ for (const r of rows) {
223
+ const entry: CoincidentRole = {
224
+ localityID: r.locality_id,
225
+ relationshipType: r.relationship_type,
226
+ adminPlacetype: r.admin_placetype,
227
+ distanceKm: r.distance_km,
228
+ population: r.locality_population,
229
+ }
230
+ const list = map.get(r.admin_id)
231
+
232
+ if (list) {
233
+ list.push(entry)
234
+ } else {
235
+ map.set(r.admin_id, [entry])
236
+ }
237
+ }
238
+
239
+ return map
240
+ }
package/convention.ts ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * The **Geographic Rule Engine** convention model (Direction E, #289 — see
7
+ * `docs/articles/plan/2026-06-05-geographic-rule-engine.md` and epic #288).
8
+ *
9
+ * A `Convention` is a declarative resolution profile attached to a Who's-On-First admin polygon.
10
+ * The engine deep-merges the conventions along a resolved place's ancestor chain — country →
11
+ * region → … → locality, most-specific winning — and the backend dispatches the named strategies
12
+ * in `candidateStrategies`, first to return candidates wins.
13
+ *
14
+ * This module is the backend-agnostic core: the convention TYPES, the deep-merge, and the seed
15
+ * source. The strategy IMPLEMENTATIONS are SQL-bound and live in `lookup.ts`, registered by
16
+ * name.
17
+ *
18
+ * For the existing EU locales (DE/FR/GB/NL) the seed source is empty, so every query resolves to
19
+ * `WORLD_DEFAULT` and the dispatch is byte-identical to the pre-engine coordinate-first path. JP
20
+ * / KR / TW add rows here (and #290 swaps the seed map for a build-from-source sqlite-backed
21
+ * source).
22
+ */
23
+
24
+ import type { FindPlaceQuery, PlaceCandidate } from "./types.ts"
25
+
26
+ /**
27
+ * Soft-scoring weights for the `postcode_area_resolution` strategy: `pc·S_pc + name·S_name + pop·S_pop`.
28
+ */
29
+ export interface ScoringWeights {
30
+ pc: number
31
+ name: number
32
+ pop: number
33
+ }
34
+
35
+ /**
36
+ * A geographically-scoped resolution profile. Namespaced sections grow per phase; #289 ships the dispatch + scoring
37
+ * slice (`candidateStrategies` + `scoringWeights`). Later phases add `fieldMapping` (locale semantics for `locator[]`),
38
+ * `tokenNormalization`, etc.
39
+ */
40
+ export interface Convention {
41
+ /** Ordered strategy names the dispatcher runs; the first to return a non-null result wins. */
42
+ candidateStrategies?: string[]
43
+ /**
44
+ * Weights for `postcode_area_resolution`'s soft-score. Partial — a layer may nudge one weight and inherit the rest
45
+ * from the layers below it (`resolveConvention` fills any gaps from WORLD_DEFAULT).
46
+ */
47
+ scoringWeights?: Partial<ScoringWeights>
48
+ }
49
+
50
+ /**
51
+ * A fully-resolved convention: every field present, weights complete. What `resolveConvention` returns and what
52
+ * strategies consume.
53
+ */
54
+ export interface ResolvedConvention {
55
+ candidateStrategies: string[]
56
+ scoringWeights: ScoringWeights
57
+ }
58
+
59
+ /**
60
+ * The base layer every ancestor chain starts from. Reproduces the pre-engine coordinate-first behavior exactly: try
61
+ * `postcode_area_resolution`, else fall back to fuzzy name match; soft-score weights 0.6 / 0.3 / 0.1. Changing these
62
+ * changes EU behavior — don't, without a byte-stability run.
63
+ */
64
+ export const WORLD_DEFAULT: ResolvedConvention = {
65
+ candidateStrategies: ["postcode_area_resolution", "fallback_fuzzy_name_match"],
66
+ scoringWeights: { pc: 0.6, name: 0.3, pop: 0.1 },
67
+ }
68
+
69
+ /**
70
+ * The strategy names the backend registers. The single source of truth shared by the dispatch registry and the
71
+ * build-time validator, so an authored convention that names a non-existent strategy is caught at build (loud) rather
72
+ * than silently skipped at runtime.
73
+ */
74
+ export const BUILTIN_STRATEGY_NAMES = ["postcode_area_resolution", "fallback_fuzzy_name_match"] as const
75
+
76
+ /**
77
+ * Table name for the convention asset (#290). Carried here so the build script, the runtime source, and the shard
78
+ * auto-detect all agree.
79
+ */
80
+ export const ADDRESS_CONVENTION_TABLE = "address_convention"
81
+
82
+ /**
83
+ * A named resolution primitive. Returns `null` to abstain (gate unmet / no data) → the dispatcher tries the next
84
+ * strategy; returns an array (possibly empty) to claim the result.
85
+ */
86
+ export type Strategy = (query: FindPlaceQuery, convention: ResolvedConvention) => Promise<PlaceCandidate[] | null>
87
+
88
+ /**
89
+ * Look up a convention record by WOF polygon id. Returns `undefined` when the polygon has no override.
90
+ */
91
+ export interface ConventionSource {
92
+ get(wofID: number): Convention | undefined
93
+ }
94
+
95
+ /**
96
+ * In-memory convention source seeded from a `{ wofID: Convention }` map. Empty for the EU locales (they ride
97
+ * `WORLD_DEFAULT`); JP / KR / TW add rows. #290 replaces this with a sqlite-backed source built from source, same
98
+ * distributable-asset discipline as `postcode-locality-intl.db`.
99
+ */
100
+ export class SeedConventionSource implements ConventionSource {
101
+ readonly #rows: Map<number, Convention>
102
+
103
+ constructor(rows: Record<number, Convention> = {}) {
104
+ this.#rows = new Map(Object.entries(rows).map(([k, v]) => [Number(k), v]))
105
+ }
106
+
107
+ get(wofID: number): Convention | undefined {
108
+ return this.#rows.get(wofID)
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Deep-merge convention layers, later (more-specific) layers winning per field. `candidateStrategies` is replaced
114
+ * wholesale — a convention names its full ordered list, it does not append. `scoringWeights` is merged key-by-key so a
115
+ * locality can nudge one weight without restating the others.
116
+ */
117
+ export function mergeConventions(base: Convention, ...overrides: Array<Convention | undefined>): Convention {
118
+ const out: Convention = {
119
+ candidateStrategies: base.candidateStrategies ? [...base.candidateStrategies] : undefined,
120
+ scoringWeights: base.scoringWeights ? { ...base.scoringWeights } : undefined,
121
+ }
122
+
123
+ for (const o of overrides) {
124
+ if (!o) continue
125
+
126
+ if (o.candidateStrategies !== undefined) {
127
+ out.candidateStrategies = [...o.candidateStrategies]
128
+ }
129
+
130
+ if (o.scoringWeights !== undefined) {
131
+ out.scoringWeights = { ...(out.scoringWeights ?? WORLD_DEFAULT.scoringWeights), ...o.scoringWeights }
132
+ }
133
+ }
134
+
135
+ return out
136
+ }
137
+
138
+ /**
139
+ * Resolve the effective convention for a place given its ancestor chain, ordered MOST-GENERAL → MOST-SPECIFIC (country,
140
+ * region, …, locality). Starts from `WORLD_DEFAULT` so every field is defined regardless of which (if any) ancestors
141
+ * carry an override.
142
+ */
143
+ export function resolveConvention(source: ConventionSource, ancestorIds: readonly number[]): ResolvedConvention {
144
+ const layers = ancestorIds.map((id) => source.get(id))
145
+ const merged = mergeConventions(WORLD_DEFAULT, ...layers)
146
+
147
+ return {
148
+ candidateStrategies: merged.candidateStrategies ?? WORLD_DEFAULT.candidateStrategies,
149
+ // Fill any weight gaps from the base so strategies always see a complete set.
150
+ scoringWeights: { ...WORLD_DEFAULT.scoringWeights, ...merged.scoringWeights },
151
+ }
152
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * FST-based autocomplete. Prefix walk + BFS expansion to collect ranked place suggestions. O(depth
7
+ * × branching) — the FST IS the autocomplete index.
8
+ *
9
+ * Two query shapes are handled (the FST is a trie over normalized WORD tokens):
10
+ *
11
+ * - COMPLETE tokens ("new york") — `walk` lands on a state; collect its accepting entries + BFS a
12
+ * couple tokens past it for nearby completions. This is the CLI's "complete a place word"
13
+ * path.
14
+ * - A PARTIAL last token ("new yor", "chic") — `walk` fails (there is no "yor" edge, only "york"). So
15
+ * walk the complete prefix, then complete the partial token by prefix-filtering the
16
+ * continuation edges (`token.startsWith(partial)`). This is what a char-level typeahead
17
+ * needs; without it "new yor" returns nothing useful. (#587)
18
+ */
19
+
20
+ import { FSTMatcher, normalizeTokens } from "./fst-matcher.ts"
21
+ import type { PlaceEntry } from "./fst-types.ts"
22
+
23
+ export interface AutocompleteResult {
24
+ query: string
25
+ normalizedTokens: string[]
26
+ depth: number
27
+ suggestions: AutocompleteSuggestion[]
28
+ }
29
+
30
+ export interface AutocompleteSuggestion {
31
+ name: string
32
+ placetype: string
33
+ importance: number
34
+ wofID: number
35
+ parentChain: number[]
36
+ matchDepth: number
37
+ completionTokens: string[]
38
+ }
39
+
40
+ export interface AutocompleteOpts {
41
+ maxSuggestions?: number
42
+ maxExpansionDepth?: number
43
+ /**
44
+ * Collapse same-name suggestions to the single highest-importance one. Off by default (the CLI surfaces distinct
45
+ * same-name places — New York the city vs the county); a typeahead wants it ON so the dropdown isn't four "New
46
+ * London"s. (#587)
47
+ */
48
+ dedupeByName?: boolean
49
+ }
50
+
51
+ interface BfsItem {
52
+ stateID: number
53
+ depth: number
54
+ tokens: string[]
55
+ }
56
+
57
+ /** Max accepting entries collected per BFS branch — keeps one dense branch from starving the search. */
58
+ const PER_BRANCH = 4
59
+
60
+ /**
61
+ * The top-`k` entries by importance (descending). Avoids sorting/allocating when `entries` is small.
62
+ */
63
+ function topByImportance(entries: readonly PlaceEntry[], k: number): PlaceEntry[] {
64
+ if (entries.length <= k) return [...entries]
65
+
66
+ return [...entries].sort((a, b) => b.importance - a.importance).slice(0, k)
67
+ }
68
+
69
+ /**
70
+ * Autocomplete from the current prefix. Returns suggestions ranked importance-descending.
71
+ */
72
+ export function autocomplete(fst: FSTMatcher, query: string, opts: AutocompleteOpts = {}): AutocompleteResult {
73
+ const maxSuggestions = opts.maxSuggestions ?? 10
74
+ const maxExpansionDepth = opts.maxExpansionDepth ?? 2
75
+ const normalizedTokens = normalizeTokens(query)
76
+
77
+ if (normalizedTokens.length === 0) {
78
+ return { query, normalizedTokens: [], depth: 0, suggestions: [] }
79
+ }
80
+
81
+ const seen = new Map<number, AutocompleteSuggestion>()
82
+ const queue: BfsItem[] = []
83
+ let depth = 0
84
+
85
+ const match = fst.walk(normalizedTokens)
86
+
87
+ if (match) {
88
+ // COMPLETE-token prefix landed on a state. Seed at the match state (accepting + continuations).
89
+ depth = match.depth
90
+
91
+ for (const entry of fst.accepting(match.stateID)) {
92
+ addSuggestion(seen, entry, match.depth, [])
93
+ }
94
+
95
+ for (const cont of fst.continuations(match.stateID)) {
96
+ queue.push({ stateID: cont.targetState, depth: 1, tokens: [cont.token] })
97
+ }
98
+ } else {
99
+ // PARTIAL last token — walk the complete prefix, complete the partial by prefix-filtering edges.
100
+ const complete = normalizedTokens.slice(0, -1)
101
+ const partial = normalizedTokens[normalizedTokens.length - 1]!
102
+ const prefixState = complete.length === 0 ? 0 : (fst.walk(complete)?.stateID ?? undefined)
103
+
104
+ if (prefixState === undefined) {
105
+ return { query, normalizedTokens, depth: 0, suggestions: [] }
106
+ }
107
+ depth = complete.length
108
+
109
+ for (const cont of fst.continuations(prefixState)) {
110
+ if (!cont.token.startsWith(partial)) continue
111
+
112
+ // This edge completes the typed partial token — its target is a real match at depth+1.
113
+ for (const entry of topByImportance(fst.accepting(cont.targetState), PER_BRANCH)) {
114
+ addSuggestion(seen, entry, complete.length + 1, [cont.token])
115
+ }
116
+ // BFS a little past it too (multi-token completions: "new yor" → "New York Mills").
117
+ queue.push({ stateID: cont.targetState, depth: 1, tokens: [cont.token] })
118
+ }
119
+ }
120
+
121
+ // BFS expansion (shared by both paths) — find nearby completions up to maxExpansionDepth. Each
122
+ // branch contributes only its top PER_BRANCH places: a state like "new london" has dozens of
123
+ // accepting entries and would otherwise blow the budget before the BFS ever reaches "new york"
124
+ // (the "new" state has 311 continuations). Per-branch capping keeps the search broad. (#587)
125
+ while (queue.length > 0 && seen.size < maxSuggestions * 4) {
126
+ const item = queue.shift()!
127
+
128
+ if (item.depth > maxExpansionDepth) continue
129
+
130
+ for (const entry of topByImportance(fst.accepting(item.stateID), PER_BRANCH)) {
131
+ addSuggestion(seen, entry, depth + item.depth, item.tokens)
132
+ }
133
+
134
+ if (item.depth < maxExpansionDepth) {
135
+ for (const cont of fst.continuations(item.stateID)) {
136
+ queue.push({ stateID: cont.targetState, depth: item.depth + 1, tokens: [...item.tokens, cont.token] })
137
+ }
138
+ }
139
+ }
140
+
141
+ let suggestions = [...seen.values()].sort((a, b) => b.importance - a.importance)
142
+
143
+ if (opts.dedupeByName) {
144
+ suggestions = dedupeByName(suggestions)
145
+ }
146
+
147
+ return { query, normalizedTokens, depth, suggestions: suggestions.slice(0, maxSuggestions) }
148
+ }
149
+
150
+ function addSuggestion(
151
+ seen: Map<number, AutocompleteSuggestion>,
152
+ entry: PlaceEntry,
153
+ matchDepth: number,
154
+ completionTokens: string[]
155
+ ): void {
156
+ const existing = seen.get(entry.wofID)
157
+
158
+ if (existing && existing.matchDepth <= matchDepth) return
159
+ seen.set(entry.wofID, {
160
+ name: entry.name,
161
+ placetype: entry.placetype,
162
+ importance: entry.importance,
163
+ wofID: entry.wofID,
164
+ parentChain: entry.parentChain,
165
+ matchDepth,
166
+ completionTokens: [...completionTokens],
167
+ })
168
+ }
169
+
170
+ /**
171
+ * Keep one suggestion per name — the highest-importance. Input is already importance-sorted, so the first occurrence
172
+ * per name wins; order is preserved.
173
+ */
174
+ function dedupeByName(suggestions: AutocompleteSuggestion[]): AutocompleteSuggestion[] {
175
+ const seenNames = new Set<string>()
176
+ const out: AutocompleteSuggestion[] = []
177
+
178
+ for (const s of suggestions) {
179
+ const key = s.name.toLowerCase()
180
+
181
+ if (seenNames.has(key)) continue
182
+ seenNames.add(key)
183
+ out.push(s)
184
+ }
185
+
186
+ return out
187
+ }