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

2.0.2: a rule's label wins on its characters

Browse files
Files changed (6) hide show
  1. CHANGELOG.md +15 -0
  2. README.md +9 -8
  3. hybrid.json +2 -2
  4. nergal.py +31 -18
  5. scrub_pii.py +3 -0
  6. test_nergal.py +73 -2
CHANGELOG.md CHANGED
@@ -8,6 +8,21 @@ 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.1
12
 
13
  Same NERGAL weights, threshold and API. Rules SHA `c1b924a8…`: one fix for input that already holds `[PERSON]`.
 
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]`.
14
+
15
+ - **A rule's label wins on its characters.** In 2.0.1 each character took the highest-ranked label of every span over it (phone > pii > person), so a model `phone` span over a rule `pii` span turned a labelled PESEL into `[PHONE]` (`Kontakt: tel. 601 222 333, PESEL 44051401359.` → `PESEL [PHONE]`). In 2.0.2, where a rule span covers a character, only rule labels compete for it; model and person spans fill the characters no rule covers. Which characters are masked does not change, only the placeholder.
16
+ - **An unlabelled PESEL still yields to a model phone.** The rules mask an 11-digit number with a valid PESEL checksum even without a label, and many phone numbers pass that checksum. Such a span is marked `bare` in `scrub_pii`'s spans and keeps 2.0.1's ranking, so `Kontakt: 44051401359` with a model phone stays `[PHONE]`. Every other rule span (labelled PESEL, NIP, REGON, ID card, passport, account, email, phone) is firm.
17
+ - **Rule phones are unaffected.** A rule `phone` span already outranked a model `pii` span; that is unchanged.
18
+
19
+ 841-dev, names off: unchanged from 2.0.1, and 124/130 phones and 179/185 other PII are wholly masked with their own placeholder in both versions. Release gates (`artifacts/nergal_2_0_2_rule_label/`, frozen before scoring, stored spans): rule spans and masked characters identical on every set; the 1,837 bench rows (841-dev and the 16 pool cohorts) 0 relabelled; the 1,000-document Dynaword runtime sample, names off and on, 0 relabelled, each 2.0.1 rebuild matching the GPU gate's stored output; 41,204 FineWeb miss-hunt passages, 2 relabelled, 51 characters `[PHONE]` → `[PII]`, all `pii` per the teacher; every on-disk Dynaword source, full text, placeholder documents included, rule spans identical (32 sources, 3,378,438 documents, 997 with a placeholder; the on-disk corpus is smaller than at 2.0.1, which had 37 sources and 4,144,908 documents). Before the fix, the same miss-hunt overlap also held 22 characters the teacher calls phone, all under an unlabelled PESEL span; they keep `[PHONE]`. The benchmark replay (`bench`, and v3 `bench` with names off and on) gives 2.0.1's numbers on every cohort.
20
+
21
+ | Version | Whole /315 | Residual | Rules FP | Union FP | Char P | Char R | What changed |
22
+ |---|---:|---:|---:|---:|---:|---:|---|
23
+ | 2.0.1, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | A `[PERSON]` placeholder ends an other-number label |
24
+ | 2.0.2, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | A rule's label wins on its characters |
25
+
26
  ## 2.0.1
27
 
28
  Same NERGAL weights, threshold and API. Rules SHA `c1b924a8…`: one fix for input that already holds `[PERSON]`.
README.md CHANGED
@@ -16,7 +16,7 @@ tags:
16
  - hybrid
17
  ---
18
 
19
- # NERGAL 2.0.1
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.1
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.1 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
 
@@ -79,7 +79,8 @@ Unlabelled phones and identifiers (the rules take a bare phone only in the group
79
  | 1.1.2 | 303 | 9 | 83 | 333 | 94.37% | 97.83% | Email boundary rules |
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
 
84
  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`.
85
 
@@ -110,7 +111,7 @@ Names off hides no name. A component is the independent unit, covered when every
110
 
111
  ## Compared with other systems
112
 
113
- Same split and gold. Every model runs through the 1.2.0 recipe: its spans at 0.95, phone spans cut by phone policy v3, unioned with the 1.2.0 rules. **Naked** is the model alone. **Residual** is passages with any gold character left unmasked. Character scores are gold vs masked characters.
114
 
115
  | System | Mode | Whole /315 | Residual | False chars | Char P | Char R |
116
  |---|---|---:|---:|---:|---:|---:|
@@ -120,15 +121,15 @@ Same split and gold. Every model runs through the 1.2.0 recipe: its spans at 0.9
120
  | Fine-tuned GLiNER (previous incumbent) | naked | 263 | 28 | 15 | 99.70% | 88.80% |
121
  | Fine-tuned GLiNER (previous incumbent) | ∪ regex | 299 | 12 | 39 | 99.30% | 97.18% |
122
  | XLM-R epoch 5 | naked | 275 | 28 | 67 | 98.72% | 90.25% |
123
- | **NERGAL 1.2.0** | **∪ regex** | **303** | **9** | **80** | **98.59%** | **97.83%** |
124
 
125
- 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 1.2.0 wholly masks 124/130 phones and 179/185 other PII; exact-span precision 91.05%, recall 93.65%, F1 92.33%.
126
 
127
  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).
128
 
129
  ## Load
130
 
131
- Regex runs on the original text, the model adds spans at 0.95, then the two are unioned and replaced with `[PHONE]` / `[PII]` (and `[PERSON]` with `names=True`). Each character takes its highest-ranked label, phone > pii > person.
132
 
133
  ```python
134
  from pathlib import Path
 
16
  - hybrid
17
  ---
18
 
19
+ # NERGAL 2.0.2
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.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
 
 
79
  | 1.1.2 | 303 | 9 | 83 | 333 | 94.37% | 97.83% | Email boundary rules |
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
 
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
  | 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
 
130
  ## Load
131
 
132
+ Regex runs on the original text, the model adds spans at 0.95, then the two are unioned and replaced with `[PHONE]` / `[PII]` (and `[PERSON]` with `names=True`). Each character takes its highest-ranked label, phone > pii > person, except that a rule's label wins on the characters its span covers: a labelled PESEL, NIP, REGON or ID number stays `[PII]` when the model calls it a phone. An unlabelled 11-digit number the rules take only for its PESEL checksum still yields to a model phone.
133
 
134
  ```python
135
  from pathlib import Path
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.1",
5
  "mode": "rules_union",
6
  "epoch": 5,
7
  "seed": 202609160,
8
  "threshold": 0.95,
9
- "rules_sha256": "c1b924a893ed01b739d6616fcc1e05ffc5c9c0df138ac1409ab3af427c6b0768",
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.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": [
nergal.py CHANGED
@@ -17,16 +17,16 @@ 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.1'
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 = 'c1b924a893ed01b739d6616fcc1e05ffc5c9c0df138ac1409ab3af427c6b0768'
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 precedence
30
  RANK = {label: i for i, label in enumerate(TAGS)}
31
  PERSON_LABELS = ('PERSON', 'PERSON_F', 'PERSON_L')
32
  _WORD = re.compile(r"[\w'’-]")
@@ -174,10 +174,11 @@ class Encoding:
174
 
175
 
176
  def spans_from(module, text):
177
- """Rule spans on the original text. Existing [PHONE]/[PII]/[Telefon] placeholders do not switch the rules off."""
 
178
  result = []
179
  module.scrub_pii(text, spans=result)
180
- return sorted(({k: s[k] for k in ('start', 'end', 'label')} | {'score': 1.0} for s in result),
181
  key=lambda s: s['start'])
182
 
183
 
@@ -210,18 +211,27 @@ def person_spans(text, ranges):
210
  return [{'start': a, 'end': b, 'label': 'person', 'score': 1.0} for a, b in merged]
211
 
212
 
213
- def apply_union(text, spans):
214
- """Each character takes its highest-ranked label (phone > pii > person); each run becomes one placeholder."""
215
- labels = [None] * len(text)
216
- for span in spans:
217
- start, end, label = span['start'], span['end'], span['label']
218
- if label not in RANK:
219
- raise ValueError(f'Unknown label {label!r}')
220
- if not 0 <= start < end <= len(text):
221
- raise ValueError('Span outside text')
222
- for i in range(start, end):
223
- if labels[i] is None or RANK[label] > RANK[labels[i]]:
224
- labels[i] = label
 
 
 
 
 
 
 
 
 
225
  out, chars, counts, i = [], 0, dict.fromkeys(TAGS, 0), 0
226
  while i < len(text):
227
  lab = labels[i]
@@ -252,7 +262,10 @@ def scrub_spans(text, rule_spans, model_spans, *, threshold=THRESHOLD, module=sc
252
  keep = model_keep(text, model_spans, threshold=threshold, module=module)
253
  extra = sum(1 for s in keep if (s['start'], s['end'], s['label']) not in rule_keys)
254
  _, rules_chars, _ = apply_union(text, rule_spans)
255
- masked, union_chars, counts = apply_union(text, [*rule_spans, *keep, *name_spans])
 
 
 
256
  return masked, {**counts, 'rules_placeholder_chars': rules_chars, 'union_placeholder_chars': union_chars,
257
  'model_extra_spans': extra}
258
 
 
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
30
  RANK = {label: i for i, label in enumerate(TAGS)}
31
  PERSON_LABELS = ('PERSON', 'PERSON_F', 'PERSON_L')
32
  _WORD = re.compile(r"[\w'’-]")
 
174
 
175
 
176
  def spans_from(module, text):
177
+ """Rule spans on the original text; an unlabelled PESEL carries `bare`. Existing [PHONE]/[PII]/[Telefon]
178
+ placeholders do not switch the rules off."""
179
  result = []
180
  module.scrub_pii(text, spans=result)
181
+ return sorted(({k: s[k] for k in ('start', 'end', 'label', 'bare') if k in s} | {'score': 1.0} for s in result),
182
  key=lambda s: s['start'])
183
 
184
 
 
211
  return [{'start': a, 'end': b, 'label': 'person', 'score': 1.0} for a, b in merged]
212
 
213
 
214
+ def char_labels(text, spans, firm=()):
215
+ """Each character's highest-ranked label (phone > pii > person), or None; under a `firm` span only firm labels
216
+ compete."""
217
+ labels, best = [None] * len(text), [None] * len(text)
218
+ for tier, group in ((0, spans), (1, firm)):
219
+ for span in group:
220
+ start, end, label = span['start'], span['end'], span['label']
221
+ if label not in RANK:
222
+ raise ValueError(f'Unknown label {label!r}')
223
+ if not 0 <= start < end <= len(text):
224
+ raise ValueError('Span outside text')
225
+ key = (tier, RANK[label])
226
+ for i in range(start, end):
227
+ if best[i] is None or key > best[i]:
228
+ labels[i], best[i] = label, key
229
+ return labels
230
+
231
+
232
+ def apply_union(text, spans, firm=()):
233
+ """Each run of one label (char_labels) becomes one placeholder."""
234
+ labels = char_labels(text, spans, firm)
235
  out, chars, counts, i = [], 0, dict.fromkeys(TAGS, 0), 0
236
  while i < len(text):
237
  lab = labels[i]
 
262
  keep = model_keep(text, model_spans, threshold=threshold, module=module)
263
  extra = sum(1 for s in keep if (s['start'], s['end'], s['label']) not in rule_keys)
264
  _, rules_chars, _ = apply_union(text, rule_spans)
265
+ # A rule's label wins on its characters (2.0.2: "PESEL 44051401359" in a contact line is [PII], not [PHONE]),
266
+ # except an unlabelled PESEL, which the rules mask last and a model phone may keep.
267
+ firm = [s for s in rule_spans if not s.get('bare')]
268
+ masked, union_chars, counts = apply_union(text, [*rule_spans, *keep, *name_spans], firm)
269
  return masked, {**counts, 'rules_placeholder_chars': rules_chars, 'union_placeholder_chars': union_chars,
270
  'model_extra_spans': extra}
271
 
scrub_pii.py CHANGED
@@ -954,6 +954,9 @@ def scrub_pii(text: str, *, spans: list | None = None) -> tuple[str, dict[str, i
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
  bare_pesel = apply(_replace_checked, _PESEL_RE, PII_TAG, _pesel_ok)
 
 
958
  counts['pesel'] += bare_pesel
959
  return text, counts
 
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
958
  bare_pesel = apply(_replace_checked, _PESEL_RE, PII_TAG, _pesel_ok)
959
+ for span in spans[first:] if spans is not None else ():
960
+ span['bare'] = True # unlabelled: NERGAL lets a model phone keep it (2.0.2)
961
  counts['pesel'] += bare_pesel
962
  return text, counts
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 = 'c1b924a893ed01b739d6616fcc1e05ffc5c9c0df138ac1409ab3af427c6b0768'
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.1')
19
  self.assertEqual(card['version'], VERSION)
20
  self.assertEqual(card['eval']['union_fp'], 80)
21
  self.assertEqual(card['eval']['rules_fp'], 24)
@@ -151,6 +151,50 @@ class NergalTests(unittest.TestCase):
151
  with self.assertRaises(ValueError):
152
  apply_union(text, [{'start': 0, 'end': 1, 'label': 'org'}])
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  def test_person_spans_expand_merge_and_skip_markers(self):
155
  from nergal import person_spans
156
  text = 'Pani Nowakowskiej-Kowal, O’Brien. [PERSON] i [PHONE]'
@@ -186,6 +230,33 @@ class NergalTests(unittest.TestCase):
186
  self.assertEqual(scrub_spans(text, [], [span(text, '112')])[0], text)
187
  self.assertEqual(model_keep(text, [span(text, '112', score=0.9)], threshold=0.95), [])
188
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  def _names(self):
190
  from nergal import Names
191
  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 = '205a253b7f496b00a3a7644b9588b6c22d94d8ebb223d2d9d8a4d6326bdcccdb'
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.2')
19
  self.assertEqual(card['version'], VERSION)
20
  self.assertEqual(card['eval']['union_fp'], 80)
21
  self.assertEqual(card['eval']['rules_fp'], 24)
 
151
  with self.assertRaises(ValueError):
152
  apply_union(text, [{'start': 0, 'end': 1, 'label': 'org'}])
153
 
154
+ def test_a_rule_label_wins_on_its_characters(self):
155
+ from nergal import char_labels
156
+ spans = [{'start': 0, 'end': 4, 'label': 'phone'}, {'start': 2, 'end': 6, 'label': 'pii'},
157
+ {'start': 5, 'end': 8, 'label': 'phone'}]
158
+ self.assertEqual(char_labels('x' * 8, spans), ['phone'] * 4 + ['pii'] + ['phone'] * 3)
159
+ self.assertEqual(char_labels('x' * 8, spans, firm=spans[1:2]), ['phone'] * 2 + ['pii'] * 4 + ['phone'] * 2)
160
+ self.assertEqual(char_labels('x' * 8, spans, firm=spans), ['phone'] * 4 + ['pii'] + ['phone'] * 3) # firm vs firm: rank
161
+ self.assertEqual(char_labels('xy', []), [None, None])
162
+
163
+ def test_labelled_identifiers_keep_pii_under_a_model_phone(self):
164
+ from nergal import rules, scrub_spans
165
+ account = '28 1020 5558 1111 1119 0100 0000' # invented, checksum-valid
166
+ for value, label in (('44051401359', 'PESEL'), ('44051401359', 'PESEL:'), ('123456785', 'REGON'),
167
+ ('12345678512347', 'REGON'), ('1234567802', 'NIP'), ('123-456-78-02', 'NIP'),
168
+ ('ABA300000', 'dowód osobisty'), ('AB1234567', 'paszport nr'), (account, 'konto'),
169
+ (account.replace(' ', ''), 'nr rachunku')):
170
+ for lead in ('Kontakt: tel. 601 222 333, ', 'Dane: tel. 601 222 333, ', 'tel. 601 222 333, ', 'Kontakt: ', ''):
171
+ text = f'{lead}{label} {value}.'
172
+ at = text.index(value, len(lead))
173
+ phone = [dict(start=at, end=at + len(value), label='phone', score=0.99)]
174
+ with self.subTest(text=text):
175
+ found = rules(text)
176
+ self.assertFalse(any(s.get('bare') for s in found))
177
+ masked = scrub_spans(text, found, phone)[0]
178
+ self.assertTrue(masked.endswith(f'{label} [PII].'), masked)
179
+ self.assertEqual(masked.count('[PHONE]'), lead.count('601')) # the contact phone keeps [PHONE]
180
+
181
+ def test_an_unlabelled_pesel_yields_to_a_model_phone(self):
182
+ from nergal import rules, scrub_spans
183
+ for text in ('Kontakt: 44051401359', 'tel. 44051401359', 'PESEL? Nie, tel. 44051401359.'):
184
+ at = next(i for i, c in enumerate(text) if c.isdigit())
185
+ phone = [dict(start=at, end=at + 11, label='phone', score=0.99)]
186
+ with self.subTest(text=text):
187
+ found = rules(text)
188
+ self.assertTrue(found and all(s.get('bare') and s['label'] == 'pii' for s in found)) # rules alone: [PII]
189
+ self.assertEqual(scrub_spans(text, found, [])[0], text[:at] + '[PII]' + text[at + 11:])
190
+ self.assertEqual(scrub_spans(text, found, phone)[0], text[:at] + '[PHONE]' + text[at + 11:])
191
+
192
+ def test_a_rule_phone_keeps_phone_under_a_model_pii(self):
193
+ from nergal import rules, scrub_spans
194
+ text = 'Kontakt: tel. 601 222 333, kom. 602 333 444.'
195
+ model = [dict(start=text.index('602'), end=text.index('602') + 11, label='pii', score=0.99)]
196
+ self.assertEqual(scrub_spans(text, rules(text), model)[0], 'Kontakt: tel. [PHONE], kom. [PHONE].')
197
+
198
  def test_person_spans_expand_merge_and_skip_markers(self):
199
  from nergal import person_spans
200
  text = 'Pani Nowakowskiej-Kowal, O’Brien. [PERSON] i [PHONE]'
 
230
  self.assertEqual(scrub_spans(text, [], [span(text, '112')])[0], text)
231
  self.assertEqual(model_keep(text, [span(text, '112', score=0.9)], threshold=0.95), [])
232
 
233
+ def test_published_weights_label_contact_lines(self):
234
+ from nergal import Nergal
235
+ nergal = Nergal(HERE, device='cpu')
236
+ account = '28 1020 5558 1111 1119 0100 0000' # invented, checksum-valid
237
+ cases = {
238
+ 'Kontakt: tel. 601 222 333, PESEL 44051401359.': 'Kontakt: tel. [PHONE], PESEL [PII].',
239
+ 'Kontakt: jan.kowalski@example.com, PESEL 44051401359.': 'Kontakt: [PII], PESEL [PII].',
240
+ 'Kontakt: tel. 601 222 333, REGON 123456785.': 'Kontakt: tel. [PHONE], REGON [PII].',
241
+ 'Kontakt: tel. 601 222 333, REGON 12345678512347.': 'Kontakt: tel. [PHONE], REGON [PII].',
242
+ 'Kontakt: PESEL 44051401359.': 'Kontakt: PESEL [PII].',
243
+ 'Dane: tel. 601 222 333, PESEL 44051401359.': 'Dane: tel. [PHONE], PESEL [PII].',
244
+ 'tel. 601 222 333, PESEL 44051401359.': 'tel. [PHONE], PESEL [PII].',
245
+ 'Telefon: 601 222 333, PESEL: 44051401359': 'Telefon: [PHONE], PESEL: [PII]',
246
+ 'PESEL 44051401359.': 'PESEL [PII].',
247
+ 'Kontakt: tel. 601 222 333, NIP 1234567802.': 'Kontakt: tel. [PHONE], NIP [PII].',
248
+ 'Kontakt: tel. 601 222 333, NIP 123-456-78-02.': 'Kontakt: tel. [PHONE], NIP [PII].',
249
+ 'Kontakt: tel. 601 222 333, dowód osobisty ABA300000.': 'Kontakt: tel. [PHONE], dowód osobisty [PII].',
250
+ 'Kontakt: tel. 601 222 333, paszport nr AB1234567.': 'Kontakt: tel. [PHONE], paszport nr [PII].',
251
+ f'Kontakt: tel. 601 222 333, konto {account}.': 'Kontakt: tel. [PHONE], konto [PII].',
252
+ 'Kontakt: tel. 601 222 333, kom. 602 333 444.': 'Kontakt: tel. [PHONE], kom. [PHONE].',
253
+ 'Kontakt: +48 22 123 45 67, e-mail biuro@example.pl.': 'Kontakt: [PHONE], e-mail [PII].',
254
+ 'Dane: tel. (22) 123 45 67, fax 22 123 45 68.': 'Dane: tel. [PHONE], fax [PHONE].',
255
+ 'Kontakt: 44051401359': 'Kontakt: [PHONE]', # an unlabelled PESEL-valid number the model reads as a phone
256
+ 'Zadzwoń: 48601222333.': 'Zadzwoń: [PHONE].',
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'])