/** * @copyright Sister Software * @license AGPL-3.0 * @author Teffen Ellis, et al. * * #511 base-consistency lint, GENERALIZED + COUNTRY-SCOPED (v2) — any synthetic slice vs the base. * * Ported from scripts/lint-slice-vocab.py (pyarrow → @duckdb/node-api); behavior preserved * byte-for-byte (same flags, same stdout, same verdicts). The base-root default routes through * `dataRootPath` so the lab `/mnt/playpen` literal stays in its one home * (core/utils/data-root.ts) and `$MAILWOMAN_DATA_ROOT` is honored; with the env unset it equals * the Python default. * * The #511 lesson: a synthetic slice must not label a token a tag the BASE dominantly labels * something else, or training gets conflicting gradients on the same token and the minority (the * slice) loses. This reads a slice's own (token -> tag) and checks each token against the base. * * WHY v2 IS COUNTRY-SCOPED + FULL-COUNT (the night-2026-06-18 lesson, learned the hard way over * three tries): a token's correct tag is COUNTRY-specific — "Paris" is locality in FR data and * street in US "Paris Ave"; "Marion" is a US town AND many US "Marion" streets. So: * * 1. A cross-COUNTRY aggregate mis-judges any country-specific token (v1 uniform AND a proportional * retry both false-flagged FR cities as "street" from US street-contexts). * 2. A SMALL sample is street-BIASED regardless, because the street sources (tiger 39 + nad 378 parts) * dwarf the locality sources (a small US-scoped spot-check read Indianapolis 54% street vs * its true 219700:29 LOCALITY). The fix: tally each slice token's base tag SCOPED to the * country the slice uses it in (the base has a `country` column), over a LARGE/FULL scan * (`fraction`, default 1.0). Pure-numeric tokens excluded (house_number/postcode are * context-determined). An affix-split flag (slice street_suffix/_prefix vs base "street") is * EXPECTED — the loader's affix-relabel handles it; weigh those separately. * * Usage: mailwoman dev lint slice-vocab --slice * [--base-version v0.5.0] [--base-root ] [--fraction 1.0] [--threshold 0.7] [--min-count * 50] */ import { dataRootPath } from "@mailwoman/core/data-root" import { tryParsingJSON } from "@mailwoman/core/json" import { pyRound } from "@mailwoman/core/numeric" import { join } from "path-ts" import { Globerator } from "spliterator/node/fs" import { connectDuckDB, type DuckDBConnection } from "#utils/parquet" /** * A column-projected base/slice row: parallel token + label lists plus the row's country. */ interface CorpusRow { tokens: string[] labels: string[] country: string | null } /** * Strip a BIO prefix ("B-"/"I-") off a label, matching the Python `strip_bio`. */ function stripBIO(label: string): string { const head = label.slice(0, 2) return head === "B-" || head === "I-" ? label.slice(2) : label } /** * Python `str.isdigit()`: non-empty and every character a Unicode digit. Pure-numeric tokens (house_number / postcode) * are context-determined, not lexical vocab, so they're excluded. `\p{Nd}` covers the decimal digits these address * corpora actually contain. */ function isDigit(token: string): boolean { return token.length > 0 && /^\p{Nd}+$/u.test(token) } /** * Format a fraction as a whole-percent string the way Python's `:.0%` does, e.g. 0.73 -> "73%". */ function pct(frac: number): string { return `${pyRound(frac * 100)}%` } /** * Format a float the way a Python f-string renders it: integer-valued floats keep one decimal (1.0 -> "1.0"), * everything else is its shortest decimal (0.5 -> "0.5"). Used for the `fraction` echo so the banner matches the Python * print. */ function formatPyFloat(n: number): string { return Number.isInteger(n) ? n.toFixed(1) : String(n) } /** * Left-justify to a minimum width with spaces, matching Python's `{value:N}` string field. */ function pad(value: string, width: number): string { return value.padEnd(width) } /** * The dominant tag of a counter: (tag, total, fraction). Empty counter -> ("", 0, 0.0). Ties go to the first-inserted * tag, mirroring `Counter.most_common(1)` (stable on equal counts). */ function dominant(counter: Map): [string, number, number] { let total = 0 let bestTag = "" let bestCount = -1 for (const [tag, count] of counter) { total += count if (count > bestCount) { bestCount = count bestTag = tag } } if (total === 0) return ["", 0, 0] return [bestTag, total, bestCount / total] } /** * Bump a (key -> count) tally, creating the inner counter on first sight. */ function bump(table: Map>, key: string, sub: string): void { let counter = table.get(key) if (!counter) { counter = new Map() table.set(key, counter) } counter.set(sub, (counter.get(sub) ?? 0) + 1) } /** * Read a corpus parquet into rows, projecting only tokens/labels/country. The list columns ride out as JSON text * (DuckDB `to_json`) — the same trick the gazetteer builders use for nested columns — and parse back to string arrays * here. */ async function readRows(con: DuckDBConnection, path: string): Promise { const result = await con.runAndReadAll( `SELECT to_json(tokens) AS tokens, to_json(labels) AS labels, country FROM read_parquet('${path}')` ) const raw = result.getRowObjects() as Array<{ tokens: unknown; labels: unknown; country: unknown }> const rows: CorpusRow[] = [] for (const r of raw) { const tokens = tryParsingJSON(String(r.tokens)) const labels = tryParsingJSON(String(r.labels)) if (!Array.isArray(tokens) || !Array.isArray(labels)) continue rows.push({ tokens: tokens as string[], labels: labels as string[], country: r.country == null ? null : String(r.country), }) } return rows } /** * Read just the first row's `source` value — used to group base parts for a proportional slice. */ async function readSource(con: DuckDBConnection, path: string): Promise { const result = await con.runAndReadAll(`SELECT source FROM read_parquet('${path}') LIMIT 1`) const rows = result.getRowObjects() as Array<{ source: unknown }> return rows.length ? String(rows[0]!.source) : "" } /** * Options for {@linkcode lintSliceVocab}. */ export interface LintSliceVocabOptions { /** * The slice parquet to lint. */ slice: string /** * Base corpus version. Default `v0.5.0`. */ baseVersion?: string /** * Base corpus root. Default `$MAILWOMAN_DATA_ROOT/corpus/versioned`. */ baseRoot?: string /** * Base-majority confidence floor for a contradiction. Default 0.7. */ threshold?: number /** * Minimum base support to judge a token. Default 50. */ minCount?: number /** * Fraction of base parts to scan (proportional per-source slice below 1.0). Default 1.0. */ fraction?: number } /** * One contradiction row: token, slice tag, base tag, base fraction, base total. */ export type SliceVocabRow = [token: string, sliceTag: string, baseTag: string, baseFrac: number, baseTotal: number] /** * Findings summary returned by {@linkcode lintSliceVocab}. */ export interface LintSliceVocabSummary { /** * Real contradictions — the command exits 1 when nonzero. */ errors: number /** * Affix-split rows (EXPECTED — the loader's affix-relabel handles them). */ warnings: number findings: { contradictions: SliceVocabRow[]; affixSplits: SliceVocabRow[] } } /** * Lint a synthetic slice's (token → tag) vocabulary against the base corpus, country-scoped. */ export async function lintSliceVocab(options: LintSliceVocabOptions): Promise { const baseVersion = options.baseVersion ?? "v0.5.0" const baseRoot = options.baseRoot ?? dataRootPath("corpus", "versioned") const threshold = options.threshold ?? 0.7 const minCount = options.minCount ?? 50 const fraction = options.fraction ?? 1 const con = await connectDuckDB() // 1. the slice's own (token -> dominant tag) + the COUNTRIES it uses each token in const sliceRows = await readRows(con, options.slice) const sliceTags = new Map>() const sliceCountries = new Map>() for (const { tokens, labels, country } of sliceRows) { const n = Math.min(tokens.length, labels.length) for (let i = 0; i < n; i++) { const w = tokens[i]! const l = labels[i]! if (isDigit(w)) continue // numbers are context-determined (house_number/postcode), not lexical vocab bump(sliceTags, w, stripBIO(l)) let set = sliceCountries.get(w) if (!set) { set = new Set() sliceCountries.set(w, set) } set.add(country) } } const sliceVocab = new Set(sliceTags.keys()) console.log(`slice: ${sliceRows.length} rows, ${sliceVocab.size} unique tokens`) // 2. base parts — FULL by default; fraction<1 takes a proportional per-source slice (still big) const trainDir = join(baseRoot, baseVersion, `corpus-${baseVersion}`, "train") let parts = ( await Globerator.from("*.parquet", { cwd: trainDir, absolute: true, }).toArray() ).toSorted() if (!parts.length) { throw new Error("no base parts found") } if (fraction < 1) { const bysrc = new Map() for (const p of parts) { const src = await readSource(con, p) let list = bysrc.get(src) if (!list) { list = [] bysrc.set(src, list) } list.push(p) } const sliced: string[] = [] for (const ps of bysrc.values()) { const take = Math.max(2, pyRound(ps.length * fraction)) for (const p of ps.slice(0, take)) { sliced.push(p) } } parts = sliced } console.log( `base ${baseVersion}: scanning ${parts.length} parts (fraction=${formatPyFloat(fraction)}), COUNTRY-scoped` ) // 3. tally each slice token's base tag, SCOPED to the country the slice uses it in const baseTags = new Map>() for (let i = 0; i < parts.length; i++) { const rows = await readRows(con, parts[i]!) for (const { tokens, labels, country } of rows) { const n = Math.min(tokens.length, labels.length) for (let j = 0; j < n; j++) { const w = tokens[j]! if (sliceVocab.has(w) && sliceCountries.get(w)!.has(country)) { bump(baseTags, w, stripBIO(labels[j]!)) } } } if ((i + 1) % 100 === 0) { console.log(` ...${i + 1}/${parts.length} parts`) } } // 4. compare; flag contradictions (affix-split is expected — surfaced but tagged) const flagged: SliceVocabRow[] = [] const affix: SliceVocabRow[] = [] for (const w of sliceVocab) { const [sTag] = dominant(sliceTags.get(w)!) const [bTag, bTotal, bFrac] = dominant(baseTags.get(w) ?? new Map()) if (bTotal < minCount || !bTag || bTag === sTag || bFrac < threshold) continue const row: SliceVocabRow = [w, sTag, bTag, bFrac, bTotal] if ((sTag === "street_suffix" || sTag === "street_prefix") && bTag === "street") { affix.push(row) } else { flagged.push(row) } } const sections: Array<[string, SliceVocabRow[]]> = [ ["CONTRADICTION", flagged], ["affix-split (EXPECTED — affix-relabel handles)", affix], ] for (const [label, rows] of sections) { if (!rows.length) continue rows.sort((a, b) => b[4] - a[4] || b[3] - a[3]) console.log(`\n${label.startsWith("CONTRA") ? "⚠️ " : "· "}${rows.length} ${label}:`) for (const [w, sTag, bTag, bFrac, bTotal] of rows) { console.log(` ${pad(w, 18)} slice=${pad(sTag, 14)} base=${bTag} (${pct(bFrac)}, n=${bTotal})`) } } if (!flagged.length) { console.log( `\n✅ NO real contradictions (country-scoped, threshold ${pct(threshold)}, support ${minCount}) — slice base-consistent` ) } return { errors: flagged.length, warnings: affix.length, findings: { contradictions: flagged, affixSplits: affix }, } }