ids2eval 0.2.2__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 (73) hide show
  1. ids2eval/__init__.py +0 -0
  2. ids2eval/__main__.py +4 -0
  3. ids2eval/_json_utils.py +24 -0
  4. ids2eval/audit/__init__.py +239 -0
  5. ids2eval/audit/_auc.py +48 -0
  6. ids2eval/audit/_by_class.py +29 -0
  7. ids2eval/audit/_checkpoint.py +74 -0
  8. ids2eval/audit/_fit_score.py +40 -0
  9. ids2eval/audit/_materiality.py +28 -0
  10. ids2eval/audit/_status.py +15 -0
  11. ids2eval/audit/_time.py +14 -0
  12. ids2eval/audit/artifact_sensitivity.py +88 -0
  13. ids2eval/audit/class_distribution.py +57 -0
  14. ids2eval/audit/cross_capture_matrix.py +115 -0
  15. ids2eval/audit/cross_dataset_drift.py +74 -0
  16. ids2eval/audit/data_integrity.py +67 -0
  17. ids2eval/audit/dedup.py +67 -0
  18. ids2eval/audit/feature_auc_ranking.py +125 -0
  19. ids2eval/audit/feature_category_ablation.py +98 -0
  20. ids2eval/audit/flow_group_leakage.py +66 -0
  21. ids2eval/audit/homogeneity.py +160 -0
  22. ids2eval/audit/identity_columns.py +171 -0
  23. ids2eval/audit/known_issues.py +144 -0
  24. ids2eval/audit/label_conflict.py +92 -0
  25. ids2eval/audit/leakage.py +99 -0
  26. ids2eval/audit/near_duplicate_class.py +87 -0
  27. ids2eval/audit/one_rule.py +82 -0
  28. ids2eval/audit/port_protocol_shortcut.py +75 -0
  29. ids2eval/audit/repeated_seed_falsification.py +182 -0
  30. ids2eval/audit/resplit.py +92 -0
  31. ids2eval/audit/result_robustness.py +154 -0
  32. ids2eval/audit/row_order_leakage.py +86 -0
  33. ids2eval/audit/scenario_holdout.py +115 -0
  34. ids2eval/audit/schema_fingerprint.py +52 -0
  35. ids2eval/audit/seed_sensitivity.py +69 -0
  36. ids2eval/audit/synthetic_realism.py +73 -0
  37. ids2eval/audit/temporal_leakage.py +58 -0
  38. ids2eval/audit/temporal_realism.py +81 -0
  39. ids2eval/cli.py +284 -0
  40. ids2eval/config.py +362 -0
  41. ids2eval/dashboard/__init__.py +1 -0
  42. ids2eval/dashboard/__main__.py +3 -0
  43. ids2eval/dashboard/server.py +527 -0
  44. ids2eval/dashboard/static/app.js +1064 -0
  45. ids2eval/dashboard/static/charts.js +366 -0
  46. ids2eval/dashboard/static/index.html +33 -0
  47. ids2eval/dashboard/static/style.css +293 -0
  48. ids2eval/data/__init__.py +3 -0
  49. ids2eval/data/cache.py +99 -0
  50. ids2eval/data/chunked_io.py +387 -0
  51. ids2eval/data/dataset.py +208 -0
  52. ids2eval/data/features.py +95 -0
  53. ids2eval/data/label_grouping.py +38 -0
  54. ids2eval/modeling/__init__.py +3 -0
  55. ids2eval/modeling/benchmark.py +360 -0
  56. ids2eval/modeling/classifiers.py +145 -0
  57. ids2eval/modeling/preprocessing.py +73 -0
  58. ids2eval/reporting/__init__.py +2 -0
  59. ids2eval/reporting/cite.py +57 -0
  60. ids2eval/reporting/compare_datasets.py +103 -0
  61. ids2eval/reporting/drift.py +80 -0
  62. ids2eval/reporting/intervention_manifest.py +83 -0
  63. ids2eval/reporting/recommend.py +402 -0
  64. ids2eval/reporting/run_manager.py +184 -0
  65. ids2eval/reporting/scorecard.py +875 -0
  66. ids2eval/reporting/scorecard_plot.py +162 -0
  67. ids2eval/version_info.py +36 -0
  68. ids2eval-0.2.2.dist-info/METADATA +119 -0
  69. ids2eval-0.2.2.dist-info/RECORD +73 -0
  70. ids2eval-0.2.2.dist-info/WHEEL +5 -0
  71. ids2eval-0.2.2.dist-info/entry_points.txt +4 -0
  72. ids2eval-0.2.2.dist-info/licenses/LICENSE +661 -0
  73. ids2eval-0.2.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,527 @@
1
+ """The IDS2Eval dashboard's server: browse runs, read scorecards, launch runs,
2
+ cite a run, compare two datasets, and get structural fix recommendations for
3
+ a flagged/warned run - every CLI subcommand available here too.
4
+
5
+ ids2eval-dashboard --output ./output # then open http://127.0.0.1:8765
6
+
7
+ The page itself lives next to this file, in static/ (index.html, style.css,
8
+ app.js) - plain files, served as-is, so they can be opened and edited
9
+ directly. See README.md in this folder for the endpoints and troubleshooting.
10
+
11
+ Standard library only (http.server), so it adds no dependencies. A run is
12
+ launched as a real `python -m ids2eval` subprocess - exactly what the CLI
13
+ does - so the UI can't drift from the CLI's behavior; it only writes the
14
+ YAML to a temp file and streams the log back.
15
+
16
+ Local by design: it binds to 127.0.0.1, and every request must carry a
17
+ localhost Host header (blocks DNS rebinding) and every POST a JSON body
18
+ from a same-origin page (blocks a random website from POSTing a config
19
+ that reads arbitrary files). Anyone who can reach the port can run a
20
+ config, so don't bind it to a public interface.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import csv
27
+ import json
28
+ import logging
29
+ import os
30
+ import subprocess
31
+ import sys
32
+ import tempfile
33
+ import threading
34
+ import time
35
+ import webbrowser
36
+ from collections import deque
37
+ from http import HTTPStatus
38
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
39
+ from pathlib import Path
40
+ from urllib.parse import unquote, urlsplit
41
+
42
+ import yaml
43
+
44
+ from ..config import load_config
45
+ from ..reporting import cite, compare_datasets, recommend, run_manager, scorecard
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+ STATIC_DIR = Path(__file__).parent / "static"
50
+ _LOG_LINES = 2000
51
+ _MAX_BODY = 1_000_000
52
+ _CONTENT_TYPES = {
53
+ ".html": "text/html; charset=utf-8",
54
+ ".css": "text/css; charset=utf-8",
55
+ ".js": "text/javascript; charset=utf-8",
56
+ ".md": "text/markdown; charset=utf-8",
57
+ ".json": "application/json",
58
+ ".csv": "text/csv; charset=utf-8",
59
+ ".png": "image/png",
60
+ ".pdf": "application/pdf",
61
+ ".parquet": "application/octet-stream",
62
+ }
63
+ _MINIMAL_STARTER_CONFIG = """\
64
+ # Minimal config - every other field falls back to its default.
65
+ # Every field explained, with defaults and examples: configs/README.md
66
+ dataset:
67
+ name: my-dataset
68
+ raw_files: [path/to/data.csv] # or train_file + test_file
69
+ schema:
70
+ label_column: Label
71
+ audit:
72
+ # resplit_falsification (on by default) needs dataset.group_columns; off here
73
+ # since most datasets don't have such columns loaded, see configs/README.md
74
+ resplit_falsification: false
75
+ classifiers:
76
+ list: [DecisionTree, RandomForest]
77
+ output:
78
+ dir: ./output
79
+ """
80
+
81
+ # The repo's own config schema (every field, its real default, required fields
82
+ # marked), one directory up from ids2eval/dashboard/server.py's install location.
83
+ # Only resolvable from an editable install (`pip install -e .`, this project's
84
+ # only documented install path) or a repo checkout; _build_starter_config() falls
85
+ # back to _MINIMAL_STARTER_CONFIG when it isn't there, e.g. a built wheel.
86
+ _SCHEMA_YAML_PATH = Path(__file__).resolve().parents[2] / "configs" / "schema.yaml"
87
+
88
+ # resplit_falsification defaults to true in configs/schema.yaml (an accurate
89
+ # reflection of the real default), but that trips audit.resplit_falsification's
90
+ # dataset.group_columns requirement on the very first run for most datasets, which
91
+ # don't have session-identifying columns loaded at all. Patched to false here, for
92
+ # the editor's starting point only - schema.yaml on disk is left untouched, since
93
+ # it's meant to state the real default, not a dashboard-friendly one.
94
+ _RESPLIT_BLOCK = " resplit_falsification: true # optional, needs dataset.group_columns set"
95
+ _RESPLIT_REPLACEMENT = (
96
+ " resplit_falsification: false # true is the real default; off here since most datasets "
97
+ "have no dataset.group_columns to build its comparison split from, see configs/README.md"
98
+ )
99
+
100
+
101
+ def _build_starter_config() -> str:
102
+ try:
103
+ text = _SCHEMA_YAML_PATH.read_text()
104
+ except OSError:
105
+ return _MINIMAL_STARTER_CONFIG
106
+ if _RESPLIT_BLOCK not in text:
107
+ # configs/schema.yaml's wording moved on without this patch - fall back
108
+ # rather than silently ship a starter that still trips the same wall.
109
+ logger.warning(
110
+ "configs/schema.yaml's resplit_falsification comment changed; "
111
+ "New run will start from the minimal template instead"
112
+ )
113
+ return _MINIMAL_STARTER_CONFIG
114
+ return text.replace(_RESPLIT_BLOCK, _RESPLIT_REPLACEMENT, 1)
115
+
116
+
117
+ _STARTER_CONFIG = _build_starter_config()
118
+
119
+
120
+ class Job:
121
+ """At most one run at a time: runs are CPU/RAM heavy, and two sharing
122
+ one output.dir would race on the load-cache.
123
+ """
124
+
125
+ def __init__(self):
126
+ self.lock = threading.Lock()
127
+ self.proc: subprocess.Popen | None = None
128
+ self.log: deque[str] = deque(maxlen=_LOG_LINES)
129
+ self.state = "idle"
130
+ self.returncode: int | None = None
131
+ self.output_dir: str | None = None
132
+ self.started_at: float | None = None
133
+ self.finished_at: float | None = None
134
+ self.last_output_at: float | None = None
135
+
136
+ def start(self, config_path: Path, output_dir: Path, skip_audit: bool, skip_benchmark: bool) -> None:
137
+ cmd = [sys.executable, "-m", "ids2eval", "--config", str(config_path)]
138
+ if skip_audit:
139
+ cmd.append("--skip-audit")
140
+ if skip_benchmark:
141
+ cmd.append("--skip-benchmark")
142
+ self.log.clear()
143
+ self.log.append("$ " + " ".join(cmd))
144
+ # Unbuffered so each line reaches the live log as it's written (a pipe
145
+ # would otherwise block-buffer stdout); UTF-8 so a non-ASCII class or
146
+ # feature name can't fail decoding on a Windows code page.
147
+ env = {**os.environ, "PYTHONUNBUFFERED": "1", "PYTHONIOENCODING": "utf-8"}
148
+ self.proc = subprocess.Popen( # noqa: S603 - argv list, no shell; config was validated first
149
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1,
150
+ encoding="utf-8", errors="replace", env=env,
151
+ )
152
+ self.state, self.returncode, self.output_dir = "running", None, str(output_dir)
153
+ self.started_at = self.last_output_at = time.time()
154
+ self.finished_at = None
155
+ threading.Thread(target=self._pump, args=(self.proc, config_path), daemon=True).start()
156
+
157
+ def _pump(self, proc: subprocess.Popen, config_path: Path) -> None:
158
+ for line in proc.stdout:
159
+ self.log.append(line.rstrip("\n"))
160
+ self.last_output_at = time.time()
161
+ proc.wait()
162
+ with self.lock:
163
+ self.finished_at = time.time()
164
+ self.returncode = proc.returncode
165
+ self.state = "completed" if proc.returncode == 0 else "failed"
166
+ config_path.unlink(missing_ok=True)
167
+
168
+ def snapshot(self) -> dict:
169
+ # Server-side clock in the payload, so the page's elapsed/idle timers
170
+ # don't depend on the browser's clock agreeing with the server's.
171
+ return {"state": self.state, "returncode": self.returncode, "output_dir": self.output_dir,
172
+ "started_at": self.started_at, "finished_at": self.finished_at,
173
+ "last_output_at": self.last_output_at, "now": time.time(), "log": list(self.log)}
174
+
175
+
176
+ class App:
177
+ def __init__(self, output_dirs: list[Path], starter_config: str):
178
+ self.output_dirs = [p.resolve() for p in output_dirs]
179
+ self.starter_config = starter_config
180
+ self.job = Job()
181
+
182
+ def add_output_dir(self, path: Path) -> None:
183
+ path = path.resolve()
184
+ if path not in self.output_dirs:
185
+ self.output_dirs.append(path)
186
+
187
+ def run_dir(self, dir_idx: str, run_name: str) -> Path | None:
188
+ if not dir_idx.isdigit() or int(dir_idx) >= len(self.output_dirs):
189
+ return None
190
+ runs_root = self.output_dirs[int(dir_idx)] / run_manager.RUNS_DIRNAME
191
+ candidate = (runs_root / run_name).resolve()
192
+ if candidate.parent != runs_root.resolve() or not candidate.is_dir():
193
+ return None
194
+ return candidate
195
+
196
+ def list_runs(self) -> list[dict]:
197
+ runs = []
198
+ for idx, out in enumerate(self.output_dirs):
199
+ runs_root = out / run_manager.RUNS_DIRNAME
200
+ if not runs_root.is_dir():
201
+ continue
202
+ for run_dir in runs_root.iterdir():
203
+ if run_dir.is_dir():
204
+ runs.append(_run_summary(idx, out, run_dir))
205
+ return sorted(runs, key=lambda r: r["name"], reverse=True)
206
+
207
+
208
+ def _read_scorecard(run_dir: Path) -> dict | None:
209
+ sc = _read_json(run_dir / "scorecard.json")
210
+ return scorecard.upgrade(sc) if isinstance(sc, dict) else None
211
+
212
+
213
+ def _read_json(path: Path) -> dict | list | None:
214
+ try:
215
+ return json.loads(path.read_text())
216
+ except (OSError, ValueError):
217
+ return None
218
+
219
+
220
+ def _run_summary(idx: int, output_dir: Path, run_dir: Path) -> dict:
221
+ sc = _read_scorecard(run_dir) or {}
222
+ status = _read_json(run_dir / "run_status.json") or {}
223
+ cfg = _read_json(run_dir / "resolved_config.json") or {}
224
+ fp = sc.get("dataset_fingerprint") or {}
225
+ return {
226
+ "id": f"{idx}/{run_dir.name}",
227
+ "name": run_dir.name,
228
+ "output_dir": str(output_dir),
229
+ "dataset": sc.get("dataset_name") or cfg.get("dataset", {}).get("name"),
230
+ "run_status": status.get("status", "running"),
231
+ "failed_stage": status.get("failed_stage"),
232
+ "error": status.get("error"),
233
+ "verdict": sc.get("overall_status"),
234
+ "generated_at": sc.get("generated_at"),
235
+ "counts": sc.get("counts"),
236
+ "train_rows": fp.get("train_rows"),
237
+ "test_rows": fp.get("test_rows"),
238
+ "feature_count": fp.get("feature_count"),
239
+ # Each check's final status (after dedup when it ran), for the
240
+ # dashboard's cross-run views without fetching every scorecard.
241
+ "check_status": {c["check"]: (c.get("after") or c.get("before") or {}).get("status")
242
+ for c in sc.get("checks", []) if "check" in c},
243
+ "files": sorted(p.name for p in run_dir.iterdir() if p.is_file()),
244
+ }
245
+
246
+
247
+ def _benchmark_rows(run_dir: Path) -> list[dict]:
248
+ path = run_dir / "benchmark_results.csv"
249
+ if not path.exists():
250
+ return []
251
+ with path.open(newline="") as f:
252
+ return list(csv.DictReader(f))
253
+
254
+
255
+ class Handler(BaseHTTPRequestHandler):
256
+ app: App
257
+ allowed_hosts: set[str]
258
+
259
+ def log_message(self, fmt, *args):
260
+ logger.debug("%s - %s", self.address_string(), fmt % args)
261
+
262
+ def _host_ok(self) -> bool:
263
+ return (self.headers.get("Host") or "") in self.allowed_hosts
264
+
265
+ def _send(self, status: int, body: bytes, content_type: str, extra: dict | None = None) -> None:
266
+ self.send_response(status)
267
+ self.send_header("Content-Type", content_type)
268
+ self.send_header("Content-Length", str(len(body)))
269
+ self.send_header("X-Content-Type-Options", "nosniff")
270
+ self.send_header("Cache-Control", "no-store")
271
+ for k, v in (extra or {}).items():
272
+ self.send_header(k, v)
273
+ self.end_headers()
274
+ self.wfile.write(body)
275
+
276
+ def _json(self, payload, status: int = HTTPStatus.OK) -> None:
277
+ self._send(status, json.dumps(payload).encode(), "application/json")
278
+
279
+ def _error(self, status: int, message: str) -> None:
280
+ self._json({"error": message}, status)
281
+
282
+ def do_GET(self):
283
+ if not self._host_ok():
284
+ return self._error(HTTPStatus.FORBIDDEN, "bad Host header")
285
+ path = unquote(urlsplit(self.path).path)
286
+ if path == "/" or path.startswith("/static/"):
287
+ return self._static("index.html" if path == "/" else path.removeprefix("/static/"))
288
+ if path == "/api/runs":
289
+ return self._json(self.app.list_runs())
290
+ if path == "/api/job":
291
+ return self._json(self.app.job.snapshot())
292
+ if path == "/api/starter-config":
293
+ return self._json({"yaml": self.app.starter_config})
294
+ if path == "/api/check-info":
295
+ # The same "what it checks" / "rule" text SCORECARD.html shows on hover.
296
+ return self._json({k: {"what": what, "rule": rule}
297
+ for k, (what, rule) in scorecard.CHECK_INFO.items()})
298
+
299
+ parts = path.strip("/").split("/")
300
+ # /api/run/<dir_idx>/<run_name> - one run's detail
301
+ if len(parts) == 4 and parts[:2] == ["api", "run"]:
302
+ run_dir = self.app.run_dir(parts[2], parts[3])
303
+ if run_dir is None:
304
+ return self._error(HTTPStatus.NOT_FOUND, "no such run")
305
+ summary = _run_summary(int(parts[2]), self.app.output_dirs[int(parts[2])], run_dir)
306
+ return self._json({**summary, "scorecard": _read_scorecard(run_dir),
307
+ "audit_report": _read_json(run_dir / "audit_report_after.json")
308
+ or _read_json(run_dir / "audit_report_before.json"),
309
+ # dedup_check's per-class rates only exist before dedup
310
+ "audit_report_before": _read_json(run_dir / "audit_report_before.json"),
311
+ "environment": _read_json(run_dir / "environment.json"),
312
+ "benchmark": _benchmark_rows(run_dir)})
313
+ # /api/run/<dir_idx>/<run_name>/citation - the same BibTeX `ids2eval cite` prints
314
+ if len(parts) == 5 and parts[:2] == ["api", "run"] and parts[4] == "citation":
315
+ run_dir = self.app.run_dir(parts[2], parts[3])
316
+ if run_dir is None or not (run_dir / "scorecard.json").is_file():
317
+ return self._error(HTTPStatus.NOT_FOUND, "no scorecard.json for this run")
318
+ try:
319
+ bibtex = cite.citation_bibtex(cite.load_scorecard(run_dir))
320
+ except (OSError, ValueError, KeyError) as e:
321
+ return self._error(HTTPStatus.INTERNAL_SERVER_ERROR, str(e))
322
+ return self._json({"bibtex": bibtex})
323
+ # /api/run/<dir_idx>/<run_name>/recommendations - structural fix suggestions
324
+ if len(parts) == 5 and parts[:2] == ["api", "run"] and parts[4] == "recommendations":
325
+ run_dir = self.app.run_dir(parts[2], parts[3])
326
+ if run_dir is None or not (run_dir / "resolved_config.json").is_file():
327
+ return self._error(HTTPStatus.NOT_FOUND, "no resolved_config.json for this run")
328
+ try:
329
+ recs, _ = recommend.recommendations_for_run(run_dir)
330
+ except (OSError, ValueError, KeyError) as e:
331
+ return self._error(HTTPStatus.INTERNAL_SERVER_ERROR, str(e))
332
+ return self._json({"recommendations": recs})
333
+ # /files/<dir_idx>/<run_name>/<file> - a run artifact, top level of the run dir only
334
+ if len(parts) == 4 and parts[0] == "files":
335
+ run_dir = self.app.run_dir(parts[1], parts[2])
336
+ target = run_dir / parts[3] if run_dir else None
337
+ if target is None or "/" in parts[3] or parts[3].startswith(".") or not target.is_file():
338
+ return self._error(HTTPStatus.NOT_FOUND, "no such file")
339
+ ctype = _CONTENT_TYPES.get(target.suffix.lower(), "application/octet-stream")
340
+ extra = {}
341
+ if target.suffix.lower() == ".html":
342
+ # Only the scorecard's own inline styles - no scripts, nothing external.
343
+ extra["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; img-src 'self'"
344
+ elif ctype == "application/octet-stream":
345
+ extra["Content-Disposition"] = f'attachment; filename="{target.name}"'
346
+ return self._send(HTTPStatus.OK, target.read_bytes(), ctype, extra)
347
+ return self._error(HTTPStatus.NOT_FOUND, "not found")
348
+
349
+ def _static(self, name: str) -> None:
350
+ # Only files directly inside static/ - no subdirectories, no dotfiles, no "..".
351
+ target = STATIC_DIR / name
352
+ if "/" in name or name.startswith(".") or not target.is_file():
353
+ return self._error(HTTPStatus.NOT_FOUND, "not found")
354
+ ctype = _CONTENT_TYPES.get(target.suffix.lower(), "application/octet-stream")
355
+ extra = {}
356
+ if target.suffix == ".html":
357
+ # Everything is a same-origin file now, so no inline script/style is allowed at all.
358
+ extra["Content-Security-Policy"] = "default-src 'self'; frame-ancestors 'none'"
359
+ return self._send(HTTPStatus.OK, target.read_bytes(), ctype, extra)
360
+
361
+ def do_POST(self):
362
+ if not self._host_ok():
363
+ return self._error(HTTPStatus.FORBIDDEN, "bad Host header")
364
+ origin = self.headers.get("Origin")
365
+ if origin is not None and urlsplit(origin).netloc not in self.allowed_hosts:
366
+ return self._error(HTTPStatus.FORBIDDEN, "cross-origin request refused")
367
+ # A JSON content type can't be sent cross-site without a CORS preflight, which this server never approves.
368
+ if not (self.headers.get("Content-Type") or "").startswith("application/json"):
369
+ return self._error(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "expected application/json")
370
+ length = int(self.headers.get("Content-Length") or 0)
371
+ if length > _MAX_BODY:
372
+ return self._error(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "body too large")
373
+ try:
374
+ body = json.loads(self.rfile.read(length) or b"{}")
375
+ except ValueError:
376
+ return self._error(HTTPStatus.BAD_REQUEST, "invalid JSON")
377
+
378
+ path = urlsplit(self.path).path
379
+ if path == "/api/validate":
380
+ cfg, err = _validate(body.get("yaml", ""))
381
+ return self._json({"ok": err is None, "error": err,
382
+ "output_dir": cfg["output"]["dir"] if cfg else None})
383
+ if path == "/api/run":
384
+ return self._start_run(body)
385
+ if path == "/api/compare-datasets":
386
+ return self._compare_datasets(body)
387
+ if path == "/api/job/stop":
388
+ job = self.app.job
389
+ with job.lock:
390
+ if job.state == "running" and job.proc is not None:
391
+ job.proc.terminate()
392
+ job.log.append("[stopped from the web UI]")
393
+ return self._json(job.snapshot())
394
+
395
+ parts = path.strip("/").split("/")
396
+ # /api/run/<dir_idx>/<run_name>/apply-recommendations - merge selected
397
+ # patches onto the run's resolved config, return the result as YAML
398
+ # ready to prefill the New run editor.
399
+ if len(parts) == 5 and parts[:2] == ["api", "run"] and parts[4] == "apply-recommendations":
400
+ run_dir = self.app.run_dir(parts[2], parts[3])
401
+ if run_dir is None or not (run_dir / "resolved_config.json").is_file():
402
+ return self._error(HTTPStatus.NOT_FOUND, "no resolved_config.json for this run")
403
+ try:
404
+ recs, cfg = recommend.recommendations_for_run(run_dir)
405
+ patched = recommend.apply_recommendations(cfg, recs, body.get("ids") or [])
406
+ except (OSError, ValueError, KeyError) as e:
407
+ return self._error(HTTPStatus.INTERNAL_SERVER_ERROR, str(e))
408
+ return self._json({"yaml": yaml.safe_dump(patched, sort_keys=False)})
409
+ return self._error(HTTPStatus.NOT_FOUND, "not found")
410
+
411
+ def _compare_datasets(self, body: dict) -> None:
412
+ """The same comparison `ids2eval compare-datasets` runs, over two pasted
413
+ configs rather than two file paths - loads both datasets fresh, so
414
+ it's exactly as heavy as the CLI command, just without a subprocess.
415
+ """
416
+ yaml_a, yaml_b = body.get("yaml_a", ""), body.get("yaml_b", "")
417
+ if not yaml_a.strip() or not yaml_b.strip():
418
+ return self._error(HTTPStatus.BAD_REQUEST, "both configs are required")
419
+ with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as fa, \
420
+ tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as fb:
421
+ fa.write(yaml_a)
422
+ fb.write(yaml_b)
423
+ try:
424
+ result = compare_datasets.compare(fa.name, fb.name)
425
+ return self._json({"result": result, "report": compare_datasets.render_report(result)})
426
+ except Exception as e: # bad YAML, missing files, schema errors - report, never crash the server
427
+ return self._error(HTTPStatus.BAD_REQUEST, f"{type(e).__name__}: {e}")
428
+ finally:
429
+ Path(fa.name).unlink(missing_ok=True)
430
+ Path(fb.name).unlink(missing_ok=True)
431
+
432
+ def _start_run(self, body: dict) -> None:
433
+ job = self.app.job
434
+ with job.lock:
435
+ if job.state == "running":
436
+ return self._error(HTTPStatus.CONFLICT, "a run is already in progress")
437
+ cfg, err = _validate(body.get("yaml", ""))
438
+ if err:
439
+ return self._error(HTTPStatus.BAD_REQUEST, err)
440
+ fd, name = tempfile.mkstemp(prefix="ids2eval-ui-", suffix=".yaml")
441
+ with open(fd, "w") as f:
442
+ f.write(body["yaml"])
443
+ output_dir = Path(cfg["output"]["dir"])
444
+ self.app.add_output_dir(output_dir)
445
+ job.start(Path(name), output_dir, bool(body.get("skip_audit")), bool(body.get("skip_benchmark")))
446
+ return self._json(job.snapshot())
447
+
448
+
449
+ def _validate(yaml_text: str) -> tuple[dict | None, str | None]:
450
+ """The same load_config() the CLI runs, so a config the UI accepts is
451
+ one the CLI will too - errors surface before a subprocess is spawned.
452
+ """
453
+ if not yaml_text.strip():
454
+ return None, "config is empty"
455
+ with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as f:
456
+ f.write(yaml_text)
457
+ try:
458
+ return load_config(f.name), None
459
+ except Exception as e: # yaml errors, validation ValueErrors, bad shapes - all reported, never crash
460
+ return None, f"{type(e).__name__}: {e}"
461
+ finally:
462
+ Path(f.name).unlink(missing_ok=True)
463
+
464
+
465
+ def make_server(host: str, port: int, app: App) -> ThreadingHTTPServer:
466
+ allowed = {f"{h}:{port}" for h in ("127.0.0.1", "localhost", "[::1]", host)}
467
+ handler = type("BoundHandler", (Handler,), {"app": app, "allowed_hosts": allowed})
468
+ return ThreadingHTTPServer((host, port), handler)
469
+
470
+
471
+ def _resolve_starter(config_path: str | None, output_args: list[str]) -> tuple[str, list[Path]]:
472
+ """--config is entirely optional and never fatal: a missing or invalid path
473
+ falls back to _STARTER_CONFIG with a logged warning rather than crashing the
474
+ dashboard before it can even open. The bare `ids2eval-dashboard` invocation
475
+ (no flags at all) is the intended first-run path, not a degraded one.
476
+ """
477
+ starter = _STARTER_CONFIG
478
+ output_dirs = [Path(p) for p in output_args]
479
+ if config_path:
480
+ try:
481
+ starter = Path(config_path).read_text()
482
+ except OSError as e:
483
+ logger.warning("--config %s isn't readable (%s); opening with the starter template instead",
484
+ config_path, e)
485
+ else:
486
+ cfg, err = _validate(starter)
487
+ if cfg:
488
+ output_dirs.append(Path(cfg["output"]["dir"]))
489
+ else:
490
+ logger.warning("--config doesn't validate yet (%s); prefilling it anyway", err)
491
+ if not output_dirs:
492
+ output_dirs = [Path("./output")]
493
+ return starter, output_dirs
494
+
495
+
496
+ def main(argv=None) -> None:
497
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
498
+ parser = argparse.ArgumentParser(description="IDS2Eval dashboard (local web UI)")
499
+ parser.add_argument("--output", action="append", default=[],
500
+ help="An output.dir whose runs to list (repeatable; default ./output)")
501
+ parser.add_argument("--config", help="Optional: a YAML config to prefill the New run editor with")
502
+ parser.add_argument("--host", default="127.0.0.1", help="Interface to bind (default 127.0.0.1 - keep it local)")
503
+ parser.add_argument("--port", type=int, default=8765)
504
+ parser.add_argument("--no-browser", action="store_true", help="Don't open a browser tab")
505
+ args = parser.parse_args(argv)
506
+
507
+ starter, output_dirs = _resolve_starter(args.config, args.output)
508
+
509
+ if args.host not in ("127.0.0.1", "localhost", "::1"):
510
+ logger.warning("Binding to %s: anyone who can reach this port can run configs on this machine", args.host)
511
+
512
+ app = App(output_dirs, starter)
513
+ server = make_server(args.host, args.port, app)
514
+ url = f"http://{'localhost' if args.host in ('127.0.0.1', '::1') else args.host}:{args.port}/"
515
+ logger.info("IDS2Eval dashboard at %s (listing runs in: %s)", url, ", ".join(map(str, app.output_dirs)))
516
+ if not args.no_browser:
517
+ webbrowser.open(url)
518
+ try:
519
+ server.serve_forever()
520
+ except KeyboardInterrupt:
521
+ pass
522
+ finally:
523
+ server.server_close()
524
+
525
+
526
+ if __name__ == "__main__":
527
+ main()