@mailwoman/resolver-wof-sqlite 7.2.0 → 7.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) 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/out/poi-lookup.d.ts +14 -2
  27. package/out/poi-lookup.d.ts.map +1 -1
  28. package/out/poi-lookup.js +55 -21
  29. package/out/poi-lookup.js.map +1 -1
  30. package/out/poi-schema.d.ts +9 -0
  31. package/out/poi-schema.d.ts.map +1 -1
  32. package/out/poi-schema.js +16 -0
  33. package/out/poi-schema.js.map +1 -1
  34. package/out/reverse.d.ts +8 -1
  35. package/out/reverse.d.ts.map +1 -1
  36. package/out/reverse.js +10 -1
  37. package/out/reverse.js.map +1 -1
  38. package/package.json +168 -82
  39. package/poi-lookup.ts +375 -0
  40. package/poi-schema.ts +164 -0
  41. package/postal-city-alias-lookup.ts +89 -0
  42. package/postal-city-alias-schema.ts +75 -0
  43. package/postal-city-candidate-schema.ts +81 -0
  44. package/postcode-point-lookup.ts +64 -0
  45. package/reverse.ts +439 -0
  46. package/schema.ts +176 -0
  47. package/sharding.ts +235 -0
  48. package/sqlite-convention-source.ts +61 -0
  49. package/sqlite-utils.ts +25 -0
  50. package/street-centroid-schema.ts +124 -0
  51. package/street-centroid.ts +124 -0
  52. package/street-morphology-fst-builder.ts +230 -0
  53. package/street-name-lookup.ts +101 -0
  54. package/street-normalize.ts +302 -0
  55. package/street-segment-schema.ts +104 -0
  56. package/types.ts +164 -0
  57. package/unified-schema.ts +171 -0
package/fst-builder.ts ADDED
@@ -0,0 +1,291 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Build an FST (finite-state transducer) from a WOF SQLite database. The FST maps normalized token
7
+ * sequences to PlaceEntry arrays, pre-computing the valid interpretations for every prefix of
8
+ * every place name in the gazetteer.
9
+ *
10
+ * Build pipeline: open WOF DB → query spr + names → normalize names → insert into trie → attach
11
+ * PlaceEntry at terminals → return FSTMatcher.
12
+ */
13
+
14
+ import { DatabaseSync } from "node:sqlite"
15
+
16
+ import type { FSTNode } from "./fst-matcher.ts"
17
+ import { FSTMatcher, normalizeTokens } from "./fst-matcher.ts"
18
+ import type { BuildFSTOpts, BuildFSTResult, FSTProvenance, PlaceEntry, PlacetypeID } from "./fst-types.ts"
19
+
20
+ const DEFAULT_PLACETYPES: PlacetypeID[] = [
21
+ "country",
22
+ "region",
23
+ "county",
24
+ "locality",
25
+ "localadmin",
26
+ "borough",
27
+ "neighbourhood",
28
+ ]
29
+ const DEFAULT_COUNTRIES = ["US"]
30
+ const DEFAULT_LANGUAGES = ["eng", ""]
31
+
32
+ interface SprRow {
33
+ id: number
34
+ name: string
35
+ placetype: string
36
+ parent_id: number
37
+ latitude: number
38
+ longitude: number
39
+ }
40
+
41
+ interface NameRow {
42
+ id: number
43
+ name: string
44
+ language: string
45
+ privateuse: string
46
+ }
47
+
48
+ interface PopulationRow {
49
+ id: number
50
+ population: number
51
+ }
52
+
53
+ export function buildFSTFromWOF(opts: BuildFSTOpts): {
54
+ matcher: FSTMatcher
55
+ provenance: FSTProvenance
56
+ result: BuildFSTResult
57
+ } {
58
+ const countries = opts.countries ?? DEFAULT_COUNTRIES
59
+ const placetypes = opts.placetypes ?? DEFAULT_PLACETYPES
60
+ const languages = opts.languages ?? DEFAULT_LANGUAGES
61
+ const progress = opts.onProgress ?? (() => {})
62
+
63
+ progress("open", opts.dbPath)
64
+ const db = new DatabaseSync(opts.dbPath, { open: true })
65
+
66
+ // Phase 1: Load all matching SPR rows.
67
+ progress("spr", `Loading places for countries=[${countries}], placetypes=[${placetypes}]`)
68
+ const placeholders = (arr: string[]) => arr.map(() => "?").join(",")
69
+ const sprStmt = db.prepare(
70
+ `SELECT id, name, placetype, parent_id, latitude, longitude
71
+ FROM spr
72
+ WHERE is_current = 1
73
+ AND country IN (${placeholders(countries)})
74
+ AND placetype IN (${placeholders(placetypes)})`
75
+ )
76
+ const sprRows = sprStmt.all(...countries, ...placetypes) as unknown as SprRow[]
77
+ progress("spr", `Loaded ${sprRows.length} places`)
78
+
79
+ // Phase 2: Build a lookup for parent chain resolution.
80
+ const sprByID = new Map<number, SprRow>()
81
+
82
+ for (const row of sprRows) {
83
+ sprByID.set(row.id, row)
84
+ }
85
+
86
+ // Also load parent rows that might be outside our placetype filter (e.g., country for region).
87
+ const parentStmt = db.prepare("SELECT id, name, placetype, parent_id, latitude, longitude FROM spr WHERE id = ?")
88
+
89
+ // Fallback: use ancestors table when parent_id is a sentinel (-1, -4, etc.).
90
+ let ancestorStmt: ReturnType<typeof db.prepare> | null = null
91
+
92
+ try {
93
+ ancestorStmt = db.prepare(
94
+ `SELECT DISTINCT ancestor_id FROM ancestors
95
+ WHERE id = ? AND ancestor_placetype IN ('country', 'region', 'county')
96
+ ORDER BY CASE ancestor_placetype
97
+ WHEN 'county' THEN 1
98
+ WHEN 'region' THEN 2
99
+ WHEN 'country' THEN 3
100
+ END`
101
+ )
102
+ } catch {
103
+ progress("ancestors", "No ancestors table — sentinel parent_ids will produce empty chains")
104
+ }
105
+
106
+ function resolveParentChain(id: number): number[] {
107
+ const row = sprByID.get(id)
108
+
109
+ if (!row) return []
110
+
111
+ // If parent_id is a sentinel (≤ 0), use ancestors table.
112
+ if (row.parent_id <= 0 && ancestorStmt) {
113
+ const ancestors = ancestorStmt.all(id) as unknown as Array<{ ancestor_id: number }>
114
+
115
+ return ancestors.map((a) => a.ancestor_id).filter((aid) => aid !== id)
116
+ }
117
+
118
+ // Normal case: walk parent_id chain.
119
+ const chain: number[] = []
120
+ let current = row.parent_id
121
+ const seen = new Set<number>([id])
122
+
123
+ while (current > 0 && !seen.has(current)) {
124
+ seen.add(current)
125
+ chain.push(current)
126
+ let parentRow = sprByID.get(current)
127
+
128
+ if (!parentRow) {
129
+ const fetched = parentStmt.get(current) as unknown as SprRow | undefined
130
+
131
+ if (!fetched) break
132
+ parentRow = fetched
133
+ sprByID.set(current, parentRow)
134
+ }
135
+
136
+ if (parentRow.parent_id > 0 && parentRow.parent_id !== current) {
137
+ current = parentRow.parent_id
138
+ } else {
139
+ break
140
+ }
141
+ }
142
+
143
+ return chain
144
+ }
145
+
146
+ // Phase 3: Load importance data (Wikipedia-based, falls back to population-scaled).
147
+ // See docs/articles/concepts/importance-vs-population.md for the two-signal contract.
148
+ progress("importance", "Loading importance data")
149
+ const importanceMap = new Map<number, number>()
150
+
151
+ try {
152
+ const impStmt = db.prepare("SELECT id, importance FROM place_importance")
153
+ const impRows = impStmt.all() as unknown as Array<{ id: number; importance: number }>
154
+
155
+ for (const row of impRows) {
156
+ importanceMap.set(row.id, row.importance)
157
+ }
158
+ progress("importance", `Loaded ${importanceMap.size} importance scores`)
159
+ } catch {
160
+ progress("importance", "No place_importance table — falling back to population")
161
+
162
+ try {
163
+ const popStmt = db.prepare("SELECT id, population FROM place_population")
164
+ const popRows = popStmt.all() as unknown as PopulationRow[]
165
+
166
+ for (const row of popRows) {
167
+ const normalized = row.population > 0 ? Math.min(1.0, Math.log2(1 + row.population / 1000) / 14) : 0
168
+ importanceMap.set(row.id, normalized)
169
+ }
170
+ } catch {
171
+ progress("importance", "No place_population either — using 0 for all")
172
+ }
173
+ }
174
+
175
+ // Phase 4: Load names for matching places.
176
+ progress("names", "Loading name variants")
177
+ const placeIds = sprRows.map((r) => r.id)
178
+ const namesByPlace = new Map<number, string[]>()
179
+
180
+ const allLanguages = languages.includes("*")
181
+
182
+ for (let i = 0; i < placeIds.length; i += 500) {
183
+ const chunk = placeIds.slice(i, i + 500)
184
+ const idPlaceholders = chunk.map(() => "?").join(",")
185
+ const nameStmt = allLanguages
186
+ ? db.prepare(`SELECT id, name, language, privateuse FROM names WHERE id IN (${idPlaceholders})`)
187
+ : db.prepare(
188
+ `SELECT id, name, language, privateuse FROM names WHERE id IN (${idPlaceholders}) AND language IN (${languages.map(() => "?").join(",")})`
189
+ )
190
+ const nameRows = (allLanguages
191
+ ? nameStmt.all(...chunk)
192
+ : nameStmt.all(...chunk, ...languages)) as unknown as NameRow[]
193
+
194
+ for (const row of nameRows) {
195
+ const existing = namesByPlace.get(row.id) ?? []
196
+
197
+ if (!existing.includes(row.name)) {
198
+ existing.push(row.name)
199
+ }
200
+ namesByPlace.set(row.id, existing)
201
+ }
202
+ }
203
+ progress("names", `Loaded names for ${namesByPlace.size} places`)
204
+
205
+ // Phase 5: Build the trie.
206
+ progress("trie", "Building trie")
207
+ const nodes: FSTNode[] = [{ edges: new Map(), places: [] }]
208
+
209
+ function insertName(tokens: string[], entry: PlaceEntry): void {
210
+ if (tokens.length === 0) return
211
+ let stateID = 0
212
+
213
+ for (const t of tokens) {
214
+ const node = nodes[stateID]!
215
+ let next = node.edges.get(t)
216
+
217
+ if (next === undefined) {
218
+ next = nodes.length
219
+ nodes.push({ edges: new Map(), places: [] })
220
+ node.edges.set(t, next)
221
+ }
222
+ stateID = next
223
+ }
224
+ // Deduplicate: don't add the same wofID twice at the same state.
225
+ const existing = nodes[stateID]!.places
226
+
227
+ if (!existing.some((p) => p.wofID === entry.wofID && p.placetype === entry.placetype)) {
228
+ existing.push(entry)
229
+ }
230
+ }
231
+
232
+ let insertCount = 0
233
+
234
+ for (const row of sprRows) {
235
+ const parentChain = resolveParentChain(row.id)
236
+ const entry: PlaceEntry = {
237
+ wofID: row.id,
238
+ placetype: row.placetype as PlacetypeID,
239
+ name: row.name,
240
+ parentChain,
241
+ importance: importanceMap.get(row.id) ?? 0,
242
+ lat: row.latitude,
243
+ lon: row.longitude,
244
+ }
245
+
246
+ // Insert the primary name from spr.
247
+ const primaryTokens = normalizeTokens(row.name)
248
+ insertName(primaryTokens, entry)
249
+ insertCount++
250
+
251
+ // Insert alt names from the names table.
252
+ const altNames = namesByPlace.get(row.id) ?? []
253
+
254
+ for (const altName of altNames) {
255
+ if (altName === row.name) continue
256
+ const altTokens = normalizeTokens(altName)
257
+
258
+ if (altTokens.length > 0 && altTokens.join(" ") !== primaryTokens.join(" ")) {
259
+ insertName(altTokens, entry)
260
+ insertCount++
261
+ }
262
+ }
263
+ }
264
+
265
+ db.close()
266
+ progress("done", `Built trie: ${nodes.length} states, ${insertCount} name insertions`)
267
+
268
+ const edgeCount = nodes.reduce((sum, n) => sum + n.edges.size, 0)
269
+ const matcher = FSTMatcher.fromNodes(nodes)
270
+ const provenance: FSTProvenance = {
271
+ builtAt: new Date().toISOString(),
272
+ countries,
273
+ stateCount: nodes.length,
274
+ placeCount: sprRows.length,
275
+ edgeCount,
276
+ nameInsertions: insertCount,
277
+ importanceMatches: importanceMap.size,
278
+ sourceDB: opts.dbPath,
279
+ }
280
+
281
+ return {
282
+ matcher,
283
+ provenance,
284
+ result: {
285
+ stateCount: nodes.length,
286
+ placeCount: sprRows.length,
287
+ edgeCount,
288
+ tokenCount: insertCount,
289
+ },
290
+ }
291
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Browser-compatible FST deserializer. Uses DataView + TextDecoder instead of Node's Buffer so the
7
+ * same binary format can be loaded in the browser via fetch(url).then(r => r.arrayBuffer()).
8
+ *
9
+ * This is a read-only counterpart to fst-serialize.ts — serialization stays Node-only (it's a
10
+ * build-time operation).
11
+ */
12
+
13
+ import type { FSTNode } from "./fst-matcher.ts"
14
+ import { FSTMatcher } from "./fst-matcher.ts"
15
+ import type { FSTProvenance, PlaceEntry, PlacetypeID } from "./fst-types.ts"
16
+
17
+ const HEADER_SIZE = 32
18
+ const EDGE_ENTRY_SIZE = 8
19
+ const PLACE_ENTRY_SIZE = 56
20
+ const MAGIC_BYTES = [0x46, 0x53, 0x54, 0x00] // "FST\0"
21
+ // Must track the serializer's VERSION (fst-serialize.ts, currently 4). The v3 provenance + v4
22
+ // 16-byte-state/u32-count layout logic below already matches the Node deserializer; only this gate
23
+ // was left stale at 2, so the browser FST loader rejected every real (v4) artifact.
24
+ const MAX_VERSION = 4
25
+
26
+ const PLACETYPE_ORDER: readonly PlacetypeID[] = [
27
+ "country",
28
+ "region",
29
+ "county",
30
+ "locality",
31
+ "localadmin",
32
+ "borough",
33
+ "neighbourhood",
34
+ "postalcode",
35
+ "campus",
36
+ "dependency",
37
+ "street_affix",
38
+ ]
39
+
40
+ export function deserializeFSTWeb(input: ArrayBuffer | Uint8Array): FSTMatcher {
41
+ const bytes = input instanceof ArrayBuffer ? new Uint8Array(input) : input
42
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
43
+ const decoder = new TextDecoder("utf-8")
44
+
45
+ if (bytes.byteLength < HEADER_SIZE) throw new Error("FST buffer too small for header")
46
+
47
+ if (
48
+ bytes[0] !== MAGIC_BYTES[0] ||
49
+ bytes[1] !== MAGIC_BYTES[1] ||
50
+ bytes[2] !== MAGIC_BYTES[2] ||
51
+ bytes[3] !== MAGIC_BYTES[3]
52
+ ) {
53
+ throw new Error("FST magic mismatch")
54
+ }
55
+
56
+ const version = view.getUint16(4, true)
57
+
58
+ if (version < 1 || version > MAX_VERSION) {
59
+ throw new Error(`FST version ${version} unsupported (expected 1..${MAX_VERSION})`)
60
+ }
61
+ const isV2 = version >= 2
62
+
63
+ const stateCount = view.getUint32(8, true)
64
+ const edgeCount = view.getUint32(12, true)
65
+ const _placeCount = view.getUint32(16, true)
66
+ const stringCount = view.getUint32(20, true)
67
+ const stringBytes = view.getUint32(24, true)
68
+
69
+ let pos = HEADER_SIZE
70
+
71
+ // --- String table ---
72
+ const strOffsets = new Uint32Array(stringCount + 1)
73
+
74
+ for (let i = 0; i <= stringCount; i++) {
75
+ strOffsets[i] = view.getUint32(pos, true)
76
+ pos += 4
77
+ }
78
+ const strDataStart = pos
79
+ const strings: string[] = new Array(stringCount)
80
+
81
+ for (let i = 0; i < stringCount; i++) {
82
+ const start = strDataStart + strOffsets[i]!
83
+ const end = strDataStart + strOffsets[i + 1]!
84
+ strings[i] = decoder.decode(bytes.subarray(start, end))
85
+ }
86
+ pos += stringBytes
87
+
88
+ // --- State table ---
89
+ const stateEntrySize = version >= 4 ? 16 : 12
90
+ const stateTableStart = pos
91
+ const edgeTableStart = stateTableStart + stateCount * stateEntrySize
92
+ const placeTableStart = edgeTableStart + edgeCount * EDGE_ENTRY_SIZE
93
+
94
+ const nodes: FSTNode[] = new Array(stateCount)
95
+
96
+ for (let si = 0; si < stateCount; si++) {
97
+ const sp = stateTableStart + si * stateEntrySize
98
+ const edgeStart = view.getUint32(sp, true)
99
+ const placeStart = view.getUint32(sp + 4, true)
100
+ const edgeCountForState = version >= 4 ? view.getUint32(sp + 8, true) : view.getUint16(sp + 8, true)
101
+ const placeCountForState = version >= 4 ? view.getUint32(sp + 12, true) : view.getUint16(sp + 10, true)
102
+
103
+ const edges = new Map<string, number>()
104
+
105
+ for (let ei = 0; ei < edgeCountForState; ei++) {
106
+ const ep = edgeTableStart + (edgeStart + ei) * EDGE_ENTRY_SIZE
107
+ const stringIdx = view.getUint32(ep, true)
108
+ const target = view.getUint32(ep + 4, true)
109
+ edges.set(strings[stringIdx]!, target)
110
+ }
111
+
112
+ const places: PlaceEntry[] = new Array(placeCountForState)
113
+
114
+ for (let pi = 0; pi < placeCountForState; pi++) {
115
+ const pp = placeTableStart + (placeStart + pi) * PLACE_ENTRY_SIZE
116
+ const chainLen = view.getUint8(pp + 5)
117
+ const parentChain: number[] = []
118
+
119
+ for (let ci = 0; ci < chainLen; ci++) {
120
+ parentChain.push(view.getUint32(pp + 24 + ci * 4, true))
121
+ }
122
+ const rawImportance = isV2
123
+ ? view.getFloat32(pp + 12, true)
124
+ : Math.min(1.0, Math.log2(1 + view.getUint32(pp + 12, true) / 1000) / 14)
125
+
126
+ places[pi] = {
127
+ wofID: view.getUint32(pp, true),
128
+ placetype: PLACETYPE_ORDER[view.getUint8(pp + 4)] ?? "locality",
129
+ name: strings[view.getUint32(pp + 8, true)]!,
130
+ importance: rawImportance,
131
+ lat: view.getFloat32(pp + 16, true),
132
+ lon: view.getFloat32(pp + 20, true),
133
+ parentChain,
134
+ }
135
+ }
136
+
137
+ nodes[si] = { edges, places }
138
+ }
139
+
140
+ return FSTMatcher.fromNodes(nodes)
141
+ }
142
+
143
+ export function readFSTProvenanceWeb(input: ArrayBuffer | Uint8Array): FSTProvenance | undefined {
144
+ const bytes = input instanceof ArrayBuffer ? new Uint8Array(input) : input
145
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
146
+ const decoder = new TextDecoder("utf-8")
147
+
148
+ if (bytes.byteLength < HEADER_SIZE) return undefined
149
+ const version = view.getUint16(4, true)
150
+
151
+ if (version < 3) return undefined
152
+ const provenanceOffset = view.getUint32(28, true)
153
+
154
+ if (provenanceOffset === 0 || provenanceOffset >= bytes.byteLength) return undefined
155
+
156
+ try {
157
+ const jsonLen = view.getUint32(provenanceOffset, true)
158
+ const jsonStr = decoder.decode(bytes.subarray(provenanceOffset + 4, provenanceOffset + 4 + jsonLen))
159
+
160
+ return JSON.parse(jsonStr) as FSTProvenance
161
+ } catch {
162
+ return undefined
163
+ }
164
+ }
package/fst-matcher.ts ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * In-memory FST matcher. Built by `fst-builder.ts`, queried at runtime for emission priors and CLI
7
+ * introspection. The structure is a deterministic trie over normalized tokens with PlaceEntry
8
+ * arrays at accepting states.
9
+ */
10
+
11
+ import type { FSTContinuation, FSTMatchResult, FSTQueryResult, PlaceEntry } from "./fst-types.ts"
12
+
13
+ interface FSTNode {
14
+ edges: Map<string, number>
15
+ places: PlaceEntry[]
16
+ }
17
+
18
+ export class FSTMatcher {
19
+ private nodes: FSTNode[]
20
+
21
+ constructor(nodes: FSTNode[]) {
22
+ this.nodes = nodes
23
+ }
24
+
25
+ get stateCount(): number {
26
+ return this.nodes.length
27
+ }
28
+
29
+ get placeCount(): number {
30
+ let count = 0
31
+
32
+ for (const n of this.nodes) {
33
+ count += n.places.length
34
+ }
35
+
36
+ return count
37
+ }
38
+
39
+ walk(tokens: string[]): FSTMatchResult | null {
40
+ let stateID = 0
41
+
42
+ for (let i = 0; i < tokens.length; i++) {
43
+ const node = this.nodes[stateID]
44
+
45
+ if (!node) return null
46
+ const next = node.edges.get(tokens[i]!)
47
+
48
+ if (next === undefined) return null
49
+ stateID = next
50
+ }
51
+ const node = this.nodes[stateID]!
52
+
53
+ return { stateID, accepted: node.places.length > 0, depth: tokens.length }
54
+ }
55
+
56
+ walkFrom(prev: FSTMatchResult, token: string): FSTMatchResult | null {
57
+ const node = this.nodes[prev.stateID]
58
+
59
+ if (!node) return null
60
+ const next = node.edges.get(token)
61
+
62
+ if (next === undefined) return null
63
+ const target = this.nodes[next]!
64
+
65
+ return { stateID: next, accepted: target.places.length > 0, depth: prev.depth + 1 }
66
+ }
67
+
68
+ accepting(stateID: number): PlaceEntry[] {
69
+ return this.nodes[stateID]?.places ?? []
70
+ }
71
+
72
+ continuations(stateID: number): FSTContinuation[] {
73
+ const node = this.nodes[stateID]
74
+
75
+ if (!node) return []
76
+ const result: FSTContinuation[] = []
77
+
78
+ for (const [token, targetID] of node.edges) {
79
+ const target = this.nodes[targetID]!
80
+ result.push({
81
+ token,
82
+ targetState: targetID,
83
+ acceptingCount: target.places.length,
84
+ })
85
+ }
86
+
87
+ return result
88
+ }
89
+
90
+ query(text: string): FSTQueryResult {
91
+ const tokens = normalizeTokens(text)
92
+ const match = this.walk(tokens)
93
+
94
+ if (!match) {
95
+ // Walk as far as possible to find where we fall off
96
+ let stateID = 0
97
+ let depth = 0
98
+
99
+ for (const t of tokens) {
100
+ const node = this.nodes[stateID]
101
+
102
+ if (!node) break
103
+ const next = node.edges.get(t)
104
+
105
+ if (next === undefined) break
106
+ stateID = next
107
+ depth++
108
+ }
109
+
110
+ return {
111
+ path: tokens.slice(0, depth),
112
+ stateID,
113
+ accepting: this.accepting(stateID),
114
+ continuations: this.continuations(stateID),
115
+ }
116
+ }
117
+
118
+ return {
119
+ path: tokens,
120
+ stateID: match.stateID,
121
+ accepting: this.accepting(match.stateID),
122
+ continuations: this.continuations(match.stateID),
123
+ }
124
+ }
125
+
126
+ get nodeCount(): number {
127
+ return this.nodes.length
128
+ }
129
+
130
+ /** Expose the internal node array for serialization. */
131
+ toNodes(): readonly FSTNode[] {
132
+ return this.nodes
133
+ }
134
+
135
+ static fromNodes(nodes: FSTNode[]): FSTMatcher {
136
+ return new FSTMatcher(nodes)
137
+ }
138
+ }
139
+
140
+ /** Normalize text into FST tokens: lowercase, NFKC, strip punctuation, split on whitespace. */
141
+ export function normalizeTokens(text: string): string[] {
142
+ return text
143
+ .normalize("NFKC")
144
+ .toLowerCase()
145
+ .replace(/[\p{P}\p{S}]/gu, "")
146
+ .split(/\s+/)
147
+ .filter((t) => t.length > 0)
148
+ }
149
+
150
+ export type { FSTNode }