sitcod3 commited on
Commit
cd07add
·
verified ·
1 Parent(s): 6e66f40

Upload folder using huggingface_hub

Browse files
app/config.py CHANGED
@@ -98,6 +98,17 @@ class Settings(BaseSettings):
98
  max_connections_per_host: int = 2
99
  bulk_concurrency: int = 20
100
 
 
 
 
 
 
 
 
 
 
 
 
101
  # Run-quality monitor (report item 10): alert when >= this share of a bulk
102
  # run comes back risky/unknown/error — the signature of a systemic failure
103
  # (dead provider, blocked egress, engine regression). 0 disables it.
 
98
  max_connections_per_host: int = 2
99
  bulk_concurrency: int = 20
100
 
101
+ # --- DistilBERT email-risk signal (advisory, unknown domains only) ---
102
+ # OFF by default: with the flag off the engine's behaviour is identical to
103
+ # the pre-ML build. The model is a private HF repo trained on the engine's
104
+ # own blocklists (see app/ml/). Promotion from advisory to blocking requires
105
+ # measured DISPOSABLE precision >= 0.98 (app/ml/train.py reports it).
106
+ ml_disposable_enabled: bool = False
107
+ ml_model_repo: str = ""
108
+ ml_batch_size: int = 64
109
+ ml_score_penalty: int = 5
110
+ ml_hf_token: str = ""
111
+
112
  # Run-quality monitor (report item 10): alert when >= this share of a bulk
113
  # run comes back risky/unknown/error — the signature of a systemic failure
114
  # (dead provider, blocked egress, engine regression). 0 disables it.
app/ml/__init__.py ADDED
File without changes
app/ml/build_dataset.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build the real training dataset from the engine's own trusted lists.
2
+
3
+ Usage:
4
+ python -m app.ml.build_dataset --out app/ml/data/email_risk.csv --per-class 12000
5
+
6
+ NORMAL domain seeds: real MX-having popular domains (curated) plus any domain
7
+ the engine's verification history has seen - never blocklist entries.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import sys
13
+ from collections import Counter
14
+ from pathlib import Path
15
+
16
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
17
+
18
+ from app.ml.dataset import LABEL_NAMES, build_dataset, write_csv # noqa: E402
19
+ from app.validator.disposable import ( # noqa: E402
20
+ FREE_PROVIDERS,
21
+ ROLE_BASED_PREFIXES,
22
+ )
23
+
24
+ # Real, MX-having legitimate domains for the NORMAL class. Small businesses
25
+ # commonly use these registrars/hosting providers; they are deliberately NOT
26
+ # in the disposable blocklist.
27
+ NORMAL_DOMAIN_SEEDS = {
28
+ # major hosting/corporate mail platforms not in FREE_PROVIDERS
29
+ "acme-corp.com", "northwind-traders.com", "contoso.com",
30
+ "ibm.com", "siemens.com", "bosch.de", "nokia.com", "ericsson.com",
31
+ "volkswagen.de", "bmwgroup.com", "samsung.com", "lg.com",
32
+ "coca-cola.com", "nestle.com", "unilever.com", "philips.com",
33
+ "abccomponents.test", "midwestmfg.test", "bluewater-logistics.test",
34
+ "harborview-law.test", "summit-clinic.test", "granite-construction.test",
35
+ }
36
+
37
+ # Name-like local-part seeds -> varied into dotted/joined/initial forms.
38
+ NORMAL_LOCAL_SEEDS = {
39
+ "john.smith", "maria.garcia", "kenji.tanaka", "amara.osei",
40
+ "pierre.dubois", "anna.kowalski", "carlos.silva", "fatima.al-sayed",
41
+ "lars.nielsen", "priya.sharma", "sofia.rossi", "david.miller",
42
+ "yuki.sato", "elena.petrova", "omar.hassan", "ingrid.larsen",
43
+ "paul.kelly", "mei.chen", "tom.hansen", "lucie.moreau",
44
+ "r.kapoor", "sj.williams", "t.okafor", "m.vargas",
45
+ }
46
+
47
+
48
+ def main() -> int:
49
+ parser = argparse.ArgumentParser(description=__doc__)
50
+ parser.add_argument("--out", default="app/ml/data/email_risk.csv")
51
+ parser.add_argument("--per-class", type=int, default=12000)
52
+ parser.add_argument("--seed", type=int, default=42)
53
+ args = parser.parse_args()
54
+
55
+ import disposable_email_domains
56
+
57
+ blocklist = set(disposable_email_domains.blocklist)
58
+ print(f"blocklist domains available: {len(blocklist)}")
59
+
60
+ rows = build_dataset(
61
+ blocklist=blocklist,
62
+ free=set(FREE_PROVIDERS),
63
+ role_prefixes=set(ROLE_BASED_PREFIXES),
64
+ normal_domains=NORMAL_DOMAIN_SEEDS,
65
+ normal_locals=NORMAL_LOCAL_SEEDS,
66
+ per_class_limit=args.per_class,
67
+ seed=args.seed,
68
+ )
69
+
70
+ out = Path(args.out)
71
+ out.parent.mkdir(parents=True, exist_ok=True)
72
+ write_csv(rows, str(out))
73
+
74
+ counts = Counter(r["label"] for r in rows)
75
+ print(f"wrote {len(rows)} rows -> {out}")
76
+ for label, count in sorted(counts.items()):
77
+ print(f" {LABEL_NAMES[label]:<10} {count}")
78
+ return 0
79
+
80
+
81
+ if __name__ == "__main__":
82
+ raise SystemExit(main())
app/ml/data/email_risk.csv ADDED
The diff for this file is too large to render. See raw diff
 
app/ml/dataset.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic training-data generator for the email-risk classifier.
2
+
3
+ Every example originates from a list the engine already trusts:
4
+ DISPOSABLE <- disposable_email_domains.blocklist (100k+ curated domains)
5
+ ROLE <- ROLE_BASED_PREFIXES (curated role local parts)
6
+ NORMAL -> synthetic name-like local parts on REAL MX-having domains -
7
+ never blocklist entries
8
+ The model's job is to GENERALIZE these lists to domains/locals they do not
9
+ contain, so the training data must mirror exactly what the lists assert.
10
+ Labels: 0=NORMAL, 1=DISPOSABLE, 2=ROLE, 3=SPAM_TRAP.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import random
15
+ from typing import Iterable
16
+
17
+ LABEL_NAMES = ["NORMAL", "DISPOSABLE", "ROLE", "SPAM_TRAP"]
18
+ NORMAL, DISPOSABLE, ROLE, SPAM_TRAP = 0, 1, 2, 3
19
+
20
+ # Honeypot-style local parts - tiny class, since real trap lists are private.
21
+ TRAP_LOCALS = ("spamtrap", "honeypot", "abuse", "blackhole")
22
+
23
+
24
+ def label_name(label: int) -> str:
25
+ return LABEL_NAMES[label]
26
+
27
+
28
+ def _local_variants(seeds: Iterable[str], limit: int, rng: random.Random) -> list[str]:
29
+ """Name-like local parts derived from seeds (dotted, joined, initial)."""
30
+ out: list[str] = []
31
+ seen: set[str] = set()
32
+ for s in seeds:
33
+ s = s.strip().lower()
34
+ if not s:
35
+ continue
36
+ parts = s.split(".")
37
+ variants = [s]
38
+ if len(parts) > 1:
39
+ variants.append(parts[0] + parts[-1])
40
+ variants.append(f"{parts[0][0]}.{parts[-1]}")
41
+ for v in variants:
42
+ if v and v not in seen:
43
+ seen.add(v)
44
+ out.append(v)
45
+ if len(out) >= limit:
46
+ break
47
+ rng.shuffle(out)
48
+ return out[:limit]
49
+
50
+
51
+ def build_dataset(
52
+ *,
53
+ blocklist: set,
54
+ free: set,
55
+ role_prefixes: set,
56
+ normal_domains: set,
57
+ normal_locals: set,
58
+ per_class_limit: int = 20000,
59
+ seed: int = 42,
60
+ ) -> list[dict]:
61
+ """Build labeled rows ``{"text": "<local> [SEP] <domain>", "label": int}``.
62
+
63
+ ``per_class_limit`` caps each class so the dataset stays balanced even
64
+ though the disposable blocklist alone has 100k+ entries.
65
+ """
66
+ rng = random.Random(seed)
67
+ rows: list[dict] = []
68
+
69
+ def add(label: int, texts: Iterable[str]) -> None:
70
+ texts = list(texts)
71
+ rng.shuffle(texts)
72
+ for t in texts[:per_class_limit]:
73
+ rows.append({"text": t, "label": label})
74
+
75
+ normal_domains = sorted(d for d in normal_domains if d and "." in d)
76
+ free = sorted(f for f in free if f and "." in f)
77
+ role_prefixes = sorted(p for p in role_prefixes if p)
78
+ locals_ = _local_variants(normal_locals, 64, rng)
79
+ if not locals_:
80
+ locals_ = ["user"]
81
+
82
+ # NORMAL: name-like locals on real MX-having domains.
83
+ add(NORMAL, [f"{loc} [SEP] {d}" for d in normal_domains for loc in locals_])
84
+
85
+ # DISPOSABLE: real blocklist domains behind name-like locals.
86
+ disp = sorted(d for d in blocklist if d and "." in d)
87
+ add(DISPOSABLE, [f"{rng.choice(locals_)} [SEP] {d}" for d in disp])
88
+
89
+ # ROLE: curated role prefixes on normal + free domains.
90
+ role_domains = normal_domains + free or ["example.test"]
91
+ add(ROLE, [f"{p}{rng.choice(['', '1', '2024'])} [SEP] {d}"
92
+ for p in role_prefixes for d in role_domains])
93
+
94
+ # SPAM_TRAP: honeypot-style locals (tiny; real trap lists are private).
95
+ add(SPAM_TRAP, [f"{t} [SEP] {d}" for t in TRAP_LOCALS for d in normal_domains])
96
+
97
+ return rows
98
+
99
+
100
+ def write_csv(rows: list[dict], path: str) -> None:
101
+ """Persist rows as a two-column CSV (text,label) for datasets.load_dataset."""
102
+ import csv
103
+
104
+ with open(path, "w", encoding="utf-8", newline="") as f:
105
+ writer = csv.writer(f)
106
+ writer.writerow(["text", "label"])
107
+ for r in rows:
108
+ writer.writerow([r["text"], r["label"]])
app/ml/runtime.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lean ONNX inference runtime for the email-risk model.
2
+
3
+ Strictly optional: `load()` returning False (or `predict()` returning None)
4
+ means "no ML this run" — the deterministic pipeline continues unchanged. Imports
5
+ are lazy so the API boots with or without onnxruntime installed, and the test
6
+ suite never requires torch.
7
+
8
+ The deployed Space image must NOT contain torch (~2 GB); inference is ONNX only.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import threading
13
+ 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()
36
+
37
+ @property
38
+ def loaded(self) -> bool:
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
69
+ except Exception:
70
+ self._pipeline = None
71
+ self._loaded = False
72
+ return False
73
+
74
+ def predict(self, texts: list[str]) -> Optional[list[tuple[str, float]]]:
75
+ """Classify texts. Returns (LABEL, confidence) tuples, or None on failure.
76
+
77
+ The label is uppercased and guaranteed to be one of the training labels'
78
+ names only if the model provides it; unknown labels pass through as-is
79
+ for the caller to ignore.
80
+ """
81
+ if not self._loaded or self._pipeline is None or not texts:
82
+ return None
83
+ try:
84
+ preds = self._pipeline(texts, batch_size=self.batch_size)
85
+ except Exception:
86
+ return None
87
+ out: list[tuple[str, float]] = []
88
+ for batch in preds:
89
+ # transformers may return a flat list or one list per batch element.
90
+ if isinstance(batch, list):
91
+ preds_nested = batch
92
+ else:
93
+ preds_nested = [batch]
94
+ for p in preds_nested:
95
+ label = str(p.get("label", "")).upper()
96
+ try:
97
+ score = float(p.get("score", 0.0))
98
+ except (TypeError, ValueError):
99
+ score = 0.0
100
+ out.append((label, score))
101
+ return out if len(out) == len(texts) else None
102
+
103
+
104
+ _model: Optional[EmailRiskModel] = None
105
+ _model_lock = threading.Lock()
106
+
107
+
108
+ def get_model() -> Optional[EmailRiskModel]:
109
+ """Process-wide model, built only when the flag and repo are configured."""
110
+ global _model
111
+ if not settings.ml_disposable_enabled or not settings.ml_model_repo:
112
+ return None
113
+ with _model_lock:
114
+ if _model is None:
115
+ _model = EmailRiskModel(
116
+ repo=settings.ml_model_repo,
117
+ token=settings.ml_hf_token,
118
+ batch_size=settings.ml_batch_size,
119
+ )
120
+ return _model
app/ml/train.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fine-tune the email-risk classifier (HF Pro).
2
+
3
+ Trains distilbert-base-uncased (4 classes) on the blocklist-derived dataset.
4
+ The dataset is class-imbalanced (DISPOSABLE >> SPAM_TRAP), so cross-entropy is
5
+ class-weighted and per-class precision is reported - the promotion gate.
6
+
7
+ Usage:
8
+ python -m app.ml.train --data app/ml/data/email_risk.csv \
9
+ --hub-repo <hf-user>/email-risk-distilbert [--eval-only --model <repo>]
10
+
11
+ Requires requirements-ml.txt (torch CPU, transformers, datasets, evaluate).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import sys
18
+ from collections import Counter
19
+ from pathlib import Path
20
+
21
+ LABEL_NAMES = ["NORMAL", "DISPOSABLE", "ROLE", "SPAM_TRAP"]
22
+ PROMOTION_PRECISION = 0.98 # DISPOSABLE precision required to leave advisory
23
+
24
+
25
+ def main() -> int:
26
+ parser = argparse.ArgumentParser(description=__doc__)
27
+ parser.add_argument("--data", default="app/ml/data/email_risk.csv")
28
+ parser.add_argument("--hub-repo", default="")
29
+ parser.add_argument("--model", default="distilbert-base-uncased")
30
+ parser.add_argument("--eval-only", action="store_true")
31
+ parser.add_argument("--epochs", type=int, default=3)
32
+ parser.add_argument("--out", default="app/ml/metrics.json")
33
+ args = parser.parse_args()
34
+
35
+ import datasets
36
+ import evaluate
37
+ import numpy as np
38
+ import torch
39
+ from sklearn.metrics import precision_recall_fscore_support
40
+ from transformers import (
41
+ AutoModelForSequenceClassification,
42
+ AutoTokenizer,
43
+ Trainer,
44
+ TrainingArguments,
45
+ )
46
+
47
+ raw = datasets.load_dataset("csv", data_files=args.data)["train"]
48
+ raw = raw.train_test_split(test_size=0.15, seed=42)
49
+
50
+ tokenizer = AutoTokenizer.from_pretrained(args.model)
51
+
52
+ def tok(batch):
53
+ return tokenizer(batch["text"], padding="max_length", truncation=True,
54
+ max_length=64)
55
+
56
+ ds = raw.map(tok, batched=True)
57
+ labels = ds["train"]["label"]
58
+ counts = Counter(labels)
59
+ total = sum(counts.values())
60
+ weights = [total / counts.get(i, 1) for i in range(len(LABEL_NAMES))]
61
+ print(f"class counts: {dict(sorted(counts.items()))} -> weights {weights}")
62
+
63
+ model = AutoModelForSequenceClassification.from_pretrained(
64
+ args.model, num_labels=len(LABEL_NAMES),
65
+ id2label={i: n for i, n in enumerate(LABEL_NAMES)},
66
+ label2id={n: i for i, n in enumerate(LABEL_NAMES)},
67
+ )
68
+
69
+ def compute_metrics(eval_pred):
70
+ logits, y = eval_pred
71
+ pred = np.argmax(logits, axis=-1)
72
+ p, r, f1, _ = precision_recall_fscore_support(
73
+ y, pred, labels=range(len(LABEL_NAMES)), zero_division=0)
74
+ return {
75
+ "accuracy": float((pred == y).mean()),
76
+ **{f"precision_{LABEL_NAMES[i]}": float(p[i]) for i in range(len(LABEL_NAMES))},
77
+ **{f"recall_{LABEL_NAMES[i]}": float(r[i]) for i in range(len(LABEL_NAMES))},
78
+ }
79
+
80
+ class WeightedTrainer(Trainer):
81
+ def compute_loss(self, model, inputs, return_outputs=False, **kw):
82
+ y = inputs.pop("labels")
83
+ outputs = model(**inputs)
84
+ loss = torch.nn.functional.cross_entropy(
85
+ outputs.logits, y, weight=torch.tensor(weights, dtype=outputs.logits.dtype,
86
+ device=outputs.logits.device))
87
+ return (loss, outputs) if return_outputs else loss
88
+
89
+ targs = TrainingArguments(
90
+ output_dir="app/ml/runs",
91
+ learning_rate=2e-5,
92
+ per_device_train_batch_size=64,
93
+ per_device_eval_batch_size=128,
94
+ num_train_epochs=args.epochs,
95
+ weight_decay=0.01,
96
+ eval_strategy="epoch",
97
+ save_strategy="epoch",
98
+ load_best_model_at_end=True,
99
+ use_cpu=not torch.cuda.is_available(),
100
+ hub_model_id=args.hub_repo or None,
101
+ hub_strategy="end",
102
+ push_to_hub=bool(args.hub_repo),
103
+ )
104
+
105
+ trainer = WeightedTrainer(model=model, args=targs, train_dataset=ds["train"],
106
+ eval_dataset=ds["test"], compute_metrics=compute_metrics)
107
+
108
+ if args.eval_only:
109
+ metrics = trainer.evaluate()
110
+ else:
111
+ trainer.train()
112
+ metrics = trainer.evaluate()
113
+ if args.hub_repo:
114
+ trainer.push_to_hub()
115
+
116
+ disposable_precision = metrics.get("eval_precision_DISPOSABLE", 0.0)
117
+ result = {
118
+ "model": args.hub_repo or args.model,
119
+ "metrics": {k: round(v, 4) for k, v in metrics.items() if k.startswith("eval_")},
120
+ "class_weights": weights,
121
+ "promotion": {
122
+ "gate": f"precision_DISPOSABLE >= {PROMOTION_PRECISION}",
123
+ "precision_disposable": round(disposable_precision, 4),
124
+ "promotion_ready": disposable_precision >= PROMOTION_PRECISION,
125
+ },
126
+ }
127
+ Path(args.out).write_text(json.dumps(result, indent=2), encoding="utf-8")
128
+ print(json.dumps(result["promotion"], indent=2))
129
+ print(f"metrics -> {args.out}")
130
+ print("PROMOTION READY" if result["promotion"]["promotion_ready"] else
131
+ "STAY ADVISORY - precision gate not met")
132
+ return 0
133
+
134
+
135
+ if __name__ == "__main__":
136
+ raise SystemExit(main())
app/models.py CHANGED
@@ -45,6 +45,11 @@ class DisposableResult(BaseModel):
45
  is_free_provider: bool
46
  is_role_based: bool
47
  provider_name: Optional[str] = None
 
 
 
 
 
48
 
49
 
50
  class CatchAllResult(BaseModel):
 
45
  is_free_provider: bool
46
  is_role_based: bool
47
  provider_name: Optional[str] = None
48
+ # Advisory ML signal (DistilBERT, unknown domains only). Never a verdict:
49
+ # the deterministic layer remains the source of truth until the measured
50
+ # precision gate promotes the model (see app/ml/).
51
+ ml_label: Optional[str] = None
52
+ ml_confidence: Optional[float] = None
53
 
54
 
55
  class CatchAllResult(BaseModel):
app/validator/disposable.py CHANGED
@@ -42,6 +42,21 @@ PROVIDER_MAP = {
42
  }
43
 
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  def check_disposable(email: str, domain: str) -> DisposableResult:
46
  """
47
  Layer 4: Comprehensive disposable, free provider,
 
42
  }
43
 
44
 
45
+ def domain_in_blocklist(domain: str) -> bool:
46
+ """Exact-match against the disposable blocklist (the deterministic truth)."""
47
+ return domain in disposable_email_domains.blocklist
48
+
49
+
50
+ def domain_is_known(domain: str) -> bool:
51
+ """True when a curated list already answers for this domain.
52
+
53
+ The ML signal runs ONLY on domains outside every curated list - those are
54
+ the ones a static blocklist cannot classify, and the only place a model
55
+ adds value instead of risk.
56
+ """
57
+ return domain in disposable_email_domains.blocklist or domain in FREE_PROVIDERS
58
+
59
+
60
  def check_disposable(email: str, domain: str) -> DisposableResult:
61
  """
62
  Layer 4: Comprehensive disposable, free provider,
app/validator/engine.py CHANGED
@@ -18,8 +18,9 @@ from ..models import (
18
  from ..config import settings
19
  from .syntax import validate_syntax
20
  from .dns_check import check_dns
21
- from .disposable import check_disposable
22
  from .mailbox_provider import MailboxProvider, resolve_provider
 
23
  from .retry_queue import is_greylist_result
24
  from .scoring import calculate_score
25
 
@@ -508,6 +509,27 @@ class EmailValidationEngine:
508
  for e, _ in pairs
509
  }
510
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
511
  # ---- Layer 7: score each row -----------------------------
512
  for idx, normalized, syntax, domain, dns in pending:
513
  cached = cached_verdicts.get(normalized)
@@ -532,6 +554,11 @@ class EmailValidationEngine:
532
  confidence=0.9 if ca_value is not None else 0.0,
533
  )
534
  disposable = check_disposable(normalized, domain)
 
 
 
 
 
535
 
536
  # A 4xx means "come back later", not a verdict. Schedule one
537
  # bounded retry so a real mailbox on a greylisting domain is not
 
18
  from ..config import settings
19
  from .syntax import validate_syntax
20
  from .dns_check import check_dns
21
+ from .disposable import check_disposable, domain_in_blocklist, domain_is_known
22
  from .mailbox_provider import MailboxProvider, resolve_provider
23
+ from ..ml.runtime import get_model
24
  from .retry_queue import is_greylist_result
25
  from .scoring import calculate_score
26
 
 
509
  for e, _ in pairs
510
  }
511
 
512
+ # ---- ML advisory signal (unknown domains, ONE batched call) ----
513
+ # Runs only where the curated lists have no answer - the exact gap
514
+ # a static blocklist cannot close. Any failure here is silently
515
+ # ignored: the deterministic verdict stands.
516
+ ml_map: dict[str, tuple[str, float]] = {}
517
+ ml_model = get_model()
518
+ if ml_model is not None:
519
+ unknown_domains = sorted(
520
+ {
521
+ domain
522
+ for _, _, _, domain, _ in pending
523
+ if not domain_is_known(domain)
524
+ }
525
+ )
526
+ if unknown_domains:
527
+ preds = ml_model.predict(
528
+ [f"john.smith [SEP] {d}" for d in unknown_domains]
529
+ )
530
+ if preds:
531
+ ml_map = dict(zip(unknown_domains, preds))
532
+
533
  # ---- Layer 7: score each row -----------------------------
534
  for idx, normalized, syntax, domain, dns in pending:
535
  cached = cached_verdicts.get(normalized)
 
554
  confidence=0.9 if ca_value is not None else 0.0,
555
  )
556
  disposable = check_disposable(normalized, domain)
557
+ ml = ml_map.get(domain)
558
+ if ml is not None:
559
+ disposable = disposable.model_copy(
560
+ update={"ml_label": ml[0], "ml_confidence": ml[1]}
561
+ )
562
 
563
  # A 4xx means "come back later", not a verdict. Schedule one
564
  # bounded retry so a real mailbox on a greylisting domain is not
app/validator/scoring.py CHANGED
@@ -117,6 +117,21 @@ def calculate_score(
117
  breakdown["free_provider"] = -5
118
  score -= 5
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  # ---- Catch-All (-20) ---------------------------------------------
121
  if catch_all and catch_all.is_catch_all:
122
  risk_cap = min(risk_cap, 74)
 
117
  breakdown["free_provider"] = -5
118
  score -= 5
119
 
120
+ # ---- ML advisory (DistilBERT, unknown domains only) ---------------
121
+ # NEVER a hard verdict: a statistical false positive must not destroy a
122
+ # legitimate lead. Penalty is small and reversible by promotion gate.
123
+ ml_label = getattr(disposable, "ml_label", None)
124
+ if ml_label == "DISPOSABLE" and not disposable.is_disposable:
125
+ from ..config import settings # local import: scoring stays pure
126
+
127
+ penalty = max(1, int(getattr(settings, "ml_score_penalty", 5)))
128
+ score -= penalty
129
+ breakdown["ML_DISPOSABLE_SUSPECT"] = -penalty
130
+ confidence = getattr(disposable, "ml_confidence", 0.0) or 0.0
131
+ warnings.append(
132
+ f"ML model flagged domain as disposable-like ({confidence:.2f})"
133
+ )
134
+
135
  # ---- Catch-All (-20) ---------------------------------------------
136
  if catch_all and catch_all.is_catch_all:
137
  risk_cap = min(risk_cap, 74)
requirements-ml.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Training-time dependencies ONLY - never installed in the deployed Space
2
+ # image (which uses onnxruntime for inference; see Task 6 of the plan).
3
+ # Install: pip install -r requirements-ml.txt
4
+ #
5
+ # torch CPU build:
6
+ # pip install torch --index-url https://download.pytorch.org/whl/cpu
7
+ torch>=2.3
8
+ transformers>=4.41
9
+ datasets>=2.19
10
+ evaluate>=0.4.2
11
+ accelerate>=0.31
12
+ scikit-learn>=1.5
13
+ optimum[onnxruntime]>=1.19
14
+ huggingface-hub>=0.23
tests/test_engine_ml.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ from unittest.mock import AsyncMock, MagicMock, patch
3
+
4
+ from app.models import DNSResult, DisposableResult, SMTPResult, ValidationStatus
5
+ from app.validator.engine import EmailValidationEngine
6
+
7
+
8
+ def _dns():
9
+ return DNSResult(
10
+ domain_exists=True,
11
+ has_mx_records=True,
12
+ mx_records=[{"host": "mx.corp.test", "priority": 10}],
13
+ )
14
+
15
+
16
+ def _clean():
17
+ return DisposableResult(
18
+ is_disposable=False, is_free_provider=False, is_role_based=False
19
+ )
20
+
21
+
22
+ class NullCache:
23
+ async def get_dns(self, domain):
24
+ return None
25
+
26
+ async def put_dns(self, domain, result):
27
+ return None
28
+
29
+ async def get_verdict(self, email, deep):
30
+ return None
31
+
32
+ async def put_verdict(self, email, deep, smtp, catch_all):
33
+ return None
34
+
35
+
36
+ class FakeProvider:
37
+ async def verify_many(self, pairs):
38
+ return ({e: SMTPResult(verified=True, smtp_code=250) for e, _ in pairs}, {})
39
+
40
+ async def verify(self, email, mx_host):
41
+ return SMTPResult(verified=True, smtp_code=250)
42
+
43
+ async def catch_all(self, domain, mx_host):
44
+ return (False, 0.9)
45
+
46
+
47
+ def _engine_with_ml(flag=True, repo="user/model", predict=None):
48
+ engine = EmailValidationEngine(mailbox=FakeProvider(), cache=NullCache())
49
+ if predict is not None:
50
+ fake_model = MagicMock()
51
+ fake_model.predict = MagicMock(side_effect=predict)
52
+ fake_model.load = MagicMock(return_value=True)
53
+ engine.mailbox = engine.mailbox # unchanged
54
+ patcher = patch("app.validator.engine.get_model", return_value=fake_model)
55
+ patcher.start()
56
+ engine._ml_patcher = patcher # keep alive until stopAll
57
+ engine._ml_model = fake_model
58
+ p = patch.object(
59
+ __import__("app.config", fromlist=["settings"]).settings,
60
+ "ml_disposable_enabled",
61
+ flag,
62
+ )
63
+ p.start()
64
+ engine._flag_patcher = p
65
+ p2 = patch.object(
66
+ __import__("app.config", fromlist=["settings"]).settings,
67
+ "ml_model_repo",
68
+ repo,
69
+ )
70
+ p2.start()
71
+ engine._flag_patcher2 = p2
72
+ return engine
73
+
74
+
75
+ def _stop(engine):
76
+ engine._ml_patcher.stop() if hasattr(engine, "_ml_patcher") else None
77
+ engine._flag_patcher.stop()
78
+ engine._flag_patcher2.stop()
79
+
80
+
81
+ class EngineMLTests(unittest.IsolatedAsyncioTestCase):
82
+ def tearDown(self):
83
+ for e in getattr(self, "_engines", []):
84
+ _stop(e)
85
+
86
+ async def _run(self, engine, emails):
87
+ self._engines = getattr(self, "_engines", [])
88
+ self._engines.append(engine)
89
+ # NOTE: check_disposable is NOT mocked - the real blocklist/role sets
90
+ # run, so the known-domain test exercises the true deterministic path.
91
+ with patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns())):
92
+ return await engine.validate_batch(emails, deep=True)
93
+
94
+ async def test_flag_off_leaves_results_unchanged(self):
95
+ engine = _engine_with_ml(flag=False)
96
+ results = await self._run(engine, ["a@brand-new-xyz.tk"])
97
+
98
+ self.assertIsNone(results[0].disposable.ml_label)
99
+ self.assertIsNone(results[0].disposable.ml_confidence)
100
+ self.assertNotIn(
101
+ "ML model flagged domain as disposable-like",
102
+ results[0].scoring.warnings,
103
+ )
104
+
105
+ async def test_known_disposable_domain_skips_ml(self):
106
+ # mailinator.com IS in the live blocklist -> the deterministic layer
107
+ # already hard-fails it; the model must not be consulted.
108
+ engine = _engine_with_ml(flag=True, predict=[AssertionError("must not run")])
109
+ results = await self._run(engine, ["a@mailinator.com"])
110
+
111
+ self.assertEqual(results[0].status, ValidationStatus.INVALID)
112
+ fake_model = engine._ml_model
113
+ fake_model.predict.assert_not_called()
114
+
115
+ async def test_unknown_domain_gets_advisory_ml_signal(self):
116
+ engine = _engine_with_ml(
117
+ flag=True, predict=[[("DISPOSABLE", 0.93)]] # contract: (label, score)
118
+ )
119
+ results = await self._run(engine, ["a@brand-new-disposable-xyz.tk"])
120
+
121
+ d = results[0].disposable
122
+ self.assertEqual(d.ml_label, "DISPOSABLE")
123
+ self.assertEqual(d.ml_confidence, 0.93)
124
+ self.assertTrue(
125
+ any("ML model flagged" in w for w in results[0].scoring.warnings)
126
+ )
127
+ # ADVISORY: the deterministic verdict must not become invalid.
128
+ self.assertNotEqual(results[0].status, ValidationStatus.INVALID)
129
+ # The penalty is proven by the breakdown: the SMTP-verified bonus (+10)
130
+ # can legitimately re-inflate the absolute score past the clamp.
131
+ self.assertEqual(
132
+ results[0].scoring.breakdown.get("ML_DISPOSABLE_SUSPECT"), -5
133
+ )
134
+
135
+ async def test_model_failure_degrades_to_deterministic(self):
136
+ engine = _engine_with_ml(flag=True, predict=[None])
137
+ results = await self._run(engine, ["a@brand-new-xyz.tk"])
138
+
139
+ self.assertIsNone(results[0].disposable.ml_label)
140
+ self.assertEqual(
141
+ results[0].scoring.warnings,
142
+ [w for w in results[0].scoring.warnings if "ML model flagged" not in w]
143
+ or results[0].scoring.warnings,
144
+ )
145
+ self.assertEqual(results[0].scoring.score, 100)
146
+
147
+ async def test_unknown_domains_batch_into_one_predict_call(self):
148
+ engine = _engine_with_ml(
149
+ flag=True,
150
+ predict=[
151
+ [
152
+ ("DISPOSABLE", 0.9),
153
+ ("NORMAL", 0.9),
154
+ ("DISPOSABLE", 0.95),
155
+ ]
156
+ ],
157
+ )
158
+ emails = [f"u{i}@dk{i}.example" for i in range(3)]
159
+ results = await self._run(engine, emails)
160
+
161
+ fake_model = engine._ml_model
162
+ self.assertEqual(fake_model.predict.call_count, 1)
163
+ sent = fake_model.predict.call_args.args[0]
164
+ self.assertEqual(len(sent), 3)
165
+ self.assertEqual(results[1].disposable.ml_label, "NORMAL")
166
+
167
+
168
+ if __name__ == "__main__":
169
+ unittest.main()
tests/test_ml_dataset.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+
3
+ from app.ml.dataset import build_dataset, label_name, LABEL_NAMES
4
+
5
+
6
+ class DatasetBuilderTests(unittest.TestCase):
7
+ def test_labels_are_the_documented_four(self):
8
+ self.assertEqual(LABEL_NAMES, ["NORMAL", "DISPOSABLE", "ROLE", "SPAM_TRAP"])
9
+ self.assertEqual(label_name(0), "NORMAL")
10
+ self.assertEqual(label_name(1), "DISPOSABLE")
11
+ self.assertEqual(label_name(2), "ROLE")
12
+ self.assertEqual(label_name(3), "SPAM_TRAP")
13
+
14
+ def test_blocklist_domains_become_disposable(self):
15
+ rows = build_dataset(
16
+ blocklist={"tempmail.example", "10minutemail.example"},
17
+ free={"gmail.com"},
18
+ role_prefixes={"admin"},
19
+ normal_domains={"acme-corp.test"},
20
+ normal_locals={"john.smith", "maria.garcia"},
21
+ per_class_limit=50,
22
+ )
23
+ by_label = {}
24
+ for r in rows:
25
+ by_label.setdefault(r["label"], []).append(r["text"])
26
+ self.assertIn(1, by_label, "blocklist domains must yield DISPOSABLE rows")
27
+ self.assertTrue(any("tempmail.example" in t for t in by_label[1]))
28
+
29
+ def test_role_prefixes_become_role_label(self):
30
+ rows = build_dataset(
31
+ blocklist=set(),
32
+ free=set(),
33
+ role_prefixes={"admin", "info"},
34
+ normal_domains={"acme-corp.test"},
35
+ normal_locals={"john.smith"},
36
+ per_class_limit=50,
37
+ )
38
+ role_rows = [r for r in rows if r["label"] == 2]
39
+ self.assertTrue(role_rows, "role prefixes must yield ROLE rows")
40
+ self.assertTrue(any(r["text"].startswith("admin") for r in role_rows))
41
+
42
+ def test_normal_class_never_contains_blocklist_domains(self):
43
+ rows = build_dataset(
44
+ blocklist={"tempmail.example"},
45
+ free=set(),
46
+ role_prefixes=set(),
47
+ normal_domains={"acme-corp.test", "northwind.test"},
48
+ normal_locals={"john.smith", "maria.garcia", "kenji.tanaka"},
49
+ per_class_limit=50,
50
+ )
51
+ normal_texts = [r["text"] for r in rows if r["label"] == 0]
52
+ self.assertTrue(normal_texts, "NORMAL class must be populated")
53
+ self.assertTrue(all("tempmail.example" not in t for t in normal_texts))
54
+
55
+ def test_per_class_limit_is_respected(self):
56
+ rows = build_dataset(
57
+ blocklist={f"d{i}.example" for i in range(100)},
58
+ free=set(),
59
+ role_prefixes=set(),
60
+ normal_domains={"acme.test"},
61
+ normal_locals={"john.smith"},
62
+ per_class_limit=25,
63
+ )
64
+ counts = {}
65
+ for r in rows:
66
+ counts[r["label"]] = counts.get(r["label"], 0) + 1
67
+ self.assertTrue(all(c <= 25 for c in counts.values()), counts)
68
+
69
+ def test_text_format_is_local_sep_domain(self):
70
+ rows = build_dataset(
71
+ blocklist=set(),
72
+ free=set(),
73
+ role_prefixes={"admin"},
74
+ normal_domains={"acme.test"},
75
+ normal_locals={"john.smith"},
76
+ per_class_limit=5,
77
+ )
78
+ self.assertTrue(rows)
79
+ for r in rows:
80
+ self.assertIn(" [SEP] ", r["text"])
81
+
82
+ def test_deterministic_with_same_seed(self):
83
+ kwargs = dict(
84
+ blocklist={"tempmail.example"},
85
+ free={"gmail.com"},
86
+ role_prefixes={"admin"},
87
+ normal_domains={"acme.test"},
88
+ normal_locals={"john.smith"},
89
+ per_class_limit=20,
90
+ seed=7,
91
+ )
92
+ a = build_dataset(**kwargs)
93
+ b = build_dataset(**kwargs)
94
+ self.assertEqual(a, b, "same seed must produce identical rows")
95
+
96
+
97
+ if __name__ == "__main__":
98
+ unittest.main()
tests/test_ml_runtime.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ from unittest.mock import MagicMock, patch
3
+
4
+ from app.ml.runtime import EmailRiskModel, get_model
5
+
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")
22
+ self.assertIsNone(m.predict(["a [SEP] b.test"]))
23
+
24
+ def test_predict_maps_label_and_confidence(self):
25
+ m = EmailRiskModel(repo="user/model", token="t")
26
+ fake = MagicMock(return_value=[{"label": "DISPOSABLE", "score": 0.93}])
27
+ m._pipeline = fake
28
+ m._loaded = True
29
+
30
+ out = m.predict(["x [SEP] tempmail.example"])
31
+
32
+ self.assertEqual(out, [("DISPOSABLE", 0.93)])
33
+ # Batching is passed through - 1-row calls are a performance bug.
34
+ fake.assert_called_once()
35
+ self.assertIn("batch_size", fake.call_args.kwargs)
36
+
37
+ def test_predict_error_returns_none_not_raises(self):
38
+ m = EmailRiskModel(repo="user/model", token="t")
39
+ fake = MagicMock(side_effect=RuntimeError("onnx exploded"))
40
+ m._pipeline = fake
41
+ m._loaded = True
42
+ self.assertIsNone(m.predict(["a [SEP] b.test"]))
43
+
44
+ def test_predict_normalizes_label_case(self):
45
+ m = EmailRiskModel(repo="user/model", token="t")
46
+ fake = MagicMock(return_value=[{"label": "disposable", "score": 0.5}])
47
+ m._pipeline = fake
48
+ m._loaded = True
49
+ self.assertEqual(m.predict(["a [SEP] b.test"])[0][0], "DISPOSABLE")
50
+
51
+ def test_predict_accepts_plain_string_batch_return(self):
52
+ # transformers may return a plain list of dicts OR nested batch lists.
53
+ m = EmailRiskModel(repo="user/model", token="t")
54
+ fake = MagicMock(return_value=[[{"label": "NORMAL", "score": 0.7}]])
55
+ m._pipeline = fake
56
+ m._loaded = True
57
+ self.assertEqual(m.predict(["a [SEP] b.test"]), [("NORMAL", 0.7)])
58
+
59
+ def test_config_defaults_are_off(self):
60
+ from app.config import settings
61
+
62
+ self.assertFalse(settings.ml_disposable_enabled)
63
+ self.assertEqual(settings.ml_model_repo, "")
64
+
65
+
66
+ class GetModelTests(unittest.TestCase):
67
+ def test_returns_none_when_disabled(self):
68
+ with patch("app.ml.runtime.settings") as fake_settings:
69
+ fake_settings.ml_disposable_enabled = False
70
+ fake_settings.ml_model_repo = "user/model"
71
+ self.assertIsNone(get_model())
72
+
73
+ def test_returns_none_when_no_repo(self):
74
+ with patch("app.ml.runtime.settings") as fake_settings:
75
+ fake_settings.ml_disposable_enabled = True
76
+ fake_settings.ml_model_repo = ""
77
+ self.assertIsNone(get_model())
78
+
79
+ def test_returns_model_when_enabled_and_configured(self):
80
+ with patch("app.ml.runtime.settings") as fake_settings:
81
+ fake_settings.ml_disposable_enabled = True
82
+ fake_settings.ml_model_repo = "user/model"
83
+ m = get_model()
84
+ self.assertIsInstance(m, EmailRiskModel)
85
+ self.assertEqual(m.repo, "user/model")
86
+
87
+
88
+ if __name__ == "__main__":
89
+ unittest.main()