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.
- {euredact-0.3.3 → euredact-0.3.4}/CHANGELOG.md +100 -5
- {euredact-0.3.3 → euredact-0.3.4}/PKG-INFO +12 -6
- {euredact-0.3.3 → euredact-0.3.4}/README.md +11 -5
- {euredact-0.3.3 → euredact-0.3.4}/pyproject.toml +1 -1
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/__init__.py +1 -1
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/_shared.py +5 -1
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/de.py +8 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/matchers.py +59 -22
- {euredact-0.3.3 → euredact-0.3.4}/tests/eval_full.py +7 -0
- euredact-0.3.4/tests/metrics.py +268 -0
- {euredact-0.3.3 → euredact-0.3.4}/.github/workflows/ci.yml +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/.gitignore +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/CONTRIBUTING.md +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/LICENSE +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/benchmarks/bench_engine.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/examples/basic_usage.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/examples/batch_processing.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/examples/llm_preprocessing.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/cache.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/cloud/__init__.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/cloud/client.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/cloud/hasher.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/cloud/shuffler.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/coref/__init__.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/coref/models.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/coref/resolver.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/normalizer.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/py.typed +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/__init__.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/bic_registry.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/context.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/__init__.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/_base.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/at.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/be.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/bg.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/ch.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/cy.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/cz.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/dk.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/ee.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/el.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/es.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/fi.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/fr.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/hr.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/hu.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/ie.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/is_.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/it.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/lt.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/lu.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/lv.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/mt.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/nl.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/no.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/pl.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/pt.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/ro.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/se.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/si.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/sk.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/countries/uk.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/de_districts.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/engine.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/evidence.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/registry.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/structural.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/suppressors.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/rules/validators.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/sdk.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/src/euredact/types.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/tests/__init__.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/tests/benchmark.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/tests/conftest.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/tests/countries/__init__.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/tests/countries/test_be.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/tests/countries/test_nl.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/tests/eval_diff.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/tests/sweep.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/tests/test_api_contract.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/tests/test_batch.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/tests/test_bic.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/tests/test_cache.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/tests/test_conformance.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/tests/test_country_inference.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/tests/test_document_context.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/tests/test_engine.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/tests/test_international.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/tests/test_invariant_generation.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/tests/test_normalizer.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/tests/test_postal_code.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/tests/test_redact.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/tests/test_scan_path_parity.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/tests/test_structdata.py +0 -0
- {euredact-0.3.3 → euredact-0.3.4}/tests/test_structural_invariants.py +0 -0
- {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
|
-
|
|
72
|
-
|
|
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 **
|
|
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
|
+
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
|
),
|
|
@@ -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.
|
|
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
|
|
106
|
-
#
|
|
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
|
|
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
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
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
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|