euredact 0.3.0__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.2/CHANGELOG.md +426 -0
- {euredact-0.3.0 → euredact-0.3.2}/PKG-INFO +229 -47
- {euredact-0.3.0 → euredact-0.3.2}/README.md +225 -46
- {euredact-0.3.0 → euredact-0.3.2}/pyproject.toml +4 -2
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/__init__.py +21 -1
- euredact-0.3.2/src/euredact/rules/context.py +85 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/de.py +8 -1
- euredact-0.3.2/src/euredact/rules/de_districts.py +99 -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.0 → euredact-0.3.2}/src/euredact/rules/matchers.py +171 -6
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/registry.py +17 -8
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/suppressors.py +99 -13
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/sdk.py +63 -9
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/types.py +56 -0
- {euredact-0.3.0 → 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_conformance.py +74 -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.0/CHANGELOG.md +0 -181
- euredact-0.3.0/src/euredact/rules/engine.py +0 -220
- {euredact-0.3.0 → euredact-0.3.2}/.github/workflows/ci.yml +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/.gitignore +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/CONTRIBUTING.md +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/LICENSE +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/benchmarks/bench_engine.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/examples/basic_usage.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/examples/batch_processing.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/examples/llm_preprocessing.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/cache.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/cloud/__init__.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/cloud/client.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/cloud/hasher.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/cloud/shuffler.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/coref/__init__.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/coref/models.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/coref/resolver.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/normalizer.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/py.typed +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/__init__.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/bic_registry.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/__init__.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/_base.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/_shared.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/at.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/be.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/bg.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/ch.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/cy.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/cz.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/dk.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/ee.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/el.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/es.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/fi.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/fr.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/hr.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/hu.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/ie.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/is_.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/it.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/lt.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/lu.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/lv.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/mt.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/nl.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/no.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/pl.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/pt.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/ro.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/se.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/si.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/sk.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/uk.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/structural.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/validators.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/tests/__init__.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/tests/benchmark.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/tests/conftest.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/tests/countries/__init__.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/tests/countries/test_nl.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/tests/eval_full.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/tests/test_api_contract.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/tests/test_batch.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/tests/test_bic.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/tests/test_cache.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/tests/test_engine.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/tests/test_international.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/tests/test_normalizer.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/tests/test_postal_code.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/tests/test_redact.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/tests/test_structdata.py +0 -0
- {euredact-0.3.0 → euredact-0.3.2}/tests/test_validators.py +0 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
# Changelog
|
|
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
|
+
|
|
196
|
+
## 0.3.1 (2026-07-27)
|
|
197
|
+
|
|
198
|
+
### Fixed
|
|
199
|
+
|
|
200
|
+
- **German plates are now validated against the district-code list.** The
|
|
201
|
+
pattern accepted any 1-3 uppercase letters as a city code, so document
|
|
202
|
+
references kept the plate shape: `REF-A12`, `SYS-B3`, `KTO-A1`, `JOB-C4`
|
|
203
|
+
were all detected. The ~790 *Unterscheidungszeichen* are a closed set fixed
|
|
204
|
+
by the Fahrzeug-Zulassungsverordnung, which makes membership a whitelist
|
|
205
|
+
rather than the open-ended blocklist of standards prefixes it replaces —
|
|
206
|
+
references of that shape now fail without needing to be enumerated.
|
|
207
|
+
|
|
208
|
+
Applied as a tier, not a filter: a code on the list emits with no cue, a
|
|
209
|
+
code absent from it still emits when a plate cue (`Kennzeichen`, `Kfz`,
|
|
210
|
+
`Fahrzeug`) is nearby, and only the combination of neither is rejected. A
|
|
211
|
+
code missing from the list therefore costs recall solely where there is no
|
|
212
|
+
other evidence. Verified on the corpus: 548 true positives, 0 false
|
|
213
|
+
positives, 0 missed.
|
|
214
|
+
|
|
215
|
+
The standards guard is kept alongside it, because `DIN` is both a standards
|
|
216
|
+
body and the district code for Dinslaken — a whitelist alone cannot tell
|
|
217
|
+
`DIN A4` from a Dinslaken plate.
|
|
218
|
+
|
|
219
|
+
- **German `LICENSE_PLATE` matched standards codes and document references.**
|
|
220
|
+
With `countries=["DE"]`, `ICD-10`, `ISO-9001`, `EN-1090`, `DIN-4102`,
|
|
221
|
+
`RFC-2822`, `COVID-19`, `POL-2025` and similar were replaced with
|
|
222
|
+
`[LICENSE_PLATE]`, corrupting the returned text —
|
|
223
|
+
`Diagnose: ... ([LICENSE_PLATE]: E11.9)`. Measured at 98 occurrences across
|
|
224
|
+
68 documents in a 5,010-document corpus, and the same token shape occurs
|
|
225
|
+
14,563 times corpus-wide, so the blast radius grows sharply if `DE` is passed
|
|
226
|
+
for a cross-border document.
|
|
227
|
+
|
|
228
|
+
The cause was not that the letter group after the hyphen was optional — it
|
|
229
|
+
never was. The *first* separator was optional, so a contiguous letter run
|
|
230
|
+
split across both groups and the hyphen was consumed as the second
|
|
231
|
+
separator: `ICD-10` parsed as city `IC` + letters `D` + `-` + `10`. The
|
|
232
|
+
separator after the city code is now mandatory, which is correct for a real
|
|
233
|
+
plate (`B-AB 1234`, `M-XY 99`) since that is where the seal sits.
|
|
234
|
+
|
|
235
|
+
A standards-prefix guard (`ICD`, `ISO`, `DIN`, `IEC`, `RFC`, `DSM`, `ATC`,
|
|
236
|
+
`MDR`) covers the residue that is genuinely plate-shaped, such as `ATC-N06`.
|
|
237
|
+
It is gated on the absence of a plate cue, so `Kennzeichen ATC-N 06` is still
|
|
238
|
+
detected.
|
|
239
|
+
|
|
240
|
+
### Added
|
|
241
|
+
|
|
242
|
+
- **Shared conformance suite** (`conformance/vectors.json`). Language-neutral
|
|
243
|
+
input/expectation pairs run by both SDKs, so a behavioural difference between
|
|
244
|
+
Python and TypeScript fails a test rather than surfacing later as a corpus
|
|
245
|
+
diff. Verified to work: reverting the plate fix in one engine alone fails
|
|
246
|
+
five shared vectors.
|
|
247
|
+
|
|
248
|
+
## 0.3.0 (2026-07-27)
|
|
249
|
+
|
|
250
|
+
### Measured effect
|
|
251
|
+
|
|
252
|
+
Full evaluation over 152,300 generated records (`tests/eval_full.py`), measured
|
|
253
|
+
against the same engine with the detection changes below reverted, on identical
|
|
254
|
+
ground truth:
|
|
255
|
+
|
|
256
|
+
| | 0.2.0 | 0.3.0 |
|
|
257
|
+
|---|---|---|
|
|
258
|
+
| Recall, `countries=[...]` supplied | 98.11% | **98.28%** |
|
|
259
|
+
| Precision, `countries=[...]` supplied | 98.21% | **98.97%** |
|
|
260
|
+
| False positives | 12,468 | **7,160** |
|
|
261
|
+
| Recall / precision, blind (no `countries`) | — | 94.39% / 95.22% |
|
|
262
|
+
|
|
263
|
+
Net: recall up, false positives down 43%. DOB is excluded (40.6%; deferred to
|
|
264
|
+
the LLM tier by design).
|
|
265
|
+
|
|
266
|
+
The Python and TypeScript engines now produce **byte-identical redacted output
|
|
267
|
+
on 2,500 corpus documents** and agree to within 0.01 points on recall.
|
|
268
|
+
|
|
269
|
+
Two figures published previously do not reproduce and should not be reused:
|
|
270
|
+
"99.1% recall / 147K records" (the set is 152,300 records and the engine
|
|
271
|
+
measured 98.11% before these changes), and "0.02 ms per page" — that is the
|
|
272
|
+
cache-hit path; uncached, a 2,000-character page takes **4.6 ms** in Python and
|
|
273
|
+
**0.28 ms** in the TypeScript engine.
|
|
274
|
+
|
|
275
|
+
### Breaking
|
|
276
|
+
|
|
277
|
+
- **`IBAN` is renamed to the canonical `BANK_ACCOUNT`.** The engine emitted a
|
|
278
|
+
legacy type name; the canon (`config/entity_types.json` → `legacy_aliases`)
|
|
279
|
+
defines `"IBAN": "BANK_ACCOUNT"`. `detection.entity_type.value` is now
|
|
280
|
+
`"BANK_ACCOUNT"` and the placeholder written into redacted text is
|
|
281
|
+
`[BANK_ACCOUNT]`, not `[IBAN]`.
|
|
282
|
+
|
|
283
|
+
`EntityType.IBAN` is kept as an **alias** of `EntityType.BANK_ACCOUNT`, and
|
|
284
|
+
`EntityType("IBAN")` still resolves, so code referring to the member keeps
|
|
285
|
+
working. Code matching on the *string* `"IBAN"` — or on the `[IBAN]`
|
|
286
|
+
placeholder — must be updated. `euredact.types.LEGACY_TYPE_ALIASES`
|
|
287
|
+
publishes the mapping.
|
|
288
|
+
|
|
289
|
+
An audit against the canon found this to be the only mismatch: every other
|
|
290
|
+
emitted type name is canonical (`NAME` and `OTHER` are cloud-tier internals
|
|
291
|
+
with no rules-engine emission).
|
|
292
|
+
|
|
293
|
+
### Fixed
|
|
294
|
+
|
|
295
|
+
- **BIC no longer matches ordinary ALL-CAPS words.** The rule was shape-only:
|
|
296
|
+
any 8- or 11-character uppercase run was treated as a BIC, so section
|
|
297
|
+
headings and shouted words (`DRINGEND`, `HOSPITAL`, `GEGEVENS`,
|
|
298
|
+
`MAANDELIJKS`) were masked. Measured on a 5,010-document corpus, 78% of
|
|
299
|
+
`BIC` detections were false positives. Because a masked span is replaced in
|
|
300
|
+
the document body, this destroyed text rather than merely mislabelling it —
|
|
301
|
+
`QUEEN ELIZABETH [BIC] BIRMINGHAM` left the hospital name unlabelable.
|
|
302
|
+
|
|
303
|
+
Structural validation alone is not enough: characters 5-6 of ordinary words
|
|
304
|
+
are frequently valid ISO 3166 country codes (`DRINGEND` -> `GE`,
|
|
305
|
+
`HOSPITAL` -> `IT`), and BIC is the only bank identifier here with no check
|
|
306
|
+
digit. Detection is now tiered — see "BIC detection" in the README.
|
|
307
|
+
|
|
308
|
+
- **Bare 4-digit postal codes no longer shred longer identifiers.** Any bare
|
|
309
|
+
digit run was claimable as a postal code, including digits inside a longer
|
|
310
|
+
number: `SV-Nummer: [POSTAL_CODE] 040390` cut an Austrian social security
|
|
311
|
+
number in half, and `+43 664 [POSTAL_CODE] 907` took digits belonging to a
|
|
312
|
+
phone number. A digits-only match is now rejected when it is directly
|
|
313
|
+
adjacent to another digit; when `. - / _` **joins it to another digit
|
|
314
|
+
group** (`0456.2398.71-02`); when a further digit group follows on the same
|
|
315
|
+
line; when an identifier label introduces it (`DiNr.`, `Policen-Nr.`, `N°`);
|
|
316
|
+
or when it follows an international dialling prefix.
|
|
317
|
+
|
|
318
|
+
Note the punctuation rule is deliberately narrow: the separator only counts
|
|
319
|
+
when a digit sits on the far side of it. Treating a trailing `.` as a
|
|
320
|
+
separator rejects every postal code that ends a sentence
|
|
321
|
+
(`Domicilio: Palma, 13867. Pagos a ...`) — measured at 18,977 lost true
|
|
322
|
+
positives, POSTAL_CODE recall 96.2% -> 61.0%, on the 152,300-record set.
|
|
323
|
+
|
|
324
|
+
- **International phone numbers were missed in 11 countries.** Each country's
|
|
325
|
+
international pattern hard-coded a single grouping, so `+43 664 8213 907`
|
|
326
|
+
was invisible to the Austrian pattern (which expects an unbroken subscriber
|
|
327
|
+
number) while `+32 498 22 67 31` matched fine. 16 of 59 realistic formats
|
|
328
|
+
went undetected, including every `(0)` trunk-prefix form. A missed phone
|
|
329
|
+
number in a redaction product is leaked PII.
|
|
330
|
+
|
|
331
|
+
This was also the **root cause of the postal-code split**: with no phone
|
|
332
|
+
match to claim the span, the bare-4-digit rule took `8213` out of the middle
|
|
333
|
+
of the number. A country-independent E.164 pattern now runs alongside the
|
|
334
|
+
per-country ones — a leading `+` is self-identifying, so it is not gated.
|
|
335
|
+
|
|
336
|
+
- **IBANs were gated by `countries=[...]`.** `BE68 5390 0754 7034` was
|
|
337
|
+
detected with `countries=["BE"]` and missed with `countries=["AT"]`, so
|
|
338
|
+
every cross-border document leaked the account number. An IBAN carries its
|
|
339
|
+
own country code and a mod-97 checksum; it is now detected on structure and
|
|
340
|
+
checksum alone, whatever the caller requests. `countries` still prioritises,
|
|
341
|
+
never gates.
|
|
342
|
+
|
|
343
|
+
- **`countries=["GB"]` raised `ValueError`.** The registry uses the EU/VAT
|
|
344
|
+
spellings (`UK`, `EL`) while the documented contract is ISO 3166-1 alpha-2
|
|
345
|
+
(`GB`, `GR`), so a caller passing correct ISO codes crashed. Both spellings
|
|
346
|
+
are now accepted and are exactly equivalent.
|
|
347
|
+
|
|
348
|
+
An unrecognised code no longer raises at all: it emits an
|
|
349
|
+
`UnknownCountryWarning` and continues with the shared, country-independent
|
|
350
|
+
patterns. Throwing invited callers to wrap the call in `try/except` and skip
|
|
351
|
+
redaction — failing open, with unredacted PII.
|
|
352
|
+
|
|
353
|
+
- **Austrian national numbers with a short area code were missed.**
|
|
354
|
+
`01 53460 2215` (Vienna) did not match, because the national pattern
|
|
355
|
+
requires 3-4 digits after the trunk `0`. A grouped variant now covers short
|
|
356
|
+
area codes; both separators are mandatory so date fragments like `01 2025`
|
|
357
|
+
stay out.
|
|
358
|
+
|
|
359
|
+
- **Space-separated bank codes were missed** — `RZBA AT WW`, `NICA BE BB`.
|
|
360
|
+
Added as a separate pattern requiring a space between every group rather
|
|
361
|
+
than making spaces optional in the existing one, which would have let
|
|
362
|
+
`GEBABEBB KBC` match as a single 11-character code and claim the following
|
|
363
|
+
word. Only literal spaces are allowed, so a match cannot span a line break.
|
|
364
|
+
|
|
365
|
+
- **Place names beginning "St." suppressed the postal code before them.**
|
|
366
|
+
The unit filter's bare `st` (stuks/pieces) matched the `St.` in
|
|
367
|
+
`8386 St. Gallen` and `9600 St. Paul's Bay`. It now requires that `st` not be
|
|
368
|
+
followed by `.` and a capital.
|
|
369
|
+
|
|
370
|
+
- **Country-prefixed postal codes were read as subtraction.** `A-1010 Wien`,
|
|
371
|
+
`B-2000 Antwerpen` and `D-10115 Berlin` were discarded because the hyphen
|
|
372
|
+
looked like a minus sign to the maths filter. Genuine arithmetic
|
|
373
|
+
(`Ergebnis = 1010`, `Rabatt -2000 EUR`) is still suppressed.
|
|
374
|
+
|
|
375
|
+
- **Residence phrasing now counts as postal context.** `wonende te 2000
|
|
376
|
+
Antwerpen` and `domicilié à 1000 Bruxelles` were dropped by the
|
|
377
|
+
year-as-postal filter, which only recognised explicit address words.
|
|
378
|
+
|
|
379
|
+
### Changed
|
|
380
|
+
|
|
381
|
+
- `validate_bic()` now requires a real ISO 3166-1 alpha-2 code at positions
|
|
382
|
+
5-6. `ISO_3166_ALPHA2` is exported from `euredact.rules.validators`.
|
|
383
|
+
- The IBAN length table is hoisted to `euredact.rules.validators.IBAN_LENGTHS`
|
|
384
|
+
and drives the country-independent IBAN pattern, which pins each country's
|
|
385
|
+
exact length so a match cannot absorb a following word.
|
|
386
|
+
- Some spans are now **relabelled** rather than newly masked, because a
|
|
387
|
+
checksum-validated detector reclaims them from a weaker one: `RO57` at the
|
|
388
|
+
head of an IBAN is no longer a Romanian `VAT`, and `+31 621036924` is a
|
|
389
|
+
`PHONE` rather than a Dutch `NATIONAL_ID`. Verified on 28,852 documents: no
|
|
390
|
+
character that was masked before is unmasked now.
|
|
391
|
+
- POSTAL_CODE now resolves **last** in overlap deduplication, so it can only
|
|
392
|
+
claim spans no structured detector (PHONE, SSN, NATIONAL_ID, IBAN, VAT,
|
|
393
|
+
CREDIT_CARD...) wants, regardless of span length.
|
|
394
|
+
|
|
395
|
+
### Added
|
|
396
|
+
|
|
397
|
+
- `euredact.set_bic_registry()` — install a BIC registry consulted ahead of
|
|
398
|
+
the bundled seed prefixes. Accepts a membership callable, an iterable of
|
|
399
|
+
BICs, a path to a newline-delimited file, or `None` to remove. Entries may
|
|
400
|
+
be full BIC8/BIC11 codes or bare BIC6 prefixes.
|
|
401
|
+
- A bundled seed list of BIC6 institution+country prefixes for major European
|
|
402
|
+
banks, compiled from publicly published bank data. No licensed directory is
|
|
403
|
+
bundled.
|
|
404
|
+
|
|
405
|
+
## 0.1.0 (2026-03-30)
|
|
406
|
+
|
|
407
|
+
Initial release of the EuRedact rule engine.
|
|
408
|
+
|
|
409
|
+
### Features
|
|
410
|
+
|
|
411
|
+
- **31 countries**: All EU-27 member states plus UK, Switzerland, Iceland, and Norway
|
|
412
|
+
- **20+ PII entity types**: National IDs, IBANs, phone numbers, email, VAT, license plates, VIN, credit cards, BIC/SWIFT, IMEI, GPS coordinates, UUIDs, social handles, MAC/IP addresses
|
|
413
|
+
- **Checksum validation**: IBAN (mod-97), Luhn, and 20+ country-specific national ID validators
|
|
414
|
+
- **Two-pass detection**: Liberal pattern matching followed by suppression filters for false positive reduction
|
|
415
|
+
- **Context-aware detection**: Keyword proximity checks and structural detection (JSON field names, CSV headers)
|
|
416
|
+
- **Batch processing**: `redact_batch()`, `redact_iter()`, `aredact_batch()` for bulk workloads
|
|
417
|
+
- **True async**: `aredact()` offloads to thread pool, non-blocking for async frameworks
|
|
418
|
+
- **Referential integrity**: Consistent label mapping within a session (`referential_integrity=True`)
|
|
419
|
+
- **Aho-Corasick acceleration**: Optional `pyahocorasick` for faster pattern scanning
|
|
420
|
+
- **Zero required dependencies**: Pure Python, works with `pip install euredact`
|
|
421
|
+
|
|
422
|
+
### Performance
|
|
423
|
+
|
|
424
|
+
- Sub-millisecond per page (~0.5ms for typical documents)
|
|
425
|
+
- ~2,000 records/second on mixed workloads
|
|
426
|
+
- 99.1% recall, 99.3% precision on 147K-record evaluation across all 31 countries
|