maskflow-bench 0.1.0__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.
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ .env
6
+ node_modules/
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+ .DS_Store
11
+ .pytest_cache/
12
+ .idea/
13
+ .coverage
14
+ .mypy_cache/
15
+ .ruff_cache/
16
+ bench/indiapii/quality/.cache/
@@ -0,0 +1,30 @@
1
+ Metadata-Version: 2.5
2
+ Name: maskflow-bench
3
+ Version: 0.1.0
4
+ Summary: MaskFlow benchmark scoring core: load labelled PII data, score detections under strict/partial-overlap precision-recall-F1, write JSON/Markdown reports. Powers `maskflow bench --my-data`.
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: maskflow-core<0.9,>=0.8.0
8
+ Provides-Extra: dev
9
+ Requires-Dist: maskflow-pack-india<0.6,>=0.5.1; extra == 'dev'
10
+ Requires-Dist: pytest>=8.0; extra == 'dev'
11
+ Description-Content-Type: text/markdown
12
+
13
+ # maskflow-bench
14
+
15
+ The scoring core behind MaskFlow's accuracy measurements: load a labelled
16
+ JSONL corpus, canonicalize its entity taxonomy, score detections under
17
+ strict-span and partial-overlap precision/recall/F1, write `results.json`
18
+ and `results.md`.
19
+
20
+ Most people reach this through `maskflow bench --my-data <path>`
21
+ (`maskflow-cli`) — see [`docs/bench.md`](../../docs/bench.md) for the
22
+ labelled-data schema and command reference.
23
+
24
+ This package intentionally ships **one** adapter — MaskFlow itself
25
+ (`adapters.maskflow_adapter.MaskflowAdapter`) — with no dependency beyond
26
+ `maskflow-core`. The multi-adapter comparison against Presidio,
27
+ mask-privacy, a naive-regex baseline, and an LLM judge that produced the
28
+ published IndiaPII-Bench tables lives in `bench/indiapii/harness/` (repo
29
+ dev tooling, not published), and imports this package for the parts that
30
+ don't change per adapter.
@@ -0,0 +1,18 @@
1
+ # maskflow-bench
2
+
3
+ The scoring core behind MaskFlow's accuracy measurements: load a labelled
4
+ JSONL corpus, canonicalize its entity taxonomy, score detections under
5
+ strict-span and partial-overlap precision/recall/F1, write `results.json`
6
+ and `results.md`.
7
+
8
+ Most people reach this through `maskflow bench --my-data <path>`
9
+ (`maskflow-cli`) — see [`docs/bench.md`](../../docs/bench.md) for the
10
+ labelled-data schema and command reference.
11
+
12
+ This package intentionally ships **one** adapter — MaskFlow itself
13
+ (`adapters.maskflow_adapter.MaskflowAdapter`) — with no dependency beyond
14
+ `maskflow-core`. The multi-adapter comparison against Presidio,
15
+ mask-privacy, a naive-regex baseline, and an LLM judge that produced the
16
+ published IndiaPII-Bench tables lives in `bench/indiapii/harness/` (repo
17
+ dev tooling, not published), and imports this package for the parts that
18
+ don't change per adapter.
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "maskflow-bench"
3
+ version = "0.1.0"
4
+ description = "MaskFlow benchmark scoring core: load labelled PII data, score detections under strict/partial-overlap precision-recall-F1, write JSON/Markdown reports. Powers `maskflow bench --my-data`."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ dependencies = [
9
+ # Only adapters.maskflow_adapter needs this, to call
10
+ # maskflow_core.detection.detect(). No presidio/mask-privacy/anthropic
11
+ # here -- those five competitor adapters stay dev-only in
12
+ # bench/indiapii/harness/adapters/, never a dependency of this package.
13
+ "maskflow-core>=0.8.0,<0.9",
14
+ ]
15
+
16
+ [project.optional-dependencies]
17
+ dev = [
18
+ "pytest>=8.0",
19
+ # test_scorer.py's end-to-end test needs a real recognizer registered
20
+ # to detect something -- this package's own runtime code never imports
21
+ # a pack (see module docstring), only its test suite does.
22
+ "maskflow-pack-india>=0.5.1,<0.6",
23
+ ]
24
+
25
+ [tool.uv.sources]
26
+ maskflow-core = { workspace = true }
27
+ maskflow-pack-india = { workspace = true }
28
+
29
+ [tool.uv]
30
+ package = true
31
+
32
+ [build-system]
33
+ requires = ["hatchling"]
34
+ build-backend = "hatchling.build"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/maskflow_bench"]
@@ -0,0 +1,25 @@
1
+ """maskflow-bench: the corpus-agnostic PII-detection scoring core.
2
+
3
+ Loads a labelled JSONL corpus into `Document` objects (corpus.py),
4
+ canonicalizes its entity taxonomy (labels.py), scores an adapter's raw
5
+ (start, end, label) predictions against it under strict-span and
6
+ partial-overlap matching (matching.py), times and profiles that run
7
+ (profiling.py, runner.py), and writes `results.json`/`results.md`
8
+ (report.py).
9
+
10
+ This package has exactly one built-in adapter, `adapters.maskflow_adapter.
11
+ MaskflowAdapter` (wraps `maskflow_core.detection.detect`, zero extra
12
+ third-party dependencies) -- the `loader`/`scorer` modules on top of it are
13
+ what `maskflow bench --my-data <path>` (packages/maskflow-cli) uses to
14
+ score MaskFlow against a user's own labelled documents.
15
+
16
+ It is also the shared implementation behind bench/indiapii/harness's,
17
+ bench/intlpii/harness's, and bench/scanbench/harness's own dev-only,
18
+ multi-adapter comparisons (Presidio, mask-privacy, naive regex, an LLM
19
+ judge) used to produce the published IndiaPII-Bench-style figures -- those
20
+ adapters, and the harness CLIs that run all of them together, stay in
21
+ bench/ since they pull heavy, optional third-party dependencies this
22
+ package never needs.
23
+ """
24
+
25
+ from __future__ import annotations
@@ -0,0 +1,15 @@
1
+ """Just the one adapter this package owns (`maskflow_adapter.MaskflowAdapter`)
2
+ plus the `Adapter` protocol (`base.Adapter`) it implements. The other five
3
+ competitor adapters (Presidio, mask-privacy, naive regex, an LLM judge)
4
+ stay in bench/indiapii/harness/adapters/ -- they pull heavy, optional
5
+ third-party dependencies (presidio-analyzer, mask-privacy, anthropic) this
6
+ package never needs, and they are only ever used by the dev-only
7
+ multi-adapter comparison harness, never by `maskflow bench --my-data`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .base import Adapter, AdapterEntry
13
+ from .maskflow_adapter import MaskflowAdapter
14
+
15
+ __all__ = ["Adapter", "AdapterEntry", "MaskflowAdapter"]
@@ -0,0 +1,31 @@
1
+ """The Adapter protocol every competitor detector implements.
2
+
3
+ `available()` is checked once per harness run, before any `detect()` call:
4
+ an adapter missing an optional dependency (presidio, mask-privacy) or an
5
+ API key (the LLM adapter) reports `(False, reason)` and is skipped, never
6
+ crashing the run. `detect()` itself is called once per document by
7
+ runner.py, which wraps each call in its own bounded try/except so one bad
8
+ document can't take out the rest of an adapter's run either.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Protocol
14
+
15
+
16
+ class Adapter(Protocol):
17
+ name: str
18
+
19
+ def available(self) -> tuple[bool, str]:
20
+ """Returns (True, "") if this adapter can run, else (False, reason)."""
21
+ ...
22
+
23
+ def detect(self, text: str) -> list[tuple[int, int, str]]:
24
+ """Returns (start, end, raw_label) spans in this adapter's own
25
+ label vocabulary -- translated to the corpus's canonical labels by
26
+ labels.py, not here."""
27
+ ...
28
+
29
+
30
+ # adapter instance, label_map -- the pair runner.py needs for each entry.
31
+ AdapterEntry = tuple[Adapter, dict[str, str]]
@@ -0,0 +1,27 @@
1
+ """Adapter 1: MaskFlow itself, both bundled packs.
2
+
3
+ Importing maskflow_pack_intl and maskflow_pack_india registers every
4
+ recognizer against maskflow-core's global registry as a side effect (same
5
+ mechanism bench/indiapii/report.py already relies on for its own,
6
+ unrelated L1-L3 accuracy report) -- there is no separate "activate" step.
7
+ PIIType values already equal the corpus's own label vocabulary (the corpus
8
+ was generated from this pack's own types), so no label translation table
9
+ is needed here; labels.py's identity_map() is used by the caller.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import maskflow_pack_india # noqa: F401 (import-time registration side effect)
15
+ import maskflow_pack_intl # noqa: F401 (import-time registration side effect)
16
+ from maskflow_core.detection import detect
17
+
18
+
19
+ class MaskflowAdapter:
20
+ name = "maskflow"
21
+
22
+ def available(self) -> tuple[bool, str]:
23
+ return True, ""
24
+
25
+ def detect(self, text: str) -> list[tuple[int, int, str]]:
26
+ spans = detect(text)
27
+ return [(s.start, s.end, str(s.entity_type)) for s in spans]
@@ -0,0 +1,73 @@
1
+ """Loads bench/indiapii/data/*.jsonl into Document objects.
2
+
3
+ Each corpus line has an `entities` list mixing `value_class: "positive"`
4
+ (real gold spans) and `value_class: "hard_negative"` (decoy spans, shaped
5
+ like real PII but never gold -- see generator/hard_negatives.py). Document
6
+ keeps these separate: `gold` is the only thing matching.py scores recall
7
+ against; `decoys` exist purely so a prediction landing on one still counts
8
+ as a false positive for whatever type it claimed (see matching.py).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from collections.abc import Iterator
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Document:
21
+ id: str
22
+ text: str
23
+ domain: str
24
+ lang: str
25
+ gold: tuple[tuple[int, int, str], ...]
26
+ decoys: tuple[tuple[int, int, str], ...]
27
+
28
+ @property
29
+ def size_bytes(self) -> int:
30
+ return len(self.text.encode("utf-8"))
31
+
32
+
33
+ def _parse_line(line: str) -> Document:
34
+ row = json.loads(line)
35
+ gold = []
36
+ decoys = []
37
+ for e in row["entities"]:
38
+ span = (e["start"], e["end"], e["label"])
39
+ if e["value_class"] == "positive":
40
+ gold.append(span)
41
+ else:
42
+ decoys.append(span)
43
+ return Document(
44
+ id=row["id"],
45
+ text=row["text"],
46
+ domain=row["domain"],
47
+ lang=row["lang"],
48
+ gold=tuple(gold),
49
+ decoys=tuple(decoys),
50
+ )
51
+
52
+
53
+ def iter_corpus(path: Path) -> Iterator[Document]:
54
+ with path.open(encoding="utf-8") as f:
55
+ for line in f:
56
+ line = line.strip()
57
+ if line:
58
+ yield _parse_line(line)
59
+
60
+
61
+ def load_corpus(path: Path, limit: int | None = None) -> list[Document]:
62
+ """Loads `path` in file order (the corpus's own doc-id order), which is
63
+ already deterministic per-seed at generation time -- `limit` therefore
64
+ always selects the same first-N documents run to run, which is what
65
+ the CI regression subset (see harness/tests/test_ci_regression.py)
66
+ relies on for a stable baseline.
67
+ """
68
+ docs = []
69
+ for i, doc in enumerate(iter_corpus(path)):
70
+ if limit is not None and i >= limit:
71
+ break
72
+ docs.append(doc)
73
+ return docs
@@ -0,0 +1,97 @@
1
+ """Canonical entity taxonomy (the corpus's own positive labels) and each
2
+ adapter's raw-label -> canonical-label map.
3
+
4
+ The canonical taxonomy is never a new ontology invented here -- it's
5
+ whatever `value_class: "positive"` labels actually appear in the loaded
6
+ corpus, so a future corpus version's label set is picked up automatically
7
+ rather than drifting out of sync with a hardcoded list here.
8
+
9
+ Every LABEL_MAP below is intentionally partial: a raw label an adapter
10
+ emits with no entry here is dropped before scoring rather than counted as
11
+ a false positive against some unrelated canonical type -- see matching.py
12
+ and the harness plan's "Label mapping" section for why (mirrors the
13
+ `target_types`-restricted convention already used by
14
+ bench/indiapii/metrics.py).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Iterable
20
+
21
+ from .corpus import Document
22
+
23
+ # One-line definitions handed to the LLM adapter's prompt -- the taxonomy
24
+ # itself always comes from canonical_labels(docs) below; this dict is just
25
+ # human-readable glosses for whichever of those labels happen to have one.
26
+ LABEL_DESCRIPTIONS: dict[str, str] = {
27
+ "AADHAAR": "12-digit Indian national ID (Aadhaar), may be spaced/hyphenated in groups of 4",
28
+ "AADHAAR_MASKED": "Aadhaar number with first 8 digits masked, only last 4 digits visible",
29
+ "ABHA_ADDRESS": "Ayushman Bharat Health Account address, email-shaped (e.g. name@abdm)",
30
+ "ABHA_NUMBER": "14-digit Ayushman Bharat Health Account number",
31
+ "BANK_ACCOUNT_IN": "Indian bank account number, 9-18 digits",
32
+ "DRIVING_LICENCE": "Indian driving licence number (state code + digits)",
33
+ "GSTIN": "15-character Goods and Services Tax Identification Number",
34
+ "IFSC": "11-character bank branch code (4 letters + 0 + 6 alphanumeric)",
35
+ "INDIAN_ADDRESS": "a residential/postal address in India (street, locality, city, state)",
36
+ "INDIAN_MOBILE": "Indian mobile phone number, 10 digits, optionally with +91 prefix",
37
+ "INDIAN_PASSPORT": "Indian passport number (1 letter + 7 digits) or its MRZ block",
38
+ "PAN": "10-character Permanent Account Number (5 letters + 4 digits + 1 letter)",
39
+ "PERSON_NAME": "a person's full name",
40
+ "PIN_CODE": "6-digit Indian postal PIN code",
41
+ "UPI_VPA": "UPI Virtual Payment Address, looks like username@bank-handle",
42
+ "VEHICLE_REG": "Indian vehicle registration number (state code + district + series + digits)",
43
+ "VOTER_ID": "Voter ID / EPIC number, 3 letters + 7 digits",
44
+ }
45
+
46
+
47
+ def canonical_labels(docs: Iterable[Document]) -> tuple[str, ...]:
48
+ labels: set[str] = set()
49
+ for doc in docs:
50
+ for _start, _end, label in doc.gold:
51
+ labels.add(label)
52
+ return tuple(sorted(labels))
53
+
54
+
55
+ # maskflow's own PIIType values are literally the corpus's label vocabulary
56
+ # (the corpus was generated from maskflow-pack-india's own types) -- no
57
+ # translation needed, but adapters.base.Adapter still calls through this
58
+ # module for a uniform code path, so this is an identity function.
59
+ def identity_map(raw_labels: Iterable[str]) -> dict[str, str]:
60
+ return {label: label for label in raw_labels}
61
+
62
+
63
+ PRESIDIO_LABEL_MAP: dict[str, str] = {
64
+ "PERSON": "PERSON_NAME",
65
+ "PHONE_NUMBER": "INDIAN_MOBILE",
66
+ "LOCATION": "INDIAN_ADDRESS",
67
+ }
68
+
69
+ # Same as PRESIDIO_LABEL_MAP, plus the two custom pattern recognizers
70
+ # presidio_custom_adapter.py registers directly under these names.
71
+ PRESIDIO_CUSTOM_LABEL_MAP: dict[str, str] = {
72
+ **PRESIDIO_LABEL_MAP,
73
+ "IN_AADHAAR": "AADHAAR",
74
+ "IN_PAN": "PAN",
75
+ }
76
+
77
+ # mask-privacy's DLP registry (core/dlp/registry.py) has zero India-specific
78
+ # entity types (confirmed by inspecting the installed package -- 38 raw
79
+ # types, all US/EU/generic: BANK_ACCT_NUM, PHONE_NUM, EMAIL_ADDR, US_SSN,
80
+ # VEHICLE_PLATE, ...). Its Tier-2 NLP tier is Presidio underneath, reusing
81
+ # the same spaCy PERSON/LOCATION labels as PRESIDIO_LABEL_MAP.
82
+ MASK_PRIVACY_LABEL_MAP: dict[str, str] = {
83
+ "PERSON": "PERSON_NAME",
84
+ "LOCATION": "INDIAN_ADDRESS",
85
+ "PHONE_NUM": "INDIAN_MOBILE",
86
+ "PHONE_NUM_INTL": "INDIAN_MOBILE",
87
+ "BANK_ACCT_NUM": "BANK_ACCOUNT_IN",
88
+ "VEHICLE_PLATE": "VEHICLE_REG",
89
+ }
90
+
91
+ # naive_regex_adapter.py's own made-up label names, mapped to canonical.
92
+ NAIVE_REGEX_LABEL_MAP: dict[str, str] = {
93
+ "PHONE_SHAPED": "INDIAN_MOBILE",
94
+ "AADHAAR_SHAPED": "AADHAAR",
95
+ "PAN_SHAPED": "PAN",
96
+ "PINCODE_SHAPED": "PIN_CODE",
97
+ }
@@ -0,0 +1,105 @@
1
+ """Lenient JSONL loader for a user's own labelled data (`maskflow bench
2
+ --my-data <path>`, packages/maskflow-cli), as opposed to corpus.py's
3
+ strict parser for the bundled benchmark corpora.
4
+
5
+ A user checking "is it accurate on my documents?" has no reason to know
6
+ about `domain`/`lang`/`value_class` -- those are benchmark-corpus
7
+ bookkeeping, not concepts a newcomer's own labelled file would naturally
8
+ carry. Only `text` and `entities[].{start,end,label}` are required; every
9
+ other field gets a sensible default. The result is the exact same
10
+ `Document` dataclass corpus.py produces, so every downstream function
11
+ (labels.canonical_labels, matching.evaluate, runner.run_adapter,
12
+ report.write_report) is used unmodified.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from collections.abc import Iterator
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ from .corpus import Document
23
+
24
+ _REQUIRED_ENTITY_FIELDS = ("start", "end", "label")
25
+
26
+
27
+ @dataclass
28
+ class LoaderError(Exception):
29
+ """Raised on one malformed line, with the 1-based line number so the
30
+ CLI can report exactly where a user's file needs fixing -- never a raw
31
+ KeyError/TypeError/JSONDecodeError with no context."""
32
+
33
+ line_no: int
34
+ message: str
35
+
36
+ def __str__(self) -> str:
37
+ return f"line {self.line_no}: {self.message}"
38
+
39
+
40
+ def _parse_line(line_no: int, line: str) -> Document:
41
+ try:
42
+ row = json.loads(line)
43
+ except json.JSONDecodeError as e:
44
+ raise LoaderError(line_no, f"invalid JSON ({e})") from e
45
+ if not isinstance(row, dict):
46
+ raise LoaderError(line_no, "each line must be a JSON object")
47
+
48
+ if "text" not in row:
49
+ raise LoaderError(line_no, "missing required field 'text'")
50
+ text = row["text"]
51
+ if not isinstance(text, str):
52
+ raise LoaderError(line_no, "'text' must be a string")
53
+
54
+ if "entities" not in row:
55
+ raise LoaderError(line_no, "missing required field 'entities'")
56
+ raw_entities = row["entities"]
57
+ if not isinstance(raw_entities, list):
58
+ raise LoaderError(line_no, "'entities' must be a list")
59
+
60
+ gold = []
61
+ decoys = []
62
+ for i, entity in enumerate(raw_entities):
63
+ if not isinstance(entity, dict):
64
+ raise LoaderError(line_no, f"entities[{i}] must be an object")
65
+ missing = [f for f in _REQUIRED_ENTITY_FIELDS if f not in entity]
66
+ if missing:
67
+ raise LoaderError(line_no, f"entities[{i}] missing field(s): {', '.join(missing)}")
68
+ start, end, label = entity["start"], entity["end"], entity["label"]
69
+ if not isinstance(start, int) or not isinstance(end, int):
70
+ raise LoaderError(line_no, f"entities[{i}]: 'start'/'end' must be integers")
71
+ if not isinstance(label, str):
72
+ raise LoaderError(line_no, f"entities[{i}]: 'label' must be a string")
73
+ span = (start, end, label)
74
+ if entity.get("value_class", "positive") == "positive":
75
+ gold.append(span)
76
+ else:
77
+ decoys.append(span)
78
+
79
+ return Document(
80
+ id=str(row.get("id", f"line-{line_no}")),
81
+ text=text,
82
+ domain=str(row.get("domain", "user_data")),
83
+ lang=str(row.get("lang", "en")),
84
+ gold=tuple(gold),
85
+ decoys=tuple(decoys),
86
+ )
87
+
88
+
89
+ def iter_my_data(path: Path) -> Iterator[Document]:
90
+ with path.open(encoding="utf-8") as f:
91
+ for line_no, line in enumerate(f, start=1):
92
+ line = line.strip()
93
+ if line:
94
+ yield _parse_line(line_no, line)
95
+
96
+
97
+ def load_my_data(path: Path, limit: int | None = None) -> list[Document]:
98
+ """Loads `path` in file order. `limit` scores only the first N lines --
99
+ a quick smoke run on a large file, same convention as corpus.py's."""
100
+ docs = []
101
+ for i, doc in enumerate(iter_my_data(path)):
102
+ if limit is not None and i >= limit:
103
+ break
104
+ docs.append(doc)
105
+ return docs
@@ -0,0 +1,130 @@
1
+ """Strict-span and partial-overlap precision/recall/F1 scoring.
2
+
3
+ Gold spans for a Document are `Document.gold` only (`value_class ==
4
+ "positive"`) -- `Document.decoys` (hard-negative spans) are never gold, but
5
+ a prediction landing on one is still scored: after label-mapping (see
6
+ labels.py), any predicted span left unmatched to a gold span -- whether it
7
+ overlaps background text or a decoy -- counts as a false positive for its
8
+ mapped canonical type. That's what makes the corpus's hard negatives (e.g.
9
+ a naive-regex hit on PAN_SHAPED_INVOICE_NO) actually cost precision.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from enum import Enum
16
+
17
+ from .corpus import Document
18
+
19
+ RawSpan = tuple[int, int, str] # (start, end, raw_label) as an adapter emits it
20
+ GoldSpan = tuple[int, int, str] # (start, end, canonical_label)
21
+
22
+
23
+ class MatchMode(str, Enum):
24
+ STRICT = "strict"
25
+ PARTIAL = "partial"
26
+
27
+
28
+ @dataclass
29
+ class PRFResult:
30
+ entity_type: str
31
+ tp: int = 0
32
+ fp: int = 0
33
+ fn: int = 0
34
+
35
+ @property
36
+ def precision(self) -> float | None:
37
+ denom = self.tp + self.fp
38
+ return self.tp / denom if denom else None
39
+
40
+ @property
41
+ def recall(self) -> float | None:
42
+ denom = self.tp + self.fn
43
+ return self.tp / denom if denom else None
44
+
45
+ @property
46
+ def f1(self) -> float | None:
47
+ """None only when there was nothing to score on one side (precision
48
+ or recall has no denominator -- no predictions AND/OR no gold). A
49
+ detector that fired on this type but matched no gold span (tp=0,
50
+ fp>0, fn>0) has a *measured* F1 of 0.0, not "not applicable" -- the
51
+ report renders that as `0.0%`, distinct from `—`."""
52
+ p, r = self.precision, self.recall
53
+ if p is None or r is None:
54
+ return None
55
+ if p + r == 0:
56
+ return 0.0
57
+ return 2 * p * r / (p + r)
58
+
59
+
60
+ def _mapped_predictions(
61
+ predictions: list[RawSpan], label_map: dict[str, str]
62
+ ) -> list[tuple[int, int, str]]:
63
+ """Drops any prediction whose raw label has no canonical mapping --
64
+ never scored as a false positive against an unrelated type."""
65
+ out = []
66
+ for start, end, raw_label in predictions:
67
+ canonical = label_map.get(raw_label)
68
+ if canonical is not None:
69
+ out.append((start, end, canonical))
70
+ return out
71
+
72
+
73
+ def _score_document(
74
+ gold: tuple[GoldSpan, ...],
75
+ predictions: list[tuple[int, int, str]],
76
+ mode: MatchMode,
77
+ results: dict[str, PRFResult],
78
+ ) -> None:
79
+ unclaimed_pred = list(range(len(predictions)))
80
+
81
+ def overlaps(a: tuple[int, int, str], b: tuple[int, int, str]) -> bool:
82
+ return max(a[0], b[0]) < min(a[1], b[1])
83
+
84
+ for g_start, g_end, g_label in gold:
85
+ gold_span = (g_start, g_end, g_label)
86
+ match_idx = None
87
+ for idx in unclaimed_pred:
88
+ p_start, p_end, p_label = predictions[idx]
89
+ if p_label != g_label:
90
+ continue
91
+ if mode is MatchMode.STRICT:
92
+ hit = (p_start, p_end) == (g_start, g_end)
93
+ else:
94
+ hit = overlaps((p_start, p_end, p_label), gold_span)
95
+ if hit:
96
+ match_idx = idx
97
+ break
98
+ if match_idx is not None:
99
+ results[g_label].tp += 1
100
+ unclaimed_pred.remove(match_idx)
101
+ else:
102
+ results[g_label].fn += 1
103
+
104
+ for idx in unclaimed_pred:
105
+ _p_start, _p_end, p_label = predictions[idx]
106
+ if p_label in results:
107
+ results[p_label].fp += 1
108
+
109
+
110
+ def evaluate(
111
+ docs: list[Document],
112
+ predictions_by_doc: list[list[RawSpan]],
113
+ label_map: dict[str, str],
114
+ canonical_labels: tuple[str, ...],
115
+ mode: MatchMode,
116
+ ) -> dict[str, PRFResult]:
117
+ """Scores each doc's already-computed raw predictions (see runner.py,
118
+ which owns calling detect() so it can time/guard each call once and
119
+ reuse the same predictions for both matching modes) against
120
+ `doc.gold`, after mapping raw labels through `label_map`. Restricted to
121
+ `canonical_labels` so an adapter's detections of types outside this
122
+ corpus's taxonomy never enter the tally at all.
123
+ """
124
+ results: dict[str, PRFResult] = {t: PRFResult(entity_type=t) for t in canonical_labels}
125
+ for doc, raw_predictions in zip(docs, predictions_by_doc, strict=True):
126
+ predictions = _mapped_predictions(raw_predictions, label_map)
127
+ predictions = [p for p in predictions if p[2] in results]
128
+ gold = tuple(g for g in doc.gold if g[2] in results)
129
+ _score_document(gold, predictions, mode, results)
130
+ return results
@@ -0,0 +1,27 @@
1
+ """Recovers character offsets for adapters that only return matched
2
+ substrings, not (start, end) positions -- mask-privacy's Tier-2 NLP
3
+ entities and the LLM adapter both need this (see their modules' docstrings
4
+ for why offsets aren't available directly from those APIs).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ def locate_span(text: str, value: str, used_starts: set[int]) -> tuple[int, int] | None:
11
+ """Finds the first occurrence of `value` in `text` whose start index
12
+ isn't already in `used_starts`, claims it, and returns (start, end).
13
+ Returns None if `value` doesn't occur (or every occurrence is already
14
+ claimed) -- the caller drops that finding and counts it as unlocatable
15
+ rather than guessing.
16
+ """
17
+ if not value:
18
+ return None
19
+ cursor = 0
20
+ while True:
21
+ idx = text.find(value, cursor)
22
+ if idx == -1:
23
+ return None
24
+ if idx not in used_starts:
25
+ used_starts.add(idx)
26
+ return idx, idx + len(value)
27
+ cursor = idx + 1
@@ -0,0 +1,78 @@
1
+ """Latency (ms/KB) and peak-memory (RSS delta) measurement for one
2
+ adapter's run over a corpus.
3
+
4
+ Known limitation (documented, not hidden): adapters run in-process,
5
+ sequentially, not subprocess-isolated -- ru_maxrss is a whole-process,
6
+ monotonically-nondecreasing high-water mark, so peak_memory_mb here is an
7
+ *attributable delta* across one adapter's calls, not a hard ceiling that
8
+ adapter alone ever touched. Good enough for a first cut / relative
9
+ comparison, not a substitute for real per-process isolation.
10
+
11
+ A second limitation, specifically because `maskflow bench --my-data`
12
+ (packages/maskflow-cli) makes this module reachable on Windows, unlike the
13
+ dev-only multi-adapter harness that only ever ran on Linux/macOS CI:
14
+ `resource` is POSIX-only. On a platform without it, peak_memory_mb is
15
+ always reported as 0.0 rather than crashing -- timing (latency_ms_per_kb,
16
+ median/p95 doc ms) is unaffected either way.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import sys
22
+ from dataclasses import dataclass, field
23
+
24
+ try:
25
+ import resource
26
+ except ImportError: # Windows
27
+ resource = None # type: ignore[assignment]
28
+
29
+
30
+ def _current_maxrss_mb() -> float:
31
+ if resource is None:
32
+ return 0.0
33
+ raw = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
34
+ # macOS reports ru_maxrss in bytes; Linux reports it in KB.
35
+ return raw / (1024 * 1024) if sys.platform == "darwin" else raw / 1024
36
+
37
+
38
+ @dataclass
39
+ class ProfileResult:
40
+ latency_ms_per_kb: float
41
+ median_doc_ms: float
42
+ p95_doc_ms: float
43
+ peak_memory_mb: float
44
+ errors: int = 0
45
+
46
+
47
+ @dataclass
48
+ class Profiler:
49
+ _doc_ms: list[float] = field(default_factory=list)
50
+ _total_bytes: int = 0
51
+ _start_rss_mb: float = 0.0
52
+ _started: bool = False
53
+ errors: int = 0
54
+
55
+ def start(self) -> None:
56
+ self._start_rss_mb = _current_maxrss_mb()
57
+ self._started = True
58
+
59
+ def record(self, elapsed_seconds: float, size_bytes: int) -> None:
60
+ self._doc_ms.append(elapsed_seconds * 1000)
61
+ self._total_bytes += size_bytes
62
+
63
+ def finish(self) -> ProfileResult:
64
+ end_rss_mb = _current_maxrss_mb()
65
+ total_ms = sum(self._doc_ms)
66
+ total_kb = (self._total_bytes / 1024) or 1.0
67
+ sorted_ms = sorted(self._doc_ms)
68
+ median = sorted_ms[len(sorted_ms) // 2] if sorted_ms else 0.0
69
+ p95_idx = min(len(sorted_ms) - 1, int(len(sorted_ms) * 0.95)) if sorted_ms else 0
70
+ p95 = sorted_ms[p95_idx] if sorted_ms else 0.0
71
+ start_rss = self._start_rss_mb if self._started else end_rss_mb
72
+ return ProfileResult(
73
+ latency_ms_per_kb=total_ms / total_kb,
74
+ median_doc_ms=median,
75
+ p95_doc_ms=p95,
76
+ peak_memory_mb=max(0.0, end_rss_mb - start_rss),
77
+ errors=self.errors,
78
+ )
@@ -0,0 +1,149 @@
1
+ """Writes results.json (full structured data) and results.md (generated
2
+ tables) from a run_all() result. Module path is
3
+ `maskflow_bench.report`, distinct from the unrelated, pre-existing
4
+ `bench.indiapii.report` (the pack-india L1-L3 dev accuracy report) -- see
5
+ harness/__init__.py's docstring.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+
12
+ # datetime.UTC is 3.11+ only; this package's floor is requires-python
13
+ # >=3.10 (it's now reachable from maskflow-cli, unlike when this lived in
14
+ # the dev-only bench/indiapii/harness/ and never ran on 3.10 in CI).
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+
18
+ from .matching import PRFResult
19
+ from .runner import AdapterRunResult
20
+
21
+
22
+ def _prf_to_dict(r: PRFResult) -> dict[str, float | int | None]:
23
+ return {
24
+ "precision": r.precision,
25
+ "recall": r.recall,
26
+ "f1": r.f1,
27
+ "tp": r.tp,
28
+ "fp": r.fp,
29
+ "fn": r.fn,
30
+ }
31
+
32
+
33
+ def to_json_dict(
34
+ corpus_name: str,
35
+ num_docs: int,
36
+ canonical_labels: tuple[str, ...],
37
+ results: dict[str, AdapterRunResult],
38
+ ) -> dict:
39
+ adapters: dict[str, dict] = {}
40
+ for name, r in results.items():
41
+ entry: dict = {"available": r.available, "skipped_reason": r.skipped_reason}
42
+ if r.available:
43
+ entry["strict"] = {label: _prf_to_dict(v) for label, v in r.strict.items()}
44
+ entry["partial"] = {label: _prf_to_dict(v) for label, v in r.partial.items()}
45
+ if r.profile is not None:
46
+ entry["latency_ms_per_kb"] = r.profile.latency_ms_per_kb
47
+ entry["median_doc_ms"] = r.profile.median_doc_ms
48
+ entry["p95_doc_ms"] = r.profile.p95_doc_ms
49
+ entry["peak_memory_mb"] = r.profile.peak_memory_mb
50
+ entry["doc_errors"] = r.profile.errors
51
+ adapters[name] = entry
52
+ return {
53
+ "corpus": corpus_name,
54
+ "generated_at": datetime.now(timezone.utc).isoformat(),
55
+ "num_docs": num_docs,
56
+ "canonical_labels": list(canonical_labels),
57
+ "adapters": adapters,
58
+ }
59
+
60
+
61
+ def write_json(path: Path, data: dict) -> None:
62
+ path.parent.mkdir(parents=True, exist_ok=True)
63
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
64
+
65
+
66
+ def _f1_cell(results: dict[str, AdapterRunResult], adapter_name: str, mode: str, label: str) -> str:
67
+ r = results[adapter_name]
68
+ if not r.available:
69
+ return "skipped"
70
+ prf = getattr(r, mode).get(label)
71
+ if prf is None or prf.f1 is None:
72
+ return "—"
73
+ return f"{prf.f1:.1%}"
74
+
75
+
76
+ def _pivot_table(
77
+ results: dict[str, AdapterRunResult],
78
+ adapter_names: list[str],
79
+ canonical_labels: tuple[str, ...],
80
+ mode: str,
81
+ title: str,
82
+ ) -> str:
83
+ header = "| entity_type | " + " | ".join(adapter_names) + " |"
84
+ sep = "|---" * (len(adapter_names) + 1) + "|"
85
+ lines = [f"### {title}", "", header, sep]
86
+ for label in canonical_labels:
87
+ row = [_f1_cell(results, name, mode, label) for name in adapter_names]
88
+ lines.append(f"| {label} | " + " | ".join(row) + " |")
89
+ return "\n".join(lines)
90
+
91
+
92
+ def _latency_table(results: dict[str, AdapterRunResult], adapter_names: list[str]) -> str:
93
+ header = "| adapter | ms/KB | median ms/doc | p95 ms/doc | peak memory (MB) | doc errors |"
94
+ sep = "|---|---|---|---|---|---|"
95
+ lines = ["### Latency & memory", "", header, sep]
96
+ for name in adapter_names:
97
+ r = results[name]
98
+ if not r.available:
99
+ lines.append(f"| {name} | skipped ({r.skipped_reason}) | | | | |")
100
+ continue
101
+ p = r.profile
102
+ assert p is not None
103
+ lines.append(
104
+ f"| {name} | {p.latency_ms_per_kb:.3f} | {p.median_doc_ms:.3f} | "
105
+ f"{p.p95_doc_ms:.3f} | {p.peak_memory_mb:.1f} | {p.errors} |"
106
+ )
107
+ return "\n".join(lines)
108
+
109
+
110
+ def to_markdown(
111
+ corpus_name: str,
112
+ num_docs: int,
113
+ canonical_labels: tuple[str, ...],
114
+ results: dict[str, AdapterRunResult],
115
+ ) -> str:
116
+ adapter_names = list(results.keys())
117
+ parts = [
118
+ f"# {corpus_name} benchmark results",
119
+ "",
120
+ f"{num_docs} documents, {len(canonical_labels)} canonical entity types. "
121
+ 'F1 shown per entity per adapter. "0.0%" is a *measured* zero -- the adapter '
122
+ 'made predictions for this type but none matched a gold span. "—" means F1 '
123
+ "is undefined: the adapter made no prediction for this type at all (its "
124
+ "recognizer / label map doesn't cover it), or the corpus has no gold spans "
125
+ "for it. \"skipped\" means the adapter's dependency/API key wasn't available "
126
+ "in this environment.",
127
+ "",
128
+ _pivot_table(results, adapter_names, canonical_labels, "strict", "Strict-span F1"),
129
+ "",
130
+ _pivot_table(results, adapter_names, canonical_labels, "partial", "Partial-overlap F1"),
131
+ "",
132
+ _latency_table(results, adapter_names),
133
+ "",
134
+ ]
135
+ return "\n".join(parts)
136
+
137
+
138
+ def write_report(
139
+ out_dir: Path,
140
+ corpus_name: str,
141
+ num_docs: int,
142
+ canonical_labels: tuple[str, ...],
143
+ results: dict[str, AdapterRunResult],
144
+ ) -> None:
145
+ data = to_json_dict(corpus_name, num_docs, canonical_labels, results)
146
+ write_json(out_dir / "results.json", data)
147
+ md = to_markdown(corpus_name, num_docs, canonical_labels, results)
148
+ out_dir.mkdir(parents=True, exist_ok=True)
149
+ (out_dir / "results.md").write_text(md, encoding="utf-8")
@@ -0,0 +1,77 @@
1
+ """Orchestrates: for each adapter, check availability, run detect() once
2
+ per document (timed, error-guarded), then score the same cached
3
+ predictions under both matching modes -- no adapter's detect() is ever
4
+ called twice for the same document.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from dataclasses import dataclass, field
11
+
12
+ from .adapters import AdapterEntry
13
+ from .corpus import Document
14
+ from .matching import MatchMode, PRFResult, evaluate
15
+ from .profiling import Profiler, ProfileResult
16
+
17
+
18
+ @dataclass
19
+ class AdapterRunResult:
20
+ name: str
21
+ available: bool
22
+ skipped_reason: str = ""
23
+ strict: dict[str, PRFResult] = field(default_factory=dict)
24
+ partial: dict[str, PRFResult] = field(default_factory=dict)
25
+ profile: ProfileResult | None = None
26
+
27
+
28
+ def run_adapter(
29
+ entry: AdapterEntry, docs: list[Document], canonical_labels: tuple[str, ...]
30
+ ) -> AdapterRunResult:
31
+ adapter, label_map = entry
32
+ ok, reason = adapter.available()
33
+ if not ok:
34
+ return AdapterRunResult(name=adapter.name, available=False, skipped_reason=reason)
35
+
36
+ # One untimed warm-up call so a one-time cost every adapter pays somewhere
37
+ # (spaCy model load, first-call JIT/regex compilation, ...) lands the same
38
+ # way for all of them -- some adapters already pay it inside available()
39
+ # (e.g. presidio's engine construction), others pay it lazily on their
40
+ # first detect() call (maskflow's NER pass) -- without this, whichever
41
+ # adapter happens to defer its warm-up into the timed loop looks
42
+ # artificially slower for reasons that have nothing to do with steady-
43
+ # state per-document latency, which is what ms/KB is meant to measure.
44
+ try:
45
+ adapter.detect("warm-up, no PII here.")
46
+ except Exception: # noqa: BLE001 -- warm-up failures don't affect timing/scoring
47
+ pass
48
+
49
+ profiler = Profiler()
50
+ profiler.start()
51
+ predictions_by_doc: list[list[tuple[int, int, str]]] = []
52
+ for doc in docs:
53
+ t0 = time.perf_counter()
54
+ try:
55
+ preds = adapter.detect(doc.text)
56
+ except Exception: # noqa: BLE001 -- one bad doc must not sink the whole adapter run
57
+ preds = []
58
+ profiler.errors += 1
59
+ profiler.record(time.perf_counter() - t0, doc.size_bytes)
60
+ predictions_by_doc.append(preds)
61
+
62
+ strict = evaluate(docs, predictions_by_doc, label_map, canonical_labels, MatchMode.STRICT)
63
+ partial = evaluate(docs, predictions_by_doc, label_map, canonical_labels, MatchMode.PARTIAL)
64
+
65
+ return AdapterRunResult(
66
+ name=adapter.name,
67
+ available=True,
68
+ strict=strict,
69
+ partial=partial,
70
+ profile=profiler.finish(),
71
+ )
72
+
73
+
74
+ def run_all(
75
+ entries: list[AdapterEntry], docs: list[Document], canonical_labels: tuple[str, ...]
76
+ ) -> dict[str, AdapterRunResult]:
77
+ return {entry[0].name: run_adapter(entry, docs, canonical_labels) for entry in entries}
@@ -0,0 +1,29 @@
1
+ """Scores MaskFlow itself against a user's own labelled data
2
+ (`maskflow bench --my-data <path>`). Thin composition of loader.py +
3
+ labels.py + runner.py + adapters.maskflow_adapter -- the same pieces
4
+ bench/indiapii/harness/__main__.py assembles ad hoc for the bundled
5
+ corpora, wired together here as one reusable call so the CLI (and anyone
6
+ else) doesn't have to re-derive it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from .adapters.maskflow_adapter import MaskflowAdapter
14
+ from .labels import canonical_labels, identity_map
15
+ from .loader import load_my_data
16
+ from .runner import AdapterRunResult, run_adapter
17
+
18
+
19
+ def score_my_data(
20
+ path: Path, limit: int | None = None
21
+ ) -> tuple[int, tuple[str, ...], AdapterRunResult]:
22
+ """Returns (num_docs, canonical_labels, result). Raises LoaderError
23
+ (loader.py) on a malformed line, FileNotFoundError if `path` doesn't
24
+ exist."""
25
+ docs = load_my_data(path, limit=limit)
26
+ labels = canonical_labels(docs)
27
+ entry = (MaskflowAdapter(), identity_map(labels))
28
+ result = run_adapter(entry, docs, labels)
29
+ return len(docs), labels, result
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from maskflow_bench.corpus import load_corpus
7
+
8
+
9
+ def _write_jsonl(path: Path, rows: list[dict]) -> None:
10
+ path.write_text("\n".join(json.dumps(r) for r in rows) + "\n", encoding="utf-8")
11
+
12
+
13
+ def test_load_corpus_splits_positive_and_hard_negative(tmp_path: Path) -> None:
14
+ row = {
15
+ "id": "doc-1",
16
+ "text": "PAN: ABCDE1234F, invoice INV-0001",
17
+ "entities": [
18
+ {"start": 5, "end": 15, "label": "PAN", "value_class": "positive"},
19
+ {
20
+ "start": 25,
21
+ "end": 33,
22
+ "label": "PAN_SHAPED_INVOICE_NO",
23
+ "value_class": "hard_negative",
24
+ },
25
+ ],
26
+ "domain": "kyc_form",
27
+ "lang": "en",
28
+ }
29
+ path = tmp_path / "corpus.jsonl"
30
+ _write_jsonl(path, [row])
31
+
32
+ docs = load_corpus(path)
33
+ assert len(docs) == 1
34
+ doc = docs[0]
35
+ assert doc.gold == ((5, 15, "PAN"),)
36
+ assert doc.decoys == ((25, 33, "PAN_SHAPED_INVOICE_NO"),)
37
+ assert doc.size_bytes == len(doc.text.encode("utf-8"))
38
+
39
+
40
+ def test_load_corpus_limit_takes_first_n_in_file_order(tmp_path: Path) -> None:
41
+ rows = [
42
+ {"id": f"doc-{i}", "text": "t", "entities": [], "domain": "d", "lang": "en"}
43
+ for i in range(5)
44
+ ]
45
+ path = tmp_path / "corpus.jsonl"
46
+ _write_jsonl(path, rows)
47
+
48
+ docs = load_corpus(path, limit=2)
49
+ assert [d.id for d in docs] == ["doc-0", "doc-1"]
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from maskflow_bench.corpus import Document
4
+ from maskflow_bench.labels import canonical_labels, identity_map
5
+
6
+
7
+ def test_canonical_labels_derived_from_gold_only() -> None:
8
+ docs = [
9
+ Document(
10
+ id="d1",
11
+ text="x",
12
+ domain="t",
13
+ lang="en",
14
+ gold=((0, 1, "PAN"), (2, 3, "AADHAAR")),
15
+ decoys=((4, 5, "PAN_SHAPED_INVOICE_NO"),),
16
+ )
17
+ ]
18
+ labels = canonical_labels(docs)
19
+ assert labels == ("AADHAAR", "PAN")
20
+ assert "PAN_SHAPED_INVOICE_NO" not in labels
21
+
22
+
23
+ def test_identity_map_is_reflexive() -> None:
24
+ m = identity_map(("AADHAAR", "PAN"))
25
+ assert m == {"AADHAAR": "AADHAAR", "PAN": "PAN"}
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+ from maskflow_bench.loader import LoaderError, load_my_data
8
+
9
+
10
+ def _write(path: Path, lines: list[dict]) -> None:
11
+ path.write_text("\n".join(json.dumps(r) for r in lines) + "\n", encoding="utf-8")
12
+
13
+
14
+ def test_minimal_line_needs_only_text_and_entities(tmp_path: Path) -> None:
15
+ path = tmp_path / "my.jsonl"
16
+ # PAN below is structurally shaped but not a real allotted number --
17
+ # synthetic fixture data only.
18
+ _write(
19
+ path,
20
+ [{"text": "PAN: ABCDE1234F", "entities": [{"start": 5, "end": 15, "label": "PAN"}]}],
21
+ )
22
+ docs = load_my_data(path)
23
+ assert len(docs) == 1
24
+ doc = docs[0]
25
+ assert doc.gold == ((5, 15, "PAN"),)
26
+ assert doc.decoys == ()
27
+ # Defaults applied when the user's file doesn't supply them.
28
+ assert doc.domain == "user_data"
29
+ assert doc.lang == "en"
30
+ assert doc.id == "line-1"
31
+
32
+
33
+ def test_explicit_id_domain_lang_are_preserved(tmp_path: Path) -> None:
34
+ path = tmp_path / "my.jsonl"
35
+ _write(
36
+ path,
37
+ [
38
+ {
39
+ "id": "ticket-42",
40
+ "text": "x",
41
+ "entities": [],
42
+ "domain": "support_ticket",
43
+ "lang": "en-hi",
44
+ }
45
+ ],
46
+ )
47
+ doc = load_my_data(path)[0]
48
+ assert doc.id == "ticket-42"
49
+ assert doc.domain == "support_ticket"
50
+ assert doc.lang == "en-hi"
51
+
52
+
53
+ def test_hard_negative_value_class_goes_to_decoys(tmp_path: Path) -> None:
54
+ path = tmp_path / "my.jsonl"
55
+ _write(
56
+ path,
57
+ [
58
+ {
59
+ "text": "invoice INV-0001",
60
+ "entities": [
61
+ {"start": 8, "end": 16, "label": "PAN_SHAPED", "value_class": "hard_negative"}
62
+ ],
63
+ }
64
+ ],
65
+ )
66
+ doc = load_my_data(path)[0]
67
+ assert doc.gold == ()
68
+ assert doc.decoys == ((8, 16, "PAN_SHAPED"),)
69
+
70
+
71
+ def test_limit_takes_first_n_in_file_order(tmp_path: Path) -> None:
72
+ path = tmp_path / "my.jsonl"
73
+ _write(path, [{"text": f"t{i}", "entities": []} for i in range(5)])
74
+ docs = load_my_data(path, limit=2)
75
+ assert [d.text for d in docs] == ["t0", "t1"]
76
+
77
+
78
+ @pytest.mark.parametrize(
79
+ "row",
80
+ [
81
+ {"entities": []}, # missing text
82
+ {"text": "x"}, # missing entities
83
+ {"text": "x", "entities": [{"start": 0, "end": 1}]}, # entity missing label
84
+ {"text": "x", "entities": [{"start": 0, "label": "PAN"}]}, # entity missing end
85
+ {"text": "x", "entities": "not-a-list"},
86
+ {"text": 123, "entities": []},
87
+ {"text": "x", "entities": [{"start": "0", "end": 1, "label": "PAN"}]},
88
+ ],
89
+ )
90
+ def test_malformed_line_raises_loader_error_with_line_number(tmp_path: Path, row: dict) -> None:
91
+ path = tmp_path / "my.jsonl"
92
+ # Line 1 is valid, so the error must point at line 2, not line 1.
93
+ _write(path, [{"text": "ok", "entities": []}])
94
+ with path.open("a", encoding="utf-8") as f:
95
+ f.write(json.dumps(row) + "\n")
96
+
97
+ with pytest.raises(LoaderError) as exc_info:
98
+ load_my_data(path)
99
+ assert exc_info.value.line_no == 2
100
+
101
+
102
+ def test_invalid_json_raises_loader_error(tmp_path: Path) -> None:
103
+ path = tmp_path / "my.jsonl"
104
+ path.write_text("{not json\n", encoding="utf-8")
105
+ with pytest.raises(LoaderError) as exc_info:
106
+ load_my_data(path)
107
+ assert exc_info.value.line_no == 1
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ from maskflow_bench.corpus import Document
4
+ from maskflow_bench.matching import MatchMode, evaluate
5
+
6
+
7
+ def _doc(gold: tuple, decoys: tuple = ()) -> Document:
8
+ return Document(id="d1", text="x" * 100, domain="test", lang="en", gold=gold, decoys=decoys)
9
+
10
+
11
+ def test_strict_exact_match_is_tp() -> None:
12
+ docs = [_doc(gold=((0, 5, "PAN"),))]
13
+ preds = [[(0, 5, "PAN")]]
14
+ r = evaluate(docs, preds, {"PAN": "PAN"}, ("PAN",), MatchMode.STRICT)
15
+ assert r["PAN"].tp == 1
16
+ assert r["PAN"].fp == 0
17
+ assert r["PAN"].fn == 0
18
+
19
+
20
+ def test_strict_offset_mismatch_is_fn_and_fp() -> None:
21
+ docs = [_doc(gold=((0, 5, "PAN"),))]
22
+ preds = [[(0, 6, "PAN")]] # one char off
23
+ r = evaluate(docs, preds, {"PAN": "PAN"}, ("PAN",), MatchMode.STRICT)
24
+ assert r["PAN"].tp == 0
25
+ assert r["PAN"].fn == 1
26
+ assert r["PAN"].fp == 1
27
+ # fired + had gold + matched nothing -> a *measured* 0.0, not None.
28
+ assert r["PAN"].precision == 0.0
29
+ assert r["PAN"].recall == 0.0
30
+ assert r["PAN"].f1 == 0.0
31
+
32
+
33
+ def test_partial_overlap_counts_as_tp() -> None:
34
+ docs = [_doc(gold=((10, 20, "AADHAAR"),))]
35
+ preds = [[(12, 18, "AADHAAR")]] # contained, not exact
36
+ r = evaluate(docs, preds, {"AADHAAR": "AADHAAR"}, ("AADHAAR",), MatchMode.PARTIAL)
37
+ assert r["AADHAAR"].tp == 1
38
+ assert r["AADHAAR"].fp == 0
39
+ assert r["AADHAAR"].fn == 0
40
+
41
+
42
+ def test_no_overlap_is_fn_and_fp() -> None:
43
+ docs = [_doc(gold=((10, 20, "AADHAAR"),))]
44
+ preds = [[(30, 40, "AADHAAR")]]
45
+ r = evaluate(docs, preds, {"AADHAAR": "AADHAAR"}, ("AADHAAR",), MatchMode.PARTIAL)
46
+ assert r["AADHAAR"].tp == 0
47
+ assert r["AADHAAR"].fn == 1
48
+ assert r["AADHAAR"].fp == 1
49
+
50
+
51
+ def test_hard_negative_hit_counts_as_false_positive() -> None:
52
+ # A prediction landing on a hard-negative decoy span (never gold) is
53
+ # still a false positive for whatever canonical type it claimed.
54
+ docs = [_doc(gold=(), decoys=((0, 10, "PAN_SHAPED_INVOICE_NO"),))]
55
+ preds = [[(0, 10, "PAN")]]
56
+ r = evaluate(docs, preds, {"PAN": "PAN"}, ("PAN",), MatchMode.PARTIAL)
57
+ assert r["PAN"].tp == 0
58
+ assert r["PAN"].fp == 1
59
+ assert r["PAN"].fn == 0
60
+
61
+
62
+ def test_unmapped_raw_label_is_dropped_not_scored() -> None:
63
+ # An adapter's raw label with no entry in label_map (e.g. Presidio's
64
+ # ORGANIZATION) must never count as a false positive against an
65
+ # unrelated canonical type.
66
+ docs = [_doc(gold=((0, 5, "PAN"),))]
67
+ preds = [[(50, 60, "ORGANIZATION")]]
68
+ r = evaluate(docs, preds, {"PAN": "PAN"}, ("PAN",), MatchMode.STRICT)
69
+ assert r["PAN"].tp == 0
70
+ assert r["PAN"].fp == 0
71
+ assert r["PAN"].fn == 1
72
+
73
+
74
+ def test_precision_recall_f1_none_when_no_denominator() -> None:
75
+ docs = [_doc(gold=())]
76
+ preds = [[]]
77
+ r = evaluate(docs, preds, {}, ("PAN",), MatchMode.STRICT)
78
+ assert r["PAN"].precision is None
79
+ assert r["PAN"].recall is None
80
+ assert r["PAN"].f1 is None
81
+
82
+
83
+ def test_f1_is_zero_not_none_when_gold_exists_but_nothing_matched() -> None:
84
+ # Gold present, no predictions at all: recall has a denominator (0.0),
85
+ # precision does not -> still "nothing to score" on the precision side.
86
+ docs = [_doc(gold=((0, 5, "PAN"),))]
87
+ r = evaluate(docs, [[]], {"PAN": "PAN"}, ("PAN",), MatchMode.STRICT)
88
+ assert r["PAN"].recall == 0.0
89
+ assert r["PAN"].precision is None
90
+ assert r["PAN"].f1 is None # can't compute F1 with no precision
91
+
92
+ # But once the detector fires (fp>0) it has both sides -> measured 0.0.
93
+ docs2 = [_doc(gold=((0, 5, "PAN"),))]
94
+ r2 = evaluate(docs2, [[(50, 60, "PAN")]], {"PAN": "PAN"}, ("PAN",), MatchMode.STRICT)
95
+ assert r2["PAN"].f1 == 0.0
96
+
97
+
98
+ def test_multiple_gold_spans_claim_distinct_predictions() -> None:
99
+ # Two gold spans of the same type must each match a different
100
+ # prediction, not both greedily match the first candidate.
101
+ docs = [_doc(gold=((0, 5, "PAN"), (10, 15, "PAN")))]
102
+ preds = [[(0, 5, "PAN"), (10, 15, "PAN")]]
103
+ r = evaluate(docs, preds, {"PAN": "PAN"}, ("PAN",), MatchMode.STRICT)
104
+ assert r["PAN"].tp == 2
105
+ assert r["PAN"].fp == 0
106
+ assert r["PAN"].fn == 0
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import maskflow_pack_india # noqa: F401 -- registers AADHAAR/PAN/etc. recognizers
7
+ from maskflow_bench.scorer import score_my_data
8
+
9
+
10
+ def test_score_my_data_runs_maskflow_end_to_end(tmp_path: Path) -> None:
11
+ path = tmp_path / "my.jsonl"
12
+ # Structurally valid (checksum-passing) but fabricated for this test --
13
+ # not a real allotted PAN.
14
+ row = {
15
+ "text": "Please update my PAN ABCDE1234F on file.",
16
+ "entities": [{"start": 22, "end": 32, "label": "PAN"}],
17
+ }
18
+ path.write_text(json.dumps(row) + "\n", encoding="utf-8")
19
+
20
+ num_docs, labels, result = score_my_data(path)
21
+
22
+ assert num_docs == 1
23
+ assert labels == ("PAN",)
24
+ assert result.name == "maskflow"
25
+ assert result.available is True
26
+ assert "PAN" in result.strict
27
+ assert "PAN" in result.partial
28
+
29
+
30
+ def test_score_my_data_respects_limit(tmp_path: Path) -> None:
31
+ path = tmp_path / "my.jsonl"
32
+ rows = [{"text": f"doc {i}", "entities": []} for i in range(5)]
33
+ path.write_text("\n".join(json.dumps(r) for r in rows) + "\n", encoding="utf-8")
34
+
35
+ num_docs, _labels, _result = score_my_data(path, limit=2)
36
+ assert num_docs == 2