euredact 0.3.1 → 0.3.3

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.
@@ -1,4 +1,37 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.RuleEngine = exports.COUNTRY_CODE_ALIASES = void 0;
4
37
  exports.resolveCountryCode = resolveCountryCode;
@@ -7,6 +40,7 @@ const matchers_js_1 = require("./matchers.js");
7
40
  const suppressors_js_1 = require("./suppressors.js");
8
41
  const structural_js_1 = require("./structural.js");
9
42
  const index_js_1 = require("./countries/index.js");
43
+ const evidenceMod = __importStar(require("./evidence.js"));
10
44
  /**
11
45
  * Heuristic to detect nested quantifiers that cause catastrophic backtracking.
12
46
  * After stripping escaped chars and character classes, look for an unbounded
@@ -104,118 +138,324 @@ class RuleEngine {
104
138
  if (newCountries)
105
139
  this.matcher.compile();
106
140
  }
107
- detect(text, countryCodes) {
108
- this.loadCountries(countryCodes);
109
- // Scope the scan to requested countries for efficiency
110
- // Resolve through the alias table so `countries: ["GB"]` actually scans
111
- // the patterns registered under "UK".
112
- let scanCodes = null;
141
+ /**
142
+ * Generate candidate matches. Country-blind, by contract.
143
+ *
144
+ * This is the only caller of {@link MultiPatternMatcher.scan}, and it takes
145
+ * no country argument. Which country the caller declared may influence how a
146
+ * match is *scored*; it may never influence whether the match is *found*.
147
+ * Before this held, `countries: ["BE"]` made a valid Dutch BSN vanish
148
+ * entirely because the Dutch patterns were never run.
149
+ */
150
+ generateCandidates(text) {
151
+ this.loadCountries(null);
152
+ return this.matcher.scan(text, null);
153
+ }
154
+ detect(text, countryCodes, countryHint) {
155
+ return this.detectWithEvidence(text, countryCodes, countryHint).detections;
156
+ }
157
+ /**
158
+ * {@link RuleEngine.detect}, plus the country evidence it reasoned from.
159
+ *
160
+ * `countryCodes` is **scope**: it flags detections from elsewhere as
161
+ * `outOfScope` and, like `countryHint`, contributes a prior. `countryHint` is
162
+ * **only** a prior — it helps resolve ambiguity without marking anything out
163
+ * of scope. Neither gates what is looked for.
164
+ */
165
+ detectWithEvidence(text, countryCodes, countryHint, priorEvidence) {
166
+ const rawMatches = this.generateCandidates(text);
167
+ // The declared countries are a scoring signal from here on: they rank
168
+ // candidates and flag out-of-scope detections. They do not gate.
169
+ let declared = null;
113
170
  if (countryCodes !== null) {
114
- scanCodes = new Set();
171
+ declared = new Set();
115
172
  for (const c of countryCodes) {
116
173
  const code = resolveCountryCode(c);
117
- if (code !== null)
118
- scanCodes.add(code);
174
+ if (code === null)
175
+ warnUnknownCountry(c);
176
+ else
177
+ declared.add(code);
119
178
  }
120
- scanCodes.add("SHARED");
121
- scanCodes.add("CUSTOM");
179
+ declared.add("SHARED");
180
+ declared.add("CUSTOM");
122
181
  }
123
- let rawMatches = this.matcher.scan(text, scanCodes);
124
- // Matches that have a validator but fail it create suppression zones:
125
- // the span is recognisably a specific entity (e.g. IBAN-shaped) so
126
- // overlapping regex-only matches (license plate, phone) are false
127
- // positives and must be suppressed.
182
+ // The prior fed to the evidence bus. Declared countries count too — a
183
+ // caller naming a country is asserting context, which is exactly what a
184
+ // prior is — but a hint never narrows scope, so it is kept out of
185
+ // `declared` and cannot flag anything out of scope.
186
+ const prior = new Set(declared ?? []);
187
+ for (const c of countryHint ?? []) {
188
+ const code = resolveCountryCode(c);
189
+ if (code === null)
190
+ warnUnknownCountry(c);
191
+ else
192
+ prior.add(code);
193
+ }
194
+ // Pass 1: checksum validation. A span that matched a checksummed pattern
195
+ // but failed the checksum is recorded with its country and entity type;
196
+ // what it is allowed to demote is decided below, once the document's
197
+ // countries are known.
128
198
  const validated = [];
129
- const rawZones = [];
199
+ const failedSpans = [];
130
200
  for (const m of rawMatches) {
131
- const isValid = this.matcher.validate(m);
132
- if (isValid) {
201
+ if (this.matcher.validate(m)) {
133
202
  validated.push({ match: m, hasValidValidator: m.patternDef.validator !== null });
134
203
  }
135
204
  else if (m.patternDef.validator !== null && !m.patternDef.requiresContext) {
136
- rawZones.push([m.start, m.end]);
205
+ failedSpans.push({ start: m.start, end: m.end, code: m.countryCode,
206
+ etype: String(m.patternDef.entityType) });
137
207
  }
138
208
  }
139
- // Merge overlapping suppression zones for O(log n) containment checks
140
- let zoneStarts = [];
141
- let zoneEnds = [];
142
- if (rawZones.length > 0) {
143
- rawZones.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
144
- let [ms, me] = rawZones[0];
145
- for (let i = 1; i < rawZones.length; i++) {
146
- const [zs, ze] = rawZones[i];
147
- if (zs <= me) {
209
+ // Evidence pass: work out which countries this document belongs to, from
210
+ // the entities that carry their country in the string.
211
+ //
212
+ // Every surviving candidate is eligible, not only checksum-backed ones. An
213
+ // earlier version required a validator here, which silently dropped the
214
+ // email ccTLD — the second-strongest signal at 4.50 log-odds and present in
215
+ // 98,949 of 152,300 corpus documents — because EMAIL has nothing to
216
+ // checksum. Candidates that failed a validator never reach `validated`, so
217
+ // this admits the unvalidatable, not the invalid.
218
+ const evidenceDets = [];
219
+ for (const { match } of validated) {
220
+ if (!evidenceMod.EVIDENCE_TYPES.has(String(match.patternDef.entityType)))
221
+ continue;
222
+ evidenceDets.push({
223
+ entityType: match.patternDef.entityType,
224
+ start: match.start, end: match.end, text: match.text,
225
+ source: types_js_1.DetectionSource.RULES,
226
+ country: (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
227
+ ? match.countryCode : null,
228
+ confidence: "high",
229
+ });
230
+ }
231
+ const countryEvidence = evidenceMod.collect(evidenceDets);
232
+ const countryScores = evidenceMod.accumulate(priorEvidence && priorEvidence.length ? [...countryEvidence, ...priorEvidence] : countryEvidence, prior);
233
+ const hasScores = countryScores.size > 0;
234
+ const corroborated = (code) => !hasScores || code === "SHARED" || code === "CUSTOM" || countryScores.has(code);
235
+ // Now that the document's countries are known, decide which failed
236
+ // validators are entitled to demote anything. A checksum failure only means
237
+ // "not a valid X"; it says nothing about the span unless X was plausible
238
+ // here in the first place. A Belgian national-ID pattern failing on
239
+ // 0612345678 is not evidence against that span being a Dutch phone number.
240
+ //
241
+ // Zones are also per entity type. A failed checksum is evidence against
242
+ // *that type*, not against the span: Sweden's own personnummer checksum
243
+ // failing on 0708787668 used to demote the Swedish *phone* candidate for
244
+ // the same digits, handing the span to a Danish CPR that happened to
245
+ // validate. Worth 878 mistyped phone numbers per 30,000 documents.
246
+ const zonesByType = new Map();
247
+ for (const f of failedSpans) {
248
+ if (!corroborated(f.code))
249
+ continue;
250
+ const list = zonesByType.get(f.etype);
251
+ if (list)
252
+ list.push([f.start, f.end]);
253
+ else
254
+ zonesByType.set(f.etype, [[f.start, f.end]]);
255
+ }
256
+ // Any type, strict subset: a candidate covering only *part* of a rejected
257
+ // identifier is a fragment of it, not a different entity. The generic
258
+ // separator-tolerant phone pattern claimed characters 3-14 of the rejected
259
+ // Belgian national number in "Rijksregisternummer: 85.03.19-284.73" and
260
+ // reported it as a PHONE. An equal span is two schemes competing for the
261
+ // whole value; a strict subset is one picking over another's wreckage.
262
+ //
263
+ // Deliberately ignores corroboration and uses every failed span, because it
264
+ // *removes* candidates and must therefore be country-blind — gating it on
265
+ // the document's countries would make the set of spans found depend on
266
+ // `countries`, which is invariant I1.
267
+ //
268
+ // Kept unmerged: merging adjacent spans would invent strict-subset
269
+ // relationships that neither original span had.
270
+ const allFailed = failedSpans
271
+ .map(f => [f.start, f.end])
272
+ .sort((a, b) => a[0] - b[0] || a[1] - b[1]);
273
+ const mergedZones = new Map();
274
+ for (const [etype, spans] of zonesByType) {
275
+ spans.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
276
+ const starts = [];
277
+ const ends = [];
278
+ let [ms, me] = spans[0];
279
+ for (let i = 1; i < spans.length; i++) {
280
+ const [zs, ze] = spans[i];
281
+ if (zs <= me)
148
282
  me = Math.max(me, ze);
149
- }
150
283
  else {
151
- zoneStarts.push(ms);
152
- zoneEnds.push(me);
284
+ starts.push(ms);
285
+ ends.push(me);
153
286
  ms = zs;
154
287
  me = ze;
155
288
  }
156
289
  }
157
- zoneStarts.push(ms);
158
- zoneEnds.push(me);
290
+ starts.push(ms);
291
+ ends.push(me);
292
+ mergedZones.set(etype, { starts, ends });
159
293
  }
160
- // Build candidates with priority: validated (3) > custom (2) > regex-only (1)
161
294
  const candidates = [];
162
295
  for (const { match, hasValidValidator } of validated) {
163
296
  if ((0, suppressors_js_1.shouldSuppress)(text, match))
164
297
  continue;
165
- // O(log n) binary-search containment check on merged zones
166
- if (match.patternDef.validator === null && zoneStarts.length > 0) {
167
- let lo = 0, hi = zoneStarts.length;
168
- while (lo < hi) {
169
- const mid = (lo + hi) >>> 1;
170
- if (zoneStarts[mid] <= match.start)
171
- lo = mid + 1;
172
- else
173
- hi = mid;
298
+ // A validator-less match inside a failed-validation span of its own type
299
+ // is demoted below every other candidate rather than deleted. Demotion
300
+ // cannot silence a detection, which is the property deletion lacked:
301
+ // measured across the corpus, deletion removed 454 detections of which
302
+ // 454 overlapped real labelled PII.
303
+ let demoted = false;
304
+ if (match.patternDef.validator === null) {
305
+ const zone = mergedZones.get(String(match.patternDef.entityType));
306
+ if (zone) {
307
+ let lo = 0, hi = zone.starts.length;
308
+ while (lo < hi) {
309
+ const mid = (lo + hi) >>> 1;
310
+ if (zone.starts[mid] <= match.start)
311
+ lo = mid + 1;
312
+ else
313
+ hi = mid;
314
+ }
315
+ const idx = lo - 1;
316
+ demoted = idx >= 0 && zone.ends[idx] >= match.end;
174
317
  }
175
- const idx = lo - 1;
176
- if (idx >= 0 && zoneEnds[idx] >= match.end)
318
+ }
319
+ // Dropped rather than demoted. Demotion cannot help here: nothing else
320
+ // wants a fragment, so a demoted candidate still wins its span by
321
+ // default — the generic phone pattern kept claiming the tail of the
322
+ // rejected national number.
323
+ if (match.patternDef.validator === null && !demoted) {
324
+ let fragment = false;
325
+ for (const [zs, ze] of allFailed) {
326
+ if (zs > match.start)
327
+ break;
328
+ if (match.end <= ze && (match.start > zs || match.end < ze)) {
329
+ fragment = true;
330
+ break;
331
+ }
332
+ }
333
+ if (fragment)
177
334
  continue;
178
335
  }
179
- // A bare digit run is the weakest evidence in the engine and must never
180
- // re-cut a span a structured detector claims (PHONE, SSN, NATIONAL_ID,
181
- // BANK_ACCOUNT, VAT...). Postal codes take unclaimed spans only,
182
- // whatever the span lengths.
183
- const priority = hasValidValidator
184
- ? 3
185
- : match.countryCode === "CUSTOM"
186
- ? 2
187
- : match.patternDef.entityType === types_js_1.EntityType.POSTAL_CODE
188
- ? 0
189
- : 1;
190
- const spanLength = match.end - match.start;
191
- const score = priority * 1_000_000 + spanLength;
336
+ let priority;
337
+ let blindPriority;
338
+ if (demoted) {
339
+ priority = -1;
340
+ blindPriority = -1;
341
+ }
342
+ else if (hasValidValidator) {
343
+ // A passing checksum normally outranks everything. But a checksum only
344
+ // says the digits fit *some* national scheme, and weak ones fit by luck
345
+ // — a mod-11 scheme accepts a random number about one time in eleven.
346
+ // So a validated candidate from a country the document shows no trace
347
+ // of is treated as coincidence, not evidence.
348
+ //
349
+ // Concretely: the Dutch phone number 0612345678 also passes the Danish
350
+ // CPR checksum. Entities that carry their own country vouch for
351
+ // themselves — an IBAN emits evidence for its own country — so a
352
+ // foreign IBAN in a domestic invoice keeps its rank.
353
+ priority = corroborated(match.countryCode) ? 3 : 1;
354
+ blindPriority = 3;
355
+ }
356
+ else if (match.countryCode === "CUSTOM") {
357
+ priority = 2;
358
+ blindPriority = 2;
359
+ }
360
+ else if (match.patternDef.entityType === types_js_1.EntityType.POSTAL_CODE) {
361
+ // A bare digit run is the weakest evidence in the engine and must never
362
+ // re-cut a span a structured detector claims (PHONE, SSN, NATIONAL_ID,
363
+ // BANK_ACCOUNT, VAT...). Postal codes take unclaimed spans only,
364
+ // whatever the span lengths.
365
+ priority = 0;
366
+ blindPriority = 0;
367
+ }
368
+ else {
369
+ priority = 1;
370
+ blindPriority = 1;
371
+ }
372
+ const country = (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
373
+ ? match.countryCode : null;
192
374
  candidates.push({
375
+ blindPriority,
376
+ priority,
377
+ length: match.end - match.start,
378
+ countryScore: countryScores.get(match.countryCode) ?? 0,
379
+ inScope: declared === null || declared.has(match.countryCode) ? 1 : 0,
193
380
  det: {
194
381
  entityType: match.patternDef.entityType,
195
382
  start: match.start,
196
383
  end: match.end,
197
384
  text: match.text,
198
385
  source: types_js_1.DetectionSource.RULES,
199
- country: (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM") ? match.countryCode : null,
386
+ country,
200
387
  confidence: "high",
388
+ outOfScope: declared !== null && country !== null && !declared.has(match.countryCode),
201
389
  },
202
- score,
203
390
  });
204
391
  }
205
392
  for (const d of (0, structural_js_1.detectStructuralDob)(text)) {
206
- candidates.push({ det: d, score: 1_000_000 + (d.end - d.start) });
393
+ candidates.push({ blindPriority: 1, priority: 1, length: d.end - d.start,
394
+ countryScore: 0, inScope: 1, det: d });
395
+ }
396
+ const detections = this.deduplicate(candidates);
397
+ detections.sort((a, b) => a.start - b.start || b.end - a.end);
398
+ // Attach the confidence behind each country attribution, so a caller can
399
+ // tell "Danish, and the document is plainly Danish" from "Danish, on a
400
+ // checksum alone".
401
+ const ranking = evidenceMod.weightsToRanking(countryScores);
402
+ for (const d of detections) {
403
+ d.countryConfidence = (d.country !== null ? ranking.get(d.country) : undefined) ?? 0;
207
404
  }
208
- return this.deduplicate(candidates);
405
+ return { detections, evidence: countryEvidence, scores: countryScores };
209
406
  }
210
407
  /**
211
408
  * Remove overlapping detections with priority-aware resolution.
212
- * Priority: validated (3) > custom (2) > regex-only (1).
213
- * Within the same tier, longer span wins.
409
+ * Priority: validated (3) > custom (2) > regex-only (1) > postal (0) >
410
+ * demoted (-1). Within a tier, longer span wins, then country evidence.
214
411
  */
215
412
  deduplicate(candidates) {
216
413
  if (candidates.length === 0)
217
414
  return [];
218
- const sorted = [...candidates].sort((a, b) => b.score - a.score);
415
+ // The structural tier is a property of the *span*, not of the candidate:
416
+ // the best tier anything claiming those exact characters can offer. Used
417
+ // per candidate it would also discriminate *within* a span, where the
418
+ // country-aware tier must have the final word — that silently disabled
419
+ // country inference entirely in the Python SDK, handing every ambiguous
420
+ // value to whichever foreign scheme happened to validate.
421
+ const spanTier = new Map();
422
+ for (const c of candidates) {
423
+ const key = `${c.det.start}:${c.det.end}`;
424
+ const seen = spanTier.get(key);
425
+ if (seen === undefined || c.blindPriority > seen)
426
+ spanTier.set(key, c.blindPriority);
427
+ }
428
+ const tierOf = (c) => spanTier.get(`${c.det.start}:${c.det.end}`);
429
+ // Span length outranks *everything*, including validation.
430
+ //
431
+ // A shorter candidate winning re-cuts a longer one and leaves the
432
+ // remainder in the clear, which is a leak. Two measured cases:
433
+ //
434
+ // "06 12 34 56 78" the Belgian phone pattern claimed 11 of 14
435
+ // characters and exposed three digits
436
+ // "194.232.104.77" the Dutch national-ID pattern validates on
437
+ // "194.232.104" and outranked the IP address,
438
+ // leaving ".77" in the output
439
+ //
440
+ // The second also broke invariant I1 in the Python SDK, where promotion
441
+ // depends on whether the document corroborates the country, so `countries`
442
+ // decided which span won. Ranking length first removes both — a redaction
443
+ // tool masking more than it must is recoverable; masking less is not.
444
+ //
445
+ // Priority still decides between candidates of *equal* length, which is
446
+ // where it matters: a valid IBAN beats a coincidental phone match on the
447
+ // same characters.
448
+ // Ordering between *different* spans is country-blind; the country only
449
+ // ever picks the winner among candidates covering the *same* span. That
450
+ // split makes invariant I1 structural rather than merely tested: the key is
451
+ // country-blind up to and including `start`, and (length, start) already
452
+ // identifies a span uniquely.
453
+ const sorted = [...candidates].sort((a, b) => b.length - a.length || // longest first } country-blind
454
+ tierOf(b) - tierOf(a) || // best tier here } — decides
455
+ a.det.start - b.det.start || // leftmost } WHICH span
456
+ b.priority - a.priority || // country priority } country-aware
457
+ b.countryScore - a.countryScore || // country evidence } — decides the
458
+ b.inScope - a.inScope); // declared scope } LABEL only
219
459
  const result = [];
220
460
  const occupied = new Set();
221
461
  for (const { det } of sorted) {
@@ -0,0 +1,201 @@
1
+ "use strict";
2
+ /**
3
+ * Country evidence: what a document says about which country it belongs to.
4
+ *
5
+ * Some entities carry their country in the string — an IBAN's country code, a
6
+ * `+CC` dialling prefix, a VAT prefix, an email ccTLD. Those are used to work
7
+ * out which country a document belongs to, which in turn decides which
8
+ * national scheme owns an ambiguous value elsewhere in the same document.
9
+ * 36.6% of national-ID values in the corpus validate under more than one
10
+ * country's checksum, so without this the engine cannot tell a Dutch BSN from
11
+ * a Polish PESEL.
12
+ *
13
+ * Evidence influences **scoring only**. It ranks candidates that already exist
14
+ * and annotates the result; it never decides what is looked for. A wrong
15
+ * inference must not be able to cause a miss.
16
+ *
17
+ * Weights are derived, not chosen — measured as P(document country = X |
18
+ * signal claims X) across the corpus. Kept identical to the Python SDK's
19
+ * `rules/evidence.py`; re-derive there and port, rather than tuning by feel.
20
+ */
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.EVIDENCE_TYPES = exports.CC_TLDS = exports.DIALLING_CODES = exports.WEIGHTS = void 0;
23
+ exports.collect = collect;
24
+ exports.accumulate = accumulate;
25
+ exports.weightsToRanking = weightsToRanking;
26
+ const types_js_1 = require("../types.js");
27
+ // ── Derived weights ─────────────────────────────────────────────────────
28
+ //
29
+ // Measured on 152,300 documents:
30
+ //
31
+ // signal agree total P(doc=X|signal) raw log-odds
32
+ // e164Prefix 41,402 41,402 0.9999 9.21
33
+ // bicCountry 2,588 2,588 0.9999 9.21
34
+ // emailTld 97,865 98,949 0.9890 4.50
35
+ // vatPrefix 19,022 20,136 0.9447 2.84
36
+ // ibanPrefix 110,572 126,428 0.8746 1.94
37
+ //
38
+ // The IBAN prefix being the *weakest* signal is real and worth knowing: a
39
+ // Belgian IBAN in a Dutch invoice is ordinary, so the account's country is only
40
+ // weak evidence about the document's.
41
+ //
42
+ // The perfect scores are capped. They are perfect because the generator emits
43
+ // country-consistent values, not because a foreign mobile number never appears
44
+ // in a real document. Treating a synthetic 1.0 as certainty would make a single
45
+ // phone number unfalsifiable by any amount of contrary evidence.
46
+ const MAX_LOG_ODDS = 4.0;
47
+ exports.WEIGHTS = {
48
+ e164Prefix: Math.min(9.21, MAX_LOG_ODDS),
49
+ bicCountry: Math.min(9.21, MAX_LOG_ODDS),
50
+ emailTld: Math.min(4.5, MAX_LOG_ODDS),
51
+ vatPrefix: 2.84,
52
+ ibanPrefix: 1.94,
53
+ declared: 2.0, // the caller's `countries`, a prior rather than a measurement
54
+ };
55
+ // ── Signal tables ───────────────────────────────────────────────────────
56
+ /**
57
+ * E.164 calling code -> country, for the countries this engine supports. Codes
58
+ * are unique within this set; a shared code (+1, +7) would have to emit
59
+ * evidence for every candidate country rather than one.
60
+ */
61
+ exports.DIALLING_CODES = {
62
+ "43": "AT", "32": "BE", "359": "BG", "41": "CH", "357": "CY", "420": "CZ",
63
+ "49": "DE", "45": "DK", "372": "EE", "30": "EL", "34": "ES", "358": "FI",
64
+ "33": "FR", "385": "HR", "36": "HU", "353": "IE", "354": "IS", "39": "IT",
65
+ "370": "LT", "352": "LU", "371": "LV", "356": "MT", "31": "NL", "47": "NO",
66
+ "48": "PL", "351": "PT", "40": "RO", "46": "SE", "386": "SI", "421": "SK",
67
+ "44": "UK",
68
+ };
69
+ /**
70
+ * ccTLD -> country. Deliberately excludes .eu/.com/.org/.net: those are not
71
+ * weak evidence, they are no evidence, and treating them as weak would let a
72
+ * .com address outvote a genuine national signal.
73
+ */
74
+ exports.CC_TLDS = {
75
+ at: "AT", be: "BE", bg: "BG", ch: "CH", cy: "CY", cz: "CZ",
76
+ de: "DE", dk: "DK", ee: "EE", gr: "EL", es: "ES", fi: "FI",
77
+ fr: "FR", hr: "HR", hu: "HU", ie: "IE", is: "IS", it: "IT",
78
+ lt: "LT", lu: "LU", lv: "LV", mt: "MT", nl: "NL", no: "NO",
79
+ pl: "PL", pt: "PT", ro: "RO", se: "SE", si: "SI", sk: "SK",
80
+ uk: "UK",
81
+ };
82
+ /** VAT prefixes that are not the ISO code: Greece trades as EL, Northern
83
+ * Ireland as XI. Naive alpha-2 handling gets both wrong. */
84
+ const VAT_PREFIX_OVERRIDES = { EL: "EL", XI: "UK" };
85
+ const emitIban = (det) => {
86
+ const code = det.text.slice(0, 2).toUpperCase();
87
+ if (!/^[A-Z]{2}$/.test(code))
88
+ return [];
89
+ return [{ country: code, source: "ibanPrefix", logOdds: exports.WEIGHTS.ibanPrefix,
90
+ span: [det.start, det.end] }];
91
+ };
92
+ const emitE164 = (det) => {
93
+ if (!det.text.trimStart().startsWith("+"))
94
+ return [];
95
+ const digits = det.text.replace(/\D/g, "");
96
+ for (const len of [3, 2]) {
97
+ const country = exports.DIALLING_CODES[digits.slice(0, len)];
98
+ if (country) {
99
+ return [{ country, source: "e164Prefix", logOdds: exports.WEIGHTS.e164Prefix,
100
+ span: [det.start, det.end] }];
101
+ }
102
+ }
103
+ return [];
104
+ };
105
+ const emitVat = (det) => {
106
+ const prefix = det.text.slice(0, 2).toUpperCase();
107
+ if (!/^[A-Z]{2}$/.test(prefix))
108
+ return [];
109
+ const country = VAT_PREFIX_OVERRIDES[prefix] ?? prefix;
110
+ return [{ country, source: "vatPrefix", logOdds: exports.WEIGHTS.vatPrefix,
111
+ span: [det.start, det.end] }];
112
+ };
113
+ const emitBic = (det) => {
114
+ const clean = det.text.replace(/ /g, "").toUpperCase();
115
+ if (clean.length < 6)
116
+ return [];
117
+ return [{ country: clean.slice(4, 6), source: "bicCountry",
118
+ logOdds: exports.WEIGHTS.bicCountry, span: [det.start, det.end] }];
119
+ };
120
+ const emitTld = (det) => {
121
+ const parts = det.text.split(".");
122
+ const country = exports.CC_TLDS[parts[parts.length - 1].toLowerCase()];
123
+ if (!country)
124
+ return [];
125
+ return [{ country, source: "emailTld", logOdds: exports.WEIGHTS.emailTld,
126
+ span: [det.start, det.end] }];
127
+ };
128
+ /** Dispatch by entity type, mirroring the suppressor tables. */
129
+ const TYPE_EMITTERS = {
130
+ [types_js_1.EntityType.BANK_ACCOUNT]: [emitIban],
131
+ [types_js_1.EntityType.PHONE]: [emitE164],
132
+ [types_js_1.EntityType.VAT]: [emitVat],
133
+ [types_js_1.EntityType.BIC]: [emitBic],
134
+ [types_js_1.EntityType.EMAIL]: [emitTld],
135
+ };
136
+ /** Entity types that can carry country evidence. */
137
+ exports.EVIDENCE_TYPES = new Set(Object.keys(TYPE_EMITTERS));
138
+ /**
139
+ * Country evidence carried by *detections*, in document order.
140
+ *
141
+ * Deduplicated by (span, source): several countries' patterns can match the
142
+ * same IBAN, and counting that span once per pattern would let a single entity
143
+ * outvote the rest of the document.
144
+ */
145
+ function collect(detections) {
146
+ const evidence = [];
147
+ const seen = new Set();
148
+ for (const det of detections) {
149
+ for (const emit of TYPE_EMITTERS[det.entityType] ?? []) {
150
+ for (const ev of emit(det)) {
151
+ const key = `${ev.span[0]}:${ev.span[1]}:${ev.source}`;
152
+ if (!seen.has(key)) {
153
+ seen.add(key);
154
+ evidence.push(ev);
155
+ }
156
+ }
157
+ }
158
+ }
159
+ // Document order, not pattern-registration order: this list is an audit
160
+ // trail, and a reader checking it against the text should not have to jump
161
+ // around. Also keeps the two SDKs byte-comparable.
162
+ evidence.sort((a, b) => a.span[0] - b.span[0] || a.span[1] - b.span[1]);
163
+ return evidence;
164
+ }
165
+ /**
166
+ * Sum evidence into a per-country log-odds score.
167
+ *
168
+ * Returns a weighted set, never a single winner: a Belgian supplier invoicing a
169
+ * German customer is not "70% Belgian", both countries are genuinely present,
170
+ * and an ambiguous value must be resolvable against either.
171
+ */
172
+ function accumulate(evidence, declared) {
173
+ const scores = new Map();
174
+ for (const ev of evidence) {
175
+ scores.set(ev.country, (scores.get(ev.country) ?? 0) + ev.logOdds);
176
+ }
177
+ for (const country of declared ?? []) {
178
+ if (country !== "SHARED" && country !== "CUSTOM") {
179
+ scores.set(country, (scores.get(country) ?? 0) + exports.WEIGHTS.declared);
180
+ }
181
+ }
182
+ return scores;
183
+ }
184
+ /**
185
+ * Convert per-country log-odds into confidences in [0, 1].
186
+ *
187
+ * Each country is scored independently — a logistic on its own log-odds —
188
+ * because document countries are not mutually exclusive. A Belgian supplier
189
+ * invoicing a German customer is genuinely both; softmaxing across countries
190
+ * would report the German side of that invoice at 0.00 and make cross-border
191
+ * documents indistinguishable from misattributions.
192
+ *
193
+ * Confidences therefore do not sum to 1, by design.
194
+ */
195
+ function weightsToRanking(scores) {
196
+ const out = new Map();
197
+ for (const [country, score] of scores) {
198
+ out.set(country, 1 / (1 + Math.exp(-score)));
199
+ }
200
+ return out;
201
+ }
@@ -22,6 +22,16 @@ class MultiPatternMatcher {
22
22
  compile() {
23
23
  this.compiled = true;
24
24
  }
25
+ /**
26
+ * Scan text against patterns.
27
+ *
28
+ * `countryCodes` restricts which patterns run. **The engine never passes
29
+ * it.** Generation is country-blind by contract — see
30
+ * `RuleEngine.generateCandidates` and `src/__tests__/inference.ts` — because
31
+ * gating generation on the caller's declared country caused silent misses.
32
+ * The parameter is kept for a future opt-in fast mode, which would be the
33
+ * only caller permitted to trade recall for speed.
34
+ */
25
35
  scan(text, countryCodes = null) {
26
36
  if (!this.compiled)
27
37
  this.compile();