@mailwoman/corpus 8.5.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,982 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `sub-venue` shard recipe (#35 step 4) — teach the `unit` tag the venue-INTERIOR shapes it was
7
+ * never taught, so the modifier+designator class wins at the shipped `venueStructureBiasScale` of
8
+ * 6.0 instead of needing ~11 nats. `docs/engineering/sub-venue-corpus-task.mdx` is the spec; the
9
+ * vocabulary is `corpus/data/sub-venue-lexicon.json` (v0.2.0) and the curation ledger is
10
+ * `corpus/src/tools/sub-venue-promotions.ts`. The READ half — promotions, identifier
11
+ * distributions, name pools — is `sub-venue-sources.ts`; this file renders lines and emits rows.
12
+ *
13
+ * ── WHY THIS SYNTHESIZES RATHER THAN HARVESTS ────────────────────────────────────────────────────
14
+ * Wave 1's lesson, and the reason the spec's "get real data first" instruction is honoured in a
15
+ * shape it did not anticipate: the attested SURFACE STRINGS are thin. 87 GB features attest
16
+ * `terminal`, 29 attest `wing`, 4 attest `concourse` (3 of which are a street called CONCOURSE WAY).
17
+ * You cannot train a tag on 29 strings. What the five extracts DO carry at volume is the three
18
+ * things a generator needs — 45,000+ real venue names across four countries, a per-region
19
+ * identifier DISTRIBUTION measured over 2,868 gate/terminal/campus refs, and the confound
20
+ * population that becomes the negatives. So the bulk is `designator × per-region-identifier ×
21
+ * modifier` sampled per locale, and the attested strings ride along as seasoning
22
+ * ({@link ATTESTED_FRACTION}) rather than as the corpus.
23
+ *
24
+ * ── THE PER-REGION IDENTIFIER RULE, AND WHY IT IS NOT COSMETIC ───────────────────────────────────
25
+ * `Gate A12` is a rendering, not a string anyone wrote down: all but 13 of Great Britain's 658
26
+ * `aeroway=gate` features are unnamed and carry only a `ref`. The lexicon therefore ships a
27
+ * distribution, and it differs by country far more than the shared English vocabulary suggests —
28
+ * GB gates are 71% bare digits and JP 89%, FR and DE are ~60% letter-digit (`A37`, `B05`), and ES
29
+ * gives a THIRD of its gates a range (`B18-B20`), which no other country does at that rate. A
30
+ * generator that samples Great Britain's shape into a Spanish line produces a plausible string that
31
+ * is wrong about Spain, so every leg samples its own region.
32
+ *
33
+ * ── ONLY PROMOTED (designator, locale) PAIRS PRODUCE POSITIVES ───────────────────────────────────
34
+ * A promotion names a designator, a phrase AND a locale, because the same token is a designator in
35
+ * one language and a disaster in another: `hall` is 0-of-3,273 in Great Britain and 35-of-40 in
36
+ * France; `wing` is 23-of-29 in Great Britain and 4-of-3,358 in the United States. A REJECTED pair
37
+ * generates NEGATIVES in that locale instead — en-US `wing` rows are Red Wing, not units.
38
+ *
39
+ * `shape: "identifier-required"` is honoured as the ledger's docstring demands: de-DE `halle` is
40
+ * emitted only as `Halle <identifier>`, never bare and never after a modifier, because its 168-hit
41
+ * confound includes the CITY Halle (Saale) and only the identifier-bearing shape separates them.
42
+ * {@link buildSubVenueForm} enforces it and `sub-venue.test.ts` pins it.
43
+ *
44
+ * ── LABELS ───────────────────────────────────────────────────────────────────────────────────────
45
+ * Sub-venue is `unit`; the container is `venue`. That is the spec's wording and it invents nothing:
46
+ * `block` / `sub_block` exist in the `ComponentTag` union but are JP-char-model-only and outside
47
+ * `ACTIVE_TAGS` (STAGE3), so they are not reachable from a Latin shard.
48
+ *
49
+ * ── WHAT IS DELIBERATELY NOT HERE ────────────────────────────────────────────────────────────────
50
+ * 1. **A modifier+designator form outside English.** `VENUE_STRUCTURE_MODIFIERS` is an English
51
+ * list, and the extracts say the localized modifier surfaces do not exist to copy: `aile` in
52
+ * France is 0 hits, `ala` in Spain 0, `flügel` in Germany 0. Generating `Terminal Sud` would
53
+ * be inventing a vocabulary with no confound board behind it, which is the exact failure the
54
+ * promotion ledger exists to prevent. Non-English legs get designator+identifier only.
55
+ * 2. **A ja-JP leg.** `ターミナル` is promoted (1,213 real of 1,215) and it is the one non-Latin
56
+ * surface the task named, but Japanese addresses train through `build_jp_shard.py` against the
57
+ * `stage3-jp` 47-label head, where the interior tags are `block`/`sub_block`/`building_number`
58
+ * — a different model, a different label set, and a different builder. A katakana `unit` row
59
+ * in this (Latin) feed would in any case be dropped by `country_weights`, which carries no
60
+ * `JP` key. The JP extract is harvested and ready; the leg belongs to the JP shard.
61
+ */
62
+
63
+ import type { ComponentTag } from "@mailwoman/core/types"
64
+ import { dataRootPath } from "@mailwoman/core/utils"
65
+
66
+ import { alignRow } from "../align.ts"
67
+ import type { LocaleBaseTuple } from "../synthesize-german.ts"
68
+ import type { SubVenueLexiconTable } from "../tools/sub-venue-lexicon.ts"
69
+ import { makeMulberry32, shardSourceID, type ShardRecipe } from "./scaffold.ts"
70
+ import {
71
+ buildIdentifierModel,
72
+ buildStreetNegatives,
73
+ defaultLexiconPath,
74
+ EMPTY_NAME_POOLS,
75
+ type IdentifierModel,
76
+ loadContextTuples,
77
+ type LegPools,
78
+ mergeNamePools,
79
+ type PoolQuery,
80
+ type PromotedSurface,
81
+ promotedSurfacesFor,
82
+ readExtractPools,
83
+ readPOIPools,
84
+ readSubVenueLexicon,
85
+ rejectedPhrasesFor,
86
+ sampleIdentifier,
87
+ type StreetNegatives,
88
+ titleCase,
89
+ } from "./sub-venue-sources.ts"
90
+
91
+ export * from "./sub-venue-sources.ts"
92
+
93
+ /* oxlint-disable sister-software/no-unnamed-threshold -- the bare decimals in the register and
94
+ template samplers are weighted-sampler cutoffs, not thresholds: a `const r = random()` followed by
95
+ a cascade of `r < 0.45` branches IS the output distribution, and reading the cascade top-to-bottom
96
+ is how you see it. Genuine thresholds are named constants above. */
97
+
98
+ //#region Plan
99
+
100
+ /**
101
+ * One locale's leg of the shard.
102
+ *
103
+ * `positiveShare` / `negativeShare` are relative weights, normalized at run time — they do not have to sum to 1.
104
+ */
105
+ export interface SubVenueLeg {
106
+ locale: string
107
+ country: string
108
+ /**
109
+ * ISO 3166-1 alpha-2 key into the lexicon's `identifierShapes` AND the extract filename. The two axes are the same
110
+ * axis: a distribution is measured in a region's own extract.
111
+ */
112
+ region: string
113
+ /**
114
+ * Extract filename under `--extracts-dir`. Absent = no OSM extract for this leg (en-US), which then draws its venue
115
+ * and confound pools from `poi.db` instead.
116
+ */
117
+ extract?: string
118
+ /**
119
+ * May this leg use the English `<modifier> <designator>` grammar? See the module docstring's exclusion 1.
120
+ */
121
+ english: boolean
122
+ positiveShare: number
123
+ negativeShare: number
124
+ /**
125
+ * Ca-ES only — keep context tuples whose postcode starts with one of these. Catalan-language territories by postal
126
+ * prefix (07 Illes Balears, 08 Barcelona, 17 Girona, 25 Lleida, 43 Tarragona) rather than by a REGION string, whose
127
+ * spelling in the OA export is not something to guess at.
128
+ */
129
+ postcodePrefixes?: readonly string[]
130
+ }
131
+
132
+ /**
133
+ * The legs, and the numbers behind the shares.
134
+ *
135
+ * En-GB and en-US carry the most because they are where the eval board lives (28 of the 30 confound rows are GB or US
136
+ * addresses) and because the English shipped vocabulary is the only one with a modifier grammar — the target class.
137
+ * fr-FR, de-DE and es-ES exist because the ledger promoted surfaces there (169, 19+32 and 190 real hits respectively)
138
+ * and a shard that skipped them would leave every non-English promotion untrained. ca-ES is small on purpose: its
139
+ * promotion is 15 hits and its line differs from es-ES only in the Catalan street vocabulary the postal-prefix filter
140
+ * selects for.
141
+ *
142
+ * The negative shares invert that ordering where the confound mass does. en-US carries the largest negative share
143
+ * because its confound population is the largest measured anywhere in the ledger — 3,354 `wing` (Red Wing boots 676,
144
+ * chicken wings 759), 2,330 `pier` (Pier 1 Imports, which IS the designator+identifier shape), 27,081 `hall`.
145
+ */
146
+ export const SUBVENUE_LEGS: readonly SubVenueLeg[] = [
147
+ {
148
+ locale: "en-GB",
149
+ country: "GB",
150
+ region: "GB",
151
+ extract: "great-britain.jsonl",
152
+ english: true,
153
+ positiveShare: 0.25,
154
+ negativeShare: 0.3,
155
+ },
156
+ { locale: "en-US", country: "US", region: "GB", english: true, positiveShare: 0.25, negativeShare: 0.35 },
157
+ {
158
+ locale: "fr-FR",
159
+ country: "FR",
160
+ region: "FR",
161
+ extract: "france.jsonl",
162
+ english: false,
163
+ positiveShare: 0.16,
164
+ negativeShare: 0.2,
165
+ },
166
+ {
167
+ locale: "de-DE",
168
+ country: "DE",
169
+ region: "DE",
170
+ extract: "germany.jsonl",
171
+ english: false,
172
+ positiveShare: 0.14,
173
+ negativeShare: 0.1,
174
+ },
175
+ {
176
+ locale: "es-ES",
177
+ country: "ES",
178
+ region: "ES",
179
+ extract: "spain.jsonl",
180
+ english: false,
181
+ positiveShare: 0.15,
182
+ negativeShare: 0.05,
183
+ },
184
+ {
185
+ locale: "ca-ES",
186
+ country: "ES",
187
+ region: "ES",
188
+ extract: "spain.jsonl",
189
+ english: false,
190
+ positiveShare: 0.05,
191
+ negativeShare: 0,
192
+ postcodePrefixes: ["07", "08", "17", "25", "43"],
193
+ },
194
+ ]
195
+
196
+ /**
197
+ * En-US has no OSM extract, so its identifier distribution has to be borrowed. GB is the borrow, and the leg's `region`
198
+ * says so literally rather than in a comment: the two English-speaking aviation systems number their gates the same way
199
+ * (GB 71% bare digit) and poi.db — the only US source in reach — carries names, not refs, so it cannot supply a
200
+ * distribution of its own. Recorded here because it is the one place a leg's `region` is not its own country.
201
+ */
202
+ export const US_IDENTIFIER_REGION_BORROWED_FROM = "GB"
203
+
204
+ //#endregion
205
+
206
+ //#region Tunables
207
+
208
+ /**
209
+ * The row count this shard is built at, and the arithmetic behind it. `--count` overrides; this is the number to use
210
+ * absent a reason.
211
+ *
212
+ * The training sampler (`corpus-python/src/mailwoman_train/data_loader.py`, `_raw_row_stream`) draws SOURCES from a
213
+ * multinomial over `source_weights` and yields the next row from that source's iterator. Two consequences set the
214
+ * size:
215
+ *
216
+ * 1. A source's share of an epoch is `weight / Σweights`, independent of how many rows it has.
217
+ * 2. **A source that exhausts is DELETED from the multinomial** — there is no cycling. Under-size the shard and its
218
+ * nominal dose is fiction for the rest of the epoch.
219
+ *
220
+ * Measured against the shipped `v4.1.0-gb-venue-l1e4-2k` weight table: 33 sources summing to 144.5. At the dose the B11
221
+ * GB-venue exercise settled on for a hard rare class — 12.0, the value `synth-fr-bare-street`, `synth-si-bare-village`,
222
+ * `synth-cz-pcfirst-preposition`, `synth-fr-fragment` and `synth-no-fragment` all carry — the share is `12 / 156.5 =
223
+ * 7.67%`, and `train_rows_per_epoch` is 1,000,000. So the epoch draws **76,677 rows** from this shard, and anything
224
+ * smaller runs dry mid-epoch. 120,000 clears that with room for a config that drops a source or raises the dose. (For
225
+ * contrast: `synth-fr-bare-street` is 10,803 rows at dose 12.0, so it exhausts 14% into its own nominal share every
226
+ * epoch — a precedent for the dose, not for the size.)
227
+ */
228
+ export const RECOMMENDED_ROW_COUNT = 120_000
229
+
230
+ /**
231
+ * Share of emitted rows that are NEGATIVES (the confound classes, carrying no `unit`).
232
+ *
233
+ * The spec's instruction is structural: "include the confound shapes as NEGATIVES in the same shard, or the model
234
+ * learns the surface rather than the structure". 0.3 is the dose the `no-fragment` recipe settled on for its own
235
+ * counter-distribution and there is no measurement here that beats it; `--negative-fraction` moves it.
236
+ */
237
+ const DEFAULT_NEGATIVE_FRACTION = 0.3
238
+
239
+ /**
240
+ * Share of POSITIVES whose sub-venue string is a REAL name lifted verbatim out of an extract rather than synthesized —
241
+ * `Terminal 2 D`, `Pier 1`, `Terminal 1 Flugsteig B`. The seasoning, per the module docstring. Kept small because the
242
+ * attested pool is small: after promotion + shape filtering it is 13–47 strings per leg, and a larger share would just
243
+ * repeat them.
244
+ */
245
+ const ATTESTED_FRACTION = 0.1
246
+
247
+ /**
248
+ * Within the SYNTHESIZED positives of an English leg: the split between the two proposal shapes.
249
+ *
250
+ * Modifier-heavy on purpose. Designator+identifier proposes at 0.85 confidence and already wins the decode at the
251
+ * shipped 6.0; modifier+designator proposes at 0.6 and needs 5.87–10.65. The failing class is the one to teach, and the
252
+ * passing one is here to not regress (`Concourse B` / `Terminal 5` / `Gate 12` / `Wing B` must stay correct).
253
+ */
254
+ const ENGLISH_MODIFIER_FORM_FRACTION = 0.6
255
+
256
+ //#endregion
257
+
258
+ //#region Board reservation
259
+
260
+ /**
261
+ * Surfaces reserved by `mailwoman/eval-harness/fixtures/venue-structure-confounds.jsonl` — the 30-row board this shard
262
+ * has to hold. A row containing any of these is DROPPED and counted in `contaminated`.
263
+ *
264
+ * The `--exclude-surfaces` precedent from `fr-fragment` / `no-fragment`, applied by hand rather than by file because
265
+ * the board lives in `mailwoman/` and `@mailwoman/corpus` cannot reach across that workspace boundary at run time. Keep
266
+ * it in sync when the board grows; a shard that trains on its own eval set measures memorization.
267
+ *
268
+ * Note what this costs and why it is still right: reserving `east gate` / `west gate` removes the two GB surfaces the
269
+ * board uses for its `modifier-designator-street` class, so the shard teaches that class from the OTHER real ones its
270
+ * sources carry (`North Gate`, `South Gate`, `East Hall`, `West Hall`, `Lower Hall`, `East Campus`, …). The class is
271
+ * taught; the board's own strings are not.
272
+ */
273
+ export const BOARD_RESERVED_SURFACES: readonly string[] = [
274
+ // gb-street-gate
275
+ "briggate",
276
+ "kirkgate",
277
+ "castlegate",
278
+ "micklegate",
279
+ "fishergate",
280
+ "gallowgate",
281
+ "cowgate",
282
+ "canongate",
283
+ "westgate",
284
+ "northgate",
285
+ // gate-house-venue
286
+ "gate house",
287
+ "gatehouse",
288
+ // terminal-estate
289
+ "terminal industrial estate",
290
+ "terminal house",
291
+ "ocean terminal",
292
+ "terminal warehouse",
293
+ // wing-name
294
+ "wing yip",
295
+ "wing lee",
296
+ "bletchley park",
297
+ // designator-as-street
298
+ "campus drive",
299
+ "arcade avenue",
300
+ "concourse village",
301
+ "building society place",
302
+ "enclosure road",
303
+ // modifier-designator-street
304
+ "east gate",
305
+ "west gate",
306
+ "west wickham",
307
+ ]
308
+
309
+ /**
310
+ * Does this row's text collide with a board-reserved surface?
311
+ */
312
+ export function isBoardReserved(raw: string): boolean {
313
+ const low = raw.toLowerCase()
314
+
315
+ return BOARD_RESERVED_SURFACES.some((surface) => low.includes(surface))
316
+ }
317
+
318
+ //#endregion
319
+
320
+ //#region Rendering
321
+
322
+ /**
323
+ * One labelled piece of the line. Pieces inside a group are space-joined; groups are joined by the register's
324
+ * separator.
325
+ */
326
+ interface Piece {
327
+ text: string
328
+ tag?: ComponentTag
329
+ }
330
+
331
+ type Group = Piece[]
332
+
333
+ /**
334
+ * Surface register. Every eval in this repo gets a lowercase leg because lowercase is the register users type — Google
335
+ * Maps taught them — so every shard has to carry one.
336
+ */
337
+ const Register = {
338
+ Canonical: "canonical",
339
+ CommaFree: "comma-free",
340
+ Lower: "lower",
341
+ Upper: "upper",
342
+ } as const
343
+
344
+ type Register = (typeof Register)[keyof typeof Register]
345
+
346
+ function sampleRegister(random: () => number): Register {
347
+ const r = random()
348
+
349
+ if (r < 0.45) return Register.Canonical
350
+
351
+ if (r < 0.65) return Register.CommaFree
352
+
353
+ if (r < 0.9) return Register.Lower
354
+
355
+ return Register.Upper
356
+ }
357
+
358
+ /**
359
+ * Join groups into `raw` + `components`, applying the register to BOTH so alignment still finds every value.
360
+ */
361
+ export function renderGroups(
362
+ groups: Group[],
363
+ register: Register
364
+ ): { raw: string; components: Partial<Record<ComponentTag, string>> } {
365
+ const fold = (text: string): string => {
366
+ if (register === Register.Lower) return text.toLowerCase()
367
+
368
+ if (register === Register.Upper) return text.toUpperCase()
369
+
370
+ return text
371
+ }
372
+
373
+ const separator = register === Register.CommaFree ? " " : ", "
374
+
375
+ const raw = groups
376
+ .map((group) => group.map((piece) => fold(piece.text)).join(" "))
377
+ .filter(Boolean)
378
+ .join(separator)
379
+
380
+ const components: Partial<Record<ComponentTag, string>> = {}
381
+
382
+ for (const group of groups) {
383
+ for (const piece of group) {
384
+ if (piece.tag && !components[piece.tag]) {
385
+ components[piece.tag] = fold(piece.text)
386
+ }
387
+ }
388
+ }
389
+
390
+ return { raw, components }
391
+ }
392
+
393
+ /**
394
+ * The street + tail groups for a country, in that country's own order.
395
+ *
396
+ * DE/ES/FR put the postcode before the locality and DE/ES put the house number after the street; GB and US keep the
397
+ * anglophone order and US carries a region. These are the same orders `synthesize-german.ts` renders, restated here
398
+ * because this recipe assembles its groups piece-by-piece (it has to, to place a sub-venue group in front of them).
399
+ */
400
+ export function addressGroups(country: string, tuple: LocaleBaseTuple, withStreet: boolean): Group[] {
401
+ const groups: Group[] = []
402
+ const houseNumber = tuple.house_number?.trim()
403
+ const street = tuple.street.trim()
404
+
405
+ if (withStreet && street) {
406
+ const streetPiece: Piece = { text: street, tag: "street" }
407
+ const numberPiece: Piece | null = houseNumber ? { text: houseNumber, tag: "house_number" } : null
408
+
409
+ if (!numberPiece) {
410
+ groups.push([streetPiece])
411
+ } else if (country === "DE" || country === "ES") {
412
+ groups.push([streetPiece, numberPiece])
413
+ } else {
414
+ groups.push([numberPiece, streetPiece])
415
+ }
416
+ }
417
+
418
+ const locality: Piece = { text: tuple.locality.trim(), tag: "locality" }
419
+ const postcode = tuple.postcode?.trim()
420
+ const region = tuple.region?.trim()
421
+
422
+ if (country === "US") {
423
+ groups.push([locality])
424
+ const tail: Group = []
425
+
426
+ if (region) {
427
+ tail.push({ text: region, tag: "region" })
428
+ }
429
+
430
+ if (postcode) {
431
+ tail.push({ text: postcode, tag: "postcode" })
432
+ }
433
+
434
+ if (tail.length) {
435
+ groups.push(tail)
436
+ }
437
+ } else if (country === "GB") {
438
+ groups.push([locality])
439
+
440
+ if (postcode) {
441
+ groups.push([{ text: postcode, tag: "postcode" }])
442
+ }
443
+ } else {
444
+ // FR / DE / ES — postcode then locality, one group.
445
+ const tail: Group = []
446
+
447
+ if (postcode) {
448
+ tail.push({ text: postcode, tag: "postcode" })
449
+ }
450
+
451
+ tail.push(locality)
452
+ groups.push(tail)
453
+ }
454
+
455
+ return groups
456
+ }
457
+
458
+ //#endregion
459
+
460
+ //#region Positive forms
461
+
462
+ /**
463
+ * A sub-venue string plus how it was made, for the composition report.
464
+ */
465
+ export interface SubVenueForm {
466
+ text: string
467
+ form: "designator-identifier" | "modifier-designator" | "attested"
468
+ designatorID: string
469
+ }
470
+
471
+ /**
472
+ * Build one sub-venue string for a leg.
473
+ *
474
+ * The `identifier-required` guard is here and not at the call site on purpose: it is the one rule in this file that a
475
+ * refactor must not be able to route around. A promotion carrying `shape: "identifier-required"` can only ever leave
476
+ * this function as `<Phrase> <identifier>`, and returns `null` rather than a bare or modified form.
477
+ */
478
+ export function buildSubVenueForm(
479
+ leg: SubVenueLeg,
480
+ promoted: readonly PromotedSurface[],
481
+ model: IdentifierModel,
482
+ modifiers: readonly string[],
483
+ attested: readonly string[],
484
+ random: () => number
485
+ ): SubVenueForm | null {
486
+ if (!promoted.length) return null
487
+
488
+ if (attested.length && random() < ATTESTED_FRACTION) {
489
+ const text = attested[Math.floor(random() * attested.length)]!
490
+
491
+ return { text, form: "attested", designatorID: "attested" }
492
+ }
493
+
494
+ const modifierCandidates = leg.english ? promoted.filter((p) => p.modifierEligible && !p.identifierRequired) : []
495
+ const useModifier = modifierCandidates.length > 0 && random() < ENGLISH_MODIFIER_FORM_FRACTION
496
+
497
+ if (useModifier) {
498
+ const promotedSurface = modifierCandidates[Math.floor(random() * modifierCandidates.length)]!
499
+ const modifier = modifiers[Math.floor(random() * modifiers.length)]!
500
+
501
+ return {
502
+ text: `${titleCase(modifier)} ${promotedSurface.surface}`,
503
+ form: "modifier-designator",
504
+ designatorID: promotedSurface.designatorID,
505
+ }
506
+ }
507
+
508
+ const promotedSurface = promoted[Math.floor(random() * promoted.length)]!
509
+ const identifier = sampleIdentifier(model, promotedSurface.designatorID, random)
510
+
511
+ if (!identifier) return null
512
+
513
+ return {
514
+ text: `${promotedSurface.surface} ${identifier}`,
515
+ form: "designator-identifier",
516
+ designatorID: promotedSurface.designatorID,
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Alias kept for readers of the arc's earlier drafts.
522
+ *
523
+ * @deprecated Use {@link buildSubVenueForm}.
524
+ */
525
+ export const buildPositiveForms = buildSubVenueForm
526
+
527
+ //#endregion
528
+
529
+ //#region Negatives
530
+
531
+ /**
532
+ * Negative classes, named so the composition report can count them and a failure can be attributed.
533
+ */
534
+ export const NegativeClass = {
535
+ /**
536
+ * A locale-REJECTED surface in the venue slot: Red Wing Shoes, Village Hall, Porte de Champerret.
537
+ */
538
+ RejectedVenue: "rejected-venue",
539
+ /**
540
+ * The designator inside a longer proper name, whole string tagged `venue`: Lochaline Ferry Terminal.
541
+ */
542
+ LongerName: "longer-name",
543
+ /**
544
+ * A real street whose name carries a designator token: Pier Road, Egg Hall, Orchard Gate.
545
+ */
546
+ DesignatorStreet: "designator-street",
547
+ /**
548
+ * A real street of the `<modifier> <designator>` shape — the class that would otherwise be read as a sub-venue.
549
+ */
550
+ ModifierDesignatorStreet: "modifier-designator-street",
551
+ /**
552
+ * A GB single-token `-gate` street: Eastgate, Southgate, Moorgate, Stonegate.
553
+ */
554
+ GateSuffixStreet: "gate-suffix-street",
555
+ /**
556
+ * A PROMOTED phrase outside the shape its promotion covers — `Halle Rosengarten`, `PHOENIX Halle`. The other half of
557
+ * an `identifier-required` ruling; see `LegPools.unpromotedShapes`.
558
+ */
559
+ UnpromotedShape: "unpromoted-shape",
560
+ } as const
561
+
562
+ export type NegativeClass = (typeof NegativeClass)[keyof typeof NegativeClass]
563
+
564
+ /**
565
+ * Which negative classes this leg's pools can actually produce. A class with no source is ABSENT rather than
566
+ * substituted — the report then says so, and a reader can tell a missing class from an unsampled one.
567
+ */
568
+ function availableNegativeClasses(pools: LegPools, streets: StreetNegatives): NegativeClass[] {
569
+ const available: NegativeClass[] = []
570
+
571
+ const sourced: ReadonlyArray<readonly [NegativeClass, number]> = [
572
+ [NegativeClass.RejectedVenue, pools.rejectedVenues.length],
573
+ [NegativeClass.LongerName, pools.longerNames.length],
574
+ [NegativeClass.UnpromotedShape, pools.unpromotedShapes.length],
575
+ [NegativeClass.DesignatorStreet, streets.designator.length],
576
+ [NegativeClass.ModifierDesignatorStreet, streets.modifierDesignator.length],
577
+ [NegativeClass.GateSuffixStreet, streets.gateSuffix.length],
578
+ ]
579
+
580
+ for (const [negativeClass, size] of sourced) {
581
+ if (size > 0) {
582
+ available.push(negativeClass)
583
+ }
584
+ }
585
+
586
+ return available
587
+ }
588
+
589
+ //#endregion
590
+
591
+ //#region Recipe
592
+
593
+ /**
594
+ * Per-leg composition tallies the build prints and the report quotes.
595
+ */
596
+ export interface SubVenueLegStats {
597
+ locale: string
598
+ positives: number
599
+ negatives: number
600
+ byForm: Record<string, number>
601
+ byDesignator: Record<string, number>
602
+ byNegativeClass: Record<string, number>
603
+ byRegister: Record<string, number>
604
+ poolSizes: Record<string, number>
605
+ }
606
+
607
+ const LICENSE =
608
+ "Synthetic — OpenStreetMap venue + sub-venue names (ODbL, © OpenStreetMap contributors) and Overture Places names " +
609
+ "(CDLA-Permissive-2.0) over OpenAddresses / HM Land Registry Price Paid Data address skeletons"
610
+
611
+ const CORPUS_VERSION = "0.16.0"
612
+
613
+ const bump = (record: Record<string, number>, key: string): void => {
614
+ record[key] = (record[key] ?? 0) + 1
615
+ }
616
+
617
+ /**
618
+ * Everything the emit loop needs that does not vary per row.
619
+ */
620
+ interface EmitContext {
621
+ write: (line: string) => void
622
+ source: string
623
+ random: () => number
624
+ modifiers: readonly string[]
625
+ designatorPhrases: readonly string[]
626
+ counters: { emitted: number; skipped: number; contaminated: number }
627
+ }
628
+
629
+ /**
630
+ * Render one row, drop it if it collides with the eval board, align it, write it.
631
+ *
632
+ * A free function rather than a closure over the leg loop: a closure there is both a lint error (`no-loop-func`) and a
633
+ * real hazard, since it would capture the loop's mutable counters.
634
+ */
635
+ function emitRow(
636
+ context: EmitContext,
637
+ leg: SubVenueLeg,
638
+ stats: SubVenueLegStats,
639
+ groups: Group[],
640
+ register: Register,
641
+ synthMethod: string,
642
+ disambiguator: Record<string, string>
643
+ ): boolean {
644
+ const { raw, components } = renderGroups(groups, register)
645
+
646
+ if (isBoardReserved(raw)) {
647
+ context.counters.contaminated++
648
+
649
+ return false
650
+ }
651
+
652
+ const aligned = alignRow({
653
+ raw,
654
+ components,
655
+ country: leg.country,
656
+ locale: leg.locale,
657
+ source: context.source,
658
+ source_id: shardSourceID(context.source, { ...components, ...disambiguator }),
659
+ corpus_version: CORPUS_VERSION,
660
+ license: LICENSE,
661
+ })
662
+
663
+ if (aligned.kind !== "labeled" || !aligned.row) {
664
+ context.counters.skipped++
665
+
666
+ return false
667
+ }
668
+
669
+ context.write(JSON.stringify({ ...aligned.row, synth_method: synthMethod, synth_base_id: null }) + "\n")
670
+
671
+ context.counters.emitted++
672
+ bump(stats.byRegister, register)
673
+
674
+ return true
675
+ }
676
+
677
+ /**
678
+ * Emit one leg's POSITIVE rows: `<sub-venue> unit`, a real `venue`, and the leg's own address skeleton.
679
+ */
680
+ function emitPositives(
681
+ context: EmitContext,
682
+ leg: SubVenueLeg,
683
+ pools: LegPools,
684
+ promoted: readonly PromotedSurface[],
685
+ model: IdentifierModel,
686
+ stats: SubVenueLegStats,
687
+ target: number
688
+ ): void {
689
+ const { random } = context
690
+ let produced = 0
691
+ let guard = 0
692
+
693
+ while (produced < target && guard++ < target * 8) {
694
+ if (!pools.context.length || !pools.venues.length) break
695
+ const form = buildSubVenueForm(leg, promoted, model, context.modifiers, pools.attested, random)
696
+
697
+ if (!form) {
698
+ context.counters.skipped++
699
+
700
+ continue
701
+ }
702
+
703
+ const tuple = pools.context[Math.floor(random() * pools.context.length)]!
704
+ const venue = pools.venues[Math.floor(random() * pools.venues.length)]!
705
+
706
+ // A venue name that CONTAINS the sub-venue string (or vice versa) makes the two spans
707
+ // unresolvable — alignment claims the longer one and quarantines the other — and the row would
708
+ // teach an overlap that never occurs on a real envelope. Redraw instead.
709
+ const lowVenue = venue.toLowerCase()
710
+ const lowForm = form.text.toLowerCase()
711
+
712
+ if (lowVenue.includes(lowForm) || lowForm.includes(lowVenue)) continue
713
+
714
+ const register = sampleRegister(random)
715
+ const subGroup: Group = [{ text: form.text, tag: "unit" }]
716
+ const venueGroup: Group = [{ text: venue, tag: "venue" }]
717
+ const r = random()
718
+ // A quarter of rows carry no street: an airport terminal's address usually does not have one.
719
+ const body = addressGroups(leg.country, tuple, r >= 0.25)
720
+ // Both orders occur on real signage and mail — "Terminal 5, Heathrow" and "Heathrow, Terminal 5".
721
+ const groups = r < 0.55 ? [subGroup, venueGroup, ...body] : [venueGroup, subGroup, ...body]
722
+
723
+ const ok = emitRow(context, leg, stats, groups, register, `sub-venue:${form.form}`, {
724
+ leg: leg.locale,
725
+ form: form.form,
726
+ v: String(produced),
727
+ })
728
+
729
+ if (!ok) continue
730
+
731
+ produced++
732
+
733
+ stats.positives++
734
+ bump(stats.byForm, form.form)
735
+ bump(stats.byDesignator, form.designatorID)
736
+ }
737
+ }
738
+
739
+ /**
740
+ * Emit one leg's NEGATIVE rows — the confound classes, none of which carries a `unit`.
741
+ */
742
+ function emitNegatives(
743
+ context: EmitContext,
744
+ leg: SubVenueLeg,
745
+ pools: LegPools,
746
+ stats: SubVenueLegStats,
747
+ target: number
748
+ ): void {
749
+ const { random } = context
750
+ const streets = buildStreetNegatives(pools.context, context.designatorPhrases, context.modifiers, leg.country)
751
+ const available = availableNegativeClasses(pools, streets)
752
+ let produced = 0
753
+ let guard = 0
754
+
755
+ while (produced < target && guard++ < target * 8) {
756
+ if (!available.length || !pools.context.length) break
757
+ const negativeClass = available[Math.floor(random() * available.length)]!
758
+ const register = sampleRegister(random)
759
+ let groups: Group[]
760
+
761
+ if (negativeClass === NegativeClass.DesignatorStreet) {
762
+ groups = addressGroups(leg.country, pickTuple(streets.designator, random), true)
763
+ } else if (negativeClass === NegativeClass.ModifierDesignatorStreet) {
764
+ groups = addressGroups(leg.country, pickTuple(streets.modifierDesignator, random), true)
765
+ } else if (negativeClass === NegativeClass.GateSuffixStreet) {
766
+ groups = addressGroups(leg.country, pickTuple(streets.gateSuffix, random), true)
767
+ } else {
768
+ const pool =
769
+ negativeClass === NegativeClass.RejectedVenue
770
+ ? pools.rejectedVenues
771
+ : negativeClass === NegativeClass.LongerName
772
+ ? pools.longerNames
773
+ : pools.unpromotedShapes
774
+
775
+ const name = pool[Math.floor(random() * pool.length)]!
776
+ const tuple = pools.context[Math.floor(random() * pools.context.length)]!
777
+
778
+ groups = [[{ text: name, tag: "venue" }], ...addressGroups(leg.country, tuple, random() < 0.75)]
779
+ }
780
+
781
+ const ok = emitRow(context, leg, stats, groups, register, `sub-venue-negative:${negativeClass}`, {
782
+ leg: leg.locale,
783
+ negative: negativeClass,
784
+ v: String(produced),
785
+ })
786
+
787
+ if (!ok) continue
788
+
789
+ produced++
790
+
791
+ stats.negatives++
792
+ bump(stats.byNegativeClass, negativeClass)
793
+ }
794
+ }
795
+
796
+ function pickTuple(pool: readonly LocaleBaseTuple[], random: () => number): LocaleBaseTuple {
797
+ return pool[Math.floor(random() * pool.length)]!
798
+ }
799
+
800
+ /**
801
+ * Split `total` across `shares` (which need not sum to 1), largest-remainder so the parts sum exactly.
802
+ */
803
+ export function allocate(total: number, shares: readonly number[]): number[] {
804
+ const sum = shares.reduce((a, b) => a + b, 0)
805
+
806
+ if (sum <= 0) return shares.map(() => 0)
807
+ const exact = shares.map((s) => (total * s) / sum)
808
+ const floored = exact.map((v) => Math.floor(v))
809
+ let remainder = total - floored.reduce((a, b) => a + b, 0)
810
+ const order = exact.map((v, i) => ({ i, frac: v - Math.floor(v) })).toSorted((a, b) => b.frac - a.frac)
811
+
812
+ for (const entry of order) {
813
+ if (remainder <= 0) break
814
+ floored[entry.i]! += 1
815
+
816
+ remainder--
817
+ }
818
+
819
+ return floored
820
+ }
821
+
822
+ /**
823
+ * Build one leg's pools: the name pools from its extract and/or poi.db, plus its address context.
824
+ */
825
+ function buildLegPools(
826
+ leg: SubVenueLeg,
827
+ query: PoolQuery,
828
+ contextByCountry: ReadonlyMap<string, LocaleBaseTuple[]>,
829
+ paths: { extractsDir: string; poiDb: string }
830
+ ): LegPools {
831
+ const extractPools = leg.extract ? readExtractPools(`${paths.extractsDir}/${leg.extract}`, query) : EMPTY_NAME_POOLS
832
+
833
+ // poi.db holds four countries; only these two legs are inside it.
834
+ const poiPools =
835
+ leg.country === "US" || leg.country === "FR" ? readPOIPools(paths.poiDb, leg.country, query) : EMPTY_NAME_POOLS
836
+
837
+ const names = mergeNamePools(extractPools, poiPools)
838
+ let context = contextByCountry.get(leg.country) ?? []
839
+
840
+ if (leg.postcodePrefixes) {
841
+ const prefixes = leg.postcodePrefixes
842
+ const filtered = context.filter((t) => prefixes.some((p) => (t.postcode ?? "").startsWith(p)))
843
+
844
+ // A leg that filters itself empty is a build-time fact worth failing on, not a silent fallback
845
+ // to the parent locale's rows under a different `locale` stamp.
846
+ if (!filtered.length) {
847
+ throw new Error(
848
+ `${leg.locale}: no context tuples matched postcode prefixes ${prefixes.join(",")} among ${context.length} ${leg.country} rows`
849
+ )
850
+ }
851
+
852
+ context = filtered
853
+ }
854
+
855
+ return { context, ...names }
856
+ }
857
+
858
+ function emptyStats(leg: SubVenueLeg, pools: LegPools, promotedCount: number): SubVenueLegStats {
859
+ return {
860
+ locale: leg.locale,
861
+ positives: 0,
862
+ negatives: 0,
863
+ byForm: {},
864
+ byDesignator: {},
865
+ byNegativeClass: {},
866
+ byRegister: {},
867
+ poolSizes: {
868
+ promoted: promotedCount,
869
+ context: pools.context.length,
870
+ venues: pools.venues.length,
871
+ attested: pools.attested.length,
872
+ rejectedVenues: pools.rejectedVenues.length,
873
+ longerNames: pools.longerNames.length,
874
+ unpromotedShapes: pools.unpromotedShapes.length,
875
+ },
876
+ }
877
+ }
878
+
879
+ /**
880
+ * Shard recipe registered with the corpus builder — see the file header for the parse behaviour it exists to exercise,
881
+ * and `description` below for the surface form it generates.
882
+ */
883
+ export const subVenueRecipe: ShardRecipe = {
884
+ name: "sub-venue",
885
+ description:
886
+ "Venue-interior structure (#35): <sub-venue> unit + <venue> lines per promoted (designator, locale) pair, with the rejection ledger's confounds as negatives",
887
+ mode: "generate",
888
+ options: [
889
+ { flag: "--lexicon <path>", description: "sub-venue lexicon JSON (default: the committed corpus/data one)" },
890
+ {
891
+ flag: "--extracts-dir <dir>",
892
+ description: "OSM sub-venue extract JSONLs (default: $MAILWOMAN_DATA_ROOT/sub-venue/extracts)",
893
+ },
894
+ {
895
+ flag: "--poi-db <path>",
896
+ description: "poi.db for the en-US / fr-FR pools (default: $MAILWOMAN_DATA_ROOT/poi/poi.db)",
897
+ },
898
+ { flag: "--sub-venue-tuples <path>", description: "GB/US/FR address-context tuples JSONL" },
899
+ {
900
+ flag: "--negative-fraction <n>",
901
+ description: `share of rows that are confound negatives (default ${DEFAULT_NEGATIVE_FRACTION})`,
902
+ },
903
+ ],
904
+ async run(opts, write) {
905
+ if (opts.count == null) throw new Error("sub-venue recipe requires --count <N>")
906
+ const count = opts.count
907
+ const negativeFraction = opts.negativeFraction ?? DEFAULT_NEGATIVE_FRACTION
908
+ const extractsDir = opts.extractsDir ?? dataRootPath("sub-venue", "extracts")
909
+ const poiDb = opts.poiDb ?? dataRootPath("poi", "poi.db")
910
+ const tuplesPath = opts.subVenueTuples ?? dataRootPath("corpus", "intermediate", "house-venue-tuples-v3.jsonl")
911
+ const lexicon: SubVenueLexiconTable = readSubVenueLexicon(opts.lexicon ?? defaultLexiconPath())
912
+
913
+ const context: EmitContext = {
914
+ write,
915
+ source: opts.sourceName ?? "synth-sub-venue",
916
+ random: makeMulberry32(opts.seed),
917
+ modifiers: lexicon.modifiers.filter((m) => m.shipped).map((m) => m.id),
918
+ designatorPhrases: lexicon.designators.filter((d) => d.tier === "subvenue").map((d) => d.id),
919
+ counters: { emitted: 0, skipped: 0, contaminated: 0 },
920
+ }
921
+
922
+ console.error(` reading context tuples: ${tuplesPath}`)
923
+
924
+ const contextByCountry = await loadContextTuples(tuplesPath, opts.seed)
925
+ const legPools = new Map<string, LegPools>()
926
+ const legStats = new Map<string, SubVenueLegStats>()
927
+ const legPromoted = new Map<string, PromotedSurface[]>()
928
+
929
+ for (const leg of SUBVENUE_LEGS) {
930
+ const promoted = promotedSurfacesFor(leg.locale, lexicon)
931
+
932
+ const query: PoolQuery = {
933
+ promoted,
934
+ rejectedPhrases: rejectedPhrasesFor(leg.locale),
935
+ designatorPhrases: context.designatorPhrases,
936
+ modifiers: context.modifiers,
937
+ english: leg.english,
938
+ }
939
+
940
+ const pools = buildLegPools(leg, query, contextByCountry, { extractsDir, poiDb })
941
+
942
+ legPromoted.set(leg.locale, promoted)
943
+ legPools.set(leg.locale, pools)
944
+ legStats.set(leg.locale, emptyStats(leg, pools, promoted.length))
945
+ }
946
+
947
+ const positiveTotal = Math.round(count * (1 - negativeFraction))
948
+
949
+ const positiveQuota = allocate(
950
+ positiveTotal,
951
+ SUBVENUE_LEGS.map((l) => l.positiveShare)
952
+ )
953
+
954
+ const negativeQuota = allocate(
955
+ count - positiveTotal,
956
+ SUBVENUE_LEGS.map((l) => l.negativeShare)
957
+ )
958
+
959
+ for (const [index, leg] of SUBVENUE_LEGS.entries()) {
960
+ const pools = legPools.get(leg.locale)!
961
+ const stats = legStats.get(leg.locale)!
962
+ const promoted = legPromoted.get(leg.locale)!
963
+ const model = buildIdentifierModel(lexicon, leg.region)
964
+
965
+ emitPositives(context, leg, pools, promoted, model, stats, positiveQuota[index]!)
966
+ emitNegatives(context, leg, pools, stats, negativeQuota[index]!)
967
+ }
968
+
969
+ for (const stats of legStats.values()) {
970
+ console.error(
971
+ ` ${stats.locale}: +${stats.positives} positives / -${stats.negatives} negatives ` +
972
+ `forms=${JSON.stringify(stats.byForm)} designators=${JSON.stringify(stats.byDesignator)} ` +
973
+ `negclasses=${JSON.stringify(stats.byNegativeClass)} registers=${JSON.stringify(stats.byRegister)} ` +
974
+ `pools=${JSON.stringify(stats.poolSizes)}`
975
+ )
976
+ }
977
+
978
+ return { read: count, ...context.counters }
979
+ },
980
+ }
981
+
982
+ //#endregion