@mailwoman/corpus 7.2.0 → 7.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +59 -27
- package/src/adapter.ts +153 -0
- package/src/adapters/ban/adapter.ts +189 -0
- package/src/adapters/ban/street-decompose.ts +98 -0
- package/src/adapters/fcc-bdc/adapter.ts +188 -0
- package/src/adapters/geonames/adapter.ts +185 -0
- package/src/adapters/geonames-postal/adapter.ts +111 -0
- package/src/adapters/gnaf/adapter.ts +139 -0
- package/src/adapters/gnaf/assemble.ts +200 -0
- package/src/adapters/index.ts +135 -0
- package/src/adapters/openaddresses/adapter.ts +237 -0
- package/src/adapters/overture/adapter.ts +149 -0
- package/src/adapters/state-hi-schools/adapter.ts +171 -0
- package/src/adapters/state-ia-contractors/adapter.ts +148 -0
- package/src/adapters/state-ny-notaries/adapter.ts +169 -0
- package/src/adapters/state-tx-notaries/adapter.ts +160 -0
- package/src/adapters/synth-po-box/adapter.ts +197 -0
- package/src/adapters/tiger/adapter.ts +232 -0
- package/src/adapters/tiger/street-decompose.ts +120 -0
- package/src/adapters/usgov-hrsa-fqhc/adapter.ts +189 -0
- package/src/adapters/usgov-imls-pls/adapter.ts +152 -0
- package/src/adapters/usgov-irs-bmf/adapter.ts +155 -0
- package/src/adapters/usgov-nad/adapter.ts +338 -0
- package/src/adapters/usgov-nppes/adapter.ts +169 -0
- package/src/adapters/usgov-samhsa-treatment-locator/adapter.ts +211 -0
- package/src/adapters/wof-admin-jp/adapter.ts +176 -0
- package/src/adapters/wof-admin-json/adapter.ts +280 -0
- package/src/adapters/wof-postalcode-json/adapter.ts +209 -0
- package/src/align.ts +317 -0
- package/src/build.ts +348 -0
- package/src/codex/us-fips-state.ts +109 -0
- package/src/format.ts +14 -0
- package/src/golden.ts +147 -0
- package/src/index.ts +28 -0
- package/src/license.ts +48 -0
- package/src/parquet-wrapper/index.ts +9 -0
- package/src/parquet-wrapper/reader.ts +67 -0
- package/src/parquet-wrapper/schema.ts +80 -0
- package/src/parquet-wrapper/writer.ts +102 -0
- package/src/parquet.ts +360 -0
- package/src/runner.ts +283 -0
- package/src/shard-recipes/anchor-absorption.ts +86 -0
- package/src/shard-recipes/boundary-stress.ts +87 -0
- package/src/shard-recipes/country-balanced.ts +471 -0
- package/src/shard-recipes/cz-pcfirst-preposition.ts +83 -0
- package/src/shard-recipes/fr-admin-split.ts +212 -0
- package/src/shard-recipes/fr-bare-street.ts +85 -0
- package/src/shard-recipes/fr-fragment.ts +304 -0
- package/src/shard-recipes/fr-order.ts +283 -0
- package/src/shard-recipes/german.ts +206 -0
- package/src/shard-recipes/house-venue.ts +78 -0
- package/src/shard-recipes/index.ts +76 -0
- package/src/shard-recipes/intersection.ts +617 -0
- package/src/shard-recipes/locale.ts +415 -0
- package/src/shard-recipes/nl-postcode.ts +105 -0
- package/src/shard-recipes/no-fragment.ts +206 -0
- package/src/shard-recipes/no-street-led.ts +130 -0
- package/src/shard-recipes/no-street.ts +78 -0
- package/src/shard-recipes/po-box-cedex.ts +853 -0
- package/src/shard-recipes/po-box.ts +111 -0
- package/src/shard-recipes/scaffold.ts +187 -0
- package/src/shard-recipes/si-bare-village.ts +88 -0
- package/src/shard-recipes/street-affix.ts +549 -0
- package/src/shard-recipes/street-bare.ts +90 -0
- package/src/shard-recipes/street.ts +74 -0
- package/src/shard-recipes/unit.ts +315 -0
- package/src/split.ts +251 -0
- package/src/synthesize-anchor-absorption.ts +263 -0
- package/src/synthesize-boundary-stress.ts +504 -0
- package/src/synthesize-german.ts +227 -0
- package/src/synthesize-house-venue.ts +192 -0
- package/src/synthesize-intersection.ts +205 -0
- package/src/synthesize-no-street.ts +341 -0
- package/src/synthesize-po-box.ts +307 -0
- package/src/synthesize-street.ts +264 -0
- package/src/synthesize.ts +872 -0
- package/src/tokenize.ts +68 -0
- package/src/tools/align-shard.ts +67 -0
- package/src/tools/audit.ts +352 -0
- package/src/tools/corpus-stats.ts +183 -0
- package/src/tools/fetch/ban.ts +288 -0
- package/src/tools/fetch/download.ts +119 -0
- package/src/tools/fetch/hrsa.ts +65 -0
- package/src/tools/fetch/imls-pls.ts +162 -0
- package/src/tools/fetch/index.ts +121 -0
- package/src/tools/fetch/nad.ts +304 -0
- package/src/tools/fetch/nppes.ts +177 -0
- package/src/tools/fetch/openaddresses.ts +380 -0
- package/src/tools/fetch/state-hi-schools.ts +217 -0
- package/src/tools/fetch/state-sources.ts +142 -0
- package/src/tools/fetch/tiger-full.ts +318 -0
- package/src/tools/golden-expand.ts +524 -0
- package/src/tools/golden-promote.ts +304 -0
- package/src/tools/index.ts +24 -0
- package/src/tools/ingest-csv.ts +420 -0
- package/src/tools/jsonl-to-parquet.ts +200 -0
- package/src/tools/lint-shard-vocab.ts +357 -0
- package/src/tools/lint-shard.ts +502 -0
- package/src/tools/overlay-manifest.ts +141 -0
- package/src/tools/shard-kryptonite.ts +131 -0
- package/src/tools/shard-translit.ts +244 -0
- package/src/types.ts +213 -0
- package/src/wof-json.ts +236 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* `wof-postalcode`: Who's On First postalcode GeoJSON-bundle adapter.
|
|
7
|
+
*
|
|
8
|
+
* **Phase 1.5.1 pivot.** Replaces the previous SpatiaLite-backed implementation (formerly at
|
|
9
|
+
* `packages/corpus/src/adapters/wof-postalcode/`, removed in this same change). The rationale is
|
|
10
|
+
* in `wof-admin-json/adapter.ts` and in `DECISIONS.md` — short version: the SQLite distribution
|
|
11
|
+
* mirror is dead, the live distro tags every postcode row `mz:is_current = -1` which the old
|
|
12
|
+
* `is_current = 1` predicate excluded, and localized `name:*` variants don't ship in the SQLite
|
|
13
|
+
* export at all.
|
|
14
|
+
*
|
|
15
|
+
* Input: a directory containing one or more cloned `whosonfirst-data-postalcode-<cc>` repos plus
|
|
16
|
+
* the relevant `whosonfirst-data-admin-<cc>` repos (postcode records reference admin ancestry by
|
|
17
|
+
* `wof:parent_id`, so the locality / region / country records must be in the same walk for the
|
|
18
|
+
* ancestry chain to resolve). The corpus pipeline clones all four repos under
|
|
19
|
+
* `/data/corpus/sources/wof/repos/` and points the adapter at that root.
|
|
20
|
+
*
|
|
21
|
+
* Per live postalcode record, the adapter emits one row per `(name-variant, hierarchy-variant)`
|
|
22
|
+
* pair:
|
|
23
|
+
*
|
|
24
|
+
* - **Name variants**: canonical `wof:name` (slot key `default`, typically the postcode digits
|
|
25
|
+
* themselves) plus any `name:*` variants on the postcode feature. In practice WOF postcode
|
|
26
|
+
* records rarely carry localized name variants, so this expansion is usually a no-op — but
|
|
27
|
+
* the code path stays symmetric with the admin adapter for consistency.
|
|
28
|
+
* - **Hierarchy variants** (unchanged from the SQLite adapter): self, +locality, +locality+region,
|
|
29
|
+
* +locality+region+country.
|
|
30
|
+
*
|
|
31
|
+
* `source_id` is `wof-postalcode-<wof_id>-<name-slot>-<hierarchy-variant>`. Ancestor names always
|
|
32
|
+
* come from the ancestor's canonical `wof:name`; this adapter does NOT iterate ancestor name
|
|
33
|
+
* variants (e.g. it does not emit `"75008 Париж"` even when Paris has a `name:rus_x_preferred`).
|
|
34
|
+
* That cross-product belongs to a future synthesis pass; emitting it here would multiply row
|
|
35
|
+
* counts ~10× without a clear training-value story.
|
|
36
|
+
*
|
|
37
|
+
* License: CC0.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import type { WhosOnFirstPlacetype } from "@mailwoman/core/resources/whosonfirst"
|
|
41
|
+
import type { ComponentTag } from "@mailwoman/core/types"
|
|
42
|
+
|
|
43
|
+
import { formatAddress, reconcileComponents } from "../../format.ts"
|
|
44
|
+
import type { AdapterOptions, CanonicalRow, CorpusAdapter } from "../../types.ts"
|
|
45
|
+
import { buildAncestryIndex, normalizeNameKey, walkFeatures, type WOFRecord } from "../../wof-json.ts"
|
|
46
|
+
|
|
47
|
+
const COUNTRY_DISPLAY_NAME: Record<string, string> = {
|
|
48
|
+
US: "United States of America",
|
|
49
|
+
FR: "France",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const LOCALE_BY_COUNTRY: Record<string, string> = {
|
|
53
|
+
US: "en-US",
|
|
54
|
+
FR: "fr-FR",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function placetypeToTag(placetype: WhosOnFirstPlacetype | string): ComponentTag | undefined {
|
|
58
|
+
switch (placetype) {
|
|
59
|
+
case "country":
|
|
60
|
+
case "nation":
|
|
61
|
+
return "country"
|
|
62
|
+
case "macroregion":
|
|
63
|
+
case "region":
|
|
64
|
+
return "region"
|
|
65
|
+
case "locality":
|
|
66
|
+
return "locality"
|
|
67
|
+
case "postalcode":
|
|
68
|
+
return "postcode"
|
|
69
|
+
default:
|
|
70
|
+
return undefined
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface VariantSpec {
|
|
75
|
+
suffix: string
|
|
76
|
+
components: Partial<Record<ComponentTag, string>>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Compute hierarchy variants for a postcode record. `selfName` is the postcode surface form (canonical `wof:name` for
|
|
81
|
+
* the `default` slot, a `name:*` localized variant otherwise).
|
|
82
|
+
*/
|
|
83
|
+
export function postcodeVariantsFor(row: WOFRecord, ancestry: WOFRecord[], selfName: string): VariantSpec[] {
|
|
84
|
+
if (placetypeToTag(row.placetype) !== "postcode") return []
|
|
85
|
+
|
|
86
|
+
const locality = ancestry.find((a) => placetypeToTag(a.placetype) === "locality")
|
|
87
|
+
const region = ancestry.find((a) => placetypeToTag(a.placetype) === "region")
|
|
88
|
+
const country = ancestry.find((a) => placetypeToTag(a.placetype) === "country")
|
|
89
|
+
const countryDisplay = COUNTRY_DISPLAY_NAME[row.country] ?? country?.name ?? row.country
|
|
90
|
+
|
|
91
|
+
const variants: VariantSpec[] = [{ suffix: "self", components: { postcode: selfName } }]
|
|
92
|
+
|
|
93
|
+
if (locality) {
|
|
94
|
+
variants.push({
|
|
95
|
+
suffix: "with-locality",
|
|
96
|
+
components: { postcode: selfName, locality: locality.name },
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (locality && region) {
|
|
101
|
+
variants.push({
|
|
102
|
+
suffix: "with-locality-region",
|
|
103
|
+
components: { postcode: selfName, locality: locality.name, region: region.name },
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (locality && region && country) {
|
|
108
|
+
variants.push({
|
|
109
|
+
suffix: "with-locality-region-country",
|
|
110
|
+
components: {
|
|
111
|
+
postcode: selfName,
|
|
112
|
+
locality: locality.name,
|
|
113
|
+
region: region.name,
|
|
114
|
+
country: countryDisplay,
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return variants
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Build the per-record name-slot list. The `default` slot uses `wof:name` verbatim (postcode digits); subsequent slots
|
|
124
|
+
* come from `name:*` variants dedup'd against the default.
|
|
125
|
+
*/
|
|
126
|
+
export function nameSlotsFor(rec: WOFRecord): Array<{ key: string; value: string }> {
|
|
127
|
+
const seen = new Set<string>([rec.name])
|
|
128
|
+
const slots: Array<{ key: string; value: string }> = [{ key: "default", value: rec.name }]
|
|
129
|
+
|
|
130
|
+
for (const [rawKey, value] of rec.nameVariants) {
|
|
131
|
+
if (seen.has(value)) continue
|
|
132
|
+
seen.add(value)
|
|
133
|
+
slots.push({ key: normalizeNameKey(rawKey), value })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return slots
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export const WOF_POSTALCODE_ADAPTER_ID = "wof-postalcode"
|
|
140
|
+
|
|
141
|
+
export function createWOFPostalcodeAdapter(): CorpusAdapter {
|
|
142
|
+
return {
|
|
143
|
+
id: WOF_POSTALCODE_ADAPTER_ID,
|
|
144
|
+
defaultLicense: "CC0-1.0",
|
|
145
|
+
description:
|
|
146
|
+
"Who's On First postalcode GeoJSON bundles (postcode → locality/region pairs). Ancestor names from sibling admin repos.",
|
|
147
|
+
|
|
148
|
+
async *rows(opts: AdapterOptions): AsyncIterable<CanonicalRow> {
|
|
149
|
+
// Pass 1: full walk. We keep every record whose placetype maps to a ComponentTag — the
|
|
150
|
+
// postcode adapter needs locality / region / country admin records in the index so it
|
|
151
|
+
// can resolve postcode ancestry, even though it only emits rows for postcode records.
|
|
152
|
+
const byID = new Map<number, WOFRecord>()
|
|
153
|
+
|
|
154
|
+
for await (const rec of walkFeatures(opts.inputPath, { signal: opts.signal })) {
|
|
155
|
+
if (opts.signal?.aborted) return
|
|
156
|
+
|
|
157
|
+
if (opts.country && rec.country !== opts.country) continue
|
|
158
|
+
|
|
159
|
+
if (!placetypeToTag(rec.placetype)) continue
|
|
160
|
+
byID.set(rec.id, rec)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const ancestry = buildAncestryIndex(byID)
|
|
164
|
+
|
|
165
|
+
// Pass 2: emit postcode rows only, sorted by id for determinism.
|
|
166
|
+
const ids = [...byID.keys()].sort((a, b) => a - b)
|
|
167
|
+
let emitted = 0
|
|
168
|
+
|
|
169
|
+
for (const id of ids) {
|
|
170
|
+
if (opts.signal?.aborted) return
|
|
171
|
+
const rec = byID.get(id)!
|
|
172
|
+
|
|
173
|
+
if (placetypeToTag(rec.placetype) !== "postcode") continue
|
|
174
|
+
|
|
175
|
+
const chain = ancestry.get(id) ?? []
|
|
176
|
+
const slots = nameSlotsFor(rec)
|
|
177
|
+
|
|
178
|
+
for (const slot of slots) {
|
|
179
|
+
const variants = postcodeVariantsFor(rec, chain, slot.value)
|
|
180
|
+
|
|
181
|
+
for (const variant of variants) {
|
|
182
|
+
if (opts.limit !== undefined && emitted >= opts.limit) return
|
|
183
|
+
|
|
184
|
+
const raw = formatAddress(variant.components, rec.country, { separator: ", " })
|
|
185
|
+
|
|
186
|
+
if (!raw) continue
|
|
187
|
+
const aligned = reconcileComponents(variant.components, raw)
|
|
188
|
+
|
|
189
|
+
if (Object.keys(aligned).length === 0) continue
|
|
190
|
+
|
|
191
|
+
yield {
|
|
192
|
+
raw,
|
|
193
|
+
components: aligned,
|
|
194
|
+
country: rec.country,
|
|
195
|
+
locale: LOCALE_BY_COUNTRY[rec.country],
|
|
196
|
+
source: WOF_POSTALCODE_ADAPTER_ID,
|
|
197
|
+
source_id: `${WOF_POSTALCODE_ADAPTER_ID}-${rec.id}-${slot.key}-${variant.suffix}`,
|
|
198
|
+
corpus_version: "",
|
|
199
|
+
license: "CC0-1.0",
|
|
200
|
+
}
|
|
201
|
+
emitted++
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export const wofPostalcodeAdapter = createWOFPostalcodeAdapter()
|
package/src/align.ts
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Alignment: turn a `CanonicalRow` (raw + components) into a `LabeledRow` (raw + tokens + BIO
|
|
7
|
+
* labels) or a `QuarantinedRow` (raw + reason) per the Phase 1 plan.
|
|
8
|
+
*
|
|
9
|
+
* Pipeline:
|
|
10
|
+
*
|
|
11
|
+
* 1. For each `(tag, value)` in `components`, find the value's character span in `raw`. First try a
|
|
12
|
+
* verbatim substring match (case-insensitive, whitespace-collapsed). If that fails, fall
|
|
13
|
+
* back to fuzzy match via `fastest-levenshtein`, with a tunable edit distance threshold.
|
|
14
|
+
* 2. If any component cannot be located, reject the row with a human-readable reason and send it to
|
|
15
|
+
* the quarantine pile (`reason: "component-not-found:<tag>"` or
|
|
16
|
+
* `"edit-distance-exceeded:<tag>:<dist>"`).
|
|
17
|
+
* 3. Tokenize `raw` with the supplied `Tokenizer` (defaults to the whitespace tokenizer).
|
|
18
|
+
* 4. For each token: walk the list of component spans, pick the one whose span contains the token's
|
|
19
|
+
* character range. First token in a component span → `B-<tag>`; subsequent tokens →
|
|
20
|
+
* `I-<tag>`; no overlap → `O`.
|
|
21
|
+
* 5. Emit the located char spans verbatim as `span_starts[]` / `span_ends[]` / `span_tags[]` (the
|
|
22
|
+
* v0.5.0 char-offset format, #519). The token quantization in step 4 is the part the v0.5.0
|
|
23
|
+
* rebuild deletes; during the transition both representations ride on every labeled row.
|
|
24
|
+
*
|
|
25
|
+
* Structural invariants the function preserves (the span ones loudly — a violation throws rather
|
|
26
|
+
* than quarantines, because it indicates a bug here, not bad source data):
|
|
27
|
+
*
|
|
28
|
+
* - `tokens.length === labels.length` always.
|
|
29
|
+
* - Each component contributes at most one contiguous BIO run (no `B-tag … O … I-tag` gaps). This is
|
|
30
|
+
* enforced by greedy first-match span assignment + ordered token iteration.
|
|
31
|
+
* - The span triple is sorted ascending by start and non-overlapping.
|
|
32
|
+
* - `raw` is NFC-normalized (asserted per row; a non-NFC raw makes char offsets ambiguous downstream
|
|
33
|
+
* — NFD `é` occupies two code units where NFC `é` occupies one — and silently so).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import type { BIOLabel, ComponentTag } from "@mailwoman/core/types"
|
|
37
|
+
import { distance as levenshteinDistance } from "fastest-levenshtein"
|
|
38
|
+
|
|
39
|
+
import { whitespaceTokenizer, type TokenSpan, type Tokenizer } from "./tokenize.ts"
|
|
40
|
+
import type { CanonicalRow, LabeledRow, QuarantinedRow } from "./types.ts"
|
|
41
|
+
|
|
42
|
+
/** Options for `alignRow`. */
|
|
43
|
+
export interface AlignOptions {
|
|
44
|
+
/** Tokenizer to use. Defaults to `whitespaceTokenizer()`. */
|
|
45
|
+
tokenizer?: Tokenizer
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Max Levenshtein edit distance to accept when a verbatim substring match fails. Set `0` to require verbatim matches
|
|
49
|
+
* only. Default `2`.
|
|
50
|
+
*
|
|
51
|
+
* Distance is computed against same-length windows in `raw`, so the threshold scales naturally with the component
|
|
52
|
+
* value length.
|
|
53
|
+
*/
|
|
54
|
+
maxEditDistance?: number
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Case-insensitive comparison for substring search. Default `true`. The retained span in `raw` is the original case;
|
|
58
|
+
* only matching is case-insensitive.
|
|
59
|
+
*/
|
|
60
|
+
caseInsensitive?: boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Either a successful labeled row or a quarantined one. */
|
|
64
|
+
export type AlignmentResult = { kind: "labeled"; row: LabeledRow } | { kind: "quarantined"; row: QuarantinedRow }
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* One located char-offset label span over a row's `raw` ([start, end) in UTF-16 code units). The element type behind
|
|
68
|
+
* the parallel `span_starts[]`/`span_ends[]`/`span_tags[]` triple on `LabeledRow` (#519).
|
|
69
|
+
*/
|
|
70
|
+
export interface ComponentSpan {
|
|
71
|
+
tag: ComponentTag
|
|
72
|
+
start: number
|
|
73
|
+
end: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Align a single row. */
|
|
77
|
+
export function alignRow(row: CanonicalRow, opts: AlignOptions = {}): AlignmentResult {
|
|
78
|
+
const tokenizer = opts.tokenizer ?? whitespaceTokenizer()
|
|
79
|
+
const maxEditDistance = opts.maxEditDistance ?? 2
|
|
80
|
+
const caseInsensitive = opts.caseInsensitive ?? true
|
|
81
|
+
|
|
82
|
+
if (!row.raw) {
|
|
83
|
+
return { kind: "quarantined", row: { row, reason: "raw-empty" } }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// #519 NFC handling — relaxed from a hard throw to normalization (2026-06-12, DeepSeek-validated):
|
|
87
|
+
// one non-NFC row (e.g. a non-Latin name variant like "দক্ষিণ কোরিয়া") must not crash a multi-hour
|
|
88
|
+
// build. Normalize `raw` AND every component value to NFC, compute spans over the NFC raw, and
|
|
89
|
+
// store the NFC raw — preserving the #519 single-normalization-form principle while keeping the row.
|
|
90
|
+
const raw = row.raw.normalize("NFC")
|
|
91
|
+
const components = { ...row.components }
|
|
92
|
+
|
|
93
|
+
for (const key of Object.keys(components)) {
|
|
94
|
+
const v = components[key as keyof typeof components]
|
|
95
|
+
|
|
96
|
+
if (typeof v === "string") {
|
|
97
|
+
components[key as keyof typeof components] = v.normalize("NFC")
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const componentSpans: ComponentSpan[] = []
|
|
102
|
+
const claimed: Array<[number, number]> = []
|
|
103
|
+
|
|
104
|
+
const haystack = caseInsensitive ? raw.toLowerCase() : raw
|
|
105
|
+
|
|
106
|
+
// Longest value first: a short component must not claim a word that a longer, more specific
|
|
107
|
+
// component owns ("Alaska Regional Dr, Alaska" — region "Alaska" stealing the street's first
|
|
108
|
+
// word quarantined the street; pilot2's residual class). Emit order is unaffected — spans are
|
|
109
|
+
// re-sorted by start below.
|
|
110
|
+
const entries = (Object.entries(components) as Array<[ComponentTag, string | undefined]>).sort(
|
|
111
|
+
(a, b) => (b[1]?.length ?? 0) - (a[1]?.length ?? 0)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
for (const [tag, value] of entries) {
|
|
115
|
+
if (!value) continue
|
|
116
|
+
|
|
117
|
+
const needle = caseInsensitive ? value.toLowerCase() : value
|
|
118
|
+
const span = locateSpan({ haystack, needle, raw, claimed, maxEditDistance })
|
|
119
|
+
|
|
120
|
+
if (!span) {
|
|
121
|
+
return {
|
|
122
|
+
kind: "quarantined",
|
|
123
|
+
row: { row, reason: `component-not-found:${tag}` },
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Defensive bounds quarantine (2026-06-12): locateSpan's fuzzy/boundary logic can over-run the
|
|
128
|
+
// raw by a code unit on some non-Latin / combining-mark strings (e.g. Bengali name variants),
|
|
129
|
+
// yielding a span past the end. An out-of-bounds offset can never be a valid char span —
|
|
130
|
+
// quarantine the row rather than crash assertSpanInvariants and take down a multi-hour build.
|
|
131
|
+
// (If this class proves large in the quarantine report, locateSpan's boundary logic needs a
|
|
132
|
+
// combining-mark fix to KEEP these non-Latin rows.)
|
|
133
|
+
if (span.start < 0 || span.end > raw.length || span.start >= span.end) {
|
|
134
|
+
return {
|
|
135
|
+
kind: "quarantined",
|
|
136
|
+
row: { row, reason: `span-out-of-bounds:${tag}` },
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
componentSpans.push({ tag, start: span.start, end: span.end })
|
|
141
|
+
claimed.push([span.start, span.end])
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
componentSpans.sort((a, b) => a.start - b.start)
|
|
145
|
+
assertSpanInvariants(componentSpans, { ...row, raw })
|
|
146
|
+
const tokens = tokenizer.tokenize(raw)
|
|
147
|
+
const labels = labelTokens(tokens, componentSpans)
|
|
148
|
+
|
|
149
|
+
const labeled: LabeledRow = {
|
|
150
|
+
...row,
|
|
151
|
+
raw,
|
|
152
|
+
components,
|
|
153
|
+
tokens: tokens.map((t) => t.text),
|
|
154
|
+
labels,
|
|
155
|
+
// The v0.5.0 char-offset triple (#519): the located spans, emitted verbatim. The token
|
|
156
|
+
// quantization above is what the rebuild deletes; both ride during the transition.
|
|
157
|
+
span_starts: componentSpans.map((s) => s.start),
|
|
158
|
+
span_ends: componentSpans.map((s) => s.end),
|
|
159
|
+
span_tags: componentSpans.map((s) => s.tag),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { kind: "labeled", row: labeled }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Enforce the #519 span-triple invariants — in-bounds, sorted ascending by start, non-overlapping — loudly.
|
|
167
|
+
*
|
|
168
|
+
* For `alignRow`: `claimed`-span bookkeeping in `locateSpan` already makes overlap impossible and the caller sorts, so
|
|
169
|
+
* a violation here is a bug in this file, not bad source data: throw (naming the row) rather than quarantine, so the
|
|
170
|
+
* corruption can't ride into a corpus. Exported for every OTHER span producer (`composeAdversarialRow`'s offset
|
|
171
|
+
* arithmetic, future synthesis paths) — any code that emits the triple without going through `alignRow` must pass its
|
|
172
|
+
* output through this.
|
|
173
|
+
*/
|
|
174
|
+
export function assertSpanInvariants(
|
|
175
|
+
spans: readonly ComponentSpan[],
|
|
176
|
+
row: Pick<CanonicalRow, "raw" | "source" | "source_id">
|
|
177
|
+
): void {
|
|
178
|
+
for (let i = 0; i < spans.length; i++) {
|
|
179
|
+
const s = spans[i]!
|
|
180
|
+
|
|
181
|
+
if (!(s.start >= 0 && s.start < s.end && s.end <= row.raw.length)) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`alignRow: span out of bounds (source=${row.source}, source_id=${row.source_id}): ` +
|
|
184
|
+
`${s.tag}@[${s.start}, ${s.end}) over raw of length ${row.raw.length}`
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (i === 0) continue
|
|
189
|
+
const prev = spans[i - 1]!
|
|
190
|
+
|
|
191
|
+
if (s.start < prev.start) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`alignRow: spans not sorted (source=${row.source}, source_id=${row.source_id}): ` +
|
|
194
|
+
`${prev.tag}@[${prev.start}, ${prev.end}) precedes ${s.tag}@[${s.start}, ${s.end})`
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (s.start < prev.end) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`alignRow: spans overlap (source=${row.source}, source_id=${row.source_id}): ` +
|
|
201
|
+
`${prev.tag}@[${prev.start}, ${prev.end}) overlaps ${s.tag}@[${s.start}, ${s.end})`
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Locate `needle` in `haystack` (both already normalized for case if requested), preferring verbatim substring match.
|
|
209
|
+
* Falls back to a fuzzy window scan when verbatim fails and `maxEditDistance > 0`. Already-claimed spans are skipped so
|
|
210
|
+
* two components don't grab overlapping ranges.
|
|
211
|
+
*
|
|
212
|
+
* Returns the span in the original `raw` (not the lower-cased `haystack`).
|
|
213
|
+
*/
|
|
214
|
+
function locateSpan(args: {
|
|
215
|
+
haystack: string
|
|
216
|
+
needle: string
|
|
217
|
+
raw: string
|
|
218
|
+
claimed: Array<[number, number]>
|
|
219
|
+
maxEditDistance: number
|
|
220
|
+
}): { start: number; end: number } | undefined {
|
|
221
|
+
const { haystack, needle, claimed, maxEditDistance } = args
|
|
222
|
+
|
|
223
|
+
if (needle.length === 0) return undefined
|
|
224
|
+
|
|
225
|
+
// Pass 1: verbatim substring. Word-boundary-aligned matches are PREFERRED over intra-word ones
|
|
226
|
+
// — leftmost-substring alone let a short value claim the inside of an earlier word (region "AK"
|
|
227
|
+
// matched inside "Umak"/"Lake", scrambling every later span; caught by the v0.5.0 pilot build).
|
|
228
|
+
// Intra-word matches stay allowed as the fallback because they are essential for affix
|
|
229
|
+
// supervision (street_suffix "straße" inside "Hauptstraße" has no boundary-aligned occurrence —
|
|
230
|
+
// sub-word spans are the point of the char-offset format).
|
|
231
|
+
let intraWord: { start: number; end: number } | undefined
|
|
232
|
+
let from = 0
|
|
233
|
+
|
|
234
|
+
while (true) {
|
|
235
|
+
const idx = haystack.indexOf(needle, from)
|
|
236
|
+
|
|
237
|
+
if (idx < 0) break
|
|
238
|
+
const end = idx + needle.length
|
|
239
|
+
|
|
240
|
+
if (!overlapsClaimed(idx, end, claimed)) {
|
|
241
|
+
if (isBoundaryAligned(haystack, idx, end)) return { start: idx, end }
|
|
242
|
+
intraWord ??= { start: idx, end }
|
|
243
|
+
}
|
|
244
|
+
from = idx + 1
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (intraWord) return intraWord
|
|
248
|
+
|
|
249
|
+
if (maxEditDistance <= 0) return undefined
|
|
250
|
+
|
|
251
|
+
// Pass 2: fuzzy sliding-window. Walk over candidate windows of length `needle.length`
|
|
252
|
+
// across haystack, compute Levenshtein, pick the leftmost window under the threshold.
|
|
253
|
+
const len = needle.length
|
|
254
|
+
|
|
255
|
+
for (let i = 0; i + len <= haystack.length; i++) {
|
|
256
|
+
if (overlapsClaimed(i, i + len, claimed)) continue
|
|
257
|
+
const window = haystack.slice(i, i + len)
|
|
258
|
+
|
|
259
|
+
if (window === needle) return { start: i, end: i + len } // covered by pass 1, but cheap
|
|
260
|
+
const d = levenshteinDistance(window, needle)
|
|
261
|
+
|
|
262
|
+
if (d <= maxEditDistance) return { start: i, end: i + len }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return undefined
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const WORD_CHAR = /[\p{L}\p{N}]/u
|
|
269
|
+
|
|
270
|
+
/** Both needle edges sit on word boundaries of the haystack (string edges count as boundaries). */
|
|
271
|
+
function isBoundaryAligned(haystack: string, start: number, end: number): boolean {
|
|
272
|
+
const before = start === 0 || !WORD_CHAR.test(haystack[start - 1]!)
|
|
273
|
+
const after = end === haystack.length || !WORD_CHAR.test(haystack[end]!)
|
|
274
|
+
|
|
275
|
+
return before && after
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function overlapsClaimed(start: number, end: number, claimed: Array<[number, number]>): boolean {
|
|
279
|
+
for (const [a, b] of claimed) {
|
|
280
|
+
if (start < b && a < end) return true
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return false
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Assign BIO labels to tokens given the component spans. Components MUST be sorted by start offset. For each token,
|
|
288
|
+
* find the first component span that contains the token's start offset; if the token is the first one inside that span
|
|
289
|
+
* emit `B-<tag>`, else `I-<tag>`.
|
|
290
|
+
*/
|
|
291
|
+
function labelTokens(tokens: readonly TokenSpan[], spans: readonly ComponentSpan[]): readonly BIOLabel[] {
|
|
292
|
+
const out: BIOLabel[] = []
|
|
293
|
+
const seenSpan = new Set<number>()
|
|
294
|
+
|
|
295
|
+
// index into `spans`
|
|
296
|
+
|
|
297
|
+
for (const token of tokens) {
|
|
298
|
+
let assigned: BIOLabel = "O"
|
|
299
|
+
|
|
300
|
+
for (let i = 0; i < spans.length; i++) {
|
|
301
|
+
const s = spans[i]!
|
|
302
|
+
|
|
303
|
+
if (token.start >= s.start && token.end <= s.end) {
|
|
304
|
+
if (!seenSpan.has(i)) {
|
|
305
|
+
assigned = `B-${s.tag}` as BIOLabel
|
|
306
|
+
seenSpan.add(i)
|
|
307
|
+
} else {
|
|
308
|
+
assigned = `I-${s.tag}` as BIOLabel
|
|
309
|
+
}
|
|
310
|
+
break
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
out.push(assigned)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return out
|
|
317
|
+
}
|