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/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 hashtagRR
3
+ """IDSLab: evaluation framework for staged intrusion detection systems."""
4
+
5
+ __version__ = "0.3.0"
idslab/adapters.py ADDED
@@ -0,0 +1,214 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 hashtagRR
3
+ """Model adapters: build estimators and read their output as a score contract.
4
+
5
+ Contracts (DESIGN.md section 0.1):
6
+
7
+ * AttackScore: one value per row, P(attack), read from ``predict_proba`` at the
8
+ column of class 1.
9
+ * ClassProba: per row, probabilities over named types, read from ``predict_proba``
10
+ with the columns named by ``classes_``.
11
+ * AnomalyScore: one value per row, higher means more anomalous. scikit-learn
12
+ detectors use ``score_samples`` (higher means more normal, so it is negated);
13
+ PyOD detectors use ``decision_function`` (already higher means more anomalous);
14
+ a candidate may override both with ``score: {method, higher_is_anomalous}``.
15
+ * BatchAnomaly: the detector is fitted on one batch of flows and scores that same
16
+ batch, higher = more anomalous (LOF without novelty, PyOD ``decision_scores_``).
17
+
18
+ A class that is not a scikit-learn estimator can still be used: give it a class
19
+ attribute ``idslab_contract`` naming its contract, with ``fit(X, y)`` and
20
+ ``attack_score(X)`` (AttackScore), ``fit(X, y)`` and ``class_proba(X)`` plus
21
+ ``classes_`` (ClassProba), ``fit(X)`` and ``anomaly_score(X)`` (AnomalyScore), or
22
+ ``fit_score(X)`` (BatchAnomaly).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass
28
+ from typing import Any
29
+
30
+ import numpy as np
31
+
32
+ from idslab.config import Candidate, import_class
33
+
34
+ ATTACK = 1
35
+
36
+
37
+ class ContractError(RuntimeError):
38
+ """A model's output does not meet its score contract."""
39
+
40
+
41
+ def is_custom(model: Any) -> bool:
42
+ return getattr(model, "idslab_contract", None) is not None
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class ClassScores:
47
+ """Probabilities over named types (ClassProba contract); rows follow the data, columns ``classes``."""
48
+
49
+ proba: np.ndarray
50
+ classes: tuple[str, ...]
51
+
52
+ def __len__(self) -> int:
53
+ return len(self.proba)
54
+
55
+
56
+ def build_estimator(cand: Candidate, seed: int, n_jobs: int, *, novelty: bool = True) -> Any:
57
+ cls = import_class(cand.cls)
58
+ params = dict(cand.params)
59
+ if getattr(cls, "idslab_contract", None) is not None:
60
+ return cls(**params)
61
+ est = cls(**params)
62
+ known = est.get_params()
63
+ extra = {}
64
+ if "random_state" in known and "random_state" not in params:
65
+ extra["random_state"] = seed
66
+ if "n_jobs" in known and "n_jobs" not in params and n_jobs != 1:
67
+ extra["n_jobs"] = n_jobs
68
+ if "novelty" in known:
69
+ extra["novelty"] = novelty
70
+ if extra:
71
+ est.set_params(**extra)
72
+ return est
73
+
74
+
75
+ def build_pipeline(steps: tuple[dict, ...], estimator: Any, seed: int) -> Any:
76
+ """Pipeline of the stage's steps with the model last, named ``model``.
77
+
78
+ An imbalanced-learn sampler in the steps switches to its Pipeline, which
79
+ resamples during fit only.
80
+ """
81
+ built = []
82
+ for i, step in enumerate(steps):
83
+ obj = import_class(step["class"])(**(step.get("params") or {}))
84
+ if "random_state" in obj.get_params() and "random_state" not in (step.get("params") or {}):
85
+ obj.set_params(random_state=seed)
86
+ built.append((f"step{i}", obj))
87
+ if is_custom(estimator):
88
+ if built:
89
+ raise ContractError("a custom adapter class cannot be combined with pipeline steps")
90
+ return estimator
91
+ if any(hasattr(obj, "fit_resample") for _, obj in built):
92
+ from imblearn.pipeline import Pipeline as ImbPipeline # noqa: PLC0415 (optional extra)
93
+
94
+ return ImbPipeline([*built, ("model", estimator)])
95
+ from sklearn.pipeline import Pipeline # noqa: PLC0415
96
+
97
+ return Pipeline([*built, ("model", estimator)])
98
+
99
+
100
+ def attack_score(model: Any, X: Any) -> np.ndarray:
101
+ """P(attack) per row (AttackScore contract)."""
102
+ if is_custom(model):
103
+ out = np.asarray(model.attack_score(X), dtype=np.float64)
104
+ else:
105
+ classes = list(np.asarray(model.classes_).tolist())
106
+ if ATTACK not in classes:
107
+ raise ContractError(f"classifier classes {classes} do not include the attack class 1")
108
+ out = np.asarray(model.predict_proba(X), dtype=np.float64)[:, classes.index(ATTACK)]
109
+ _check(out, X, "AttackScore")
110
+ if out.min() < 0 or out.max() > 1:
111
+ raise ContractError("AttackScore must be a probability in [0, 1]")
112
+ return out
113
+
114
+
115
+ def class_proba(model: Any, X: Any) -> ClassScores:
116
+ """Probabilities over the model's types (ClassProba contract)."""
117
+ raw = model.class_proba(X) if is_custom(model) else model.predict_proba(X)
118
+ proba = np.asarray(raw, dtype=np.float64)
119
+ classes = tuple(str(c) for c in np.asarray(model.classes_).tolist())
120
+ if proba.ndim != 2 or proba.shape != (len(X), len(classes)):
121
+ raise ContractError(f"ClassProba must be rows x {len(classes)} classes; got shape {proba.shape}")
122
+ if not np.isfinite(proba).all() or proba.min() < 0 or proba.max() > 1:
123
+ raise ContractError("ClassProba must hold finite probabilities in [0, 1]")
124
+ return ClassScores(proba=proba, classes=classes)
125
+
126
+
127
+ class PoolDetector:
128
+ """A detector refitted on each batch of flows reaching its stage (BatchAnomaly contract).
129
+
130
+ The preprocessing steps are fitted once on FIT rows; the detector itself is built
131
+ afresh for every batch, fitted on that batch's features only and asked to score it.
132
+ """
133
+
134
+ def __init__(self, cand: Candidate, steps: tuple[dict, ...], seed: int, n_jobs: int):
135
+ self.cand, self.steps, self.seed, self.n_jobs = cand, steps, seed, n_jobs
136
+ self.prep: Any = None
137
+
138
+ def fit(self, X: Any) -> PoolDetector:
139
+ if self.steps:
140
+ from sklearn.pipeline import Pipeline # noqa: PLC0415
141
+
142
+ built = []
143
+ for i, step in enumerate(self.steps):
144
+ obj = import_class(step["class"])(**(step.get("params") or {}))
145
+ if "random_state" in obj.get_params() and "random_state" not in (step.get("params") or {}):
146
+ obj.set_params(random_state=self.seed)
147
+ built.append((f"step{i}", obj))
148
+ self.prep = Pipeline(built).fit(X)
149
+ return self
150
+
151
+ def score_pool(self, X: Any) -> np.ndarray:
152
+ data = self.prep.transform(X) if self.prep is not None else X
153
+ det = build_estimator(self.cand, self.seed, self.n_jobs, novelty=False)
154
+ if is_custom(det):
155
+ out = np.asarray(det.fit_score(data), dtype=np.float64)
156
+ else:
157
+ det.fit(data)
158
+ if hasattr(det, "negative_outlier_factor_"):
159
+ out = -np.asarray(det.negative_outlier_factor_, dtype=np.float64)
160
+ elif hasattr(det, "decision_scores_"): # PyOD: training scores, higher = more anomalous
161
+ out = np.asarray(det.decision_scores_, dtype=np.float64)
162
+ elif hasattr(det, "score_samples"):
163
+ out = -np.asarray(det.score_samples(data), dtype=np.float64)
164
+ else:
165
+ raise ContractError(f"{self.cand.cls} gives no batch scores (BatchAnomaly contract)")
166
+ _check(out, X, "BatchAnomaly")
167
+ return out
168
+
169
+
170
+ def anomaly_score(model: Any, X: Any, cls_path: str, override: dict | None) -> np.ndarray:
171
+ """Anomaly score per row, higher = more anomalous (AnomalyScore contract)."""
172
+ if is_custom(model):
173
+ out = np.asarray(model.anomaly_score(X), dtype=np.float64)
174
+ else:
175
+ detector, data = final_step(model, X)
176
+ if override is not None:
177
+ method, higher_is_anomalous = override["method"], bool(override["higher_is_anomalous"])
178
+ elif cls_path.startswith("pyod."):
179
+ method, higher_is_anomalous = "decision_function", True
180
+ elif hasattr(detector, "score_samples"):
181
+ method, higher_is_anomalous = "score_samples", False
182
+ else:
183
+ method, higher_is_anomalous = "decision_function", False
184
+ if not hasattr(detector, method):
185
+ raise ContractError(f"{cls_path} has no method {method!r}")
186
+ raw = np.asarray(getattr(detector, method)(data), dtype=np.float64)
187
+ out = raw if higher_is_anomalous else -raw
188
+ _check(out, X, "AnomalyScore")
189
+ return out
190
+
191
+
192
+ def final_step(model: Any, X: Any) -> tuple[Any, Any]:
193
+ """The last estimator of a pipeline and ``X`` passed through the steps before it.
194
+
195
+ Scoring methods are called on the detector itself, so any method it has can be
196
+ named in a ``score`` override (a Pipeline passes only a fixed set through).
197
+ """
198
+ steps = getattr(model, "steps", None)
199
+ if not steps:
200
+ return model, X
201
+ data = X
202
+ for _, step in steps[:-1]:
203
+ if hasattr(step, "transform"):
204
+ data = step.transform(data)
205
+ return steps[-1][1], data
206
+
207
+
208
+ def _check(out: np.ndarray, X: Any, contract: str) -> None:
209
+ if out.ndim != 1 or len(out) != len(X):
210
+ raise ContractError(
211
+ f"{contract} must give one value per row; got shape {out.shape} for {len(X)} rows"
212
+ )
213
+ if not np.isfinite(out).all():
214
+ raise ContractError(f"{contract} has {int((~np.isfinite(out)).sum())} non-finite values")
idslab/cli.py ADDED
@@ -0,0 +1,301 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 hashtagRR
3
+ """Command line: idslab validate | run | report | export-splits | example | compare | sweep | dashboard."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import logging
9
+ import os
10
+ import shutil
11
+ import sys
12
+ from importlib import resources
13
+
14
+ from idslab import __version__
15
+ from idslab.adapters import ContractError
16
+ from idslab.compare import CompareError
17
+ from idslab.config import Config, ConfigError, load
18
+ from idslab.data import DataError
19
+ from idslab.fitting import FitError
20
+ from idslab.report import write_report
21
+ from idslab.runner import data_fingerprints, export_splits, run
22
+ from idslab.sweep import SweepError
23
+ from idslab.synthetic import write_example
24
+
25
+ EXAMPLES = ("r1_router_gate.yaml", "r2_anomaly_first.yaml", "r1_four_layer.yaml")
26
+
27
+
28
+ def validation_text(path: str, cfg: Config) -> str:
29
+ """What ``idslab validate`` prints for a valid config (the dashboard shows the same lines)."""
30
+ lines = [f"{path}: valid", f" stages in selection order: {' > '.join(cfg.order())}"]
31
+ for name in cfg.order():
32
+ st = cfg.stages[name]
33
+ tune = "chosen on VAL" if st.decision.tunable else "fixed"
34
+ cands = ", ".join(c.label for c in st.candidates)
35
+ lines.append(
36
+ f" {name}: {st.kind}, {st.decision.mode} {list(st.decision.values)} ({tune}), candidates {cands}"
37
+ )
38
+ lines.append(f" controls compare against: {cfg.controls.first_stage}; seeds {list(cfg.protocol.seeds)}")
39
+ return "\n".join(lines)
40
+
41
+
42
+ def _validate(args: argparse.Namespace) -> int:
43
+ print(validation_text(args.config, load(args.config)))
44
+ return 0
45
+
46
+
47
+ def _run(args: argparse.Namespace) -> int:
48
+ cfg = load(args.config)
49
+ data_fps = data_fingerprints(cfg) # hash the data once for the run and the report
50
+ results = run(
51
+ cfg, out_root=args.out, seeds=args.seeds, jobs=args.jobs, force=args.force, data_fps=data_fps
52
+ )
53
+ goal = cfg.protocol.objective
54
+ for r in results:
55
+ s = r["summary"]
56
+ sys_ = s["controls"]["systems_test"]
57
+ tag = "skipped (already done)" if r["skipped"] else f"{s['runtime_seconds']}s"
58
+ print(
59
+ f"seed {r['seed']}{' family ' + r['family'] if r.get('family') else ''}: {tag}; "
60
+ f"TEST {goal} cascade {sys_['cascade'][goal]:.4f}, "
61
+ f"first stage {sys_['B1_first_stage'][goal]:.4f}"
62
+ )
63
+ if not args.no_report:
64
+ md, _ = write_report(args.out, cfg, args.seeds or (), data_fps)
65
+ print(f"report: {md} (and report.html next to it)")
66
+ return 0
67
+
68
+
69
+ def _report(args: argparse.Namespace) -> int:
70
+ cfg = load(args.config)
71
+ md, js = write_report(args.out, cfg, args.seeds or ())
72
+ print(f"report: {md}\nreport page: {os.path.splitext(md)[0]}.html\nreport data: {js}")
73
+ return 0
74
+
75
+
76
+ def _export(args: argparse.Namespace) -> int:
77
+ cfg = load(args.config)
78
+ paths = export_splits(cfg, args.out, args.seeds)
79
+ print(
80
+ f"wrote {len(paths)} split files (each with a .json sidecar) under {os.path.join(args.out, cfg.name)}"
81
+ )
82
+ return 0
83
+
84
+
85
+ def _example(args: argparse.Namespace) -> int:
86
+ target = args.directory
87
+ data_dir = os.path.join(target, "data")
88
+ targets = [os.path.join(target, n) for n in EXAMPLES]
89
+ targets += [os.path.join(data_dir, n) for n in ("train.parquet", "test.parquet")]
90
+ existing = [t for t in targets if os.path.exists(t)]
91
+ if existing and not args.overwrite:
92
+ print(f"already present: {', '.join(existing)}; use --overwrite to replace them", file=sys.stderr)
93
+ return 2
94
+ os.makedirs(target, exist_ok=True)
95
+ for name in EXAMPLES:
96
+ with resources.as_file(resources.files("idslab.examples") / name) as src:
97
+ shutil.copyfile(src, os.path.join(target, name))
98
+ train, test = write_example(data_dir)
99
+ print(f"wrote {train}, {test} and {', '.join(EXAMPLES)} in {target}")
100
+ print(f"try: idslab run {os.path.join(target, EXAMPLES[0])} --out {os.path.join(target, 'runs')}")
101
+ return 0
102
+
103
+
104
+ def _compare(args: argparse.Namespace) -> int:
105
+ from idslab.compare import compare, write_comparison # noqa: PLC0415
106
+
107
+ c = compare(args.experiments, bootstrap=args.bootstrap)
108
+ out = args.out or os.path.join(
109
+ "comparisons", "-vs-".join(os.path.basename(os.path.normpath(p)) for p in args.experiments)[:120]
110
+ )
111
+ md, _, html = write_comparison(c, out)
112
+ goal = c["objective"]
113
+ for h in c["experiments"]:
114
+ cas, first = (h["cascade"] or {}).get("mean"), (h["first_stage"] or {}).get("mean")
115
+ print(f"{h['name']}: TEST {goal} cascade {_num(cas)}, first stage alone {_num(first)}")
116
+ for p in c["paired"]:
117
+ n = len(p["per_unit"])
118
+ print(
119
+ f"{p['other']} minus {p['base']}: {_num(p['mean_diff'], signed=True)} over {n} paired units "
120
+ f"(CI above 0 in {p['units_ci_above_zero']}, below 0 in {p['units_ci_below_zero']})"
121
+ )
122
+ for w in c["warnings"]:
123
+ print(f"warning: {w}")
124
+ print(f"comparison: {md} (and {html})")
125
+ return 0
126
+
127
+
128
+ def _num(v: float | None, signed: bool = False) -> str:
129
+ return "n/a" if v is None else (f"{v:+.4f}" if signed else f"{v:.4f}")
130
+
131
+
132
+ def _sweep(args: argparse.Namespace) -> int:
133
+ from idslab.sweep import load_sweep, run_sweep # noqa: PLC0415
134
+
135
+ if args.dry_run:
136
+ sw = load_sweep(args.sweep)
137
+ print(f"{args.sweep}: valid; {len(sw.variants)} variants of {sw.base_path}")
138
+ for v in sw.variants:
139
+ print(f" {v.name}: {v.label}")
140
+ return 0
141
+ md, _, html = run_sweep(
142
+ args.sweep,
143
+ out_root=args.out,
144
+ seeds=args.seeds,
145
+ jobs=args.jobs,
146
+ force=args.force,
147
+ bootstrap=args.bootstrap,
148
+ )
149
+ print(f"comparison: {md} (and {html})")
150
+ return 0
151
+
152
+
153
+ def _name(folder: str) -> str:
154
+ """A folder as typed, when it is a plain name every shell passes unchanged; otherwise a
155
+ placeholder rather than a command that would paste wrongly."""
156
+ plain = folder and folder[0] != "-" and all(c.isalnum() or c in "_.-" for c in folder)
157
+ return folder if plain else "<this folder>"
158
+
159
+
160
+ def _dashboard(args: argparse.Namespace) -> int:
161
+ from idslab.dashboard.server import App, make_server # noqa: PLC0415 (only this command needs it)
162
+ from idslab.dashboard.workspace import PathError, WorkspaceMissingError # noqa: PLC0415
163
+
164
+ try:
165
+ app = App(args.workspace)
166
+ except PathError as e: # the workspace folder: told plainly, it is not an unexpected error
167
+ if args.debug:
168
+ raise
169
+ print(f"idslab: {e}", file=sys.stderr)
170
+ if isinstance(e, WorkspaceMissingError):
171
+ print(
172
+ "If it should hold your own data, check the path for a typo. To try the example data, "
173
+ "create the folder first with the example command, started the same way as this one: "
174
+ f"idslab example {_name(args.workspace)}",
175
+ file=sys.stderr,
176
+ )
177
+ return 2
178
+ server = make_server(args.host, args.port, app)
179
+ host, port = server.server_address[:2]
180
+ url = f"http://{'127.0.0.1' if host in ('0.0.0.0', '::') else host}:{port}/" # noqa: S104
181
+ print(f"IDSLab dashboard for {app.ws.root} at {url} (Ctrl+C to stop)", flush=True)
182
+ if args.host not in ("127.0.0.1", "localhost", "::1"):
183
+ print("warning: the dashboard runs commands and has no login; keep it on a trusted network")
184
+ if args.open:
185
+ import webbrowser # noqa: PLC0415
186
+
187
+ webbrowser.open(url)
188
+ try:
189
+ server.serve_forever()
190
+ except KeyboardInterrupt:
191
+ pass
192
+ finally:
193
+ app.job.stop()
194
+ server.server_close()
195
+ return 0
196
+
197
+
198
+ def _jobs(text: str) -> int:
199
+ v = int(text)
200
+ if v == 0 or v < -1:
201
+ raise argparse.ArgumentTypeError("--jobs must be a positive integer or -1")
202
+ return v
203
+
204
+
205
+ def build_parser() -> argparse.ArgumentParser:
206
+ p = argparse.ArgumentParser(prog="idslab", description="Evaluate staged intrusion detection designs.")
207
+ p.add_argument("--version", action="version", version=f"idslab {__version__}")
208
+ p.add_argument("--debug", action="store_true", help="show the full traceback on errors")
209
+ sub = p.add_subparsers(dest="command", required=True)
210
+
211
+ v = sub.add_parser("validate", help="check a config without reading data")
212
+ v.add_argument("config")
213
+ v.set_defaults(func=_validate)
214
+
215
+ r = sub.add_parser("run", help="fit, select on VAL, evaluate on TEST, write results and the report")
216
+ r.add_argument("config")
217
+ r.add_argument("--out", default="runs", help="output root (default: runs)")
218
+ r.add_argument("--seeds", type=int, nargs="+", help="run only these seeds")
219
+ r.add_argument("--jobs", type=_jobs, default=1, help="seeds run in parallel (default 1, -1 = all cores)")
220
+ r.add_argument("--force", action="store_true", help="rerun seeds that are already done")
221
+ r.add_argument("--no-report", action="store_true", help="skip writing report.md")
222
+ r.set_defaults(func=_run)
223
+
224
+ rep = sub.add_parser("report", help="aggregate finished seeds into report.md and report.json")
225
+ rep.add_argument("config")
226
+ rep.add_argument("--out", default="runs")
227
+ rep.add_argument("--seeds", type=int, nargs="+", help="also include these seeds")
228
+ rep.set_defaults(func=_report)
229
+
230
+ x = sub.add_parser("export-splits", help="write row ids, folds and fingerprints for external score jobs")
231
+ x.add_argument("config")
232
+ x.add_argument("--out", default="splits", help="output root (default: splits)")
233
+ x.add_argument("--seeds", type=int, nargs="+", help="only these seeds")
234
+ x.set_defaults(func=_export)
235
+
236
+ e = sub.add_parser("example", help="write the synthetic dataset and the two reference configs")
237
+ e.add_argument("directory")
238
+ e.add_argument("--overwrite", action="store_true")
239
+ e.set_defaults(func=_example)
240
+
241
+ cp = sub.add_parser("compare", help="compare finished experiments side by side, paired where possible")
242
+ cp.add_argument(
243
+ "experiments", nargs="+", help="experiment folders (<out>/<name>); the first is the baseline"
244
+ )
245
+ cp.add_argument("--out", help="folder for compare.md/.json/.html (default: comparisons/<names>)")
246
+ cp.add_argument("--bootstrap", type=int, default=1000, help="paired bootstrap replicates (default 1000)")
247
+ cp.set_defaults(func=_compare)
248
+
249
+ sw = sub.add_parser("sweep", help="run every variant of a sweep file and compare them")
250
+ sw.add_argument("sweep", help="sweep file (YAML: name, base, and variants or grid)")
251
+ sw.add_argument("--out", default="runs", help="output root (default: runs)")
252
+ sw.add_argument("--seeds", type=int, nargs="+", help="run only these seeds")
253
+ sw.add_argument("--jobs", type=_jobs, default=1, help="seeds run in parallel (default 1, -1 = all cores)")
254
+ sw.add_argument("--force", action="store_true", help="rerun seeds that are already done")
255
+ sw.add_argument("--bootstrap", type=int, default=1000, help="paired bootstrap replicates (default 1000)")
256
+ sw.add_argument(
257
+ "--dry-run", action="store_true", help="check the sweep and list its variants, run nothing"
258
+ )
259
+ sw.set_defaults(func=_sweep)
260
+
261
+ d = sub.add_parser("dashboard", help="open the web dashboard over a workspace folder")
262
+ d.add_argument("--workspace", default=".", help="folder with configs, data and runs (default: .)")
263
+ d.add_argument("--host", default="127.0.0.1", help="address to bind (default: 127.0.0.1)")
264
+ d.add_argument("--port", type=int, default=8765, help="port (default 8765, 0 = any free port)")
265
+ d.add_argument("--open", action="store_true", help="open the page in the default browser")
266
+ d.set_defaults(func=_dashboard)
267
+ return p
268
+
269
+
270
+ def main(argv: list[str] | None = None) -> int:
271
+ args = build_parser().parse_args(argv)
272
+ if args.command in ("run", "export-splits", "sweep"): # progress lines on stderr; results stay on stdout
273
+ handler = logging.StreamHandler()
274
+ handler.setFormatter(logging.Formatter("%(asctime)s %(message)s", "%H:%M:%S"))
275
+ log = logging.getLogger("idslab")
276
+ if not log.handlers: # main() can run more than once in a process (tests)
277
+ log.addHandler(handler)
278
+ log.setLevel(logging.INFO)
279
+ try:
280
+ return int(args.func(args))
281
+ except (
282
+ ConfigError,
283
+ DataError,
284
+ FitError,
285
+ ContractError,
286
+ CompareError,
287
+ SweepError,
288
+ ImportError,
289
+ OSError,
290
+ ) as e:
291
+ print(f"idslab: {e}", file=sys.stderr)
292
+ return 2
293
+ except (ValueError, RuntimeError, IndexError) as e:
294
+ if args.debug:
295
+ raise
296
+ print(f"idslab: {type(e).__name__}: {e} (rerun with --debug for the traceback)", file=sys.stderr)
297
+ return 2
298
+
299
+
300
+ if __name__ == "__main__":
301
+ sys.exit(main())