edgepii 0.1.0__py3-none-any.whl

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.
edgepii/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """Locate PII as typed character spans on the source string."""
2
+
3
+ from edgepii.errors import DetectorLoadError, OverlapError, UnknownType
4
+ from edgepii.locator import Locator
5
+ from edgepii.redact import redact
6
+ from edgepii.span import LocateResult, Span
7
+
8
+ __all__ = [
9
+ "DetectorLoadError",
10
+ "LocateResult",
11
+ "Locator",
12
+ "OverlapError",
13
+ "Span",
14
+ "UnknownType",
15
+ "redact",
16
+ ]
File without changes
@@ -0,0 +1,17 @@
1
+ """Convert spans to the Presidio recognizer-result shape. Core does not import Presidio."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from edgepii.span import Span
6
+
7
+
8
+ def to_presidio(spans: tuple[Span, ...] | list[Span]) -> list[dict]:
9
+ return [
10
+ {
11
+ "entity_type": span.type,
12
+ "start": span.start,
13
+ "end": span.end,
14
+ "score": 0.0 if span.score is None else span.score,
15
+ }
16
+ for span in spans
17
+ ]
edgepii/check.py ADDED
@@ -0,0 +1,14 @@
1
+ """Slice check. A failed hit is dropped. The locator does not search for a repair."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from edgepii.protocol import Hit
6
+
7
+
8
+ def slice_text(source: str, hit: Hit) -> str | None:
9
+ if hit.start < 0 or hit.end <= hit.start or hit.end > len(source):
10
+ return None
11
+ text = source[hit.start : hit.end]
12
+ if hit.claimed_text is not None and hit.claimed_text != text:
13
+ return None
14
+ return text
File without changes
@@ -0,0 +1,58 @@
1
+ """GLiNER v1 adapter for nvidia/gliner-PII and the urchade PII checkpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from edgepii.errors import DetectorLoadError
6
+ from edgepii.protocol import Hit
7
+
8
+
9
+ class GlinerDetector:
10
+ max_chars = 8_000
11
+ overlap_chars = 64
12
+
13
+ def __init__(
14
+ self,
15
+ repo_id: str,
16
+ revision: str,
17
+ device: str = "cpu",
18
+ candidate_floor: float | None = None,
19
+ ) -> None:
20
+ self.repo_id = repo_id
21
+ self.revision = revision
22
+ self.device = device
23
+ self.candidate_floor = candidate_floor
24
+ try:
25
+ from gliner import GLiNER
26
+ except ImportError as exc:
27
+ raise DetectorLoadError(
28
+ f"extra=gliner repo={repo_id} revision={revision}"
29
+ ) from exc
30
+ try:
31
+ self._model = GLiNER.from_pretrained(repo_id, revision=revision)
32
+ except TypeError:
33
+ self._model = GLiNER.from_pretrained(repo_id)
34
+ except Exception as exc:
35
+ raise DetectorLoadError(
36
+ f"extra=gliner repo={repo_id} revision={revision}"
37
+ ) from exc
38
+
39
+ def locate(self, source: str, labels: list[str]) -> tuple[Hit, ...]:
40
+ if source == "" or not labels:
41
+ return ()
42
+ options: dict = {}
43
+ # The checkpoint sets its own boundary. Pass no second, unmeasured one.
44
+ if self.candidate_floor is not None:
45
+ options["threshold"] = self.candidate_floor
46
+ rows = self._model.predict_entities(source, labels, **options)
47
+ hits: list[Hit] = []
48
+ for row in rows:
49
+ hits.append(
50
+ Hit(
51
+ raw_label=str(row.get("label") or ""),
52
+ start=int(row["start"]),
53
+ end=int(row["end"]),
54
+ score=None if row.get("score") is None else float(row["score"]),
55
+ detector_id=f"gliner.{self.repo_id}",
56
+ )
57
+ )
58
+ return tuple(hits)
@@ -0,0 +1,77 @@
1
+ """GLiNER2 open-schema adapter. The type list is an input to the model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from edgepii.errors import DetectorLoadError
6
+ from edgepii.protocol import Hit
7
+
8
+
9
+ class Gliner2Detector:
10
+ max_chars = 8_000
11
+ overlap_chars = 64
12
+
13
+ def __init__(
14
+ self,
15
+ repo_id: str,
16
+ revision: str,
17
+ device: str = "cpu",
18
+ candidate_floor: float | None = None,
19
+ ) -> None:
20
+ self.repo_id = repo_id
21
+ self.revision = revision
22
+ self.device = device
23
+ self.candidate_floor = candidate_floor
24
+ try:
25
+ from gliner2 import GLiNER2
26
+ except ImportError as exc:
27
+ raise DetectorLoadError(
28
+ f"extra=gliner2 repo={repo_id} revision={revision}"
29
+ ) from exc
30
+ try:
31
+ self._model = GLiNER2.from_pretrained(repo_id, revision=revision)
32
+ except TypeError:
33
+ self._model = GLiNER2.from_pretrained(repo_id)
34
+ except Exception as exc:
35
+ raise DetectorLoadError(
36
+ f"extra=gliner2 repo={repo_id} revision={revision}"
37
+ ) from exc
38
+
39
+ def locate(self, source: str, labels: list[str]) -> tuple[Hit, ...]:
40
+ if source == "" or not labels:
41
+ return ()
42
+ options: dict = {
43
+ "include_confidence": True,
44
+ "include_spans": True,
45
+ }
46
+ # The checkpoint author chose its own decision boundary. This adapter
47
+ # adds no second one. A caller that measured a floor passes one.
48
+ if self.candidate_floor is not None:
49
+ options["threshold"] = self.candidate_floor
50
+ result = self._model.extract_entities(source, labels, **options)
51
+ return tuple(_hits_from_tree(result, f"gliner2.{self.repo_id}"))
52
+
53
+
54
+ def _hits_from_tree(node: object, detector_id: str, label: str | None = None) -> list[Hit]:
55
+ hits: list[Hit] = []
56
+ if isinstance(node, dict) and "start" in node and "end" in node:
57
+ raw = str(node.get("label") or node.get("type") or label or "")
58
+ score = node.get("confidence", node.get("score"))
59
+ hits.append(
60
+ Hit(
61
+ raw_label=raw,
62
+ start=int(node["start"]),
63
+ end=int(node["end"]),
64
+ score=None if score is None else float(score),
65
+ detector_id=detector_id,
66
+ )
67
+ )
68
+ return hits
69
+ if isinstance(node, dict):
70
+ for key, value in node.items():
71
+ next_label = key if isinstance(key, str) else label
72
+ hits.extend(_hits_from_tree(value, detector_id, next_label))
73
+ return hits
74
+ if isinstance(node, list):
75
+ for item in node:
76
+ hits.extend(_hits_from_tree(item, detector_id, label))
77
+ return hits
@@ -0,0 +1,70 @@
1
+ """Hugging Face token classifiers. BERT and ModernBERT share this class."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from edgepii.errors import DetectorLoadError
6
+ from edgepii.protocol import Hit
7
+
8
+
9
+ class HfTokenDetector:
10
+ """A window is 75 percent of the checkpoint max position to stay under it."""
11
+
12
+ default_context = 512
13
+ context_fraction = 0.75
14
+ overlap_chars = 64
15
+
16
+ def __init__(self, repo_id: str, revision: str, device: str = "cpu") -> None:
17
+ self.repo_id = repo_id
18
+ self.revision = revision
19
+ self.device = device
20
+ try:
21
+ from transformers import pipeline
22
+ except ImportError as exc:
23
+ raise DetectorLoadError(
24
+ f"extra=hf repo={repo_id} revision={revision}"
25
+ ) from exc
26
+ device_index = 0 if device == "cuda" else -1
27
+ try:
28
+ self._pipe = pipeline(
29
+ "token-classification",
30
+ model=repo_id,
31
+ revision=revision,
32
+ aggregation_strategy="simple",
33
+ device=device_index,
34
+ )
35
+ except Exception as exc:
36
+ raise DetectorLoadError(
37
+ f"extra=hf repo={repo_id} revision={revision}"
38
+ ) from exc
39
+ self.max_chars = self._window_from_context()
40
+
41
+ def _window_from_context(self) -> int:
42
+ """Read the checkpoint limit. Characters here are a safe lower bound."""
43
+
44
+ config = getattr(self._pipe.model, "config", None)
45
+ positions = getattr(config, "max_position_embeddings", None)
46
+ if not isinstance(positions, int) or positions <= 0:
47
+ positions = self.default_context
48
+ return max(1, int(positions * self.context_fraction))
49
+
50
+ def locate(self, source: str, labels: list[str]) -> tuple[Hit, ...]:
51
+ del labels
52
+ if source == "":
53
+ return ()
54
+ rows = self._pipe(source)
55
+ hits: list[Hit] = []
56
+ for row in rows:
57
+ start = int(row["start"])
58
+ end = int(row["end"])
59
+ raw = str(row.get("entity_group") or row.get("entity") or "")
60
+ score = float(row["score"])
61
+ hits.append(
62
+ Hit(
63
+ raw_label=raw,
64
+ start=start,
65
+ end=end,
66
+ score=score,
67
+ detector_id=f"hf.{self.repo_id}",
68
+ )
69
+ )
70
+ return tuple(hits)
@@ -0,0 +1,228 @@
1
+ """Structured forms with a named validator.
2
+
3
+ Every rule here answers one question: does a published grammar or checksum
4
+ confirm this slice? A rule that a validator confirms scores 1.0. A rule that
5
+ only matches a shape scores null. No rule invents a probability.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ipaddress
11
+ import re
12
+
13
+ import phonenumbers
14
+ from phonenumbers import PhoneNumberMatcher, ValidationResult
15
+
16
+ from edgepii.protocol import Hit
17
+
18
+ # RFC 5321 section 4.5.3.1: local part at most 64 octets, path at most 256.
19
+ # The domain must hold at least one dot and a two-letter top label.
20
+ _EMAIL = re.compile(
21
+ r"(?<![A-Za-z0-9._%+\-])"
22
+ r"([A-Za-z0-9!#$%&'*+/=?^_`{|}~\-]{1,64}"
23
+ r"(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~\-]{1,64})*)"
24
+ r"@"
25
+ r"((?:[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63})"
26
+ r"(?![A-Za-z0-9\-])"
27
+ )
28
+
29
+ # ISO/IEC 7812-1: a PAN holds 8 to 19 digits and starts with a major industry
30
+ # identifier in the range 1 to 9. Separators are spaces or hyphens.
31
+ _CARD = re.compile(r"(?<![A-Za-z0-9])\d(?:[ \-]?\d){7,18}(?![A-Za-z0-9])")
32
+
33
+ # ISO 13616: two country letters, two check digits, then 11 to 30 alphanumerics.
34
+ _IBAN = re.compile(r"(?<![A-Za-z0-9])[A-Z]{2}\d{2}[A-Z0-9]{11,30}(?![A-Za-z0-9])")
35
+
36
+ # RFC 3986 section 3.2.2 for IPv4 text form. Octet ranges are checked by value.
37
+ # A trailing sentence period is allowed. A trailing digit or dotted octet is not.
38
+ _IPV4 = re.compile(r"(?<![0-9.])(?:\d{1,3}\.){3}\d{1,3}(?![0-9])(?!\.[0-9])")
39
+
40
+ # RFC 4291 section 2.2 text forms. The stdlib validator decides membership.
41
+ _IPV6 = re.compile(r"(?<![0-9A-Za-z:])(?:[0-9A-Fa-f]{0,4}:){2,7}[0-9A-Fa-f]{0,4}(?![0-9A-Za-z:])")
42
+
43
+ # A cue word, then one of two forms.
44
+ # 1. Assignment: cue, separator, value. The separator marks the value.
45
+ # 2. Mention: cue, space, value, where the value holds a digit or a symbol.
46
+ # Form 2 exists because prose says "the password hunter2". A bare word after a
47
+ # cue is prose, so form 2 refuses it. That is a grammar rule, not a word list.
48
+ _CUE_ASSIGN = re.compile(
49
+ r"(?i)\b(?:password|passcode|passwd|api[_\-]?key|access[_\-]?token|secret[_\-]?key)\b"
50
+ r"\s*[:=]\s*"
51
+ r"([^\s,.;:=\"'()<>\[\]]{1,256})"
52
+ )
53
+ _CUE_MENTION = re.compile(
54
+ r"(?i)\b(?:password|passcode|passwd|api[_\-]?key|access[_\-]?token|secret[_\-]?key)\b"
55
+ r"\s+"
56
+ r"([^\s,.;:=\"'()<>\[\]]{1,256})"
57
+ )
58
+ _SECRET_SHAPE = re.compile(r"[0-9!-/:-@\[-`{-~]")
59
+
60
+ # ISO 3166 regions that the phone rule asks libphonenumber to try.
61
+ PHONE_REGIONS = ("US", "CN", "HK", "GB")
62
+
63
+
64
+ def _luhn_ok(digits: str) -> bool:
65
+ """ISO/IEC 7812-1 check digit."""
66
+
67
+ if not 8 <= len(digits) <= 19:
68
+ return False
69
+ if digits[0] == "0":
70
+ return False
71
+ total = 0
72
+ for index, char in enumerate(reversed(digits)):
73
+ value = ord(char) - 48
74
+ if index % 2 == 1:
75
+ value *= 2
76
+ if value > 9:
77
+ value -= 9
78
+ total += value
79
+ return total % 10 == 0
80
+
81
+
82
+ def _iban_ok(raw: str) -> bool:
83
+ """ISO 13616 with the ISO 7064 MOD-97-10 check."""
84
+
85
+ compact = raw.replace(" ", "").upper()
86
+ if not 15 <= len(compact) <= 34:
87
+ return False
88
+ rearranged = compact[4:] + compact[:4]
89
+ number = ""
90
+ for char in rearranged:
91
+ if char.isdigit():
92
+ number += char
93
+ elif "A" <= char <= "Z":
94
+ number += str(ord(char) - 55)
95
+ else:
96
+ return False
97
+ return int(number) % 97 == 1
98
+
99
+
100
+ def _ip_ok(raw: str) -> bool:
101
+ try:
102
+ ipaddress.ip_address(raw)
103
+ except ValueError:
104
+ return False
105
+ return True
106
+
107
+
108
+ class RuleDetector:
109
+ """Validators and grammars. Person names are not this detector's job."""
110
+
111
+ max_chars = 10_000_000
112
+ overlap_chars = 0
113
+ detector_kind = "rules"
114
+
115
+ def locate(self, source: str, labels: list[str]) -> tuple[Hit, ...]:
116
+ allowed = {_norm(label) for label in labels}
117
+ hits: list[Hit] = []
118
+ if _wants(allowed, "email"):
119
+ hits.extend(_emails(source))
120
+ if _wants(allowed, "phone", "phone_number", "phonenumber"):
121
+ hits.extend(_phones(source))
122
+ if _wants(allowed, "card_number", "payment_card", "creditcardnumber", "credit_debit_card", "iban", "bank_account"):
123
+ hits.extend(_payments(source, allowed))
124
+ if _wants(allowed, "ip", "ip_address", "ipv4", "ipv6"):
125
+ hits.extend(_ips(source))
126
+ if _wants(allowed, "password", "passcode", "secret", "api_key", "access_token", "secret_key"):
127
+ hits.extend(_secrets(source))
128
+ return tuple(hits)
129
+
130
+
131
+ def _norm(label: str) -> str:
132
+ return label.strip().casefold().replace(" ", "_")
133
+
134
+
135
+ def _wants(allowed: set[str], *names: str) -> bool:
136
+ return any(name in allowed for name in names)
137
+
138
+
139
+ def _emails(source: str) -> list[Hit]:
140
+ """RFC 5322 address in its common ASCII form. Shape only, so score is null."""
141
+
142
+ hits = []
143
+ for match in _EMAIL.finditer(source):
144
+ if len(match.group()) > 254:
145
+ continue
146
+ hits.append(Hit("email", match.start(), match.end(), None, "rules.email"))
147
+ return hits
148
+
149
+
150
+ def _phones(source: str) -> list[Hit]:
151
+ """libphonenumber decides. Two accept tests, one per documented form.
152
+
153
+ A number that libphonenumber calls valid is a phone. A number that starts
154
+ with an explicit international prefix and has a plan-correct length is a
155
+ phone. Nothing else is. The region list only supplies a default region.
156
+ """
157
+
158
+ found: dict[tuple[int, int], float | None] = {}
159
+ for region in PHONE_REGIONS:
160
+ matcher = PhoneNumberMatcher(
161
+ source, region, leniency=phonenumbers.Leniency.POSSIBLE
162
+ )
163
+ for match in matcher:
164
+ key = (match.start, match.end)
165
+ text = source[match.start : match.end]
166
+ if phonenumbers.is_valid_number(match.number):
167
+ found[key] = 1.0
168
+ continue
169
+ if not text.startswith("+"):
170
+ continue
171
+ reason = phonenumbers.is_possible_number_with_reason(match.number)
172
+ if reason == ValidationResult.IS_POSSIBLE:
173
+ found.setdefault(key, None)
174
+ return [
175
+ Hit("phone", start, end, score, "rules.phone")
176
+ for (start, end), score in sorted(found.items())
177
+ ]
178
+
179
+
180
+ def _payments(source: str, allowed: set[str]) -> list[Hit]:
181
+ hits: list[Hit] = []
182
+ iban_spans: list[tuple[int, int]] = []
183
+ if _wants(allowed, "iban", "bank_account"):
184
+ for match in _IBAN.finditer(source):
185
+ if not _iban_ok(match.group()):
186
+ continue
187
+ iban_spans.append((match.start(), match.end()))
188
+ hits.append(Hit("iban", match.start(), match.end(), 1.0, "rules.iban"))
189
+ if _wants(allowed, "card_number", "payment_card", "creditcardnumber", "credit_debit_card"):
190
+ for match in _CARD.finditer(source):
191
+ if _inside(match.start(), match.end(), iban_spans):
192
+ continue
193
+ raw = match.group()
194
+ digits = "".join(char for char in raw if char.isdigit())
195
+ if not _luhn_ok(digits):
196
+ continue
197
+ hits.append(Hit("card_number", match.start(), match.end(), 1.0, "rules.card"))
198
+ return hits
199
+
200
+
201
+ def _inside(start: int, end: int, spans: list[tuple[int, int]]) -> bool:
202
+ return any(left <= start and end <= right for left, right in spans)
203
+
204
+
205
+ def _ips(source: str) -> list[Hit]:
206
+ hits: list[Hit] = []
207
+ for pattern in (_IPV4, _IPV6):
208
+ for match in pattern.finditer(source):
209
+ if not _ip_ok(match.group()):
210
+ continue
211
+ hits.append(Hit("ip_address", match.start(), match.end(), 1.0, "rules.ip"))
212
+ return hits
213
+
214
+
215
+ def _secrets(source: str) -> list[Hit]:
216
+ """A cue names a value. An assignment always counts, a mention needs a shape."""
217
+
218
+ spans: dict[tuple[int, int], Hit] = {}
219
+ for match in _CUE_ASSIGN.finditer(source):
220
+ key = (match.start(1), match.end(1))
221
+ spans[key] = Hit("password", key[0], key[1], None, "rules.secret")
222
+ for match in _CUE_MENTION.finditer(source):
223
+ value = match.group(1)
224
+ if _SECRET_SHAPE.search(value) is None:
225
+ continue
226
+ key = (match.start(1), match.end(1))
227
+ spans.setdefault(key, Hit("password", key[0], key[1], None, "rules.secret"))
228
+ return [spans[key] for key in sorted(spans)]
@@ -0,0 +1,107 @@
1
+ """ModelScope Siamese-UIE. A hit is public only when the slice equals the surface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+ from edgepii.errors import DetectorLoadError
8
+ from edgepii.protocol import Hit
9
+
10
+
11
+ class UieDetector:
12
+ max_chars = 2_000
13
+ overlap_chars = 64
14
+
15
+ def __init__(
16
+ self,
17
+ repo_id: str,
18
+ revision: str,
19
+ schema: dict[str, str],
20
+ device: str = "cpu",
21
+ runner: Callable | None = None,
22
+ ) -> None:
23
+ self.repo_id = repo_id
24
+ self.revision = revision
25
+ self.schema = schema
26
+ self.device = device
27
+ self._runner = runner
28
+ if runner is not None:
29
+ return
30
+ try:
31
+ from modelscope.pipelines import pipeline
32
+ from modelscope.utils.constant import Tasks
33
+ except ImportError as exc:
34
+ raise DetectorLoadError(
35
+ f"extra=ms repo={repo_id} revision={revision}"
36
+ ) from exc
37
+ try:
38
+ self._pipe = pipeline(Tasks.siamese_uie, model=repo_id, model_revision=revision)
39
+ except Exception as exc:
40
+ raise DetectorLoadError(
41
+ f"extra=ms repo={repo_id} revision={revision}"
42
+ ) from exc
43
+
44
+ def locate(self, source: str, labels: list[str]) -> tuple[Hit, ...]:
45
+ if source == "":
46
+ return ()
47
+ allowed = {label.strip() for label in labels}
48
+ schema = {
49
+ name: None
50
+ for name in self.schema.values()
51
+ if name in allowed
52
+ }
53
+ if not schema:
54
+ return ()
55
+ if self._runner is not None:
56
+ raw = self._runner(source, schema)
57
+ else:
58
+ raw = self._pipe(source, schema=schema)
59
+ hits: list[Hit] = []
60
+ for label, items in _pairs(raw):
61
+ if not isinstance(items, list):
62
+ continue
63
+ for item in items:
64
+ hit = _one(source, label, item, f"uie.{self.repo_id}")
65
+ if hit is not None:
66
+ hits.append(hit)
67
+ return tuple(hits)
68
+
69
+
70
+ def _pairs(raw: object) -> list[tuple[str, object]]:
71
+ if isinstance(raw, dict) and "output" in raw and isinstance(raw["output"], dict):
72
+ raw = raw["output"]
73
+ if isinstance(raw, dict):
74
+ return [(str(key), value) for key, value in raw.items()]
75
+ return []
76
+
77
+
78
+ def _one(source: str, label: str, item: object, detector_id: str) -> Hit | None:
79
+ if not isinstance(item, dict):
80
+ return None
81
+ surface = str(item.get("span") or item.get("text") or "")
82
+ start, end = _bounds(item)
83
+ if start is None or end is None or surface == "":
84
+ return None
85
+ if source[start:end] == surface:
86
+ chosen = end
87
+ elif end + 1 <= len(source) and source[start : end + 1] == surface:
88
+ chosen = end + 1
89
+ else:
90
+ return None
91
+ score = item.get("prob", item.get("probs", item.get("score")))
92
+ return Hit(
93
+ raw_label=str(item.get("type") or label),
94
+ start=start,
95
+ end=chosen,
96
+ score=None if score is None else float(score),
97
+ detector_id=detector_id,
98
+ )
99
+
100
+
101
+ def _bounds(item: dict) -> tuple[int | None, int | None]:
102
+ if "start" in item and "end" in item:
103
+ return int(item["start"]), int(item["end"])
104
+ offset = item.get("offset")
105
+ if isinstance(offset, (list, tuple)) and len(offset) == 2:
106
+ return int(offset[0]), int(offset[1])
107
+ return None, None
edgepii/errors.py ADDED
@@ -0,0 +1,22 @@
1
+ """Errors for the public locator. Messages do not include the source string."""
2
+
3
+
4
+ class UnknownType(ValueError):
5
+ """The caller named a type that is not in the active taxonomy."""
6
+
7
+ def __init__(self, name: str) -> None:
8
+ self.name = name
9
+ super().__init__(name)
10
+
11
+
12
+ class DetectorLoadError(RuntimeError):
13
+ """Weights or an extra failed to load. The message has no source text."""
14
+
15
+
16
+ class OverlapError(ValueError):
17
+ """redact saw two spans that share a character."""
18
+
19
+ def __init__(self, start: int, end: int) -> None:
20
+ self.start = start
21
+ self.end = end
22
+ super().__init__(f"{start}:{end}")
@@ -0,0 +1,32 @@
1
+ """Scoring. Exact-span F1, the pinned public corpora, and the run report.
2
+
3
+ `score` holds the metric. `corpora` holds the pins and the label fold. `runner`
4
+ holds the per-document pass. `cli` is the entry point:
5
+
6
+ python -m edgepii.eval.cli --preset rules --corpus nemotron-pii
7
+ """
8
+
9
+ from edgepii.eval.corpora import (
10
+ CorpusError,
11
+ CorpusSpec,
12
+ GoldDiagnostics,
13
+ corpus_keys,
14
+ load_spec,
15
+ )
16
+ from edgepii.eval.runner import RunReport, SliceScore, score_corpus, score_docs
17
+ from edgepii.eval.score import ScoreReport, exact_span_f1, leakage_count
18
+
19
+ __all__ = [
20
+ "CorpusError",
21
+ "CorpusSpec",
22
+ "GoldDiagnostics",
23
+ "RunReport",
24
+ "ScoreReport",
25
+ "SliceScore",
26
+ "corpus_keys",
27
+ "exact_span_f1",
28
+ "leakage_count",
29
+ "load_spec",
30
+ "score_corpus",
31
+ "score_docs",
32
+ ]