euredact 0.3.7 → 0.3.9

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/dist/cjs/types.js CHANGED
@@ -36,6 +36,16 @@ var EntityType;
36
36
  EntityType["HEALTH_INSURANCE"] = "HEALTH_INSURANCE";
37
37
  EntityType["HEALTHCARE_PROVIDER"] = "HEALTHCARE_PROVIDER";
38
38
  EntityType["CHAMBER_OF_COMMERCE"] = "CHAMBER_OF_COMMERCE";
39
+ /**
40
+ * Employee, badge or customer number tied to a person.
41
+ *
42
+ * Emitted **only** when an explicit label names it — see `rules/cues.ts`.
43
+ * There is no pattern for an internal identifier because there is no shape
44
+ * for one; without the label, a digit run is not distinguishable from any
45
+ * other. The type exists so that a labelled employee number is filed
46
+ * correctly instead of being claimed by the phone pattern.
47
+ */
48
+ EntityType["INTERNAL_ID"] = "INTERNAL_ID";
39
49
  EntityType["IMEI"] = "IMEI";
40
50
  EntityType["GPS_COORDINATES"] = "GPS_COORDINATES";
41
51
  EntityType["UUID"] = "UUID";
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Local cues: what the document calls the value sitting next to it.
3
+ *
4
+ * A label touching the left edge of a span is the strongest statement a
5
+ * document makes about what that value *is*. "Phone: 0705237535" is a phone
6
+ * number even though those digits pass the Swedish personnummer checksum, and
7
+ * "Αρ. Ταυτότητας: 00892341" is an identity-card number even though nothing in
8
+ * the engine can checksum it.
9
+ *
10
+ * This table is the single source of that knowledge. Three callers read it, and
11
+ * none of them may move a span:
12
+ *
13
+ * 1. `localCueBonus` — ranks a candidate above its rivals on the *same span*
14
+ * when the cue names its type. The oldest use, and why the table exists.
15
+ * 2. `detectWithEvidence` — re-admits a candidate whose checksum failed when the
16
+ * cue names that same type. A declined identifier with its own label in front
17
+ * of it is still that identifier, mistyped or truncated.
18
+ * 3. `retypedBy` — relabels a winning generic candidate whose type the cue
19
+ * contradicts, when nothing of the cued type claimed the span.
20
+ *
21
+ * Consolidated here from two places that had drifted apart: `LOCAL_CUES` in
22
+ * `engine.ts` (typed, but missing most of Europe) and `ID_LABEL_BEFORE` in
23
+ * `suppressors.ts` (broader, but untyped — it could only *delete* a phone
24
+ * candidate, which left the value unmasked).
25
+ *
26
+ * Kept character-for-character in step with `euredact/rules/cues.py`. Two
27
+ * constructs are written the way they are so that both engines agree:
28
+ *
29
+ * * Boundaries are `(?<![A-Za-z0-9_])`, not `\b`. JavaScript's `\b` is
30
+ * ASCII-only, so `\bΑΦΜ` never matches — a Greek letter is not a word
31
+ * character there, and a space before it is not a transition. A boundary of
32
+ * some kind is required regardless: without one the short alternatives match
33
+ * the *tail* of an ordinary word. "NIS" matched "tālru**nis**", the Latvian
34
+ * for telephone, and suppressed 653 real phone numbers; "iva" matched
35
+ * "Pr**iva**t:" and read a personal e-mail address as a VAT number.
36
+ * * Neither the qualifier nor the run-on may use `\w`. The qualifier is a
37
+ * negated ASCII class, fixed for this in 0.3.8; `[^\W\d_]` would reject
38
+ * "εταιρειας" here for the same ASCII reason. The run-on was *not* fixed then
39
+ * and went on diverging for another release: Slovak "telefón 0956550012"
40
+ * needs it to absorb "efón" after `tel`, and Bulgarian
41
+ * "пощенски кодове 4000-4999" needs "ове" after `пощенски код`. Python's
42
+ * Unicode `\w` did; this SDK's ASCII `\w` did not, so one engine said `PHONE`
43
+ * and the other `NATIONAL_ID` on the same document. They were the last two
44
+ * type divergences on the corpus, and only the type-aware half of
45
+ * `make parity` could see them.
46
+ *
47
+ * The run-on is spelled `(?:[A-Za-z0-9_]|[^\x00-\x7F])` — an ASCII word
48
+ * character, or any non-ASCII character — rather than as a negated class of
49
+ * separators. A negated class also admits `'`, `%`, `&`, `@` and the rest of
50
+ * ASCII punctuation, which `\w` never did, and widening it that far was
51
+ * measured at 46 extra false positives with hints and 57 blind over the
52
+ * 152,300-document corpus. This is `\w` plus the non-ASCII letters `\w`
53
+ * should have matched all along, and nothing else.
54
+ *
55
+ * Every label ends in `(?:<run-on>|\s+<one word>)`: the label either runs on
56
+ * into a longer word ("telefoon" → "telefoonnummer") **or** takes one qualifier
57
+ * word naming whose identifier it is ("ΑΦΜ εταιρειας:", "Tel Nr:") — never both.
58
+ * Allowing both let "nie" grow into the surname "Nieminen" and then swallow the
59
+ * real label in "Matti Nieminen\nDOB: 08.05.1994", reading a date of birth as a
60
+ * Spanish NIE: 52 false positives across the corpus.
61
+ */
62
+ import { EntityType } from "../types.js";
63
+ /**
64
+ * How far back a cue may sit. Short on purpose: a cue is only evidence about
65
+ * the value it introduces, which is the lesson of the postal-code year defect,
66
+ * where a cue 150 characters away licensed every number in the window.
67
+ */
68
+ export declare const CUE_WINDOW = 32;
69
+ /**
70
+ * (entity type, label pattern). Ordered: the first match wins, so the narrower
71
+ * label comes first where two could read the same text.
72
+ */
73
+ export declare const CUES: Array<[EntityType, RegExp]>;
74
+ /**
75
+ * The entity type a label immediately before `start` names, or null.
76
+ *
77
+ * Reads at most `CUE_WINDOW` characters, and every pattern is anchored to the
78
+ * end of that window, so the cue must *touch* the span. A label further away is
79
+ * about some other value.
80
+ */
81
+ export declare function cuedType(text: string, start: number): EntityType | null;
82
+ /**
83
+ * Types a label can assert on its own.
84
+ *
85
+ * PHONE is absent on purpose: "Tel:" in front of something no phone pattern
86
+ * matched means the document is laid out oddly, not that the value is a phone
87
+ * number. So are EMAIL, DOB, VIN and the rest — those carry their own
88
+ * structure, and a nearby word is not entitled to overrule it.
89
+ */
90
+ export declare const CUE_TARGETS: Set<string>;
91
+ /**
92
+ * Which of those a label may also *rescue* — re-admit after its checksum
93
+ * failed. Until 0.3.9 this was the same set as CUE_TARGETS and it cannot stay
94
+ * that way: re-typing a span the engine already decided to mask is cheap to be
95
+ * wrong about, while rescuing one asserts that a value which failed its check
96
+ * digit is a real identifier anyway.
97
+ *
98
+ * BIC's absence is what keeps the rescue honest. Its validator is a *registry
99
+ * lookup*, not a checksum, so a failure means "no such bank", which no label can
100
+ * talk you out of. Rescuing it read 34 unlisted codes behind a "BIC:" label as
101
+ * real ones. The same reasoning keeps CREDIT_CARD and BANK_ACCOUNT out: Luhn and
102
+ * mod-97 are strong enough that a failure really does mean "not one of these".
103
+ * BANK_ACCOUNT joined CUE_TARGETS in 0.3.9 so "sort code 20-45-91" stops being a
104
+ * LICENSE_PLATE, and is kept out here for exactly the 0.3.8 reason.
105
+ *
106
+ * HEALTHCARE_PROVIDER and SECRET are absent because neither is checksummed;
107
+ * there is no failed validation for a label to overrule in the first place.
108
+ */
109
+ export declare const RESCUE_TARGETS: Set<string>;
110
+ /**
111
+ * The type a cue overrules `entityType` with, or null to leave it alone.
112
+ *
113
+ * Both gates are load-bearing. Measured over 2,000 corpus documents, re-typing
114
+ * without them fired 52 times and got roughly 30 of those wrong — EMAIL read as
115
+ * VAT, a date of birth as a phone number, a VIN as VAT — against 20 real fixes.
116
+ * With them: 14 re-typings, every one of them PHONE to a structured identifier.
117
+ *
118
+ * `countryScore` is the document's support for the country the winning pattern
119
+ * came from; see RETYPABLE_UNCORROBORATED.
120
+ */
121
+ export declare function retypedBy(text: string, start: number, entityType: EntityType | string, countryScore: number): EntityType | null;
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Local cues: what the document calls the value sitting next to it.
3
+ *
4
+ * A label touching the left edge of a span is the strongest statement a
5
+ * document makes about what that value *is*. "Phone: 0705237535" is a phone
6
+ * number even though those digits pass the Swedish personnummer checksum, and
7
+ * "Αρ. Ταυτότητας: 00892341" is an identity-card number even though nothing in
8
+ * the engine can checksum it.
9
+ *
10
+ * This table is the single source of that knowledge. Three callers read it, and
11
+ * none of them may move a span:
12
+ *
13
+ * 1. `localCueBonus` — ranks a candidate above its rivals on the *same span*
14
+ * when the cue names its type. The oldest use, and why the table exists.
15
+ * 2. `detectWithEvidence` — re-admits a candidate whose checksum failed when the
16
+ * cue names that same type. A declined identifier with its own label in front
17
+ * of it is still that identifier, mistyped or truncated.
18
+ * 3. `retypedBy` — relabels a winning generic candidate whose type the cue
19
+ * contradicts, when nothing of the cued type claimed the span.
20
+ *
21
+ * Consolidated here from two places that had drifted apart: `LOCAL_CUES` in
22
+ * `engine.ts` (typed, but missing most of Europe) and `ID_LABEL_BEFORE` in
23
+ * `suppressors.ts` (broader, but untyped — it could only *delete* a phone
24
+ * candidate, which left the value unmasked).
25
+ *
26
+ * Kept character-for-character in step with `euredact/rules/cues.py`. Two
27
+ * constructs are written the way they are so that both engines agree:
28
+ *
29
+ * * Boundaries are `(?<![A-Za-z0-9_])`, not `\b`. JavaScript's `\b` is
30
+ * ASCII-only, so `\bΑΦΜ` never matches — a Greek letter is not a word
31
+ * character there, and a space before it is not a transition. A boundary of
32
+ * some kind is required regardless: without one the short alternatives match
33
+ * the *tail* of an ordinary word. "NIS" matched "tālru**nis**", the Latvian
34
+ * for telephone, and suppressed 653 real phone numbers; "iva" matched
35
+ * "Pr**iva**t:" and read a personal e-mail address as a VAT number.
36
+ * * Neither the qualifier nor the run-on may use `\w`. The qualifier is a
37
+ * negated ASCII class, fixed for this in 0.3.8; `[^\W\d_]` would reject
38
+ * "εταιρειας" here for the same ASCII reason. The run-on was *not* fixed then
39
+ * and went on diverging for another release: Slovak "telefón 0956550012"
40
+ * needs it to absorb "efón" after `tel`, and Bulgarian
41
+ * "пощенски кодове 4000-4999" needs "ове" after `пощенски код`. Python's
42
+ * Unicode `\w` did; this SDK's ASCII `\w` did not, so one engine said `PHONE`
43
+ * and the other `NATIONAL_ID` on the same document. They were the last two
44
+ * type divergences on the corpus, and only the type-aware half of
45
+ * `make parity` could see them.
46
+ *
47
+ * The run-on is spelled `(?:[A-Za-z0-9_]|[^\x00-\x7F])` — an ASCII word
48
+ * character, or any non-ASCII character — rather than as a negated class of
49
+ * separators. A negated class also admits `'`, `%`, `&`, `@` and the rest of
50
+ * ASCII punctuation, which `\w` never did, and widening it that far was
51
+ * measured at 46 extra false positives with hints and 57 blind over the
52
+ * 152,300-document corpus. This is `\w` plus the non-ASCII letters `\w`
53
+ * should have matched all along, and nothing else.
54
+ *
55
+ * Every label ends in `(?:<run-on>|\s+<one word>)`: the label either runs on
56
+ * into a longer word ("telefoon" → "telefoonnummer") **or** takes one qualifier
57
+ * word naming whose identifier it is ("ΑΦΜ εταιρειας:", "Tel Nr:") — never both.
58
+ * Allowing both let "nie" grow into the surname "Nieminen" and then swallow the
59
+ * real label in "Matti Nieminen\nDOB: 08.05.1994", reading a date of birth as a
60
+ * Spanish NIE: 52 false positives across the corpus.
61
+ */
62
+ import { EntityType } from "../types.js";
63
+ /**
64
+ * How far back a cue may sit. Short on purpose: a cue is only evidence about
65
+ * the value it introduces, which is the lesson of the postal-code year defect,
66
+ * where a cue 150 characters away licensed every number in the window.
67
+ */
68
+ export const CUE_WINDOW = 32;
69
+ /**
70
+ * (entity type, label pattern). Ordered: the first match wins, so the narrower
71
+ * label comes first where two could read the same text.
72
+ */
73
+ export const CUES = [
74
+ // Phone. Ranking only — nothing is ever relabelled *to* PHONE: a phone cue in
75
+ // front of a span that no phone pattern matched says the document is laid out
76
+ // unusually, not that the value is a phone number.
77
+ [EntityType.PHONE,
78
+ /(?<![A-Za-z0-9_])(?:phone|tel|telephone|téléphone|telefoon|telefon|telefono|teléfono|tlf|mobil|mobile|gsm|handy|sími|puh|móvil|cell|tālrunis|τηλέφωνο|τηλ)(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
79
+ // National identity number.
80
+ [EntityType.NATIONAL_ID,
81
+ /(?<![A-Za-z0-9_])(?:bsn|burgerservicenummer(?:\s*\(bsn\))?|personnummer|rijksregisternummer|nationaal\s*nummer|numéro\s*national|national\s*(?:id|number)|identiteitsnummer|henkilötunnus|kennitala|cpr(?:-?nummer)?|nif|nie|dni|rr|nn|nir|insz|niss|nis|matricule|ausweisnummer|personalausweis|osobní\s*číslo|ЕГН|EGN)(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
82
+ // Cyprus: "ID number" in both official languages, plus the Greek abbreviation
83
+ // ΑΔΤ (Αστυνομική Ταυτότητα). Neither has a phone reading.
84
+ [EntityType.NATIONAL_ID,
85
+ /(?<![A-Za-z0-9_])(?:αρ\.?\s*ταυτότητας|ταυτότητας|αδτ|kimlik\s*numaras[ıi])(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
86
+ // Social security.
87
+ [EntityType.SSN,
88
+ /(?<![A-Za-z0-9_])(?:ssn|social\s*security|sozialversicherungsnummer|svnr|sofi)(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
89
+ // Tax number. Greece's ΑΦΜ is issued by the tax authority; the identity-card
90
+ // number is the ΑΔΤ, above.
91
+ [EntityType.TAX_ID,
92
+ /(?<![A-Za-z0-9_])(?:αφμ|α\.φ\.μ\.|steuer-?id|steuernummer|st\.?-?nr|stnr|tin|finanzamt\s+ist|tax\s*(?:id|no|number)|daňové\s*číslo|numer\s*podatkowy)(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
93
+ // Health insurance: the insured person.
94
+ [EntityType.HEALTH_INSURANCE,
95
+ /(?<![A-Za-z0-9_])(?:versichertennummer|krankenversicherung|kvnr|kv-?nr|mutualité|ziekenfonds|aansluitingsnummer|medical\s*card(?:\s*(?:no|number))?)(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
96
+ // Healthcare provider: the clinician or practice, not the patient — the Dutch
97
+ // AGB-code, the German LANR, the UK GMC number.
98
+ [EntityType.HEALTHCARE_PROVIDER,
99
+ /(?<![A-Za-z0-9_])(?:agb-?code|lanr|bsnr|gmc(?:\s*(?:no|number|reg))?|big-?nummer|zorgverlener(?:snummer)?)(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
100
+ // Company / trade register. "Companies House" and "Company Registration
101
+ // Number" are written out: the tail allows a run-on *or* one qualifier word,
102
+ // never a second word, so a three-word label needs to be spelled.
103
+ [EntityType.CHAMBER_OF_COMMERCE,
104
+ /(?<![A-Za-z0-9_])(?:siren|siret|kbo|bce|kvk|ondernemingsnummer|ondernemingen\s+onder\s+nummer|kruispuntbank|numéro\s*d'entreprise|enterprise\s*number|handelsregister|companies\s*house(?:\s*(?:registration|reg|no|number))?|company\s*registration(?:\s*(?:number|no))?|company\s*(?:no|number|reg)|organisationsnummer|orgnr|i[čc]o|identifikační\s*číslo|ЕИК|eik|bulstat|cvr(?:-?nummer)?|virksomhedsnummer)(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
105
+ // VAT.
106
+ [EntityType.VAT,
107
+ /(?<![A-Za-z0-9_])(?:vat|btw|tva|ust|iva|moms|alv|mwst|dič|vsk)(?:(?:[A-Za-z0-9_.\-]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
108
+ // Bank account. A UK sort code is NN-NN-NN, also a licence-plate shape, so
109
+ // "sort code 20-45-91" was typed LICENSE_PLATE with the label in front of it.
110
+ // Retype target only — a failed mod-97 is never rescued by a label.
111
+ [EntityType.BANK_ACCOUNT,
112
+ /(?<![A-Za-z0-9_])(?:sort\s*code|account\s*(?:no|number)|rekeningnummer|kontonummer|numéro\s*de\s*compte|sort-?code)(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
113
+ // Passport.
114
+ [EntityType.PASSPORT,
115
+ /(?<![A-Za-z0-9_])(?:passport(?:\s*(?:no|number))?|paspoort(?:nummer)?|reisepass(?:nummer)?|passeport|passnummer)(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
116
+ // BIC / SWIFT.
117
+ [EntityType.BIC,
118
+ /(?<![A-Za-z0-9_])(?:bic|swift)(?:[A-Za-z0-9_\/]|[^\x00-\x7F])*\s*[:.\-]?\s*\(?\s*$/i],
119
+ // Postal code. A postal code loses every contested span on priority (it is
120
+ // the weakest evidence the engine has), so without a cue entry
121
+ // "Cod postal: 040171" was handed to a German phone pattern.
122
+ [EntityType.POSTAL_CODE,
123
+ /(?<![A-Za-z0-9_])(?:cod\s*po[sș]tal|postnummer|postnr|postleitzahl|plz(?:-?bereiche?)?|postcode|code\s*postal|codice\s*postale|kod\s*pocztowy|ps[čc]|τ\.?κ\.?|пощенски\s*код)(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
124
+ // Internal / employee identifier. The canon calls these INTERNAL_ID and
125
+ // annotates them with an LLM, so the engine has no pattern for them — but it
126
+ // does have a phone pattern that was happily claiming them. The cue is the
127
+ // only thing that identifies one, which is why the engine emits this type
128
+ // from here and nowhere else.
129
+ [EntityType.INTERNAL_ID,
130
+ /(?<![A-Za-z0-9_])(?:medarbejdernummer|ansattnummer|anställningsnummer|personalnummer|personeelsnummer|matricule\s*salarié|employee\s*(?:no|number|id)|badge(?:\s*(?:no|number))?|betriebsstätten(?:nr|nummer)|grundstücks(?:nr|nummer)|dossier-?(?:nr|nummer)|aktenzeichen|osobní\s*(?:číslo\s*)?zaměstnance)(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
131
+ // Credential. Typed SECRET because that is the type this SDK has; the
132
+ // training pipeline's report asks for CREDENTIAL, which does not exist here.
133
+ [EntityType.SECRET,
134
+ /(?<![A-Za-z0-9_])(?:tan-?activatiecode|activatiecode|activation\s*code|verificatiecode|verification\s*code|otp|pincode)(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
135
+ ];
136
+ /**
137
+ * The entity type a label immediately before `start` names, or null.
138
+ *
139
+ * Reads at most `CUE_WINDOW` characters, and every pattern is anchored to the
140
+ * end of that window, so the cue must *touch* the span. A label further away is
141
+ * about some other value.
142
+ */
143
+ export function cuedType(text, start) {
144
+ const before = text.slice(Math.max(0, start - CUE_WINDOW), start);
145
+ for (const [entityType, pattern] of CUES) {
146
+ if (pattern.test(before))
147
+ return entityType;
148
+ }
149
+ return null;
150
+ }
151
+ /**
152
+ * Shapes so generic that a label in front is better evidence than the pattern
153
+ * that matched. A bare digit run is not a phone number because it *could* be
154
+ * dialled.
155
+ */
156
+ const RETYPABLE = new Set([
157
+ EntityType.PHONE, EntityType.POSTAL_CODE, EntityType.LICENSE_PLATE,
158
+ ]);
159
+ /**
160
+ * Retypable, but only when the document gives its country no support at all.
161
+ *
162
+ * These carry a checksum, so normally the pattern outranks any label. But a
163
+ * checksum only says the digits fit *some* national scheme, and a weak one fits
164
+ * by luck — the same reasoning that demotes an uncorroborated validated
165
+ * candidate to priority 1 in `engine.ts`. At a country score of 0.0 the engine
166
+ * is already saying "this passed a Czech checksum in a document with nothing
167
+ * Czech about it", and an explicit "Passport No.:" is the better evidence.
168
+ *
169
+ * Restricted to zero country support on purpose: a Dutch BSN in a Dutch
170
+ * document scores above zero and stays a NATIONAL_ID whatever label sits near
171
+ * it, so this cannot relabel a domestic identifier — only the coincidental
172
+ * foreign ones, which is where "Passport No.: 512847603" and
173
+ * "Betriebsstättennr. 260326822" both landed, each reported as Czech.
174
+ */
175
+ const RETYPABLE_UNCORROBORATED = new Set([EntityType.NATIONAL_ID]);
176
+ /**
177
+ * Types a label can assert on its own.
178
+ *
179
+ * PHONE is absent on purpose: "Tel:" in front of something no phone pattern
180
+ * matched means the document is laid out oddly, not that the value is a phone
181
+ * number. So are EMAIL, DOB, VIN and the rest — those carry their own
182
+ * structure, and a nearby word is not entitled to overrule it.
183
+ */
184
+ export const CUE_TARGETS = new Set([
185
+ EntityType.NATIONAL_ID, EntityType.SSN, EntityType.TAX_ID,
186
+ EntityType.HEALTH_INSURANCE, EntityType.HEALTHCARE_PROVIDER,
187
+ EntityType.CHAMBER_OF_COMMERCE, EntityType.VAT, EntityType.POSTAL_CODE,
188
+ EntityType.PASSPORT, EntityType.INTERNAL_ID, EntityType.BANK_ACCOUNT,
189
+ EntityType.SECRET,
190
+ ]);
191
+ /**
192
+ * Which of those a label may also *rescue* — re-admit after its checksum
193
+ * failed. Until 0.3.9 this was the same set as CUE_TARGETS and it cannot stay
194
+ * that way: re-typing a span the engine already decided to mask is cheap to be
195
+ * wrong about, while rescuing one asserts that a value which failed its check
196
+ * digit is a real identifier anyway.
197
+ *
198
+ * BIC's absence is what keeps the rescue honest. Its validator is a *registry
199
+ * lookup*, not a checksum, so a failure means "no such bank", which no label can
200
+ * talk you out of. Rescuing it read 34 unlisted codes behind a "BIC:" label as
201
+ * real ones. The same reasoning keeps CREDIT_CARD and BANK_ACCOUNT out: Luhn and
202
+ * mod-97 are strong enough that a failure really does mean "not one of these".
203
+ * BANK_ACCOUNT joined CUE_TARGETS in 0.3.9 so "sort code 20-45-91" stops being a
204
+ * LICENSE_PLATE, and is kept out here for exactly the 0.3.8 reason.
205
+ *
206
+ * HEALTHCARE_PROVIDER and SECRET are absent because neither is checksummed;
207
+ * there is no failed validation for a label to overrule in the first place.
208
+ */
209
+ export const RESCUE_TARGETS = new Set([
210
+ EntityType.NATIONAL_ID, EntityType.SSN, EntityType.TAX_ID,
211
+ EntityType.HEALTH_INSURANCE, EntityType.CHAMBER_OF_COMMERCE,
212
+ EntityType.VAT, EntityType.POSTAL_CODE, EntityType.PASSPORT,
213
+ EntityType.INTERNAL_ID,
214
+ ]);
215
+ /**
216
+ * The type a cue overrules `entityType` with, or null to leave it alone.
217
+ *
218
+ * Both gates are load-bearing. Measured over 2,000 corpus documents, re-typing
219
+ * without them fired 52 times and got roughly 30 of those wrong — EMAIL read as
220
+ * VAT, a date of birth as a phone number, a VIN as VAT — against 20 real fixes.
221
+ * With them: 14 re-typings, every one of them PHONE to a structured identifier.
222
+ *
223
+ * `countryScore` is the document's support for the country the winning pattern
224
+ * came from; see RETYPABLE_UNCORROBORATED.
225
+ */
226
+ export function retypedBy(text, start, entityType, countryScore) {
227
+ const type = String(entityType);
228
+ if (!RETYPABLE.has(type)
229
+ && !(RETYPABLE_UNCORROBORATED.has(type) && countryScore === 0))
230
+ return null;
231
+ const cued = cuedType(text, start);
232
+ if (cued === null || cued === entityType || !CUE_TARGETS.has(String(cued)))
233
+ return null;
234
+ return cued;
235
+ }
@@ -8,6 +8,38 @@ import { type RawMatch } from "./matchers.js";
8
8
  export declare const COUNTRY_CODE_ALIASES: Record<string, string>;
9
9
  /** Map a caller-supplied code to a registered one, or null if unknown. */
10
10
  export declare function resolveCountryCode(countryCode: string): string | null;
11
+ /**
12
+ * Last-resort ordering for candidates every other field has tied on.
13
+ *
14
+ * Reaching this means the engine has no evidence left: same span, no cue, same
15
+ * priority, the same (usually zero) country score, and the same declared scope.
16
+ * Something still has to win, and before this the winner was whichever candidate
17
+ * happened to be generated first — which is country *registration* order, and
18
+ * the two SDKs do not share one. Python registers alphabetically
19
+ * (`AT, BE, BG, CH, CY, CZ, ...`); this SDK uses a curated order
20
+ * (`NL, BE, DE, AT, CH, FR, ..., PT, LU, PL, ..., CZ`). So a bare digit run that
21
+ * several national schemes accept was filed as a Czech `NATIONAL_ID` by Python
22
+ * and a Portuguese `PHONE` here — identical characters masked, contradictory
23
+ * labels, and `make parity` compared only characters, so it stayed green through
24
+ * the whole of 0.3.8.
25
+ *
26
+ * The order reproduces what Python already did rather than inventing a new one,
27
+ * because every accuracy figure this engine has published was measured with
28
+ * Python resolving these ties by its registration order. Ordering on the entity
29
+ * type instead was tried first and re-tuned the corpus twice over: it turned the
30
+ * German `Steuerß12345678911` from `TAX_ID`/DE into `NATIONAL_ID`/EL, and moved
31
+ * 820 further spans from `DOB` to `DATE_OF_DEATH`. So the key is deliberately
32
+ * not total — candidates from the same country are left to the stable sort,
33
+ * which preserves the order that country's pattern list was written in, and both
34
+ * SDKs write theirs in the same order.
35
+ *
36
+ * Taken from the **pattern**, never the finished detection: this SDK re-types a
37
+ * cued candidate before the sort and Python re-types after it, so keying on the
38
+ * final type would reintroduce the divergence in a subtler form.
39
+ *
40
+ * Kept character-for-character in step with `tie_key` in `rules/engine.py`.
41
+ */
42
+ export declare function tieKey(countryCode: string): string;
11
43
  export declare class RuleEngine {
12
44
  private matcher;
13
45
  private loadedCountries;
@@ -1,6 +1,7 @@
1
1
  import { DetectionSource, EntityType } from "../types.js";
2
2
  import { MultiPatternMatcher } from "./matchers.js";
3
3
  import { shouldSuppress, SuppressionScratch } from "./suppressors.js";
4
+ import { RESCUE_TARGETS, cuedType, retypedBy } from "./cues.js";
4
5
  import { detectStructuralDob } from "./structural.js";
5
6
  import { COUNTRY_CONFIGS } from "./countries/index.js";
6
7
  import * as evidenceMod from "./evidence.js";
@@ -43,6 +44,48 @@ export function resolveCountryCode(countryCode) {
43
44
  code = COUNTRY_CODE_ALIASES[code] ?? code;
44
45
  return code in COUNTRY_CONFIGS && code !== "SHARED" ? code : null;
45
46
  }
47
+ /**
48
+ * Last-resort ordering for candidates every other field has tied on.
49
+ *
50
+ * Reaching this means the engine has no evidence left: same span, no cue, same
51
+ * priority, the same (usually zero) country score, and the same declared scope.
52
+ * Something still has to win, and before this the winner was whichever candidate
53
+ * happened to be generated first — which is country *registration* order, and
54
+ * the two SDKs do not share one. Python registers alphabetically
55
+ * (`AT, BE, BG, CH, CY, CZ, ...`); this SDK uses a curated order
56
+ * (`NL, BE, DE, AT, CH, FR, ..., PT, LU, PL, ..., CZ`). So a bare digit run that
57
+ * several national schemes accept was filed as a Czech `NATIONAL_ID` by Python
58
+ * and a Portuguese `PHONE` here — identical characters masked, contradictory
59
+ * labels, and `make parity` compared only characters, so it stayed green through
60
+ * the whole of 0.3.8.
61
+ *
62
+ * The order reproduces what Python already did rather than inventing a new one,
63
+ * because every accuracy figure this engine has published was measured with
64
+ * Python resolving these ties by its registration order. Ordering on the entity
65
+ * type instead was tried first and re-tuned the corpus twice over: it turned the
66
+ * German `Steuerß12345678911` from `TAX_ID`/DE into `NATIONAL_ID`/EL, and moved
67
+ * 820 further spans from `DOB` to `DATE_OF_DEATH`. So the key is deliberately
68
+ * not total — candidates from the same country are left to the stable sort,
69
+ * which preserves the order that country's pattern list was written in, and both
70
+ * SDKs write theirs in the same order.
71
+ *
72
+ * Taken from the **pattern**, never the finished detection: this SDK re-types a
73
+ * cued candidate before the sort and Python re-types after it, so keying on the
74
+ * final type would reintroduce the divergence in a subtler form.
75
+ *
76
+ * Kept character-for-character in step with `tie_key` in `rules/engine.py`.
77
+ */
78
+ export function tieKey(countryCode) {
79
+ // "SHARED" and "CUSTOM" are not countries and sort as themselves under a
80
+ // plain alphabetical key — landing between PT and SK, rather than ahead of
81
+ // every country the way the registry loads them. The bucket restores that.
82
+ const bucket = countryCode === "SHARED" ? "0" : countryCode === "CUSTOM" ? "1" : "2";
83
+ // NUL separator, written as an escape so this file stays plain ASCII, and
84
+ // matching Python byte for byte. It must sort below every character a code
85
+ // or type name can contain, or a code that is a prefix of another would
86
+ // order differently in the two SDKs.
87
+ return `${bucket}\u0000${countryCode}`;
88
+ }
46
89
  const warnedCountries = new Set();
47
90
  function warnUnknownCountry(code) {
48
91
  if (warnedCountries.has(code))
@@ -68,30 +111,13 @@ function warnUnknownCountry(code) {
68
111
  //
69
112
  // Measured: 263 phone numbers and 18 French SIREN were reported as national IDs,
70
113
  // each with an explicit cue in front of it.
71
- const LOCAL_CUES = [
72
- [EntityType.PHONE,
73
- /(?:phone|tel|telephone|téléphone|telefoon|telefon|telefono|teléfono|tlf|mobil|mobile|gsm|handy|sími|puh|móvil|cell)\w*\s*[:.\-]?\s*$/i],
74
- [EntityType.CHAMBER_OF_COMMERCE,
75
- /(?:siren|siret|kbo|bce|kvk|ondernemingsnummer|handelsregister|company\s*(?:no|number|reg)|organisationsnummer|orgnr)\w*\s*[:.\-]?\s*$/i],
76
- [EntityType.VAT,
77
- /(?:vat|btw|tva|ust|iva|moms|alv|mwst)[\w.\-]*\s*[:.\-]?\s*$/i],
78
- [EntityType.BIC,
79
- /(?:bic|swift)[\w/]*\s*[:.\-]?\s*\(?\s*$/i],
80
- [EntityType.NATIONAL_ID,
81
- /(?:bsn|personnummer|rijksregisternummer|national\s*id|identiteitsnummer|henkilötunnus|kennitala|cpr|nif|dni)\w*\s*[:.\-]?\s*$/i],
82
- ];
83
- // How far back a cue may sit. Short on purpose: a cue is only evidence about the
84
- // value it introduces, which is the lesson of the postal-code year defect, where
85
- // a cue 150 characters away licensed every number in the window.
86
- const CUE_WINDOW = 22;
114
+ //
115
+ // The table itself lives in `cues.ts`, because two other steps read it: the
116
+ // rescue of a declined identifier below, and the re-typing in deduplicate.
117
+ // `CUE_WINDOW` is re-exported from there.
87
118
  /** 1 when a cue naming `entityType` sits immediately before the span. */
88
119
  function localCueBonus(text, start, entityType) {
89
- const before = text.slice(Math.max(0, start - CUE_WINDOW), start);
90
- for (const [cuedType, pattern] of LOCAL_CUES) {
91
- if (cuedType === entityType && pattern.test(before))
92
- return 1;
93
- }
94
- return 0;
120
+ return cuedType(text, start) === entityType ? 1 : 0;
95
121
  }
96
122
  export class RuleEngine {
97
123
  matcher = new MultiPatternMatcher();
@@ -198,7 +224,19 @@ export class RuleEngine {
198
224
  // but failed the checksum is recorded with its country and entity type;
199
225
  // what it is allowed to demote is decided below, once the document's
200
226
  // countries are known.
227
+ //
228
+ // One kind of failure is rescued rather than recorded: a span the document
229
+ // itself labels as the very type whose checksum just failed.
230
+ // "Rijksregisternummer: 85.03.19-284.73" is a Belgian national number with a
231
+ // bad check digit — mistyped, OCR'd, or invented — and it is still a
232
+ // national number. Before this, the pattern declined, the generic phone rule
233
+ // was denied the span as a fragment, and the value was left in the clear: a
234
+ // redaction library printing an identifier it had recognised and rejected.
235
+ //
236
+ // Rescued candidates are still recorded as failed spans, so the demotion
237
+ // zones and the fragment rule below are unchanged.
201
238
  const validated = [];
239
+ const rescued = [];
202
240
  const failedSpans = [];
203
241
  for (const m of rawMatches) {
204
242
  if (this.matcher.validate(m)) {
@@ -207,6 +245,9 @@ export class RuleEngine {
207
245
  else if (m.patternDef.validator !== null && !m.patternDef.requiresContext) {
208
246
  failedSpans.push({ start: m.start, end: m.end, code: m.countryCode,
209
247
  etype: String(m.patternDef.entityType) });
248
+ if (RESCUE_TARGETS.has(String(m.patternDef.entityType))
249
+ && cuedType(text, m.start) === m.patternDef.entityType)
250
+ rescued.push(m);
210
251
  }
211
252
  }
212
253
  // Evidence pass: work out which countries this document belongs to, from
@@ -401,8 +442,28 @@ export class RuleEngine {
401
442
  priority = 1;
402
443
  blindPriority = 1;
403
444
  }
404
- const country = (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
445
+ let entityType = match.patternDef.entityType;
446
+ let country = (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
405
447
  ? match.countryCode : null;
448
+ let outOfScope = declared !== null && country !== null && !declared.has(match.countryCode);
449
+ let confidence = "high";
450
+ // The document labels this span as something else. Relabelling, not
451
+ // suppression: the span is found either way, so removing the claim would
452
+ // decide only whether the value is masked — and the answer was "no".
453
+ // "Rijksregisternummer: 85.03.19-284.73" produced no detection at all.
454
+ //
455
+ // The country came from the pattern that matched, and that pattern was
456
+ // just overruled: a Finnish phone rule saying "Finland" is worthless once
457
+ // the value is filed as a Cypriot identity number. Dropping it also clears
458
+ // outOfScope, which would otherwise claim the detection belongs to a
459
+ // country the caller did not declare while naming no country at all.
460
+ const retyped = retypedBy(text, match.start, entityType, countryScores.get(match.countryCode) ?? 0);
461
+ if (retyped !== null) {
462
+ entityType = retyped;
463
+ country = null;
464
+ outOfScope = false;
465
+ confidence = "medium";
466
+ }
406
467
  candidates.push({
407
468
  blindPriority,
408
469
  cue: localCueBonus(text, match.start, match.patternDef.entityType),
@@ -410,6 +471,46 @@ export class RuleEngine {
410
471
  length: match.end - match.start,
411
472
  countryScore: countryScores.get(match.countryCode) ?? 0,
412
473
  inScope: declared === null || declared.has(match.countryCode) ? 1 : 0,
474
+ tie: tieKey(match.countryCode),
475
+ tier: 0,
476
+ det: {
477
+ entityType,
478
+ start: match.start,
479
+ end: match.end,
480
+ text: match.text,
481
+ source: DetectionSource.RULES,
482
+ country,
483
+ confidence,
484
+ outOfScope,
485
+ },
486
+ });
487
+ }
488
+ // A declined identifier the document itself labels. Cue-backed by
489
+ // construction, so `cue` is 1 — that is what lets it take a span from a
490
+ // foreign pattern whose checksum happened to pass, which is how
491
+ // "ΑΦΜ: 147382965" was reported as a Czech national ID.
492
+ //
493
+ // `priority` is 0: below every regex-only claim, so it wins only what
494
+ // nothing better wants. `blindPriority` is 1 so that span selection — the
495
+ // country-blind half of the sort — treats it like any other regex-strength
496
+ // claim and invariant I1 is untouched.
497
+ //
498
+ // Emitted as "low", which is the honest description: the shape and the label
499
+ // agree, the check digit does not. A caller wanting only checksum-backed
500
+ // detections filters on it.
501
+ for (const match of rescued) {
502
+ if (shouldSuppress(text, match, scratch))
503
+ continue;
504
+ const country = (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
505
+ ? match.countryCode : null;
506
+ candidates.push({
507
+ blindPriority: 1,
508
+ cue: 1,
509
+ priority: 0,
510
+ length: match.end - match.start,
511
+ countryScore: countryScores.get(match.countryCode) ?? 0,
512
+ inScope: declared === null || declared.has(match.countryCode) ? 1 : 0,
513
+ tie: tieKey(match.countryCode),
413
514
  tier: 0,
414
515
  det: {
415
516
  entityType: match.patternDef.entityType,
@@ -418,14 +519,15 @@ export class RuleEngine {
418
519
  text: match.text,
419
520
  source: DetectionSource.RULES,
420
521
  country,
421
- confidence: "high",
522
+ confidence: "low",
422
523
  outOfScope: declared !== null && country !== null && !declared.has(match.countryCode),
423
524
  },
424
525
  });
425
526
  }
426
527
  for (const d of detectStructuralDob(text)) {
427
528
  candidates.push({ blindPriority: 1, cue: 0, priority: 1, length: d.end - d.start,
428
- countryScore: 0, inScope: 1, tier: 0, det: d });
529
+ countryScore: 0, inScope: 1, tier: 0, det: d,
530
+ tie: tieKey(d.country ?? "") });
429
531
  }
430
532
  const detections = this.deduplicate(candidates);
431
533
  detections.sort((a, b) => a.start - b.start || b.end - a.end);
@@ -493,7 +595,8 @@ export class RuleEngine {
493
595
  b.cue - a.cue || // local cue } country-aware
494
596
  b.priority - a.priority || // country priority } — decides the
495
597
  b.countryScore - a.countryScore || // country evidence } LABEL only
496
- b.inScope - a.inScope); // declared scope }
598
+ b.inScope - a.inScope || // declared scope }
599
+ (a.tie < b.tie ? -1 : a.tie > b.tie ? 1 : 0)); // total } — see tieKey
497
600
  const result = [];
498
601
  const occupied = new Set();
499
602
  for (const { det } of sorted) {