@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.
- package/package.json +59 -27
- package/src/adapter.ts +153 -0
- package/src/adapters/ban/adapter.ts +189 -0
- package/src/adapters/ban/street-decompose.ts +98 -0
- package/src/adapters/fcc-bdc/adapter.ts +188 -0
- package/src/adapters/geonames/adapter.ts +185 -0
- package/src/adapters/geonames-postal/adapter.ts +111 -0
- package/src/adapters/gnaf/adapter.ts +139 -0
- package/src/adapters/gnaf/assemble.ts +200 -0
- package/src/adapters/index.ts +135 -0
- package/src/adapters/openaddresses/adapter.ts +237 -0
- package/src/adapters/overture/adapter.ts +149 -0
- package/src/adapters/state-hi-schools/adapter.ts +171 -0
- package/src/adapters/state-ia-contractors/adapter.ts +148 -0
- package/src/adapters/state-ny-notaries/adapter.ts +169 -0
- package/src/adapters/state-tx-notaries/adapter.ts +160 -0
- package/src/adapters/synth-po-box/adapter.ts +197 -0
- package/src/adapters/tiger/adapter.ts +232 -0
- package/src/adapters/tiger/street-decompose.ts +120 -0
- package/src/adapters/usgov-hrsa-fqhc/adapter.ts +189 -0
- package/src/adapters/usgov-imls-pls/adapter.ts +152 -0
- package/src/adapters/usgov-irs-bmf/adapter.ts +155 -0
- package/src/adapters/usgov-nad/adapter.ts +338 -0
- package/src/adapters/usgov-nppes/adapter.ts +169 -0
- package/src/adapters/usgov-samhsa-treatment-locator/adapter.ts +211 -0
- package/src/adapters/wof-admin-jp/adapter.ts +176 -0
- package/src/adapters/wof-admin-json/adapter.ts +280 -0
- package/src/adapters/wof-postalcode-json/adapter.ts +209 -0
- package/src/align.ts +317 -0
- package/src/build.ts +348 -0
- package/src/codex/us-fips-state.ts +109 -0
- package/src/format.ts +14 -0
- package/src/golden.ts +147 -0
- package/src/index.ts +28 -0
- package/src/license.ts +48 -0
- package/src/parquet-wrapper/index.ts +9 -0
- package/src/parquet-wrapper/reader.ts +67 -0
- package/src/parquet-wrapper/schema.ts +80 -0
- package/src/parquet-wrapper/writer.ts +102 -0
- package/src/parquet.ts +360 -0
- package/src/runner.ts +283 -0
- package/src/shard-recipes/anchor-absorption.ts +86 -0
- package/src/shard-recipes/boundary-stress.ts +87 -0
- package/src/shard-recipes/country-balanced.ts +471 -0
- package/src/shard-recipes/cz-pcfirst-preposition.ts +83 -0
- package/src/shard-recipes/fr-admin-split.ts +212 -0
- package/src/shard-recipes/fr-bare-street.ts +85 -0
- package/src/shard-recipes/fr-fragment.ts +304 -0
- package/src/shard-recipes/fr-order.ts +283 -0
- package/src/shard-recipes/german.ts +206 -0
- package/src/shard-recipes/house-venue.ts +78 -0
- package/src/shard-recipes/index.ts +76 -0
- package/src/shard-recipes/intersection.ts +617 -0
- package/src/shard-recipes/locale.ts +415 -0
- package/src/shard-recipes/nl-postcode.ts +105 -0
- package/src/shard-recipes/no-fragment.ts +206 -0
- package/src/shard-recipes/no-street-led.ts +130 -0
- package/src/shard-recipes/no-street.ts +78 -0
- package/src/shard-recipes/po-box-cedex.ts +853 -0
- package/src/shard-recipes/po-box.ts +111 -0
- package/src/shard-recipes/scaffold.ts +187 -0
- package/src/shard-recipes/si-bare-village.ts +88 -0
- package/src/shard-recipes/street-affix.ts +549 -0
- package/src/shard-recipes/street-bare.ts +90 -0
- package/src/shard-recipes/street.ts +74 -0
- package/src/shard-recipes/unit.ts +315 -0
- package/src/split.ts +251 -0
- package/src/synthesize-anchor-absorption.ts +263 -0
- package/src/synthesize-boundary-stress.ts +504 -0
- package/src/synthesize-german.ts +227 -0
- package/src/synthesize-house-venue.ts +192 -0
- package/src/synthesize-intersection.ts +205 -0
- package/src/synthesize-no-street.ts +341 -0
- package/src/synthesize-po-box.ts +307 -0
- package/src/synthesize-street.ts +264 -0
- package/src/synthesize.ts +872 -0
- package/src/tokenize.ts +68 -0
- package/src/tools/align-shard.ts +67 -0
- package/src/tools/audit.ts +352 -0
- package/src/tools/corpus-stats.ts +183 -0
- package/src/tools/fetch/ban.ts +288 -0
- package/src/tools/fetch/download.ts +119 -0
- package/src/tools/fetch/hrsa.ts +65 -0
- package/src/tools/fetch/imls-pls.ts +162 -0
- package/src/tools/fetch/index.ts +121 -0
- package/src/tools/fetch/nad.ts +304 -0
- package/src/tools/fetch/nppes.ts +177 -0
- package/src/tools/fetch/openaddresses.ts +380 -0
- package/src/tools/fetch/state-hi-schools.ts +217 -0
- package/src/tools/fetch/state-sources.ts +142 -0
- package/src/tools/fetch/tiger-full.ts +318 -0
- package/src/tools/golden-expand.ts +524 -0
- package/src/tools/golden-promote.ts +304 -0
- package/src/tools/index.ts +24 -0
- package/src/tools/ingest-csv.ts +420 -0
- package/src/tools/jsonl-to-parquet.ts +200 -0
- package/src/tools/lint-shard-vocab.ts +357 -0
- package/src/tools/lint-shard.ts +502 -0
- package/src/tools/overlay-manifest.ts +141 -0
- package/src/tools/shard-kryptonite.ts +131 -0
- package/src/tools/shard-translit.ts +244 -0
- package/src/types.ts +213 -0
- 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"
|