euredact 0.3.2__tar.gz → 0.3.4__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 (97) hide show
  1. {euredact-0.3.2 → euredact-0.3.4}/CHANGELOG.md +193 -3
  2. {euredact-0.3.2 → euredact-0.3.4}/PKG-INFO +12 -6
  3. {euredact-0.3.2 → euredact-0.3.4}/README.md +11 -5
  4. {euredact-0.3.2 → euredact-0.3.4}/pyproject.toml +1 -1
  5. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/__init__.py +1 -1
  6. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/_shared.py +5 -1
  7. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/de.py +8 -0
  8. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/engine.py +139 -24
  9. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/matchers.py +104 -2
  10. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/sdk.py +7 -2
  11. {euredact-0.3.2 → euredact-0.3.4}/tests/eval_full.py +7 -0
  12. euredact-0.3.4/tests/metrics.py +268 -0
  13. euredact-0.3.4/tests/sweep.py +234 -0
  14. {euredact-0.3.2 → euredact-0.3.4}/tests/test_api_contract.py +37 -0
  15. euredact-0.3.4/tests/test_structural_invariants.py +189 -0
  16. {euredact-0.3.2 → euredact-0.3.4}/.github/workflows/ci.yml +0 -0
  17. {euredact-0.3.2 → euredact-0.3.4}/.gitignore +0 -0
  18. {euredact-0.3.2 → euredact-0.3.4}/CONTRIBUTING.md +0 -0
  19. {euredact-0.3.2 → euredact-0.3.4}/LICENSE +0 -0
  20. {euredact-0.3.2 → euredact-0.3.4}/benchmarks/bench_engine.py +0 -0
  21. {euredact-0.3.2 → euredact-0.3.4}/examples/basic_usage.py +0 -0
  22. {euredact-0.3.2 → euredact-0.3.4}/examples/batch_processing.py +0 -0
  23. {euredact-0.3.2 → euredact-0.3.4}/examples/llm_preprocessing.py +0 -0
  24. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/cache.py +0 -0
  25. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/cloud/__init__.py +0 -0
  26. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/cloud/client.py +0 -0
  27. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/cloud/hasher.py +0 -0
  28. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/cloud/shuffler.py +0 -0
  29. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/coref/__init__.py +0 -0
  30. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/coref/models.py +0 -0
  31. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/coref/resolver.py +0 -0
  32. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/normalizer.py +0 -0
  33. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/py.typed +0 -0
  34. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/__init__.py +0 -0
  35. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/bic_registry.py +0 -0
  36. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/context.py +0 -0
  37. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/__init__.py +0 -0
  38. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/_base.py +0 -0
  39. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/at.py +0 -0
  40. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/be.py +0 -0
  41. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/bg.py +0 -0
  42. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/ch.py +0 -0
  43. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/cy.py +0 -0
  44. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/cz.py +0 -0
  45. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/dk.py +0 -0
  46. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/ee.py +0 -0
  47. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/el.py +0 -0
  48. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/es.py +0 -0
  49. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/fi.py +0 -0
  50. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/fr.py +0 -0
  51. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/hr.py +0 -0
  52. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/hu.py +0 -0
  53. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/ie.py +0 -0
  54. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/is_.py +0 -0
  55. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/it.py +0 -0
  56. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/lt.py +0 -0
  57. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/lu.py +0 -0
  58. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/lv.py +0 -0
  59. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/mt.py +0 -0
  60. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/nl.py +0 -0
  61. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/no.py +0 -0
  62. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/pl.py +0 -0
  63. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/pt.py +0 -0
  64. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/ro.py +0 -0
  65. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/se.py +0 -0
  66. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/si.py +0 -0
  67. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/sk.py +0 -0
  68. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/uk.py +0 -0
  69. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/de_districts.py +0 -0
  70. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/evidence.py +0 -0
  71. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/registry.py +0 -0
  72. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/structural.py +0 -0
  73. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/suppressors.py +0 -0
  74. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/validators.py +0 -0
  75. {euredact-0.3.2 → euredact-0.3.4}/src/euredact/types.py +0 -0
  76. {euredact-0.3.2 → euredact-0.3.4}/tests/__init__.py +0 -0
  77. {euredact-0.3.2 → euredact-0.3.4}/tests/benchmark.py +0 -0
  78. {euredact-0.3.2 → euredact-0.3.4}/tests/conftest.py +0 -0
  79. {euredact-0.3.2 → euredact-0.3.4}/tests/countries/__init__.py +0 -0
  80. {euredact-0.3.2 → euredact-0.3.4}/tests/countries/test_be.py +0 -0
  81. {euredact-0.3.2 → euredact-0.3.4}/tests/countries/test_nl.py +0 -0
  82. {euredact-0.3.2 → euredact-0.3.4}/tests/eval_diff.py +0 -0
  83. {euredact-0.3.2 → euredact-0.3.4}/tests/test_batch.py +0 -0
  84. {euredact-0.3.2 → euredact-0.3.4}/tests/test_bic.py +0 -0
  85. {euredact-0.3.2 → euredact-0.3.4}/tests/test_cache.py +0 -0
  86. {euredact-0.3.2 → euredact-0.3.4}/tests/test_conformance.py +0 -0
  87. {euredact-0.3.2 → euredact-0.3.4}/tests/test_country_inference.py +0 -0
  88. {euredact-0.3.2 → euredact-0.3.4}/tests/test_document_context.py +0 -0
  89. {euredact-0.3.2 → euredact-0.3.4}/tests/test_engine.py +0 -0
  90. {euredact-0.3.2 → euredact-0.3.4}/tests/test_international.py +0 -0
  91. {euredact-0.3.2 → euredact-0.3.4}/tests/test_invariant_generation.py +0 -0
  92. {euredact-0.3.2 → euredact-0.3.4}/tests/test_normalizer.py +0 -0
  93. {euredact-0.3.2 → euredact-0.3.4}/tests/test_postal_code.py +0 -0
  94. {euredact-0.3.2 → euredact-0.3.4}/tests/test_redact.py +0 -0
  95. {euredact-0.3.2 → euredact-0.3.4}/tests/test_scan_path_parity.py +0 -0
  96. {euredact-0.3.2 → euredact-0.3.4}/tests/test_structdata.py +0 -0
  97. {euredact-0.3.2 → euredact-0.3.4}/tests/test_validators.py +0 -0
@@ -1,5 +1,190 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.4 (2026-07-30)
4
+
5
+ ### Fixed
6
+
7
+ - **Recovered the latency 0.3.3 gave away, without giving back its recall.**
8
+ 0.3.3 made `\b` catch identifiers glued to a non-ASCII letter by rewriting it
9
+ to a three-branch lookaround union on all 303 patterns. Correct, but it cost
10
+ **1.8× on short records and 2.8× on real documents**, and its ASCII branch
11
+ manufactured boundaries *inside* words at non-ASCII letters — truncating
12
+ `@Ciarán` to `@Ciar` and typing `FICIAIRES`, a fragment of `BÉNÉFICIAIRES`,
13
+ as a national ID.
14
+
15
+ The boundary is now chosen per occurrence. Next to a digit, the ASCII reading
16
+ alone is *exactly* the union — a Unicode letter is never an ASCII word
17
+ character, so the ASCII branch already succeeds everywhere the Unicode branch
18
+ does — and one lookaround replaces the alternation. Everywhere else plain `\b`
19
+ stays, which is what a Greek e-mail local part needs.
20
+
21
+ | | 186 chars | 3,424 chars |
22
+ |---|---:|---:|
23
+ | pure Python | 3.0 ms → **907 µs** | 58 ms → **13.6 ms** |
24
+ | with `pyahocorasick` | 2.3 ms → **775 µs** | 42 ms → **10.9 ms** |
25
+ | with `[fast]` | 878 µs → **516 µs** | 14.4 ms → **5.3 ms** |
26
+
27
+ Within 4% of the pre-0.3.3 baseline, with all five glued-identifier cases and
28
+ both Unicode e-mail cases still detected. Per-type precision, recall and F1
29
+ are **byte-identical** across all 152,300 corpus documents.
30
+
31
+ - **Social handles containing a non-ASCII letter were masked only up to it.**
32
+ `@Ciarán` came back as `@Ciar`, leaving `án` in the clear, because the pattern's
33
+ character class was ASCII-only and the old boundary let the match stop mid-word.
34
+ The class is now Unicode-aware, so the whole handle is masked. The TypeScript
35
+ SDK already had this right; the two now agree.
36
+
37
+ - **German social-security numbers were unredacted whenever the document used
38
+ the abbreviation `SVNR`.** The pattern and its context gate were both correct;
39
+ the cue list simply had `SV-Nummer` and `Sozialversicherung` and not the short
40
+ form people actually type.
41
+
42
+ Measured across all 152,300 corpus documents, this was **every** German
43
+ social-security number the rule missed — 204 of them, in the clear:
44
+
45
+ | | recall before | after |
46
+ |---|---:|---:|
47
+ | `SOCIAL_SECURITY`, country hints | 84.16% | **100.00%** |
48
+ | `SOCIAL_SECURITY`, blind | 83.77% | **99.61%** |
49
+
50
+ No false-positive cost: 0 before, 0 after, and no other entity type moved.
51
+ `SV-Nr` and `RVNR` are added alongside it — the same gap for the other two
52
+ spellings, and the health-insurance rule already carries `KVNR` next to
53
+ `KV-Nummer` for exactly this reason.
54
+
55
+ Found by per-type measurement rather than a report. `SOCIAL_SECURITY` was the
56
+ only type below 90% recall that was not a known cloud-tier case, which is
57
+ what made it worth chasing.
58
+
59
+ ### Added
60
+
61
+ - **`tests/metrics.py`** — per-entity-type precision, recall, F1 and
62
+ false-positive counts over the corpus, both detection modes, with `--csv`.
63
+ `eval_full.py` renders an HTML report; this is the plain-text counterpart with
64
+ its matching rules stated in the module docstring, so a figure quoted from it
65
+ can be reproduced and argued with.
66
+
67
+ Writing it surfaced two flaws in the shared evaluation config, both of which
68
+ had been quietly distorting published numbers:
69
+
70
+ - `HEALTH_ID` had no entry in `CATEGORY_MAP`, so its 252 labels could never be
71
+ matched — counted as 252 misses *and* charging the engine's correct
72
+ `HEALTH_INSURANCE` detections as 252 false positives. `SECRET` was also
73
+ unmapped and worked only by accident, its fallback happening to be a real
74
+ entity type. Both are mapped now, which slightly raises measured recall.
75
+ - False positives were attributed to a category that may carry no labels at
76
+ all: every `BIC` detection was charged to a zero-support `BIC` row while the
77
+ corpus labels them `SWIFT_BIC`, splitting one type's precision across two
78
+ rows and reporting it as 0.00%.
79
+
80
+ ## 0.3.3 (2026-07-29)
81
+
82
+ ### Fixed
83
+
84
+ - **A shorter validated match could re-cut a longer one and leak the
85
+ remainder.** Under `countries` `["NL"]` the Dutch national-ID pattern
86
+ validates on `194.232.104`, outranked the IP address `194.232.104.77`, and
87
+ left `.77` in the output. The TypeScript SDK did the same with no country
88
+ declared at all.
89
+
90
+ Span length now outranks every other signal, including validation. Priority
91
+ still decides between candidates of *equal* length, which is where it was
92
+ always meant to apply — a valid IBAN beats a coincidental match on the same
93
+ characters. Masking more than necessary is recoverable; masking less is not.
94
+
95
+ - **`countries` could change which spans were found, not just how they were
96
+ labelled.** Promotion depends on whether the document corroborates a
97
+ country, so the declared country decided which of two overlapping candidates
98
+ won. Two cases were found, the second only after fixing the first.
99
+
100
+ Ordering between different spans is now country-blind by construction —
101
+ length, then the span's structural tier, then leftmost — and `(length, start)`
102
+ identifies a span uniquely, so the country-aware signals can only choose the
103
+ **label within a span**, never its extent. The invariant holds by design
104
+ rather than by test.
105
+
106
+ - **A bare string for `countries` silently discarded every detection.**
107
+ `countries="NL"` is iterable, so it was walked into the codes `"N"` and `"L"`.
108
+ Neither resolves, so nothing was declared — and every detection carrying a
109
+ country came back flagged out of scope, while the redacted text still looked
110
+ correct. The README tells callers to filter on exactly that field, so a
111
+ documented pipeline kept none of them. It failed toward "no PII here", from a
112
+ one-character typo.
113
+
114
+ Both SDKs now raise `TypeError` naming the argument and showing the fix, on
115
+ every entry point. A wrong *code* still only warns: raising there would invite
116
+ callers to wrap redaction in try/except and skip it. A wrong *type* is a
117
+ programming error with no correct interpretation to fall back on.
118
+
119
+ - **The generic phone pattern claimed fragments of rejected identifiers.**
120
+ `Rijksregisternummer: 85.03.19-284.73` was reported as a `PHONE`. The Belgian
121
+ national-number pattern matched the whole value and failed its checksum; the
122
+ separator-tolerant phone pattern then took characters 3-14 of it.
123
+
124
+ A candidate covering only *part* of a rejected identifier is a fragment of
125
+ that identifier, not a separate entity, and is now dropped. An equal span is
126
+ left alone: that is two schemes competing for the whole value, and demoting
127
+ those is what previously handed a Swedish phone number to a Danish CPR.
128
+
129
+ Fragment detection ignores the declared country by design. It removes
130
+ candidates, so making it country-aware would let `countries` change which
131
+ spans are found — the invariant in `test_invariant_generation.py`.
132
+
133
+ No measurable cost: recall, precision and type-correct rate are unchanged on
134
+ all 152,300 corpus documents.
135
+
136
+ - **Identifiers glued to a non-ASCII letter were missed.** Python's `\b` is
137
+ Unicode-aware, so it saw no boundary between a Cyrillic or accented letter
138
+ and a digit: `ЕГН7523169263`, `PESELŁ44051401359`, `čísloř7401011233`,
139
+ `Steuerß12345678911` and `kodasž38605181232` were all detected by the
140
+ TypeScript SDK and silently missed by this one.
141
+
142
+ `\b` is now compiled to the *union* of the Unicode and ASCII readings.
143
+ Swapping to ASCII-only — the obvious fix — trades one silent miss for
144
+ another: it drops Greek and Cyrillic e-mail local parts, which are
145
+ deliberately supported. `re.ASCII` is unusable for the same reason, since it
146
+ would also narrow `\w`.
147
+
148
+ **This is expensive.** The rewritten boundary is a lookaround union rather
149
+ than a single opcode, and every pattern carries it: **1.8× on short records
150
+ and 2.8× on real documents** (497 µs → 878 µs, and 5.2 ms → 14.4 ms, with
151
+ `[fast]` installed). An earlier revision of this entry said "about 20%" —
152
+ that was measured on 186-character synthetic records only and extrapolated,
153
+ wrongly, to real documents.
154
+
155
+ `[fast]` is close to required as a result: the RE2 prefilter absorbs most of
156
+ the cost by not running patterns that cannot match. Accuracy on the corpus is
157
+ unchanged.
158
+
159
+ Recovering the speed without giving back the five silent misses is open — the
160
+ likely route is to ASCII-ise only those boundaries that abut a digit or an
161
+ ASCII-only class, and leave plain `\b` where the neighbouring element is
162
+ Unicode-capable, rather than applying the union to all 303 patterns.
163
+
164
+ ### Added
165
+
166
+ - **`make check` and `make verify`.** One entry point for every check.
167
+ `make check` is what CI runs; `make verify` adds the corpus checks CI cannot
168
+ run, because the corpus lives outside the repository.
169
+
170
+ - `tests/sweep.py` — structural properties over ~187,000 documents: offsets
171
+ index the text, spans do not overlap, detection is deterministic, the cache
172
+ is transparent, and no country argument changes which spans are found. Both
173
+ ranking bugs above were found here; the twenty-document version that runs in
174
+ CI showed nothing.
175
+ - `scripts/parity.py` — do both SDKs mask the same *characters*, over whole
176
+ corpora. Conformance vectors pin named cases; this is the broad counterpart,
177
+ and it is how a 19,014-character gap was found that no vector showed.
178
+
179
+
180
+ ### Known issues
181
+
182
+ - A checksum-invalid identifier occupying a span no other detector claims can
183
+ still be reported under the wrong type — `7401011234` with `countries` `["CZ"]`
184
+ is masked as a Romanian `PHONE` at `countryConfidence` 0. The span *is*
185
+ redacted; only the label is wrong. The fragment rule above does not apply
186
+ because the spans are equal rather than nested.
187
+
3
188
  ## 0.3.2 (2026-07-29)
4
189
 
5
190
  ### Changed
@@ -76,9 +261,14 @@
76
261
  Identical digits: `0612345678` is both a valid Dutch mobile number and a
77
262
  valid Danish CPR. Only the document distinguishes them.
78
263
 
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.
264
+ On all 152,300 documents, blind detection (no `countries=`) improved from
265
+ **95.6% to 98.3% precision** and from **96.10% to 98.84% type-correct**,
266
+ closing the precision gap to hinted detection from 3.4 points to 0.3.
267
+
268
+ (An earlier revision of this entry quoted 94.90% → 98.38% for type-correct.
269
+ Those came from a 30,000-document prefix of the corpus, which is drawn from
270
+ one dataset and is not representative; they were labelled as full-corpus in
271
+ error. The figures above are measured over all 152,300.)
82
272
 
83
273
  Weights are derived from the corpus, not hand-tuned. Inference influences
84
274
  scoring only; it can never cause a miss.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: euredact
3
- Version: 0.3.2
3
+ Version: 0.3.4
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
@@ -792,11 +792,17 @@ Each `PatternDef` can specify:
792
792
  Measured on one core, all 31 countries loaded, `detect_dates=True`. Cost scales
793
793
  with document length, so the input size is stated rather than averaged away.
794
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 | |
795
+ | Input | Pure Python | Aho-Corasick | With `[fast]` |
796
+ |---|---:|---:|---:|
797
+ | Short record (186 chars) | 907 µs — 1,103/s | 775 µs — 1,290/s | **516 µs — 1,938/s** |
798
+ | Real document (3,424 chars) | 13.6 ms — 73/s | 10.9 ms — 92/s | **5.3 ms — 190/s** |
799
+ | Memory per country | ~50 KB | ~50 KB | ~50 KB |
800
+
801
+ Making `\b` catch identifiers glued to a non-ASCII letter costs something, but
802
+ only where it must: next to a digit the ASCII reading alone is exactly
803
+ equivalent, so one lookaround replaces a three-way alternation, and plain `\b`
804
+ stays everywhere else. 0.3.3 applied the union to all 303 patterns and was
805
+ 1.8×/2.8× slower for it.
800
806
 
801
807
  ```bash
802
808
  pip install euredact[fast]
@@ -751,11 +751,17 @@ Each `PatternDef` can specify:
751
751
  Measured on one core, all 31 countries loaded, `detect_dates=True`. Cost scales
752
752
  with document length, so the input size is stated rather than averaged away.
753
753
 
754
- | Input | Base | With `[fast]` |
755
- |---|---:|---:|
756
- | Short record (186 chars) | 723 µs — 1,383/s | **496 µs — 2,017/s** |
757
- | Real document (3,424 chars) | 10.4 ms — 96/s | **5.4 ms — 187/s** |
758
- | Memory per country | ~50 KB compiled patterns | |
754
+ | Input | Pure Python | Aho-Corasick | With `[fast]` |
755
+ |---|---:|---:|---:|
756
+ | Short record (186 chars) | 907 µs — 1,103/s | 775 µs — 1,290/s | **516 µs — 1,938/s** |
757
+ | Real document (3,424 chars) | 13.6 ms — 73/s | 10.9 ms — 92/s | **5.3 ms — 190/s** |
758
+ | Memory per country | ~50 KB | ~50 KB | ~50 KB |
759
+
760
+ Making `\b` catch identifiers glued to a non-ASCII letter costs something, but
761
+ only where it must: next to a digit the ASCII reading alone is exactly
762
+ equivalent, so one lookaround replaces a three-way alternation, and plain `\b`
763
+ stays everywhere else. 0.3.3 applied the union to all 303 patterns and was
764
+ 1.8×/2.8× slower for it.
759
765
 
760
766
  ```bash
761
767
  pip install euredact[fast]
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "euredact"
7
- version = "0.3.2"
7
+ version = "0.3.4"
8
8
  description = "European PII redaction SDK — rule engine for 31 countries"
9
9
  requires-python = ">=3.10"
10
10
  license = "Apache-2.0"
@@ -22,7 +22,7 @@ from __future__ import annotations
22
22
 
23
23
  from typing import Iterator
24
24
 
25
- __version__ = "0.3.2"
25
+ __version__ = "0.3.4"
26
26
 
27
27
  from euredact.rules.bic_registry import set_bic_registry
28
28
  from euredact.rules.context import DocumentContext
@@ -218,7 +218,11 @@ class SharedConfig(CountryConfig):
218
218
  # Must be preceded by whitespace or start-of-string, 1-30 alphanumeric/underscore/dot chars
219
219
  PatternDef(
220
220
  entity_type=EntityType.SOCIAL_HANDLE,
221
- pattern=r"(?<!\w)@[a-zA-Z][a-zA-Z0-9_.]{1,29}\b",
221
+ # Unicode-aware: an ASCII-only class cannot match "@Ciarán"
222
+ # at all, and the boundary rewrite used to paper over that by
223
+ # stopping at "@Ciar" and masking a fragment while leaving "án"
224
+ # in the clear. [^\W\d_] is "a letter in any script".
225
+ pattern=r"(?<!\w)@[^\W\d_][\w.]{1,29}\b",
222
226
  validator=None,
223
227
  description="Social media handle (@username)",
224
228
  ),
@@ -132,6 +132,14 @@ class DEConfig(CountryConfig):
132
132
  "Rentenversicherung", "RV-Nummer", "Sozialversicherung",
133
133
  "SV-Nummer", "Versicherungsnummer", "Rentenversicherungsnummer",
134
134
  "Sozialversicherungsnummer",
135
+ # The punctuation-free abbreviations. "SVNR" alone accounted
136
+ # for every German social-security number this rule missed
137
+ # across the corpus — 204 of them, unredacted, because the
138
+ # list had "SV-Nummer" and "Sozialversicherung" but not the
139
+ # form people actually type. "SV-Nr" and "RVNR" are the same
140
+ # gap for the other two spellings; cf. KVNR alongside
141
+ # KV-Nummer on the health-insurance rule.
142
+ "SVNR", "SV-Nr", "RVNR",
135
143
  ],
136
144
  requires_context=True,
137
145
  ),
@@ -39,6 +39,31 @@ def _validate_custom_pattern(pattern: str) -> None:
39
39
  )
40
40
 
41
41
 
42
+ def check_country_arg(value: object, param: str) -> None:
43
+ """Reject a bare string where a list of country codes is expected.
44
+
45
+ ``countries="NL"`` is iterable, so Python walks it into the codes ``"N"``
46
+ and ``"L"``. Neither resolves, so nothing is declared — and every detection
47
+ that *does* carry a country is then flagged ``out_of_scope``. A caller
48
+ following the documented pattern of filtering on that field silently keeps
49
+ none of them, while ``redacted_text`` still looks perfectly correct. The
50
+ failure direction is "no PII here", from a one-character typo.
51
+
52
+ A wrong *code* is data and only warns (see ``CountryRegistry.warn_unknown``)
53
+ — raising there would invite callers to wrap redaction in try/except and
54
+ skip it. A wrong *type* is a programming error, surfaces immediately, and
55
+ has no correct interpretation to fall back on.
56
+ """
57
+ if isinstance(value, (str, bytes)):
58
+ shown = value.decode(errors="replace") if isinstance(value, bytes) else value
59
+ raise TypeError(
60
+ f"{param} must be a list of country codes, not a bare string. "
61
+ f"Pass {param}=[{shown!r}] rather than {param}={shown!r} — a string "
62
+ f"is iterated character by character, which silently declares "
63
+ f"nothing and flags every detection out_of_scope."
64
+ )
65
+
66
+
42
67
  class RuleEngine:
43
68
  """The core rule-based PII detection engine.
44
69
 
@@ -135,6 +160,9 @@ class RuleEngine:
135
160
  It is scored alongside this text's own evidence but is **not** returned,
136
161
  so a caller accumulating evidence never counts it twice.
137
162
  """
163
+ check_country_arg(country_codes, "countries")
164
+ check_country_arg(country_hint, "country_hint")
165
+
138
166
  raw_matches = self.generate_candidates(text)
139
167
 
140
168
  # The declared countries are a scoring signal from here on: they rank
@@ -226,16 +254,50 @@ class RuleEngine:
226
254
  # phone number — and demoting on it cost every PHONE candidate in the
227
255
  # document its rank, which is how a Dutch mobile came back as a Danish
228
256
  # NATIONAL_ID even after the digits were correctly ranked.
229
- # Zones are per entity type. A failed checksum means "these digits are
230
- # not a valid X" — that is evidence against X, not against the span.
231
- # Demoting every type at once meant Sweden's own personnummer checksum
232
- # failing on 0708787668 demoted the Swedish *phone* candidate for the
233
- # same digits, handing the span to a Danish CPR that happened to
234
- # validate. Worth 878 mistyped phone numbers on 30,000 documents.
257
+ # Two rules, because a failed checksum carries two different messages
258
+ # depending on how the spans line up.
259
+ #
260
+ # Same type, any containment: "these digits are not a valid X" is
261
+ # evidence against X, not against the span. Demoting *every* type on an
262
+ # equal span meant Sweden's own personnummer checksum failing on
263
+ # 0708787668 demoted the Swedish *phone* candidate for the same digits,
264
+ # handing the span to a Danish CPR that happened to validate — 878
265
+ # mistyped phone numbers per 30,000 documents.
266
+ #
267
+ # Any type, strict subset: a candidate covering only *part* of a
268
+ # rejected identifier is a fragment of it, not a different entity. The
269
+ # generic separator-tolerant phone pattern claimed characters 3-14 of
270
+ # the rejected Belgian national number in
271
+ # "Rijksregisternummer: 85.03.19-284.73" and reported it as a PHONE.
272
+ # This is the case the original suppression zones were written for —
273
+ # licence-plate fragments inside an invalid IBAN — which the per-type
274
+ # split above had given up along with the over-reach.
275
+ #
276
+ # An equal span is two schemes competing for the whole value; a strict
277
+ # subset is one scheme picking over another's wreckage.
278
+ corroborated: list[tuple[int, int, object]] = [
279
+ (start, end, etype)
280
+ for start, end, cc, etype in failed_spans
281
+ if not country_scores or cc in ("SHARED", "CUSTOM") or cc in country_scores
282
+ ]
283
+ corroborated.sort()
284
+
285
+ # Fragment detection deliberately ignores corroboration and uses every
286
+ # failed span, because it *removes* candidates and must therefore be
287
+ # country-blind. Gating it on the document's countries made the set of
288
+ # spans found depend on `countries=`, which is precisely invariant I1 —
289
+ # caught by tests/test_invariant_generation.py within seconds of being
290
+ # written. Demotion may be country-aware because it only reorders; a
291
+ # demoted candidate still claims a span nothing else wants.
292
+ #
293
+ # Kept unmerged: merging adjacent spans would invent strict-subset
294
+ # relationships that neither original span had.
295
+ all_failed = sorted((start, end) for start, end, _cc, _et in failed_spans)
296
+ strict_starts = [f[0] for f in all_failed]
297
+
235
298
  by_type: dict[object, list[tuple[int, int]]] = {}
236
- for start, end, cc, etype in failed_spans:
237
- if not country_scores or cc in ("SHARED", "CUSTOM") or cc in country_scores:
238
- by_type.setdefault(etype, []).append((start, end))
299
+ for start, end, etype in corroborated:
300
+ by_type.setdefault(etype, []).append((start, end))
239
301
 
240
302
  # Merge overlapping zones per type and index them for O(log n)
241
303
  # containment checks instead of O(n) per match.
@@ -274,7 +336,7 @@ class RuleEngine:
274
336
  # (priority, in_scope, start, end, match, prebuilt). in_scope ranks
275
337
  # candidates within a priority tier; it never removes any.
276
338
  candidates: list[
277
- tuple[int, float, int, int, int, RawMatch | None, Detection | None]
339
+ tuple[int, int, float, int, int, int, RawMatch | None, Detection | None]
278
340
  ] = []
279
341
  for match, has_valid_validator in validated:
280
342
  # A validator-less match inside a failed-validation span used to be
@@ -295,9 +357,31 @@ class RuleEngine:
295
357
  zone_starts, zone_ends = zone
296
358
  idx = bisect.bisect_right(zone_starts, match.start) - 1
297
359
  demoted = idx >= 0 and zone_ends[idx] >= match.end
360
+ # Strict subset of a rejected identifier, whatever its type: the
361
+ # candidate covers only part of something already recognised and
362
+ # rejected, so it is a fragment of that, not a separate entity.
363
+ #
364
+ # Dropped rather than demoted. Demotion cannot help here: nothing
365
+ # else wants a fragment, so a demoted candidate still wins its span
366
+ # by default — the generic phone pattern kept claiming characters
367
+ # 3-14 of a rejected Belgian national number. Dropping is safe in a
368
+ # way that whole-span deletion was not, because the characters stay
369
+ # covered by whatever claims the enclosing span, or by nothing —
370
+ # and "nothing" is the right answer for the tail of a rejected
371
+ # identifier.
372
+ fragment = False
373
+ if match.pattern_def.validator is None and not demoted:
374
+ hi = bisect.bisect_right(strict_starts, match.start)
375
+ for zs, ze in all_failed[:hi]:
376
+ if match.end <= ze and (match.start > zs or match.end < ze):
377
+ fragment = True
378
+ break
379
+ if fragment:
380
+ continue
298
381
 
299
382
  if demoted:
300
383
  priority = -1
384
+ blind_priority = -1
301
385
  elif has_valid_validator:
302
386
  # A passing checksum normally outranks everything. But a
303
387
  # checksum only says the digits fit *some* national scheme, and
@@ -321,28 +405,32 @@ class RuleEngine:
321
405
  or match.country_code in country_scores
322
406
  )
323
407
  priority = 3 if vouched else 1
408
+ blind_priority = 3
324
409
  elif match.country_code == "CUSTOM":
325
410
  priority = 2
411
+ blind_priority = 2
326
412
  elif match.pattern_def.entity_type == EntityType.POSTAL_CODE:
327
413
  # A bare digit run is the weakest evidence in the engine and
328
414
  # must never re-cut a span a structured detector claims
329
415
  # (PHONE, SSN, NATIONAL_ID, BANK_ACCOUNT, VAT...). Postal
330
416
  # codes take unclaimed spans only, whatever the span lengths.
331
417
  priority = 0
418
+ blind_priority = 0
332
419
  else:
333
420
  priority = 1
421
+ blind_priority = 1
334
422
 
335
423
  in_scope = 1 if (declared is None or match.country_code in declared) else 0
336
424
  country_score = country_scores.get(match.country_code, 0.0)
337
425
  candidates.append(
338
- (priority, country_score, in_scope,
426
+ (blind_priority, priority, country_score, in_scope,
339
427
  match.start, match.end, match, None)
340
428
  )
341
429
 
342
430
  # Structural detectors (JSON field names, CSV headers). These arrive as
343
431
  # finished Detections, carry no RawMatch, and are not suppressed.
344
432
  for d in detect_structural_dob(text):
345
- candidates.append((1, 0.0, 1, d.start, d.end, None, d))
433
+ candidates.append((1, 1, 0.0, 1, d.start, d.end, None, d))
346
434
 
347
435
  # Deduplicate: validated > custom > regex-only, then longer wins
348
436
  detections = self._deduplicate(text, candidates, declared)
@@ -366,7 +454,7 @@ class RuleEngine:
366
454
  def _deduplicate(
367
455
  text: str,
368
456
  candidates: list[
369
- tuple[int, float, int, int, int, RawMatch | None, Detection | None]
457
+ tuple[int, int, float, int, int, int, RawMatch | None, Detection | None]
370
458
  ],
371
459
  declared: set[str] | None = None,
372
460
  ) -> list[Detection]:
@@ -388,24 +476,51 @@ class RuleEngine:
388
476
  if not candidates:
389
477
  return []
390
478
 
391
- # Sort by (priority, span_length) descending. Stable, so ties fall back
392
- # to pattern registration order, as before.
479
+ # Ordering between *different* spans is country-blind; the country
480
+ # only ever picks the winner among candidates covering the *same* span.
481
+ #
482
+ # That split is what makes invariant I1 structural rather than merely
483
+ # tested. Country-aware promotion had twice decided which span won:
484
+ # under countries=["NL"] a validated national ID outranked a longer IP
485
+ # address and exposed ".77", and two equal-length candidates at
486
+ # different offsets swapped places. Both were found by sweeping the
487
+ # corpus, not by reasoning, and both are impossible now — the key below
488
+ # is country-blind up to and including `start`, and (length, start)
489
+ # already identifies a span uniquely.
490
+ #
491
+ # Ordering, in words: longest first (a shorter match winning re-cuts a
492
+ # longer one and leaks the remainder), then the structural tier, then
493
+ # leftmost. Only after a span is fixed do the country-aware fields
494
+ # break the tie, deciding the label rather than the extent.
495
+ #
496
+ # The structural tier is a property of the *span*, not of the candidate:
497
+ # the best tier anything claiming those exact characters can offer. Used
498
+ # per candidate it would also discriminate *within* a span, where the
499
+ # country-aware tier is supposed to have the final word — that silently
500
+ # disabled country inference entirely, handing every ambiguous value to
501
+ # whichever foreign scheme happened to validate.
502
+ span_tier: dict[tuple[int, int], int] = {}
503
+ for c in candidates:
504
+ key = (c[4], c[5])
505
+ if c[0] > span_tier.get(key, -2):
506
+ span_tier[key] = c[0]
507
+
393
508
  sorted_cands = sorted(
394
509
  candidates,
395
- # Span length outranks scope: preferring the declared country over
396
- # the longest match can truncate an entity. Measured: with
397
- # countries=["BE"], the Belgian phone pattern claims 11 of the 14
398
- # characters of "06 12 34 56 78" and leaves three digits exposed.
399
- # Scope only breaks ties between candidates of equal length, so it
400
- # can change WHICH country is attributed, never WHAT is masked.
401
- key=lambda c: (c[0], c[4] - c[3], c[1], c[2]),
402
- reverse=True,
510
+ key=lambda c: (
511
+ -(c[5] - c[4]), # longest first } country-blind
512
+ -span_tier[(c[4], c[5])], # best tier here } — decides
513
+ c[4], # leftmost } WHICH span
514
+ -c[1], # country priority } country-aware
515
+ -c[2], # country evidence } — decides the
516
+ -c[3], # declared scope } LABEL only
517
+ ),
403
518
  )
404
519
  result: list[Detection] = []
405
520
  occupied: set[int] = set()
406
521
  span_verdicts: dict[tuple[object, int, int], bool] = {}
407
522
 
408
- for _priority, _score, in_scope, start, end, match, prebuilt in sorted_cands:
523
+ for _bp, _prio, _score, in_scope, start, end, match, prebuilt in sorted_cands:
409
524
  if any(p in occupied for p in range(start, end)):
410
525
  continue
411
526