@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.
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,872 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Synthesis / augmentation per Phase 1 task #6.
7
+ *
8
+ * An `Augmentation` is a pure function that takes a `CanonicalRow` and either returns a new
9
+ * `CanonicalRow` (with `raw` AND `components` transformed in lockstep so alignment still
10
+ * succeeds) or `null` when the augmentation doesn't apply to the row's shape.
11
+ *
12
+ * Synthesis runs **before** alignment: augmentations transform raw + components together, and the
13
+ * runner reruns alignment on each augmented row to produce its labels. This keeps the synthesis
14
+ * surface small (no token/label arithmetic) at the cost of a re-run.
15
+ *
16
+ * Every augmented row carries the `synth` marker:
17
+ *
18
+ * - `method`: the augmentation's stable id (e.g. `"case-upper"`, `"accent-strip"`).
19
+ * - `base_source_id`: the source_id of the un-augmented (or upstream-augmented) row, so ancestry is
20
+ * traceable.
21
+ *
22
+ * Phase 1 implements the locale-agnostic + most useful US/FR augmentations. Typo injection (#530)
23
+ * is now implemented ({@link typoInject}) — the "seed-aware API" the deferral asked for is
24
+ * resolved by seeding the PRNG from each row's `source_id`. It ships in {@link AUGMENTATIONS} but
25
+ * is kept OUT of the default set ({@link defaultAugmentationsForCountry}) until its on-model
26
+ * effect is measured; see the note there.
27
+ */
28
+
29
+ import {
30
+ US_STREET_SUFFIX_PREFERRED_ABBR,
31
+ US_UNIT_DESIGNATOR_PREFERRED_ABBR,
32
+ matchCase,
33
+ matchLeadingDesignator,
34
+ matchTrailingSuffix,
35
+ } from "@mailwoman/codex/us"
36
+ import type { BIOLabel, ComponentTag } from "@mailwoman/core/types"
37
+
38
+ import { alignRow, assertSpanInvariants, type ComponentSpan } from "./align.ts"
39
+ import { whitespaceTokenizer, type Tokenizer } from "./tokenize.ts"
40
+ import type { CanonicalRow, LabeledRow, QuarantinedRow } from "./types.ts"
41
+
42
+ /**
43
+ * An augmentation transforms a single row. Return `null` if the augmentation doesn't apply (e.g. accent-strip on a row
44
+ * that has no accents; particle-strip on a US row).
45
+ */
46
+ export type Augmentation = (row: CanonicalRow) => CanonicalRow | null
47
+
48
+ type ComponentDict = Partial<Record<ComponentTag, string>>
49
+
50
+ /** Helper: build the augmented row with synth marker + chained source_id. */
51
+ function withAugmentation(
52
+ source: CanonicalRow,
53
+ method: string,
54
+ newRaw: string,
55
+ newComponents: ComponentDict
56
+ ): CanonicalRow {
57
+ const baseID = source.synth?.base_source_id ?? source.source_id
58
+
59
+ return {
60
+ ...source,
61
+ raw: newRaw,
62
+ components: newComponents,
63
+ source_id: `${source.source_id}+${method}`,
64
+ synth: { method, base_source_id: baseID },
65
+ }
66
+ }
67
+
68
+ // ===========================================================================
69
+ // Locale-agnostic augmentations
70
+ // ===========================================================================
71
+
72
+ /** Upper-case raw + every component value. Returns null if already all-upper. */
73
+ export const caseUpper: Augmentation = (row) => {
74
+ if (row.raw === row.raw.toUpperCase()) return null
75
+ const upRaw = row.raw.toUpperCase()
76
+ const upComponents: ComponentDict = {}
77
+
78
+ for (const [k, v] of Object.entries(row.components)) {
79
+ if (v) {
80
+ upComponents[k as ComponentTag] = v.toUpperCase()
81
+ }
82
+ }
83
+
84
+ return withAugmentation(row, "case-upper", upRaw, upComponents)
85
+ }
86
+
87
+ /** Lower-case raw + every component value. Returns null if already all-lower. */
88
+ export const caseLower: Augmentation = (row) => {
89
+ if (row.raw === row.raw.toLowerCase()) return null
90
+ const downRaw = row.raw.toLowerCase()
91
+ const downComponents: ComponentDict = {}
92
+
93
+ for (const [k, v] of Object.entries(row.components)) {
94
+ if (v) {
95
+ downComponents[k as ComponentTag] = v.toLowerCase()
96
+ }
97
+ }
98
+
99
+ return withAugmentation(row, "case-lower", downRaw, downComponents)
100
+ }
101
+
102
+ /** Drop commas from `raw`. Components unchanged (they didn't carry commas). */
103
+ export const dropCommas: Augmentation = (row) => {
104
+ if (!row.raw.includes(",")) return null
105
+ const newRaw = row.raw.replace(/,/g, "").replace(/\s+/g, " ").trim()
106
+
107
+ return withAugmentation(row, "drop-commas", newRaw, { ...row.components })
108
+ }
109
+
110
+ /**
111
+ * Replace single spaces with double spaces in `raw` AND in every component value. The component update is essential for
112
+ * alignment: `alignRow` substring-searches each component's surface form inside `raw`, so doubling the spaces in `raw`
113
+ * only would leave single-spaced components unfindable (this was the bug behind v0.1.1's first build attempt — 99.9% of
114
+ * quarantined rows traced back to this augmentation). Doubling both keeps the substring contract intact.
115
+ */
116
+ export const doubleSpace: Augmentation = (row) => {
117
+ if (!/ /.test(row.raw)) return null
118
+ const newRaw = row.raw.replace(/ /g, " ")
119
+ const newComponents: ComponentDict = {}
120
+
121
+ for (const [k, v] of Object.entries(row.components)) {
122
+ if (v) {
123
+ newComponents[k as ComponentTag] = v.replace(/ /g, " ")
124
+ }
125
+ }
126
+
127
+ return withAugmentation(row, "double-space", newRaw, newComponents)
128
+ }
129
+
130
+ /**
131
+ * Strip Unicode combining marks (accents, diacritics) from raw + components. "Hôtel" → "Hotel"; "Île-de-France" →
132
+ * "Ile-de-France". Returns null if the row has no accents.
133
+ */
134
+ export const accentStrip: Augmentation = (row) => {
135
+ const stripped = stripAccents(row.raw)
136
+
137
+ if (stripped === row.raw) return null
138
+ const newComponents: ComponentDict = {}
139
+
140
+ for (const [k, v] of Object.entries(row.components)) {
141
+ if (v) {
142
+ newComponents[k as ComponentTag] = stripAccents(v)
143
+ }
144
+ }
145
+
146
+ return withAugmentation(row, "accent-strip", stripped, newComponents)
147
+ }
148
+
149
+ function stripAccents(s: string): string {
150
+ return s.normalize("NFD").replace(/\p{M}/gu, "")
151
+ }
152
+
153
+ // --- typo injection (#530) -------------------------------------------------
154
+ // The Phase-1 deferral asked for a "seed-aware API" so the corpus stays reproducible. Resolution:
155
+ // seed the PRNG from the row's own `source_id` — deterministic per row, no global state, fits the
156
+ // existing `(row) => CanonicalRow | null` signature unchanged.
157
+
158
+ /** QWERTY adjacency for realistic single-key substitutions (lowercase; case is restored on apply). */
159
+ const QWERTY_ADJACENCY: Record<string, string> = {
160
+ a: "qwsz",
161
+ b: "vghn",
162
+ c: "xdfv",
163
+ d: "serfcx",
164
+ e: "wsdr",
165
+ f: "drtgvc",
166
+ g: "ftyhbv",
167
+ h: "gyujnb",
168
+ i: "ujko",
169
+ j: "huikmn",
170
+ k: "jiolm",
171
+ l: "kop",
172
+ m: "njk",
173
+ n: "bhjm",
174
+ o: "iklp",
175
+ p: "ol",
176
+ q: "wa",
177
+ r: "edft",
178
+ s: "awedxz",
179
+ t: "rfgy",
180
+ u: "yhji",
181
+ v: "cfgb",
182
+ w: "qase",
183
+ x: "zsdc",
184
+ y: "tghu",
185
+ z: "asx",
186
+ }
187
+
188
+ /**
189
+ * A component value eligible for a typo: a pure-letter name of ≥4 chars (excludes numbers/postcodes/units).
190
+ */
191
+ const ALPHA_NAME = /^[\p{L}][\p{L} '.-]{3,}$/u
192
+
193
+ /**
194
+ * Djb2 → uint32 seed. Deterministic; no `Math.random` (banned here and breaks corpus reproducibility).
195
+ */
196
+ function hashString(s: string): number {
197
+ let h = 5381
198
+
199
+ for (let i = 0; i < s.length; i++) {
200
+ h = ((h << 5) + h + s.charCodeAt(i)) >>> 0
201
+ }
202
+
203
+ return h >>> 0
204
+ }
205
+
206
+ /** Mulberry32 — a tiny seeded PRNG. Same seed → same stream → reproducible typos. */
207
+ function mulberry32(seed: number): () => number {
208
+ let a = seed >>> 0
209
+
210
+ return () => {
211
+ a = (a + 0x6d2b79f5) | 0
212
+ let t = Math.imul(a ^ (a >>> 15), 1 | a)
213
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
214
+
215
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Inject ONE realistic typo — an adjacent-QWERTY-key substitution OR an adjacent-character transposition — into a
221
+ * single alpha name component (street/locality/region…), teaching the model to recover from real-world misspellings
222
+ * ("Cupertino" → "Cupertimo"). The edit is applied to BOTH `raw` and the component so the substring contract `alignRow`
223
+ * depends on holds. Number / postcode / unit components are never touched (they fail {@link ALPHA_NAME}). Deterministic
224
+ * per row (seeded from `source_id`). Returns `null` when no eligible component exists or the edit is a no-op.
225
+ */
226
+ export const typoInject: Augmentation = (row) => {
227
+ const rng = mulberry32(hashString(`${row.source_id}:typo`))
228
+ // Count occurrences so we only edit an UNAMBIGUOUS target — a value that appears exactly once in
229
+ // raw and isn't a substring of another component. (e.g. "Cupertino" the locality is a substring of
230
+ // "Cupertino Avenue" the street; editing it would `replace` the street's occurrence and break the
231
+ // span. The substring contract `alignRow` enforces is why we filter rather than guess the position.)
232
+ const occurs = (needle: string): number => {
233
+ let n = 0
234
+
235
+ for (let i = row.raw.indexOf(needle); i >= 0; i = row.raw.indexOf(needle, i + needle.length)) {
236
+ n++
237
+ }
238
+
239
+ return n
240
+ }
241
+ const values = Object.values(row.components).filter(Boolean) as string[]
242
+ const eligible = (Object.entries(row.components) as Array<[ComponentTag, string]>).filter(
243
+ ([, v]) => v && ALPHA_NAME.test(v) && occurs(v) === 1 && !values.some((o) => o !== v && o.includes(v))
244
+ )
245
+
246
+ if (eligible.length === 0) return null
247
+ const [tag, value] = eligible[Math.floor(rng() * eligible.length)]!
248
+ // Interior alpha positions only — keep the first char (most real typos are interior) + a right neighbour.
249
+ const positions: number[] = []
250
+
251
+ for (let i = 1; i < value.length - 1; i++)
252
+ if (/\p{L}/u.test(value[i]!)) {
253
+ positions.push(i)
254
+ }
255
+
256
+ if (positions.length === 0) return null
257
+ const i = positions[Math.floor(rng() * positions.length)]!
258
+ const ch = value[i]!
259
+ let typed: string
260
+
261
+ if (rng() < 0.5) {
262
+ const next = value[i + 1]!
263
+
264
+ if (ch === next) return null // transposing equal chars is a no-op
265
+ typed = value.slice(0, i) + next + ch + value.slice(i + 2)
266
+ } else {
267
+ const lower = ch.toLowerCase()
268
+ const adj = QWERTY_ADJACENCY[lower]
269
+
270
+ if (!adj) return null
271
+ const sub = adj[Math.floor(rng() * adj.length)]!
272
+ typed = value.slice(0, i) + (ch !== lower ? sub.toUpperCase() : sub) + value.slice(i + 1)
273
+ }
274
+
275
+ if (typed === value) return null
276
+ const newRaw = row.raw.replace(value, typed)
277
+
278
+ // first occurrence; `replace(string, …)` is literal, not regex
279
+ return withAugmentation(row, "typo-inject", newRaw, { ...row.components, [tag]: typed })
280
+ }
281
+
282
+ // ===========================================================================
283
+ // US-specific augmentations
284
+ // ===========================================================================
285
+
286
+ /** US state full ↔ alpha-2 mapping. Two-way: `STATE_TO_ABBR["Oregon"] = "OR"`. */
287
+ const STATE_NAME_TO_ABBR: Record<string, string> = {
288
+ Alabama: "AL",
289
+ Alaska: "AK",
290
+ Arizona: "AZ",
291
+ Arkansas: "AR",
292
+ California: "CA",
293
+ Colorado: "CO",
294
+ Connecticut: "CT",
295
+ Delaware: "DE",
296
+ Florida: "FL",
297
+ Georgia: "GA",
298
+ Hawaii: "HI",
299
+ Idaho: "ID",
300
+ Illinois: "IL",
301
+ Indiana: "IN",
302
+ Iowa: "IA",
303
+ Kansas: "KS",
304
+ Kentucky: "KY",
305
+ Louisiana: "LA",
306
+ Maine: "ME",
307
+ Maryland: "MD",
308
+ Massachusetts: "MA",
309
+ Michigan: "MI",
310
+ Minnesota: "MN",
311
+ Mississippi: "MS",
312
+ Missouri: "MO",
313
+ Montana: "MT",
314
+ Nebraska: "NE",
315
+ Nevada: "NV",
316
+ "New Hampshire": "NH",
317
+ "New Jersey": "NJ",
318
+ "New Mexico": "NM",
319
+ "New York": "NY",
320
+ "North Carolina": "NC",
321
+ "North Dakota": "ND",
322
+ Ohio: "OH",
323
+ Oklahoma: "OK",
324
+ Oregon: "OR",
325
+ Pennsylvania: "PA",
326
+ "Rhode Island": "RI",
327
+ "South Carolina": "SC",
328
+ "South Dakota": "SD",
329
+ Tennessee: "TN",
330
+ Texas: "TX",
331
+ Utah: "UT",
332
+ Vermont: "VT",
333
+ Virginia: "VA",
334
+ Washington: "WA",
335
+ "West Virginia": "WV",
336
+ Wisconsin: "WI",
337
+ Wyoming: "WY",
338
+ "District of Columbia": "DC",
339
+ }
340
+
341
+ const STATE_ABBR_TO_NAME: Record<string, string> = Object.fromEntries(
342
+ Object.entries(STATE_NAME_TO_ABBR).map(([k, v]) => [v, k])
343
+ )
344
+
345
+ /** US: substitute the full state name for its alpha-2 abbreviation. */
346
+ export const stateExpand: Augmentation = (row) => {
347
+ if (row.country !== "US") return null
348
+ const region = row.components.region
349
+
350
+ if (!region) return null
351
+ const full = STATE_ABBR_TO_NAME[region]
352
+
353
+ if (!full) return null
354
+ // Replace the bounded "OR" surface form with "Oregon" in raw. Use word boundaries so we
355
+ // don't match inside "Stop" or similar.
356
+ const re = new RegExp(`\\b${region}\\b`, "g")
357
+
358
+ if (!re.test(row.raw)) return null
359
+ const newRaw = row.raw.replace(new RegExp(`\\b${region}\\b`, "g"), full)
360
+ const newComponents: ComponentDict = { ...row.components, region: full }
361
+
362
+ return withAugmentation(row, "state-expand", newRaw, newComponents)
363
+ }
364
+
365
+ /** US: substitute the alpha-2 abbreviation for the full state name. */
366
+ export const stateAbbreviate: Augmentation = (row) => {
367
+ if (row.country !== "US") return null
368
+ const region = row.components.region
369
+
370
+ if (!region) return null
371
+ const abbr = STATE_NAME_TO_ABBR[region]
372
+
373
+ if (!abbr) return null
374
+ const re = new RegExp(`\\b${region}\\b`, "g")
375
+
376
+ if (!re.test(row.raw)) return null
377
+ const newRaw = row.raw.replace(new RegExp(`\\b${region}\\b`, "g"), abbr)
378
+ const newComponents: ComponentDict = { ...row.components, region: abbr }
379
+
380
+ return withAugmentation(row, "state-abbreviate", newRaw, newComponents)
381
+ }
382
+
383
+ const DIRECTIONAL_FULL_TO_ABBR: Record<string, string> = {
384
+ North: "N",
385
+ South: "S",
386
+ East: "E",
387
+ West: "W",
388
+ Northeast: "NE",
389
+ Northwest: "NW",
390
+ Southeast: "SE",
391
+ Southwest: "SW",
392
+ }
393
+ const DIRECTIONAL_ABBR_TO_FULL: Record<string, string> = Object.fromEntries(
394
+ Object.entries(DIRECTIONAL_FULL_TO_ABBR).map(([k, v]) => [v, k])
395
+ )
396
+
397
+ /** US: expand directional abbreviations in `street`/`street_suffix` (NW → Northwest). */
398
+ export const directionalExpand: Augmentation = (row) => {
399
+ if (row.country !== "US") return null
400
+ const tagsToCheck: ComponentTag[] = ["street", "street_suffix", "street_prefix"]
401
+ let changed = false
402
+ let newRaw = row.raw
403
+ const newComponents: ComponentDict = { ...row.components }
404
+
405
+ for (const tag of tagsToCheck) {
406
+ const v = newComponents[tag]
407
+
408
+ if (!v) continue
409
+ const replaced = v.replace(/\b(N|S|E|W|NE|NW|SE|SW)\b/g, (m) => DIRECTIONAL_ABBR_TO_FULL[m] ?? m)
410
+
411
+ if (replaced !== v) {
412
+ newComponents[tag] = replaced
413
+ newRaw = newRaw.replace(new RegExp(`\\b${escapeRegex(v)}\\b`, "g"), replaced)
414
+ changed = true
415
+ }
416
+ }
417
+
418
+ if (!changed) return null
419
+
420
+ return withAugmentation(row, "directional-expand", newRaw, newComponents)
421
+ }
422
+
423
+ /** US: abbreviate directional words (Northwest → NW). */
424
+ export const directionalAbbreviate: Augmentation = (row) => {
425
+ if (row.country !== "US") return null
426
+ const tagsToCheck: ComponentTag[] = ["street", "street_suffix", "street_prefix"]
427
+ let changed = false
428
+ let newRaw = row.raw
429
+ const newComponents: ComponentDict = { ...row.components }
430
+
431
+ for (const tag of tagsToCheck) {
432
+ const v = newComponents[tag]
433
+
434
+ if (!v) continue
435
+ const replaced = v.replace(
436
+ /\b(North|South|East|West|Northeast|Northwest|Southeast|Southwest)\b/g,
437
+ (m) => DIRECTIONAL_FULL_TO_ABBR[m] ?? m
438
+ )
439
+
440
+ if (replaced !== v) {
441
+ newComponents[tag] = replaced
442
+ newRaw = newRaw.replace(new RegExp(`\\b${escapeRegex(v)}\\b`, "g"), replaced)
443
+ changed = true
444
+ }
445
+ }
446
+
447
+ if (!changed) return null
448
+
449
+ return withAugmentation(row, "directional-abbreviate", newRaw, newComponents)
450
+ }
451
+
452
+ /**
453
+ * US: swap the trailing street-suffix word in `components.street` to its preferred USPS abbreviation, preserving case.
454
+ * `"5th Avenue"` → `"5th Ave"`; `"5TH AVENUE"` → `"5TH AVE"`; `"main street"` → `"main st"`. Returns null when no
455
+ * trailing suffix is recognized, when the trailing word is already the preferred abbreviation, or when the swap would
456
+ * leave `raw` un- touched (alignment requires both raw and components to move in lockstep).
457
+ *
458
+ * Targets the trailing word only to avoid mangling streets like "Avenue of the Americas" where the suffix-shaped word
459
+ * is part of the proper name rather than a USPS suffix.
460
+ */
461
+ export const streetSuffixAbbreviate: Augmentation = (row) => {
462
+ if (row.country !== "US") return null
463
+ const street = row.components.street
464
+
465
+ if (!street) return null
466
+ const match = matchTrailingSuffix(street)
467
+
468
+ if (!match) return null
469
+
470
+ const preferred = US_STREET_SUFFIX_PREFERRED_ABBR[match.canonical]
471
+ const target = matchCase(preferred, match.matched)
472
+
473
+ if (target === match.matched) return null
474
+
475
+ const newStreet = `${street.slice(0, street.lastIndexOf(match.matched))}${target}`
476
+
477
+ if (newStreet === street) return null
478
+
479
+ const newComponents: ComponentDict = { ...row.components, street: newStreet }
480
+ const newRaw = row.raw.replace(new RegExp(`\\b${escapeRegex(street)}\\b`, "g"), newStreet)
481
+
482
+ if (newRaw === row.raw) return null
483
+
484
+ return withAugmentation(row, "us-street-suffix-abbreviate", newRaw, newComponents)
485
+ }
486
+
487
+ /**
488
+ * US: swap the trailing street-suffix word in `components.street` to its full canonical form, preserving case. `"5th
489
+ * Ave"` → `"5th Avenue"`; `"5TH AVE"` → `"5TH AVENUE"`; `"main st"` → `"main street"`. Returns null when no trailing
490
+ * suffix is recognized, when the trailing word is already the canonical full form, or when the swap would leave `raw`
491
+ * untouched.
492
+ *
493
+ * Same trailing-word-only rule as `streetSuffixAbbreviate`.
494
+ */
495
+ export const streetSuffixExpand: Augmentation = (row) => {
496
+ if (row.country !== "US") return null
497
+ const street = row.components.street
498
+
499
+ if (!street) return null
500
+ const match = matchTrailingSuffix(street)
501
+
502
+ if (!match) return null
503
+
504
+ const target = matchCase(match.canonical, match.matched)
505
+
506
+ if (target === match.matched) return null
507
+
508
+ const newStreet = `${street.slice(0, street.lastIndexOf(match.matched))}${target}`
509
+
510
+ if (newStreet === street) return null
511
+
512
+ const newComponents: ComponentDict = { ...row.components, street: newStreet }
513
+ const newRaw = row.raw.replace(new RegExp(`\\b${escapeRegex(street)}\\b`, "g"), newStreet)
514
+
515
+ if (newRaw === row.raw) return null
516
+
517
+ return withAugmentation(row, "us-street-suffix-expand", newRaw, newComponents)
518
+ }
519
+
520
+ /**
521
+ * US: swap the LEADING secondary-unit designator in `components.unit` to its approved USPS abbreviation, preserving
522
+ * case + the identifier. `"Apartment 4B"` → `"Apt 4B"`; `"SUITE 200"` → `"STE 200"`; `"floor 3"` → `"fl 3"`. Returns
523
+ * null when the unit has no recognized leading designator (a bare `"4B"` / `"#210"`), the designator is already the
524
+ * approved abbreviation, or the swap would leave `raw` untouched.
525
+ *
526
+ * Mirrors `streetSuffixAbbreviate`, but designators LEAD the unit (vs suffixes that trail the street). Sourced from the
527
+ * USPS Pub-28 C2 codex — the data-generation counterpart to the runtime `UnitDesignatorClassifier`.
528
+ */
529
+ export const unitDesignatorAbbreviate: Augmentation = (row) => {
530
+ if (row.country !== "US") return null
531
+ const unit = row.components.unit
532
+
533
+ if (!unit) return null
534
+ const match = matchLeadingDesignator(unit)
535
+
536
+ if (!match) return null
537
+
538
+ const preferred = US_UNIT_DESIGNATOR_PREFERRED_ABBR[match.canonical]
539
+ const target = matchCase(preferred, match.matched)
540
+
541
+ if (target === match.matched) return null
542
+
543
+ const newUnit = `${target}${unit.slice(match.matched.length)}`
544
+
545
+ if (newUnit === unit) return null
546
+
547
+ const newComponents: ComponentDict = { ...row.components, unit: newUnit }
548
+ const newRaw = row.raw.replace(new RegExp(`\\b${escapeRegex(unit)}\\b`, "g"), newUnit)
549
+
550
+ if (newRaw === row.raw) return null
551
+
552
+ return withAugmentation(row, "us-unit-designator-abbreviate", newRaw, newComponents)
553
+ }
554
+
555
+ /**
556
+ * US: swap the LEADING secondary-unit designator in `components.unit` to its full canonical form, preserving case + the
557
+ * identifier. `"Apt 4B"` → `"Apartment 4B"`; `"STE 200"` → `"SUITE 200"`. Returns null when there's no recognized
558
+ * leading designator, it's already the canonical word, or the swap would leave `raw` untouched. Same leading-word-only
559
+ * rule as `unitDesignatorAbbreviate`.
560
+ */
561
+ export const unitDesignatorExpand: Augmentation = (row) => {
562
+ if (row.country !== "US") return null
563
+ const unit = row.components.unit
564
+
565
+ if (!unit) return null
566
+ const match = matchLeadingDesignator(unit)
567
+
568
+ if (!match) return null
569
+
570
+ const target = matchCase(match.canonical, match.matched)
571
+
572
+ if (target === match.matched) return null
573
+
574
+ const newUnit = `${target}${unit.slice(match.matched.length)}`
575
+
576
+ if (newUnit === unit) return null
577
+
578
+ const newComponents: ComponentDict = { ...row.components, unit: newUnit }
579
+ const newRaw = row.raw.replace(new RegExp(`\\b${escapeRegex(unit)}\\b`, "g"), newUnit)
580
+
581
+ if (newRaw === row.raw) return null
582
+
583
+ return withAugmentation(row, "us-unit-designator-expand", newRaw, newComponents)
584
+ }
585
+
586
+ /** US: ZIP+4 form `12345-6789` → `123456789` (dash dropped). */
587
+ export const zipPlus4DashDrop: Augmentation = (row) => {
588
+ if (row.country !== "US") return null
589
+ const postcode = row.components.postcode
590
+
591
+ if (!postcode || !/^\d{5}-\d{4}$/.test(postcode)) return null
592
+ const noDash = postcode.replace("-", "")
593
+ const newRaw = row.raw.replace(postcode, noDash)
594
+
595
+ if (newRaw === row.raw) return null
596
+
597
+ return withAugmentation(row, "zip-plus4-dash-drop", newRaw, { ...row.components, postcode: noDash })
598
+ }
599
+
600
+ // ===========================================================================
601
+ // FR-specific augmentations
602
+ // ===========================================================================
603
+
604
+ /** FR: drop the article particle from a street ("Rue de la République" → "Rue République"). */
605
+ export const particleStrip: Augmentation = (row) => {
606
+ if (row.country !== "FR") return null
607
+ const particle = row.components.street_prefix_particle
608
+
609
+ if (!particle) return null
610
+ const newComponents: ComponentDict = { ...row.components }
611
+ delete newComponents.street_prefix_particle
612
+ // Drop the particle from raw, then collapse any double spaces.
613
+ const re = new RegExp(`\\s+${escapeRegex(particle)}\\s+`, "g")
614
+
615
+ if (!re.test(row.raw)) return null
616
+ const newRaw = row.raw.replace(re, " ").replace(/\s+/g, " ").trim()
617
+
618
+ return withAugmentation(row, "particle-strip", newRaw, newComponents)
619
+ }
620
+
621
+ // ===========================================================================
622
+ // Registry + default policies
623
+ // ===========================================================================
624
+
625
+ /** Stable id → augmentation table. */
626
+ export const AUGMENTATIONS: Record<string, Augmentation> = {
627
+ "case-upper": caseUpper,
628
+ "case-lower": caseLower,
629
+ "drop-commas": dropCommas,
630
+ "double-space": doubleSpace,
631
+ "accent-strip": accentStrip,
632
+ "state-expand": stateExpand,
633
+ "state-abbreviate": stateAbbreviate,
634
+ "directional-expand": directionalExpand,
635
+ "directional-abbreviate": directionalAbbreviate,
636
+ "us-street-suffix-abbreviate": streetSuffixAbbreviate,
637
+ "us-street-suffix-expand": streetSuffixExpand,
638
+ "us-unit-designator-abbreviate": unitDesignatorAbbreviate,
639
+ "us-unit-designator-expand": unitDesignatorExpand,
640
+ "zip-plus4-dash-drop": zipPlus4DashDrop,
641
+ "particle-strip": particleStrip,
642
+ "typo-inject": typoInject,
643
+ }
644
+
645
+ /** Default augmentation set, by country. Phase 1: US + FR; others get the locale-agnostic set. */
646
+ export function defaultAugmentationsForCountry(country: string): readonly Augmentation[] {
647
+ // `typoInject` (#530) is deliberately NOT in the default set. It is implemented, tested, and
648
+ // registered in {@link AUGMENTATIONS} so callers can opt in (add it here or compose it directly),
649
+ // but it changes the synthesized corpus distribution and its effect on the trained model is not
650
+ // yet measured. Per the project's default-OFF discipline, promotion into the default build is an
651
+ // operator call once an A/B vs the current corpus exists — keeping the default byte-stable.
652
+ const universal = [caseUpper, caseLower, dropCommas, doubleSpace]
653
+
654
+ switch (country) {
655
+ case "US":
656
+ return [
657
+ ...universal,
658
+ stateExpand,
659
+ stateAbbreviate,
660
+ directionalExpand,
661
+ directionalAbbreviate,
662
+ streetSuffixAbbreviate,
663
+ streetSuffixExpand,
664
+ unitDesignatorAbbreviate,
665
+ unitDesignatorExpand,
666
+ zipPlus4DashDrop,
667
+ ]
668
+ case "FR":
669
+ return [...universal, accentStrip, particleStrip]
670
+ default:
671
+ return universal
672
+ }
673
+ }
674
+
675
+ /**
676
+ * Run every augmentation against a row; collect the non-null outputs. The augmentations are pure, so callers can
677
+ * compose them off this generator (e.g. nesting accent-strip ∘ state-abbreviate).
678
+ */
679
+ export function* synthesizeRow(
680
+ row: CanonicalRow,
681
+ augmentations: readonly Augmentation[] = defaultAugmentationsForCountry(row.country)
682
+ ): Generator<CanonicalRow> {
683
+ for (const aug of augmentations) {
684
+ const out = aug(row)
685
+
686
+ if (out) {
687
+ yield out
688
+ }
689
+ }
690
+ }
691
+
692
+ function escapeRegex(s: string): string {
693
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
694
+ }
695
+
696
+ // ===========================================================================
697
+ // Compositional synthesis (Phase 1.6 §2.1)
698
+ // ===========================================================================
699
+ //
700
+ // The single-row augmentations above transform one `CanonicalRow` into another with `raw` and
701
+ // `components` moved in lockstep, leaving alignment to derive labels downstream. Composition is
702
+ // fundamentally different: it takes a **venue string** and an **address row** from a different
703
+ // source and renders them together as a single `raw`, producing adversarial training examples
704
+ // where embedded place-shaped tokens collide with real address components.
705
+ //
706
+ // Naive post-hoc alignment of the composed string would mis-label the embedded tokens: a venue
707
+ // like `"Buffalo Health Clinic"` shares the token `"Buffalo"` with an address locality
708
+ // `"Buffalo, NY"`, and alignment's leftmost-substring search would claim the venue's `"Buffalo"`
709
+ // as the locality (or vice versa, depending on order). Composition therefore **emits labels
710
+ // directly** — venue tokens are unconditionally labeled `B-venue` / `I-venue`, and the address
711
+ // half re-uses the labels produced by aligning the un-composed address row in isolation. No
712
+ // re-search across the composed boundary.
713
+ //
714
+ // Why a separate primitive (not an `Augmentation`):
715
+ //
716
+ // - Augmentations are unary `(CanonicalRow) -> CanonicalRow | null` and run through
717
+ // `synthesizeRow`. Composition is binary `(string, CanonicalRow) -> LabeledRow` and emits
718
+ // `LabeledRow` directly (it cannot defer labels to alignment without the embedded-token bug).
719
+ // - Augmentations preserve provenance to a single source; compositions cite the address source
720
+ // in `synth.base_source_id` and carry the venue surface form on the `venue` component.
721
+ // - Throttling (the issue calls for ~5-15% of training set) is a build-time policy, not an
722
+ // adapter-level concern — the build pipeline applies it; the primitive stays pure.
723
+ //
724
+ // See `DECISIONS.md` for the rationale on why composition lives alongside augmentation but is
725
+ // not part of the `AUGMENTATIONS` registry.
726
+
727
+ /** Options accepted by `composeAdversarialRow`. */
728
+ export interface ComposeAdversarialOptions {
729
+ /**
730
+ * Stable pattern label written into the emitted row's `synth.method` field (as `compose:<pattern>`). Free-form but
731
+ * should be one of a small set of canonical pattern names so downstream filtering / stratification can target
732
+ * individual patterns.
733
+ *
734
+ * Recommended values (Phase 1.6 §2.1):
735
+ *
736
+ * - `"place-name-venue"` — venue token shared with locality (`Buffalo Health Clinic, Buffalo NY`).
737
+ * - `"place-shaped-venue"` — venue contains a place-shaped substring (`New York, New York Steakhouse, Las Vegas NV`).
738
+ * - `"particle-honorific"` — apostrophe + St./Saint ambiguity (`P'tit St. Denis Street Café`).
739
+ */
740
+ pattern: string
741
+
742
+ /**
743
+ * Separator inserted between the venue and the address `raw`. Default `", "`. Single space (`" "`) produces the
744
+ * harder unpunctuated variant; newline (`"\n"`) the multi-line variant.
745
+ */
746
+ separator?: string
747
+
748
+ /**
749
+ * Tokenizer to apply to the venue prefix. Default `whitespaceTokenizer()`. The address half uses the same tokenizer
750
+ * when re-aligned — pass a consistent one if customizing.
751
+ */
752
+ tokenizer?: Tokenizer
753
+ }
754
+
755
+ /** Either a successful labeled composition or a quarantined attempt. */
756
+ export type ComposeResult = { kind: "labeled"; row: LabeledRow } | { kind: "quarantined"; row: QuarantinedRow }
757
+
758
+ /**
759
+ * Compose a venue string + an address row into a single adversarial `LabeledRow`.
760
+ *
761
+ * The emitted row's `raw` is `${venue}${separator}${address.raw}`. Tokens are produced by tokenizing the two halves
762
+ * independently and concatenating; labels are venue tokens → `B-venue` / `I-venue` followed by the address's labels
763
+ * (obtained by aligning the input address in isolation). This deterministic boundary is the entire point of the
764
+ * primitive: the embedded place-shaped tokens in the venue stay labeled as `venue`, never as the address's locality /
765
+ * region / etc., even when they share surface forms.
766
+ *
767
+ * The char-offset span triple (#519) is re-targeted to the composed surface by the same deterministic boundary: one
768
+ * `venue` span over `[0, venue.length)` (no re-search), then the address's own spans shifted by `venue.length +
769
+ * separator.length` — plain offset arithmetic, no token indirection. The separator chars sit outside every span
770
+ * (deliberately unlabeled — now expressible). The composed triple is passed through `assertSpanInvariants` so a
771
+ * composition bug can't ride into a corpus.
772
+ *
773
+ * The address's components are forwarded as-is (alignment ran on them and they survived); `venue` is added on top with
774
+ * the trimmed venue string as its surface form.
775
+ *
776
+ * Returns `{ kind: "quarantined" }` when:
777
+ *
778
+ * - The venue is empty or whitespace-only.
779
+ * - The venue is not NFC-normalized (char offsets over a non-NFC raw are ambiguous — the same discipline `alignRow`
780
+ * enforces on adapter rows, surfaced as quarantine here because the venue is caller-supplied data).
781
+ * - The address row fails alignment in isolation (the underlying failure reason is propagated).
782
+ */
783
+ export function composeAdversarialRow(
784
+ venue: string,
785
+ address: CanonicalRow,
786
+ options: ComposeAdversarialOptions
787
+ ): ComposeResult {
788
+ const separator = options.separator ?? ", "
789
+ const tokenizer = options.tokenizer ?? whitespaceTokenizer()
790
+
791
+ const venueTrimmed = venue.trim()
792
+
793
+ if (!venueTrimmed) {
794
+ return { kind: "quarantined", row: { row: address, reason: "venue-empty" } }
795
+ }
796
+
797
+ // Char-offset spans over the composed raw are only meaningful under NFC (#519) — the address
798
+ // half is enforced by alignRow; the venue is caller-supplied and checked here.
799
+ if (venueTrimmed.normalize("NFC") !== venueTrimmed) {
800
+ return { kind: "quarantined", row: { row: address, reason: "venue-not-nfc" } }
801
+ }
802
+
803
+ const addressAligned = alignRow(address, { tokenizer })
804
+
805
+ if (addressAligned.kind !== "labeled") {
806
+ // Surface the address's quarantine reason but tag it with the compose attempt for
807
+ // debugging. The original CanonicalRow stays on the QuarantinedRow so callers can
808
+ // inspect the address payload.
809
+ return {
810
+ kind: "quarantined",
811
+ row: { row: address, reason: `compose-address-${addressAligned.row.reason}` },
812
+ }
813
+ }
814
+
815
+ const venueTokens = tokenizer.tokenize(venueTrimmed)
816
+
817
+ if (venueTokens.length === 0) {
818
+ return { kind: "quarantined", row: { row: address, reason: "venue-no-tokens" } }
819
+ }
820
+
821
+ const venueLabels: BIOLabel[] = venueTokens.map((_, i) => (i === 0 ? "B-venue" : "I-venue"))
822
+
823
+ const tokens: string[] = [...venueTokens.map((t) => t.text), ...addressAligned.row.tokens]
824
+ const labels: BIOLabel[] = [...venueLabels, ...addressAligned.row.labels]
825
+
826
+ const composedRaw = `${venueTrimmed}${separator}${address.raw}`
827
+ const composedComponents = {
828
+ venue: venueTrimmed,
829
+ ...address.components,
830
+ }
831
+
832
+ // Re-target the char-offset spans (#519) onto the composed surface: the venue span covers the
833
+ // whole trimmed venue (internal punctuation included — the token path cannot say that), and the
834
+ // address's spans shift right by the venue + separator length. alignRow emits the triple on
835
+ // every labeled row, so absence here is an alignment-contract bug, not data — fail loudly.
836
+ const { span_starts: addrStarts, span_ends: addrEnds, span_tags: addrTags } = addressAligned.row
837
+
838
+ if (addrStarts === undefined || addrEnds === undefined || addrTags === undefined) {
839
+ throw new Error(
840
+ `composeAdversarialRow: alignRow returned a labeled row without the span triple ` +
841
+ `(source=${address.source}, source_id=${address.source_id}) — alignment contract violation`
842
+ )
843
+ }
844
+ const offset = venueTrimmed.length + separator.length
845
+ const spans: ComponentSpan[] = [
846
+ { tag: "venue", start: 0, end: venueTrimmed.length },
847
+ ...addrTags.map((tag, i) => ({ tag, start: addrStarts[i]! + offset, end: addrEnds[i]! + offset })),
848
+ ]
849
+
850
+ const baseSourceID = address.synth?.base_source_id ?? address.source_id
851
+ const method = `compose:${options.pattern}`
852
+
853
+ const composed: LabeledRow = {
854
+ raw: composedRaw,
855
+ components: composedComponents,
856
+ country: address.country,
857
+ locale: address.locale,
858
+ source: address.source,
859
+ source_id: `${address.source_id}+${method}`,
860
+ corpus_version: address.corpus_version,
861
+ license: address.license,
862
+ synth: { method, base_source_id: baseSourceID },
863
+ tokens,
864
+ labels,
865
+ span_starts: spans.map((s) => s.start),
866
+ span_ends: spans.map((s) => s.end),
867
+ span_tags: spans.map((s) => s.tag),
868
+ }
869
+ assertSpanInvariants(spans, composed)
870
+
871
+ return { kind: "labeled", row: composed }
872
+ }