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.
- {euredact-0.3.1 → euredact-0.3.2}/CHANGELOG.md +193 -0
- {euredact-0.3.1 → euredact-0.3.2}/PKG-INFO +229 -47
- {euredact-0.3.1 → euredact-0.3.2}/README.md +225 -46
- {euredact-0.3.1 → euredact-0.3.2}/pyproject.toml +4 -2
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/__init__.py +21 -1
- euredact-0.3.2/src/euredact/rules/context.py +85 -0
- euredact-0.3.2/src/euredact/rules/engine.py +445 -0
- euredact-0.3.2/src/euredact/rules/evidence.py +207 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/matchers.py +171 -6
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/registry.py +17 -8
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/suppressors.py +48 -12
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/sdk.py +63 -9
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/types.py +56 -0
- {euredact-0.3.1 → euredact-0.3.2}/tests/countries/test_be.py +19 -2
- euredact-0.3.2/tests/eval_diff.py +161 -0
- euredact-0.3.2/tests/test_country_inference.py +132 -0
- euredact-0.3.2/tests/test_document_context.py +127 -0
- euredact-0.3.2/tests/test_invariant_generation.py +141 -0
- euredact-0.3.2/tests/test_scan_path_parity.py +237 -0
- euredact-0.3.1/src/euredact/rules/engine.py +0 -220
- {euredact-0.3.1 → euredact-0.3.2}/.github/workflows/ci.yml +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/.gitignore +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/CONTRIBUTING.md +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/LICENSE +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/benchmarks/bench_engine.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/examples/basic_usage.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/examples/batch_processing.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/examples/llm_preprocessing.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/cache.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/cloud/__init__.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/cloud/client.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/cloud/hasher.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/cloud/shuffler.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/coref/__init__.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/coref/models.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/coref/resolver.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/normalizer.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/py.typed +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/__init__.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/bic_registry.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/__init__.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/_base.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/_shared.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/at.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/be.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/bg.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/ch.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/cy.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/cz.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/de.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/dk.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/ee.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/el.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/es.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/fi.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/fr.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/hr.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/hu.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/ie.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/is_.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/it.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/lt.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/lu.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/lv.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/mt.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/nl.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/no.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/pl.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/pt.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/ro.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/se.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/si.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/sk.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/countries/uk.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/de_districts.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/structural.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/src/euredact/rules/validators.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/tests/__init__.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/tests/benchmark.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/tests/conftest.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/tests/countries/__init__.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/tests/countries/test_nl.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/tests/eval_full.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/tests/test_api_contract.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/tests/test_batch.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/tests/test_bic.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/tests/test_cache.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/tests/test_conformance.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/tests/test_engine.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/tests/test_international.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/tests/test_normalizer.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/tests/test_postal_code.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/tests/test_redact.py +0 -0
- {euredact-0.3.1 → euredact-0.3.2}/tests/test_structdata.py +0 -0
- {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.
|
|
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
|
|
84
|
-
patterns
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
- **
|
|
92
|
-
|
|
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
|
-
|
|
317
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
620
|
+
euredact.redact("Telefoon 0612345678")
|
|
621
|
+
# -> NATIONAL_ID (DK) <- a valid Danish CPR, and nothing here says otherwise
|
|
509
622
|
```
|
|
510
623
|
|
|
511
|
-
|
|
512
|
-
like IBAN, email, credit card, and secrets). Benefits:
|
|
624
|
+
Pass a `DocumentContext` to carry evidence forward across chunks:
|
|
513
625
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
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
|
-
|
|
521
|
-
|
|
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
|
|
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
|
|
688
|
+
| references). Failed checksums demote same-type
|
|
689
|
+
| matches rather than deleting them
|
|
558
690
|
v
|
|
559
|
-
[Deduplication] -- Priority-aware
|
|
560
|
-
|
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
3
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
|
643
|
-
|
|
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
|
|
646
|
-
|
|
647
|
-
|
|
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
|
|