@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,304 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Promote LLM-synthesized golden-set candidates into a versioned golden dir, with
7
+ * human-typed-likelihood filters + dedup. Companion to `golden-expand.ts`.
8
+ *
9
+ * ## What it does
10
+ *
11
+ * 1. Reads a candidates JSONL from `data/eval/golden/candidates/`
12
+ * 2. Reads the previous-version golden dir for forward-copy + dedup base
13
+ * 3. Applies filters that drop candidates unlikely to be human-typed:
14
+ *
15
+ * - Components-glued-without-commas (5+ components but <2 separators → freeform jumble)
16
+ * - Postcode-at-start with many other components (US/UK conventions put postcode last; FR puts it
17
+ * before locality only)
18
+ * - Suspicious-token signals (unmatched brackets, control chars, etc.)
19
+ * 4. Dedupes by normalized raw (case-insensitive, whitespace-collapsed)
20
+ * 5. Splits by country (US/FR/other) and writes `data/eval/golden/v<X.Y.Z>/{us,fr,other}.jsonl`
21
+ * 6. Forward-copies the prior version's adversarial.jsonl + README as-is
22
+ * 7. Writes MANIFEST.json with sha256 of each output file
23
+ *
24
+ * ## Usage
25
+ *
26
+ * ```sh
27
+ * mailwoman corpus golden promote \
28
+ * --input data/eval/golden/candidates/expand-20260518-162627.jsonl \
29
+ * --bump-to v0.1.1 \
30
+ * --prior v0.1.0
31
+ * ```
32
+ */
33
+
34
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs"
35
+ import { join } from "node:path"
36
+
37
+ import { readJSONL, sha256File, writeJSONL } from "@mailwoman/core/utils"
38
+
39
+ // ── Types ──────────────────────────────────────────────────────────────────
40
+
41
+ interface GoldenEntry {
42
+ raw: string
43
+ components: Record<string, string>
44
+ country: string
45
+ source: string
46
+ notes?: string
47
+ seed_source_id?: string
48
+ seed_source_adapter?: string
49
+ dropped_components?: string[]
50
+ provenance?: { provider: string; model: string }
51
+ }
52
+
53
+ export interface PromoteStats {
54
+ candidatesIn: number
55
+ filteredOut: { glued: number; postcodeLeading: number; suspicious: number; duplicate: number; forwardDup: number }
56
+ kept: number
57
+ perCountry: Record<string, number>
58
+ }
59
+
60
+ export interface PromoteGoldenOptions {
61
+ /** Candidates JSONL (required). */
62
+ input: string
63
+ /** Target golden version dir (required, e.g. `v0.1.1`). */
64
+ bumpTo: string
65
+ /** Previous version to forward-copy + dedup against. Default `v0.1.0`. */
66
+ prior?: string
67
+ /** Golden dir root. Default `data/eval/golden`. */
68
+ goldenRoot?: string
69
+ /** Skip the human-typed-likelihood filters (keep everything that passed expand-golden's validator). */
70
+ noFilters?: boolean
71
+ /** Report what would be written but don't touch disk. */
72
+ dryRun?: boolean
73
+ }
74
+
75
+ // ── Filters ───────────────────────────────────────────────────────────────
76
+
77
+ function normalize(s: string): string {
78
+ return s.toLowerCase().replace(/\s+/g, " ").trim()
79
+ }
80
+
81
+ /**
82
+ * Heuristic: an address with 5+ components but fewer than 2 separators (commas/newlines/dashes) is most likely glued
83
+ * together rather than human-typed.
84
+ */
85
+ function isComponentsGlued(entry: GoldenEntry): boolean {
86
+ const componentCount = Object.keys(entry.components).length
87
+
88
+ if (componentCount < 5) return false
89
+ const separators = (entry.raw.match(/[,\n;]/g) ?? []).length
90
+
91
+ return separators < 2
92
+ }
93
+
94
+ /**
95
+ * Heuristic: in US/UK conventions, postcode goes at the END of the address. If postcode appears in the first third of a
96
+ * multi-component raw AND there are 4+ components, the LLM probably over-aggressively reordered. FR is exempt (postcode
97
+ * often precedes locality there).
98
+ */
99
+ function isPostcodeBadlyLeading(entry: GoldenEntry): boolean {
100
+ if (Object.keys(entry.components).length < 4) return false
101
+
102
+ if (entry.country === "FR" || entry.country === "France") return false
103
+ const postcode = entry.components.postcode
104
+
105
+ if (!postcode) return false
106
+ const postcodeIdx = entry.raw.indexOf(postcode)
107
+
108
+ if (postcodeIdx === -1) return false
109
+
110
+ return postcodeIdx < entry.raw.length / 3
111
+ }
112
+
113
+ /**
114
+ * Heuristic: catch-all for visually-bad outputs — unmatched brackets, control chars, suspicious punctuation that
115
+ * suggests the LLM emitted markup instead of an address.
116
+ */
117
+ function isSuspicious(entry: GoldenEntry): boolean {
118
+ const raw = entry.raw
119
+
120
+ // eslint-disable-next-line no-control-regex
121
+ if (/[\x00-\x1f\x7f]/.test(raw)) return true
122
+ const openBrackets = (raw.match(/[[({<]/g) ?? []).length
123
+ const closeBrackets = (raw.match(/[\])}>]/g) ?? []).length
124
+
125
+ if (openBrackets !== closeBrackets) return true
126
+
127
+ if (raw.split('"').length > 3) return true
128
+
129
+ // too many quote marks
130
+ return false
131
+ }
132
+
133
+ // ── IO ─────────────────────────────────────────────────────────────────────
134
+
135
+ /** Read a JSONL, tolerating a missing file (returns `[]`) — prior golden dirs may lack a bucket. */
136
+ function readJSONLIfPresent<T>(path: string): T[] {
137
+ if (!existsSync(path)) return []
138
+
139
+ return readJSONL<T>(path)
140
+ }
141
+
142
+ // ── Main ───────────────────────────────────────────────────────────────────
143
+
144
+ export async function promoteGolden(
145
+ options: PromoteGoldenOptions,
146
+ report?: (line: string) => void
147
+ ): Promise<PromoteStats> {
148
+ const prior = options.prior ?? "v0.1.0"
149
+ const goldenRoot = options.goldenRoot ?? "data/eval/golden"
150
+ const applyFilters = !options.noFilters
151
+ const dryRun = options.dryRun ?? false
152
+
153
+ report?.(`reading candidates: ${options.input}`)
154
+ const candidates = readJSONLIfPresent<GoldenEntry>(options.input)
155
+ report?.(` ${candidates.length} candidates loaded`)
156
+
157
+ // Forward-copy base: existing entries from the prior golden version go forward verbatim,
158
+ // and we dedupe new candidates against them so v_new = v_old ∪ accepted_candidates.
159
+ const priorDir = join(goldenRoot, prior)
160
+ const priorEntries: { country: string; entries: GoldenEntry[] }[] = []
161
+ const seenNormalized = new Set<string>()
162
+
163
+ if (existsSync(priorDir)) {
164
+ for (const f of readdirSync(priorDir).filter((n) => n.endsWith(".jsonl"))) {
165
+ const country = f.replace(".jsonl", "").toUpperCase()
166
+ const entries = readJSONLIfPresent<GoldenEntry>(join(priorDir, f))
167
+ priorEntries.push({ country, entries })
168
+
169
+ for (const e of entries) {
170
+ seenNormalized.add(normalize(e.raw))
171
+ }
172
+ report?.(` prior ${country}: ${entries.length} entries (forward-copy base)`)
173
+ }
174
+ } else {
175
+ report?.(` ⚠ prior dir ${priorDir} not found — starting fresh`)
176
+ }
177
+
178
+ // Filter pass
179
+ const stats: PromoteStats = {
180
+ candidatesIn: candidates.length,
181
+ filteredOut: { glued: 0, postcodeLeading: 0, suspicious: 0, duplicate: 0, forwardDup: 0 },
182
+ kept: 0,
183
+ perCountry: {},
184
+ }
185
+ const accepted: GoldenEntry[] = []
186
+ const seenInBatch = new Set<string>()
187
+
188
+ for (const cand of candidates) {
189
+ const norm = normalize(cand.raw)
190
+
191
+ // Dedup pass 1: against prior versioned golden
192
+ if (seenNormalized.has(norm)) {
193
+ stats.filteredOut.forwardDup++
194
+ continue
195
+ }
196
+
197
+ // Dedup pass 2: against this batch
198
+ if (seenInBatch.has(norm)) {
199
+ stats.filteredOut.duplicate++
200
+ continue
201
+ }
202
+
203
+ if (applyFilters) {
204
+ if (isComponentsGlued(cand)) {
205
+ stats.filteredOut.glued++
206
+ continue
207
+ }
208
+
209
+ if (isPostcodeBadlyLeading(cand)) {
210
+ stats.filteredOut.postcodeLeading++
211
+ continue
212
+ }
213
+
214
+ if (isSuspicious(cand)) {
215
+ stats.filteredOut.suspicious++
216
+ continue
217
+ }
218
+ }
219
+
220
+ accepted.push(cand)
221
+ seenInBatch.add(norm)
222
+ stats.kept++
223
+ const country = cand.country || "OTHER"
224
+ stats.perCountry[country] = (stats.perCountry[country] ?? 0) + 1
225
+ }
226
+
227
+ // Bucket per country, including forward-copied entries
228
+ const buckets = new Map<string, GoldenEntry[]>()
229
+
230
+ for (const { country, entries } of priorEntries) {
231
+ // Existing files keyed by filename uppercase (us.jsonl → US, adversarial.jsonl → ADVERSARIAL)
232
+ buckets.set(country, [...entries])
233
+ }
234
+
235
+ for (const cand of accepted) {
236
+ const key = (cand.country || "OTHER").toUpperCase()
237
+
238
+ if (!buckets.has(key)) {
239
+ buckets.set(key, [])
240
+ }
241
+ buckets.get(key)!.push(cand)
242
+ }
243
+
244
+ // Output
245
+ const outDir = join(goldenRoot, options.bumpTo)
246
+ report?.(`=== plan ===`)
247
+ report?.(`output dir: ${outDir}${dryRun ? " (dry-run)" : ""}`)
248
+
249
+ for (const [key, entries] of buckets) {
250
+ report?.(` ${key.toLowerCase()}.jsonl: ${entries.length} entries`)
251
+ }
252
+ report?.(`=== filter stats ===`)
253
+ report?.(`candidates in: ${stats.candidatesIn}`)
254
+ report?.(` filtered (glued): ${stats.filteredOut.glued}`)
255
+ report?.(` filtered (postcode-lead): ${stats.filteredOut.postcodeLeading}`)
256
+ report?.(` filtered (suspicious): ${stats.filteredOut.suspicious}`)
257
+ report?.(` filtered (dup-in-batch): ${stats.filteredOut.duplicate}`)
258
+ report?.(` filtered (dup-vs-prior): ${stats.filteredOut.forwardDup}`)
259
+ report?.(` kept: ${stats.kept}`)
260
+ report?.(`per-country (kept):`)
261
+
262
+ for (const [c, n] of Object.entries(stats.perCountry).sort((a, b) => b[1] - a[1])) {
263
+ report?.(` ${c}: ${n}`)
264
+ }
265
+
266
+ if (dryRun) {
267
+ report?.(`(dry-run: no files written)`)
268
+
269
+ return stats
270
+ }
271
+
272
+ mkdirSync(outDir, { recursive: true })
273
+ const manifest: {
274
+ promoted_at: string
275
+ from: string
276
+ from_sha256: string
277
+ files: Record<string, { entries: number; sha256: string }>
278
+ } = {
279
+ promoted_at: new Date().toISOString(),
280
+ from: options.input,
281
+ from_sha256: await sha256File(options.input),
282
+ files: {},
283
+ }
284
+
285
+ for (const [key, entries] of buckets) {
286
+ const filename = `${key.toLowerCase()}.jsonl`
287
+ const path = join(outDir, filename)
288
+ writeJSONL(path, entries)
289
+ manifest.files[filename] = { entries: entries.length, sha256: await sha256File(path) }
290
+ }
291
+
292
+ // Forward-copy non-.jsonl files (README.md, etc.) from prior
293
+ if (existsSync(priorDir)) {
294
+ for (const f of readdirSync(priorDir).filter((n) => !n.endsWith(".jsonl"))) {
295
+ copyFileSync(join(priorDir, f), join(outDir, f))
296
+ report?.(` forward-copied: ${f}`)
297
+ }
298
+ }
299
+
300
+ writeFileSync(join(outDir, "MANIFEST.json"), JSON.stringify(manifest, null, 2) + "\n")
301
+ report?.(`✓ promoted to ${outDir}`)
302
+
303
+ return stats
304
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Corpus operator tools — the `run()`-style modules behind `mailwoman corpus …` commands. No argv,
7
+ * no `process.exit`: commands own parsing, rendering, and exit codes (see the 2026-07-09
8
+ * scripts→Pastel spec).
9
+ */
10
+
11
+ export * from "./align-shard.ts"
12
+ export * from "./audit.ts"
13
+ export * from "./corpus-stats.ts"
14
+ export * from "./ingest-csv.ts"
15
+ export * from "./overlay-manifest.ts"
16
+ export * from "./fetch/download.ts"
17
+ export * from "./fetch/index.ts"
18
+ export * from "./golden-expand.ts"
19
+ export * from "./golden-promote.ts"
20
+ export * from "./jsonl-to-parquet.ts"
21
+ export * from "./lint-shard.ts"
22
+ export * from "./lint-shard-vocab.ts"
23
+ export * from "./shard-kryptonite.ts"
24
+ export * from "./shard-translit.ts"