idslab 0.3.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.
Files changed (51) hide show
  1. idslab/__init__.py +5 -0
  2. idslab/adapters.py +214 -0
  3. idslab/cli.py +301 -0
  4. idslab/compare.py +375 -0
  5. idslab/config.py +1179 -0
  6. idslab/controls.py +185 -0
  7. idslab/dashboard/README.md +113 -0
  8. idslab/dashboard/__init__.py +3 -0
  9. idslab/dashboard/catalog.py +122 -0
  10. idslab/dashboard/jobs.py +333 -0
  11. idslab/dashboard/server.py +377 -0
  12. idslab/dashboard/static/app.js +188 -0
  13. idslab/dashboard/static/builder.js +884 -0
  14. idslab/dashboard/static/charts.js +151 -0
  15. idslab/dashboard/static/compare.js +231 -0
  16. idslab/dashboard/static/core.js +375 -0
  17. idslab/dashboard/static/draft.js +276 -0
  18. idslab/dashboard/static/experiments.js +413 -0
  19. idslab/dashboard/static/index.html +49 -0
  20. idslab/dashboard/static/jobs.js +122 -0
  21. idslab/dashboard/static/library.js +215 -0
  22. idslab/dashboard/static/results.js +313 -0
  23. idslab/dashboard/static/setup.js +567 -0
  24. idslab/dashboard/static/styles.css +421 -0
  25. idslab/dashboard/static/views.js +94 -0
  26. idslab/dashboard/workspace.py +778 -0
  27. idslab/data.py +268 -0
  28. idslab/examples/__init__.py +3 -0
  29. idslab/examples/r1_four_layer.yaml +66 -0
  30. idslab/examples/r1_router_gate.yaml +57 -0
  31. idslab/examples/r2_anomaly_first.yaml +45 -0
  32. idslab/external.py +196 -0
  33. idslab/features.py +96 -0
  34. idslab/fitting.py +429 -0
  35. idslab/graph.py +174 -0
  36. idslab/html_report.py +314 -0
  37. idslab/metrics.py +365 -0
  38. idslab/plugins.py +64 -0
  39. idslab/prep.py +74 -0
  40. idslab/report.py +453 -0
  41. idslab/runner.py +529 -0
  42. idslab/selection.py +308 -0
  43. idslab/sweep.py +261 -0
  44. idslab/synthetic.py +56 -0
  45. idslab/torch_models.py +169 -0
  46. idslab-0.3.0.dist-info/METADATA +254 -0
  47. idslab-0.3.0.dist-info/RECORD +51 -0
  48. idslab-0.3.0.dist-info/WHEEL +5 -0
  49. idslab-0.3.0.dist-info/entry_points.txt +2 -0
  50. idslab-0.3.0.dist-info/licenses/LICENSE +661 -0
  51. idslab-0.3.0.dist-info/top_level.txt +1 -0
idslab/config.py ADDED
@@ -0,0 +1,1179 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 hashtagRR
3
+ """Load and validate a cascade config (DESIGN.md sections 2 to 5).
4
+
5
+ Every check that can run without data runs here, so a wrong config fails before
6
+ any file is read: unknown keys, broken routing graph, missing ``off`` routes,
7
+ classes that do not import, parameters the estimator does not have, and
8
+ settings that do not fit together. Checks that need the data (multiclass route
9
+ names against the type values) run in ``check_against_data``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import copy
15
+ import hashlib
16
+ import importlib
17
+ import importlib.util
18
+ import json
19
+ import os
20
+ import re
21
+ import string
22
+ from dataclasses import dataclass, field
23
+ from typing import Any
24
+
25
+ import yaml
26
+
27
+ from idslab.metrics import objective_name
28
+
29
+ TERMINALS = ("BENIGN", "ATTACK")
30
+ OUTPUTS = {"classifier": ("attack", "benign"), "detector": ("anomalous", "normal")}
31
+ OBJECTIVES = ("accuracy", "mcc", "f1", "balanced_accuracy")
32
+ EXTRAS = {
33
+ "xgboost": "xgboost",
34
+ "lightgbm": "lightgbm",
35
+ "pyod": "pyod",
36
+ "imblearn": "imbalance",
37
+ "torch": "torch",
38
+ }
39
+ DEFAULT_CUTOFFS = [round(0.05 + 0.025 * i, 3) for i in range(37)]
40
+
41
+ UNCERTAIN, DEFAULT, NEXT = "uncertain", "default", "next"
42
+ TYPE_OBJECTIVES = ("macro_f1", "accuracy", "auc")
43
+ DEFAULT_MIN_POOL = 20
44
+
45
+ MODES = ("standard", "heldout_family", "natural_novelty")
46
+ SEARCHES = ("greedy", "joint", "greedy_fixed")
47
+
48
+ PLACEHOLDERS = {"seed", "family"} # allowed in an external score path
49
+
50
+
51
+ class ConfigError(ValueError):
52
+ """The config is invalid; the message lists every problem found."""
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Candidate:
57
+ cls: str
58
+ params: dict
59
+ search: dict | None
60
+ score: dict | None
61
+ label: str
62
+ external: str | None = None # path template of a score file made elsewhere (ExternalScore)
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class Decision:
67
+ mode: str # threshold | grid | budget | top_fraction | confidence
68
+ values: tuple[float, ...]
69
+ off: str # output every flow takes when the stage is off ("next" for an annotate stage)
70
+ listed: bool = False # the config gave a list, so the value is chosen on VAL
71
+
72
+ @property
73
+ def tunable(self) -> bool:
74
+ return self.mode == "grid" or (self.mode != "threshold" and self.listed and len(self.values) > 1)
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class Stage:
79
+ name: str
80
+ kind: str
81
+ candidates: tuple[Candidate, ...]
82
+ routes: dict
83
+ decision: Decision
84
+ train_on: Any
85
+ calibrate: str
86
+ fit_on: str
87
+ pipeline: tuple[dict, ...]
88
+ select_by: str
89
+ min_val_rows: int
90
+ role: str = "decide" # decide | annotate
91
+ extra_features: tuple[str, ...] = ()
92
+ hard: dict | None = None # {source, band} for train_on: hard
93
+ type_objective: str = "macro_f1"
94
+ prep_fit_on: str = "fit" # rows that fit the preprocessing of a pool detector
95
+ min_pool: int = DEFAULT_MIN_POOL
96
+ cost: float = 1.0 # declared relative cost of running the stage on one flow (reports only)
97
+
98
+ @property
99
+ def transductive(self) -> bool:
100
+ return self.kind == "detector" and self.fit_on == "pool"
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class DatasetSpec:
105
+ train: str
106
+ test: str | None
107
+ test_fraction: float | None
108
+ label: str | None
109
+ type: str | None
110
+ normal_type: str
111
+ type_map: dict
112
+ type_fill: str | None
113
+ drop: tuple[str, ...]
114
+ id_column: str | None
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class Protocol:
119
+ seeds: tuple[int, ...]
120
+ val_fraction: float
121
+ cal_fraction: float
122
+ objective: str
123
+ mode: str
124
+ n_jobs: int
125
+ subsample: dict
126
+ oof_folds: int = 3
127
+ search: str = "greedy" # joint adds a check over every combination of decisions
128
+ joint_max: int = 5000 # largest number of combinations the joint check evaluates
129
+ family_level: str = "mapped" # held-out families defined after (mapped) or before (raw) type_map
130
+ families: tuple[str, ...] | None = None # held-out families to run (None: every attack family)
131
+ novel_types: tuple[str, ...] = () # natural novelty: raw types found only in TEST
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class Controls:
136
+ first_stage: str
137
+ bootstrap: int
138
+ frontier_bootstrap: int
139
+ cutoff_grid: tuple[float, ...]
140
+
141
+
142
+ @dataclass
143
+ class Config:
144
+ name: str
145
+ dataset: DatasetSpec
146
+ protocol: Protocol
147
+ entry: str
148
+ stages: dict[str, Stage]
149
+ controls: Controls
150
+ base_dir: str
151
+ raw: dict = field(repr=False)
152
+
153
+ def order(self) -> list[str]:
154
+ """Selection order: topological order of the routing graph joined with the score-dependency
155
+ graph (a score source comes before every stage that uses it); ties keep config order."""
156
+ return topo_order(self.entry, self.edges(), list(self.stages))
157
+
158
+ def edges(self) -> dict[str, dict]:
159
+ """Routing edges plus one edge from each score source to the stage using it."""
160
+ out = {n: dict(s.routes) for n, s in self.stages.items()}
161
+ for n in self.stages:
162
+ for i, src in enumerate(self.sources(n)):
163
+ if src in out:
164
+ out[src][f"__feeds_{n}_{i}"] = n
165
+ return out
166
+
167
+ def sources(self, name: str) -> tuple[str, ...]:
168
+ """Stages whose scores the stage ``name`` uses: extra features and the hard-row source."""
169
+ st = self.stages[name]
170
+ hard = (st.hard["source"],) if st.hard else ()
171
+ return tuple(dict.fromkeys(st.extra_features + hard))
172
+
173
+ def fingerprint(self) -> str:
174
+ """Hash of everything that can change one seed's result.
175
+
176
+ ``protocol.seeds`` (which seeds exist), ``protocol.n_jobs`` (how fast they
177
+ run) and each stage's declared ``cost`` (used by reports only) are left out, so
178
+ changing them keeps finished seeds valid.
179
+ """
180
+ raw = copy.deepcopy(self.raw)
181
+ proto = raw.get("protocol") or {}
182
+ for k in ("seeds", "n_jobs"):
183
+ proto.pop(k, None)
184
+ raw["protocol"] = proto
185
+ for st in (raw.get("stages") or {}).values():
186
+ if isinstance(st, dict):
187
+ st.pop("cost", None)
188
+ blob = json.dumps(raw, sort_keys=True, default=str).encode()
189
+ return hashlib.sha256(blob).hexdigest()
190
+
191
+ def outline(self) -> dict:
192
+ """The cascade's structure as data: stages in selection order with their routes, decisions
193
+ and score sources. Run units store it so a finished run can be drawn later even after the
194
+ config file has changed."""
195
+ stages = []
196
+ for name in self.order():
197
+ st = self.stages[name]
198
+ stages.append(
199
+ {
200
+ "name": name,
201
+ "kind": st.kind,
202
+ "role": st.role,
203
+ "decision": st.decision.mode,
204
+ "values": list(st.decision.values),
205
+ "tunable": st.decision.tunable,
206
+ "off": st.decision.off,
207
+ "fit_on": st.fit_on,
208
+ "candidates": [c.label for c in st.candidates],
209
+ "routes": {k: v for k, v in st.routes.items() if isinstance(v, str)},
210
+ "sources": list(self.sources(name)),
211
+ }
212
+ )
213
+ return {
214
+ "name": self.name,
215
+ "entry": self.entry,
216
+ "order": self.order(),
217
+ "stages": stages,
218
+ "first_stage": self.controls.first_stage,
219
+ }
220
+
221
+ def path(self, p: str) -> str:
222
+ return p if os.path.isabs(p) else os.path.normpath(os.path.join(self.base_dir, p))
223
+
224
+
225
+ def topo_order(entry: str, routes: dict[str, dict], config_order: list[str]) -> list[str]:
226
+ reach, todo = set(), [entry]
227
+ while todo:
228
+ n = todo.pop()
229
+ if n in reach or n in TERMINALS or n not in routes:
230
+ continue
231
+ reach.add(n)
232
+ todo.extend(routes[n].values())
233
+ indeg = dict.fromkeys(reach, 0)
234
+ for n in reach:
235
+ for t in set(routes[n].values()):
236
+ if t in indeg:
237
+ indeg[t] += 1
238
+ rank = {n: i for i, n in enumerate(config_order)}
239
+ ready = sorted((n for n in reach if indeg[n] == 0), key=rank.__getitem__)
240
+ out = []
241
+ while ready:
242
+ n = ready.pop(0)
243
+ out.append(n)
244
+ for t in set(routes[n].values()):
245
+ if t in indeg:
246
+ indeg[t] -= 1
247
+ if indeg[t] == 0:
248
+ ready.append(t)
249
+ ready.sort(key=rank.__getitem__)
250
+ return out
251
+
252
+
253
+ class _Loader(yaml.SafeLoader):
254
+ """YAML 1.2 booleans: only true/false. YAML 1.1 would read the key ``off`` as False."""
255
+
256
+
257
+ _Loader.yaml_implicit_resolvers = {
258
+ first: [(tag, rx) for tag, rx in resolvers if tag != "tag:yaml.org,2002:bool"]
259
+ for first, resolvers in yaml.SafeLoader.yaml_implicit_resolvers.items()
260
+ }
261
+ _Loader.add_implicit_resolver(
262
+ "tag:yaml.org,2002:bool", re.compile(r"^(?:true|True|TRUE|false|False|FALSE)$"), list("tTfF")
263
+ )
264
+
265
+
266
+ class _Dumper(yaml.SafeDumper):
267
+ """Writes configs the loader reads back unchanged: only true/false are booleans, so ``off``
268
+ is written as a plain word."""
269
+
270
+
271
+ _Dumper.yaml_implicit_resolvers = _Loader.yaml_implicit_resolvers
272
+
273
+
274
+ class _Flow(dict):
275
+ """A mapping written on one line, as in hand-written configs."""
276
+
277
+
278
+ class _FlowList(list):
279
+ """A list written on one line."""
280
+
281
+
282
+ _Dumper.add_representer(_Flow, lambda d, v: d.represent_mapping("tag:yaml.org,2002:map", v, flow_style=True))
283
+ _Dumper.add_representer(
284
+ _FlowList, lambda d, v: d.represent_sequence("tag:yaml.org,2002:seq", v, flow_style=True)
285
+ )
286
+
287
+
288
+ def _scalar(v: Any) -> bool:
289
+ return v is None or isinstance(v, (str, int, float, bool))
290
+
291
+
292
+ def _short(v: Any) -> bool:
293
+ """A value that reads well on one line: a scalar, a list of scalars, or a small mapping of those."""
294
+ if _scalar(v):
295
+ return True
296
+ if isinstance(v, list):
297
+ return all(_scalar(x) for x in v) and len(v) <= 12
298
+ if isinstance(v, dict):
299
+ return len(v) <= 4 and all(_short(x) for x in v.values())
300
+ return False
301
+
302
+
303
+ def _styled(v: Any, depth: int) -> Any:
304
+ if isinstance(v, dict):
305
+ out = {str(k): _styled(x, depth + 1) for k, x in v.items()}
306
+ return _Flow(out) if depth >= 3 and _short(v) else out
307
+ if isinstance(v, list):
308
+ items = [_styled(x, depth + 1) for x in v]
309
+ return _FlowList(items) if depth >= 2 and all(_scalar(x) for x in v) else items
310
+ return v
311
+
312
+
313
+ def dump_yaml(raw: dict) -> str:
314
+ """The config as YAML in the shape people write by hand: blocks for sections and stages, one-line
315
+ maps for routes, decisions, parameters and pipeline steps. ``parse_yaml`` reads it back unchanged."""
316
+ return yaml.dump(_styled(raw, 0), Dumper=_Dumper, sort_keys=False, allow_unicode=True, width=100)
317
+
318
+
319
+ def parse_yaml(text: str) -> Any:
320
+ try:
321
+ return yaml.load(text, Loader=_Loader) # noqa: S506 (_Loader is a SafeLoader subclass)
322
+ except yaml.YAMLError as e:
323
+ raise ConfigError(f"not valid YAML: {e}") from None
324
+
325
+
326
+ def load(path: str) -> Config:
327
+ with open(path, encoding="utf-8") as f:
328
+ raw = parse_yaml(f.read())
329
+ return from_dict(raw, base_dir=os.path.dirname(os.path.abspath(path)))
330
+
331
+
332
+ def from_dict(raw: Any, base_dir: str = ".") -> Config:
333
+ errs: list[str] = []
334
+ if not isinstance(raw, dict):
335
+ raise ConfigError("the config must be a mapping")
336
+ raw = copy.deepcopy(raw)
337
+ _keys(
338
+ raw,
339
+ {"name", "dataset", "protocol", "entry", "stages", "controls"},
340
+ "config",
341
+ errs,
342
+ required={"name", "dataset", "entry", "stages"},
343
+ )
344
+ if errs:
345
+ raise ConfigError("\n".join(errs))
346
+ dataset = _dataset(raw["dataset"], errs)
347
+ protocol = _protocol(raw.get("protocol") or {}, errs)
348
+ stages_raw = raw["stages"]
349
+ if not isinstance(stages_raw, dict) or not stages_raw:
350
+ raise ConfigError("stages: give at least one stage as a mapping")
351
+ stages = {}
352
+ for name, d in stages_raw.items():
353
+ st = _stage(str(name), d, errs)
354
+ if st is not None:
355
+ stages[str(name)] = st
356
+ entry = str(raw["entry"])
357
+ controls = _controls(raw.get("controls") or {}, entry, errs)
358
+ if errs:
359
+ raise ConfigError("\n".join(errs))
360
+ cfg = Config(
361
+ name=str(raw["name"]),
362
+ dataset=dataset,
363
+ protocol=protocol,
364
+ entry=entry,
365
+ stages=stages,
366
+ controls=controls,
367
+ base_dir=base_dir,
368
+ raw=raw,
369
+ )
370
+ _graph(cfg, errs)
371
+ if errs:
372
+ raise ConfigError("\n".join(errs))
373
+ _dependencies(cfg, errs)
374
+ if errs:
375
+ raise ConfigError("\n".join(errs))
376
+ _semantics(cfg, errs)
377
+ if errs:
378
+ raise ConfigError("\n".join(errs))
379
+ return cfg
380
+
381
+
382
+ def _is_int(v: Any) -> bool:
383
+ """An integer that is not a boolean (YAML true would otherwise pass as 1)."""
384
+ return isinstance(v, int) and not isinstance(v, bool)
385
+
386
+
387
+ def _external(
388
+ c: dict, i: int, cw: str, *, kind: str, fit_on: str, calibrate: str, errs: list[str]
389
+ ) -> Candidate:
390
+ """A candidate whose scores come from a file made elsewhere (DESIGN.md section 4.4)."""
391
+ _keys(c, {"external", "label"}, cw, errs)
392
+ ext = c["external"]
393
+ path = ext.get("path") if isinstance(ext, dict) else ext
394
+ if isinstance(ext, dict):
395
+ _keys(ext, {"path"}, f"{cw} external", errs, required={"path"})
396
+ if not isinstance(path, str) or not path:
397
+ errs.append(f"{cw}: external needs a score file path (it may use {{seed}} and {{family}})")
398
+ path = ""
399
+ else:
400
+ try:
401
+ names = {f for _, f, _, _ in string.Formatter().parse(path) if f is not None}
402
+ except ValueError as e:
403
+ names = set()
404
+ errs.append(f"{cw}: external path {path!r}: {e}")
405
+ if names - PLACEHOLDERS:
406
+ errs.append(
407
+ f"{cw}: external path placeholders {sorted(names - PLACEHOLDERS)} not in {{seed}}, {{family}}"
408
+ )
409
+ if kind == "detector" and fit_on == "pool":
410
+ errs.append(f"{cw}: a pool detector refits on each batch, which a score file cannot do")
411
+ if kind == "multiclass" and calibrate != "none":
412
+ errs.append(f"{cw}: external type scores cannot be calibrated here; set calibrate: none")
413
+ label = str(c.get("label") or f"external#{i}")
414
+ return Candidate(cls="external", params={}, search=None, score=None, label=label, external=path)
415
+
416
+
417
+ def _keys(
418
+ d: Any, allowed: set[str], where: str, errs: list[str], required: set[str] | frozenset[str] = frozenset()
419
+ ) -> bool:
420
+ if not isinstance(d, dict):
421
+ errs.append(f"{where}: expected a mapping")
422
+ return False
423
+ unknown = sorted(set(map(str, d)) - allowed)
424
+ if unknown:
425
+ errs.append(f"{where}: unknown keys {unknown} (allowed: {sorted(allowed)})")
426
+ errs.extend(f"{where}: '{k}' is required" for k in sorted(required - set(d)))
427
+ return True
428
+
429
+
430
+ def _dataset(d: Any, errs: list[str]) -> DatasetSpec:
431
+ allowed = {
432
+ "train",
433
+ "test",
434
+ "test_fraction",
435
+ "label",
436
+ "type",
437
+ "normal_type",
438
+ "type_map",
439
+ "type_fill",
440
+ "drop",
441
+ "id_column",
442
+ }
443
+ if not _keys(d, allowed, "dataset", errs, required={"train"}):
444
+ d = {}
445
+ test, frac = d.get("test"), d.get("test_fraction")
446
+ if (test is None) == (frac is None):
447
+ errs.append("dataset: give exactly one of 'test' (a file) or 'test_fraction'")
448
+ if frac is not None and not (isinstance(frac, (int, float)) and 0 < frac < 1):
449
+ errs.append("dataset.test_fraction: must be between 0 and 1")
450
+ if d.get("label") is None and d.get("type") is None:
451
+ errs.append(
452
+ "dataset: give 'label', 'type', or both (the binary label is derived from 'type' if needed)"
453
+ )
454
+ tmap = d.get("type_map") or {}
455
+ if not isinstance(tmap, dict):
456
+ errs.append("dataset.type_map: expected a mapping from raw type to group")
457
+ tmap = {}
458
+ if tmap and d.get("type") is None:
459
+ errs.append("dataset.type_map: needs dataset.type")
460
+ return DatasetSpec(
461
+ train=str(d.get("train", "")),
462
+ test=None if test is None else str(test),
463
+ test_fraction=None if frac is None else float(frac),
464
+ label=d.get("label"),
465
+ type=d.get("type"),
466
+ normal_type=str(d.get("normal_type", "Normal")),
467
+ type_map={str(k): str(v) for k, v in tmap.items()},
468
+ type_fill=d.get("type_fill"),
469
+ drop=tuple(map(str, d.get("drop") or ())),
470
+ id_column=d.get("id_column"),
471
+ )
472
+
473
+
474
+ def _protocol(d: Any, errs: list[str]) -> Protocol:
475
+ allowed = {
476
+ "seeds",
477
+ "val_fraction",
478
+ "cal_fraction",
479
+ "objective",
480
+ "mode",
481
+ "n_jobs",
482
+ "subsample",
483
+ "search",
484
+ "family_level",
485
+ "families",
486
+ "novel_types",
487
+ "oof_folds",
488
+ "joint_max",
489
+ }
490
+ _keys(d, allowed, "protocol", errs)
491
+ if not isinstance(d, dict):
492
+ d = {}
493
+ search_mode = d.get("search", "greedy")
494
+ if search_mode not in SEARCHES:
495
+ errs.append(f"protocol.search: one of {', '.join(SEARCHES)}")
496
+ search_mode = "greedy"
497
+ joint_max = d.get("joint_max", 5000)
498
+ if not _is_int(joint_max) or joint_max < 1:
499
+ errs.append("protocol.joint_max: a positive integer")
500
+ joint_max = 5000
501
+ mode = d.get("mode", "standard")
502
+ if mode not in MODES:
503
+ errs.append(f"protocol.mode: {mode} not in {MODES}")
504
+ family_level = d.get("family_level", "mapped")
505
+ if family_level not in ("mapped", "raw"):
506
+ errs.append("protocol.family_level: mapped or raw")
507
+ families = d.get("families")
508
+ if families is not None and (
509
+ not isinstance(families, list) or not families or not all(isinstance(x, str) for x in families)
510
+ ):
511
+ errs.append("protocol.families: a non-empty list of type names")
512
+ families = None
513
+ novel = d.get("novel_types") or []
514
+ if not isinstance(novel, list) or not all(isinstance(x, str) for x in novel):
515
+ errs.append("protocol.novel_types: a list of raw type names")
516
+ novel = []
517
+ if mode == "natural_novelty" and not novel:
518
+ errs.append("protocol.novel_types: natural_novelty needs the raw types that appear only in TEST")
519
+ if mode != "heldout_family":
520
+ errs.extend(
521
+ f"protocol.{k} applies to mode heldout_family only"
522
+ for k in ("family_level", "families")
523
+ if k in d
524
+ )
525
+ if mode != "natural_novelty" and "novel_types" in d:
526
+ errs.append("protocol.novel_types applies to mode natural_novelty only")
527
+ objective = d.get("objective", "accuracy")
528
+ try:
529
+ objective = objective_name(objective)
530
+ except ValueError as e:
531
+ errs.append(f"protocol.{e}")
532
+ objective = "accuracy"
533
+ if isinstance(d.get("objective"), str) and objective not in OBJECTIVES:
534
+ from idslab.plugins import objectives as plugin_objectives # noqa: PLC0415
535
+
536
+ if objective not in plugin_objectives():
537
+ errs.append(f"protocol.objective: {objective} not in {OBJECTIVES} (or an installed plugin's)")
538
+ seeds = d.get("seeds", [0, 1, 2, 3, 4])
539
+ if not isinstance(seeds, list) or not seeds or not all(_is_int(s) for s in seeds):
540
+ errs.append("protocol.seeds: give a non-empty list of integers")
541
+ seeds = [0]
542
+ if len(set(seeds)) != len(seeds):
543
+ errs.append("protocol.seeds: seeds must be unique")
544
+ fr = {}
545
+ for k, default in (("val_fraction", 0.2), ("cal_fraction", 0.2)):
546
+ v = d.get(k, default)
547
+ if not isinstance(v, (int, float)) or not 0 < v < 0.5:
548
+ errs.append(f"protocol.{k}: must be between 0 and 0.5")
549
+ v = default
550
+ fr[k] = float(v)
551
+ sub = d.get("subsample") or {}
552
+ if (
553
+ not isinstance(sub, dict)
554
+ or set(sub) - {"train", "test"}
555
+ or not all(_is_int(v) and v > 0 for v in sub.values())
556
+ ):
557
+ errs.append("protocol.subsample: give {train: N, test: M} with positive integers")
558
+ sub = {}
559
+ oof_folds = d.get("oof_folds", 3)
560
+ if not _is_int(oof_folds) or oof_folds < 2:
561
+ errs.append("protocol.oof_folds: an integer of at least 2")
562
+ oof_folds = 3
563
+ n_jobs = d.get("n_jobs", 1)
564
+ if not _is_int(n_jobs) or n_jobs == 0:
565
+ errs.append("protocol.n_jobs: a non-zero integer (-1 means all cores)")
566
+ n_jobs = 1
567
+ return Protocol(
568
+ seeds=tuple(seeds),
569
+ val_fraction=fr["val_fraction"],
570
+ cal_fraction=fr["cal_fraction"],
571
+ objective=str(objective),
572
+ mode=str(mode),
573
+ n_jobs=n_jobs,
574
+ subsample=dict(sub),
575
+ oof_folds=int(oof_folds),
576
+ search=str(search_mode),
577
+ joint_max=int(joint_max),
578
+ family_level=str(family_level),
579
+ families=None if families is None else tuple(families),
580
+ novel_types=tuple(novel),
581
+ )
582
+
583
+
584
+ def _controls(d: Any, entry: str, errs: list[str]) -> Controls:
585
+ _keys(d, {"first_stage", "bootstrap", "frontier_bootstrap", "cutoff_grid"}, "controls", errs)
586
+ if not isinstance(d, dict):
587
+ d = {}
588
+ boot, fboot = d.get("bootstrap", 1000), d.get("frontier_bootstrap", 200)
589
+ for k, v in (("bootstrap", boot), ("frontier_bootstrap", fboot)):
590
+ if not _is_int(v) or v < 0:
591
+ errs.append(f"controls.{k}: a non-negative integer")
592
+ grid = d.get("cutoff_grid", DEFAULT_CUTOFFS)
593
+ if (
594
+ not isinstance(grid, list)
595
+ or not grid
596
+ or not all(isinstance(x, (int, float)) and 0 <= x <= 1 for x in grid)
597
+ ):
598
+ errs.append("controls.cutoff_grid: a non-empty list of values in [0, 1]")
599
+ grid = DEFAULT_CUTOFFS
600
+ return Controls(
601
+ first_stage=str(d.get("first_stage", entry)),
602
+ bootstrap=int(boot) if _is_int(boot) else 0,
603
+ frontier_bootstrap=int(fboot) if _is_int(fboot) else 0,
604
+ cutoff_grid=tuple(float(x) for x in grid),
605
+ )
606
+
607
+
608
+ def _routes(d: dict, kind: str, role: str, where: str, errs: list[str]) -> dict:
609
+ """Routes as {output: target}; an annotate stage has the single output 'next'."""
610
+ if kind == "multiclass" and role == "annotate":
611
+ if "routes" in d:
612
+ errs.append(f"{where}: an annotate stage cannot change the verdict; use 'next' (or role: decide)")
613
+ if "next" not in d:
614
+ errs.append(f"{where}: an annotate stage needs 'next' (the stage or terminal every flow goes to)")
615
+ return {NEXT: str(d.get("next", "ATTACK"))}
616
+ if "next" in d:
617
+ errs.append(f"{where}: 'next' is for annotate stages; a {kind} stage with role decide uses routes")
618
+ routes = d.get("routes")
619
+ if not isinstance(routes, dict):
620
+ errs.append(f"{where}: 'routes' must map each output to a stage or terminal")
621
+ return {}
622
+ routes = {str(k): str(v) for k, v in routes.items()}
623
+ if kind in OUTPUTS and set(routes) != set(OUTPUTS[kind]):
624
+ errs.append(f"{where}: routes must cover exactly {OUTPUTS[kind]} (got {sorted(routes)})")
625
+ if kind == "multiclass" and DEFAULT not in routes:
626
+ errs.append(f"{where}: a decide multiclass stage needs a '{DEFAULT}' route for types not listed")
627
+ return routes
628
+
629
+
630
+ def _neighbours(cands: list[Candidate]) -> int | None:
631
+ """Largest neighbour count among the candidates (explicit or the estimator default)."""
632
+ best = None
633
+ for c in cands:
634
+ if c.external is not None:
635
+ continue
636
+ try:
637
+ obj = import_class(c.cls)(**c.params)
638
+ k = obj.get_params().get("n_neighbors") if hasattr(obj, "get_params") else None
639
+ except Exception: # noqa: BLE001 (reported by _check_class)
640
+ k = None
641
+ if isinstance(k, int):
642
+ best = k if best is None else max(best, k)
643
+ return best
644
+
645
+
646
+ def _stage(name: str, d: Any, errs: list[str]) -> Stage | None:
647
+ where = f"stage {name}"
648
+ if name in TERMINALS:
649
+ errs.append(f"{where}: {TERMINALS} are terminal names and cannot name a stage")
650
+ allowed = {
651
+ "kind",
652
+ "candidates",
653
+ "routes",
654
+ "decision",
655
+ "train_on",
656
+ "calibrate",
657
+ "fit_on",
658
+ "pipeline",
659
+ "select_by",
660
+ "min_val_rows",
661
+ "extra_features",
662
+ "hard",
663
+ "next",
664
+ "role",
665
+ "type_objective",
666
+ "prep_fit_on",
667
+ "min_pool",
668
+ "cost",
669
+ }
670
+ if not _keys(d, allowed, where, errs, required={"kind", "candidates", "decision"}):
671
+ return None
672
+ kind = d.get("kind")
673
+ if kind not in ("classifier", "multiclass", "detector"):
674
+ errs.append(f"{where}: kind {kind!r} not in classifier, multiclass, detector")
675
+ return None
676
+ role = d.get("role", "annotate" if kind == "multiclass" else "decide")
677
+ if role not in ("decide", "annotate"):
678
+ errs.append(f"{where}: role {role!r} not in decide, annotate")
679
+ role = "decide"
680
+ if role == "annotate" and kind != "multiclass":
681
+ errs.append(f"{where}: only a multiclass stage can annotate; a {kind} stage decides")
682
+ role = "decide"
683
+ routes = _routes(d, kind, role, where, errs)
684
+
685
+ calibrate = d.get("calibrate", "none")
686
+ fit_on = d.get("fit_on", "fit")
687
+ if kind in ("classifier", "multiclass"):
688
+ if calibrate not in ("none", "sigmoid", "isotonic"):
689
+ errs.append(f"{where}: calibrate {calibrate!r} not in none, sigmoid, isotonic")
690
+ errs.extend(
691
+ f"{where}: {k} applies to detectors only" for k in ("fit_on", "prep_fit_on", "min_pool") if k in d
692
+ )
693
+ else:
694
+ if "calibrate" in d:
695
+ errs.append(f"{where}: detectors are not calibrated")
696
+ if fit_on not in ("fit", "benign", "pool"):
697
+ errs.append(f"{where}: fit_on {fit_on!r} not in fit, benign, pool")
698
+ if fit_on != "pool":
699
+ errs.extend(
700
+ f"{where}: {k} applies to detectors with fit_on: pool only"
701
+ for k in ("prep_fit_on", "min_pool")
702
+ if k in d
703
+ )
704
+ prep_fit_on = d.get("prep_fit_on", "fit")
705
+ if prep_fit_on not in ("fit", "benign"):
706
+ errs.append(f"{where}: prep_fit_on {prep_fit_on!r} not in fit, benign")
707
+
708
+ decision = _decision(d.get("decision"), kind, role, routes, where=where, errs=errs)
709
+ if fit_on == "pool" and decision.mode == "budget":
710
+ errs.append(
711
+ f"{where}: budget thresholds need a detector fitted once (fit_on fit or benign), not pool"
712
+ )
713
+
714
+ train_on = d.get("train_on", "all")
715
+ hard = d.get("hard")
716
+ if kind == "detector":
717
+ if "train_on" in d or "hard" in d:
718
+ errs.append(
719
+ f"{where}: detectors choose their rows with fit_on (fit, benign or pool), not train_on"
720
+ )
721
+ train_on, hard = "all", None
722
+ else:
723
+ one_class = train_on == "benign" or (kind == "classifier" and train_on == "attack")
724
+ if one_class and kind == "classifier":
725
+ errs.append(
726
+ f"{where}: a classifier needs benign and attack rows; train_on {train_on!r} has one class"
727
+ )
728
+ elif one_class:
729
+ errs.append(f"{where}: a multiclass stage needs several types; train_on 'benign' has one")
730
+ if train_on == "hard":
731
+ if hard is None:
732
+ errs.append(
733
+ f"{where}: train_on: hard needs a 'hard' block {{source: <stage>, band: [low, high]}}"
734
+ )
735
+ elif not _keys(hard, {"source", "band"}, f"{where} hard", errs, required={"source", "band"}):
736
+ hard = None
737
+ else:
738
+ band = hard.get("band")
739
+ if not (
740
+ isinstance(band, list)
741
+ and len(band) == 2
742
+ and all(isinstance(x, (int, float)) and not isinstance(x, bool) for x in band)
743
+ and band[0] < band[1]
744
+ ):
745
+ errs.append(f"{where} hard.band: two numbers [low, high] with low < high")
746
+ hard = {
747
+ "source": str(hard.get("source")),
748
+ "band": [float(x) for x in band]
749
+ if isinstance(band, list) and len(band) == 2
750
+ else [0.0, 1.0],
751
+ }
752
+ elif hard is not None:
753
+ errs.append(f"{where}: 'hard' needs train_on: hard")
754
+ hard = None
755
+ if not (
756
+ one_class or train_on in ("all", "attack", "hard") or (isinstance(train_on, list) and train_on)
757
+ ):
758
+ errs.append(f"{where}: train_on must be all, attack, hard or a list of types")
759
+
760
+ extra = d.get("extra_features") or []
761
+ if not isinstance(extra, list) or not all(isinstance(x, str) for x in extra):
762
+ errs.append(f"{where}: extra_features must be a list of stage names")
763
+ extra = []
764
+ if kind == "detector" and fit_on == "pool" and extra:
765
+ errs.append(
766
+ f"{where}: a pool detector refits on raw features each batch; extra_features are not used"
767
+ )
768
+
769
+ type_objective = d.get("type_objective", "macro_f1")
770
+ if type_objective not in TYPE_OBJECTIVES:
771
+ errs.append(f"{where}: type_objective {type_objective!r} not in {TYPE_OBJECTIVES}")
772
+ if "type_objective" in d and kind != "multiclass":
773
+ errs.append(f"{where}: type_objective applies to multiclass stages only")
774
+
775
+ select_by = d.get("select_by", "objective" if kind == "detector" or role == "annotate" else "auc")
776
+ if select_by not in ("auc", "objective"):
777
+ errs.append(f"{where}: select_by {select_by!r} not in auc, objective")
778
+ mvr = d.get("min_val_rows", 20)
779
+ if not _is_int(mvr) or mvr < 1:
780
+ errs.append(f"{where}: min_val_rows must be a positive integer")
781
+ mvr = 20
782
+
783
+ pipeline = d.get("pipeline") or []
784
+ if not isinstance(pipeline, list):
785
+ errs.append(f"{where}: pipeline must be a list of steps")
786
+ pipeline = []
787
+ for i, step in enumerate(pipeline):
788
+ if _keys(step, {"class", "params"}, f"{where} pipeline step {i}", errs, required={"class"}):
789
+ _check_class(
790
+ step["class"], step.get("params") or {}, None, f"{where} pipeline step {i}", errs, role="step"
791
+ )
792
+
793
+ cands_raw = d.get("candidates")
794
+ if isinstance(cands_raw, dict):
795
+ cands_raw = [cands_raw]
796
+ if not isinstance(cands_raw, list) or not cands_raw:
797
+ errs.append(f"{where}: candidates must be a non-empty list")
798
+ cands_raw = []
799
+ cands = []
800
+ for i, c in enumerate(cands_raw):
801
+ cw = f"{where} candidate {i}"
802
+ if isinstance(c, dict) and "external" in c:
803
+ cands.append(_external(c, i, cw, kind=kind, fit_on=fit_on, calibrate=calibrate, errs=errs))
804
+ continue
805
+ if not _keys(c, {"class", "params", "search", "score"}, cw, errs, required={"class"}):
806
+ continue
807
+ params = c.get("params") or {}
808
+ search = c.get("search")
809
+ score = c.get("score")
810
+ if search is not None:
811
+ if kind == "detector":
812
+ errs.append(
813
+ f"{cw}: detectors have no labels to search on; list each setting as its own candidate"
814
+ )
815
+ elif _keys(search, {"space", "n_iter", "cv", "kind"}, f"{cw} search", errs, required={"space"}):
816
+ if search.get("kind", "random") not in ("random", "grid"):
817
+ errs.append(f"{cw} search: kind must be random or grid")
818
+ if not isinstance(search.get("space"), dict) or not all(
819
+ isinstance(v, list) and v for v in search["space"].values()
820
+ ):
821
+ errs.append(f"{cw} search: space must map parameter names to non-empty lists")
822
+ cv = search.get("cv", 3)
823
+ if not _is_int(cv) or cv < 2:
824
+ errs.append(f"{cw} search: cv must be an integer of at least 2")
825
+ n_iter = search.get("n_iter", 10)
826
+ if not _is_int(n_iter) or n_iter < 1:
827
+ errs.append(f"{cw} search: n_iter must be a positive integer")
828
+ if score is not None:
829
+ if kind != "detector" or fit_on == "pool":
830
+ errs.append(f"{cw}: 'score' overrides apply to detectors fitted once only")
831
+ else:
832
+ _keys(
833
+ score,
834
+ {"method", "higher_is_anomalous"},
835
+ f"{cw} score",
836
+ errs,
837
+ required={"method", "higher_is_anomalous"},
838
+ )
839
+ role_key = "pool" if fit_on == "pool" and kind == "detector" else kind
840
+ _check_class(c["class"], params, search, cw, errs, role=role_key, calibrate=calibrate, score=score)
841
+ label = f"{str(c['class']).rsplit('.', 1)[-1]}#{i}"
842
+ cands.append(
843
+ Candidate(cls=str(c["class"]), params=dict(params), search=search, score=score, label=label)
844
+ )
845
+ if pipeline and any(c.external is None and _is_custom_class(c.cls) for c in cands):
846
+ errs.append(f"{where}: a custom adapter class does its own preprocessing; remove the pipeline steps")
847
+
848
+ min_pool = DEFAULT_MIN_POOL
849
+ if kind == "detector" and fit_on == "pool":
850
+ k = _neighbours(cands)
851
+ need = (k + 1) if k is not None else DEFAULT_MIN_POOL
852
+ min_pool = d.get("min_pool", need)
853
+ if not _is_int(min_pool) or min_pool < 2:
854
+ errs.append(f"{where}: min_pool must be an integer of at least 2")
855
+ min_pool = need
856
+ elif k is not None and min_pool < k + 1:
857
+ errs.append(f"{where}: min_pool {min_pool} is below the largest neighbour count + 1 ({k + 1})")
858
+ cost = d.get("cost", 1.0)
859
+ if isinstance(cost, bool) or not isinstance(cost, (int, float)) or cost <= 0:
860
+ errs.append(
861
+ f"{where}: cost must be a positive number (relative cost of running the stage on one flow)"
862
+ )
863
+ cost = 1.0
864
+ return Stage(
865
+ name=name,
866
+ kind=kind,
867
+ candidates=tuple(cands),
868
+ routes=routes,
869
+ decision=decision,
870
+ train_on=train_on,
871
+ calibrate=str(calibrate) if kind != "detector" else "none",
872
+ fit_on=str(fit_on) if kind == "detector" else "fit",
873
+ pipeline=tuple(pipeline),
874
+ select_by=str(select_by),
875
+ min_val_rows=int(mvr),
876
+ role=role,
877
+ extra_features=tuple(extra),
878
+ hard=hard,
879
+ type_objective=str(type_objective),
880
+ prep_fit_on=str(prep_fit_on),
881
+ min_pool=int(min_pool),
882
+ cost=float(cost),
883
+ )
884
+
885
+
886
+ def _is_custom_class(path: str) -> bool:
887
+ try:
888
+ return getattr(import_class(path), "idslab_contract", None) is not None
889
+ except ImportError:
890
+ return False
891
+
892
+
893
+ def _decision(d: Any, kind: str, role: str, routes: dict, *, where: str, errs: list[str]) -> Decision:
894
+ outputs = OUTPUTS.get(kind, tuple(routes))
895
+ fallback = Decision("threshold", (0.5,), outputs[1] if len(outputs) > 1 else NEXT)
896
+ if not _keys(
897
+ d, {"threshold", "grid", "budget", "off", "top_fraction", "confidence"}, f"{where} decision", errs
898
+ ):
899
+ return fallback
900
+ allowed = {
901
+ "classifier": ("threshold", "grid"),
902
+ "detector": ("threshold", "grid", "budget", "top_fraction"),
903
+ "multiclass": ("confidence",),
904
+ }[kind]
905
+ modes = [k for k in ("threshold", "grid", "budget", "top_fraction", "confidence") if k in d]
906
+ if len(modes) != 1 or modes[0] not in allowed:
907
+ errs.append(f"{where} decision: give exactly one of {allowed}")
908
+ return fallback
909
+ mode = modes[0]
910
+ v = d[mode]
911
+ values = v if isinstance(v, list) else [v]
912
+ if not values or not all(isinstance(x, (int, float)) and not isinstance(x, bool) for x in values):
913
+ errs.append(f"{where} decision.{mode}: numbers only")
914
+ return fallback
915
+ if mode == "threshold" and isinstance(v, list):
916
+ errs.append(f"{where} decision.threshold: a single number (use grid for a list chosen on VAL)")
917
+ if mode == "grid" and not isinstance(v, list):
918
+ errs.append(f"{where} decision.grid: a list of values")
919
+ if kind == "classifier" and not all(0 <= x <= 1 for x in values):
920
+ errs.append(f"{where} decision.{mode}: classifier thresholds are probabilities in [0, 1]")
921
+ if mode in ("budget", "top_fraction") and not all(0 < x < 1 for x in values):
922
+ errs.append(f"{where} decision.{mode}: fractions in (0, 1)")
923
+ if mode == "confidence" and not all(0 <= x <= 1 for x in values):
924
+ errs.append(f"{where} decision.confidence: probabilities in [0, 1]")
925
+ off = d.get("off")
926
+ if kind == "multiclass" and role == "annotate":
927
+ if off is not None:
928
+ errs.append(f"{where} decision.off: an annotate stage is off by passing flows on without a type")
929
+ off = NEXT
930
+ elif off is None:
931
+ errs.append(
932
+ f"{where} decision: 'off' is required (the output every flow takes when the stage is off; "
933
+ f"one of {tuple(outputs)})"
934
+ )
935
+ off = outputs[-1] if outputs else NEXT
936
+ elif off not in outputs:
937
+ errs.append(f"{where} decision.off: must be one of {tuple(outputs)}")
938
+ return Decision(
939
+ mode=mode, values=tuple(float(x) for x in values), off=str(off), listed=isinstance(v, list)
940
+ )
941
+
942
+
943
+ def import_class(path: str) -> Any:
944
+ mod_name, _, cls_name = str(path).rpartition(".")
945
+ if not mod_name:
946
+ raise ImportError(
947
+ f"'{path}' is not a dotted import path such as sklearn.ensemble.RandomForestClassifier"
948
+ )
949
+ try:
950
+ mod = importlib.import_module(mod_name)
951
+ except ModuleNotFoundError as e:
952
+ top = mod_name.split(".")[0]
953
+ hint = f" (install it with: pip install idslab[{EXTRAS[top]}])" if top in EXTRAS else ""
954
+ raise ImportError(f"cannot import module '{mod_name}'{hint}") from e
955
+ if not hasattr(mod, cls_name):
956
+ raise ImportError(f"module '{mod_name}' has no attribute '{cls_name}'")
957
+ return getattr(mod, cls_name)
958
+
959
+
960
+ def _check_class(
961
+ path: Any,
962
+ params: Any,
963
+ search: Any,
964
+ where: str,
965
+ errs: list[str],
966
+ *,
967
+ role: str,
968
+ calibrate: str = "none",
969
+ score: Any = None,
970
+ ) -> None:
971
+ try:
972
+ cls = import_class(str(path))
973
+ except ImportError as e:
974
+ errs.append(f"{where}: {e}")
975
+ return
976
+ if not isinstance(params, dict):
977
+ errs.append(f"{where}: params must be a mapping")
978
+ return
979
+ missing = [m for m in getattr(cls, "idslab_requires", ()) if importlib.util.find_spec(m) is None]
980
+ if missing:
981
+ extras = ", ".join(f"idslab[{EXTRAS.get(m, m)}]" for m in missing)
982
+ errs.append(f"{where}: {path} needs {missing} (install it with: pip install {extras})")
983
+ return
984
+ contract = getattr(cls, "idslab_contract", None)
985
+ if contract is not None:
986
+ expect = {
987
+ "classifier": "AttackScore",
988
+ "multiclass": "ClassProba",
989
+ "detector": "AnomalyScore",
990
+ "pool": "BatchAnomaly",
991
+ }.get(role)
992
+ if role != "step" and contract != expect:
993
+ errs.append(f"{where}: adapter declares contract {contract!r}, this stage needs {expect!r}")
994
+ if role in ("classifier", "multiclass") and calibrate != "none":
995
+ errs.append(f"{where}: a custom adapter cannot be calibrated; set calibrate: none")
996
+ if search is not None:
997
+ errs.append(f"{where}: a custom adapter cannot be searched; list each setting as a candidate")
998
+ return
999
+ try:
1000
+ obj = cls(**params)
1001
+ except Exception as e: # noqa: BLE001 (any constructor failure is a config error)
1002
+ errs.append(f"{where}: {path}({', '.join(map(str, params))}) failed: {e}")
1003
+ return
1004
+ get_params = getattr(obj, "get_params", None)
1005
+ if get_params is None:
1006
+ errs.append(
1007
+ f"{where}: {path} is not a scikit-learn style estimator (no get_params); "
1008
+ f"wrap it in an adapter class with idslab_contract"
1009
+ )
1010
+ return
1011
+ known = set(get_params())
1012
+ if isinstance(search, dict) and isinstance(search.get("space"), dict):
1013
+ bad = sorted(set(map(str, search["space"])) - known)
1014
+ if bad:
1015
+ errs.append(f"{where} search: {path} has no parameters {bad}")
1016
+ if role == "step":
1017
+ if not (hasattr(obj, "transform") or hasattr(obj, "fit_resample")):
1018
+ errs.append(f"{where}: pipeline step {path} has neither transform nor fit_resample")
1019
+ elif role in ("classifier", "multiclass"):
1020
+ if not hasattr(obj, "predict_proba") and not hasattr(obj, "decision_function"):
1021
+ errs.append(f"{where}: {path} has neither predict_proba nor decision_function")
1022
+ elif not hasattr(obj, "predict_proba") and calibrate == "none":
1023
+ errs.append(f"{where}: {path} has no predict_proba; set calibrate: sigmoid to get probabilities")
1024
+ elif role == "pool":
1025
+ if "novelty" in known and params.get("novelty") is True:
1026
+ errs.append(f"{where}: a pool detector scores the batch it was fitted on; novelty must be false")
1027
+ if not hasattr(obj, "fit"):
1028
+ errs.append(f"{where}: {path} has no fit method")
1029
+ elif role == "detector":
1030
+ if "novelty" in known and params.get("novelty") is False:
1031
+ errs.append(f"{where}: novelty must be true for a detector fitted once (fit_on fit or benign)")
1032
+ if score is None and not (
1033
+ hasattr(obj, "score_samples") or hasattr(obj, "decision_function") or "novelty" in known
1034
+ ):
1035
+ errs.append(f"{where}: {path} has no score_samples or decision_function; add a 'score' override")
1036
+ if (
1037
+ isinstance(score, dict)
1038
+ and isinstance(score.get("method"), str)
1039
+ and not hasattr(obj, score["method"])
1040
+ and "novelty" not in known
1041
+ ):
1042
+ errs.append(f"{where}: {path} has no method {score['method']!r}")
1043
+
1044
+
1045
+ def _graph(cfg: Config, errs: list[str]) -> None:
1046
+ if cfg.entry not in cfg.stages:
1047
+ errs.append(f"entry: stage '{cfg.entry}' is not defined")
1048
+ return
1049
+ state: dict[str, int] = {}
1050
+
1051
+ def visit(n: str, path: list[str]) -> None:
1052
+ if n in TERMINALS:
1053
+ return
1054
+ if n not in cfg.stages:
1055
+ errs.append(f"stage {path[-1]}: route target '{n}' is neither a stage nor one of {TERMINALS}")
1056
+ return
1057
+ if state.get(n) == 1:
1058
+ errs.append(f"routing cycle: {' -> '.join([*path[1:], n])}")
1059
+ return
1060
+ if state.get(n) == 2:
1061
+ return
1062
+ state[n] = 1
1063
+ for t in cfg.stages[n].routes.values():
1064
+ visit(t, [*path, n])
1065
+ state[n] = 2
1066
+
1067
+ visit(cfg.entry, ["(entry)"])
1068
+ unreachable = [n for n in cfg.stages if n not in state]
1069
+ if unreachable:
1070
+ errs.append(f"stages not reachable from the entry stage: {unreachable}")
1071
+
1072
+
1073
+ def _dependencies(cfg: Config, errs: list[str]) -> None:
1074
+ """Score sources must exist, give a per-row score on every split, and form no cycle with the routes."""
1075
+ for name, st in cfg.stages.items():
1076
+ for src in cfg.sources(name):
1077
+ what = "hard.source" if st.hard and src == st.hard["source"] else "extra_features"
1078
+ if src == name:
1079
+ errs.append(f"stage {name}: {what} names the stage itself")
1080
+ elif src not in cfg.stages:
1081
+ errs.append(f"stage {name}: {what} '{src}' is not a stage")
1082
+ elif cfg.stages[src].transductive:
1083
+ errs.append(
1084
+ f"stage {name}: {what} '{src}' is a pool detector, which scores only the flows reaching "
1085
+ "it; a score source must score every row"
1086
+ )
1087
+ elif what == "hard.source" and cfg.stages[src].kind == "multiclass":
1088
+ errs.append(
1089
+ f"stage {name}: hard.source '{src}' must give one score per row (not a multiclass stage)"
1090
+ )
1091
+ elif cfg.sources(src):
1092
+ # The out-of-fold scores of src are refitted per fold on features of its own sources,
1093
+ # and those features were made by models that saw the held-out fold. Nesting the folds
1094
+ # would fix it at folds-times the cost per level; until then the chain is refused.
1095
+ errs.append(
1096
+ f"stage {name}: {what} '{src}' itself uses the scores of {list(cfg.sources(src))}; "
1097
+ "chained score dependencies are not supported, because the out-of-fold scores of "
1098
+ f"'{src}' would carry labels of their held-out folds through its own score features"
1099
+ )
1100
+ if errs:
1101
+ return
1102
+ edges = cfg.edges()
1103
+ state: dict[str, int] = {}
1104
+
1105
+ def visit(n: str, path: list[str]) -> None:
1106
+ if n not in edges:
1107
+ return
1108
+ if state.get(n) == 1:
1109
+ errs.append(f"routes and score dependencies form a cycle: {' -> '.join([*path, n])}")
1110
+ return
1111
+ if state.get(n) == 2:
1112
+ return
1113
+ state[n] = 1
1114
+ for t in edges[n].values():
1115
+ visit(t, [*path, n])
1116
+ state[n] = 2
1117
+
1118
+ for n in cfg.stages:
1119
+ visit(n, [])
1120
+
1121
+
1122
+ def check_against_data(cfg: Config, types: set[str] | None, raw_types: set[str] | None = None) -> None:
1123
+ """Checks that need the dataset's type values (mapped, and raw for the modes); raises ConfigError."""
1124
+ errs = []
1125
+ proto = cfg.protocol
1126
+ if proto.mode == "heldout_family" and proto.families and types is not None:
1127
+ known = (raw_types or set()) if proto.family_level == "raw" else types
1128
+ bad = sorted(set(proto.families) - known)
1129
+ if bad:
1130
+ errs.append(
1131
+ f"protocol.families {bad} are not {proto.family_level} types in the data ({sorted(known)})"
1132
+ )
1133
+ if cfg.dataset.normal_type in proto.families:
1134
+ errs.append("protocol.families: the normal type cannot be held out")
1135
+ if proto.mode == "natural_novelty" and raw_types is not None:
1136
+ bad = sorted(set(proto.novel_types) - raw_types)
1137
+ if bad:
1138
+ errs.append(f"protocol.novel_types {bad} are not raw types in the data")
1139
+ for name, st in cfg.stages.items():
1140
+ if st.kind == "multiclass" and types is None:
1141
+ errs.append(f"stage {name}: a multiclass stage needs dataset.type")
1142
+ if types is None:
1143
+ continue
1144
+ if st.kind == "multiclass" and st.role == "decide":
1145
+ bad = sorted(set(st.routes) - types - {UNCERTAIN, DEFAULT})
1146
+ if bad:
1147
+ errs.append(f"stage {name}: route names {bad} are not types in the data ({sorted(types)})")
1148
+ if st.decision.off not in st.routes:
1149
+ errs.append(f"stage {name}: decision.off '{st.decision.off}' is not one of its routes")
1150
+ if isinstance(st.train_on, list):
1151
+ bad = sorted(set(st.train_on) - types)
1152
+ if bad:
1153
+ errs.append(f"stage {name}: train_on types {bad} are not in the data ({sorted(types)})")
1154
+ if errs:
1155
+ raise ConfigError("\n".join(errs))
1156
+
1157
+
1158
+ def _semantics(cfg: Config, errs: list[str]) -> None:
1159
+ fs = cfg.controls.first_stage
1160
+ if fs not in cfg.stages or cfg.stages[fs].kind != "classifier":
1161
+ errs.append(
1162
+ f"controls.first_stage '{fs}' must be a classifier stage (the controls compare against it)"
1163
+ )
1164
+ ds = cfg.dataset
1165
+ if cfg.protocol.mode != "standard" and ds.type is None:
1166
+ errs.append(f"protocol.mode {cfg.protocol.mode} needs dataset.type")
1167
+ for name, st in cfg.stages.items():
1168
+ if st.kind == "multiclass" and ds.type is None:
1169
+ errs.append(f"stage {name}: a multiclass stage needs dataset.type")
1170
+ if isinstance(st.train_on, list):
1171
+ if ds.type is None:
1172
+ errs.append(f"stage {name}: train_on as a list of types needs dataset.type")
1173
+ elif st.kind == "classifier" and (
1174
+ ds.normal_type not in st.train_on or not [t for t in st.train_on if t != ds.normal_type]
1175
+ ):
1176
+ errs.append(
1177
+ f"stage {name}: a classifier's train_on list must include the normal type "
1178
+ f"'{ds.normal_type}' and at least one attack type"
1179
+ )