@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.
Files changed (103) hide show
  1. package/package.json +59 -27
  2. package/src/adapter.ts +153 -0
  3. package/src/adapters/ban/adapter.ts +189 -0
  4. package/src/adapters/ban/street-decompose.ts +98 -0
  5. package/src/adapters/fcc-bdc/adapter.ts +188 -0
  6. package/src/adapters/geonames/adapter.ts +185 -0
  7. package/src/adapters/geonames-postal/adapter.ts +111 -0
  8. package/src/adapters/gnaf/adapter.ts +139 -0
  9. package/src/adapters/gnaf/assemble.ts +200 -0
  10. package/src/adapters/index.ts +135 -0
  11. package/src/adapters/openaddresses/adapter.ts +237 -0
  12. package/src/adapters/overture/adapter.ts +149 -0
  13. package/src/adapters/state-hi-schools/adapter.ts +171 -0
  14. package/src/adapters/state-ia-contractors/adapter.ts +148 -0
  15. package/src/adapters/state-ny-notaries/adapter.ts +169 -0
  16. package/src/adapters/state-tx-notaries/adapter.ts +160 -0
  17. package/src/adapters/synth-po-box/adapter.ts +197 -0
  18. package/src/adapters/tiger/adapter.ts +232 -0
  19. package/src/adapters/tiger/street-decompose.ts +120 -0
  20. package/src/adapters/usgov-hrsa-fqhc/adapter.ts +189 -0
  21. package/src/adapters/usgov-imls-pls/adapter.ts +152 -0
  22. package/src/adapters/usgov-irs-bmf/adapter.ts +155 -0
  23. package/src/adapters/usgov-nad/adapter.ts +338 -0
  24. package/src/adapters/usgov-nppes/adapter.ts +169 -0
  25. package/src/adapters/usgov-samhsa-treatment-locator/adapter.ts +211 -0
  26. package/src/adapters/wof-admin-jp/adapter.ts +176 -0
  27. package/src/adapters/wof-admin-json/adapter.ts +280 -0
  28. package/src/adapters/wof-postalcode-json/adapter.ts +209 -0
  29. package/src/align.ts +317 -0
  30. package/src/build.ts +348 -0
  31. package/src/codex/us-fips-state.ts +109 -0
  32. package/src/format.ts +14 -0
  33. package/src/golden.ts +147 -0
  34. package/src/index.ts +28 -0
  35. package/src/license.ts +48 -0
  36. package/src/parquet-wrapper/index.ts +9 -0
  37. package/src/parquet-wrapper/reader.ts +67 -0
  38. package/src/parquet-wrapper/schema.ts +80 -0
  39. package/src/parquet-wrapper/writer.ts +102 -0
  40. package/src/parquet.ts +360 -0
  41. package/src/runner.ts +283 -0
  42. package/src/shard-recipes/anchor-absorption.ts +86 -0
  43. package/src/shard-recipes/boundary-stress.ts +87 -0
  44. package/src/shard-recipes/country-balanced.ts +471 -0
  45. package/src/shard-recipes/cz-pcfirst-preposition.ts +83 -0
  46. package/src/shard-recipes/fr-admin-split.ts +212 -0
  47. package/src/shard-recipes/fr-bare-street.ts +85 -0
  48. package/src/shard-recipes/fr-fragment.ts +304 -0
  49. package/src/shard-recipes/fr-order.ts +283 -0
  50. package/src/shard-recipes/german.ts +206 -0
  51. package/src/shard-recipes/house-venue.ts +78 -0
  52. package/src/shard-recipes/index.ts +76 -0
  53. package/src/shard-recipes/intersection.ts +617 -0
  54. package/src/shard-recipes/locale.ts +415 -0
  55. package/src/shard-recipes/nl-postcode.ts +105 -0
  56. package/src/shard-recipes/no-fragment.ts +206 -0
  57. package/src/shard-recipes/no-street-led.ts +130 -0
  58. package/src/shard-recipes/no-street.ts +78 -0
  59. package/src/shard-recipes/po-box-cedex.ts +853 -0
  60. package/src/shard-recipes/po-box.ts +111 -0
  61. package/src/shard-recipes/scaffold.ts +187 -0
  62. package/src/shard-recipes/si-bare-village.ts +88 -0
  63. package/src/shard-recipes/street-affix.ts +549 -0
  64. package/src/shard-recipes/street-bare.ts +90 -0
  65. package/src/shard-recipes/street.ts +74 -0
  66. package/src/shard-recipes/unit.ts +315 -0
  67. package/src/split.ts +251 -0
  68. package/src/synthesize-anchor-absorption.ts +263 -0
  69. package/src/synthesize-boundary-stress.ts +504 -0
  70. package/src/synthesize-german.ts +227 -0
  71. package/src/synthesize-house-venue.ts +192 -0
  72. package/src/synthesize-intersection.ts +205 -0
  73. package/src/synthesize-no-street.ts +341 -0
  74. package/src/synthesize-po-box.ts +307 -0
  75. package/src/synthesize-street.ts +264 -0
  76. package/src/synthesize.ts +872 -0
  77. package/src/tokenize.ts +68 -0
  78. package/src/tools/align-shard.ts +67 -0
  79. package/src/tools/audit.ts +352 -0
  80. package/src/tools/corpus-stats.ts +183 -0
  81. package/src/tools/fetch/ban.ts +288 -0
  82. package/src/tools/fetch/download.ts +119 -0
  83. package/src/tools/fetch/hrsa.ts +65 -0
  84. package/src/tools/fetch/imls-pls.ts +162 -0
  85. package/src/tools/fetch/index.ts +121 -0
  86. package/src/tools/fetch/nad.ts +304 -0
  87. package/src/tools/fetch/nppes.ts +177 -0
  88. package/src/tools/fetch/openaddresses.ts +380 -0
  89. package/src/tools/fetch/state-hi-schools.ts +217 -0
  90. package/src/tools/fetch/state-sources.ts +142 -0
  91. package/src/tools/fetch/tiger-full.ts +318 -0
  92. package/src/tools/golden-expand.ts +524 -0
  93. package/src/tools/golden-promote.ts +304 -0
  94. package/src/tools/index.ts +24 -0
  95. package/src/tools/ingest-csv.ts +420 -0
  96. package/src/tools/jsonl-to-parquet.ts +200 -0
  97. package/src/tools/lint-shard-vocab.ts +357 -0
  98. package/src/tools/lint-shard.ts +502 -0
  99. package/src/tools/overlay-manifest.ts +141 -0
  100. package/src/tools/shard-kryptonite.ts +131 -0
  101. package/src/tools/shard-translit.ts +244 -0
  102. package/src/types.ts +213 -0
  103. package/src/wof-json.ts +236 -0
@@ -0,0 +1,617 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `intersection` shard recipe — the REAL-pair intersection training shard (#487). The model scored
7
+ * 0.0 on intersection_a/b because the training mix had ZERO intersection-labeled rows. This is
8
+ * the missing data. Ported from scripts/build-intersection-shard.mjs.
9
+ *
10
+ * STREET PAIRS ARE REAL: the same TIGER 2023 EDGES extraction as the eval builder
11
+ * (scripts/eval/build-intersection-real.ts) — a node where two road edges (MTFCC S1*) with
12
+ * distinct FULLNAMEs meet is a real crossing. Real pairs avoid teaching fake street-street
13
+ * co-occurrences.
14
+ *
15
+ * LEAKAGE POLICY (mirrors the affix shard's VT discipline):
16
+ *
17
+ * - TRAIN counties: Cook IL (grid city) + Morris NJ (suburb).
18
+ * - GOLDEN (`--golden`) county: Washington VT (rural) ONLY — the corpus defaultHoldout state.
19
+ * - Every crossing in data/eval/external/intersection-real.jsonl is excluded from BOTH modes, by node
20
+ * id AND by order-insensitive name pair (the eval shares all three counties).
21
+ *
22
+ * RENDERING: junction-format variety — padded/TIGHT `&` and `/`, `and`, `at`, `@`, leading-phrase
23
+ * `corner of` / `intersection of` — crossed with tails (bare / `, ST` / `, ST ZIP` / `, City, ST
24
+ * [ZIP]`) and case variants. ZIPs are the crossing's own TIGER edge ZIPL (real); the locality
25
+ * tail comes from the OA Cook-county ZIP→city majority map.
26
+ *
27
+ * AUDIT: every emitted row is label-checked on the RAW SURFACE via the #519 char-offset span
28
+ * triple. Any violation FAILS the build (throws). A JSON audit report lands next to the output.
29
+ *
30
+ * External inputs (`--edges-dir`, opts.edgesDir; both already on disk — do not re-download):
31
+ *
32
+ * - <edges-dir>/tl_2023_{17031,34027,50023}_edges.shp (unzipped TIGER 2023 EDGES; default
33
+ * /tmp/tiger-edges)
34
+ * - /tmp/oa-cache/us__il__cook.zip (ZIP→city tails)
35
+ */
36
+
37
+ import { spawnSync } from "node:child_process"
38
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
39
+
40
+ import type { DuckDBConnection } from "@duckdb/node-api"
41
+ import type { ComponentTag } from "@mailwoman/core/types"
42
+ import { repoRootPath } from "@mailwoman/core/utils"
43
+
44
+ import { stableSourceID } from "../adapter.ts"
45
+ import { alignRow } from "../align.ts"
46
+ import type { CanonicalRow, LabeledRow } from "../types.ts"
47
+ import { makeMulberry32, type ShardRecipe } from "./scaffold.ts"
48
+
49
+ interface County {
50
+ fips: string
51
+ state: string
52
+ regime: string
53
+ }
54
+ const TRAIN_COUNTIES: readonly County[] = [
55
+ { fips: "17031", state: "IL", regime: "grid-city" },
56
+ { fips: "34027", state: "NJ", regime: "suburb" },
57
+ ]
58
+ const GOLDEN_COUNTIES: readonly County[] = [{ fips: "50023", state: "VT", regime: "rural" }]
59
+
60
+ const EVAL_GOLD_PATH = repoRootPath("data", "eval", "external", "intersection-real.jsonl")
61
+ const OA_COOK = { zip: "/tmp/oa-cache/us__il__cook.zip", csv: "us/il/cook.csv" }
62
+
63
+ /** One real crossing extracted from a county's TIGER EDGES shapefile. */
64
+ interface Crossing {
65
+ a: string
66
+ b: string
67
+ zip: string | null
68
+ node: number
69
+ fips: string
70
+ state: string
71
+ }
72
+
73
+ /**
74
+ * Junction forms. Weights favor the common connectors; the tight (unpadded) variants and leading phrases get enough
75
+ * mass to register (each ≥5%) — they're the audited gaps the old synth missed.
76
+ */
77
+ interface Form {
78
+ id: string
79
+ w: number
80
+ render: (a: string, b: string) => string
81
+ }
82
+ const FORMS: readonly Form[] = [
83
+ { id: "amp", w: 0.2, render: (a, b) => `${a} & ${b}` },
84
+ { id: "and", w: 0.2, render: (a, b) => `${a} and ${b}` },
85
+ { id: "at", w: 0.12, render: (a, b) => `${a} at ${b}` },
86
+ { id: "slash", w: 0.08, render: (a, b) => `${a} / ${b}` },
87
+ { id: "slash-tight", w: 0.06, render: (a, b) => `${a}/${b}` },
88
+ { id: "amp-tight", w: 0.05, render: (a, b) => `${a}&${b}` },
89
+ { id: "at-sign", w: 0.06, render: (a, b) => `${a} @ ${b}` },
90
+ { id: "corner-of", w: 0.115, render: (a, b) => `corner of ${a} and ${b}` },
91
+ { id: "intersection-of", w: 0.115, render: (a, b) => `intersection of ${a} and ${b}` },
92
+ ]
93
+
94
+ /**
95
+ * Tail forms. ~55% bare (the v0.7.2 lesson: an always-present tail taught the model to read post-intersection text as a
96
+ * locality and fumble bare "X & Y"). City tails require a ZIP→city hit (Cook only); ZIP tails require the edge to carry
97
+ * a ZIPL. Misses downgrade to the region tail.
98
+ */
99
+ interface Tail {
100
+ id: string
101
+ w: number
102
+ }
103
+ const TAILS: readonly Tail[] = [
104
+ { id: "bare", w: 0.55 },
105
+ { id: "region", w: 0.16 },
106
+ { id: "region-zip", w: 0.09 },
107
+ { id: "city-region", w: 0.08 },
108
+ { id: "city-region-zip", w: 0.12 },
109
+ ]
110
+
111
+ interface Casing {
112
+ id: string
113
+ w: number
114
+ apply: (s: string) => string
115
+ }
116
+ const CASES: readonly Casing[] = [
117
+ { id: "as-is", w: 0.82, apply: (s) => s },
118
+ { id: "upper", w: 0.12, apply: (s) => s.toUpperCase() },
119
+ { id: "lower", w: 0.06, apply: (s) => s.toLowerCase() },
120
+ ]
121
+
122
+ /**
123
+ * Words a connector may contribute as O tokens. The audit rejects any O token outside this set — an unlabeled
124
+ * street/locality token would surface here.
125
+ */
126
+ const CONNECTOR_O_TOKENS = new Set(["and", "at", "of", "corner", "intersection"])
127
+
128
+ /**
129
+ * Street names that would make the connector ambiguous or break verbatim alignment: embedded connector punctuation, or
130
+ * a standalone "and"/"at" word.
131
+ */
132
+ const BAD_NAME = /[,&@/]|\b(and|at)\b/i
133
+
134
+ /** Punctuation a connector form may leave between spans (besides whitespace): `, & @ /`. */
135
+ const CONNECTOR_PUNCT_RE = /^[\s,&@/]*$/
136
+
137
+ function weightedPick<T extends { w: number }>(items: readonly T[], random: () => number): T {
138
+ const total = items.reduce((s, x) => s + x.w, 0)
139
+ let r = random() * total
140
+
141
+ for (const item of items) {
142
+ r -= item.w
143
+
144
+ if (r <= 0) return item
145
+ }
146
+
147
+ return items[items.length - 1]!
148
+ }
149
+
150
+ /** Minimal RFC-4180-ish splitter (handles quoted fields) — same as the affix builder. */
151
+ function splitCSV(line: string): string[] {
152
+ const out: string[] = []
153
+ let cur = ""
154
+ let inQ = false
155
+
156
+ for (let i = 0; i < line.length; i++) {
157
+ const c = line[i]
158
+
159
+ if (inQ) {
160
+ if (c === '"') {
161
+ if (line[i + 1] === '"') {
162
+ cur += '"'
163
+ i++
164
+ } else {
165
+ inQ = false
166
+ }
167
+ } else {
168
+ cur += c
169
+ }
170
+ } else if (c === '"') {
171
+ inQ = true
172
+ } else if (c === ",") {
173
+ out.push(cur)
174
+ cur = ""
175
+ } else {
176
+ cur += c
177
+ }
178
+ }
179
+ out.push(cur)
180
+
181
+ return out
182
+ }
183
+
184
+ /** Order-insensitive crossing key, for eval-leakage exclusion + pair dedup. */
185
+ const pairKey = (a: string, b: string): string => [a.toLowerCase(), b.toLowerCase()].sort().join("\x1f")
186
+
187
+ /** Load the eval's crossings so neither train nor golden ever sees them. */
188
+ function readEvalExclusions(): { nodes: Set<number>; pairs: Set<string> } {
189
+ const nodes = new Set<number>()
190
+ const pairs = new Set<string>()
191
+
192
+ if (!existsSync(EVAL_GOLD_PATH)) {
193
+ console.error(` WARN: eval gold not found at ${EVAL_GOLD_PATH} — no eval-leakage exclusion applied`)
194
+
195
+ return { nodes, pairs }
196
+ }
197
+
198
+ for (const line of readFileSync(EVAL_GOLD_PATH, "utf8").split("\n")) {
199
+ if (!line) continue
200
+ const row = JSON.parse(line)
201
+ nodes.add(Number(row.node))
202
+ pairs.add(pairKey(row.components.intersection_a, row.components.intersection_b))
203
+ }
204
+
205
+ return { nodes, pairs }
206
+ }
207
+
208
+ /**
209
+ * Extract real crossings from one county's TIGER EDGES shapefile. Same query shape as the eval builder (2 incident
210
+ * distinct S1* FULLNAMEs at a node, both names >=6 chars), plus the edge ZIPL so tails can carry the crossing's own
211
+ * ZIP. Hash-ordered for seed-stable determinism.
212
+ */
213
+ async function extractCrossings(
214
+ db: DuckDBConnection,
215
+ edgesDir: string,
216
+ county: County,
217
+ seed: number
218
+ ): Promise<Crossing[]> {
219
+ const shp = `${edgesDir}/tl_2023_${county.fips}_edges.shp`
220
+ const result = await db.runAndReadAll(`
221
+ WITH incidence AS (
222
+ SELECT TNIDF AS node, FULLNAME AS name, ZIPL AS zip
223
+ FROM ST_Read('${shp}') WHERE MTFCC LIKE 'S1%' AND FULLNAME IS NOT NULL
224
+ UNION ALL
225
+ SELECT TNIDT AS node, FULLNAME AS name, ZIPL AS zip
226
+ FROM ST_Read('${shp}') WHERE MTFCC LIKE 'S1%' AND FULLNAME IS NOT NULL
227
+ ),
228
+ nodes AS (
229
+ SELECT node,
230
+ list_sort(list_distinct(list(name))) AS names,
231
+ max(zip) AS zip
232
+ FROM incidence GROUP BY node
233
+ HAVING len(list_distinct(list(name))) = 2
234
+ )
235
+ SELECT node, names[1] AS a, names[2] AS b, zip,
236
+ hash(node::VARCHAR || '${seed}') AS h
237
+ FROM nodes
238
+ WHERE len(names[1]) >= 6 AND len(names[2]) >= 6
239
+ ORDER BY h
240
+ `)
241
+ const out: Crossing[] = []
242
+
243
+ for (const r of result.getRowObjects()) {
244
+ out.push({
245
+ a: String(r.a),
246
+ b: String(r.b),
247
+ zip: r.zip == null ? null : String(r.zip),
248
+ node: Number(r.node),
249
+ fips: county.fips,
250
+ state: county.state,
251
+ })
252
+ }
253
+
254
+ return out
255
+ }
256
+
257
+ /** ZIP → majority city from the cached OA Cook-county CSV (real ZIP/city pairings). */
258
+ function buildZipCityMap(): Map<string, string> {
259
+ const r = spawnSync("unzip", ["-p", OA_COOK.zip, OA_COOK.csv], { maxBuffer: 1024 * 1024 * 1024, encoding: "buffer" })
260
+
261
+ if (r.status !== 0) {
262
+ console.error(` WARN: unzip failed for ${OA_COOK.zip} — city tails disabled`)
263
+
264
+ return new Map()
265
+ }
266
+ const lines = r.stdout.toString("utf8").split(/\r?\n/)
267
+
268
+ if (lines.length < 2) return new Map()
269
+ const header = splitCSV(lines[0]!).map((h) => h.trim().toLowerCase())
270
+ const iCity = header.indexOf("city")
271
+ const iPost = header.indexOf("postcode")
272
+ const counts = new Map<string, Map<string, number>>()
273
+
274
+ // zip → Map(city → n)
275
+ for (let li = 1; li < lines.length; li++) {
276
+ if (!lines[li]) continue
277
+ const cells = splitCSV(lines[li]!)
278
+ const city = (cells[iCity] ?? "").trim()
279
+ const zip = (cells[iPost] ?? "").trim()
280
+
281
+ if (!city || !/^\d{5}$/.test(zip) || BAD_NAME.test(city)) continue
282
+ let byCity = counts.get(zip)
283
+
284
+ if (!byCity) {
285
+ counts.set(zip, (byCity = new Map()))
286
+ }
287
+ byCity.set(city, (byCity.get(city) ?? 0) + 1)
288
+ }
289
+ const map = new Map<string, string>()
290
+
291
+ for (const [zip, byCity] of counts) {
292
+ let best: { city: string; n: number } | null = null
293
+
294
+ for (const [city, n] of byCity)
295
+ if (!best || n > best.n) {
296
+ best = { city, n }
297
+ }
298
+
299
+ if (best) {
300
+ map.set(zip, best.city)
301
+ }
302
+ }
303
+
304
+ return map
305
+ }
306
+
307
+ /**
308
+ * Render one crossing → { raw, components, formID, tailID, caseID }. Components are inserted in claim order (streets
309
+ * first) so alignment can't grab a region/postcode lookalike inside a street.
310
+ */
311
+ function renderRow(
312
+ random: () => number,
313
+ crossing: Crossing,
314
+ zipCity: Map<string, string>
315
+ ): { raw: string; components: Partial<Record<ComponentTag, string>>; formID: string; tailID: string; caseID: string } {
316
+ const form = weightedPick(FORMS, random)
317
+ const body = form.render(crossing.a, crossing.b)
318
+
319
+ let tail = weightedPick(TAILS, random)
320
+ const city = crossing.zip ? (zipCity.get(crossing.zip) ?? null) : null
321
+
322
+ // Downgrade unsatisfiable tails (no ZIP on the edge / no city for the ZIP) to the region tail.
323
+ if ((tail.id === "region-zip" || tail.id === "city-region-zip") && !crossing.zip) {
324
+ tail = TAILS[1]!
325
+ }
326
+
327
+ if ((tail.id === "city-region" || tail.id === "city-region-zip") && !city) {
328
+ tail = TAILS[1]!
329
+ }
330
+
331
+ const components: Partial<Record<ComponentTag, string>> = { intersection_a: crossing.a, intersection_b: crossing.b }
332
+ let raw = body
333
+
334
+ if (tail.id === "region") {
335
+ raw = `${body}, ${crossing.state}`
336
+ components.region = crossing.state
337
+ } else if (tail.id === "region-zip") {
338
+ raw = `${body}, ${crossing.state} ${crossing.zip}`
339
+ components.region = crossing.state
340
+ components.postcode = crossing.zip!
341
+ } else if (tail.id === "city-region") {
342
+ raw = `${body}, ${city}, ${crossing.state}`
343
+ components.locality = city!
344
+ components.region = crossing.state
345
+ } else if (tail.id === "city-region-zip") {
346
+ raw = `${body}, ${city}, ${crossing.state} ${crossing.zip}`
347
+ components.locality = city!
348
+ components.region = crossing.state
349
+ components.postcode = crossing.zip!
350
+ }
351
+
352
+ const casing = weightedPick(CASES, random)
353
+ raw = casing.apply(raw)
354
+
355
+ // Components keep their original case; alignRow matches case-insensitively and labels the
356
+ // tokens of the (cased) raw — the parquet row carries tokens+labels only.
357
+ return { raw, components, formID: form.id, tailID: tail.id, caseID: casing.id }
358
+ }
359
+
360
+ /**
361
+ * Label-correctness audit for one aligned row, on the RAW SURFACE via the #519 span triple. Returns a list of
362
+ * violations (empty = clean). Re-derives the span checks independent of `alignRow`'s own assertion, so a builder bug
363
+ * can't vouch for itself.
364
+ */
365
+ function auditRow(row: LabeledRow, components: Partial<Record<ComponentTag, string>>): string[] {
366
+ const errors: string[] = []
367
+ const { raw, tokens, labels, span_starts, span_ends, span_tags } = row
368
+
369
+ if (tokens.length !== labels.length) {
370
+ errors.push("tokens/labels length mismatch")
371
+ }
372
+
373
+ if (!span_starts || !span_ends || !span_tags) {
374
+ errors.push("missing the char-offset span triple (#519)")
375
+
376
+ return errors
377
+ }
378
+
379
+ if (span_starts.length !== span_ends.length || span_starts.length !== span_tags.length) {
380
+ errors.push(`span triple not parallel: ${span_starts.length}/${span_ends.length}/${span_tags.length}`)
381
+
382
+ return errors
383
+ }
384
+
385
+ for (let i = 0; i < span_starts.length; i++) {
386
+ if (!(span_starts[i]! >= 0 && span_starts[i]! < span_ends[i]! && span_ends[i]! <= raw.length)) {
387
+ errors.push(`span ${span_tags[i]}@[${span_starts[i]}, ${span_ends[i]}) out of bounds`)
388
+ }
389
+
390
+ if (i > 0 && span_starts[i]! < span_ends[i - 1]!) {
391
+ errors.push(`spans unsorted/overlapping at index ${i}`)
392
+ }
393
+ }
394
+
395
+ if (errors.length > 0) return errors
396
+
397
+ const compCount = Object.keys(components).length
398
+
399
+ if (span_tags.length !== compCount) {
400
+ errors.push(`span count ${span_tags.length} != components ${compCount}`)
401
+ }
402
+
403
+ // Raw-surface reconstruction: each component's single span slices raw to the component verbatim.
404
+ for (const [tag, value] of Object.entries(components)) {
405
+ if (value == null) continue
406
+ const indices = span_tags.map((t, i) => (t === tag ? i : -1)).filter((i) => i >= 0)
407
+
408
+ if (indices.length !== 1) {
409
+ errors.push(`${tag}: expected 1 span, got ${indices.length}`)
410
+ continue
411
+ }
412
+ const idx = indices[0]!
413
+ const got = raw.slice(span_starts[idx]!, span_ends[idx]!)
414
+
415
+ if (got.toLowerCase() !== value.toLowerCase()) {
416
+ errors.push(`${tag} span "${got}" != component "${value}"`)
417
+ }
418
+ }
419
+
420
+ // Negative space: every char outside the spans must be connector material.
421
+ let cursor = 0
422
+ const uncovered: string[] = []
423
+
424
+ for (let i = 0; i < span_starts.length; i++) {
425
+ if (span_starts[i]! > cursor) {
426
+ uncovered.push(raw.slice(cursor, span_starts[i]!))
427
+ }
428
+ cursor = span_ends[i]!
429
+ }
430
+
431
+ if (cursor < raw.length) {
432
+ uncovered.push(raw.slice(cursor))
433
+ }
434
+
435
+ for (const segment of uncovered) {
436
+ const words = segment.match(/[\p{L}\p{N}]+/gu) ?? []
437
+
438
+ for (const word of words) {
439
+ if (!CONNECTOR_O_TOKENS.has(word.toLowerCase())) {
440
+ errors.push(`illegal uncovered word "${word}"`)
441
+ }
442
+ }
443
+ const punctOnly = segment.replace(/[\p{L}\p{N}]+/gu, "")
444
+
445
+ if (!CONNECTOR_PUNCT_RE.test(punctOnly)) {
446
+ errors.push(`illegal uncovered punctuation in "${segment}"`)
447
+ }
448
+ }
449
+
450
+ return errors
451
+ }
452
+
453
+ export const intersectionRecipe: ShardRecipe = {
454
+ name: "intersection",
455
+ description: "Real-pair intersection rows (US): TIGER 2023 EDGES crossings → audited intersection_a/b labels",
456
+ mode: "generate",
457
+ options: [{ flag: "--edges-dir <dir>", description: "Unzipped TIGER 2023 EDGES dir. Default /tmp/tiger-edges" }],
458
+ async run(opts, write) {
459
+ // Legacy build-intersection-shard.mjs seeded `mulberry32(opts.seed)`.
460
+ const random = makeMulberry32(opts.seed)
461
+ const count = opts.count ?? 40000
462
+ const source = opts.sourceName ?? "synth-intersection"
463
+ const edgesDir = opts.edgesDir ?? "/tmp/tiger-edges"
464
+ const counties = opts.golden ? GOLDEN_COUNTIES : TRAIN_COUNTIES
465
+ const exclusions = readEvalExclusions()
466
+ console.error(` eval exclusions: ${exclusions.nodes.size} nodes, ${exclusions.pairs.size} pairs`)
467
+
468
+ const { DuckDBInstance } = await import("@duckdb/node-api")
469
+ const instance = await DuckDBInstance.create()
470
+ const db = await instance.connect()
471
+ await db.run("INSTALL spatial; LOAD spatial;")
472
+
473
+ // Pool real crossings: eval-excluded, connector-safe names, one crossing per distinct pair.
474
+ const pool: Crossing[] = []
475
+ const seenPairs = new Set<string>()
476
+ const stats = { evalExcluded: 0, badName: 0, dupPair: 0 }
477
+
478
+ for (const county of counties) {
479
+ const crossings = await extractCrossings(db, edgesDir, county, opts.seed)
480
+ let kept = 0
481
+
482
+ for (const c of crossings) {
483
+ const key = pairKey(c.a, c.b)
484
+
485
+ if (exclusions.nodes.has(c.node) || exclusions.pairs.has(key)) {
486
+ stats.evalExcluded++
487
+ continue
488
+ }
489
+
490
+ if (BAD_NAME.test(c.a) || BAD_NAME.test(c.b) || c.a.includes(c.b) || c.b.includes(c.a)) {
491
+ stats.badName++
492
+ continue
493
+ }
494
+
495
+ if (seenPairs.has(key)) {
496
+ stats.dupPair++
497
+ continue
498
+ }
499
+ seenPairs.add(key)
500
+ pool.push(c)
501
+ kept++
502
+ }
503
+ console.error(` ${county.fips} (${county.state}, ${county.regime}): ${crossings.length} crossings, ${kept} kept`)
504
+ }
505
+
506
+ if (pool.length === 0) {
507
+ throw new Error(`No crossings found — are the TIGER EDGES shapefiles present in ${edgesDir}?`)
508
+ }
509
+
510
+ const zipCity = opts.golden ? new Map<string, string>() : buildZipCityMap()
511
+
512
+ if (!opts.golden) {
513
+ console.error(` zip→city map: ${zipCity.size} ZIPs (OA Cook)`)
514
+ }
515
+
516
+ let emitted = 0
517
+ let skipped = 0
518
+ let guard = 0
519
+ const formCounts: Record<string, number> = {}
520
+ const tailCounts: Record<string, number> = {}
521
+ const caseCounts: Record<string, number> = {}
522
+ const countyCounts: Record<string, number> = {}
523
+ const usedCrossings = new Set<number>()
524
+ const seenRaw = new Set<string>()
525
+ const auditErrors: Array<{ raw: string; violations: string[] }> = []
526
+ const samples: Array<{ form: string; raw: string; tokens: readonly string[]; labels: readonly string[] }> = []
527
+
528
+ while (emitted < count && guard++ < count * 10) {
529
+ const crossing = pool[Math.floor(random() * pool.length)]!
530
+ const { raw, components, formID, tailID, caseID } = renderRow(random, crossing, zipCity)
531
+
532
+ if (seenRaw.has(raw)) {
533
+ skipped++
534
+ continue
535
+ }
536
+
537
+ if (opts.golden) {
538
+ seenRaw.add(raw)
539
+ write(JSON.stringify({ raw, components, country: "US", form: formID }) + "\n")
540
+ formCounts[formID] = (formCounts[formID] ?? 0) + 1
541
+ tailCounts[tailID] = (tailCounts[tailID] ?? 0) + 1
542
+ caseCounts[caseID] = (caseCounts[caseID] ?? 0) + 1
543
+ countyCounts[crossing.fips] = (countyCounts[crossing.fips] ?? 0) + 1
544
+ usedCrossings.add(crossing.node)
545
+ emitted++
546
+ continue
547
+ }
548
+
549
+ const canonical: CanonicalRow = {
550
+ raw,
551
+ components,
552
+ country: "US",
553
+ locale: "en-US",
554
+ source,
555
+ source_id: stableSourceID(source, components),
556
+ corpus_version: "0.4.0",
557
+ license: "TIGER/Line 2023 EDGES (US Census, public domain) real street pairs; OA Cook IL zip-to-city tails",
558
+ }
559
+ // Verbatim-only alignment: raw is built from the component values, so a fuzzy fallback could
560
+ // only ever mislabel (e.g. claim a lookalike window for a near-duplicate street).
561
+ const aligned = alignRow(canonical, { maxEditDistance: 0 })
562
+
563
+ if (aligned.kind !== "labeled" || !aligned.row) {
564
+ skipped++
565
+ continue
566
+ }
567
+ const violations = auditRow(aligned.row, components)
568
+
569
+ if (violations.length > 0) {
570
+ auditErrors.push({ raw, violations })
571
+ continue
572
+ }
573
+
574
+ seenRaw.add(raw)
575
+ write(JSON.stringify({ ...aligned.row, synth_method: "intersection", synth_base_id: null }) + "\n")
576
+ formCounts[formID] = (formCounts[formID] ?? 0) + 1
577
+ tailCounts[tailID] = (tailCounts[tailID] ?? 0) + 1
578
+ caseCounts[caseID] = (caseCounts[caseID] ?? 0) + 1
579
+ countyCounts[crossing.fips] = (countyCounts[crossing.fips] ?? 0) + 1
580
+ usedCrossings.add(crossing.node)
581
+
582
+ if (samples.length < FORMS.length && !samples.some((s) => s.form === formID)) {
583
+ samples.push({ form: formID, raw, tokens: aligned.row.tokens, labels: aligned.row.labels })
584
+ }
585
+ emitted++
586
+ }
587
+
588
+ const report = {
589
+ mode: opts.golden ? "golden" : "train",
590
+ rows: emitted,
591
+ skipped,
592
+ pool: { crossings: pool.length, used: usedCrossings.size, ...stats },
593
+ per_county: countyCounts,
594
+ forms: formCounts,
595
+ tails: tailCounts,
596
+ cases: caseCounts,
597
+ audit: { errors: auditErrors.length, examples: auditErrors.slice(0, 10) },
598
+ seed: opts.seed,
599
+ source: "TIGER2023 EDGES via DuckDB ST_Read; node = 2 distinct S1* FULLNAMEs; eval crossings excluded",
600
+ samples,
601
+ }
602
+ writeFileSync(opts.output.replace(/\.jsonl$/, ".report.json"), JSON.stringify(report, null, "\t"))
603
+ console.error(
604
+ `Done: emitted ${emitted} rows (skipped ${skipped}) from ${usedCrossings.size}/${pool.length} real crossings. → ${opts.output}\n` +
605
+ ` forms: ${JSON.stringify(formCounts)}\n` +
606
+ ` tails: ${JSON.stringify(tailCounts)}\n` +
607
+ ` cases: ${JSON.stringify(caseCounts)}\n` +
608
+ ` audit: ${auditErrors.length} violation(s)`
609
+ )
610
+
611
+ if (auditErrors.length > 0) {
612
+ throw new Error(`AUDIT FAILED — first violation: ${JSON.stringify(auditErrors[0])}`)
613
+ }
614
+
615
+ return { emitted, skipped }
616
+ },
617
+ }