@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,502 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Corpus linter. Compares a new shard against pre-computed corpus statistics and flags patterns
7
+ * that would cause the class of failure we hit with v0.6.2's "5th Avenue Theatre" adversarial
8
+ * venue templates.
9
+ *
10
+ * Per DeepSeek turn 9 design (2026-05-29). v1 checks:
11
+ *
12
+ * 1. **Token-label distribution outliers.** For each token in the new shard, compare the shard's
13
+ * majority label to the corpus's majority label. Flag when the corpus has a
14
+ * confidently-established majority (>66%) AND the shard's majority differs AND both have
15
+ * non-trivial counts (shard ≥ 50, corpus ≥ 200).
16
+ * 2. **Label-vacuum tokens.** Token labeled with a tag that has ZERO instances in the corpus for that
17
+ * token, despite the token being well-represented in the corpus. Stronger signal than #1 —
18
+ * we're introducing a novel association, not shifting a distribution.
19
+ * 3. **Bigram-label collisions.** Identical (token_bigram, label_bigram) appears in shard while the
20
+ * same token_bigram has a DIFFERENT majority label_bigram in the corpus. The "5th Avenue"
21
+ * with [B-venue, I-venue] vs corpus's [B-house_number, I-street] case.
22
+ * 4. **Common-form anti-pattern rules.** Applies `lint-rules.json` — token-regex → forbidden-labels
23
+ * mappings — flagging matches.
24
+ * 5. **Basic sanity.** Truncated rows (tokens.length !== labels.length), all-O rows >90% of shard.
25
+ *
26
+ * Output: markdown report on stdout, optional JSON sidecar via `outJson`. The command exits 0 if
27
+ * no errors, 1 if any errors (warnings don't gate). Per the design, the MANIFEST entry for a
28
+ * flagged shard should require `lint_acknowledged: true` before training consumes it.
29
+ *
30
+ * Usage: mailwoman dev lint corpus-shard\
31
+ * --shard <new-shard.parquet>\
32
+ * --stats <corpus-stats.json>\
33
+ * [--rules <rules.json>]\
34
+ * [--out-md /tmp/lint-report.md]\
35
+ * [--out-json /tmp/lint-report.json]
36
+ */
37
+
38
+ import { execSync } from "node:child_process"
39
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
40
+ import { fileURLToPath } from "node:url"
41
+
42
+ const SEP = ""
43
+
44
+ // Calibrated thresholds (DeepSeek turn 9). These can be tuned over time if new failure
45
+ // modes surface that the current numbers miss.
46
+ const CORPUS_CONFIDENCE_FLOOR = 0.66
47
+ const SHARD_MIN_COUNT = 50
48
+ const CORPUS_MIN_COUNT = 200
49
+ const VACUUM_SHARD_MIN_COUNT = 20
50
+ const VACUUM_CORPUS_MIN_COUNT = 100
51
+ const BIGRAM_MIN_COUNT = 10
52
+ const ALL_O_RATIO_CEILING = 0.9
53
+
54
+ /**
55
+ * Default `lint-rules.json` path — the rules ship beside this module in the source tree. tsc does not emit
56
+ * readFileSync'd JSON into `out/`, so the compiled tree falls back to the source-tree copy (corpus/out/src/tools/ →
57
+ * corpus/src/tools/). In-repo the `node` exports condition loads this module from source anyway, so the sibling URL is
58
+ * the common path.
59
+ */
60
+ function defaultRulesPath(): string {
61
+ const sibling = new URL("./lint-rules.json", import.meta.url)
62
+
63
+ if (existsSync(sibling)) return fileURLToPath(sibling)
64
+
65
+ return fileURLToPath(new URL("../../../src/tools/lint-rules.json", import.meta.url))
66
+ }
67
+
68
+ /** Options for {@linkcode lintCorpusShard}. */
69
+ export interface LintCorpusShardOptions {
70
+ /** The new shard parquet to lint. */
71
+ shardPath: string
72
+ /** Pre-computed corpus stats JSON (see `corpus-stats.ts`). */
73
+ statsPath: string
74
+ /** Anti-pattern rules JSON. Default: the `lint-rules.json` beside this module. */
75
+ rulesPath?: string
76
+ /** Write the markdown report here as well as stdout. */
77
+ outMd?: string
78
+ /** Write a JSON sidecar of the flags + summary here. */
79
+ outJson?: string
80
+ }
81
+
82
+ interface CorpusStats {
83
+ row_count: number
84
+ shard_paths: string[]
85
+ tokens: Record<string, Record<string, number>>
86
+ bigrams: Record<string, Record<string, number>>
87
+ }
88
+
89
+ interface LintRule {
90
+ id: string
91
+ pattern: string
92
+ pattern_case_sensitive: boolean
93
+ forbidden_labels: string[]
94
+ message: string
95
+ severity: "error" | "warn"
96
+ }
97
+
98
+ interface LintRulesFile {
99
+ rules: LintRule[]
100
+ }
101
+
102
+ interface ShardRow {
103
+ tokens: string[]
104
+ labels: string[]
105
+ }
106
+
107
+ function readShard(shardPath: string): ShardRow[] {
108
+ const py = `
109
+ import pyarrow.parquet as pq
110
+ import json, sys
111
+ t = pq.read_table(${JSON.stringify(shardPath)}, columns=['tokens', 'labels'])
112
+ tokens_col = t['tokens'].to_pylist()
113
+ labels_col = t['labels'].to_pylist()
114
+ for i in range(len(tokens_col)):
115
+ sys.stdout.write(json.dumps({"tokens": tokens_col[i], "labels": labels_col[i]}) + "\\n")
116
+ `
117
+ const buf = execSync(`python3`, { input: py, maxBuffer: 1024 * 1024 * 1024 })
118
+ const rows: ShardRow[] = []
119
+
120
+ for (const line of buf.toString("utf8").split("\n")) {
121
+ if (!line) continue
122
+ rows.push(JSON.parse(line))
123
+ }
124
+
125
+ return rows
126
+ }
127
+
128
+ interface ShardStats {
129
+ rowCount: number
130
+ tokens: Map<string, Map<string, number>>
131
+ bigrams: Map<string, Map<string, number>>
132
+ truncatedRows: number
133
+ allORows: number
134
+ }
135
+
136
+ function statsFromShard(rows: ShardRow[]): ShardStats {
137
+ const out: ShardStats = {
138
+ rowCount: rows.length,
139
+ tokens: new Map(),
140
+ bigrams: new Map(),
141
+ truncatedRows: 0,
142
+ allORows: 0,
143
+ }
144
+
145
+ for (const row of rows) {
146
+ if (row.tokens.length !== row.labels.length) {
147
+ out.truncatedRows++
148
+ continue
149
+ }
150
+
151
+ if (row.labels.every((l) => l === "O")) {
152
+ out.allORows++
153
+ }
154
+
155
+ for (let i = 0; i < row.tokens.length; i++) {
156
+ const tk = row.tokens[i]!
157
+ const lb = row.labels[i]!
158
+ let labelMap = out.tokens.get(tk)
159
+
160
+ if (!labelMap) {
161
+ labelMap = new Map()
162
+ out.tokens.set(tk, labelMap)
163
+ }
164
+ labelMap.set(lb, (labelMap.get(lb) ?? 0) + 1)
165
+
166
+ if (i + 1 < row.tokens.length) {
167
+ const bigramKey = tk + SEP + row.tokens[i + 1]!
168
+ const bigramLabel = lb + SEP + row.labels[i + 1]!
169
+ let bMap = out.bigrams.get(bigramKey)
170
+
171
+ if (!bMap) {
172
+ bMap = new Map()
173
+ out.bigrams.set(bigramKey, bMap)
174
+ }
175
+ bMap.set(bigramLabel, (bMap.get(bigramLabel) ?? 0) + 1)
176
+ }
177
+ }
178
+ }
179
+
180
+ return out
181
+ }
182
+
183
+ function majorityLabel(distribution: Map<string, number> | Record<string, number>): {
184
+ label: string
185
+ count: number
186
+ total: number
187
+ confidence: number
188
+ } {
189
+ const entries = distribution instanceof Map ? [...distribution.entries()] : Object.entries(distribution)
190
+ let bestLabel = ""
191
+ let bestCount = 0
192
+ let total = 0
193
+
194
+ for (const [label, count] of entries) {
195
+ total += count
196
+
197
+ if (count > bestCount) {
198
+ bestCount = count
199
+ bestLabel = label
200
+ }
201
+ }
202
+
203
+ return { label: bestLabel, count: bestCount, total, confidence: total === 0 ? 0 : bestCount / total }
204
+ }
205
+
206
+ /** One lint flag emitted by a check. */
207
+ export interface LintShardFlag {
208
+ check: string
209
+ severity: "error" | "warn"
210
+ token?: string
211
+ bigram?: string
212
+ shardLabel?: string
213
+ corpusLabel?: string
214
+ shardCount?: number
215
+ corpusCount?: number
216
+ detail: string
217
+ ruleID?: string
218
+ }
219
+
220
+ /** Findings summary returned by {@linkcode lintCorpusShard}. */
221
+ export interface LintCorpusShardSummary {
222
+ errors: number
223
+ warnings: number
224
+ findings: LintShardFlag[]
225
+ /** The rendered markdown report (also printed to stdout). */
226
+ report: string
227
+ }
228
+
229
+ function checkDistributionOutliers(shard: ShardStats, corpus: CorpusStats): LintShardFlag[] {
230
+ const flags: LintShardFlag[] = []
231
+
232
+ for (const [token, shardLabelMap] of shard.tokens) {
233
+ const corpusLabelMap = corpus.tokens[token]
234
+
235
+ if (!corpusLabelMap) continue
236
+ const shardMaj = majorityLabel(shardLabelMap)
237
+ const corpusMaj = majorityLabel(corpusLabelMap)
238
+
239
+ if (
240
+ corpusMaj.confidence >= CORPUS_CONFIDENCE_FLOOR &&
241
+ shardMaj.label !== corpusMaj.label &&
242
+ shardMaj.count >= SHARD_MIN_COUNT &&
243
+ corpusMaj.total >= CORPUS_MIN_COUNT
244
+ ) {
245
+ flags.push({
246
+ check: "distribution-outlier",
247
+ severity: "error",
248
+ token,
249
+ shardLabel: shardMaj.label,
250
+ corpusLabel: corpusMaj.label,
251
+ shardCount: shardMaj.count,
252
+ corpusCount: corpusMaj.count,
253
+ detail: `Token "${token}": shard majority is ${shardMaj.label} (${shardMaj.count}/${shardMaj.total}, ${(shardMaj.confidence * 100).toFixed(0)}%), corpus majority is ${corpusMaj.label} (${corpusMaj.count}/${corpusMaj.total}, ${(corpusMaj.confidence * 100).toFixed(0)}%).`,
254
+ })
255
+ }
256
+ }
257
+
258
+ return flags
259
+ }
260
+
261
+ function checkLabelVacuum(shard: ShardStats, corpus: CorpusStats): LintShardFlag[] {
262
+ const flags: LintShardFlag[] = []
263
+
264
+ for (const [token, shardLabelMap] of shard.tokens) {
265
+ const corpusLabelMap = corpus.tokens[token]
266
+
267
+ if (!corpusLabelMap) continue
268
+ const corpusTotal = Object.values(corpusLabelMap).reduce((a, b) => a + b, 0)
269
+
270
+ if (corpusTotal < VACUUM_CORPUS_MIN_COUNT) continue
271
+
272
+ for (const [label, shardCount] of shardLabelMap) {
273
+ if (shardCount < VACUUM_SHARD_MIN_COUNT) continue
274
+
275
+ if (corpusLabelMap[label] === undefined || corpusLabelMap[label] === 0) {
276
+ flags.push({
277
+ check: "label-vacuum",
278
+ severity: "error",
279
+ token,
280
+ shardLabel: label,
281
+ shardCount,
282
+ corpusCount: corpusTotal,
283
+ detail: `Token "${token}": shard labels it ${label} ${shardCount} times, but the corpus (${corpusTotal} instances of this token) has ZERO instances of this label.`,
284
+ })
285
+ }
286
+ }
287
+ }
288
+
289
+ return flags
290
+ }
291
+
292
+ function checkBigramCollisions(shard: ShardStats, corpus: CorpusStats): LintShardFlag[] {
293
+ const flags: LintShardFlag[] = []
294
+
295
+ for (const [bigram, shardLabelMap] of shard.bigrams) {
296
+ const corpusLabelMap = corpus.bigrams[bigram]
297
+
298
+ if (!corpusLabelMap) continue
299
+ const shardMaj = majorityLabel(shardLabelMap)
300
+ const corpusMaj = majorityLabel(corpusLabelMap)
301
+
302
+ if (
303
+ shardMaj.label !== corpusMaj.label &&
304
+ shardMaj.count >= BIGRAM_MIN_COUNT &&
305
+ corpusMaj.count >= BIGRAM_MIN_COUNT
306
+ ) {
307
+ const renderBigram = bigram.split(SEP).join(" ")
308
+ const renderShardLabel = shardMaj.label.split(SEP).join(" → ")
309
+ const renderCorpusLabel = corpusMaj.label.split(SEP).join(" → ")
310
+ flags.push({
311
+ check: "bigram-collision",
312
+ severity: "error",
313
+ bigram: renderBigram,
314
+ shardLabel: renderShardLabel,
315
+ corpusLabel: renderCorpusLabel,
316
+ shardCount: shardMaj.count,
317
+ corpusCount: corpusMaj.count,
318
+ detail: `Bigram "${renderBigram}": shard label-bigram is [${renderShardLabel}] (${shardMaj.count}×), corpus label-bigram is [${renderCorpusLabel}] (${corpusMaj.count}×). Same surface text, different structural reading.`,
319
+ })
320
+ }
321
+ }
322
+
323
+ return flags
324
+ }
325
+
326
+ function checkRules(shard: ShardStats, rulesFile: LintRulesFile): LintShardFlag[] {
327
+ const flags: LintShardFlag[] = []
328
+ const compiled = rulesFile.rules.map((r) => ({
329
+ rule: r,
330
+ regex: new RegExp(r.pattern, r.pattern_case_sensitive ? "" : "i"),
331
+ }))
332
+
333
+ for (const [token, labelMap] of shard.tokens) {
334
+ for (const { rule, regex } of compiled) {
335
+ if (!regex.test(token)) continue
336
+
337
+ for (const [label, count] of labelMap) {
338
+ if (rule.forbidden_labels.includes(label) && count >= 5) {
339
+ flags.push({
340
+ check: "anti-pattern-rule",
341
+ severity: rule.severity,
342
+ ruleID: rule.id,
343
+ token,
344
+ shardLabel: label,
345
+ shardCount: count,
346
+ detail: `Token "${token}" matched rule ${rule.id} and is labeled ${label} ${count} time(s). Rule message: ${rule.message}`,
347
+ })
348
+ }
349
+ }
350
+ }
351
+ }
352
+
353
+ return flags
354
+ }
355
+
356
+ function checkSanity(shard: ShardStats): LintShardFlag[] {
357
+ const flags: LintShardFlag[] = []
358
+
359
+ if (shard.truncatedRows > 0) {
360
+ flags.push({
361
+ check: "truncated-rows",
362
+ severity: "error",
363
+ detail: `${shard.truncatedRows} row(s) have tokens.length !== labels.length. Pipeline alignment bug.`,
364
+ })
365
+ }
366
+ const allORatio = shard.allORows / Math.max(1, shard.rowCount)
367
+
368
+ if (allORatio >= ALL_O_RATIO_CEILING) {
369
+ flags.push({
370
+ check: "all-O-shard",
371
+ severity: "warn",
372
+ detail: `${shard.allORows}/${shard.rowCount} rows (${(allORatio * 100).toFixed(0)}%) are entirely O-labeled. Shard contributes no signal.`,
373
+ })
374
+ }
375
+
376
+ return flags
377
+ }
378
+
379
+ function renderReport(
380
+ opts: { shardPath: string; statsPath: string; rulesPath: string },
381
+ shard: ShardStats,
382
+ flags: LintShardFlag[]
383
+ ): string {
384
+ const errors = flags.filter((f) => f.severity === "error")
385
+ const warns = flags.filter((f) => f.severity === "warn")
386
+ const verdict = errors.length === 0 ? "**PASS** ✓" : "**FLAGGED** ⚠"
387
+ const lines: string[] = []
388
+ lines.push(`# Corpus Lint: ${verdict}`)
389
+ lines.push("")
390
+ lines.push(`- **Shard:** \`${opts.shardPath}\``)
391
+ lines.push(`- **Corpus stats:** \`${opts.statsPath}\``)
392
+ lines.push(`- **Rules:** \`${opts.rulesPath}\``)
393
+ lines.push(`- **Shard rows:** ${shard.rowCount}`)
394
+ lines.push(`- **Unique tokens:** ${shard.tokens.size}`)
395
+ lines.push(`- **Unique bigrams:** ${shard.bigrams.size}`)
396
+ lines.push("")
397
+ lines.push(
398
+ `**Errors:** ${errors.length} (gates the shard's inclusion unless MANIFEST sets \`lint_acknowledged: true\`)`
399
+ )
400
+ lines.push(`**Warnings:** ${warns.length} (advisory)`)
401
+ lines.push("")
402
+
403
+ if (flags.length === 0) {
404
+ lines.push("No anomalies detected.")
405
+
406
+ return lines.join("\n")
407
+ }
408
+ const byCheck = new Map<string, LintShardFlag[]>()
409
+
410
+ for (const f of flags) {
411
+ const arr = byCheck.get(f.check) ?? []
412
+ arr.push(f)
413
+ byCheck.set(f.check, arr)
414
+ }
415
+
416
+ for (const [check, list] of byCheck) {
417
+ lines.push(`## ${check} (${list.length})`)
418
+ lines.push("")
419
+ // Sort by shardCount desc — highest-volume issues first
420
+ list.sort((a, b) => (b.shardCount ?? 0) - (a.shardCount ?? 0))
421
+
422
+ for (const f of list.slice(0, 20)) {
423
+ lines.push(`- **[${f.severity.toUpperCase()}]** ${f.detail}`)
424
+ }
425
+
426
+ if (list.length > 20) {
427
+ lines.push(`- ... and ${list.length - 20} more`)
428
+ }
429
+ lines.push("")
430
+ }
431
+
432
+ return lines.join("\n")
433
+ }
434
+
435
+ /** Lint a shard against corpus stats + the anti-pattern rules; print the markdown report to stdout. */
436
+ export function lintCorpusShard(
437
+ options: LintCorpusShardOptions,
438
+ report?: (line: string) => void
439
+ ): LintCorpusShardSummary {
440
+ const rulesPath = options.rulesPath ?? defaultRulesPath()
441
+ report?.(`Reading corpus stats from ${options.statsPath}...`)
442
+ const corpus: CorpusStats = JSON.parse(readFileSync(options.statsPath, "utf8"))
443
+ report?.(
444
+ ` ${corpus.row_count} rows from ${corpus.shard_paths.length} shard(s); ${Object.keys(corpus.tokens).length} tokens, ${Object.keys(corpus.bigrams).length} bigrams`
445
+ )
446
+
447
+ report?.(`Reading shard from ${options.shardPath}...`)
448
+ const rows = readShard(options.shardPath)
449
+ report?.(` ${rows.length} rows`)
450
+
451
+ report?.(`Computing shard stats...`)
452
+ const shard = statsFromShard(rows)
453
+
454
+ report?.(`Loading rules from ${rulesPath}...`)
455
+ const rulesFile: LintRulesFile = JSON.parse(readFileSync(rulesPath, "utf8"))
456
+
457
+ report?.(`Running checks...`)
458
+ const flags: LintShardFlag[] = [
459
+ ...checkDistributionOutliers(shard, corpus),
460
+ ...checkLabelVacuum(shard, corpus),
461
+ ...checkBigramCollisions(shard, corpus),
462
+ ...checkRules(shard, rulesFile),
463
+ ...checkSanity(shard),
464
+ ]
465
+
466
+ const rendered = renderReport({ shardPath: options.shardPath, statsPath: options.statsPath, rulesPath }, shard, flags)
467
+ console.log(rendered)
468
+
469
+ if (options.outMd) {
470
+ writeFileSync(options.outMd, rendered)
471
+ }
472
+
473
+ if (options.outJson) {
474
+ writeFileSync(
475
+ options.outJson,
476
+ JSON.stringify(
477
+ {
478
+ shard: options.shardPath,
479
+ stats: options.statsPath,
480
+ flags,
481
+ summary: {
482
+ errors: flags.filter((f) => f.severity === "error").length,
483
+ warnings: flags.filter((f) => f.severity === "warn").length,
484
+ },
485
+ },
486
+ null,
487
+ 2
488
+ )
489
+ )
490
+ }
491
+
492
+ const errorCount = flags.filter((f) => f.severity === "error").length
493
+ const warningCount = flags.filter((f) => f.severity === "warn").length
494
+
495
+ if (errorCount > 0) {
496
+ report?.(`LINT FAILED: ${errorCount} error(s).`)
497
+ } else {
498
+ report?.("LINT PASSED.")
499
+ }
500
+
501
+ return { errors: errorCount, warnings: warningCount, findings: flags, report: rendered }
502
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Assemble a corpus OVERLAY MANIFEST — generalized from assemble-fr-admin-split-overlay-manifest.
7
+ * ADDS one shard parquet to a base corpus, keeping every base shard VERBATIM (pure overlay ADD),
8
+ * and re-roots base paths to /data (the Modal volume). Parameterized by --shard-parquet +
9
+ * --source so it works for any overlay shard (the fr-admin-split one is the original; #148's
10
+ * overture-multilocale is the second user).
11
+ *
12
+ * Ported faithfully from scripts/assemble-overlay-manifest.py. The new shard's source_id column is
13
+ * read through DuckDB (`@duckdb/node-api`) instead of PyArrow; everything else is pure JSON.
14
+ *
15
+ * Pipeline (the recipe rides the result): node scripts/build-overture-multilocale-canonical.mjs
16
+ * --cap 150000 --out /tmp/ovl/overture-ml.canonical.jsonl node scripts/align-canonical-shard.ts
17
+ * --input <canonical> --output <labeled> --corpus-version 0.5.0 mailwoman dev jsonl-to-parquet
18
+ * --input <labeled> --output <NEW>/train/<shard-parquet> node
19
+ * scripts/assemble-overlay-manifest.ts --base <BASE>/MANIFEST.json --new-dir <NEW>\
20
+ * --modal-root /data/corpus/versioned/<ver>/<dir> --version <ver>\
21
+ * --shard-parquet <shard-parquet> --source <source> --note "..."
22
+ *
23
+ * # then push the overlay to R2 + sync + `modal run -d ... --config <recipe>.yaml --resume none`.
24
+ */
25
+
26
+ import { readFileSync, statSync, writeFileSync } from "node:fs"
27
+ import { join } from "node:path"
28
+
29
+ import { sha256Hex } from "@mailwoman/core/utils"
30
+
31
+ interface ShardDescriptor {
32
+ split: string
33
+ path: string
34
+ format: "parquet"
35
+ compression: string
36
+ rows: number
37
+ bytes: number
38
+ sha256: string
39
+ first_source_id: string
40
+ last_source_id: string
41
+ source: string
42
+ }
43
+
44
+ interface BaseManifest {
45
+ corpus_version?: string
46
+ schema: unknown
47
+ rows_per_shard: unknown
48
+ row_group_size: unknown
49
+ shards: Array<Record<string, unknown> & { path: string; source?: string }>
50
+ counts: { train: number; val: number; test: number }
51
+ total_rows: number
52
+ }
53
+
54
+ /** Escape a path for single-quoted SQL string literals. */
55
+ function sqlString(value: string): string {
56
+ return value.replace(/'/g, "''")
57
+ }
58
+
59
+ async function descriptor(
60
+ localPath: string,
61
+ modalPath: string,
62
+ split: string,
63
+ source: string
64
+ ): Promise<ShardDescriptor> {
65
+ // @duckdb/node-api is an optional peer — lazy import (the pipeline convention).
66
+ const { DuckDBInstance } = await import("@duckdb/node-api")
67
+ const instance = await DuckDBInstance.create()
68
+ const db = await instance.connect()
69
+ const result = await db.runAndReadAll(`SELECT source_id FROM read_parquet('${sqlString(localPath)}')`)
70
+ const sids = result.getRowObjects().map((r) => r.source_id as string)
71
+
72
+ return {
73
+ split,
74
+ path: modalPath,
75
+ format: "parquet",
76
+ compression: "SNAPPY",
77
+ rows: sids.length,
78
+ bytes: statSync(localPath).size,
79
+ sha256: sha256Hex(readFileSync(localPath)),
80
+ first_source_id: sids[0]!,
81
+ last_source_id: sids[sids.length - 1]!,
82
+ source,
83
+ }
84
+ }
85
+
86
+ export interface OverlayManifestOptions {
87
+ base: string
88
+ newDir: string
89
+ modalRoot: string
90
+ version: string
91
+ shardParquet: string
92
+ source: string
93
+ note: string
94
+ }
95
+
96
+ export async function assembleOverlayManifest(args: OverlayManifestOptions): Promise<void> {
97
+ const base = JSON.parse(readFileSync(args.base, "utf8")) as BaseManifest
98
+
99
+ if (base.shards.some((s) => s.source === args.source)) {
100
+ console.log(`WARN: base already contains source '${args.source}' — is this the right base?`)
101
+ }
102
+
103
+ const reroot = (p: string): string => {
104
+ const i = p.indexOf("/corpus/versioned/")
105
+
106
+ return i >= 0 ? "/data" + p.slice(i) : p
107
+ }
108
+
109
+ const kept = base.shards.map((s) => ({ ...s, path: reroot(s.path) }))
110
+
111
+ const newTrain = await descriptor(
112
+ join(args.newDir, "train", args.shardParquet),
113
+ `${args.modalRoot}/train/${args.shardParquet}`,
114
+ "train",
115
+ args.source
116
+ )
117
+
118
+ const manifest = {
119
+ corpus_version: args.version,
120
+ overlay_base: base.corpus_version ?? null,
121
+ note:
122
+ args.note || `${base.corpus_version} shards (all kept verbatim) + the ${args.source} shard. Pure overlay add.`,
123
+ schema: base.schema,
124
+ rows_per_shard: base.rows_per_shard,
125
+ row_group_size: base.row_group_size,
126
+ shards: [...kept, newTrain],
127
+ counts: {
128
+ train: base.counts.train + newTrain.rows,
129
+ val: base.counts.val,
130
+ test: base.counts.test,
131
+ },
132
+ total_rows: base.total_rows + newTrain.rows,
133
+ }
134
+
135
+ const out = join(args.newDir, "MANIFEST.json")
136
+ writeFileSync(out, JSON.stringify(manifest, null, 1) + "\n")
137
+ console.log(`wrote ${out}`)
138
+ console.log(` shards: ${manifest.shards.length} (${kept.length} base kept, +1 ${args.source})`)
139
+ console.log(` counts: ${JSON.stringify(manifest.counts)} total: ${manifest.total_rows}`)
140
+ console.log(` ${args.source} train: ${newTrain.rows} rows (${newTrain.bytes} bytes)`)
141
+ }