ppuzio commited on
Commit
ee88d62
·
verified ·
1 Parent(s): c6b0914

2.0.3: values cut or preceded by a placeholder

Browse files
Files changed (6) hide show
  1. CHANGELOG.md +18 -0
  2. README.md +8 -7
  3. hybrid.json +2 -2
  4. nergal.py +2 -2
  5. scrub_pii.py +42 -6
  6. test_nergal.py +54 -2
CHANGELOG.md CHANGED
@@ -8,6 +8,24 @@ Semver for this island:
8
 
9
  Accuracy is 841-dev, union at 0.95. Through 1.1.2 the gold has 354 spans; from 1.2.0 it is restated to phone policy v3 (315 spans), and the two are not comparable. A version that changes those numbers must update `hybrid.json` `eval` and the tables below.
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  ## 2.0.2
12
 
13
  Same NERGAL weights, threshold and API. Rules SHA `205a253b…`: an unlabelled PESEL span is marked `bare`; every rule span keeps its start, end and label. With one union change in `nergal.py`, a labelled identifier keeps `[PII]`.
 
8
 
9
  Accuracy is 841-dev, union at 0.95. Through 1.1.2 the gold has 354 spans; from 1.2.0 it is restated to phone policy v3 (315 spans), and the two are not comparable. A version that changes those numbers must update `hybrid.json` `eval` and the tables below.
10
 
11
+ ## 2.0.3
12
+
13
+ Same NERGAL weights, threshold, union and API. Rules SHA `4d054723…`: input that already holds a `[PII]`, `[PHONE]` or `[Telefon]` placeholder.
14
+
15
+ - **A NIP or REGON label reaches past one placeholder.** `NIP [PII] 526-000-12-46`, `NIP: [PII], 526-000-12-46.` and `REGON [PII] 123456785` now mask the number; in 2.0.2 the placeholder ended the label, and a bare NIP or REGON is never masked. The value still needs a valid checksum, and a second placeholder ends the reach. PESEL, account and email need no label, so they were already masked. The placeholder can also be one this scrub made: a second checksum-valid NIP or REGON after a comma or space is masked too (`NIP 526-000-12-46, 5213017228` → `NIP [PII], [PII]`); after `i` or `/` it is not.
16
+ - **A labelled value cut by a placeholder is masked.** A run of digit groups and placeholders joined by single spaces, tabs or hyphens, with 5+ digits and a `PESEL`, `NIP` or `REGON` label up to 64 characters before it, has every digit fragment masked `[PII]`: `PESEL 8503[PII]1212343` → `PESEL [PII][PII][PII]`, `NIP 526-000-[PII]-46` → `NIP [PII]-[PII]-[PII]`. A fragment has no checksum to test, so the label is required.
17
+ - **A phone cut by a placeholder is masked only after a phone cue.** The same run after a contact or phone label gives `[PHONE]` fragments: `tel. 601 [PII] 200 300` → `tel. [PHONE] [PII] [PHONE]`, `tel. 601 200 [PHONE]` → `tel. [PHONE] [PHONE]`. Spaced digit groups next to a placeholder are also pages, amounts and counts, so without a cue `601 [PII] 200 300` is left as is; a postcode, date or amount fragment never counts as a phone.
18
+ - **Existing placeholders and offsets are kept.** Only digits are replaced; placeholders and separators stay, and span offsets refer to the input text. This pass runs before every other rule, so every placeholder it sees came with the input.
19
+
20
+ Still missed: an unlabelled split value without a cue (`8503[PII]1212343`, `601 [PII] 200 300`); a run that starts with a placeholder (`tel. [PHONE] 200 300`); a run with fewer than 5 digits (`PESEL 1234 [PII]`).
21
+
22
+ 841-dev, names off: unchanged from 2.0.2 (no 841-dev passage holds a placeholder). Release gates (`artifacts/nergal_2_0_3_placeholder/`, frozen before scoring, stored spans): the 1,837 bench rows, the 1,000-document Dynaword runtime sample (names off and on) and 41,204 FineWeb miss-hunt passages, rule spans and output identical, 0 masked characters lost; every on-disk Dynaword source, full text, rule spans identical in all 3,378,438 documents, the 997 with a `[PII]` or `[Telefon]` placeholder included. No labelled set or corpus document changes, so the new behaviour is exercised only by the invented cases in `test_nergal.py`.
23
+
24
+ | Version | Whole /315 | Residual | Rules FP | Union FP | Char P | Char R | What changed |
25
+ |---|---:|---:|---:|---:|---:|---:|---|
26
+ | 2.0.2, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | A rule's label wins on its characters |
27
+ | 2.0.3, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | Values cut or preceded by a placeholder |
28
+
29
  ## 2.0.2
30
 
31
  Same NERGAL weights, threshold and API. Rules SHA `205a253b…`: an unlabelled PESEL span is marked `bare`; every rule span keeps its start, end and label. With one union change in `nergal.py`, a labelled identifier keeps `[PII]`.
README.md CHANGED
@@ -16,7 +16,7 @@ tags:
16
  - hybrid
17
  ---
18
 
19
- # NERGAL 2.0.2
20
 
21
  **Named Entity Recognition with Grounded Additive Labels**
22
 
@@ -66,9 +66,9 @@ NERGAL is not designed to remove:
66
 
67
  These are intended exclusions; false positives can still mask some of this content.
68
 
69
- ### Known gaps in 2.0.2
70
 
71
- Unlabelled phones and identifiers (the rules take a bare phone only in the grouped national forms `601 234 567` and `22 123 45 67`; a plain `601234567` needs a label or the model), unusual formatting and damaged text can escape detection. **VINs and obfuscated emails** (such as `name (at) domain.pl`) are approved annotation targets, but that approval alone does not establish reliable support in the released 2.0.2 model. Do not rely on it to remove them consistently. An email with a lowercase word glued onto its domain (`jan@firma.plkontakt`) is masked together with that word, and text glued before an address can be masked with it. A phone written as short parts joined by two slashes (`12 / 345 / 678`) is left as text. An identifier with a `[PII]` / `[PHONE]` / `[Telefon]` placeholder inside it or right before it can be missed. Names-on gaps are under [Names](#names).
72
 
73
  ## Versions
74
 
@@ -80,7 +80,8 @@ Unlabelled phones and identifiers (the rules take a bare phone only in the group
80
  | 1.2.0 | 303 | 9 | 24 | 80 | 98.59% | 97.83% | Phone policy v3 at mask time, rules and model spans |
81
  | 2.0.0, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | `[PHONE]` replaces `[Telefon]`; opt-in `[PERSON]` names |
82
  | 2.0.1, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | A `[PERSON]` placeholder ends an other-number label |
83
- | **2.0.2, names off** | **303** | **9** | **24** | **80** | **98.59%** | **97.83%** | A rule's label wins on its characters |
 
84
 
85
  1.0.0–1.1.2 were scored on the earlier 354-span gold (whole 323 → 326, union FP 133 → 80); per-version numbers are in `CHANGELOG.md`.
86
 
@@ -111,7 +112,7 @@ Names off hides no name. A component is the independent unit, covered when every
111
 
112
  ## Compared with other systems
113
 
114
- Same split and gold. Every model runs through the NERGAL recipe with names off: its spans at 0.95, phone spans cut by phone policy v3, unioned with the rules. The rows were scored with the 1.2.0 rules; 2.0.0, 2.0.1 and 2.0.2 give the same rule spans on this split. **Naked** is the model alone. **Residual** is passages with any gold character left unmasked. Character scores are gold vs masked characters.
115
 
116
  | System | Mode | Whole /315 | Residual | False chars | Char P | Char R |
117
  |---|---|---:|---:|---:|---:|---:|
@@ -121,9 +122,9 @@ Same split and gold. Every model runs through the NERGAL recipe with names off:
121
  | Fine-tuned GLiNER (previous incumbent) | naked | 263 | 28 | 15 | 99.70% | 88.80% |
122
  | Fine-tuned GLiNER (previous incumbent) | ∪ regex | 299 | 12 | 39 | 99.30% | 97.18% |
123
  | XLM-R epoch 5 | naked | 275 | 28 | 67 | 98.72% | 90.25% |
124
- | **NERGAL 2.0.2, names off** | **∪ regex** | **303** | **9** | **80** | **98.59%** | **97.83%** |
125
 
126
- Zero-shot [GLiNER 2.5](https://huggingface.co/fastino/gliner2.5-multi-v1) is not competitive, especially on non-phone PII (7/185 whole, naked). The fine-tuned GLiNER that NERGAL replaced is the more precise union (39 false characters vs 80) and the stronger phone model (128/130 whole, naked, vs 115), but it masks 4 fewer values whole and leaves 12 residual passages vs 9; XLM-R is the stronger model on other PII (160/185 vs 135). NERGAL 2.0.2 wholly masks 124/130 phones and 179/185 other PII, each under its own placeholder (`[PHONE]` / `[PII]`); exact-span precision 91.05%, recall 93.65%, F1 92.33%.
127
 
128
  XLM-R and epoch 5 were chosen in September 2026, on the earlier gold and before the phone-policy cut. Fine-tuned GLiNER, HerBERT-large and XLM-R-large were trained on the same split; only XLM-R passed the content-preservation gate, and epoch 5 at 0.95 (seed `202609160`) was the only one of 133 epoch/threshold points that covered more than the fine-tuned GLiNER union of that time while adding no false-mask characters it did not already make. Under the 1.2.0 rules, gold and phone cut, that no longer holds: the GLiNER union makes fewer false masks (table above).
129
 
 
16
  - hybrid
17
  ---
18
 
19
+ # NERGAL 2.0.3
20
 
21
  **Named Entity Recognition with Grounded Additive Labels**
22
 
 
66
 
67
  These are intended exclusions; false positives can still mask some of this content.
68
 
69
+ ### Known gaps in 2.0.3
70
 
71
+ Unlabelled phones and identifiers (the rules take a bare phone only in the grouped national forms `601 234 567` and `22 123 45 67`; a plain `601234567` needs a label or the model), unusual formatting and damaged text can escape detection. **VINs and obfuscated emails** (such as `name (at) domain.pl`) are approved annotation targets, but that approval alone does not establish reliable support in the released 2.0.3 model. Do not rely on it to remove them consistently. An email with a lowercase word glued onto its domain (`jan@firma.plkontakt`) is masked together with that word, and text glued before an address can be masked with it. A phone written as short parts joined by two slashes (`12 / 345 / 678`) is left as text. Input that already holds a `[PII]` / `[PHONE]` / `[Telefon]` placeholder: a NIP or REGON label reaches past one placeholder to a checksum-valid value, and a value cut by a placeholder is masked digit group by digit group only after a `PESEL` / `NIP` / `REGON` label (`[PII]`) or a phone cue (`[PHONE]`). Without either (`8503[PII]1212343`, `601 [PII] 200 300`), with fewer than 5 digits, or when the run starts with a placeholder (`tel. [PHONE] 200 300`), the fragments stay. Names-on gaps are under [Names](#names).
72
 
73
  ## Versions
74
 
 
80
  | 1.2.0 | 303 | 9 | 24 | 80 | 98.59% | 97.83% | Phone policy v3 at mask time, rules and model spans |
81
  | 2.0.0, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | `[PHONE]` replaces `[Telefon]`; opt-in `[PERSON]` names |
82
  | 2.0.1, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | A `[PERSON]` placeholder ends an other-number label |
83
+ | 2.0.2, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | A rule's label wins on its characters |
84
+ | **2.0.3, names off** | **303** | **9** | **24** | **80** | **98.59%** | **97.83%** | Values cut or preceded by a placeholder |
85
 
86
  1.0.0–1.1.2 were scored on the earlier 354-span gold (whole 323 → 326, union FP 133 → 80); per-version numbers are in `CHANGELOG.md`.
87
 
 
112
 
113
  ## Compared with other systems
114
 
115
+ Same split and gold. Every model runs through the NERGAL recipe with names off: its spans at 0.95, phone spans cut by phone policy v3, unioned with the rules. The rows were scored with the 1.2.0 rules; 2.0.0 through 2.0.3 give the same rule spans on this split. **Naked** is the model alone. **Residual** is passages with any gold character left unmasked. Character scores are gold vs masked characters.
116
 
117
  | System | Mode | Whole /315 | Residual | False chars | Char P | Char R |
118
  |---|---|---:|---:|---:|---:|---:|
 
122
  | Fine-tuned GLiNER (previous incumbent) | naked | 263 | 28 | 15 | 99.70% | 88.80% |
123
  | Fine-tuned GLiNER (previous incumbent) | ∪ regex | 299 | 12 | 39 | 99.30% | 97.18% |
124
  | XLM-R epoch 5 | naked | 275 | 28 | 67 | 98.72% | 90.25% |
125
+ | **NERGAL 2.0.3, names off** | **∪ regex** | **303** | **9** | **80** | **98.59%** | **97.83%** |
126
 
127
+ Zero-shot [GLiNER 2.5](https://huggingface.co/fastino/gliner2.5-multi-v1) is not competitive, especially on non-phone PII (7/185 whole, naked). The fine-tuned GLiNER that NERGAL replaced is the more precise union (39 false characters vs 80) and the stronger phone model (128/130 whole, naked, vs 115), but it masks 4 fewer values whole and leaves 12 residual passages vs 9; XLM-R is the stronger model on other PII (160/185 vs 135). NERGAL 2.0.3 wholly masks 124/130 phones and 179/185 other PII, each under its own placeholder (`[PHONE]` / `[PII]`); exact-span precision 91.05%, recall 93.65%, F1 92.33%.
128
 
129
  XLM-R and epoch 5 were chosen in September 2026, on the earlier gold and before the phone-policy cut. Fine-tuned GLiNER, HerBERT-large and XLM-R-large were trained on the same split; only XLM-R passed the content-preservation gate, and epoch 5 at 0.95 (seed `202609160`) was the only one of 133 epoch/threshold points that covered more than the fine-tuned GLiNER union of that time while adding no false-mask characters it did not already make. Under the 1.2.0 rules, gold and phone cut, that no longer holds: the GLiNER union makes fewer false masks (table above).
130
 
hybrid.json CHANGED
@@ -1,12 +1,12 @@
1
  {
2
  "full_name": "Named Entity Recognition with Grounded Additive Labels",
3
  "hub_id": "SlayerLab/NERGAL",
4
- "version": "2.0.2",
5
  "mode": "rules_union",
6
  "epoch": 5,
7
  "seed": 202609160,
8
  "threshold": 0.95,
9
- "rules_sha256": "205a253b7f496b00a3a7644b9588b6c22d94d8ebb223d2d9d8a4d6326bdcccdb",
10
  "eval": {
11
  "split": "841-dev",
12
  "gold_amendments": [
 
1
  {
2
  "full_name": "Named Entity Recognition with Grounded Additive Labels",
3
  "hub_id": "SlayerLab/NERGAL",
4
+ "version": "2.0.3",
5
  "mode": "rules_union",
6
  "epoch": 5,
7
  "seed": 202609160,
8
  "threshold": 0.95,
9
+ "rules_sha256": "4d0547238131908ca498a23607a7877512b2fb6022c484171334af1e604fc95a",
10
  "eval": {
11
  "split": "841-dev",
12
  "gold_amendments": [
nergal.py CHANGED
@@ -17,13 +17,13 @@ import scrub_pii
17
  from scrub_pii import LEGACY_PHONE_TAG, PERSON_TAG, PHONE_TAG, PII_TAG
18
 
19
  HUB_ID = 'SlayerLab/NERGAL'
20
- VERSION = '2.0.2'
21
  GAPS = ['[PII_SPACE]', '[PII_BREAK]']
22
  GAP_IDS = [250002, 250003]
23
  BIO_LABELS = ['O', 'B-phone', 'I-phone', 'B-pii', 'I-pii']
24
  LABELS = ['phone', 'pii']
25
  THRESHOLD = 0.95
26
- RULES_SHA = '205a253b7f496b00a3a7644b9588b6c22d94d8ebb223d2d9d8a4d6326bdcccdb'
27
  DTYPES = ('float32', 'float16')
28
  MARKERS = (PHONE_TAG, PII_TAG, PERSON_TAG, LEGACY_PHONE_TAG)
29
  TAGS = {'person': PERSON_TAG, 'pii': PII_TAG, 'phone': PHONE_TAG} # ascending rank; a non-bare rule span outranks every model and person span
 
17
  from scrub_pii import LEGACY_PHONE_TAG, PERSON_TAG, PHONE_TAG, PII_TAG
18
 
19
  HUB_ID = 'SlayerLab/NERGAL'
20
+ VERSION = '2.0.3'
21
  GAPS = ['[PII_SPACE]', '[PII_BREAK]']
22
  GAP_IDS = [250002, 250003]
23
  BIO_LABELS = ['O', 'B-phone', 'I-phone', 'B-pii', 'I-pii']
24
  LABELS = ['phone', 'pii']
25
  THRESHOLD = 0.95
26
+ RULES_SHA = '4d0547238131908ca498a23607a7877512b2fb6022c484171334af1e604fc95a'
27
  DTYPES = ('float32', 'float16')
28
  MARKERS = (PHONE_TAG, PII_TAG, PERSON_TAG, LEGACY_PHONE_TAG)
29
  TAGS = {'person': PERSON_TAG, 'pii': PII_TAG, 'phone': PHONE_TAG} # ascending rank; a non-bare rule span outranks every model and person span
scrub_pii.py CHANGED
@@ -121,10 +121,12 @@ _DOCUMENT_RE = re.compile(
121
  r"[:=-]?[ \t]*)"
122
  r"(?P<number>[A-Z]{3}[ \t-]?[0-9OIl]{6}|[A-Z]{2}[ \t-]?[0-9OIl]{7}|[A-Z][ \t-]?[0-9]{6,9}|[0-9]{4,12})\b", re.I,
123
  )
124
- _LABELLED_ID_RE = re.compile(
125
- r"\b(?P<label>(?P<kind>PESEL|NIP|REGON)\b"
126
  r"(?:[ \t]+(?:Gminy|Powiatu|Miasta|firmy)(?:[ \t]+[^\W\d_][^\W\d_-]*){0,4})?[ \t]{0,8}"
127
- r"(?:(?:nr\.?|numer)[ \t]{0,8})?[:=.\-]?[ \t]{0,8}(?:\r?\n[ \t]{0,8})?)"
 
 
128
  r"(?P<number>(?:PL[ \t]*)?(?:[0-9OIl]{9,14}|\d(?:[ \t-]?\d){8,13}))"
129
  r"(?!\w|[ \t-]*\d)", re.I,
130
  )
@@ -140,8 +142,18 @@ _ID_LABEL_END = r"[ \t]{0,8}(?:(?:nr\.?|numer)[ \t]{0,8})?[:=.\-]?[ \t]{0,8}(?:\
140
  # Form labels may carry footnote asterisks and one short digit-free gloss: "NIP (Wykonawcy)**:".
141
  # Only checksum-gated NIP/REGON take it; KRS has no checksum.
142
  _ID_LABEL_NOTE = r"(?:[ \t]{0,8}\*{1,3})?(?:[ \t]{0,8}\([^()\d\n]{1,40}\))?(?:[ \t]{0,8}\*{1,3})?"
143
- _NIP_LABEL_RE = re.compile(r"\bNIP\b" + _ID_LABEL_NOTE + _ID_LABEL_END, re.I)
144
- _REGON_LABEL_RE = re.compile(r"\bREGON\b" + _ID_LABEL_NOTE + _ID_LABEL_END, re.I)
 
 
 
 
 
 
 
 
 
 
145
  _KRS_RE = re.compile(r"\b\d{6,10}(?!\w|[ \t-]*\d)")
146
  # Abbreviation or written-out register name, optionally "pod nr/numerem".
147
  _KRS_LABEL_RE = re.compile(
@@ -920,6 +932,30 @@ def scrub_pii(text: str, *, spans: list | None = None) -> tuple[str, dict[str, i
920
  counts['pin'] += 1
921
  return m['label'] + mask(m.start('number'), m.end('number'), PII_TAG)
922
  return _PIN_RE.sub(replace, value), None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
923
  apply(pins)
924
  foreign = apply(_replace_checked, _IBAN_RE, PII_TAG, _iban_ok)
925
  bare = apply(_replace_checked, _ACCOUNT_RE, PII_TAG, _iban_ok)
@@ -951,7 +987,7 @@ def scrub_pii(text: str, *, spans: list | None = None) -> tuple[str, dict[str, i
951
  counts["email"] += apply(_replace_checked, _EMAIL_RE, PII_TAG, lambda _: True)
952
  counts["email"] += apply(_replace_checked, _EMAIL_FRAGMENT_RE, PII_TAG,
953
  lambda _: True, _EMAIL_LABEL_RE)
954
- counts["phone"] = apply(_replace_phones)
955
  # Contact context takes precedence over coincidental PESEL checksums in
956
  # foreign phone numbers; explicitly labelled identifiers were handled first.
957
  first = len(spans) if spans is not None else 0
 
121
  r"[:=-]?[ \t]*)"
122
  r"(?P<number>[A-Z]{3}[ \t-]?[0-9OIl]{6}|[A-Z]{2}[ \t-]?[0-9OIl]{7}|[A-Z][ \t-]?[0-9]{6,9}|[0-9]{4,12})\b", re.I,
123
  )
124
+ _LABELLED_ID_LABEL = (
125
+ r"\b(?P<kind>PESEL|NIP|REGON)\b"
126
  r"(?:[ \t]+(?:Gminy|Powiatu|Miasta|firmy)(?:[ \t]+[^\W\d_][^\W\d_-]*){0,4})?[ \t]{0,8}"
127
+ r"(?:(?:nr\.?|numer)[ \t]{0,8})?[:=.\-]?[ \t]{0,8}(?:\r?\n[ \t]{0,8})?")
128
+ _LABELLED_ID_RE = re.compile(
129
+ r"(?P<label>" + _LABELLED_ID_LABEL + r")"
130
  r"(?P<number>(?:PL[ \t]*)?(?:[0-9OIl]{9,14}|\d(?:[ \t-]?\d){8,13}))"
131
  r"(?!\w|[ \t-]*\d)", re.I,
132
  )
 
142
  # Form labels may carry footnote asterisks and one short digit-free gloss: "NIP (Wykonawcy)**:".
143
  # Only checksum-gated NIP/REGON take it; KRS has no checksum.
144
  _ID_LABEL_NOTE = r"(?:[ \t]{0,8}\*{1,3})?(?:[ \t]{0,8}\([^()\d\n]{1,40}\))?(?:[ \t]{0,8}\*{1,3})?"
145
+ # One placeholder on the same line does not end their reach (2.0.3): "NIP [PII] 526-000-12-46", "NIP: [PII], 526…".
146
+ _ID_LABEL_SKIP = r"(?:[ \t]{0,8}[:=.\-]?[ \t]{0,8}(?:" + _BOUNDARY + r")[ \t]{0,3}[,;]?)?"
147
+ _NIP_LABEL_RE = re.compile(r"\bNIP\b" + _ID_LABEL_NOTE + _ID_LABEL_SKIP + _ID_LABEL_END, re.I)
148
+ _REGON_LABEL_RE = re.compile(r"\bREGON\b" + _ID_LABEL_NOTE + _ID_LABEL_SKIP + _ID_LABEL_END, re.I)
149
+ # A value cut by a placeholder (2.0.3): digit groups and placeholders joined by single spaces, tabs or hyphens,
150
+ # starting with a digit ("8503[PII]1212343", "601 [PII] 200 300", "526-000-[PII]-46"). A fragment has no checksum, so
151
+ # it is masked only after a PESEL/NIP/REGON label ([PII]) or a phone cue ([PHONE]), and only with 5+ digits in all.
152
+ _SPLIT_RE = re.compile(r"(?<![\w\]])(?:\+[ \t]?)?\d+(?:[ \t-]?(?:\d+|" + _BOUNDARY + r"))++(?!\w|[.,:/]\d)")
153
+ _SPLIT_FRAGMENT_RE = re.compile(r"(?:\+[ \t]?)?\d+(?:[ \t-]\d+)*")
154
+ _SPLIT_LABEL_RE = re.compile(_LABELLED_ID_LABEL + r"\Z", re.I)
155
+ _SPLIT_NOT_PHONE_RE = re.compile(r"\d{2}-\d{3}|\d{4}-\d{2}-\d{2}|\d{2}-\d{2}-\d{4}") # postcode, dates
156
+ _SPLIT_MIN_DIGITS = 5
157
  _KRS_RE = re.compile(r"\b\d{6,10}(?!\w|[ \t-]*\d)")
158
  # Abbreviation or written-out register name, optionally "pod nr/numerem".
159
  _KRS_LABEL_RE = re.compile(
 
932
  counts['pin'] += 1
933
  return m['label'] + mask(m.start('number'), m.end('number'), PII_TAG)
934
  return _PIN_RE.sub(replace, value), None
935
+ # First, so every placeholder it sees came with the input, not from this run's own masks.
936
+ def split_values(value, *, mask):
937
+ def replace(m):
938
+ run, start = m[0], m.start()
939
+ fragments = list(_SPLIT_FRAGMENT_RE.finditer(run))
940
+ if (not re.search(_BOUNDARY, run) or len(_digits(run)) < _SPLIT_MIN_DIGITS
941
+ or _is_amount(value, start, m.end())):
942
+ return run
943
+ label = _SPLIT_LABEL_RE.search(value, max(0, start - 64), start)
944
+ if label:
945
+ tag, kind = PII_TAG, label['kind'].lower()
946
+ elif (_phone_context(value, start, run)
947
+ and not any(_SPLIT_NOT_PHONE_RE.fullmatch(f[0]) for f in fragments)):
948
+ tag, kind = PHONE_TAG, 'phone'
949
+ else:
950
+ return run
951
+ counts[kind] += 1
952
+ out, pos = [], 0
953
+ for f in fragments:
954
+ out += [run[pos:f.start()], mask(start + f.start(), start + f.end(), tag)]
955
+ pos = f.end()
956
+ return ''.join(out) + run[pos:]
957
+ return _SPLIT_RE.sub(replace, value), None
958
+ apply(split_values)
959
  apply(pins)
960
  foreign = apply(_replace_checked, _IBAN_RE, PII_TAG, _iban_ok)
961
  bare = apply(_replace_checked, _ACCOUNT_RE, PII_TAG, _iban_ok)
 
987
  counts["email"] += apply(_replace_checked, _EMAIL_RE, PII_TAG, lambda _: True)
988
  counts["email"] += apply(_replace_checked, _EMAIL_FRAGMENT_RE, PII_TAG,
989
  lambda _: True, _EMAIL_LABEL_RE)
990
+ counts["phone"] += apply(_replace_phones)
991
  # Contact context takes precedence over coincidental PESEL checksums in
992
  # foreign phone numbers; explicitly labelled identifiers were handled first.
993
  first = len(spans) if spans is not None else 0
test_nergal.py CHANGED
@@ -7,7 +7,7 @@ import unittest
7
  from pathlib import Path
8
 
9
  HERE = Path(__file__).resolve().parent
10
- RULES_SHA = '205a253b7f496b00a3a7644b9588b6c22d94d8ebb223d2d9d8a4d6326bdcccdb'
11
 
12
 
13
  class NergalTests(unittest.TestCase):
@@ -15,7 +15,7 @@ class NergalTests(unittest.TestCase):
15
  from nergal import GAP_IDS, GAPS, HUB_ID, RULES_SHA as PINNED, THRESHOLD, VERSION
16
  card = json.loads((HERE / 'hybrid.json').read_text())
17
  self.assertEqual(HUB_ID, 'SlayerLab/NERGAL')
18
- self.assertEqual(VERSION, '2.0.2')
19
  self.assertEqual(card['version'], VERSION)
20
  self.assertEqual(card['eval']['union_fp'], 80)
21
  self.assertEqual(card['eval']['rules_fp'], 24)
@@ -95,6 +95,43 @@ class NergalTests(unittest.TestCase):
95
  with self.subTest(text=text):
96
  self.assertEqual([text[s['start']:s['end']] for s in rules(text) if s['label'] == 'phone'], masked)
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  def test_grouped_national_phones_mask_without_a_cue(self):
99
  from nergal import rules
100
  for text, masked in (('Sklep Ala, 601 234 567, czynne 9-17', ['601 234 567']),
@@ -257,6 +294,21 @@ class NergalTests(unittest.TestCase):
257
  }
258
  self.assertEqual({t: m for t, (m, _) in zip(cases, nergal.scrub_many(list(cases)))}, cases)
259
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  def _names(self):
261
  from nergal import Names
262
  return Names(HERE, json.loads((HERE / 'hybrid.json').read_text())['names'])
 
7
  from pathlib import Path
8
 
9
  HERE = Path(__file__).resolve().parent
10
+ RULES_SHA = '4d0547238131908ca498a23607a7877512b2fb6022c484171334af1e604fc95a'
11
 
12
 
13
  class NergalTests(unittest.TestCase):
 
15
  from nergal import GAP_IDS, GAPS, HUB_ID, RULES_SHA as PINNED, THRESHOLD, VERSION
16
  card = json.loads((HERE / 'hybrid.json').read_text())
17
  self.assertEqual(HUB_ID, 'SlayerLab/NERGAL')
18
+ self.assertEqual(VERSION, '2.0.3')
19
  self.assertEqual(card['version'], VERSION)
20
  self.assertEqual(card['eval']['union_fp'], 80)
21
  self.assertEqual(card['eval']['rules_fp'], 24)
 
95
  with self.subTest(text=text):
96
  self.assertEqual([text[s['start']:s['end']] for s in rules(text) if s['label'] == 'phone'], masked)
97
 
98
+ def test_a_placeholder_between_a_label_and_its_value_or_inside_a_value(self):
99
+ from nergal import apply_union, rules
100
+ from scrub_pii import scrub_pii
101
+ for text, masked in ( # invented; 5260001246 and 123456785 are checksum-valid
102
+ ('NIP [PII] 526-000-12-46', 'NIP [PII] [PII]'), # the label reaches past one placeholder
103
+ ('NIP: [PII], 526-000-12-46.', 'NIP: [PII], [PII].'),
104
+ ('REGON [PII] 123456785', 'REGON [PII] [PII]'),
105
+ ('NIP [PII]\n5260001246', 'NIP [PII]\n[PII]'),
106
+ ('NIP [PII] 526-000-12-47', None), # still checksum-gated
107
+ ('NIP [PII] [PII] 5260001246', None), # one placeholder only
108
+ ('NIP 526-000-12-46, 5260001246', 'NIP [PII], [PII]'), # this scrub's own placeholder counts too
109
+ ('NIP 526-000-12-46 i 5260001246', 'NIP [PII] i 5260001246'),
110
+ ('PESEL 8503[PII]1212343', 'PESEL [PII][PII][PII]'), # labelled fragments are masked
111
+ ('NIP 526-000-[PII]-46', 'NIP [PII]-[PII]-[PII]'),
112
+ ('PESEL: 85031[PII]', 'PESEL: [PII][PII]'),
113
+ ('PESEL 1234 [PII]', None), # under 5 digits in all
114
+ ('tel. 601 [PII] 200 300', 'tel. [PHONE] [PII] [PHONE]'), # a phone cue: fragments are [PHONE]
115
+ ('tel. 601 200 [PHONE]', 'tel. [PHONE] [PHONE]'),
116
+ ('tel. +48 601 [PII] 300', 'tel. [PHONE] [PII] [PHONE]'),
117
+ ('Kontakt: 22 123 [PHONE] 67', 'Kontakt: [PHONE] [PHONE] [PHONE]'),
118
+ ('8503[PII]1212343', None), # no label, no cue: known gap
119
+ ('601 [PII] 200 300', None), ('601 [PHONE] 200 300', None),
120
+ ('tel. 601 200 [PHONE]abc', None), # glued to a word
121
+ ('tel. 00-950 [PHONE]', None), ('tel. 2023-10-16 [PHONE]', None), # postcode, date
122
+ ('tel. 12 345 [PII] zł', None), # amount
123
+ ('Strona [PII] 12 z 40', None), ('[PII] 2019 r.', None), ('art. [PII] ust. 2', None),
124
+ ('tel. [PHONE], godz. 8–16', None), ('[PHONE] 123 osób', None),
125
+ ('Kontakt: [PHONE], 2 000 000 zł', None), ('NIP [PII], rok 2023', None),
126
+ ('tel. [PHONE], pok. 101', None), ('tel. [PHONE], kod 00-950', None), ('[PII] 12 345 678 zł', None),
127
+ ('tel. [PHONE] w godz. 10–18, sala 204', None)):
128
+ with self.subTest(text=text):
129
+ spans = rules(text)
130
+ self.assertEqual(apply_union(text, spans)[0], masked or text)
131
+ self.assertTrue(all('[' not in text[s['start']:s['end']] for s in spans)) # placeholders stay as they are
132
+ self.assertEqual(scrub_pii('PESEL 8503[PII]1212343')[1]['pesel'], 1)
133
+ self.assertEqual(scrub_pii('tel. 601 [PII] 200 300')[1]['phone'], 1)
134
+
135
  def test_grouped_national_phones_mask_without_a_cue(self):
136
  from nergal import rules
137
  for text, masked in (('Sklep Ala, 601 234 567, czynne 9-17', ['601 234 567']),
 
294
  }
295
  self.assertEqual({t: m for t, (m, _) in zip(cases, nergal.scrub_many(list(cases)))}, cases)
296
 
297
+ def test_published_weights_mask_values_split_by_a_placeholder(self):
298
+ from nergal import Nergal
299
+ nergal = Nergal(HERE, device='cpu')
300
+ cases = { # invented; the model adds nothing to the rules here, and the gaps stay unmasked
301
+ 'NIP [PII] 526-000-12-46': 'NIP [PII] [PII]', 'NIP: [PII], 526-000-12-46.': 'NIP: [PII], [PII].',
302
+ 'REGON [PII] 123456785': 'REGON [PII] [PII]', 'PESEL 8503[PII]1212343': 'PESEL [PII][PII][PII]',
303
+ 'NIP 526-000-[PII]-46': 'NIP [PII]-[PII]-[PII]', 'tel. 601 [PII] 200 300': 'tel. [PHONE] [PII] [PHONE]',
304
+ 'tel. 601 200 [PHONE]': 'tel. [PHONE] [PHONE]', 'tel. [Telefon], 601 200 300': 'tel. [Telefon], [PHONE]',
305
+ **{t: t for t in ('8503[PII]1212343', '601 [PII] 200 300', '601 [PHONE] 200 300', 'Strona [PII] 12 z 40',
306
+ '[PII] 2019 r.', 'art. [PII] ust. 2', 'tel. [PHONE], godz. 8–16', '[PHONE] 123 osób',
307
+ 'Kontakt: [PHONE], 2 000 000 zł', 'NIP [PII], rok 2023', 'tel. [PHONE], pok. 101',
308
+ 'tel. [PHONE], kod 00-950', '[PII] 12 345 678 zł', 'tel. [PHONE] w godz. 10–18, sala 204')},
309
+ }
310
+ self.assertEqual({t: m for t, (m, _) in zip(cases, nergal.scrub_many(list(cases)))}, cases)
311
+
312
  def _names(self):
313
  from nergal import Names
314
  return Names(HERE, json.loads((HERE / 'hybrid.json').read_text())['names'])