Skip to content

Commit f32bedc

Browse files
fix(enterprise): run detect_label_issues single-process by default; test FPE with and without pyffx (#433)
detect_label_issues passed cleanlab's default n_jobs, which starts one worker per core. On spawn platforms (macOS, Windows) a call from a plain script without a __main__ guard re-imported the script in every worker and never returned. Default n_jobs to 1; an explicit n_jobs is passed through. test_fpe_falls_back_to_surrogate_without_crypto asserted the surrogate mode without hiding pyffx, so it failed whenever the [privacy] or [all] extra was installed. Hide pyffx in that test and add a test for the crypto_fpe path when pyffx is present.
1 parent de25527 commit f32bedc

3 files changed

Lines changed: 83 additions & 2 deletions

File tree

‎src/freshdata/enterprise/cleaner.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,8 +768,14 @@ def detect_label_issues(
768768
out-of-sample predicted probabilities. Returns indices ranked by
769769
``return_indices_ranked_by`` (a valid Cleanlab option). Raises a clear
770770
:class:`ImportError` if Cleanlab is not installed.
771+
772+
``n_jobs`` defaults to 1. Cleanlab's own default starts one worker per core,
773+
and on platforms that spawn workers (macOS, Windows) a call from a plain
774+
script without an ``if __name__ == "__main__"`` guard then never returns.
775+
Pass ``n_jobs`` explicitly to use more workers.
771776
"""
772777
_require_cleanlab()
778+
kwargs.setdefault("n_jobs", 1)
773779
from cleanlab.filter import find_label_issues # pragma: no cover - needs cleanlab
774780

775781
return find_label_issues( # pragma: no cover - needs cleanlab

‎tests/test_enterprise_cleaner.py‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
"""Clustering, PII masking, semantic validation, and cleanlab-guard tests."""
22

3+
import os
4+
import subprocess
5+
import sys
6+
import types
7+
38
import pandas as pd
49
import pytest
510

@@ -394,3 +399,58 @@ def test_cleanlab_wrappers_raise_clear_error_when_absent():
394399
detect_label_issues([0, 1], [[0.9, 0.1], [0.2, 0.8]])
395400
with pytest.raises(ImportError, match="cleanlab"):
396401
detect_outliers([[1, 2], [3, 4]])
402+
403+
404+
def _fake_cleanlab(monkeypatch) -> list:
405+
calls: list = []
406+
filter_module = types.ModuleType("cleanlab.filter")
407+
408+
def find_label_issues(**kwargs):
409+
calls.append(kwargs)
410+
return [2]
411+
412+
filter_module.find_label_issues = find_label_issues
413+
package = types.ModuleType("cleanlab")
414+
package.filter = filter_module
415+
monkeypatch.setitem(sys.modules, "cleanlab", package)
416+
monkeypatch.setitem(sys.modules, "cleanlab.filter", filter_module)
417+
return calls
418+
419+
420+
def test_detect_label_issues_runs_single_process_by_default(monkeypatch):
421+
calls = _fake_cleanlab(monkeypatch)
422+
assert detect_label_issues([0, 1], [[0.9, 0.1], [0.2, 0.8]]) == [2]
423+
assert calls[0]["n_jobs"] == 1
424+
assert calls[0]["return_indices_ranked_by"] == "self_confidence"
425+
426+
427+
def test_detect_label_issues_keeps_an_explicit_n_jobs(monkeypatch):
428+
calls = _fake_cleanlab(monkeypatch)
429+
detect_label_issues([0, 1], [[0.9, 0.1], [0.2, 0.8]], n_jobs=4)
430+
assert calls[0]["n_jobs"] == 4
431+
432+
433+
def test_detect_label_issues_returns_from_an_unguarded_script(tmp_path):
434+
# With cleanlab's default worker pool, spawn start methods re-import this
435+
# script in every worker and the call never returns.
436+
pytest.importorskip("cleanlab")
437+
script = tmp_path / "label_issues.py"
438+
script.write_text(
439+
"import numpy as np\n"
440+
"from freshdata.enterprise.cleaner import detect_label_issues\n"
441+
"labels = np.array([0, 1, 0, 1, 0, 1])\n"
442+
"probs = np.array([[0.9, 0.1], [0.2, 0.8], [0.1, 0.9],"
443+
" [0.3, 0.7], [0.8, 0.2], [0.05, 0.95]])\n"
444+
"print([int(i) for i in detect_label_issues(labels, probs)])\n"
445+
)
446+
env = {**os.environ, "PYTHONPATH": os.pathsep.join(sys.path)}
447+
done = subprocess.run(
448+
[sys.executable, str(script)],
449+
capture_output=True,
450+
text=True,
451+
timeout=120,
452+
env=env,
453+
check=False,
454+
)
455+
assert done.returncode == 0, done.stderr[-2000:]
456+
assert done.stdout.strip().endswith("[2]")

‎tests/test_enterprise_privacy.py‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from __future__ import annotations
44

5+
import sys
6+
57
import pandas as pd
68
import pytest
79

@@ -169,13 +171,26 @@ def test_surrogate_preserves_shape():
169171
assert report.metadata["fpe_mode"] == "surrogate_format_preserving_not_crypto_fpe"
170172

171173

172-
def test_fpe_falls_back_to_surrogate_without_crypto():
174+
def test_fpe_falls_back_to_surrogate_without_crypto(monkeypatch):
175+
# Hide pyffx so the fallback runs even when the [privacy] extra is installed.
176+
monkeypatch.setitem(sys.modules, "pyffx", None)
177+
df = pd.DataFrame({"acct": ["1234567890"]})
178+
rule = MaskingRule(name="f", columns=("acct",), strategy="fpe", key="K", preserve_format=True)
179+
out, report = anonymize(df, rules=(rule,))
180+
masked = out["acct"].iloc[0]
181+
assert len(masked) == 10 and masked.isdigit()
182+
assert report.metadata["fpe_mode"] == "surrogate_format_preserving_not_crypto_fpe"
183+
184+
185+
def test_fpe_uses_crypto_when_pyffx_is_installed():
186+
pytest.importorskip("pyffx")
173187
df = pd.DataFrame({"acct": ["1234567890"]})
174188
rule = MaskingRule(name="f", columns=("acct",), strategy="fpe", key="K", preserve_format=True)
175189
out, report = anonymize(df, rules=(rule,))
176190
masked = out["acct"].iloc[0]
191+
assert masked != "1234567890"
177192
assert len(masked) == 10 and masked.isdigit()
178-
assert report.metadata["fpe_mode"].startswith("surrogate_format_preserving")
193+
assert report.metadata["fpe_mode"] == "crypto_fpe"
179194

180195

181196
def test_detection_driven_anonymization_scrubs_spans():

0 commit comments

Comments
 (0)