euredact 0.5.0 → 0.6.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
@@ -370,6 +370,85 @@ masked is unchanged either way.
370
370
 
371
371
  For custom patterns, `entityType` is a plain string (e.g. `"EMPLOYEE_ID"`).
372
372
 
373
+ ## Batch processing and concurrency
374
+
375
+ `redactBatch` loads the country configurations once instead of per document,
376
+ which is the whole reason to prefer it to a loop:
377
+
378
+ ```ts
379
+ import { redactBatch } from "euredact";
380
+
381
+ const docs = ["Mijn BSN is 111222333.", "IBAN NL91ABNA0417164300."];
382
+
383
+ redactBatch(docs, { countries: ["NL"] }).map((r) => r.redactedText);
384
+ // [ 'Mijn BSN is [NATIONAL_ID].', 'IBAN [BANK_ACCOUNT].' ]
385
+ ```
386
+
387
+ `redactBatch` is **synchronous**, and deliberately so: the rules engine is
388
+ CPU-bound, so wrapping it in a promise would add scheduling without adding
389
+ parallelism. There is no `redactBatchAsync`. Where the Python SDK offers
390
+ `aredact_batch(..., max_concurrency=n)` — it can offload to a thread pool —
391
+ this SDK has one thread, so the honest equivalent is either `redactBatch` on the
392
+ main thread or real workers:
393
+
394
+ ```ts
395
+ // Keep the event loop responsive across a large batch.
396
+ import { redactBatch } from "euredact";
397
+
398
+ async function* redactInChunks(docs: string[], size = 200) {
399
+ for (let i = 0; i < docs.length; i += size) {
400
+ yield redactBatch(docs.slice(i, i + size), { countries: ["NL"] });
401
+ await new Promise((resolve) => setImmediate(resolve));
402
+ }
403
+ }
404
+ ```
405
+
406
+ For genuine parallelism use `node:worker_threads` and give each worker its own
407
+ `EuRedact`; for true throughput at scale that is the only thing that helps,
408
+ because the cost is regex matching rather than waiting.
409
+
410
+ `redactAsync` is for the **cloud tier**, not for parallelism — it exists because
411
+ a network call cannot be synchronous. With `mode: "rules"` it resolves
412
+ immediately with exactly what `redact()` returns. Cloud calls *are* I/O, so
413
+ those parallelise properly:
414
+
415
+ ```ts
416
+ import { redactAsync } from "euredact";
417
+
418
+ const results = await Promise.all(
419
+ docs.map((d) => redactAsync(d, { countries: ["BE"], mode: "cloud" })),
420
+ );
421
+ ```
422
+
423
+ Bound that yourself if the batch is large — the service enforces quotas and will
424
+ answer `429`, which the client honours via `Retry-After`.
425
+
426
+ ### Reuse one instance
427
+
428
+ The module-level functions share a single hidden instance, which is usually what
429
+ you want. Construct your own when the configuration must be per-tenant: custom
430
+ patterns, an allowlist and the result cache all live on the instance.
431
+
432
+ ```ts
433
+ import { EuRedact } from "euredact";
434
+
435
+ const engine = new EuRedact({ allowlist: ["ACME Corporation"] });
436
+ engine.addCustomPattern("CASE_REF", String.raw`CASE-\d{8}`);
437
+
438
+ engine.redactBatch(
439
+ ["ACME Corporation, ref CASE-20260401, BSN 111222333."],
440
+ { countries: ["NL"] },
441
+ )[0].redactedText;
442
+ // 'ACME Corporation, ref [CASE_REF], BSN [NATIONAL_ID].'
443
+ ```
444
+
445
+ ### Tokens and referential labels do not span a batch
446
+
447
+ `tokenize: true` mints tokens **per call**, so item 3 of a batch does not share a
448
+ token with item 1 even for the same value. If two documents must agree on a
449
+ label, redact them as one text, or use `referentialIntegrity: true`, whose
450
+ `TYPE_n` numbering is per instance rather than per call.
451
+
373
452
  ## Cloud tier
374
453
 
375
454
  > **Status: private alpha.** The cloud tier is in closed testing — it is **not**
@@ -412,13 +491,105 @@ seen a chunk boundary.
412
491
 
413
492
  Options the service cannot honour reject rather than being ignored: multiple
414
493
  `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.
494
+ `tokenize`, `allowlist` and `allowlistDomains` are honoured: the SDK applies
495
+ them locally, after the response, to its own spans and the service's.
496
+
497
+ `CloudClient` is the transport underneath. It sends exactly the text it is
498
+ given and returns spans relative to it: the local masking happens in
499
+ `redactAsync()`, not in the client. Use it directly only with text you have
500
+ already masked.
417
501
 
418
502
  The package stays **zero-dependency** — the client uses the platform's own
419
503
  `fetch`. Node 18+ provides one; on Node 16 the rules engine is unaffected and a
420
504
  `fetchImpl` can be supplied.
421
505
 
506
+ ### What leaves your machine
507
+
508
+ Be precise about this, because it is the question a security review asks first.
509
+
510
+ In `mode: "cloud"` the rules engine runs **on your machine first**, and only
511
+ the text it leaves behind is sent to the service over TLS. Every value it finds
512
+ is already a `[TYPE]` placeholder by the time the request is built:
513
+
514
+ ```
515
+ your text IBAN NL91 ABNA 0417 1643 00 belongs to Nick Bols, tel +31 6 12345678
516
+ │ rules engine, on your machine
517
+ what is sent IBAN [BANK_ACCOUNT] belongs to Nick Bols, tel [PHONE]
518
+ │ TLS ──▶ service (model) ──▶ spans relative to the masked text
519
+ what you get IBAN [BANK_ACCOUNT] belongs to [PERSON_NAME], tel [PHONE]
520
+ ```
521
+
522
+ The request body is that one `text` field plus the country. No list of types,
523
+ no offsets and no original values travel beside it. The service answers with
524
+ spans that index the masked text, and the SDK maps them back onto your
525
+ original, so `detections` always index the document you passed in. Service offsets count
526
+ code points and JavaScript slices UTF-16 units; the client converts.
527
+
528
+ **This is minimisation, not an exemption.** Three things still leave:
529
+
530
+ - what the model is there to find — names, employers, job titles, diagnoses;
531
+ - anything the rules engine missed, such as an identifier in a format it does
532
+ not know;
533
+ - the surrounding prose.
534
+
535
+ So the service still processes personal data on your behalf, and a DPIA should
536
+ say so. What it no longer receives is every IBAN, national ID, phone number and
537
+ email address the local engine could place.
538
+
539
+ Three things behave differently here than in rules mode:
540
+
541
+ - `detectDates` has no effect. The local pass always runs with dates on,
542
+ because that is the rules output the model was trained against. It can only
543
+ cause more to be masked, never less.
544
+ - `tokenize` and the allowlists are applied locally, after the response, and do
545
+ not change what is sent: placeholders on the wire are always `[TYPE]`, and an
546
+ allowlisted value is masked in the request and put back in your result.
547
+ - [Custom patterns](#custom-patterns) run in the local pass, so a value only
548
+ you know how to recognise is masked before the request too.
549
+
550
+ Every span the service returns is checked against the text that was sent. One
551
+ that does not match raises `CloudError` rather than being placed by guesswork.
552
+
553
+ ### Keeping identifiers local
554
+
555
+ `mode: "cloud"` does this for you with our model. The same pattern works with
556
+ any other model — your own, or a general-purpose LLM — and that is what
557
+ `tokenize` is for:
558
+
559
+ ```ts
560
+ import { redact, restore } from "euredact";
561
+
562
+ const prompt = "Stuur een mail naar Bas Verhoeven (bas@example.nl) over NL91ABNA0417164300.";
563
+
564
+ // 1. Mask locally. Nothing has left the process.
565
+ const local = redact(prompt, { countries: ["NL"], tokenize: true });
566
+ local.redactedText;
567
+ // 'Stuur een mail naar Bas Verhoeven (EMAIL_S5SH) over BANK_ACCOUNT_5XXR.'
568
+ local.tokens;
569
+ // { BANK_ACCOUNT_5XXR: 'NL91ABNA0417164300', EMAIL_S5SH: 'bas@example.nl' }
570
+
571
+ // 2. Send only the masked text to any model, ours or someone else's.
572
+ const answer = await callYourModel(local.redactedText);
573
+
574
+ // 3. Put the real values back locally.
575
+ restore(answer, local.tokens);
576
+ ```
577
+
578
+ The token suffixes are random per call, so yours will differ.
579
+
580
+ **Read that output carefully: `Bas Verhoeven` is still there.** That is the whole
581
+ trade-off and it is why the cloud tier exists. The local engine masks what has a
582
+ shape — the IBAN and the address — and cannot mask a name, an employer or a
583
+ diagnosis, because it cannot find them. So this pattern keeps every identifier
584
+ the engine detects inside your process and sends the prose, names included.
585
+ `mode: "cloud"` sends that same masked prose to a model trained to find the
586
+ names in it.
587
+
588
+ Either way the prose leaves. If names must not leave your infrastructure
589
+ either, no hosted model can help; run one yourself against
590
+ `local.redactedText`.
591
+
592
+
422
593
  ## `NAME` is now `PERSON_NAME`
423
594
 
424
595
  The canonical type name is `PERSON_NAME`; `NAME` is a legacy alias, exactly as
@@ -431,6 +602,82 @@ matching the *string* `"NAME"` should be updated; `LEGACY_TYPE_ALIASES`
431
602
  publishes the mapping, and `STREET_ADDRESS` → `ADDRESS` and
432
603
  `NATIONALITY_ETHNICITY` → `SENSITIVE_ATTRIBUTE` are recognised the same way.
433
604
 
605
+ ## What `countries` actually controls
606
+
607
+ This is the parameter most often misread, and reading it wrongly leads to
608
+ under-redaction in exactly the case you care about — a foreign identifier in a
609
+ domestic document. So, precisely:
610
+
611
+ > `countries` decides how a detection is **attributed and scored**.
612
+ > It does **not** decide what gets **found**.
613
+
614
+ Country-specific patterns for every supported country run on every document,
615
+ whatever you declare. Declaring `countries: ["NL"]` does not switch the Belgian
616
+ patterns off; it says "this document is Dutch", and anything Belgian that turns
617
+ up is still detected and still masked — it is just flagged as sitting outside
618
+ what you declared.
619
+
620
+ The same Belgian national number, under four different calls:
621
+
622
+ ```ts
623
+ import { redact } from "euredact";
624
+
625
+ const text = "Rijksregisternummer 85.07.30-033.61 van onze klant.";
626
+
627
+ for (const options of [{ countries: ["NL"] },
628
+ { countries: ["BE"] },
629
+ {},
630
+ { countries: ["NL"], countryHint: ["BE"] }]) {
631
+ const result = redact(text, options);
632
+ const [detection] = result.detections;
633
+ console.log(result.redactedText, "|", result.detectionMode,
634
+ "| country:", detection.country,
635
+ "| countryConfidence:", detection.countryConfidence,
636
+ "| outOfScope:", detection.outOfScope);
637
+ }
638
+ ```
639
+
640
+ ```
641
+ Rijksregisternummer [NATIONAL_ID] van onze klant. | declared | country: BE | countryConfidence: 0 | outOfScope: true
642
+ Rijksregisternummer [NATIONAL_ID] van onze klant. | declared | country: BE | countryConfidence: 0.8807970779778823 | outOfScope: false
643
+ Rijksregisternummer [NATIONAL_ID] van onze klant. | inferred | country: BE | countryConfidence: 0 | outOfScope: false
644
+ Rijksregisternummer [NATIONAL_ID] van onze klant. | declared | country: BE | countryConfidence: 0.8807970779778823 | outOfScope: true
645
+ ```
646
+
647
+ **The masked output is byte-identical in all four.** What moved is the metadata.
648
+
649
+ | You pass | Effect on what is found | Effect on labelling |
650
+ |---|---|---|
651
+ | `countries: ["NL"]` | None — every country's patterns still run | Attributed normally; a non-Dutch hit gets `outOfScope: true` and `countryConfidence: 0` |
652
+ | `countries: ["BE"]` | None | The Belgian hit is corroborated: `countryConfidence` ~0.88, `outOfScope: false` |
653
+ | `countries` omitted | None | `detectionMode: "inferred"`; the country is inferred from the document and nothing is out of scope, because nothing was declared |
654
+ | `countryHint: ["BE"]` | None | A *prior*: it corroborates Belgian attribution **without** widening or narrowing scope, so `out_of_scope` still reflects `countries` alone |
655
+
656
+ ### How to use each one
657
+
658
+ - **`countries: [...]`** — you know where the document is from. Use it. It
659
+ improves attribution and makes `out_of_scope` meaningful, and it is the
660
+ cheapest path because country inference is skipped.
661
+ - **`countries` omitted** — you do not know. The engine infers from the document
662
+ and reports `inferredCountries` with confidences.
663
+ - **`countryHint: [...]`** — you have a weak signal (the customer's billing
664
+ country, the mailbox a document arrived in) that should resolve ambiguity
665
+ without declaring scope. Added in 0.3.2 precisely so that a hint could not be
666
+ mistaken for a scope restriction.
667
+
668
+ ### Why it works this way
669
+
670
+ Until 0.3.2, `countries` *did* gate detection, and 0.3.3 fixed the remaining
671
+ case where it "could change which spans were found, not just how they were
672
+ labelled". Both were changed deliberately: a redaction library that hides a
673
+ Belgian national number because you told it the document was Dutch has failed
674
+ at the only job it has. Scoping is a reporting concern; masking is not.
675
+
676
+ The practical consequence: **do not use `countries` as a filter.** If you only
677
+ want Dutch detections in your output, filter on
678
+ `detection.country === "NL"` or on `detection.outOfScope` after the call —
679
+ the value was still masked in the text either way.
680
+
434
681
  ## Country codes
435
682
 
436
683
  `countries` accepts **ISO 3166-1 alpha-2** codes. The two EU/VAT spellings are
@@ -756,9 +1003,9 @@ itself be redacted without `restore()` putting the wrong value back.
756
1003
  `restore()` replaces every occurrence, including a token an LLM glued to other
757
1004
  characters (`EMAIL_P4RTs`) — leaving a token behind is the worse failure.
758
1005
 
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.
1006
+ Works in cloud mode via `redactAsync`: tokens are minted locally, after the
1007
+ response, over the local spans and the service's. What is sent to the service
1008
+ always carries `[TYPE]` placeholders, never tokens.
762
1009
 
763
1010
  ## Allowlist
764
1011
 
@@ -926,20 +1173,22 @@ Measured on the same 611 documents, this SDK runs about 6–7× faster than the
926
1173
  accelerated Python path, so adding a native addon — and with it the loss of
927
1174
  bundler, edge-runtime and Deno compatibility — would buy nothing.
928
1175
 
1176
+ | Package | |
1177
+ |---|---:|
1178
+ | Tarball | 150 kB |
1179
+ | Unpacked | 629 kB |
1180
+ | Runtime dependencies | **0** |
1181
+
1182
+ Measured with `npm pack --dry-run` at 0.5.1. A stale duplicate of this section
1183
+ previously quoted 0.02 ms and 86 KB; both were wrong, and the figures above are
1184
+ the measured ones.
1185
+
929
1186
  ## CommonJS
930
1187
 
931
1188
  ```js
932
1189
  const { redact } = require("euredact");
933
1190
  ```
934
1191
 
935
- ## Performance
936
-
937
- | Metric | Value |
938
- |---|---|
939
- | Latency per redaction | ~0.02 ms |
940
- | Package size | ~86 KB |
941
- | Runtime dependencies | 0 |
942
-
943
1192
  ## License
944
1193
 
945
1194
  Apache-2.0
@@ -2,11 +2,15 @@
2
2
  /**
3
3
  * [CLOUD EXTENSION] The cloud client.
4
4
  *
5
- * Speaks to the euRedact inference service: a deterministic rules engine
6
- * followed by a fine-tuned model asked only *what did the rules miss?* The
7
- * service returns both the redacted document and located spans, so this
5
+ * Speaks to the euRedact inference service: a fine-tuned model asked only
6
+ * *what did the rules miss?* The service returns located spans, so this
8
7
  * module's job is transport and translation, not detection.
9
8
  *
9
+ * It sends the text it is given, as given. `redactAsync(text, { mode: "cloud" })`
10
+ * is what runs the rules engine first and hands this client only the masked
11
+ * text; call the client directly and whatever you pass is what leaves the
12
+ * machine. The spans that come back index the text that was sent.
13
+ *
10
14
  * Three things it hides from the caller:
11
15
  *
12
16
  * - **Whether the document finished inside the service's sync window.** A short
@@ -156,7 +160,6 @@ class CloudClient {
156
160
  country: options.country.toUpperCase(),
157
161
  language: options.language ?? "",
158
162
  priority: options.priority ?? "interactive",
159
- rules_only: options.rulesOnly ?? false,
160
163
  });
161
164
  let attempt = 0;
162
165
  for (;;) {