@mailwoman/corpus 8.6.0 → 9.0.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 (310) hide show
  1. package/README.md +1 -1
  2. package/out/src/adapter.d.ts +41 -0
  3. package/out/src/adapter.d.ts.map +1 -1
  4. package/out/src/adapter.js +46 -1
  5. package/out/src/adapter.js.map +1 -1
  6. package/out/src/adapters/ban/adapter.d.ts +4 -3
  7. package/out/src/adapters/ban/adapter.d.ts.map +1 -1
  8. package/out/src/adapters/ban/adapter.js +60 -67
  9. package/out/src/adapters/ban/adapter.js.map +1 -1
  10. package/out/src/adapters/ban/street-decompose.d.ts.map +1 -1
  11. package/out/src/adapters/ban/street-decompose.js +18 -25
  12. package/out/src/adapters/ban/street-decompose.js.map +1 -1
  13. package/out/src/adapters/fcc-bdc/adapter.d.ts +0 -6
  14. package/out/src/adapters/fcc-bdc/adapter.d.ts.map +1 -1
  15. package/out/src/adapters/fcc-bdc/adapter.js +2 -24
  16. package/out/src/adapters/fcc-bdc/adapter.js.map +1 -1
  17. package/out/src/adapters/geonames/adapter.d.ts.map +1 -1
  18. package/out/src/adapters/geonames/adapter.js +70 -76
  19. package/out/src/adapters/geonames/adapter.js.map +1 -1
  20. package/out/src/adapters/geonames-postal/adapter.d.ts.map +1 -1
  21. package/out/src/adapters/geonames-postal/adapter.js +44 -49
  22. package/out/src/adapters/geonames-postal/adapter.js.map +1 -1
  23. package/out/src/adapters/gnaf/adapter.d.ts.map +1 -1
  24. package/out/src/adapters/gnaf/adapter.js +4 -7
  25. package/out/src/adapters/gnaf/adapter.js.map +1 -1
  26. package/out/src/adapters/gnaf/assemble.d.ts.map +1 -1
  27. package/out/src/adapters/gnaf/assemble.js +6 -11
  28. package/out/src/adapters/gnaf/assemble.js.map +1 -1
  29. package/out/src/adapters/openaddresses/adapter.d.ts.map +1 -1
  30. package/out/src/adapters/openaddresses/adapter.js +2 -7
  31. package/out/src/adapters/openaddresses/adapter.js.map +1 -1
  32. package/out/src/adapters/overture/adapter.d.ts.map +1 -1
  33. package/out/src/adapters/overture/adapter.js +3 -7
  34. package/out/src/adapters/overture/adapter.js.map +1 -1
  35. package/out/src/adapters/state-hi-schools/adapter.d.ts.map +1 -1
  36. package/out/src/adapters/state-hi-schools/adapter.js +55 -73
  37. package/out/src/adapters/state-hi-schools/adapter.js.map +1 -1
  38. package/out/src/adapters/state-ia-contractors/adapter.d.ts.map +1 -1
  39. package/out/src/adapters/state-ia-contractors/adapter.js +58 -76
  40. package/out/src/adapters/state-ia-contractors/adapter.js.map +1 -1
  41. package/out/src/adapters/state-ny-notaries/adapter.d.ts.map +1 -1
  42. package/out/src/adapters/state-ny-notaries/adapter.js +67 -85
  43. package/out/src/adapters/state-ny-notaries/adapter.js.map +1 -1
  44. package/out/src/adapters/state-tx-notaries/adapter.d.ts.map +1 -1
  45. package/out/src/adapters/state-tx-notaries/adapter.js +69 -87
  46. package/out/src/adapters/state-tx-notaries/adapter.js.map +1 -1
  47. package/out/src/adapters/synth-po-box/adapter.d.ts.map +1 -1
  48. package/out/src/adapters/synth-po-box/adapter.js +7 -15
  49. package/out/src/adapters/synth-po-box/adapter.js.map +1 -1
  50. package/out/src/adapters/tiger/street-decompose.d.ts.map +1 -1
  51. package/out/src/adapters/tiger/street-decompose.js +18 -27
  52. package/out/src/adapters/tiger/street-decompose.js.map +1 -1
  53. package/out/src/adapters/usgov-hrsa-fqhc/adapter.d.ts +3 -3
  54. package/out/src/adapters/usgov-hrsa-fqhc/adapter.d.ts.map +1 -1
  55. package/out/src/adapters/usgov-hrsa-fqhc/adapter.js +62 -80
  56. package/out/src/adapters/usgov-hrsa-fqhc/adapter.js.map +1 -1
  57. package/out/src/adapters/usgov-imls-pls/adapter.d.ts.map +1 -1
  58. package/out/src/adapters/usgov-imls-pls/adapter.js +60 -82
  59. package/out/src/adapters/usgov-imls-pls/adapter.js.map +1 -1
  60. package/out/src/adapters/usgov-irs-bmf/adapter.d.ts.map +1 -1
  61. package/out/src/adapters/usgov-irs-bmf/adapter.js +58 -65
  62. package/out/src/adapters/usgov-irs-bmf/adapter.js.map +1 -1
  63. package/out/src/adapters/usgov-nad/adapter.d.ts.map +1 -1
  64. package/out/src/adapters/usgov-nad/adapter.js +5 -8
  65. package/out/src/adapters/usgov-nad/adapter.js.map +1 -1
  66. package/out/src/adapters/usgov-nppes/adapter.d.ts.map +1 -1
  67. package/out/src/adapters/usgov-nppes/adapter.js +58 -76
  68. package/out/src/adapters/usgov-nppes/adapter.js.map +1 -1
  69. package/out/src/adapters/usgov-samhsa-treatment-locator/adapter.d.ts.map +1 -1
  70. package/out/src/adapters/usgov-samhsa-treatment-locator/adapter.js +51 -71
  71. package/out/src/adapters/usgov-samhsa-treatment-locator/adapter.js.map +1 -1
  72. package/out/src/adapters/wof-admin-jp/adapter.d.ts.map +1 -1
  73. package/out/src/adapters/wof-admin-jp/adapter.js +24 -6
  74. package/out/src/adapters/wof-admin-jp/adapter.js.map +1 -1
  75. package/out/src/build.d.ts.map +1 -1
  76. package/out/src/build.js +2 -1
  77. package/out/src/build.js.map +1 -1
  78. package/out/src/golden.d.ts +5 -0
  79. package/out/src/golden.d.ts.map +1 -1
  80. package/out/src/golden.js +23 -9
  81. package/out/src/golden.js.map +1 -1
  82. package/out/src/parquet-wrapper/reader.d.ts +8 -0
  83. package/out/src/parquet-wrapper/reader.d.ts.map +1 -1
  84. package/out/src/parquet-wrapper/reader.js +16 -0
  85. package/out/src/parquet-wrapper/reader.js.map +1 -1
  86. package/out/src/parquet-wrapper/schema.d.ts +4 -0
  87. package/out/src/parquet-wrapper/schema.d.ts.map +1 -1
  88. package/out/src/parquet-wrapper/schema.js.map +1 -1
  89. package/out/src/parquet.d.ts.map +1 -1
  90. package/out/src/parquet.js +2 -13
  91. package/out/src/parquet.js.map +1 -1
  92. package/out/src/shard-recipes/anchor-absorption.d.ts.map +1 -1
  93. package/out/src/shard-recipes/anchor-absorption.js +2 -1
  94. package/out/src/shard-recipes/anchor-absorption.js.map +1 -1
  95. package/out/src/shard-recipes/country-balanced.d.ts.map +1 -1
  96. package/out/src/shard-recipes/country-balanced.js +68 -60
  97. package/out/src/shard-recipes/country-balanced.js.map +1 -1
  98. package/out/src/shard-recipes/fr-fragment.d.ts.map +1 -1
  99. package/out/src/shard-recipes/fr-fragment.js +8 -5
  100. package/out/src/shard-recipes/fr-fragment.js.map +1 -1
  101. package/out/src/shard-recipes/fr-order.d.ts.map +1 -1
  102. package/out/src/shard-recipes/fr-order.js +14 -58
  103. package/out/src/shard-recipes/fr-order.js.map +1 -1
  104. package/out/src/shard-recipes/german.d.ts.map +1 -1
  105. package/out/src/shard-recipes/german.js +11 -58
  106. package/out/src/shard-recipes/german.js.map +1 -1
  107. package/out/src/shard-recipes/index.d.ts.map +1 -1
  108. package/out/src/shard-recipes/index.js +2 -0
  109. package/out/src/shard-recipes/index.js.map +1 -1
  110. package/out/src/shard-recipes/intersection.d.ts +2 -2
  111. package/out/src/shard-recipes/intersection.d.ts.map +1 -1
  112. package/out/src/shard-recipes/intersection.js +24 -62
  113. package/out/src/shard-recipes/intersection.js.map +1 -1
  114. package/out/src/shard-recipes/locale.d.ts.map +1 -1
  115. package/out/src/shard-recipes/locale.js +14 -16
  116. package/out/src/shard-recipes/locale.js.map +1 -1
  117. package/out/src/shard-recipes/no-fragment.d.ts.map +1 -1
  118. package/out/src/shard-recipes/no-fragment.js +8 -5
  119. package/out/src/shard-recipes/no-fragment.js.map +1 -1
  120. package/out/src/shard-recipes/no-street-led.d.ts.map +1 -1
  121. package/out/src/shard-recipes/no-street-led.js +8 -5
  122. package/out/src/shard-recipes/no-street-led.js.map +1 -1
  123. package/out/src/shard-recipes/po-box-cedex.d.ts +3 -3
  124. package/out/src/shard-recipes/po-box-cedex.d.ts.map +1 -1
  125. package/out/src/shard-recipes/po-box-cedex.js +51 -94
  126. package/out/src/shard-recipes/po-box-cedex.js.map +1 -1
  127. package/out/src/shard-recipes/scaffold.d.ts +59 -9
  128. package/out/src/shard-recipes/scaffold.d.ts.map +1 -1
  129. package/out/src/shard-recipes/scaffold.js +51 -30
  130. package/out/src/shard-recipes/scaffold.js.map +1 -1
  131. package/out/src/shard-recipes/street-affix.d.ts.map +1 -1
  132. package/out/src/shard-recipes/street-affix.js +56 -82
  133. package/out/src/shard-recipes/street-affix.js.map +1 -1
  134. package/out/src/shard-recipes/sub-venue-sources.d.ts +231 -0
  135. package/out/src/shard-recipes/sub-venue-sources.d.ts.map +1 -0
  136. package/out/src/shard-recipes/sub-venue-sources.js +669 -0
  137. package/out/src/shard-recipes/sub-venue-sources.js.map +1 -0
  138. package/out/src/shard-recipes/sub-venue.d.ts +269 -0
  139. package/out/src/shard-recipes/sub-venue.d.ts.map +1 -0
  140. package/out/src/shard-recipes/sub-venue.js +709 -0
  141. package/out/src/shard-recipes/sub-venue.js.map +1 -0
  142. package/out/src/shard-recipes/unit.d.ts +1 -1
  143. package/out/src/shard-recipes/unit.d.ts.map +1 -1
  144. package/out/src/shard-recipes/unit.js +22 -64
  145. package/out/src/shard-recipes/unit.js.map +1 -1
  146. package/out/src/split.d.ts +7 -0
  147. package/out/src/split.d.ts.map +1 -1
  148. package/out/src/split.js +7 -0
  149. package/out/src/split.js.map +1 -1
  150. package/out/src/synthesize.d.ts.map +1 -1
  151. package/out/src/synthesize.js +1 -12
  152. package/out/src/synthesize.js.map +1 -1
  153. package/out/src/tools/align-shard.d.ts.map +1 -1
  154. package/out/src/tools/align-shard.js +3 -7
  155. package/out/src/tools/align-shard.js.map +1 -1
  156. package/out/src/tools/audit.d.ts.map +1 -1
  157. package/out/src/tools/audit.js +4 -4
  158. package/out/src/tools/audit.js.map +1 -1
  159. package/out/src/tools/corpus-stats.d.ts +2 -2
  160. package/out/src/tools/corpus-stats.d.ts.map +1 -1
  161. package/out/src/tools/corpus-stats.js +18 -31
  162. package/out/src/tools/corpus-stats.js.map +1 -1
  163. package/out/src/tools/fetch/download.d.ts.map +1 -1
  164. package/out/src/tools/fetch/download.js +5 -6
  165. package/out/src/tools/fetch/download.js.map +1 -1
  166. package/out/src/tools/fetch/imls-pls.d.ts.map +1 -1
  167. package/out/src/tools/fetch/imls-pls.js +2 -2
  168. package/out/src/tools/fetch/imls-pls.js.map +1 -1
  169. package/out/src/tools/fetch/index.d.ts +14 -1
  170. package/out/src/tools/fetch/index.d.ts.map +1 -1
  171. package/out/src/tools/fetch/index.js +14 -1
  172. package/out/src/tools/fetch/index.js.map +1 -1
  173. package/out/src/tools/fetch/nad.d.ts +1 -1
  174. package/out/src/tools/fetch/nad.js +1 -1
  175. package/out/src/tools/fetch/nppes.d.ts.map +1 -1
  176. package/out/src/tools/fetch/nppes.js +2 -1
  177. package/out/src/tools/fetch/nppes.js.map +1 -1
  178. package/out/src/tools/fetch/openaddresses.d.ts +1 -1
  179. package/out/src/tools/fetch/openaddresses.js +1 -1
  180. package/out/src/tools/fetch/ourairports.d.ts +45 -0
  181. package/out/src/tools/fetch/ourairports.d.ts.map +1 -0
  182. package/out/src/tools/fetch/ourairports.js +123 -0
  183. package/out/src/tools/fetch/ourairports.js.map +1 -0
  184. package/out/src/tools/fetch/wikidata-subvenue.d.ts +171 -0
  185. package/out/src/tools/fetch/wikidata-subvenue.d.ts.map +1 -0
  186. package/out/src/tools/fetch/wikidata-subvenue.js +275 -0
  187. package/out/src/tools/fetch/wikidata-subvenue.js.map +1 -0
  188. package/out/src/tools/golden-expand.d.ts.map +1 -1
  189. package/out/src/tools/golden-expand.js +18 -16
  190. package/out/src/tools/golden-expand.js.map +1 -1
  191. package/out/src/tools/golden-promote.d.ts.map +1 -1
  192. package/out/src/tools/golden-promote.js +12 -6
  193. package/out/src/tools/golden-promote.js.map +1 -1
  194. package/out/src/tools/golden-relabel-street.d.ts +196 -0
  195. package/out/src/tools/golden-relabel-street.d.ts.map +1 -0
  196. package/out/src/tools/golden-relabel-street.js +513 -0
  197. package/out/src/tools/golden-relabel-street.js.map +1 -0
  198. package/out/src/tools/index.d.ts +4 -0
  199. package/out/src/tools/index.d.ts.map +1 -1
  200. package/out/src/tools/index.js +4 -0
  201. package/out/src/tools/index.js.map +1 -1
  202. package/out/src/tools/jsonl-to-parquet.d.ts.map +1 -1
  203. package/out/src/tools/jsonl-to-parquet.js +3 -2
  204. package/out/src/tools/jsonl-to-parquet.js.map +1 -1
  205. package/out/src/tools/lint-shard-vocab.d.ts.map +1 -1
  206. package/out/src/tools/lint-shard-vocab.js +3 -2
  207. package/out/src/tools/lint-shard-vocab.js.map +1 -1
  208. package/out/src/tools/lint-shard.d.ts +3 -3
  209. package/out/src/tools/lint-shard.d.ts.map +1 -1
  210. package/out/src/tools/lint-shard.js +23 -32
  211. package/out/src/tools/lint-shard.js.map +1 -1
  212. package/out/src/tools/overlay-manifest.d.ts.map +1 -1
  213. package/out/src/tools/overlay-manifest.js +2 -1
  214. package/out/src/tools/overlay-manifest.js.map +1 -1
  215. package/out/src/tools/overture-subvenue.d.ts +112 -0
  216. package/out/src/tools/overture-subvenue.d.ts.map +1 -0
  217. package/out/src/tools/overture-subvenue.js +144 -0
  218. package/out/src/tools/overture-subvenue.js.map +1 -0
  219. package/out/src/tools/shard-kryptonite.d.ts +1 -1
  220. package/out/src/tools/shard-kryptonite.d.ts.map +1 -1
  221. package/out/src/tools/shard-kryptonite.js +5 -4
  222. package/out/src/tools/shard-kryptonite.js.map +1 -1
  223. package/out/src/tools/shard-translit.d.ts +6 -3
  224. package/out/src/tools/shard-translit.d.ts.map +1 -1
  225. package/out/src/tools/shard-translit.js +8 -6
  226. package/out/src/tools/shard-translit.js.map +1 -1
  227. package/out/src/tools/sub-venue-lexicon.d.ts +507 -0
  228. package/out/src/tools/sub-venue-lexicon.d.ts.map +1 -0
  229. package/out/src/tools/sub-venue-lexicon.js +817 -0
  230. package/out/src/tools/sub-venue-lexicon.js.map +1 -0
  231. package/out/src/tools/sub-venue-promotions.d.ts +94 -0
  232. package/out/src/tools/sub-venue-promotions.d.ts.map +1 -0
  233. package/out/src/tools/sub-venue-promotions.js +266 -0
  234. package/out/src/tools/sub-venue-promotions.js.map +1 -0
  235. package/out/src/wof-json.d.ts +2 -2
  236. package/out/src/wof-json.d.ts.map +1 -1
  237. package/out/src/wof-json.js +5 -8
  238. package/out/src/wof-json.js.map +1 -1
  239. package/package.json +8 -8
  240. package/src/adapter.ts +58 -1
  241. package/src/adapters/ban/adapter.ts +55 -65
  242. package/src/adapters/ban/street-decompose.ts +17 -25
  243. package/src/adapters/fcc-bdc/adapter.ts +2 -33
  244. package/src/adapters/geonames/adapter.ts +64 -72
  245. package/src/adapters/geonames-postal/adapter.ts +42 -50
  246. package/src/adapters/gnaf/adapter.ts +4 -7
  247. package/src/adapters/gnaf/assemble.ts +6 -10
  248. package/src/adapters/openaddresses/adapter.ts +2 -7
  249. package/src/adapters/overture/adapter.ts +3 -6
  250. package/src/adapters/state-hi-schools/adapter.ts +50 -73
  251. package/src/adapters/state-ia-contractors/adapter.ts +52 -76
  252. package/src/adapters/state-ny-notaries/adapter.ts +61 -85
  253. package/src/adapters/state-tx-notaries/adapter.ts +60 -84
  254. package/src/adapters/synth-po-box/adapter.ts +7 -17
  255. package/src/adapters/tiger/street-decompose.ts +21 -31
  256. package/src/adapters/usgov-hrsa-fqhc/adapter.ts +61 -84
  257. package/src/adapters/usgov-imls-pls/adapter.ts +54 -82
  258. package/src/adapters/usgov-irs-bmf/adapter.ts +53 -63
  259. package/src/adapters/usgov-nad/adapter.ts +5 -8
  260. package/src/adapters/usgov-nppes/adapter.ts +51 -75
  261. package/src/adapters/usgov-samhsa-treatment-locator/adapter.ts +45 -71
  262. package/src/adapters/wof-admin-jp/adapter.ts +34 -8
  263. package/src/build.ts +2 -1
  264. package/src/golden.ts +24 -9
  265. package/src/parquet-wrapper/reader.ts +19 -0
  266. package/src/parquet-wrapper/schema.ts +4 -0
  267. package/src/parquet.ts +3 -16
  268. package/src/shard-recipes/anchor-absorption.ts +2 -1
  269. package/src/shard-recipes/country-balanced.ts +69 -70
  270. package/src/shard-recipes/fr-fragment.ts +10 -7
  271. package/src/shard-recipes/fr-order.ts +13 -63
  272. package/src/shard-recipes/german.ts +12 -65
  273. package/src/shard-recipes/index.ts +2 -0
  274. package/src/shard-recipes/intersection.ts +32 -60
  275. package/src/shard-recipes/locale.ts +14 -16
  276. package/src/shard-recipes/no-fragment.ts +10 -7
  277. package/src/shard-recipes/no-street-led.ts +10 -7
  278. package/src/shard-recipes/po-box-cedex.ts +68 -119
  279. package/src/shard-recipes/scaffold.ts +82 -28
  280. package/src/shard-recipes/street-affix.ts +58 -95
  281. package/src/shard-recipes/sub-venue-sources.ts +895 -0
  282. package/src/shard-recipes/sub-venue.ts +982 -0
  283. package/src/shard-recipes/unit.ts +22 -70
  284. package/src/split.ts +7 -0
  285. package/src/synthesize.ts +1 -15
  286. package/src/tools/align-shard.ts +3 -6
  287. package/src/tools/audit.ts +6 -5
  288. package/src/tools/corpus-stats.ts +25 -33
  289. package/src/tools/fetch/download.ts +7 -5
  290. package/src/tools/fetch/imls-pls.ts +2 -2
  291. package/src/tools/fetch/index.ts +14 -1
  292. package/src/tools/fetch/nad.ts +1 -1
  293. package/src/tools/fetch/nppes.ts +2 -1
  294. package/src/tools/fetch/openaddresses.ts +1 -1
  295. package/src/tools/fetch/ourairports.ts +166 -0
  296. package/src/tools/fetch/wikidata-subvenue.ts +386 -0
  297. package/src/tools/golden-expand.ts +18 -13
  298. package/src/tools/golden-promote.ts +15 -6
  299. package/src/tools/golden-relabel-street.ts +748 -0
  300. package/src/tools/index.ts +4 -0
  301. package/src/tools/jsonl-to-parquet.ts +3 -2
  302. package/src/tools/lint-shard-vocab.ts +3 -2
  303. package/src/tools/lint-shard.ts +33 -36
  304. package/src/tools/overlay-manifest.ts +2 -1
  305. package/src/tools/overture-subvenue.ts +215 -0
  306. package/src/tools/shard-kryptonite.ts +5 -4
  307. package/src/tools/shard-translit.ts +12 -7
  308. package/src/tools/sub-venue-lexicon.ts +1250 -0
  309. package/src/tools/sub-venue-promotions.ts +330 -0
  310. package/src/wof-json.ts +5 -8
@@ -0,0 +1,895 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * The READ half of the `sub-venue` shard recipe (#35 step 4): which surfaces a locale may emit,
7
+ * what identifier follows them in that region, and which real names become the confound negatives.
8
+ * `sub-venue.ts` owns the WRITE half (line rendering + the emit loop) and the recipe registration;
9
+ * split because the two halves together run past the 750-line file cap, and this is the seam — one
10
+ * side reads disk and the ledger, the other side never touches either.
11
+ *
12
+ * Every rule in this file is a rule about EVIDENCE, and each one has a measurement behind it:
13
+ *
14
+ * - {@link promotedSurfacesFor} — a promotion names a designator, a phrase AND a locale, because the
15
+ * same token is a designator in one language and a disaster in another (`hall` is 0-of-3,273 in
16
+ * Great Britain and 35-of-40 in France).
17
+ * - {@link hasPromotedShape} — an `identifier-required` promotion is exercised only as
18
+ * `<phrase> <identifier>`. The de-DE `halle` board is the founding case: its 168-hit confound
19
+ * includes the CITY Halle (Saale), and only the identifier-bearing shape separates them.
20
+ * - {@link buildIdentifierModel} — the identifier distribution is measured per REGION, because it
21
+ * differs by country far more than the shared vocabulary suggests (GB gates 71% bare digit, ES
22
+ * 35% ranges).
23
+ * - {@link isVenueSlotName} / {@link isSignIdentifier} — the filters that keep bus-stop codes, route
24
+ * descriptions and street names out of the slots they would mislabel. Both were written FROM
25
+ * smoke output, not predicted; the docstrings name the strings that produced them.
26
+ */
27
+
28
+ import { readFileSync } from "node:fs"
29
+ import { createRequire } from "node:module"
30
+ import { dirname, resolve } from "node:path"
31
+ import { DatabaseSync } from "node:sqlite"
32
+
33
+ import { parseJSONStrict } from "@mailwoman/core/objects"
34
+ import { dataRootPath } from "@mailwoman/core/utils"
35
+
36
+ import type { LocaleBaseTuple } from "../synthesize-german.ts"
37
+ import { classifyIdentifier, readSubVenueJSONL, type SubVenueLexiconTable } from "../tools/sub-venue-lexicon.ts"
38
+ import { SUBVENUE_PROMOTIONS, type SubVenuePromotion } from "../tools/sub-venue-promotions.ts"
39
+ import { readTuples as readLocaleTuples, type LocalePart } from "./locale.ts"
40
+ import { makeMulberry32, readTuples as readShardTuples } from "./scaffold.ts"
41
+
42
+ //#region Lexicon
43
+
44
+ const require_ = createRequire(import.meta.url)
45
+
46
+ /**
47
+ * The committed lexicon, resolved through the package manifest rather than by counting `..` segments — the same file
48
+ * sits at `corpus/data/` from both the source tree and the compiled `out/` tree, and `createRequire().resolve` finds it
49
+ * from either without a `__isCompiledTree` flag of its own.
50
+ */
51
+ export function defaultLexiconPath(): string {
52
+ return resolve(dirname(require_.resolve("@mailwoman/corpus/package.json")), "data", "sub-venue-lexicon.json")
53
+ }
54
+
55
+ /**
56
+ * Read and parse the lexicon. Strict: a corrupt lexicon is a build failure, not a fallback.
57
+ */
58
+ export function readSubVenueLexicon(path: string = defaultLexiconPath()): SubVenueLexiconTable {
59
+ return parseJSONStrict<SubVenueLexiconTable>(readFileSync(path, "utf8"))
60
+ }
61
+
62
+ //#endregion
63
+
64
+ //#region Name filters
65
+
66
+ /**
67
+ * Longest venue name kept for the venue slot. The extracts carry a tail of route descriptions and junction names
68
+ * ("Furnival Gate/Moorhead MH2", "SPEKE HALL ROAD/HILLFOOT AVE") that are not venue names at all; a length cap plus
69
+ * {@link isCleanName} removes the bulk of them without a hand list.
70
+ */
71
+ const MAX_VENUE_NAME_LENGTH = 44
72
+
73
+ /**
74
+ * Shortest kept name. Below four characters a "name" is an airport code or a platform letter, not something that can
75
+ * stand in a venue slot.
76
+ */
77
+ const MIN_NAME_LENGTH = 4
78
+
79
+ /**
80
+ * Longest attested sub-venue string, in whitespace tokens. `Terminal 1 Flugsteig B` is four and real; anything longer
81
+ * is a venue's own name that happens to contain a designator.
82
+ */
83
+ const MAX_ATTESTED_TOKENS = 4
84
+
85
+ /**
86
+ * Reject a name that is a route description, a junction, or a code rather than a name: embedded `/`, `;`, `,`, `:`,
87
+ * parentheses, no letters, or a bare source code. Measured motivation, not taste — the GB extract's `platform` tier
88
+ * contributes 7,549 `other`-shaped names like `kntgwdgj` and `SPEKE HALL ROAD/HILLFOOT AVE`.
89
+ */
90
+ export function isCleanName(name: string): boolean {
91
+ if (name.length < MIN_NAME_LENGTH || name.length > MAX_VENUE_NAME_LENGTH) return false
92
+
93
+ // A colon in a name is a qualifier, not part of it — "Porte 4 : Ferrys", "Derby College: Ilkeston Campus".
94
+ if (/[/;,:()[\]<>|]/.test(name)) return false
95
+
96
+ if (!/\p{L}/u.test(name)) return false
97
+
98
+ // A name that is all lower-case with no space is a source code (`kntgwdgj`), not a name.
99
+ if (!name.includes(" ") && name === name.toLowerCase()) return false
100
+
101
+ return true
102
+ }
103
+
104
+ /**
105
+ * Head words that make a "name" something other than a venue name, checked on the FIRST token.
106
+ *
107
+ * Two populations, both found by reading the 2026-08-05 smoke output rather than predicted:
108
+ *
109
+ * - **Street types.** A bus stop is routinely named after the street it stands on, so the FR extract offers `Rue de la
110
+ * Porte Bergault` as a `porte` confound. It IS a confound, but it is a STREET, and putting it in the venue slot would
111
+ * train `Rue …` as a venue name — trading one mislabel for another.
112
+ * - **Stop qualifiers.** British stop names carry a position prefix (`OPPOSITE BRICKLEHAMPTON HALL`, `ADJ THE GREEN`)
113
+ * that names a relationship rather than a place.
114
+ *
115
+ * Per-language and short on purpose: this is a head-token filter over four Latin languages, not a street-type
116
+ * gazetteer. `@mailwoman/corpus` cannot reach the shipped street-type lexicon (it lives behind the gazetteer build),
117
+ * and a longer list here would be a second, drifting copy of it.
118
+ */
119
+ const NON_VENUE_HEAD_WORDS: ReadonlySet<string> = new Set([
120
+ // en
121
+ "street",
122
+ "road",
123
+ "lane",
124
+ "avenue",
125
+ "drive",
126
+ "way",
127
+ "close",
128
+ "opposite",
129
+ "opp",
130
+ "adj",
131
+ "adjacent",
132
+ "outside",
133
+ "near",
134
+ "nr",
135
+ "stop",
136
+ // fr
137
+ "rue",
138
+ "boulevard",
139
+ "chemin",
140
+ "impasse",
141
+ "allée",
142
+ "allee",
143
+ "route",
144
+ "quai",
145
+ "place",
146
+ // es / ca
147
+ "calle",
148
+ "carrer",
149
+ "avenida",
150
+ "avinguda",
151
+ "carretera",
152
+ "plaza",
153
+ "paseo",
154
+ "camino",
155
+ // de
156
+ "straße",
157
+ "strasse",
158
+ "weg",
159
+ "platz",
160
+ "gasse",
161
+ ])
162
+
163
+ /**
164
+ * Street types that appear at the END of an anglophone street name, checked on the LAST token.
165
+ *
166
+ * The head-word filter cannot see these — English streets are `<name> <type>`, so `Strawberry Hall Lane` and `Guinea
167
+ * Hall Mews` reached the venue slot in the second smoke and would have trained a STREET as a venue name. The
168
+ * street-side confound already has its own class (`designator-street`), drawn from real (street, locality, postcode)
169
+ * pairings, so nothing is lost by refusing these here. German is suffix-compounded rather than suffix-worded and is
170
+ * handled by {@link GERMAN_STREET_TAIL} instead.
171
+ */
172
+ const STREET_TAIL_WORDS: ReadonlySet<string> = new Set([
173
+ "street",
174
+ "road",
175
+ "lane",
176
+ "avenue",
177
+ "drive",
178
+ "close",
179
+ "crescent",
180
+ "mews",
181
+ "terrace",
182
+ "walk",
183
+ "way",
184
+ "court",
185
+ "gardens",
186
+ "grove",
187
+ "rise",
188
+ "row",
189
+ "parade",
190
+ "esplanade",
191
+ ])
192
+
193
+ const GERMAN_STREET_TAIL = /(?:straße|strasse|weg|platz|gasse|allee|ring|damm)$/
194
+
195
+ /**
196
+ * Is this name usable as a VENUE-slot string (positive venue or negative confound venue)?
197
+ */
198
+ export function isVenueSlotName(name: string): boolean {
199
+ if (!isCleanName(name)) return false
200
+ const words = name.toLowerCase().match(/[\p{L}]+/gu) ?? []
201
+
202
+ if (!words.length) return false
203
+
204
+ if (NON_VENUE_HEAD_WORDS.has(words[0]!)) return false
205
+ const tail = words.at(-1)!
206
+
207
+ return !STREET_TAIL_WORDS.has(tail) && !GERMAN_STREET_TAIL.test(tail)
208
+ }
209
+
210
+ //#endregion
211
+
212
+ //#region Promotions
213
+
214
+ /**
215
+ * A (designator, surface) pair usable in one locale, with the shape constraint the ledger attached to it.
216
+ */
217
+ export interface PromotedSurface {
218
+ designatorID: string
219
+ phrase: string
220
+ /**
221
+ * Rendering form — the phrase title-cased, which is how a sign writes it.
222
+ */
223
+ surface: string
224
+ identifierRequired: boolean
225
+ modifierEligible: boolean
226
+ }
227
+
228
+ /**
229
+ * Title-case a designator phrase for rendering (`flugsteig` → `Flugsteig`). Single-token by construction: every
230
+ * promoted phrase in the ledger is one word.
231
+ */
232
+ export function titleCase(phrase: string): string {
233
+ return phrase.charAt(0).toUpperCase() + phrase.slice(1)
234
+ }
235
+
236
+ /**
237
+ * Word-boundary containment, script-aware enough for the Latin legs this recipe runs.
238
+ *
239
+ * The boundary matters: without it `gate` matches Briggate and `wing` matches Wingate, which is how a harvest teaches
240
+ * itself that a Yorkshire street is a sub-venue. (The lexicon's own harvest carries the same rule, and it drops the
241
+ * boundary only for Han/Kana, which has no word boundaries and no leg here.)
242
+ */
243
+ export function containsPhrase(lowerName: string, phrase: string): boolean {
244
+ let from = 0
245
+
246
+ for (;;) {
247
+ const at = lowerName.indexOf(phrase, from)
248
+
249
+ if (at === -1) return false
250
+ const before = at === 0 ? "" : lowerName[at - 1]!
251
+ const after = lowerName[at + phrase.length] ?? ""
252
+
253
+ if (!/[\p{L}\p{N}]/u.test(before) && !/[\p{L}\p{N}]/u.test(after)) return true
254
+ from = at + 1
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Identifier-shape classes a sub-venue string may sample.
260
+ *
261
+ * `other` is excluded: it is the junk bucket the lexicon's own examples advertise — `C15/C15A+C15B`, `Segelflug Start
262
+ * 06`, `152, 240`, `de.05374048.drabenderhoehezeithstrasse`. Everything else is a real identifier register.
263
+ */
264
+ const USABLE_IDENTIFIER_SHAPES: ReadonlySet<string> = new Set([
265
+ "digit",
266
+ "letter",
267
+ "letter-digit",
268
+ "digit-letter",
269
+ "range",
270
+ ])
271
+
272
+ /**
273
+ * One atom of a sign identifier: a short number (`5`, `205`), a single letter (`B`), a letter-then-number (`A12`,
274
+ * `B05`), or a number-then-letter (`2F`, `4S`).
275
+ *
276
+ * The **single** leading letter is the load-bearing part, and it is what the third smoke found. A letter-digit ref with
277
+ * a MULTI-letter prefix is not an identifier read off a sign, it is a network code: the lexicon's own examples of that
278
+ * shape are `BS04`, `BS07`, `PWP2`, `WSW3687`, `RQ8` — campus and platform codes — and the GB extract offers `Arundel
279
+ * Gate AG1` … `AG124`, fourteen bus stops on a Sheffield STREET called Arundel Gate whose stop codes begin with its
280
+ * initials. Admitting two-letter prefixes put all fourteen in the attested pool as `unit`.
281
+ */
282
+ const SIGN_IDENTIFIER_ATOM = /^(?:[0-9]{1,3}|[A-Za-z]|[A-Za-z][0-9]{1,3}|[0-9]{1,3}[A-Za-z]{1,2})$/
283
+
284
+ /**
285
+ * Is `value` what a sub-venue identifier looks like on a sign — an atom, or a range of two?
286
+ *
287
+ * Applied on top of {@link USABLE_IDENTIFIER_SHAPES}, which classifies but does not bound: `WSW3687` classifies as
288
+ * `letter-digit` and is a station code.
289
+ */
290
+ export function isSignIdentifier(value: string): boolean {
291
+ const parts = value.split(/[/-]/)
292
+
293
+ if (parts.length > 2 || parts.some((p) => !p)) return false
294
+
295
+ return parts.every((part) => SIGN_IDENTIFIER_ATOM.test(part))
296
+ }
297
+
298
+ /**
299
+ * Does a real extract name exercise `promoted` in one of the two shapes this shard teaches?
300
+ *
301
+ * Only `<phrase> <identifier>` and (English legs) `<modifier> <phrase>` qualify. That is stricter than "contains the
302
+ * phrase", and the 2026-08-05 smoke is why: the loose test put `Glasgow Clyde College - Langside Campus`, `Terminal de
303
+ * Ferry de Bilbao` and — worst — `Halle Wohnstadt Nord` into the attested pool, the last of which is a bare German
304
+ * `Halle` wearing a name where the ledger requires an identifier. A whole venue's name is not a sub-venue string, and
305
+ * an attested string that violates the promotion's own shape constraint is not attestation of it.
306
+ *
307
+ * The follower is checked with {@link classifyIdentifier} plus {@link isSignIdentifier} rather than "any following
308
+ * word": `Wohnstadt` is a word, `8` is an identifier, and the de-DE board turns on exactly that difference.
309
+ */
310
+ export function hasPromotedShape(
311
+ lowerName: string,
312
+ promoted: PromotedSurface,
313
+ modifiers: readonly string[] = [],
314
+ english = false
315
+ ): boolean {
316
+ if (!containsPhrase(lowerName, promoted.phrase)) return false
317
+
318
+ const escaped = promoted.phrase.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
319
+ const withFollower = new RegExp(`(?:^|[^\\p{L}\\p{N}])${escaped}\\s+(\\S+)`, "u")
320
+ const follower = withFollower.exec(lowerName)?.[1]
321
+
322
+ if (follower && USABLE_IDENTIFIER_SHAPES.has(classifyIdentifier(follower)) && isSignIdentifier(follower)) return true
323
+
324
+ if (promoted.identifierRequired) return false
325
+
326
+ if (!english || !promoted.modifierEligible) return false
327
+
328
+ return modifiers.some((modifier) =>
329
+ new RegExp(`(?:^|[^\\p{L}\\p{N}])${modifier}\\s+${escaped}(?![\\p{L}\\p{N}])`, "u").test(lowerName)
330
+ )
331
+ }
332
+
333
+ /**
334
+ * {@link hasPromotedShape} plus the length cap that makes a string usable AS a sub-venue span.
335
+ *
336
+ * The two are separate because the difference decides which POOL a name lands in. `navette n2 vers terminal 2g` carries
337
+ * the promoted shape and is five tokens: too long to be a sub-venue string, and disqualified from being a NEGATIVE
338
+ * precisely because it does contain the shape. It belongs to neither pool, and only splitting the test says so.
339
+ */
340
+ export function matchesPromotedShape(
341
+ lowerName: string,
342
+ promoted: PromotedSurface,
343
+ modifiers: readonly string[] = [],
344
+ english = false
345
+ ): boolean {
346
+ if ((lowerName.match(/\S+/g) ?? []).length > MAX_ATTESTED_TOKENS) return false
347
+
348
+ return hasPromotedShape(lowerName, promoted, modifiers, english)
349
+ }
350
+
351
+ /**
352
+ * The surfaces a locale may emit as `unit`.
353
+ *
354
+ * Two inputs, and the difference between them is the advisory/binding split the ledger's docstring names:
355
+ *
356
+ * - The SHIPPED English vocabulary (`neural/venue-structure.ts`, re-declared in the lexicon as `shipped: true`) is a flat
357
+ * English list with no locale gate. It is promoted-by-shipping for the English legs, because the span proposer fires
358
+ * on it there today and the eval board's target cases are drawn from it.
359
+ * - {@link SUBVENUE_PROMOTIONS} adds the localized surfaces and SUBTRACTS the rejections. A rejection of a shipped
360
+ * designator cannot un-ship it (nothing here stops the proposer firing on "Red Wing"), but it absolutely stops this
361
+ * recipe generating a positive: en-US `wing` produces negatives instead.
362
+ */
363
+ export function promotedSurfacesFor(
364
+ locale: string,
365
+ lexicon: SubVenueLexiconTable,
366
+ promotions: readonly SubVenuePromotion[] = SUBVENUE_PROMOTIONS
367
+ ): PromotedSurface[] {
368
+ const language = locale.split("-")[0]!
369
+
370
+ const rejected = new Set(
371
+ promotions.filter((p) => p.decision === "reject" && p.locale === locale).map((p) => `${p.designatorID}|${p.phrase}`)
372
+ )
373
+
374
+ const out = new Map<string, PromotedSurface>()
375
+
376
+ if (language === "en") {
377
+ for (const designator of lexicon.designators) {
378
+ if (!designator.shipped) continue
379
+ const key = `${designator.id}|${designator.id}`
380
+
381
+ if (rejected.has(key)) continue
382
+
383
+ out.set(key, {
384
+ designatorID: designator.id,
385
+ phrase: designator.id,
386
+ surface: titleCase(designator.id),
387
+ identifierRequired: false,
388
+ modifierEligible: designator.modifierEligible,
389
+ })
390
+ }
391
+ }
392
+
393
+ for (const promotion of promotions) {
394
+ if (promotion.decision !== "promote" || promotion.locale !== locale) continue
395
+ const designator = lexicon.designators.find((d) => d.id === promotion.designatorID)
396
+ const key = `${promotion.designatorID}|${promotion.phrase}`
397
+
398
+ if (rejected.has(key)) continue
399
+
400
+ out.set(key, {
401
+ designatorID: promotion.designatorID,
402
+ phrase: promotion.phrase,
403
+ surface: titleCase(promotion.phrase),
404
+ identifierRequired: promotion.shape === "identifier-required",
405
+ // A promotion marks a SURFACE usable; it does not widen the modifier grammar (the ledger's
406
+ // own words). Modifier eligibility stays the designator's, and only English legs read it.
407
+ modifierEligible: Boolean(designator?.modifierEligible) && promotion.shape !== "identifier-required",
408
+ })
409
+ }
410
+
411
+ return [...out.values()].toSorted((a, b) => a.phrase.localeCompare(b.phrase))
412
+ }
413
+
414
+ /**
415
+ * The phrases REJECTED in a locale — the negatives' vocabulary.
416
+ */
417
+ export function rejectedPhrasesFor(
418
+ locale: string,
419
+ promotions: readonly SubVenuePromotion[] = SUBVENUE_PROMOTIONS
420
+ ): string[] {
421
+ return promotions.filter((p) => p.decision === "reject" && p.locale === locale).map((p) => p.phrase)
422
+ }
423
+
424
+ //#endregion
425
+
426
+ //#region Identifier sampling
427
+
428
+ interface ShapeBucket {
429
+ shape: string
430
+ observations: number
431
+ examples: string[]
432
+ }
433
+
434
+ /**
435
+ * A region's identifier distributions: per designator, plus the pooled fallback.
436
+ */
437
+ export interface IdentifierModel {
438
+ byDesignator: Map<string, ShapeBucket[]>
439
+ pooled: ShapeBucket[]
440
+ }
441
+
442
+ /**
443
+ * Designators whose refs the pooled fallback is built from.
444
+ *
445
+ * `platform` and `station` are excluded deliberately even though they are by far the largest buckets (GB alone has
446
+ * 25,109 platform digits): a platform ref is a network identifier, and its `other` bucket is 7,549 rows of
447
+ * `kntgwdgj`-style source codes. The three kept here are the ones whose refs are what a person reads off a sign.
448
+ */
449
+ const POOLED_IDENTIFIER_DESIGNATORS: readonly string[] = ["gate", "terminal", "campus"]
450
+
451
+ /**
452
+ * Minimum usable observations before a (region, designator) uses its OWN identifier distribution.
453
+ *
454
+ * Below this the sample is noise — ES `terminal` has 5 usable refs — so the leg falls back to the region's pooled
455
+ * gate+terminal+campus distribution, which is what the lexicon measured at volume (452–655 refs per region). The
456
+ * fallback keeps the axis that matters (the REGION) and drops only the per-designator refinement.
457
+ */
458
+ const MIN_OWN_SHAPE_OBSERVATIONS = 20
459
+
460
+ /**
461
+ * Build the per-region identifier model out of the lexicon's `identifierShapes`.
462
+ */
463
+ export function buildIdentifierModel(lexicon: SubVenueLexiconTable, region: string): IdentifierModel {
464
+ const byDesignator = new Map<string, ShapeBucket[]>()
465
+ const pooled: ShapeBucket[] = []
466
+
467
+ for (const row of lexicon.identifierShapes) {
468
+ if (row.region !== region) continue
469
+
470
+ if (!USABLE_IDENTIFIER_SHAPES.has(row.shape)) continue
471
+ const examples = row.examples.filter((e) => isSignIdentifier(e))
472
+
473
+ if (!examples.length) continue
474
+ const bucket: ShapeBucket = { shape: row.shape, observations: row.observations, examples }
475
+ const list = byDesignator.get(row.designatorID)
476
+
477
+ if (list) {
478
+ list.push(bucket)
479
+ } else {
480
+ byDesignator.set(row.designatorID, [bucket])
481
+ }
482
+
483
+ if (POOLED_IDENTIFIER_DESIGNATORS.includes(row.designatorID)) {
484
+ pooled.push(bucket)
485
+ }
486
+ }
487
+
488
+ return { byDesignator, pooled }
489
+ }
490
+
491
+ /**
492
+ * Draw one identifier for `designatorID` in this region.
493
+ *
494
+ * Own distribution when it has {@link MIN_OWN_SHAPE_OBSERVATIONS} usable observations, else the region's pooled one.
495
+ * Shapes are weighted by observation count and an example is drawn uniformly inside the chosen shape — the lexicon
496
+ * ships up to eight per shape, which is the resolution available.
497
+ */
498
+ export function sampleIdentifier(model: IdentifierModel, designatorID: string, random: () => number): string | null {
499
+ const own = model.byDesignator.get(designatorID) ?? []
500
+ const ownTotal = own.reduce((sum, b) => sum + b.observations, 0)
501
+ const buckets = ownTotal >= MIN_OWN_SHAPE_OBSERVATIONS ? own : model.pooled
502
+
503
+ if (!buckets.length) return null
504
+ const total = buckets.reduce((sum, b) => sum + b.observations, 0)
505
+ let r = random() * total
506
+
507
+ for (const bucket of buckets) {
508
+ r -= bucket.observations
509
+
510
+ if (r < 0) return bucket.examples[Math.floor(random() * bucket.examples.length)]!
511
+ }
512
+
513
+ const last = buckets.at(-1)!
514
+
515
+ return last.examples[Math.floor(random() * last.examples.length)]!
516
+ }
517
+
518
+ //#endregion
519
+
520
+ //#region Pools
521
+
522
+ /**
523
+ * Per-leg pools read off disk once.
524
+ */
525
+ export interface LegPools {
526
+ context: LocaleBaseTuple[]
527
+ /**
528
+ * Real venue names for the venue slot (stations, airports, campuses; US: airports, terminals, hospitals, rail).
529
+ */
530
+ venues: string[]
531
+ /**
532
+ * Real sub-venue strings, already filtered to this locale's promoted surfaces and their shape constraint.
533
+ */
534
+ attested: string[]
535
+ /**
536
+ * Real names carrying a surface REJECTED in this locale, for the venue slot of a negative row.
537
+ */
538
+ rejectedVenues: string[]
539
+ /**
540
+ * Real names that contain a designator inside a longer proper name — "Lochaline Ferry Terminal", "Kingdom Hall". The
541
+ * whole string is `venue`; nothing in it is `unit`.
542
+ */
543
+ longerNames: string[]
544
+ /**
545
+ * Real names carrying a PROMOTED phrase in a shape the promotion does NOT cover — `Halle Rosengarten`, `PHOENIX
546
+ * Halle`, `Halle-Südstadt`. The other half of an `identifier-required` ruling, and the only thing that teaches the
547
+ * shape boundary rather than the word: de-DE has no `reject` row at all, so without this class its 168-hit confound
548
+ * (97 of them the CITY Halle) would go untaught while its 32-hit promotion got 11,000 rows.
549
+ */
550
+ unpromotedShapes: string[]
551
+ }
552
+
553
+ /**
554
+ * The name pools a source contributes. `attested` and `unpromotedShapes` come only from an extract — poi.db carries no
555
+ * `tier` and no localized names, so it cannot say which side of a shape boundary a name sits on.
556
+ */
557
+ export type NamePools = Pick<LegPools, "venues" | "attested" | "rejectedVenues" | "longerNames" | "unpromotedShapes">
558
+
559
+ /**
560
+ * The pools a source that has nothing to say contributes — en-US has no OSM extract, and DE/ES/GB are outside poi.db's
561
+ * four countries. Empty rather than absent so a leg's merge is unconditional.
562
+ */
563
+ export const EMPTY_NAME_POOLS: NamePools = {
564
+ venues: [],
565
+ attested: [],
566
+ rejectedVenues: [],
567
+ longerNames: [],
568
+ unpromotedShapes: [],
569
+ }
570
+
571
+ /**
572
+ * An extract row, as `sub-venue-extract` writes it. `SubVenueHarvestRow` plus the `tier` the harvest does not need and
573
+ * this recipe does: `venue` rows (station, airport, campus) fill the venue slot.
574
+ */
575
+ interface SubVenueExtractRow {
576
+ designatorID: string
577
+ tier?: string
578
+ name?: string | null
579
+ ref?: string | null
580
+ }
581
+
582
+ /**
583
+ * What every pool reader needs to know about the leg it is reading for.
584
+ */
585
+ export interface PoolQuery {
586
+ promoted: readonly PromotedSurface[]
587
+ rejectedPhrases: readonly string[]
588
+ designatorPhrases: readonly string[]
589
+ modifiers: readonly string[]
590
+ english: boolean
591
+ }
592
+
593
+ /**
594
+ * Is this name a designator sitting inside a longer proper name — the "Grand Central Terminal" class the span
595
+ * proposer's second structural guard already knows about, and which the corpus has to agree with?
596
+ */
597
+ function isLongerProperName(low: string, name: string, designatorPhrases: readonly string[]): boolean {
598
+ return designatorPhrases.some((phrase) => containsPhrase(low, phrase) && !low.startsWith(phrase) && !/\d/.test(name))
599
+ }
600
+
601
+ /**
602
+ * Read one OSM extract and split it into name pools.
603
+ */
604
+ export function readExtractPools(path: string, query: PoolQuery): NamePools {
605
+ const rows = readSubVenueJSONL(path) as unknown as SubVenueExtractRow[]
606
+ const venues = new Set<string>()
607
+ const attested = new Set<string>()
608
+ const rejectedVenues = new Set<string>()
609
+ const longerNames = new Set<string>()
610
+ const unpromotedShapes = new Set<string>()
611
+
612
+ for (const row of rows) {
613
+ const name = (row.name ?? "").trim()
614
+
615
+ if (!name || !isCleanName(name)) continue
616
+ const low = name.toLowerCase()
617
+ const venueSlot = isVenueSlotName(name)
618
+
619
+ if (row.tier === "venue" && venueSlot) {
620
+ venues.add(name)
621
+ }
622
+
623
+ for (const promoted of query.promoted) {
624
+ if (!containsPhrase(low, promoted.phrase)) continue
625
+
626
+ if (!hasPromotedShape(low, promoted, query.modifiers, query.english)) {
627
+ if (venueSlot) {
628
+ unpromotedShapes.add(name)
629
+ }
630
+ } else if (matchesPromotedShape(low, promoted, query.modifiers, query.english)) {
631
+ attested.add(name)
632
+ }
633
+ }
634
+
635
+ if (!venueSlot) continue
636
+
637
+ if (query.rejectedPhrases.some((phrase) => containsPhrase(low, phrase))) {
638
+ rejectedVenues.add(name)
639
+ }
640
+
641
+ if (isLongerProperName(low, name, query.designatorPhrases)) {
642
+ longerNames.add(name)
643
+ }
644
+ }
645
+
646
+ return {
647
+ venues: [...venues],
648
+ attested: [...attested],
649
+ rejectedVenues: [...rejectedVenues],
650
+ longerNames: [...longerNames],
651
+ unpromotedShapes: [...unpromotedShapes],
652
+ }
653
+ }
654
+
655
+ /**
656
+ * Poi.db category ids this recipe reads, by category NAME (ids are assigned per build, so they are resolved at run time
657
+ * out of `poi_category_codes`).
658
+ *
659
+ * The venue set is the transport + institution categories whose rows name a whole venue — exactly what
660
+ * `overture-subvenue.ts` REJECTED as a lexicon source ("4,071 of them are the token `airport` in the aerodrome's own
661
+ * name") and exactly what a venue slot wants. The confound set is that file's rejection list read as a source of
662
+ * negatives: `shoe_store` contributes 708 hits of `wing` because Red Wing sells boots, and that is the row this shard
663
+ * needs to see with `wing` NOT tagged `unit`.
664
+ */
665
+ const POI_VENUE_CATEGORIES: readonly string[] = [
666
+ "airport",
667
+ "airport_terminal",
668
+ "train_station",
669
+ "hospital",
670
+ "college_university",
671
+ ]
672
+
673
+ const POI_CONFOUND_CATEGORIES: readonly string[] = [
674
+ "shoe_store",
675
+ "furniture_store",
676
+ "home_decor_store",
677
+ "town_hall",
678
+ "martial_arts_club",
679
+ "chicken_wings_restaurant",
680
+ "fire_station",
681
+ ]
682
+
683
+ /**
684
+ * Read the venue + confound pools for a country out of `poi.db`.
685
+ *
686
+ * Poi.db is FOUR COUNTRIES — US 11,521,612 / CA 794,418 / FR 721,352 / MX 644,316 — so this is reachable for en-US and
687
+ * fr-FR and nothing else, and a zero here is evidence of absence in four countries rather than in the world.
688
+ */
689
+ export function readPOIPools(dbPath: string, country: string, query: PoolQuery): NamePools {
690
+ const db = new DatabaseSync(dbPath, { readOnly: true })
691
+
692
+ try {
693
+ const codes = db.prepare("select id, category from poi_category_codes").all() as Array<{
694
+ id: number
695
+ category: string
696
+ }>
697
+
698
+ const byName = new Map(codes.map((c) => [c.category, c.id]))
699
+ const venueIDs = POI_VENUE_CATEGORIES.map((c) => byName.get(c)).filter((id): id is number => id != null)
700
+ const confoundIDs = POI_CONFOUND_CATEGORIES.map((c) => byName.get(c)).filter((id): id is number => id != null)
701
+ const wanted = [...venueIDs, ...confoundIDs]
702
+
703
+ if (!wanted.length) throw new Error(`poi.db at ${dbPath} has none of the expected categories`)
704
+
705
+ // One filtered full scan (measured 4.3 s over all 13,681,698 rows, 2026-08-05) rather than one
706
+ // query per category: `poi` is `without rowid` on (h3_cell, category_id, …), so a category
707
+ // predicate scans either way and scanning once is the cheaper shape.
708
+ const rows = db
709
+ .prepare(
710
+ `select name, category_id from poi where country = ? and name is not null and category_id in (${wanted.map(() => "?").join(",")})`
711
+ )
712
+ .all(country, ...wanted) as Array<{ name: string; category_id: number }>
713
+
714
+ const venueSet = new Set(venueIDs)
715
+ const venues = new Set<string>()
716
+ const rejectedVenues = new Set<string>()
717
+ const longerNames = new Set<string>()
718
+
719
+ for (const row of rows) {
720
+ const name = row.name.trim()
721
+
722
+ if (!isVenueSlotName(name)) continue
723
+ const low = name.toLowerCase()
724
+
725
+ if (venueSet.has(row.category_id)) {
726
+ venues.add(name)
727
+ }
728
+
729
+ if (query.rejectedPhrases.some((phrase) => containsPhrase(low, phrase))) {
730
+ rejectedVenues.add(name)
731
+ }
732
+
733
+ if (isLongerProperName(low, name, query.designatorPhrases)) {
734
+ longerNames.add(name)
735
+ }
736
+ }
737
+
738
+ return {
739
+ venues: [...venues],
740
+ attested: [],
741
+ rejectedVenues: [...rejectedVenues],
742
+ longerNames: [...longerNames],
743
+ unpromotedShapes: [],
744
+ }
745
+ } finally {
746
+ db.close()
747
+ }
748
+ }
749
+
750
+ /**
751
+ * Merge two sources' name pools.
752
+ */
753
+ export function mergeNamePools(a: NamePools, b: NamePools): NamePools {
754
+ return {
755
+ venues: [...a.venues, ...b.venues],
756
+ attested: [...a.attested, ...b.attested],
757
+ rejectedVenues: [...a.rejectedVenues, ...b.rejectedVenues],
758
+ longerNames: [...a.longerNames, ...b.longerNames],
759
+ unpromotedShapes: [...a.unpromotedShapes, ...b.unpromotedShapes],
760
+ }
761
+ }
762
+
763
+ //#endregion
764
+
765
+ //#region Address context
766
+
767
+ /**
768
+ * DE + ES address context. Both read through {@link readLocaleTuples}, the `locale` recipe's own streaming + reservoir
769
+ * reader, so the CSV handling (quoted fields, CRLF, city-noise cleaning, the DE per-part region fallback) has exactly
770
+ * one implementation.
771
+ *
772
+ * DE reads `europe.zip`'s two members rather than `oa-cache/de__*.zip`: the cached per-state zips the `locale` recipe
773
+ * names are not materialized on this host, and the archive members are byte-identical to OA's current run (verified in
774
+ * `corpus/AGENTS.md`'s "a file's mtime is not its data's vintage" note).
775
+ */
776
+ const CONTEXT_PARTS: Readonly<Record<string, readonly LocalePart[]>> = {
777
+ DE: [
778
+ { zip: dataRootPath("openaddresses", "europe.zip"), csv: "de/berlin.csv", region: "Berlin" },
779
+ { zip: dataRootPath("openaddresses", "europe.zip"), csv: "de/sn/statewide.csv", region: "Sachsen" },
780
+ ],
781
+ ES: [{ path: dataRootPath("openaddresses", "extracted", "es", "countrywide.csv") }],
782
+ }
783
+
784
+ /**
785
+ * Load the address skeletons every leg renders onto: GB / US / FR from the house-venue v3 tuples (the same 176,519 real
786
+ * rows the `synth-house-venue` shard is built from, so the two shards' address halves are drawn from one pool), DE and
787
+ * ES streamed out of OpenAddresses.
788
+ */
789
+ export async function loadContextTuples(tuplesPath: string, seed: number): Promise<Map<string, LocaleBaseTuple[]>> {
790
+ const byCountry = new Map<string, LocaleBaseTuple[]>()
791
+
792
+ for await (const tuple of readShardTuples(tuplesPath)) {
793
+ const country = String(tuple.country ?? "")
794
+
795
+ if (!country || !tuple.locality || !tuple.street) continue
796
+
797
+ const mapped: LocaleBaseTuple = {
798
+ house_number: String(tuple.houseNumber ?? tuple.house_number ?? ""),
799
+ street: String(tuple.street),
800
+ locality: String(tuple.locality),
801
+ region: String(tuple.region ?? ""),
802
+ postcode: String(tuple.postcode ?? ""),
803
+ }
804
+
805
+ const list = byCountry.get(country)
806
+
807
+ if (list) {
808
+ list.push(mapped)
809
+ } else {
810
+ byCountry.set(country, [mapped])
811
+ }
812
+ }
813
+
814
+ for (const [country, parts] of Object.entries(CONTEXT_PARTS)) {
815
+ if (byCountry.has(country)) continue
816
+ const pooled: LocaleBaseTuple[] = []
817
+
818
+ for (const [index, part] of parts.entries()) {
819
+ // A dedicated stream PRNG, seeded per part, so the input sample is reproducible without
820
+ // perturbing the emit loop's draws (the `locale` recipe's rule, kept).
821
+ const streamRandom = makeMulberry32(seed + index)
822
+
823
+ for (const tuple of await readLocaleTuples(part, streamRandom)) {
824
+ pooled.push(tuple)
825
+ }
826
+ }
827
+
828
+ byCountry.set(country, pooled)
829
+ }
830
+
831
+ return byCountry
832
+ }
833
+
834
+ /**
835
+ * The street-side confound classes, mined from the leg's OWN address tuples.
836
+ *
837
+ * Real streets, not invented ones. The 176,519-row context pool carries 195 GB `hall` streets, 114 GB `gate` streets,
838
+ * 134 distinct GB `-gate` single tokens and a two-figure `<modifier> <designator>` population in both GB and US — small
839
+ * absolute numbers, but every one of them a street somebody lives on, which is the property an invented list cannot
840
+ * have.
841
+ */
842
+ export interface StreetNegatives {
843
+ designator: LocaleBaseTuple[]
844
+ modifierDesignator: LocaleBaseTuple[]
845
+ gateSuffix: LocaleBaseTuple[]
846
+ }
847
+
848
+ /**
849
+ * Shortest token that can carry a `-gate` street suffix and still be a NAME rather than the bare word: `gate` itself is
850
+ * four characters, so the class starts at five (`Highgate`, `Moorgate`, `Stonegate`).
851
+ */
852
+ const MIN_GATE_SUFFIX_TOKEN_LENGTH = 5
853
+
854
+ export function buildStreetNegatives(
855
+ context: readonly LocaleBaseTuple[],
856
+ designatorPhrases: readonly string[],
857
+ modifiers: readonly string[],
858
+ country: string
859
+ ): StreetNegatives {
860
+ const designatorSet = new Set(designatorPhrases)
861
+ const modifierSet = new Set(modifiers)
862
+ const designator: LocaleBaseTuple[] = []
863
+ const modifierDesignator: LocaleBaseTuple[] = []
864
+ const gateSuffix: LocaleBaseTuple[] = []
865
+
866
+ for (const tuple of context) {
867
+ const tokens = tuple.street.toLowerCase().match(/[\p{L}]+/gu) ?? []
868
+ let isDesignator = false
869
+ let isPair = false
870
+
871
+ for (const [index, token] of tokens.entries()) {
872
+ if (designatorSet.has(token)) {
873
+ isDesignator = true
874
+ }
875
+
876
+ if (index + 1 < tokens.length && modifierSet.has(token) && designatorSet.has(tokens[index + 1]!)) {
877
+ isPair = true
878
+ }
879
+
880
+ if (country === "GB" && token.length >= MIN_GATE_SUFFIX_TOKEN_LENGTH && token.endsWith("gate")) {
881
+ gateSuffix.push(tuple)
882
+ }
883
+ }
884
+
885
+ if (isPair) {
886
+ modifierDesignator.push(tuple)
887
+ } else if (isDesignator) {
888
+ designator.push(tuple)
889
+ }
890
+ }
891
+
892
+ return { designator, modifierDesignator, gateSuffix }
893
+ }
894
+
895
+ //#endregion