@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,256 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * The FTS5 candidate fetch behind the fuzzy name match: the schema-qualified `place_search`
7
+ * MATCH, its population-ordered companion fetch, and the raw row shape both of them return.
8
+ *
9
+ * Bbox and near-with-radius narrow at the SQL level through SQLite's built-in `rtree`, whose index name and schema
10
+ * live in `fts.ts` beside the FTS5 build. That is why this package pulls neither SpatiaLite nor turf: the R*Tree does
11
+ * the narrowing, and the passes downstream of it operate on ≤ a few hundred candidates per query rather than the
12
+ * whole corpus, so an exact haversine over the survivors is cheap.
13
+ */
14
+
15
+ import type { DatabaseSync, SQLInputValue } from "node:sqlite"
16
+
17
+ import { bboxAround } from "@mailwoman/spatial"
18
+
19
+ import { PLACE_BBOX_TABLE, PLACE_POPULATION_TABLE } from "./fts.ts"
20
+ import type { RankingWeights } from "./ranking-weights.ts"
21
+ import { allRows } from "./sqlite-utils.ts"
22
+ import type { FindPlaceQuery, WOFPlacetype } from "./types.ts"
23
+
24
+ /**
25
+ * Query length at or below which the FTS window is widened. A two- or three-character query is almost always a region
26
+ * abbreviation, where the exact match can otherwise fall outside the window behind higher-bm25 partial hits — "NY"
27
+ * losing to "New York".
28
+ */
29
+ const SHORT_QUERY_MAX_LENGTH = 3
30
+
31
+ /**
32
+ * Over-fetch floor for SHORT (≤3-char) queries — region abbreviations like "NY"/"VT". An exact-abbrev holder's BM25 is
33
+ * poor (long multilingual alt-name document), so the normal `limit * 4` window can drop it before `exactMatchTiering`
34
+ * promotes it. 200 comfortably covers every same-abbrev region across the 12-country gazetteer (a 2-letter token
35
+ * matches a few dozen regions at most) while staying a cheap region-placetype fetch. See the `#fuzzyNameMatch`
36
+ * over-fetch comment.
37
+ */
38
+ const SHORT_QUERY_OVERFETCH = 200
39
+
40
+ /**
41
+ * How many rows the population-ordered companion fetch (#905) adds to the candidate pool. Small on purpose: its only
42
+ * job is to guarantee the FAMOUS holders of a name enter the pool at all — for "Paris"-class floods the bm25 window is
43
+ * saturated by thousands of tiny same-name rows and no boost inside the bm25-based ORDER BY can rescue a candidate
44
+ * whose bm25 is length-poisoned by ~15 points (see the fetch-site comment).
45
+ */
46
+ const POPULATION_FETCH_LIMIT = 15
47
+
48
+ export interface RawSearchRow {
49
+ id: number
50
+ name: string
51
+ placetype: string
52
+ country: string | null
53
+ parent_id: number | null
54
+ rank: number // BM25 (lower = better in SQLite); we negate to get higher-is-better
55
+ lat: number | null
56
+ lon: number | null
57
+ min_latitude: number | null
58
+ max_latitude: number | null
59
+ min_longitude: number | null
60
+ max_longitude: number | null
61
+ population: number | null // from the place_population aux table; null when missing
62
+ /**
63
+ * From `place_importance.encyclopedic` when the shard's table carries the two-score split columns. NULL means the
64
+ * place has no Wikipedia article, or the shard predates the split — absence either way, and never 0 (ROAD_TO_V9 §2).
65
+ */
66
+ encyclopedic: number | null
67
+ }
68
+
69
+ /**
70
+ * Fetch the raw candidate rows for a name match on one shard: the BM25-ordered window over `place_search` (widened for
71
+ * short queries), plus the population-ordered companion fetch that keeps the prominent holders of a name pool-complete.
72
+ * `schemaName` is the routed shard's bare schema name — validated at construction, so it is interpolated directly.
73
+ */
74
+ export function fetchSearchRows(options: {
75
+ db: DatabaseSync
76
+ schemaName: string
77
+ query: FindPlaceQuery
78
+ placetypes: WOFPlacetype[] | null
79
+ ftsQuery: string
80
+ limit: number
81
+ hasBboxIndex: ReadonlyMap<string, boolean>
82
+ hasPopulationIndex: ReadonlyMap<string, boolean>
83
+ encyclopedicClauses: ReadonlyMap<string, { select: string; join: string }>
84
+ weights: RankingWeights
85
+ }): RawSearchRow[] {
86
+ const {
87
+ db,
88
+ schemaName: sch,
89
+ query,
90
+ placetypes,
91
+ ftsQuery,
92
+ limit,
93
+ hasBboxIndex,
94
+ hasPopulationIndex,
95
+ encyclopedicClauses,
96
+ weights,
97
+ } = options
98
+
99
+ // Over-fetch so post-scoring + exact-match tiering have room to re-rank. SHORT queries (a 2–3-char
100
+ // region abbreviation like "NY"/"VT") are the danger case the `exactMatchTiering` docstring flags:
101
+ // the exact-abbrev holder's BM25 is poor (its long multilingual alt-name document tanks the score),
102
+ // so under the normal `limit * 4` window it drops OUT of the candidate pool BEFORE tiering can
103
+ // promote it — "NY" then resolves to a token-matching foreign region (Highland, GB) instead of New
104
+ // York. Widen the window for short queries so the exact match is always present to be tiered.
105
+ // (Cross-country abbrev collisions — "VT" is BOTH Vermont and Viterbo — still need a country/
106
+ // postcode signal to disambiguate; this only rescues the window-drop class, not genuine ambiguity.
107
+ // With a `country` hint every abbrev resolves; bare + no-context lifts 7→10/15 US states.)
108
+ const ftsLimit =
109
+ query.text.trim().length <= SHORT_QUERY_MAX_LENGTH ? Math.max(limit * 4, SHORT_QUERY_OVERFETCH) : limit * 4
110
+
111
+ // Filter out historical / superseded / deprecated places by default — they live in the same
112
+ // spr table but should never win a contemporary lookup. `is_current = 0` is the only WOF
113
+ // value that means "not current"; both `-1` (modern) and `1` (legacy) mean current. See #91.
114
+ // Note: with schema-qualified FROM the bare `place_search` reference in MATCH resolves to
115
+ // the FROM table — required by FTS5 parser, see sharding.ts header comment.
116
+ const where: string[] = ["place_search MATCH ?", "spr.is_current != 0", "spr.is_deprecated = 0"]
117
+ const params: SQLInputValue[] = [ftsQuery]
118
+
119
+ if (placetypes && placetypes.length) {
120
+ where.push(`spr.placetype IN (${placetypes.map(() => "?").join(", ")})`)
121
+ params.push(...placetypes)
122
+ }
123
+
124
+ if (query.country) {
125
+ where.push("spr.country = ?")
126
+ params.push(query.country)
127
+ }
128
+
129
+ if (query.parentID !== undefined) {
130
+ where.push(`(spr.parent_id = ? OR spr.id IN (SELECT id FROM ${sch}.ancestors WHERE ancestor_id = ?))`)
131
+ params.push(query.parentID, query.parentID)
132
+ }
133
+
134
+ // Bbox + near-with-radius are SQL-level filters via the R*Tree. We only emit the JOIN when
135
+ // the active shard has the R*Tree; missing-but-requested is silently treated as no-bbox-
136
+ // filter so legacy DBs / shards-without-bbox don't crash.
137
+ const shardHasBbox = hasBboxIndex.get(sch) === true
138
+ const useBboxJoin = (query.bbox || query.near?.maxDistanceKm !== undefined) && shardHasBbox
139
+ let joinClause = `JOIN ${sch}.spr ON spr.id = place_search.wof_id`
140
+
141
+ if (useBboxJoin) {
142
+ joinClause += ` JOIN ${sch}.${PLACE_BBOX_TABLE} bbox ON bbox.id = spr.id`
143
+ // AABB intersection — both bbox sides must overlap. R*Tree handles this in O(log n).
144
+ const filterBox = query.bbox || bboxAround(query.near!.lat, query.near!.lon, query.near!.maxDistanceKm!)
145
+ where.push("bbox.min_lat <= ? AND bbox.max_lat >= ?", "bbox.min_lon <= ? AND bbox.max_lon >= ?")
146
+ params.push(filterBox.maxLat, filterBox.minLat, filterBox.maxLon, filterBox.minLon)
147
+ }
148
+
149
+ // LEFT JOIN the population aux table when present. Missing-on-this-shard means the SELECT
150
+ // just doesn't include the population column; the post-scoring loop treats it as 0.
151
+ const shardHasPopulation = hasPopulationIndex.get(sch) === true
152
+
153
+ const populationSelect = shardHasPopulation
154
+ ? `${PLACE_POPULATION_TABLE}.population AS population`
155
+ : `NULL AS population`
156
+
157
+ const populationJoin = shardHasPopulation
158
+ ? `LEFT JOIN ${sch}.${PLACE_POPULATION_TABLE} ON ${PLACE_POPULATION_TABLE}.id = spr.id`
159
+ : ""
160
+
161
+ // The encyclopedic score is CARRIED, never ranked on (ROAD_TO_V9 §2, ratified 2026-08-06) — it
162
+ // appears in the SELECT and in no ORDER BY, here or in the companion fetch below. Gated on the
163
+ // split column, so a pre-split shard emits a literal NULL and builds no join at all.
164
+ const { select: encyclopedicSelect, join: encyclopedicJoin } = encyclopedicClauses.get(sch)!
165
+
166
+ // Push the population boost into the ORDER BY when the index is available, so famous places
167
+ // (whose long alt-name lists hurt BM25) actually make it into the over-fetch window. The TS
168
+ // post-scoring will still compute the same boost for the final score; this just ensures the
169
+ // candidate set is right.
170
+ //
171
+ // Formula: rank_adjusted = bm25 - populationBoost * min(1.0, log10(1 + pop) / scaleLog10)
172
+ // Lower rank_adjusted = better (matches SQLite's bm25 convention of "more negative = better").
173
+ //
174
+ // #905 — do NOT reach for bm25 column weights here. Measured falsification (2026-07-02): FTS5's
175
+ // bm25 length normalization is polluted by the row's TOTAL document size, so identical 1-token
176
+ // `name` docs read −16.0 (empty alt_names) vs −0.43 (2.7 KB alt_names) EVEN with the alt_names
177
+ // column weighted to zero — no weighting isolates name relevance in this schema. The famous-
178
+ // holder guarantee lives in the population-ordered companion fetch below instead, and the
179
+ // exact tier breaks ties by population in the post-scoring sort.
180
+ const orderByExpr = shardHasPopulation
181
+ ? `(bm25(place_search) - ? * MIN(1.0, COALESCE(log10(1.0 + ${PLACE_POPULATION_TABLE}.population), 0) / ?))`
182
+ : "bm25(place_search)"
183
+
184
+ // Schema-qualified FROM with bare-name MATCH — required syntax for FTS5 on attached schemas.
185
+ // See sharding.ts header for the gotcha that drove this design.
186
+ const stmt = db.prepare(`
187
+ SELECT
188
+ spr.id AS id,
189
+ spr.name,
190
+ spr.placetype,
191
+ spr.country,
192
+ spr.parent_id,
193
+ bm25(place_search) AS rank,
194
+ spr.latitude AS lat,
195
+ spr.longitude AS lon,
196
+ spr.min_latitude, spr.max_latitude, spr.min_longitude, spr.max_longitude,
197
+ ${populationSelect},
198
+ ${encyclopedicSelect}
199
+ FROM ${sch}.place_search
200
+ ${joinClause}
201
+ ${populationJoin}
202
+ ${encyclopedicJoin}
203
+ WHERE ${where.join(" AND ")}
204
+ ORDER BY ${orderByExpr} ASC
205
+ LIMIT ?
206
+ `)
207
+
208
+ if (shardHasPopulation) {
209
+ params.push(weights.populationBoost, weights.populationScaleLog10)
210
+ }
211
+
212
+ params.push(ftsLimit)
213
+
214
+ const rawRows = allRows<RawSearchRow>(stmt, ...params)
215
+
216
+ // #905 companion fetch: the same MATCH, ordered by population alone. For name floods
217
+ // ("Paris" matches thousands of gap-fill villages) the bm25-based window above cannot admit
218
+ // the famous holder — its bm25 is length-poisoned by the row's alias bulk (measured ~15 pts,
219
+ // vs a +4.0 boost cap), so FR Paris never even reaches post-scoring. This fetch makes the
220
+ // prominent holders of a name pool-complete BY CONSTRUCTION; the exact-tier sort below
221
+ // decides whether they win. Skipped without a population index (nothing to order by).
222
+ if (shardHasPopulation) {
223
+ const popStmt = db.prepare(`
224
+ SELECT
225
+ spr.id AS id,
226
+ spr.name,
227
+ spr.placetype,
228
+ spr.country,
229
+ spr.parent_id,
230
+ bm25(place_search) AS rank,
231
+ spr.latitude AS lat,
232
+ spr.longitude AS lon,
233
+ spr.min_latitude, spr.max_latitude, spr.min_longitude, spr.max_longitude,
234
+ ${populationSelect},
235
+ ${encyclopedicSelect}
236
+ FROM ${sch}.place_search
237
+ ${joinClause}
238
+ ${populationJoin}
239
+ ${encyclopedicJoin}
240
+ WHERE ${where.join(" AND ")}
241
+ ORDER BY COALESCE(${PLACE_POPULATION_TABLE}.population, 0) DESC
242
+ LIMIT ?
243
+ `)
244
+
245
+ const popParams = params.slice(0, -3) // drop the two boost params + ftsLimit
246
+ const seen = new Set(rawRows.map((r) => r.id))
247
+
248
+ for (const row of allRows<RawSearchRow>(popStmt, ...popParams, POPULATION_FETCH_LIMIT)) {
249
+ if (!seen.has(row.id)) {
250
+ rawRows.push(row)
251
+ }
252
+ }
253
+ }
254
+
255
+ return rawRows
256
+ }
package/sharding.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * @license AGPL-3.0
4
4
  * @author Teffen Ellis, et al.
5
5
  *
6
- * Multi-shard support for `WOFSqlitePlaceLookup` — opens multiple WOF SQLite distributions on one
6
+ * Multi-shard support for `WOFSQLitePlaceLookup` — opens multiple WOF SQLite distributions on one
7
7
  * connection via `ATTACH DATABASE`, and routes queries to the right shard based on placetype.
8
8
  *
9
9
  * ## The FTS5 syntax rule that drove this design
@@ -78,7 +78,7 @@ export interface ShardConfig {
78
78
 
79
79
  /**
80
80
  * Resolved post-derivation: paired path + chosen schema name + (possibly empty) placetypes hint. Used internally by
81
- * `WOFSqlitePlaceLookup` so the routing logic operates on uniform structures.
81
+ * `WOFSQLitePlaceLookup` so the routing logic operates on uniform structures.
82
82
  */
83
83
  export interface ResolvedShard {
84
84
  path: string
@@ -200,7 +200,7 @@ export function pickShardForPlacetype(
200
200
  */
201
201
  country?: string
202
202
  /**
203
- * Per-schema probed country sets (see `WOFSqlitePlaceLookup`'s construction probe).
203
+ * Per-schema probed country sets (see `WOFSQLitePlaceLookup`'s construction probe).
204
204
  */
205
205
  countriesBySchema?: ReadonlyMap<string, ReadonlySet<string>>
206
206
  }
@@ -32,7 +32,7 @@ export class SqliteConventionSource implements ConventionSource {
32
32
  /**
33
33
  * @param db An open handle to a DB that has the convention asset attached (or is it).
34
34
  * @param schema The schema name the `address_convention` table lives under (`main` or an ATTACHed shard name —
35
- * `WOFSqlitePlaceLookup` auto-detects which shard carries the table).
35
+ * `WOFSQLitePlaceLookup` auto-detects which shard carries the table).
36
36
  */
37
37
  constructor(db: DatabaseSync, schema: string) {
38
38
  this.#db = db
package/sqlite-utils.ts CHANGED
@@ -6,7 +6,48 @@
6
6
  * Small shared helpers for the SQLite-backed lookups.
7
7
  */
8
8
 
9
- import type { DatabaseSync } from "node:sqlite"
9
+ import type { DatabaseSync, SQLInputValue } from "node:sqlite"
10
+
11
+ import { allRows, getRow } from "@mailwoman/core/utils"
12
+
13
+ // The row-shape assertion itself lives in `core` so the readers that cannot depend on this package reach the same
14
+ // seam; re-exported here because this module is where this package's readers already look for it.
15
+ export { allRows, getRow } from "@mailwoman/core/utils"
16
+
17
+ /**
18
+ * A prepared single-row query whose parameter tuple remains visible to TypeScript. `StatementSync` accepts only the
19
+ * broad `SQLInputValue[]`, which otherwise erases tagged key types before they reach SQLite.
20
+ */
21
+ export type PreparedGet<Parameters extends SQLInputValue[], Row> = (...parameters: Parameters) => Row | undefined
22
+
23
+ /**
24
+ * Prepare a single-row query while preserving its exact parameter tuple at every call site.
25
+ */
26
+ export function prepareGet<Parameters extends SQLInputValue[], Row>(
27
+ db: DatabaseSync,
28
+ sql: string
29
+ ): PreparedGet<Parameters, Row> {
30
+ const statement = db.prepare(sql)
31
+
32
+ return (...parameters) => getRow<Row>(statement, ...parameters)
33
+ }
34
+
35
+ /**
36
+ * Multi-row counterpart to {@link PreparedGet}.
37
+ */
38
+ export type PreparedAll<Parameters extends SQLInputValue[], Row> = (...parameters: Parameters) => Row[]
39
+
40
+ /**
41
+ * Prepare a multi-row query while preserving its exact parameter tuple at every call site.
42
+ */
43
+ export function prepareAll<Parameters extends SQLInputValue[], Row>(
44
+ db: DatabaseSync,
45
+ sql: string
46
+ ): PreparedAll<Parameters, Row> {
47
+ const statement = db.prepare(sql)
48
+
49
+ return (...parameters) => allRows<Row>(statement, ...parameters)
50
+ }
10
51
 
11
52
  /**
12
53
  * True when `name` is a table in the open database. The street-level lookups use this to degrade gracefully on an
@@ -23,3 +64,24 @@ export function hasTable(db: DatabaseSync, name: string): boolean {
23
64
  return false
24
65
  }
25
66
  }
67
+
68
+ /**
69
+ * True when `table` exists in the open database AND carries `column`.
70
+ *
71
+ * The column-level sibling of {@link hasTable}, and it exists for the same reason one layer down: an artifact built
72
+ * before a column was added is still a VALID artifact, and a reader that unconditionally names the new column in its
73
+ * `SELECT` turns "this gazetteer is a build behind" into `no such column` at the first keystroke. Probe once at
74
+ * construction and shape the query — `table_info` is a PRAGMA, so it must not sit on a per-query path.
75
+ *
76
+ * Note the interpolation: PRAGMA does not take bound parameters, so `table` is spliced. Every caller passes a
77
+ * module-level constant; never pass user input.
78
+ */
79
+ export function hasColumn(db: DatabaseSync, table: string, column: string): boolean {
80
+ try {
81
+ const rows = allRows<{ name: string }>(db.prepare(`PRAGMA table_info(${table})`))
82
+
83
+ return rows.some((r) => String(r.name) === column)
84
+ } catch {
85
+ return false
86
+ }
87
+ }
@@ -22,6 +22,8 @@
22
22
 
23
23
  import type { Kysely } from "kysely"
24
24
 
25
+ import type { NameKey, StreetKey } from "./street-normalize.ts"
26
+
25
27
  /**
26
28
  * One street roll-up. `(street_norm, postcode, locality_base)` is unique. `lat`/`lon` are the UNWEIGHTED mean of the
27
29
  * group's member address points (each source row = one point), so a cross-group weighted mean (`SUM(lat*point_count) /
@@ -32,7 +34,7 @@ export interface StreetCentroidTable {
32
34
  /**
33
35
  * Shared `normalizeStreetForKeyLocale` of the street — the build/query-consistent probe key.
34
36
  */
35
- street_norm: string
37
+ street_norm: StreetKey
36
38
  /**
37
39
  * The 5-digit postcode of this group, or null when the source row carried none.
38
40
  */
@@ -40,7 +42,7 @@ export interface StreetCentroidTable {
40
42
  /**
41
43
  * Arrondissement-stripped commune (`stripArrondissement(normalizeLocalityForKey(commune))`) — the fallback scope.
42
44
  */
43
- locality_base: string
45
+ locality_base: NameKey
44
46
  /**
45
47
  * Weighted-mean centroid latitude of the street's member points.
46
48
  */
@@ -75,6 +77,10 @@ export interface StreetCentroidTable {
75
77
  * name-evidence rerank folds the model's street surface with the SAME `foldStreetSurface` used to build this column
76
78
  * (the fold-parity contract), so it must not drift from `street_norm`'s richer normalizer. Indexed (`idx_sc_name`)
77
79
  * for a direct seek.
80
+ *
81
+ * Deliberately UNBRANDED despite the `name_key` column name: `foldStreetSurface` is a different fold from the
82
+ * {@link NameKey} one every other `name_key` column carries, and giving it that brand would invite exactly the
83
+ * cross-fold probe the brands exist to stop.
78
84
  */
79
85
  name_key: string
80
86
  }
@@ -23,10 +23,13 @@ import { DatabaseSync } from "node:sqlite"
23
23
 
24
24
  import type { StreetCentroidHit, StreetCentroidLookup } from "@mailwoman/resolver"
25
25
 
26
- import { hasTable } from "./sqlite-utils.ts"
26
+ import { hasTable, prepareGet, type PreparedGet } from "./sqlite-utils.ts"
27
27
  import {
28
28
  normalizeLocalityForKey,
29
29
  normalizeStreetForKeyLocale,
30
+ type NameKey,
31
+ type StreetKey,
32
+ streetLocaleForSurface,
30
33
  type StreetLocale,
31
34
  stripArrondissement,
32
35
  } from "./street-normalize.ts"
@@ -72,8 +75,8 @@ function extentRadiusM(minLat: number, maxLat: number, minLon: number, maxLon: n
72
75
  export class StreetCentroidSqliteLookup implements StreetCentroidLookup {
73
76
  readonly #db: DatabaseSync
74
77
  readonly #locale: StreetLocale
75
- readonly #byPostcode: ReturnType<DatabaseSync["prepare"]> | undefined
76
- readonly #byLocality: ReturnType<DatabaseSync["prepare"]> | undefined
78
+ readonly #byPostcode: PreparedGet<[postcode: string, street: StreetKey], AggRow> | undefined
79
+ readonly #byLocality: PreparedGet<[locality: NameKey, street: StreetKey], AggRow> | undefined
77
80
 
78
81
  /**
79
82
  * @param dbPath Shard path.
@@ -87,11 +90,13 @@ export class StreetCentroidSqliteLookup implements StreetCentroidLookup {
87
90
  // Degrade gracefully on an empty/tableless shard (interrupted build, stray 0-byte file): with no
88
91
  // `street_centroid` table this lookup is a no-op miss, not a crash (mirrors the address-point reader).
89
92
  if (hasTable(this.#db, "street_centroid")) {
90
- this.#byPostcode = this.#db.prepare(
93
+ this.#byPostcode = prepareGet(
94
+ this.#db,
91
95
  `SELECT ${AGG_SELECT} FROM street_centroid WHERE postcode = ? AND street_norm = ?`
92
96
  )
93
97
 
94
- this.#byLocality = this.#db.prepare(
98
+ this.#byLocality = prepareGet(
99
+ this.#db,
95
100
  `SELECT ${AGG_SELECT} FROM street_centroid WHERE locality_base = ? AND street_norm = ?`
96
101
  )
97
102
  }
@@ -99,19 +104,19 @@ export class StreetCentroidSqliteLookup implements StreetCentroidLookup {
99
104
 
100
105
  find(query: { street: string; postcode?: string; locality?: string }): StreetCentroidHit | null {
101
106
  if (!this.#byPostcode || !this.#byLocality) return null
102
- const streetNorm = normalizeStreetForKeyLocale(query.street, this.#locale)
107
+ const streetNorm = normalizeStreetForKeyLocale(query.street, streetLocaleForSurface(query.street, this.#locale))
103
108
 
104
109
  if (!streetNorm) return null
105
110
 
106
111
  let row: AggRow | undefined
107
112
 
108
113
  if (query.postcode?.trim()) {
109
- row = this.#byPostcode.get(query.postcode.trim(), streetNorm) as AggRow | undefined
114
+ row = this.#byPostcode(query.postcode.trim(), streetNorm)
110
115
  }
111
116
 
112
117
  if ((!row || row.lat == null) && query.locality?.trim()) {
113
118
  const base = stripArrondissement(normalizeLocalityForKey(query.locality))
114
- row = this.#byLocality.get(base, streetNorm) as AggRow | undefined
119
+ row = this.#byLocality(base, streetNorm)
115
120
  }
116
121
 
117
122
  if (!row || row.lat == null || row.lon == null) return null
@@ -196,10 +196,11 @@ export function buildStreetMorphologyFST(opts: BuildStreetMorphologyFSTOpts): Bu
196
196
  placetype: "street_affix",
197
197
  name: canonical,
198
198
  parentChain: [],
199
- // Fixed importance: street affixes are structurally unambiguous (Avenue is almost never
200
- // anything but street-typing). The morphology prior caps bias separately; this value
201
- // just feeds the cap formula `importance * cap`.
202
- importance: 1,
199
+ // Fixed referential score: street affixes are structurally unambiguous (Avenue is almost
200
+ // never anything but street-typing). The morphology prior caps bias separately; this value
201
+ // just feeds the cap formula `referential * cap`. No encyclopedic field — a street affix is
202
+ // not a place and has no article; absence here is the correct statement.
203
+ referential: 1,
203
204
  lat: 0,
204
205
  lon: 0,
205
206
  }