@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,212 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `fr-admin-split` shard recipe — the FR admin-split coverage shard (night 2026-06-19,
7
+ * surpass-v1.5.0). Teaches the model to SPLIT the département out of the locality on
8
+ * bare/space/comma-delimited French place rows — the admin-deciding failure class the pre-GPU
9
+ * self-validation proved moves the resolved coordinate (collision communes −61%; see
10
+ * docs/articles/evals/experiments/2026-06-19-fr-admin-split-prevalidation.md). Ported from
11
+ * scripts/build-fr-admin-split-shard.mjs.
12
+ *
13
+ * Failure shapes (the model currently mis-handles all of these):
14
+ *
15
+ * - `Thauron, Creuse` → région dropped to null (the comma+full-name miss)
16
+ * - `Montredon, Lozère` → région = "ère" (the diacritic subword split, #727)
17
+ * - (AU analog) `CANBERRA ACT` → the space-delimited admin fuse
18
+ *
19
+ * The département is the essential admin unit for FR postal geography and maps to the `region`
20
+ * component tag in our schema. We derive it DETERMINISTICALLY from the real postcode via codex
21
+ * `departementForCodePostal` (first two digits = département) — salvage-first, no re-derived
22
+ * table.
23
+ *
24
+ * Data (`--communes`, opts.communes): REAL BAN (Base Adresse Nationale) commune+postcode+coord
25
+ * tuples, one per line, TAB-separated `commune <TAB> postcode <TAB> lon <TAB> lat`. Build the
26
+ * input TSV once from the BAN staging CSV (see the legacy script header). Anchor-ON by
27
+ * construction: rows carry a REAL postcode token in `raw` + a `postcode` component, so the
28
+ * training loader paints the anchor feature onto that span automatically. The trailing-postcode
29
+ * anchor REINFORCES the FR split (FR postcode is trailing, unlike German PLZ-leading — the v0.9.2
30
+ * scar is positional, not universal).
31
+ */
32
+
33
+ import { departementForCodePostal } from "@mailwoman/codex/fr"
34
+ import type { ComponentTag } from "@mailwoman/core/types"
35
+ import { CSVSpliterator, Delimiters } from "spliterator"
36
+
37
+ import { stableSourceID } from "../adapter.ts"
38
+ import { alignRow } from "../align.ts"
39
+ import type { CanonicalRow } from "../types.ts"
40
+ import { makeMulberry32, type ShardRecipe } from "./scaffold.ts"
41
+
42
+ const DEFAULT_COMMUNES = "/tmp/reg/fr-communes.tsv"
43
+ const LICENSE = "BAN (Base Adresse Nationale) commune+postcode tuples, rendered admin-split — see ingest SOURCE"
44
+
45
+ /** One distinct commune row from the TSV, with the département derived from its postcode. */
46
+ interface CommuneRow {
47
+ commune: string
48
+ postcode: string
49
+ departement: string
50
+ lon: string | undefined
51
+ lat: string | undefined
52
+ }
53
+
54
+ /** One rendered admin-split variant. */
55
+ interface AdminSplitVariant {
56
+ raw: string
57
+ components: Partial<Record<ComponentTag, string>>
58
+ order: string
59
+ }
60
+
61
+ /** Read the distinct commune TSV (commune, postcode, lon, lat); derive the département name. */
62
+ async function readCommunes(path: string): Promise<CommuneRow[]> {
63
+ const rows: CommuneRow[] = []
64
+
65
+ // The TSV is headerless — every line is a commune tuple — so `header: false` keeps row 1 instead of
66
+ // spending it on column names. Source is repo-generated (LF); even under CRLF only the trailing `lat`
67
+ // column carries a CR, and it's consumed via `Number()` (whitespace-trimming), so it stays harmless.
68
+ for await (const [commune, postcode, lon, lat] of CSVSpliterator.fromAsync(path, {
69
+ columnDelimiter: Delimiters.Tab,
70
+ header: false,
71
+ })) {
72
+ if (!commune || !postcode) continue
73
+ const dep = departementForCodePostal(postcode)
74
+
75
+ if (!dep) continue // bad/unmappable postcode — skip (CEDEX, etc.)
76
+ // Substring invariant: a département whose name isn't a clean token (none are) or a commune
77
+ // containing the département name would confuse alignment — both are vanishingly rare here.
78
+ rows.push({ commune, postcode, departement: dep.name, lon, lat })
79
+ }
80
+
81
+ return rows
82
+ }
83
+
84
+ /**
85
+ * Render one admin-split variant. The CORE teaching signal: the département, even as a full word after a comma or a
86
+ * space, is `region` — never folded into `locality`. Variants 1-3 are the failure class; 4-5 are canonical-FR
87
+ * preservation so the model doesn't over-fire region on every trailing token (and the bare commune still resolves).
88
+ */
89
+ function render(random: () => number, c: CommuneRow): AdminSplitVariant {
90
+ const r = random()
91
+ const loc = random() < 0.1 ? c.commune.toUpperCase() : c.commune
92
+ const dep = c.departement
93
+ const pc = c.postcode
94
+ let out: AdminSplitVariant
95
+
96
+ if (r < 0.25) {
97
+ // 1. bare comma, NO postcode — the Thauron/#727 shape (anchor off)
98
+ out = { raw: `${loc}, ${dep}`, components: { locality: loc, region: dep }, order: "bare-comma" }
99
+ } else if (r < 0.5) {
100
+ // 2. bare comma + postcode — anchor ON
101
+ out = {
102
+ raw: `${loc}, ${dep} ${pc}`,
103
+ components: { locality: loc, region: dep, postcode: pc },
104
+ order: "bare-comma-pc",
105
+ }
106
+ } else if (r < 0.7) {
107
+ // 3. space-delimited admin (the AU `CANBERRA ACT` fuse applied to FR) — anchor ON
108
+ out = { raw: `${loc} ${dep} ${pc}`, components: { locality: loc, region: dep, postcode: pc }, order: "space-pc" }
109
+ } else if (r < 0.85) {
110
+ // 4. canonical FR postcode-first (NO département) — preservation, anchor ON
111
+ out = { raw: `${pc} ${loc}`, components: { postcode: pc, locality: loc }, order: "canonical-pc-first" }
112
+ } else {
113
+ // 5. commune + postcode (NO département) — preservation, anchor ON
114
+ out = { raw: `${loc} ${pc}`, components: { locality: loc, postcode: pc }, order: "commune-pc" }
115
+ }
116
+
117
+ // fr.country preservation (the v1.8.0 #728 finding): the v1.8.0 shard's bare rows carried NO country
118
+ // token, so the model under-emitted country on FR (fr.country −3.5pp). ~20% of rows now append an
119
+ // explicit "France" + a `country` component — the model relearns to emit country WHEN the token is
120
+ // present without over-firing it on the (still-majority) country-less rows. Substring invariant holds.
121
+ if (random() < 0.2) {
122
+ out = {
123
+ raw: `${out.raw}, France`,
124
+ components: { ...out.components, country: "France" },
125
+ order: `${out.order}+fr`,
126
+ }
127
+ }
128
+
129
+ return out
130
+ }
131
+
132
+ export const frAdminSplitRecipe: ShardRecipe = {
133
+ name: "fr-admin-split",
134
+ description: "FR admin-split rows: BAN communes → split département into `region` (+ canonical-FR preservation)",
135
+ mode: "generate",
136
+ options: [
137
+ { flag: "--communes <tsv>", description: "BAN commune+postcode+coord TSV. Default /tmp/reg/fr-communes.tsv" },
138
+ ],
139
+ async run(opts, write) {
140
+ // Legacy build-fr-admin-split-shard.mjs seeded `mulberry32(opts.seed)`.
141
+ const random = makeMulberry32(opts.seed)
142
+ const count = opts.count ?? 60000
143
+ const source = opts.sourceName ?? "synth-fr-admin-split"
144
+ const communesPath = opts.communes ?? DEFAULT_COMMUNES
145
+
146
+ const pool = await readCommunes(communesPath)
147
+ console.error(` ${communesPath}: ${pool.length} communes with derived département`)
148
+
149
+ if (pool.length === 0) {
150
+ throw new Error("No communes — build the TSV from BAN first (see the recipe header).")
151
+ }
152
+
153
+ let emitted = 0
154
+ let skipped = 0
155
+ let guard = 0
156
+ const orderCounts: Record<string, number> = {}
157
+ const N = pool.length
158
+
159
+ while (emitted < count && guard++ < count * 12) {
160
+ const base = pool[Math.floor(random() * N)]!
161
+ const { raw, components, order } = render(random, base)
162
+
163
+ // Alignment precondition: every component surface appears verbatim in raw.
164
+ const values = Object.values(components).filter((v): v is string => Boolean(v))
165
+
166
+ if (!values.every((v) => raw.includes(v))) {
167
+ skipped++
168
+ continue
169
+ }
170
+
171
+ if (opts.golden) {
172
+ // Held-out eval slice for the centroid gate — carries the truth coordinate.
173
+ write(JSON.stringify({ raw, components, country: "FR", lat: Number(base.lat), lon: Number(base.lon) }) + "\n")
174
+ emitted++
175
+ orderCounts[order] = (orderCounts[order] ?? 0) + 1
176
+ continue
177
+ }
178
+
179
+ const sourceID = stableSourceID(source, {
180
+ locality: components.locality,
181
+ region: components.region,
182
+ postcode: components.postcode,
183
+ })
184
+ const canonical: CanonicalRow = {
185
+ raw,
186
+ components,
187
+ country: "FR",
188
+ locale: "fr-FR",
189
+ source,
190
+ source_id: sourceID,
191
+ corpus_version: "0.5.0",
192
+ license: LICENSE,
193
+ }
194
+ const aligned = alignRow(canonical)
195
+
196
+ if (aligned.kind !== "labeled" || !aligned.row) {
197
+ skipped++
198
+ continue
199
+ }
200
+ write(
201
+ JSON.stringify({ ...aligned.row, synth_method: "fr-admin-split", synth_order: order, synth_base_id: null }) +
202
+ "\n"
203
+ )
204
+ emitted++
205
+ orderCounts[order] = (orderCounts[order] ?? 0) + 1
206
+ }
207
+
208
+ console.error(` emitted=${emitted} skipped=${skipped} order-mix=${JSON.stringify(orderCounts)}`)
209
+
210
+ return { emitted, skipped }
211
+ },
212
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `fr-bare-street` shard recipe (#251) — the postcode-anchoring-imbalance lever. BAN (and every
7
+ * other comprehensive FR source) is postcode-COMPLETE, so the model learned the French
8
+ * street→locality boundary as "the token after the 5-digit postcode," never as "comma + city." Strip
9
+ * the postcode and it leaks the street's proper-noun tokens into the following locality ("Rue René
10
+ * Cassin, Paris" → street="Rue Ren", locality="Cassin"). This recipe mints the MISSING distribution:
11
+ * the BARE comma form, NO postcode, real `(street, number, city)` tuples from BAN (Licence Ouverte —
12
+ * permissive; the model stays clean of ODbL, unlike the opt-in OSM rooftop shards).
13
+ *
14
+ * Each tuple → `<n> <Rue/Avenue/…> <proper-noun name>, <City>` with the FR prefix split
15
+ * ({@link decomposeFrStreet}: "Rue" → street_prefix, the rest → street). Tuples whose street carries
16
+ * no recognized FR type word are skipped — the failing class is precisely the prefix-led street.
17
+ *
18
+ * ⚠ Convention loss-mask: this recipe TEACHES FR `street_prefix`. The conventions loss-mask forbids it
19
+ * for FR and will `-inf` these gold labels (the v1.6.0 ~7M-loss blow-up). Disable that mask for any
20
+ * run including this shard.
21
+ */
22
+
23
+ import { decomposeFrStreet } from "../adapters/ban/street-decompose.ts"
24
+ import { alignAndWrite, makeMulberry32, readTuples, type ShardRecipe, shardSourceID } from "./scaffold.ts"
25
+
26
+ export const frBareStreetRecipe: ShardRecipe = {
27
+ name: "fr-bare-street",
28
+ description:
29
+ "FR bare comma-form street+city, NO postcode (#251): '<n> Rue <name>, <City>' — the postcode-anchoring lever",
30
+ mode: "tuples",
31
+ async run(opts, write) {
32
+ // Seeded for parity with the other recipes; unused beyond reproducibility (the tuples drive the content).
33
+ makeMulberry32(opts.seed)
34
+ let read = 0
35
+ let emitted = 0
36
+ let skipped = 0
37
+
38
+ for await (const t of readTuples(opts.input!)) {
39
+ read++
40
+ const fullStreet = String(t.street ?? "").trim()
41
+ const number = String(t.number ?? "").trim()
42
+ const locality = String(t.locality ?? "").trim()
43
+
44
+ if (!fullStreet || !number || !locality) {
45
+ skipped++
46
+ continue
47
+ }
48
+ const { prefix, street } = decomposeFrStreet(fullStreet)
49
+
50
+ // The failing class is the prefix-led FR street; a no-prefix nom_voie ("La Ville Mois") isn't it.
51
+ if (!prefix || !street) {
52
+ skipped++
53
+ continue
54
+ }
55
+ const components: Record<string, string> = {
56
+ house_number: number,
57
+ street_prefix: prefix,
58
+ street,
59
+ locality,
60
+ }
61
+ // BARE comma form, number-before (FR's dominant order), NO postcode — the whole point.
62
+ const raw = `${number} ${prefix} ${street}, ${locality}`
63
+ const source_id = shardSourceID("synth-fr-bare-street", { ...components, v: String(read) })
64
+ const canonical = {
65
+ raw,
66
+ components,
67
+ country: "FR",
68
+ locale: "fr-FR",
69
+ source: "synth-fr-bare-street",
70
+ source_id,
71
+ corpus_version: "0.9.4",
72
+ license:
73
+ "Synthetic — fr-bare-street; (street, number, city) from BAN (Base Adresse Nationale, Licence Ouverte)",
74
+ }
75
+
76
+ if (alignAndWrite(write, canonical, "fr-bare-street")) {
77
+ emitted++
78
+ } else {
79
+ skipped++
80
+ }
81
+ }
82
+
83
+ return { read, emitted, skipped }
84
+ },
85
+ }
@@ -0,0 +1,304 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `fr-fragment` shard recipe (#727 T2) — the HOUSE-NUMBER-LICENCE lever.
7
+ *
8
+ * The measured problem (T1c, `2026-07-16-t1c-fragment-board-verdict.md`): the shipped model scores
9
+ * **0.925** on `<n> Rue X` and **0.215** on `Rue X`. Same streets, same model; the only difference
10
+ * is a leading number. It has learned that a house number LICENSES a street reading at all — strip
11
+ * it and a designator-led phrase parses as a locality, designator included:
12
+ *
13
+ * ```
14
+ * "Rue Montmartre" -> locality="Rue Montmartre"
15
+ * "Allee Poque" -> locality="Allee Poque"
16
+ * ```
17
+ *
18
+ * `Rue` can only mean street in French. The model is not mislabelling an ambiguous toponym; it is
19
+ * mislabelling `Rue`.
20
+ *
21
+ * WHY THE EXISTING RECIPE DOESN'T COVER THIS. {@link frBareStreetRecipe} (#251) mints
22
+ * `<n> Rue <name>, <City>` — the bare COMMA form, no postcode. It targets postcode-anchoring
23
+ * imbalance, and every row it emits still carries a house number AND a locality. It cannot teach
24
+ * the class above, because it never shows the model a street standing alone.
25
+ *
26
+ * WHAT THIS MINTS. Five street forms — the first three carry NO house number and NO locality, which
27
+ * is the whole point — plus the counter-distribution:
28
+ *
29
+ * 1. `bare-street` "Rue Montmartre"
30
+ * 2. `street-particle` "Rue de la Paix"
31
+ * 3. `date-name` "Allee du 11 Novembre 1918"
32
+ * 4. `street-housenumber` "12 Rue Montmartre" — the anchor, so the licence isn't UNLEARNED
33
+ * 5. `alnum-housenumber` "12 bis Rue Montmartre"
34
+ * 6. `bare-locality` "Mery-sur-Oise" — NEGATIVE: a bare toponym that IS a locality
35
+ *
36
+ * The **admin/street homonym** class (`Rue de Rome`) is deliberately NOT a separate form. Homonym
37
+ * streets are already in the register and fall into 1–3 naturally; there is nothing different to
38
+ * teach about them, because the lesson is identical — the designator makes it a street. The
39
+ * fragment board scores them separately (they need measuring, not minting), and the recipe would
40
+ * need a second pass over the commune set to label them, buying a `synth_method` string and no
41
+ * training signal.
42
+ *
43
+ * FORM 7 IS NOT OPTIONAL. T1c's standing prediction: the board's `bare-locality` cell reads 0.980
44
+ * for the WRONG REASON — the model calls everything without a house number a locality, and on bare
45
+ * localities that is accidentally right. Teach bare streets alone and the model has every incentive
46
+ * to flip that default rather than learn the distinction, trading a 0.215 for a 0.980. The shard
47
+ * must show BOTH bare forms so the discriminating evidence is the designator, which is the only
48
+ * thing that actually distinguishes them. This is the same counter-distribution principle
49
+ * {@link noStreetRecipe} established after synth-street pushed the model into "decompose mode".
50
+ *
51
+ * SPLIT. `--exclude-surfaces` takes the fragment board's reserved surface list
52
+ * (`mailwoman/eval-harness/fixtures/ban-fragments-fr.surfaces.txt`). Every listed surface is
53
+ * skipped — source-disjoint by normalized street SURFACE, never by record row. Row-disjoint leaks
54
+ * the surface across the boundary and measures memorization of `Rue de Rivoli` while claiming
55
+ * generalization to unseen streets. The recipe REFUSES to run without the list rather than
56
+ * silently minting a contaminated shard.
57
+ *
58
+ * MIX. The shard is ~145K rows off a 120K-tuple draw; the intended corpus mix is **5–10%**, set at
59
+ * assembly time by shard weight rather than by row count. Keep the cap: a shard that fixes fragments
60
+ * by degrading full addresses has moved the failure, not fixed it — which is what
61
+ * `street-housenumber` / `alnum-housenumber` on the fragment board and the global parity floor are
62
+ * there to catch. `date-name` is ~0.5% of the shard because BAN only holds ~1,418 date-name streets
63
+ * after filtering and the tuple extractor already takes every one; if that class needs more, the
64
+ * lever is shard weight, not invented data.
65
+ *
66
+ * ⚠ Convention loss-mask: like {@link frBareStreetRecipe}, this recipe TEACHES FR `street_prefix`.
67
+ * The conventions loss-mask forbids it for FR and will `-inf` these gold labels (the v1.6.0 ~7M-loss
68
+ * blow-up). Disable that mask for any run including this shard.
69
+ */
70
+
71
+ import { readFileSync } from "node:fs"
72
+
73
+ import { decomposeFrStreet } from "../adapters/ban/street-decompose.ts"
74
+ import { alignAndWrite, makeMulberry32, readTuples, type ShardRecipe, shardSourceID } from "./scaffold.ts"
75
+
76
+ /** House numbers, weighted toward the small values that dominate real BAN rows. */
77
+ const HOUSE_NUMBERS = [
78
+ 1, 1, 2, 2, 3, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 15, 16, 18, 20, 21, 24, 27, 30, 33, 42, 57, 68, 84, 102, 115, 140,
79
+ ]
80
+ /** FR alphanumeric house-number forms. `bis`/`ter` are separated; a bare letter is suffixed. */
81
+ const ALNUM_SUFFIXES = ["bis", "ter", "A", "B"]
82
+
83
+ const norm = (value: string): string =>
84
+ value
85
+ .normalize("NFD")
86
+ .replace(/[̀-ͯ]/g, "")
87
+ .toLowerCase()
88
+ .replace(/\s+/g, " ")
89
+ .trim()
90
+
91
+ /**
92
+ * French commune convention: capitalize each element, leave the joining particles lowercase. `saint-jean-de-luz` →
93
+ * `Saint-Jean-de-Luz`, not `Saint-Jean-De-Luz`.
94
+ *
95
+ * Needed because BAN's sharded DBs keep only `locality_base` — normalized, lowercase, accent-stripped. Emitting that
96
+ * verbatim would teach the counter-distribution that a lowercase accent-stripped string is a locality, which is not a
97
+ * fact about French and would not match the fragment board (which reconstructs the same casing). The accents are gone
98
+ * from the source and cannot be recovered here; the casing can.
99
+ */
100
+ const FR_LOWER = new Set([
101
+ "le",
102
+ "la",
103
+ "les",
104
+ "de",
105
+ "du",
106
+ "des",
107
+ "d",
108
+ "l",
109
+ "sur",
110
+ "sous",
111
+ "en",
112
+ "aux",
113
+ "au",
114
+ "et",
115
+ "lez",
116
+ ])
117
+
118
+ export function frTitleCase(value: string): string {
119
+ const cap = (token: string, first: boolean): string =>
120
+ !first && FR_LOWER.has(token) ? token : token.charAt(0).toUpperCase() + token.slice(1)
121
+
122
+ return value
123
+ .split(" ")
124
+ .map((word, wordIndex) =>
125
+ word
126
+ .split("-")
127
+ .map((bit, bitIndex) => cap(bit, wordIndex === 0 && bitIndex === 0))
128
+ .join("-")
129
+ )
130
+ .join(" ")
131
+ }
132
+
133
+ /** Does the street name carry a particle? Decides the particle vs bare classification. */
134
+ const PARTICLE = /\b(de la|de l'|du|des|de|d'|le|la|les)\b/i
135
+ /** Does the street name carry date material (a year, or a day + French month)? */
136
+ const DATEISH =
137
+ /\b(1[0-9]|20)\d{2}\b|\b\d{1,2}\s+(janvier|f[ée]vrier|mars|avril|mai|juin|juillet|ao[ûu]t|septembre|octobre|novembre|d[ée]cembre)\b/i
138
+
139
+ export const frFragmentRecipe: ShardRecipe = {
140
+ name: "fr-fragment",
141
+ description:
142
+ "FR street fragments with NO house number (#727 T2): the house-number-licence lever — bare/particle/date-name/homonym + the bare-locality counter",
143
+ mode: "tuples",
144
+ options: [
145
+ {
146
+ flag: "--exclude-surfaces <path>",
147
+ description: "REQUIRED. The fragment board's reserved surface list; every listed street is skipped.",
148
+ },
149
+ {
150
+ flag: "--hn-prob <n>",
151
+ description: "Share of rows carrying a house number (default 0.35 — the anchor, not the point)",
152
+ },
153
+ {
154
+ flag: "--bare-prob <n>",
155
+ description: "Share of NO-house-number rows that are bare LOCALITIES (default 0.25 — the counter)",
156
+ },
157
+ ],
158
+ async run(opts, write) {
159
+ const random = makeMulberry32(opts.seed)
160
+ const excludePath = opts.excludeSurfaces
161
+
162
+ if (!excludePath) {
163
+ throw new Error(
164
+ "fr-fragment: --exclude-surfaces is REQUIRED. Pass the fragment board's reserved list " +
165
+ "(mailwoman/eval-harness/fixtures/ban-fragments-fr.surfaces.txt) or this shard trains on its own eval set. " +
166
+ "Source-disjoint by street SURFACE is the split discipline; there is no safe default."
167
+ )
168
+ }
169
+
170
+ const excluded = new Set(
171
+ readFileSync(excludePath, "utf8")
172
+ .split("\n")
173
+ .map((line) => line.trim())
174
+ .filter((line) => line && !line.startsWith("#"))
175
+ )
176
+
177
+ if (!excluded.size) throw new Error(`fr-fragment: --exclude-surfaces "${excludePath}" listed no surfaces`)
178
+
179
+ const hnProb = opts.hnProb ?? 0.35
180
+ const bareLocalityProb = opts.bareProb ?? 0.25
181
+
182
+ // The locality pool is harvested from the tuples themselves — every BAN row carries its
183
+ // commune, so the counter-distribution needs no second source.
184
+ const localities = new Set<string>()
185
+
186
+ let read = 0
187
+ let emitted = 0
188
+ let skipped = 0
189
+ let contaminated = 0
190
+
191
+ for await (const tuple of readTuples(opts.input!)) {
192
+ read++
193
+ const fullStreet = String(tuple.street ?? "").trim()
194
+ const locality = String(tuple.locality ?? "").trim()
195
+
196
+ if (locality) {
197
+ localities.add(locality)
198
+ }
199
+
200
+ if (!fullStreet) {
201
+ skipped++
202
+ continue
203
+ }
204
+
205
+ // THE SPLIT. A surface on the fragment board never enters training.
206
+ if (excluded.has(norm(fullStreet))) {
207
+ contaminated++
208
+ continue
209
+ }
210
+
211
+ const { prefix, street } = decomposeFrStreet(fullStreet)
212
+
213
+ // The failing class is the designator-led street. A no-prefix nom_voie ("La Ville Mois")
214
+ // is a different problem and would muddy the signal.
215
+ if (!prefix || !street) {
216
+ skipped++
217
+ continue
218
+ }
219
+
220
+ const carriesNumber = random() < hnProb
221
+ const components: Record<string, string> = { street_prefix: prefix, street }
222
+ let raw = `${prefix} ${street}`
223
+ let klass = DATEISH.test(street) ? "date-name" : PARTICLE.test(street) ? "street-particle" : "bare-street"
224
+
225
+ if (carriesNumber) {
226
+ const number = HOUSE_NUMBERS[Math.floor(random() * HOUSE_NUMBERS.length)]!
227
+ const alnum = random() < 0.25
228
+ const suffix = ALNUM_SUFFIXES[Math.floor(random() * ALNUM_SUFFIXES.length)]!
229
+ const houseNumber = alnum
230
+ ? suffix === "bis" || suffix === "ter"
231
+ ? `${number} ${suffix}`
232
+ : `${number}${suffix}`
233
+ : String(number)
234
+ components.house_number = houseNumber
235
+ raw = `${houseNumber} ${prefix} ${street}`
236
+ klass = alnum ? "alnum-housenumber" : "street-housenumber"
237
+ }
238
+
239
+ const sourceID = shardSourceID("synth-fr-fragment", { ...components, v: String(read) })
240
+
241
+ if (
242
+ alignAndWrite(
243
+ write,
244
+ {
245
+ raw,
246
+ components,
247
+ country: "FR",
248
+ locale: "fr-FR",
249
+ source: "synth-fr-fragment",
250
+ source_id: sourceID,
251
+ corpus_version: "0.9.4",
252
+ license: "Synthetic — fr-fragment; (street, commune) from BAN (Base Adresse Nationale, Licence Ouverte)",
253
+ },
254
+ `fr-fragment:${klass}`
255
+ )
256
+ ) {
257
+ emitted++
258
+ } else {
259
+ skipped++
260
+ }
261
+ }
262
+
263
+ // ---- the counter-distribution: bare localities, NO street anywhere in the row -------------
264
+ // Minted last so the locality pool is complete. Without these the model can satisfy every row
265
+ // above by flipping its default from "bare => locality" to "bare => street", which trades one
266
+ // broken prior for another and would show up as bare-locality collapsing on the board.
267
+ const pool = [...localities].sort()
268
+ const wanted = Math.round((emitted / Math.max(1, 1 - bareLocalityProb)) * bareLocalityProb)
269
+
270
+ for (let i = 0; i < wanted && pool.length; i++) {
271
+ // BAN gives `locality_base` normalized; restore the casing the fragment board also
272
+ // reconstructs, so train and eval show the model the same shape of French.
273
+ const name = frTitleCase(pool[Math.floor(random() * pool.length)]!)
274
+ const sourceID = shardSourceID("synth-fr-fragment", { locality: name, v: `neg-${i}` })
275
+
276
+ if (
277
+ alignAndWrite(
278
+ write,
279
+ {
280
+ raw: name,
281
+ components: { locality: name },
282
+ country: "FR",
283
+ locale: "fr-FR",
284
+ source: "synth-fr-fragment",
285
+ source_id: sourceID,
286
+ corpus_version: "0.9.4",
287
+ license: "Synthetic — fr-fragment counter-distribution; commune from BAN (Licence Ouverte)",
288
+ },
289
+ "fr-fragment:bare-locality"
290
+ )
291
+ ) {
292
+ emitted++
293
+ } else {
294
+ skipped++
295
+ }
296
+ }
297
+
298
+ if (contaminated) {
299
+ console.error(`fr-fragment: skipped ${contaminated} rows whose street surface is reserved by the fragment board`)
300
+ }
301
+
302
+ return { read, emitted, skipped }
303
+ },
304
+ }