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.
- {euredact-0.3.2 → euredact-0.3.4}/CHANGELOG.md +193 -3
- {euredact-0.3.2 → euredact-0.3.4}/PKG-INFO +12 -6
- {euredact-0.3.2 → euredact-0.3.4}/README.md +11 -5
- {euredact-0.3.2 → euredact-0.3.4}/pyproject.toml +1 -1
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/__init__.py +1 -1
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/_shared.py +5 -1
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/de.py +8 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/engine.py +139 -24
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/matchers.py +104 -2
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/sdk.py +7 -2
- {euredact-0.3.2 → euredact-0.3.4}/tests/eval_full.py +7 -0
- euredact-0.3.4/tests/metrics.py +268 -0
- euredact-0.3.4/tests/sweep.py +234 -0
- {euredact-0.3.2 → euredact-0.3.4}/tests/test_api_contract.py +37 -0
- euredact-0.3.4/tests/test_structural_invariants.py +189 -0
- {euredact-0.3.2 → euredact-0.3.4}/.github/workflows/ci.yml +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/.gitignore +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/CONTRIBUTING.md +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/LICENSE +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/benchmarks/bench_engine.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/examples/basic_usage.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/examples/batch_processing.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/examples/llm_preprocessing.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/cache.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/cloud/__init__.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/cloud/client.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/cloud/hasher.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/cloud/shuffler.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/coref/__init__.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/coref/models.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/coref/resolver.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/normalizer.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/py.typed +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/__init__.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/bic_registry.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/context.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/__init__.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/_base.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/at.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/be.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/bg.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/ch.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/cy.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/cz.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/dk.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/ee.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/el.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/es.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/fi.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/fr.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/hr.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/hu.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/ie.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/is_.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/it.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/lt.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/lu.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/lv.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/mt.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/nl.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/no.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/pl.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/pt.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/ro.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/se.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/si.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/sk.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/countries/uk.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/de_districts.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/evidence.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/registry.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/structural.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/suppressors.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/rules/validators.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/src/euredact/types.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/tests/__init__.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/tests/benchmark.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/tests/conftest.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/tests/countries/__init__.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/tests/countries/test_be.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/tests/countries/test_nl.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/tests/eval_diff.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/tests/test_batch.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/tests/test_bic.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/tests/test_cache.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/tests/test_conformance.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/tests/test_country_inference.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/tests/test_document_context.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/tests/test_engine.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/tests/test_international.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/tests/test_invariant_generation.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/tests/test_normalizer.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/tests/test_postal_code.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/tests/test_redact.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/tests/test_scan_path_parity.py +0 -0
- {euredact-0.3.2 → euredact-0.3.4}/tests/test_structdata.py +0 -0
- {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 **
|
|
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.
|
|
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 |
|
|
796
|
-
|
|
797
|
-
| Short record (186 chars) |
|
|
798
|
-
| Real document (3,424 chars) | 10.
|
|
799
|
-
| Memory per country | ~50 KB
|
|
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 |
|
|
755
|
-
|
|
756
|
-
| Short record (186 chars) |
|
|
757
|
-
| Real document (3,424 chars) | 10.
|
|
758
|
-
| Memory per country | ~50 KB
|
|
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]
|
|
@@ -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
|
-
|
|
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
|
-
#
|
|
230
|
-
#
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
234
|
-
#
|
|
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,
|
|
237
|
-
|
|
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
|
-
#
|
|
392
|
-
#
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
|
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
|
|