@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
package/src/tokenize.ts
ADDED
|
@@ -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
|
+
}
|