@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,131 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Build a parquet shard from the DeepSeek-generated kryptonite JSONL and emit the corpus-v0.4.0
7
+ * MANIFEST. corpus-v0.4.0 is a pure adapter-addition revision: it points at every shard from
8
+ * v0.3.0 plus the new kryptonite shard(s). No v0.3.0 bytes are touched or re-shuffled.
9
+ *
10
+ * See docs/articles/plan/reference/CORPUS_V0_4_0_GENERATION.md for the why; that doc also pins the
11
+ * DeepSeek model version + prompt versions used to produce the JSONL.
12
+ *
13
+ * Invoke via `mailwoman corpus shard kryptonite \
14
+ * --jsonl /data/corpus/versioned/v0.4.0/kryptonite/canonical-kryptonite.jsonl \
15
+ * --base-manifest /data/corpus/versioned/v0.3.0/corpus-v0.3.0/MANIFEST.json \
16
+ * --out-dir /data/corpus/versioned/v0.4.0`
17
+ */
18
+
19
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
20
+ import { mkdir } from "node:fs/promises"
21
+ import { join } from "node:path"
22
+
23
+ import { iterateJSONL } from "@mailwoman/core/utils"
24
+
25
+ import { alignRow } from "../align.ts"
26
+ import type { ShardManifest } from "../parquet.ts"
27
+ import { PARQUET_COLUMNS, ROW_GROUP_SIZE, SHARD_COMPRESSION, writeShards } from "../parquet.ts"
28
+ import type { CanonicalRow, LabeledRow } from "../types.ts"
29
+
30
+ export interface ShardKryptoniteOptions {
31
+ jsonl: string
32
+ baseManifest: string
33
+ outDir: string
34
+ /** Default `"0.4.0"`. */
35
+ corpusVersion?: string
36
+ /** Default `"deepseek-kryptonite"`. */
37
+ source?: string
38
+ }
39
+
40
+ async function* canonicalRows(jsonl: string, corpusVersion: string): AsyncIterable<CanonicalRow> {
41
+ for await (const raw of iterateJSONL<Record<string, unknown>>(jsonl)) {
42
+ // Strip sidecar underscore-prefixed fields the generator left behind for debugging.
43
+ const components = raw["components"] as Record<string, string>
44
+ yield {
45
+ raw: raw["raw"] as string,
46
+ components,
47
+ country: (raw["country"] as string) ?? "US",
48
+ locale: (raw["locale"] as string) ?? undefined,
49
+ source: (raw["source"] as string) ?? "deepseek-kryptonite",
50
+ source_id: raw["source_id"] as string,
51
+ corpus_version: corpusVersion,
52
+ license: (raw["license"] as string) ?? "Synthetic (DeepSeek-v4-flash, AGPL-compatible)",
53
+ synth: raw["synth"] as CanonicalRow["synth"],
54
+ }
55
+ }
56
+ }
57
+
58
+ async function* labeledRows(jsonl: string, corpusVersion: string, quarantineLog: string[]): AsyncIterable<LabeledRow> {
59
+ for await (const row of canonicalRows(jsonl, corpusVersion)) {
60
+ const result = alignRow(row)
61
+
62
+ if (result.kind === "labeled") {
63
+ yield result.row
64
+ } else {
65
+ quarantineLog.push(`${row.source_id}\t${result.row.reason}`)
66
+ }
67
+ }
68
+ }
69
+
70
+ export async function buildKryptoniteShard(
71
+ options: ShardKryptoniteOptions,
72
+ report?: (line: string) => void
73
+ ): Promise<void> {
74
+ const corpusVersion = options.corpusVersion ?? "0.4.0"
75
+ const source = options.source ?? "deepseek-kryptonite"
76
+
77
+ if (!existsSync(options.jsonl)) throw new Error(`jsonl not found: ${options.jsonl}`)
78
+
79
+ if (!existsSync(options.baseManifest)) throw new Error(`base-manifest not found: ${options.baseManifest}`)
80
+
81
+ await mkdir(options.outDir, { recursive: true })
82
+
83
+ const quarantine: string[] = []
84
+ const newManifest = await writeShards(
85
+ { train: labeledRows(options.jsonl, corpusVersion, quarantine) },
86
+ { outputDir: options.outDir, corpusVersion }
87
+ )
88
+
89
+ report?.(
90
+ `wrote ${newManifest.total_rows} rows into ${newManifest.shards.length} shard(s); ` +
91
+ `quarantined ${quarantine.length}`
92
+ )
93
+
94
+ if (quarantine.length > 0) {
95
+ const qPath = join(options.outDir, `corpus-v${corpusVersion}`, "quarantine-kryptonite.tsv")
96
+ writeFileSync(qPath, quarantine.join("\n") + "\n", "utf8")
97
+ report?.(`quarantine log → ${qPath}`)
98
+ }
99
+
100
+ // Stamp the new shard's source field for audit.ts (which prefers shard.source over
101
+ // first_source_id-prefix inference). Without this, deepseek-kryptonite IDs would have
102
+ // to match a prefix in KNOWN_SOURCE_PREFIXES — we add it there too as a belt-and-braces.
103
+ for (const sh of newManifest.shards) {
104
+ ;(sh as unknown as { source: string }).source = source
105
+ }
106
+
107
+ // Compose the final corpus-v0.4.0 manifest: every shard from base + the new shard(s).
108
+ const base = JSON.parse(readFileSync(options.baseManifest, "utf8")) as ShardManifest
109
+ const combined: ShardManifest = {
110
+ corpus_version: corpusVersion,
111
+ schema: PARQUET_COLUMNS,
112
+ rows_per_shard: base.rows_per_shard,
113
+ row_group_size: base.row_group_size ?? ROW_GROUP_SIZE,
114
+ shards: [...base.shards, ...newManifest.shards],
115
+ counts: {
116
+ train: base.counts.train + (newManifest.counts.train ?? 0),
117
+ val: base.counts.val,
118
+ test: base.counts.test,
119
+ },
120
+ total_rows: base.total_rows + newManifest.total_rows,
121
+ }
122
+ // Stamp source on the legacy v0.3.0 shards too, so audit's shard.source path is the
123
+ // authoritative one. v0.3.0 shards mix sources; we use the first_source_id-prefix
124
+ // inference for them (audit.ts will re-derive on its own when shard.source is absent).
125
+ const combinedPath = join(options.outDir, `corpus-v${corpusVersion}`, "MANIFEST.json")
126
+ writeFileSync(combinedPath, JSON.stringify(combined, null, 2) + "\n", "utf8")
127
+ report?.(`wrote combined manifest → ${combinedPath}`)
128
+ report?.(` total_rows=${combined.total_rows} (base=${base.total_rows}, added=${newManifest.total_rows})`)
129
+ report?.(` shards=${combined.shards.length} (base=${base.shards.length}, added=${newManifest.shards.length})`)
130
+ report?.(` compression=${SHARD_COMPRESSION}`)
131
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Build per-script parquet shards from the DeepSeek-generated transliteration JSONL and emit the
7
+ * corpus-v0.4.0 MANIFEST that combines them with the existing kryptonite + v0.3.0 shards.
8
+ *
9
+ * Sibling to `shard-kryptonite.ts`. The two modules share the same composition pattern: take a
10
+ * base MANIFEST, append new shards, write a combined MANIFEST. Differences specific to
11
+ * transliteration:
12
+ *
13
+ * - One JSONL contains rows from N target scripts (source = `deepseek-translit-<slug>`). We bucket by
14
+ * `source` and write one shard per script so `audit.ts` can attribute each shard to its
15
+ * synthetic source without relying on filename-prefix inference.
16
+ * - Each shard is written to `train/part-translit-<slug>.parquet` (distinct from kryptonite's
17
+ * `part-0000.parquet`, which v0.4.0's first builder already produced).
18
+ * - Inherits the path-canonicalization fix flagged in Thread B's postmortem: v0.3.0 shard paths are
19
+ * rewritten from `/mnt/playpen/mailwoman-data/...` to `/data/...` in the combined MANIFEST so
20
+ * all paths share one container-friendly form.
21
+ *
22
+ * See docs/articles/plan/reference/CORPUS_V0_4_0_GENERATION.md for prompts, model, and the
23
+ * reproducibility contract.
24
+ *
25
+ * Invoke via `mailwoman corpus shard translit \
26
+ * --jsonl /data/corpus/versioned/v0.4.0/transliteration/canonical-transliteration.jsonl \
27
+ * --base-manifest /data/corpus/versioned/v0.4.0/corpus-v0.4.0/MANIFEST.json \
28
+ * --out-dir /data/corpus/versioned/v0.4.0`
29
+ */
30
+
31
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
32
+ import { mkdir, stat } from "node:fs/promises"
33
+ import { join } from "node:path"
34
+
35
+ import { iterateJSONL, sha256File } from "@mailwoman/core/utils"
36
+
37
+ import { alignRow } from "../align.ts"
38
+ import { ParquetWriter } from "../parquet-wrapper/index.ts"
39
+ import type { ParquetRow, ShardDescriptor, ShardManifest } from "../parquet.ts"
40
+ import { LABELED_ROW_SCHEMA, PARQUET_COLUMNS, ROW_GROUP_SIZE, rowToParquet, SHARD_COMPRESSION } from "../parquet.ts"
41
+ import type { CanonicalRow, LabeledRow } from "../types.ts"
42
+
43
+ export interface ShardTranslitOptions {
44
+ jsonl: string
45
+ baseManifest: string
46
+ outDir: string
47
+ /** Default `"0.4.0"`. */
48
+ corpusVersion?: string
49
+ /** Default `"/data/"`. */
50
+ canonicalPathPrefix?: string
51
+ /** Default `"/mnt/playpen/mailwoman-data/"`. */
52
+ legacyPathPrefix?: string
53
+ }
54
+
55
+ function toCanonicalRow(raw: Record<string, unknown>, corpusVersion: string): CanonicalRow {
56
+ return {
57
+ raw: raw["raw"] as string,
58
+ components: raw["components"] as Record<string, string>,
59
+ country: (raw["country"] as string) ?? "US",
60
+ locale: (raw["locale"] as string) ?? undefined,
61
+ source: raw["source"] as string,
62
+ source_id: raw["source_id"] as string,
63
+ corpus_version: corpusVersion,
64
+ license: (raw["license"] as string) ?? "Synthetic (DeepSeek-v4-flash, AGPL-compatible)",
65
+ synth: raw["synth"] as CanonicalRow["synth"],
66
+ }
67
+ }
68
+
69
+ function appendShape(row: ParquetRow): Record<string, unknown> {
70
+ const out: Record<string, unknown> = {
71
+ raw: row.raw,
72
+ tokens: row.tokens,
73
+ labels: row.labels,
74
+ country: row.country,
75
+ source: row.source,
76
+ source_id: row.source_id,
77
+ corpus_version: row.corpus_version,
78
+ license: row.license,
79
+ }
80
+
81
+ if (row.locale !== null) {
82
+ out.locale = row.locale
83
+ }
84
+
85
+ if (row.synth_method !== null) {
86
+ out.synth_method = row.synth_method
87
+ }
88
+
89
+ if (row.synth_base_id !== null) {
90
+ out.synth_base_id = row.synth_base_id
91
+ }
92
+
93
+ return out
94
+ }
95
+
96
+ /**
97
+ * Write one shard for a single source slug. Returns the populated ShardDescriptor + a list of quarantine reasons for
98
+ * rows that failed alignment.
99
+ */
100
+ async function writeOneShard(
101
+ rows: readonly LabeledRow[],
102
+ outPath: string,
103
+ source: string,
104
+ corpusVersion: string
105
+ ): Promise<ShardDescriptor> {
106
+ const writer = await ParquetWriter.openFile<ParquetRow>(LABELED_ROW_SCHEMA, outPath, {
107
+ rowGroupSize: ROW_GROUP_SIZE,
108
+ })
109
+ writer.setMetadata("mailwoman.corpus_version", corpusVersion)
110
+ writer.setMetadata("mailwoman.split", "train")
111
+ writer.setMetadata("mailwoman.shard_source", source)
112
+
113
+ let firstSourceID = ""
114
+ let lastSourceID = ""
115
+
116
+ for (const row of rows) {
117
+ const pq = rowToParquet(row)
118
+ await writer.appendRow(appendShape(pq) as unknown as ParquetRow)
119
+
120
+ if (firstSourceID === "") {
121
+ firstSourceID = row.source_id
122
+ }
123
+ lastSourceID = row.source_id
124
+ }
125
+ await writer.close()
126
+
127
+ const fileStat = await stat(outPath)
128
+ const sha256 = await sha256File(outPath)
129
+
130
+ return {
131
+ split: "train",
132
+ path: outPath,
133
+ format: "parquet",
134
+ compression: SHARD_COMPRESSION,
135
+ rows: rows.length,
136
+ bytes: fileStat.size,
137
+ sha256,
138
+ first_source_id: firstSourceID,
139
+ last_source_id: lastSourceID,
140
+ // Stamp source so audit.ts attributes the shard without falling back to filename-prefix
141
+ // inference. Cast widens ShardDescriptor; the field is read by audit.ts.
142
+ ...({ source } as Record<string, string>),
143
+ }
144
+ }
145
+
146
+ function canonicalizeShardPath(path: string, legacyPrefix: string, canonicalPrefix: string): string {
147
+ if (path.startsWith(legacyPrefix)) return canonicalPrefix + path.slice(legacyPrefix.length)
148
+
149
+ return path
150
+ }
151
+
152
+ export async function buildTranslitShard(
153
+ options: ShardTranslitOptions,
154
+ report?: (line: string) => void
155
+ ): Promise<void> {
156
+ const corpusVersion = options.corpusVersion ?? "0.4.0"
157
+ const canonicalPathPrefix = options.canonicalPathPrefix ?? "/data/"
158
+ const legacyPathPrefix = options.legacyPathPrefix ?? "/mnt/playpen/mailwoman-data/"
159
+
160
+ if (!existsSync(options.jsonl)) throw new Error(`jsonl not found: ${options.jsonl}`)
161
+
162
+ if (!existsSync(options.baseManifest)) throw new Error(`base-manifest not found: ${options.baseManifest}`)
163
+
164
+ const corpusDir = join(options.outDir, `corpus-v${corpusVersion}`)
165
+ const trainDir = join(corpusDir, "train")
166
+ await mkdir(trainDir, { recursive: true })
167
+
168
+ // Bucket canonical rows by source. Quarantined rows are logged.
169
+ const buckets = new Map<string, LabeledRow[]>()
170
+ const quarantine: string[] = []
171
+ let totalIn = 0
172
+
173
+ for await (const raw of iterateJSONL<Record<string, unknown>>(options.jsonl)) {
174
+ totalIn++
175
+ const canon = toCanonicalRow(raw, corpusVersion)
176
+ const result = alignRow(canon)
177
+
178
+ if (result.kind !== "labeled") {
179
+ quarantine.push(`${canon.source_id}\t${result.row.reason}`)
180
+ continue
181
+ }
182
+ const bucket = buckets.get(canon.source)
183
+
184
+ if (bucket) {
185
+ bucket.push(result.row)
186
+ } else {
187
+ buckets.set(canon.source, [result.row])
188
+ }
189
+ }
190
+ report?.(`read ${totalIn} rows; ${quarantine.length} quarantined; ${buckets.size} script buckets`)
191
+
192
+ const newShards: ShardDescriptor[] = []
193
+ const sortedKeys = [...buckets.keys()].sort()
194
+
195
+ for (const source of sortedKeys) {
196
+ const rows = buckets.get(source)!
197
+ const slug = source.startsWith("deepseek-translit-") ? source.slice("deepseek-translit-".length) : source
198
+ const outPath = join(trainDir, `part-translit-${slug}.parquet`)
199
+ const descriptor = await writeOneShard(rows, outPath, source, corpusVersion)
200
+ newShards.push(descriptor)
201
+ report?.(` ${source}: ${descriptor.rows} rows → ${outPath} (${descriptor.bytes} bytes)`)
202
+ }
203
+
204
+ if (quarantine.length > 0) {
205
+ const qPath = join(corpusDir, "quarantine-transliteration.tsv")
206
+ writeFileSync(qPath, quarantine.join("\n") + "\n", "utf8")
207
+ report?.(`quarantine log → ${qPath} (${quarantine.length} rows)`)
208
+ }
209
+
210
+ // Compose final MANIFEST: rewrite base.shards paths from /mnt/playpen/... → /data/... and append
211
+ // the new translit shards. Kryptonite shard already lives in the base manifest (it was written
212
+ // there by Thread B).
213
+ const base = JSON.parse(readFileSync(options.baseManifest, "utf8")) as ShardManifest
214
+ const rewrittenBase = base.shards.map((sh) => ({
215
+ ...sh,
216
+ path: canonicalizeShardPath(sh.path, legacyPathPrefix, canonicalPathPrefix),
217
+ }))
218
+ const newTrainRows = newShards.reduce((sum, sh) => sum + sh.rows, 0)
219
+ const combined: ShardManifest = {
220
+ corpus_version: corpusVersion,
221
+ schema: PARQUET_COLUMNS,
222
+ rows_per_shard: base.rows_per_shard,
223
+ row_group_size: base.row_group_size ?? ROW_GROUP_SIZE,
224
+ shards: [...rewrittenBase, ...newShards],
225
+ counts: {
226
+ train: base.counts.train + newTrainRows,
227
+ val: base.counts.val,
228
+ test: base.counts.test,
229
+ },
230
+ total_rows: base.total_rows + newTrainRows,
231
+ }
232
+
233
+ const combinedPath = join(corpusDir, "MANIFEST.json")
234
+ writeFileSync(combinedPath, JSON.stringify(combined, null, 2) + "\n", "utf8")
235
+ report?.(`wrote combined manifest → ${combinedPath}`)
236
+ report?.(` total_rows=${combined.total_rows} (base=${base.total_rows}, added=${newTrainRows})`)
237
+ report?.(` shards=${combined.shards.length} (base=${base.shards.length}, added=${newShards.length})`)
238
+ report?.(` compression=${SHARD_COMPRESSION}`)
239
+ const pathFix = rewrittenBase.filter((s, i) => s.path !== base.shards[i]!.path).length
240
+
241
+ if (pathFix > 0) {
242
+ report?.(` path-canonicalized base shards: ${pathFix} (legacy '${legacyPathPrefix}' → '${canonicalPathPrefix}')`)
243
+ }
244
+ }
package/src/types.ts ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Canonical row schemas for the corpus pipeline (per #6 / Phase 1 plan).
7
+ *
8
+ * The corpus pipeline produces two row shapes:
9
+ *
10
+ * 1. `CanonicalRow`: an adapter's raw output. Carries a free-form `raw` string, a per-component
11
+ * ground-truth dict, provenance, and an optional augmentation marker. Adapters emit these.
12
+ * 2. `LabeledRow`: alignment's output. Adds a SentencePiece token list and a parallel BIO label list,
13
+ * suitable for direct ingestion by the neural training loop.
14
+ *
15
+ * `CorpusAdapter` is the contract every data source implements; `AdapterOptions` is the
16
+ * per-invocation knob set (input path, optional country filter, row cap, abort signal).
17
+ */
18
+
19
+ import type { BIOLabel, ComponentTag } from "@mailwoman/core/types"
20
+
21
+ /**
22
+ * Provenance + augmentation metadata that travels with every corpus row.
23
+ *
24
+ * `synth` is `undefined` for natural (un-augmented) rows; present only when a row was produced by the synthesis
25
+ * pipeline (see `synthesize.ts`).
26
+ */
27
+ export interface SourceProvenance {
28
+ /** Adapter id that emitted this row, e.g. `"wof-admin"`, `"ban"`, `"openaddresses"`. */
29
+ source: string
30
+
31
+ /**
32
+ * Stable id within the adapter's source. For SQLite-backed adapters this is the row's primary key; for CSV/GeoJSON, a
33
+ * hash of the canonical components. Must be stable across reruns so that dedup and holdout manifests are
34
+ * reproducible.
35
+ */
36
+ source_id: string
37
+
38
+ /**
39
+ * Corpus version string. Stamped by the runner, not the adapter. Locked together with the tokenizer version:
40
+ * `corpus-v0.1.0` ships with `tokenizer-v0.1.0`.
41
+ */
42
+ corpus_version: string
43
+
44
+ /**
45
+ * Short license label or SPDX id for _this_ row. Defaults to the adapter's `defaultLicense`, but per-row sources
46
+ * (OpenAddresses) override.
47
+ */
48
+ license: string
49
+ }
50
+
51
+ /**
52
+ * Marker placed on rows produced by `synthesize.ts`. Allows downstream code to weight, stratify, or exclude
53
+ * augmentations.
54
+ */
55
+ export interface SynthMarker {
56
+ /**
57
+ * Pipeline id describing what augmentation produced this row. Free-form but stable — e.g. `"case-perturb"`,
58
+ * `"accent-strip"`, `"abbrev-swap"`, `"compose:case-perturb+typo"`.
59
+ */
60
+ method: string
61
+
62
+ /**
63
+ * `source_id` of the un-augmented row this was derived from. Allows tracing every synthetic row back to its natural
64
+ * ancestor.
65
+ */
66
+ base_source_id: string
67
+ }
68
+
69
+ /**
70
+ * One address row, before tokenization + BIO labeling.
71
+ *
72
+ * `raw` is what a parser would see in the wild — possibly multi-line, with arbitrary whitespace. `components` is the
73
+ * ground-truth tagging: every `ComponentTag` present in the source data, mapped to its surface form _as it appears in
74
+ * `raw`_. Alignment uses this to assign BIO labels.
75
+ *
76
+ * Country is ISO 3166-1 alpha-2 (`"US"`, `"FR"`). Locale is BCP-47 (`"en-US"`, `"fr-FR"`) and is optional; adapters
77
+ * that can't be sure leave it empty and let the runner default by country.
78
+ */
79
+ export interface CanonicalRow extends SourceProvenance {
80
+ /** Address string as it might appear in source data. */
81
+ raw: string
82
+
83
+ /**
84
+ * Component-by-tag ground truth. Surface forms must occur in `raw` (within the alignment edit distance threshold) or
85
+ * the row will land in the quarantine pile.
86
+ */
87
+ components: Partial<Record<ComponentTag, string>>
88
+
89
+ /** ISO 3166-1 alpha-2 country code. */
90
+ country: string
91
+
92
+ /** Optional BCP-47 locale. Defaulted by country if absent. */
93
+ locale?: string
94
+
95
+ /** Present only on synthetic rows. */
96
+ synth?: SynthMarker
97
+ }
98
+
99
+ /**
100
+ * Output of `align.ts`. Carries everything `CanonicalRow` does, plus parallel `tokens` and `labels` arrays of identical
101
+ * length (`labels[i]` is the BIO tag for `tokens[i]`) and — as of the v0.5.0 char-offset migration (#519) — parallel
102
+ * char-span arrays addressing `raw` directly.
103
+ *
104
+ * The span triple is the v0.5.0 source of truth; `tokens`/`labels` remain emitted during the transition (and stay
105
+ * derivable afterwards: whitespace split + span lookup). The reverse derivation — today's token labels — is the lossy
106
+ * direction (punctuation-mute).
107
+ */
108
+ export interface LabeledRow extends CanonicalRow {
109
+ /** SentencePiece subword tokens for `raw`. */
110
+ tokens: readonly string[]
111
+
112
+ /** BIO labels, one per token. Same length as `tokens`. */
113
+ labels: readonly BIOLabel[]
114
+
115
+ /**
116
+ * Char-offset label spans over `raw` (parallel arrays, per the #519 ruling): `span_starts[i]` is the inclusive start
117
+ * offset (UTF-16 code units) of span `i`, `span_ends[i]` its exclusive end, `span_tags[i]` its component tag.
118
+ * Invariants — enforced loudly by `alignRow`, documented for every other producer: sorted ascending by start,
119
+ * non-overlapping. `raw` must be NFC-normalized or the offsets are ambiguous (also enforced by `alignRow`).
120
+ *
121
+ * Optional during the v0.4.x → v0.5.0 transition only: alignment always emits the triple; frozen historical corpora
122
+ * and not-yet-migrated synthesis paths may lack it. Required once v0.5.0 lands and the token path is deleted.
123
+ */
124
+ span_starts?: readonly number[]
125
+
126
+ /** Exclusive end offsets, parallel to `span_starts`. */
127
+ span_ends?: readonly number[]
128
+
129
+ /** Component tags, parallel to `span_starts`. */
130
+ span_tags?: readonly ComponentTag[]
131
+ }
132
+
133
+ /**
134
+ * A row that alignment refused to label. Lands in `/data/corpus/quarantine/` for human review.
135
+ *
136
+ * The `reason` is human-readable; common values are `"component-not-found:<tag>"`, `"edit-distance-exceeded:<tag>"`,
137
+ * `"raw-empty"`. Re-running alignment after a fix should re-emit the quarantined rows; the runner keys them by
138
+ * `source_id`.
139
+ */
140
+ export interface QuarantinedRow {
141
+ row: CanonicalRow
142
+ reason: string
143
+ }
144
+
145
+ /**
146
+ * Per-invocation knobs handed to an adapter by the runner.
147
+ *
148
+ * `inputPath` is interpreted by the adapter — it might be a single file path, a directory of shards, or even an HTTPS
149
+ * URL. Each adapter documents its own expected shape in its README.
150
+ *
151
+ * `country` filters to a single ISO 3166-1 alpha-2 country _at the adapter level_. Adapters that hold multi-country
152
+ * data (OSM PBF, OpenAddresses) MUST honor this; single-country adapters (BAN) may ignore it but should reject
153
+ * mismatches.
154
+ *
155
+ * `limit` is a soft cap on rows emitted; useful for fixture-driven tests and smoke runs.
156
+ *
157
+ * `signal` allows the runner to cancel a long-running scan cleanly.
158
+ */
159
+ export interface AdapterOptions {
160
+ /** Path to the adapter's input data (file, directory, or URL — adapter-specific). */
161
+ inputPath: string
162
+
163
+ /** Optional output directory, available to adapters that maintain side state (rare). */
164
+ outputDir?: string
165
+
166
+ /** ISO 3166-1 alpha-2 country filter. */
167
+ country?: string
168
+
169
+ /** Soft row cap. Adapters should stop iterating once this is reached. */
170
+ limit?: number
171
+
172
+ /** Cancellation hook. Adapters should respect this on every iteration boundary. */
173
+ signal?: AbortSignal
174
+ }
175
+
176
+ /**
177
+ * The contract every data source implements.
178
+ *
179
+ * Adapters are async generators: they yield `CanonicalRow`s one at a time, the runner consumes them (writing JSONL +
180
+ * maintaining checksums + driving alignment). Streaming is mandatory — many sources are tens of millions of rows and
181
+ * cannot be buffered.
182
+ *
183
+ * `defaultLicense` is stamped onto every emitted row's `license` field unless the adapter sets `license` explicitly
184
+ * (e.g. OpenAddresses, which carries per-source licenses).
185
+ */
186
+ export interface CorpusAdapter {
187
+ /** Stable, machine-friendly id used in paths and CLI args. E.g. `"wof-admin"`. */
188
+ readonly id: string
189
+
190
+ /** Default SPDX-ish license label for rows from this adapter. Per-row overrides allowed. */
191
+ readonly defaultLicense: string
192
+
193
+ /** One-sentence description shown by `npx mailwoman corpus list`. */
194
+ readonly description: string
195
+
196
+ /**
197
+ * Async iterable of canonical rows.
198
+ *
199
+ * Implementations MUST:
200
+ *
201
+ * - Honor `opts.country` (filter or reject mismatches).
202
+ * - Honor `opts.limit` (stop after N rows).
203
+ * - Respect `opts.signal` on every iteration.
204
+ * - Set `source` to `this.id` on every emitted row.
205
+ * - Set `license` to `this.defaultLicense` unless overriding per-row.
206
+ *
207
+ * Implementations MUST NOT:
208
+ *
209
+ * - Set `corpus_version` (the runner stamps it).
210
+ * - Mutate previously-yielded rows.
211
+ */
212
+ rows(opts: AdapterOptions): AsyncIterable<CanonicalRow>
213
+ }