@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/license.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Corpus licensing — the single source of truth for the training-data license policy (#26).
|
|
7
|
+
*
|
|
8
|
+
* Posture (operator, 2026-06-19): **exclusion is a deliberate act, not a silent default.** The
|
|
9
|
+
* build INCLUDES every row an adapter yields (stamping its `license`); a build that needs a clean
|
|
10
|
+
* license set — e.g. the proprietary `@mailwoman/neural-weights-*` weights, which must not
|
|
11
|
+
* inherit a share-alike obligation — PURPOSELY excludes kinds via `buildCorpus({ excludeLicenses
|
|
12
|
+
* })` (CLI `--exclude-licenses` / `--exclude-share-alike`). Nothing is dropped on a license
|
|
13
|
+
* string unless the operator named it. This avoids the trap of silently dropping allowed data
|
|
14
|
+
* mis-stamped with a conservative license (e.g. BAN, which is dual-licensed Licence Ouverte OR
|
|
15
|
+
* ODbL — we elect Licence Ouverte; a default-deny on the old `ODbL` stamp would have wrongly
|
|
16
|
+
* dropped 48M allowed rows).
|
|
17
|
+
*
|
|
18
|
+
* Tier reference (#26): A = PD/CC0 (allowed); B = CC-BY / Licence Ouverte (allowed WITH attribution
|
|
19
|
+
* — the model card must carry it); C = share-alike (ODbL, CC-BY-SA, CC-SA) — exclude for a
|
|
20
|
+
* proprietary-weights build via `--exclude-share-alike`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Licenses that require share-alike / create a copyleft obligation on derived works (Tier C). The
|
|
25
|
+
* `--exclude-share-alike` convenience expands to this; `allowShareAlike: false` adapters also use it.
|
|
26
|
+
*/
|
|
27
|
+
export const SHARE_ALIKE_PATTERN = /^ODbL|^Open Database License|^CC-BY-SA|^CC-SA/i
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Compile a `--exclude-licenses` spec (comma-separated, e.g. `"ODbL,CC-BY-SA"`) into anchored, case-insensitive prefix
|
|
31
|
+
* patterns. Each entry matches a license string that STARTS with it, so `CC-BY-SA` catches `CC-BY-SA-3.0`,
|
|
32
|
+
* `CC-BY-SA-4.0`, etc. Regex metacharacters are escaped — the spec is a literal license prefix, not a user-supplied
|
|
33
|
+
* regex.
|
|
34
|
+
*/
|
|
35
|
+
export function compileLicenseExcludes(spec: string): RegExp[] {
|
|
36
|
+
return spec
|
|
37
|
+
.split(",")
|
|
38
|
+
.map((s) => s.trim())
|
|
39
|
+
.filter(Boolean)
|
|
40
|
+
.map((s) => new RegExp("^" + s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** True iff `license` matches any of the exclude `patterns` (empty patterns → never excluded). */
|
|
44
|
+
export function licenseExcluded(license: string | undefined, patterns: readonly RegExp[]): boolean {
|
|
45
|
+
const l = license ?? ""
|
|
46
|
+
|
|
47
|
+
return patterns.some((p) => p.test(l))
|
|
48
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Typed wrapper around `@dsnp/parquetjs`'s `ParquetReader` that narrows the row-iterator generic to
|
|
7
|
+
* a user-supplied record type and adds `AsyncDisposable` support so `await using` cleans up the
|
|
8
|
+
* envelope reader without an explicit `close()`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { ParquetReader as BaseParquetReader } from "@dsnp/parquetjs"
|
|
12
|
+
import type { BufferReaderOptions } from "@dsnp/parquetjs/dist/lib/bufferReader.js"
|
|
13
|
+
import { ParquetEnvelopeReader } from "@dsnp/parquetjs/dist/lib/reader.js"
|
|
14
|
+
|
|
15
|
+
import { type ParquetRecordLike, ParquetSchema } from "./schema.ts"
|
|
16
|
+
|
|
17
|
+
/** A typed Parquet reader, wrapping the base Parquet reader. */
|
|
18
|
+
export class ParquetReader<T extends ParquetRecordLike> extends BaseParquetReader implements AsyncDisposable {
|
|
19
|
+
declare schema: ParquetSchema<T>
|
|
20
|
+
|
|
21
|
+
static override async openFile<T extends ParquetRecordLike>(
|
|
22
|
+
filePath: string | URL,
|
|
23
|
+
options?: BufferReaderOptions
|
|
24
|
+
): Promise<ParquetReader<T>> {
|
|
25
|
+
const envelopeReader = await ParquetEnvelopeReader.openFile(filePath.toString(), options)
|
|
26
|
+
|
|
27
|
+
return ParquetReader.openEnvelopeReader<T>(envelopeReader, options)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static override async openBuffer<T extends ParquetRecordLike>(buffer: Buffer, options?: BufferReaderOptions) {
|
|
31
|
+
const envelopeReader = await ParquetEnvelopeReader.openBuffer(buffer, options)
|
|
32
|
+
|
|
33
|
+
return this.openEnvelopeReader<T>(envelopeReader, options)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
static override async openEnvelopeReader<T extends ParquetRecordLike>(
|
|
37
|
+
envelopeReader: ParquetEnvelopeReader,
|
|
38
|
+
opts?: BufferReaderOptions
|
|
39
|
+
) {
|
|
40
|
+
if (opts?.metadata) {
|
|
41
|
+
return new ParquetReader<T>(opts.metadata, envelopeReader, opts)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
await envelopeReader.readHeader()
|
|
46
|
+
|
|
47
|
+
const metadata = await envelopeReader.readFooter()
|
|
48
|
+
|
|
49
|
+
return new ParquetReader<T>(metadata, envelopeReader, opts)
|
|
50
|
+
} catch (err) {
|
|
51
|
+
await envelopeReader.close()
|
|
52
|
+
throw err
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
public override [Symbol.asyncIterator](): AsyncGenerator<T, void, unknown> {
|
|
57
|
+
return super[Symbol.asyncIterator]() as AsyncGenerator<T, void, unknown>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
public async [Symbol.asyncDispose]() {
|
|
61
|
+
return this.close()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public async dispose() {
|
|
65
|
+
return this[Symbol.asyncDispose]()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Typed wrapper around `@dsnp/parquetjs`'s schema definition. Adds:
|
|
7
|
+
*
|
|
8
|
+
* - `ParquetSchema<T>`: a generic class narrowing the base schema's `schema` property to a
|
|
9
|
+
* field-by-field typed dict.
|
|
10
|
+
* - `ParquetSchemaDefinitionCache`: an LRU lookup so hot paths that compute the same schema
|
|
11
|
+
* repeatedly pay the cost once. Implements `Disposable` so `using` works.
|
|
12
|
+
* - `createBloomFilters<T>`: helper that takes a schema and a list of columns and returns the
|
|
13
|
+
* `@dsnp/parquetjs`-shaped bloom-filter spec array.
|
|
14
|
+
*
|
|
15
|
+
* `Symbol.dispose` is sync (the original was async, but `Disposable`'s contract is sync — async
|
|
16
|
+
* cleanup belongs on `AsyncDisposable`).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { ParquetSchema as BaseParquetSchema } from "@dsnp/parquetjs"
|
|
20
|
+
import type { createSBBFParams as BloomFilterCreation } from "@dsnp/parquetjs/dist/lib/bloomFilterIO/bloomFilterWriter.js"
|
|
21
|
+
import type { FieldDefinition } from "@dsnp/parquetjs/dist/lib/declare.js"
|
|
22
|
+
import { LRUCache } from "lru-cache"
|
|
23
|
+
|
|
24
|
+
/** A Parquet record-like object, i.e. a record with string keys and JSON-serializable values. */
|
|
25
|
+
export type ParquetRecordLike = {
|
|
26
|
+
[key: string]: unknown | undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Typed Parquet schema definition. */
|
|
30
|
+
export type ParquetSchemaDefinition<T = ParquetRecordLike> = {
|
|
31
|
+
[field in Extract<keyof T, string>]: FieldDefinition
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Typed Parquet schema. */
|
|
35
|
+
export class ParquetSchema<T> extends BaseParquetSchema {
|
|
36
|
+
declare schema: ParquetSchemaDefinition<T>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Given a Parquet schema and a list of columns, create a list of Bloom filters for those columns. */
|
|
40
|
+
export function createBloomFilters<T>(
|
|
41
|
+
parquetSchemaDef: ParquetSchemaDefinition<T>,
|
|
42
|
+
columns: Extract<keyof T, string>[]
|
|
43
|
+
) {
|
|
44
|
+
const bloomFilters: BloomFilterCreation[] = []
|
|
45
|
+
|
|
46
|
+
for (const column of columns) {
|
|
47
|
+
if (!parquetSchemaDef[column]) {
|
|
48
|
+
throw new Error(`Bloom filter column ${column} not found in Parquet schema`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
bloomFilters.push({ column })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return bloomFilters
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class ParquetSchemaDefinitionCache
|
|
58
|
+
extends LRUCache<ParquetSchemaDefinition<ParquetRecordLike>, ParquetSchema<ParquetRecordLike>>
|
|
59
|
+
implements Disposable
|
|
60
|
+
{
|
|
61
|
+
constructor(max = 1000) {
|
|
62
|
+
super({ max })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
public findOrCreateSchema<T extends ParquetRecordLike>(schemaDef: ParquetSchemaDefinition<T>): ParquetSchema<T> {
|
|
66
|
+
const key = schemaDef as ParquetSchemaDefinition<ParquetRecordLike>
|
|
67
|
+
let schema = this.get(key) as ParquetSchema<T> | undefined
|
|
68
|
+
|
|
69
|
+
if (!schema) {
|
|
70
|
+
schema = new ParquetSchema<T>(schemaDef)
|
|
71
|
+
this.set(key, schema as ParquetSchema<ParquetRecordLike>)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return schema
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
public [Symbol.dispose]() {
|
|
78
|
+
this.clear()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Typed Parquet writer. Two static constructors mirror the base class:
|
|
7
|
+
*
|
|
8
|
+
* - `openStream`: wrap an existing writable stream.
|
|
9
|
+
* - `openFile`: open a path on disk, ensuring the parent directory exists first.
|
|
10
|
+
*
|
|
11
|
+
* Implements `AsyncDisposable` so `await using writer = await ParquetWriter.openFile(...)` flushes
|
|
12
|
+
* and closes cleanly. `close()` internally serializes against any in-flight flush so back-to-back
|
|
13
|
+
* dispose calls don't race.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as fs from "node:fs/promises"
|
|
17
|
+
import * as path from "node:path"
|
|
18
|
+
|
|
19
|
+
import { ParquetWriter as BaseParquetWriter } from "@dsnp/parquetjs"
|
|
20
|
+
import type { WriterOptions } from "@dsnp/parquetjs/dist/lib/declare.js"
|
|
21
|
+
import { osopen, type WriteStreamMinimal } from "@dsnp/parquetjs/dist/lib/util.js"
|
|
22
|
+
import { ParquetEnvelopeWriter } from "@dsnp/parquetjs/dist/lib/writer.js"
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
type ParquetRecordLike,
|
|
26
|
+
ParquetSchema,
|
|
27
|
+
type ParquetSchemaDefinition,
|
|
28
|
+
ParquetSchemaDefinitionCache,
|
|
29
|
+
} from "./schema.ts"
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A typed Parquet writer, wrapping the base Parquet writer.
|
|
33
|
+
*/
|
|
34
|
+
export class ParquetWriter<T extends ParquetRecordLike> extends BaseParquetWriter implements AsyncDisposable {
|
|
35
|
+
declare schema: ParquetSchema<T>
|
|
36
|
+
protected static readonly SchemaDefinitionCache = new ParquetSchemaDefinitionCache()
|
|
37
|
+
#flushing: Promise<void> = Promise.resolve()
|
|
38
|
+
|
|
39
|
+
static override async openStream<T extends ParquetRecordLike>(
|
|
40
|
+
schemaLike: ParquetSchema<T> | ParquetSchemaDefinition<T>,
|
|
41
|
+
outputStream: WriteStreamMinimal,
|
|
42
|
+
opts: WriterOptions = {}
|
|
43
|
+
): Promise<ParquetWriter<T>> {
|
|
44
|
+
const schema =
|
|
45
|
+
schemaLike instanceof ParquetSchema
|
|
46
|
+
? schemaLike
|
|
47
|
+
: ParquetWriter.SchemaDefinitionCache.findOrCreateSchema(schemaLike)
|
|
48
|
+
|
|
49
|
+
const envelopeWriter = await ParquetEnvelopeWriter.openStream(schema, outputStream, opts)
|
|
50
|
+
|
|
51
|
+
return new ParquetWriter<T>(schema, envelopeWriter, opts)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Convenience method to create a new buffered parquet writer that writes to the specified file. */
|
|
55
|
+
static override async openFile<T extends ParquetRecordLike>(
|
|
56
|
+
schemaLike: ParquetSchema<T> | ParquetSchemaDefinition<T>,
|
|
57
|
+
sourcePath: string | Buffer | URL,
|
|
58
|
+
opts?: WriterOptions
|
|
59
|
+
): Promise<ParquetWriter<T>> {
|
|
60
|
+
if (typeof sourcePath === "string") {
|
|
61
|
+
await fs.mkdir(path.dirname(sourcePath), { recursive: true })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const outputStream = await osopen(sourcePath, opts)
|
|
65
|
+
|
|
66
|
+
return ParquetWriter.openStream<T>(schemaLike, outputStream, opts)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// @note This fixes invalid Markdown in the base class JSDoc.
|
|
70
|
+
/** Set a metadata key-value pair on the writer. */
|
|
71
|
+
public override setMetadata(key: string, value: string): void {
|
|
72
|
+
return super.setMetadata(key, value)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Append a row to the buffer. If the buffer is full, the data will be written to disk. */
|
|
76
|
+
public override async appendRow(row: T): Promise<void> {
|
|
77
|
+
return super.appendRow(row)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Flush all buffered data to disk, close the file, and release resources. */
|
|
81
|
+
public override async close(): Promise<void> {
|
|
82
|
+
await this.#flushing
|
|
83
|
+
|
|
84
|
+
if (this.closed) return
|
|
85
|
+
|
|
86
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>()
|
|
87
|
+
|
|
88
|
+
super.close().then(resolve, reject)
|
|
89
|
+
|
|
90
|
+
this.#flushing = promise
|
|
91
|
+
|
|
92
|
+
return this.#flushing
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
public async [Symbol.asyncDispose]() {
|
|
96
|
+
return this.close()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
public async dispose() {
|
|
100
|
+
return this[Symbol.asyncDispose]()
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/parquet.ts
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Final output sharder for the corpus pipeline.
|
|
7
|
+
*
|
|
8
|
+
* Phase 1 (#9) shipped JSONL shards + a Python (PyArrow) converter as the path to binary Parquet —
|
|
9
|
+
* bridging until the JS toolchain caught up. Phase 1.5 (#18 §4) replaced that with a native JS
|
|
10
|
+
* writer. The build pipeline no longer touches Python at all in its hot path; the only remaining
|
|
11
|
+
* Python is the one-shot `train_tokenizer.py` SentencePiece step.
|
|
12
|
+
*
|
|
13
|
+
* Compression: `SNAPPY`. The plan in #18 §4 specified `zstd`, but `@dsnp/parquetjs` 1.7.0 only
|
|
14
|
+
* supports UNCOMPRESSED / GZIP / SNAPPY / BROTLI (see `node_modules/@dsnp/parquetjs/dist/lib/
|
|
15
|
+
* compression.js`). SNAPPY is the standard ML-corpus default (PyArrow's default too) and is the
|
|
16
|
+
* closest substitute on speed; revisit if @dsnp/parquetjs gains zstd support. Documented in
|
|
17
|
+
* `DECISIONS.md`.
|
|
18
|
+
*
|
|
19
|
+
* Layout under `<outputDir>`:
|
|
20
|
+
*
|
|
21
|
+
* ```
|
|
22
|
+
* corpus-v<version>/
|
|
23
|
+
* MANIFEST.json
|
|
24
|
+
* train/
|
|
25
|
+
* part-0000.parquet
|
|
26
|
+
* part-0001.parquet
|
|
27
|
+
* ...
|
|
28
|
+
* val/
|
|
29
|
+
* part-0000.parquet
|
|
30
|
+
* test/
|
|
31
|
+
* part-0000.parquet
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* Each shard caps at `rowsPerShard` (default 1_000_000); within a shard, parquetjs flushes row
|
|
35
|
+
* groups every `ROW_GROUP_SIZE` (50_000) rows per the issue spec. The MANIFEST captures every
|
|
36
|
+
* shard's path, row count, byte size, and SHA-256 (computed by re-reading the shard once after
|
|
37
|
+
* close — cheap relative to writing it).
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { createHash } from "node:crypto"
|
|
41
|
+
import { createReadStream } from "node:fs"
|
|
42
|
+
import { mkdir, stat, writeFile } from "node:fs/promises"
|
|
43
|
+
import { join } from "node:path"
|
|
44
|
+
|
|
45
|
+
import { ParquetWriter, type ParquetSchemaDefinition } from "./parquet-wrapper/index.ts"
|
|
46
|
+
import type { SplitName } from "./split.ts"
|
|
47
|
+
import type { LabeledRow } from "./types.ts"
|
|
48
|
+
|
|
49
|
+
/** Row groups flush at this many rows (parquetjs internal cadence within a shard). */
|
|
50
|
+
export const ROW_GROUP_SIZE = 50_000
|
|
51
|
+
|
|
52
|
+
/** Snappy is the only zstd-equivalent codec available in @dsnp/parquetjs 1.7.0. */
|
|
53
|
+
export const SHARD_COMPRESSION = "SNAPPY" as const
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A single Parquet-style row shape. The `[key: string]: unknown` index signature is required for compatibility with
|
|
57
|
+
* `ParquetRecordLike` in the wrapper — parquetjs accepts any string key on rows.
|
|
58
|
+
*/
|
|
59
|
+
export interface ParquetRow {
|
|
60
|
+
raw: string
|
|
61
|
+
tokens: readonly string[]
|
|
62
|
+
labels: readonly string[]
|
|
63
|
+
span_starts: readonly number[]
|
|
64
|
+
span_ends: readonly number[]
|
|
65
|
+
span_tags: readonly string[]
|
|
66
|
+
country: string
|
|
67
|
+
locale: string | null
|
|
68
|
+
source: string
|
|
69
|
+
source_id: string
|
|
70
|
+
corpus_version: string
|
|
71
|
+
license: string
|
|
72
|
+
synth_method: string | null
|
|
73
|
+
synth_base_id: string | null
|
|
74
|
+
[key: string]: unknown
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Column names emitted into every shard. Matches `ParquetRow`. */
|
|
78
|
+
export const PARQUET_COLUMNS = [
|
|
79
|
+
"raw",
|
|
80
|
+
"tokens",
|
|
81
|
+
"labels",
|
|
82
|
+
"span_starts",
|
|
83
|
+
"span_ends",
|
|
84
|
+
"span_tags",
|
|
85
|
+
"country",
|
|
86
|
+
"locale",
|
|
87
|
+
"source",
|
|
88
|
+
"source_id",
|
|
89
|
+
"corpus_version",
|
|
90
|
+
"license",
|
|
91
|
+
"synth_method",
|
|
92
|
+
"synth_base_id",
|
|
93
|
+
] as const
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parquet schema for `LabeledRow` per #18 §4. Optional fields use `optional: true`; repeated UTF8 columns capture
|
|
97
|
+
* tokens/labels arrays. Compression is per-column SNAPPY.
|
|
98
|
+
*/
|
|
99
|
+
export const LABELED_ROW_SCHEMA: ParquetSchemaDefinition<ParquetRow> = {
|
|
100
|
+
raw: { type: "UTF8", compression: SHARD_COMPRESSION },
|
|
101
|
+
tokens: { type: "UTF8", repeated: true, compression: SHARD_COMPRESSION },
|
|
102
|
+
labels: { type: "UTF8", repeated: true, compression: SHARD_COMPRESSION },
|
|
103
|
+
// v0.5.0 char-offset label spans (#519): parallel arrays over `raw` (UTF-16 code units,
|
|
104
|
+
// [start, end) exclusive-end, sorted, non-overlapping). INT32 — raw is a short address string,
|
|
105
|
+
// and INT32 round-trips as `number` where parquetjs INT64 would surface bigint.
|
|
106
|
+
span_starts: { type: "INT32", repeated: true, compression: SHARD_COMPRESSION },
|
|
107
|
+
span_ends: { type: "INT32", repeated: true, compression: SHARD_COMPRESSION },
|
|
108
|
+
span_tags: { type: "UTF8", repeated: true, compression: SHARD_COMPRESSION },
|
|
109
|
+
country: { type: "UTF8", compression: SHARD_COMPRESSION },
|
|
110
|
+
locale: { type: "UTF8", compression: SHARD_COMPRESSION, optional: true },
|
|
111
|
+
source: { type: "UTF8", compression: SHARD_COMPRESSION },
|
|
112
|
+
source_id: { type: "UTF8", compression: SHARD_COMPRESSION },
|
|
113
|
+
corpus_version: { type: "UTF8", compression: SHARD_COMPRESSION },
|
|
114
|
+
license: { type: "UTF8", compression: SHARD_COMPRESSION },
|
|
115
|
+
synth_method: { type: "UTF8", compression: SHARD_COMPRESSION, optional: true },
|
|
116
|
+
synth_base_id: { type: "UTF8", compression: SHARD_COMPRESSION, optional: true },
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Per-shard metadata captured in `MANIFEST.json`. */
|
|
120
|
+
export interface ShardDescriptor {
|
|
121
|
+
split: SplitName
|
|
122
|
+
path: string
|
|
123
|
+
format: "parquet"
|
|
124
|
+
compression: typeof SHARD_COMPRESSION
|
|
125
|
+
rows: number
|
|
126
|
+
bytes: number
|
|
127
|
+
sha256: string
|
|
128
|
+
first_source_id: string
|
|
129
|
+
last_source_id: string
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface ShardManifest {
|
|
133
|
+
corpus_version: string
|
|
134
|
+
schema: readonly string[]
|
|
135
|
+
rows_per_shard: number
|
|
136
|
+
row_group_size: number
|
|
137
|
+
shards: ShardDescriptor[]
|
|
138
|
+
counts: Record<SplitName, number>
|
|
139
|
+
total_rows: number
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface WriteShardsOptions {
|
|
143
|
+
/** Root output directory; corpus version dir is created beneath. */
|
|
144
|
+
outputDir: string
|
|
145
|
+
|
|
146
|
+
/** Corpus version stamped onto rows + into the output directory name. */
|
|
147
|
+
corpusVersion: string
|
|
148
|
+
|
|
149
|
+
/** Max rows per `.parquet` shard. Default 1_000_000 per the Phase 1 plan. */
|
|
150
|
+
rowsPerShard?: number
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Pre-partitioned labeled-row streams, one per split. Callers (`buildCorpus`) decide each row's split inline at align
|
|
155
|
+
* time via `splitForRow` and route rows to the matching stream, eliminating the prior `Map<source_id, SplitName>` O(n)
|
|
156
|
+
* lookup table.
|
|
157
|
+
*
|
|
158
|
+
* Splits with no rows can be omitted (or passed as an empty iterable); `writeShards` skips them.
|
|
159
|
+
*/
|
|
160
|
+
export type PerSplitRows = Partial<Record<SplitName, AsyncIterable<LabeledRow>>>
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Project a labeled row to the Parquet schema.
|
|
164
|
+
*
|
|
165
|
+
* The span triple is REQUIRED here (#519): `alignRow` emits it on every labeled row, so a row arriving without it came
|
|
166
|
+
* from a producer that hasn't migrated — writing it would silently drop the v0.5.0 labels from the shard (the "builders
|
|
167
|
+
* before parquet = silent loss" hazard). Loud failure, naming the row, instead.
|
|
168
|
+
*/
|
|
169
|
+
export function rowToParquet(row: LabeledRow): ParquetRow {
|
|
170
|
+
const { span_starts, span_ends, span_tags } = row
|
|
171
|
+
|
|
172
|
+
if (span_starts === undefined || span_ends === undefined || span_tags === undefined) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`rowToParquet: row is missing the char-offset span triple (#519) — ` +
|
|
175
|
+
`span_starts=${span_starts !== undefined} span_ends=${span_ends !== undefined} span_tags=${span_tags !== undefined} ` +
|
|
176
|
+
`(source=${row.source}, source_id=${row.source_id}). ` +
|
|
177
|
+
`Every parquet-bound row must carry span_starts/span_ends/span_tags; ` +
|
|
178
|
+
`producers that emit tokens/labels only have not migrated to the v0.5.0 format.`
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (span_starts.length !== span_ends.length || span_starts.length !== span_tags.length) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`rowToParquet: span triple arrays are not parallel — ` +
|
|
185
|
+
`starts=${span_starts.length} ends=${span_ends.length} tags=${span_tags.length} ` +
|
|
186
|
+
`(source=${row.source}, source_id=${row.source_id})`
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
raw: row.raw,
|
|
192
|
+
tokens: row.tokens,
|
|
193
|
+
labels: row.labels,
|
|
194
|
+
span_starts,
|
|
195
|
+
span_ends,
|
|
196
|
+
span_tags,
|
|
197
|
+
country: row.country,
|
|
198
|
+
locale: row.locale ?? null,
|
|
199
|
+
source: row.source,
|
|
200
|
+
source_id: row.source_id,
|
|
201
|
+
corpus_version: row.corpus_version,
|
|
202
|
+
license: row.license,
|
|
203
|
+
synth_method: row.synth?.method ?? null,
|
|
204
|
+
synth_base_id: row.synth?.base_source_id ?? null,
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Project a `ParquetRow` for `appendRow`. parquetjs treats `null` as "skip" for `optional` columns; passing it
|
|
210
|
+
* explicitly is fine, but cleaner to omit so the on-disk Definition Levels match what PyArrow / DuckDB / etc. produce
|
|
211
|
+
* for the same logical row.
|
|
212
|
+
*/
|
|
213
|
+
function appendShape(row: ParquetRow): Record<string, unknown> {
|
|
214
|
+
const out: Record<string, unknown> = {
|
|
215
|
+
raw: row.raw,
|
|
216
|
+
tokens: row.tokens,
|
|
217
|
+
labels: row.labels,
|
|
218
|
+
span_starts: row.span_starts,
|
|
219
|
+
span_ends: row.span_ends,
|
|
220
|
+
span_tags: row.span_tags,
|
|
221
|
+
country: row.country,
|
|
222
|
+
source: row.source,
|
|
223
|
+
source_id: row.source_id,
|
|
224
|
+
corpus_version: row.corpus_version,
|
|
225
|
+
license: row.license,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (row.locale !== null) {
|
|
229
|
+
out.locale = row.locale
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (row.synth_method !== null) {
|
|
233
|
+
out.synth_method = row.synth_method
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (row.synth_base_id !== null) {
|
|
237
|
+
out.synth_base_id = row.synth_base_id
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return out
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Stream labeled rows into `.parquet` shards, one set of shards per split. Splits are processed sequentially so that
|
|
245
|
+
* only one shard writer is open at a time — memory cost is bounded by the parquetjs row-group buffer (~`ROW_GROUP_SIZE
|
|
246
|
+
* × row_size`), not by the labeled-row count.
|
|
247
|
+
*
|
|
248
|
+
* Callers pass per-split `AsyncIterable<LabeledRow>` (`PerSplitRows`); the prior `splitFor(sourceID)` callback is gone
|
|
249
|
+
* because pre-partitioning at the caller eliminates the O(n) `Map<source_id, SplitName>` it required. See `buildCorpus`
|
|
250
|
+
* for the new wire-up.
|
|
251
|
+
*/
|
|
252
|
+
export async function writeShards(perSplit: PerSplitRows, opts: WriteShardsOptions): Promise<ShardManifest> {
|
|
253
|
+
const rowsPerShard = opts.rowsPerShard ?? 1_000_000
|
|
254
|
+
const corpusDir = join(opts.outputDir, `corpus-v${opts.corpusVersion}`)
|
|
255
|
+
await mkdir(corpusDir, { recursive: true })
|
|
256
|
+
|
|
257
|
+
const shards: ShardDescriptor[] = []
|
|
258
|
+
const counts: Record<SplitName, number> = { train: 0, val: 0, test: 0 }
|
|
259
|
+
let totalRows = 0
|
|
260
|
+
|
|
261
|
+
for (const split of ["train", "val", "test"] as const) {
|
|
262
|
+
const rows = perSplit[split]
|
|
263
|
+
|
|
264
|
+
if (!rows) continue
|
|
265
|
+
|
|
266
|
+
let shardIndex = 0
|
|
267
|
+
let writer: ParquetWriter<ParquetRow> | null = null
|
|
268
|
+
let path = ""
|
|
269
|
+
let shardRows = 0
|
|
270
|
+
let firstSourceID = ""
|
|
271
|
+
let lastSourceID = ""
|
|
272
|
+
|
|
273
|
+
const openShard = async (): Promise<void> => {
|
|
274
|
+
const splitDir = join(corpusDir, split)
|
|
275
|
+
await mkdir(splitDir, { recursive: true })
|
|
276
|
+
path = join(splitDir, `part-${String(shardIndex).padStart(4, "0")}.parquet`)
|
|
277
|
+
writer = await ParquetWriter.openFile<ParquetRow>(LABELED_ROW_SCHEMA, path, {
|
|
278
|
+
rowGroupSize: ROW_GROUP_SIZE,
|
|
279
|
+
})
|
|
280
|
+
writer.setMetadata("mailwoman.corpus_version", opts.corpusVersion)
|
|
281
|
+
writer.setMetadata("mailwoman.split", split)
|
|
282
|
+
writer.setMetadata("mailwoman.shard_index", String(shardIndex))
|
|
283
|
+
shardRows = 0
|
|
284
|
+
firstSourceID = ""
|
|
285
|
+
lastSourceID = ""
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const closeShard = async (): Promise<void> => {
|
|
289
|
+
if (!writer) return
|
|
290
|
+
await writer.close()
|
|
291
|
+
|
|
292
|
+
if (shardRows > 0) {
|
|
293
|
+
const fileStat = await stat(path)
|
|
294
|
+
const sha256 = await hashFile(path)
|
|
295
|
+
shards.push({
|
|
296
|
+
split,
|
|
297
|
+
path,
|
|
298
|
+
format: "parquet",
|
|
299
|
+
compression: SHARD_COMPRESSION,
|
|
300
|
+
rows: shardRows,
|
|
301
|
+
bytes: fileStat.size,
|
|
302
|
+
sha256,
|
|
303
|
+
first_source_id: firstSourceID,
|
|
304
|
+
last_source_id: lastSourceID,
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
writer = null
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
for await (const row of rows) {
|
|
311
|
+
if (!writer) {
|
|
312
|
+
await openShard()
|
|
313
|
+
}
|
|
314
|
+
const pq = rowToParquet(row)
|
|
315
|
+
await writer!.appendRow(appendShape(pq) as unknown as ParquetRow)
|
|
316
|
+
|
|
317
|
+
if (shardRows === 0) {
|
|
318
|
+
firstSourceID = row.source_id
|
|
319
|
+
}
|
|
320
|
+
lastSourceID = row.source_id
|
|
321
|
+
shardRows++
|
|
322
|
+
counts[split]++
|
|
323
|
+
totalRows++
|
|
324
|
+
|
|
325
|
+
if (shardRows >= rowsPerShard) {
|
|
326
|
+
await closeShard()
|
|
327
|
+
shardIndex++
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
await closeShard()
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
shards.sort((a, b) => (a.split === b.split ? a.path.localeCompare(b.path) : a.split.localeCompare(b.split)))
|
|
335
|
+
|
|
336
|
+
const manifest: ShardManifest = {
|
|
337
|
+
corpus_version: opts.corpusVersion,
|
|
338
|
+
schema: PARQUET_COLUMNS,
|
|
339
|
+
rows_per_shard: rowsPerShard,
|
|
340
|
+
row_group_size: ROW_GROUP_SIZE,
|
|
341
|
+
shards,
|
|
342
|
+
counts,
|
|
343
|
+
total_rows: totalRows,
|
|
344
|
+
}
|
|
345
|
+
await writeFile(join(corpusDir, "MANIFEST.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8")
|
|
346
|
+
|
|
347
|
+
return manifest
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Single-pass SHA-256 over the file at `path`. Cheap relative to Parquet write throughput. */
|
|
351
|
+
async function hashFile(path: string): Promise<string> {
|
|
352
|
+
const hash = createHash("sha256")
|
|
353
|
+
const stream = createReadStream(path)
|
|
354
|
+
|
|
355
|
+
for await (const chunk of stream) {
|
|
356
|
+
hash.update(chunk as Buffer)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return hash.digest("hex")
|
|
360
|
+
}
|