@mailwoman/corpus 7.2.0 → 7.2.1

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,420 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * CSV → SQLite ingestion utility. Ported from isp-nexus's `sdk/data/csv.ts`.
7
+ *
8
+ * Reads a CSV file, infers column types from a sample of rows, creates a SQLite table, and imports
9
+ * the data. Handles quoted fields, NULL normalization, and duplicate column name disambiguation.
10
+ *
11
+ * ## Usage
12
+ *
13
+ * ```sh
14
+ * mailwoman corpus ingest-csv \
15
+ * --input /data/corpus/sources/usgov-nppes/npidata_pfile.csv \
16
+ * --table nppes_providers \
17
+ * --output /data/corpus/sources/usgov-nppes/nppes.db
18
+ * ```
19
+ *
20
+ * Options: --input <path> CSV file to ingest (required) --table <name> SQLite table name (default:
21
+ * derived from input filename) --output <path> SQLite database path (default: input dir /
22
+ * table.db) --sample <n> Rows to sample for type inference (default: 100) --separator <char>
23
+ * Field separator (default: ,) --skip <n> Lines to skip before header (default: 0) --no-header
24
+ * CSV has no header row — columns will be col_0, col_1, etc. --dry-run Infer schema and print
25
+ * CREATE TABLE, but don't import
26
+ */
27
+
28
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs"
29
+ import { basename, dirname, extname, join } from "node:path"
30
+ import type { SQLInputValue } from "node:sqlite"
31
+
32
+ import { TextSpliterator } from "spliterator"
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Core: quote-aware CSV field splitting
36
+ // ---------------------------------------------------------------------------
37
+
38
+ const COMMA = 44
39
+ const DOUBLE_QUOTE = 34
40
+
41
+ // These stay hand-rolled on purpose — spliterator ≥ 3.2.0's CSVSpliterator does quote handling
42
+ // correctly now, but it can't express this tool's CLI contract: `--skip N` drops N preamble lines
43
+ // BEFORE the header (CSVSpliterator consumes row 1 as the header immediately, with no skip-first-N
44
+ // hook), `--no-header` auto-generates `col_0…col_N` names from the first data row's width, and the
45
+ // separator is any single caller-supplied byte. If a future edit removes those knobs, revisit;
46
+ // until then, don't "finish the job" by swapping in CSVSpliterator.
47
+ function splitCSVLine(line: string, separator: number = COMMA): string[] {
48
+ const fields: string[] = []
49
+ let start = 0
50
+ let inQuotes = false
51
+
52
+ for (let i = 0; i < line.length; i++) {
53
+ const ch = line.charCodeAt(i)
54
+
55
+ if (ch === DOUBLE_QUOTE) {
56
+ inQuotes = !inQuotes
57
+ } else if (ch === separator && !inQuotes) {
58
+ fields.push(line.slice(start, i))
59
+ start = i + 1
60
+ }
61
+ }
62
+ fields.push(line.slice(start))
63
+
64
+ return fields
65
+ }
66
+
67
+ function stripQuotes(field: string): string {
68
+ const trimmed = field.trim()
69
+
70
+ if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
71
+ return trimmed.slice(1, -1).replace(/""/g, '"')
72
+ }
73
+
74
+ return trimmed
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Column name normalization
79
+ // ---------------------------------------------------------------------------
80
+
81
+ function normalizeColumnName(raw: string): string {
82
+ return (
83
+ raw
84
+ .trim()
85
+ .replace(/^"+|"+$/g, "")
86
+ .toLowerCase()
87
+ .replace(/[^a-z0-9]+/g, "_")
88
+ .replace(/^_|_$/g, "") || "unnamed"
89
+ )
90
+ }
91
+
92
+ function dedupColumns(names: string[]): string[] {
93
+ const seen = new Map<string, number>()
94
+
95
+ return names.map((name) => {
96
+ const count = seen.get(name) ?? 0
97
+ seen.set(name, count + 1)
98
+
99
+ return count === 0 ? name : `${name}_${count + 1}`
100
+ })
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Type inference
105
+ // ---------------------------------------------------------------------------
106
+
107
+ type SQLiteColType = "INTEGER" | "REAL" | "TEXT"
108
+
109
+ interface ColumnInfo {
110
+ name: string
111
+ type: SQLiteColType
112
+ nullable: boolean
113
+ }
114
+
115
+ function normalizeField(raw: string): string | null {
116
+ let s = raw.trim()
117
+
118
+ if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
119
+ s = s.slice(1, -1).replace(/""/g, '"')
120
+ }
121
+
122
+ // Normalize common null-like values
123
+ if (!s || s === "null" || s === "NULL" || s === "N/A" || s === "n/a" || s === "-" || s === "<UNAVAIL>") {
124
+ return null
125
+ }
126
+
127
+ return s
128
+ }
129
+
130
+ function inferColumnType(samples: (string | null)[]): ColumnInfo {
131
+ let nullCount = 0
132
+ let intCount = 0
133
+ let realCount = 0
134
+ let textCount = 0
135
+
136
+ for (const s of samples) {
137
+ if (s === null) {
138
+ nullCount++
139
+ continue
140
+ }
141
+
142
+ if (/^-?\d+$/.test(s)) {
143
+ intCount++
144
+ } else if (/^-?\d+\.?\d+$/.test(s)) {
145
+ realCount++
146
+ } else {
147
+ textCount++
148
+ }
149
+ }
150
+
151
+ const total = samples.length
152
+ const type: SQLiteColType = realCount / total >= 0.5 ? "REAL" : intCount / total >= 0.5 ? "INTEGER" : "TEXT"
153
+
154
+ return { name: "", type, nullable: nullCount / total >= 0.5 }
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Main: read CSV, infer schema, produce SQL
159
+ // ---------------------------------------------------------------------------
160
+
161
+ interface IngestOptions {
162
+ inputPath: string
163
+ tableName: string
164
+ outputPath: string
165
+ sampleSize: number
166
+ separator: string
167
+ skipLines: number
168
+ hasHeader: boolean
169
+ dryRun: boolean
170
+ }
171
+
172
+ async function runIngest(opts: IngestOptions): Promise<void> {
173
+ const sep = opts.separator.charCodeAt(0)
174
+
175
+ // --- Pass 1: read header + sample rows for type inference ---
176
+ process.stderr.write(`Reading ${opts.inputPath} for schema inference...\n`)
177
+
178
+ // CRLF-safe by construction: every field below flows through stripQuotes/normalizeField, both of
179
+ // which `.trim()`, so a trailing CR on the last column of a CRLF file is stripped. TextSpliterator's
180
+ // default skipEmpty matches readline row-for-row on files with a trailing newline (the common case);
181
+ // it drops interior blank lines that readline would have turned into all-null rows. The early `break`
182
+ // closes the file descriptor.
183
+ let headerLine: string | null = null
184
+ const sampleRows: string[][] = []
185
+ let lineNum = 0
186
+
187
+ for await (const line of TextSpliterator.fromAsync(opts.inputPath)) {
188
+ lineNum++
189
+
190
+ // Skip lines before header
191
+ if (lineNum <= opts.skipLines) continue
192
+
193
+ if (!headerLine && opts.hasHeader) {
194
+ headerLine = line
195
+ continue
196
+ }
197
+
198
+ if (sampleRows.length < opts.sampleSize) {
199
+ sampleRows.push(splitCSVLine(line, sep).map(stripQuotes))
200
+ } else {
201
+ break
202
+ }
203
+ }
204
+
205
+ if (!headerLine && opts.hasHeader) {
206
+ throw new Error("No header line found in CSV")
207
+ }
208
+
209
+ // --- Determine column names ---
210
+ let rawHeaders: string[]
211
+
212
+ if (opts.hasHeader && headerLine) {
213
+ rawHeaders = splitCSVLine(headerLine, sep).map(stripQuotes)
214
+ } else {
215
+ // Auto-generate column names: col_0, col_1, ...
216
+ const numCols = sampleRows[0]?.length ?? 0
217
+ rawHeaders = Array.from({ length: numCols }, (_, i) => `col_${i}`)
218
+ }
219
+
220
+ const colNames = dedupColumns(rawHeaders.map(normalizeColumnName))
221
+
222
+ // --- Infer column types ---
223
+ const columns: ColumnInfo[] = colNames.map((name, i) => {
224
+ const samples = sampleRows.map((row) => {
225
+ const raw = row[i] ?? ""
226
+
227
+ return normalizeField(raw)
228
+ })
229
+ const info = inferColumnType(samples)
230
+ info.name = name
231
+
232
+ return info
233
+ })
234
+
235
+ // --- Generate SQL ---
236
+ const colDefs = columns.map((c) => `"${c.name}" ${c.type}`).join(",\n ")
237
+ // Raw DDL by design: the column set + types are INFERRED from the CSV at runtime (colDefs above),
238
+ // so a Kysely builder loop would just wrap the same dynamic strings with ceremony and no type safety.
239
+ const createTableSQL = `CREATE TABLE IF NOT EXISTS "${opts.tableName}" (\n ${colDefs}\n);`
240
+
241
+ const tempCols = columns.map((c) => `"${c.name}"`).join(", ")
242
+ const insertSQL = `INSERT INTO "${opts.tableName}" (${tempCols})\nSELECT ${tempCols} FROM temp."${opts.tableName}_source";`
243
+
244
+ process.stderr.write(`\nSchema inferred from ${sampleRows.length} sample rows:\n`)
245
+ process.stderr.write(`${createTableSQL}\n\n`)
246
+
247
+ if (opts.dryRun) {
248
+ process.stderr.write("--dry-run: stopping before import\n")
249
+
250
+ return
251
+ }
252
+
253
+ // --- Create database + import ---
254
+ const { DatabaseSync } = await import("node:sqlite")
255
+ mkdirSync(dirname(opts.outputPath), { recursive: true })
256
+
257
+ const db = new DatabaseSync(opts.outputPath)
258
+ db.exec("PRAGMA journal_mode = OFF") // faster for bulk import
259
+ db.exec("PRAGMA synchronous = OFF")
260
+
261
+ db.exec(createTableSQL)
262
+
263
+ // Use the .import approach via a temp table, then INSERT INTO ... SELECT to handle
264
+ // NULL normalization and type coercion.
265
+ const csvBasename = basename(opts.inputPath)
266
+ const importSQL = [
267
+ `CREATE TEMP TABLE "${opts.tableName}_source" (${colDefs});`,
268
+ `.mode csv`,
269
+ `.separator "${opts.separator}"`,
270
+ `.import "${csvBasename}" --skip ${opts.skipLines + (opts.hasHeader ? 1 : 0)} --schema temp ${opts.tableName}_source`,
271
+ insertSQL,
272
+ `DROP TABLE temp."${opts.tableName}_source";`,
273
+ ]
274
+
275
+ // better-sqlite3 doesn't support .import natively, so we use a different approach:
276
+ // Read the CSV line-by-line and INSERT in a transaction.
277
+ process.stderr.write(`Importing rows...\n`)
278
+
279
+ const insertStmt = db.prepare(
280
+ `INSERT INTO "${opts.tableName}" (${tempCols}) VALUES (${columns.map(() => "?").join(", ")})`
281
+ )
282
+
283
+ let imported = 0
284
+ let headerSkipped = false
285
+
286
+ // node:sqlite has no `db.transaction(fn)` wrapper; use raw BEGIN/COMMIT around the batch.
287
+ const doInsert = () => {
288
+ db.exec("BEGIN")
289
+
290
+ try {
291
+ for (const row of batch) {
292
+ insertStmt.run(...row)
293
+ }
294
+ db.exec("COMMIT")
295
+ } catch (err) {
296
+ db.exec("ROLLBACK")
297
+ throw err
298
+ }
299
+ }
300
+
301
+ const batch: SQLInputValue[][] = []
302
+ const BATCH_SIZE = 10000
303
+
304
+ for await (const line of TextSpliterator.fromAsync(opts.inputPath)) {
305
+ lineNum++
306
+
307
+ if (lineNum <= opts.skipLines) continue
308
+
309
+ if (opts.hasHeader && !headerSkipped) {
310
+ headerSkipped = true
311
+ continue
312
+ }
313
+
314
+ const fields = splitCSVLine(line, sep).map(stripQuotes)
315
+ const values = fields.map((f, i) => {
316
+ const v = normalizeField(f)
317
+
318
+ if (v === null) return null
319
+ const col = columns[i]
320
+
321
+ if (col?.type === "INTEGER" && /^-?\d+$/.test(v)) return parseInt(v, 10)
322
+
323
+ if (col?.type === "REAL" && /^-?\d+\.?\d+$/.test(v)) return parseFloat(v)
324
+
325
+ return v
326
+ })
327
+
328
+ // Pad or truncate to column count
329
+ while (values.length < columns.length) {
330
+ values.push(null)
331
+ }
332
+ values.length = columns.length
333
+
334
+ batch.push(values)
335
+
336
+ if (batch.length >= BATCH_SIZE) {
337
+ doInsert()
338
+ imported += batch.length
339
+ batch.length = 0
340
+
341
+ if (imported % 100000 === 0) {
342
+ process.stderr.write(` ${(imported / 1_000_000).toFixed(1)}M rows...\n`)
343
+ }
344
+ }
345
+ }
346
+
347
+ // Flush remaining
348
+ if (batch.length > 0) {
349
+ doInsert()
350
+ imported += batch.length
351
+ }
352
+
353
+ process.stderr.write(` Imported ${imported.toLocaleString()} rows into "${opts.tableName}"\n`)
354
+
355
+ // Build a basic index on the first TEXT column (likely the primary key)
356
+ const firstTextCol = columns.find((c) => c.type === "TEXT")
357
+
358
+ if (firstTextCol) {
359
+ process.stderr.write(`Building index on "${firstTextCol.name}"...\n`)
360
+ db.exec(
361
+ `CREATE INDEX IF NOT EXISTS idx_${opts.tableName}_${firstTextCol.name} ON "${opts.tableName}"("${firstTextCol.name}");`
362
+ )
363
+ }
364
+
365
+ db.close()
366
+
367
+ // Write MANIFEST
368
+ const fileSize = (await import("node:fs/promises")).stat
369
+ const stat = await (await import("node:fs/promises")).stat(opts.outputPath)
370
+ const manifest = {
371
+ ingested_at: new Date().toISOString(),
372
+ source_csv: basename(opts.inputPath),
373
+ table_name: opts.tableName,
374
+ columns: columns.map((c) => ({ name: c.name, type: c.type, nullable: c.nullable })),
375
+ row_count: imported,
376
+ db_bytes: stat.size,
377
+ }
378
+ const manifestPath = opts.outputPath.replace(/\.db$/, ".manifest.json")
379
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n")
380
+
381
+ process.stderr.write(
382
+ `Done. ${imported.toLocaleString()} rows → ${opts.outputPath} (${(stat.size / 1024 / 1024).toFixed(0)} MB)\n`
383
+ )
384
+ }
385
+
386
+ /** Flag-shaped options for {@linkcode ingestCSV} — `table`/`output` derive from `input` when omitted. */
387
+ export interface IngestCSVOptions {
388
+ input: string
389
+ table?: string
390
+ output?: string
391
+ sample?: number
392
+ separator?: string
393
+ skip?: number
394
+ noHeader?: boolean
395
+ dryRun?: boolean
396
+ }
397
+
398
+ /**
399
+ * Ingest a CSV into SQLite: infer column types from a sample, create the table, import the rows. Throws when `input` is
400
+ * missing. NOTE(phase1): progress narration still writes stderr directly — this predates the report-callback contract
401
+ * and the write sites are deep in the type-inference helpers; thread a report param if a caller ever needs to capture
402
+ * it.
403
+ */
404
+ export async function ingestCSV(options: IngestCSVOptions): Promise<void> {
405
+ if (!existsSync(options.input)) {
406
+ throw new Error(`File not found: ${options.input}`)
407
+ }
408
+ const csvName = basename(options.input, extname(options.input))
409
+
410
+ await runIngest({
411
+ inputPath: options.input,
412
+ tableName: options.table ?? csvName.replace(/[^a-zA-Z0-9_]/g, "_"),
413
+ outputPath: options.output ?? join(dirname(options.input), csvName + ".db"),
414
+ sampleSize: options.sample ?? 100,
415
+ separator: options.separator ?? ",",
416
+ skipLines: options.skip ?? 0,
417
+ hasHeader: !options.noHeader,
418
+ dryRun: options.dryRun ?? false,
419
+ })
420
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Convert a JSONL of LabeledRow objects to a Parquet shard matching the v0.5.0 schema.
7
+ *
8
+ * Ported faithfully from scripts/jsonl-to-parquet.py. The Python original wrote Parquet through
9
+ * PyArrow; this writes it through DuckDB (`@duckdb/node-api`) — `read_json` with an EXPLICIT
10
+ * `columns` type map projects the validated rows to the v0.5.0 schema, then `COPY … TO … (FORMAT
11
+ * PARQUET, COMPRESSION SNAPPY, ROW_GROUP_SIZE …)` emits the shard. DuckDB reproduces the exact
12
+ * logical schema PyArrow did — `VARCHAR` (UTF8) scalars, `VARCHAR[]` (LIST<UTF8>) for the string
13
+ * arrays, and `INTEGER[]` (LIST<INT32>) for the span offsets — in the exact column order below.
14
+ * Verified field-for-field against the PyArrow original (same column order, same logical types,
15
+ * same `list<element: …>` child naming, same values), so a PyArrow reader sees an identical
16
+ * table. The trainer in any case reads shards by column name (`pq.read_table(...).to_pylist()`),
17
+ * which is blind to physical layout. INT32 matches the corpus's native TS writer
18
+ * (`@mailwoman/corpus` `LABELED_ROW_SCHEMA`), which already writes the base shards this overlay
19
+ * rides alongside.
20
+ *
21
+ * Schema: raw, tokens, labels, span_starts, span_ends, span_tags, country, locale, source,
22
+ * source_id, corpus_version, license, synth_method, synth_base_id.
23
+ *
24
+ * The span triple (#519, v0.5.0 char-offset labels) is REQUIRED on every row: `alignRow` emits it
25
+ * on every labeled row, so a row arriving without it came from a producer that hasn't migrated —
26
+ * writing it would silently drop the v0.5.0 labels from the shard. Loud failure, naming the row
27
+ * number, instead.
28
+ *
29
+ * Usage: mailwoman dev jsonl-to-parquet --input /tmp/po-box-labeled.jsonl --output
30
+ * /tmp/part-po-box.parquet
31
+ */
32
+
33
+ import { randomUUID } from "node:crypto"
34
+ import { createWriteStream } from "node:fs"
35
+ import { unlink } from "node:fs/promises"
36
+ import { tmpdir } from "node:os"
37
+ import { join } from "node:path"
38
+
39
+ import { TextSpliterator } from "spliterator"
40
+
41
+ const REQUIRED_COLUMNS = [
42
+ "raw",
43
+ "tokens",
44
+ "labels",
45
+ "span_starts",
46
+ "span_ends",
47
+ "span_tags",
48
+ "country",
49
+ "locale",
50
+ "source",
51
+ "source_id",
52
+ "corpus_version",
53
+ "license",
54
+ "synth_method",
55
+ "synth_base_id",
56
+ ] as const
57
+
58
+ const SPAN_COLUMNS = ["span_starts", "span_ends", "span_tags"] as const
59
+
60
+ /**
61
+ * The v0.5.0 DuckDB type for each column, in {@link REQUIRED_COLUMNS} order.
62
+ *
63
+ * Mirrors the PyArrow schema the Python original declared: `pa.string()` → `VARCHAR`, `pa.list_(pa.string())` →
64
+ * `VARCHAR[]`, `pa.list_(pa.int32())` → `INTEGER[]`. The span offsets are INT32 (#519): parallel arrays over `raw`
65
+ * (UTF-16 code units, `[start, end)` exclusive-end, sorted, non-overlapping); `raw` is a short address string, so INT32
66
+ * round-trips as a plain integer where INT64 would surface as bigint.
67
+ */
68
+ const COLUMN_TYPES: Record<(typeof REQUIRED_COLUMNS)[number], string> = {
69
+ raw: "VARCHAR",
70
+ tokens: "VARCHAR[]",
71
+ labels: "VARCHAR[]",
72
+ span_starts: "INTEGER[]",
73
+ span_ends: "INTEGER[]",
74
+ span_tags: "VARCHAR[]",
75
+ country: "VARCHAR",
76
+ locale: "VARCHAR",
77
+ source: "VARCHAR",
78
+ source_id: "VARCHAR",
79
+ corpus_version: "VARCHAR",
80
+ license: "VARCHAR",
81
+ synth_method: "VARCHAR",
82
+ synth_base_id: "VARCHAR",
83
+ }
84
+
85
+ /** Options for {@linkcode jsonlToParquet}. */
86
+ export interface JSONLToParquetOptions {
87
+ /** The labeled-row JSONL to convert. */
88
+ input: string
89
+ /** The parquet shard to write. */
90
+ output: string
91
+ /** Parquet row-group size. Default 50000. */
92
+ rowGroupSize?: number
93
+ }
94
+
95
+ /** Summary returned by {@linkcode jsonlToParquet}. */
96
+ export interface JSONLToParquetSummary {
97
+ read: number
98
+ written: number
99
+ outPath: string
100
+ }
101
+
102
+ /**
103
+ * Enforce the #519 span contract per row: all three present, parallel lengths.
104
+ *
105
+ * A row with span_starts but no span_tags is a corrupt row — never a silent fallback.
106
+ */
107
+ function assertSpanTriple(row: Record<string, unknown>, lineNo: number): void {
108
+ const present = SPAN_COLUMNS.filter((c) => row[c] != null)
109
+
110
+ if (present.length !== SPAN_COLUMNS.length) {
111
+ const missing = SPAN_COLUMNS.filter((c) => row[c] == null)
112
+ throw new Error(
113
+ `line ${lineNo}: row is missing the char-offset span triple (#519): ` +
114
+ `missing ${JSON.stringify(missing)} (source_id=${JSON.stringify(row.source_id ?? null)}). Every parquet-bound row ` +
115
+ "must carry span_starts/span_ends/span_tags; re-emit this shard through alignRow."
116
+ )
117
+ }
118
+ const n = (row.span_starts as unknown[]).length
119
+
120
+ if ((row.span_ends as unknown[]).length !== n || (row.span_tags as unknown[]).length !== n) {
121
+ throw new Error(
122
+ `line ${lineNo}: span triple arrays are not parallel — ` +
123
+ `starts=${(row.span_starts as unknown[]).length} ends=${(row.span_ends as unknown[]).length} ` +
124
+ `tags=${(row.span_tags as unknown[]).length} (source_id=${JSON.stringify(row.source_id ?? null)})`
125
+ )
126
+ }
127
+ }
128
+
129
+ /** Escape a path for single-quoted SQL string literals. */
130
+ function sqlString(value: string): string {
131
+ return value.replace(/'/g, "''")
132
+ }
133
+
134
+ /** Convert a labeled-row JSONL to a v0.5.0-schema Parquet shard. */
135
+ export async function jsonlToParquet(
136
+ options: JSONLToParquetOptions,
137
+ report?: (line: string) => void
138
+ ): Promise<JSONLToParquetSummary> {
139
+ const rowGroupSize = options.rowGroupSize ?? 50000
140
+
141
+ if (!Number.isInteger(rowGroupSize) || rowGroupSize <= 0) {
142
+ throw new Error(`rowGroupSize must be a positive integer (got ${JSON.stringify(rowGroupSize)})`)
143
+ }
144
+
145
+ // Stage the validated rows to a temp NDJSON, then let DuckDB type + write them. Streaming keeps
146
+ // memory O(1) on the Node side (the Python original buffered every column into memory first). The
147
+ // `finally` covers the validation pass too, so a mid-stream span-triple failure leaves no orphan.
148
+ const stagePath = join(tmpdir(), `mw-jsonl-to-parquet-${randomUUID()}.ndjson`)
149
+ const stage = createWriteStream(stagePath, { encoding: "utf8" })
150
+
151
+ try {
152
+ let rows = 0
153
+ let lineNo = 0
154
+
155
+ // TextSpliterator, not JSONSpliterator: the staging write below streams the RAW line bytes to
156
+ // DuckDB verbatim (JSON.parse here only validates), so a re-serialized JSONSpliterator row would
157
+ // defeat the point. CRLF is handled by the existing `rawLine.trim()` (strips a trailing \r),
158
+ // same as readline's crlfDelay:Infinity did.
159
+ for await (const rawLine of TextSpliterator.fromAsync(options.input)) {
160
+ lineNo++
161
+ const line = rawLine.trim()
162
+
163
+ if (!line) continue
164
+ const row = JSON.parse(line) as Record<string, unknown>
165
+ assertSpanTriple(row, lineNo)
166
+ // Write the validated line verbatim; DuckDB's `read_json` projects to the explicit `columns`
167
+ // map below (extra keys dropped, absent keys → NULL — matching the Python `row.get(c)`).
168
+ stage.write(line + "\n")
169
+ rows++
170
+ }
171
+ await new Promise<void>((resolve, reject) => stage.end((err?: Error | null) => (err ? reject(err) : resolve())))
172
+
173
+ report?.(`Read ${rows} rows from ${options.input}`)
174
+
175
+ const columnsLiteral = "{" + REQUIRED_COLUMNS.map((c) => `'${c}': '${COLUMN_TYPES[c]}'`).join(", ") + "}"
176
+ const selectList = REQUIRED_COLUMNS.join(", ")
177
+
178
+ // @duckdb/node-api is an optional peer — lazy import (the pipeline convention).
179
+ const { DuckDBInstance } = await import("@duckdb/node-api")
180
+ const instance = await DuckDBInstance.create()
181
+ const db = await instance.connect()
182
+ // Row order is load-bearing: the overlay-manifest assembler records first/last source_id from
183
+ // shard order. `preserve_insertion_order` (DuckDB default) keeps output order = input order.
184
+ await db.run("SET preserve_insertion_order=true")
185
+ await db.run(
186
+ `COPY (SELECT ${selectList} FROM read_json('${sqlString(stagePath)}', ` +
187
+ `columns = ${columnsLiteral}, format = 'newline_delimited')) ` +
188
+ `TO '${sqlString(options.output)}' (FORMAT PARQUET, COMPRESSION SNAPPY, ROW_GROUP_SIZE ${rowGroupSize})`
189
+ )
190
+
191
+ const counted = await db.runAndReadAll(`SELECT count(*) AS n FROM read_parquet('${sqlString(options.output)}')`)
192
+ const written = Number(counted.getRowObjects()[0]!.n)
193
+ report?.(`Wrote ${written} rows to ${options.output}`)
194
+
195
+ return { read: rows, written, outPath: options.output }
196
+ } finally {
197
+ stage.destroy()
198
+ await unlink(stagePath).catch(() => {})
199
+ }
200
+ }