euredact 0.3.2__tar.gz → 0.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. {euredact-0.3.2 → euredact-0.3.3}/CHANGELOG.md +95 -0
  2. {euredact-0.3.2 → euredact-0.3.3}/PKG-INFO +1 -1
  3. {euredact-0.3.2 → euredact-0.3.3}/pyproject.toml +1 -1
  4. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/__init__.py +1 -1
  5. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/engine.py +139 -24
  6. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/matchers.py +67 -2
  7. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/sdk.py +7 -2
  8. euredact-0.3.3/tests/sweep.py +234 -0
  9. {euredact-0.3.2 → euredact-0.3.3}/tests/test_api_contract.py +37 -0
  10. euredact-0.3.3/tests/test_structural_invariants.py +189 -0
  11. {euredact-0.3.2 → euredact-0.3.3}/.github/workflows/ci.yml +0 -0
  12. {euredact-0.3.2 → euredact-0.3.3}/.gitignore +0 -0
  13. {euredact-0.3.2 → euredact-0.3.3}/CONTRIBUTING.md +0 -0
  14. {euredact-0.3.2 → euredact-0.3.3}/LICENSE +0 -0
  15. {euredact-0.3.2 → euredact-0.3.3}/README.md +0 -0
  16. {euredact-0.3.2 → euredact-0.3.3}/benchmarks/bench_engine.py +0 -0
  17. {euredact-0.3.2 → euredact-0.3.3}/examples/basic_usage.py +0 -0
  18. {euredact-0.3.2 → euredact-0.3.3}/examples/batch_processing.py +0 -0
  19. {euredact-0.3.2 → euredact-0.3.3}/examples/llm_preprocessing.py +0 -0
  20. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/cache.py +0 -0
  21. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/cloud/__init__.py +0 -0
  22. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/cloud/client.py +0 -0
  23. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/cloud/hasher.py +0 -0
  24. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/cloud/shuffler.py +0 -0
  25. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/coref/__init__.py +0 -0
  26. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/coref/models.py +0 -0
  27. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/coref/resolver.py +0 -0
  28. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/normalizer.py +0 -0
  29. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/py.typed +0 -0
  30. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/__init__.py +0 -0
  31. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/bic_registry.py +0 -0
  32. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/context.py +0 -0
  33. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/__init__.py +0 -0
  34. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/_base.py +0 -0
  35. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/_shared.py +0 -0
  36. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/at.py +0 -0
  37. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/be.py +0 -0
  38. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/bg.py +0 -0
  39. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/ch.py +0 -0
  40. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/cy.py +0 -0
  41. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/cz.py +0 -0
  42. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/de.py +0 -0
  43. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/dk.py +0 -0
  44. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/ee.py +0 -0
  45. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/el.py +0 -0
  46. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/es.py +0 -0
  47. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/fi.py +0 -0
  48. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/fr.py +0 -0
  49. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/hr.py +0 -0
  50. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/hu.py +0 -0
  51. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/ie.py +0 -0
  52. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/is_.py +0 -0
  53. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/it.py +0 -0
  54. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/lt.py +0 -0
  55. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/lu.py +0 -0
  56. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/lv.py +0 -0
  57. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/mt.py +0 -0
  58. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/nl.py +0 -0
  59. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/no.py +0 -0
  60. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/pl.py +0 -0
  61. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/pt.py +0 -0
  62. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/ro.py +0 -0
  63. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/se.py +0 -0
  64. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/si.py +0 -0
  65. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/sk.py +0 -0
  66. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/uk.py +0 -0
  67. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/de_districts.py +0 -0
  68. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/evidence.py +0 -0
  69. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/registry.py +0 -0
  70. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/structural.py +0 -0
  71. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/suppressors.py +0 -0
  72. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/validators.py +0 -0
  73. {euredact-0.3.2 → euredact-0.3.3}/src/euredact/types.py +0 -0
  74. {euredact-0.3.2 → euredact-0.3.3}/tests/__init__.py +0 -0
  75. {euredact-0.3.2 → euredact-0.3.3}/tests/benchmark.py +0 -0
  76. {euredact-0.3.2 → euredact-0.3.3}/tests/conftest.py +0 -0
  77. {euredact-0.3.2 → euredact-0.3.3}/tests/countries/__init__.py +0 -0
  78. {euredact-0.3.2 → euredact-0.3.3}/tests/countries/test_be.py +0 -0
  79. {euredact-0.3.2 → euredact-0.3.3}/tests/countries/test_nl.py +0 -0
  80. {euredact-0.3.2 → euredact-0.3.3}/tests/eval_diff.py +0 -0
  81. {euredact-0.3.2 → euredact-0.3.3}/tests/eval_full.py +0 -0
  82. {euredact-0.3.2 → euredact-0.3.3}/tests/test_batch.py +0 -0
  83. {euredact-0.3.2 → euredact-0.3.3}/tests/test_bic.py +0 -0
  84. {euredact-0.3.2 → euredact-0.3.3}/tests/test_cache.py +0 -0
  85. {euredact-0.3.2 → euredact-0.3.3}/tests/test_conformance.py +0 -0
  86. {euredact-0.3.2 → euredact-0.3.3}/tests/test_country_inference.py +0 -0
  87. {euredact-0.3.2 → euredact-0.3.3}/tests/test_document_context.py +0 -0
  88. {euredact-0.3.2 → euredact-0.3.3}/tests/test_engine.py +0 -0
  89. {euredact-0.3.2 → euredact-0.3.3}/tests/test_international.py +0 -0
  90. {euredact-0.3.2 → euredact-0.3.3}/tests/test_invariant_generation.py +0 -0
  91. {euredact-0.3.2 → euredact-0.3.3}/tests/test_normalizer.py +0 -0
  92. {euredact-0.3.2 → euredact-0.3.3}/tests/test_postal_code.py +0 -0
  93. {euredact-0.3.2 → euredact-0.3.3}/tests/test_redact.py +0 -0
  94. {euredact-0.3.2 → euredact-0.3.3}/tests/test_scan_path_parity.py +0 -0
  95. {euredact-0.3.2 → euredact-0.3.3}/tests/test_structdata.py +0 -0
  96. {euredact-0.3.2 → euredact-0.3.3}/tests/test_validators.py +0 -0
@@ -1,5 +1,100 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.3 (2026-07-29)
4
+
5
+ ### Fixed
6
+
7
+ - **A shorter validated match could re-cut a longer one and leak the
8
+ remainder.** Under `countries` `["NL"]` the Dutch national-ID pattern
9
+ validates on `194.232.104`, outranked the IP address `194.232.104.77`, and
10
+ left `.77` in the output. The TypeScript SDK did the same with no country
11
+ declared at all.
12
+
13
+ Span length now outranks every other signal, including validation. Priority
14
+ still decides between candidates of *equal* length, which is where it was
15
+ always meant to apply — a valid IBAN beats a coincidental match on the same
16
+ characters. Masking more than necessary is recoverable; masking less is not.
17
+
18
+ - **`countries` could change which spans were found, not just how they were
19
+ labelled.** Promotion depends on whether the document corroborates a
20
+ country, so the declared country decided which of two overlapping candidates
21
+ won. Two cases were found, the second only after fixing the first.
22
+
23
+ Ordering between different spans is now country-blind by construction —
24
+ length, then the span's structural tier, then leftmost — and `(length, start)`
25
+ identifies a span uniquely, so the country-aware signals can only choose the
26
+ **label within a span**, never its extent. The invariant holds by design
27
+ rather than by test.
28
+
29
+ - **A bare string for `countries` silently discarded every detection.**
30
+ `countries="NL"` is iterable, so it was walked into the codes `"N"` and `"L"`.
31
+ Neither resolves, so nothing was declared — and every detection carrying a
32
+ country came back flagged out of scope, while the redacted text still looked
33
+ correct. The README tells callers to filter on exactly that field, so a
34
+ documented pipeline kept none of them. It failed toward "no PII here", from a
35
+ one-character typo.
36
+
37
+ Both SDKs now raise `TypeError` naming the argument and showing the fix, on
38
+ every entry point. A wrong *code* still only warns: raising there would invite
39
+ callers to wrap redaction in try/except and skip it. A wrong *type* is a
40
+ programming error with no correct interpretation to fall back on.
41
+
42
+ - **The generic phone pattern claimed fragments of rejected identifiers.**
43
+ `Rijksregisternummer: 85.03.19-284.73` was reported as a `PHONE`. The Belgian
44
+ national-number pattern matched the whole value and failed its checksum; the
45
+ separator-tolerant phone pattern then took characters 3-14 of it.
46
+
47
+ A candidate covering only *part* of a rejected identifier is a fragment of
48
+ that identifier, not a separate entity, and is now dropped. An equal span is
49
+ left alone: that is two schemes competing for the whole value, and demoting
50
+ those is what previously handed a Swedish phone number to a Danish CPR.
51
+
52
+ Fragment detection ignores the declared country by design. It removes
53
+ candidates, so making it country-aware would let `countries` change which
54
+ spans are found — the invariant in `test_invariant_generation.py`.
55
+
56
+ No measurable cost: recall, precision and type-correct rate are unchanged on
57
+ all 152,300 corpus documents.
58
+
59
+ - **Identifiers glued to a non-ASCII letter were missed.** Python's `\b` is
60
+ Unicode-aware, so it saw no boundary between a Cyrillic or accented letter
61
+ and a digit: `ЕГН7523169263`, `PESELŁ44051401359`, `čísloř7401011233`,
62
+ `Steuerß12345678911` and `kodasž38605181232` were all detected by the
63
+ TypeScript SDK and silently missed by this one.
64
+
65
+ `\b` is now compiled to the *union* of the Unicode and ASCII readings.
66
+ Swapping to ASCII-only — the obvious fix — trades one silent miss for
67
+ another: it drops Greek and Cyrillic e-mail local parts, which are
68
+ deliberately supported. `re.ASCII` is unusable for the same reason, since it
69
+ would also narrow `\w`.
70
+
71
+ Costs about 20% latency on short documents from the added lookarounds.
72
+ Accuracy on the corpus is unchanged.
73
+
74
+ ### Added
75
+
76
+ - **`make check` and `make verify`.** One entry point for every check.
77
+ `make check` is what CI runs; `make verify` adds the corpus checks CI cannot
78
+ run, because the corpus lives outside the repository.
79
+
80
+ - `tests/sweep.py` — structural properties over ~187,000 documents: offsets
81
+ index the text, spans do not overlap, detection is deterministic, the cache
82
+ is transparent, and no country argument changes which spans are found. Both
83
+ ranking bugs above were found here; the twenty-document version that runs in
84
+ CI showed nothing.
85
+ - `scripts/parity.py` — do both SDKs mask the same *characters*, over whole
86
+ corpora. Conformance vectors pin named cases; this is the broad counterpart,
87
+ and it is how a 19,014-character gap was found that no vector showed.
88
+
89
+
90
+ ### Known issues
91
+
92
+ - A checksum-invalid identifier occupying a span no other detector claims can
93
+ still be reported under the wrong type — `7401011234` with `countries` `["CZ"]`
94
+ is masked as a Romanian `PHONE` at `countryConfidence` 0. The span *is*
95
+ redacted; only the label is wrong. The fragment rule above does not apply
96
+ because the spans are equal rather than nested.
97
+
3
98
  ## 0.3.2 (2026-07-29)
4
99
 
5
100
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: euredact
3
- Version: 0.3.2
3
+ Version: 0.3.3
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "euredact"
7
- version = "0.3.2"
7
+ version = "0.3.3"
8
8
  description = "European PII redaction SDK — rule engine for 31 countries"
9
9
  requires-python = ">=3.10"
10
10
  license = "Apache-2.0"
@@ -22,7 +22,7 @@ from __future__ import annotations
22
22
 
23
23
  from typing import Iterator
24
24
 
25
- __version__ = "0.3.2"
25
+ __version__ = "0.3.3"
26
26
 
27
27
  from euredact.rules.bic_registry import set_bic_registry
28
28
  from euredact.rules.context import DocumentContext
@@ -39,6 +39,31 @@ def _validate_custom_pattern(pattern: str) -> None:
39
39
  )
40
40
 
41
41
 
42
+ def check_country_arg(value: object, param: str) -> None:
43
+ """Reject a bare string where a list of country codes is expected.
44
+
45
+ ``countries="NL"`` is iterable, so Python walks it into the codes ``"N"``
46
+ and ``"L"``. Neither resolves, so nothing is declared — and every detection
47
+ that *does* carry a country is then flagged ``out_of_scope``. A caller
48
+ following the documented pattern of filtering on that field silently keeps
49
+ none of them, while ``redacted_text`` still looks perfectly correct. The
50
+ failure direction is "no PII here", from a one-character typo.
51
+
52
+ A wrong *code* is data and only warns (see ``CountryRegistry.warn_unknown``)
53
+ — raising there would invite callers to wrap redaction in try/except and
54
+ skip it. A wrong *type* is a programming error, surfaces immediately, and
55
+ has no correct interpretation to fall back on.
56
+ """
57
+ if isinstance(value, (str, bytes)):
58
+ shown = value.decode(errors="replace") if isinstance(value, bytes) else value
59
+ raise TypeError(
60
+ f"{param} must be a list of country codes, not a bare string. "
61
+ f"Pass {param}=[{shown!r}] rather than {param}={shown!r} — a string "
62
+ f"is iterated character by character, which silently declares "
63
+ f"nothing and flags every detection out_of_scope."
64
+ )
65
+
66
+
42
67
  class RuleEngine:
43
68
  """The core rule-based PII detection engine.
44
69
 
@@ -135,6 +160,9 @@ class RuleEngine:
135
160
  It is scored alongside this text's own evidence but is **not** returned,
136
161
  so a caller accumulating evidence never counts it twice.
137
162
  """
163
+ check_country_arg(country_codes, "countries")
164
+ check_country_arg(country_hint, "country_hint")
165
+
138
166
  raw_matches = self.generate_candidates(text)
139
167
 
140
168
  # The declared countries are a scoring signal from here on: they rank
@@ -226,16 +254,50 @@ class RuleEngine:
226
254
  # phone number — and demoting on it cost every PHONE candidate in the
227
255
  # document its rank, which is how a Dutch mobile came back as a Danish
228
256
  # NATIONAL_ID even after the digits were correctly ranked.
229
- # Zones are per entity type. A failed checksum means "these digits are
230
- # not a valid X" — that is evidence against X, not against the span.
231
- # Demoting every type at once meant Sweden's own personnummer checksum
232
- # failing on 0708787668 demoted the Swedish *phone* candidate for the
233
- # same digits, handing the span to a Danish CPR that happened to
234
- # validate. Worth 878 mistyped phone numbers on 30,000 documents.
257
+ # Two rules, because a failed checksum carries two different messages
258
+ # depending on how the spans line up.
259
+ #
260
+ # Same type, any containment: "these digits are not a valid X" is
261
+ # evidence against X, not against the span. Demoting *every* type on an
262
+ # equal span meant Sweden's own personnummer checksum failing on
263
+ # 0708787668 demoted the Swedish *phone* candidate for the same digits,
264
+ # handing the span to a Danish CPR that happened to validate — 878
265
+ # mistyped phone numbers per 30,000 documents.
266
+ #
267
+ # Any type, strict subset: a candidate covering only *part* of a
268
+ # rejected identifier is a fragment of it, not a different entity. The
269
+ # generic separator-tolerant phone pattern claimed characters 3-14 of
270
+ # the rejected Belgian national number in
271
+ # "Rijksregisternummer: 85.03.19-284.73" and reported it as a PHONE.
272
+ # This is the case the original suppression zones were written for —
273
+ # licence-plate fragments inside an invalid IBAN — which the per-type
274
+ # split above had given up along with the over-reach.
275
+ #
276
+ # An equal span is two schemes competing for the whole value; a strict
277
+ # subset is one scheme picking over another's wreckage.
278
+ corroborated: list[tuple[int, int, object]] = [
279
+ (start, end, etype)
280
+ for start, end, cc, etype in failed_spans
281
+ if not country_scores or cc in ("SHARED", "CUSTOM") or cc in country_scores
282
+ ]
283
+ corroborated.sort()
284
+
285
+ # Fragment detection deliberately ignores corroboration and uses every
286
+ # failed span, because it *removes* candidates and must therefore be
287
+ # country-blind. Gating it on the document's countries made the set of
288
+ # spans found depend on `countries=`, which is precisely invariant I1 —
289
+ # caught by tests/test_invariant_generation.py within seconds of being
290
+ # written. Demotion may be country-aware because it only reorders; a
291
+ # demoted candidate still claims a span nothing else wants.
292
+ #
293
+ # Kept unmerged: merging adjacent spans would invent strict-subset
294
+ # relationships that neither original span had.
295
+ all_failed = sorted((start, end) for start, end, _cc, _et in failed_spans)
296
+ strict_starts = [f[0] for f in all_failed]
297
+
235
298
  by_type: dict[object, list[tuple[int, int]]] = {}
236
- for start, end, cc, etype in failed_spans:
237
- if not country_scores or cc in ("SHARED", "CUSTOM") or cc in country_scores:
238
- by_type.setdefault(etype, []).append((start, end))
299
+ for start, end, etype in corroborated:
300
+ by_type.setdefault(etype, []).append((start, end))
239
301
 
240
302
  # Merge overlapping zones per type and index them for O(log n)
241
303
  # containment checks instead of O(n) per match.
@@ -274,7 +336,7 @@ class RuleEngine:
274
336
  # (priority, in_scope, start, end, match, prebuilt). in_scope ranks
275
337
  # candidates within a priority tier; it never removes any.
276
338
  candidates: list[
277
- tuple[int, float, int, int, int, RawMatch | None, Detection | None]
339
+ tuple[int, int, float, int, int, int, RawMatch | None, Detection | None]
278
340
  ] = []
279
341
  for match, has_valid_validator in validated:
280
342
  # A validator-less match inside a failed-validation span used to be
@@ -295,9 +357,31 @@ class RuleEngine:
295
357
  zone_starts, zone_ends = zone
296
358
  idx = bisect.bisect_right(zone_starts, match.start) - 1
297
359
  demoted = idx >= 0 and zone_ends[idx] >= match.end
360
+ # Strict subset of a rejected identifier, whatever its type: the
361
+ # candidate covers only part of something already recognised and
362
+ # rejected, so it is a fragment of that, not a separate entity.
363
+ #
364
+ # Dropped rather than demoted. Demotion cannot help here: nothing
365
+ # else wants a fragment, so a demoted candidate still wins its span
366
+ # by default — the generic phone pattern kept claiming characters
367
+ # 3-14 of a rejected Belgian national number. Dropping is safe in a
368
+ # way that whole-span deletion was not, because the characters stay
369
+ # covered by whatever claims the enclosing span, or by nothing —
370
+ # and "nothing" is the right answer for the tail of a rejected
371
+ # identifier.
372
+ fragment = False
373
+ if match.pattern_def.validator is None and not demoted:
374
+ hi = bisect.bisect_right(strict_starts, match.start)
375
+ for zs, ze in all_failed[:hi]:
376
+ if match.end <= ze and (match.start > zs or match.end < ze):
377
+ fragment = True
378
+ break
379
+ if fragment:
380
+ continue
298
381
 
299
382
  if demoted:
300
383
  priority = -1
384
+ blind_priority = -1
301
385
  elif has_valid_validator:
302
386
  # A passing checksum normally outranks everything. But a
303
387
  # checksum only says the digits fit *some* national scheme, and
@@ -321,28 +405,32 @@ class RuleEngine:
321
405
  or match.country_code in country_scores
322
406
  )
323
407
  priority = 3 if vouched else 1
408
+ blind_priority = 3
324
409
  elif match.country_code == "CUSTOM":
325
410
  priority = 2
411
+ blind_priority = 2
326
412
  elif match.pattern_def.entity_type == EntityType.POSTAL_CODE:
327
413
  # A bare digit run is the weakest evidence in the engine and
328
414
  # must never re-cut a span a structured detector claims
329
415
  # (PHONE, SSN, NATIONAL_ID, BANK_ACCOUNT, VAT...). Postal
330
416
  # codes take unclaimed spans only, whatever the span lengths.
331
417
  priority = 0
418
+ blind_priority = 0
332
419
  else:
333
420
  priority = 1
421
+ blind_priority = 1
334
422
 
335
423
  in_scope = 1 if (declared is None or match.country_code in declared) else 0
336
424
  country_score = country_scores.get(match.country_code, 0.0)
337
425
  candidates.append(
338
- (priority, country_score, in_scope,
426
+ (blind_priority, priority, country_score, in_scope,
339
427
  match.start, match.end, match, None)
340
428
  )
341
429
 
342
430
  # Structural detectors (JSON field names, CSV headers). These arrive as
343
431
  # finished Detections, carry no RawMatch, and are not suppressed.
344
432
  for d in detect_structural_dob(text):
345
- candidates.append((1, 0.0, 1, d.start, d.end, None, d))
433
+ candidates.append((1, 1, 0.0, 1, d.start, d.end, None, d))
346
434
 
347
435
  # Deduplicate: validated > custom > regex-only, then longer wins
348
436
  detections = self._deduplicate(text, candidates, declared)
@@ -366,7 +454,7 @@ class RuleEngine:
366
454
  def _deduplicate(
367
455
  text: str,
368
456
  candidates: list[
369
- tuple[int, float, int, int, int, RawMatch | None, Detection | None]
457
+ tuple[int, int, float, int, int, int, RawMatch | None, Detection | None]
370
458
  ],
371
459
  declared: set[str] | None = None,
372
460
  ) -> list[Detection]:
@@ -388,24 +476,51 @@ class RuleEngine:
388
476
  if not candidates:
389
477
  return []
390
478
 
391
- # Sort by (priority, span_length) descending. Stable, so ties fall back
392
- # to pattern registration order, as before.
479
+ # Ordering between *different* spans is country-blind; the country
480
+ # only ever picks the winner among candidates covering the *same* span.
481
+ #
482
+ # That split is what makes invariant I1 structural rather than merely
483
+ # tested. Country-aware promotion had twice decided which span won:
484
+ # under countries=["NL"] a validated national ID outranked a longer IP
485
+ # address and exposed ".77", and two equal-length candidates at
486
+ # different offsets swapped places. Both were found by sweeping the
487
+ # corpus, not by reasoning, and both are impossible now — the key below
488
+ # is country-blind up to and including `start`, and (length, start)
489
+ # already identifies a span uniquely.
490
+ #
491
+ # Ordering, in words: longest first (a shorter match winning re-cuts a
492
+ # longer one and leaks the remainder), then the structural tier, then
493
+ # leftmost. Only after a span is fixed do the country-aware fields
494
+ # break the tie, deciding the label rather than the extent.
495
+ #
496
+ # The structural tier is a property of the *span*, not of the candidate:
497
+ # the best tier anything claiming those exact characters can offer. Used
498
+ # per candidate it would also discriminate *within* a span, where the
499
+ # country-aware tier is supposed to have the final word — that silently
500
+ # disabled country inference entirely, handing every ambiguous value to
501
+ # whichever foreign scheme happened to validate.
502
+ span_tier: dict[tuple[int, int], int] = {}
503
+ for c in candidates:
504
+ key = (c[4], c[5])
505
+ if c[0] > span_tier.get(key, -2):
506
+ span_tier[key] = c[0]
507
+
393
508
  sorted_cands = sorted(
394
509
  candidates,
395
- # Span length outranks scope: preferring the declared country over
396
- # the longest match can truncate an entity. Measured: with
397
- # countries=["BE"], the Belgian phone pattern claims 11 of the 14
398
- # characters of "06 12 34 56 78" and leaves three digits exposed.
399
- # Scope only breaks ties between candidates of equal length, so it
400
- # can change WHICH country is attributed, never WHAT is masked.
401
- key=lambda c: (c[0], c[4] - c[3], c[1], c[2]),
402
- reverse=True,
510
+ key=lambda c: (
511
+ -(c[5] - c[4]), # longest first } country-blind
512
+ -span_tier[(c[4], c[5])], # best tier here } — decides
513
+ c[4], # leftmost } WHICH span
514
+ -c[1], # country priority } country-aware
515
+ -c[2], # country evidence } — decides the
516
+ -c[3], # declared scope } LABEL only
517
+ ),
403
518
  )
404
519
  result: list[Detection] = []
405
520
  occupied: set[int] = set()
406
521
  span_verdicts: dict[tuple[object, int, int], bool] = {}
407
522
 
408
- for _priority, _score, in_scope, start, end, match, prebuilt in sorted_cands:
523
+ for _bp, _prio, _score, in_scope, start, end, match, prebuilt in sorted_cands:
409
524
  if any(p in occupied for p in range(start, end)):
410
525
  continue
411
526
 
@@ -90,6 +90,68 @@ def _silence_stderr() -> Iterator[None]:
90
90
  os.close(saved_fd)
91
91
 
92
92
 
93
+ # Python's \b is Unicode-aware: a Cyrillic or accented letter counts as a word
94
+ # character, so "\b\d{10}\b" does not match the digits in "ЕГН7523169263". JS's
95
+ # \b is ASCII-only, so Node matched and redacted exactly those cases and Python
96
+ # did not — five of five, across the alphabets this engine targets.
97
+ #
98
+ # Which rule is "right" is arguable: Python's is self-consistent, JS's is
99
+ # script-dependent. But for a redaction tool the argument is settled by the
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.
103
+ #
104
+ # 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.
107
+ #
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:
110
+ #
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)
115
+ #
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
+ )
124
+
125
+
126
+ def _ascii_word_boundaries(pattern: str) -> str:
127
+ """Rewrite every ``\b`` in *pattern* to the union boundary above.
128
+
129
+ Skips escaped backslashes and character classes, where ``\b`` means a
130
+ backspace rather than a boundary. Neither occurs in the shipped patterns
131
+ today; the scan is here so that adding one cannot silently corrupt it.
132
+ """
133
+ out: list[str] = []
134
+ i = 0
135
+ in_class = False
136
+ while i < len(pattern):
137
+ ch = pattern[i]
138
+ 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)
142
+ else:
143
+ out.append(pattern[i:i + 2])
144
+ i += 2
145
+ continue
146
+ if ch == "[":
147
+ in_class = True
148
+ elif ch == "]":
149
+ in_class = False
150
+ out.append(ch)
151
+ i += 1
152
+ return "".join(out)
153
+
154
+
93
155
  def _max_match_width(pattern: str) -> int:
94
156
  """Longest string *pattern* can match, or a huge number if unbounded.
95
157
 
@@ -149,14 +211,14 @@ class MultiPatternMatcher:
149
211
 
150
212
  def add_pattern(self, pattern_def: PatternDef, country_code: str) -> None:
151
213
  """Add a single pattern."""
152
- compiled = re.compile(pattern_def.pattern, re.UNICODE)
214
+ compiled = re.compile(_ascii_word_boundaries(pattern_def.pattern), re.UNICODE)
153
215
  self._patterns.append((compiled, pattern_def, country_code))
154
216
  self._compiled = False
155
217
 
156
218
  def add_country(self, config: CountryConfig) -> None:
157
219
  """Add a country's patterns."""
158
220
  for pdef in config.patterns:
159
- compiled = re.compile(pdef.pattern, re.UNICODE)
221
+ compiled = re.compile(_ascii_word_boundaries(pdef.pattern), re.UNICODE)
160
222
  self._patterns.append((compiled, pdef, config.code))
161
223
  self._compiled = False
162
224
 
@@ -176,6 +238,9 @@ class MultiPatternMatcher:
176
238
  codes and secrets), and those that can match further than the window
177
239
  overlap, which the sliding window could otherwise straddle.
178
240
  """
241
+ # The *original* source goes to RE2, not the rewritten one: RE2's \b is
242
+ # already ASCII-only, so the semantics match, while the rewrite uses
243
+ # lookbehind that RE2 cannot compile.
179
244
  automaton = _re2.Set.SearchSet()
180
245
  self._re2_slot = []
181
246
  added = 0
@@ -10,7 +10,7 @@ from typing import Iterator
10
10
  from euredact.cache import ResultCache
11
11
  from euredact.normalizer import map_offset_to_original, normalize
12
12
  from euredact.rules.context import DocumentContext
13
- from euredact.rules.engine import RuleEngine
13
+ from euredact.rules.engine import RuleEngine, check_country_arg
14
14
  from euredact.rules.evidence import weights_to_ranking
15
15
  from euredact.types import EntityType, RedactResult
16
16
 
@@ -112,7 +112,12 @@ class EuRedact:
112
112
  engine applies keyword and structural (JSON/CSV header)
113
113
  checks before emitting a date detection.
114
114
  """
115
- # Step 0: Input size guard
115
+ # Step 0: argument and input-size guards. The country check runs here
116
+ # as well as in the engine so that a bare string is rejected before any
117
+ # work happens, and on every entry point that funnels through redact().
118
+ check_country_arg(countries, "countries")
119
+ check_country_arg(country_hint, "country_hint")
120
+
116
121
  if len(text) > self._max_input_length:
117
122
  raise ValueError(
118
123
  f"Input text length ({len(text):,} chars) exceeds the maximum "