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.
- idslab/__init__.py +5 -0
- idslab/adapters.py +214 -0
- idslab/cli.py +301 -0
- idslab/compare.py +375 -0
- idslab/config.py +1179 -0
- idslab/controls.py +185 -0
- idslab/dashboard/README.md +113 -0
- idslab/dashboard/__init__.py +3 -0
- idslab/dashboard/catalog.py +122 -0
- idslab/dashboard/jobs.py +333 -0
- idslab/dashboard/server.py +377 -0
- idslab/dashboard/static/app.js +188 -0
- idslab/dashboard/static/builder.js +884 -0
- idslab/dashboard/static/charts.js +151 -0
- idslab/dashboard/static/compare.js +231 -0
- idslab/dashboard/static/core.js +375 -0
- idslab/dashboard/static/draft.js +276 -0
- idslab/dashboard/static/experiments.js +413 -0
- idslab/dashboard/static/index.html +49 -0
- idslab/dashboard/static/jobs.js +122 -0
- idslab/dashboard/static/library.js +215 -0
- idslab/dashboard/static/results.js +313 -0
- idslab/dashboard/static/setup.js +567 -0
- idslab/dashboard/static/styles.css +421 -0
- idslab/dashboard/static/views.js +94 -0
- idslab/dashboard/workspace.py +778 -0
- idslab/data.py +268 -0
- idslab/examples/__init__.py +3 -0
- idslab/examples/r1_four_layer.yaml +66 -0
- idslab/examples/r1_router_gate.yaml +57 -0
- idslab/examples/r2_anomaly_first.yaml +45 -0
- idslab/external.py +196 -0
- idslab/features.py +96 -0
- idslab/fitting.py +429 -0
- idslab/graph.py +174 -0
- idslab/html_report.py +314 -0
- idslab/metrics.py +365 -0
- idslab/plugins.py +64 -0
- idslab/prep.py +74 -0
- idslab/report.py +453 -0
- idslab/runner.py +529 -0
- idslab/selection.py +308 -0
- idslab/sweep.py +261 -0
- idslab/synthetic.py +56 -0
- idslab/torch_models.py +169 -0
- idslab-0.3.0.dist-info/METADATA +254 -0
- idslab-0.3.0.dist-info/RECORD +51 -0
- idslab-0.3.0.dist-info/WHEEL +5 -0
- idslab-0.3.0.dist-info/entry_points.txt +2 -0
- idslab-0.3.0.dist-info/licenses/LICENSE +661 -0
- idslab-0.3.0.dist-info/top_level.txt +1 -0
idslab/dashboard/jobs.py
ADDED
|
@@ -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
|