mobiusguard-eval 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ """Evaluation harness for LLM guardrails.
2
+
3
+ Held-out attack families, false-positive sets, recall at a fixed false-positive
4
+ rate, and CPU latency.
5
+ """
6
+
7
+ __version__ = "0.1.0"
@@ -0,0 +1,72 @@
1
+ """Checkpoints: save finished work as it happens, so a run resumes instead of restarting.
2
+
3
+ A checkpoint is an append-only JSONL file. Its first line is a *key*: everything that
4
+ decides the result (detector and model revision, the suite's item IDs, the patterns). The
5
+ other lines are finished units of work. Opening a checkpoint whose key differs sets the
6
+ old file aside (``.stale``) and starts afresh, so saved work is never reused for a
7
+ different question. A last line cut short by a crash is ignored, and its unit redone.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import os
15
+ from pathlib import Path
16
+ from typing import Any, cast
17
+
18
+
19
+ def fingerprint(value: Any) -> str:
20
+ """A stable hash of JSON-able data (keys sorted)."""
21
+ return hashlib.sha256(json.dumps(value, sort_keys=True, default=str).encode()).hexdigest()
22
+
23
+
24
+ class Checkpoint:
25
+ def __init__(self, path: Path, key: dict[str, Any]) -> None:
26
+ self.path = path
27
+ self.key = key
28
+ self.key_hash = fingerprint(key)
29
+ self.units: dict[str, Any] = {}
30
+ path.parent.mkdir(parents=True, exist_ok=True)
31
+ if path.exists() and not self._load():
32
+ path.replace(path.with_suffix(path.suffix + ".stale"))
33
+ if not path.exists():
34
+ with path.open("w", encoding="utf-8") as f:
35
+ f.write(json.dumps({"checkpoint": self.key_hash, "key": key}) + "\n")
36
+
37
+ def _load(self) -> bool:
38
+ """Read saved units; False when the file belongs to a different key."""
39
+ with self.path.open(encoding="utf-8") as f:
40
+ lines = f.read().splitlines()
41
+ try:
42
+ header = cast("dict[str, Any]", json.loads(lines[0])) if lines else {}
43
+ except json.JSONDecodeError:
44
+ return False
45
+ if header.get("checkpoint") != self.key_hash:
46
+ return False
47
+ for line in lines[1:]:
48
+ try:
49
+ row = json.loads(line)
50
+ except json.JSONDecodeError:
51
+ continue # cut short by a crash: that unit is done again
52
+ self.units[row["unit"]] = row["data"]
53
+ return True
54
+
55
+ def __contains__(self, unit: str) -> bool:
56
+ return unit in self.units
57
+
58
+ def add(self, unit: str, data: Any) -> None:
59
+ """Record a finished unit; it is on disk before this returns."""
60
+ with self.path.open("a", encoding="utf-8") as f:
61
+ f.write(json.dumps({"unit": unit, "data": data}, ensure_ascii=False) + "\n")
62
+ f.flush()
63
+ os.fsync(f.fileno())
64
+ self.units[unit] = data
65
+
66
+
67
+ def write_atomic(path: Path, text: str) -> None:
68
+ """Write a file so that readers see the old or the new content, never half of it."""
69
+ path.parent.mkdir(parents=True, exist_ok=True)
70
+ tmp = path.with_suffix(path.suffix + ".tmp")
71
+ tmp.write_text(text, encoding="utf-8")
72
+ tmp.replace(path)
@@ -0,0 +1,317 @@
1
+ """Command line: ``mobiusguard-eval <command>``.
2
+
3
+ sources list the ledger
4
+ build --out runs/suite fetch sources and build a suite
5
+ score --suite S --detector D --out runs/D [--device cuda]
6
+ report --suite S --runs runs/a runs/b --out runs/report
7
+ latency --detector D [--threads N] --out runs/latency-D.json [--suite S]
8
+ redaction --suite S --out runs/redaction [--per-pattern 200]
9
+ pipeline --out runs [--tag v0.1] [--device cuda] all of the above, in order
10
+
11
+ Every step saves its work as it goes and skips what is already done for the same inputs,
12
+ so running a command again resumes it. ``--force`` redoes a step from the start.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import importlib
19
+ import json
20
+ import sys
21
+ import time
22
+ import traceback
23
+ from collections.abc import Callable, Sequence
24
+ from datetime import UTC, datetime
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from . import detectors
29
+ from .checkpoint import fingerprint, write_atomic
30
+ from .fetch import fetch
31
+ from .items import Item
32
+ from .suite import build, is_built, ledger_sources, load_suite
33
+
34
+
35
+ def log(message: str) -> None:
36
+ print(f"[{datetime.now(UTC):%H:%M:%S}] {message}", file=sys.stderr, flush=True)
37
+
38
+
39
+ # ------------------------------------------------------------------------------ steps
40
+
41
+
42
+ def step_build(out: Path, source_ids: Sequence[str] | None = None, *, force: bool = False) -> None:
43
+ sources = ledger_sources(source_ids)
44
+ if not force and is_built(sources, out):
45
+ log(f"suite: {out} is up to date, skipping (--force to rebuild)")
46
+ return
47
+ loaded: dict[str, list[Item]] = {}
48
+ for s in sources:
49
+ loaded[s.id] = list(s.parse(fetch(s)))
50
+ log(f"suite: {s.id} {len(loaded[s.id]):,} items")
51
+ manifest = build(sources, loaded, out)
52
+ log(f"suite: {manifest['items']:,} items, dropped {manifest['dropped']}")
53
+
54
+
55
+ def step_score(
56
+ suite: Path,
57
+ detector: str,
58
+ out: Path,
59
+ *,
60
+ device: str = "cpu",
61
+ batch_size: int = 32,
62
+ limit: int = 0,
63
+ force: bool = False,
64
+ ) -> None:
65
+ from .run import score
66
+
67
+ items = load_suite(suite)
68
+ if limit:
69
+ items = items[:limit]
70
+ score(items, detectors.get(detector, device=device, batch_size=batch_size), out,
71
+ force=force, log=log) # fmt: skip
72
+ log(f"score: wrote {out}")
73
+
74
+
75
+ def step_latency(
76
+ detector: str,
77
+ out: Path,
78
+ *,
79
+ suite: Path | None = None,
80
+ threads: int = 0,
81
+ iterations: int = 200,
82
+ force: bool = False,
83
+ ) -> None:
84
+ from .latency import LENGTHS, measure
85
+
86
+ if threads:
87
+ importlib.import_module("torch").set_num_threads(threads)
88
+ det = detectors.get(detector)
89
+ config = {k: v for k, v in det.fingerprint().items() if k != "output"}
90
+ key: dict[str, Any] = {"config": config, "lengths": list(LENGTHS),
91
+ "iterations": iterations, "threads": threads} # fmt: skip
92
+ if detector == "tfidf":
93
+ if suite is None:
94
+ raise ValueError("tfidf latency needs --suite (it trains first)")
95
+ from .detectors.tfidf import _pipeline # pyright: ignore[reportPrivateUsage]
96
+
97
+ items = [it for it in load_suite(suite) if not it.family.startswith("obf:")]
98
+ key["suite"] = fingerprint([it.id for it in items])
99
+ if not force and _latency_done(out, key):
100
+ return
101
+ model = _pipeline().fit([it.text for it in items], [it.label for it in items])
102
+
103
+ def one(text: str) -> float:
104
+ return float(model.predict_proba([text])[0][1])
105
+ elif detector == "keyword":
106
+ from .detectors.keyword import KeywordDetector
107
+
108
+ one = KeywordDetector().score_text
109
+ elif detector == "mobiusguard-rules":
110
+ from .detectors.proxy import RulesDetector
111
+
112
+ one = RulesDetector().score_text
113
+ elif detector.endswith("-norm"):
114
+ from mobiusguard.normalize import views
115
+
116
+ from .detectors.proxy import NormalizedClassifier
117
+
118
+ if not isinstance(det, NormalizedClassifier):
119
+ raise TypeError(detector)
120
+ nc = det
121
+
122
+ def one(text: str) -> float: # every view is scored, as the detector does
123
+ return max(nc.score_texts([v.text for v in views(text)]))
124
+ else:
125
+ from .detectors.hf import HFClassifier
126
+
127
+ if not isinstance(det, HFClassifier):
128
+ raise TypeError(detector)
129
+ hf = det
130
+
131
+ def one(text: str) -> float:
132
+ return hf.score_texts([text])[0]
133
+
134
+ if detector != "tfidf" and not force and _latency_done(out, key):
135
+ return
136
+ result = measure(det, one, iterations=iterations)
137
+ result["key"] = fingerprint(key)
138
+ write_atomic(out, json.dumps(result, indent=2) + "\n")
139
+ log(f"latency: {detector} {json.dumps(result['tokens'])}")
140
+
141
+
142
+ def _latency_done(out: Path, key: dict[str, Any]) -> bool:
143
+ if out.exists() and json.loads(out.read_text()).get("key") == fingerprint(key):
144
+ log(f"latency: {out} is up to date, skipping (--force to redo)")
145
+ return True
146
+ return False
147
+
148
+
149
+ def step_redaction(suite: Path, out: Path, *, per_pattern: int = 200, force: bool = False) -> None:
150
+ from .redaction import evaluate
151
+
152
+ summary = evaluate(load_suite(suite), out, per_pattern=per_pattern, force=force, log=log)
153
+ log(f"redaction: {summary['texts']:,} texts; wrote {out}/redaction_report.md")
154
+
155
+
156
+ def step_report(suite: Path, runs: Sequence[Path], out: Path) -> None:
157
+ from .run import report
158
+
159
+ ledger_sources() # register sources so contamination marks resolve
160
+ report(load_suite(suite), runs, out)
161
+ log(f"report: wrote {out / 'report.md'}")
162
+
163
+
164
+ # ------------------------------------------------------------------------------ pipeline
165
+
166
+
167
+ def pipeline(
168
+ root: Path,
169
+ *,
170
+ tag: str = "v0.1",
171
+ names: Sequence[str] = detectors.NAMES,
172
+ device: str = "cpu",
173
+ batch_size: int = 32,
174
+ threads: int = 4,
175
+ per_pattern: int = 200,
176
+ latency: bool = True,
177
+ redaction: bool = True,
178
+ force: bool = False,
179
+ ) -> int:
180
+ """Every step in order. A failed step is reported and the rest still run; running the
181
+ pipeline again resumes from what was saved."""
182
+ suite = root / f"suite-{tag}"
183
+ scores = root / f"scores-{tag}"
184
+ steps: list[tuple[str, Callable[[], None]]] = [
185
+ ("build", lambda: step_build(suite, force=force))
186
+ ]
187
+ for name in names:
188
+ dev = device if name in detectors.GPU else "cpu"
189
+ steps.append((f"score {name}", lambda n=name, d=dev: step_score(
190
+ suite, n, scores / n, device=d, batch_size=batch_size, force=force))) # fmt: skip
191
+ if redaction:
192
+ steps.append(("redaction", lambda: step_redaction(
193
+ suite, root / f"redaction-{tag}", per_pattern=per_pattern, force=force))) # fmt: skip
194
+ if latency: # last before the report, so nothing else competes for the CPU
195
+ for name in names:
196
+ steps.append((f"latency {name}", lambda n=name: step_latency(
197
+ n, root / f"latency-{tag}" / f"{n}.json", suite=suite, threads=threads,
198
+ force=force))) # fmt: skip
199
+
200
+ def report() -> None:
201
+ done = [scores / n for n in names if (scores / n / "run.json").exists()]
202
+ if not done:
203
+ raise RuntimeError("no finished scores to report")
204
+ step_report(suite, done, root / f"report-{tag}")
205
+
206
+ steps.append(("report", report))
207
+
208
+ results: list[tuple[str, str, float]] = []
209
+ for name, run in steps:
210
+ t0 = time.perf_counter()
211
+ log(f"== {name}")
212
+ try:
213
+ run()
214
+ results.append((name, "ok", time.perf_counter() - t0))
215
+ except Exception:
216
+ traceback.print_exc()
217
+ results.append((name, "FAILED", time.perf_counter() - t0))
218
+ if name == "build":
219
+ break # nothing else can run without a suite
220
+ log("summary:")
221
+ for name, status, seconds in results:
222
+ log(f" {status:<6} {name:<24} {seconds:7.1f}s")
223
+ return 1 if any(status != "ok" for _, status, _ in results) else 0
224
+
225
+
226
+ # ------------------------------------------------------------------------------ main
227
+
228
+
229
+ def _sources(_: argparse.Namespace) -> int:
230
+ for s in ledger_sources():
231
+ trained = ", ".join(s.trained_on_by) or "-"
232
+ print(f"{s.id:<14} {s.role:<12} {s.licence:<11} trained on by: {trained:<22} {s.title}")
233
+ return 0
234
+
235
+
236
+ def _run(a: argparse.Namespace) -> int:
237
+ cmd: str = a.command
238
+ if cmd == "sources":
239
+ return _sources(a)
240
+ if cmd == "build":
241
+ step_build(Path(a.out), a.sources, force=a.force)
242
+ elif cmd == "score":
243
+ step_score(Path(a.suite), a.detector, Path(a.out), device=a.device,
244
+ batch_size=a.batch_size, limit=a.limit, force=a.force) # fmt: skip
245
+ elif cmd == "report":
246
+ step_report(Path(a.suite), [Path(r) for r in a.runs], Path(a.out))
247
+ elif cmd == "latency":
248
+ step_latency(a.detector, Path(a.out), suite=Path(a.suite) if a.suite else None,
249
+ threads=a.threads, iterations=a.iterations, force=a.force) # fmt: skip
250
+ elif cmd == "redaction":
251
+ step_redaction(Path(a.suite), Path(a.out), per_pattern=a.per_pattern, force=a.force)
252
+ elif cmd == "pipeline":
253
+ return pipeline(Path(a.out), tag=a.tag, names=a.detectors, device=a.device,
254
+ batch_size=a.batch_size, threads=a.threads, per_pattern=a.per_pattern,
255
+ latency=not a.no_latency, redaction=not a.no_redaction,
256
+ force=a.force) # fmt: skip
257
+ return 0
258
+
259
+
260
+ def main(argv: Sequence[str] | None = None) -> int:
261
+ ap = argparse.ArgumentParser(prog="mobiusguard-eval", description=__doc__,
262
+ formatter_class=argparse.RawDescriptionHelpFormatter) # fmt: skip
263
+ sub = ap.add_subparsers(dest="command", required=True)
264
+
265
+ sub.add_parser("sources")
266
+
267
+ p = sub.add_parser("build")
268
+ p.add_argument("--out", required=True)
269
+ p.add_argument("--sources", nargs="*", help="source IDs (default: all)")
270
+ p.add_argument("--force", action="store_true")
271
+
272
+ p = sub.add_parser("score")
273
+ p.add_argument("--suite", required=True)
274
+ p.add_argument("--detector", required=True, choices=detectors.NAMES)
275
+ p.add_argument("--out", required=True)
276
+ p.add_argument("--device", default="cpu")
277
+ p.add_argument("--batch-size", type=int, default=32)
278
+ p.add_argument("--limit", type=int, default=0, help="score only the first N items")
279
+ p.add_argument("--force", action="store_true")
280
+
281
+ p = sub.add_parser("report")
282
+ p.add_argument("--suite", required=True)
283
+ p.add_argument("--runs", nargs="+", required=True)
284
+ p.add_argument("--out", required=True)
285
+
286
+ p = sub.add_parser("latency")
287
+ p.add_argument("--detector", required=True, choices=detectors.NAMES)
288
+ p.add_argument("--out", required=True)
289
+ p.add_argument("--suite", help="needed for tfidf, which trains first")
290
+ p.add_argument("--threads", type=int, default=0)
291
+ p.add_argument("--iterations", type=int, default=200)
292
+ p.add_argument("--force", action="store_true")
293
+
294
+ p = sub.add_parser("redaction")
295
+ p.add_argument("--suite", required=True)
296
+ p.add_argument("--out", required=True)
297
+ p.add_argument("--per-pattern", type=int, default=200)
298
+ p.add_argument("--force", action="store_true")
299
+
300
+ p = sub.add_parser("pipeline", help="every step in order, resuming what was saved")
301
+ p.add_argument("--out", default="runs")
302
+ p.add_argument("--tag", default="v0.1")
303
+ p.add_argument("--detectors", nargs="+", default=list(detectors.NAMES),
304
+ choices=detectors.NAMES) # fmt: skip
305
+ p.add_argument("--device", default="cpu", help="for the transformer baselines")
306
+ p.add_argument("--batch-size", type=int, default=32)
307
+ p.add_argument("--threads", type=int, default=4, help="CPU threads for latency")
308
+ p.add_argument("--per-pattern", type=int, default=200)
309
+ p.add_argument("--no-latency", action="store_true")
310
+ p.add_argument("--no-redaction", action="store_true")
311
+ p.add_argument("--force", action="store_true", help="redo every step")
312
+
313
+ return _run(ap.parse_args(argv))
314
+
315
+
316
+ if __name__ == "__main__":
317
+ raise SystemExit(main())
@@ -0,0 +1,106 @@
1
+ """Exact and near-duplicate detection.
2
+
3
+ Near duplicates are found with MinHash over character 5-gram sets and grouped into
4
+ clusters; a suite puts a whole cluster in one split, so a near copy of a test item can't
5
+ sit in the calibration split (or in a baseline's training data).
6
+ """
7
+
8
+ # numpy's stubs leave some overloads partially unknown under strict mode.
9
+ # pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections import defaultdict
14
+ from collections.abc import Sequence
15
+ from typing import cast
16
+
17
+ import numpy as np
18
+ import numpy.typing as npt
19
+
20
+ from .items import normalize
21
+
22
+ SHINGLE = 5
23
+ N_PERM = 128
24
+ BANDS = 32 # 32 bands x 4 rows: pairs above ~0.5 Jaccard become candidates
25
+ JACCARD = 0.8 # candidates at or above this estimated Jaccard are duplicates
26
+
27
+ _U64 = np.uint64
28
+ _rng = np.random.default_rng(20261027)
29
+ _A = _rng.integers(1, 2**63, N_PERM, dtype=np.int64).astype(_U64) | _U64(1)
30
+ _B = _rng.integers(0, 2**63, N_PERM, dtype=np.int64).astype(_U64)
31
+ _P = _U64(1_000_003)
32
+
33
+
34
+ def _shingle_hashes(text: str) -> npt.NDArray[np.uint64]:
35
+ codes = np.frombuffer(normalize(text).encode("utf-32-le"), dtype=np.uint32).astype(_U64)
36
+ if len(codes) < SHINGLE:
37
+ codes = np.concatenate([codes, np.zeros(SHINGLE - len(codes), dtype=_U64)])
38
+ n = len(codes) - SHINGLE + 1
39
+ h = np.zeros(n, dtype=_U64)
40
+ with np.errstate(over="ignore"):
41
+ for j in range(SHINGLE):
42
+ h = h * _P + codes[j : j + n]
43
+ return np.unique(h)
44
+
45
+
46
+ def signature(text: str) -> npt.NDArray[np.uint64]:
47
+ """MinHash signature: for each of N_PERM hash functions, the minimum over shingles."""
48
+ h = _shingle_hashes(text)
49
+ with np.errstate(over="ignore"):
50
+ sig = (np.outer(_A, h) + _B[:, None]).min(axis=1)
51
+ return cast("npt.NDArray[np.uint64]", sig)
52
+
53
+
54
+ def similarity(a: npt.NDArray[np.uint64], b: npt.NDArray[np.uint64]) -> float:
55
+ """Estimated Jaccard similarity of the two shingle sets."""
56
+ return float(np.mean(a == b))
57
+
58
+
59
+ class _UnionFind:
60
+ def __init__(self, n: int) -> None:
61
+ self.parent = list(range(n))
62
+
63
+ def find(self, i: int) -> int:
64
+ while self.parent[i] != i:
65
+ self.parent[i] = self.parent[self.parent[i]]
66
+ i = self.parent[i]
67
+ return i
68
+
69
+ def union(self, i: int, j: int) -> None:
70
+ a, b = self.find(i), self.find(j)
71
+ if a != b:
72
+ self.parent[max(a, b)] = min(a, b)
73
+
74
+
75
+ def clusters(texts: Sequence[str], *, threshold: float = JACCARD) -> list[int]:
76
+ """Cluster index for each text: exact (normalised) and near duplicates share one.
77
+
78
+ The index is the position of the cluster's first member, so it is deterministic for a
79
+ given input order.
80
+ """
81
+ n = len(texts)
82
+ uf = _UnionFind(n)
83
+ first: dict[str, int] = {}
84
+ for i, t in enumerate(texts):
85
+ key = normalize(t)
86
+ if key in first:
87
+ uf.union(first[key], i)
88
+ else:
89
+ first[key] = i
90
+
91
+ reps = sorted(first.values())
92
+ sigs = {i: signature(texts[i]) for i in reps}
93
+ rows = N_PERM // BANDS
94
+ # Each bucket keeps a few representatives; a new text is compared with those only.
95
+ buckets: dict[tuple[int, bytes], list[int]] = defaultdict(list)
96
+ for i in reps:
97
+ sig = sigs[i]
98
+ for band in range(BANDS):
99
+ key = (band, sig[band * rows : (band + 1) * rows].tobytes())
100
+ members = buckets[key]
101
+ for j in members:
102
+ if uf.find(i) != uf.find(j) and similarity(sig, sigs[j]) >= threshold:
103
+ uf.union(i, j)
104
+ if len(members) < 8:
105
+ members.append(i)
106
+ return [uf.find(i) for i in range(n)]
@@ -0,0 +1,110 @@
1
+ """Detectors the harness can score: our own and the baselines (decision 0006, rule 6).
2
+
3
+ A detector gives every item a score where higher means "more likely an attack". It sees the
4
+ whole list of items so that a baseline which needs training (TF-IDF) can train
5
+ leave-one-source-out; detectors that don't train just score the texts.
6
+
7
+ Work is split into *groups* (chunks of items, or one source for TF-IDF) so that a run can
8
+ save each group as it finishes and resume after a crash (:mod:`..checkpoint`).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable, Sequence
14
+ from typing import Protocol
15
+
16
+ from ..items import Item
17
+
18
+
19
+ class Detector(Protocol):
20
+ name: str
21
+
22
+ def info(self) -> dict[str, str]:
23
+ """Model ID, revision, versions, device: whatever identifies what was measured."""
24
+ ...
25
+
26
+ def fingerprint(self) -> dict[str, str]:
27
+ """What must match for saved scores to be reused (no timings or counts)."""
28
+ ...
29
+
30
+ def groups(self, items: Sequence[Item]) -> list[tuple[str, list[int]]]:
31
+ """Units of work: a name and the positions of the items it scores."""
32
+ ...
33
+
34
+ def score_group(self, items: Sequence[Item], idx: Sequence[int]) -> list[float]:
35
+ """Scores for ``items[i]`` for each ``i`` in ``idx``, in that order."""
36
+ ...
37
+
38
+
39
+ CHUNK = 2048
40
+
41
+
42
+ def chunks(n: int, size: int = CHUNK) -> list[tuple[str, list[int]]]:
43
+ """Fixed-size groups by position, for detectors that score items independently."""
44
+ return [(f"chunk-{i // size:05d}", list(range(i, min(i + size, n)))) for i in range(0, n, size)]
45
+
46
+
47
+ def score_items(detector: Detector, items: Sequence[Item]) -> list[float]:
48
+ """Score everything in one go, without checkpoints."""
49
+ scores = [0.0] * len(items)
50
+ for _, idx in detector.groups(items):
51
+ for i, s in zip(idx, detector.score_group(items, idx), strict=True):
52
+ scores[i] = s
53
+ return scores
54
+
55
+
56
+ def get(name: str, *, device: str = "cpu", batch_size: int = 32) -> Detector:
57
+ """A detector by name (see NAMES)."""
58
+ factories: dict[str, Callable[[], Detector]] = {}
59
+
60
+ from .keyword import KeywordDetector
61
+
62
+ factories["keyword"] = KeywordDetector
63
+
64
+ def tfidf() -> Detector:
65
+ from .tfidf import TfidfLosoDetector
66
+
67
+ return TfidfLosoDetector()
68
+
69
+ def hf(key: str) -> Callable[[], Detector]:
70
+ def make() -> Detector:
71
+ from .hf import HF_MODELS, HFClassifier
72
+
73
+ return HFClassifier(HF_MODELS[key], device=device, batch_size=batch_size)
74
+
75
+ return make
76
+
77
+ def rules() -> Detector:
78
+ from .proxy import RulesDetector
79
+
80
+ return RulesDetector()
81
+
82
+ def norm(key: str) -> Callable[[], Detector]:
83
+ def make() -> Detector:
84
+ from .proxy import NormalizedClassifier
85
+
86
+ return NormalizedClassifier(key, device=device, batch_size=batch_size)
87
+
88
+ return make
89
+
90
+ factories["tfidf"] = tfidf
91
+ factories["protectai-v2"] = hf("protectai-v2")
92
+ factories["piguard"] = hf("piguard")
93
+ factories["mobiusguard-rules"] = rules
94
+ factories["protectai-v2-norm"] = norm("protectai-v2")
95
+ factories["piguard-norm"] = norm("piguard")
96
+ if name not in factories:
97
+ raise KeyError(f"unknown detector {name!r}; choose from {sorted(factories)}")
98
+ return factories[name]()
99
+
100
+
101
+ NAMES = (
102
+ "keyword",
103
+ "tfidf",
104
+ "protectai-v2",
105
+ "piguard",
106
+ "mobiusguard-rules",
107
+ "protectai-v2-norm",
108
+ "piguard-norm",
109
+ )
110
+ GPU = ("protectai-v2", "piguard", "protectai-v2-norm", "piguard-norm")
@@ -0,0 +1,38 @@
1
+ """Pretrained classifiers for the harness: the proxy's own ``mobiusguard.classifier``,
2
+ with the methods the harness needs (fingerprint, groups). Needs the ``hf`` extra.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections.abc import Sequence
8
+
9
+ from mobiusguard.classifier import HF_MODELS, HFModel
10
+ from mobiusguard.classifier import HFClassifier as _ProxyClassifier
11
+
12
+ from ..items import Item
13
+ from . import chunks
14
+
15
+ __all__ = ["HF_MODELS", "HFClassifier", "HFModel"]
16
+
17
+
18
+ class HFClassifier(_ProxyClassifier):
19
+ def fingerprint(self) -> dict[str, str]:
20
+ return {
21
+ "repo": self.spec.repo,
22
+ "revision": self.spec.revision,
23
+ "max_length": str(self.spec.max_length),
24
+ "device": self.device.split(":")[0],
25
+ "output": "log-odds",
26
+ }
27
+
28
+ def groups(self, items: Sequence[Item]) -> list[tuple[str, list[int]]]:
29
+ return chunks(len(items))
30
+
31
+ def score_group(self, items: Sequence[Item], idx: Sequence[int]) -> list[float]:
32
+ # Sort by length so batches pad less; return in the given order.
33
+ order = sorted(range(len(idx)), key=lambda k: len(items[idx[k]].text))
34
+ scores = self.score_texts([items[idx[k]].text for k in order])
35
+ result = [0.0] * len(idx)
36
+ for k, s in zip(order, scores, strict=True):
37
+ result[k] = s
38
+ return result