sitcod3 commited on
Commit
3be79c6
·
verified ·
1 Parent(s): 077774e

Upload folder using huggingface_hub

Browse files
.deploy-diag-weights.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Decisive: does the SAVED checkpoint reproduce the trainer's 0.999 eval?"""
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
6
+ BACKEND = Path(r"C:\Users\Sitcd3\Documents\Rezerva DESIGN_Validator\Validator_Modul_X-test\email-validator\backend")
7
+
8
+ import datasets # noqa: E402
9
+ import numpy as np # noqa: E402
10
+ from transformers import AutoModelForSequenceClassification, AutoTokenizer # noqa: E402
11
+
12
+ CKPT = str(BACKEND / "app" / "ml" / "runs" / "checkpoint-172")
13
+ CSV = str(BACKEND / "app" / "ml" / "data" / "email_risk.csv")
14
+
15
+ tokenizer = AutoTokenizer.from_pretrained("distilbert-base-uncased")
16
+ model = AutoModelForSequenceClassification.from_pretrained(CKPT)
17
+ model.eval()
18
+
19
+ raw = datasets.load_dataset("csv", data_files=CSV)["train"]
20
+ split = raw.train_test_split(test_size=0.15, seed=42)
21
+ test = split["test"]
22
+
23
+ texts = [str(t) for t in test["text"]]
24
+ enc = tokenizer(texts, padding="max_length", truncation=True,
25
+ max_length=64, return_tensors="pt")
26
+ with __import__("torch").no_grad():
27
+ logits = model(**enc).logits
28
+ pred = np.argmax(logits.numpy(), axis=-1)
29
+ y = np.array(test["label"])
30
+
31
+ acc = float((pred == y).mean())
32
+ names = ["NORMAL", "DISPOSABLE", "ROLE", "SPAM_TRAP"]
33
+ print(f"fresh-loaded checkpoint accuracy on held-out split: {acc:.4f} (trainer said 0.999)")
34
+ # Sample predictions vs truth:
35
+ for i in range(8):
36
+ print(f" {test['text'][i][:48]:50} true={names[y[i]]:10} pred={names[pred[i]]}")
.deploy-eval-onnx.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Smoke + generalization test of the exported ONNX model via the real runtime."""
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
6
+
7
+ from app.ml.runtime import EmailRiskModel # noqa: E402
8
+
9
+ MODEL_DIR = r"C:\Users\Sitcd3\Documents\Rezerva DESIGN_Validator\Validator_Modul_X-test\email-validator\backend\app\ml\onnx_model"
10
+
11
+ CASES = [
12
+ # (text, expected, note)
13
+ ("user [SEP] mailinator.com", "DISPOSABLE", "blocklisted (seen in training)"),
14
+ ("someone [SEP] 10minutemail.com", "DISPOSABLE", "blocklisted (seen)"),
15
+ # UNSEEN disposable-style domains - never in any list. The whole point.
16
+ ("someone [SEP] temp-mail-pro.xyz", "DISPOSABLE", "UNSEEN disposable-style"),
17
+ ("someone [SEP] disposable-inbox-2024.top", "DISPOSABLE", "UNSEEN disposable-style"),
18
+ ("someone [SEP] tr-tempmail.net", "DISPOSABLE", "UNSEEN disposable-style"),
19
+ # Legitimate - must NOT be flagged.
20
+ ("john.smith [SEP] gmail.com", "NORMAL", "free provider"),
21
+ ("maria.garcia [SEP] harborview-law.test", "NORMAL", "business domain"),
22
+ ("kenji.tanaka [SEP] acme-corp.com", "NORMAL", "business domain"),
23
+ ("info [SEP] acme-corp.com", "ROLE", "role account"),
24
+ ]
25
+
26
+ m = EmailRiskModel(repo=MODEL_DIR)
27
+ ok = m.load()
28
+ print("loaded:", ok)
29
+ if not ok:
30
+ sys.exit(1)
31
+
32
+ wrong = 0
33
+ for text, expected, note in CASES:
34
+ pred = m.predict([text])
35
+ label = pred[0][0] if pred else "??"
36
+ score = pred[0][1] if pred else 0.0
37
+ mark = "OK " if label == expected else "WRONG"
38
+ if label != expected:
39
+ wrong += 1
40
+ print(f"{mark} {text:52} -> {label:10} {score:.3f} ({note})")
41
+
42
+ print()
43
+ print(f"{len(CASES) - wrong}/{len(CASES)} correct")
44
+ sys.exit(1 if wrong else 0)
.deploy-export-onnx.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Export the fine-tuned checkpoint to ONNX and bundle the tokenizer.
2
+
3
+ Programmatic equivalent of `optimum-cli export onnx` (the CLI has a
4
+ packaging issue on this Python build).
5
+ """
6
+ import json
7
+ import shutil
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ BACKEND = Path(__file__).resolve().parent
12
+ CKPT = BACKEND / "app" / "ml" / "runs" / "checkpoint-172"
13
+ OUT = BACKEND / "app" / "ml" / "onnx_model"
14
+
15
+ from optimum.onnxruntime import ORTModelForSequenceClassification # noqa: E402
16
+ from transformers import AutoTokenizer # noqa: E402
17
+
18
+ print("loading checkpoint + exporting ONNX (CPU)...")
19
+ model = ORTModelForSequenceClassification.from_pretrained(str(CKPT), export=True)
20
+ tokenizer = AutoTokenizer.from_pretrained("distilbert-base-uncased")
21
+
22
+ OUT.mkdir(parents=True, exist_ok=True)
23
+ model.save_pretrained(str(OUT))
24
+ tokenizer.save_pretrained(str(OUT))
25
+
26
+ files = sorted(f.name for f in OUT.iterdir() if f.is_file())
27
+ total_mb = sum(f.stat().st_size for f in OUT.iterdir() if f.is_file()) / 1e6
28
+ print(f"exported to {OUT}")
29
+ for name in files:
30
+ print(" ", name)
31
+ print(f"total: {total_mb:.1f} MB")
32
+ print("config labels:", json.loads((OUT / "config.json").read_text(encoding="utf-8")).get("id2label"))
.deploy-test-pytorch.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Disambiguate: is the fine-tuned PyTorch model good, and only the ONNX
2
+ export broken? Run the checkpoint directly through transformers."""
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ CKPT = Path(__file__).resolve().parent / "app" / "ml" / "runs" / "checkpoint-172"
7
+
8
+ from transformers import AutoTokenizer, pipeline # noqa: E402
9
+
10
+ tok = AutoTokenizer.from_pretrained("distilbert-base-uncased")
11
+ clf = pipeline("text-classification", model=str(CKPT), tokenizer=tok,
12
+ device="cpu", truncation=True, max_length=64)
13
+
14
+ CASES = [
15
+ ("user [SEP] mailinator.com", "DISPOSABLE"),
16
+ ("someone [SEP] 10minutemail.com", "DISPOSABLE"),
17
+ ("someone [SEP] temp-mail-pro.xyz", "DISPOSABLE", ),
18
+ ("someone [SEP] disposable-inbox-2024.top", "DISPOSABLE"),
19
+ ("john.smith [SEP] gmail.com", "NORMAL"),
20
+ ("maria.garcia [SEP] harborview-law.test", "NORMAL"),
21
+ ("info [SEP] acme-corp.com", "ROLE"),
22
+ ]
23
+
24
+ wrong = 0
25
+ for text, expected in CASES:
26
+ r = clf(text)[0]
27
+ label, score = r["label"], r["score"]
28
+ ok = label == expected
29
+ wrong += 0 if ok else 1
30
+ print(f"{'OK ' if ok else 'WRONG'} {text:52} -> {label:10} {score:.3f}")
31
+ print(f"\n{len(CASES)-wrong}/{len(CASES)} correct (PyTorch checkpoint)")
32
+ sys.exit(1 if wrong else 0)
Dockerfile CHANGED
@@ -10,6 +10,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
10
  && rm -rf /var/lib/apt/lists/*
11
 
12
  # Install Python dependencies
 
 
 
13
  COPY requirements.txt .
14
  RUN pip install --no-cache-dir -r requirements.txt
15
 
 
10
  && rm -rf /var/lib/apt/lists/*
11
 
12
  # Install Python dependencies
13
+ # torch CPU FIRST (the PyPI default wheel is the CUDA build, ~2.5 GB) — the
14
+ # DistilBERT advisory layer runs on PyTorch (the ONNX export was unreliable).
15
+ RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu
16
  COPY requirements.txt .
17
  RUN pip install --no-cache-dir -r requirements.txt
18
 
app/ml/evaluate.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluation harness and promotion gate for the email-risk model.
2
+
3
+ The single most dangerous failure mode is a **false DISPOSABLE**: flagging a
4
+ legitimate business domain as throwaway destroys a real lead and a credit. So
5
+ the promotion gate is deliberately stricter than "good accuracy":
6
+
7
+ promotion_ready = DISPOSABLE precision >= 0.98 AND zero dangerous misses
8
+
9
+ `dangerous_misses` lists every NORMAL case the model predicted DISPOSABLE -
10
+ those are reviewed individually before the model is ever allowed to block.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from typing import Optional
15
+
16
+ PROMOTION_PRECISION = 0.98
17
+ LABELS = ("NORMAL", "DISPOSABLE", "ROLE", "SPAM_TRAP")
18
+
19
+
20
+ def evaluate_model(model, cases: list[dict]) -> dict:
21
+ """Run `cases` ({"text", "expected"}) through the model and score it.
22
+
23
+ `model.predict(texts)` must honor the runtime contract:
24
+ list[tuple[label, confidence]] or None on failure.
25
+ """
26
+ empty = {
27
+ "checked": 0, "correct": 0, "wrong": 0,
28
+ "per_class": {}, "dangerous_misses": [],
29
+ "promotion_ready": False,
30
+ "gate": f"DISPOSABLE precision >= {PROMOTION_PRECISION} and no dangerous misses",
31
+ }
32
+ if not cases:
33
+ return empty
34
+
35
+ texts = [c["text"] for c in cases]
36
+ try:
37
+ preds = model.predict(texts)
38
+ except Exception:
39
+ return empty
40
+ if preds is None or len(preds) != len(cases):
41
+ return empty
42
+
43
+ per_class: dict[str, dict[str, int]] = {
44
+ label: {"tp": 0, "fp": 0, "fn": 0, "support": 0} for label in LABELS
45
+ }
46
+ dangerous: list[str] = []
47
+ correct = 0
48
+ checked = 0
49
+
50
+ for case, (pred_label, _score) in zip(cases, preds):
51
+ expected = case.get("expected")
52
+ checked += 1
53
+ per_class.setdefault(expected, {"tp": 0, "fp": 0, "fn": 0, "support": 0})
54
+ per_class[expected]["support"] += 1
55
+
56
+ if pred_label == expected:
57
+ correct += 1
58
+ per_class[expected]["tp"] += 1
59
+ continue
60
+
61
+ per_class[pred_label]["fp"] += 1
62
+ per_class[expected]["fn"] += 1
63
+
64
+ if pred_label == "DISPOSABLE" and expected == "NORMAL":
65
+ dangerous.append(case["text"])
66
+
67
+ precision = {}
68
+ for label, m in per_class.items():
69
+ predicted = m["tp"] + m["fp"]
70
+ precision[label] = (m["tp"] / predicted) if predicted else 0.0
71
+
72
+ disposable_precision = precision.get("DISPOSABLE", 0.0)
73
+ promotion = disposable_precision >= PROMOTION_PRECISION and not dangerous
74
+
75
+ return {
76
+ "checked": checked,
77
+ "correct": correct,
78
+ "wrong": checked - correct,
79
+ "per_class": {
80
+ label: {
81
+ "precision": round(precision.get(label, 0.0), 4),
82
+ "tp": m["tp"], "fp": m["fp"], "fn": m["fn"], "support": m["support"],
83
+ }
84
+ for label, m in per_class.items()
85
+ },
86
+ "dangerous_misses": dangerous,
87
+ "promotion_ready": promotion,
88
+ "gate": empty["gate"],
89
+ }
app/ml/model/config.json ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "activation": "gelu",
3
+ "architectures": [
4
+ "DistilBertForSequenceClassification"
5
+ ],
6
+ "attention_dropout": 0.1,
7
+ "bos_token_id": null,
8
+ "dim": 768,
9
+ "dropout": 0.1,
10
+ "dtype": "float32",
11
+ "eos_token_id": null,
12
+ "hidden_dim": 3072,
13
+ "id2label": {
14
+ "0": "NORMAL",
15
+ "1": "DISPOSABLE",
16
+ "2": "ROLE",
17
+ "3": "SPAM_TRAP"
18
+ },
19
+ "initializer_range": 0.02,
20
+ "label2id": {
21
+ "DISPOSABLE": 1,
22
+ "NORMAL": 0,
23
+ "ROLE": 2,
24
+ "SPAM_TRAP": 3
25
+ },
26
+ "max_position_embeddings": 512,
27
+ "model_type": "distilbert",
28
+ "n_heads": 12,
29
+ "n_layers": 6,
30
+ "pad_token_id": 0,
31
+ "qa_dropout": 0.1,
32
+ "seq_classif_dropout": 0.2,
33
+ "sinusoidal_pos_embds": false,
34
+ "tie_weights_": true,
35
+ "tie_word_embeddings": true,
36
+ "transformers_version": "5.19.0",
37
+ "use_cache": false,
38
+ "vocab_size": 30522
39
+ }
app/ml/model/model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:212e338fe9e6d8cb89fce895dc21c306e6c61ab3d480922e82dd084e55283093
3
+ size 267838720
app/ml/model/special_tokens_map.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "cls_token": "[CLS]",
3
+ "mask_token": "[MASK]",
4
+ "pad_token": "[PAD]",
5
+ "sep_token": "[SEP]",
6
+ "unk_token": "[UNK]"
7
+ }
app/ml/model/tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
app/ml/model/tokenizer_config.json ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "added_tokens_decoder": {
3
+ "0": {
4
+ "content": "[PAD]",
5
+ "lstrip": false,
6
+ "normalized": false,
7
+ "rstrip": false,
8
+ "single_word": false,
9
+ "special": true
10
+ },
11
+ "100": {
12
+ "content": "[UNK]",
13
+ "lstrip": false,
14
+ "normalized": false,
15
+ "rstrip": false,
16
+ "single_word": false,
17
+ "special": true
18
+ },
19
+ "101": {
20
+ "content": "[CLS]",
21
+ "lstrip": false,
22
+ "normalized": false,
23
+ "rstrip": false,
24
+ "single_word": false,
25
+ "special": true
26
+ },
27
+ "102": {
28
+ "content": "[SEP]",
29
+ "lstrip": false,
30
+ "normalized": false,
31
+ "rstrip": false,
32
+ "single_word": false,
33
+ "special": true
34
+ },
35
+ "103": {
36
+ "content": "[MASK]",
37
+ "lstrip": false,
38
+ "normalized": false,
39
+ "rstrip": false,
40
+ "single_word": false,
41
+ "special": true
42
+ }
43
+ },
44
+ "clean_up_tokenization_spaces": false,
45
+ "cls_token": "[CLS]",
46
+ "do_lower_case": true,
47
+ "extra_special_tokens": {},
48
+ "mask_token": "[MASK]",
49
+ "model_max_length": 512,
50
+ "pad_token": "[PAD]",
51
+ "sep_token": "[SEP]",
52
+ "strip_accents": null,
53
+ "tokenize_chinese_chars": true,
54
+ "tokenizer_class": "DistilBertTokenizer",
55
+ "unk_token": "[UNK]"
56
+ }
app/ml/model/vocab.txt ADDED
The diff for this file is too large to render. See raw diff
 
app/ml/runtime.py CHANGED
@@ -14,22 +14,48 @@ from typing import Optional
14
 
15
  from ..config import settings
16
 
17
- # Optional at import time: the deployed image has onnxruntime, but dev/test
18
- # environments may not - and a missing runtime must read as "no ML", never as
19
- # a boot failure. Tests patch this module-level name.
20
- try:
21
- from optimum.onnxruntime import ORTModelForSequenceClassification
22
- except Exception: # pragma: no cover - runtime not installed
23
- ORTModelForSequenceClassification = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
 
26
  class EmailRiskModel:
27
  """Lazily-loaded ONNX text-classification wrapper. Fail-open by design."""
28
 
29
- def __init__(self, repo: str, token: str = "", batch_size: int = 64):
 
 
 
 
 
 
30
  self.repo = repo
31
  self.token = token
32
  self.batch_size = batch_size
 
33
  self._pipeline = None
34
  self._loaded = False
35
  self._lock = threading.Lock()
@@ -39,30 +65,13 @@ class EmailRiskModel:
39
  return self._loaded
40
 
41
  def load(self) -> bool:
42
- """Download/load the ONNX model. Returns False on ANY failure."""
43
  with self._lock:
44
  if self._loaded:
45
  return True
46
- if ORTModelForSequenceClassification is None:
47
- # onnxruntime/optimum not installed in this environment.
48
- self._loaded = False
49
- return False
50
  try:
51
- from transformers import AutoTokenizer, pipeline # noqa: PLC0415
52
-
53
- tokenizer = AutoTokenizer.from_pretrained(
54
- self.repo, token=self.token or None
55
- )
56
- model = ORTModelForSequenceClassification.from_pretrained(
57
- self.repo, token=self.token or None
58
- )
59
- self._pipeline = pipeline(
60
- "text-classification",
61
- model=model,
62
- tokenizer=tokenizer,
63
- device="cpu",
64
- truncation=True,
65
- max_length=64,
66
  )
67
  self._loaded = True
68
  return True
 
14
 
15
  from ..config import settings
16
 
17
+ def default_pipeline_factory(repo: str, token: str, batch_size: int):
18
+ """Production pipeline builder: transformers (PyTorch).
19
+
20
+ The ONNX export produced confidently-wrong labels under two transformers
21
+ versions while the PyTorch checkpoint scored 0.999 held-out - so inference
22
+ runs on the trained weights directly. transformers is imported lazily here:
23
+ a module-level import can fail under some import contexts (importlib_metadata
24
+ 'Version' bug on Python 3.14), which must degrade to "no ML" only when
25
+ inference is actually attempted, not kill the capability at import time.
26
+ """
27
+ import transformers
28
+
29
+ tokenizer = transformers.AutoTokenizer.from_pretrained(
30
+ repo, token=token or None
31
+ )
32
+ model = transformers.AutoModelForSequenceClassification.from_pretrained(
33
+ repo, token=token or None
34
+ )
35
+ return transformers.pipeline(
36
+ "text-classification",
37
+ model=model,
38
+ tokenizer=tokenizer,
39
+ device="cpu",
40
+ truncation=True,
41
+ max_length=64,
42
+ )
43
 
44
 
45
  class EmailRiskModel:
46
  """Lazily-loaded ONNX text-classification wrapper. Fail-open by design."""
47
 
48
+ def __init__(
49
+ self,
50
+ repo: str,
51
+ token: str = "",
52
+ batch_size: int = 64,
53
+ pipeline_factory: Optional[Callable[[str, str, int], object]] = None,
54
+ ):
55
  self.repo = repo
56
  self.token = token
57
  self.batch_size = batch_size
58
+ self._factory = pipeline_factory or default_pipeline_factory
59
  self._pipeline = None
60
  self._loaded = False
61
  self._lock = threading.Lock()
 
65
  return self._loaded
66
 
67
  def load(self) -> bool:
68
+ """Load the model. Returns False on ANY failure (fail-open design)."""
69
  with self._lock:
70
  if self._loaded:
71
  return True
 
 
 
 
72
  try:
73
+ self._pipeline = self._factory(
74
+ self.repo, self.token, self.batch_size
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  )
76
  self._loaded = True
77
  return True
requirements.txt CHANGED
@@ -38,3 +38,8 @@ python-jose[cryptography]>=3.3.0
38
  # --- Logging ---
39
  structlog>=24.4.0
40
 
 
 
 
 
 
 
38
  # --- Logging ---
39
  structlog>=24.4.0
40
 
41
+ # --- DistilBERT advisory layer (PyTorch backend; torch installed separately
42
+ # from the CPU index in the Dockerfile) ---
43
+ transformers>=4.41,<5
44
+ safetensors>=0.4
45
+
tests/test_ml_evaluate.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+
3
+ from app.ml.evaluate import evaluate_model
4
+
5
+
6
+ class FakeModel:
7
+ """Scripted predictions per text, honoring the runtime contract."""
8
+
9
+ def __init__(self, answers):
10
+ self.answers = answers # text -> (label, score)
11
+
12
+ def predict(self, texts):
13
+ out = []
14
+ for t in texts:
15
+ if t not in self.answers:
16
+ return None # contract: None on failure
17
+ out.append(self.answers[t])
18
+ return out
19
+
20
+
21
+ class EvaluateModelTests(unittest.TestCase):
22
+ def setUp(self):
23
+ self.cases = [
24
+ {"text": "john.smith [SEP] acme.test", "expected": "NORMAL"},
25
+ {"text": "info [SEP] acme.test", "expected": "ROLE"},
26
+ {"text": "user [SEP] tempmail.example", "expected": "DISPOSABLE"},
27
+ {"text": "spamtrap [SEP] acme.test", "expected": "SPAM_TRAP"},
28
+ # A real-looking business domain the model must NOT flag.
29
+ {"text": "maria.garcia [SEP] harborview-law.test", "expected": "NORMAL"},
30
+ ]
31
+
32
+ def test_per_class_metrics_are_returned(self):
33
+ model = FakeModel({
34
+ "john.smith [SEP] acme.test": ("NORMAL", 0.9),
35
+ "info [SEP] acme.test": ("ROLE", 0.9),
36
+ "user [SEP] tempmail.example": ("DISPOSABLE", 0.95),
37
+ "spamtrap [SEP] acme.test": ("SPAM_TRAP", 0.9),
38
+ "maria.garcia [SEP] harborview-law.test": ("NORMAL", 0.9),
39
+ })
40
+ r = evaluate_model(model, self.cases)
41
+ self.assertEqual(r["checked"], 5)
42
+ self.assertEqual(r["correct"], 5)
43
+ for cls in ("NORMAL", "DISPOSABLE", "ROLE", "SPAM_TRAP"):
44
+ self.assertIn(cls, r["per_class"])
45
+ self.assertEqual(r["per_class"][cls]["precision"], 1.0)
46
+
47
+ def test_false_disposable_destroys_the_gate(self):
48
+ # The single dangerous failure: flagging a legitimate business domain.
49
+ model = FakeModel({
50
+ "john.smith [SEP] acme.test": ("NORMAL", 0.9),
51
+ "info [SEP] acme.test": ("ROLE", 0.9),
52
+ "user [SEP] tempmail.example": ("DISPOSABLE", 0.95),
53
+ "spamtrap [SEP] acme.test": ("SPAM_TRAP", 0.9),
54
+ "maria.garcia [SEP] harborview-law.test": ("DISPOSABLE", 0.55),
55
+ })
56
+ r = evaluate_model(model, self.cases)
57
+ self.assertFalse(r["promotion_ready"])
58
+ self.assertEqual(r["wrong"], 1)
59
+ self.assertEqual(
60
+ r["dangerous_misses"],
61
+ ["maria.garcia [SEP] harborview-law.test"],
62
+ "a NORMAL row predicted DISPOSABLE is the worst failure mode",
63
+ )
64
+
65
+ def test_promotion_requires_high_disposable_precision(self):
66
+ # 4/5 correct but the DISPOSABLE predictions are all right -> gate off
67
+ # only because sample support is tiny; precision alone decides.
68
+ model = FakeModel({
69
+ "john.smith [SEP] acme.test": ("NORMAL", 0.9),
70
+ "info [SEP] acme.test": ("ROLE", 0.9),
71
+ "user [SEP] tempmail.example": ("DISPOSABLE", 0.95),
72
+ "spamtrap [SEP] acme.test": ("SPAM_TRAP", 0.9),
73
+ "maria.garcia [SEP] harborview-law.test": ("NORMAL", 0.9),
74
+ })
75
+ r = evaluate_model(model, self.cases)
76
+ self.assertTrue(r["promotion_ready"])
77
+ self.assertEqual(r["per_class"]["DISPOSABLE"]["precision"], 1.0)
78
+
79
+ def test_model_failure_fails_the_gate(self):
80
+ model = FakeModel({}) # predict returns None for everything
81
+ r = evaluate_model(model, self.cases)
82
+ self.assertFalse(r["promotion_ready"])
83
+ self.assertEqual(r["checked"], 0)
84
+
85
+ def test_empty_cases_is_a_noop(self):
86
+ r = evaluate_model(FakeModel({}), [])
87
+ self.assertEqual(r["checked"], 0)
88
+ self.assertFalse(r["promotion_ready"])
89
+
90
+
91
+ if __name__ == "__main__":
92
+ unittest.main()
tests/test_ml_runtime.py CHANGED
@@ -6,16 +6,47 @@ from app.ml.runtime import EmailRiskModel, get_model
6
 
7
  class RuntimeLoaderTests(unittest.TestCase):
8
  """The ML layer must be strictly optional: any failure degrades to "no ML"
9
- and validation continues deterministically."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
- def test_load_failure_returns_false_and_never_raises(self):
12
  m = EmailRiskModel(repo="user/model", token="t")
13
- with patch(
14
- "app.ml.runtime.ORTModelForSequenceClassification",
15
- side_effect=RuntimeError("hub down"),
16
- ):
17
- self.assertFalse(m.load())
18
- self.assertFalse(m.loaded)
19
 
20
  def test_predict_before_load_returns_none(self):
21
  m = EmailRiskModel(repo="user/model", token="t")
 
6
 
7
  class RuntimeLoaderTests(unittest.TestCase):
8
  """The ML layer must be strictly optional: any failure degrades to "no ML"
9
+ and validation continues deterministically.
10
+
11
+ Backend = PyTorch transformers, NOT ONNX: the ONNX export produced
12
+ confidently-wrong labels under two transformers versions while the PyTorch
13
+ checkpoint scored 0.999 held-out (see VALIDATOR-PERFORMANCE-REPORT).
14
+ """
15
+
16
+ def test_load_uses_the_injected_factory(self):
17
+ # Dependency injection: the factory builds the pipeline; the runtime
18
+ # never imports transformers itself. This is what makes the ML layer
19
+ # testable without patching a lazy module boundary.
20
+ fake_pipe = MagicMock()
21
+ calls = {}
22
+
23
+ def factory(repo, token, batch_size):
24
+ calls["args"] = (repo, token, batch_size)
25
+ return fake_pipe
26
+
27
+ m = EmailRiskModel(repo="user/model", token="tok123",
28
+ batch_size=32, pipeline_factory=factory)
29
+ self.assertTrue(m.load())
30
+ self.assertTrue(m.loaded)
31
+ self.assertEqual(calls["args"], ("user/model", "tok123", 32))
32
+ self.assertIs(m._pipeline, fake_pipe)
33
+
34
+ def test_factory_failure_returns_false_and_never_raises(self):
35
+ def bad_factory(repo, token, batch_size):
36
+ raise RuntimeError("hub down")
37
+
38
+ m = EmailRiskModel(repo="user/model", pipeline_factory=bad_factory)
39
+ self.assertFalse(m.load())
40
+ self.assertFalse(m.loaded)
41
+ self.assertIsNone(m._pipeline)
42
+
43
+ def test_load_uses_pytorch_transformers_by_default(self):
44
+ # The production factory loads from transformers (PyTorch - the ONNX
45
+ # export produced garbage predictions, so it was abandoned).
46
+ from app.ml.runtime import default_pipeline_factory
47
 
 
48
  m = EmailRiskModel(repo="user/model", token="t")
49
+ self.assertIs(m._factory, default_pipeline_factory)
 
 
 
 
 
50
 
51
  def test_predict_before_load_returns_none(self):
52
  m = EmailRiskModel(repo="user/model", token="t")