@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,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* #511 base-consistency lint, GENERALIZED + COUNTRY-SCOPED (v2) — any synthetic shard vs the base.
|
|
7
|
+
*
|
|
8
|
+
* Ported from scripts/lint-shard-vocab.py (pyarrow → @duckdb/node-api); behavior preserved
|
|
9
|
+
* byte-for-byte (same flags, same stdout, same verdicts). The base-root default routes through
|
|
10
|
+
* `dataRootPath` so the lab `/mnt/playpen` literal stays in its one home
|
|
11
|
+
* (core/utils/data-root.ts) and `$MAILWOMAN_DATA_ROOT` is honored; with the env unset it equals
|
|
12
|
+
* the Python default.
|
|
13
|
+
*
|
|
14
|
+
* The #511 lesson: a synthetic shard must not label a token a tag the BASE dominantly labels
|
|
15
|
+
* something else, or training gets conflicting gradients on the same token and the minority (the
|
|
16
|
+
* shard) loses. This reads a shard's own (token -> tag) and checks each token against the base.
|
|
17
|
+
*
|
|
18
|
+
* WHY v2 IS COUNTRY-SCOPED + FULL-COUNT (the night-2026-06-18 lesson, learned the hard way over
|
|
19
|
+
* three tries): a token's correct tag is COUNTRY-specific — "Paris" is locality in FR data and
|
|
20
|
+
* street in US "Paris Ave"; "Marion" is a US town AND many US "Marion" streets. So:
|
|
21
|
+
*
|
|
22
|
+
* 1. A cross-COUNTRY aggregate mis-judges any country-specific token (v1 uniform AND a proportional
|
|
23
|
+
* retry both false-flagged FR cities as "street" from US street-contexts).
|
|
24
|
+
* 2. A SMALL sample is street-BIASED regardless, because the street sources (tiger 39 + nad 378 parts)
|
|
25
|
+
* dwarf the locality sources (a small US-scoped spot-check read Indianapolis 54% street vs
|
|
26
|
+
* its true 219700:29 LOCALITY). The fix: tally each shard token's base tag SCOPED to the
|
|
27
|
+
* country the shard uses it in (the base has a `country` column), over a LARGE/FULL scan
|
|
28
|
+
* (`fraction`, default 1.0). Pure-numeric tokens excluded (house_number/postcode are
|
|
29
|
+
* context-determined). An affix-split flag (shard street_suffix/_prefix vs base "street") is
|
|
30
|
+
* EXPECTED — the loader's affix-relabel handles it; weigh those separately.
|
|
31
|
+
*
|
|
32
|
+
* Usage: mailwoman dev lint shard-vocab --shard <shard.parquet>
|
|
33
|
+
* [--base-version v0.5.0] [--base-root <dir>] [--fraction 1.0] [--threshold 0.7] [--min-count
|
|
34
|
+
* 50]
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { readdirSync } from "node:fs"
|
|
38
|
+
import { join } from "node:path"
|
|
39
|
+
|
|
40
|
+
import { dataRootPath } from "@mailwoman/core/utils"
|
|
41
|
+
|
|
42
|
+
/** A column-projected base/shard row: parallel token + label lists plus the row's country. */
|
|
43
|
+
interface CorpusRow {
|
|
44
|
+
tokens: string[]
|
|
45
|
+
labels: string[]
|
|
46
|
+
country: string | null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Strip a BIO prefix ("B-"/"I-") off a label, matching the Python `strip_bio`. */
|
|
50
|
+
function stripBIO(label: string): string {
|
|
51
|
+
const head = label.slice(0, 2)
|
|
52
|
+
|
|
53
|
+
return head === "B-" || head === "I-" ? label.slice(2) : label
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Python `str.isdigit()`: non-empty and every character a Unicode digit. Pure-numeric tokens (house_number / postcode)
|
|
58
|
+
* are context-determined, not lexical vocab, so they're excluded. `\p{Nd}` covers the decimal digits these address
|
|
59
|
+
* corpora actually contain.
|
|
60
|
+
*/
|
|
61
|
+
function isDigit(token: string): boolean {
|
|
62
|
+
return token.length > 0 && /^\p{Nd}+$/u.test(token)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Round half to even (banker's rounding) — Python's built-in `round()` and `format(..., ".0%")` both use it, so percent
|
|
67
|
+
* strings and the proportional `fraction` slice match the Python output exactly.
|
|
68
|
+
*/
|
|
69
|
+
function pyRound(x: number): number {
|
|
70
|
+
const floor = Math.floor(x)
|
|
71
|
+
const diff = x - floor
|
|
72
|
+
|
|
73
|
+
if (diff < 0.5) return floor
|
|
74
|
+
|
|
75
|
+
if (diff > 0.5) return floor + 1
|
|
76
|
+
|
|
77
|
+
return floor % 2 === 0 ? floor : floor + 1
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Format a fraction as a whole-percent string the way Python's `:.0%` does, e.g. 0.73 -> "73%". */
|
|
81
|
+
function pct(frac: number): string {
|
|
82
|
+
return `${pyRound(frac * 100)}%`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Format a float the way a Python f-string renders it: integer-valued floats keep one decimal (1.0 -> "1.0"),
|
|
87
|
+
* everything else is its shortest decimal (0.5 -> "0.5"). Used for the `fraction` echo so the banner matches the Python
|
|
88
|
+
* print.
|
|
89
|
+
*/
|
|
90
|
+
function pyFloat(n: number): string {
|
|
91
|
+
return Number.isInteger(n) ? n.toFixed(1) : String(n)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Left-justify to a minimum width with spaces, matching Python's `{value:N}` string field. */
|
|
95
|
+
function pad(value: string, width: number): string {
|
|
96
|
+
return value.padEnd(width)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The dominant tag of a counter: (tag, total, fraction). Empty counter -> ("", 0, 0.0). Ties go to the first-inserted
|
|
101
|
+
* tag, mirroring `Counter.most_common(1)` (stable on equal counts).
|
|
102
|
+
*/
|
|
103
|
+
function dominant(counter: Map<string, number>): [string, number, number] {
|
|
104
|
+
let total = 0
|
|
105
|
+
let bestTag = ""
|
|
106
|
+
let bestCount = -1
|
|
107
|
+
|
|
108
|
+
for (const [tag, count] of counter) {
|
|
109
|
+
total += count
|
|
110
|
+
|
|
111
|
+
if (count > bestCount) {
|
|
112
|
+
bestCount = count
|
|
113
|
+
bestTag = tag
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (total === 0) return ["", 0, 0.0]
|
|
118
|
+
|
|
119
|
+
return [bestTag, total, bestCount / total]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Bump a (key -> count) tally, creating the inner counter on first sight. */
|
|
123
|
+
function bump(table: Map<string, Map<string, number>>, key: string, sub: string): void {
|
|
124
|
+
let counter = table.get(key)
|
|
125
|
+
|
|
126
|
+
if (!counter) {
|
|
127
|
+
counter = new Map()
|
|
128
|
+
table.set(key, counter)
|
|
129
|
+
}
|
|
130
|
+
counter.set(sub, (counter.get(sub) ?? 0) + 1)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The DuckDB connection type, without a static dependency on the optional-peer package. */
|
|
134
|
+
type DuckDBConnection = Awaited<
|
|
135
|
+
ReturnType<Awaited<ReturnType<(typeof import("@duckdb/node-api"))["DuckDBInstance"]["create"]>>["connect"]>
|
|
136
|
+
>
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Read a corpus parquet into rows, projecting only tokens/labels/country. The list columns ride out as JSON text
|
|
140
|
+
* (DuckDB `to_json`) — the same trick the gazetteer builders use for nested columns — and parse back to string arrays
|
|
141
|
+
* here.
|
|
142
|
+
*/
|
|
143
|
+
async function readRows(con: DuckDBConnection, path: string): Promise<CorpusRow[]> {
|
|
144
|
+
const result = await con.runAndReadAll(
|
|
145
|
+
`SELECT to_json(tokens) AS tokens, to_json(labels) AS labels, country FROM read_parquet('${path}')`
|
|
146
|
+
)
|
|
147
|
+
const raw = result.getRowObjects() as Array<{ tokens: unknown; labels: unknown; country: unknown }>
|
|
148
|
+
const rows: CorpusRow[] = []
|
|
149
|
+
|
|
150
|
+
for (const r of raw) {
|
|
151
|
+
const tokens = JSON.parse(String(r.tokens)) as unknown
|
|
152
|
+
const labels = JSON.parse(String(r.labels)) as unknown
|
|
153
|
+
|
|
154
|
+
if (!Array.isArray(tokens) || !Array.isArray(labels)) continue
|
|
155
|
+
rows.push({
|
|
156
|
+
tokens: tokens as string[],
|
|
157
|
+
labels: labels as string[],
|
|
158
|
+
country: r.country == null ? null : String(r.country),
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return rows
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Read just the first row's `source` value — used to group base parts for a proportional slice. */
|
|
166
|
+
async function readSource(con: DuckDBConnection, path: string): Promise<string> {
|
|
167
|
+
const result = await con.runAndReadAll(`SELECT source FROM read_parquet('${path}') LIMIT 1`)
|
|
168
|
+
const rows = result.getRowObjects() as Array<{ source: unknown }>
|
|
169
|
+
|
|
170
|
+
return rows.length ? String(rows[0]!.source) : ""
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Non-recursive `*.parquet` glob, sorted lexicographically — the Python `sorted(glob.glob(...))`. */
|
|
174
|
+
function globParquet(dir: string): string[] {
|
|
175
|
+
let names: string[]
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
names = readdirSync(dir)
|
|
179
|
+
} catch {
|
|
180
|
+
return []
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return names
|
|
184
|
+
.filter((f) => f.endsWith(".parquet"))
|
|
185
|
+
.map((f) => join(dir, f))
|
|
186
|
+
.sort()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Options for {@linkcode lintShardVocab}. */
|
|
190
|
+
export interface LintShardVocabOptions {
|
|
191
|
+
/** The shard parquet to lint. */
|
|
192
|
+
shard: string
|
|
193
|
+
/** Base corpus version. Default `v0.5.0`. */
|
|
194
|
+
baseVersion?: string
|
|
195
|
+
/** Base corpus root. Default `$MAILWOMAN_DATA_ROOT/corpus/versioned`. */
|
|
196
|
+
baseRoot?: string
|
|
197
|
+
/** Base-majority confidence floor for a contradiction. Default 0.7. */
|
|
198
|
+
threshold?: number
|
|
199
|
+
/** Minimum base support to judge a token. Default 50. */
|
|
200
|
+
minCount?: number
|
|
201
|
+
/** Fraction of base parts to scan (proportional per-source slice below 1.0). Default 1.0. */
|
|
202
|
+
fraction?: number
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** One contradiction row: token, shard tag, base tag, base fraction, base total. */
|
|
206
|
+
export type ShardVocabRow = [token: string, shardTag: string, baseTag: string, baseFrac: number, baseTotal: number]
|
|
207
|
+
|
|
208
|
+
/** Findings summary returned by {@linkcode lintShardVocab}. */
|
|
209
|
+
export interface LintShardVocabSummary {
|
|
210
|
+
/** Real contradictions — the command exits 1 when nonzero. */
|
|
211
|
+
errors: number
|
|
212
|
+
/** Affix-split rows (EXPECTED — the loader's affix-relabel handles them). */
|
|
213
|
+
warnings: number
|
|
214
|
+
findings: { contradictions: ShardVocabRow[]; affixSplits: ShardVocabRow[] }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Lint a synthetic shard's (token → tag) vocabulary against the base corpus, country-scoped. */
|
|
218
|
+
export async function lintShardVocab(options: LintShardVocabOptions): Promise<LintShardVocabSummary> {
|
|
219
|
+
const baseVersion = options.baseVersion ?? "v0.5.0"
|
|
220
|
+
const baseRoot = options.baseRoot ?? dataRootPath("corpus", "versioned")
|
|
221
|
+
const threshold = options.threshold ?? 0.7
|
|
222
|
+
const minCount = options.minCount ?? 50
|
|
223
|
+
const fraction = options.fraction ?? 1.0
|
|
224
|
+
|
|
225
|
+
// @duckdb/node-api is an optional peer — lazy import (the pipeline convention).
|
|
226
|
+
const { DuckDBInstance } = await import("@duckdb/node-api")
|
|
227
|
+
const instance = await DuckDBInstance.create()
|
|
228
|
+
const con = await instance.connect()
|
|
229
|
+
|
|
230
|
+
// 1. the shard's own (token -> dominant tag) + the COUNTRIES it uses each token in
|
|
231
|
+
const shardRows = await readRows(con, options.shard)
|
|
232
|
+
const shardTags = new Map<string, Map<string, number>>()
|
|
233
|
+
const shardCountries = new Map<string, Set<string | null>>()
|
|
234
|
+
|
|
235
|
+
for (const { tokens, labels, country } of shardRows) {
|
|
236
|
+
const n = Math.min(tokens.length, labels.length)
|
|
237
|
+
|
|
238
|
+
for (let i = 0; i < n; i++) {
|
|
239
|
+
const w = tokens[i]!
|
|
240
|
+
const l = labels[i]!
|
|
241
|
+
|
|
242
|
+
if (isDigit(w)) continue // numbers are context-determined (house_number/postcode), not lexical vocab
|
|
243
|
+
bump(shardTags, w, stripBIO(l))
|
|
244
|
+
let set = shardCountries.get(w)
|
|
245
|
+
|
|
246
|
+
if (!set) {
|
|
247
|
+
set = new Set()
|
|
248
|
+
shardCountries.set(w, set)
|
|
249
|
+
}
|
|
250
|
+
set.add(country)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const shardVocab = new Set(shardTags.keys())
|
|
254
|
+
console.log(`shard: ${shardRows.length} rows, ${shardVocab.size} unique tokens`)
|
|
255
|
+
|
|
256
|
+
// 2. base parts — FULL by default; fraction<1 takes a proportional per-source slice (still big)
|
|
257
|
+
const trainDir = join(baseRoot, baseVersion, `corpus-${baseVersion}`, "train")
|
|
258
|
+
let parts = globParquet(trainDir)
|
|
259
|
+
|
|
260
|
+
if (!parts.length) {
|
|
261
|
+
throw new Error("no base parts found")
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (fraction < 1.0) {
|
|
265
|
+
const bysrc = new Map<string, string[]>()
|
|
266
|
+
|
|
267
|
+
for (const p of parts) {
|
|
268
|
+
const src = await readSource(con, p)
|
|
269
|
+
let list = bysrc.get(src)
|
|
270
|
+
|
|
271
|
+
if (!list) {
|
|
272
|
+
list = []
|
|
273
|
+
bysrc.set(src, list)
|
|
274
|
+
}
|
|
275
|
+
list.push(p)
|
|
276
|
+
}
|
|
277
|
+
const sliced: string[] = []
|
|
278
|
+
|
|
279
|
+
for (const ps of bysrc.values()) {
|
|
280
|
+
const take = Math.max(2, pyRound(ps.length * fraction))
|
|
281
|
+
|
|
282
|
+
for (const p of ps.slice(0, take)) {
|
|
283
|
+
sliced.push(p)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
parts = sliced
|
|
287
|
+
}
|
|
288
|
+
console.log(`base ${baseVersion}: scanning ${parts.length} parts (fraction=${pyFloat(fraction)}), COUNTRY-scoped`)
|
|
289
|
+
|
|
290
|
+
// 3. tally each shard token's base tag, SCOPED to the country the shard uses it in
|
|
291
|
+
const baseTags = new Map<string, Map<string, number>>()
|
|
292
|
+
|
|
293
|
+
for (let i = 0; i < parts.length; i++) {
|
|
294
|
+
const rows = await readRows(con, parts[i]!)
|
|
295
|
+
|
|
296
|
+
for (const { tokens, labels, country } of rows) {
|
|
297
|
+
const n = Math.min(tokens.length, labels.length)
|
|
298
|
+
|
|
299
|
+
for (let j = 0; j < n; j++) {
|
|
300
|
+
const w = tokens[j]!
|
|
301
|
+
|
|
302
|
+
if (shardVocab.has(w) && shardCountries.get(w)!.has(country)) {
|
|
303
|
+
bump(baseTags, w, stripBIO(labels[j]!))
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if ((i + 1) % 100 === 0) {
|
|
309
|
+
console.log(` ...${i + 1}/${parts.length} parts`)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 4. compare; flag contradictions (affix-split is expected — surfaced but tagged)
|
|
314
|
+
const flagged: ShardVocabRow[] = []
|
|
315
|
+
const affix: ShardVocabRow[] = []
|
|
316
|
+
|
|
317
|
+
for (const w of shardVocab) {
|
|
318
|
+
const [sTag] = dominant(shardTags.get(w)!)
|
|
319
|
+
const [bTag, bTotal, bFrac] = dominant(baseTags.get(w) ?? new Map())
|
|
320
|
+
|
|
321
|
+
if (bTotal < minCount || !bTag || bTag === sTag || bFrac < threshold) continue
|
|
322
|
+
const row: ShardVocabRow = [w, sTag, bTag, bFrac, bTotal]
|
|
323
|
+
|
|
324
|
+
if ((sTag === "street_suffix" || sTag === "street_prefix") && bTag === "street") {
|
|
325
|
+
affix.push(row)
|
|
326
|
+
} else {
|
|
327
|
+
flagged.push(row)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const sections: Array<[string, ShardVocabRow[]]> = [
|
|
332
|
+
["CONTRADICTION", flagged],
|
|
333
|
+
["affix-split (EXPECTED — affix-relabel handles)", affix],
|
|
334
|
+
]
|
|
335
|
+
|
|
336
|
+
for (const [label, rows] of sections) {
|
|
337
|
+
if (!rows.length) continue
|
|
338
|
+
rows.sort((a, b) => b[4] - a[4] || b[3] - a[3])
|
|
339
|
+
console.log(`\n${label.startsWith("CONTRA") ? "⚠️ " : "· "}${rows.length} ${label}:`)
|
|
340
|
+
|
|
341
|
+
for (const [w, sTag, bTag, bFrac, bTotal] of rows) {
|
|
342
|
+
console.log(` ${pad(w, 18)} shard=${pad(sTag, 14)} base=${bTag} (${pct(bFrac)}, n=${bTotal})`)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (!flagged.length) {
|
|
347
|
+
console.log(
|
|
348
|
+
`\n✅ NO real contradictions (country-scoped, threshold ${pct(threshold)}, support ${minCount}) — shard base-consistent`
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
errors: flagged.length,
|
|
354
|
+
warnings: affix.length,
|
|
355
|
+
findings: { contradictions: flagged, affixSplits: affix },
|
|
356
|
+
}
|
|
357
|
+
}
|