@mailwoman/corpus 7.2.0 → 7.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Build a parquet shard from the DeepSeek-generated kryptonite JSONL and emit the corpus-v0.4.0
|
|
7
|
+
* MANIFEST. corpus-v0.4.0 is a pure adapter-addition revision: it points at every shard from
|
|
8
|
+
* v0.3.0 plus the new kryptonite shard(s). No v0.3.0 bytes are touched or re-shuffled.
|
|
9
|
+
*
|
|
10
|
+
* See docs/articles/plan/reference/CORPUS_V0_4_0_GENERATION.md for the why; that doc also pins the
|
|
11
|
+
* DeepSeek model version + prompt versions used to produce the JSONL.
|
|
12
|
+
*
|
|
13
|
+
* Invoke via `mailwoman corpus shard kryptonite \
|
|
14
|
+
* --jsonl /data/corpus/versioned/v0.4.0/kryptonite/canonical-kryptonite.jsonl \
|
|
15
|
+
* --base-manifest /data/corpus/versioned/v0.3.0/corpus-v0.3.0/MANIFEST.json \
|
|
16
|
+
* --out-dir /data/corpus/versioned/v0.4.0`
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
20
|
+
import { mkdir } from "node:fs/promises"
|
|
21
|
+
import { join } from "node:path"
|
|
22
|
+
|
|
23
|
+
import { iterateJSONL } from "@mailwoman/core/utils"
|
|
24
|
+
|
|
25
|
+
import { alignRow } from "../align.ts"
|
|
26
|
+
import type { ShardManifest } from "../parquet.ts"
|
|
27
|
+
import { PARQUET_COLUMNS, ROW_GROUP_SIZE, SHARD_COMPRESSION, writeShards } from "../parquet.ts"
|
|
28
|
+
import type { CanonicalRow, LabeledRow } from "../types.ts"
|
|
29
|
+
|
|
30
|
+
export interface ShardKryptoniteOptions {
|
|
31
|
+
jsonl: string
|
|
32
|
+
baseManifest: string
|
|
33
|
+
outDir: string
|
|
34
|
+
/** Default `"0.4.0"`. */
|
|
35
|
+
corpusVersion?: string
|
|
36
|
+
/** Default `"deepseek-kryptonite"`. */
|
|
37
|
+
source?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function* canonicalRows(jsonl: string, corpusVersion: string): AsyncIterable<CanonicalRow> {
|
|
41
|
+
for await (const raw of iterateJSONL<Record<string, unknown>>(jsonl)) {
|
|
42
|
+
// Strip sidecar underscore-prefixed fields the generator left behind for debugging.
|
|
43
|
+
const components = raw["components"] as Record<string, string>
|
|
44
|
+
yield {
|
|
45
|
+
raw: raw["raw"] as string,
|
|
46
|
+
components,
|
|
47
|
+
country: (raw["country"] as string) ?? "US",
|
|
48
|
+
locale: (raw["locale"] as string) ?? undefined,
|
|
49
|
+
source: (raw["source"] as string) ?? "deepseek-kryptonite",
|
|
50
|
+
source_id: raw["source_id"] as string,
|
|
51
|
+
corpus_version: corpusVersion,
|
|
52
|
+
license: (raw["license"] as string) ?? "Synthetic (DeepSeek-v4-flash, AGPL-compatible)",
|
|
53
|
+
synth: raw["synth"] as CanonicalRow["synth"],
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function* labeledRows(jsonl: string, corpusVersion: string, quarantineLog: string[]): AsyncIterable<LabeledRow> {
|
|
59
|
+
for await (const row of canonicalRows(jsonl, corpusVersion)) {
|
|
60
|
+
const result = alignRow(row)
|
|
61
|
+
|
|
62
|
+
if (result.kind === "labeled") {
|
|
63
|
+
yield result.row
|
|
64
|
+
} else {
|
|
65
|
+
quarantineLog.push(`${row.source_id}\t${result.row.reason}`)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function buildKryptoniteShard(
|
|
71
|
+
options: ShardKryptoniteOptions,
|
|
72
|
+
report?: (line: string) => void
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
const corpusVersion = options.corpusVersion ?? "0.4.0"
|
|
75
|
+
const source = options.source ?? "deepseek-kryptonite"
|
|
76
|
+
|
|
77
|
+
if (!existsSync(options.jsonl)) throw new Error(`jsonl not found: ${options.jsonl}`)
|
|
78
|
+
|
|
79
|
+
if (!existsSync(options.baseManifest)) throw new Error(`base-manifest not found: ${options.baseManifest}`)
|
|
80
|
+
|
|
81
|
+
await mkdir(options.outDir, { recursive: true })
|
|
82
|
+
|
|
83
|
+
const quarantine: string[] = []
|
|
84
|
+
const newManifest = await writeShards(
|
|
85
|
+
{ train: labeledRows(options.jsonl, corpusVersion, quarantine) },
|
|
86
|
+
{ outputDir: options.outDir, corpusVersion }
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
report?.(
|
|
90
|
+
`wrote ${newManifest.total_rows} rows into ${newManifest.shards.length} shard(s); ` +
|
|
91
|
+
`quarantined ${quarantine.length}`
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if (quarantine.length > 0) {
|
|
95
|
+
const qPath = join(options.outDir, `corpus-v${corpusVersion}`, "quarantine-kryptonite.tsv")
|
|
96
|
+
writeFileSync(qPath, quarantine.join("\n") + "\n", "utf8")
|
|
97
|
+
report?.(`quarantine log → ${qPath}`)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Stamp the new shard's source field for audit.ts (which prefers shard.source over
|
|
101
|
+
// first_source_id-prefix inference). Without this, deepseek-kryptonite IDs would have
|
|
102
|
+
// to match a prefix in KNOWN_SOURCE_PREFIXES — we add it there too as a belt-and-braces.
|
|
103
|
+
for (const sh of newManifest.shards) {
|
|
104
|
+
;(sh as unknown as { source: string }).source = source
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Compose the final corpus-v0.4.0 manifest: every shard from base + the new shard(s).
|
|
108
|
+
const base = JSON.parse(readFileSync(options.baseManifest, "utf8")) as ShardManifest
|
|
109
|
+
const combined: ShardManifest = {
|
|
110
|
+
corpus_version: corpusVersion,
|
|
111
|
+
schema: PARQUET_COLUMNS,
|
|
112
|
+
rows_per_shard: base.rows_per_shard,
|
|
113
|
+
row_group_size: base.row_group_size ?? ROW_GROUP_SIZE,
|
|
114
|
+
shards: [...base.shards, ...newManifest.shards],
|
|
115
|
+
counts: {
|
|
116
|
+
train: base.counts.train + (newManifest.counts.train ?? 0),
|
|
117
|
+
val: base.counts.val,
|
|
118
|
+
test: base.counts.test,
|
|
119
|
+
},
|
|
120
|
+
total_rows: base.total_rows + newManifest.total_rows,
|
|
121
|
+
}
|
|
122
|
+
// Stamp source on the legacy v0.3.0 shards too, so audit's shard.source path is the
|
|
123
|
+
// authoritative one. v0.3.0 shards mix sources; we use the first_source_id-prefix
|
|
124
|
+
// inference for them (audit.ts will re-derive on its own when shard.source is absent).
|
|
125
|
+
const combinedPath = join(options.outDir, `corpus-v${corpusVersion}`, "MANIFEST.json")
|
|
126
|
+
writeFileSync(combinedPath, JSON.stringify(combined, null, 2) + "\n", "utf8")
|
|
127
|
+
report?.(`wrote combined manifest → ${combinedPath}`)
|
|
128
|
+
report?.(` total_rows=${combined.total_rows} (base=${base.total_rows}, added=${newManifest.total_rows})`)
|
|
129
|
+
report?.(` shards=${combined.shards.length} (base=${base.shards.length}, added=${newManifest.shards.length})`)
|
|
130
|
+
report?.(` compression=${SHARD_COMPRESSION}`)
|
|
131
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Build per-script parquet shards from the DeepSeek-generated transliteration JSONL and emit the
|
|
7
|
+
* corpus-v0.4.0 MANIFEST that combines them with the existing kryptonite + v0.3.0 shards.
|
|
8
|
+
*
|
|
9
|
+
* Sibling to `shard-kryptonite.ts`. The two modules share the same composition pattern: take a
|
|
10
|
+
* base MANIFEST, append new shards, write a combined MANIFEST. Differences specific to
|
|
11
|
+
* transliteration:
|
|
12
|
+
*
|
|
13
|
+
* - One JSONL contains rows from N target scripts (source = `deepseek-translit-<slug>`). We bucket by
|
|
14
|
+
* `source` and write one shard per script so `audit.ts` can attribute each shard to its
|
|
15
|
+
* synthetic source without relying on filename-prefix inference.
|
|
16
|
+
* - Each shard is written to `train/part-translit-<slug>.parquet` (distinct from kryptonite's
|
|
17
|
+
* `part-0000.parquet`, which v0.4.0's first builder already produced).
|
|
18
|
+
* - Inherits the path-canonicalization fix flagged in Thread B's postmortem: v0.3.0 shard paths are
|
|
19
|
+
* rewritten from `/mnt/playpen/mailwoman-data/...` to `/data/...` in the combined MANIFEST so
|
|
20
|
+
* all paths share one container-friendly form.
|
|
21
|
+
*
|
|
22
|
+
* See docs/articles/plan/reference/CORPUS_V0_4_0_GENERATION.md for prompts, model, and the
|
|
23
|
+
* reproducibility contract.
|
|
24
|
+
*
|
|
25
|
+
* Invoke via `mailwoman corpus shard translit \
|
|
26
|
+
* --jsonl /data/corpus/versioned/v0.4.0/transliteration/canonical-transliteration.jsonl \
|
|
27
|
+
* --base-manifest /data/corpus/versioned/v0.4.0/corpus-v0.4.0/MANIFEST.json \
|
|
28
|
+
* --out-dir /data/corpus/versioned/v0.4.0`
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
32
|
+
import { mkdir, stat } from "node:fs/promises"
|
|
33
|
+
import { join } from "node:path"
|
|
34
|
+
|
|
35
|
+
import { iterateJSONL, sha256File } from "@mailwoman/core/utils"
|
|
36
|
+
|
|
37
|
+
import { alignRow } from "../align.ts"
|
|
38
|
+
import { ParquetWriter } from "../parquet-wrapper/index.ts"
|
|
39
|
+
import type { ParquetRow, ShardDescriptor, ShardManifest } from "../parquet.ts"
|
|
40
|
+
import { LABELED_ROW_SCHEMA, PARQUET_COLUMNS, ROW_GROUP_SIZE, rowToParquet, SHARD_COMPRESSION } from "../parquet.ts"
|
|
41
|
+
import type { CanonicalRow, LabeledRow } from "../types.ts"
|
|
42
|
+
|
|
43
|
+
export interface ShardTranslitOptions {
|
|
44
|
+
jsonl: string
|
|
45
|
+
baseManifest: string
|
|
46
|
+
outDir: string
|
|
47
|
+
/** Default `"0.4.0"`. */
|
|
48
|
+
corpusVersion?: string
|
|
49
|
+
/** Default `"/data/"`. */
|
|
50
|
+
canonicalPathPrefix?: string
|
|
51
|
+
/** Default `"/mnt/playpen/mailwoman-data/"`. */
|
|
52
|
+
legacyPathPrefix?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function toCanonicalRow(raw: Record<string, unknown>, corpusVersion: string): CanonicalRow {
|
|
56
|
+
return {
|
|
57
|
+
raw: raw["raw"] as string,
|
|
58
|
+
components: raw["components"] as Record<string, string>,
|
|
59
|
+
country: (raw["country"] as string) ?? "US",
|
|
60
|
+
locale: (raw["locale"] as string) ?? undefined,
|
|
61
|
+
source: raw["source"] as string,
|
|
62
|
+
source_id: raw["source_id"] as string,
|
|
63
|
+
corpus_version: corpusVersion,
|
|
64
|
+
license: (raw["license"] as string) ?? "Synthetic (DeepSeek-v4-flash, AGPL-compatible)",
|
|
65
|
+
synth: raw["synth"] as CanonicalRow["synth"],
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function appendShape(row: ParquetRow): Record<string, unknown> {
|
|
70
|
+
const out: Record<string, unknown> = {
|
|
71
|
+
raw: row.raw,
|
|
72
|
+
tokens: row.tokens,
|
|
73
|
+
labels: row.labels,
|
|
74
|
+
country: row.country,
|
|
75
|
+
source: row.source,
|
|
76
|
+
source_id: row.source_id,
|
|
77
|
+
corpus_version: row.corpus_version,
|
|
78
|
+
license: row.license,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (row.locale !== null) {
|
|
82
|
+
out.locale = row.locale
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (row.synth_method !== null) {
|
|
86
|
+
out.synth_method = row.synth_method
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (row.synth_base_id !== null) {
|
|
90
|
+
out.synth_base_id = row.synth_base_id
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Write one shard for a single source slug. Returns the populated ShardDescriptor + a list of quarantine reasons for
|
|
98
|
+
* rows that failed alignment.
|
|
99
|
+
*/
|
|
100
|
+
async function writeOneShard(
|
|
101
|
+
rows: readonly LabeledRow[],
|
|
102
|
+
outPath: string,
|
|
103
|
+
source: string,
|
|
104
|
+
corpusVersion: string
|
|
105
|
+
): Promise<ShardDescriptor> {
|
|
106
|
+
const writer = await ParquetWriter.openFile<ParquetRow>(LABELED_ROW_SCHEMA, outPath, {
|
|
107
|
+
rowGroupSize: ROW_GROUP_SIZE,
|
|
108
|
+
})
|
|
109
|
+
writer.setMetadata("mailwoman.corpus_version", corpusVersion)
|
|
110
|
+
writer.setMetadata("mailwoman.split", "train")
|
|
111
|
+
writer.setMetadata("mailwoman.shard_source", source)
|
|
112
|
+
|
|
113
|
+
let firstSourceID = ""
|
|
114
|
+
let lastSourceID = ""
|
|
115
|
+
|
|
116
|
+
for (const row of rows) {
|
|
117
|
+
const pq = rowToParquet(row)
|
|
118
|
+
await writer.appendRow(appendShape(pq) as unknown as ParquetRow)
|
|
119
|
+
|
|
120
|
+
if (firstSourceID === "") {
|
|
121
|
+
firstSourceID = row.source_id
|
|
122
|
+
}
|
|
123
|
+
lastSourceID = row.source_id
|
|
124
|
+
}
|
|
125
|
+
await writer.close()
|
|
126
|
+
|
|
127
|
+
const fileStat = await stat(outPath)
|
|
128
|
+
const sha256 = await sha256File(outPath)
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
split: "train",
|
|
132
|
+
path: outPath,
|
|
133
|
+
format: "parquet",
|
|
134
|
+
compression: SHARD_COMPRESSION,
|
|
135
|
+
rows: rows.length,
|
|
136
|
+
bytes: fileStat.size,
|
|
137
|
+
sha256,
|
|
138
|
+
first_source_id: firstSourceID,
|
|
139
|
+
last_source_id: lastSourceID,
|
|
140
|
+
// Stamp source so audit.ts attributes the shard without falling back to filename-prefix
|
|
141
|
+
// inference. Cast widens ShardDescriptor; the field is read by audit.ts.
|
|
142
|
+
...({ source } as Record<string, string>),
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function canonicalizeShardPath(path: string, legacyPrefix: string, canonicalPrefix: string): string {
|
|
147
|
+
if (path.startsWith(legacyPrefix)) return canonicalPrefix + path.slice(legacyPrefix.length)
|
|
148
|
+
|
|
149
|
+
return path
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function buildTranslitShard(
|
|
153
|
+
options: ShardTranslitOptions,
|
|
154
|
+
report?: (line: string) => void
|
|
155
|
+
): Promise<void> {
|
|
156
|
+
const corpusVersion = options.corpusVersion ?? "0.4.0"
|
|
157
|
+
const canonicalPathPrefix = options.canonicalPathPrefix ?? "/data/"
|
|
158
|
+
const legacyPathPrefix = options.legacyPathPrefix ?? "/mnt/playpen/mailwoman-data/"
|
|
159
|
+
|
|
160
|
+
if (!existsSync(options.jsonl)) throw new Error(`jsonl not found: ${options.jsonl}`)
|
|
161
|
+
|
|
162
|
+
if (!existsSync(options.baseManifest)) throw new Error(`base-manifest not found: ${options.baseManifest}`)
|
|
163
|
+
|
|
164
|
+
const corpusDir = join(options.outDir, `corpus-v${corpusVersion}`)
|
|
165
|
+
const trainDir = join(corpusDir, "train")
|
|
166
|
+
await mkdir(trainDir, { recursive: true })
|
|
167
|
+
|
|
168
|
+
// Bucket canonical rows by source. Quarantined rows are logged.
|
|
169
|
+
const buckets = new Map<string, LabeledRow[]>()
|
|
170
|
+
const quarantine: string[] = []
|
|
171
|
+
let totalIn = 0
|
|
172
|
+
|
|
173
|
+
for await (const raw of iterateJSONL<Record<string, unknown>>(options.jsonl)) {
|
|
174
|
+
totalIn++
|
|
175
|
+
const canon = toCanonicalRow(raw, corpusVersion)
|
|
176
|
+
const result = alignRow(canon)
|
|
177
|
+
|
|
178
|
+
if (result.kind !== "labeled") {
|
|
179
|
+
quarantine.push(`${canon.source_id}\t${result.row.reason}`)
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
const bucket = buckets.get(canon.source)
|
|
183
|
+
|
|
184
|
+
if (bucket) {
|
|
185
|
+
bucket.push(result.row)
|
|
186
|
+
} else {
|
|
187
|
+
buckets.set(canon.source, [result.row])
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
report?.(`read ${totalIn} rows; ${quarantine.length} quarantined; ${buckets.size} script buckets`)
|
|
191
|
+
|
|
192
|
+
const newShards: ShardDescriptor[] = []
|
|
193
|
+
const sortedKeys = [...buckets.keys()].sort()
|
|
194
|
+
|
|
195
|
+
for (const source of sortedKeys) {
|
|
196
|
+
const rows = buckets.get(source)!
|
|
197
|
+
const slug = source.startsWith("deepseek-translit-") ? source.slice("deepseek-translit-".length) : source
|
|
198
|
+
const outPath = join(trainDir, `part-translit-${slug}.parquet`)
|
|
199
|
+
const descriptor = await writeOneShard(rows, outPath, source, corpusVersion)
|
|
200
|
+
newShards.push(descriptor)
|
|
201
|
+
report?.(` ${source}: ${descriptor.rows} rows → ${outPath} (${descriptor.bytes} bytes)`)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (quarantine.length > 0) {
|
|
205
|
+
const qPath = join(corpusDir, "quarantine-transliteration.tsv")
|
|
206
|
+
writeFileSync(qPath, quarantine.join("\n") + "\n", "utf8")
|
|
207
|
+
report?.(`quarantine log → ${qPath} (${quarantine.length} rows)`)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Compose final MANIFEST: rewrite base.shards paths from /mnt/playpen/... → /data/... and append
|
|
211
|
+
// the new translit shards. Kryptonite shard already lives in the base manifest (it was written
|
|
212
|
+
// there by Thread B).
|
|
213
|
+
const base = JSON.parse(readFileSync(options.baseManifest, "utf8")) as ShardManifest
|
|
214
|
+
const rewrittenBase = base.shards.map((sh) => ({
|
|
215
|
+
...sh,
|
|
216
|
+
path: canonicalizeShardPath(sh.path, legacyPathPrefix, canonicalPathPrefix),
|
|
217
|
+
}))
|
|
218
|
+
const newTrainRows = newShards.reduce((sum, sh) => sum + sh.rows, 0)
|
|
219
|
+
const combined: ShardManifest = {
|
|
220
|
+
corpus_version: corpusVersion,
|
|
221
|
+
schema: PARQUET_COLUMNS,
|
|
222
|
+
rows_per_shard: base.rows_per_shard,
|
|
223
|
+
row_group_size: base.row_group_size ?? ROW_GROUP_SIZE,
|
|
224
|
+
shards: [...rewrittenBase, ...newShards],
|
|
225
|
+
counts: {
|
|
226
|
+
train: base.counts.train + newTrainRows,
|
|
227
|
+
val: base.counts.val,
|
|
228
|
+
test: base.counts.test,
|
|
229
|
+
},
|
|
230
|
+
total_rows: base.total_rows + newTrainRows,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const combinedPath = join(corpusDir, "MANIFEST.json")
|
|
234
|
+
writeFileSync(combinedPath, JSON.stringify(combined, null, 2) + "\n", "utf8")
|
|
235
|
+
report?.(`wrote combined manifest → ${combinedPath}`)
|
|
236
|
+
report?.(` total_rows=${combined.total_rows} (base=${base.total_rows}, added=${newTrainRows})`)
|
|
237
|
+
report?.(` shards=${combined.shards.length} (base=${base.shards.length}, added=${newShards.length})`)
|
|
238
|
+
report?.(` compression=${SHARD_COMPRESSION}`)
|
|
239
|
+
const pathFix = rewrittenBase.filter((s, i) => s.path !== base.shards[i]!.path).length
|
|
240
|
+
|
|
241
|
+
if (pathFix > 0) {
|
|
242
|
+
report?.(` path-canonicalized base shards: ${pathFix} (legacy '${legacyPathPrefix}' → '${canonicalPathPrefix}')`)
|
|
243
|
+
}
|
|
244
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Canonical row schemas for the corpus pipeline (per #6 / Phase 1 plan).
|
|
7
|
+
*
|
|
8
|
+
* The corpus pipeline produces two row shapes:
|
|
9
|
+
*
|
|
10
|
+
* 1. `CanonicalRow`: an adapter's raw output. Carries a free-form `raw` string, a per-component
|
|
11
|
+
* ground-truth dict, provenance, and an optional augmentation marker. Adapters emit these.
|
|
12
|
+
* 2. `LabeledRow`: alignment's output. Adds a SentencePiece token list and a parallel BIO label list,
|
|
13
|
+
* suitable for direct ingestion by the neural training loop.
|
|
14
|
+
*
|
|
15
|
+
* `CorpusAdapter` is the contract every data source implements; `AdapterOptions` is the
|
|
16
|
+
* per-invocation knob set (input path, optional country filter, row cap, abort signal).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { BIOLabel, ComponentTag } from "@mailwoman/core/types"
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Provenance + augmentation metadata that travels with every corpus row.
|
|
23
|
+
*
|
|
24
|
+
* `synth` is `undefined` for natural (un-augmented) rows; present only when a row was produced by the synthesis
|
|
25
|
+
* pipeline (see `synthesize.ts`).
|
|
26
|
+
*/
|
|
27
|
+
export interface SourceProvenance {
|
|
28
|
+
/** Adapter id that emitted this row, e.g. `"wof-admin"`, `"ban"`, `"openaddresses"`. */
|
|
29
|
+
source: string
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Stable id within the adapter's source. For SQLite-backed adapters this is the row's primary key; for CSV/GeoJSON, a
|
|
33
|
+
* hash of the canonical components. Must be stable across reruns so that dedup and holdout manifests are
|
|
34
|
+
* reproducible.
|
|
35
|
+
*/
|
|
36
|
+
source_id: string
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Corpus version string. Stamped by the runner, not the adapter. Locked together with the tokenizer version:
|
|
40
|
+
* `corpus-v0.1.0` ships with `tokenizer-v0.1.0`.
|
|
41
|
+
*/
|
|
42
|
+
corpus_version: string
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Short license label or SPDX id for _this_ row. Defaults to the adapter's `defaultLicense`, but per-row sources
|
|
46
|
+
* (OpenAddresses) override.
|
|
47
|
+
*/
|
|
48
|
+
license: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Marker placed on rows produced by `synthesize.ts`. Allows downstream code to weight, stratify, or exclude
|
|
53
|
+
* augmentations.
|
|
54
|
+
*/
|
|
55
|
+
export interface SynthMarker {
|
|
56
|
+
/**
|
|
57
|
+
* Pipeline id describing what augmentation produced this row. Free-form but stable — e.g. `"case-perturb"`,
|
|
58
|
+
* `"accent-strip"`, `"abbrev-swap"`, `"compose:case-perturb+typo"`.
|
|
59
|
+
*/
|
|
60
|
+
method: string
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `source_id` of the un-augmented row this was derived from. Allows tracing every synthetic row back to its natural
|
|
64
|
+
* ancestor.
|
|
65
|
+
*/
|
|
66
|
+
base_source_id: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One address row, before tokenization + BIO labeling.
|
|
71
|
+
*
|
|
72
|
+
* `raw` is what a parser would see in the wild — possibly multi-line, with arbitrary whitespace. `components` is the
|
|
73
|
+
* ground-truth tagging: every `ComponentTag` present in the source data, mapped to its surface form _as it appears in
|
|
74
|
+
* `raw`_. Alignment uses this to assign BIO labels.
|
|
75
|
+
*
|
|
76
|
+
* Country is ISO 3166-1 alpha-2 (`"US"`, `"FR"`). Locale is BCP-47 (`"en-US"`, `"fr-FR"`) and is optional; adapters
|
|
77
|
+
* that can't be sure leave it empty and let the runner default by country.
|
|
78
|
+
*/
|
|
79
|
+
export interface CanonicalRow extends SourceProvenance {
|
|
80
|
+
/** Address string as it might appear in source data. */
|
|
81
|
+
raw: string
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Component-by-tag ground truth. Surface forms must occur in `raw` (within the alignment edit distance threshold) or
|
|
85
|
+
* the row will land in the quarantine pile.
|
|
86
|
+
*/
|
|
87
|
+
components: Partial<Record<ComponentTag, string>>
|
|
88
|
+
|
|
89
|
+
/** ISO 3166-1 alpha-2 country code. */
|
|
90
|
+
country: string
|
|
91
|
+
|
|
92
|
+
/** Optional BCP-47 locale. Defaulted by country if absent. */
|
|
93
|
+
locale?: string
|
|
94
|
+
|
|
95
|
+
/** Present only on synthetic rows. */
|
|
96
|
+
synth?: SynthMarker
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Output of `align.ts`. Carries everything `CanonicalRow` does, plus parallel `tokens` and `labels` arrays of identical
|
|
101
|
+
* length (`labels[i]` is the BIO tag for `tokens[i]`) and — as of the v0.5.0 char-offset migration (#519) — parallel
|
|
102
|
+
* char-span arrays addressing `raw` directly.
|
|
103
|
+
*
|
|
104
|
+
* The span triple is the v0.5.0 source of truth; `tokens`/`labels` remain emitted during the transition (and stay
|
|
105
|
+
* derivable afterwards: whitespace split + span lookup). The reverse derivation — today's token labels — is the lossy
|
|
106
|
+
* direction (punctuation-mute).
|
|
107
|
+
*/
|
|
108
|
+
export interface LabeledRow extends CanonicalRow {
|
|
109
|
+
/** SentencePiece subword tokens for `raw`. */
|
|
110
|
+
tokens: readonly string[]
|
|
111
|
+
|
|
112
|
+
/** BIO labels, one per token. Same length as `tokens`. */
|
|
113
|
+
labels: readonly BIOLabel[]
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Char-offset label spans over `raw` (parallel arrays, per the #519 ruling): `span_starts[i]` is the inclusive start
|
|
117
|
+
* offset (UTF-16 code units) of span `i`, `span_ends[i]` its exclusive end, `span_tags[i]` its component tag.
|
|
118
|
+
* Invariants — enforced loudly by `alignRow`, documented for every other producer: sorted ascending by start,
|
|
119
|
+
* non-overlapping. `raw` must be NFC-normalized or the offsets are ambiguous (also enforced by `alignRow`).
|
|
120
|
+
*
|
|
121
|
+
* Optional during the v0.4.x → v0.5.0 transition only: alignment always emits the triple; frozen historical corpora
|
|
122
|
+
* and not-yet-migrated synthesis paths may lack it. Required once v0.5.0 lands and the token path is deleted.
|
|
123
|
+
*/
|
|
124
|
+
span_starts?: readonly number[]
|
|
125
|
+
|
|
126
|
+
/** Exclusive end offsets, parallel to `span_starts`. */
|
|
127
|
+
span_ends?: readonly number[]
|
|
128
|
+
|
|
129
|
+
/** Component tags, parallel to `span_starts`. */
|
|
130
|
+
span_tags?: readonly ComponentTag[]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A row that alignment refused to label. Lands in `/data/corpus/quarantine/` for human review.
|
|
135
|
+
*
|
|
136
|
+
* The `reason` is human-readable; common values are `"component-not-found:<tag>"`, `"edit-distance-exceeded:<tag>"`,
|
|
137
|
+
* `"raw-empty"`. Re-running alignment after a fix should re-emit the quarantined rows; the runner keys them by
|
|
138
|
+
* `source_id`.
|
|
139
|
+
*/
|
|
140
|
+
export interface QuarantinedRow {
|
|
141
|
+
row: CanonicalRow
|
|
142
|
+
reason: string
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Per-invocation knobs handed to an adapter by the runner.
|
|
147
|
+
*
|
|
148
|
+
* `inputPath` is interpreted by the adapter — it might be a single file path, a directory of shards, or even an HTTPS
|
|
149
|
+
* URL. Each adapter documents its own expected shape in its README.
|
|
150
|
+
*
|
|
151
|
+
* `country` filters to a single ISO 3166-1 alpha-2 country _at the adapter level_. Adapters that hold multi-country
|
|
152
|
+
* data (OSM PBF, OpenAddresses) MUST honor this; single-country adapters (BAN) may ignore it but should reject
|
|
153
|
+
* mismatches.
|
|
154
|
+
*
|
|
155
|
+
* `limit` is a soft cap on rows emitted; useful for fixture-driven tests and smoke runs.
|
|
156
|
+
*
|
|
157
|
+
* `signal` allows the runner to cancel a long-running scan cleanly.
|
|
158
|
+
*/
|
|
159
|
+
export interface AdapterOptions {
|
|
160
|
+
/** Path to the adapter's input data (file, directory, or URL — adapter-specific). */
|
|
161
|
+
inputPath: string
|
|
162
|
+
|
|
163
|
+
/** Optional output directory, available to adapters that maintain side state (rare). */
|
|
164
|
+
outputDir?: string
|
|
165
|
+
|
|
166
|
+
/** ISO 3166-1 alpha-2 country filter. */
|
|
167
|
+
country?: string
|
|
168
|
+
|
|
169
|
+
/** Soft row cap. Adapters should stop iterating once this is reached. */
|
|
170
|
+
limit?: number
|
|
171
|
+
|
|
172
|
+
/** Cancellation hook. Adapters should respect this on every iteration boundary. */
|
|
173
|
+
signal?: AbortSignal
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The contract every data source implements.
|
|
178
|
+
*
|
|
179
|
+
* Adapters are async generators: they yield `CanonicalRow`s one at a time, the runner consumes them (writing JSONL +
|
|
180
|
+
* maintaining checksums + driving alignment). Streaming is mandatory — many sources are tens of millions of rows and
|
|
181
|
+
* cannot be buffered.
|
|
182
|
+
*
|
|
183
|
+
* `defaultLicense` is stamped onto every emitted row's `license` field unless the adapter sets `license` explicitly
|
|
184
|
+
* (e.g. OpenAddresses, which carries per-source licenses).
|
|
185
|
+
*/
|
|
186
|
+
export interface CorpusAdapter {
|
|
187
|
+
/** Stable, machine-friendly id used in paths and CLI args. E.g. `"wof-admin"`. */
|
|
188
|
+
readonly id: string
|
|
189
|
+
|
|
190
|
+
/** Default SPDX-ish license label for rows from this adapter. Per-row overrides allowed. */
|
|
191
|
+
readonly defaultLicense: string
|
|
192
|
+
|
|
193
|
+
/** One-sentence description shown by `npx mailwoman corpus list`. */
|
|
194
|
+
readonly description: string
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Async iterable of canonical rows.
|
|
198
|
+
*
|
|
199
|
+
* Implementations MUST:
|
|
200
|
+
*
|
|
201
|
+
* - Honor `opts.country` (filter or reject mismatches).
|
|
202
|
+
* - Honor `opts.limit` (stop after N rows).
|
|
203
|
+
* - Respect `opts.signal` on every iteration.
|
|
204
|
+
* - Set `source` to `this.id` on every emitted row.
|
|
205
|
+
* - Set `license` to `this.defaultLicense` unless overriding per-row.
|
|
206
|
+
*
|
|
207
|
+
* Implementations MUST NOT:
|
|
208
|
+
*
|
|
209
|
+
* - Set `corpus_version` (the runner stamps it).
|
|
210
|
+
* - Mutate previously-yielded rows.
|
|
211
|
+
*/
|
|
212
|
+
rows(opts: AdapterOptions): AsyncIterable<CanonicalRow>
|
|
213
|
+
}
|