@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.
- package/address-point-interpolation.ts +207 -0
- package/address-point-schema.ts +107 -0
- package/address-point.ts +122 -0
- package/ancestry-backfill.ts +205 -0
- package/ancestry.ts +70 -0
- package/build-candidate.ts +351 -0
- package/build-slim.ts +394 -0
- package/candidate-fts.ts +43 -0
- package/candidate-lookup.ts +382 -0
- package/candidate-schema.ts +166 -0
- package/coincident-roles.ts +240 -0
- package/convention.ts +152 -0
- package/fst-autocomplete.ts +187 -0
- package/fst-builder.ts +291 -0
- package/fst-deserialize-web.ts +164 -0
- package/fst-matcher.ts +150 -0
- package/fst-serialize.ts +311 -0
- package/fst-types.ts +78 -0
- package/fts.ts +318 -0
- package/geo.ts +140 -0
- package/geonames-aliases.ts +317 -0
- package/geonames-postal.ts +150 -0
- package/index.ts +117 -0
- package/interpolation.ts +232 -0
- package/lookup.ts +1498 -0
- package/out/poi-lookup.d.ts +14 -2
- package/out/poi-lookup.d.ts.map +1 -1
- package/out/poi-lookup.js +55 -21
- package/out/poi-lookup.js.map +1 -1
- package/out/poi-schema.d.ts +9 -0
- package/out/poi-schema.d.ts.map +1 -1
- package/out/poi-schema.js +16 -0
- package/out/poi-schema.js.map +1 -1
- package/out/reverse.d.ts +8 -1
- package/out/reverse.d.ts.map +1 -1
- package/out/reverse.js +10 -1
- package/out/reverse.js.map +1 -1
- package/package.json +168 -82
- package/poi-lookup.ts +375 -0
- package/poi-schema.ts +164 -0
- package/postal-city-alias-lookup.ts +89 -0
- package/postal-city-alias-schema.ts +75 -0
- package/postal-city-candidate-schema.ts +81 -0
- package/postcode-point-lookup.ts +64 -0
- package/reverse.ts +439 -0
- package/schema.ts +176 -0
- package/sharding.ts +235 -0
- package/sqlite-convention-source.ts +61 -0
- package/sqlite-utils.ts +25 -0
- package/street-centroid-schema.ts +124 -0
- package/street-centroid.ts +124 -0
- package/street-morphology-fst-builder.ts +230 -0
- package/street-name-lookup.ts +101 -0
- package/street-normalize.ts +302 -0
- package/street-segment-schema.ts +104 -0
- package/types.ts +164 -0
- 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 }
|