@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
@@ -0,0 +1,207 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Bounded cross-country PRIMARY-NAME preference over a population-ordered candidate row set — the
7
+ * `is_primary` ranking signal, PURE and platform-free so the Node candidate lookup
8
+ * (`candidate-lookup.ts`) and the browser twin (`docs/src/shared/httpvfs-resolver.ts`) rank with the
9
+ * SAME function rather than two copies that drift (the #861 server↔demo parity contract).
10
+ *
11
+ * Only type imports and arithmetic live here: anything with a `node:` import stays out.
12
+ */
13
+
14
+ import type { CandidateTable } from "./candidate-schema.ts"
15
+
16
+ /**
17
+ * The row shape the re-rank needs. `is_primary` is optional: a reader over an artifact vintage that predates the column
18
+ * omits it from the SELECT, no row reads as primary, and the re-rank no-ops by construction — the exact degradation the
19
+ * browser reader's vintage guard relies on.
20
+ */
21
+ export type PrimaryPreferenceRow = Pick<CandidateTable, "neg_rank" | "country_id"> &
22
+ Partial<Pick<CandidateTable, "is_primary" | "placetype_id" | "population" | "name_role">>
23
+
24
+ /**
25
+ * Bounded PRIMARY-NAME preference across a CROSS-COUNTRY name collision (the `is_primary` ranking signal).
26
+ *
27
+ * The raw candidate order is population-first (`neg_rank ASC`) and treats an ALIAS row (a place's alt-name / exonym,
28
+ * `is_primary=0`) and a PRIMARY-name row (`is_primary=1`) on equal footing. So a foreign place whose transliterated
29
+ * exonym coincidentally normalizes to a query — Changchun CN stores the Turkish exonym "Çançun" (`name_key="cancun"`),
30
+ * 4.19 M pop — outranks the PRIMARY-name place the query actually means (Cancún MX, 0.89 M pop). This penalty makes a
31
+ * same-key alias have to clear a population MARGIN over a foreign primary before it wins.
32
+ *
33
+ * It is deliberately NOT a dominant sort key (no `ORDER BY is_primary DESC`, which would make every primary outrank
34
+ * every alias and break the alt-names users depend on — "NYC"→New York, "LA"→Los Angeles, "Frisco"→San Francisco). Two
35
+ * bounds keep it a soft prior:
36
+ *
37
+ * 1. **Cross-country only.** The penalty applies to an alias ONLY when the top-population primary sharing the key is in a
38
+ * DIFFERENT country. A SAME-country nickname contest (San Francisco's alias "Frisco" vs the primary Frisco, TX —
39
+ * both US) is left on pure population, so the legitimate alias still wins.
40
+ * 2. **Population-bounded.** The penalty is {@link PRIMARY_PREFERENCE_LOG10} in log10-population units — an alias must be
41
+ * at least 10x more populous than the foreign primary to still win. So a genuinely dominant alias keeps winning
42
+ * ("Los Angeles" over La, Ghana — gap 1.6; "Las Vegas" over Vegas, Cuba — gap 2.4) while a near-tie coincidental
43
+ * collision defers to the primary (Cancún over Changchun — gap 0.7).
44
+ */
45
+ export const PRIMARY_PREFERENCE_LOG10 = 1
46
+
47
+ /**
48
+ * The placetype the seat preference promotes: the populated-place tier a district duplicate shares its name and its
49
+ * population with. Named rather than inlined because narrowing the term to this ONE tier is what keeps it off the
50
+ * region/county and locality/neighbourhood contests — see `rankByPrimaryPreference` for the measurement.
51
+ */
52
+ const SEAT_PLACETYPE = "locality"
53
+
54
+ /**
55
+ * Over-fetch cap for {@link rankByPrimaryPreference}: the candidate rows for one `name_key` (all same-name places
56
+ * worldwide) are re-ranked in-process, so the probe fetches this many (population-ordered) before the re-rank rather
57
+ * than the caller's small `limit`, ensuring the intended primary isn't cut below the fold by a cluster of more-populous
58
+ * foreign aliases. Bounded and small — a single contiguous B-tree scan.
59
+ */
60
+ export const RERANK_FETCH = 64
61
+
62
+ /**
63
+ * A candidate row annotated with the {@link rankByPrimaryPreference} effective rank + the exact-tier demotion flag.
64
+ */
65
+ export type RankedRow<R> = R & {
66
+ /**
67
+ * `neg_rank` plus the bounded cross-country alias penalty — the value the row is ORDERED by, and the base the emitted
68
+ * `prominence` is derived from (so the resolver walk's `prominence ?? score` sort, `resolve.ts`, agrees with this
69
+ * order; the raw `score`/`neg_rank` is left intact for the walk's `minWinningScore` gate).
70
+ */
71
+ effectiveNegRank: number
72
+ /**
73
+ * True when this row is a cross-country alias that LOST the bounded population contest to the same-key primary — a
74
+ * coincidental foreign exonym (Changchun's "Çançun" for "Cancun"). Such a row is dropped out of the exact-match tier
75
+ * (`exactMatch=false`) so the resolver walk's country pin — the model's `anchorPosterior`, which "never crosses the
76
+ * exact/partial boundary" (`resolve.ts`) — can't ride a spurious posterior (CN 0.86 for "Cancun") back over the
77
+ * primary. Only the LOSING foreign alias is demoted; a dominant alias (Los Angeles over La, Ghana) keeps its exact
78
+ * tier, and a same-country nickname (San Francisco's "Frisco") is never touched.
79
+ */
80
+ demoted: boolean
81
+ /**
82
+ * True when this row came from the TYPO-CORRECTOR tier — the FTS5-trigram fallback that fires only after the exact
83
+ * and qualifier-strip probes both missed. Such a row answers a query the gazetteer does not contain, so it is a fuzzy
84
+ * match by construction and must not claim `exactMatch` (#17). Recall is unaffected: the row is still returned, still
85
+ * ranked, still resolvable — it just stops asserting a match quality it does not have, which is what the FTS backend
86
+ * has always done and what every `exactMatch`-filtering consumer assumed.
87
+ */
88
+ fuzzy?: boolean
89
+ /**
90
+ * The admin-containment verdict (#1717 stage 2), stamped by `candidate-lookup.ts` when the query carried a
91
+ * `regionQualifier` and the artifact carries the ancestors sidecar — the `fuzzy` precedent: a lookup-tier annotation
92
+ * declared on the shared row shape. Tri-state like `ResolvedPlace.containedByQualifier`: absent means the question
93
+ * was never asked, never "not contained".
94
+ */
95
+ containedByQualifier?: boolean
96
+ }
97
+
98
+ /**
99
+ * Bounded cross-country primary-name preference (see {@link PRIMARY_PREFERENCE_LOG10}). Pure + total-ordered so
100
+ * `candidate-lookup.test.ts` can exercise it on synthetic rows. `rows` arrive population-ordered (`neg_rank ASC`); an
101
+ * alias (`is_primary=0`) is pushed back by `delta` in log10-population units ONLY when the top-population primary
102
+ * sharing the key is in a different country, and is `demoted` out of the exact tier when that penalty leaves it BEHIND
103
+ * the primary. Returns the top `limit` after the re-rank, each annotated.
104
+ *
105
+ * `placetypes` (the artifact's own `placetype_codes` map) enables the LAST tiebreak, the SEAT preference: when
106
+ * `effectiveNegRank` and raw `neg_rank` both tie — two same-key rows population cannot separate at all — a
107
+ * {@link SEAT_PLACETYPE} row carrying a real population outranks every other placetype. Omit the map and every row
108
+ * scores 0, the term cancels, and the order is exactly the population-then-scan-order it was before.
109
+ *
110
+ * The tie it exists for is a DUPLICATE, not a contest. A district and its identically-named seat town are stored as two
111
+ * rows carrying the SAME population, so `neg_rank` is equal to the bit and `referential` follows it
112
+ * (`referentialFromPopulation` is a pure function of population). Turkey's `Of` is the measured case — locality
113
+ * 8114738869649 and its parent county 8837168432019 both hold population 44212 — and 358 locality/parent-county pairs
114
+ * across 15 countries share the shape in `admin-global-priority.db` (TR 162, CA 77, US 47, HR 24, DO 14). Without the
115
+ * term their order is whatever the scan hands the sorter.
116
+ *
117
+ * WHERE THE TERM DECIDES, AND WHERE IT CANNOT (#1729). It binds inside `findPlace`, so it orders every row set that
118
+ * actually CONTAINS the tie — but the resolver walk's probes all carry a placetype filter, and
119
+ * `PLACETYPE_FILTER_GROUPS` (core/resolver) never mixes `locality` with `county`: the `Of`-shape locality/county pair
120
+ * is PARTITIONED before this ranker runs, the locality probe fetches one row, and the walk's own `locality` request
121
+ * selects the seat by construction — the same winner, decided upstream. The tie that reaches an end-to-end answer
122
+ * through this term is the IN-GROUP residue: a locality/localadmin (or borough) duplicate whose `importance` values
123
+ * also tie. Downstream the resolver re-sorts by importance (`resolver/toponym-prior.ts`) but is stable on equal keys,
124
+ * so the order stamped here is the order that answers — inverting this term moves bare `Pu-cheng-hsien` 1,100 km
125
+ * (locality Pucheng over the 浦城县 localadmin, identical population and importance). Where importance separates the pair,
126
+ * the fame prior overrides by design; a probe with NO placetype filter (the browser cascade's last resort, the dev
127
+ * lookup tools) presents the full tie and this term is all that breaks it.
128
+ *
129
+ * BOTH GATES ARE LOAD-BEARING, and a plain "finer placetype wins" measured wrong before this shape was settled: it
130
+ * moved the top slot on 11,377 keys in `candidate.db`, of which only 722 were the seat/district duplicate. The rest
131
+ * were contests between genuinely distinct places that merely tie — 2,885 `locality → neighbourhood` (a bare city name
132
+ * losing to a same-named hood), 2,973 `region → county`, 2,662 `postalcode → locality` — and 7,179 of the 11,377 sat at
133
+ * population 0, where a tie means NO EVIDENCE rather than equal evidence. Requiring a real population keeps the term
134
+ * off every no-evidence tie; promoting the populated-place tier specifically, rather than whatever is finer, keeps it
135
+ * off the admin-tier and hood contests. It can never reach a pair population separates: it does not override a
136
+ * population gap, it replaces an undetermined order with a stated one.
137
+ */
138
+ export function rankByPrimaryPreference<R extends PrimaryPreferenceRow>(
139
+ rows: readonly R[],
140
+ limit: number,
141
+ delta = PRIMARY_PREFERENCE_LOG10,
142
+ placetypes?: ReadonlyMap<number, string>,
143
+ exemptVariantAliases = false
144
+ ): Array<RankedRow<R>> {
145
+ // The primary the alias actually competes with for the top slot: highest population (min neg_rank). Undefined
146
+ // when the set has no primary → nothing to prefer, penalty is 0, order stays population-first (today's behavior).
147
+ let topPrimary: R | undefined
148
+
149
+ for (const r of rows) {
150
+ if (r.is_primary === 1 && (topPrimary == null || r.neg_rank < topPrimary.neg_rank)) {
151
+ topPrimary = r
152
+ }
153
+ }
154
+
155
+ const topCountry = topPrimary?.country_id
156
+
157
+ // A cross-country alias (different country than the top primary) is penalized; it is DEMOTED when even after — i.e.
158
+ // the penalty leaves its effective rank behind the primary's raw rank (it lost the bounded population contest).
159
+ //
160
+ // #1882 exemption (opt-in): a `name_role = 'variant'` alias is the holder's OWN primary name in another
161
+ // orthography (`Брэст` → `brest`, `George Town` → `georgetown` — the build's own-name detector), so the
162
+ // query is naming THAT place, not colliding with it; the penalty exists for the coincidental-collision
163
+ // class ("Çançun"/`cancun`), which the detector's measured threshold keeps un-stamped. An artifact
164
+ // predating the role column carries no 'variant' rows, so the flag no-ops there by construction.
165
+ const isCrossCountryAlias = (r: R): boolean =>
166
+ typeof topCountry === "number" &&
167
+ r.is_primary !== 1 &&
168
+ r.country_id !== topCountry &&
169
+ !(exemptVariantAliases && r.name_role === "variant")
170
+
171
+ const annotate = (r: R): RankedRow<R> => {
172
+ const penalized = isCrossCountryAlias(r)
173
+ const effectiveNegRank = r.neg_rank + (penalized ? delta : 0)
174
+
175
+ return { ...r, effectiveNegRank, demoted: penalized && effectiveNegRank > topPrimary!.neg_rank }
176
+ }
177
+
178
+ // 1 for a populated-place row that can BE a district's seat, 0 for everything else — no code map, no
179
+ // placetype on the row, an id the map does not carry, a placetype that is not the seat tier, or no
180
+ // recorded population. Every row scoring 0 cancels the term, leaving exactly the
181
+ // population-then-scan-order the sort had before it existed.
182
+ const seatPreference = (r: R): number =>
183
+ placetypes != null &&
184
+ typeof r.placetype_id === "number" &&
185
+ typeof r.population === "number" &&
186
+ r.population > 0 &&
187
+ placetypes.get(r.placetype_id) === SEAT_PLACETYPE
188
+ ? 1
189
+ : 0
190
+
191
+ return (
192
+ rows
193
+ .map((r, i) => ({ row: annotate(r), i }))
194
+ // Effective rank ASC; ties keep population order, then the seat preference DESC, then original
195
+ // index (stable).
196
+ // oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
197
+ .sort(
198
+ (a, b) =>
199
+ a.row.effectiveNegRank - b.row.effectiveNegRank ||
200
+ a.row.neg_rank - b.row.neg_rank ||
201
+ seatPreference(b.row) - seatPreference(a.row) ||
202
+ a.i - b.i
203
+ )
204
+ .slice(0, limit)
205
+ .map((x) => x.row)
206
+ )
207
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * The proximity re-rank (#938): with bias hints — the demo's map viewport, a user location — re-order exact-match
7
+ * candidates by population and nearness on one additive scale, so an in-view namesake wins a tie without a hard
8
+ * filter. Byte-identical to plain population order when no bias is passed.
9
+ *
10
+ * This lives in its own platform-free module because it has to run identically in two places: the Node candidate
11
+ * reader and the browser byte-range twin. That is the #861 server↔demo parity contract, and it is the second thing
12
+ * here held by construction rather than by comment (`primary-preference.ts` was the first). Constants alone were not
13
+ * enough — the two copies agreed on every literal and still diverged on which field the population term reads and on
14
+ * whether the combined value is written back, which is the half that actually decides the answer.
15
+ *
16
+ * Two properties are load-bearing and easy to lose when transcribing:
17
+ *
18
+ * 1. The population base is `prominence ?? score`, NOT `score`. `prominence` carries the bounded cross-country
19
+ * primary preference, so reading raw score lets a coincidental foreign alias ride population back over a primary
20
+ * whenever a viewport hint happens to be present.
21
+ * 2. The combined value is PERSISTED into `prominence`. The resolver walk re-sorts by `prominence ?? score`, so a
22
+ * caller that only returns the array in bias order has its ordering silently discarded downstream.
23
+ */
24
+
25
+ import { haversineKm } from "@mailwoman/spatial"
26
+
27
+ /**
28
+ * Full magnitude of the nearness term at distance 0, before decay.
29
+ */
30
+ export const BIAS_BOOST = 4
31
+
32
+ /**
33
+ * Full magnitude of the population term, reached at {@link POP_SCALE_LOG10} and capped there.
34
+ */
35
+ export const POP_BOOST = 4
36
+
37
+ /**
38
+ * `log10(population + 1)` at which the population term saturates — 6 means a population of one million earns the whole
39
+ * {@link POP_BOOST}, and larger populations earn no more.
40
+ */
41
+ export const POP_SCALE_LOG10 = 6
42
+
43
+ /**
44
+ * Distance at which the nearness term halves.
45
+ *
46
+ * SHARPER than the FTS reader's 100 km on purpose: the candidate backend's score is log-population ALONE, with no bm25
47
+ * document term, so the population signal is weaker relative to the bias and a gentle 100 km decay let a 230 km-distant
48
+ * alias-exact township ("Paris Township", OH) edge out a global city ("Paris", FR) from a nearby view. At ~30 km the
49
+ * boost reaches only candidates the user is actually looking at: an in-view namesake still wins (Dublin, OH from an
50
+ * Ohio view), a distant one no longer does (Paris stays FR from a Michigan view).
51
+ */
52
+ export const PROX_SCALE_KM = 30
53
+
54
+ /**
55
+ * One bias hint — a coordinate the user is looking at or standing on, optionally weighted.
56
+ */
57
+ export interface ProximityBias {
58
+ lat: number
59
+ lon: number
60
+ weight?: number
61
+ }
62
+
63
+ /**
64
+ * The candidate fields the re-rank reads and writes. Structural rather than a concrete candidate type, so the Node
65
+ * reader's `PlaceCandidate` and the browser twin's row shape both satisfy it without an adapter.
66
+ */
67
+ export interface ProximityRerankable {
68
+ lat: number
69
+ lon: number
70
+ score: number
71
+ prominence?: number
72
+ }
73
+
74
+ /**
75
+ * Population plus nearness on one additive scale. Exported for tests and for a caller that wants the value without the
76
+ * sort; ordinary callers want {@link applyProximityRerank}.
77
+ */
78
+ export function combinedProminence(candidate: ProximityRerankable, bias: readonly ProximityBias[]): number {
79
+ const popBase = candidate.prominence ?? candidate.score
80
+ const popTerm = POP_BOOST * Math.min(1, Math.max(0, popBase) / POP_SCALE_LOG10)
81
+ let proxTerm = 0
82
+
83
+ // A candidate at the null island has no coordinate, not a coordinate at 0,0 — it earns no nearness term rather
84
+ // than an enormous one.
85
+ if (!(candidate.lat === 0 && candidate.lon === 0)) {
86
+ for (const b of bias) {
87
+ const d = haversineKm(b.lat, b.lon, candidate.lat, candidate.lon)
88
+ const term = (BIAS_BOOST * (b.weight ?? 1)) / (1 + d / PROX_SCALE_KM)
89
+
90
+ if (term > proxTerm) {
91
+ proxTerm = term
92
+ }
93
+ }
94
+ }
95
+
96
+ return popTerm + proxTerm
97
+ }
98
+
99
+ /**
100
+ * Re-order `candidates` in place by {@link combinedProminence}, persisting each combined value into `prominence` so the
101
+ * resolver walk's own `prominence ?? score` sort carries the bias order rather than undoing it. Stable within equal
102
+ * prominence, preserving the population order the index already gave. A caller with no bias hints must not call this —
103
+ * the no-bias path is plain population order by construction.
104
+ */
105
+ export function applyProximityRerank<T extends ProximityRerankable>(
106
+ candidates: T[],
107
+ bias: readonly ProximityBias[]
108
+ ): T[] {
109
+ candidates
110
+ .map((c, i) => {
111
+ c.prominence = combinedProminence(c, bias)
112
+
113
+ return { c, i, p: c.prominence }
114
+ })
115
+ // oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
116
+ .sort((a, b) => b.p - a.p || a.i - b.i)
117
+ .forEach((x, j) => (candidates[j] = x.c))
118
+
119
+ return candidates
120
+ }
package/region-keys.ts ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * The comparable-key expansion for a parsed REGION qualifier — one function, both consumers (the
7
+ * #861 rule). The admin-coherence verdicts (`mailwoman/admin-coherence.ts`) fold a qualifier and a
8
+ * winner-ancestry name through this to decide `confirmed`/`contradicted`; the candidate backend's
9
+ * admin-containment re-rank (#1717 stage 2, `candidate-lookup.ts`) folds the same qualifier through
10
+ * it to find the qualifier's own region-class rows in the candidate table. A check that says
11
+ * `contradicted` and a re-rank that cannot find the qualifier would otherwise be two readings of
12
+ * one string that silently disagree — the exact drift the shared-function rule exists to prevent.
13
+ *
14
+ * Lives HERE rather than in `mailwoman` because the dependency points this way: `mailwoman`
15
+ * depends on `@mailwoman/resolver-wof-sqlite` (which owns the fold and already depends on codex),
16
+ * never the reverse.
17
+ */
18
+
19
+ import { matchSubdivision, matchSubdivisionIn } from "@mailwoman/codex/country"
20
+
21
+ import { normalizeLocalityForKey } from "./street-normalize.ts"
22
+
23
+ /**
24
+ * The ancestry placetypes that answer for a parsed `region` qualifier — WOF's admin band between country and locality.
25
+ * Deliberately the whole band: a qualifier stated at any grain ("Lancashire", a ceremonial county; "Thüringen", a Land)
26
+ * may confirm against whichever level the backend stored, and `contradicted` requires the entire band to miss, so
27
+ * widening the band only ever makes the check more conservative.
28
+ */
29
+ export const REGION_CLASS_PLACETYPES: ReadonlySet<string> = new Set(["region", "macroregion", "county", "macrocounty"])
30
+
31
+ /**
32
+ * County-style qualifier prefixes stripped to produce a comparison VARIANT. Ireland writes `Co. Westmeath` where WOF
33
+ * stores `Westmeath`, so the prefix defeats the fold and every Irish county qualifier read `contradicted` on the first
34
+ * board census (2026-08-17, five rows). The stripped form is ADDED to the key set, never substituted — `County Durham`
35
+ * is a real name whose stripped variant simply also matches, and a set union can only widen confirmation, so the
36
+ * closure is monotone: `contradicted → confirmed` is the only movement it can cause.
37
+ */
38
+ const COUNTY_QUALIFIER_PREFIXES = ["county", "co.", "co"] as const
39
+
40
+ /**
41
+ * Trailing admin-qualifier words, the suffix sibling of the prefix above: `San José Province` (CR board row) folds
42
+ * against stored `San José` only with the word removed. Same monotone rule — the stripped form joins the set, never
43
+ * replaces the original.
44
+ */
45
+ const ADMIN_QUALIFIER_SUFFIXES = ["province", "prov.", "prov"] as const
46
+
47
+ function withoutPrefix(value: string, prefixes: readonly string[]): string {
48
+ const folded = value.toLowerCase()
49
+
50
+ for (const prefix of prefixes) {
51
+ if (!folded.startsWith(prefix) || !/\s/u.test(value[prefix.length] ?? "")) continue
52
+
53
+ let offset = prefix.length
54
+
55
+ while (/\s/u.test(value[offset] ?? "")) {
56
+ offset++
57
+ }
58
+
59
+ return value.slice(offset)
60
+ }
61
+
62
+ return value
63
+ }
64
+
65
+ function withoutSuffix(value: string, suffixes: readonly string[]): string {
66
+ const folded = value.toLowerCase()
67
+
68
+ for (const suffix of suffixes) {
69
+ const offset = value.length - suffix.length
70
+
71
+ if (offset <= 0 || !folded.endsWith(suffix) || !/\s/u.test(value[offset - 1] ?? "")) continue
72
+
73
+ let end = offset
74
+
75
+ while (end > 0 && /\s/u.test(value[end - 1] ?? "")) {
76
+ end--
77
+ }
78
+
79
+ return value.slice(0, end)
80
+ }
81
+
82
+ return value
83
+ }
84
+
85
+ /**
86
+ * The comparable keys a region string expands to: its own fold (the shared candidate.db `name_key` normalizer,
87
+ * {@link normalizeLocalityForKey} — build side and check side agree by construction); a county-prefix-stripped variant;
88
+ * the codex subdivision expansions — the disjoint US+CA table always, plus the COUNTRY-SCOPED table when the caller
89
+ * knows a country (`WA` under AU is Western Australia; under US, Washington — the collision that keeps AU out of the
90
+ * unscoped table). Every expansion lands the canonical name and code folds in the set, so `IL`/`Illinois` and
91
+ * `WA`/`Western Australia` meet from either side.
92
+ */
93
+ export function regionKeys(value: string, countryAlpha2?: string): Set<string> {
94
+ const keys = new Set([normalizeLocalityForKey(value)])
95
+
96
+ for (const stripped of [
97
+ withoutPrefix(value, COUNTY_QUALIFIER_PREFIXES),
98
+ withoutSuffix(value, ADMIN_QUALIFIER_SUFFIXES),
99
+ ]) {
100
+ if (stripped !== value && stripped.trim()) {
101
+ keys.add(normalizeLocalityForKey(stripped))
102
+ }
103
+ }
104
+
105
+ const expansions = [matchSubdivision(value), countryAlpha2 ? matchSubdivisionIn(countryAlpha2, value) : null]
106
+
107
+ for (const subdivision of expansions) {
108
+ if (subdivision) {
109
+ keys.add(normalizeLocalityForKey(subdivision.name))
110
+ keys.add(normalizeLocalityForKey(subdivision.code))
111
+ }
112
+ }
113
+
114
+ // The empty fold stays IN the set on purpose — the admin-coherence verdicts have always compared
115
+ // the empty key (two empty-folding strings intersect → `confirmed`), and this move must not shift
116
+ // a verdict. A consumer probing a table by key filters the empty string out itself.
117
+ return keys
118
+ }
119
+
120
+ /**
121
+ * The PROBE-side expansion for a region qualifier — {@link regionKeys} plus the county-PREFIXED variant of every key.
122
+ *
123
+ * The verdict machinery intersects two {@link regionKeys} SETS, so `Co. Donegal` meets stored `County Donegal` at the
124
+ * shared stripped key `donegal`. A table probe is one-sided: it matches the STORED fold verbatim, and WOF stores Irish
125
+ * counties under `county donegal` with no bare `donegal` key (measured on the shipped candidate.db — the qualifier
126
+ * probe missed every Irish county until this variant landed). Adding `county <key>` restores the two-sidedness for the
127
+ * one stored-form family with an evidenced case; the union is monotone (a wider qualifier set can only find more
128
+ * BEARERS, each of which must still genuinely contain a candidate before anything moves). The suffix sibling (`<key>
129
+ * province`) is deliberately absent — no stored-form case has been evidenced, and a lever without a board does not get
130
+ * built.
131
+ */
132
+ export function regionQualifierProbeKeys(value: string, countryAlpha2?: string): Set<string> {
133
+ const keys = regionKeys(value, countryAlpha2)
134
+ // Snapshot before widening: the loop adds `county <key>` members that must not themselves be revisited.
135
+ const bare = [...keys]
136
+
137
+ for (const key of bare) {
138
+ if (key && !key.startsWith("county ")) {
139
+ keys.add(`county ${key}`)
140
+ }
141
+ }
142
+
143
+ return keys
144
+ }
package/reverse.ts CHANGED
@@ -31,10 +31,11 @@
31
31
  import { DatabaseSync } from "node:sqlite"
32
32
 
33
33
  import { tryParsingJSON } from "@mailwoman/core/objects"
34
+ import { geometryContains, haversineKm, type GeojsonGeometry } from "@mailwoman/spatial"
34
35
 
35
36
  import { ancestorLineage, placetypeDepth } from "./ancestry.ts"
36
37
  import { PLACE_BBOX_TABLE } from "./fts.ts"
37
- import { geometryContains, haversineKm, type GeojsonGeometry } from "./geo.ts"
38
+ import { allRows } from "./sqlite-utils.ts"
38
39
  import type { PlaceCandidate, WOFPlacetype } from "./types.ts"
39
40
 
40
41
  /**
@@ -389,16 +390,17 @@ export class WOFReverseGeocoder implements Disposable {
389
390
 
390
391
  params.push(opts.maxCandidates ?? DEFAULT_MAX_CANDIDATES)
391
392
 
392
- return this.#admin
393
- .prepare(
393
+ return allRows<CandidateRow>(
394
+ this.#admin.prepare(
394
395
  `SELECT spr.id AS id, spr.name AS name, spr.placetype AS placetype, spr.country AS country,
395
396
  spr.parent_id AS parent_id, spr.latitude AS lat, spr.longitude AS lon
396
397
  FROM ${PLACE_BBOX_TABLE} bbox JOIN spr ON spr.id = bbox.id
397
398
  WHERE ${where.join(" AND ")}
398
399
  ORDER BY (bbox.max_lat - bbox.min_lat) * (bbox.max_lon - bbox.min_lon) ASC
399
400
  LIMIT ?`
400
- )
401
- .all(...params) as unknown as CandidateRow[]
401
+ ),
402
+ ...params
403
+ )
402
404
  }
403
405
 
404
406
  /**
@@ -415,22 +417,21 @@ export class WOFReverseGeocoder implements Disposable {
415
417
  ): CandidateRow[] {
416
418
  const windowDeg = (maxApproximateKm * 4) / 111
417
419
 
418
- return this.#admin
419
- .prepare(
420
+ return allRows<CandidateRow>(
421
+ this.#admin.prepare(
420
422
  `SELECT s.id AS id, s.name AS name, s.placetype AS placetype, s.country AS country,
421
423
  s.parent_id AS parent_id, s.latitude AS lat, s.longitude AS lon
422
424
  FROM ancestors a JOIN spr s ON s.id = a.id
423
425
  WHERE a.ancestor_id = ? AND s.placetype = ? AND s.is_current != 0 AND s.is_deprecated = 0
424
426
  AND s.latitude BETWEEN ? AND ? AND s.longitude BETWEEN ? AND ?`
425
- )
426
- .all(
427
- parentID,
428
- placetype,
429
- lat - windowDeg,
430
- lat + windowDeg,
431
- lon - windowDeg,
432
- lon + windowDeg
433
- ) as unknown as CandidateRow[]
427
+ ),
428
+ parentID,
429
+ placetype,
430
+ lat - windowDeg,
431
+ lat + windowDeg,
432
+ lon - windowDeg,
433
+ lon + windowDeg
434
+ )
434
435
  }
435
436
 
436
437
  /**
package/schema.ts CHANGED
@@ -159,7 +159,7 @@ export interface CoincidentRolesTable {
159
159
 
160
160
  /**
161
161
  * The full schema we hand to `Kysely<WOFDatabase>` / `new DatabaseClient<WOFDatabase>(...)`. Tables not listed here
162
- * will fail type-checked queries — by design. The reader ({@link WOFSqlitePlaceLookup}) already consumes this; the
162
+ * will fail type-checked queries — by design. The reader ({@link WOFSQLitePlaceLookup}) already consumes this; the
163
163
  * build/augment WRITERS adopt it so a column rename is a compile error on both sides (the drift that bit the corpus
164
164
  * TIGER adapter).
165
165
  */