@mailwoman/corpus 7.2.0 → 7.3.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 (103) hide show
  1. package/package.json +59 -27
  2. package/src/adapter.ts +153 -0
  3. package/src/adapters/ban/adapter.ts +189 -0
  4. package/src/adapters/ban/street-decompose.ts +98 -0
  5. package/src/adapters/fcc-bdc/adapter.ts +188 -0
  6. package/src/adapters/geonames/adapter.ts +185 -0
  7. package/src/adapters/geonames-postal/adapter.ts +111 -0
  8. package/src/adapters/gnaf/adapter.ts +139 -0
  9. package/src/adapters/gnaf/assemble.ts +200 -0
  10. package/src/adapters/index.ts +135 -0
  11. package/src/adapters/openaddresses/adapter.ts +237 -0
  12. package/src/adapters/overture/adapter.ts +149 -0
  13. package/src/adapters/state-hi-schools/adapter.ts +171 -0
  14. package/src/adapters/state-ia-contractors/adapter.ts +148 -0
  15. package/src/adapters/state-ny-notaries/adapter.ts +169 -0
  16. package/src/adapters/state-tx-notaries/adapter.ts +160 -0
  17. package/src/adapters/synth-po-box/adapter.ts +197 -0
  18. package/src/adapters/tiger/adapter.ts +232 -0
  19. package/src/adapters/tiger/street-decompose.ts +120 -0
  20. package/src/adapters/usgov-hrsa-fqhc/adapter.ts +189 -0
  21. package/src/adapters/usgov-imls-pls/adapter.ts +152 -0
  22. package/src/adapters/usgov-irs-bmf/adapter.ts +155 -0
  23. package/src/adapters/usgov-nad/adapter.ts +338 -0
  24. package/src/adapters/usgov-nppes/adapter.ts +169 -0
  25. package/src/adapters/usgov-samhsa-treatment-locator/adapter.ts +211 -0
  26. package/src/adapters/wof-admin-jp/adapter.ts +176 -0
  27. package/src/adapters/wof-admin-json/adapter.ts +280 -0
  28. package/src/adapters/wof-postalcode-json/adapter.ts +209 -0
  29. package/src/align.ts +317 -0
  30. package/src/build.ts +348 -0
  31. package/src/codex/us-fips-state.ts +109 -0
  32. package/src/format.ts +14 -0
  33. package/src/golden.ts +147 -0
  34. package/src/index.ts +28 -0
  35. package/src/license.ts +48 -0
  36. package/src/parquet-wrapper/index.ts +9 -0
  37. package/src/parquet-wrapper/reader.ts +67 -0
  38. package/src/parquet-wrapper/schema.ts +80 -0
  39. package/src/parquet-wrapper/writer.ts +102 -0
  40. package/src/parquet.ts +360 -0
  41. package/src/runner.ts +283 -0
  42. package/src/shard-recipes/anchor-absorption.ts +86 -0
  43. package/src/shard-recipes/boundary-stress.ts +87 -0
  44. package/src/shard-recipes/country-balanced.ts +471 -0
  45. package/src/shard-recipes/cz-pcfirst-preposition.ts +83 -0
  46. package/src/shard-recipes/fr-admin-split.ts +212 -0
  47. package/src/shard-recipes/fr-bare-street.ts +85 -0
  48. package/src/shard-recipes/fr-fragment.ts +304 -0
  49. package/src/shard-recipes/fr-order.ts +283 -0
  50. package/src/shard-recipes/german.ts +206 -0
  51. package/src/shard-recipes/house-venue.ts +78 -0
  52. package/src/shard-recipes/index.ts +76 -0
  53. package/src/shard-recipes/intersection.ts +617 -0
  54. package/src/shard-recipes/locale.ts +415 -0
  55. package/src/shard-recipes/nl-postcode.ts +105 -0
  56. package/src/shard-recipes/no-fragment.ts +206 -0
  57. package/src/shard-recipes/no-street-led.ts +130 -0
  58. package/src/shard-recipes/no-street.ts +78 -0
  59. package/src/shard-recipes/po-box-cedex.ts +853 -0
  60. package/src/shard-recipes/po-box.ts +111 -0
  61. package/src/shard-recipes/scaffold.ts +187 -0
  62. package/src/shard-recipes/si-bare-village.ts +88 -0
  63. package/src/shard-recipes/street-affix.ts +549 -0
  64. package/src/shard-recipes/street-bare.ts +90 -0
  65. package/src/shard-recipes/street.ts +74 -0
  66. package/src/shard-recipes/unit.ts +315 -0
  67. package/src/split.ts +251 -0
  68. package/src/synthesize-anchor-absorption.ts +263 -0
  69. package/src/synthesize-boundary-stress.ts +504 -0
  70. package/src/synthesize-german.ts +227 -0
  71. package/src/synthesize-house-venue.ts +192 -0
  72. package/src/synthesize-intersection.ts +205 -0
  73. package/src/synthesize-no-street.ts +341 -0
  74. package/src/synthesize-po-box.ts +307 -0
  75. package/src/synthesize-street.ts +264 -0
  76. package/src/synthesize.ts +872 -0
  77. package/src/tokenize.ts +68 -0
  78. package/src/tools/align-shard.ts +67 -0
  79. package/src/tools/audit.ts +352 -0
  80. package/src/tools/corpus-stats.ts +183 -0
  81. package/src/tools/fetch/ban.ts +288 -0
  82. package/src/tools/fetch/download.ts +119 -0
  83. package/src/tools/fetch/hrsa.ts +65 -0
  84. package/src/tools/fetch/imls-pls.ts +162 -0
  85. package/src/tools/fetch/index.ts +121 -0
  86. package/src/tools/fetch/nad.ts +304 -0
  87. package/src/tools/fetch/nppes.ts +177 -0
  88. package/src/tools/fetch/openaddresses.ts +380 -0
  89. package/src/tools/fetch/state-hi-schools.ts +217 -0
  90. package/src/tools/fetch/state-sources.ts +142 -0
  91. package/src/tools/fetch/tiger-full.ts +318 -0
  92. package/src/tools/golden-expand.ts +524 -0
  93. package/src/tools/golden-promote.ts +304 -0
  94. package/src/tools/index.ts +24 -0
  95. package/src/tools/ingest-csv.ts +420 -0
  96. package/src/tools/jsonl-to-parquet.ts +200 -0
  97. package/src/tools/lint-shard-vocab.ts +357 -0
  98. package/src/tools/lint-shard.ts +502 -0
  99. package/src/tools/overlay-manifest.ts +141 -0
  100. package/src/tools/shard-kryptonite.ts +131 -0
  101. package/src/tools/shard-translit.ts +244 -0
  102. package/src/types.ts +213 -0
  103. package/src/wof-json.ts +236 -0
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `street` shard recipe — synthetic street-decomposition rows for Stage 3 (US-only): tuples →
7
+ * {@link synthesizeStreetRow} → aligned LabeledRow. Ported from scripts/build-street-shard.mjs.
8
+ */
9
+
10
+ import { stableSourceID } from "../adapter.ts"
11
+ import { synthesizeStreetRow, type StreetBaseTuple } from "../synthesize-street.ts"
12
+ import { alignAndWrite, makeLcg, readTuples, type ShardRecipe } from "./scaffold.ts"
13
+
14
+ export const streetRecipe: ShardRecipe = {
15
+ name: "street",
16
+ description: "Street-decomposition rows (US): tuples → synthesizeStreetRow → aligned LabeledRow",
17
+ mode: "tuples",
18
+ options: [{ flag: "--house-number-prob <p>", description: "P(emit a house number). Default 0.85" }],
19
+ async run(opts, write) {
20
+ if (!opts.input) throw new Error("street recipe requires --input <tuples.jsonl>")
21
+ const random = makeLcg(opts.seed)
22
+ const includeHouseNumberProb = opts.houseNumberProb ?? 0.85
23
+ let read = 0
24
+ let emitted = 0
25
+ let skipped = 0
26
+
27
+ for await (const tuple of readTuples(opts.input)) {
28
+ read++
29
+
30
+ if (!tuple.locality || !tuple.region || !tuple.postcode || !tuple.country) {
31
+ skipped++
32
+ continue
33
+ }
34
+
35
+ if (tuple.country !== "US") {
36
+ skipped++
37
+ continue
38
+ }
39
+
40
+ for (let v = 0; v < opts.variants; v++) {
41
+ const synth = synthesizeStreetRow(tuple as StreetBaseTuple, { random, includeHouseNumberProb })
42
+
43
+ if (!synth) continue
44
+ const ok = alignAndWrite(
45
+ write,
46
+ {
47
+ raw: synth.raw,
48
+ components: synth.components,
49
+ country: tuple.country,
50
+ locale: synth.locale,
51
+ source: "synth-street",
52
+ source_id: stableSourceID("synth-street", {
53
+ locality: `${tuple.locality}#${v}`,
54
+ region: tuple.region,
55
+ postcode: tuple.postcode,
56
+ country: tuple.country,
57
+ }),
58
+ corpus_version: "0.4.0",
59
+ license: "Synthetic — public-domain street name + tuple combination",
60
+ },
61
+ "street-decomp"
62
+ )
63
+
64
+ if (ok) {
65
+ emitted++
66
+ } else {
67
+ skipped++
68
+ }
69
+ }
70
+ }
71
+
72
+ return { read, emitted, skipped }
73
+ },
74
+ }
@@ -0,0 +1,315 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `unit` shard recipe — US secondary-unit coverage (#451, the v0-parity `unit` gap). Onto REAL US
7
+ * OpenAddresses skeletons (cached zips under `/tmp/oa-cache`) it INJECTS a USPS Pub-28 Appendix
8
+ * C2 secondary-unit designator (the `@mailwoman/codex/us` table), varying the surface form
9
+ * (canonical "Apartment" vs approved "Apt") AND the unit's POSITION (after-street / unit-first /
10
+ * bare / venue-prefixed) per row, so the model learns to RECOGNIZE the designator wherever it
11
+ * sits. The inline synthesis (the OA-CSV reader, the designator tables, `makeUnit`/`renderUnit`)
12
+ * is ported faithfully from scripts/build-unit-shard.mjs.
13
+ *
14
+ * `--golden`: a held-out eval over the VERMONT source only (the corpus `defaultHoldout`, never
15
+ * trained) with a different seed, emitting `{raw, components, country}` for per-locale-f1. Train
16
+ * uses every NON-Vermont US source. Designators are injected in both (OA carries none), so the
17
+ * eval measures designator recognition on held-out addresses.
18
+ *
19
+ * NOTE: this is a `generate`-mode recipe but it still reads REAL tuples off disk (`unzip` of the
20
+ * cached OA zips) — `--count` bounds the OUTPUT, not the input. The passed `random` (the
21
+ * framework LCG) is consumed in the exact call order the legacy script used.
22
+ */
23
+
24
+ import { spawnSync } from "node:child_process"
25
+
26
+ import { US_UNIT_DESIGNATOR_PREFERRED_ABBR, type USUnitDesignator } from "@mailwoman/codex/us"
27
+ import type { ComponentTag } from "@mailwoman/core/types"
28
+
29
+ import { stableSourceID } from "../adapter.ts"
30
+ import { alignRow } from "../align.ts"
31
+ import type { CanonicalRow } from "../types.ts"
32
+ import { makeMulberry32, type ShardRecipe } from "./scaffold.ts"
33
+
34
+ /** A cached OpenAddresses extract: the zip, the CSV member, and the implied (file-level) region. */
35
+ interface UnitSource {
36
+ zip: string
37
+ csv: string
38
+ region: string
39
+ }
40
+
41
+ // OA REGION is empty for US per-state extracts — the region is implied by the file. Train sources are
42
+ // every NON-Vermont state cached; eval is Vermont only (the corpus holdout).
43
+ const TRAIN_SOURCES: readonly UnitSource[] = [
44
+ { zip: "/tmp/oa-cache/us__ca__berkeley.zip", csv: "us/ca/berkeley.csv", region: "CA" },
45
+ { zip: "/tmp/oa-cache/us__ca__marin.zip", csv: "us/ca/marin.csv", region: "CA" },
46
+ { zip: "/tmp/oa-cache/us__dc__statewide.zip", csv: "us/dc/statewide.csv", region: "DC" },
47
+ { zip: "/tmp/oa-cache/us__ia__statewide.zip", csv: "us/ia/statewide.csv", region: "IA" },
48
+ { zip: "/tmp/oa-cache/us__il__cook.zip", csv: "us/il/cook.csv", region: "IL" },
49
+ { zip: "/tmp/oa-cache/us__mt__statewide.zip", csv: "us/mt/statewide.csv", region: "MT" },
50
+ { zip: "/tmp/oa-cache/us__sd__statewide.zip", csv: "us/sd/statewide.csv", region: "SD" },
51
+ ]
52
+ const EVAL_SOURCE: UnitSource = { zip: "/tmp/oa-cache/us__vt__statewide.zip", csv: "us/vt/statewide.csv", region: "VT" }
53
+
54
+ // USPS Pub-28 C2 designators that take a secondary identifier ("Apt 4B"). Weighted toward the common
55
+ // ones the v0-parity arena failed on (Apt/Ste/Unit/Fl/Rm). Standalone designators (Basement, Lobby,
56
+ // Penthouse) are emitted occasionally with no id.
57
+ const ID_DESIGNATORS: readonly USUnitDesignator[] = [
58
+ "APARTMENT",
59
+ "SUITE",
60
+ "UNIT",
61
+ "FLOOR",
62
+ "ROOM",
63
+ "BUILDING",
64
+ "DEPARTMENT",
65
+ "SPACE",
66
+ "LOT",
67
+ ]
68
+ const STANDALONE_DESIGNATORS: readonly USUnitDesignator[] = [
69
+ "BASEMENT",
70
+ "LOBBY",
71
+ "PENTHOUSE",
72
+ "FRONT",
73
+ "REAR",
74
+ "UPPER",
75
+ "LOWER",
76
+ ]
77
+ const ID_WEIGHT = 0.85 // 85% id-bearing designators, 15% standalone
78
+ const SYNTH_IDS: readonly string[] = ["4B", "200", "12", "3", "A", "101", "5", "2A", "310", "B", "7", "1500", "404"]
79
+
80
+ /** A real US tuple read out of a cached OA zip (number/street/city/postcode + the bare OA unit id). */
81
+ interface UnitTuple {
82
+ house_number: string
83
+ street: string
84
+ locality: string
85
+ region: string
86
+ postcode: string
87
+ oaUnit: string
88
+ }
89
+
90
+ /** Minimal RFC-4180-ish splitter (handles quoted fields). */
91
+ function splitCSV(line: string): string[] {
92
+ const out: string[] = []
93
+ let cur = ""
94
+ let inQ = false
95
+
96
+ for (let i = 0; i < line.length; i++) {
97
+ const c = line[i]
98
+
99
+ if (inQ) {
100
+ if (c === '"') {
101
+ if (line[i + 1] === '"') {
102
+ cur += '"'
103
+ i++
104
+ } else {
105
+ inQ = false
106
+ }
107
+ } else {
108
+ cur += c
109
+ }
110
+ } else if (c === '"') {
111
+ inQ = true
112
+ } else if (c === ",") {
113
+ out.push(cur)
114
+ cur = ""
115
+ } else {
116
+ cur += c
117
+ }
118
+ }
119
+ out.push(cur)
120
+
121
+ return out
122
+ }
123
+
124
+ /** Stream real US tuples (number/street/city/postcode + the bare OA unit id) out of a cached OA zip. */
125
+ function readTuples(source: UnitSource): UnitTuple[] {
126
+ const r = spawnSync("unzip", ["-p", source.zip, source.csv], { maxBuffer: 1024 * 1024 * 1024, encoding: "buffer" })
127
+
128
+ if (r.status !== 0) {
129
+ console.error(` WARN: unzip failed for ${source.zip} (status ${r.status})`)
130
+
131
+ return []
132
+ }
133
+ const lines = r.stdout.toString("utf8").split(/\r?\n/)
134
+
135
+ if (lines.length < 2) return []
136
+ const header = splitCSV(lines[0]!).map((h) => h.trim().toLowerCase())
137
+ const idx = (name: string): number => header.indexOf(name)
138
+ const iNum = idx("number"),
139
+ iStreet = idx("street"),
140
+ iUnit = idx("unit"),
141
+ iCity = idx("city"),
142
+ iPost = idx("postcode")
143
+ const get = (cells: string[], i: number): string => (i >= 0 && i < cells.length ? (cells[i] ?? "").trim() : "")
144
+ const tuples: UnitTuple[] = []
145
+ const seen = new Set<string>()
146
+
147
+ for (let li = 1; li < lines.length; li++) {
148
+ if (!lines[li]) continue
149
+ const cells = splitCSV(lines[li]!)
150
+ const street = get(cells, iStreet)
151
+ const locality = get(cells, iCity)
152
+ const house_number = get(cells, iNum)
153
+
154
+ if (!street || !locality || !house_number) continue
155
+ const key = `${house_number}|${street}|${locality}`.toLowerCase()
156
+
157
+ if (seen.has(key)) continue
158
+ seen.add(key)
159
+ tuples.push({
160
+ house_number,
161
+ street,
162
+ locality,
163
+ region: source.region,
164
+ postcode: get(cells, iPost),
165
+ oaUnit: get(cells, iUnit),
166
+ })
167
+ }
168
+
169
+ return tuples
170
+ }
171
+
172
+ /** Title-case a canonical/abbrev designator ("APARTMENT" → "Apartment", "APT" → "Apt"). */
173
+ const title = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()
174
+
175
+ /** Build an injected unit string ("Apt 4B"), varying canonical vs approved-abbrev form per row. */
176
+ function makeUnit(random: () => number, oaUnit: string): string {
177
+ const standalone = random() >= ID_WEIGHT
178
+ const pool = standalone ? STANDALONE_DESIGNATORS : ID_DESIGNATORS
179
+ const canonical = pool[Math.floor(random() * pool.length)]!
180
+ // Vary the surface form 50/50 (this is the #454 expand/abbreviate variety, baked into the shard).
181
+ const designator = random() < 0.5 ? title(canonical) : title(US_UNIT_DESIGNATOR_PREFERRED_ABBR[canonical])
182
+
183
+ if (standalone) return designator
184
+ const id = oaUnit && oaUnit.length <= 6 ? oaUnit : SYNTH_IDS[Math.floor(random() * SYNTH_IDS.length)]!
185
+
186
+ return `${designator} ${id}`
187
+ }
188
+
189
+ /** Synthetic recipient/venue prefixes — the "JOHN DOE, ACME INC, ..." arena pattern. */
190
+ const VENUES: readonly string[] = [
191
+ "John Doe",
192
+ "Jane Smith",
193
+ "Acme Inc",
194
+ "Wayne Enterprises",
195
+ "Stark Industries",
196
+ "Globex Corp",
197
+ "Maria Garcia",
198
+ "Robert Chen",
199
+ "Oak Street Dental",
200
+ "Riverside Clinic",
201
+ ]
202
+
203
+ /** Address tail: "City, ST 12345" (or no postcode). */
204
+ const tail = (loc: string, reg: string, pc: string): string => (pc ? `${loc}, ${reg} ${pc}` : `${loc}, ${reg}`)
205
+
206
+ /**
207
+ * Render a unit row in a RANDOM layout — units spread across positions, the city/state tail dropped on bare rows, a
208
+ * recipient/venue prefixed on the venue format — so the model learns to RECOGNIZE the designator wherever it sits.
209
+ * Returns {fmt, raw, components}.
210
+ */
211
+ function renderUnit(
212
+ random: () => number,
213
+ base: UnitTuple,
214
+ unit: string
215
+ ): { fmt: string; raw: string; components: Partial<Record<ComponentTag, string>> } {
216
+ const hn = base.house_number,
217
+ street = base.street,
218
+ loc = base.locality,
219
+ reg = base.region,
220
+ pc = base.postcode
221
+ const road = `${hn} ${street}`
222
+ const full: Partial<Record<ComponentTag, string>> = {
223
+ house_number: hn,
224
+ street,
225
+ unit,
226
+ locality: loc,
227
+ region: reg,
228
+ ...(pc ? { postcode: pc } : {}),
229
+ }
230
+ const r = random()
231
+
232
+ if (r < 0.34) return { fmt: "full-after", raw: `${road} ${unit}, ${tail(loc, reg, pc)}`, components: full }
233
+
234
+ if (r < 0.52) return { fmt: "full-first", raw: `${unit}, ${road}, ${tail(loc, reg, pc)}`, components: full }
235
+
236
+ if (r < 0.68) return { fmt: "bare-after", raw: `${road} ${unit}`, components: { house_number: hn, street, unit } }
237
+
238
+ if (r < 0.84) return { fmt: "bare-first", raw: `${unit} ${road}`, components: { house_number: hn, street, unit } }
239
+ const v = VENUES[Math.floor(random() * VENUES.length)]!
240
+
241
+ return { fmt: "venue", raw: `${v}, ${road} ${unit}, ${tail(loc, reg, pc)}`, components: { venue: v, ...full } }
242
+ }
243
+
244
+ export const unitRecipe: ShardRecipe = {
245
+ name: "unit",
246
+ description: "US secondary-unit rows (#451): real OA skeletons + injected USPS Pub-28 C2 unit designators",
247
+ mode: "generate",
248
+ options: [{ flag: "--golden", description: "Emit the held-out VT eval slice ({raw, components, country})" }],
249
+ async run(opts, write) {
250
+ if (opts.count == null) throw new Error("unit recipe requires --count <N>")
251
+ const count = opts.count
252
+ // Legacy build-unit-shard.mjs seeded mulberry32 with the raw seed: `const random = mulberry32(opts.seed)`.
253
+ const random = makeMulberry32(opts.seed)
254
+ const source = opts.sourceName ?? "synth-unit"
255
+ const sources = opts.golden ? [EVAL_SOURCE] : TRAIN_SOURCES
256
+
257
+ const pool: UnitTuple[] = []
258
+
259
+ for (const s of sources) {
260
+ const t = readTuples(s)
261
+ console.error(` ${s.csv}: ${t.length} unique tuples`)
262
+
263
+ for (const x of t) {
264
+ pool.push(x)
265
+ }
266
+ }
267
+
268
+ if (pool.length === 0) {
269
+ throw new Error("No US tuples found — are the cached OA zips present in /tmp/oa-cache?")
270
+ }
271
+
272
+ let emitted = 0
273
+ let skipped = 0
274
+ let guard = 0
275
+ const N = pool.length
276
+
277
+ while (emitted < count && guard++ < count * 6) {
278
+ const base = pool[Math.floor(random() * N)]!
279
+ const unit = makeUnit(random, base.oaUnit)
280
+ const { raw, components } = renderUnit(random, base, unit)
281
+
282
+ // The unit must survive verbatim in raw, else alignment can't label it.
283
+ if (!raw.includes(unit)) {
284
+ skipped++
285
+ continue
286
+ }
287
+
288
+ if (opts.golden) {
289
+ write(JSON.stringify({ raw, components, country: "US" }) + "\n")
290
+ emitted++
291
+ continue
292
+ }
293
+ const canonical: CanonicalRow = {
294
+ raw,
295
+ components,
296
+ country: "US",
297
+ locale: "en-US",
298
+ source,
299
+ source_id: stableSourceID(source, components),
300
+ corpus_version: "0.4.0",
301
+ license: "OpenAddresses US (non-VT) skeletons + injected USPS Pub-28 C2 unit designators",
302
+ }
303
+ const aligned = alignRow(canonical)
304
+
305
+ if (aligned.kind !== "labeled" || !aligned.row) {
306
+ skipped++
307
+ continue
308
+ }
309
+ write(JSON.stringify({ ...aligned.row, synth_method: "unit", synth_base_id: null }) + "\n")
310
+ emitted++
311
+ }
312
+
313
+ return { emitted, skipped }
314
+ },
315
+ }
package/src/split.ts ADDED
@@ -0,0 +1,251 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Train / val / test split with **locality holdout** per the Phase 1 plan.
7
+ *
8
+ * The corpus's val + test sets are not randomly sampled rows — they're entire low-density regions
9
+ * held out so the model cannot memorize them at training time. Rationale (per the plan's "Common
10
+ * pitfalls" section): random splits leak by neighborhood — a model fed "13 Main St, Springfield,
11
+ * IL" in train and "15 Main St, Springfield, IL" in test generalizes via region/locality
12
+ * memorization, not by learning the underlying schema.
13
+ *
14
+ * Phase 1 holdouts (chosen for low data density + administrative isolation):
15
+ *
16
+ * - **US**: Vermont, Wyoming, North Dakota
17
+ * - **FR**: Corse, Lozère, Creuse
18
+ *
19
+ * Held-out rows are deterministically split 50/50 between val and test by hashing the row's
20
+ * `source_id`. Non-held-out rows go to train. The 90/5/5 ratio is approximate — what matters is
21
+ * the locality boundary, not the exact split percentages.
22
+ *
23
+ * The output is a `SplitManifest`: three `string[]` arrays of `source_id`. Manifests live in git
24
+ * (under `corpus/splits/<version>/`) so reruns are reproducible bit-for-bit.
25
+ */
26
+
27
+ import { spawn } from "node:child_process"
28
+ import { createHash } from "node:crypto"
29
+ import { createWriteStream } from "node:fs"
30
+ import { mkdir, unlink, writeFile } from "node:fs/promises"
31
+ import { join } from "node:path"
32
+
33
+ import { childEnv } from "@mailwoman/core/scripting/utils"
34
+ import { JSONSpliterator } from "spliterator"
35
+
36
+ import type { CanonicalRow, LabeledRow } from "./types.ts"
37
+
38
+ export type SplitName = "train" | "val" | "test"
39
+
40
+ export interface SplitOptions {
41
+ /**
42
+ * Region-name → holdout policy, keyed by ISO 3166-1 alpha-2 country. The values are the region-component strings the
43
+ * splitter looks for in `row.components.region`. Override to change the holdout for an experiment; defaults to
44
+ * `defaultHoldouts()`.
45
+ */
46
+ holdouts?: Record<string, readonly string[]>
47
+ }
48
+
49
+ /** Output manifest: source_id lists per split. */
50
+ export interface SplitManifest {
51
+ train: string[]
52
+ val: string[]
53
+ test: string[]
54
+ /** Echoes the holdouts used, so the manifest is self-describing. */
55
+ holdouts: Record<string, readonly string[]>
56
+ /** Corpus version stamped onto the manifest. Read from the first row. */
57
+ corpus_version: string
58
+ /** Counts for quick sanity checks. */
59
+ counts: { train: number; val: number; test: number; total: number }
60
+ }
61
+
62
+ /**
63
+ * Phase 1 default holdouts (per plan).
64
+ *
65
+ * - US: Vermont, Wyoming, North Dakota (low density, easy to identify in WOF/admin sources).
66
+ * - FR: Corse, Lozère, Creuse (small departments / regions).
67
+ * - DE (added 2026-06-11, night-11): Saarland + Mecklenburg-Vorpommern — small Länder so the training cost is low while
68
+ * the slice clears the honest-eval 1000-row trust floor. DE has had NO trustable honest-eval slice since the harness
69
+ * shipped (flagged 2026-06-08); this takes effect at the NEXT base corpus rebuild — existing versioned corpora keep
70
+ * their committed SPLIT_MANIFESTs (a holdout added after a corpus is built is leakage-laundering, not a holdout).
71
+ */
72
+ export function defaultHoldouts(): Record<string, readonly string[]> {
73
+ return {
74
+ US: ["Vermont", "VT", "Wyoming", "WY", "North Dakota", "ND"],
75
+ FR: ["Corse", "Lozère", "Lozere", "Creuse"],
76
+ DE: ["Saarland", "SL", "Mecklenburg-Vorpommern", "MV"],
77
+ }
78
+ }
79
+
80
+ type SplitInputRow = Pick<CanonicalRow, "source_id" | "country" | "corpus_version" | "components">
81
+
82
+ /**
83
+ * Pure per-row split decision. Used by both the in-memory `splitRows` and by the streaming `buildCorpus` align loop
84
+ * (`build.ts`) to decide each row's split without retaining the row in heap. Identical hash bucketing semantics to the
85
+ * array-based path so the decision is stable regardless of caller.
86
+ */
87
+ export function splitForRow(
88
+ row: Pick<SplitInputRow, "source_id" | "country" | "components">,
89
+ holdouts: Record<string, readonly string[]> = defaultHoldouts()
90
+ ): SplitName {
91
+ const region = row.components.region
92
+ const countryHoldouts = holdouts[row.country] ?? []
93
+ const isHeldOut = region !== undefined && countryHoldouts.includes(region)
94
+
95
+ if (!isHeldOut) return "train"
96
+
97
+ // 50/50 deterministic by source_id hash. Same input always lands in the same split.
98
+ return hashBucket(row.source_id, 2) === 0 ? "val" : "test"
99
+ }
100
+
101
+ /**
102
+ * Compute a `SplitManifest` from an iterable of labeled (or canonical) rows. Both shapes are accepted — only
103
+ * `source_id`, `country`, `corpus_version`, and `components.region` are consulted.
104
+ *
105
+ * Retained for in-memory callers (tests; small-scale fixture runs). Real-data builds via `buildCorpus` use the
106
+ * streaming path (`splitForRow` + `writeSplitManifestsFromLabeledFiles`) to avoid materializing every aligned row's
107
+ * split membership in heap.
108
+ */
109
+ export function splitRows(rows: Iterable<SplitInputRow>, opts: SplitOptions = {}): SplitManifest {
110
+ const holdouts = opts.holdouts ?? defaultHoldouts()
111
+ const train: string[] = []
112
+ const val: string[] = []
113
+ const test: string[] = []
114
+ let corpus_version = ""
115
+
116
+ for (const row of rows) {
117
+ if (!corpus_version && row.corpus_version) {
118
+ corpus_version = row.corpus_version
119
+ }
120
+ const split = splitForRow(row, holdouts)
121
+
122
+ if (split === "train") {
123
+ train.push(row.source_id)
124
+ } else if (split === "val") {
125
+ val.push(row.source_id)
126
+ } else {
127
+ test.push(row.source_id)
128
+ }
129
+ }
130
+
131
+ const total = train.length + val.length + test.length
132
+
133
+ return {
134
+ train,
135
+ val,
136
+ test,
137
+ holdouts,
138
+ corpus_version,
139
+ counts: { train: train.length, val: val.length, test: test.length, total },
140
+ }
141
+ }
142
+
143
+ /** Lightweight deterministic 0..(n-1) bucket from a string id. */
144
+ export function hashBucket(id: string, n: number): number {
145
+ const digest = createHash("sha256").update(id).digest()
146
+ // Read 4 bytes as uint32 to avoid bigint overhead.
147
+ const u = digest[0]! * 0x01_00_00_00 + digest[1]! * 0x01_00_00 + digest[2]! * 0x01_00 + digest[3]!
148
+
149
+ return u % n
150
+ }
151
+
152
+ /**
153
+ * Write a `SplitManifest` to `<outputDir>/{train,val,test}.json`. The manifests are line-separated source_id lists (one
154
+ * id per line) so they diff cleanly in git. Also writes `<outputDir>/MANIFEST.json` with the full structured manifest
155
+ * including holdouts + counts + corpus version.
156
+ *
157
+ * Reruns produce byte-identical files (the underlying `splitRows` is deterministic).
158
+ */
159
+ export async function writeSplitManifests(manifest: SplitManifest, outputDir: string): Promise<void> {
160
+ await mkdir(outputDir, { recursive: true })
161
+
162
+ for (const name of ["train", "val", "test"] as const) {
163
+ const sorted = [...manifest[name]].sort()
164
+ await writeFile(join(outputDir, `${name}.txt`), sorted.join("\n") + (sorted.length ? "\n" : ""), "utf8")
165
+ }
166
+ const summary = {
167
+ corpus_version: manifest.corpus_version,
168
+ holdouts: manifest.holdouts,
169
+ counts: manifest.counts,
170
+ }
171
+ await writeFile(join(outputDir, "SPLIT_MANIFEST.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8")
172
+ }
173
+
174
+ /** Type re-export for callers that want to ingest LabeledRow specifically. */
175
+ export type SplitInputLabeledRow = Pick<LabeledRow, "source_id" | "country" | "corpus_version" | "components">
176
+
177
+ /**
178
+ * Streaming variant of `writeSplitManifests`: derives the per-split source-id .txt manifests + `SPLIT_MANIFEST.json` by
179
+ * streaming three per-split labeled-row JSONL files (one per split). Memory cost is O(1) — `sort(1)` from coreutils
180
+ * handles the deterministic sort with disk spill for files that exceed in-memory thresholds.
181
+ *
182
+ * Used by `buildCorpus` after the align loop has already partitioned labeled rows into `labeled-{train,val,test}.jsonl`
183
+ * via `splitForRow`. Counts are pre-computed by the align loop and passed in (zero re-scan).
184
+ */
185
+ export async function writeSplitManifestsFromLabeledFiles(opts: {
186
+ labeledPaths: Record<SplitName, string>
187
+ outputDir: string
188
+ corpusVersion: string
189
+ counts: Record<SplitName, number>
190
+ holdouts?: Record<string, readonly string[]>
191
+ }): Promise<SplitManifest["counts"]> {
192
+ await mkdir(opts.outputDir, { recursive: true })
193
+ const holdouts = opts.holdouts ?? defaultHoldouts()
194
+
195
+ for (const split of ["train", "val", "test"] as const) {
196
+ const labeledPath = opts.labeledPaths[split]
197
+ const outPath = join(opts.outputDir, `${split}.txt`)
198
+ await streamSortedSourceIds(labeledPath, outPath)
199
+ }
200
+
201
+ const total = opts.counts.train + opts.counts.val + opts.counts.test
202
+ const summary = {
203
+ corpus_version: opts.corpusVersion,
204
+ holdouts,
205
+ counts: { ...opts.counts, total },
206
+ }
207
+ await writeFile(join(opts.outputDir, "SPLIT_MANIFEST.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8")
208
+
209
+ return summary.counts
210
+ }
211
+
212
+ /**
213
+ * Extract `source_id`s from a labeled JSONL file, write them sorted to `outPath`. Empty input → empty output file (not
214
+ * absent). Uses `sort(1)` for disk-spilling external sort so peak memory stays O(1) regardless of labeled-row count.
215
+ */
216
+ async function streamSortedSourceIds(labeledJsonlPath: string, outPath: string): Promise<void> {
217
+ const unsortedPath = `${outPath}.unsorted`
218
+ const out = createWriteStream(unsortedPath, { encoding: "utf8" })
219
+ const outClosed = new Promise<void>((resolve, reject) => {
220
+ out.on("close", () => resolve())
221
+ out.on("error", reject)
222
+ })
223
+
224
+ // JSONSpliterator parses each row (skipEmpty drops blank lines); a malformed row throws
225
+ // SyntaxError out of the loop, matching the prior `reject(err)` fail-loud behavior. `finally`
226
+ // always ends the write stream so `sort` reads a complete file even if the read throws.
227
+ try {
228
+ for await (const obj of JSONSpliterator.fromAsync<{ source_id?: string }>(labeledJsonlPath)) {
229
+ if (typeof obj.source_id === "string") {
230
+ out.write(`${obj.source_id}\n`)
231
+ }
232
+ }
233
+ } finally {
234
+ out.end()
235
+ }
236
+ await outClosed
237
+
238
+ await new Promise<void>((resolve, reject) => {
239
+ // LC_ALL=C: byte-sort, locale-independent → deterministic across hosts.
240
+ const proc = spawn("sort", [unsortedPath, "-o", outPath], { env: childEnv({ LC_ALL: "C" }) })
241
+ proc.on("error", reject)
242
+ proc.on("exit", (code) => {
243
+ if (code === 0) {
244
+ resolve()
245
+ } else {
246
+ reject(new Error(`sort exited with code ${code}`))
247
+ }
248
+ })
249
+ })
250
+ await unlink(unsortedPath).catch(() => {})
251
+ }