@mailwoman/resolver-wof-sqlite 9.0.0 → 9.1.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/build-candidate.ts +97 -4
- package/candidate-importance.ts +227 -0
- package/candidate-lookup.ts +144 -7
- package/candidate-schema.ts +29 -0
- package/fst-autocomplete.ts +22 -11
- package/fst-builder.ts +25 -35
- package/fst-deserialize-web.ts +43 -7
- package/fst-serialize.ts +68 -12
- package/fst-types.ts +35 -1
- package/fts.ts +12 -0
- package/index.ts +8 -0
- package/lookup.ts +44 -5
- package/out/build-candidate.d.ts +33 -0
- package/out/build-candidate.d.ts.map +1 -1
- package/out/build-candidate.js +37 -4
- package/out/build-candidate.js.map +1 -1
- package/out/candidate-importance.d.ts +132 -0
- package/out/candidate-importance.d.ts.map +1 -0
- package/out/candidate-importance.js +174 -0
- package/out/candidate-importance.js.map +1 -0
- package/out/candidate-lookup.d.ts +9 -1
- package/out/candidate-lookup.d.ts.map +1 -1
- package/out/candidate-lookup.js +115 -6
- package/out/candidate-lookup.js.map +1 -1
- package/out/candidate-schema.d.ts +25 -1
- package/out/candidate-schema.d.ts.map +1 -1
- package/out/candidate-schema.js +5 -0
- package/out/candidate-schema.js.map +1 -1
- package/out/fst-autocomplete.d.ts +13 -3
- package/out/fst-autocomplete.d.ts.map +1 -1
- package/out/fst-autocomplete.js +10 -9
- package/out/fst-autocomplete.js.map +1 -1
- package/out/fst-builder.d.ts.map +1 -1
- package/out/fst-builder.js +21 -28
- package/out/fst-builder.js.map +1 -1
- package/out/fst-deserialize-web.d.ts.map +1 -1
- package/out/fst-deserialize-web.js +36 -7
- package/out/fst-deserialize-web.js.map +1 -1
- package/out/fst-serialize.d.ts +14 -4
- package/out/fst-serialize.d.ts.map +1 -1
- package/out/fst-serialize.js +60 -12
- package/out/fst-serialize.js.map +1 -1
- package/out/fst-types.d.ts +35 -1
- package/out/fst-types.d.ts.map +1 -1
- package/out/fts.d.ts +11 -0
- package/out/fts.d.ts.map +1 -1
- package/out/fts.js +11 -0
- package/out/fts.js.map +1 -1
- package/out/index.d.ts +1 -0
- package/out/index.d.ts.map +1 -1
- package/out/index.js +1 -0
- package/out/index.js.map +1 -1
- package/out/lookup.d.ts.map +1 -1
- package/out/lookup.js +37 -5
- package/out/lookup.js.map +1 -1
- package/out/place-importance-schema.d.ts +189 -0
- package/out/place-importance-schema.d.ts.map +1 -0
- package/out/place-importance-schema.js +242 -0
- package/out/place-importance-schema.js.map +1 -0
- package/out/sqlite-utils.d.ts +12 -0
- package/out/sqlite-utils.d.ts.map +1 -1
- package/out/sqlite-utils.js +20 -0
- package/out/sqlite-utils.js.map +1 -1
- package/out/street-morphology-fst-builder.d.ts.map +1 -1
- package/out/street-morphology-fst-builder.js +5 -4
- package/out/street-morphology-fst-builder.js.map +1 -1
- package/out/street-normalize.d.ts +2 -1
- package/out/street-normalize.d.ts.map +1 -1
- package/out/street-normalize.js +2 -1
- package/out/street-normalize.js.map +1 -1
- package/out/types.d.ts +39 -0
- package/out/types.d.ts.map +1 -1
- package/package.json +14 -6
- package/place-importance-schema.ts +353 -0
- package/sqlite-utils.ts +21 -0
- package/street-morphology-fst-builder.ts +5 -4
- package/street-normalize.ts +3 -2
- package/types.ts +39 -0
package/candidate-schema.ts
CHANGED
|
@@ -59,6 +59,30 @@ export interface CandidateTable {
|
|
|
59
59
|
* 1 when the row is the place's canonical name (vs an alias/abbrev).
|
|
60
60
|
*/
|
|
61
61
|
is_primary: number | null
|
|
62
|
+
/**
|
|
63
|
+
* Blended place importance in [0, 1] — the toponym-fame prior the bare-city-name class is decided on (#28). NULL
|
|
64
|
+
* means the score source had no row for this place: UNMEASURED, never "an importance of zero" (meaning-of-zero).
|
|
65
|
+
* Constant across every row of one place — primary, alias and abbrev alike — because it is a property of the PLACE,
|
|
66
|
+
* not of the name that reached it, which is what lets a bare `Moscow` inherit Москва's score through the alias row.
|
|
67
|
+
*
|
|
68
|
+
* **THIS IS THE PRE-SPLIT CONFLATION, AND THE NAME SAYS SO.** It is `place_importance.importance` copied verbatim
|
|
69
|
+
* from the score source — a legacy blended importance whose own build already coalesced the concordance's
|
|
70
|
+
* encyclopedia-derived channel with a population-derived fallback; `place-importance-schema.ts` calls that column
|
|
71
|
+
* DEPRECATED. It is NOT the split `encyclopedic` channel, and the two must not be conflated in a future build:
|
|
72
|
+
* writing the split value here instead was measured on 2026-08-10 and makes the ranking key INERT on three of the
|
|
73
|
+
* four rows it exists to fix. The reason is coverage, not principle — the encyclopedia-concordance join in
|
|
74
|
+
* `admin-global-priority-importance.db` reaches 133,888 of 702,709 scored places and only eleven countries
|
|
75
|
+
* (US/FR/GB/DE/IT/ES/NL/JP/CN/KR/TW). CA, AU and RU have ZERO concordance rows, so Whitby CA, Windsor CA and Epping
|
|
76
|
+
* AU carry the population fallback and nothing else. Under the strict split those three become unmeasured, the
|
|
77
|
+
* consumer's positive-evidence-only rule leaves them exactly where population put them (first), and the famous GB
|
|
78
|
+
* bearer can never overtake them. The conflated column is the only one on which every bearer of a name is scored on a
|
|
79
|
+
* single comparable scale, which is the precondition for comparing them at all.
|
|
80
|
+
*
|
|
81
|
+
* So a consumer reads this as "fame, with population standing in where fame was never measured" — the legacy blended
|
|
82
|
+
* semantics — and NOT as "this place has an encyclopedia entry of this importance". When the score source grows a
|
|
83
|
+
* real `encyclopedic` column for every country, add a SECOND column rather than redefining this one.
|
|
84
|
+
*/
|
|
85
|
+
importance: number | null
|
|
62
86
|
}
|
|
63
87
|
|
|
64
88
|
/**
|
|
@@ -114,6 +138,9 @@ export const CANDIDATE_COLUMNS = [
|
|
|
114
138
|
"max_lon",
|
|
115
139
|
"population",
|
|
116
140
|
"is_primary",
|
|
141
|
+
// Appended, never inserted mid-list: the first six entries ARE the clustered primary key, and the
|
|
142
|
+
// positional `INSERT INTO cand_stage VALUES (…)` in the builder binds by position.
|
|
143
|
+
"importance",
|
|
117
144
|
] as const
|
|
118
145
|
|
|
119
146
|
/**
|
|
@@ -151,6 +178,7 @@ export async function createCandidateStagingTables(db: Kysely<CandidateDatabase>
|
|
|
151
178
|
.addColumn("max_lon", "real")
|
|
152
179
|
.addColumn("population", "integer")
|
|
153
180
|
.addColumn("is_primary", "integer")
|
|
181
|
+
.addColumn("importance", "real")
|
|
154
182
|
.execute()
|
|
155
183
|
}
|
|
156
184
|
|
|
@@ -176,6 +204,7 @@ export async function createCandidateTable(db: Kysely<CandidateDatabase>): Promi
|
|
|
176
204
|
.addColumn("max_lon", "real")
|
|
177
205
|
.addColumn("population", "integer")
|
|
178
206
|
.addColumn("is_primary", "integer")
|
|
207
|
+
.addColumn("importance", "real")
|
|
179
208
|
.addPrimaryKeyConstraint("candidate_pk", [
|
|
180
209
|
"name_key",
|
|
181
210
|
"country_id",
|
package/fst-autocomplete.ts
CHANGED
|
@@ -31,7 +31,17 @@ export interface AutocompleteResult {
|
|
|
31
31
|
export interface AutocompleteSuggestion {
|
|
32
32
|
name: string
|
|
33
33
|
placetype: string
|
|
34
|
-
|
|
34
|
+
/**
|
|
35
|
+
* The REFERENTIAL likelihood the suggestion is ranked by (ROAD_TO_V9 §2). Autocomplete answers "which place does the
|
|
36
|
+
* user mean", so it ranks referentially like everything else; encyclopedic importance rides along on
|
|
37
|
+
* {@link AutocompleteSuggestion.encyclopedic} for display and never enters the order.
|
|
38
|
+
*/
|
|
39
|
+
referential: number
|
|
40
|
+
/**
|
|
41
|
+
* Encyclopedic (Wikipedia) importance, when the FST artifact carries one for this place. `undefined` = no article, or
|
|
42
|
+
* a pre-v5 binary — never 0.
|
|
43
|
+
*/
|
|
44
|
+
encyclopedic?: number
|
|
35
45
|
wofID: number
|
|
36
46
|
parentChain: number[]
|
|
37
47
|
matchDepth: number
|
|
@@ -42,7 +52,7 @@ export interface AutocompleteOpts {
|
|
|
42
52
|
maxSuggestions?: number
|
|
43
53
|
maxExpansionDepth?: number
|
|
44
54
|
/**
|
|
45
|
-
* Collapse same-name suggestions to the single highest-
|
|
55
|
+
* Collapse same-name suggestions to the single highest-referential one. Off by default (the CLI surfaces distinct
|
|
46
56
|
* same-name places — New York the city vs the county); a typeahead wants it ON so the dropdown isn't four "New
|
|
47
57
|
* London"s. (#587)
|
|
48
58
|
*/
|
|
@@ -61,16 +71,16 @@ interface BfsItem {
|
|
|
61
71
|
const PER_BRANCH = 4
|
|
62
72
|
|
|
63
73
|
/**
|
|
64
|
-
* The top-`k` entries by
|
|
74
|
+
* The top-`k` entries by REFERENTIAL likelihood (descending). Avoids sorting/allocating when `entries` is small.
|
|
65
75
|
*/
|
|
66
|
-
function
|
|
76
|
+
function topByReferential(entries: readonly PlaceEntry[], k: number): PlaceEntry[] {
|
|
67
77
|
if (entries.length <= k) return [...entries]
|
|
68
78
|
|
|
69
|
-
return [...entries].toSorted((a, b) => b.
|
|
79
|
+
return [...entries].toSorted((a, b) => b.referential - a.referential).slice(0, k)
|
|
70
80
|
}
|
|
71
81
|
|
|
72
82
|
/**
|
|
73
|
-
* Autocomplete from the current prefix. Returns suggestions ranked
|
|
83
|
+
* Autocomplete from the current prefix. Returns suggestions ranked referential-descending.
|
|
74
84
|
*/
|
|
75
85
|
export function autocomplete(fst: FSTMatcher, query: string, opts: AutocompleteOpts = {}): AutocompleteResult {
|
|
76
86
|
const maxSuggestions = opts.maxSuggestions ?? 10
|
|
@@ -114,7 +124,7 @@ export function autocomplete(fst: FSTMatcher, query: string, opts: AutocompleteO
|
|
|
114
124
|
if (!cont.token.startsWith(partial)) continue
|
|
115
125
|
|
|
116
126
|
// This edge completes the typed partial token — its target is a real match at depth+1.
|
|
117
|
-
for (const entry of
|
|
127
|
+
for (const entry of topByReferential(fst.accepting(cont.targetState), PER_BRANCH)) {
|
|
118
128
|
addSuggestion(seen, entry, complete.length + 1, [cont.token])
|
|
119
129
|
}
|
|
120
130
|
|
|
@@ -132,7 +142,7 @@ export function autocomplete(fst: FSTMatcher, query: string, opts: AutocompleteO
|
|
|
132
142
|
|
|
133
143
|
if (item.depth > maxExpansionDepth) continue
|
|
134
144
|
|
|
135
|
-
for (const entry of
|
|
145
|
+
for (const entry of topByReferential(fst.accepting(item.stateID), PER_BRANCH)) {
|
|
136
146
|
addSuggestion(seen, entry, depth + item.depth, item.tokens)
|
|
137
147
|
}
|
|
138
148
|
|
|
@@ -143,7 +153,7 @@ export function autocomplete(fst: FSTMatcher, query: string, opts: AutocompleteO
|
|
|
143
153
|
}
|
|
144
154
|
}
|
|
145
155
|
|
|
146
|
-
let suggestions = [...seen.values()].toSorted((a, b) => b.
|
|
156
|
+
let suggestions = [...seen.values()].toSorted((a, b) => b.referential - a.referential)
|
|
147
157
|
|
|
148
158
|
if (opts.dedupeByName) {
|
|
149
159
|
suggestions = dedupeByName(suggestions)
|
|
@@ -165,7 +175,8 @@ function addSuggestion(
|
|
|
165
175
|
seen.set(entry.wofID, {
|
|
166
176
|
name: entry.name,
|
|
167
177
|
placetype: entry.placetype,
|
|
168
|
-
|
|
178
|
+
referential: entry.referential,
|
|
179
|
+
...(entry.encyclopedic === undefined ? {} : { encyclopedic: entry.encyclopedic }),
|
|
169
180
|
wofID: entry.wofID,
|
|
170
181
|
parentChain: entry.parentChain,
|
|
171
182
|
matchDepth,
|
|
@@ -174,7 +185,7 @@ function addSuggestion(
|
|
|
174
185
|
}
|
|
175
186
|
|
|
176
187
|
/**
|
|
177
|
-
* Keep one suggestion per name — the highest-
|
|
188
|
+
* Keep one suggestion per name — the highest-referential. Input is already referential-sorted, so the first occurrence
|
|
178
189
|
* per name wins; order is preserved.
|
|
179
190
|
*/
|
|
180
191
|
function dedupeByName(suggestions: AutocompleteSuggestion[]): AutocompleteSuggestion[] {
|
package/fst-builder.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { readWOFSourceIdentity } from "./fst-freshness.ts"
|
|
|
17
17
|
import type { FSTNode } from "./fst-matcher.ts"
|
|
18
18
|
import { FSTMatcher, normalizeTokens } from "./fst-matcher.ts"
|
|
19
19
|
import type { BuildFSTOpts, BuildFSTResult, FSTProvenance, PlaceEntry, PlacetypeID } from "./fst-types.ts"
|
|
20
|
+
import { loadImportanceSplit } from "./place-importance-schema.ts"
|
|
20
21
|
|
|
21
22
|
const DEFAULT_PLACETYPES: PlacetypeID[] = [
|
|
22
23
|
"country",
|
|
@@ -52,11 +53,6 @@ interface NameRow {
|
|
|
52
53
|
privateuse: string
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
interface PopulationRow {
|
|
56
|
-
id: number
|
|
57
|
-
population: number
|
|
58
|
-
}
|
|
59
|
-
|
|
60
56
|
export function buildFSTFromWOF(opts: BuildFSTOpts): {
|
|
61
57
|
matcher: FSTMatcher
|
|
62
58
|
provenance: FSTProvenance
|
|
@@ -171,35 +167,22 @@ export function buildFSTFromWOF(opts: BuildFSTOpts): {
|
|
|
171
167
|
return chain
|
|
172
168
|
}
|
|
173
169
|
|
|
174
|
-
// Phase 3: Load
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
for (const row of impRows) {
|
|
184
|
-
importanceMap.set(row.id, row.importance)
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
progress("importance", `Loaded ${importanceMap.size} importance scores`)
|
|
188
|
-
} catch {
|
|
189
|
-
progress("importance", "No place_importance table — falling back to population")
|
|
190
|
-
|
|
191
|
-
try {
|
|
192
|
-
const popStmt = db.prepare("SELECT id, population FROM place_population")
|
|
193
|
-
const popRows = popStmt.all() as unknown as PopulationRow[]
|
|
170
|
+
// Phase 3: Load BOTH scores (ROAD_TO_V9 §2 R1, the two-score split).
|
|
171
|
+
//
|
|
172
|
+
// Referential is ALWAYS population-anchored and never read out of a legacy `place_importance`
|
|
173
|
+
// column, because a legacy row that got a Wikipedia score overwrote whatever population would have
|
|
174
|
+
// said and the two are indistinguishable afterwards. Encyclopedic rides along for consumers and is
|
|
175
|
+
// never handed to the decoder. `loadImportanceSplit` handles all four schema generations; the
|
|
176
|
+
// source it reports is stamped into provenance so an artifact says which one it read.
|
|
177
|
+
progress("importance", "Loading referential + encyclopedic scores")
|
|
178
|
+
const split = loadImportanceSplit(db)
|
|
194
179
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
}
|
|
202
|
-
}
|
|
180
|
+
progress(
|
|
181
|
+
"importance",
|
|
182
|
+
`${split.referential.size} referential, ${split.encyclopedic.size} encyclopedic (source: ${split.source}` +
|
|
183
|
+
(split.legacyFallbackRows ? `, ${split.legacyFallbackRows} legacy rows attributed to the population pass` : "") +
|
|
184
|
+
")"
|
|
185
|
+
)
|
|
203
186
|
|
|
204
187
|
// Phase 4: Load names for matching places.
|
|
205
188
|
progress("names", "Loading name variants")
|
|
@@ -304,12 +287,17 @@ export function buildFSTFromWOF(opts: BuildFSTOpts): {
|
|
|
304
287
|
for (const row of sprRows) {
|
|
305
288
|
const parentChain = resolveParentChain(row.id)
|
|
306
289
|
|
|
290
|
+
const encyclopedic = split.encyclopedic.get(row.id)
|
|
291
|
+
|
|
307
292
|
const entry: PlaceEntry = {
|
|
308
293
|
wofID: row.id,
|
|
309
294
|
placetype: row.placetype as PlacetypeID,
|
|
310
295
|
name: row.name,
|
|
311
296
|
parentChain,
|
|
312
|
-
|
|
297
|
+
referential: split.referential.get(row.id) ?? 0,
|
|
298
|
+
// Spread rather than assigned: a place with no Wikipedia article must carry NO field, not a
|
|
299
|
+
// zero. The serializer's per-place presence bit reads `!== undefined`.
|
|
300
|
+
...(encyclopedic === undefined ? {} : { encyclopedic }),
|
|
313
301
|
lat: row.latitude,
|
|
314
302
|
lon: row.longitude,
|
|
315
303
|
}
|
|
@@ -361,7 +349,9 @@ export function buildFSTFromWOF(opts: BuildFSTOpts): {
|
|
|
361
349
|
placeCount: sprRows.length,
|
|
362
350
|
edgeCount,
|
|
363
351
|
nameInsertions: insertCount,
|
|
364
|
-
importanceMatches:
|
|
352
|
+
importanceMatches: split.referential.size,
|
|
353
|
+
encyclopedicMatches: split.encyclopedic.size,
|
|
354
|
+
importanceSource: split.source,
|
|
365
355
|
sourceDB: opts.dbPath,
|
|
366
356
|
sourceDBMD5: source.md5,
|
|
367
357
|
sourceDBBytes: source.bytes,
|
package/fst-deserialize-web.ts
CHANGED
|
@@ -39,17 +39,44 @@ const VERSION_WITH_METADATA = 3
|
|
|
39
39
|
|
|
40
40
|
const HEADER_SIZE = 32
|
|
41
41
|
const EDGE_ENTRY_SIZE = 8
|
|
42
|
-
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Format version that split the single `importance` float into `referential` + `encyclopedic` (ROAD_TO_V9 §2 R1),
|
|
45
|
+
* growing the place entry from 56 to 60 bytes. Mirrors `fst-serialize.ts`'s constant of the same name.
|
|
46
|
+
*/
|
|
47
|
+
const VERSION_TWO_SCORE_SPLIT = 5
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Place-entry size in bytes at or above {@link VERSION_TWO_SCORE_SPLIT}.
|
|
51
|
+
*/
|
|
52
|
+
const SPLIT_PLACE_ENTRY_SIZE = 60
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Place-entry size in bytes below {@link VERSION_TWO_SCORE_SPLIT}.
|
|
56
|
+
*/
|
|
57
|
+
const LEGACY_PLACE_ENTRY_SIZE = 56
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Byte offset of the encyclopedic float inside a v5 place entry — immediately after the 8-slot parent chain.
|
|
61
|
+
*/
|
|
62
|
+
const ENCYCLOPEDIC_OFFSET = 56
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `placeFlags` bit 0 (byte `pp+7`, v5+): this place carries an encyclopedic score. Per-place because absence is the
|
|
66
|
+
* common case; see `fst-serialize.ts`.
|
|
67
|
+
*/
|
|
68
|
+
const PLACE_FLAG_HAS_ENCYCLOPEDIC = 1
|
|
43
69
|
/**
|
|
44
70
|
* "FST\0".
|
|
45
71
|
*/
|
|
46
72
|
const MAGIC_BYTES = [0x46, 0x53, 0x54, 0x00]
|
|
47
73
|
/**
|
|
48
|
-
* Must track the serializer's VERSION (fst-serialize.ts, currently
|
|
74
|
+
* Must track the serializer's VERSION (fst-serialize.ts, currently 5). The v3 provenance + v4 16-byte-state/u32-count
|
|
49
75
|
* layout logic below already matches the Node deserializer; only this gate was left stale at 2, so the browser FST
|
|
50
|
-
* loader rejected every real (v4) artifact.
|
|
76
|
+
* loader rejected every real (v4) artifact. It was left stale again at 4 by the v5 two-score split until this line
|
|
77
|
+
* moved with it — the gate is a SEPARATE number from the layout branches, which is exactly why it keeps drifting.
|
|
51
78
|
*/
|
|
52
|
-
const MAX_VERSION =
|
|
79
|
+
const MAX_VERSION = 5
|
|
53
80
|
|
|
54
81
|
const PLACETYPE_ORDER: readonly PlacetypeID[] = [
|
|
55
82
|
"country",
|
|
@@ -88,6 +115,7 @@ export function deserializeFSTWeb(input: ArrayBuffer | Uint8Array): FSTMatcher {
|
|
|
88
115
|
}
|
|
89
116
|
|
|
90
117
|
const isV2 = version >= 2
|
|
118
|
+
const isSplit = version >= VERSION_TWO_SCORE_SPLIT
|
|
91
119
|
// flags bit0 (survey #4, mirrors fst-serialize.ts): place rows carry surface-ambiguity data.
|
|
92
120
|
const hasAmbiguity = (view.getUint16(6, true) & 1) === 1
|
|
93
121
|
|
|
@@ -120,6 +148,8 @@ export function deserializeFSTWeb(input: ArrayBuffer | Uint8Array): FSTMatcher {
|
|
|
120
148
|
|
|
121
149
|
// --- State table ---
|
|
122
150
|
const stateEntrySize = version >= VERSION_WIDE_STATE_COUNTERS ? WIDE_STATE_ENTRY_SIZE : NARROW_STATE_ENTRY_SIZE
|
|
151
|
+
// v5 grew the place entry by the encyclopedic float; v4-and-below files are read at the old stride.
|
|
152
|
+
const placeEntrySize = isSplit ? SPLIT_PLACE_ENTRY_SIZE : LEGACY_PLACE_ENTRY_SIZE
|
|
123
153
|
const stateTableStart = pos
|
|
124
154
|
const edgeTableStart = stateTableStart + stateCount * stateEntrySize
|
|
125
155
|
const placeTableStart = edgeTableStart + edgeCount * EDGE_ENTRY_SIZE
|
|
@@ -149,7 +179,7 @@ export function deserializeFSTWeb(input: ArrayBuffer | Uint8Array): FSTMatcher {
|
|
|
149
179
|
const places: PlaceEntry[] = new Array(placeCountForState)
|
|
150
180
|
|
|
151
181
|
for (let pi = 0; pi < placeCountForState; pi++) {
|
|
152
|
-
const pp = placeTableStart + (placeStart + pi) *
|
|
182
|
+
const pp = placeTableStart + (placeStart + pi) * placeEntrySize
|
|
153
183
|
const chainLen = view.getUint8(pp + 5)
|
|
154
184
|
const parentChain: number[] = []
|
|
155
185
|
|
|
@@ -157,19 +187,25 @@ export function deserializeFSTWeb(input: ArrayBuffer | Uint8Array): FSTMatcher {
|
|
|
157
187
|
parentChain.push(view.getUint32(pp + 24 + ci * 4, true))
|
|
158
188
|
}
|
|
159
189
|
|
|
160
|
-
|
|
190
|
+
// v1 stored a raw population u32 here; v2-v4 the conflated `importance` float; v5 the
|
|
191
|
+
// referential score. See the Node deserializer for why a v1 value is genuinely referential.
|
|
192
|
+
const referential = isV2
|
|
161
193
|
? view.getFloat32(pp + 12, true)
|
|
162
194
|
: Math.min(1, Math.log2(1 + view.getUint32(pp + 12, true) / 1000) / 14)
|
|
163
195
|
|
|
196
|
+
const hasEncyclopedic =
|
|
197
|
+
isSplit && (view.getUint8(pp + 7) & PLACE_FLAG_HAS_ENCYCLOPEDIC) === PLACE_FLAG_HAS_ENCYCLOPEDIC
|
|
198
|
+
|
|
164
199
|
places[pi] = {
|
|
165
200
|
wofID: view.getUint32(pp, true),
|
|
166
201
|
placetype: PLACETYPE_ORDER[view.getUint8(pp + 4)] ?? "locality",
|
|
167
202
|
name: strings[view.getUint32(pp + 8, true)]!,
|
|
168
|
-
|
|
203
|
+
referential,
|
|
169
204
|
lat: view.getFloat32(pp + 16, true),
|
|
170
205
|
lon: view.getFloat32(pp + 20, true),
|
|
171
206
|
parentChain,
|
|
172
207
|
...(hasAmbiguity ? { crossCountryBranches: view.getUint8(pp + 6) } : {}),
|
|
208
|
+
...(hasEncyclopedic ? { encyclopedic: view.getFloat32(pp + ENCYCLOPEDIC_OFFSET, true) } : {}),
|
|
173
209
|
}
|
|
174
210
|
}
|
|
175
211
|
|
package/fst-serialize.ts
CHANGED
|
@@ -18,9 +18,19 @@
|
|
|
18
18
|
*
|
|
19
19
|
* EDGE TABLE [edgeCount × 8 bytes] stringIdx u32 index into string table targetState u32
|
|
20
20
|
*
|
|
21
|
-
* PLACE TABLE [placeCount × 56
|
|
22
|
-
* u8 0..8
|
|
23
|
-
* (
|
|
21
|
+
* PLACE TABLE [placeCount × 60 bytes at V5, 56 below] wofID u32 placetypeIdx u8 index into
|
|
22
|
+
* PLACETYPE_ORDER chainLen u8 0..8 crossCountryBranches u8 (header flags bit0 gates the read)
|
|
23
|
+
* placeFlags u8 (V5; bit0 = encyclopedic present) nameIdx u32 index into string table referential f32
|
|
24
|
+
* population-anchored likelihood [0,1] — was the conflated `importance` (V2–V4), was population u32
|
|
25
|
+
* (V1) lat f32 lon f32 chain [u32; 8] parent chain (unused slots = 0) encyclopedic f32 (V5 only;
|
|
26
|
+
* read only when placeFlags bit0 is set)
|
|
27
|
+
*
|
|
28
|
+
* THE V5 BUMP IS THE TWO-SCORE SPLIT (ROAD_TO_V9 §2 R1). Through V4 one float carried whichever score
|
|
29
|
+
* the source database held, and nothing in the bytes said which — so a reader could not tell a
|
|
30
|
+
* population proxy from a Wikipedia score. V5 names the ranking score `referential` and gives the
|
|
31
|
+
* encyclopedic signal its own slot plus a PER-PLACE presence bit, because most places have no
|
|
32
|
+
* Wikipedia article and a 0 there would be a fact nobody recorded. `fst-freshness.ts` reports every
|
|
33
|
+
* V4-and-below artifact as format-stale for exactly this reason: its single float is unattributable.
|
|
24
34
|
*/
|
|
25
35
|
|
|
26
36
|
import { tryParsingJSON } from "@mailwoman/core/objects"
|
|
@@ -50,6 +60,34 @@ const NARROW_STATE_ENTRY_SIZE = 12
|
|
|
50
60
|
*/
|
|
51
61
|
const VERSION_WITH_METADATA = 3
|
|
52
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Format version that split the single `importance` float into `referential` + `encyclopedic`, growing the place entry
|
|
65
|
+
* from 56 to 60 bytes and claiming the previously-reserved `pp+7` byte as {@link PLACE_FLAG_HAS_ENCYCLOPEDIC}.
|
|
66
|
+
*/
|
|
67
|
+
const VERSION_TWO_SCORE_SPLIT = 5
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Place-entry size in bytes at or above {@link VERSION_TWO_SCORE_SPLIT}.
|
|
71
|
+
*/
|
|
72
|
+
const SPLIT_PLACE_ENTRY_SIZE = 60
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Place-entry size in bytes below {@link VERSION_TWO_SCORE_SPLIT}.
|
|
76
|
+
*/
|
|
77
|
+
const LEGACY_PLACE_ENTRY_SIZE = 56
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Byte offset of the encyclopedic float inside a v5 place entry — immediately after the 8-slot parent chain.
|
|
81
|
+
*/
|
|
82
|
+
const ENCYCLOPEDIC_OFFSET = 56
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `placeFlags` bit 0 (byte `pp+7`, v5+): this place carries an encyclopedic score. Per-PLACE rather than per-file
|
|
86
|
+
* because absence is the common case — roughly 89% of the 2026-08-05 gazetteer has no Wikipedia article — and a
|
|
87
|
+
* file-level flag would force every one of those rows to claim a 0 it never had.
|
|
88
|
+
*/
|
|
89
|
+
const PLACE_FLAG_HAS_ENCYCLOPEDIC = 1
|
|
90
|
+
|
|
53
91
|
/**
|
|
54
92
|
* File magic. A reader rejects anything not starting with these four bytes before parsing further.
|
|
55
93
|
*/
|
|
@@ -58,7 +96,7 @@ const MAGIC = Buffer.from("FST\0", "ascii")
|
|
|
58
96
|
/**
|
|
59
97
|
* Format version this serializer emits. See {@link VERSION_WIDE_STATE_COUNTERS} for what each bump changed.
|
|
60
98
|
*/
|
|
61
|
-
const VERSION =
|
|
99
|
+
const VERSION = 5
|
|
62
100
|
|
|
63
101
|
/**
|
|
64
102
|
* The format version this tree WRITES, published so a freshness guard can call an older artifact format-stale without
|
|
@@ -83,9 +121,9 @@ const STATE_ENTRY_SIZE = 16
|
|
|
83
121
|
const EDGE_ENTRY_SIZE = 8
|
|
84
122
|
|
|
85
123
|
/**
|
|
86
|
-
* Place-table entry: the place id, its placetype, coordinates, and
|
|
124
|
+
* Place-table entry at the version this tree WRITES: the place id, its placetype, coordinates, and both scores.
|
|
87
125
|
*/
|
|
88
|
-
const PLACE_ENTRY_SIZE =
|
|
126
|
+
const PLACE_ENTRY_SIZE = SPLIT_PLACE_ENTRY_SIZE
|
|
89
127
|
|
|
90
128
|
/**
|
|
91
129
|
* Longest ancestry chain stored per place. Deeper hierarchies are truncated at the leaf end, since the specific end of
|
|
@@ -246,11 +284,14 @@ export function serializeFST(matcher: FSTMatcher, provenance?: FSTProvenance): B
|
|
|
246
284
|
buf.writeUInt32LE(place.wofID, pp)
|
|
247
285
|
buf.writeUInt8(placetypeToIdx.get(place.placetype) ?? 0, pp + 4)
|
|
248
286
|
buf.writeUInt8(chainLen, pp + 5)
|
|
249
|
-
// Former _pad: byte 0 = crossCountryBranches (header flags bit0 gates the read), byte 1
|
|
287
|
+
// Former _pad: byte 0 = crossCountryBranches (header flags bit0 gates the read), byte 1 = v5 placeFlags.
|
|
250
288
|
buf.writeUInt8(hasAmbiguity ? Math.min(place.crossCountryBranches ?? 0, 255) : 0, pp + 6)
|
|
251
|
-
|
|
289
|
+
// An absent encyclopedic score writes flag 0 AND a 0.0 float. The float is unread in that
|
|
290
|
+
// state, so absence can never surface as a score — the meaning-of-zero rule, in bytes.
|
|
291
|
+
const hasEncyclopedic = place.encyclopedic !== undefined
|
|
292
|
+
buf.writeUInt8(hasEncyclopedic ? PLACE_FLAG_HAS_ENCYCLOPEDIC : 0, pp + 7)
|
|
252
293
|
buf.writeUInt32LE(intern(place.name), pp + 8)
|
|
253
|
-
buf.writeFloatLE(place.
|
|
294
|
+
buf.writeFloatLE(place.referential, pp + 12)
|
|
254
295
|
buf.writeFloatLE(place.lat, pp + 16)
|
|
255
296
|
buf.writeFloatLE(place.lon, pp + 20)
|
|
256
297
|
|
|
@@ -258,6 +299,8 @@ export function serializeFST(matcher: FSTMatcher, provenance?: FSTProvenance): B
|
|
|
258
299
|
buf.writeUInt32LE(ci < chainLen ? validChain[ci]! : 0, pp + 24 + ci * 4)
|
|
259
300
|
}
|
|
260
301
|
|
|
302
|
+
buf.writeFloatLE(hasEncyclopedic ? place.encyclopedic! : 0, pp + ENCYCLOPEDIC_OFFSET)
|
|
303
|
+
|
|
261
304
|
placeIdx++
|
|
262
305
|
}
|
|
263
306
|
}
|
|
@@ -280,6 +323,7 @@ export function deserializeFST(buf: Buffer): FSTMatcher {
|
|
|
280
323
|
|
|
281
324
|
if (version < 1 || version > VERSION) throw new Error(`FST version ${version} unsupported (expected 1..${VERSION})`)
|
|
282
325
|
const isV2 = version >= 2
|
|
326
|
+
const isSplit = version >= VERSION_TWO_SCORE_SPLIT
|
|
283
327
|
// flags bit0 (survey #4): place rows carry surface-ambiguity data in the former _pad byte.
|
|
284
328
|
const hasAmbiguity = (buf.readUInt16LE(6) & 1) === 1
|
|
285
329
|
|
|
@@ -312,6 +356,8 @@ export function deserializeFST(buf: Buffer): FSTMatcher {
|
|
|
312
356
|
|
|
313
357
|
// --- State table ---
|
|
314
358
|
const stateEntrySize = version >= VERSION_WIDE_STATE_COUNTERS ? WIDE_STATE_ENTRY_SIZE : NARROW_STATE_ENTRY_SIZE
|
|
359
|
+
// v5 grew the place entry by the encyclopedic float; v4-and-below files are read at the old stride.
|
|
360
|
+
const placeEntrySize = version >= VERSION_TWO_SCORE_SPLIT ? SPLIT_PLACE_ENTRY_SIZE : LEGACY_PLACE_ENTRY_SIZE
|
|
315
361
|
const stateTableStart = pos
|
|
316
362
|
const edgeTableStart = stateTableStart + stateCount * stateEntrySize
|
|
317
363
|
const placeTableStart = edgeTableStart + edgeCount * EDGE_ENTRY_SIZE
|
|
@@ -341,7 +387,7 @@ export function deserializeFST(buf: Buffer): FSTMatcher {
|
|
|
341
387
|
const places: PlaceEntry[] = new Array(placeCountForState)
|
|
342
388
|
|
|
343
389
|
for (let pi = 0; pi < placeCountForState; pi++) {
|
|
344
|
-
const pp = placeTableStart + (placeStart + pi) *
|
|
390
|
+
const pp = placeTableStart + (placeStart + pi) * placeEntrySize
|
|
345
391
|
const chainLen = buf.readUInt8(pp + 5)
|
|
346
392
|
const parentChain: number[] = []
|
|
347
393
|
|
|
@@ -349,20 +395,30 @@ export function deserializeFST(buf: Buffer): FSTMatcher {
|
|
|
349
395
|
parentChain.push(buf.readUInt32LE(pp + 24 + ci * 4))
|
|
350
396
|
}
|
|
351
397
|
|
|
352
|
-
|
|
398
|
+
// v1 stored a raw population u32 here; v2–v4 the conflated `importance` float; v5 the
|
|
399
|
+
// referential score. A v1 file's population is mapped through the SAME curve
|
|
400
|
+
// `referentialFromPopulation` uses, so its value is genuinely referential — the only
|
|
401
|
+
// generation of this format for which that can be said without reading the source database.
|
|
402
|
+
const referential = isV2
|
|
353
403
|
? buf.readFloatLE(pp + 12)
|
|
354
404
|
: Math.min(1, Math.log2(1 + buf.readUInt32LE(pp + 12) / 1000) / 14)
|
|
355
405
|
|
|
406
|
+
// Per-place presence bit (v5+). A v4-and-below file has no encyclopedic channel at all, so
|
|
407
|
+
// the field stays undefined rather than reading the reserved byte as a flag.
|
|
408
|
+
const hasEncyclopedic =
|
|
409
|
+
isSplit && (buf.readUInt8(pp + 7) & PLACE_FLAG_HAS_ENCYCLOPEDIC) === PLACE_FLAG_HAS_ENCYCLOPEDIC
|
|
410
|
+
|
|
356
411
|
places[pi] = {
|
|
357
412
|
wofID: buf.readUInt32LE(pp),
|
|
358
413
|
placetype: PLACETYPE_ORDER[buf.readUInt8(pp + 4)] ?? "locality",
|
|
359
414
|
name: strings[buf.readUInt32LE(pp + 8)]!,
|
|
360
|
-
|
|
415
|
+
referential,
|
|
361
416
|
lat: buf.readFloatLE(pp + 16),
|
|
362
417
|
lon: buf.readFloatLE(pp + 20),
|
|
363
418
|
parentChain,
|
|
364
419
|
// Header flags bit0 gates the read (survey #4): pre-ambiguity artifacts expose undefined.
|
|
365
420
|
...(hasAmbiguity ? { crossCountryBranches: buf.readUInt8(pp + 6) } : {}),
|
|
421
|
+
...(hasEncyclopedic ? { encyclopedic: buf.readFloatLE(pp + ENCYCLOPEDIC_OFFSET) } : {}),
|
|
366
422
|
}
|
|
367
423
|
}
|
|
368
424
|
|
package/fst-types.ts
CHANGED
|
@@ -13,7 +13,25 @@ export interface PlaceEntry {
|
|
|
13
13
|
placetype: PlacetypeID
|
|
14
14
|
name: string
|
|
15
15
|
parentChain: number[]
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* REFERENTIAL likelihood in [0, 1] — population-anchored (`referentialFromPopulation`), and the ONLY score the
|
|
18
|
+
* decoder bias is allowed to read (ROAD_TO_V9 §2, ratified 2026-08-06).
|
|
19
|
+
*
|
|
20
|
+
* This field was called `importance` through format v4, where it carried whichever score the source database happened
|
|
21
|
+
* to hold: the population proxy on a database with no `place_importance` table (which is every shipped
|
|
22
|
+
* `fst-per-locale` binary), the Wikipedia score where the concordance join landed otherwise. The rename is the point
|
|
23
|
+
* — a v4 artifact's value is a CONFLATION that cannot be told apart after the fact, which is why `fst-freshness.ts`
|
|
24
|
+
* reports anything below {@link FST_FORMAT_VERSION} as format-stale rather than reading it as referential.
|
|
25
|
+
*/
|
|
26
|
+
referential: number
|
|
27
|
+
/**
|
|
28
|
+
* Encyclopedic (Wikipedia) importance in [0, 1], fan-out-guarded per #1497. Carried for consumers that want to
|
|
29
|
+
* DISPLAY salience; never read by the decoder or by any ranking.
|
|
30
|
+
*
|
|
31
|
+
* `undefined` = this place has no encyclopedic signal, or the artifact predates format 5. NEVER conflate either with
|
|
32
|
+
* 0 — the meaning-of-zero rule; roughly 89% of the 2026-08-05 gazetteer's rows have no Wikipedia article at all.
|
|
33
|
+
*/
|
|
34
|
+
encyclopedic?: number
|
|
17
35
|
lat: number
|
|
18
36
|
lon: number
|
|
19
37
|
/**
|
|
@@ -64,7 +82,23 @@ export interface FSTProvenance {
|
|
|
64
82
|
placeCount: number
|
|
65
83
|
edgeCount: number
|
|
66
84
|
nameInsertions: number
|
|
85
|
+
/**
|
|
86
|
+
* How many places carried a non-zero REFERENTIAL score at build time. Named `importanceMatches` for stamp
|
|
87
|
+
* compatibility with every artifact written before the two-score split — renaming the JSON key would make every
|
|
88
|
+
* existing stamp unreadable, and the number means the same thing it always did on a population-built artifact.
|
|
89
|
+
*/
|
|
67
90
|
importanceMatches: number
|
|
91
|
+
/**
|
|
92
|
+
* How many places carried an ENCYCLOPEDIC score at build time. `undefined` on a pre-split build — which is not the
|
|
93
|
+
* same as 0 (a v5 build against a population-only database), so the freshness report says the two in different
|
|
94
|
+
* words.
|
|
95
|
+
*/
|
|
96
|
+
encyclopedicMatches?: number
|
|
97
|
+
/**
|
|
98
|
+
* How the builder obtained its two scores — an {@link ImportanceSplitSource}. Recorded so an artifact states, in its
|
|
99
|
+
* own provenance, whether its encyclopedic channel is real, reconstructed from a legacy conflated column, or absent.
|
|
100
|
+
*/
|
|
101
|
+
importanceSource?: string
|
|
68
102
|
sourceDB?: string
|
|
69
103
|
/**
|
|
70
104
|
* MD5 of the source database's bytes at build time — the artifact's link to the gazetteer it is a projection of.
|
package/fts.ts
CHANGED
|
@@ -123,6 +123,18 @@ export const PLACE_BBOX_TABLE = "place_bbox"
|
|
|
123
123
|
*/
|
|
124
124
|
export const PLACE_POPULATION_TABLE = "place_population"
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Name of the auxiliary table holding the two salience scores per place — `referential` (population-anchored, the
|
|
128
|
+
* ranking backbone) and `encyclopedic` (the Wikipedia join, NULL when there is no article). Built by `mailwoman
|
|
129
|
+
* gazetteer importance`; schema and derivations in `place-importance-schema.ts`.
|
|
130
|
+
*
|
|
131
|
+
* The lookup reads ONLY `encyclopedic` from it, and only to CARRY the value onto the result — referential is derived
|
|
132
|
+
* from the population already joined, and no ORDER BY anywhere touches this table (ROAD_TO_V9 §2). Sparse and
|
|
133
|
+
* schema-versioned: a pre-split gazetteer has this table with a single conflated `importance` column instead, which the
|
|
134
|
+
* lookup's column probe deliberately refuses to read.
|
|
135
|
+
*/
|
|
136
|
+
export const PLACE_IMPORTANCE_TABLE = "place_importance"
|
|
137
|
+
|
|
126
138
|
/**
|
|
127
139
|
* Counters for a single `buildPlaceSearchFTS` run. Exposed so callers (CLI, lazy-build) can render progress to the
|
|
128
140
|
* user.
|
package/index.ts
CHANGED
|
@@ -22,6 +22,14 @@ export type {
|
|
|
22
22
|
export { WOFSqlitePlaceLookup, type RankingWeights, type WOFSqlitePlaceLookupOpts } from "./lookup.ts"
|
|
23
23
|
|
|
24
24
|
export { CANDIDATE_FTS_TABLE, createCandidateFTS } from "./candidate-fts.ts"
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
ImportanceIndex,
|
|
28
|
+
IMPORTANCE_JOIN_GATE_KM,
|
|
29
|
+
type ImportanceIndexStats,
|
|
30
|
+
loadImportanceIndex,
|
|
31
|
+
} from "./candidate-importance.ts"
|
|
32
|
+
|
|
25
33
|
export { WOFCandidateTableLookup, type WOFCandidateTableLookupOpts } from "./candidate-lookup.ts"
|
|
26
34
|
|
|
27
35
|
export {
|