euredact 0.3.9 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,13 +34,19 @@ console.log(result.detections);
34
34
  ## Features
35
35
 
36
36
  - **31 European countries** with country-specific patterns
37
- - **25+ PII entity types:** national IDs, IBANs, phone numbers, email, VAT
37
+ - **41 PII entity types** (30 from the rules engine, 11 from the
38
+ [cloud tier](#cloud-tier)): national IDs, IBANs, phone numbers, email, VAT
38
39
  numbers, license plates, credit cards, BIC/SWIFT, VIN, IMEI, GPS coordinates,
39
40
  UUIDs, social handles, IP/IPv6, MAC addresses, secrets/API keys, and more
40
41
  - **Secret/API key detection:** known-prefix patterns for AWS, GitHub, Stripe,
41
42
  OpenAI, Slack, JWT, SendGrid, plus Shannon entropy-based detection for generic
42
43
  high-entropy tokens near context keywords
43
44
  - **Custom patterns:** register your own regex patterns for domain-specific PII
45
+ - **Reversible tokenization:** `tokenize: true` swaps values for `EMAIL_K7Q2`-style
46
+ tokens and `restore()` puts them back — for prompts that go to an LLM and
47
+ come back
48
+ - **Allowlist:** values that are never redacted, such as your own organisation's
49
+ name and addresses, per call or per instance
44
50
  - **Checksum validation:** IBAN mod-97, Luhn (credit cards/IMEI), and 30+
45
51
  country-specific validators
46
52
  - **Priority-aware deduplication:** validated (checksum, corroborated by the
@@ -65,6 +71,26 @@ console.log(result.detections);
65
71
 
66
72
  ## API Reference
67
73
 
74
+ ### Which option do I need?
75
+
76
+ | I want to… | Use |
77
+ |---|---|
78
+ | redact a document with no further setup | `redact(text)` — all 31 countries, `[ENTITY_TYPE]` output |
79
+ | restrict scope to the countries I operate in | `{ countries: ["NL", "BE"] }` |
80
+ | keep detection wide but resolve ambiguity | `{ countryHint: ["DE"] }` |
81
+ | send a prompt to an LLM and restore the reply | `{ tokenize: true }`, then [`restore()`](#restoretext-tokens) |
82
+ | keep relationships visible across a whole session | `{ referentialIntegrity: true }` |
83
+ | never redact my own company name or addresses | `{ allowlist: [...] }`, or `new EuRedact({ allowlist })` |
84
+ | never redact anything at my own domain | `{ allowlistDomains: ["acme.be"] }` |
85
+ | audit what the allowlist kept in the document | `result.exempted` |
86
+ | catch person names, employers, job titles, diagnoses | `redactAsync(text, { mode: "cloud" })` — see [Cloud tier](#cloud-tier) |
87
+ | include dates of birth | `{ detectDates: true }` |
88
+ | redact a document too large for one call | `context` + `chunkOffset` — see [Chunked documents](#chunked-documents) |
89
+ | detect an identifier the engine does not know | [`addCustomPattern()`](#addcustompatternname-pattern) |
90
+ | isolate tenants from each other | one `new EuRedact()` each |
91
+ | free PII held in memory | `clear()` |
92
+
93
+
68
94
  ### Module-Level Functions
69
95
 
70
96
  #### `redact(text, options?)`
@@ -77,25 +103,68 @@ Main entry point. Detects and redacts PII in the given text.
77
103
 
78
104
  ```ts
79
105
  interface RedactOptions {
80
- countries?: string[] | null; // Scope. Flags anything attributed elsewhere as
81
- // outOfScope. Never gates what is looked for.
82
- countryHint?: string[] | null; // A prior only — resolves ambiguity without
83
- // narrowing scope or flagging anything
84
- context?: DocumentContext | null; // Share country evidence across the chunks
85
- // of one document (see "Chunked documents")
86
- chunkOffset?: number; // Where this chunk starts in the document
87
- referentialIntegrity?: boolean; // Replace with consistent labels (default: false)
88
- detectDates?: boolean; // Include DOB/date-of-death detections (default: false)
89
- cache?: boolean; // Enable result caching (default: true)
106
+ countries?: string[] | null; // scope (see below); null = all 31
107
+ countryHint?: string[] | null; // a prior only; does not narrow scope
108
+ context?: DocumentContext | null; // share evidence across chunks
109
+ chunkOffset?: number; // where this chunk starts
110
+ mode?: string; // "rules" (default) | "cloud"
111
+ referentialIntegrity?: boolean; // consistent labels, EMAIL_1
112
+ tokenize?: boolean; // reversible tokens, EMAIL_K7Q2
113
+ allowlist?: string[] | null; // values never redacted
114
+ allowlistDomains?: string[] | null; // domains never redacted (EMAIL/URL)
115
+ detectDates?: boolean; // include DOB / date of death
116
+ cache?: boolean; // reuse results for identical input
90
117
  }
91
118
  ```
92
119
 
120
+ **What is looked for**
121
+
122
+ | Parameter | Default | Description |
123
+ |---|---|---|
124
+ | `text` | — | Input text to scan. Longer than `maxInputLength` throws. |
125
+ | `countries` | `null` | ISO 3166-1 alpha-2 codes that define **scope**. `null` loads all 31. This never gates what is looked for: a detection attributed elsewhere is flagged `outOfScope`, never dropped. A bare string throws `TypeError`. See [Country codes](#country-codes). |
126
+ | `countryHint` | `null` | A **prior only**. Resolves an ambiguous value without narrowing scope. See [Country Hints](#country-hints). |
127
+ | `detectDates` | `false` | Include `DOB` and `DATE_OF_DEATH`. Off by default: a bare date without keyword or structural context is deferred to the cloud tier. |
128
+
129
+ **How the output looks** — pick at most one of the first three.
130
+
131
+ | Parameter | Default | Description |
132
+ |---|---|---|
133
+ | *(none)* | — | Default: each span becomes `[ENTITY_TYPE]`. |
134
+ | `referentialIntegrity` | `false` | Consistent label per distinct value (`EMAIL_1`), persisting **on the instance** across calls. See [Referential Integrity](#referential-integrity). |
135
+ | `tokenize` | `false` | Reversible token per value (`EMAIL_K7Q2`), with the mapping in `result.tokens` for [`restore()`](#restoretext-tokens). Unique to the **call**. Cannot be combined with `referentialIntegrity`. See [Reversible tokenization](#reversible-tokenization). |
136
+ | `allowlist` | `null` | Values never to redact, whole-span and case-insensitive, merged with the instance's list. Structured identifiers (IBAN, phone, VAT, …) also match across spacing and hyphenation. Applies to cloud-tier types too. See [Allowlist](#allowlist). |
137
+ | `allowlistDomains` | `null` | Domains whose addresses are never redacted, e.g. `["acme.be"]`. Applies to `EMAIL` and `URL` only, and covers subdomains. See [Allowlist](#allowlist). |
138
+
139
+ **Long documents and tiers**
140
+
93
141
  | Parameter | Default | Description |
94
142
  |---|---|---|
95
- | `countries` | `null` | ISO 3166-1 alpha-2 codes to restrict detection. `null` loads all 31 countries. |
96
- | `referentialIntegrity` | `false` | Replace PII with consistent labels instead of entity-type labels. |
97
- | `detectDates` | `false` | Include date-of-birth and date-of-death detections. Off by default. |
98
- | `cache` | `true` | Cache results for identical inputs. |
143
+ | `mode` | `"rules"` | `"rules"` runs locally and synchronously. `"cloud"` must go through [`redactAsync()`](#redactasynctext-options) — `redact()` is synchronous and a network call cannot be. See [Cloud tier](#cloud-tier). |
144
+ | `context` | `null` | Share country evidence across the chunks of one document. Pass the same `DocumentContext` to every chunk. Disables the cache. See [Chunked documents](#chunked-documents). |
145
+ | `chunkOffset` | `0` | Where this chunk starts in the whole document. Only rebases spans recorded in `context`; returned detections stay relative to `text`. |
146
+ | `cache` | `true` | Reuse the result for an identical input and configuration. |
147
+
148
+ TypeScript has no `coref` option; the Python SDK accepts one as a reserved
149
+ no-op.
150
+
151
+ #### `redactAsync(text, options?)`
152
+
153
+ ```ts
154
+ function redactAsync(text: string, options?: RedactOptions): Promise<RedactResult>;
155
+ ```
156
+
157
+ The asynchronous entry point, and the only way to reach the [cloud
158
+ tier](#cloud-tier). With `mode: "rules"` (the default) it resolves immediately
159
+ with exactly what `redact()` returns, so a caller that may or may not use the
160
+ cloud tier can keep one code path:
161
+
162
+ ```ts
163
+ const result = await redactAsync(text, { countries: ["BE"], mode: "cloud" });
164
+ ```
165
+
166
+ Takes the same `RedactOptions` as `redact()`. Options the service cannot honour
167
+ throw rather than being ignored — see [Cloud tier](#cloud-tier).
99
168
 
100
169
  #### `redactBatch(texts, options?)`
101
170
 
@@ -115,6 +184,16 @@ function addCustomPattern(name: string, pattern: string): void;
115
184
  Register a custom regex pattern. Matches are reported with `name` as the entity
116
185
  type. See [Custom Patterns](#custom-patterns) below.
117
186
 
187
+ #### `restore(text, tokens)`
188
+
189
+ ```ts
190
+ function restore(text: string, tokens: Record<string, string>): string;
191
+ ```
192
+
193
+ Put the original values back into text that derives from a `tokenize: true`
194
+ result — typically an LLM's reply to the tokenized prompt. `tokens` is
195
+ `result.tokens`. See [Reversible tokenization](#reversible-tokenization).
196
+
118
197
  #### `availableCountries()`
119
198
 
120
199
  ```ts
@@ -141,8 +220,25 @@ console.log(result.redactedText);
141
220
  // "See [CASE_REF] for details"
142
221
  ```
143
222
 
144
- The `EuRedact` class exposes: `redact()`, `redactBatch()`, and
145
- `addCustomPattern()`.
223
+ The `EuRedact` class exposes `redact()`, `redactAsync()`, `redactBatch()`,
224
+ `clear()` and `addCustomPattern()`.
225
+
226
+ ```ts
227
+ new EuRedact({
228
+ maxInputLength?: number, // default 10_485_760 (~10 MB)
229
+ allowlist?: string[] | null,
230
+ })
231
+ ```
232
+
233
+ | Parameter | Default | Description |
234
+ |---|---|---|
235
+ | `maxInputLength` | `10_485_760` | Longest document `redact()` accepts, in characters. Above it, it throws — split the input or raise the ceiling. |
236
+ | `allowlist` | `null` | Values never to redact, for every call on this instance. Merged with the per-call `allowlist`. See [Allowlist](#allowlist). |
237
+ | `allowlistDomains` | `null` | Domains never to redact, for every call on this instance. |
238
+
239
+ The result cache, referential-integrity labels and custom patterns are all per
240
+ instance, which is what makes one instance per tenant the right default.
241
+ `clear()` releases the cache and the label mapping.
146
242
 
147
243
  ### Return Types
148
244
 
@@ -154,6 +250,8 @@ interface RedactResult {
154
250
  detections: Detection[]; // All PII spans found
155
251
  source: string; // Detection backend ("rules")
156
252
  degraded: boolean; // True if the engine fell back to a simpler mode
253
+ tokens: Record<string, string>; // token -> original value; only with tokenize: true
254
+ exempted: Exemption[]; // spans the allowlist kept, with the rule that matched
157
255
  }
158
256
  ```
159
257
 
@@ -210,6 +308,7 @@ interface RedactResult {
210
308
  evidence: CountryEvidence[]; // every signal, with the span behind it
211
309
  detectionMode: string; // "declared" if countries was passed,
212
310
  // "inferred" otherwise
311
+ tokens: Record<string, string>; // token -> original value; only with tokenize: true
213
312
  }
214
313
  ```
215
314
 
@@ -218,7 +317,7 @@ interface RedactResult {
218
317
  String enum with all supported PII categories:
219
318
 
220
319
  ```
221
- NAME ADDRESS BANK_ACCOUNT BIC
320
+ PERSON_NAME ADDRESS BANK_ACCOUNT BIC
222
321
  CREDIT_CARD PHONE EMAIL DOB
223
322
  DATE_OF_DEATH NATIONAL_ID SSN TAX_ID
224
323
  PASSPORT DRIVERS_LICENSE RESIDENCE_PERMIT LICENSE_PLATE
@@ -226,6 +325,13 @@ VIN VAT POSTAL_CODE IP_ADDRESS
226
325
  IPV6_ADDRESS MAC_ADDRESS HEALTH_INSURANCE HEALTHCARE_PROVIDER
227
326
  CHAMBER_OF_COMMERCE IMEI GPS_COORDINATES UUID
228
327
  SOCIAL_HANDLE SECRET INTERNAL_ID OTHER
328
+
329
+ Cloud tier only — the rule engine never emits these, because there is no shape
330
+ to match on. That is precisely why the model exists:
331
+
332
+ ORGANISATION_NAME JOB_TITLE MEDICAL_CONDITION SENSITIVE_ATTRIBUTE
333
+ BIOMETRIC_REF FINANCIAL_AMOUNT QUASI_IDENTIFIER CREDENTIAL
334
+ URL
229
335
  ```
230
336
 
231
337
  `INTERNAL_ID` — an employee, badge or customer number tied to a person — is
@@ -264,6 +370,67 @@ masked is unchanged either way.
264
370
 
265
371
  For custom patterns, `entityType` is a plain string (e.g. `"EMPLOYEE_ID"`).
266
372
 
373
+ ## Cloud tier
374
+
375
+ > **Status: private alpha.** The cloud tier is in closed testing — it is **not**
376
+ > in public beta and is not generally available. Keys are issued to alpha
377
+ > participants only, and the request/response surface may still change between
378
+ > releases. The rules engine below is unaffected and is the supported path:
379
+ > `mode="rules"` is the default and carries no alpha caveat.
380
+
381
+ The rule engine catches what has a shape: IBANs, national IDs, phone numbers,
382
+ anything with a checksum. It cannot catch what does not — a person's name, an
383
+ employer, a diagnosis, a job title. The cloud tier adds a fine-tuned model
384
+ asked only *what did the rules miss?*
385
+
386
+ ```ts
387
+ import { configure, redactAsync } from "euredact";
388
+
389
+ configure({ apiKey: "erk_..." }); // or set EUREDACT_API_KEY
390
+ const result = await redactAsync(text, { countries: ["BE"], mode: "cloud" });
391
+
392
+ result.source; // "cloud"
393
+ result.detections.map(d => [d.entityType, d.text]);
394
+ // [["PERSON_NAME", "Bas Verhoeven"], ["PHONE", "+32 ..."]]
395
+ ```
396
+
397
+ **`redact()` is synchronous and cannot serve the cloud tier**, so
398
+ `redact(text, { mode: "cloud" })` throws and names `redactAsync`. It never
399
+ falls back to rules-only output: a caller who believes names and diagnoses were
400
+ checked, and ships a document that only had its phone numbers masked, is the
401
+ one failure this library must not have.
402
+
403
+ `redactAsync` with `mode: "rules"` resolves immediately with exactly what
404
+ `redact()` returns, so a caller that may or may not use the cloud tier can hold
405
+ one code path.
406
+
407
+ Retries carry an `Idempotency-Key`, so a retry after a timeout cannot bill
408
+ twice. `Retry-After` is obeyed. A document that outlives the service's sync
409
+ window is polled transparently. Oversized input rejects with `TooLargeError`
410
+ (413): the service refuses it rather than chunking, because the model has never
411
+ seen a chunk boundary.
412
+
413
+ Options the service cannot honour reject rather than being ignored: multiple
414
+ `countries`, `countryHint`, `context`/`chunkOffset` and `referentialIntegrity`.
415
+ `tokenize`, `allowlist` and `allowlistDomains` are honoured: the SDK applies them to the spans the
416
+ service returns and rebuilds the text from those.
417
+
418
+ The package stays **zero-dependency** — the client uses the platform's own
419
+ `fetch`. Node 18+ provides one; on Node 16 the rules engine is unaffected and a
420
+ `fetchImpl` can be supplied.
421
+
422
+ ## `NAME` is now `PERSON_NAME`
423
+
424
+ The canonical type name is `PERSON_NAME`; `NAME` is a legacy alias, exactly as
425
+ `IBAN` aliases `BANK_ACCOUNT`. The placeholder written into redacted text is
426
+ `[PERSON_NAME]`.
427
+
428
+ Nothing could have depended on the old value: the type is cloud-only and the
429
+ cloud tier was stubbed until this release, so it was never emitted. Code
430
+ matching the *string* `"NAME"` should be updated; `LEGACY_TYPE_ALIASES`
431
+ publishes the mapping, and `STREET_ADDRESS` → `ADDRESS` and
432
+ `NATIONALITY_ETHNICITY` → `SENSITIVE_ATTRIBUTE` are recognised the same way.
433
+
267
434
  ## Country codes
268
435
 
269
436
  `countries` accepts **ISO 3166-1 alpha-2** codes. The two EU/VAT spellings are
@@ -314,8 +481,9 @@ redact("Rekening: BE68 5390 0754 7034", { countries: ["AT"] });
314
481
  ### Which country a value belongs to
315
482
 
316
483
  Because every pattern runs, the same digits often match several countries'
317
- schemes. 36.6% of national-ID values in our corpus validate under more than one
318
- country's checksum, so the digits alone cannot decide it — the *document* does.
484
+ schemes. Of the national-ID values in our corpus that pass any country's checksum,
485
+ 34.7% pass more than one country's (32,827 of 94,528), so the digits alone
486
+ cannot decide it — the *document* does.
319
487
 
320
488
  The engine infers the document's countries from entities that carry their
321
489
  country in the string, then uses that to resolve the ambiguity:
@@ -549,6 +717,120 @@ console.log(result.redactedText);
549
717
  // "BSN NATIONAL_ID_1 en later weer NATIONAL_ID_1"
550
718
  ```
551
719
 
720
+ ## Reversible tokenization
721
+
722
+ `tokenize: true` is for text that has to come back. Each value is replaced by a
723
+ token that names its type and nothing else, and the result carries the mapping
724
+ that turns tokens back into values:
725
+
726
+ ```ts
727
+ import { redact, restore } from "euredact";
728
+
729
+ const prompt = "Write an email to Joren at joren.janssens@euredact.be or call +32 475 12 34 56 about the invoice.";
730
+ const result = redact(prompt, { countries: ["BE"], tokenize: true });
731
+ console.log(result.redactedText);
732
+ // "Write an email to Joren at EMAIL_K7Q2 or call PHONE_P4RT about the invoice."
733
+ console.log(result.tokens);
734
+ // { EMAIL_K7Q2: "joren.janssens@euredact.be", PHONE_P4RT: "+32 475 12 34 56" }
735
+
736
+ const reply = await callYourLlm(result.redactedText); // sees tokens, never the values
737
+ console.log(restore(reply, result.tokens));
738
+ // the reply, with the real address and number back in it
739
+ ```
740
+
741
+ The suffixes are random; yours will differ. With the [cloud tier](#cloud-tier)
742
+ the name is tokenized too (`PERSON_NAME_W3NB`), since person names have no
743
+ shape for the rules tier to match on.
744
+
745
+ Within one call the same value gets the same token, so a prompt that names
746
+ someone twice still reads as one person. Across calls it gets a different
747
+ token: nothing is retained on the instance, `result.tokens` is the only copy,
748
+ and two tokenized documents never reveal that they share a value. That is the
749
+ opposite retention model from `referentialIntegrity`, which is why the two
750
+ cannot be combined. `redactBatch` tokenizes each text on its own.
751
+
752
+ A token is `TYPE_` plus four characters from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`
753
+ (no vowels, no `0`/`1`/`I`/`O`). Tokens are kept clear of any token-shaped
754
+ string already in the document, so an LLM's reply to a tokenized prompt can
755
+ itself be redacted without `restore()` putting the wrong value back.
756
+ `restore()` replaces every occurrence, including a token an LLM glued to other
757
+ characters (`EMAIL_P4RTs`) — leaving a token behind is the worse failure.
758
+
759
+ Works in cloud mode via `redactAsync`: the SDK rebuilds the text from the spans
760
+ the service returns, which is exactly what the service built its own output
761
+ from.
762
+
763
+ ## Allowlist
764
+
765
+ Values a caller declares are not PII to them — their own organisation's name,
766
+ their own addresses. Set it per call, on the instance, or both; the two merge:
767
+
768
+ ```ts
769
+ import { EuRedact } from "euredact";
770
+
771
+ const sdk = new EuRedact({ allowlist: ["ACME NV", "info@acme.be"] });
772
+
773
+ const text = "ACME NV: mail info@acme.be or jan@acme.be about IBAN NL91 ABNA 0417 1643 00.";
774
+ console.log(sdk.redact(text, { countries: ["NL"] }).redactedText);
775
+ // "ACME NV: mail info@acme.be or [EMAIL] about IBAN [BANK_ACCOUNT]."
776
+
777
+ console.log(sdk.redact(text, { countries: ["NL"], allowlist: ["jan@acme.be"] }).redactedText);
778
+ // "ACME NV: mail info@acme.be or jan@acme.be about IBAN [BANK_ACCOUNT]."
779
+ ```
780
+
781
+ Matching is whole-span and case-insensitive. A bare string
782
+ (`allowlist: "ACME NV"`) throws `TypeError` rather than being iterated into
783
+ single letters that exempt nothing. Works in cloud mode via `redactAsync` —
784
+ including on types only the model finds, such as `ORGANISATION_NAME` — and
785
+ together with `tokenize`.
786
+
787
+ ### Spacing and punctuation
788
+
789
+ For **structured identifiers** separators are presentational, so an allowlisted
790
+ value matches however the document writes it:
791
+
792
+ ```ts
793
+ const sdk = new EuRedact({ allowlist: ["NL91ABNA0417164300"] });
794
+ sdk.redact("Pay to NL91 ABNA 0417 1643 00.", { countries: ["NL"] }).redactedText;
795
+ // 'Pay to NL91 ABNA 0417 1643 00.' — exempt, despite the spacing
796
+ ```
797
+
798
+ Applies to `BANK_ACCOUNT`, `BIC`, `CREDIT_CARD`, `PHONE`, `VAT`, `NATIONAL_ID`,
799
+ `SSN`, `TAX_ID`, `PASSPORT`, `DRIVERS_LICENSE`, `RESIDENCE_PERMIT`,
800
+ `HEALTH_INSURANCE`, `CHAMBER_OF_COMMERCE`, `IMEI` and `VIN`. It widens the
801
+ *spelling*, never the *scope*: a different account is still redacted. Free-text
802
+ types stay literal, because `jan.devries@acme.be` and `jandevries@acme.be` are
803
+ different mailboxes at most providers.
804
+
805
+ ### Exempting a whole domain
806
+
807
+ ```ts
808
+ const sdk = new EuRedact({ allowlistDomains: ["acme.be"] });
809
+ sdk.redact("Mail jan@acme.be or piet@acme.be", { countries: ["NL"] }).redactedText;
810
+ // 'Mail jan@acme.be or piet@acme.be'
811
+ ```
812
+
813
+ `EMAIL` and `URL` only, covering subdomains (`mail.acme.be`). The match is on a
814
+ label boundary, so `acme.be` does **not** exempt `evilacme.be`. Entries may be
815
+ written `acme.be`, `@acme.be` or `.acme.be`.
816
+
817
+ There are deliberately **no wildcards**. The allowlist is the only option that
818
+ turns redaction *off*, so an over-broad entry fails toward under-redaction and
819
+ does so silently.
820
+
821
+ ### What was exempted
822
+
823
+ ```ts
824
+ const r = redact("Mail jan@acme.be", { countries: ["NL"], allowlistDomains: ["acme.be"] });
825
+ for (const e of r.exempted) console.log(e.entityType, e.text, e.rule, e.ruleKind);
826
+ // EMAIL jan@acme.be acme.be domain
827
+ ```
828
+
829
+ `Exemption` carries `entityType`, `start`, `end`, `text`, the `rule` that
830
+ matched as you wrote it, and `ruleKind` (`"value"` or `"domain"`). An exempted
831
+ span is absent from `detections`, so `exempted` is the only record that it was
832
+ found at all.
833
+
552
834
  ## Architecture
553
835
 
554
836
  ```
@@ -623,18 +905,24 @@ Two tiers are less obvious than they look:
623
905
 
624
906
  ## Performance
625
907
 
626
- Measured on one core, all 31 countries loaded, `detectDates: true`. Cost scales
627
- with document length, so the input size is stated rather than averaged away.
908
+ Measured on one core (Apple Silicon M3 Pro, Node 22.12), all 31 countries
909
+ loaded, `detectDates: true`, cache off, over two cohorts sampled evenly from the
910
+ corpus: 3,000 distinct short records and 611 distinct real documents. Median per
911
+ document, 10th–90th percentile in brackets:
628
912
 
629
913
  | Input | Latency | Throughput |
630
914
  |---|---:|---:|
631
- | Short record (186 chars) | 155 µs | 6,462 docs/s |
632
- | Real document (3,424 chars) | 1.56 ms | 643 docs/s |
915
+ | Short record (~190 chars) | 154 µs <br><sub>89 – 237</sub> | 6,494 docs/s |
916
+ | Real document (~3,450 chars) | 1.43 ms <br><sub>1.04 – 1.96</sub> | 699 docs/s |
917
+
918
+ Cost tracks identifier density rather than length: the per-candidate cue and
919
+ suppressor checks dominate, so a form-like document dense in national IDs costs
920
+ several times an ordinary chat log of the same size.
633
921
 
634
922
  No optional accelerator is needed or offered. The Python SDK ships an
635
923
  `[fast]` extra (RE2 / Aho-Corasick) because CPython's regex engine is the
636
924
  bottleneck there; V8's has literal prefilters that make it unnecessary here.
637
- Measured on the same 611 documents, this SDK runs about 3× faster than the
925
+ Measured on the same 611 documents, this SDK runs about 6–7× faster than the
638
926
  accelerated Python path, so adding a native addon — and with it the loss of
639
927
  bundler, edge-runtime and Deno compatibility — would buy nothing.
640
928
 
package/dist/cjs/cache.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ResultCache = void 0;
4
+ exports.resultChars = resultChars;
4
5
  const node_crypto_1 = require("node:crypto");
5
6
  // Default ceiling on retained characters, not entries. A count-only cap of
6
7
  // 1024 entries says nothing about memory: 1024 cached 1 MB documents is a
@@ -11,6 +12,10 @@ function resultChars(result) {
11
12
  let chars = result.redactedText.length;
12
13
  for (const d of result.detections)
13
14
  chars += d.text.length;
15
+ for (const [token, value] of Object.entries(result.tokens))
16
+ chars += token.length + value.length;
17
+ for (const e of result.exempted)
18
+ chars += e.text.length + e.rule.length;
14
19
  return chars;
15
20
  }
16
21
  class ResultCache {