euredact 0.3.3__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.3 → euredact-0.3.4}/CHANGELOG.md +100 -5
  2. {euredact-0.3.3 → euredact-0.3.4}/PKG-INFO +12 -6
  3. {euredact-0.3.3 → euredact-0.3.4}/README.md +11 -5
  4. {euredact-0.3.3 → euredact-0.3.4}/pyproject.toml +1 -1
  5. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/__init__.py +1 -1
  6. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/_shared.py +5 -1
  7. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/de.py +8 -0
  8. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/matchers.py +59 -22
  9. {euredact-0.3.3 → euredact-0.3.4}/tests/eval_full.py +7 -0
  10. euredact-0.3.4/tests/metrics.py +268 -0
  11. {euredact-0.3.3 → euredact-0.3.4}/.github/workflows/ci.yml +0 -0
  12. {euredact-0.3.3 → euredact-0.3.4}/.gitignore +0 -0
  13. {euredact-0.3.3 → euredact-0.3.4}/CONTRIBUTING.md +0 -0
  14. {euredact-0.3.3 → euredact-0.3.4}/LICENSE +0 -0
  15. {euredact-0.3.3 → euredact-0.3.4}/benchmarks/bench_engine.py +0 -0
  16. {euredact-0.3.3 → euredact-0.3.4}/examples/basic_usage.py +0 -0
  17. {euredact-0.3.3 → euredact-0.3.4}/examples/batch_processing.py +0 -0
  18. {euredact-0.3.3 → euredact-0.3.4}/examples/llm_preprocessing.py +0 -0
  19. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/cache.py +0 -0
  20. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/cloud/__init__.py +0 -0
  21. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/cloud/client.py +0 -0
  22. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/cloud/hasher.py +0 -0
  23. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/cloud/shuffler.py +0 -0
  24. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/coref/__init__.py +0 -0
  25. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/coref/models.py +0 -0
  26. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/coref/resolver.py +0 -0
  27. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/normalizer.py +0 -0
  28. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/py.typed +0 -0
  29. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/__init__.py +0 -0
  30. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/bic_registry.py +0 -0
  31. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/context.py +0 -0
  32. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/__init__.py +0 -0
  33. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/_base.py +0 -0
  34. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/at.py +0 -0
  35. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/be.py +0 -0
  36. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/bg.py +0 -0
  37. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/ch.py +0 -0
  38. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/cy.py +0 -0
  39. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/cz.py +0 -0
  40. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/dk.py +0 -0
  41. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/ee.py +0 -0
  42. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/el.py +0 -0
  43. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/es.py +0 -0
  44. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/fi.py +0 -0
  45. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/fr.py +0 -0
  46. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/hr.py +0 -0
  47. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/hu.py +0 -0
  48. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/ie.py +0 -0
  49. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/is_.py +0 -0
  50. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/it.py +0 -0
  51. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/lt.py +0 -0
  52. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/lu.py +0 -0
  53. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/lv.py +0 -0
  54. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/mt.py +0 -0
  55. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/nl.py +0 -0
  56. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/no.py +0 -0
  57. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/pl.py +0 -0
  58. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/pt.py +0 -0
  59. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/ro.py +0 -0
  60. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/se.py +0 -0
  61. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/si.py +0 -0
  62. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/sk.py +0 -0
  63. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/uk.py +0 -0
  64. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/de_districts.py +0 -0
  65. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/engine.py +0 -0
  66. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/evidence.py +0 -0
  67. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/registry.py +0 -0
  68. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/structural.py +0 -0
  69. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/suppressors.py +0 -0
  70. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/validators.py +0 -0
  71. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/sdk.py +0 -0
  72. {euredact-0.3.3 → euredact-0.3.4}/src/euredact/types.py +0 -0
  73. {euredact-0.3.3 → euredact-0.3.4}/tests/__init__.py +0 -0
  74. {euredact-0.3.3 → euredact-0.3.4}/tests/benchmark.py +0 -0
  75. {euredact-0.3.3 → euredact-0.3.4}/tests/conftest.py +0 -0
  76. {euredact-0.3.3 → euredact-0.3.4}/tests/countries/__init__.py +0 -0
  77. {euredact-0.3.3 → euredact-0.3.4}/tests/countries/test_be.py +0 -0
  78. {euredact-0.3.3 → euredact-0.3.4}/tests/countries/test_nl.py +0 -0
  79. {euredact-0.3.3 → euredact-0.3.4}/tests/eval_diff.py +0 -0
  80. {euredact-0.3.3 → euredact-0.3.4}/tests/sweep.py +0 -0
  81. {euredact-0.3.3 → euredact-0.3.4}/tests/test_api_contract.py +0 -0
  82. {euredact-0.3.3 → euredact-0.3.4}/tests/test_batch.py +0 -0
  83. {euredact-0.3.3 → euredact-0.3.4}/tests/test_bic.py +0 -0
  84. {euredact-0.3.3 → euredact-0.3.4}/tests/test_cache.py +0 -0
  85. {euredact-0.3.3 → euredact-0.3.4}/tests/test_conformance.py +0 -0
  86. {euredact-0.3.3 → euredact-0.3.4}/tests/test_country_inference.py +0 -0
  87. {euredact-0.3.3 → euredact-0.3.4}/tests/test_document_context.py +0 -0
  88. {euredact-0.3.3 → euredact-0.3.4}/tests/test_engine.py +0 -0
  89. {euredact-0.3.3 → euredact-0.3.4}/tests/test_international.py +0 -0
  90. {euredact-0.3.3 → euredact-0.3.4}/tests/test_invariant_generation.py +0 -0
  91. {euredact-0.3.3 → euredact-0.3.4}/tests/test_normalizer.py +0 -0
  92. {euredact-0.3.3 → euredact-0.3.4}/tests/test_postal_code.py +0 -0
  93. {euredact-0.3.3 → euredact-0.3.4}/tests/test_redact.py +0 -0
  94. {euredact-0.3.3 → euredact-0.3.4}/tests/test_scan_path_parity.py +0 -0
  95. {euredact-0.3.3 → euredact-0.3.4}/tests/test_structdata.py +0 -0
  96. {euredact-0.3.3 → euredact-0.3.4}/tests/test_structural_invariants.py +0 -0
  97. {euredact-0.3.3 → euredact-0.3.4}/tests/test_validators.py +0 -0
@@ -1,5 +1,82 @@
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
+
3
80
  ## 0.3.3 (2026-07-29)
4
81
 
5
82
  ### Fixed
@@ -68,8 +145,21 @@
68
145
  deliberately supported. `re.ASCII` is unusable for the same reason, since it
69
146
  would also narrow `\w`.
70
147
 
71
- Costs about 20% latency on short documents from the added lookarounds.
72
- Accuracy on the corpus is unchanged.
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.
73
163
 
74
164
  ### Added
75
165
 
@@ -171,9 +261,14 @@
171
261
  Identical digits: `0612345678` is both a valid Dutch mobile number and a
172
262
  valid Danish CPR. Only the document distinguishes them.
173
263
 
174
- On 152,300 documents, blind detection (no `countries=`) improved from
175
- **95.6% to 98.3% precision** and from **94.90% to 98.38% type-correct**,
176
- 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.)
177
272
 
178
273
  Weights are derived from the corpus, not hand-tuned. Inference influences
179
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
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.3"
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.3"
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
  ),
@@ -98,33 +98,66 @@ def _silence_stderr() -> Iterator[None]:
98
98
  # Which rule is "right" is arguable: Python's is self-consistent, JS's is
99
99
  # script-dependent. But for a redaction tool the argument is settled by the
100
100
  # failure direction. Python's principled rule leaves a national ID glued to a
101
- # non-ASCII letter unredacted; JS's limitation catches it. Over-detection is
102
- # recoverable, a silent miss is not.
101
+ # non-ASCII letter unredacted; JS's limitation catches it.
103
102
  #
104
103
  # So \b is rewritten at compile time rather than in 303 pattern sources.
105
- # re.ASCII is not usable here: it would also narrow \w, breaking Unicode e-mail
106
- # local parts, which are deliberately supported.
104
+ # re.ASCII is not usable: it would also narrow \w, breaking Unicode e-mail local
105
+ # parts, which are deliberately supported.
107
106
  #
108
- # The rewrite is a *union* of both readings, not a swap to the ASCII one.
109
- # Neither alone is sufficient, and swapping regresses the other direction:
107
+ # The rewrite is chosen per boundary, by what sits next to it.
110
108
  #
111
- # "ЕГН7523169263" needs the ASCII reading (Н and 7 are both
112
- # word characters to Python, so no boundary)
113
- # "Mail: αλέξης@example.gr" needs the Unicode reading (α is not an ASCII
114
- # word character, so no boundary)
109
+ # **Next to a digit** — the ASCII reading alone is *exactly* the union of both
110
+ # readings, because a Unicode letter is never an ASCII word character, so the
111
+ # ASCII branch already succeeds everywhere the Unicode branch does. One
112
+ # lookaround replaces a three-way alternation.
115
113
  #
116
- # Taking either one alone trades one silent miss for another — swapping to
117
- # ASCII-only was measured to drop the Greek address entirely. The union fires
118
- # wherever *either* reading sees a boundary, which is the over-detecting
119
- # direction on both counts and matches what Node already does on all seven
120
- # cases.
121
- _ASCII_WORD_BOUNDARY = (
122
- r"(?:\b|(?<![0-9A-Za-z_])(?=[0-9A-Za-z_])|(?<=[0-9A-Za-z_])(?![0-9A-Za-z_]))"
123
- )
114
+ # **Anything else** — plain \b, which is what "Mail: αλέξης@example.gr" needs:
115
+ # α is not an ASCII word character, so an ASCII-only boundary would refuse to
116
+ # start the match and the address would go unredacted.
117
+ #
118
+ # An earlier version applied the three-branch union everywhere. It was correct
119
+ # but cost 1.8x on short records and 2.8x on real documents, and its ASCII branch
120
+ # manufactured boundaries *inside* words at non-ASCII letters — truncating
121
+ # "@Ciarán" to "@Ciar" and typing the fragment "FICIAIRES" of "BÉNÉFICIAIRES" as
122
+ # a national ID. Choosing per boundary is both faster and cleaner.
123
+ _ASCII_WORD = "0-9A-Za-z_"
124
+
125
+
126
+ def _digit_only_element(source: str, i: int) -> bool:
127
+ """Does the pattern element starting at *source[i]* match only digits?"""
128
+ if source.startswith(r"\d", i):
129
+ return True
130
+ if i >= len(source) or source[i] != "[":
131
+ return False
132
+ close = source.find("]", i + 1)
133
+ if close < 0:
134
+ return False
135
+ body = source[i + 1:close]
136
+ return bool(body) and not body.startswith("^") and all(
137
+ c.isdigit() or c == "-" for c in body
138
+ )
139
+
140
+
141
+ def _preceding_element_is_digits(source: str, i: int) -> bool:
142
+ """Looking left from *source[i]*, skip a quantifier and test the element."""
143
+ j = i - 1
144
+ if j >= 0 and source[j] == "}":
145
+ brace = source.rfind("{", 0, j)
146
+ if brace < 0:
147
+ return False
148
+ j = brace - 1
149
+ elif j >= 0 and source[j] in "+*?":
150
+ j -= 1
151
+ if j < 0:
152
+ return False
153
+ if source[j] == "]":
154
+ open_ = source.rfind("[", 0, j)
155
+ return open_ >= 0 and _digit_only_element(source, open_)
156
+ return j >= 1 and source[j - 1] == "\\" and source[j] == "d"
124
157
 
125
158
 
126
159
  def _ascii_word_boundaries(pattern: str) -> str:
127
- """Rewrite every ``\b`` in *pattern* to the union boundary above.
160
+ """Rewrite each ``\b`` in *pattern* per the reasoning above.
128
161
 
129
162
  Skips escaped backslashes and character classes, where ``\b`` means a
130
163
  backspace rather than a boundary. Neither occurs in the shipped patterns
@@ -136,9 +169,13 @@ def _ascii_word_boundaries(pattern: str) -> str:
136
169
  while i < len(pattern):
137
170
  ch = pattern[i]
138
171
  if ch == "\\" and i + 1 < len(pattern):
139
- nxt = pattern[i + 1]
140
- if nxt == "b" and not in_class:
141
- out.append(_ASCII_WORD_BOUNDARY)
172
+ if pattern[i + 1] == "b" and not in_class:
173
+ if _digit_only_element(pattern, i + 2):
174
+ out.append(f"(?<![{_ASCII_WORD}])")
175
+ elif _preceding_element_is_digits(pattern, i):
176
+ out.append(f"(?![{_ASCII_WORD}])")
177
+ else:
178
+ out.append(r"\b")
142
179
  else:
143
180
  out.append(pattern[i:i + 2])
144
181
  i += 2
@@ -36,6 +36,13 @@ CATEGORY_MAP: dict[str, set[str]] = {
36
36
  "VIN": {EntityType.VIN.value},
37
37
  "PASSPORT": {EntityType.PASSPORT.value},
38
38
  "HEALTH_INSURANCE": {EntityType.HEALTH_INSURANCE.value, EntityType.NATIONAL_ID.value},
39
+ # The corpus also labels these two, and neither had an entry. SECRET worked
40
+ # by accident — the {SECRET} fallback happens to be a real engine type —
41
+ # but HEALTH_ID could never match anything, so all 252 of them scored as
42
+ # misses *and* charged the engine's correct HEALTH_INSURANCE detections as
43
+ # false positives. A harness gap, not an engine one.
44
+ "HEALTH_ID": {EntityType.HEALTH_INSURANCE.value, EntityType.NATIONAL_ID.value},
45
+ "SECRET": {EntityType.SECRET.value},
39
46
  "CHAMBER_OF_COMMERCE": {EntityType.CHAMBER_OF_COMMERCE.value, EntityType.VAT.value},
40
47
  "IP_ADDRESS": {EntityType.IP_ADDRESS.value},
41
48
  "IPV6_ADDRESS": {EntityType.IPV6_ADDRESS.value},
@@ -0,0 +1,268 @@
1
+ """Per-entity-type precision, recall, F1 and false-positive counts.
2
+
3
+ `eval_full.py` renders an HTML report with recall and precision per type and
4
+ country. This is the plain-text counterpart: one row per PII type, four numbers,
5
+ both detection modes, and definitions stated rather than implied — so a figure
6
+ quoted from it can be reproduced and argued with.
7
+
8
+ python tests/metrics.py # both modes, whole corpus
9
+ python tests/metrics.py --mode blind
10
+ python tests/metrics.py --limit 20000
11
+ python tests/metrics.py --csv out.csv
12
+
13
+ Definitions
14
+ -----------
15
+ Matching is span-overlap plus type-acceptability, the same rule `eval_full.py`
16
+ uses: a detection matches a ground-truth label when their spans overlap **and**
17
+ the detection's entity type is in that label category's acceptable set (see
18
+ ``CATEGORY_MAP``). Overlap rather than exact equality, because the engine's
19
+ notion of where an identifier ends can legitimately differ by a separator.
20
+
21
+ TP a labelled span with at least one matching detection
22
+ FN a labelled span with none
23
+ FP a detection that satisfied no label — including one that landed on a
24
+ labelled span under the wrong type, which is both a miss and a false
25
+ positive, and a duplicate detection of an already-satisfied label
26
+
27
+ Rows are keyed on the **ground-truth category**, since that is what the corpus
28
+ labels. A false positive is charged to the category its entity type would have
29
+ satisfied (printed as the type→category map below, so the attribution is
30
+ auditable). Types the corpus never labels get their own row and have no recall.
31
+
32
+ DOB is reported but excluded from the totals: bare dates are handled by the
33
+ cloud tier, and the rules engine only emits them with a keyword or structural
34
+ cue, so its recall here is not a defect.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import argparse
40
+ import csv
41
+ import json
42
+ import os
43
+ import sys
44
+ import time
45
+ from collections import Counter, defaultdict
46
+ from pathlib import Path
47
+
48
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
49
+
50
+ import euredact # noqa: E402
51
+ from euredact.rules.registry import CountryRegistry # noqa: E402
52
+
53
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
54
+ from eval_full import CATEGORY_MAP, DOB_CATEGORIES, EXCLUDED_CATEGORIES # noqa: E402
55
+
56
+ DATA_DIR = Path(os.environ.get(
57
+ "EUREDACT_CORPUS",
58
+ "/Users/jorenjanssens/Library/Mobile Documents/com~apple~CloudDocs/Werken/JNJS/"
59
+ "Apps/PII-EuroMask/Data-Generation",
60
+ ))
61
+
62
+ #: The ten canonical datasets. `euromask_international_10k.json.bak` is
63
+ #: deliberately absent: it is the *same* 10,000 documents with the same texts,
64
+ #: differing only in carrying 640 BIC labels that were removed as false
65
+ #: positives when the dataset was corrected. Including it would double-count
66
+ #: those documents and score the engine against labels known to be wrong.
67
+ DATASETS = [
68
+ "euromask_allcountries_20k.json",
69
+ "euromask_dach_south_20k.json",
70
+ "euromask_eastern_20k.json",
71
+ "euromask_el_cy_mt_20k.json",
72
+ "euromask_ie_baltics_uk_20k.json",
73
+ "euromask_international_10k.json",
74
+ "euromask_nordic_20k.json",
75
+ "euromask_secrets_5k.json",
76
+ "euromask_training_core.json",
77
+ "euromask_training_core2.json",
78
+ ]
79
+
80
+
81
+ def canonical_category_of_type(support: Counter[str]) -> dict[str, str]:
82
+ """Entity type -> the category a false positive of that type is charged to.
83
+
84
+ Preference order: the category named after the type *if the corpus labels
85
+ it*, then whichever candidate the corpus labels most often, then the one
86
+ with the fewest acceptable types, then alphabetical.
87
+
88
+ Both halves of that are load-bearing. Without the support check, BIC false
89
+ positives were charged to a ``BIC`` row with zero support while every real
90
+ label sat under ``SWIFT_BIC`` — one type's precision split across two rows,
91
+ reading as 0.00%. Without the name check first, TAX_ID false positives were
92
+ charged to NATIONAL_ID purely because it is a hundred times larger, even
93
+ though TAX_ID has a real row of its own.
94
+ """
95
+ owners: dict[str, list[str]] = defaultdict(list)
96
+ for category, types in CATEGORY_MAP.items():
97
+ for etype in types:
98
+ owners[etype].append(category)
99
+ resolved = {}
100
+ for etype, cats in owners.items():
101
+ resolved[etype] = sorted(
102
+ cats,
103
+ key=lambda c: (
104
+ 0 if (c == etype and support.get(c, 0)) else 1, # own row, if real
105
+ -support.get(c, 0), # else the busiest
106
+ len(CATEGORY_MAP[c]), c,
107
+ ),
108
+ )[0]
109
+ return resolved
110
+
111
+
112
+ def load(limit: int | None):
113
+ records = []
114
+ for name in DATASETS:
115
+ path = DATA_DIR / name
116
+ if not path.exists():
117
+ print(f"missing: {path}", file=sys.stderr)
118
+ continue
119
+ records.extend(json.loads(path.read_text()))
120
+ if limit and len(records) >= limit:
121
+ break
122
+ return records[:limit] if limit else records
123
+
124
+
125
+ def evaluate(records, hinted: bool):
126
+ known = set(CountryRegistry().available_countries)
127
+ support: Counter[str] = Counter()
128
+ for item in records:
129
+ for pii in item.get("PII") or []:
130
+ if pii["PII_category"] not in EXCLUDED_CATEGORIES:
131
+ support[pii["PII_category"]] += 1
132
+ charge_to = canonical_category_of_type(support)
133
+ tp: Counter[str] = Counter()
134
+ fn: Counter[str] = Counter()
135
+ fp: Counter[str] = Counter()
136
+ euredact._instance = None
137
+ t0 = time.time()
138
+
139
+ for item in records:
140
+ text = item["source_text"]
141
+ expected = item.get("PII") or []
142
+ if not expected:
143
+ continue
144
+ countries = [c for c in {p["PII_country"] for p in expected} if c in known]
145
+ detections = euredact.redact(
146
+ text, countries=(countries or None) if hinted else None,
147
+ detect_dates=True, cache=False,
148
+ ).detections
149
+
150
+ labels = []
151
+ for pii in expected:
152
+ category = pii["PII_category"]
153
+ if category in EXCLUDED_CATEGORIES:
154
+ continue
155
+ idx = text.find(pii["PII_identifier"])
156
+ if idx < 0:
157
+ continue # not present verbatim; not scoreable
158
+ labels.append((idx, idx + len(pii["PII_identifier"]), category))
159
+
160
+ matched_detections = set()
161
+ for start, end, category in labels:
162
+ acceptable = CATEGORY_MAP.get(category, {category})
163
+ hit = None
164
+ for i, d in enumerate(detections):
165
+ if (d.start < end and d.end > start
166
+ and d.entity_type.value in acceptable):
167
+ hit = i
168
+ break
169
+ if hit is None:
170
+ fn[category] += 1
171
+ else:
172
+ tp[category] += 1
173
+ matched_detections.add(hit)
174
+
175
+ for i, d in enumerate(detections):
176
+ if i in matched_detections:
177
+ continue
178
+ # Strict: a detection that did not satisfy a label is a false
179
+ # positive, including one that landed on a labelled span under the
180
+ # wrong type. That case is a miss *and* a false positive — the
181
+ # value is masked but filed wrongly, which is exactly what
182
+ # type-aware downstream handling gets hurt by. Exempting it would
183
+ # let mistypes disappear from both columns and flatter precision.
184
+ fp[charge_to.get(d.entity_type.value, d.entity_type.value)] += 1
185
+
186
+ return tp, fn, fp, time.time() - t0, charge_to
187
+
188
+
189
+ def report(tp, fn, fp, elapsed, rows_csv=None, label=""):
190
+ keys = sorted(set(tp) | set(fn) | set(fp))
191
+ core = [k for k in keys if k not in DOB_CATEGORIES]
192
+
193
+ def prf(k):
194
+ t, f_n, f_p = tp[k], fn[k], fp[k]
195
+ p = t / (t + f_p) if t + f_p else 0.0
196
+ r = t / (t + f_n) if t + f_n else 0.0
197
+ f1 = 2 * p * r / (p + r) if p + r else 0.0
198
+ return t, f_p, f_n, p, r, f1
199
+
200
+ print(f"\n{label} ({elapsed:.0f}s)")
201
+ print(f"{'type':26s} {'support':>9s} {'TP':>8s} {'FP':>7s} {'FN':>7s} "
202
+ f"{'prec':>7s} {'recall':>7s} {'F1':>7s}")
203
+ print("-" * 84)
204
+ for k in core:
205
+ t, f_p, f_n, p, r, f1 = prf(k)
206
+ print(f"{k:26s} {t + f_n:9,} {t:8,} {f_p:7,} {f_n:7,} "
207
+ f"{p * 100:6.2f}% {r * 100:6.2f}% {f1 * 100:6.2f}%")
208
+
209
+ T, FP_, FN_ = (sum(tp[k] for k in core), sum(fp[k] for k in core),
210
+ sum(fn[k] for k in core))
211
+ P = T / (T + FP_) if T + FP_ else 0.0
212
+ R = T / (T + FN_) if T + FN_ else 0.0
213
+ F = 2 * P * R / (P + R) if P + R else 0.0
214
+ print("-" * 84)
215
+ print(f"{'TOTAL (excl. DOB)':26s} {T + FN_:9,} {T:8,} {FP_:7,} {FN_:7,} "
216
+ f"{P * 100:6.2f}% {R * 100:6.2f}% {F * 100:6.2f}%")
217
+ for k in keys:
218
+ if k in DOB_CATEGORIES:
219
+ t, f_p, f_n, p, r, f1 = prf(k)
220
+ print(f"{k + ' (cloud tier)':26s} {t + f_n:9,} {t:8,} {f_p:7,} {f_n:7,} "
221
+ f"{p * 100:6.2f}% {r * 100:6.2f}% {f1 * 100:6.2f}%")
222
+
223
+ if rows_csv is not None:
224
+ for k in keys:
225
+ t, f_p, f_n, p, r, f1 = prf(k)
226
+ rows_csv.append({"mode": label, "type": k, "support": t + f_n, "tp": t,
227
+ "fp": f_p, "fn": f_n, "precision": round(p * 100, 4),
228
+ "recall": round(r * 100, 4), "f1": round(f1 * 100, 4)})
229
+
230
+
231
+ def main() -> int:
232
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
233
+ ap.add_argument("--mode", choices=["hinted", "blind", "both"], default="both")
234
+ ap.add_argument("--limit", type=int, default=None)
235
+ ap.add_argument("--csv", type=Path, default=None)
236
+ args = ap.parse_args()
237
+
238
+ records = load(args.limit)
239
+ if not records:
240
+ print("No datasets found; set EUREDACT_CORPUS.")
241
+ return 77
242
+ labelled = sum(1 for r in records if r.get("PII"))
243
+ print(f"datasets : {len(DATASETS)} files "
244
+ f"(the .bak is excluded — see module docstring)")
245
+ print(f"records : {len(records):,} ({labelled:,} carrying labels)")
246
+
247
+ rows: list[dict] = [] if args.csv else None
248
+ modes = ["hinted", "blind"] if args.mode == "both" else [args.mode]
249
+ charge_to = None
250
+ for mode in modes:
251
+ tp, fn, fp, elapsed, charge_to = evaluate(records, hinted=(mode == "hinted"))
252
+ report(tp, fn, fp, elapsed, rows, label=f"{mode} detection")
253
+
254
+ print("\nfalse positives are charged to the category their type would satisfy:")
255
+ shown = sorted({f"{t}->{c}" for t, c in charge_to.items() if t != c})
256
+ print(" " + ", ".join(shown) if shown else " (all types map to themselves)")
257
+
258
+ if args.csv:
259
+ with args.csv.open("w", newline="") as fh:
260
+ w = csv.DictWriter(fh, fieldnames=list(rows[0]))
261
+ w.writeheader()
262
+ w.writerows(rows)
263
+ print(f"\ncsv written to {args.csv}")
264
+ return 0
265
+
266
+
267
+ if __name__ == "__main__":
268
+ raise SystemExit(main())
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes