@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.
Files changed (78) hide show
  1. package/build-candidate.ts +97 -4
  2. package/candidate-importance.ts +227 -0
  3. package/candidate-lookup.ts +144 -7
  4. package/candidate-schema.ts +29 -0
  5. package/fst-autocomplete.ts +22 -11
  6. package/fst-builder.ts +25 -35
  7. package/fst-deserialize-web.ts +43 -7
  8. package/fst-serialize.ts +68 -12
  9. package/fst-types.ts +35 -1
  10. package/fts.ts +12 -0
  11. package/index.ts +8 -0
  12. package/lookup.ts +44 -5
  13. package/out/build-candidate.d.ts +33 -0
  14. package/out/build-candidate.d.ts.map +1 -1
  15. package/out/build-candidate.js +37 -4
  16. package/out/build-candidate.js.map +1 -1
  17. package/out/candidate-importance.d.ts +132 -0
  18. package/out/candidate-importance.d.ts.map +1 -0
  19. package/out/candidate-importance.js +174 -0
  20. package/out/candidate-importance.js.map +1 -0
  21. package/out/candidate-lookup.d.ts +9 -1
  22. package/out/candidate-lookup.d.ts.map +1 -1
  23. package/out/candidate-lookup.js +115 -6
  24. package/out/candidate-lookup.js.map +1 -1
  25. package/out/candidate-schema.d.ts +25 -1
  26. package/out/candidate-schema.d.ts.map +1 -1
  27. package/out/candidate-schema.js +5 -0
  28. package/out/candidate-schema.js.map +1 -1
  29. package/out/fst-autocomplete.d.ts +13 -3
  30. package/out/fst-autocomplete.d.ts.map +1 -1
  31. package/out/fst-autocomplete.js +10 -9
  32. package/out/fst-autocomplete.js.map +1 -1
  33. package/out/fst-builder.d.ts.map +1 -1
  34. package/out/fst-builder.js +21 -28
  35. package/out/fst-builder.js.map +1 -1
  36. package/out/fst-deserialize-web.d.ts.map +1 -1
  37. package/out/fst-deserialize-web.js +36 -7
  38. package/out/fst-deserialize-web.js.map +1 -1
  39. package/out/fst-serialize.d.ts +14 -4
  40. package/out/fst-serialize.d.ts.map +1 -1
  41. package/out/fst-serialize.js +60 -12
  42. package/out/fst-serialize.js.map +1 -1
  43. package/out/fst-types.d.ts +35 -1
  44. package/out/fst-types.d.ts.map +1 -1
  45. package/out/fts.d.ts +11 -0
  46. package/out/fts.d.ts.map +1 -1
  47. package/out/fts.js +11 -0
  48. package/out/fts.js.map +1 -1
  49. package/out/index.d.ts +1 -0
  50. package/out/index.d.ts.map +1 -1
  51. package/out/index.js +1 -0
  52. package/out/index.js.map +1 -1
  53. package/out/lookup.d.ts.map +1 -1
  54. package/out/lookup.js +37 -5
  55. package/out/lookup.js.map +1 -1
  56. package/out/place-importance-schema.d.ts +189 -0
  57. package/out/place-importance-schema.d.ts.map +1 -0
  58. package/out/place-importance-schema.js +242 -0
  59. package/out/place-importance-schema.js.map +1 -0
  60. package/out/sqlite-utils.d.ts +12 -0
  61. package/out/sqlite-utils.d.ts.map +1 -1
  62. package/out/sqlite-utils.js +20 -0
  63. package/out/sqlite-utils.js.map +1 -1
  64. package/out/street-morphology-fst-builder.d.ts.map +1 -1
  65. package/out/street-morphology-fst-builder.js +5 -4
  66. package/out/street-morphology-fst-builder.js.map +1 -1
  67. package/out/street-normalize.d.ts +2 -1
  68. package/out/street-normalize.d.ts.map +1 -1
  69. package/out/street-normalize.js +2 -1
  70. package/out/street-normalize.js.map +1 -1
  71. package/out/types.d.ts +39 -0
  72. package/out/types.d.ts.map +1 -1
  73. package/package.json +14 -6
  74. package/place-importance-schema.ts +353 -0
  75. package/sqlite-utils.ts +21 -0
  76. package/street-morphology-fst-builder.ts +5 -4
  77. package/street-normalize.ts +3 -2
  78. package/types.ts +39 -0
@@ -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",
@@ -31,7 +31,17 @@ export interface AutocompleteResult {
31
31
  export interface AutocompleteSuggestion {
32
32
  name: string
33
33
  placetype: string
34
- importance: number
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-importance one. Off by default (the CLI surfaces distinct
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 importance (descending). Avoids sorting/allocating when `entries` is small.
74
+ * The top-`k` entries by REFERENTIAL likelihood (descending). Avoids sorting/allocating when `entries` is small.
65
75
  */
66
- function topByImportance(entries: readonly PlaceEntry[], k: number): PlaceEntry[] {
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.importance - a.importance).slice(0, k)
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 importance-descending.
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 topByImportance(fst.accepting(cont.targetState), PER_BRANCH)) {
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 topByImportance(fst.accepting(item.stateID), PER_BRANCH)) {
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.importance - a.importance)
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
- importance: entry.importance,
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-importance. Input is already importance-sorted, so the first occurrence
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 importance data (Wikipedia-based, falls back to population-scaled).
175
- // See docs/articles/concepts/importance-vs-population.md for the two-signal contract.
176
- progress("importance", "Loading importance data")
177
- const importanceMap = new Map<number, number>()
178
-
179
- try {
180
- const impStmt = db.prepare("SELECT id, importance FROM place_importance")
181
- const impRows = impStmt.all() as unknown as Array<{ id: number; importance: number }>
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
- for (const row of popRows) {
196
- const normalized = row.population > 0 ? Math.min(1, Math.log2(1 + row.population / 1000) / 14) : 0
197
- importanceMap.set(row.id, normalized)
198
- }
199
- } catch {
200
- progress("importance", "No place_population either — using 0 for all")
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
- importance: importanceMap.get(row.id) ?? 0,
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: importanceMap.size,
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,
@@ -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
- const PLACE_ENTRY_SIZE = 56
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 4). The v3 provenance + v4 16-byte-state/u32-count
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 = 4
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) * PLACE_ENTRY_SIZE
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
- const rawImportance = isV2
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
- importance: rawImportance,
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 bytes] wofID u32 placetypeIdx u8 index into PLACETYPE_ORDER chainLen
22
- * u8 0..8 _pad u16 nameIdx u32 index into string table importance f32 Wikipedia importance [0,1]
23
- * (V2); was population u32 (V1) lat f32 lon f32 chain [u32; 8] parent chain (unused slots = 0)
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 = 4
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 importance.
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 = 56
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 reserved.
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
- buf.writeUInt8(0, pp + 7)
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.importance, pp + 12)
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) * PLACE_ENTRY_SIZE
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
- const rawImportance = isV2
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
- importance: rawImportance,
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
- importance: number
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 {