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