@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.
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,68 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Tokenizer interface for alignment.
7
+ *
8
+ * Two implementations live in the corpus package:
9
+ *
10
+ * 1. `whitespaceTokenizer()` (this file): pure-JS, depends on nothing. Splits a string into maximal
11
+ * runs of letters/digits/marks, dropping whitespace and standalone punctuation. Used as the
12
+ * default for in-container alignment tests and as a fallback when no SentencePiece model is
13
+ * available.
14
+ * 2. `sentencePieceTokenizer(modelPath)` (Phase 1 task #11, deferred): wraps the SentencePiece model
15
+ * trained on the corpus. Same interface, different splits. Locked against the corpus version
16
+ * (`tokenizer-v0.1.0` ships with `corpus-v0.1.0`).
17
+ *
18
+ * The interface is intentionally minimal — only what alignment needs. Each token comes back with
19
+ * its (start, end) character offsets so BIO labels can be assigned by span overlap with component
20
+ * spans, independent of how the tokenizer chose its splits.
21
+ */
22
+
23
+ /** A token with its character span in the source string. */
24
+ export interface TokenSpan {
25
+ /** The token text, possibly normalized (case unchanged here; tokenizers may differ). */
26
+ text: string
27
+
28
+ /** Inclusive start offset (UTF-16 code-unit index) in the source string. */
29
+ start: number
30
+
31
+ /** Exclusive end offset in the source string. `text === source.slice(start, end)`. */
32
+ end: number
33
+ }
34
+
35
+ /** A tokenizer that maps a string to a sequence of `TokenSpan`s. */
36
+ export interface Tokenizer {
37
+ tokenize(text: string): readonly TokenSpan[]
38
+ }
39
+
40
+ /**
41
+ * Whitespace + punctuation tokenizer (pure JS).
42
+ *
43
+ * Tokens are maximal runs of unicode word characters (`\p{L}` letters, `\p{N}` digits, `\p{M}` marks, plus `'`, `-`,
44
+ * `_`). Everything else — whitespace, punctuation, symbols — is treated as a separator and **not** emitted as a token.
45
+ * The resulting spans cover the original string only on token regions; in-between regions belong to no token.
46
+ *
47
+ * This is intentionally lossy at the edges (alignment can still label every meaningful span). A future SentencePiece
48
+ * tokenizer will preserve all bytes via byte-fallback.
49
+ */
50
+ export function whitespaceTokenizer(): Tokenizer {
51
+ // Maximal runs of letters/digits/marks plus the joiners common to addresses
52
+ // (apostrophe, hyphen, underscore). Comma/space/period etc. are not in the set.
53
+ const tokenRe = /[\p{L}\p{N}\p{M}'_-]+/gu
54
+
55
+ return {
56
+ tokenize(text: string): readonly TokenSpan[] {
57
+ const out: TokenSpan[] = []
58
+ tokenRe.lastIndex = 0
59
+ let m: RegExpExecArray | null
60
+
61
+ while ((m = tokenRe.exec(text))) {
62
+ out.push({ text: m[0], start: m.index, end: m.index + m[0].length })
63
+ }
64
+
65
+ return out
66
+ },
67
+ }
68
+ }
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ import { createWriteStream } from "node:fs"
3
+
4
+ /**
5
+ * Re-emit a CANONICAL jsonl ({raw, components, country, source, ...}) as a LABELED jsonl in the CURRENT align format,
6
+ * by running every row through `alignRow` (corpus/src/align.ts).
7
+ *
8
+ * ## Why this exists
9
+ *
10
+ * Most synthetic shards are GENERATED on demand by a `build-*-shard` recipe (parametrized by --count), so re-emitting
11
+ * them in a new label format is just a re-run. A few shards are FIXED corpora with a hand/DeepSeek-authored canonical
12
+ * source that is never regenerated — notably `deepseek-kryptonite` (the adversarial hard-case set) and the
13
+ * `deepseek-translit-*` variants. Their committed parquets carry whatever label format was current when they were first
14
+ * built.
15
+ *
16
+ * When the corpus label format changes (the v0.5.0 char-offset triple, #519), those fixed shards must be RE-ALIGNED,
17
+ * not regenerated — feed the canonical source back through the same `alignRow` the from-source build uses, so the spans
18
+ * land in the new format with zero drift. That is exactly what this does: canonical jsonl in → labeled jsonl out, one
19
+ * `alignRow` per row, quarantine on miss.
20
+ *
21
+ * It is the uniform counterpart to corpus/src/tools/shard-kryptonite.ts (which couples to a base manifest and writes
22
+ * parquet directly). Output goes to jsonl so it joins the SAME jsonl-to-parquet path every other overlay shard uses.
23
+ *
24
+ * Usage: node scripts/align-canonical-shard.ts\
25
+ * --input /path/canonical-kryptonite.jsonl\
26
+ * --output /tmp/kryptonite-labeled.jsonl\
27
+ * --corpus-version 0.5.0
28
+ */
29
+ import { alignRow } from "@mailwoman/corpus"
30
+ import { TextSpliterator } from "spliterator"
31
+
32
+ export interface AlignShardOptions {
33
+ input: string
34
+ output: string
35
+ corpusVersion: string
36
+ }
37
+
38
+ export async function alignCanonicalShard(args: AlignShardOptions): Promise<void> {
39
+ // Read phase only — the write path stays on createWriteStream. TextSpliterator + JSON.parse keeps the
40
+ // original tolerance: the `!line.trim()` guard skips blank lines and a trailing CR is valid JSON whitespace.
41
+ const outStream = createWriteStream(args.output, { encoding: "utf8" })
42
+ let labeled = 0
43
+ let quarantined = 0
44
+ const quarantineReasons: Record<string, number> = {}
45
+
46
+ for await (const line of TextSpliterator.fromAsync(args.input)) {
47
+ if (!line.trim()) continue
48
+ const canonical = JSON.parse(line) as Parameters<typeof alignRow>[0]
49
+ // Stamp the target corpus version so the emitted row's provenance matches the run it joins.
50
+ canonical.corpus_version = args.corpusVersion
51
+ const result = alignRow(canonical)
52
+
53
+ if (result.kind === "labeled") {
54
+ outStream.write(JSON.stringify(result.row) + "\n")
55
+ labeled++
56
+ } else {
57
+ quarantined++
58
+ const r = result.row.reason ?? "unknown"
59
+ quarantineReasons[r] = (quarantineReasons[r] ?? 0) + 1
60
+ }
61
+ }
62
+ await new Promise<void>((res) => outStream.end(res))
63
+ console.error(
64
+ `align-canonical-shard: ${labeled} labeled, ${quarantined} quarantined → ${args.output}\n` +
65
+ ` quarantine reasons: ${JSON.stringify(quarantineReasons)}`
66
+ )
67
+ }
@@ -0,0 +1,352 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `mailwoman corpus audit` — per-source shard-count vs source_weight diagnostic.
7
+ *
8
+ * Reads a corpus dir's MANIFEST.json (or scans shards directly), counts shards per source,
9
+ * optionally loads a training config to pair the counts with the configured source_weights, and
10
+ * reports the estimated sampled-row distribution at training time.
11
+ *
12
+ * Would have caught v0.3.0's "NAD = 411/674 train shards × 2.0 weight = ~75% of sampled mix"
13
+ * finding before the v0.3.0 retrospective surfaced it.
14
+ *
15
+ * Emits warnings to stderr and the audit table to stdout; never throws on an empty corpus.
16
+ */
17
+
18
+ import { existsSync, readFileSync, readdirSync } from "node:fs"
19
+ import { basename, join } from "node:path"
20
+
21
+ export interface AuditOpts {
22
+ corpusDir: string
23
+ configPath?: string
24
+ /**
25
+ * Sample at most N shards per split when counting sources. Default 100 for speed; bump to read the full set on a slow
26
+ * run. The first row of each shard determines its source — corpus-v0.2.0+ shards are 100% source-segregated, so a
27
+ * one-row read is authoritative.
28
+ */
29
+ sampleShardCount?: number
30
+ }
31
+
32
+ interface ShardStats {
33
+ /** Shards per source per split */
34
+ bySplit: Record<string, Record<string, number>>
35
+ /** Total shards counted (may be less than file count if sampleShardCount caps reads) */
36
+ totalShards: number
37
+ /** Total shards on disk (file count) — equals totalShards unless capped */
38
+ totalFiles: number
39
+ }
40
+
41
+ interface ParsedConfig {
42
+ sourceWeights: Record<string, number>
43
+ }
44
+
45
+ /**
46
+ * Try parsing a training YAML's source_weights as a minimal regex-based extract. We don't pull in a YAML lib for this
47
+ * script — the syntax is so small that a regex over the source_weights block is sufficient + keeps the script
48
+ * dep-free.
49
+ */
50
+ function parseConfig(configPath: string): ParsedConfig | null {
51
+ if (!existsSync(configPath)) return null
52
+ const text = readFileSync(configPath, "utf8")
53
+ const lines = text.split("\n")
54
+ const weights: Record<string, number> = {}
55
+ let inBlock = false
56
+ let blockIndent = -1
57
+
58
+ for (const raw of lines) {
59
+ const sourceWeightsMatch = raw.match(/^([\t ]*)source_weights:\s*$/)
60
+
61
+ if (sourceWeightsMatch) {
62
+ inBlock = true
63
+ blockIndent = sourceWeightsMatch[1]!.length
64
+ continue
65
+ }
66
+
67
+ if (!inBlock) continue
68
+
69
+ // Skip blank lines and comments.
70
+ if (/^[\t ]*(#|$)/.test(raw)) continue
71
+ // Lines indented MORE than `source_weights:` are entries; lines with ≤ indent end the block.
72
+ const indent = raw.match(/^[\t ]*/)![0].length
73
+
74
+ if (indent <= blockIndent) {
75
+ inBlock = false
76
+ continue
77
+ }
78
+ const m = raw.match(/^[\t ]+([\w-]+):\s*([\d.]+)/)
79
+
80
+ if (m) {
81
+ weights[m[1]!] = parseFloat(m[2]!)
82
+ }
83
+ }
84
+
85
+ return { sourceWeights: weights }
86
+ }
87
+
88
+ /**
89
+ * Scan a corpus directory's shards (typically under <corpus_dir>/train, /val, /test) and count shards per source per
90
+ * split.
91
+ */
92
+ function scanShards(corpusDir: string, sampleCount: number): ShardStats {
93
+ const stats: ShardStats = { bySplit: {}, totalShards: 0, totalFiles: 0 }
94
+
95
+ for (const split of ["train", "val", "test"]) {
96
+ const splitDir = join(corpusDir, split)
97
+
98
+ if (!existsSync(splitDir)) continue
99
+ const files = readdirSync(splitDir)
100
+ .filter((f) => f.endsWith(".parquet"))
101
+ .sort()
102
+ stats.totalFiles += files.length
103
+ const sampleEvery = Math.max(1, Math.floor(files.length / sampleCount))
104
+ const sampled = files.filter((_, i) => i % sampleEvery === 0).slice(0, sampleCount)
105
+ const splitMap: Record<string, number> = {}
106
+
107
+ // We can't read parquet without a dep, so we infer source from filenames where possible.
108
+ // The corpus build typically writes deterministically by source — fall back to "<unknown>"
109
+ // when filename gives no hint. For accurate per-source counts on real corpora, the
110
+ // MANIFEST.json route below is preferred.
111
+ for (const f of sampled) {
112
+ const inferred = inferSourceFromFilename(f)
113
+ splitMap[inferred] = (splitMap[inferred] ?? 0) + 1
114
+ }
115
+ // Scale to estimated full-shard counts.
116
+ const scale = files.length / Math.max(sampled.length, 1)
117
+
118
+ for (const k of Object.keys(splitMap)) {
119
+ splitMap[k] = Math.round(splitMap[k]! * scale)
120
+ }
121
+ stats.bySplit[split] = splitMap
122
+ stats.totalShards += files.length
123
+ }
124
+
125
+ return stats
126
+ }
127
+
128
+ function inferSourceFromFilename(filename: string): string {
129
+ // Many corpus builds write part-<source>-<n>.parquet or part-<n>.parquet. The latter (current
130
+ // build at corpus-v0.3.0) gives no source signal in the filename — see manifestScan() for the
131
+ // authoritative path. Return "<unknown>" so the caller flags this case.
132
+ const m = basename(filename).match(/part-([\w-]+)-\d+\.parquet$/)
133
+
134
+ if (m && m[1] !== undefined) return m[1]
135
+
136
+ return "<unknown>"
137
+ }
138
+
139
+ /**
140
+ * Known source name prefixes. Corpus-v0.3.0 uses these as `source_id` prefixes; matching against the longest prefix
141
+ * that fits a given `first_source_id` recovers the canonical source name.
142
+ *
143
+ * Order matters: longer prefixes must be tried first so `usgov-nad-...` matches `usgov-nad` rather than `usgov`. Sorted
144
+ * descending by length at use site.
145
+ */
146
+ const KNOWN_SOURCE_PREFIXES: ReadonlyArray<string> = [
147
+ "wof-admin",
148
+ "wof-postalcode",
149
+ "ban",
150
+ "tiger",
151
+ "usgov-nad",
152
+ "usgov-nppes",
153
+ "usgov-hrsa-fqhc",
154
+ "usgov-imls-pls",
155
+ "state-ia-contractors",
156
+ "state-tx-notaries",
157
+ "state-ny-notaries",
158
+ "openaddresses",
159
+ // Synthetic adversarial sources (corpus-v0.4.0+, Thread B).
160
+ "deepseek-kryptonite",
161
+ "deepseek-translit-cyrl",
162
+ "deepseek-translit-jpan",
163
+ "deepseek-translit-hans",
164
+ "deepseek-translit-hang",
165
+ "deepseek-translit-armn",
166
+ ]
167
+
168
+ /** Extract the source-name prefix from a `first_source_id` value. */
169
+ function sourceFromID(sourceID: string, knownPrefixes: readonly string[]): string {
170
+ // Sort longest-first so usgov-nad beats usgov, wof-admin beats wof.
171
+ const sorted = [...knownPrefixes].sort((a, b) => b.length - a.length)
172
+
173
+ for (const prefix of sorted) {
174
+ if (sourceID.startsWith(prefix + "-") || sourceID === prefix) return prefix
175
+ }
176
+
177
+ return "<unknown>"
178
+ }
179
+
180
+ /**
181
+ * Prefer reading MANIFEST.json when present — uses each shard's `first_source_id` + prefix matching to recover the
182
+ * source name. Falls back to scanShards when MANIFEST is absent.
183
+ *
184
+ * NOTE: corpus-v0.3.0 shards can mix sources (see `last_source_id` differing from `first_source_id`). The first-row
185
+ * source is an approximation; reading the parquet's full source column would be authoritative but requires a parquet
186
+ * dep. For audit purposes the first-row approximation is accurate within ~5% for the corpus-v0.3.0 shape (most shards
187
+ * are >95% one source).
188
+ */
189
+ function manifestScan(corpusDir: string, knownPrefixes: readonly string[]): ShardStats | null {
190
+ const manifestPath = join(corpusDir, "MANIFEST.json")
191
+
192
+ if (!existsSync(manifestPath)) return null
193
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as {
194
+ shards?: Array<{ split: string; source?: string | null; first_source_id?: string | null }>
195
+ }
196
+
197
+ if (!Array.isArray(manifest.shards)) return null
198
+ const bySplit: Record<string, Record<string, number>> = {}
199
+
200
+ for (const shard of manifest.shards) {
201
+ const split = shard.split
202
+ const src = shard.source ?? sourceFromID(shard.first_source_id ?? "", knownPrefixes)
203
+ bySplit[split] ??= {}
204
+ bySplit[split][src] = (bySplit[split][src] ?? 0) + 1
205
+ }
206
+ const total = Object.values(bySplit).reduce((sum, m) => sum + Object.values(m).reduce((a, b) => a + b, 0), 0)
207
+
208
+ return { bySplit, totalShards: total, totalFiles: total }
209
+ }
210
+
211
+ interface AuditRow {
212
+ source: string
213
+ shards: number
214
+ shardPct: number
215
+ weight: number | "—"
216
+ effectiveSamplePct: number | "—"
217
+ overweightFactor?: number
218
+ }
219
+
220
+ function buildAuditRows(stats: Record<string, number>, weights: Record<string, number>): AuditRow[] {
221
+ const totalShards = Object.values(stats).reduce((a, b) => a + b, 0)
222
+ const allSources = new Set([...Object.keys(stats), ...Object.keys(weights)])
223
+ const rows: AuditRow[] = []
224
+ // Compute effective sample weight: shard_count × source_weight. Sources with no weight get the
225
+ // "—" marker (loader skips them).
226
+ const sampleWeights: Array<[string, number]> = []
227
+
228
+ for (const src of allSources) {
229
+ const shards = stats[src] ?? 0
230
+ const weight = weights[src]
231
+ const effective = weight !== undefined ? shards * weight : 0
232
+ sampleWeights.push([src, effective])
233
+ }
234
+ const totalSampleWeight = sampleWeights.reduce((a, [, w]) => a + w, 0)
235
+
236
+ for (const src of allSources) {
237
+ const shards = stats[src] ?? 0
238
+ const weight = weights[src] ?? "—"
239
+ const effective = typeof weight === "number" ? (shards * weight) / Math.max(totalSampleWeight, 1) : "—"
240
+ rows.push({
241
+ source: src,
242
+ shards,
243
+ shardPct: totalShards > 0 ? shards / totalShards : 0,
244
+ weight,
245
+ effectiveSamplePct: typeof effective === "number" ? effective : "—",
246
+ })
247
+ }
248
+ // Flag the dominator: empirically calibrated against the v0.3.0 → v0.4.0 retrospective.
249
+ // v0.3.0 had usgov-nad at 52% effective sample (1.9× ban); the resulting label-space dilution
250
+ // was responsible for the coarse-F1 regression. So flag a source as "concentration warning"
251
+ // when it's above 40% effective sample OR more than 1.5× the next-highest.
252
+ const numeric = rows.filter((r) => typeof r.effectiveSamplePct === "number") as Array<
253
+ AuditRow & { effectiveSamplePct: number }
254
+ >
255
+ numeric.sort((a, b) => b.effectiveSamplePct - a.effectiveSamplePct)
256
+
257
+ if (numeric.length >= 1) {
258
+ const top = numeric[0]!
259
+ const next = numeric[1]?.effectiveSamplePct ?? 0
260
+
261
+ if (top.effectiveSamplePct > 0.4 || (next > 0 && top.effectiveSamplePct / next > 1.5)) {
262
+ top.overweightFactor = next > 0 ? top.effectiveSamplePct / next : Infinity
263
+ }
264
+ }
265
+ rows.sort((a, b) => b.shards - a.shards)
266
+
267
+ return rows
268
+ }
269
+
270
+ function formatPct(v: number | "—"): string {
271
+ if (v === "—") return "—"
272
+
273
+ return `${(v * 100).toFixed(1)}%`
274
+ }
275
+
276
+ function printReport(corpusDir: string, configPath: string | undefined, stats: ShardStats, rows: AuditRow[]): void {
277
+ console.log(`\nCorpus audit — ${corpusDir}`)
278
+
279
+ if (configPath) {
280
+ console.log(`Config: ${configPath}`)
281
+ }
282
+ console.log(
283
+ `Total shards: ${stats.totalShards}${stats.totalFiles !== stats.totalShards ? ` (${stats.totalFiles} files on disk)` : ""}`
284
+ )
285
+ console.log("")
286
+ const trainStats = stats.bySplit["train"]
287
+
288
+ if (trainStats) {
289
+ const total = Object.values(trainStats).reduce((a, b) => a + b, 0)
290
+ console.log(`Train split: ${total} shards`)
291
+ console.log("")
292
+ const headers = ["source", "shards", "shard %", "weight", "eff. sample %"]
293
+ const widths = [22, 8, 10, 8, 14]
294
+ const fmtRow = (cells: string[]) => cells.map((c, i) => c.padEnd(widths[i]!)).join(" ")
295
+ console.log(fmtRow(headers))
296
+ console.log(fmtRow(widths.map((w) => "─".repeat(w))))
297
+
298
+ for (const row of rows) {
299
+ console.log(
300
+ fmtRow([
301
+ row.source,
302
+ String(row.shards),
303
+ formatPct(row.shardPct),
304
+ typeof row.weight === "number" ? row.weight.toFixed(2) : "—",
305
+ formatPct(row.effectiveSamplePct),
306
+ ])
307
+ )
308
+ }
309
+ console.log("")
310
+ const dominator = rows.find((r) => r.overweightFactor !== undefined)
311
+
312
+ if (dominator) {
313
+ const factor = dominator.overweightFactor
314
+ const factorStr = factor === Infinity ? "∞" : factor?.toFixed(1)
315
+ console.error(
316
+ `⚠ Concentration: ${dominator.source} would sample ${formatPct(dominator.effectiveSamplePct)} ` +
317
+ `of training rows (${factorStr}× the next-highest). ` +
318
+ `Past lesson: v0.3.0's NAD at ~52% caused the 21-label coarse regression. ` +
319
+ `Consider lowering this source's weight or boosting others.`
320
+ )
321
+ } else {
322
+ console.log("✓ No single-source concentration (top source < 40% effective sample AND < 1.5× next).")
323
+ }
324
+ const missingWeights = rows.filter((r) => r.weight === "—" && r.shards > 0)
325
+
326
+ if (missingWeights.length > 0 && configPath) {
327
+ console.error(
328
+ `⚠ Sources present in corpus but absent from config.source_weights ` +
329
+ `(loader will skip them): ${missingWeights.map((r) => r.source).join(", ")}`
330
+ )
331
+ }
332
+ const orphanWeights = rows.filter((r) => typeof r.weight === "number" && r.shards === 0)
333
+
334
+ if (orphanWeights.length > 0) {
335
+ console.error(
336
+ `⚠ Sources weighted in config but no shards found in corpus ` +
337
+ `(no-op weights): ${orphanWeights.map((r) => r.source).join(", ")}`
338
+ )
339
+ }
340
+ }
341
+ }
342
+
343
+ export function audit(opts: AuditOpts): void {
344
+ const config = opts.configPath ? parseConfig(opts.configPath) : null
345
+ // Compose the known-prefix list from both the hardcoded set and any extra names in the config
346
+ // (forward-compat for future adapters added before this file is updated).
347
+ const prefixes = [...new Set([...KNOWN_SOURCE_PREFIXES, ...Object.keys(config?.sourceWeights ?? {})])]
348
+ const stats = manifestScan(opts.corpusDir, prefixes) ?? scanShards(opts.corpusDir, opts.sampleShardCount ?? 100)
349
+ const trainStats = stats.bySplit["train"] ?? {}
350
+ const rows = buildAuditRows(trainStats, config?.sourceWeights ?? {})
351
+ printReport(opts.corpusDir, opts.configPath, stats, rows)
352
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Pre-compute corpus-wide token + bigram label distributions for the corpus linter.
7
+ *
8
+ * Reads one or more Parquet shards, builds per-(token, label) and per-(bigram, label-bigram)
9
+ * histograms, and serializes them as JSON. The output file is consumed by `lint-corpus-shard.ts`
10
+ * as the baseline against which a new shard is compared.
11
+ *
12
+ * Stats are cheap to compute (~5–30s per 100K rows) but expensive enough that we cache them between
13
+ * linter invocations. Re-run this script whenever the corpus changes substantially (a new
14
+ * mainline shard added, a source-pool re-weighted, etc.).
15
+ *
16
+ * Output schema:
17
+ *
18
+ * ```ts
19
+ * interface CorpusStats {
20
+ * row_count: number
21
+ * shard_paths: string[]
22
+ * tokens: { [token: string]: { [label: string]: number } }
23
+ * bigrams: { [token_bigram: string]: { [label_bigram: string]: number } }
24
+ * // token_bigram = "tok1tok2" (US sep), label_bigram = "lab1lab2"
25
+ * // For memory: only keep bigrams with count >= MIN_BIGRAM_COUNT (2).
26
+ * }
27
+ * ```
28
+ *
29
+ * Usage: node scripts/build-corpus-stats.ts\
30
+ * --shards <glob-pattern-or-dir>\
31
+ * --output <stats.json>
32
+ *
33
+ * For a quick local-corpus baseline (limited but useful for linter testing): node
34
+ * scripts/build-corpus-stats.ts\
35
+ * --shards /mnt/playpen/mailwoman-data/corpus/versioned/v0.4.0/corpus-v0.4.0/train/\
36
+ * --output /tmp/corpus-stats-local.json
37
+ */
38
+
39
+ import { execSync } from "node:child_process"
40
+ import { readdirSync, statSync, writeFileSync } from "node:fs"
41
+ import { join } from "node:path"
42
+
43
+ const SEP = ""
44
+ const MIN_BIGRAM_COUNT = 2
45
+
46
+ export interface CorpusStatsOptions {
47
+ shardsArg: string
48
+ outputPath: string
49
+ limitPerShard?: number
50
+ }
51
+
52
+ function discoverShards(shardsArg: string): string[] {
53
+ const stat = statSync(shardsArg)
54
+
55
+ if (stat.isDirectory()) {
56
+ return readdirSync(shardsArg)
57
+ .filter((f) => f.endsWith(".parquet"))
58
+ .map((f) => join(shardsArg, f))
59
+ }
60
+
61
+ if (stat.isFile() && shardsArg.endsWith(".parquet")) return [shardsArg]
62
+
63
+ // Otherwise treat as a literal path list (one per line if it's stdin-friendly).
64
+ return [shardsArg]
65
+ }
66
+
67
+ /**
68
+ * Use a Python subprocess to read parquet (pyarrow is heavier than parquet-wasm but already on the path here, and we
69
+ * have nothing in the JS ecosystem that reads parquet cleanly at this scale). Emits one JSON object per line: `{tokens:
70
+ * [...], labels: [...]}`.
71
+ */
72
+ function streamShardRows(shardPath: string, limit?: number): Array<{ tokens: string[]; labels: string[] }> {
73
+ // Pipe the python script via stdin instead of `-c` to preserve newlines verbatim
74
+ // (JSON-encoding the script for -c collapses real newlines into literal `\n`).
75
+ const py = `
76
+ import pyarrow.parquet as pq
77
+ import json, sys
78
+ t = pq.read_table(${JSON.stringify(shardPath)}, columns=['tokens', 'labels'])
79
+ tokens_col = t['tokens'].to_pylist()
80
+ labels_col = t['labels'].to_pylist()
81
+ n = min(len(tokens_col), ${limit ?? "len(tokens_col)"})
82
+ for i in range(n):
83
+ sys.stdout.write(json.dumps({"tokens": tokens_col[i], "labels": labels_col[i]}) + "\\n")
84
+ `
85
+ const buf = execSync(`python3`, { input: py, maxBuffer: 1024 * 1024 * 1024 })
86
+ const rows: Array<{ tokens: string[]; labels: string[] }> = []
87
+
88
+ for (const line of buf.toString("utf8").split("\n")) {
89
+ if (!line) continue
90
+ rows.push(JSON.parse(line))
91
+ }
92
+
93
+ return rows
94
+ }
95
+
96
+ export function buildCorpusStats(args: CorpusStatsOptions): void {
97
+ const shardPaths = discoverShards(args.shardsArg)
98
+ console.error(`Discovered ${shardPaths.length} parquet shard(s)`)
99
+
100
+ const tokenStats = new Map<string, Map<string, number>>()
101
+ const bigramStats = new Map<string, Map<string, number>>()
102
+ let totalRows = 0
103
+
104
+ for (const path of shardPaths) {
105
+ console.error(`Reading ${path}...`)
106
+ const rows = streamShardRows(path, args.limitPerShard)
107
+ totalRows += rows.length
108
+
109
+ for (const row of rows) {
110
+ const { tokens, labels } = row
111
+
112
+ if (tokens.length !== labels.length) continue
113
+
114
+ // skip malformed
115
+ for (let i = 0; i < tokens.length; i++) {
116
+ const tk = tokens[i]!
117
+ const lb = labels[i]!
118
+ let labelMap = tokenStats.get(tk)
119
+
120
+ if (!labelMap) {
121
+ labelMap = new Map()
122
+ tokenStats.set(tk, labelMap)
123
+ }
124
+ labelMap.set(lb, (labelMap.get(lb) ?? 0) + 1)
125
+
126
+ if (i + 1 < tokens.length) {
127
+ const bigramKey = tk + SEP + tokens[i + 1]!
128
+ const bigramLabel = lb + SEP + labels[i + 1]!
129
+ let bMap = bigramStats.get(bigramKey)
130
+
131
+ if (!bMap) {
132
+ bMap = new Map()
133
+ bigramStats.set(bigramKey, bMap)
134
+ }
135
+ bMap.set(bigramLabel, (bMap.get(bigramLabel) ?? 0) + 1)
136
+ }
137
+ }
138
+ }
139
+ console.error(
140
+ ` ${rows.length} rows; running totals: ${tokenStats.size} unique tokens, ${bigramStats.size} unique bigrams`
141
+ )
142
+ }
143
+
144
+ // Prune bigrams below MIN_BIGRAM_COUNT to keep the output file size sane. Token stats
145
+ // stay complete — they're cheap and we need accuracy at the long tail for label-vacuum
146
+ // detection.
147
+ let prunedBigrams = 0
148
+
149
+ for (const [k, labelMap] of bigramStats) {
150
+ let total = 0
151
+
152
+ for (const v of labelMap.values()) {
153
+ total += v
154
+ }
155
+
156
+ if (total < MIN_BIGRAM_COUNT) {
157
+ bigramStats.delete(k)
158
+ prunedBigrams++
159
+ }
160
+ }
161
+ console.error(`Pruned ${prunedBigrams} singleton bigrams; ${bigramStats.size} remain`)
162
+
163
+ const out = {
164
+ row_count: totalRows,
165
+ shard_paths: shardPaths,
166
+ tokens: {} as Record<string, Record<string, number>>,
167
+ bigrams: {} as Record<string, Record<string, number>>,
168
+ }
169
+
170
+ for (const [tk, labelMap] of tokenStats) {
171
+ out.tokens[tk] = Object.fromEntries(labelMap)
172
+ }
173
+
174
+ for (const [k, labelMap] of bigramStats) {
175
+ out.bigrams[k] = Object.fromEntries(labelMap)
176
+ }
177
+
178
+ writeFileSync(args.outputPath, JSON.stringify(out))
179
+ const sizeMB = (Buffer.byteLength(JSON.stringify(out)) / 1024 / 1024).toFixed(1)
180
+ console.error(
181
+ `Wrote ${args.outputPath} (${sizeMB} MB) — ${totalRows} rows, ${tokenStats.size} tokens, ${bigramStats.size} bigrams`
182
+ )
183
+ }