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
@@ -0,0 +1,333 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 hashtagRR
3
+ """CLI commands started from the dashboard, one at a time, as ``python -m idslab.cli`` subprocesses.
4
+
5
+ The page sends a command name and options; ``build_command`` checks them the way the CLI parser
6
+ would and turns them into an argv list. Running the real CLI keeps one code path: a run started in
7
+ the browser writes the same files, and resumes the same way, as one started in a shell.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import re
14
+ import signal
15
+ import subprocess
16
+ import sys
17
+ import threading
18
+ import time
19
+ from collections import deque
20
+ from typing import Any
21
+
22
+ from idslab.config import load
23
+ from idslab.dashboard.workspace import PathError, Workspace
24
+
25
+ LOG_LINES = 5000
26
+ COMMANDS = ("run", "report", "export-splits", "example", "compare", "sweep")
27
+ DEFAULT_OUT = {
28
+ "run": "runs",
29
+ "report": "runs",
30
+ "export-splits": "splits",
31
+ "compare": "comparisons",
32
+ "sweep": "runs",
33
+ }
34
+
35
+
36
+ _STAMP = re.compile(r"^\d\d:\d\d:\d\d ")
37
+ _UNITS = re.compile(r"^(\d+) units? to run: ")
38
+ _VARIANT = re.compile(r"^sweep \S+: variant (\d+) of (\d+), (\S+)")
39
+ _UNIT_LINE = re.compile(r"^(seed -?\d+(?: family .+?)?): (.+)$")
40
+
41
+
42
+ def progress(lines: list[str]) -> dict:
43
+ """What a job's own log says about its progress: the phase, the current unit and how many
44
+ units are finished. Nothing is estimated; a count that the log does not give stays None."""
45
+ out: dict[str, Any] = {
46
+ "phase": None,
47
+ "unit": None,
48
+ "units_total": None,
49
+ "units_done": 0,
50
+ "units_reused": 0,
51
+ "variant": None,
52
+ "variant_index": None,
53
+ "variants_total": None,
54
+ }
55
+ for raw in lines:
56
+ line = _STAMP.sub("", raw)
57
+ if m := _VARIANT.match(line):
58
+ out.update(variant=m.group(3), variants_total=int(m.group(2)), variant_index=int(m.group(1)))
59
+ out.update(units_total=None, units_done=0, units_reused=0, unit=None, phase="starting variant")
60
+ elif m := _UNITS.match(line):
61
+ out["units_total"] = int(m.group(1))
62
+ out["phase"] = "loading data"
63
+ elif m := _UNIT_LINE.match(line):
64
+ unit, rest = m.groups()
65
+ out["unit"] = unit
66
+ if rest.startswith("already done"):
67
+ out["units_done"] += 1
68
+ out["units_reused"] += 1
69
+ out["phase"] = "reused a finished unit"
70
+ elif rest.startswith("finished in"):
71
+ out["units_done"] += 1
72
+ out["phase"] = "unit finished"
73
+ elif rest == "started":
74
+ out["phase"] = "splitting and fitting"
75
+ elif rest.startswith("fitting stage"):
76
+ out["phase"] = rest
77
+ elif rest.startswith("frozen on VAL"):
78
+ out["phase"] = "selected on VAL"
79
+ elif rest.startswith("evaluating on TEST"):
80
+ out["phase"] = "evaluating on TEST"
81
+ elif line.startswith(("report: ", "comparison: ")):
82
+ out["phase"] = "writing the report" if line.startswith("report") else "comparison written"
83
+ return out
84
+
85
+
86
+ class JobError(ValueError):
87
+ """Options the CLI would refuse, or a job already running."""
88
+
89
+
90
+ def _seeds(v: Any) -> list[int]:
91
+ if v in (None, "", []):
92
+ return []
93
+ items = v.replace(",", " ").split() if isinstance(v, str) else v
94
+ if not isinstance(items, list):
95
+ raise JobError("seeds: give a list of integers")
96
+ out = []
97
+ for s in items:
98
+ if isinstance(s, bool):
99
+ raise JobError(f"seeds: {s!r} is not an integer")
100
+ try:
101
+ out.append(int(s))
102
+ except (TypeError, ValueError):
103
+ raise JobError(f"seeds: {s!r} is not an integer") from None
104
+ return out
105
+
106
+
107
+ def _jobs(v: Any) -> int:
108
+ if isinstance(v, bool):
109
+ raise JobError("jobs must be a positive integer or -1")
110
+ try:
111
+ n = int(v if v not in (None, "") else 1)
112
+ except (TypeError, ValueError):
113
+ raise JobError("jobs must be a positive integer or -1") from None
114
+ if n == 0 or n < -1:
115
+ raise JobError("jobs must be a positive integer or -1")
116
+ return n
117
+
118
+
119
+ def build_command(ws: Workspace, body: dict) -> tuple[list[str], dict]:
120
+ """argv (after ``python -m idslab.cli``) and a description of what the job will write.
121
+
122
+ Paths in argv are relative to the workspace, which is the job's working folder, so the log
123
+ shows the command as it would be typed there.
124
+ """
125
+ command = body.get("command")
126
+ if command not in COMMANDS:
127
+ raise JobError(f"command must be one of {', '.join(COMMANDS)}")
128
+ try:
129
+ if command == "example":
130
+ target = ws.resolve(body.get("directory") or "example")
131
+ argv = ["example", _arg(ws, target), *(["--overwrite"] if body.get("overwrite") else [])]
132
+ return argv, {"command": command, "target": ws.rel(target), "target_kind": "folder", "argv": argv}
133
+ if command == "compare":
134
+ return _compare_command(ws, body)
135
+ if command == "sweep":
136
+ return _sweep_command(ws, body)
137
+ config = ws.resolve(body.get("config"), kind="file")
138
+ out = ws.resolve(body.get("out") or DEFAULT_OUT[command])
139
+ except PathError as e:
140
+ raise JobError(str(e)) from None
141
+ cfg = load(config) # ConfigError reaches the page before anything starts
142
+ argv = [command, _arg(ws, config), "--out", _arg(ws, out)]
143
+ seeds = _seeds(body.get("seeds"))
144
+ if seeds:
145
+ argv += ["--seeds", *map(str, seeds)]
146
+ parallel = False
147
+ if command == "run":
148
+ n_jobs = _jobs(body.get("jobs"))
149
+ parallel = n_jobs != 1
150
+ argv += ["--jobs", str(n_jobs)]
151
+ if body.get("force"):
152
+ argv.append("--force")
153
+ if body.get("no_report"):
154
+ argv.append("--no-report")
155
+ if body.get("debug"):
156
+ argv.insert(0, "--debug")
157
+ target = os.path.join(out, cfg.name)
158
+ kind = "folder" if command == "export-splits" else "experiment"
159
+ return argv, {
160
+ "command": command,
161
+ "config": ws.rel(config),
162
+ "target": ws.rel(target),
163
+ "target_kind": kind,
164
+ "parallel": parallel,
165
+ "argv": argv,
166
+ }
167
+
168
+
169
+ def _bootstrap(v: Any) -> int:
170
+ try:
171
+ n = int(v if v not in (None, "") else 1000)
172
+ except (TypeError, ValueError):
173
+ raise JobError("bootstrap must be a whole number") from None
174
+ if n < 0 or n > 100000:
175
+ raise JobError("bootstrap must be between 0 and 100000")
176
+ return n
177
+
178
+
179
+ def _compare_command(ws: Workspace, body: dict) -> tuple[list[str], dict]:
180
+ exps = body.get("experiments")
181
+ if not isinstance(exps, list) or len(exps) < 2:
182
+ raise JobError("choose at least two experiments")
183
+ try:
184
+ paths = [ws.resolve(str(p), kind="dir") for p in exps]
185
+ out = ws.resolve(
186
+ body.get("out") or "comparisons/" + "-vs-".join(os.path.basename(p) for p in paths)[:120]
187
+ )
188
+ except PathError as e:
189
+ raise JobError(str(e)) from None
190
+ argv = ["compare", *(_arg(ws, p) for p in paths), "--out", _arg(ws, out)]
191
+ argv += ["--bootstrap", str(_bootstrap(body.get("bootstrap")))]
192
+ return argv, {"command": "compare", "target": ws.rel(out), "target_kind": "folder", "argv": argv}
193
+
194
+
195
+ def _sweep_command(ws: Workspace, body: dict) -> tuple[list[str], dict]:
196
+ from idslab.sweep import load_sweep # noqa: PLC0415
197
+
198
+ try:
199
+ path = ws.resolve(body.get("sweep"), kind="file")
200
+ out = ws.resolve(body.get("out") or DEFAULT_OUT["sweep"])
201
+ except PathError as e:
202
+ raise JobError(str(e)) from None
203
+ sweep = load_sweep(path) # SweepError reaches the page before anything starts
204
+ argv = ["sweep", _arg(ws, path), "--out", _arg(ws, out)]
205
+ seeds = _seeds(body.get("seeds"))
206
+ if seeds:
207
+ argv += ["--seeds", *map(str, seeds)]
208
+ argv += ["--jobs", str(_jobs(body.get("jobs"))), "--bootstrap", str(_bootstrap(body.get("bootstrap")))]
209
+ if body.get("force"):
210
+ argv.append("--force")
211
+ target = os.path.join(out, sweep.name, "comparison")
212
+ parallel = _jobs(body.get("jobs")) != 1
213
+ return argv, {
214
+ "parallel": parallel,
215
+ "argv": argv,
216
+ "command": "sweep",
217
+ "config": ws.rel(path),
218
+ "target": ws.rel(target),
219
+ "target_kind": "folder",
220
+ }
221
+
222
+
223
+ def _arg(ws: Workspace, path: str) -> str:
224
+ rel = ws.rel(path) or "."
225
+ return "./" + rel if rel.startswith("-") else rel # never read as an option
226
+
227
+
228
+ def _kill_tree_windows(proc: subprocess.Popen) -> None:
229
+ """End a job and the worker processes it started (Windows has no process groups to signal)."""
230
+ taskkill = os.path.join(os.environ.get("SYSTEMROOT", r"C:\Windows"), "System32", "taskkill.exe")
231
+ try:
232
+ subprocess.run( # noqa: S603 (fixed program path, the job's own pid)
233
+ [taskkill, "/F", "/T", "/PID", str(proc.pid)], capture_output=True, check=False, timeout=30
234
+ )
235
+ except (OSError, subprocess.SubprocessError):
236
+ proc.terminate() # taskkill missing or stuck: end at least the main process
237
+
238
+
239
+ class Job:
240
+ """At most one job: runs are CPU and memory heavy, and two runs of one config would race on
241
+ the same seed folders."""
242
+
243
+ def __init__(self, cwd: str):
244
+ self.cwd = cwd
245
+ self.lock = threading.Lock()
246
+ self.proc: subprocess.Popen | None = None
247
+ self.log: deque[str] = deque(maxlen=LOG_LINES)
248
+ self.state = "idle"
249
+ self.info: dict = {}
250
+ self.returncode: int | None = None
251
+ self.started_at: float | None = None
252
+ self.finished_at: float | None = None
253
+ self.stopped = False
254
+
255
+ def start(self, argv: list[str], info: dict) -> None:
256
+ with self.lock:
257
+ if self.state == "running":
258
+ raise JobError("a job is already running; stop it or wait for it to finish")
259
+ cmd = [sys.executable, "-m", "idslab.cli", *argv]
260
+ env = {**os.environ, "PYTHONUNBUFFERED": "1", "PYTHONIOENCODING": "utf-8"}
261
+ self.log.clear()
262
+ self.log.append("$ idslab " + " ".join(argv))
263
+ self.proc = subprocess.Popen( # noqa: S603 (argv list, no shell; options checked first)
264
+ cmd,
265
+ cwd=self.cwd,
266
+ stdout=subprocess.PIPE,
267
+ stderr=subprocess.STDOUT,
268
+ text=True,
269
+ bufsize=1,
270
+ encoding="utf-8",
271
+ errors="replace",
272
+ env=env,
273
+ start_new_session=os.name == "posix", # its own group, so stop reaches worker processes
274
+ )
275
+ self.state, self.info, self.returncode, self.stopped = "running", info, None, False
276
+ self.started_at, self.finished_at = time.time(), None
277
+ threading.Thread(target=self._pump, args=(self.proc,), daemon=True).start()
278
+
279
+ def _pump(self, proc: subprocess.Popen) -> None:
280
+ assert proc.stdout is not None # noqa: S101 (Popen was given stdout=PIPE)
281
+ for line in proc.stdout:
282
+ self.log.append(line.rstrip("\n"))
283
+ proc.wait()
284
+ with self.lock:
285
+ self.finished_at = time.time()
286
+ self.returncode = proc.returncode
287
+ if self.stopped:
288
+ self.state = "stopped"
289
+ else:
290
+ self.state = "completed" if proc.returncode == 0 else "failed"
291
+
292
+ def stop(self) -> None:
293
+ with self.lock:
294
+ proc = self.proc
295
+ if self.state != "running" or proc is None:
296
+ return
297
+ self.stopped = True
298
+ self.log.append("[stopped from the dashboard]")
299
+ try:
300
+ if os.name == "posix":
301
+ os.killpg(proc.pid, signal.SIGTERM)
302
+ else:
303
+ _kill_tree_windows(proc)
304
+ except ProcessLookupError:
305
+ pass
306
+
307
+ def snapshot(self, tail: int | None = None) -> dict:
308
+ log = list(self.log)
309
+ return {
310
+ "state": self.state,
311
+ **self.info,
312
+ "returncode": self.returncode,
313
+ "started_at": self.started_at,
314
+ "finished_at": self.finished_at,
315
+ "now": time.time(),
316
+ "log": log[-tail:] if tail else log,
317
+ "log_lines": len(log),
318
+ "progress": progress(log),
319
+ "parallel": bool(self.info.get("parallel")),
320
+ "units_done_on_disk": self._done_on_disk(),
321
+ }
322
+
323
+ def _done_on_disk(self) -> int | None:
324
+ """Units this job finished, counted from DONE files written since it started. Worker
325
+ processes of a parallel run do not log, so this is the count the page can show for them."""
326
+ if self.info.get("target_kind") != "experiment" or self.started_at is None:
327
+ return None
328
+ root = os.path.join(self.cwd, self.info.get("target", ""))
329
+ n = 0
330
+ for d, _, files in os.walk(root):
331
+ if "DONE" in files and os.path.getmtime(os.path.join(d, "DONE")) >= self.started_at:
332
+ n += 1
333
+ return n