euredact 0.3.6 → 0.3.8

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.
@@ -0,0 +1,163 @@
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
+ * * The qualifier class is spelled out rather than written `[^\W\d_]`, which
37
+ * would reject "εταιρειας" here for the same ASCII reason.
38
+ *
39
+ * Every label ends in `(?:\w*|\s+<one word>)`: the label either runs on into a
40
+ * longer word ("telefoon" → "telefoonnummer") **or** takes one qualifier word
41
+ * naming whose identifier it is ("ΑΦΜ εταιρειας:", "Tel Nr:") — never both.
42
+ * Allowing both let "nie" grow into the surname "Nieminen" and then swallow the
43
+ * real label in "Matti Nieminen\nDOB: 08.05.1994", reading a date of birth as a
44
+ * Spanish NIE: 52 false positives across the corpus.
45
+ */
46
+ import { EntityType } from "../types.js";
47
+ /**
48
+ * How far back a cue may sit. Short on purpose: a cue is only evidence about
49
+ * the value it introduces, which is the lesson of the postal-code year defect,
50
+ * where a cue 150 characters away licensed every number in the window.
51
+ */
52
+ export const CUE_WINDOW = 22;
53
+ /**
54
+ * (entity type, label pattern). Ordered: the first match wins, so the narrower
55
+ * label comes first where two could read the same text.
56
+ */
57
+ export const CUES = [
58
+ // Phone. Ranking only — nothing is ever relabelled *to* PHONE: a phone cue in
59
+ // front of a span that no phone pattern matched says the document is laid out
60
+ // unusually, not that the value is a phone number.
61
+ [EntityType.PHONE,
62
+ /(?<![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|τηλέφωνο|τηλ)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
63
+ // National identity number.
64
+ [EntityType.NATIONAL_ID,
65
+ /(?<![A-Za-z0-9_])(?: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)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
66
+ // Cyprus: "ID number" in both official languages, plus the Greek abbreviation
67
+ // ΑΔΤ (Αστυνομική Ταυτότητα). Neither has a phone reading.
68
+ [EntityType.NATIONAL_ID,
69
+ /(?<![A-Za-z0-9_])(?:αρ\.?\s*ταυτότητας|ταυτότητας|αδτ|kimlik\s*numaras[ıi])(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
70
+ // Social security.
71
+ [EntityType.SSN,
72
+ /(?<![A-Za-z0-9_])(?:ssn|social\s*security|sozialversicherungsnummer|svnr|sofi)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
73
+ // Tax number. Greece's ΑΦΜ is issued by the tax authority; the identity-card
74
+ // number is the ΑΔΤ, above. The engine used to return NATIONAL_ID for a value
75
+ // cued ΑΦΜ, which is wrong independently of the phone problem.
76
+ [EntityType.TAX_ID,
77
+ /(?<![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)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
78
+ // Health insurance.
79
+ [EntityType.HEALTH_INSURANCE,
80
+ /(?<![A-Za-z0-9_])(?:versichertennummer|krankenversicherung|kvnr|kv-?nr|mutualité|ziekenfonds|aansluitingsnummer)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
81
+ // Company / trade register.
82
+ [EntityType.CHAMBER_OF_COMMERCE,
83
+ /(?<![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|company\s*(?:no|number|reg)|organisationsnummer|orgnr|i[čc]o|identifikační\s*číslo|ЕИК|eik|bulstat|cvr(?:-?nummer)?|virksomhedsnummer)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
84
+ // VAT.
85
+ [EntityType.VAT,
86
+ /(?<![A-Za-z0-9_])(?:vat|btw|tva|ust|iva|moms|alv|mwst|dič|vsk)(?:[\w.\-]*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
87
+ // BIC / SWIFT.
88
+ [EntityType.BIC,
89
+ /(?<![A-Za-z0-9_])(?:bic|swift)[\w/]*\s*[:.\-]?\s*\(?\s*$/i],
90
+ // Postal code. A postal code loses every contested span on priority (it is
91
+ // the weakest evidence the engine has), so without a cue entry
92
+ // "Cod postal: 040171" was handed to a German phone pattern.
93
+ [EntityType.POSTAL_CODE,
94
+ /(?<![A-Za-z0-9_])(?:cod\s*po[sș]tal|postnummer|postnr|postleitzahl|plz|postcode|code\s*postal|codice\s*postale|kod\s*pocztowy|ps[čc]|τ\.?κ\.?|пощенски\s*код)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
95
+ // Internal / employee identifier. The canon calls these INTERNAL_ID and
96
+ // annotates them with an LLM, so the engine has no pattern for them — but it
97
+ // does have a phone pattern that was happily claiming them. The cue is the
98
+ // only thing that identifies one, which is why the engine emits this type
99
+ // from here and nowhere else.
100
+ [EntityType.INTERNAL_ID,
101
+ /(?<![A-Za-z0-9_])(?:medarbejdernummer|ansattnummer|anställningsnummer|personalnummer|personeelsnummer|matricule\s*salarié|employee\s*(?:no|number|id)|badge\s*(?:no|number)|osobní\s*(?:číslo\s*)?zaměstnance)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
102
+ ];
103
+ /**
104
+ * The entity type a label immediately before `start` names, or null.
105
+ *
106
+ * Reads at most `CUE_WINDOW` characters, and every pattern is anchored to the
107
+ * end of that window, so the cue must *touch* the span. A label further away is
108
+ * about some other value.
109
+ */
110
+ export function cuedType(text, start) {
111
+ const before = text.slice(Math.max(0, start - CUE_WINDOW), start);
112
+ for (const [entityType, pattern] of CUES) {
113
+ if (pattern.test(before))
114
+ return entityType;
115
+ }
116
+ return null;
117
+ }
118
+ /**
119
+ * Shapes so generic that a label in front is better evidence than the pattern
120
+ * that matched. A bare digit run is not a phone number because it *could* be
121
+ * dialled.
122
+ */
123
+ const RETYPABLE = new Set([
124
+ EntityType.PHONE, EntityType.POSTAL_CODE, EntityType.LICENSE_PLATE,
125
+ ]);
126
+ /**
127
+ * Types a label can assert on its own. Also the types eligible for the rescue
128
+ * in `engine.ts`, for the same reason read the other way — a label may vouch
129
+ * for one of these, so it may also vouch for a malformed one.
130
+ *
131
+ * PHONE is absent on purpose: "Tel:" in front of something no phone pattern
132
+ * matched means the document is laid out oddly, not that the value is a phone
133
+ * number. So are EMAIL, SECRET, DOB, VIN and the rest — those carry their own
134
+ * structure, and a nearby word is not entitled to overrule it.
135
+ *
136
+ * BIC's absence is what keeps the rescue honest. Its validator is a *registry
137
+ * lookup*, not a checksum, so a failure means "no such bank", which no label can
138
+ * talk you out of. Rescuing it read 34 unlisted codes behind a "BIC:" label as
139
+ * real ones. The same reasoning keeps CREDIT_CARD and BANK_ACCOUNT out: Luhn and
140
+ * mod-97 are strong enough that a failure really does mean "not one of these".
141
+ */
142
+ export const CUE_TARGETS = new Set([
143
+ EntityType.NATIONAL_ID, EntityType.SSN, EntityType.TAX_ID,
144
+ EntityType.HEALTH_INSURANCE, EntityType.CHAMBER_OF_COMMERCE,
145
+ EntityType.VAT, EntityType.POSTAL_CODE, EntityType.PASSPORT,
146
+ EntityType.INTERNAL_ID,
147
+ ]);
148
+ /**
149
+ * The type a cue overrules `entityType` with, or null to leave it alone.
150
+ *
151
+ * Both gates are load-bearing. Measured over 2,000 corpus documents, re-typing
152
+ * without them fired 52 times and got roughly 30 of those wrong — EMAIL read as
153
+ * VAT, a date of birth as a phone number, a VIN as VAT — against 20 real fixes.
154
+ * With them: 14 re-typings, every one of them PHONE to a structured identifier.
155
+ */
156
+ export function retypedBy(text, start, entityType) {
157
+ if (!RETYPABLE.has(String(entityType)))
158
+ return null;
159
+ const cued = cuedType(text, start);
160
+ if (cued === null || cued === entityType || !CUE_TARGETS.has(String(cued)))
161
+ return null;
162
+ return cued;
163
+ }
@@ -1,6 +1,7 @@
1
1
  import { DetectionSource, EntityType } from "../types.js";
2
2
  import { MultiPatternMatcher } from "./matchers.js";
3
- import { shouldSuppress } from "./suppressors.js";
3
+ import { shouldSuppress, SuppressionScratch } from "./suppressors.js";
4
+ import { CUE_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";
@@ -68,30 +69,13 @@ function warnUnknownCountry(code) {
68
69
  //
69
70
  // Measured: 263 phone numbers and 18 French SIREN were reported as national IDs,
70
71
  // 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;
72
+ //
73
+ // The table itself lives in `cues.ts`, because two other steps read it: the
74
+ // rescue of a declined identifier below, and the re-typing in deduplicate.
75
+ // `CUE_WINDOW` is re-exported from there.
87
76
  /** 1 when a cue naming `entityType` sits immediately before the span. */
88
77
  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;
78
+ return cuedType(text, start) === entityType ? 1 : 0;
95
79
  }
96
80
  export class RuleEngine {
97
81
  matcher = new MultiPatternMatcher();
@@ -198,7 +182,19 @@ export class RuleEngine {
198
182
  // but failed the checksum is recorded with its country and entity type;
199
183
  // what it is allowed to demote is decided below, once the document's
200
184
  // countries are known.
185
+ //
186
+ // One kind of failure is rescued rather than recorded: a span the document
187
+ // itself labels as the very type whose checksum just failed.
188
+ // "Rijksregisternummer: 85.03.19-284.73" is a Belgian national number with a
189
+ // bad check digit — mistyped, OCR'd, or invented — and it is still a
190
+ // national number. Before this, the pattern declined, the generic phone rule
191
+ // was denied the span as a fragment, and the value was left in the clear: a
192
+ // redaction library printing an identifier it had recognised and rejected.
193
+ //
194
+ // Rescued candidates are still recorded as failed spans, so the demotion
195
+ // zones and the fragment rule below are unchanged.
201
196
  const validated = [];
197
+ const rescued = [];
202
198
  const failedSpans = [];
203
199
  for (const m of rawMatches) {
204
200
  if (this.matcher.validate(m)) {
@@ -207,6 +203,9 @@ export class RuleEngine {
207
203
  else if (m.patternDef.validator !== null && !m.patternDef.requiresContext) {
208
204
  failedSpans.push({ start: m.start, end: m.end, code: m.countryCode,
209
205
  etype: String(m.patternDef.entityType) });
206
+ if (CUE_TARGETS.has(String(m.patternDef.entityType))
207
+ && cuedType(text, m.start) === m.patternDef.entityType)
208
+ rescued.push(m);
210
209
  }
211
210
  }
212
211
  // Evidence pass: work out which countries this document belongs to, from
@@ -273,6 +272,14 @@ export class RuleEngine {
273
272
  const allFailed = failedSpans
274
273
  .map(f => [f.start, f.end])
275
274
  .sort((a, b) => a[0] - b[0] || a[1] - b[1]);
275
+ // Running maximum of the end offsets, so the fragment test below can ask
276
+ // "does any span starting at or before me reach past my end?" in O(1)
277
+ // instead of walking the prefix per candidate.
278
+ const failedStarts = allFailed.map(f => f[0]);
279
+ const failedPrefixMax = new Array(allFailed.length + 1).fill(0);
280
+ for (let i = 0; i < allFailed.length; i++) {
281
+ failedPrefixMax[i + 1] = Math.max(failedPrefixMax[i], allFailed[i][1]);
282
+ }
276
283
  const mergedZones = new Map();
277
284
  for (const [etype, spans] of zonesByType) {
278
285
  spans.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
@@ -295,8 +302,11 @@ export class RuleEngine {
295
302
  mergedZones.set(etype, { starts, ends });
296
303
  }
297
304
  const candidates = [];
305
+ // Whole-document facts a suppressor may need, derived at most once per call
306
+ // and discarded with it.
307
+ const scratch = new SuppressionScratch(text);
298
308
  for (const { match, hasValidValidator } of validated) {
299
- if (shouldSuppress(text, match))
309
+ if (shouldSuppress(text, match, scratch))
300
310
  continue;
301
311
  // A validator-less match inside a failed-validation span of its own type
302
312
  // is demoted below every other candidate rather than deleted. Demotion
@@ -324,13 +334,31 @@ export class RuleEngine {
324
334
  // default — the generic phone pattern kept claiming the tail of the
325
335
  // rejected national number.
326
336
  if (match.patternDef.validator === null && !demoted) {
337
+ // Upper bound of the spans starting at or before match.start.
338
+ let lo = 0, hi = failedStarts.length;
339
+ while (lo < hi) {
340
+ const mid = (lo + hi) >>> 1;
341
+ if (failedStarts[mid] <= match.start)
342
+ lo = mid + 1;
343
+ else
344
+ hi = mid;
345
+ }
327
346
  let fragment = false;
328
- for (const [zs, ze] of allFailed) {
329
- if (zs > match.start)
330
- break;
331
- if (match.end <= ze && (match.start > zs || match.end < ze)) {
332
- fragment = true;
333
- break;
347
+ const reach = failedPrefixMax[lo];
348
+ if (reach > match.end) {
349
+ // Something starting no later than us ends strictly later: we are a
350
+ // proper subset of it.
351
+ fragment = true;
352
+ }
353
+ else if (reach === match.end) {
354
+ // Same end offset — a proper subset only if some span with that end
355
+ // starts strictly earlier. Scanning backwards finds the enclosing
356
+ // span first, and the equal case is rare.
357
+ for (let i = lo - 1; i >= 0; i--) {
358
+ if (allFailed[i][1] === match.end && allFailed[i][0] < match.start) {
359
+ fragment = true;
360
+ break;
361
+ }
334
362
  }
335
363
  }
336
364
  if (fragment)
@@ -372,8 +400,28 @@ export class RuleEngine {
372
400
  priority = 1;
373
401
  blindPriority = 1;
374
402
  }
375
- const country = (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
403
+ let entityType = match.patternDef.entityType;
404
+ let country = (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
376
405
  ? match.countryCode : null;
406
+ let outOfScope = declared !== null && country !== null && !declared.has(match.countryCode);
407
+ let confidence = "high";
408
+ // The document labels this span as something else. Relabelling, not
409
+ // suppression: the span is found either way, so removing the claim would
410
+ // decide only whether the value is masked — and the answer was "no".
411
+ // "Rijksregisternummer: 85.03.19-284.73" produced no detection at all.
412
+ //
413
+ // The country came from the pattern that matched, and that pattern was
414
+ // just overruled: a Finnish phone rule saying "Finland" is worthless once
415
+ // the value is filed as a Cypriot identity number. Dropping it also clears
416
+ // outOfScope, which would otherwise claim the detection belongs to a
417
+ // country the caller did not declare while naming no country at all.
418
+ const retyped = retypedBy(text, match.start, entityType);
419
+ if (retyped !== null) {
420
+ entityType = retyped;
421
+ country = null;
422
+ outOfScope = false;
423
+ confidence = "medium";
424
+ }
377
425
  candidates.push({
378
426
  blindPriority,
379
427
  cue: localCueBonus(text, match.start, match.patternDef.entityType),
@@ -381,6 +429,45 @@ export class RuleEngine {
381
429
  length: match.end - match.start,
382
430
  countryScore: countryScores.get(match.countryCode) ?? 0,
383
431
  inScope: declared === null || declared.has(match.countryCode) ? 1 : 0,
432
+ tier: 0,
433
+ det: {
434
+ entityType,
435
+ start: match.start,
436
+ end: match.end,
437
+ text: match.text,
438
+ source: DetectionSource.RULES,
439
+ country,
440
+ confidence,
441
+ outOfScope,
442
+ },
443
+ });
444
+ }
445
+ // A declined identifier the document itself labels. Cue-backed by
446
+ // construction, so `cue` is 1 — that is what lets it take a span from a
447
+ // foreign pattern whose checksum happened to pass, which is how
448
+ // "ΑΦΜ: 147382965" was reported as a Czech national ID.
449
+ //
450
+ // `priority` is 0: below every regex-only claim, so it wins only what
451
+ // nothing better wants. `blindPriority` is 1 so that span selection — the
452
+ // country-blind half of the sort — treats it like any other regex-strength
453
+ // claim and invariant I1 is untouched.
454
+ //
455
+ // Emitted as "low", which is the honest description: the shape and the label
456
+ // agree, the check digit does not. A caller wanting only checksum-backed
457
+ // detections filters on it.
458
+ for (const match of rescued) {
459
+ if (shouldSuppress(text, match, scratch))
460
+ continue;
461
+ const country = (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
462
+ ? match.countryCode : null;
463
+ candidates.push({
464
+ blindPriority: 1,
465
+ cue: 1,
466
+ priority: 0,
467
+ length: match.end - match.start,
468
+ countryScore: countryScores.get(match.countryCode) ?? 0,
469
+ inScope: declared === null || declared.has(match.countryCode) ? 1 : 0,
470
+ tier: 0,
384
471
  det: {
385
472
  entityType: match.patternDef.entityType,
386
473
  start: match.start,
@@ -388,14 +475,14 @@ export class RuleEngine {
388
475
  text: match.text,
389
476
  source: DetectionSource.RULES,
390
477
  country,
391
- confidence: "high",
478
+ confidence: "low",
392
479
  outOfScope: declared !== null && country !== null && !declared.has(match.countryCode),
393
480
  },
394
481
  });
395
482
  }
396
483
  for (const d of detectStructuralDob(text)) {
397
484
  candidates.push({ blindPriority: 1, cue: 0, priority: 1, length: d.end - d.start,
398
- countryScore: 0, inScope: 1, det: d });
485
+ countryScore: 0, inScope: 1, tier: 0, det: d });
399
486
  }
400
487
  const detections = this.deduplicate(candidates);
401
488
  detections.sort((a, b) => a.start - b.start || b.end - a.end);
@@ -429,7 +516,10 @@ export class RuleEngine {
429
516
  if (seen === undefined || c.blindPriority > seen)
430
517
  spanTier.set(key, c.blindPriority);
431
518
  }
432
- const tierOf = (c) => spanTier.get(`${c.det.start}:${c.det.end}`);
519
+ // Resolved once per candidate rather than inside the comparator, which ran
520
+ // it O(n log n) times and rebuilt the key string on every comparison.
521
+ for (const c of candidates)
522
+ c.tier = spanTier.get(`${c.det.start}:${c.det.end}`);
433
523
  // Span length outranks *everything*, including validation.
434
524
  //
435
525
  // A shorter candidate winning re-cuts a longer one and leaves the
@@ -455,7 +545,7 @@ export class RuleEngine {
455
545
  // country-blind up to and including `start`, and (length, start) already
456
546
  // identifies a span uniquely.
457
547
  const sorted = [...candidates].sort((a, b) => b.length - a.length || // longest first } country-blind
458
- tierOf(b) - tierOf(a) || // best tier here } — decides
548
+ b.tier - a.tier || // best tier here } — decides
459
549
  a.det.start - b.det.start || // leftmost } WHICH span
460
550
  b.cue - a.cue || // local cue } country-aware
461
551
  b.priority - a.priority || // country priority } — decides the
@@ -6,4 +6,17 @@ export interface RawMatch {
6
6
  patternDef: PatternDef;
7
7
  countryCode: string;
8
8
  }
9
- export declare function shouldSuppress(text: string, match: RawMatch): boolean;
9
+ /**
10
+ * Per-document scratch space for suppressors that would otherwise re-derive the
11
+ * same whole-document facts for every candidate.
12
+ *
13
+ * Built lazily and held only for the duration of one `detect()` call, so no
14
+ * document text outlives the call that supplied it.
15
+ */
16
+ export declare class SuppressionScratch {
17
+ private readonly text;
18
+ private words;
19
+ constructor(text: string);
20
+ documentWords(): Set<string>;
21
+ }
22
+ export declare function shouldSuppress(text: string, match: RawMatch, scratch?: SuppressionScratch): boolean;