@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,357 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * #511 base-consistency lint, GENERALIZED + COUNTRY-SCOPED (v2) — any synthetic shard vs the base.
7
+ *
8
+ * Ported from scripts/lint-shard-vocab.py (pyarrow → @duckdb/node-api); behavior preserved
9
+ * byte-for-byte (same flags, same stdout, same verdicts). The base-root default routes through
10
+ * `dataRootPath` so the lab `/mnt/playpen` literal stays in its one home
11
+ * (core/utils/data-root.ts) and `$MAILWOMAN_DATA_ROOT` is honored; with the env unset it equals
12
+ * the Python default.
13
+ *
14
+ * The #511 lesson: a synthetic shard must not label a token a tag the BASE dominantly labels
15
+ * something else, or training gets conflicting gradients on the same token and the minority (the
16
+ * shard) loses. This reads a shard's own (token -> tag) and checks each token against the base.
17
+ *
18
+ * WHY v2 IS COUNTRY-SCOPED + FULL-COUNT (the night-2026-06-18 lesson, learned the hard way over
19
+ * three tries): a token's correct tag is COUNTRY-specific — "Paris" is locality in FR data and
20
+ * street in US "Paris Ave"; "Marion" is a US town AND many US "Marion" streets. So:
21
+ *
22
+ * 1. A cross-COUNTRY aggregate mis-judges any country-specific token (v1 uniform AND a proportional
23
+ * retry both false-flagged FR cities as "street" from US street-contexts).
24
+ * 2. A SMALL sample is street-BIASED regardless, because the street sources (tiger 39 + nad 378 parts)
25
+ * dwarf the locality sources (a small US-scoped spot-check read Indianapolis 54% street vs
26
+ * its true 219700:29 LOCALITY). The fix: tally each shard token's base tag SCOPED to the
27
+ * country the shard uses it in (the base has a `country` column), over a LARGE/FULL scan
28
+ * (`fraction`, default 1.0). Pure-numeric tokens excluded (house_number/postcode are
29
+ * context-determined). An affix-split flag (shard street_suffix/_prefix vs base "street") is
30
+ * EXPECTED — the loader's affix-relabel handles it; weigh those separately.
31
+ *
32
+ * Usage: mailwoman dev lint shard-vocab --shard <shard.parquet>
33
+ * [--base-version v0.5.0] [--base-root <dir>] [--fraction 1.0] [--threshold 0.7] [--min-count
34
+ * 50]
35
+ */
36
+
37
+ import { readdirSync } from "node:fs"
38
+ import { join } from "node:path"
39
+
40
+ import { dataRootPath } from "@mailwoman/core/utils"
41
+
42
+ /** A column-projected base/shard row: parallel token + label lists plus the row's country. */
43
+ interface CorpusRow {
44
+ tokens: string[]
45
+ labels: string[]
46
+ country: string | null
47
+ }
48
+
49
+ /** Strip a BIO prefix ("B-"/"I-") off a label, matching the Python `strip_bio`. */
50
+ function stripBIO(label: string): string {
51
+ const head = label.slice(0, 2)
52
+
53
+ return head === "B-" || head === "I-" ? label.slice(2) : label
54
+ }
55
+
56
+ /**
57
+ * Python `str.isdigit()`: non-empty and every character a Unicode digit. Pure-numeric tokens (house_number / postcode)
58
+ * are context-determined, not lexical vocab, so they're excluded. `\p{Nd}` covers the decimal digits these address
59
+ * corpora actually contain.
60
+ */
61
+ function isDigit(token: string): boolean {
62
+ return token.length > 0 && /^\p{Nd}+$/u.test(token)
63
+ }
64
+
65
+ /**
66
+ * Round half to even (banker's rounding) — Python's built-in `round()` and `format(..., ".0%")` both use it, so percent
67
+ * strings and the proportional `fraction` slice match the Python output exactly.
68
+ */
69
+ function pyRound(x: number): number {
70
+ const floor = Math.floor(x)
71
+ const diff = x - floor
72
+
73
+ if (diff < 0.5) return floor
74
+
75
+ if (diff > 0.5) return floor + 1
76
+
77
+ return floor % 2 === 0 ? floor : floor + 1
78
+ }
79
+
80
+ /** Format a fraction as a whole-percent string the way Python's `:.0%` does, e.g. 0.73 -> "73%". */
81
+ function pct(frac: number): string {
82
+ return `${pyRound(frac * 100)}%`
83
+ }
84
+
85
+ /**
86
+ * Format a float the way a Python f-string renders it: integer-valued floats keep one decimal (1.0 -> "1.0"),
87
+ * everything else is its shortest decimal (0.5 -> "0.5"). Used for the `fraction` echo so the banner matches the Python
88
+ * print.
89
+ */
90
+ function pyFloat(n: number): string {
91
+ return Number.isInteger(n) ? n.toFixed(1) : String(n)
92
+ }
93
+
94
+ /** Left-justify to a minimum width with spaces, matching Python's `{value:N}` string field. */
95
+ function pad(value: string, width: number): string {
96
+ return value.padEnd(width)
97
+ }
98
+
99
+ /**
100
+ * The dominant tag of a counter: (tag, total, fraction). Empty counter -> ("", 0, 0.0). Ties go to the first-inserted
101
+ * tag, mirroring `Counter.most_common(1)` (stable on equal counts).
102
+ */
103
+ function dominant(counter: Map<string, number>): [string, number, number] {
104
+ let total = 0
105
+ let bestTag = ""
106
+ let bestCount = -1
107
+
108
+ for (const [tag, count] of counter) {
109
+ total += count
110
+
111
+ if (count > bestCount) {
112
+ bestCount = count
113
+ bestTag = tag
114
+ }
115
+ }
116
+
117
+ if (total === 0) return ["", 0, 0.0]
118
+
119
+ return [bestTag, total, bestCount / total]
120
+ }
121
+
122
+ /** Bump a (key -> count) tally, creating the inner counter on first sight. */
123
+ function bump(table: Map<string, Map<string, number>>, key: string, sub: string): void {
124
+ let counter = table.get(key)
125
+
126
+ if (!counter) {
127
+ counter = new Map()
128
+ table.set(key, counter)
129
+ }
130
+ counter.set(sub, (counter.get(sub) ?? 0) + 1)
131
+ }
132
+
133
+ /** The DuckDB connection type, without a static dependency on the optional-peer package. */
134
+ type DuckDBConnection = Awaited<
135
+ ReturnType<Awaited<ReturnType<(typeof import("@duckdb/node-api"))["DuckDBInstance"]["create"]>>["connect"]>
136
+ >
137
+
138
+ /**
139
+ * Read a corpus parquet into rows, projecting only tokens/labels/country. The list columns ride out as JSON text
140
+ * (DuckDB `to_json`) — the same trick the gazetteer builders use for nested columns — and parse back to string arrays
141
+ * here.
142
+ */
143
+ async function readRows(con: DuckDBConnection, path: string): Promise<CorpusRow[]> {
144
+ const result = await con.runAndReadAll(
145
+ `SELECT to_json(tokens) AS tokens, to_json(labels) AS labels, country FROM read_parquet('${path}')`
146
+ )
147
+ const raw = result.getRowObjects() as Array<{ tokens: unknown; labels: unknown; country: unknown }>
148
+ const rows: CorpusRow[] = []
149
+
150
+ for (const r of raw) {
151
+ const tokens = JSON.parse(String(r.tokens)) as unknown
152
+ const labels = JSON.parse(String(r.labels)) as unknown
153
+
154
+ if (!Array.isArray(tokens) || !Array.isArray(labels)) continue
155
+ rows.push({
156
+ tokens: tokens as string[],
157
+ labels: labels as string[],
158
+ country: r.country == null ? null : String(r.country),
159
+ })
160
+ }
161
+
162
+ return rows
163
+ }
164
+
165
+ /** Read just the first row's `source` value — used to group base parts for a proportional slice. */
166
+ async function readSource(con: DuckDBConnection, path: string): Promise<string> {
167
+ const result = await con.runAndReadAll(`SELECT source FROM read_parquet('${path}') LIMIT 1`)
168
+ const rows = result.getRowObjects() as Array<{ source: unknown }>
169
+
170
+ return rows.length ? String(rows[0]!.source) : ""
171
+ }
172
+
173
+ /** Non-recursive `*.parquet` glob, sorted lexicographically — the Python `sorted(glob.glob(...))`. */
174
+ function globParquet(dir: string): string[] {
175
+ let names: string[]
176
+
177
+ try {
178
+ names = readdirSync(dir)
179
+ } catch {
180
+ return []
181
+ }
182
+
183
+ return names
184
+ .filter((f) => f.endsWith(".parquet"))
185
+ .map((f) => join(dir, f))
186
+ .sort()
187
+ }
188
+
189
+ /** Options for {@linkcode lintShardVocab}. */
190
+ export interface LintShardVocabOptions {
191
+ /** The shard parquet to lint. */
192
+ shard: string
193
+ /** Base corpus version. Default `v0.5.0`. */
194
+ baseVersion?: string
195
+ /** Base corpus root. Default `$MAILWOMAN_DATA_ROOT/corpus/versioned`. */
196
+ baseRoot?: string
197
+ /** Base-majority confidence floor for a contradiction. Default 0.7. */
198
+ threshold?: number
199
+ /** Minimum base support to judge a token. Default 50. */
200
+ minCount?: number
201
+ /** Fraction of base parts to scan (proportional per-source slice below 1.0). Default 1.0. */
202
+ fraction?: number
203
+ }
204
+
205
+ /** One contradiction row: token, shard tag, base tag, base fraction, base total. */
206
+ export type ShardVocabRow = [token: string, shardTag: string, baseTag: string, baseFrac: number, baseTotal: number]
207
+
208
+ /** Findings summary returned by {@linkcode lintShardVocab}. */
209
+ export interface LintShardVocabSummary {
210
+ /** Real contradictions — the command exits 1 when nonzero. */
211
+ errors: number
212
+ /** Affix-split rows (EXPECTED — the loader's affix-relabel handles them). */
213
+ warnings: number
214
+ findings: { contradictions: ShardVocabRow[]; affixSplits: ShardVocabRow[] }
215
+ }
216
+
217
+ /** Lint a synthetic shard's (token → tag) vocabulary against the base corpus, country-scoped. */
218
+ export async function lintShardVocab(options: LintShardVocabOptions): Promise<LintShardVocabSummary> {
219
+ const baseVersion = options.baseVersion ?? "v0.5.0"
220
+ const baseRoot = options.baseRoot ?? dataRootPath("corpus", "versioned")
221
+ const threshold = options.threshold ?? 0.7
222
+ const minCount = options.minCount ?? 50
223
+ const fraction = options.fraction ?? 1.0
224
+
225
+ // @duckdb/node-api is an optional peer — lazy import (the pipeline convention).
226
+ const { DuckDBInstance } = await import("@duckdb/node-api")
227
+ const instance = await DuckDBInstance.create()
228
+ const con = await instance.connect()
229
+
230
+ // 1. the shard's own (token -> dominant tag) + the COUNTRIES it uses each token in
231
+ const shardRows = await readRows(con, options.shard)
232
+ const shardTags = new Map<string, Map<string, number>>()
233
+ const shardCountries = new Map<string, Set<string | null>>()
234
+
235
+ for (const { tokens, labels, country } of shardRows) {
236
+ const n = Math.min(tokens.length, labels.length)
237
+
238
+ for (let i = 0; i < n; i++) {
239
+ const w = tokens[i]!
240
+ const l = labels[i]!
241
+
242
+ if (isDigit(w)) continue // numbers are context-determined (house_number/postcode), not lexical vocab
243
+ bump(shardTags, w, stripBIO(l))
244
+ let set = shardCountries.get(w)
245
+
246
+ if (!set) {
247
+ set = new Set()
248
+ shardCountries.set(w, set)
249
+ }
250
+ set.add(country)
251
+ }
252
+ }
253
+ const shardVocab = new Set(shardTags.keys())
254
+ console.log(`shard: ${shardRows.length} rows, ${shardVocab.size} unique tokens`)
255
+
256
+ // 2. base parts — FULL by default; fraction<1 takes a proportional per-source slice (still big)
257
+ const trainDir = join(baseRoot, baseVersion, `corpus-${baseVersion}`, "train")
258
+ let parts = globParquet(trainDir)
259
+
260
+ if (!parts.length) {
261
+ throw new Error("no base parts found")
262
+ }
263
+
264
+ if (fraction < 1.0) {
265
+ const bysrc = new Map<string, string[]>()
266
+
267
+ for (const p of parts) {
268
+ const src = await readSource(con, p)
269
+ let list = bysrc.get(src)
270
+
271
+ if (!list) {
272
+ list = []
273
+ bysrc.set(src, list)
274
+ }
275
+ list.push(p)
276
+ }
277
+ const sliced: string[] = []
278
+
279
+ for (const ps of bysrc.values()) {
280
+ const take = Math.max(2, pyRound(ps.length * fraction))
281
+
282
+ for (const p of ps.slice(0, take)) {
283
+ sliced.push(p)
284
+ }
285
+ }
286
+ parts = sliced
287
+ }
288
+ console.log(`base ${baseVersion}: scanning ${parts.length} parts (fraction=${pyFloat(fraction)}), COUNTRY-scoped`)
289
+
290
+ // 3. tally each shard token's base tag, SCOPED to the country the shard uses it in
291
+ const baseTags = new Map<string, Map<string, number>>()
292
+
293
+ for (let i = 0; i < parts.length; i++) {
294
+ const rows = await readRows(con, parts[i]!)
295
+
296
+ for (const { tokens, labels, country } of rows) {
297
+ const n = Math.min(tokens.length, labels.length)
298
+
299
+ for (let j = 0; j < n; j++) {
300
+ const w = tokens[j]!
301
+
302
+ if (shardVocab.has(w) && shardCountries.get(w)!.has(country)) {
303
+ bump(baseTags, w, stripBIO(labels[j]!))
304
+ }
305
+ }
306
+ }
307
+
308
+ if ((i + 1) % 100 === 0) {
309
+ console.log(` ...${i + 1}/${parts.length} parts`)
310
+ }
311
+ }
312
+
313
+ // 4. compare; flag contradictions (affix-split is expected — surfaced but tagged)
314
+ const flagged: ShardVocabRow[] = []
315
+ const affix: ShardVocabRow[] = []
316
+
317
+ for (const w of shardVocab) {
318
+ const [sTag] = dominant(shardTags.get(w)!)
319
+ const [bTag, bTotal, bFrac] = dominant(baseTags.get(w) ?? new Map())
320
+
321
+ if (bTotal < minCount || !bTag || bTag === sTag || bFrac < threshold) continue
322
+ const row: ShardVocabRow = [w, sTag, bTag, bFrac, bTotal]
323
+
324
+ if ((sTag === "street_suffix" || sTag === "street_prefix") && bTag === "street") {
325
+ affix.push(row)
326
+ } else {
327
+ flagged.push(row)
328
+ }
329
+ }
330
+
331
+ const sections: Array<[string, ShardVocabRow[]]> = [
332
+ ["CONTRADICTION", flagged],
333
+ ["affix-split (EXPECTED — affix-relabel handles)", affix],
334
+ ]
335
+
336
+ for (const [label, rows] of sections) {
337
+ if (!rows.length) continue
338
+ rows.sort((a, b) => b[4] - a[4] || b[3] - a[3])
339
+ console.log(`\n${label.startsWith("CONTRA") ? "⚠️ " : "· "}${rows.length} ${label}:`)
340
+
341
+ for (const [w, sTag, bTag, bFrac, bTotal] of rows) {
342
+ console.log(` ${pad(w, 18)} shard=${pad(sTag, 14)} base=${bTag} (${pct(bFrac)}, n=${bTotal})`)
343
+ }
344
+ }
345
+
346
+ if (!flagged.length) {
347
+ console.log(
348
+ `\n✅ NO real contradictions (country-scoped, threshold ${pct(threshold)}, support ${minCount}) — shard base-consistent`
349
+ )
350
+ }
351
+
352
+ return {
353
+ errors: flagged.length,
354
+ warnings: affix.length,
355
+ findings: { contradictions: flagged, affixSplits: affix },
356
+ }
357
+ }