@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,748 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Golden-set street-suffix relabel — v0.1.2 → v0.1.3.
7
+ *
8
+ * ## Why this exists
9
+ *
10
+ * The golden answer key and the training corpus disagreed about ONE thing, and the v9.0.0
11
+ * promotion gate read the disagreement as a model regression (`us.street` 87.4 vs a floor of
12
+ * 87.8). The corpus SPLITS a US street into `street` + `street_suffix` — TIGER's adapter
13
+ * decomposes at `corpus/src/adapters/tiger/street-decompose.ts`, the `street-affix` shard recipe
14
+ * teaches it from USPS Pub-28, and `ComponentTag` carries `street_suffix` as a first-class tag.
15
+ * The golden set FOLDED it: 2,216 US rows carry a `street`, and exactly 2 of them label a
16
+ * `street_suffix`. Operator ruling, 2026-08-06: **the split is canonical**; the golden is the
17
+ * stale side. This tool moves the answer key onto the corpus convention.
18
+ *
19
+ * ## The instrument
20
+ *
21
+ * `matchTrailingSuffix` from `@mailwoman/codex/us` — the USPS Pub-28 Appendix C table, which is
22
+ * also what the corpus shard recipe splits on. The table is NOT re-implemented here, and the
23
+ * libpostal dictionary TIGER reads is deliberately not used: measured on this golden set the two
24
+ * disagree on 51 US rows, and the disagreements run in the codex table's favour (libpostal's
25
+ * `directionals.txt` lists `center|c`, so TIGER reads the `C` of "C STREET" as a directional
26
+ * prefix and then emits no suffix at all).
27
+ *
28
+ * ## What it changes, and what it refuses to
29
+ *
30
+ * Applied only to rows whose `country` is `US`. Three branches, mirroring the SHAPE of TIGER's
31
+ * `decomposeStreet` on codex tables:
32
+ *
33
+ * - **street type** — the last whitespace-separated word is a Pub-28 suffix, and something is left
34
+ * over: "Main St" → `street: "Main"`, `street_suffix: "St"` (1,559 rows).
35
+ * - **street type + post-directional** — the last word is a directional AND the one before it is a
36
+ * Pub-28 suffix: "Pennsylvania Avenue NW" → `street: "Pennsylvania"`,
37
+ * `street_suffix: "Avenue NW"` (347 rows). The post-directional joins the suffix rather than
38
+ * becoming a tag of its own, because that is what the corpus adapter emits; there is no
39
+ * `street_postfix` tag to move it to.
40
+ * - **everything else is left folded** and reported. In particular a BARE post-directional tail
41
+ * ("Seymour East", "BROADWAY N" — 16 rows) is NOT split: a directional is not a Pub-28 suffix,
42
+ * and the observed rows in that class are unit-contaminated ("1ST AVE SW BOX E", where the
43
+ * trailing "E" is a box letter).
44
+ *
45
+ * FR rows are untouched, deliberately and permanently as far as this tool is concerned. French
46
+ * street typology puts the type FIRST ("Rue de la Paix") and the golden labels only 7 of 665 FR
47
+ * street rows with a `street_prefix`; whether FR should split at all is a different question with
48
+ * a different table behind it, and nothing here should be read as having answered it.
49
+ *
50
+ * ## Surface bytes
51
+ *
52
+ * The split is a cut at a whitespace run in the ORIGINAL string — no trimming, no case
53
+ * normalization, no re-joining of tokens. `street + gap + street_suffix` reconstructs the input
54
+ * byte-for-byte, so the whitespace between them belongs to neither span (the same shape the corpus
55
+ * adapter's spans have). The tool asserts this per row and refuses to write a file if it ever
56
+ * fails.
57
+ */
58
+
59
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
60
+ import { basename, join } from "node:path"
61
+
62
+ import { isStreetDirectionalToken, matchTrailingSuffix, type USStreetSuffix } from "@mailwoman/codex/us"
63
+ import { parseJSONStrict, tryParsingJSON } from "@mailwoman/core/objects"
64
+ import { sha256File } from "@mailwoman/core/utils"
65
+ import { TextSpliterator } from "spliterator"
66
+
67
+ // ── Types ──────────────────────────────────────────────────────────────────
68
+
69
+ /**
70
+ * A golden-set row, as stored one-per-line in `us.jsonl` / `fr.jsonl` / `adversarial.jsonl`. Only the fields this tool
71
+ * reads are modeled; every other key rides through untouched.
72
+ */
73
+ export interface GoldenStreetRow {
74
+ raw: string
75
+ components: Record<string, string>
76
+ country?: string
77
+ source?: string
78
+ notes?: string
79
+ [key: string]: unknown
80
+ }
81
+
82
+ /**
83
+ * What the tool decided about one row. Every value except the two `split-*` classes means "left folded".
84
+ */
85
+ export type GoldenRelabelClass =
86
+ | "split-suffix"
87
+ | "split-suffix-postdirectional"
88
+ | "split-prefix-only"
89
+ | "already-split"
90
+ | "single-token"
91
+ | "suffix-only-street"
92
+ | "postdirectional-tail-only"
93
+ | "no-suffix-match"
94
+ | "no-street"
95
+ | "not-us"
96
+ | "untrimmed-street"
97
+
98
+ /**
99
+ * A review trigger on a row the tool DID change. A flag is never an adjudication — it marks the row for the operator's
100
+ * deck, and the split is applied either way.
101
+ */
102
+ export interface GoldenRelabelFlag {
103
+ kind: "name-prone-suffix" | "venue-context" | "remainder-is-affix"
104
+ detail: string
105
+ }
106
+
107
+ /**
108
+ * Row-level relabel outcome.
109
+ */
110
+ export interface GoldenRelabelResult {
111
+ /**
112
+ * The row to write. Identical object reference when nothing changed.
113
+ */
114
+ row: GoldenStreetRow
115
+ changed: boolean
116
+ rowClass: GoldenRelabelClass
117
+ flags: GoldenRelabelFlag[]
118
+ /**
119
+ * A leading directional was lifted into `street_prefix` on this row.
120
+ */
121
+ prefixSplit: boolean
122
+ /**
123
+ * The street span as it stood in the parent version — recorded for the review deck.
124
+ */
125
+ beforeStreet?: string
126
+ }
127
+
128
+ // ── The name-prone suffix set ──────────────────────────────────────────────
129
+
130
+ /**
131
+ * Pub-28 canonicals that are also ordinary head nouns of PROPER NAMES — "Lincoln Park", "Boston Common", "Willow
132
+ * Brook". A split on one of these is still applied (the table is the table), but the row lands in the review deck
133
+ * because the trailing word may belong to the name rather than to the street type.
134
+ *
135
+ * Chosen against the surfaces this golden set actually carries (park 6, green 5, hill 7, heights 3, hollow 3, brook 3,
136
+ * pass 3 — the whole flagged class is 60 rows of 1,906), not from the whole 200-entry table: flagging every possible
137
+ * name-head would mark a third of the corrections and stop being a review artifact.
138
+ */
139
+ const NAME_PRONE_SUFFIXES: ReadonlySet<USStreetSuffix> = new Set<USStreetSuffix>([
140
+ "BEACH",
141
+ "BROOK",
142
+ "BROOKS",
143
+ "CAMP",
144
+ "CENTER",
145
+ "CENTERS",
146
+ "CLUB",
147
+ "COMMON",
148
+ "COMMONS",
149
+ "CREEK",
150
+ "CROSSING",
151
+ "ESTATE",
152
+ "ESTATES",
153
+ "FIELD",
154
+ "FIELDS",
155
+ "FOREST",
156
+ "GARDEN",
157
+ "GARDENS",
158
+ "GLEN",
159
+ "GREEN",
160
+ "GREENS",
161
+ "GROVE",
162
+ "GROVES",
163
+ "HARBOR",
164
+ "HEIGHTS",
165
+ "HILL",
166
+ "HILLS",
167
+ "HOLLOW",
168
+ "ISLAND",
169
+ "ISLANDS",
170
+ "ISLE",
171
+ "JUNCTION",
172
+ "LAKE",
173
+ "LAKES",
174
+ "LANDING",
175
+ "MALL",
176
+ "MANOR",
177
+ "MEADOW",
178
+ "MEADOWS",
179
+ "MILL",
180
+ "MILLS",
181
+ "MISSION",
182
+ "ORCHARD",
183
+ "PARK",
184
+ "PASS",
185
+ "PLAZA",
186
+ "POINT",
187
+ "POINTS",
188
+ "RANCH",
189
+ "RIDGE",
190
+ "SHORE",
191
+ "SHORES",
192
+ "SPRING",
193
+ "SPRINGS",
194
+ "SQUARE",
195
+ "STATION",
196
+ "SUMMIT",
197
+ "VALLEY",
198
+ "VIEW",
199
+ "VIEWS",
200
+ "VILLAGE",
201
+ "VISTA",
202
+ ])
203
+
204
+ // ── Byte-exact tail split ──────────────────────────────────────────────────
205
+
206
+ interface TailSplit {
207
+ head: string
208
+ gap: string
209
+ tail: string
210
+ }
211
+
212
+ /**
213
+ * Cut `s` at its LAST whitespace run, returning the three pieces verbatim. Null when there is no interior whitespace,
214
+ * when the head would be empty, or when `s` carries leading/trailing whitespace (a golden row is stored trimmed; an
215
+ * untrimmed one is reported rather than silently normalized).
216
+ */
217
+ function splitLastWord(s: string): TailSplit | null {
218
+ if (s !== s.trim() || !s) return null
219
+ const match = /^(.*\S)(\s+)(\S+)$/.exec(s)
220
+
221
+ if (!match) return null
222
+
223
+ return { head: match[1]!, gap: match[2]!, tail: match[3]! }
224
+ }
225
+
226
+ /**
227
+ * Cut `s` at its FIRST whitespace run — the leading-directional counterpart of {@link splitLastWord}. `head` is the
228
+ * first word, `tail` the rest, both verbatim.
229
+ */
230
+ function splitFirstWord(s: string): TailSplit | null {
231
+ if (s !== s.trim() || !s) return null
232
+ const match = /^(\S+)(\s+)(\S.*)$/.exec(s)
233
+
234
+ if (!match) return null
235
+
236
+ return { head: match[1]!, gap: match[2]!, tail: match[3]! }
237
+ }
238
+
239
+ // ── Row-level relabel ──────────────────────────────────────────────────────
240
+
241
+ /**
242
+ * Rebuild `components` with `street_suffix` inserted immediately after `street`, so the written row reads in address
243
+ * order rather than with the new tag appended at the end.
244
+ */
245
+ function withStreetSpans(
246
+ components: Record<string, string>,
247
+ spans: { prefix?: string; street: string; suffix?: string }
248
+ ): Record<string, string> {
249
+ const out: Record<string, string> = {}
250
+
251
+ for (const [key, value] of Object.entries(components)) {
252
+ if (key === "street") {
253
+ if (spans.prefix) {
254
+ out.street_prefix = spans.prefix
255
+ }
256
+
257
+ out.street = spans.street
258
+
259
+ if (spans.suffix) {
260
+ out.street_suffix = spans.suffix
261
+ }
262
+
263
+ continue
264
+ }
265
+
266
+ if (key === "street_prefix" || key === "street_suffix") continue
267
+ out[key] = value
268
+ }
269
+
270
+ return out
271
+ }
272
+
273
+ /**
274
+ * Options for {@linkcode relabelGoldenStreetRow}.
275
+ */
276
+ export interface RelabelStreetRowOptions {
277
+ /**
278
+ * Also lift a folded LEADING directional out into `street_prefix`. Default true.
279
+ *
280
+ * On by default because the fold cuts both ways and the answer key has to be corrected on both, or the correction is
281
+ * not a correction: 207 of the 1,682 split dev rows (12.3%) still opened with a directional after the suffix move —
282
+ * "N Desmet Avenue" would have graded `street: "N Desmet"` against a model that says `street_prefix: "N", street:
283
+ * "Desmet"`. Turn it OFF only to measure what the prefix fold alone costs.
284
+ */
285
+ splitPrefix?: boolean
286
+ }
287
+
288
+ /**
289
+ * Decide, and apply, the US street-span split for ONE golden row. Pure: never mutates its argument, and returns the
290
+ * same object reference when the row is left alone.
291
+ */
292
+ export function relabelGoldenStreetRow(
293
+ row: GoldenStreetRow,
294
+ options: RelabelStreetRowOptions = {}
295
+ ): GoldenRelabelResult {
296
+ const splitPrefix = options.splitPrefix ?? true
297
+
298
+ const keep = (rowClass: GoldenRelabelClass): GoldenRelabelResult => ({
299
+ row,
300
+ changed: false,
301
+ rowClass,
302
+ flags: [],
303
+ prefixSplit: false,
304
+ })
305
+
306
+ if ((row.country ?? "").toUpperCase() !== "US") return keep("not-us")
307
+ const street = row.components.street
308
+
309
+ if (!street) return keep("no-street")
310
+
311
+ if (row.components.street_suffix) return keep("already-split")
312
+
313
+ if (street !== street.trim()) return keep("untrimmed-street")
314
+
315
+ const flags: GoldenRelabelFlag[] = []
316
+ let rowClass: GoldenRelabelClass
317
+ let name = street
318
+ let suffix: string | undefined
319
+ let suffixGap = ""
320
+ let canonical: USStreetSuffix | undefined
321
+
322
+ const cut = splitLastWord(street)
323
+
324
+ if (!cut) {
325
+ rowClass = matchTrailingSuffix(street) ? "suffix-only-street" : "single-token"
326
+ } else if (isStreetDirectionalToken(cut.tail)) {
327
+ // Street type + post-directional ("Pennsylvania Avenue NW"). The corpus adapter emits the pair as
328
+ // ONE suffix span, and there is no post-directional tag to move it to.
329
+ const inner = splitLastWord(cut.head)
330
+ const typeMatch = inner ? matchTrailingSuffix(inner.tail) : null
331
+
332
+ if (!inner || !typeMatch) {
333
+ rowClass = "postdirectional-tail-only"
334
+ } else {
335
+ rowClass = "split-suffix-postdirectional"
336
+ name = inner.head
337
+ suffix = `${inner.tail}${cut.gap}${cut.tail}`
338
+ suffixGap = inner.gap
339
+ canonical = typeMatch.canonical
340
+ }
341
+ } else {
342
+ const typeMatch = matchTrailingSuffix(street)
343
+
344
+ if (!typeMatch) {
345
+ rowClass = "no-suffix-match"
346
+ } else {
347
+ rowClass = "split-suffix"
348
+ name = cut.head
349
+ suffix = cut.tail
350
+ suffixGap = cut.gap
351
+ canonical = typeMatch.canonical
352
+ }
353
+ }
354
+
355
+ // Leading directional → street_prefix, on whatever name survived the suffix move. Independent of the
356
+ // suffix branch, because "N Main" is as folded as "N Main St" is.
357
+ let prefix: string | undefined
358
+ let prefixGap = ""
359
+
360
+ if (splitPrefix && !row.components.street_prefix) {
361
+ const lead = splitFirstWord(name)
362
+
363
+ if (lead && isStreetDirectionalToken(lead.head)) {
364
+ prefix = lead.head
365
+ prefixGap = lead.gap
366
+ name = lead.tail
367
+ }
368
+ }
369
+
370
+ if (!suffix && !prefix) return keep(rowClass)
371
+
372
+ if (!suffix) {
373
+ rowClass = "split-prefix-only"
374
+ }
375
+
376
+ // The invariant this tool exists to keep: the spans plus the whitespace between them ARE the original
377
+ // span, byte for byte. Anything else means a token was rewritten.
378
+ const rebuilt = `${prefix ? prefix + prefixGap : ""}${name}${suffix ? suffixGap + suffix : ""}`
379
+
380
+ if (rebuilt !== street) {
381
+ throw new Error(`golden-relabel: span reconstruction failed for ${JSON.stringify(street)}`)
382
+ }
383
+
384
+ if (canonical && NAME_PRONE_SUFFIXES.has(canonical)) {
385
+ flags.push({
386
+ kind: "name-prone-suffix",
387
+ detail: `${canonical} heads proper names as often as street types`,
388
+ })
389
+ }
390
+
391
+ const venue = row.components.venue
392
+
393
+ if (suffix && venue && new RegExp(`(^|\\W)${escapeRegExp(suffix)}(\\W|$)`, "i").test(venue)) {
394
+ flags.push({
395
+ kind: "venue-context",
396
+ detail: `venue ${JSON.stringify(venue)} also carries ${JSON.stringify(suffix)}`,
397
+ })
398
+ }
399
+
400
+ // Narrow on purpose: a name that happens to be a Pub-28 canonical is NOT interesting ("Mountain Rd",
401
+ // "Valley Dr", "Mills Ln" are ordinary streets, and flagging them buried the deck — 108 rows of noise
402
+ // on the first run). A name that is a bare DIRECTIONAL is: "East Rd" leaves `street: "East"`, which is
403
+ // a direction, not a name.
404
+ if (isStreetDirectionalToken(name)) {
405
+ flags.push({
406
+ kind: "remainder-is-affix",
407
+ detail: `street would become the bare directional ${JSON.stringify(name)}`,
408
+ })
409
+ }
410
+
411
+ return {
412
+ row: {
413
+ ...row,
414
+ components: withStreetSpans(row.components, {
415
+ ...(prefix ? { prefix } : row.components.street_prefix ? { prefix: row.components.street_prefix } : {}),
416
+ street: name,
417
+ ...(suffix ? { suffix } : {}),
418
+ }),
419
+ },
420
+ changed: true,
421
+ rowClass,
422
+ flags,
423
+ prefixSplit: Boolean(prefix),
424
+ beforeStreet: street,
425
+ }
426
+ }
427
+
428
+ function escapeRegExp(s: string): string {
429
+ return s.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&")
430
+ }
431
+
432
+ // ── Directory-level relabel ────────────────────────────────────────────────
433
+
434
+ /**
435
+ * Per-class row counts for one relabelled file.
436
+ */
437
+ export type GoldenRelabelCounts = Record<GoldenRelabelClass, number>
438
+
439
+ /**
440
+ * One line of the review deck: what a changed (or notably unchanged) row looked like before and after.
441
+ */
442
+ export interface GoldenRelabelDeckEntry {
443
+ file: string
444
+ line: number
445
+ raw: string
446
+ rowClass: GoldenRelabelClass
447
+ before: Record<string, string>
448
+ after: Record<string, string>
449
+ flags: GoldenRelabelFlag[]
450
+ }
451
+
452
+ /**
453
+ * Options for {@linkcode relabelGoldenDirectory}.
454
+ */
455
+ export interface RelabelGoldenOptions {
456
+ /**
457
+ * Parent golden version dir (read-only), e.g. `data/eval/golden/v0.1.2`.
458
+ */
459
+ input: string
460
+ /**
461
+ * Output golden version dir. Created; never overwritten in place.
462
+ */
463
+ output: string
464
+ /**
465
+ * Review-deck JSONL path. Default `<output>/REVIEW-DECK.jsonl`.
466
+ */
467
+ deck?: string
468
+ /**
469
+ * Parent version label recorded in the manifest. Default: the input dir's basename.
470
+ */
471
+ parentLabel?: string
472
+ /**
473
+ * Tool provenance recorded in the manifest — the commit the relabel ran at.
474
+ */
475
+ commit?: string
476
+ /**
477
+ * Passed through to {@linkcode relabelGoldenStreetRow}. Default true.
478
+ */
479
+ splitPrefix?: boolean
480
+ }
481
+
482
+ /**
483
+ * Aggregate outcome for a whole golden version.
484
+ */
485
+ export interface RelabelGoldenReport {
486
+ files: Record<
487
+ string,
488
+ { entries: number; changed: number; flagged: number; prefixSplit: number; counts: GoldenRelabelCounts }
489
+ >
490
+ deckPath: string
491
+ outputDir: string
492
+ totalChanged: number
493
+ totalFlagged: number
494
+ }
495
+
496
+ const EMPTY_COUNTS = (): GoldenRelabelCounts => ({
497
+ "split-suffix": 0,
498
+ "split-suffix-postdirectional": 0,
499
+ "split-prefix-only": 0,
500
+ "already-split": 0,
501
+ "single-token": 0,
502
+ "suffix-only-street": 0,
503
+ "postdirectional-tail-only": 0,
504
+ "no-suffix-match": 0,
505
+ "no-street": 0,
506
+ "not-us": 0,
507
+ "untrimmed-street": 0,
508
+ })
509
+
510
+ /**
511
+ * Classes that are LEFT FOLDED but still belong in the deck, because the operator asked to see them by name: a street
512
+ * that is entirely one suffix word, and a bare post-directional tail.
513
+ */
514
+ const DECK_WORTHY_UNCHANGED: ReadonlySet<GoldenRelabelClass> = new Set([
515
+ "suffix-only-street",
516
+ "postdirectional-tail-only",
517
+ "untrimmed-street",
518
+ ])
519
+
520
+ /**
521
+ * Relabel every `.jsonl` in a golden version dir, writing a new version dir plus a review deck and a MANIFEST that
522
+ * records the convention, the parent, and the counts. Non-JSONL siblings (README, split manifests) are copied forward
523
+ * so the new version is self-contained; nested split dirs (`dev/`, `test/`) are relabelled recursively.
524
+ */
525
+ export async function relabelGoldenDirectory(
526
+ options: RelabelGoldenOptions,
527
+ report: (line: string) => void = console.log
528
+ ): Promise<RelabelGoldenReport> {
529
+ const { input, output } = options
530
+ const deckPath = options.deck ?? join(output, "REVIEW-DECK.jsonl")
531
+ mkdirSync(output, { recursive: true })
532
+
533
+ const deck: GoldenRelabelDeckEntry[] = []
534
+ const files: RelabelGoldenReport["files"] = {}
535
+
536
+ const walk = (dirIn: string, dirOut: string, prefix: string): void => {
537
+ mkdirSync(dirOut, { recursive: true })
538
+
539
+ for (const name of readdirSync(dirIn, { withFileTypes: true })) {
540
+ const from = join(dirIn, name.name)
541
+ const to = join(dirOut, name.name)
542
+
543
+ if (name.isDirectory()) {
544
+ walk(from, to, `${prefix}${name.name}/`)
545
+
546
+ continue
547
+ }
548
+
549
+ if (!name.name.endsWith(".jsonl")) {
550
+ // MANIFEST is rewritten below; everything else (README, SPLIT-MANIFEST) rides forward.
551
+ if (name.name !== "MANIFEST.json") {
552
+ writeFileSync(to, readFileSync(from))
553
+ }
554
+
555
+ continue
556
+ }
557
+
558
+ const counts = EMPTY_COUNTS()
559
+ let changed = 0
560
+ let flagged = 0
561
+ let prefixSplit = 0
562
+ const out: string[] = []
563
+ let lineNumber = 0
564
+
565
+ for (const line of TextSpliterator.from(readFileSync(from, "utf8"))) {
566
+ if (!line.trim()) continue
567
+
568
+ lineNumber++
569
+ // A corrupt answer-key line must STOP the relabel, not silently drop a row — a golden file
570
+ // short by one row is a floor cut against a different denominator.
571
+ const row = parseJSONStrict<GoldenStreetRow>(line)
572
+ const result = relabelGoldenStreetRow(row, { splitPrefix: options.splitPrefix ?? true })
573
+
574
+ counts[result.rowClass]++
575
+
576
+ if (result.changed) {
577
+ changed++
578
+ }
579
+
580
+ if (result.prefixSplit) {
581
+ prefixSplit++
582
+ }
583
+
584
+ if (result.flags.length) {
585
+ flagged++
586
+ }
587
+
588
+ if (result.changed || DECK_WORTHY_UNCHANGED.has(result.rowClass)) {
589
+ deck.push({
590
+ file: `${prefix}${name.name}`,
591
+ line: lineNumber,
592
+ raw: row.raw,
593
+ rowClass: result.rowClass,
594
+ before: row.components,
595
+ after: result.row.components,
596
+ flags: result.flags,
597
+ })
598
+ }
599
+
600
+ out.push(JSON.stringify(result.row))
601
+ }
602
+
603
+ writeFileSync(to, out.join("\n") + "\n")
604
+ files[`${prefix}${name.name}`] = { entries: lineNumber, changed, flagged, prefixSplit, counts }
605
+
606
+ report(
607
+ ` ${prefix}${name.name}: ${lineNumber} rows, ${changed} changed (${prefixSplit} with a prefix lift), ${flagged} flagged`
608
+ )
609
+ }
610
+ }
611
+
612
+ report(`relabel ${input} → ${output}`)
613
+ walk(input, output, "")
614
+
615
+ writeFileSync(deckPath, deck.map((entry) => JSON.stringify(entry)).join("\n") + "\n")
616
+ writeFileSync(deckPath.replace(/\.jsonl$/, ".md"), renderDeckMarkdown(deck, basename(input), basename(output)))
617
+
618
+ const manifestFiles: Record<
619
+ string,
620
+ { entries: number; sha256: string; changed: number; flagged: number; prefix_split: number }
621
+ > = {}
622
+
623
+ for (const [name, stats] of Object.entries(files)) {
624
+ manifestFiles[name] = {
625
+ entries: stats.entries,
626
+ sha256: await sha256File(join(output, name)),
627
+ changed: stats.changed,
628
+ flagged: stats.flagged,
629
+ prefix_split: stats.prefixSplit,
630
+ }
631
+ }
632
+
633
+ const totalChanged = Object.values(files).reduce((n, f) => n + f.changed, 0)
634
+ const totalFlagged = Object.values(files).reduce((n, f) => n + f.flagged, 0)
635
+
636
+ const manifest = {
637
+ version: basename(output),
638
+ parent: options.parentLabel ?? basename(input),
639
+ generated_at: new Date().toISOString(),
640
+ tool: "corpus/src/tools/golden-relabel-street.ts (mailwoman corpus golden-relabel)",
641
+ ...(options.commit ? { commit: options.commit } : {}),
642
+ convention: {
643
+ street_convention: { US: "split", "*": "folded" },
644
+ declared:
645
+ "US street spans are labeled SPLIT: a leading directional is its own `street_prefix` span, the Pub-28 " +
646
+ "street type (plus a post-directional, when one trails it) is its own `street_suffix` span, and `street` " +
647
+ "carries only the name. Non-US rows keep the folded convention of the parent version. A scorer that " +
648
+ "folds `street_prefix`/`street`/`street_suffix` back together before comparing is NOT grading this " +
649
+ "answer key.",
650
+ instrument:
651
+ "@mailwoman/codex/us — matchTrailingSuffix (USPS Pub-28 Appendix C) for the type, " +
652
+ "isStreetDirectionalToken for the directionals",
653
+ left_folded:
654
+ "single-token streets, streets that are entirely one suffix word, bare post-directional tails, and any " +
655
+ "street whose trailing word is not in the Pub-28 table",
656
+ prefix_split: options.splitPrefix ?? true,
657
+ },
658
+ counts: {
659
+ changed: totalChanged,
660
+ flagged: totalFlagged,
661
+ prefix_split: Object.values(files).reduce((n, f) => n + f.prefixSplit, 0),
662
+ per_file: Object.fromEntries(Object.entries(files).map(([name, stats]) => [name, stats.counts])),
663
+ },
664
+ files: manifestFiles,
665
+ review_deck: basename(deckPath),
666
+ }
667
+
668
+ writeFileSync(join(output, "MANIFEST.json"), JSON.stringify(manifest, null, "\t") + "\n")
669
+ report(`✓ ${totalChanged} rows split, ${totalFlagged} flagged — deck at ${deckPath}`)
670
+
671
+ return { files, deckPath, outputDir: output, totalChanged, totalFlagged }
672
+ }
673
+
674
+ /**
675
+ * Render the operator-facing half of the review deck: the flagged rows first (those are the ones asking for a ruling),
676
+ * then the classes the tool LEFT FOLDED by name, then a sample of the ordinary corrections. The JSONL sibling carries
677
+ * every row; this file is the one a human reads.
678
+ */
679
+ function renderDeckMarkdown(deck: GoldenRelabelDeckEntry[], parent: string, version: string): string {
680
+ const span = (components: Record<string, string>): string =>
681
+ [components.street_prefix, components.street, components.street_suffix]
682
+ .filter(Boolean)
683
+ .map((s) => JSON.stringify(s))
684
+ .join(" + ")
685
+
686
+ // The split dirs are copies of the same rows — dedupe the deck to the top-level files for reading.
687
+ const top = deck.filter((entry) => !entry.file.includes("/"))
688
+ const flagged = top.filter((entry) => entry.flags.length)
689
+ const folded = top.filter((entry) => isLeftFolded(entry.rowClass))
690
+ const plain = top.filter((entry) => !entry.flags.length && !isLeftFolded(entry.rowClass))
691
+
692
+ const rows = (entries: GoldenRelabelDeckEntry[]): string =>
693
+ entries
694
+ .map(
695
+ (entry) =>
696
+ `| ${entry.file}:${entry.line} | ${span(entry.before)} | ${span(entry.after)} | ${entry.flags.map((f) => f.kind).join(", ") || "—"} | ${JSON.stringify(entry.raw)} |`
697
+ )
698
+ .join("\n")
699
+
700
+ const header = "| row | before | after | flags | raw |\n|---|---|---|---|---|"
701
+
702
+ return [
703
+ `# Golden street-suffix relabel review deck — ${parent} → ${version}`,
704
+ "",
705
+ "Rows are deduped to the top-level files (`dev/` and `test/` carry the same rows).",
706
+ "A flag is a REVIEW TRIGGER, not an adjudication: the split below is already applied.",
707
+ "",
708
+ `## Flagged (${flagged.length}) — needs a ruling`,
709
+ "",
710
+ header,
711
+ rows(flagged),
712
+ "",
713
+ `## Left folded (${folded.length}) — the tool declined to split these`,
714
+ "",
715
+ header,
716
+ rows(folded),
717
+ "",
718
+ `## Ordinary corrections (${plain.length}) — first 100 shown; the JSONL deck has all`,
719
+ "",
720
+ header,
721
+ rows(plain.slice(0, 100)),
722
+ "",
723
+ ].join("\n")
724
+ }
725
+
726
+ /**
727
+ * Every relabel class that means the row was left folded, for callers that want to report the residue.
728
+ */
729
+ export function isLeftFolded(rowClass: GoldenRelabelClass): boolean {
730
+ return rowClass !== "split-suffix" && rowClass !== "split-suffix-postdirectional" && rowClass !== "split-prefix-only"
731
+ }
732
+
733
+ /**
734
+ * True when a golden dir declares the US-split convention — i.e. it is safe to grade it with an UNFOLDED scorer.
735
+ */
736
+ export function goldenDeclaresSplitStreets(dir: string): boolean {
737
+ for (const candidate of [join(dir, "MANIFEST.json"), join(dir, "..", "MANIFEST.json")]) {
738
+ if (!existsSync(candidate)) continue
739
+
740
+ const manifest = tryParsingJSON<{ convention?: { street_convention?: Record<string, string> } }>(
741
+ readFileSync(candidate, "utf8")
742
+ )
743
+
744
+ if (manifest?.convention?.street_convention?.US === "split") return true
745
+ }
746
+
747
+ return false
748
+ }