@mailwoman/resolver-wof-sqlite 8.6.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 (121) hide show
  1. package/ancestry-backfill.ts +103 -64
  2. package/build-candidate.ts +230 -29
  3. package/candidate-importance.ts +227 -0
  4. package/candidate-lookup.ts +159 -9
  5. package/candidate-schema.ts +29 -0
  6. package/coverage-manifest-schema.ts +1 -1
  7. package/fst-autocomplete.ts +22 -11
  8. package/fst-builder.ts +76 -50
  9. package/fst-deserialize-web.ts +46 -8
  10. package/fst-freshness.ts +333 -0
  11. package/fst-serialize.ts +78 -13
  12. package/fst-types.ts +58 -1
  13. package/fts.ts +12 -0
  14. package/geo.ts +16 -41
  15. package/geonames-aliases.ts +105 -37
  16. package/geonames-postal.ts +65 -21
  17. package/index.ts +16 -1
  18. package/interpolation.ts +2 -1
  19. package/lookup.ts +44 -5
  20. package/out/ancestry-backfill.d.ts +37 -18
  21. package/out/ancestry-backfill.d.ts.map +1 -1
  22. package/out/ancestry-backfill.js +82 -52
  23. package/out/ancestry-backfill.js.map +1 -1
  24. package/out/build-candidate.d.ts +42 -0
  25. package/out/build-candidate.d.ts.map +1 -1
  26. package/out/build-candidate.js +141 -13
  27. package/out/build-candidate.js.map +1 -1
  28. package/out/candidate-importance.d.ts +132 -0
  29. package/out/candidate-importance.d.ts.map +1 -0
  30. package/out/candidate-importance.js +174 -0
  31. package/out/candidate-importance.js.map +1 -0
  32. package/out/candidate-lookup.d.ts +9 -1
  33. package/out/candidate-lookup.d.ts.map +1 -1
  34. package/out/candidate-lookup.js +127 -8
  35. package/out/candidate-lookup.js.map +1 -1
  36. package/out/candidate-schema.d.ts +25 -1
  37. package/out/candidate-schema.d.ts.map +1 -1
  38. package/out/candidate-schema.js +5 -0
  39. package/out/candidate-schema.js.map +1 -1
  40. package/out/coverage-manifest-schema.d.ts +1 -1
  41. package/out/coverage-manifest-schema.js +1 -1
  42. package/out/fst-autocomplete.d.ts +13 -3
  43. package/out/fst-autocomplete.d.ts.map +1 -1
  44. package/out/fst-autocomplete.js +10 -9
  45. package/out/fst-autocomplete.js.map +1 -1
  46. package/out/fst-builder.d.ts.map +1 -1
  47. package/out/fst-builder.js +64 -40
  48. package/out/fst-builder.js.map +1 -1
  49. package/out/fst-deserialize-web.d.ts.map +1 -1
  50. package/out/fst-deserialize-web.js +38 -8
  51. package/out/fst-deserialize-web.js.map +1 -1
  52. package/out/fst-freshness.d.ts +138 -0
  53. package/out/fst-freshness.d.ts.map +1 -0
  54. package/out/fst-freshness.js +238 -0
  55. package/out/fst-freshness.js.map +1 -0
  56. package/out/fst-serialize.d.ts +19 -3
  57. package/out/fst-serialize.d.ts.map +1 -1
  58. package/out/fst-serialize.js +68 -13
  59. package/out/fst-serialize.js.map +1 -1
  60. package/out/fst-types.d.ts +61 -1
  61. package/out/fst-types.d.ts.map +1 -1
  62. package/out/fts.d.ts +11 -0
  63. package/out/fts.d.ts.map +1 -1
  64. package/out/fts.js +11 -0
  65. package/out/fts.js.map +1 -1
  66. package/out/geo.d.ts +10 -13
  67. package/out/geo.d.ts.map +1 -1
  68. package/out/geo.js +15 -35
  69. package/out/geo.js.map +1 -1
  70. package/out/geonames-aliases.d.ts +23 -1
  71. package/out/geonames-aliases.d.ts.map +1 -1
  72. package/out/geonames-aliases.js +88 -33
  73. package/out/geonames-aliases.js.map +1 -1
  74. package/out/geonames-postal.d.ts +22 -1
  75. package/out/geonames-postal.d.ts.map +1 -1
  76. package/out/geonames-postal.js +50 -16
  77. package/out/geonames-postal.js.map +1 -1
  78. package/out/index.d.ts +3 -1
  79. package/out/index.d.ts.map +1 -1
  80. package/out/index.js +3 -1
  81. package/out/index.js.map +1 -1
  82. package/out/interpolation.d.ts.map +1 -1
  83. package/out/interpolation.js +2 -1
  84. package/out/interpolation.js.map +1 -1
  85. package/out/lookup.d.ts.map +1 -1
  86. package/out/lookup.js +37 -5
  87. package/out/lookup.js.map +1 -1
  88. package/out/place-importance-schema.d.ts +189 -0
  89. package/out/place-importance-schema.d.ts.map +1 -0
  90. package/out/place-importance-schema.js +242 -0
  91. package/out/place-importance-schema.js.map +1 -0
  92. package/out/poi-lookup.d.ts +1 -1
  93. package/out/poi-lookup.js +3 -3
  94. package/out/reverse.d.ts.map +1 -1
  95. package/out/reverse.js +3 -9
  96. package/out/reverse.js.map +1 -1
  97. package/out/sqlite-convention-source.d.ts.map +1 -1
  98. package/out/sqlite-convention-source.js +4 -3
  99. package/out/sqlite-convention-source.js.map +1 -1
  100. package/out/sqlite-utils.d.ts +12 -0
  101. package/out/sqlite-utils.d.ts.map +1 -1
  102. package/out/sqlite-utils.js +20 -0
  103. package/out/sqlite-utils.js.map +1 -1
  104. package/out/street-morphology-fst-builder.d.ts.map +1 -1
  105. package/out/street-morphology-fst-builder.js +7 -5
  106. package/out/street-morphology-fst-builder.js.map +1 -1
  107. package/out/street-normalize.d.ts +2 -1
  108. package/out/street-normalize.d.ts.map +1 -1
  109. package/out/street-normalize.js +2 -1
  110. package/out/street-normalize.js.map +1 -1
  111. package/out/types.d.ts +39 -0
  112. package/out/types.d.ts.map +1 -1
  113. package/package.json +24 -6
  114. package/place-importance-schema.ts +353 -0
  115. package/poi-lookup.ts +3 -3
  116. package/reverse.ts +4 -9
  117. package/sqlite-convention-source.ts +5 -3
  118. package/sqlite-utils.ts +21 -0
  119. package/street-morphology-fst-builder.ts +8 -5
  120. package/street-normalize.ts +3 -2
  121. package/types.ts +39 -0
@@ -0,0 +1,227 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * The score side of the candidate build (#28) — load a WOF admin database's `place_importance` into
7
+ * a lookup the candidate builder can probe per place, so every candidate row can carry the
8
+ * toponym-fame prior a bare city name is decided on.
9
+ *
10
+ * ## Why this joins on the NAME and not on the id
11
+ *
12
+ * The obvious join is `candidate.spr_id = place_importance.id`, and it is wrong here. The score
13
+ * source (`admin-global-priority-importance.db`) and the candidate build's admin source are
14
+ * DIFFERENT SNAPSHOTS, and they disagree about ids for exactly the rows that matter: Whitby,
15
+ * Ontario is `8143502164401` in the shipped `candidate.db` and `8000001156384` in the score source
16
+ * — the Overture-sourced rows were re-keyed between the two. An id join silently drops every one of
17
+ * them, which means it drops precisely the FOREIGN HOMONYMS the fame prior exists to demote, and it
18
+ * drops them invisibly: the build succeeds, the column is populated, and the ranking is inert on
19
+ * the queries it was built for.
20
+ *
21
+ * So the join key is `(name_key, country, placetype)` — the same {@link normalizeLocalityForKey} the
22
+ * candidate build uses for its probe key, so the two sides fold identically by construction.
23
+ *
24
+ * ## Why the key alone is not enough
25
+ *
26
+ * `(warwick, US, locality)` names ELEVEN different places. Taking the group's max would give every
27
+ * Warwick in America the fame of Warwick, Rhode Island, which is the fan-out defect
28
+ * `importance-fanout.ts` documents one layer up, re-introduced at the join. So the group is
29
+ * disambiguated GEOGRAPHICALLY: the nearest centroid wins, and only within
30
+ * {@link IMPORTANCE_JOIN_GATE_KM}. Two artifacts describing the same settlement put its centroid in
31
+ * almost the same place; two same-named towns in one country do not.
32
+ *
33
+ * ## What lands in the column
34
+ *
35
+ * `place_importance.importance` VERBATIM — the pre-split conflation (encyclopedia-derived where
36
+ * the concordance matched, a population-derived proxy everywhere else). See
37
+ * {@link CandidateTable.importance} for why the split `encyclopedic` channel is deliberately NOT
38
+ * what is written here, with the measurement that settled it.
39
+ *
40
+ * A place with no match gets NULL. NULL is UNMEASURED, never zero — the consumer
41
+ * (`resolver/toponym-prior.ts`) leaves an unmeasured candidate exactly where population put it.
42
+ */
43
+
44
+ import { DatabaseSync } from "node:sqlite"
45
+
46
+ import { haversineKm } from "./geo.ts"
47
+ import { normalizeLocalityForKey } from "./street-normalize.ts"
48
+
49
+ /**
50
+ * How far apart two artifacts may put the same place's centroid and still be read as the same place.
51
+ *
52
+ * MEASURED, not guessed (2026-08-10, `admin-global-priority.db` × `admin-global-priority-importance.db`; 4,476,245
53
+ * current locality-tier places against 676,790 scored ones, 1,020,099 of which matched a scored group by `(name_key,
54
+ * country, placetype)`). The nearest-centroid distance is **exactly 0.00 km for 577,080 of them (56.6%)** — the two
55
+ * snapshots agree to the bit — and 656,755 (64.4%) are inside 500 m.
56
+ *
57
+ * What sets the gate is where that mode ENDS, and the per-kilometre density says so plainly. It falls from 1,076
58
+ * places/km over 3–5 km to a trough of **441 places/km over 7–10 km**, then climbs back and flattens onto a plateau of
59
+ * 760–780 places/km from 30 km out to 100 km and beyond. That plateau is the background rate of two DIFFERENT towns
60
+ * wearing one name in one country, and it does not decay with distance because there is no reason it should. 10 km is
61
+ * the floor between the two populations. Admitting it scores 679,163 places (66.6% of the matched set); pushing the
62
+ * gate to 25 km buys 8,722 more, and by then better than half of each additional kilometre is the wrong town.
63
+ *
64
+ * The four-row bare-GB board is insensitive across this whole range — 5 km and 25 km were both measured and select
65
+ * identical rows — so the value is chosen by what the join MEANS, not by what it scores. The gate is the definition of
66
+ * "this is the same place"; widening it past the floor starts handing one town's fame to another.
67
+ */
68
+ export const IMPORTANCE_JOIN_GATE_KM = 10
69
+
70
+ /**
71
+ * One scored place from the source: where it is, and what it scored.
72
+ */
73
+ interface ScoredPlace {
74
+ lat: number
75
+ lon: number
76
+ importance: number
77
+ }
78
+
79
+ /**
80
+ * What {@link loadImportanceIndex} measured while reading the source. Reported by the builder so a run says how much of
81
+ * the gazetteer it actually scored, rather than leaving the caller to infer it from a column full of nulls.
82
+ */
83
+ export interface ImportanceIndexStats {
84
+ /**
85
+ * Scored places read out of the source.
86
+ */
87
+ places: number
88
+ /**
89
+ * Distinct `(name_key, country, placetype)` groups they fall into.
90
+ */
91
+ keys: number
92
+ /**
93
+ * Places whose name folded to the empty key and could never be joined (non-Latin punctuation-only names, blanks).
94
+ */
95
+ unkeyable: number
96
+ }
97
+
98
+ /**
99
+ * `(name_key, country, placetype)` → the scored places under it. The separator is U+0000, which no WOF name carries and
100
+ * no fold can produce, so the three fields can't smear into one another.
101
+ */
102
+ function groupKey(nameKey: string, country: string | null, placetype: string | null): string {
103
+ return `${nameKey}\u0000${(country ?? "").toUpperCase()}\u0000${placetype ?? ""}`
104
+ }
105
+
106
+ /**
107
+ * A loaded `place_importance`, probed by name + country + placetype + position.
108
+ */
109
+ export class ImportanceIndex {
110
+ readonly #groups: Map<string, ScoredPlace[]>
111
+ readonly stats: ImportanceIndexStats
112
+ /**
113
+ * Places {@link find} matched inside the gate.
114
+ */
115
+ matched = 0
116
+ /**
117
+ * Places {@link find} REFUSED — the key matched a scored group, but the nearest member of it was outside the gate, so
118
+ * it is a different place wearing the same name.
119
+ *
120
+ * This is the number worth watching across rebuilds. A jump means the score source and the admin source have drifted
121
+ * apart and the join is being asked to guess; it does not mean the gate is too tight.
122
+ */
123
+ gated = 0
124
+
125
+ constructor(groups: Map<string, ScoredPlace[]>, stats: ImportanceIndexStats) {
126
+ this.#groups = groups
127
+ this.stats = stats
128
+ }
129
+
130
+ /**
131
+ * The importance of the scored place nearest `(lat, lon)` sharing `name`'s folded key, `country` and `placetype`, or
132
+ * null when there is no such place within {@link IMPORTANCE_JOIN_GATE_KM}.
133
+ *
134
+ * Null is UNMEASURED. Never substitute a zero, and never fall back to a population-derived value here — the source
135
+ * column already carries that fallback where it has one, and inventing a second one would make an absence
136
+ * indistinguishable from a measurement.
137
+ */
138
+ find(name: string, country: string | null, placetype: string | null, lat: number, lon: number): number | null {
139
+ const nameKey = normalizeLocalityForKey(name)
140
+
141
+ if (!nameKey) return null
142
+
143
+ const group = this.#groups.get(groupKey(nameKey, country, placetype))
144
+
145
+ if (!group) return null
146
+
147
+ let best: ScoredPlace | undefined
148
+ let bestKm = Infinity
149
+
150
+ for (const place of group) {
151
+ const km = haversineKm(lat, lon, place.lat, place.lon)
152
+
153
+ if (km < bestKm) {
154
+ bestKm = km
155
+ best = place
156
+ }
157
+ }
158
+
159
+ if (!best || bestKm > IMPORTANCE_JOIN_GATE_KM) {
160
+ this.gated++
161
+
162
+ return null
163
+ }
164
+
165
+ this.matched++
166
+
167
+ return best.importance
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Read `place_importance` (joined to `spr` for the name/country/placetype/centroid) out of a WOF admin database into an
173
+ * {@link ImportanceIndex}.
174
+ *
175
+ * Only CURRENT, non-deprecated places are indexed — a superseded row's score belongs to a place the gazetteer no longer
176
+ * carries, and letting it win the nearest-centroid contest would hand a live place a dead one's fame.
177
+ *
178
+ * The whole table is held in memory on purpose. The 2026-08-10 source holds 676,790 scored places in 544,823 groups,
179
+ * and the build probes it once for every one of its ~4.8 M places; the alternative is a prepared statement per place
180
+ * against a 3.7 GB database. Measured end to end, loading the index plus probing all 4.48 M locality-tier places takes
181
+ * 25 s.
182
+ */
183
+ export function loadImportanceIndex(databasePath: string): ImportanceIndex {
184
+ const db = new DatabaseSync(databasePath, { readOnly: true })
185
+
186
+ try {
187
+ const groups = new Map<string, ScoredPlace[]>()
188
+ let places = 0
189
+ let unkeyable = 0
190
+
191
+ for (const row of db
192
+ .prepare(
193
+ `SELECT s.name AS name, s.country AS country, s.placetype AS placetype,
194
+ s.latitude AS latitude, s.longitude AS longitude, i.importance AS importance
195
+ FROM place_importance i JOIN spr s ON s.id = i.id
196
+ WHERE s.is_current != 0 AND s.is_deprecated = 0`
197
+ )
198
+ .iterate()) {
199
+ const importance = Number(row.importance)
200
+
201
+ if (!Number.isFinite(importance)) continue
202
+ const nameKey = normalizeLocalityForKey(String(row.name ?? ""))
203
+
204
+ if (!nameKey) {
205
+ unkeyable++
206
+
207
+ continue
208
+ }
209
+
210
+ const key = groupKey(nameKey, row.country as string | null, row.placetype as string | null)
211
+ let group = groups.get(key)
212
+
213
+ if (!group) {
214
+ group = []
215
+ groups.set(key, group)
216
+ }
217
+
218
+ group.push({ lat: Number(row.latitude), lon: Number(row.longitude), importance })
219
+
220
+ places++
221
+ }
222
+
223
+ return new ImportanceIndex(groups, { places, keys: groups.size, unkeyable })
224
+ } finally {
225
+ db.close()
226
+ }
227
+ }
@@ -32,8 +32,9 @@ import type { CandidateTable, CountryCodeTable, PlacetypeCodeTable } from "./can
32
32
  import { readGazetteerCoverageManifest } from "./coverage-manifest-schema.ts"
33
33
  import { haversineKm } from "./geo.ts"
34
34
  import { trigramJaccard } from "./lookup.ts"
35
+ import { referentialFromPopulation } from "./place-importance-schema.ts"
35
36
  import { POSTAL_CITY_CANDIDATE_TABLE, type PostalCityCandidateTable } from "./postal-city-candidate-schema.ts"
36
- import { hasTable } from "./sqlite-utils.ts"
37
+ import { hasColumn, hasTable } from "./sqlite-utils.ts"
37
38
  import { normalizeLocalityForKey, stripLocalityQualifier } from "./street-normalize.ts"
38
39
  import type { FindPlaceQuery, PlaceCandidate, PlaceLookup, WOFPlacetype } from "./types.ts"
39
40
 
@@ -66,7 +67,14 @@ type CandidateRow = Pick<
66
67
  | "max_lon"
67
68
  | "neg_rank"
68
69
  | "is_primary"
69
- >
70
+ | "population"
71
+ > &
72
+ // `importance` is OPTIONAL on the row rather than `number | null`, because whether the SELECT names it
73
+ // depends on the artifact: a candidate.db built before #28 has no such column and the probe leaves it
74
+ // out (see `#importanceSelect`). `undefined` therefore means "this build cannot tell you", which is the
75
+ // same answer as `null`'s "the score source had no measurement" — both are UNMEASURED, and the emit
76
+ // below collapses them into the one thing the consumer understands: no `importance` field at all.
77
+ Partial<Pick<CandidateTable, "importance">>
70
78
 
71
79
  /**
72
80
  * FTS5-trigram over-fetch before the trigram-Jaccard re-rank, and the minimum similarity to count as a fuzzy hit (below
@@ -75,6 +83,14 @@ type CandidateRow = Pick<
75
83
  const FUZZY_FETCH = 40
76
84
  const FUZZY_MIN = 0.34
77
85
 
86
+ /**
87
+ * Postcode-containment re-rank gate radius (km) — the SAME value the resolver's country pass measures at
88
+ * (`POSTCODE_COUNTRY_COHERENCE_GATE_KM`, resolver/postcode-country-coherence.ts): a locality within this distance of
89
+ * the postcode's own centroid counts as "containing" it. One number, two passes — a divergence here would make the two
90
+ * mechanisms disagree about what is proximal.
91
+ */
92
+ const POSTCODE_CONTAINMENT_GATE_KM = 25
93
+
78
94
  /**
79
95
  * Bounded PRIMARY-NAME preference across a CROSS-COUNTRY name collision (the `is_primary` ranking signal).
80
96
  *
@@ -125,6 +141,14 @@ export type RankedRow<R> = R & {
125
141
  * tier, and a same-country nickname (San Francisco's "Frisco") is never touched.
126
142
  */
127
143
  demoted: boolean
144
+ /**
145
+ * True when this row came from the TYPO-CORRECTOR tier — the FTS5-trigram fallback that fires only after the exact
146
+ * and qualifier-strip probes both missed. Such a row answers a query the gazetteer does not contain, so it is a fuzzy
147
+ * match by construction and must not claim `exactMatch` (#17). Recall is unaffected: the row is still returned, still
148
+ * ranked, still resolvable — it just stops asserting a match quality it does not have, which is what the FTS backend
149
+ * has always done and what every `exactMatch`-filtering consumer assumed.
150
+ */
151
+ fuzzy?: boolean
128
152
  }
129
153
 
130
154
  /**
@@ -228,6 +252,13 @@ export class WOFCandidateTableLookup implements PlaceLookup {
228
252
  * (the hard-country coverage gate, guard-B plausibility) falls back to its code constants byte-identically.
229
253
  */
230
254
  readonly artifactCoverage: GazetteerArtifactCoverage | undefined
255
+ /**
256
+ * `", importance"` when this artifact carries the #28 fame column, `""` when it does not — spliced into the probe's
257
+ * SELECT list. Existence-gated exactly like `#ftsProbe` and `#postalCityProbe` above, and for the same reason: a
258
+ * candidate.db built before the column is a valid artifact, and naming a column it lacks would turn a stale gazetteer
259
+ * into `no such column` on the first keystroke rather than into "no fame signal", which is what it is.
260
+ */
261
+ readonly #importanceSelect: string
231
262
 
232
263
  constructor(opts: WOFCandidateTableLookupOpts) {
233
264
  if (opts.database) {
@@ -273,6 +304,9 @@ export class WOFCandidateTableLookup implements PlaceLookup {
273
304
  this.#nameKeyExistsProbe = this.#db.prepare("SELECT 1 FROM candidate WHERE name_key = ? LIMIT 1")
274
305
  }
275
306
 
307
+ // #28 fame column: probed ONCE here (it runs a PRAGMA, and `findPlace` is per-keystroke hot).
308
+ this.#importanceSelect = hasColumn(this.#db, "candidate", "importance") ? ", importance" : ""
309
+
276
310
  // Coverage manifest (survey candidate #2): the artifact's own coverage facts, existence-gated like
277
311
  // the probes above — a candidate.db built before the manifest reads `undefined` and consumers keep
278
312
  // their code-constant fallbacks byte-identically.
@@ -289,6 +323,38 @@ export class WOFCandidateTableLookup implements PlaceLookup {
289
323
  return expandPlacetypeFilter(want as readonly string[]).includes("locality")
290
324
  }
291
325
 
326
+ /**
327
+ * The postcode-containment anchor: the postcode's own centroid row in the candidate table, keyed whitespace-stripped
328
+ * (#920 — the same fold the build applies to postcode rows), country-scoped when the query is, first
329
+ * coordinate-bearing row wins. null when the candidate table carries no such postcode — the re-rank then abstains,
330
+ * because a recall gap is not evidence for the name match. Meaning-of-zero: a 0,0 row is the build's unlocated
331
+ * sentinel, never a real centroid.
332
+ */
333
+ #postcodeAnchor(postcode: string, country?: string): { lat: number; lon: number } | null {
334
+ const placetypeID = this.#placetypeToID.get("postalcode")
335
+
336
+ if (placetypeID === undefined) return null
337
+
338
+ const conds = ["name_key = ?", "placetype_id = ?"]
339
+ const params: Array<string | number> = [postcode.replaceAll(/\s+/g, ""), placetypeID]
340
+
341
+ if (country) {
342
+ const countryID = this.#countryToID.get(country.toUpperCase())
343
+
344
+ if (countryID === undefined) return null // a country the candidate table doesn't carry
345
+ conds.push("country_id = ?")
346
+ params.push(countryID)
347
+ }
348
+
349
+ const row = this.#db
350
+ .prepare(`SELECT latitude, longitude FROM candidate WHERE ${conds.join(" AND ")} ORDER BY neg_rank ASC LIMIT 1`)
351
+ .get(...params) as { latitude: number; longitude: number } | undefined
352
+
353
+ if (!row || (Number(row.latitude) === 0 && Number(row.longitude) === 0)) return null
354
+
355
+ return { lat: Number(row.latitude), lon: Number(row.longitude) }
356
+ }
357
+
292
358
  async findPlace(query: FindPlaceQuery): Promise<PlaceCandidate[]> {
293
359
  let text = (query.text ?? "").trim()
294
360
 
@@ -297,7 +363,9 @@ export class WOFCandidateTableLookup implements PlaceLookup {
297
363
  // #920 name law, candidate-key edition: postcode rows are keyed by their whitespace-stripped
298
364
  // form at build (the GeoNames fold normalizes '624 66' → '62466'), so a postcode-typed query
299
365
  // strips internal whitespace before keying. Postcode-only — locality names keep their spaces.
300
- if ([query.placetype].flat().includes("postalcode")) {
366
+ const wantsPostcode = [query.placetype].flat().includes("postalcode")
367
+
368
+ if (wantsPostcode) {
301
369
  text = text.replaceAll(/\s+/g, "")
302
370
  }
303
371
 
@@ -390,9 +458,16 @@ export class WOFCandidateTableLookup implements PlaceLookup {
390
458
  // intended primary even when a cluster of more-populous foreign aliases sits ahead of it. `is_primary`
391
459
  // + `country_id` feed that re-rank. A single-country probe (a country filter, or all rows same
392
460
  // country) re-ranks to the identical population order, so the common path is untouched.
461
+ // `population` rides along for the REFERENTIAL score on the result (ROAD_TO_V9 §2) — one more
462
+ // column off a clustered row the probe already reads, and it is NOT what the probe orders by:
463
+ // `neg_rank` remains the sort key, so this changes no ordering, only what the result reports.
464
+ // `importance` (#28) rides along on the same terms, and there is deliberately NO `ORDER BY` on it:
465
+ // the fame prior is applied by the RESOLVER (`resolver/toponym-prior.ts`), which alone knows
466
+ // whether the query was bare enough to deserve it. A backend that pre-sorted by fame would apply
467
+ // it to every lookup, including the qualified addresses the D-rule guard exists to protect.
393
468
  const sql =
394
- "SELECT spr_id, name, country_id, placetype_id, latitude, longitude, min_lat, min_lon, max_lat, max_lon, neg_rank, is_primary " +
395
- `FROM candidate WHERE ${conds.join(" AND ")} ORDER BY neg_rank ASC LIMIT ?`
469
+ "SELECT spr_id, name, country_id, placetype_id, latitude, longitude, min_lat, min_lon, max_lat, max_lon, neg_rank, is_primary, population" +
470
+ `${this.#importanceSelect} FROM candidate WHERE ${conds.join(" AND ")} ORDER BY neg_rank ASC LIMIT ?`
396
471
 
397
472
  const fetched = this.#db.prepare(sql).all(...params, Math.max(limit, RERANK_FETCH)) as unknown as CandidateRow[]
398
473
 
@@ -427,7 +502,18 @@ export class WOFCandidateTableLookup implements PlaceLookup {
427
502
  // — fuzzing it scrapes an unrelated same-filter place ("Vienna, Austria" misrouted to IT would
428
503
  // pull a tiny Italian name_key near Siena) and masks the cascade's country-agnostic retry that
429
504
  // correctly lands population-first Vienna AT. The exact/strip probes already covered the real name.
430
- if (!rows.length && this.#ftsProbe && this.#nameKeyExistsProbe && !this.#nameKeyExistsProbe.get(nameKey)) {
505
+ //
506
+ // NEVER for postcodes: fuzzy is a typo corrector for place NAMES, and a "corrected" postcode is a
507
+ // DIFFERENT postcode. The 2026-08-05 Code-Point swap exposed the trap at scale: Northern Ireland's
508
+ // `BT3 9QQ` (absent — no permissive NI source) trigram-matched Sheffield's `S3 9QQ` (Jaccard 0.4
509
+ // on {39q, 9qq}) and resolved 200+ km wrong with full confidence. An unknown postcode must abstain.
510
+ if (
511
+ !rows.length &&
512
+ !wantsPostcode &&
513
+ this.#ftsProbe &&
514
+ this.#nameKeyExistsProbe &&
515
+ !this.#nameKeyExistsProbe.get(nameKey)
516
+ ) {
431
517
  const match = ftsTrigramQuery(nameKey)
432
518
 
433
519
  if (match) {
@@ -444,7 +530,9 @@ export class WOFCandidateTableLookup implements PlaceLookup {
444
530
  for (const h of ranked) {
445
531
  if (seen.has(h.nk)) continue
446
532
  seen.add(h.nk)
447
- rows.push(...probe(h.nk, regionID))
533
+ // #17: stamp the tier. These rows answer a name the gazetteer does not carry, so they are
534
+ // fuzzy matches and `exactMatch` below must say so — see `RankedRow.fuzzy`.
535
+ rows.push(...probe(h.nk, regionID).map((r) => ({ ...r, fuzzy: true })))
448
536
 
449
537
  if (rows.length >= limit) break
450
538
  }
@@ -465,6 +553,44 @@ export class WOFCandidateTableLookup implements PlaceLookup {
465
553
  rows = cascade(undefined)
466
554
  }
467
555
 
556
+ // Postcode-containment coherence (#31, Mechanism 2): re-rank the rows by proximity to the postcode's
557
+ // own centroid, so the locality that CONTAINS the postcode wins the name-match tie (the "Paris" that
558
+ // holds 75001, not the one that holds a 75001-free namesake). The resolver sends this flag on locality
559
+ // lookups when `ResolveOpts.postcodeContainmentCoherence` is on. Strictly beneath the #741 postal-city
560
+ // short-circuit above — an exact (name, postcode) hit IS the answer and outranks any re-rank — and after
561
+ // the region-scope fallback, so it sees the final row set. Rows within the gate sort by distance first;
562
+ // the out-gate tail keeps its original population-first order. No in-gate row, or no postcode row in
563
+ // the candidate table → unchanged (byte-identical to the flag-off path).
564
+ if (
565
+ query.postcode &&
566
+ query.postcodeContainmentCoherence === true &&
567
+ this.#wantsLocality(query.placetype) &&
568
+ rows.length > 1
569
+ ) {
570
+ const anchor = this.#postcodeAnchor(query.postcode, query.country)
571
+
572
+ if (anchor) {
573
+ const inGate: Array<{ row: RankedRow<CandidateRow>; distanceKm: number }> = []
574
+ const outGate: RankedRow<CandidateRow>[] = []
575
+
576
+ for (const row of rows) {
577
+ const distanceKm = haversineKm(anchor.lat, anchor.lon, Number(row.latitude), Number(row.longitude))
578
+
579
+ if (distanceKm <= POSTCODE_CONTAINMENT_GATE_KM) {
580
+ inGate.push({ row, distanceKm })
581
+ } else {
582
+ outGate.push(row)
583
+ }
584
+ }
585
+
586
+ if (inGate.length) {
587
+ // oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
588
+ inGate.sort((a, b) => a.distanceKm - b.distanceKm)
589
+ rows = [...inGate.map(({ row }) => row), ...outGate]
590
+ }
591
+ }
592
+ }
593
+
468
594
  const candidates = rows.map((row): PlaceCandidate => {
469
595
  const hasBbox = row.min_lat != null && row.max_lat != null && row.min_lon != null && row.max_lon != null
470
596
 
@@ -489,8 +615,32 @@ export class WOFCandidateTableLookup implements PlaceLookup {
489
615
  // Every candidate row IS an exact normalized-name (or alias/abbrev) match — the cascade's exact tier
490
616
  // accepts alias-exact hits ("New York City" → New York) the same as canonical — EXCEPT a cross-country
491
617
  // alias that lost the bounded contest to a same-key primary (`demoted`): it drops to the partial tier so
492
- // the walk's country posterior can't cross back over the primary (see `RankedRow.demoted`).
493
- exactMatch: !row.demoted,
618
+ // the walk's country posterior can't cross back over the primary (see `RankedRow.demoted`) — and
619
+ // EXCEPT a row the typo-corrector produced (`fuzzy`), which by definition answers a name the
620
+ // gazetteer does not carry (see `RankedRow.fuzzy`).
621
+ exactMatch: !row.demoted && !row.fuzzy,
622
+ // The two-score split's carry (ROAD_TO_V9 §2). `referential` names the prominence this
623
+ // backend has always ordered by — `neg_rank` IS `-log10(population + 1)`, so the score and
624
+ // the sort key are two readings of the same number.
625
+ ...(row.population === null || row.population <= 0
626
+ ? {}
627
+ : { population: row.population, referential: referentialFromPopulation(row.population) }),
628
+ // #28: the fame prior, from the `importance` column the candidate build joins in. Emitted ONLY
629
+ // when the artifact measured this place — an absent field is what `rankByImportance` reads as
630
+ // "does not participate", and a 0 would be a claim nobody made (meaning-of-zero).
631
+ //
632
+ // The field name matches the column because they hold the same thing: the score source's
633
+ // BLENDED prior — the concordance's encyclopedia-derived channel where a concordance matched, a
634
+ // population-derived proxy everywhere else. It is NOT the strict `encyclopedic` channel
635
+ // `place-importance-schema.ts` defines, and it deliberately does not land in that field: the
636
+ // strict channel was measured on 2026-08-10 and covers eleven countries, none of them CA/AU/RU,
637
+ // which makes it inert on three of the four homonym contests the prior exists to settle.
638
+ // `PlaceCandidate.encyclopedic` stays reserved for a strict-channel source (the FTS backend's
639
+ // clauses are strict and today emit NULL for everything — no shipped admin DB has the split
640
+ // table at all). See `candidate-schema.ts` → {@link CandidateTable.importance}.
641
+ ...(typeof row.importance === "number" && Number.isFinite(row.importance)
642
+ ? { importance: row.importance }
643
+ : {}),
494
644
  ...(hasBbox
495
645
  ? {
496
646
  bbox: {
@@ -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",
@@ -11,7 +11,7 @@
11
11
  * promote-gate verdicts + the measured hard-resolve rates that used to live in a code comment on
12
12
  * `HARD_PLACE_COUNTRY_SAFELIST`. Presence = measured; `hard_filter_safe = 0` = measured and
13
13
  * FAILED the gate (FI 69.5%, PL 77.8%) — distinguishable from a country never measured at all
14
- * (the meaning-of-zero rule, `docs/articles/plan/reference/layer-contract.mdx`).
14
+ * (the meaning-of-zero rule, `docs/engineering/reference/layer-contract.mdx`).
15
15
  * - `country_bbox`: the coarse guard-B plausibility boxes that used to live in
16
16
  * `resolver/plausibility.ts`'s `COUNTRY_BBOX`. An absent row fails open (never trips the guard),
17
17
  * exactly like an absent key in the constant.
@@ -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[] {