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/README.md CHANGED
@@ -167,7 +167,7 @@ interface Detection {
167
167
  text: string; // The matched substring
168
168
  source: DetectionSource; // "rules" or "cloud"
169
169
  country: string | null; // ISO code or null for shared/custom patterns
170
- confidence: string; // Confidence level
170
+ confidence: string; // "high" | "medium" | "low" — see below
171
171
  countryConfidence?: number; // How strongly the document supports `country`,
172
172
  // in [0, 1]. 0 means the attribution rests on
173
173
  // a checksum alone.
@@ -176,6 +176,26 @@ interface Detection {
176
176
  }
177
177
  ```
178
178
 
179
+ **`confidence`** describes how the *type* was arrived at. It never says anything
180
+ about whether the span is masked — every detection is, at every level.
181
+
182
+ | value | meaning |
183
+ |---|---|
184
+ | `"high"` | a pattern matched and, where one exists, its checksum passed |
185
+ | `"medium"` | the type comes from a label touching the span, because no pattern of that type claimed it — `Αρ. Ταυτότητας: 00892341` is a `NATIONAL_ID` although nothing can checksum it |
186
+ | `"low"` | a pattern matched, its checksum *failed*, and the document labels the span as that very type — `Rijksregisternummer: 85.03.19-284.73` is a national number with a bad check digit |
187
+
188
+ Filter on it when you need only checksum-backed detections:
189
+
190
+ ```ts
191
+ const strict = result.detections.filter(d => d.confidence === "high");
192
+ ```
193
+
194
+ A `"low"` detection is the honest description of a mistyped, OCR'd or invented
195
+ identifier: the shape and the label agree, the check digit does not. Earlier
196
+ versions dropped these, which meant a redaction library printed in full an
197
+ identifier it had recognised and rejected.
198
+
179
199
  #### `RedactResult`
180
200
 
181
201
  ```ts
@@ -205,9 +225,43 @@ PASSPORT DRIVERS_LICENSE RESIDENCE_PERMIT LICENSE_PLATE
205
225
  VIN VAT POSTAL_CODE IP_ADDRESS
206
226
  IPV6_ADDRESS MAC_ADDRESS HEALTH_INSURANCE HEALTHCARE_PROVIDER
207
227
  CHAMBER_OF_COMMERCE IMEI GPS_COORDINATES UUID
208
- SOCIAL_HANDLE SECRET OTHER
228
+ SOCIAL_HANDLE SECRET INTERNAL_ID OTHER
209
229
  ```
210
230
 
231
+ `INTERNAL_ID` — an employee, badge or customer number tied to a person — is
232
+ emitted **only** when an explicit label names it (`medarbejdernummer:`,
233
+ `Personalnummer:`, `Employee No:`, `Betriebsstättennr.`, `Badge`). There is no
234
+ pattern for one, because there is no shape for one: without the label, a digit
235
+ run is not distinguishable from any other. The type exists so that a labelled
236
+ employee number is filed correctly instead of being claimed by the phone
237
+ pattern.
238
+
239
+ ### What a label in front of a value decides
240
+
241
+ A label touching the left edge of a value decides what that value is called.
242
+ The label may be the abbreviation or the word it abbreviates — `BSN:` and
243
+ `Burgerservicenummer:` both reach `NATIONAL_ID` — and it may be the official
244
+ name in the document's own language:
245
+
246
+ | label | type |
247
+ |---|---|
248
+ | `Companies House Registration:`, `Company Registration Number:` | `CHAMBER_OF_COMMERCE` |
249
+ | `Sozialversicherungsnummer:` | `SSN` |
250
+ | `Passport No.:`, `Paspoortnummer:` | `PASSPORT` |
251
+ | `AGB-code:`, `LANR`, `GMC Number:` | `HEALTHCARE_PROVIDER` |
252
+ | `Medical Card No.:` | `HEALTH_INSURANCE` |
253
+ | `sort code`, `account number` | `BANK_ACCOUNT` |
254
+ | `TAN-activatiecode`, `activation code` | `SECRET` |
255
+
256
+ A label can also *rule a type out*. A four-digit run is not a postal code when
257
+ a founding or payment participle introduces it (`Opgericht in 2016`,
258
+ `Fondée en 2017`) or when it sits in a telephone parenthetical (`(toest. 3841)`,
259
+ `(ext. 2219)`). Postal codes that merely look like years are unaffected —
260
+ Antwerp's `2018` in `rustige ligging in 2018` is still a `POSTAL_CODE`.
261
+
262
+ A label never moves a span; it only decides the label. Which characters are
263
+ masked is unchanged either way.
264
+
211
265
  For custom patterns, `entityType` is a plain string (e.g. `"EMPLOYEE_ID"`).
212
266
 
213
267
  ## Country codes
@@ -0,0 +1,240 @@
1
+ "use strict";
2
+ /**
3
+ * Local cues: what the document calls the value sitting next to it.
4
+ *
5
+ * A label touching the left edge of a span is the strongest statement a
6
+ * document makes about what that value *is*. "Phone: 0705237535" is a phone
7
+ * number even though those digits pass the Swedish personnummer checksum, and
8
+ * "Αρ. Ταυτότητας: 00892341" is an identity-card number even though nothing in
9
+ * the engine can checksum it.
10
+ *
11
+ * This table is the single source of that knowledge. Three callers read it, and
12
+ * none of them may move a span:
13
+ *
14
+ * 1. `localCueBonus` — ranks a candidate above its rivals on the *same span*
15
+ * when the cue names its type. The oldest use, and why the table exists.
16
+ * 2. `detectWithEvidence` — re-admits a candidate whose checksum failed when the
17
+ * cue names that same type. A declined identifier with its own label in front
18
+ * of it is still that identifier, mistyped or truncated.
19
+ * 3. `retypedBy` — relabels a winning generic candidate whose type the cue
20
+ * contradicts, when nothing of the cued type claimed the span.
21
+ *
22
+ * Consolidated here from two places that had drifted apart: `LOCAL_CUES` in
23
+ * `engine.ts` (typed, but missing most of Europe) and `ID_LABEL_BEFORE` in
24
+ * `suppressors.ts` (broader, but untyped — it could only *delete* a phone
25
+ * candidate, which left the value unmasked).
26
+ *
27
+ * Kept character-for-character in step with `euredact/rules/cues.py`. Two
28
+ * constructs are written the way they are so that both engines agree:
29
+ *
30
+ * * Boundaries are `(?<![A-Za-z0-9_])`, not `\b`. JavaScript's `\b` is
31
+ * ASCII-only, so `\bΑΦΜ` never matches — a Greek letter is not a word
32
+ * character there, and a space before it is not a transition. A boundary of
33
+ * some kind is required regardless: without one the short alternatives match
34
+ * the *tail* of an ordinary word. "NIS" matched "tālru**nis**", the Latvian
35
+ * for telephone, and suppressed 653 real phone numbers; "iva" matched
36
+ * "Pr**iva**t:" and read a personal e-mail address as a VAT number.
37
+ * * Neither the qualifier nor the run-on may use `\w`. The qualifier is a
38
+ * negated ASCII class, fixed for this in 0.3.8; `[^\W\d_]` would reject
39
+ * "εταιρειας" here for the same ASCII reason. The run-on was *not* fixed then
40
+ * and went on diverging for another release: Slovak "telefón 0956550012"
41
+ * needs it to absorb "efón" after `tel`, and Bulgarian
42
+ * "пощенски кодове 4000-4999" needs "ове" after `пощенски код`. Python's
43
+ * Unicode `\w` did; this SDK's ASCII `\w` did not, so one engine said `PHONE`
44
+ * and the other `NATIONAL_ID` on the same document. They were the last two
45
+ * type divergences on the corpus, and only the type-aware half of
46
+ * `make parity` could see them.
47
+ *
48
+ * The run-on is spelled `(?:[A-Za-z0-9_]|[^\x00-\x7F])` — an ASCII word
49
+ * character, or any non-ASCII character — rather than as a negated class of
50
+ * separators. A negated class also admits `'`, `%`, `&`, `@` and the rest of
51
+ * ASCII punctuation, which `\w` never did, and widening it that far was
52
+ * measured at 46 extra false positives with hints and 57 blind over the
53
+ * 152,300-document corpus. This is `\w` plus the non-ASCII letters `\w`
54
+ * should have matched all along, and nothing else.
55
+ *
56
+ * Every label ends in `(?:<run-on>|\s+<one word>)`: the label either runs on
57
+ * into a longer word ("telefoon" → "telefoonnummer") **or** takes one qualifier
58
+ * word naming whose identifier it is ("ΑΦΜ εταιρειας:", "Tel Nr:") — never both.
59
+ * Allowing both let "nie" grow into the surname "Nieminen" and then swallow the
60
+ * real label in "Matti Nieminen\nDOB: 08.05.1994", reading a date of birth as a
61
+ * Spanish NIE: 52 false positives across the corpus.
62
+ */
63
+ Object.defineProperty(exports, "__esModule", { value: true });
64
+ exports.RESCUE_TARGETS = exports.CUE_TARGETS = exports.CUES = exports.CUE_WINDOW = void 0;
65
+ exports.cuedType = cuedType;
66
+ exports.retypedBy = retypedBy;
67
+ const types_js_1 = require("../types.js");
68
+ /**
69
+ * How far back a cue may sit. Short on purpose: a cue is only evidence about
70
+ * the value it introduces, which is the lesson of the postal-code year defect,
71
+ * where a cue 150 characters away licensed every number in the window.
72
+ */
73
+ exports.CUE_WINDOW = 32;
74
+ /**
75
+ * (entity type, label pattern). Ordered: the first match wins, so the narrower
76
+ * label comes first where two could read the same text.
77
+ */
78
+ exports.CUES = [
79
+ // Phone. Ranking only — nothing is ever relabelled *to* PHONE: a phone cue in
80
+ // front of a span that no phone pattern matched says the document is laid out
81
+ // unusually, not that the value is a phone number.
82
+ [types_js_1.EntityType.PHONE,
83
+ /(?<![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],
84
+ // National identity number.
85
+ [types_js_1.EntityType.NATIONAL_ID,
86
+ /(?<![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],
87
+ // Cyprus: "ID number" in both official languages, plus the Greek abbreviation
88
+ // ΑΔΤ (Αστυνομική Ταυτότητα). Neither has a phone reading.
89
+ [types_js_1.EntityType.NATIONAL_ID,
90
+ /(?<![A-Za-z0-9_])(?:αρ\.?\s*ταυτότητας|ταυτότητας|αδτ|kimlik\s*numaras[ıi])(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
91
+ // Social security.
92
+ [types_js_1.EntityType.SSN,
93
+ /(?<![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],
94
+ // Tax number. Greece's ΑΦΜ is issued by the tax authority; the identity-card
95
+ // number is the ΑΔΤ, above.
96
+ [types_js_1.EntityType.TAX_ID,
97
+ /(?<![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],
98
+ // Health insurance: the insured person.
99
+ [types_js_1.EntityType.HEALTH_INSURANCE,
100
+ /(?<![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],
101
+ // Healthcare provider: the clinician or practice, not the patient — the Dutch
102
+ // AGB-code, the German LANR, the UK GMC number.
103
+ [types_js_1.EntityType.HEALTHCARE_PROVIDER,
104
+ /(?<![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],
105
+ // Company / trade register. "Companies House" and "Company Registration
106
+ // Number" are written out: the tail allows a run-on *or* one qualifier word,
107
+ // never a second word, so a three-word label needs to be spelled.
108
+ [types_js_1.EntityType.CHAMBER_OF_COMMERCE,
109
+ /(?<![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],
110
+ // VAT.
111
+ [types_js_1.EntityType.VAT,
112
+ /(?<![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],
113
+ // Bank account. A UK sort code is NN-NN-NN, also a licence-plate shape, so
114
+ // "sort code 20-45-91" was typed LICENSE_PLATE with the label in front of it.
115
+ // Retype target only — a failed mod-97 is never rescued by a label.
116
+ [types_js_1.EntityType.BANK_ACCOUNT,
117
+ /(?<![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],
118
+ // Passport.
119
+ [types_js_1.EntityType.PASSPORT,
120
+ /(?<![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],
121
+ // BIC / SWIFT.
122
+ [types_js_1.EntityType.BIC,
123
+ /(?<![A-Za-z0-9_])(?:bic|swift)(?:[A-Za-z0-9_\/]|[^\x00-\x7F])*\s*[:.\-]?\s*\(?\s*$/i],
124
+ // Postal code. A postal code loses every contested span on priority (it is
125
+ // the weakest evidence the engine has), so without a cue entry
126
+ // "Cod postal: 040171" was handed to a German phone pattern.
127
+ [types_js_1.EntityType.POSTAL_CODE,
128
+ /(?<![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],
129
+ // Internal / employee identifier. The canon calls these INTERNAL_ID and
130
+ // annotates them with an LLM, so the engine has no pattern for them — but it
131
+ // does have a phone pattern that was happily claiming them. The cue is the
132
+ // only thing that identifies one, which is why the engine emits this type
133
+ // from here and nowhere else.
134
+ [types_js_1.EntityType.INTERNAL_ID,
135
+ /(?<![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],
136
+ // Credential. Typed SECRET because that is the type this SDK has; the
137
+ // training pipeline's report asks for CREDENTIAL, which does not exist here.
138
+ [types_js_1.EntityType.SECRET,
139
+ /(?<![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],
140
+ ];
141
+ /**
142
+ * The entity type a label immediately before `start` names, or null.
143
+ *
144
+ * Reads at most `CUE_WINDOW` characters, and every pattern is anchored to the
145
+ * end of that window, so the cue must *touch* the span. A label further away is
146
+ * about some other value.
147
+ */
148
+ function cuedType(text, start) {
149
+ const before = text.slice(Math.max(0, start - exports.CUE_WINDOW), start);
150
+ for (const [entityType, pattern] of exports.CUES) {
151
+ if (pattern.test(before))
152
+ return entityType;
153
+ }
154
+ return null;
155
+ }
156
+ /**
157
+ * Shapes so generic that a label in front is better evidence than the pattern
158
+ * that matched. A bare digit run is not a phone number because it *could* be
159
+ * dialled.
160
+ */
161
+ const RETYPABLE = new Set([
162
+ types_js_1.EntityType.PHONE, types_js_1.EntityType.POSTAL_CODE, types_js_1.EntityType.LICENSE_PLATE,
163
+ ]);
164
+ /**
165
+ * Retypable, but only when the document gives its country no support at all.
166
+ *
167
+ * These carry a checksum, so normally the pattern outranks any label. But a
168
+ * checksum only says the digits fit *some* national scheme, and a weak one fits
169
+ * by luck — the same reasoning that demotes an uncorroborated validated
170
+ * candidate to priority 1 in `engine.ts`. At a country score of 0.0 the engine
171
+ * is already saying "this passed a Czech checksum in a document with nothing
172
+ * Czech about it", and an explicit "Passport No.:" is the better evidence.
173
+ *
174
+ * Restricted to zero country support on purpose: a Dutch BSN in a Dutch
175
+ * document scores above zero and stays a NATIONAL_ID whatever label sits near
176
+ * it, so this cannot relabel a domestic identifier — only the coincidental
177
+ * foreign ones, which is where "Passport No.: 512847603" and
178
+ * "Betriebsstättennr. 260326822" both landed, each reported as Czech.
179
+ */
180
+ const RETYPABLE_UNCORROBORATED = new Set([types_js_1.EntityType.NATIONAL_ID]);
181
+ /**
182
+ * Types a label can assert on its own.
183
+ *
184
+ * PHONE is absent on purpose: "Tel:" in front of something no phone pattern
185
+ * matched means the document is laid out oddly, not that the value is a phone
186
+ * number. So are EMAIL, DOB, VIN and the rest — those carry their own
187
+ * structure, and a nearby word is not entitled to overrule it.
188
+ */
189
+ exports.CUE_TARGETS = new Set([
190
+ types_js_1.EntityType.NATIONAL_ID, types_js_1.EntityType.SSN, types_js_1.EntityType.TAX_ID,
191
+ types_js_1.EntityType.HEALTH_INSURANCE, types_js_1.EntityType.HEALTHCARE_PROVIDER,
192
+ types_js_1.EntityType.CHAMBER_OF_COMMERCE, types_js_1.EntityType.VAT, types_js_1.EntityType.POSTAL_CODE,
193
+ types_js_1.EntityType.PASSPORT, types_js_1.EntityType.INTERNAL_ID, types_js_1.EntityType.BANK_ACCOUNT,
194
+ types_js_1.EntityType.SECRET,
195
+ ]);
196
+ /**
197
+ * Which of those a label may also *rescue* — re-admit after its checksum
198
+ * failed. Until 0.3.9 this was the same set as CUE_TARGETS and it cannot stay
199
+ * that way: re-typing a span the engine already decided to mask is cheap to be
200
+ * wrong about, while rescuing one asserts that a value which failed its check
201
+ * digit is a real identifier anyway.
202
+ *
203
+ * BIC's absence is what keeps the rescue honest. Its validator is a *registry
204
+ * lookup*, not a checksum, so a failure means "no such bank", which no label can
205
+ * talk you out of. Rescuing it read 34 unlisted codes behind a "BIC:" label as
206
+ * real ones. The same reasoning keeps CREDIT_CARD and BANK_ACCOUNT out: Luhn and
207
+ * mod-97 are strong enough that a failure really does mean "not one of these".
208
+ * BANK_ACCOUNT joined CUE_TARGETS in 0.3.9 so "sort code 20-45-91" stops being a
209
+ * LICENSE_PLATE, and is kept out here for exactly the 0.3.8 reason.
210
+ *
211
+ * HEALTHCARE_PROVIDER and SECRET are absent because neither is checksummed;
212
+ * there is no failed validation for a label to overrule in the first place.
213
+ */
214
+ exports.RESCUE_TARGETS = new Set([
215
+ types_js_1.EntityType.NATIONAL_ID, types_js_1.EntityType.SSN, types_js_1.EntityType.TAX_ID,
216
+ types_js_1.EntityType.HEALTH_INSURANCE, types_js_1.EntityType.CHAMBER_OF_COMMERCE,
217
+ types_js_1.EntityType.VAT, types_js_1.EntityType.POSTAL_CODE, types_js_1.EntityType.PASSPORT,
218
+ types_js_1.EntityType.INTERNAL_ID,
219
+ ]);
220
+ /**
221
+ * The type a cue overrules `entityType` with, or null to leave it alone.
222
+ *
223
+ * Both gates are load-bearing. Measured over 2,000 corpus documents, re-typing
224
+ * without them fired 52 times and got roughly 30 of those wrong — EMAIL read as
225
+ * VAT, a date of birth as a phone number, a VIN as VAT — against 20 real fixes.
226
+ * With them: 14 re-typings, every one of them PHONE to a structured identifier.
227
+ *
228
+ * `countryScore` is the document's support for the country the winning pattern
229
+ * came from; see RETYPABLE_UNCORROBORATED.
230
+ */
231
+ function retypedBy(text, start, entityType, countryScore) {
232
+ const type = String(entityType);
233
+ if (!RETYPABLE.has(type)
234
+ && !(RETYPABLE_UNCORROBORATED.has(type) && countryScore === 0))
235
+ return null;
236
+ const cued = cuedType(text, start);
237
+ if (cued === null || cued === entityType || !exports.CUE_TARGETS.has(String(cued)))
238
+ return null;
239
+ return cued;
240
+ }
@@ -35,9 +35,11 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.RuleEngine = exports.COUNTRY_CODE_ALIASES = void 0;
37
37
  exports.resolveCountryCode = resolveCountryCode;
38
+ exports.tieKey = tieKey;
38
39
  const types_js_1 = require("../types.js");
39
40
  const matchers_js_1 = require("./matchers.js");
40
41
  const suppressors_js_1 = require("./suppressors.js");
42
+ const cues_js_1 = require("./cues.js");
41
43
  const structural_js_1 = require("./structural.js");
42
44
  const index_js_1 = require("./countries/index.js");
43
45
  const evidenceMod = __importStar(require("./evidence.js"));
@@ -80,6 +82,48 @@ function resolveCountryCode(countryCode) {
80
82
  code = exports.COUNTRY_CODE_ALIASES[code] ?? code;
81
83
  return code in index_js_1.COUNTRY_CONFIGS && code !== "SHARED" ? code : null;
82
84
  }
85
+ /**
86
+ * Last-resort ordering for candidates every other field has tied on.
87
+ *
88
+ * Reaching this means the engine has no evidence left: same span, no cue, same
89
+ * priority, the same (usually zero) country score, and the same declared scope.
90
+ * Something still has to win, and before this the winner was whichever candidate
91
+ * happened to be generated first — which is country *registration* order, and
92
+ * the two SDKs do not share one. Python registers alphabetically
93
+ * (`AT, BE, BG, CH, CY, CZ, ...`); this SDK uses a curated order
94
+ * (`NL, BE, DE, AT, CH, FR, ..., PT, LU, PL, ..., CZ`). So a bare digit run that
95
+ * several national schemes accept was filed as a Czech `NATIONAL_ID` by Python
96
+ * and a Portuguese `PHONE` here — identical characters masked, contradictory
97
+ * labels, and `make parity` compared only characters, so it stayed green through
98
+ * the whole of 0.3.8.
99
+ *
100
+ * The order reproduces what Python already did rather than inventing a new one,
101
+ * because every accuracy figure this engine has published was measured with
102
+ * Python resolving these ties by its registration order. Ordering on the entity
103
+ * type instead was tried first and re-tuned the corpus twice over: it turned the
104
+ * German `Steuerß12345678911` from `TAX_ID`/DE into `NATIONAL_ID`/EL, and moved
105
+ * 820 further spans from `DOB` to `DATE_OF_DEATH`. So the key is deliberately
106
+ * not total — candidates from the same country are left to the stable sort,
107
+ * which preserves the order that country's pattern list was written in, and both
108
+ * SDKs write theirs in the same order.
109
+ *
110
+ * Taken from the **pattern**, never the finished detection: this SDK re-types a
111
+ * cued candidate before the sort and Python re-types after it, so keying on the
112
+ * final type would reintroduce the divergence in a subtler form.
113
+ *
114
+ * Kept character-for-character in step with `tie_key` in `rules/engine.py`.
115
+ */
116
+ function tieKey(countryCode) {
117
+ // "SHARED" and "CUSTOM" are not countries and sort as themselves under a
118
+ // plain alphabetical key — landing between PT and SK, rather than ahead of
119
+ // every country the way the registry loads them. The bucket restores that.
120
+ const bucket = countryCode === "SHARED" ? "0" : countryCode === "CUSTOM" ? "1" : "2";
121
+ // NUL separator, written as an escape so this file stays plain ASCII, and
122
+ // matching Python byte for byte. It must sort below every character a code
123
+ // or type name can contain, or a code that is a prefix of another would
124
+ // order differently in the two SDKs.
125
+ return `${bucket}\u0000${countryCode}`;
126
+ }
83
127
  const warnedCountries = new Set();
84
128
  function warnUnknownCountry(code) {
85
129
  if (warnedCountries.has(code))
@@ -105,30 +149,13 @@ function warnUnknownCountry(code) {
105
149
  //
106
150
  // Measured: 263 phone numbers and 18 French SIREN were reported as national IDs,
107
151
  // each with an explicit cue in front of it.
108
- const LOCAL_CUES = [
109
- [types_js_1.EntityType.PHONE,
110
- /(?: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],
111
- [types_js_1.EntityType.CHAMBER_OF_COMMERCE,
112
- /(?:siren|siret|kbo|bce|kvk|ondernemingsnummer|handelsregister|company\s*(?:no|number|reg)|organisationsnummer|orgnr)\w*\s*[:.\-]?\s*$/i],
113
- [types_js_1.EntityType.VAT,
114
- /(?:vat|btw|tva|ust|iva|moms|alv|mwst)[\w.\-]*\s*[:.\-]?\s*$/i],
115
- [types_js_1.EntityType.BIC,
116
- /(?:bic|swift)[\w/]*\s*[:.\-]?\s*\(?\s*$/i],
117
- [types_js_1.EntityType.NATIONAL_ID,
118
- /(?:bsn|personnummer|rijksregisternummer|national\s*id|identiteitsnummer|henkilötunnus|kennitala|cpr|nif|dni)\w*\s*[:.\-]?\s*$/i],
119
- ];
120
- // How far back a cue may sit. Short on purpose: a cue is only evidence about the
121
- // value it introduces, which is the lesson of the postal-code year defect, where
122
- // a cue 150 characters away licensed every number in the window.
123
- const CUE_WINDOW = 22;
152
+ //
153
+ // The table itself lives in `cues.ts`, because two other steps read it: the
154
+ // rescue of a declined identifier below, and the re-typing in deduplicate.
155
+ // `CUE_WINDOW` is re-exported from there.
124
156
  /** 1 when a cue naming `entityType` sits immediately before the span. */
125
157
  function localCueBonus(text, start, entityType) {
126
- const before = text.slice(Math.max(0, start - CUE_WINDOW), start);
127
- for (const [cuedType, pattern] of LOCAL_CUES) {
128
- if (cuedType === entityType && pattern.test(before))
129
- return 1;
130
- }
131
- return 0;
158
+ return (0, cues_js_1.cuedType)(text, start) === entityType ? 1 : 0;
132
159
  }
133
160
  class RuleEngine {
134
161
  matcher = new matchers_js_1.MultiPatternMatcher();
@@ -235,7 +262,19 @@ class RuleEngine {
235
262
  // but failed the checksum is recorded with its country and entity type;
236
263
  // what it is allowed to demote is decided below, once the document's
237
264
  // countries are known.
265
+ //
266
+ // One kind of failure is rescued rather than recorded: a span the document
267
+ // itself labels as the very type whose checksum just failed.
268
+ // "Rijksregisternummer: 85.03.19-284.73" is a Belgian national number with a
269
+ // bad check digit — mistyped, OCR'd, or invented — and it is still a
270
+ // national number. Before this, the pattern declined, the generic phone rule
271
+ // was denied the span as a fragment, and the value was left in the clear: a
272
+ // redaction library printing an identifier it had recognised and rejected.
273
+ //
274
+ // Rescued candidates are still recorded as failed spans, so the demotion
275
+ // zones and the fragment rule below are unchanged.
238
276
  const validated = [];
277
+ const rescued = [];
239
278
  const failedSpans = [];
240
279
  for (const m of rawMatches) {
241
280
  if (this.matcher.validate(m)) {
@@ -244,6 +283,9 @@ class RuleEngine {
244
283
  else if (m.patternDef.validator !== null && !m.patternDef.requiresContext) {
245
284
  failedSpans.push({ start: m.start, end: m.end, code: m.countryCode,
246
285
  etype: String(m.patternDef.entityType) });
286
+ if (cues_js_1.RESCUE_TARGETS.has(String(m.patternDef.entityType))
287
+ && (0, cues_js_1.cuedType)(text, m.start) === m.patternDef.entityType)
288
+ rescued.push(m);
247
289
  }
248
290
  }
249
291
  // Evidence pass: work out which countries this document belongs to, from
@@ -438,8 +480,28 @@ class RuleEngine {
438
480
  priority = 1;
439
481
  blindPriority = 1;
440
482
  }
441
- const country = (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
483
+ let entityType = match.patternDef.entityType;
484
+ let country = (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
442
485
  ? match.countryCode : null;
486
+ let outOfScope = declared !== null && country !== null && !declared.has(match.countryCode);
487
+ let confidence = "high";
488
+ // The document labels this span as something else. Relabelling, not
489
+ // suppression: the span is found either way, so removing the claim would
490
+ // decide only whether the value is masked — and the answer was "no".
491
+ // "Rijksregisternummer: 85.03.19-284.73" produced no detection at all.
492
+ //
493
+ // The country came from the pattern that matched, and that pattern was
494
+ // just overruled: a Finnish phone rule saying "Finland" is worthless once
495
+ // the value is filed as a Cypriot identity number. Dropping it also clears
496
+ // outOfScope, which would otherwise claim the detection belongs to a
497
+ // country the caller did not declare while naming no country at all.
498
+ const retyped = (0, cues_js_1.retypedBy)(text, match.start, entityType, countryScores.get(match.countryCode) ?? 0);
499
+ if (retyped !== null) {
500
+ entityType = retyped;
501
+ country = null;
502
+ outOfScope = false;
503
+ confidence = "medium";
504
+ }
443
505
  candidates.push({
444
506
  blindPriority,
445
507
  cue: localCueBonus(text, match.start, match.patternDef.entityType),
@@ -447,6 +509,46 @@ class RuleEngine {
447
509
  length: match.end - match.start,
448
510
  countryScore: countryScores.get(match.countryCode) ?? 0,
449
511
  inScope: declared === null || declared.has(match.countryCode) ? 1 : 0,
512
+ tie: tieKey(match.countryCode),
513
+ tier: 0,
514
+ det: {
515
+ entityType,
516
+ start: match.start,
517
+ end: match.end,
518
+ text: match.text,
519
+ source: types_js_1.DetectionSource.RULES,
520
+ country,
521
+ confidence,
522
+ outOfScope,
523
+ },
524
+ });
525
+ }
526
+ // A declined identifier the document itself labels. Cue-backed by
527
+ // construction, so `cue` is 1 — that is what lets it take a span from a
528
+ // foreign pattern whose checksum happened to pass, which is how
529
+ // "ΑΦΜ: 147382965" was reported as a Czech national ID.
530
+ //
531
+ // `priority` is 0: below every regex-only claim, so it wins only what
532
+ // nothing better wants. `blindPriority` is 1 so that span selection — the
533
+ // country-blind half of the sort — treats it like any other regex-strength
534
+ // claim and invariant I1 is untouched.
535
+ //
536
+ // Emitted as "low", which is the honest description: the shape and the label
537
+ // agree, the check digit does not. A caller wanting only checksum-backed
538
+ // detections filters on it.
539
+ for (const match of rescued) {
540
+ if ((0, suppressors_js_1.shouldSuppress)(text, match, scratch))
541
+ continue;
542
+ const country = (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
543
+ ? match.countryCode : null;
544
+ candidates.push({
545
+ blindPriority: 1,
546
+ cue: 1,
547
+ priority: 0,
548
+ length: match.end - match.start,
549
+ countryScore: countryScores.get(match.countryCode) ?? 0,
550
+ inScope: declared === null || declared.has(match.countryCode) ? 1 : 0,
551
+ tie: tieKey(match.countryCode),
450
552
  tier: 0,
451
553
  det: {
452
554
  entityType: match.patternDef.entityType,
@@ -455,14 +557,15 @@ class RuleEngine {
455
557
  text: match.text,
456
558
  source: types_js_1.DetectionSource.RULES,
457
559
  country,
458
- confidence: "high",
560
+ confidence: "low",
459
561
  outOfScope: declared !== null && country !== null && !declared.has(match.countryCode),
460
562
  },
461
563
  });
462
564
  }
463
565
  for (const d of (0, structural_js_1.detectStructuralDob)(text)) {
464
566
  candidates.push({ blindPriority: 1, cue: 0, priority: 1, length: d.end - d.start,
465
- countryScore: 0, inScope: 1, tier: 0, det: d });
567
+ countryScore: 0, inScope: 1, tier: 0, det: d,
568
+ tie: tieKey(d.country ?? "") });
466
569
  }
467
570
  const detections = this.deduplicate(candidates);
468
571
  detections.sort((a, b) => a.start - b.start || b.end - a.end);
@@ -530,7 +633,8 @@ class RuleEngine {
530
633
  b.cue - a.cue || // local cue } country-aware
531
634
  b.priority - a.priority || // country priority } — decides the
532
635
  b.countryScore - a.countryScore || // country evidence } LABEL only
533
- b.inScope - a.inScope); // declared scope }
636
+ b.inScope - a.inScope || // declared scope }
637
+ (a.tie < b.tie ? -1 : a.tie > b.tie ? 1 : 0)); // total } — see tieKey
534
638
  const result = [];
535
639
  const occupied = new Set();
536
640
  for (const { det } of sorted) {
@@ -44,11 +44,16 @@ const MATH_AFTER = /^\s*[=+\-×÷*/]/;
44
44
  const COUNTRY_PREFIX_HYPHEN = /(?:^|[^A-Za-z0-9])[A-Z]{1,2}-\s*$/;
45
45
  const SEQUENTIAL_PATTERNS = /^(?:0{6,}|1234567890?|0123456789|9876543210?|1111111111?|000000000|123456789)$/;
46
46
  // YEAR_PATTERN kept for reference but replaced by RECENT_YEAR in suppressYearAsPostal
47
- // The leading \b is load-bearing. Without it the short alternatives match the
48
- // *tail* of an ordinary word: "NIS" matched "tālru**nis** ", the Latvian for
49
- // "telephone", so every Latvian phone number was suppressed as though it sat
50
- // behind a Belgian national-ID label. 653 misses from a missing word boundary.
51
- const ID_LABEL_BEFORE = /\b(?:BSN|RR|NN|NIR|INSZ|NISS|NIS|Steuer-?ID|TIN|NIF|NIE|SSN|rijksregisternummer|numéro\s*national|national\s*number|matricule|Ausweisnummer|Personalausweis|Versichertennummer|KVNR|KV-Nr|Steuernummer|St\.\-Nr|StNr|Finanzamt\s+ist|Ondernemingen\s+onder\s+nummer|ondernemingsnummer|numéro\s*d'entreprise|enterprise\s*number|Kruispuntbank)\s*[:.]?\s*$/i;
47
+ // `ID_LABEL_BEFORE` and `suppressPhoneAfterIdLabel` lived here. Their labels are
48
+ // now typed entries in `cues.ts`, and a phone-shaped span behind one is
49
+ // *relabelled* rather than dropped.
50
+ //
51
+ // Dropping was the wrong verb. The span is found either way, so removing the
52
+ // claim decided only whether the value was masked — and the answer was "no":
53
+ // "Rijksregisternummer: 85.03.19-284.73" produced no detection at all, a
54
+ // redaction library printing in full an identifier it had recognised and
55
+ // rejected. The 653-miss word-boundary lesson recorded here moved with the
56
+ // labels; see the module comment in `cues.ts`.
52
57
  const SERVICE_NUMBER = /^0800[\-\s]/;
53
58
  const DATE_PATTERN_FULL = /^\d{2}[-/.]\d{2}[-/.]\d{4}$|^\d{4}[-/.]\d{2}[-/.]\d{2}$/;
54
59
  const PASSPORT_CONTEXT_BEFORE = /(?:Reisepass|passport|passeport|paspoort|Bisheriger\s+Reisepass)\s*(?:Nr\.?|Nummer|nummer|number|n[°o])?\s*[:.]?\s*$/i;
@@ -342,11 +347,52 @@ function suppressYearAsPostal(text, match) {
342
347
  // Suppress: recent years without postal context are almost never postal codes
343
348
  return true;
344
349
  }
345
- function suppressPhoneAfterIdLabel(text, match) {
346
- if (match.patternDef.entityType !== types_js_1.EntityType.PHONE)
350
+ // ── Postal code: disqualified by the word in front of it ───────────────
351
+ //
352
+ // The mirror image of `rules/cues.ts`. There a label promotes a type; here a
353
+ // word refuses one. Both were reported together: a bare four-digit run becomes
354
+ // a POSTAL_CODE as soon as *any* real postal code establishes the country,
355
+ // which is to say in almost every real document, because POSTAL_CONTEXT_NEAR
356
+ // above then rescues it from suppressYearAsPostal. Measured on the training
357
+ // corpus: 55 bare years in prose ("Opgericht in 2016", "Fondée en 2017") plus
358
+ // telephone extensions ("(toest. 3841)", "(poste interne 3318)", "(ext. 2219)").
359
+ /**
360
+ * Prepositions that can only be temporal. Nothing is ever located "since 2018",
361
+ * so these disqualify a postal code on their own.
362
+ */
363
+ const YEAR_WORD_BEFORE = /(?<![A-Za-z0-9_])(?:sinds|since|depuis|seit|siden|sedan|desde|vuonna|anno|dal)\s+$/i;
364
+ /**
365
+ * "in", "en" and "im" are *both* temporal and locative, and in exactly the
366
+ * countries whose postal codes are year-shaped. Belgian 2000 is Antwerp and
367
+ * 2018 is one of its districts, so "Rustige ligging in 2018, vlakbij openbaar
368
+ * vervoer" is an address, not a date — an earlier version of this rule
369
+ * suppressed it and lost a real postal code, which is the worse error for a
370
+ * redaction tool. So an ambiguous preposition needs a founding or payment
371
+ * participle in front of it, which is what every case in the report had:
372
+ * "Opgericht in 2016", "Fondée en 2017", "versé en 2025".
373
+ */
374
+ const YEAR_VERB_BEFORE = /(?<![A-Za-z0-9_])(?:opgericht|gesticht|opgestart|founded|established|created|fond[ée]e?|cr[ée][ée]e?|gegründet|gegruendet|errichtet|grundlagt|grundad|perustettu|fundada|fundado|fondata|costituita|vers[ée]|betaald|uitbetaald|ausgezahlt|paid|geboren|born)\s+(?:in|en|im|op|the)\s+$/i;
375
+ /**
376
+ * A capitalised word immediately after keeps the candidate: "in 2000 Antwerpen"
377
+ * really is a postal code, and so is the "AZ" of a Dutch "1105 AZ". Without
378
+ * this the rule would suppress the very addresses it is meant to leave alone.
379
+ */
380
+ const TOWN_AFTER = /^\s*[A-ZÀ-ÞĀ-Ž]/;
381
+ /**
382
+ * A telephone extension marker. "poste interne 3318" puts one qualifier word
383
+ * between the marker and the number, so allow exactly one.
384
+ */
385
+ const EXTENSION_BEFORE = /(?<![A-Za-z0-9_])(?:toest(?:el)?|ext|extension|poste|durchwahl|doorkiesnummer|tst|nebenstelle)\.?\s*(?:[a-zà-ÿ]{2,10}\s*)?[:.]?\s*$/i;
386
+ /** Suppress a postal code the word in front of it rules out. */
387
+ function suppressPostalAfterDisqualifyingWord(text, match) {
388
+ if (match.patternDef.entityType !== types_js_1.EntityType.POSTAL_CODE)
347
389
  return false;
348
- const [before] = getContext(text, match.start, match.end);
349
- return ID_LABEL_BEFORE.test(before);
390
+ const before = text.slice(Math.max(0, match.start - 24), match.start);
391
+ if (EXTENSION_BEFORE.test(before))
392
+ return true;
393
+ return RECENT_YEAR.test(match.text.trim())
394
+ && (YEAR_WORD_BEFORE.test(before) || YEAR_VERB_BEFORE.test(before))
395
+ && !TOWN_AFTER.test(text.slice(match.end, match.end + 3));
350
396
  }
351
397
  function suppressPhoneServiceNumber(_text, match) {
352
398
  if (match.patternDef.entityType !== types_js_1.EntityType.PHONE)
@@ -793,11 +839,11 @@ function suppressRequiresContext(text, match) {
793
839
  return !match.patternDef.contextKeywords.some(kw => context.includes(kw.toLowerCase()));
794
840
  }
795
841
  const TYPE_SUPPRESSORS = {
796
- [types_js_1.EntityType.PHONE]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressPhoneAfterIdLabel, suppressPhoneServiceNumber, suppressPhoneDateOverlap, suppressPhoneAsNumberRange],
842
+ [types_js_1.EntityType.PHONE]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressPhoneServiceNumber, suppressPhoneDateOverlap, suppressPhoneAsNumberRange],
797
843
  [types_js_1.EntityType.NATIONAL_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressLegal, suppressMath, suppressNatidAsPassport, suppressSeNatidAsOrg],
798
844
  [types_js_1.EntityType.SSN]: [suppressCurrency, suppressUnits, suppressReference, suppressMath],
799
845
  [types_js_1.EntityType.TAX_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressTaxidAsIpAddress],
800
- [types_js_1.EntityType.POSTAL_CODE]: [suppressCurrency, suppressUnits, suppressMath, suppressLegal, suppressYearAsPostal, suppressPostalInsideIban, suppressPostalAsHouseNumber, suppressPostalInLongerIdentifier, suppressReference],
846
+ [types_js_1.EntityType.POSTAL_CODE]: [suppressCurrency, suppressUnits, suppressMath, suppressLegal, suppressYearAsPostal, suppressPostalAfterDisqualifyingWord, suppressPostalInsideIban, suppressPostalAsHouseNumber, suppressPostalInLongerIdentifier, suppressReference],
801
847
  [types_js_1.EntityType.BIC]: [suppressBicWithoutEvidence],
802
848
  [types_js_1.EntityType.BANK_ACCOUNT]: [suppressReference],
803
849
  [types_js_1.EntityType.LICENSE_PLATE]: [suppressPlateInCompound, suppressDePlateUnknownDistrict, suppressPlateAsCurrencyAmount],