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.
Files changed (96) hide show
  1. euredact-0.3.2/CHANGELOG.md +426 -0
  2. {euredact-0.3.0 → euredact-0.3.2}/PKG-INFO +229 -47
  3. {euredact-0.3.0 → euredact-0.3.2}/README.md +225 -46
  4. {euredact-0.3.0 → euredact-0.3.2}/pyproject.toml +4 -2
  5. {euredact-0.3.0 → 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.0 → euredact-0.3.2}/src/euredact/rules/countries/de.py +8 -1
  8. euredact-0.3.2/src/euredact/rules/de_districts.py +99 -0
  9. euredact-0.3.2/src/euredact/rules/engine.py +445 -0
  10. euredact-0.3.2/src/euredact/rules/evidence.py +207 -0
  11. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/matchers.py +171 -6
  12. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/registry.py +17 -8
  13. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/suppressors.py +99 -13
  14. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/sdk.py +63 -9
  15. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/types.py +56 -0
  16. {euredact-0.3.0 → euredact-0.3.2}/tests/countries/test_be.py +19 -2
  17. euredact-0.3.2/tests/eval_diff.py +161 -0
  18. euredact-0.3.2/tests/test_conformance.py +74 -0
  19. euredact-0.3.2/tests/test_country_inference.py +132 -0
  20. euredact-0.3.2/tests/test_document_context.py +127 -0
  21. euredact-0.3.2/tests/test_invariant_generation.py +141 -0
  22. euredact-0.3.2/tests/test_scan_path_parity.py +237 -0
  23. euredact-0.3.0/CHANGELOG.md +0 -181
  24. euredact-0.3.0/src/euredact/rules/engine.py +0 -220
  25. {euredact-0.3.0 → euredact-0.3.2}/.github/workflows/ci.yml +0 -0
  26. {euredact-0.3.0 → euredact-0.3.2}/.gitignore +0 -0
  27. {euredact-0.3.0 → euredact-0.3.2}/CONTRIBUTING.md +0 -0
  28. {euredact-0.3.0 → euredact-0.3.2}/LICENSE +0 -0
  29. {euredact-0.3.0 → euredact-0.3.2}/benchmarks/bench_engine.py +0 -0
  30. {euredact-0.3.0 → euredact-0.3.2}/examples/basic_usage.py +0 -0
  31. {euredact-0.3.0 → euredact-0.3.2}/examples/batch_processing.py +0 -0
  32. {euredact-0.3.0 → euredact-0.3.2}/examples/llm_preprocessing.py +0 -0
  33. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/cache.py +0 -0
  34. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/cloud/__init__.py +0 -0
  35. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/cloud/client.py +0 -0
  36. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/cloud/hasher.py +0 -0
  37. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/cloud/shuffler.py +0 -0
  38. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/coref/__init__.py +0 -0
  39. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/coref/models.py +0 -0
  40. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/coref/resolver.py +0 -0
  41. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/normalizer.py +0 -0
  42. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/py.typed +0 -0
  43. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/__init__.py +0 -0
  44. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/bic_registry.py +0 -0
  45. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/__init__.py +0 -0
  46. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/_base.py +0 -0
  47. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/_shared.py +0 -0
  48. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/at.py +0 -0
  49. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/be.py +0 -0
  50. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/bg.py +0 -0
  51. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/ch.py +0 -0
  52. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/cy.py +0 -0
  53. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/cz.py +0 -0
  54. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/dk.py +0 -0
  55. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/ee.py +0 -0
  56. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/el.py +0 -0
  57. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/es.py +0 -0
  58. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/fi.py +0 -0
  59. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/fr.py +0 -0
  60. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/hr.py +0 -0
  61. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/hu.py +0 -0
  62. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/ie.py +0 -0
  63. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/is_.py +0 -0
  64. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/it.py +0 -0
  65. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/lt.py +0 -0
  66. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/lu.py +0 -0
  67. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/lv.py +0 -0
  68. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/mt.py +0 -0
  69. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/nl.py +0 -0
  70. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/no.py +0 -0
  71. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/pl.py +0 -0
  72. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/pt.py +0 -0
  73. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/ro.py +0 -0
  74. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/se.py +0 -0
  75. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/si.py +0 -0
  76. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/sk.py +0 -0
  77. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/countries/uk.py +0 -0
  78. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/structural.py +0 -0
  79. {euredact-0.3.0 → euredact-0.3.2}/src/euredact/rules/validators.py +0 -0
  80. {euredact-0.3.0 → euredact-0.3.2}/tests/__init__.py +0 -0
  81. {euredact-0.3.0 → euredact-0.3.2}/tests/benchmark.py +0 -0
  82. {euredact-0.3.0 → euredact-0.3.2}/tests/conftest.py +0 -0
  83. {euredact-0.3.0 → euredact-0.3.2}/tests/countries/__init__.py +0 -0
  84. {euredact-0.3.0 → euredact-0.3.2}/tests/countries/test_nl.py +0 -0
  85. {euredact-0.3.0 → euredact-0.3.2}/tests/eval_full.py +0 -0
  86. {euredact-0.3.0 → euredact-0.3.2}/tests/test_api_contract.py +0 -0
  87. {euredact-0.3.0 → euredact-0.3.2}/tests/test_batch.py +0 -0
  88. {euredact-0.3.0 → euredact-0.3.2}/tests/test_bic.py +0 -0
  89. {euredact-0.3.0 → euredact-0.3.2}/tests/test_cache.py +0 -0
  90. {euredact-0.3.0 → euredact-0.3.2}/tests/test_engine.py +0 -0
  91. {euredact-0.3.0 → euredact-0.3.2}/tests/test_international.py +0 -0
  92. {euredact-0.3.0 → euredact-0.3.2}/tests/test_normalizer.py +0 -0
  93. {euredact-0.3.0 → euredact-0.3.2}/tests/test_postal_code.py +0 -0
  94. {euredact-0.3.0 → euredact-0.3.2}/tests/test_redact.py +0 -0
  95. {euredact-0.3.0 → euredact-0.3.2}/tests/test_structdata.py +0 -0
  96. {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