euredact 0.3.1__tar.gz → 0.3.2__tar.gz

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.
Files changed (95) hide show
  1. {euredact-0.3.1 → euredact-0.3.2}/CHANGELOG.md +193 -0
  2. {euredact-0.3.1 → euredact-0.3.2}/PKG-INFO +229 -47
  3. {euredact-0.3.1 → euredact-0.3.2}/README.md +225 -46
  4. {euredact-0.3.1 → euredact-0.3.2}/pyproject.toml +4 -2
  5. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/__init__.py +21 -1
  6. euredact-0.3.2/src/euredact/rules/context.py +85 -0
  7. euredact-0.3.2/src/euredact/rules/engine.py +445 -0
  8. euredact-0.3.2/src/euredact/rules/evidence.py +207 -0
  9. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/matchers.py +171 -6
  10. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/registry.py +17 -8
  11. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/suppressors.py +48 -12
  12. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/sdk.py +63 -9
  13. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/types.py +56 -0
  14. {euredact-0.3.1 → euredact-0.3.2}/tests/countries/test_be.py +19 -2
  15. euredact-0.3.2/tests/eval_diff.py +161 -0
  16. euredact-0.3.2/tests/test_country_inference.py +132 -0
  17. euredact-0.3.2/tests/test_document_context.py +127 -0
  18. euredact-0.3.2/tests/test_invariant_generation.py +141 -0
  19. euredact-0.3.2/tests/test_scan_path_parity.py +237 -0
  20. euredact-0.3.1/src/euredact/rules/engine.py +0 -220
  21. {euredact-0.3.1 → euredact-0.3.2}/.github/workflows/ci.yml +0 -0
  22. {euredact-0.3.1 → euredact-0.3.2}/.gitignore +0 -0
  23. {euredact-0.3.1 → euredact-0.3.2}/CONTRIBUTING.md +0 -0
  24. {euredact-0.3.1 → euredact-0.3.2}/LICENSE +0 -0
  25. {euredact-0.3.1 → euredact-0.3.2}/benchmarks/bench_engine.py +0 -0
  26. {euredact-0.3.1 → euredact-0.3.2}/examples/basic_usage.py +0 -0
  27. {euredact-0.3.1 → euredact-0.3.2}/examples/batch_processing.py +0 -0
  28. {euredact-0.3.1 → euredact-0.3.2}/examples/llm_preprocessing.py +0 -0
  29. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/cache.py +0 -0
  30. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/cloud/__init__.py +0 -0
  31. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/cloud/client.py +0 -0
  32. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/cloud/hasher.py +0 -0
  33. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/cloud/shuffler.py +0 -0
  34. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/coref/__init__.py +0 -0
  35. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/coref/models.py +0 -0
  36. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/coref/resolver.py +0 -0
  37. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/normalizer.py +0 -0
  38. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/py.typed +0 -0
  39. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/__init__.py +0 -0
  40. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/bic_registry.py +0 -0
  41. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/__init__.py +0 -0
  42. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/_base.py +0 -0
  43. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/_shared.py +0 -0
  44. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/at.py +0 -0
  45. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/be.py +0 -0
  46. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/bg.py +0 -0
  47. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/ch.py +0 -0
  48. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/cy.py +0 -0
  49. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/cz.py +0 -0
  50. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/de.py +0 -0
  51. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/dk.py +0 -0
  52. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/ee.py +0 -0
  53. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/el.py +0 -0
  54. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/es.py +0 -0
  55. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/fi.py +0 -0
  56. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/fr.py +0 -0
  57. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/hr.py +0 -0
  58. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/hu.py +0 -0
  59. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/ie.py +0 -0
  60. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/is_.py +0 -0
  61. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/it.py +0 -0
  62. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/lt.py +0 -0
  63. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/lu.py +0 -0
  64. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/lv.py +0 -0
  65. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/mt.py +0 -0
  66. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/nl.py +0 -0
  67. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/no.py +0 -0
  68. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/pl.py +0 -0
  69. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/pt.py +0 -0
  70. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/ro.py +0 -0
  71. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/se.py +0 -0
  72. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/si.py +0 -0
  73. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/sk.py +0 -0
  74. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/uk.py +0 -0
  75. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/de_districts.py +0 -0
  76. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/structural.py +0 -0
  77. {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/validators.py +0 -0
  78. {euredact-0.3.1 → euredact-0.3.2}/tests/__init__.py +0 -0
  79. {euredact-0.3.1 → euredact-0.3.2}/tests/benchmark.py +0 -0
  80. {euredact-0.3.1 → euredact-0.3.2}/tests/conftest.py +0 -0
  81. {euredact-0.3.1 → euredact-0.3.2}/tests/countries/__init__.py +0 -0
  82. {euredact-0.3.1 → euredact-0.3.2}/tests/countries/test_nl.py +0 -0
  83. {euredact-0.3.1 → euredact-0.3.2}/tests/eval_full.py +0 -0
  84. {euredact-0.3.1 → euredact-0.3.2}/tests/test_api_contract.py +0 -0
  85. {euredact-0.3.1 → euredact-0.3.2}/tests/test_batch.py +0 -0
  86. {euredact-0.3.1 → euredact-0.3.2}/tests/test_bic.py +0 -0
  87. {euredact-0.3.1 → euredact-0.3.2}/tests/test_cache.py +0 -0
  88. {euredact-0.3.1 → euredact-0.3.2}/tests/test_conformance.py +0 -0
  89. {euredact-0.3.1 → euredact-0.3.2}/tests/test_engine.py +0 -0
  90. {euredact-0.3.1 → euredact-0.3.2}/tests/test_international.py +0 -0
  91. {euredact-0.3.1 → euredact-0.3.2}/tests/test_normalizer.py +0 -0
  92. {euredact-0.3.1 → euredact-0.3.2}/tests/test_postal_code.py +0 -0
  93. {euredact-0.3.1 → euredact-0.3.2}/tests/test_redact.py +0 -0
  94. {euredact-0.3.1 → euredact-0.3.2}/tests/test_structdata.py +0 -0
  95. {euredact-0.3.1 → euredact-0.3.2}/tests/test_validators.py +0 -0
@@ -1,5 +1,198 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.2 (2026-07-29)
4
+
5
+ ### Changed
6
+
7
+ - **`countries=` no longer gates detection — it scores it.** Every pattern now
8
+ runs on every document regardless of what the caller declares. The declared
9
+ country decides how a match is *labelled*, never whether it is *found*.
10
+
11
+ This fixes a silent recall failure. `countries=["BE"]` made a valid Dutch BSN
12
+ vanish entirely, because the Dutch patterns were never run:
13
+
14
+ ```python
15
+ euredact.redact("Werknemer met BSN 111222333", countries=["BE"])
16
+ # before: 'Werknemer met BSN 111222333' <- leaked
17
+ # now: 'Werknemer met BSN [NATIONAL_ID]'
18
+ ```
19
+
20
+ Entities attributed outside the declared set are flagged via the new
21
+ `Detection.out_of_scope`, not dropped. The invariant — no value of
22
+ `countries=` may change which spans are detected — is enforced by
23
+ `tests/test_invariant_generation.py`.
24
+
25
+ **Behaviour change for callers:** documents processed with a single
26
+ `countries=[...]` value will now detect *more* than before, including
27
+ entities belonging to other countries. Downstream code that assumed every
28
+ detection belonged to the declared country should read `detection.country`
29
+ or filter on `out_of_scope`.
30
+
31
+ - **Failed-checksum spans no longer delete overlapping detections.** A span
32
+ that matched a checksummed pattern but failed the checksum created a
33
+ "suppression zone" that removed any regex-only detection inside it. With more
34
+ than one country loaded, one country's failed checksum silently deleted
35
+ another country's correct detection.
36
+
37
+ Measured across the corpus: the mechanism removed 454 detections, of which
38
+ **454 overlapped real labelled PII** — it bought no precision at all. Such a
39
+ candidate is now demoted below every other candidate rather than deleted, so
40
+ it can still claim a span nothing else wants but can never silence one.
41
+
42
+ On 10,000 documents:
43
+
44
+ | | recall before | after | precision before | after |
45
+ |---|---:|---:|---:|---:|
46
+ | with country hints | 97.98% | **98.89%** | 98.98% | 98.97% |
47
+ | blind | 91.23% | **96.11%** | 95.87% | 95.79% |
48
+
49
+ No entity type regressed. Largest gains: `TAX_ID` 0.0% → 61.5% blind,
50
+ `CHAMBER_OF_COMMERCE` 81.4% → 98.6% hinted, `PHONE` 71.2% → 85.1% blind,
51
+ `NATIONAL_ID` 87.6% → 98.4% blind.
52
+
53
+ - **Suppression now runs only on candidates that win their span.** Previously
54
+ every surviving match was suppressed before deduplication, though most lose
55
+ their span immediately afterwards. Output is unchanged — verified zero-diff
56
+ on 8,000 documents in both modes — and detection is 1.28x faster with country
57
+ hints, 2.67x faster blind.
58
+
59
+ ### Added
60
+
61
+ - **Country inference.** The engine works out which countries a document
62
+ belongs to from entities that carry their country in the string — IBAN
63
+ prefixes, `+CC` dialling codes, VAT prefixes, BIC country codes, email
64
+ ccTLDs — and uses it to decide which national scheme owns an ambiguous
65
+ value. 36.6% of national-ID values in the corpus validate under more than one
66
+ country's checksum, so the digits alone cannot decide it.
67
+
68
+ ```python
69
+ euredact.redact("Bereikbaar op telefoon 0612345678, mail jan@test.nl")
70
+ # -> PHONE (NL)
71
+
72
+ euredact.redact("Kontakt: 0612345678, e-mail jens@test.dk")
73
+ # -> NATIONAL_ID (DK)
74
+ ```
75
+
76
+ Identical digits: `0612345678` is both a valid Dutch mobile number and a
77
+ valid Danish CPR. Only the document distinguishes them.
78
+
79
+ On 152,300 documents, blind detection (no `countries=`) improved from
80
+ **95.6% to 98.3% precision** and from **94.90% to 98.38% type-correct**,
81
+ closing the gap to hinted detection from 3.4 points to 0.3.
82
+
83
+ Weights are derived from the corpus, not hand-tuned. Inference influences
84
+ scoring only; it can never cause a miss.
85
+
86
+ - **RE2 scan prefilter, via `pip install euredact[fast]`.** One DFA pass per
87
+ 1 KB window reports which patterns can match in it; only those are then run
88
+ over the text. A real 3,424-character document matches 42 of 314 prefiltered
89
+ patterns, so the other 272 are skipped entirely.
90
+
91
+ Measured on 611 real documents (mean 3,424 chars) and 3,000 synthetic
92
+ records, end to end:
93
+
94
+ | Input | Before | After |
95
+ |---|---:|---:|
96
+ | Short record (186 chars) | 723 µs | **496 µs** (1.46×) |
97
+ | Real document (3,424 chars) | 10.4 ms | **5.4 ms** (1.94×) |
98
+
99
+ **Output is unchanged, by construction.** The prefilter only decides which
100
+ patterns are worth running; each survivor is then run over the whole text
101
+ exactly as before, so skipping a pattern that matches nowhere cannot change
102
+ the result. Verified identical to the plain-Python scan on all 4,611
103
+ documents of both corpora.
104
+
105
+ Restricting each pattern to its window instead is faster still (3.21× vs
106
+ 2.70× on the scan alone) but diverges from the reference scan on 44 matches,
107
+ which was not judged worth a third set of scan semantics.
108
+
109
+ Patterns RE2 cannot express (11 of 345 — lookbehind-based postal-code and
110
+ secret rules) and those that can match further than the window overlap
111
+ (31 more) always run. `tests/test_scan_path_parity.py` now exercises every
112
+ available scan path against the plain-Python one, and CI runs the suite both
113
+ with and without the optional extras.
114
+
115
+ - **`DocumentContext`** — shares country evidence across the chunks of one
116
+ document, via `redact(..., context=ctx, chunk_offset=n)`. Without it, a chunk
117
+ carrying no country signal of its own is scored as if the rest of the
118
+ document did not exist:
119
+
120
+ ```python
121
+ euredact.redact("Telefoon 0612345678") # -> NATIONAL_ID (DK)
122
+ # page 1 established the document is Dutch; with a context:
123
+ euredact.redact("Telefoon 0612345678", context=ctx) # -> PHONE (NL)
124
+ ```
125
+
126
+ Thread-safe, deduplicated so a re-run chunk cannot vote twice, and spans are
127
+ rebased by `chunk_offset` so recorded evidence points into the whole
128
+ document. Caching is disabled while a context is in use, since the result no
129
+ longer depends on the text alone.
130
+
131
+ - `RedactResult.inferred_countries` — `(country, confidence)` pairs, strongest
132
+ first. Confidences are per-country and do not sum to 1: document countries
133
+ are not mutually exclusive, and a Belgian supplier invoicing a German
134
+ customer is genuinely both.
135
+ - `RedactResult.evidence` — every signal behind the inference, with the span
136
+ that produced it. The audit trail for a country attribution.
137
+ - `RedactResult.detection_mode` — `"declared"` or `"inferred"`. Named
138
+ `detection_mode` rather than `mode` because `redact(mode=...)` already means
139
+ the tier selector.
140
+ - `Detection.country_confidence` — how strongly the document supports the
141
+ attributed country, in [0, 1]. `0.0` is the signal that an attribution rests
142
+ on a checksum alone.
143
+ - `Detection.out_of_scope` — attributed outside the declared `countries`.
144
+ - `country_hint=[...]` on every entry point — a prior that resolves ambiguity
145
+ **without** narrowing scope or flagging anything out of scope, as distinct
146
+ from `countries=`, which does both.
147
+
148
+ ### Fixed
149
+
150
+ - **A failed checksum no longer demotes unrelated entity types on the same
151
+ span.** A span that failed a checksum demoted *every* validator-less
152
+ candidate covering it, whatever its type. So Sweden's own personnummer
153
+ checksum failing on `0708787668` demoted the Swedish *phone* candidate for
154
+ the same digits, handing the span to a Danish CPR that happened to validate.
155
+
156
+ A failed checksum is evidence against *that type*, not against the span.
157
+ Zones are now per entity type. Worth 878 mistyped phone numbers per 30,000
158
+ documents.
159
+
160
+ - **A passing checksum from an uncorroborated country no longer outranks
161
+ everything.** A weak checksum fits by luck — a mod-11 scheme accepts a random
162
+ number about one time in eleven — so a validated candidate from a country the
163
+ document shows no trace of is now treated as coincidence rather than
164
+ evidence. Entities that carry their own country still vouch for themselves,
165
+ so a foreign IBAN in a domestic invoice keeps its rank.
166
+
167
+
168
+ ### Fixed — security
169
+
170
+ - **Installing the optional `fast` extra disabled private-key redaction.**
171
+ Prefix-indexed patterns are only run over a bounded window after their prefix
172
+ hit, but 15 SECRET patterns can match beyond it — the PEM private-key pattern
173
+ matches up to 16 KB. With `pyahocorasick` installed those matches were never
174
+ found, and a PEM block passed through into `redacted_text` unmasked.
175
+
176
+ Reproduced on 0.3.1, identical input:
177
+
178
+ ```
179
+ SECRET spans key material in output?
180
+ with pyahocorasick [15] LEAKED
181
+ without pyahocorasick [542, 15] not leaked
182
+ ```
183
+
184
+ Patterns whose maximum match width exceeds the window are now routed to the
185
+ sequential path. Verified: the two scan paths produce identical output on
186
+ 12,000 corpus documents, where they previously diverged.
187
+
188
+ Affects anyone who installed `euredact[fast]`. The pure-Python default was
189
+ never affected, and the TypeScript package does not window its scans so was
190
+ never affected either.
191
+
192
+ - `tests/test_scan_path_parity.py` runs **both** scan paths in one process and
193
+ compares them, so the optional extra can no longer change behaviour silently.
194
+ A >200-character PEM block is now a shared conformance vector.
195
+
3
196
  ## 0.3.1 (2026-07-27)
4
197
 
5
198
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: euredact
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: European PII redaction SDK — rule engine for 31 countries
5
5
  Project-URL: Documentation, https://github.com/euredact/euredact#readme
6
6
  Project-URL: Repository, https://github.com/euredact/euredact
@@ -27,12 +27,15 @@ Requires-Python: >=3.10
27
27
  Provides-Extra: cloud
28
28
  Provides-Extra: coref
29
29
  Provides-Extra: dev
30
+ Requires-Dist: google-re2>=1.1; extra == 'dev'
30
31
  Requires-Dist: mypy; extra == 'dev'
32
+ Requires-Dist: pyahocorasick>=2.0.0; extra == 'dev'
31
33
  Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
32
34
  Requires-Dist: pytest-benchmark; extra == 'dev'
33
35
  Requires-Dist: pytest>=7.0; extra == 'dev'
34
36
  Requires-Dist: ruff; extra == 'dev'
35
37
  Provides-Extra: fast
38
+ Requires-Dist: google-re2>=1.1; extra == 'fast'
36
39
  Requires-Dist: pyahocorasick>=2.0.0; extra == 'fast'
37
40
  Description-Content-Type: text/markdown
38
41
 
@@ -80,16 +83,20 @@ print(result.detections)
80
83
  - **Checksum validation:** IBAN mod-97, Luhn (credit cards), and 30+ country-specific
81
84
  national ID checksums (e.g., Dutch BSN 11-proof, Belgian national number modulo)
82
85
  - **Priority-aware deduplication:** when matches overlap, validated patterns
83
- (with passing checksums) win over custom patterns, which win over regex-only
84
- patterns; suppression zones prevent false positives from claiming spans that
85
- belong to a recognized-but-invalid pattern (e.g., license plate fragments
86
- inside an invalid IBAN)
86
+ (with passing checksums, corroborated by the document's country) win over
87
+ custom patterns, which win over regex-only patterns; a failed checksum demotes
88
+ same-type matches rather than deleting them, so it can never silence a
89
+ detection
87
90
  - **Two-pass detection:** liberal regex matching followed by suppression filters
88
91
  that eliminate false positives
89
92
  - **Context-aware:** keyword proximity checks and structural detection (JSON field
90
93
  names, CSV headers) for ambiguous patterns like dates of birth
91
- - **Fast:** sub-millisecond per page, approximately 2,000 records/second
92
- - **Zero required dependencies** (`pyahocorasick` optional for acceleration)
94
+ - **Country self-detection:** infers a document's countries from the entities
95
+ that carry one, so an ambiguous value resolves without the caller naming a
96
+ country — and `countries=` never gates what is looked for
97
+ - **Fast:** ~0.5 ms for a short record, ~5 ms for a 3,400-character document
98
+ with `[fast]` installed (see [Performance](#performance))
99
+ - **Zero required dependencies** (`pip install euredact[fast]` adds optional acceleration)
93
100
  - **Thread-safe,** immutable `Detection` objects (frozen dataclasses)
94
101
 
95
102
  ### Supported Countries
@@ -247,6 +254,12 @@ class RedactResult:
247
254
  detections: list[Detection] # All PII spans found
248
255
  source: str = "rules" # Detection backend ("rules")
249
256
  degraded: bool = False # True if the engine fell back to a simpler mode
257
+
258
+ # Country inference — see "Country-independent detection"
259
+ inferred_countries: tuple[tuple[str, float], ...] = () # (country, confidence), strongest first
260
+ evidence: tuple[CountryEvidence, ...] = () # every signal, with the span behind it
261
+ detection_mode: str = "declared" # "declared" if countries= was passed,
262
+ # "inferred" otherwise
250
263
  ```
251
264
 
252
265
  #### `Detection`
@@ -263,6 +276,10 @@ class Detection:
263
276
  source: DetectionSource # "rules" or "cloud"
264
277
  country: str | None # ISO code of the matched country, or None for shared/custom patterns
265
278
  confidence: str = "high" # Confidence level
279
+ country_confidence: float = 0.0 # How strongly the document supports `country`, in [0, 1].
280
+ # 0.0 means the attribution rests on a checksum alone.
281
+ out_of_scope: bool = False # True when attributed outside the declared `countries`.
282
+ # Flagged, never dropped.
266
283
  ```
267
284
 
268
285
  #### `EntityType`
@@ -313,29 +330,85 @@ unredacted PII in the output.
313
330
 
314
331
  ## Country-independent detection
315
332
 
316
- `countries=[...]` narrows which country-specific patterns run. It does **not**
317
- gate identifiers that carry their own evidence:
333
+ **`countries=[...]` never gates detection.** Every pattern runs on every
334
+ document, whatever you pass. The country you declare decides *how a match is
335
+ labelled*, never *whether it is found*.
318
336
 
319
- | Type | Why it ignores `countries` |
320
- |---|---|
321
- | `BANK_ACCOUNT` (IBAN) | carries an ISO 3166 country code and a mod-97 checksum |
322
- | `PHONE` (with `+` prefix) | an E.164 country prefix is self-identifying |
323
- | `EMAIL`, `CREDIT_CARD`, `VIN`, `IMEI`, `IP_ADDRESS`, `UUID`, `SECRET` | no national form to gate on |
337
+ This is the engine's central invariant, enforced by
338
+ `tests/test_invariant_generation.py`: no value of `countries=` may change which
339
+ spans are detected. A wrong or missing country cannot cause a miss — silent
340
+ recall loss is invisible in testing and surfaces in a breach report, whereas a
341
+ false positive is recoverable.
342
+
343
+ It was not always so. `countries=["BE"]` used to make a valid Dutch BSN vanish
344
+ entirely, because the Dutch patterns were never run:
345
+
346
+ ```python
347
+ euredact.redact("Werknemer met BSN 111222333", countries=["BE"])
348
+ # before: 'Werknemer met BSN 111222333' <- leaked
349
+ # now: 'Werknemer met BSN [NATIONAL_ID]'
350
+ ```
351
+
352
+ Entities found outside the countries you declared are **flagged, not dropped**:
353
+
354
+ ```python
355
+ det = euredact.redact("BSN 111222333", countries=["BE"]).detections[0]
356
+ det.out_of_scope # True — detected, masked, and marked as outside your scope
357
+ ```
324
358
 
325
359
  So a Belgian IBAN in a document processed with `countries=["AT"]` is still
326
- detected — cross-border traffic (a foreign invoice in a local file, an
327
- employee paid to a foreign account) does not leak:
360
+ detected — cross-border traffic (a foreign invoice in a local file, an employee
361
+ paid to a foreign account) does not leak:
328
362
 
329
363
  ```python
330
364
  euredact.redact("Rekening: BE68 5390 0754 7034", countries=["AT"])
331
365
  # -> 'Rekening: [BANK_ACCOUNT]'
332
366
  ```
333
367
 
368
+ ### Which country a value belongs to
369
+
370
+ Because every pattern runs, the same digits often match several countries'
371
+ schemes. 36.6% of national-ID values in our corpus validate under more than one
372
+ country's checksum, so the digits alone cannot decide it — the *document* does.
373
+
374
+ The engine infers the document's countries from entities that carry their
375
+ country in the string, then uses that to resolve the ambiguity:
376
+
377
+ ```python
378
+ r = euredact.redact("Bereikbaar op telefoon 0612345678, mail jan@test.nl")
379
+ r.inferred_countries # (('NL', 0.98),)
380
+ r.detections[0].entity_type # PHONE
381
+
382
+ r = euredact.redact("Kontakt: 0612345678, e-mail jens@test.dk")
383
+ r.inferred_countries # (('DK', 0.98),)
384
+ r.detections[0].entity_type # NATIONAL_ID
385
+ ```
386
+
387
+ Identical digits, different answer — `0612345678` is both a valid Dutch mobile
388
+ number and a valid Danish CPR. Only the surrounding document distinguishes them.
389
+
390
+ Every inference is auditable: `result.evidence` lists each signal, its weight,
391
+ and the span that produced it.
392
+
393
+ | Signal | Weight (log-odds) | Measured reliability |
394
+ |---|---:|---|
395
+ | `e164_prefix` | 4.00 (capped) | 41,402 / 41,402 |
396
+ | `bic_country` | 4.00 (capped) | 2,588 / 2,588 |
397
+ | `email_tld` | 4.00 (capped) | 97,865 / 98,949 |
398
+ | `vat_prefix` | 2.84 | 19,022 / 20,136 |
399
+ | `iban_prefix` | 1.94 | 110,572 / 126,428 |
400
+
401
+ Weights are derived from the corpus, not chosen by hand. The IBAN prefix being
402
+ weakest is real and worth knowing: a Belgian IBAN in a Dutch invoice is
403
+ ordinary, so an account's country is only weak evidence about the document's.
404
+
405
+ Confidences are per-country and do **not** sum to 1 — document countries are
406
+ not mutually exclusive. A Belgian supplier invoicing a German customer is
407
+ genuinely both.
408
+
334
409
  International phone numbers are matched by a generic E.164 pattern (`+`
335
410
  followed by 8-15 digits, any grouping or separators, including `(0)` trunk
336
- prefixes) that runs alongside the per-country patterns. National-format numbers
337
- without a `+` prefix still require the relevant country to be requested, since
338
- `0664 8213907` is only recognisable as Austrian in context.
411
+ prefixes) that runs alongside the per-country patterns.
339
412
 
340
413
  ## BIC detection
341
414
 
@@ -502,23 +575,78 @@ print(result.redacted_text)
502
575
 
503
576
  ## Country Hints
504
577
 
505
- When you know which countries appear in your data, pass them explicitly:
578
+ Two arguments tell the engine about country, and neither restricts what is
579
+ looked for:
580
+
581
+ | Argument | Meaning |
582
+ |---|---|
583
+ | `countries=[...]` | **Scope.** Resolves ambiguity, and flags anything attributed elsewhere as `out_of_scope`. |
584
+ | `country_hint=[...]` | **Prior only.** Resolves ambiguity without narrowing scope or flagging anything. |
585
+
586
+ ```python
587
+ # You know this batch is Swedish, but do not want foreign PII marked out of scope:
588
+ euredact.redact(text, country_hint=["SE"])
589
+
590
+ # You want anything non-Swedish flagged for review:
591
+ euredact.redact(text, countries=["SE"])
592
+ ```
593
+
594
+ Declaring a country helps where a value is genuinely ambiguous and the document
595
+ carries no other signal:
596
+
597
+ ```python
598
+ euredact.redact("Telefon: 0708787668", country_hint=["SE"]).detections[0]
599
+ # PHONE / SE — without the hint this is a valid Danish CPR and nothing says otherwise
600
+ ```
601
+
602
+ `result.detection_mode` reports which happened: `"declared"` if you passed
603
+ `countries=`, `"inferred"` otherwise.
604
+
605
+ Custom patterns are always active regardless of either parameter.
606
+
607
+ Passing neither is safe, and is the right default for mixed-origin data: the
608
+ engine infers what it can and reports it in `result.inferred_countries`. On our
609
+ 152,300-document corpus, blind detection scores 98.3% precision against 98.6%
610
+ with country hints — a 0.3-point gap, down from 3.4 points before inference.
611
+
612
+ ## Chunked documents
613
+
614
+ A long document is usually redacted in pieces. Each piece is scanned
615
+ independently, so each infers its own country — and a chunk that happens to
616
+ contain no IBAN, no `+CC` number and no ccTLD infers nothing at all, even when
617
+ page 1 identified the document beyond doubt:
506
618
 
507
619
  ```python
508
- result = euredact.redact(text, countries=["NL", "BE"])
620
+ euredact.redact("Telefoon 0612345678")
621
+ # -> NATIONAL_ID (DK) <- a valid Danish CPR, and nothing here says otherwise
509
622
  ```
510
623
 
511
- This restricts pattern matching to Dutch and Belgian rules (plus shared patterns
512
- like IBAN, email, credit card, and secrets). Benefits:
624
+ Pass a `DocumentContext` to carry evidence forward across chunks:
513
625
 
514
- - **Fewer false positives.** A 9-digit number that passes the Dutch BSN checksum
515
- will not also be tested against unrelated country patterns.
516
- - **Faster.** Fewer patterns to compile and scan.
626
+ ```python
627
+ from euredact import DocumentContext
628
+
629
+ ctx = DocumentContext()
630
+ offset = 0
631
+ for page in pages:
632
+ result = euredact.redact(page, context=ctx, chunk_offset=offset)
633
+ offset += len(page)
634
+
635
+ # page 1: "Factuur — IBAN NL91ABNA0417164300, info@jansen.nl"
636
+ # page 7: "Telefoon 0612345678" -> PHONE (NL)
637
+ ```
517
638
 
518
- Custom patterns are always active regardless of the `countries` parameter.
639
+ `chunk_offset` rebases spans recorded in the context so they point into the
640
+ whole document. Returned detections stay relative to the chunk you passed in.
519
641
 
520
- When `countries=None` (the default), all 31 country rule sets are loaded. This is
521
- the safest option when processing mixed-origin data.
642
+ A context is thread-safe — `aredact_batch` fans chunks across a thread pool.
643
+ Caching is disabled automatically while one is in use, because the result then
644
+ depends on evidence the text alone does not determine.
645
+
646
+ Reuse a context only for chunks of the **same** document. Sharing one across
647
+ unrelated documents mixes their countries together. That cannot hide anything
648
+ — the invariant still holds — but it will attribute values to the wrong
649
+ national scheme.
522
650
 
523
651
  ## Referential Integrity
524
652
 
@@ -551,13 +679,17 @@ Input text
551
679
  | via MultiPatternMatcher (Aho-Corasick optional)
552
680
  v
553
681
  [Pass 2a: Validation] -- Checksum validators (mod-97, Luhn, entropy, ...)
554
- | Failed-validation spans become suppression zones
682
+ | Failed spans are recorded, per entity type
683
+ v
684
+ [Evidence] -- Which countries does this document belong to? From IBAN
685
+ | prefixes, +CC codes, VAT prefixes, BIC, email ccTLDs
555
686
  v
556
687
  [Pass 2b: Suppression] -- Remove false positives (currency amounts, units,
557
- | references, overlapping matches in suppression zones)
688
+ | references). Failed checksums demote same-type
689
+ | matches rather than deleting them
558
690
  v
559
- [Deduplication] -- Priority-aware: validated > custom > regex-only
560
- | Within same tier, longer span wins
691
+ [Deduplication] -- Priority-aware, country-evidence-weighted
692
+ | Longer span outranks declared country
561
693
  v
562
694
  [Replacement] -- Right-to-left substitution with [ENTITY_TYPE] labels
563
695
  | or labels
@@ -586,12 +718,33 @@ correctly not reporting an invalid IBAN.
586
718
  When multiple patterns match overlapping spans, the engine resolves conflicts
587
719
  using a priority system:
588
720
 
589
- 1. **Validated patterns** (has a checksum validator that passes) -- highest priority
590
- 2. **Custom patterns** (registered via `add_custom_pattern()`)
591
- 3. **Regex-only patterns** (built-in patterns without a validator) -- lowest priority
592
-
593
- Within the same priority tier, the longer span wins. This ensures that a valid
594
- IBAN always beats a coincidental phone number match on the same text.
721
+ | Tier | What |
722
+ |---:|---|
723
+ | 3 | **Validated** — a checksum validator passes *and* the document corroborates its country |
724
+ | 2 | **Custom patterns** registered via `add_custom_pattern()` |
725
+ | 1 | **Regex-only**, and validated patterns whose country the document does not corroborate |
726
+ | 0 | **Postal codes** — a bare digit run, the weakest evidence in the engine |
727
+ | -1 | **Demoted** — a validator-less match inside a failed checksum of *its own type* |
728
+
729
+ Within a tier, ranking is: longer span, then stronger country evidence, then
730
+ whether the country was declared. **Span length outranks country** deliberately:
731
+ preferring the declared country over the longest match truncates entities — with
732
+ `countries=["BE"]` the Belgian phone pattern claimed 11 of the 14 characters of
733
+ `06 12 34 56 78` and left three digits exposed. Country can change *which*
734
+ country is attributed, never *what* is masked.
735
+
736
+ Two tiers are less obvious than they look:
737
+
738
+ - **A passing checksum does not automatically win.** A weak checksum fits by
739
+ luck — a mod-11 scheme accepts a random number about one time in eleven — so a
740
+ validated candidate from a country the document shows no trace of drops to
741
+ tier 1. Entities that carry their own country vouch for themselves (an IBAN
742
+ emits evidence for its own country), so a foreign IBAN in a domestic invoice
743
+ keeps tier 3.
744
+ - **Nothing is deleted for failing a checksum.** A failed checksum demotes
745
+ rather than removes, and only candidates of *the same entity type*: it is
746
+ evidence against that type, not against the span. Deleting instead removed 454
747
+ detections across the corpus, of which 454 overlapped real labelled PII.
595
748
 
596
749
  ## Adding a New Country
597
750
 
@@ -636,15 +789,44 @@ Each `PatternDef` can specify:
636
789
 
637
790
  ## Performance
638
791
 
639
- | Metric | Value |
640
- |---|---|
641
- | Latency per page (~500 words) | < 1 ms |
642
- | Throughput | ~2,000 records/second |
643
- | Memory per country | ~50 KB compiled patterns |
792
+ Measured on one core, all 31 countries loaded, `detect_dates=True`. Cost scales
793
+ with document length, so the input size is stated rather than averaged away.
794
+
795
+ | Input | Base | With `[fast]` |
796
+ |---|---:|---:|
797
+ | Short record (186 chars) | 723 µs — 1,383/s | **496 µs — 2,017/s** |
798
+ | Real document (3,424 chars) | 10.4 ms — 96/s | **5.4 ms — 187/s** |
799
+ | Memory per country | ~50 KB compiled patterns | |
800
+
801
+ ```bash
802
+ pip install euredact[fast]
803
+ ```
804
+
805
+ The `fast` extra installs two optional accelerators. Neither changes what is
806
+ detected — both are covered by `tests/test_scan_path_parity.py`, which runs
807
+ every available scan path against the plain-Python one and requires them to
808
+ agree:
809
+
810
+ - **`google-re2`** builds a prefilter over every pattern it can express (334 of
811
+ 345). One DFA pass per 1 KB window reports which patterns match anywhere in
812
+ it — typically 42 of 314 for a real document — and only those are then run.
813
+ Patterns RE2 cannot express, such as the lookbehind-based `SECRET` rules,
814
+ always run.
815
+
816
+ Asking the question per window matters: over a long document nearly every
817
+ pattern matches *somewhere*, so a whole-document prefilter stops filtering
818
+ (2.48× on a short record, decaying to 1.11× at 12 KB).
819
+
820
+ - **`pyahocorasick`** indexes patterns that begin with a literal and runs them
821
+ only near a prefix hit. Used when `google-re2` is unavailable.
822
+
823
+ Detection cost is dominated by how much prose surrounds the PII, not by
824
+ document size alone: the same engine runs 1.4× faster on dense records and
825
+ ~3× faster on prose-heavy documents where whole windows can be skipped.
644
826
 
645
- The optional `pyahocorasick` dependency replaces the regex scan with an
646
- Aho-Corasick automaton for keyword-heavy workloads, improving throughput on
647
- large batches.
827
+ The TypeScript SDK needs no such extra — V8's regex engine has literal
828
+ prefilters CPython lacks, and measured on the same documents it runs roughly 3×
829
+ faster than the accelerated Python path.
648
830
 
649
831
  ## License
650
832