@mailwoman/resolver-wof-sqlite 9.0.0 → 9.2.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 (297) hide show
  1. package/README.md +28 -9
  2. package/address-point-interpolation.ts +18 -8
  3. package/address-point-schema.ts +18 -6
  4. package/address-point.ts +111 -18
  5. package/ancestry.ts +9 -6
  6. package/build-candidate.ts +287 -157
  7. package/build-slim.ts +3 -3
  8. package/candidate/alias-bags.ts +54 -0
  9. package/candidate/ancestors-sidecar.ts +206 -0
  10. package/candidate/country-display-names.ts +79 -0
  11. package/candidate/name-roles.ts +237 -0
  12. package/candidate/own-name.ts +146 -0
  13. package/candidate/place-attrs.ts +44 -0
  14. package/candidate/shard-fold.ts +137 -0
  15. package/candidate-ancestors-schema.ts +195 -0
  16. package/candidate-fts.ts +4 -2
  17. package/candidate-importance.ts +228 -0
  18. package/candidate-lookup.ts +564 -174
  19. package/candidate-schema.ts +60 -3
  20. package/candidate-scoring.ts +268 -0
  21. package/capital-schema.ts +90 -0
  22. package/capitals.ts +148 -0
  23. package/coincident-roles.ts +69 -10
  24. package/convention-schema.ts +72 -0
  25. package/convention.ts +2 -2
  26. package/coverage-manifest-schema.ts +7 -7
  27. package/currency-backfill.ts +249 -0
  28. package/exact-match.ts +104 -0
  29. package/fst-autocomplete.ts +105 -122
  30. package/fst-builder.ts +39 -47
  31. package/fst-deserialize-web.ts +43 -7
  32. package/fst-freshness.ts +2 -2
  33. package/fst-serialize.ts +68 -12
  34. package/fst-types.ts +35 -1
  35. package/fts-query.ts +1 -1
  36. package/fts.ts +16 -4
  37. package/geonames-postal.ts +2 -2
  38. package/index.ts +26 -14
  39. package/interpolation.ts +113 -19
  40. package/lookup.ts +118 -560
  41. package/name-score.ts +6 -4
  42. package/out/address-point-interpolation.d.ts.map +1 -1
  43. package/out/address-point-interpolation.js +13 -7
  44. package/out/address-point-interpolation.js.map +1 -1
  45. package/out/address-point-schema.d.ts +16 -6
  46. package/out/address-point-schema.d.ts.map +1 -1
  47. package/out/address-point-schema.js.map +1 -1
  48. package/out/address-point.d.ts.map +1 -1
  49. package/out/address-point.js +70 -14
  50. package/out/address-point.js.map +1 -1
  51. package/out/ancestry.d.ts +2 -2
  52. package/out/ancestry.d.ts.map +1 -1
  53. package/out/ancestry.js +5 -6
  54. package/out/ancestry.js.map +1 -1
  55. package/out/build-candidate.d.ts +108 -0
  56. package/out/build-candidate.d.ts.map +1 -1
  57. package/out/build-candidate.js +151 -120
  58. package/out/build-candidate.js.map +1 -1
  59. package/out/build-slim.d.ts +1 -1
  60. package/out/build-slim.js +3 -3
  61. package/out/build-slim.js.map +1 -1
  62. package/out/candidate/alias-bags.d.ts +17 -0
  63. package/out/candidate/alias-bags.d.ts.map +1 -0
  64. package/out/candidate/alias-bags.js +39 -0
  65. package/out/candidate/alias-bags.js.map +1 -0
  66. package/out/candidate/ancestors-sidecar.d.ts +33 -0
  67. package/out/candidate/ancestors-sidecar.d.ts.map +1 -0
  68. package/out/candidate/ancestors-sidecar.js +140 -0
  69. package/out/candidate/ancestors-sidecar.js.map +1 -0
  70. package/out/candidate/country-display-names.d.ts +35 -0
  71. package/out/candidate/country-display-names.d.ts.map +1 -0
  72. package/out/candidate/country-display-names.js +59 -0
  73. package/out/candidate/country-display-names.js.map +1 -0
  74. package/out/candidate/name-roles.d.ts +55 -0
  75. package/out/candidate/name-roles.d.ts.map +1 -0
  76. package/out/candidate/name-roles.js +165 -0
  77. package/out/candidate/name-roles.js.map +1 -0
  78. package/out/candidate/own-name.d.ts +50 -0
  79. package/out/candidate/own-name.d.ts.map +1 -0
  80. package/out/candidate/own-name.js +132 -0
  81. package/out/candidate/own-name.js.map +1 -0
  82. package/out/candidate/place-attrs.d.ts +43 -0
  83. package/out/candidate/place-attrs.d.ts.map +1 -0
  84. package/out/candidate/place-attrs.js +15 -0
  85. package/out/candidate/place-attrs.js.map +1 -0
  86. package/out/candidate/shard-fold.d.ts +31 -0
  87. package/out/candidate/shard-fold.d.ts.map +1 -0
  88. package/out/candidate/shard-fold.js +104 -0
  89. package/out/candidate/shard-fold.js.map +1 -0
  90. package/out/candidate-ancestors-schema.d.ts +150 -0
  91. package/out/candidate-ancestors-schema.d.ts.map +1 -0
  92. package/out/candidate-ancestors-schema.js +123 -0
  93. package/out/candidate-ancestors-schema.js.map +1 -0
  94. package/out/candidate-fts.d.ts +4 -2
  95. package/out/candidate-fts.d.ts.map +1 -1
  96. package/out/candidate-fts.js +4 -2
  97. package/out/candidate-fts.js.map +1 -1
  98. package/out/candidate-importance.d.ts +132 -0
  99. package/out/candidate-importance.d.ts.map +1 -0
  100. package/out/candidate-importance.js +174 -0
  101. package/out/candidate-importance.js.map +1 -0
  102. package/out/candidate-lookup.d.ts +22 -37
  103. package/out/candidate-lookup.d.ts.map +1 -1
  104. package/out/candidate-lookup.js +446 -132
  105. package/out/candidate-lookup.js.map +1 -1
  106. package/out/candidate-schema.d.ts +52 -4
  107. package/out/candidate-schema.d.ts.map +1 -1
  108. package/out/candidate-schema.js +8 -0
  109. package/out/candidate-schema.js.map +1 -1
  110. package/out/candidate-scoring.d.ts +34 -0
  111. package/out/candidate-scoring.d.ts.map +1 -0
  112. package/out/candidate-scoring.js +200 -0
  113. package/out/candidate-scoring.js.map +1 -0
  114. package/out/capital-schema.d.ts +51 -0
  115. package/out/capital-schema.d.ts.map +1 -0
  116. package/out/capital-schema.js +63 -0
  117. package/out/capital-schema.js.map +1 -0
  118. package/out/capitals.d.ts +69 -0
  119. package/out/capitals.d.ts.map +1 -0
  120. package/out/capitals.js +98 -0
  121. package/out/capitals.js.map +1 -0
  122. package/out/coincident-roles.d.ts +7 -0
  123. package/out/coincident-roles.d.ts.map +1 -1
  124. package/out/coincident-roles.js +42 -8
  125. package/out/coincident-roles.js.map +1 -1
  126. package/out/convention-schema.d.ts +51 -0
  127. package/out/convention-schema.d.ts.map +1 -0
  128. package/out/convention-schema.js +34 -0
  129. package/out/convention-schema.js.map +1 -0
  130. package/out/convention.d.ts +1 -1
  131. package/out/convention.js +2 -2
  132. package/out/coverage-manifest-schema.js +3 -7
  133. package/out/coverage-manifest-schema.js.map +1 -1
  134. package/out/currency-backfill.d.ts +46 -0
  135. package/out/currency-backfill.d.ts.map +1 -0
  136. package/out/currency-backfill.js +180 -0
  137. package/out/currency-backfill.js.map +1 -0
  138. package/out/exact-match.d.ts +25 -0
  139. package/out/exact-match.d.ts.map +1 -0
  140. package/out/exact-match.js +89 -0
  141. package/out/exact-match.js.map +1 -0
  142. package/out/fst-autocomplete.d.ts +24 -14
  143. package/out/fst-autocomplete.d.ts.map +1 -1
  144. package/out/fst-autocomplete.js +84 -100
  145. package/out/fst-autocomplete.js.map +1 -1
  146. package/out/fst-builder.d.ts.map +1 -1
  147. package/out/fst-builder.js +32 -40
  148. package/out/fst-builder.js.map +1 -1
  149. package/out/fst-deserialize-web.d.ts.map +1 -1
  150. package/out/fst-deserialize-web.js +36 -7
  151. package/out/fst-deserialize-web.js.map +1 -1
  152. package/out/fst-freshness.d.ts +2 -2
  153. package/out/fst-freshness.js +2 -2
  154. package/out/fst-serialize.d.ts +14 -4
  155. package/out/fst-serialize.d.ts.map +1 -1
  156. package/out/fst-serialize.js +60 -12
  157. package/out/fst-serialize.js.map +1 -1
  158. package/out/fst-types.d.ts +35 -1
  159. package/out/fst-types.d.ts.map +1 -1
  160. package/out/fts-query.js +1 -1
  161. package/out/fts-query.js.map +1 -1
  162. package/out/fts.d.ts +15 -4
  163. package/out/fts.d.ts.map +1 -1
  164. package/out/fts.js +15 -4
  165. package/out/fts.js.map +1 -1
  166. package/out/geonames-postal.d.ts +2 -2
  167. package/out/geonames-postal.js +2 -2
  168. package/out/index.d.ts +4 -2
  169. package/out/index.d.ts.map +1 -1
  170. package/out/index.js +3 -2
  171. package/out/index.js.map +1 -1
  172. package/out/interpolation.d.ts +8 -0
  173. package/out/interpolation.d.ts.map +1 -1
  174. package/out/interpolation.js +91 -19
  175. package/out/interpolation.js.map +1 -1
  176. package/out/lookup.d.ts +4 -5
  177. package/out/lookup.d.ts.map +1 -1
  178. package/out/lookup.js +102 -444
  179. package/out/lookup.js.map +1 -1
  180. package/out/name-score.d.ts +0 -10
  181. package/out/name-score.d.ts.map +1 -1
  182. package/out/name-score.js +6 -4
  183. package/out/name-score.js.map +1 -1
  184. package/out/place-importance-schema.d.ts +226 -0
  185. package/out/place-importance-schema.d.ts.map +1 -0
  186. package/out/place-importance-schema.js +288 -0
  187. package/out/place-importance-schema.js.map +1 -0
  188. package/out/poi-lookup.d.ts +1 -1
  189. package/out/poi-lookup.d.ts.map +1 -1
  190. package/out/poi-lookup.js +12 -13
  191. package/out/poi-lookup.js.map +1 -1
  192. package/out/poi-schema.d.ts +7 -3
  193. package/out/poi-schema.d.ts.map +1 -1
  194. package/out/poi-schema.js.map +1 -1
  195. package/out/polygon-schema.d.ts +37 -0
  196. package/out/polygon-schema.d.ts.map +1 -0
  197. package/out/polygon-schema.js +23 -0
  198. package/out/polygon-schema.js.map +1 -0
  199. package/out/postal-city-alias-lookup.d.ts +1 -1
  200. package/out/postal-city-alias-lookup.js +1 -1
  201. package/out/postal-city-candidate-schema.d.ts +2 -1
  202. package/out/postal-city-candidate-schema.d.ts.map +1 -1
  203. package/out/postal-city-candidate-schema.js.map +1 -1
  204. package/out/postcode-point-lookup.d.ts +1 -1
  205. package/out/postcode-point-lookup.js +1 -1
  206. package/out/primary-preference.d.ts +125 -0
  207. package/out/primary-preference.d.ts.map +1 -0
  208. package/out/primary-preference.js +138 -0
  209. package/out/primary-preference.js.map +1 -0
  210. package/out/proximity-rerank.d.ts +77 -0
  211. package/out/proximity-rerank.d.ts.map +1 -0
  212. package/out/proximity-rerank.js +86 -0
  213. package/out/proximity-rerank.js.map +1 -0
  214. package/out/region-keys.d.ts +47 -0
  215. package/out/region-keys.d.ts.map +1 -0
  216. package/out/region-keys.js +121 -0
  217. package/out/region-keys.js.map +1 -0
  218. package/out/reverse.d.ts.map +1 -1
  219. package/out/reverse.js +6 -9
  220. package/out/reverse.js.map +1 -1
  221. package/out/schema.d.ts +1 -1
  222. package/out/search-fetch.d.ts +57 -0
  223. package/out/search-fetch.d.ts.map +1 -0
  224. package/out/search-fetch.js +183 -0
  225. package/out/search-fetch.js.map +1 -0
  226. package/out/sharding.d.ts +3 -3
  227. package/out/sharding.js +1 -1
  228. package/out/sqlite-convention-source.d.ts +1 -1
  229. package/out/sqlite-convention-source.js +1 -1
  230. package/out/sqlite-utils.d.ts +31 -1
  231. package/out/sqlite-utils.d.ts.map +1 -1
  232. package/out/sqlite-utils.js +38 -0
  233. package/out/sqlite-utils.js.map +1 -1
  234. package/out/street-centroid-schema.d.ts +7 -2
  235. package/out/street-centroid-schema.d.ts.map +1 -1
  236. package/out/street-centroid-schema.js.map +1 -1
  237. package/out/street-centroid.d.ts.map +1 -1
  238. package/out/street-centroid.js +7 -7
  239. package/out/street-centroid.js.map +1 -1
  240. package/out/street-morphology-fst-builder.d.ts.map +1 -1
  241. package/out/street-morphology-fst-builder.js +5 -4
  242. package/out/street-morphology-fst-builder.js.map +1 -1
  243. package/out/street-normalize.d.ts +83 -9
  244. package/out/street-normalize.d.ts.map +1 -1
  245. package/out/street-normalize.js +177 -10
  246. package/out/street-normalize.js.map +1 -1
  247. package/out/street-segment-schema.d.ts +6 -2
  248. package/out/street-segment-schema.d.ts.map +1 -1
  249. package/out/street-segment-schema.js.map +1 -1
  250. package/out/types.d.ts +74 -1
  251. package/out/types.d.ts.map +1 -1
  252. package/out/unified-schema.d.ts +1 -1
  253. package/out/unified-schema.js +1 -1
  254. package/out/uprn-lookup.d.ts +85 -0
  255. package/out/uprn-lookup.d.ts.map +1 -0
  256. package/out/uprn-lookup.js +152 -0
  257. package/out/uprn-lookup.js.map +1 -0
  258. package/out/uprn-schema.d.ts +93 -0
  259. package/out/uprn-schema.d.ts.map +1 -0
  260. package/out/uprn-schema.js +78 -0
  261. package/out/uprn-schema.js.map +1 -0
  262. package/out/weights-overlay-linker.d.ts +141 -0
  263. package/out/weights-overlay-linker.d.ts.map +1 -0
  264. package/out/weights-overlay-linker.js +259 -0
  265. package/out/weights-overlay-linker.js.map +1 -0
  266. package/package.json +296 -16
  267. package/place-importance-schema.ts +402 -0
  268. package/poi-lookup.ts +12 -13
  269. package/poi-schema.ts +8 -3
  270. package/polygon-schema.ts +47 -0
  271. package/postal-city-alias-lookup.ts +1 -1
  272. package/postal-city-candidate-schema.ts +3 -1
  273. package/postcode-point-lookup.ts +1 -1
  274. package/primary-preference.ts +207 -0
  275. package/proximity-rerank.ts +120 -0
  276. package/region-keys.ts +144 -0
  277. package/reverse.ts +17 -16
  278. package/schema.ts +1 -1
  279. package/search-fetch.ts +256 -0
  280. package/sharding.ts +3 -3
  281. package/sqlite-convention-source.ts +1 -1
  282. package/sqlite-utils.ts +63 -1
  283. package/street-centroid-schema.ts +8 -2
  284. package/street-centroid.ts +13 -8
  285. package/street-morphology-fst-builder.ts +5 -4
  286. package/street-normalize.ts +254 -24
  287. package/street-segment-schema.ts +7 -2
  288. package/types.ts +74 -1
  289. package/unified-schema.ts +1 -1
  290. package/uprn-lookup.ts +210 -0
  291. package/uprn-schema.ts +124 -0
  292. package/weights-overlay-linker.ts +377 -0
  293. package/geo.ts +0 -121
  294. package/out/geo.d.ts +0 -74
  295. package/out/geo.d.ts.map +0 -1
  296. package/out/geo.js +0 -71
  297. package/out/geo.js.map +0 -1
@@ -17,6 +17,10 @@
17
17
 
18
18
  import { sql, type Kysely } from "kysely"
19
19
 
20
+ import type { CandidateAncestorsDatabase } from "./candidate-ancestors-schema.ts"
21
+ import type { CapitalTable } from "./capital-schema.ts"
22
+ import type { NameKey } from "./street-normalize.ts"
23
+
20
24
  /**
21
25
  * One candidate row. `name_key` + the four small int keys + `neg_rank` + `spr_id` form the clustered primary key; the
22
26
  * rest is denormalized so a resolve is one probe (no join to `spr`). Coordinates + bbox + name are nullable at the SQL
@@ -26,7 +30,7 @@ export interface CandidateTable {
26
30
  /**
27
31
  * The shared {@link normalizeLocalityForKey} of the name/alias — the probe key.
28
32
  */
29
- name_key: string
33
+ name_key: NameKey
30
34
  /**
31
35
  * Small int from {@link CountryCodeTable} (shrinks the clustered key).
32
36
  */
@@ -59,6 +63,45 @@ export interface CandidateTable {
59
63
  * 1 when the row is the place's canonical name (vs an alias/abbrev).
60
64
  */
61
65
  is_primary: number | null
66
+ /**
67
+ * Blended place importance in [0, 1] — the toponym-fame prior the bare-city-name class is decided on (#28). NULL
68
+ * means the score source had no row for this place: UNMEASURED, never "an importance of zero" (meaning-of-zero).
69
+ * Constant across every row of one place — primary, alias and abbrev alike — because it is a property of the PLACE,
70
+ * not of the name that reached it, which is what lets a bare `Moscow` inherit Москва's score through the alias row.
71
+ *
72
+ * **THIS IS THE PRE-SPLIT CONFLATION, AND THE NAME SAYS SO.** It is `place_importance.importance` copied verbatim
73
+ * from the score source — the bounded blend `place-importance-schema.ts`'s `blendImportance` writes (the
74
+ * concordance's encyclopedia-derived channel clamped around a population-derived base); that module calls the column
75
+ * DEPRECATED. It is NOT the split `encyclopedic` channel, and the two must not be conflated in a future build:
76
+ * writing the split value here instead was measured on 2026-08-10 and makes the ranking key INERT on three of the
77
+ * four rows it exists to fix. The reason is coverage, not principle — the encyclopedia-concordance join in
78
+ * `admin-global-priority-importance.db` reaches 133,888 of 702,709 scored places and only eleven countries
79
+ * (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
80
+ * AU carry the population fallback and nothing else. Under the strict split those three become unmeasured, the
81
+ * consumer's positive-evidence-only rule leaves them exactly where population put them (first), and the famous GB
82
+ * bearer can never overtake them. The conflated column is the only one on which every bearer of a name is scored on a
83
+ * single comparable scale, which is the precondition for comparing them at all.
84
+ *
85
+ * So a consumer reads this as "fame, with population standing in where fame was never measured" — the legacy blended
86
+ * semantics — and NOT as "this place has an encyclopedia entry of this importance". When the score source grows a
87
+ * real `encyclopedic` column for every country, add a SECOND column rather than redefining this one.
88
+ */
89
+ importance: number | null
90
+ /**
91
+ * The NAME'S detected role on this row, or NULL (#1730). Two build-time detectors stamp `is_primary = 0` rows only:
92
+ *
93
+ * - `'abbr'` — provenance-based: the surface is a WOF `variant` name in one of the place's country's official languages
94
+ * (or English) — the #936 signal, measured at a 13× key-collision rate vs preferred names.
95
+ * - `'gloss'` — anomaly-based: the row belongs to a place whose key count crosses the gloss threshold with a non-admin
96
+ * placetype and NO measured prominence (population absent AND importance unmeasured) — the translation-gloss
97
+ * fingerprint (#1730's sweep; `Poisson` → a US fish-name place). Provenance CANNOT separate a gloss from an exonym
98
+ * (WOF imported both as `x_preferred`), which is why this detector is an anomaly test and stamps only the certain
99
+ * core.
100
+ *
101
+ * NULL = no role detected. The column is WRITE-ONLY in this build generation: no ranking consumer reads it — a rank
102
+ * penalty is its own future, D-rule-gated step with the `gloss_key` board as tripwire.
103
+ */
104
+ name_role: string | null
62
105
  }
63
106
 
64
107
  /**
@@ -78,9 +121,11 @@ export interface PlacetypeCodeTable {
78
121
  }
79
122
 
80
123
  /**
81
- * The candidate database schema for `new DatabaseClient<CandidateDatabase>(...)`.
124
+ * The candidate database schema for `new DatabaseClient<CandidateDatabase>(...)`. Extends the ancestors sidecar
125
+ * (`candidate_ancestor` + `candidate_interval` — see candidate-ancestors-schema.ts for the encoding decision), so the
126
+ * builder's one typed client covers every table in the artifact.
82
127
  */
83
- export interface CandidateDatabase {
128
+ export interface CandidateDatabase extends CandidateAncestorsDatabase {
84
129
  /**
85
130
  * The clustered `WITHOUT ROWID` lookup table the reader probes.
86
131
  */
@@ -91,6 +136,10 @@ export interface CandidateDatabase {
91
136
  cand_stage: CandidateTable
92
137
  country_codes: CountryCodeTable
93
138
  placetype_codes: PlacetypeCodeTable
139
+ /**
140
+ * The capital-status reference carried in-artifact (#1880's distribution home) — see capital-schema.ts.
141
+ */
142
+ capital: CapitalTable
94
143
  }
95
144
 
96
145
  /**
@@ -114,6 +163,10 @@ export const CANDIDATE_COLUMNS = [
114
163
  "max_lon",
115
164
  "population",
116
165
  "is_primary",
166
+ // Appended, never inserted mid-list: the first six entries ARE the clustered primary key, and the
167
+ // positional `INSERT INTO cand_stage VALUES (…)` in the builder binds by position.
168
+ "importance",
169
+ "name_role",
117
170
  ] as const
118
171
 
119
172
  /**
@@ -151,6 +204,8 @@ export async function createCandidateStagingTables(db: Kysely<CandidateDatabase>
151
204
  .addColumn("max_lon", "real")
152
205
  .addColumn("population", "integer")
153
206
  .addColumn("is_primary", "integer")
207
+ .addColumn("importance", "real")
208
+ .addColumn("name_role", "text")
154
209
  .execute()
155
210
  }
156
211
 
@@ -176,6 +231,8 @@ export async function createCandidateTable(db: Kysely<CandidateDatabase>): Promi
176
231
  .addColumn("max_lon", "real")
177
232
  .addColumn("population", "integer")
178
233
  .addColumn("is_primary", "integer")
234
+ .addColumn("importance", "real")
235
+ .addColumn("name_role", "text")
179
236
  .addPrimaryKeyConstraint("candidate_pk", [
180
237
  "name_key",
181
238
  "country_id",
@@ -0,0 +1,268 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * How a raw `place_search` row becomes a scored `PlaceCandidate`, and how the resulting pool is
7
+ * ordered — the weighted-sum score and the exact-match tiering that ranks over it.
8
+ */
9
+
10
+ import type { DatabaseSync } from "node:sqlite"
11
+
12
+ import { haversineKm } from "@mailwoman/spatial"
13
+
14
+ import { exactMatchIDs, officialNameIDs } from "./exact-match.ts"
15
+ import { compareReferential, referentialFromPopulation } from "./place-importance-schema.ts"
16
+ import type { RankingWeights } from "./ranking-weights.ts"
17
+ import type { RawSearchRow } from "./search-fetch.ts"
18
+ import type { FindPlaceQuery, PlaceCandidate, WOFPlacetype } from "./types.ts"
19
+
20
+ /**
21
+ * Score one raw FTS row into a `PlaceCandidate`: the weighted sum over the negated BM25 baseline, the placetype /
22
+ * country / parent boosts, the length penalty, the proximity and population terms, and the carried fields (referential,
23
+ * encyclopedic, bbox) consumers read. `queryLen` is the query text's length, hoisted out of the per-row loop.
24
+ */
25
+ export function candidateFromSearchRow(
26
+ row: RawSearchRow,
27
+ context: {
28
+ query: FindPlaceQuery
29
+ placetypes: WOFPlacetype[] | null
30
+ queryLen: number
31
+ weights: RankingWeights
32
+ }
33
+ ): PlaceCandidate {
34
+ const { query, placetypes, queryLen, weights } = context
35
+
36
+ // SQLite's bm25() returns a lower-is-better score (negative for matches). Negate so we
37
+ // start from a higher-is-better baseline.
38
+ let score = -row.rank
39
+
40
+ if (placetypes && placetypes.length && placetypes.includes(row.placetype as WOFPlacetype)) {
41
+ score += weights.placetypeMatchBoost
42
+ }
43
+
44
+ if (!placetypes && row.placetype === "locality") {
45
+ score += weights.localityImplicitBoost
46
+ }
47
+
48
+ if (query.country && row.country === query.country) {
49
+ score += weights.countryMatchBoost
50
+ }
51
+
52
+ if (query.parentID !== undefined) {
53
+ score += row.parent_id === query.parentID ? weights.directChildBoost : weights.descendantBoost
54
+ }
55
+
56
+ const extraLen = Math.max(0, row.name.length - queryLen - 3)
57
+ score -= (weights.lengthPenaltyWeight * extraLen) / 10
58
+
59
+ // Proximity boost: only applied when the query carries `near` AND the candidate has real
60
+ // coordinates. The formula decays smoothly with distance so close-but-not-exact hits
61
+ // still benefit; tunable via proximityBoost + proximityScaleKm.
62
+ let distanceKm: number | undefined
63
+ // The best decayed-distance term over `near` + every `bias` point (each point's term is
64
+ // scaled by its weight; the MAX wins — a candidate near ANY hint is "nearby"). Carried
65
+ // into the exact-tier prominence sort below when hints are present.
66
+ let proximityTerm = 0
67
+
68
+ if (row.lat !== null && row.lon !== null && !(row.lat === 0 && row.lon === 0)) {
69
+ const hints: Array<{ lat: number; lon: number; weight: number }> = []
70
+
71
+ if (query.near) {
72
+ hints.push({ lat: query.near.lat, lon: query.near.lon, weight: 1 })
73
+ }
74
+
75
+ for (const b of query.bias ?? []) {
76
+ hints.push({ lat: b.lat, lon: b.lon, weight: b.weight ?? 1 })
77
+ }
78
+
79
+ let scoreTerm = 0
80
+
81
+ for (const h of hints) {
82
+ const d = haversineKm(h.lat, h.lon, row.lat, row.lon)
83
+ const decay = h.weight / (1 + d / weights.proximityScaleKm)
84
+ const prom = decay * weights.biasBoost
85
+
86
+ if (prom > proximityTerm) {
87
+ proximityTerm = prom
88
+ distanceKm = d
89
+ scoreTerm = decay * weights.proximityBoost
90
+ }
91
+ }
92
+
93
+ score += scoreTerm
94
+ }
95
+
96
+ // Population boost: capped at `populationBoost` magnitude at `10^populationScaleLog10`
97
+ // people. Missing population → no contribution. Never penalizes.
98
+ let popTerm = 0
99
+
100
+ if (row.population !== null && row.population > 0 && weights.populationScaleLog10 > 0) {
101
+ const popLog = Math.log10(1 + row.population)
102
+ const popFraction = Math.min(1, popLog / weights.populationScaleLog10)
103
+ popTerm = weights.populationBoost * popFraction
104
+ score += popTerm
105
+ }
106
+
107
+ // Combined prominence for the exact-tier sort when proximity hints are present: population
108
+ // and nearness in the SAME additive units, so the map view / the user's location can win a
109
+ // cross-country postcode tie without a hard filter.
110
+ const prominence = popTerm + proximityTerm
111
+
112
+ const candidate: PlaceCandidate = {
113
+ id: row.id,
114
+ prominence,
115
+ name: row.name,
116
+ placetype: row.placetype as WOFPlacetype,
117
+ country: row.country ?? "",
118
+ lat: row.lat ?? 0,
119
+ lon: row.lon ?? 0,
120
+ parent_id: row.parent_id ?? undefined,
121
+ score,
122
+ }
123
+
124
+ if (distanceKm !== undefined) {
125
+ candidate.distanceKm = distanceKm
126
+ }
127
+
128
+ if (row.population !== null && row.population > 0) {
129
+ candidate.population = row.population
130
+ // The named ranking key (ROAD_TO_V9 §2). DERIVED, not stored — a pure function of the
131
+ // population already on this row, so it cannot drift from what the ordering uses.
132
+ candidate.referential = referentialFromPopulation(row.population)
133
+ }
134
+
135
+ // Carried for consumers (annotations / API surfaces). No ranking site reads it.
136
+ if (row.encyclopedic !== null) {
137
+ candidate.encyclopedic = row.encyclopedic
138
+ }
139
+
140
+ // Candidate bbox — parity with the WASM lookup (resolver-wof-wasm/lookup.ts), whose
141
+ // consumers (the demo cascade's region constraint) read it. Without this the Node
142
+ // backend's region→bbox constraint is dead and disambiguation falls to population
143
+ // ranking (the Springfield-IL→MO failure the #524 smoke eval caught).
144
+ if (row.min_latitude != null && row.max_latitude != null && row.min_longitude != null && row.max_longitude != null) {
145
+ candidate.bbox = {
146
+ minLat: row.min_latitude,
147
+ maxLat: row.max_latitude,
148
+ minLon: row.min_longitude,
149
+ maxLon: row.max_longitude,
150
+ }
151
+ }
152
+
153
+ return candidate
154
+ }
155
+
156
+ /**
157
+ * Order `candidates` IN PLACE — the exact-match tier first when the shard can answer the name probes, otherwise plain
158
+ * weighted-score order. Every candidate is stamped with its `exactMatch` flag on the way through.
159
+ */
160
+ export function rankCandidates(
161
+ candidates: PlaceCandidate[],
162
+ options: {
163
+ db: DatabaseSync
164
+ schemaName: string
165
+ query: FindPlaceQuery
166
+ weights: RankingWeights
167
+ }
168
+ ): void {
169
+ const { db, schemaName, query, weights } = options
170
+
171
+ // Exact-match tiering: a candidate whose name OR any alias equals the query text (case-folded)
172
+ // ranks above any partial match, with the weighted-sum score (incl. population) breaking ties
173
+ // WITHIN a tier. See the RankingWeights.exactMatchTiering docstring for why this aligns the
174
+ // population prior rather than overriding it. One cheap indexed lookup over the candidate ids.
175
+ // Runs even for a SINGLE candidate so `exactMatch` is stamped consistently (parity with the
176
+ // WASM lookup) — a sole alias hit ("New York City" → New York) must still carry the flag the
177
+ // demo cascade / #369 re-rank read.
178
+ if (weights.exactMatchTiering && candidates.length) {
179
+ const exactIDs = exactMatchIDs(
180
+ db,
181
+ schemaName,
182
+ candidates.map((c) => c.id as number),
183
+ query.text
184
+ )
185
+
186
+ // Stamp the tier onto every candidate (not just when the tiering sort fires) so a downstream
187
+ // re-rank — #369's postcode-anchor country pin in `resolveTree` — can keep the country pin from
188
+ // crossing the exact/partial boundary ("ME" → Maine, not the more-populous Missouri).
189
+ for (const c of candidates) {
190
+ c.exactMatch = exactIDs.has(c.id as number)
191
+ }
192
+
193
+ if (exactIDs.size) {
194
+ // #905: WITHIN the exact tier, population is the PRIMARY key and the weighted score
195
+ // only breaks population ties. Exactness saturates text relevance, and the bm25
196
+ // residue inside `score` is length-noise (see the fetch-site comment), so letting it
197
+ // order the tier is what sent unscoped "Paris" to an Ohio township. The partial tier
198
+ // keeps score order — text relevance still means something there. This makes the
199
+ // exactMatchTiering docstring literal: match quality primary, prominence within.
200
+ //
201
+ // #912 sub-tier: a NAME-exact candidate (spr.name equals the query) outranks an
202
+ // ALIAS-exact one ('Paris' the place beats 'Paris Township' held via alias 'Paris').
203
+ // The place's own name is a stronger identity claim than an alias — aliases exist to
204
+ // widen recall, not to tie primaries. ME→Maine is untouched: 'ME' name-exact-matches
205
+ // nothing, so the alias sub-tier still decides there. Population orders within each
206
+ // sub-tier as before.
207
+ const norm = (v: string): string => v.toLowerCase().trim().replaceAll(/\s+/g, " ")
208
+ const needle = norm(query.text)
209
+
210
+ // #936 option 3: an OFFICIAL name (preferred form in an official language of the place's
211
+ // country, `names.official = 1`) counts as the place's own name for the sub-tier — "Åbo" is
212
+ // Turku's name, not merely its alias. Floor-gated on the holder's population (see the
213
+ // RankingWeights docstring for the measured 100k boundary). officialIDs ⊆ exactIDs by
214
+ // construction (official rows are names rows), so only the sub-tier KIND changes.
215
+ const officialIDs = weights.officialNameExact
216
+ ? officialNameIDs(
217
+ db,
218
+ schemaName,
219
+ candidates
220
+ .filter((c) => exactIDs.has(c.id as number) && (c.population ?? 0) >= weights.officialNameExactFloor)
221
+ .map((c) => c.id as number),
222
+ query.text
223
+ )
224
+ : undefined
225
+
226
+ const kind = (c: PlaceCandidate): number => {
227
+ if (!exactIDs.has(c.id as number)) return 0
228
+
229
+ if (norm(String(c.name ?? "")) === needle) return 2
230
+
231
+ return officialIDs?.has(c.id as number) ? 2 : 1
232
+ }
233
+
234
+ // With proximity hints (near/bias), prominence (population + nearness, same units)
235
+ // replaces raw population as the within-tier key — the 48026 rule: the map view or
236
+ // the user's location breaks a cross-country postcode tie. Without hints, REFERENTIAL
237
+ // ordering decides.
238
+ //
239
+ // ROAD_TO_V9 §2: this is the site that "orders namesakes", so it is the site that has to
240
+ // say what it orders by. `compareReferential` is referential DESC with raw population as
241
+ // the tiebreak, which is provably the SAME ORDER as the `(b.population ?? 0) - (a.population ?? 0)`
242
+ // it replaces — referential is strictly increasing in population below saturation and
243
+ // constant above it, and the tiebreak restores the order in the saturated tail. Measured
244
+ // zero-delta, not assumed: see `place-importance-schema.test.ts` and `resolver-referential-ranking.test.ts`.
245
+ // Encyclopedic importance is not, and must not become, an input here.
246
+ const hasHints = !!query.near || (query.bias?.length ?? 0) > 0
247
+
248
+ candidates.sort((a, b) => {
249
+ const ax = kind(a)
250
+ const bx = kind(b)
251
+
252
+ if (bx !== ax) return bx - ax
253
+
254
+ if (ax >= 1) {
255
+ if (hasHints) return (b.prominence ?? 0) - (a.prominence ?? 0) || b.score - a.score
256
+
257
+ return compareReferential(a, b) || b.score - a.score
258
+ }
259
+
260
+ return b.score - a.score
261
+ })
262
+
263
+ return
264
+ }
265
+ }
266
+
267
+ candidates.sort((a, b) => b.score - a.score)
268
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * The `capital` table (#1880's distribution home): the capital-status reference carried INSIDE
7
+ * `candidate.db`, so an npm consumer who pulled the artifact can run `capital_tier` without the
8
+ * repo's `data/gazetteer/capitals-v1.json` — which published packages do not ship. One row per
9
+ * reference entry (241 national capitals + 3,463 admin-1 seats at the 2026-08-24 build); the
10
+ * loader reads the WHOLE table once into a `CapitalIndex` at session construction, so there is no
11
+ * per-probe query and no index beyond the rowid.
12
+ *
13
+ * `keys` holds the entry's folded name set as a JSON array — the name-membership conjunct that
14
+ * keeps the coordinate radius from promoting a capital's same-name neighbours (`capitals.ts`).
15
+ */
16
+
17
+ import type { DatabaseSync } from "node:sqlite"
18
+
19
+ import { tryParsingJSON } from "@mailwoman/core/objects"
20
+ import type { Kysely } from "kysely"
21
+
22
+ import type { CapitalPoint } from "./capitals.ts"
23
+ import { hasTable } from "./sqlite-utils.ts"
24
+
25
+ /**
26
+ * The table name the builder writes and the reader probes — one word, singular, matching the artifact's other reference
27
+ * tables (`candidate`, `country_codes`).
28
+ */
29
+ export const CAPITAL_TABLE = "capital"
30
+
31
+ /**
32
+ * One reference entry as the artifact stores it. `level` is the reference vocabulary (`national` | `admin1`) kept as
33
+ * text — the reader validates on load rather than trusting bytes.
34
+ */
35
+ export interface CapitalTable {
36
+ country: string
37
+ latitude: number
38
+ longitude: number
39
+ level: string
40
+ /**
41
+ * JSON array of folded name keys (name + romanization + alternates).
42
+ */
43
+ keys: string
44
+ }
45
+
46
+ /**
47
+ * Create the table on a build in progress. Async because Kysely's schema-builder is; called from the candidate build's
48
+ * DDL phase alongside the other typed builders.
49
+ */
50
+ export async function createCapitalTable<DB extends { capital: CapitalTable }>(db: Kysely<DB>): Promise<void> {
51
+ await db.schema
52
+ .createTable(CAPITAL_TABLE)
53
+ .addColumn("country", "text", (c) => c.notNull())
54
+ .addColumn("latitude", "real", (c) => c.notNull())
55
+ .addColumn("longitude", "real", (c) => c.notNull())
56
+ .addColumn("level", "text", (c) => c.notNull())
57
+ .addColumn("keys", "text", (c) => c.notNull())
58
+ .execute()
59
+ }
60
+
61
+ /**
62
+ * Read the whole reference out of an artifact, or `null` when the artifact predates the table — the caller then falls
63
+ * back to its next source rather than treating an old artifact as "no capitals" (the meaning-of-zero rule: a missing
64
+ * table is UNMEASURED, an empty one is a finding).
65
+ */
66
+ export function readCapitalPoints(db: DatabaseSync): CapitalPoint[] | null {
67
+ if (!hasTable(db, CAPITAL_TABLE)) return null
68
+
69
+ const points: CapitalPoint[] = []
70
+
71
+ for (const row of db.prepare(`SELECT country, latitude, longitude, level, keys FROM ${CAPITAL_TABLE}`).iterate()) {
72
+ const level = String(row.level)
73
+
74
+ if (level !== "national" && level !== "admin1") continue
75
+
76
+ const keys = tryParsingJSON<string[]>(String(row.keys))
77
+
78
+ if (!Array.isArray(keys)) continue
79
+
80
+ points.push({
81
+ country: String(row.country),
82
+ latitude: Number(row.latitude),
83
+ longitude: Number(row.longitude),
84
+ level,
85
+ k: keys,
86
+ })
87
+ }
88
+
89
+ return points
90
+ }
package/capitals.ts ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * The CAPITAL-STATUS reference index (#1880) — answers, for one resolved candidate, "is this
7
+ * place the national capital or an admin-1 seat of its country?". The consumer is the resolver's
8
+ * bounded capital promotion (`@mailwoman/resolver`'s `promoteCapitals`, applied after the fame
9
+ * key on the bare-toponym class); this module only matches, it never ranks. PURE and
10
+ * platform-free (the #861 parity discipline).
11
+ *
12
+ * Matching is an IDENTITY test with three conjuncts: same country, within
13
+ * {@link CAPITAL_MATCH_RADIUS_KM} of the reference point, and the candidate's own folded name a
14
+ * member of the reference entry's folded name set (name + romanization + the source's alternate
15
+ * names, so exonym rows — "Vienna" for Wien — still match). All three are load-bearing. The
16
+ * iteration-1 board run matched on country + coordinate alone, and the 25 km radius promoted
17
+ * capital-ADJACENT namesakes instead of capitals: North Salt Lake beside the Utah seat, a Gujarat
18
+ * Indiranagar beside Gandhinagar, Via delle Parti beside Perugia. The name set is what makes the
19
+ * radius a centroid-drift allowance rather than a catchment.
20
+ */
21
+
22
+ import { haversineKm } from "@mailwoman/spatial"
23
+
24
+ import { normalizeLocalityForKey } from "./street-normalize.ts"
25
+
26
+ /**
27
+ * Capital status of one candidate: national capital, admin-1 seat, or neither. Numeric so the resolver's promotion can
28
+ * compare levels.
29
+ */
30
+ export const CAPITAL_LEVEL = {
31
+ none: 0,
32
+ admin1: 1,
33
+ national: 2,
34
+ } as const
35
+
36
+ export type CapitalLevel = (typeof CAPITAL_LEVEL)[keyof typeof CAPITAL_LEVEL]
37
+
38
+ /**
39
+ * How far a candidate row may sit from the reference point and still read as the same place — a centroid-convention
40
+ * allowance (GeoNames point vs WOF centroid on a metro-scale city), not a catchment: the name-membership conjunct is
41
+ * what excludes neighbours inside the radius.
42
+ */
43
+ export const CAPITAL_MATCH_RADIUS_KM = 25
44
+
45
+ /**
46
+ * One reference entry, as `data/gazetteer/capitals-v1.json` carries it (`entries[]`).
47
+ */
48
+ export interface CapitalPoint {
49
+ /**
50
+ * ISO alpha-2, uppercase.
51
+ */
52
+ country: string
53
+ latitude: number
54
+ longitude: number
55
+ level: "national" | "admin1"
56
+ /**
57
+ * Folded name keys (name + romanization + alternate names, `normalizeLocalityForKey` fold) — the membership set for
58
+ * the name conjunct.
59
+ */
60
+ k: string[]
61
+ }
62
+
63
+ const LEVEL_OF: Record<CapitalPoint["level"], CapitalLevel> = {
64
+ national: CAPITAL_LEVEL.national,
65
+ admin1: CAPITAL_LEVEL.admin1,
66
+ }
67
+
68
+ interface IndexedPoint {
69
+ latitude: number
70
+ longitude: number
71
+ level: CapitalLevel
72
+ keys: ReadonlySet<string>
73
+ }
74
+
75
+ /**
76
+ * Country-bucketed capital points with the three-conjunct identity probe. Construct from the parsed reference file's
77
+ * `entries` — the loader that reads the file off disk lives with the path owners (`mailwoman`'s resolver backend),
78
+ * keeping this module platform-free.
79
+ */
80
+ export class CapitalIndex {
81
+ readonly #byCountry = new Map<string, IndexedPoint[]>()
82
+
83
+ constructor(entries: Iterable<CapitalPoint>) {
84
+ for (const entry of entries) {
85
+ const country = entry.country.toUpperCase()
86
+
87
+ const point: IndexedPoint = {
88
+ latitude: entry.latitude,
89
+ longitude: entry.longitude,
90
+ level: LEVEL_OF[entry.level],
91
+ keys: new Set(entry.k),
92
+ }
93
+
94
+ const bucket = this.#byCountry.get(country)
95
+
96
+ if (bucket) {
97
+ bucket.push(point)
98
+ } else {
99
+ this.#byCountry.set(country, [point])
100
+ }
101
+ }
102
+ }
103
+
104
+ /**
105
+ * The highest capital level whose entry passes all three conjuncts for this place. `none` — never a throw — for a
106
+ * missing name, country, or coordinate, an unknown country, or no matching entry.
107
+ */
108
+ levelOfPlace(
109
+ name: string | null | undefined,
110
+ country: string | null | undefined,
111
+ latitude: number | null | undefined,
112
+ longitude: number | null | undefined
113
+ ): CapitalLevel {
114
+ if (!name || !country || typeof latitude !== "number" || typeof longitude !== "number") return CAPITAL_LEVEL.none
115
+
116
+ const bucket = this.#byCountry.get(country.toUpperCase())
117
+
118
+ if (!bucket) return CAPITAL_LEVEL.none
119
+
120
+ const key = String(normalizeLocalityForKey(name))
121
+
122
+ if (!key) return CAPITAL_LEVEL.none
123
+
124
+ let best: CapitalLevel = CAPITAL_LEVEL.none
125
+
126
+ for (const point of bucket) {
127
+ if (
128
+ point.level > best &&
129
+ point.keys.has(key) &&
130
+ haversineKm(latitude, longitude, point.latitude, point.longitude) <= CAPITAL_MATCH_RADIUS_KM
131
+ ) {
132
+ best = point.level
133
+ }
134
+ }
135
+
136
+ return best
137
+ }
138
+
139
+ get size(): number {
140
+ let n = 0
141
+
142
+ for (const bucket of this.#byCountry.values()) {
143
+ n += bucket.length
144
+ }
145
+
146
+ return n
147
+ }
148
+ }