@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
package/src/build.ts ADDED
@@ -0,0 +1,348 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * End-to-end corpus build (Phase 1 task #10 in the plan).
7
+ *
8
+ * `buildCorpus(opts)` orchestrates every stage of the pipeline:
9
+ *
10
+ * 1. **Adapter runs** — drives every adapter in turn (via `runAdapter`), writing
11
+ * `<intermediate>/<adapter.id>/canonical.jsonl` shards.
12
+ * 2. **Synthesis** — optional. For each canonical row, every applicable augmentation in the row's
13
+ * country-default policy emits an augmented row alongside the original.
14
+ * 3. **Alignment** — every row (original + augmented) is aligned via `alignRow`. Successes go to
15
+ * `labeled.jsonl`; quarantines are appended to `quarantine.jsonl` with reasons.
16
+ * 4. **Splits** — `splitRows` partitions labeled `source_id`s into train/val/test by locality holdout.
17
+ * Manifest written to `splits/SPLIT_MANIFEST.json` + per-split `train.txt` / `val.txt` /
18
+ * `test.txt`.
19
+ * 5. **Parquet shards** — `writeShards` streams labeled rows into 1M-row `.parquet` shards per split
20
+ * under `corpus-v<version>/{train,val,test}/part-NNNN.parquet` (SNAPPY-compressed, 50k-row
21
+ * row groups), with per-shard checksums + per-stage manifest in
22
+ * `corpus-v<version>/MANIFEST.json`.
23
+ * 6. **Top-level manifest** — `<outputDir>/MANIFEST.json` ties every per-stage manifest together with
24
+ * a top-level corpus_version, built_at, and aggregate counts.
25
+ *
26
+ * Output layout:
27
+ *
28
+ * ```
29
+ * <outputDir>/
30
+ * MANIFEST.json
31
+ * intermediate/
32
+ * <adapter.id>/canonical.jsonl # one per adapter
33
+ * labeled.jsonl # post-alignment, pre-shard
34
+ * quarantine.jsonl # rows that failed alignment
35
+ * splits/
36
+ * SPLIT_MANIFEST.json
37
+ * train.txt / val.txt / test.txt
38
+ * corpus-v<version>/
39
+ * MANIFEST.json
40
+ * train/part-NNNN.parquet
41
+ * val/part-NNNN.parquet
42
+ * test/part-NNNN.parquet
43
+ * ```
44
+ *
45
+ * The intermediate files live alongside the final shards for reproducibility + debugging. Operators
46
+ * can `rm -rf intermediate/` after the build if disk is tight; the final `corpus-v<version>/` is
47
+ * self-contained.
48
+ */
49
+
50
+ import { createWriteStream, existsSync, readFileSync, type WriteStream } from "node:fs"
51
+ import { mkdir, writeFile } from "node:fs/promises"
52
+ import { join } from "node:path"
53
+
54
+ import { $public } from "@mailwoman/core/env"
55
+ import { JSONSpliterator } from "spliterator"
56
+
57
+ import { defaultAdapterRegistry } from "./adapter.ts"
58
+ import { alignRow } from "./align.ts"
59
+ import { licenseExcluded } from "./license.ts"
60
+ import { writeShards, type ShardManifest } from "./parquet.ts"
61
+ import { runAdapter, type AdapterRunManifest } from "./runner.ts"
62
+ import {
63
+ defaultHoldouts,
64
+ splitForRow,
65
+ writeSplitManifestsFromLabeledFiles,
66
+ type SplitManifest,
67
+ type SplitName,
68
+ } from "./split.ts"
69
+ import { defaultAugmentationsForCountry, synthesizeRow } from "./synthesize.ts"
70
+ import type { AdapterOptions, CanonicalRow, CorpusAdapter, LabeledRow } from "./types.ts"
71
+
72
+ /** Stage tags surfaced to `onProgress`. */
73
+ export type BuildStage = "adapter-run" | "align" | "split" | "shard" | "manifest"
74
+
75
+ /** Per-invocation options for `buildCorpus`. */
76
+ export interface BuildCorpusOptions {
77
+ /** Root output directory. All build artifacts land beneath it. */
78
+ outputDir: string
79
+
80
+ /** Corpus version (e.g. `"0.1.0"`). Stamped onto every row + into the output dir name. */
81
+ corpusVersion: string
82
+
83
+ /**
84
+ * Adapters to drive, in order. Defaults to `defaultAdapterRegistry.list()`. Pass an explicit list to filter (e.g.
85
+ * `[wofAdminAdapter]` for a smoke run).
86
+ */
87
+ adapters?: readonly CorpusAdapter[]
88
+
89
+ /**
90
+ * Per-adapter `AdapterOptions` — looked up by adapter id. Adapters whose id is missing from this map are skipped (and
91
+ * noted in the manifest).
92
+ */
93
+ adapterInputs: Record<string, AdapterOptions>
94
+
95
+ /** Enable synthesis pass. Default `true`. Set `false` for fixture-driven smoke tests. */
96
+ synthesize?: boolean
97
+
98
+ /** Forwarded to `writeShards`. Default 1_000_000. */
99
+ rowsPerShard?: number
100
+
101
+ /** Progress hook. Errors thrown abort the build. */
102
+ onProgress?: (stage: BuildStage, message: string) => void
103
+
104
+ /**
105
+ * License kinds to PURPOSELY exclude from this build (#26). Compiled patterns (see `compileLicenseExcludes` /
106
+ * `SHARE_ALIKE_PATTERN` in `license.ts`); a row whose `license` matches any is dropped at ingest. Default (omitted)
107
+ * includes EVERYTHING — exclusion is a deliberate act, not a silent default. A proprietary-weights build passes the
108
+ * share-alike set (`--exclude-share-alike`).
109
+ */
110
+ excludeLicenses?: readonly RegExp[]
111
+ }
112
+
113
+ /** Top-level manifest tying every stage together. */
114
+ export interface BuildCorpusManifest {
115
+ corpus_version: string
116
+ built_at: string
117
+ adapters: AdapterRunManifest[]
118
+ skipped_adapters: string[]
119
+ splits: { counts: SplitManifest["counts"]; holdouts: SplitManifest["holdouts"] }
120
+ shards: { counts: ShardManifest["counts"]; total_rows: number }
121
+ quarantine_count: number
122
+ total_aligned_rows: number
123
+ /**
124
+ * Resolved license set across all INCLUDED rows (license string → row count), + the count dropped by
125
+ * `excludeLicenses` (#26). The model card derives its data-attribution table from `licenses`.
126
+ */
127
+ licenses: Record<string, number>
128
+ excluded_by_license: number
129
+ }
130
+
131
+ /**
132
+ * Drive the full corpus build to completion.
133
+ *
134
+ * Memory profile: the function maintains an in-memory `Map<source_id, SplitName>` to bridge the align → shard hand-off.
135
+ * For Phase 1 fixture-scale runs (≤ 10⁴ rows) this is trivial. For real 5M+ runs, the map fits comfortably in a few
136
+ * hundred MB; the canonical.jsonl and labeled.jsonl payloads stream and never sit in memory.
137
+ */
138
+ export async function buildCorpus(opts: BuildCorpusOptions): Promise<BuildCorpusManifest> {
139
+ const adapters = opts.adapters ?? defaultAdapterRegistry.list()
140
+ const synthesize = opts.synthesize ?? true
141
+ const rowsPerShard = opts.rowsPerShard ?? 1_000_000
142
+ const built_at = new Date().toISOString()
143
+
144
+ await mkdir(opts.outputDir, { recursive: true })
145
+ const intermediateDir = join(opts.outputDir, "intermediate")
146
+ await mkdir(intermediateDir, { recursive: true })
147
+
148
+ // 1. Adapter runs.
149
+ const adapterRuns: AdapterRunManifest[] = []
150
+ const skipped: string[] = []
151
+
152
+ for (const adapter of adapters) {
153
+ const adapterOptions = opts.adapterInputs[adapter.id]
154
+
155
+ if (!adapterOptions) {
156
+ skipped.push(adapter.id)
157
+ opts.onProgress?.("adapter-run", `skipped ${adapter.id} (no input configured)`)
158
+ continue
159
+ }
160
+ // Opt-in resume (MAILWOMAN_RESUME=1): if a complete per-adapter canonical.jsonl + MANIFEST.json
161
+ // already exist, reuse them instead of re-emitting. The MANIFEST is written only after the
162
+ // canonical is fully flushed, so its presence guarantees completeness; row order is identical,
163
+ // so downstream holdout-split determinism is preserved. Recovers an align-phase crash without
164
+ // redoing the (expensive) emit phase. Default (unset) re-emits, preserving correctness. (2026-06-12.)
165
+ const adapterDir = join(intermediateDir, adapter.id)
166
+ const cachedManifest = join(adapterDir, "MANIFEST.json")
167
+
168
+ if (
169
+ $public.MAILWOMAN_RESUME === "1" &&
170
+ existsSync(cachedManifest) &&
171
+ existsSync(join(adapterDir, "canonical.jsonl"))
172
+ ) {
173
+ const cached = JSON.parse(readFileSync(cachedManifest, "utf8")) as AdapterRunManifest
174
+ opts.onProgress?.("adapter-run", `resumed ${adapter.id} (reused ${cached.yielded} canonical rows)`)
175
+ adapterRuns.push(cached)
176
+ continue
177
+ }
178
+ opts.onProgress?.("adapter-run", `running ${adapter.id}`)
179
+ const m = await runAdapter({
180
+ adapter,
181
+ adapterOptions,
182
+ outputDir: intermediateDir,
183
+ corpusVersion: opts.corpusVersion,
184
+ })
185
+ adapterRuns.push(m)
186
+ }
187
+
188
+ // 2 + 3. Synthesis + alignment: stream every canonical.jsonl, optionally augment, align,
189
+ // and route each labeled row directly to its split-specific JSONL (`labeled-{train,val,test}.
190
+ // jsonl`). Memory cost is O(1) — the prior in-memory `splitInputs` array + `splitByIDMap`
191
+ // + `SplitManifest.{train,val,test}` arrays are gone; per-row split is decided inline via
192
+ // `splitForRow` (a pure function of source_id + region + holdout policy).
193
+ const labeledPaths: Record<SplitName, string> = {
194
+ train: join(intermediateDir, "labeled-train.jsonl"),
195
+ val: join(intermediateDir, "labeled-val.jsonl"),
196
+ test: join(intermediateDir, "labeled-test.jsonl"),
197
+ }
198
+ const labeledStreams: Record<SplitName, WriteStream> = {
199
+ train: createWriteStream(labeledPaths.train, { encoding: "utf8" }),
200
+ val: createWriteStream(labeledPaths.val, { encoding: "utf8" }),
201
+ test: createWriteStream(labeledPaths.test, { encoding: "utf8" }),
202
+ }
203
+ const quarantinePath = join(intermediateDir, "quarantine.jsonl")
204
+ const quarantineStream = createWriteStream(quarantinePath, { encoding: "utf8" })
205
+
206
+ let aligned = 0
207
+ let quarantined = 0
208
+ const counts: Record<SplitName, number> = { train: 0, val: 0, test: 0 }
209
+ const holdouts = defaultHoldouts()
210
+ // License accounting + the deliberate exclusion filter (#26). `licenseCounts` is the resolved
211
+ // license set (→ manifest + model-card attribution); `excludeLicenses` (empty by default → include
212
+ // everything) is the operator's PURPOSEFUL exclusion, never a silent drop.
213
+ const excludeLicenses = opts.excludeLicenses ?? []
214
+ const licenseCounts = new Map<string, number>()
215
+ let excludedByLicense = 0
216
+
217
+ const writeQuarantine = (row: CanonicalRow, reason: string): void => {
218
+ quarantineStream.write(`${JSON.stringify({ row, reason })}\n`)
219
+ }
220
+
221
+ for (const adapterRun of adapterRuns) {
222
+ opts.onProgress?.("align", `aligning ${adapterRun.adapter_id}`)
223
+
224
+ for await (const row of streamJSONL<CanonicalRow>(adapterRun.jsonl_path)) {
225
+ licenseCounts.set(row.license, (licenseCounts.get(row.license) ?? 0) + 1)
226
+
227
+ // Deliberate license exclusion (#26): drop a row ONLY when the operator named its license
228
+ // kind via `excludeLicenses`. Default (no patterns) keeps everything — exclusion is a
229
+ // purposeful act, not a silent default. Counted BEFORE the drop so the manifest's license
230
+ // set reflects what the corpus actually CONTAINED, and `excluded_by_license` what was cut.
231
+ if (licenseExcluded(row.license, excludeLicenses)) {
232
+ excludedByLicense++
233
+ continue
234
+ }
235
+ const fanned: CanonicalRow[] = [row]
236
+
237
+ if (synthesize) {
238
+ for (const aug of synthesizeRow(row, defaultAugmentationsForCountry(row.country))) {
239
+ fanned.push(aug)
240
+ }
241
+ }
242
+
243
+ for (const r of fanned) {
244
+ let result: ReturnType<typeof alignRow>
245
+
246
+ try {
247
+ result = alignRow(r)
248
+ } catch (err) {
249
+ // Last-resort robustness (2026-06-12): no single row may crash a multi-hour build.
250
+ // alignRow's targeted paths normalize/quarantine known issues with specific reasons;
251
+ // this catches any UNKNOWN throw (e.g. assertSpanInvariants on an unforeseen span
252
+ // shape) → quarantine + continue. A spike in `align-threw` reasons is a finding.
253
+ writeQuarantine(r, `align-threw:${(err as Error).message.slice(0, 160)}`)
254
+ quarantined++
255
+ continue
256
+ }
257
+
258
+ if (result.kind === "labeled") {
259
+ const split = splitForRow(result.row, holdouts)
260
+ labeledStreams[split].write(`${JSON.stringify(result.row)}\n`)
261
+ counts[split]++
262
+ aligned++
263
+ } else {
264
+ writeQuarantine(r, result.row.reason)
265
+ quarantined++
266
+ }
267
+ }
268
+ }
269
+ }
270
+
271
+ for (const s of Object.values(labeledStreams)) {
272
+ s.end()
273
+ }
274
+ quarantineStream.end()
275
+ await Promise.all([...Object.values(labeledStreams).map(streamEnd), streamEnd(quarantineStream)])
276
+
277
+ // 4. Splits — manifest derived by streaming the per-split labeled files; no in-memory
278
+ // source-id arrays. `sort(1)` from coreutils produces the deterministic per-split .txt
279
+ // manifests with disk spill for splits that exceed in-memory thresholds.
280
+ opts.onProgress?.("split", `splitting ${aligned} aligned rows`)
281
+ const splitsDir = join(opts.outputDir, "splits")
282
+ const splitCounts = await writeSplitManifestsFromLabeledFiles({
283
+ labeledPaths,
284
+ outputDir: splitsDir,
285
+ corpusVersion: opts.corpusVersion,
286
+ counts,
287
+ holdouts,
288
+ })
289
+
290
+ // 5. Parquet shards — per-split labeled JSONL streams in, sharded `.parquet` out. The prior
291
+ // `splitFor(source_id)` callback (and the `Map<source_id, SplitName>` behind it) is gone.
292
+ opts.onProgress?.("shard", "writing parquet shards")
293
+ const shardManifest = await writeShards(
294
+ {
295
+ train: streamJSONL<LabeledRow>(labeledPaths.train),
296
+ val: streamJSONL<LabeledRow>(labeledPaths.val),
297
+ test: streamJSONL<LabeledRow>(labeledPaths.test),
298
+ },
299
+ {
300
+ outputDir: opts.outputDir,
301
+ corpusVersion: opts.corpusVersion,
302
+ rowsPerShard,
303
+ }
304
+ )
305
+
306
+ // License-set visibility (#26): loudly report the resolved license set so a build is an obvious
307
+ // deliberate act — especially a proprietary-weights build (did you pass --exclude-share-alike?).
308
+ const licenseSummary = [...licenseCounts.entries()].sort((a, b) => b[1] - a[1])
309
+ opts.onProgress?.(
310
+ "manifest",
311
+ `license set: ${licenseSummary.map(([l, c]) => `${l}=${c}`).join(", ")}` +
312
+ (excludedByLicense > 0
313
+ ? ` | EXCLUDED ${excludedByLicense} rows by --exclude-licenses`
314
+ : " | NO license exclusion applied (all rows kept)")
315
+ )
316
+
317
+ // 6. Top-level manifest.
318
+ opts.onProgress?.("manifest", "writing top-level MANIFEST.json")
319
+ const manifest: BuildCorpusManifest = {
320
+ corpus_version: opts.corpusVersion,
321
+ built_at,
322
+ adapters: adapterRuns,
323
+ skipped_adapters: skipped,
324
+ splits: { counts: splitCounts, holdouts },
325
+ shards: { counts: shardManifest.counts, total_rows: shardManifest.total_rows },
326
+ quarantine_count: quarantined,
327
+ total_aligned_rows: aligned,
328
+ licenses: Object.fromEntries(licenseSummary),
329
+ excluded_by_license: excludedByLicense,
330
+ }
331
+ await writeFile(join(opts.outputDir, "MANIFEST.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8")
332
+
333
+ return manifest
334
+ }
335
+
336
+ async function* streamJSONL<T>(path: string): AsyncIterable<T> {
337
+ // JSONSpliterator yields already-parsed rows (skipEmpty is on by default, so blank
338
+ // lines are dropped at the row level) and throws SyntaxError on a malformed row —
339
+ // same fail-loud behavior as the prior readline + bare `JSON.parse`.
340
+ yield* JSONSpliterator.fromAsync<T>(path)
341
+ }
342
+
343
+ function streamEnd(s: WriteStream): Promise<void> {
344
+ return new Promise((resolve, reject) => {
345
+ s.once("close", resolve)
346
+ s.once("error", reject)
347
+ })
348
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * US Census FIPS state codes → two-letter abbreviation + full display name.
7
+ *
8
+ * Covers all 50 states + DC + the five primary territories (PR, GU, VI, MP, AS).
9
+ *
10
+ * The data is public-domain US Census reference (FIPS PUB 5-2, withdrawn but still the canonical
11
+ * numeric ID used by every TIGER product). It is reproduced here so the TIGER adapter can resolve
12
+ * `statefp` columns (e.g. `"50"`) into a `region` component (e.g. `"VT"`) without an extra DB
13
+ * join.
14
+ */
15
+
16
+ /** Per-state record: two-letter postal abbreviation + full canonical display name. */
17
+ export interface USStateInfo {
18
+ abbreviation: string
19
+ name: string
20
+ }
21
+
22
+ /**
23
+ * FIPS state-or-territory code → `{ abbreviation, name }`. Includes all 50 states, DC, and the five primary territories
24
+ * (PR, GU, VI, MP, AS). Codes are two-digit zero-padded strings to match TIGER column `statefp`.
25
+ */
26
+ export const US_FIPS_STATE: Readonly<Record<string, USStateInfo>> = Object.freeze({
27
+ "01": { abbreviation: "AL", name: "Alabama" },
28
+ "02": { abbreviation: "AK", name: "Alaska" },
29
+ "04": { abbreviation: "AZ", name: "Arizona" },
30
+ "05": { abbreviation: "AR", name: "Arkansas" },
31
+ "06": { abbreviation: "CA", name: "California" },
32
+ "08": { abbreviation: "CO", name: "Colorado" },
33
+ "09": { abbreviation: "CT", name: "Connecticut" },
34
+ "10": { abbreviation: "DE", name: "Delaware" },
35
+ "11": { abbreviation: "DC", name: "District of Columbia" },
36
+ "12": { abbreviation: "FL", name: "Florida" },
37
+ "13": { abbreviation: "GA", name: "Georgia" },
38
+ "15": { abbreviation: "HI", name: "Hawaii" },
39
+ "16": { abbreviation: "ID", name: "Idaho" },
40
+ "17": { abbreviation: "IL", name: "Illinois" },
41
+ "18": { abbreviation: "IN", name: "Indiana" },
42
+ "19": { abbreviation: "IA", name: "Iowa" },
43
+ "20": { abbreviation: "KS", name: "Kansas" },
44
+ "21": { abbreviation: "KY", name: "Kentucky" },
45
+ "22": { abbreviation: "LA", name: "Louisiana" },
46
+ "23": { abbreviation: "ME", name: "Maine" },
47
+ "24": { abbreviation: "MD", name: "Maryland" },
48
+ "25": { abbreviation: "MA", name: "Massachusetts" },
49
+ "26": { abbreviation: "MI", name: "Michigan" },
50
+ "27": { abbreviation: "MN", name: "Minnesota" },
51
+ "28": { abbreviation: "MS", name: "Mississippi" },
52
+ "29": { abbreviation: "MO", name: "Missouri" },
53
+ "30": { abbreviation: "MT", name: "Montana" },
54
+ "31": { abbreviation: "NE", name: "Nebraska" },
55
+ "32": { abbreviation: "NV", name: "Nevada" },
56
+ "33": { abbreviation: "NH", name: "New Hampshire" },
57
+ "34": { abbreviation: "NJ", name: "New Jersey" },
58
+ "35": { abbreviation: "NM", name: "New Mexico" },
59
+ "36": { abbreviation: "NY", name: "New York" },
60
+ "37": { abbreviation: "NC", name: "North Carolina" },
61
+ "38": { abbreviation: "ND", name: "North Dakota" },
62
+ "39": { abbreviation: "OH", name: "Ohio" },
63
+ "40": { abbreviation: "OK", name: "Oklahoma" },
64
+ "41": { abbreviation: "OR", name: "Oregon" },
65
+ "42": { abbreviation: "PA", name: "Pennsylvania" },
66
+ "44": { abbreviation: "RI", name: "Rhode Island" },
67
+ "45": { abbreviation: "SC", name: "South Carolina" },
68
+ "46": { abbreviation: "SD", name: "South Dakota" },
69
+ "47": { abbreviation: "TN", name: "Tennessee" },
70
+ "48": { abbreviation: "TX", name: "Texas" },
71
+ "49": { abbreviation: "UT", name: "Utah" },
72
+ "50": { abbreviation: "VT", name: "Vermont" },
73
+ "51": { abbreviation: "VA", name: "Virginia" },
74
+ "53": { abbreviation: "WA", name: "Washington" },
75
+ "54": { abbreviation: "WV", name: "West Virginia" },
76
+ "55": { abbreviation: "WI", name: "Wisconsin" },
77
+ "56": { abbreviation: "WY", name: "Wyoming" },
78
+ // Territories
79
+ "60": { abbreviation: "AS", name: "American Samoa" },
80
+ "66": { abbreviation: "GU", name: "Guam" },
81
+ "69": { abbreviation: "MP", name: "Northern Mariana Islands" },
82
+ "72": { abbreviation: "PR", name: "Puerto Rico" },
83
+ "78": { abbreviation: "VI", name: "Virgin Islands" },
84
+ })
85
+
86
+ /** Lookup helper. Returns null when the FIPS code isn't recognized. */
87
+ export function lookupFipsState(statefp: string | null | undefined): USStateInfo | null {
88
+ if (!statefp) return null
89
+
90
+ return US_FIPS_STATE[statefp] ?? null
91
+ }
92
+
93
+ /**
94
+ * Inverted view: two-letter postal abbreviation → `USStateInfo`. Built once at module load. Used by adapters whose
95
+ * source data ships the abbreviation rather than the FIPS code (FCC BDC, most federal CSVs).
96
+ */
97
+ export const US_STATE_BY_ABBREVIATION: Readonly<Record<string, USStateInfo>> = Object.freeze(
98
+ Object.fromEntries(Object.values(US_FIPS_STATE).map((info) => [info.abbreviation, info]))
99
+ )
100
+
101
+ /**
102
+ * Lookup helper for adapters carrying 2-char USPS abbreviations (`"CA"`, `"VT"`). Case-folded; null for any value
103
+ * outside the 50 states + DC + the five primary territories.
104
+ */
105
+ export function lookupStateAbbreviation(abbreviation: string | null | undefined): USStateInfo | null {
106
+ if (!abbreviation) return null
107
+
108
+ return US_STATE_BY_ABBREVIATION[abbreviation.toUpperCase()] ?? null
109
+ }
package/src/format.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Thin re-export of `@mailwoman/formatter`.
7
+ *
8
+ * The formatter implementation moved to its own workspace (`@mailwoman/formatter`) so the parser,
9
+ * the corpus pipeline, and the record matcher can share one locale-aware renderer plus the
10
+ * canonical match key. This module stays put as the stable `@mailwoman/corpus/format` entry point
11
+ * the synthesis adapters import `formatAddress` / `reconcileComponents` from.
12
+ */
13
+
14
+ export * from "@mailwoman/formatter"
package/src/golden.ts ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Golden eval-set validator (Phase 1 task #9 in the plan).
7
+ *
8
+ * The golden set is hand-labeled ground truth for the neural classifier. Each entry must carry
9
+ * components whose surface forms actually occur in `raw` — otherwise the entry will silently rot
10
+ * the eval signal. This module:
11
+ *
12
+ * - Defines `GoldenEntry` (schema check).
13
+ * - Loads `.jsonl` files (one entry per line).
14
+ * - Validates every entry: schema shape, ComponentTag membership, reachability of each component in
15
+ * `raw` via the same `reconcileComponents` helper alignment uses.
16
+ * - Returns a structured report of per-entry errors so the CLI / CI surface can act on it.
17
+ *
18
+ * The 1000-entry target (500 US + 500 FR) is a human task. This module catches the regressions that
19
+ * creep in over time as new entries land.
20
+ */
21
+
22
+ import { readdir, readFile } from "node:fs/promises"
23
+ import { extname, join } from "node:path"
24
+
25
+ import { COMPONENT_TAGS, type ComponentTag } from "@mailwoman/core/types"
26
+
27
+ import { reconcileComponents } from "./format.ts"
28
+
29
+ const TAG_SET = new Set<string>(COMPONENT_TAGS as readonly string[])
30
+
31
+ /** One entry in a golden `.jsonl` file. */
32
+ export interface GoldenEntry {
33
+ raw: string
34
+ components: Partial<Record<ComponentTag, string>>
35
+ country: string
36
+ source: "golden"
37
+ notes?: string
38
+ }
39
+
40
+ /** Per-entry validation failure. */
41
+ export interface GoldenIssue {
42
+ file: string
43
+ line: number
44
+ reason: string
45
+ }
46
+
47
+ /** Aggregate report from `validateGoldenDir`. */
48
+ export interface GoldenReport {
49
+ entries: number
50
+ files: number
51
+ issues: GoldenIssue[]
52
+ }
53
+
54
+ /** Parse a single JSONL line into a `GoldenEntry`. Throws on schema violations. */
55
+ export function parseGoldenLine(line: string): GoldenEntry {
56
+ const obj = JSON.parse(line) as Partial<GoldenEntry> & Record<string, unknown>
57
+
58
+ if (typeof obj.raw !== "string" || obj.raw.length === 0) {
59
+ throw new Error("missing/empty raw")
60
+ }
61
+
62
+ if (typeof obj.country !== "string" || !/^[A-Z]{2}$/u.test(obj.country)) {
63
+ throw new Error(`country must be ISO 3166-1 alpha-2 (got ${JSON.stringify(obj.country)})`)
64
+ }
65
+
66
+ if (obj.source !== "golden") {
67
+ throw new Error(`source must be "golden" (got ${JSON.stringify(obj.source)})`)
68
+ }
69
+ const components = (obj.components ?? {}) as Record<string, unknown>
70
+
71
+ for (const [k, v] of Object.entries(components)) {
72
+ if (!TAG_SET.has(k)) throw new Error(`unknown ComponentTag: ${k}`)
73
+
74
+ if (typeof v !== "string" || v.length === 0) {
75
+ throw new Error(`components.${k} must be a non-empty string`)
76
+ }
77
+ }
78
+
79
+ return {
80
+ raw: obj.raw,
81
+ components: components as GoldenEntry["components"],
82
+ country: obj.country,
83
+ source: "golden",
84
+ notes: typeof obj.notes === "string" ? obj.notes : undefined,
85
+ }
86
+ }
87
+
88
+ /** Check that every component in `entry` appears in `entry.raw` (reconciliation-equivalent). */
89
+ export function unreachableComponents(entry: GoldenEntry): ComponentTag[] {
90
+ const reconciled = reconcileComponents(entry.components, entry.raw)
91
+ const missing: ComponentTag[] = []
92
+
93
+ for (const tag of Object.keys(entry.components) as ComponentTag[]) {
94
+ if (!(tag in reconciled)) {
95
+ missing.push(tag)
96
+ }
97
+ }
98
+
99
+ return missing
100
+ }
101
+
102
+ /** Validate one `.jsonl` file end-to-end, returning a list of issues. */
103
+ export async function validateGoldenFile(path: string): Promise<GoldenIssue[]> {
104
+ const text = await readFile(path, "utf8")
105
+ const lines = text.split("\n")
106
+ const issues: GoldenIssue[] = []
107
+
108
+ for (let i = 0; i < lines.length; i++) {
109
+ const line = lines[i]!.trim()
110
+
111
+ if (!line) continue
112
+
113
+ try {
114
+ const entry = parseGoldenLine(line)
115
+ const unreachable = unreachableComponents(entry)
116
+
117
+ if (unreachable.length > 0) {
118
+ issues.push({
119
+ file: path,
120
+ line: i + 1,
121
+ reason: `components not reachable in raw: ${unreachable.join(", ")}`,
122
+ })
123
+ }
124
+ } catch (err) {
125
+ issues.push({ file: path, line: i + 1, reason: (err as Error).message })
126
+ }
127
+ }
128
+
129
+ return issues
130
+ }
131
+
132
+ /** Validate every `.jsonl` in a golden directory. */
133
+ export async function validateGoldenDir(dir: string): Promise<GoldenReport> {
134
+ const files = (await readdir(dir)).filter((n) => extname(n) === ".jsonl").sort()
135
+ const issues: GoldenIssue[] = []
136
+ let entries = 0
137
+
138
+ for (const name of files) {
139
+ const fullPath = join(dir, name)
140
+ const fileIssues = await validateGoldenFile(fullPath)
141
+ issues.push(...fileIssues)
142
+ const text = await readFile(fullPath, "utf8")
143
+ entries += text.split("\n").filter((l) => l.trim()).length
144
+ }
145
+
146
+ return { entries, files: files.length, issues }
147
+ }
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ */
6
+
7
+ export * from "./adapter.ts"
8
+ export * from "./adapters/gnaf/assemble.ts"
9
+ export * from "./adapters/index.ts"
10
+ export * from "./align.ts"
11
+ export * from "./build.ts"
12
+ export * from "./format.ts"
13
+ export * from "./golden.ts"
14
+ export * from "./parquet.ts"
15
+ export * from "./parquet-wrapper/index.ts"
16
+ export * from "./runner.ts"
17
+ export * from "./shard-recipes/index.ts"
18
+ export * from "./split.ts"
19
+ export * from "./synthesize-anchor-absorption.ts"
20
+ export * from "./synthesize-german.ts"
21
+ export * from "./synthesize-house-venue.ts"
22
+ export * from "./synthesize-intersection.ts"
23
+ export * from "./synthesize-no-street.ts"
24
+ export * from "./synthesize-po-box.ts"
25
+ export * from "./synthesize-street.ts"
26
+ export * from "./synthesize.ts"
27
+ export * from "./tokenize.ts"
28
+ export * from "./types.ts"