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/compare.py ADDED
@@ -0,0 +1,375 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 hashtagRR
3
+ """Compare finished experiments side by side.
4
+
5
+ An experiment is a run folder ``<out>/<name>`` holding ``report.json`` and its run units. The
6
+ comparison lists each experiment's headline figures from its report, and, for experiments that ran
7
+ on the same data with the same splits, a paired comparison against the first experiment: per unit,
8
+ the TEST objective of one cascade minus the other on the very same flows, with a paired bootstrap
9
+ interval. Only units the report included (fresh fingerprints) take part.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import glob
15
+ import json
16
+ import os
17
+ from html import escape
18
+ from typing import Any
19
+
20
+ import numpy as np
21
+ import pandas as pd
22
+
23
+ from idslab.metrics import paired_bootstrap
24
+ from idslab.report import unit
25
+
26
+ STAGE_SHOWN = 6
27
+
28
+
29
+ class CompareError(ValueError):
30
+ """An experiment folder that cannot be compared (missing report, no units)."""
31
+
32
+
33
+ def _read(path: str) -> Any:
34
+ with open(path, encoding="utf-8") as f:
35
+ return json.load(f)
36
+
37
+
38
+ def load_experiment(path: str) -> dict:
39
+ """An experiment's report and its included units, each with its folder and fingerprints."""
40
+ path = os.path.normpath(path)
41
+ report_path = os.path.join(path, "report.json")
42
+ if not os.path.isfile(report_path):
43
+ raise CompareError(f"{path}: no report.json (run the experiment or `idslab report` first)")
44
+ rep = _read(report_path)
45
+ wanted = set(rep.get("units") or [])
46
+ units = {}
47
+ pattern = os.path.join(path, rep["mode"], "seed_*", "**", "summary.json")
48
+ for summary_path in sorted(glob.glob(pattern, recursive=True)):
49
+ d = os.path.dirname(summary_path)
50
+ done = os.path.join(d, "DONE")
51
+ if not os.path.isfile(done):
52
+ continue
53
+ s = _read(summary_path)
54
+ label = unit(s)
55
+ if label in wanted:
56
+ units[label] = {"dir": d, "fingerprints": _read(done).get("fingerprints", {})}
57
+ if not units:
58
+ raise CompareError(f"{path}: the report lists no units that are still on disk")
59
+ return {"path": path, "report": rep, "units": units}
60
+
61
+
62
+ def _mean(d: dict | None) -> float | None:
63
+ return None if not d else d.get("mean")
64
+
65
+
66
+ def _headline(e: dict) -> dict:
67
+ rep = e["report"]
68
+ goal = rep["objective"]
69
+ st = rep["systems_test"]
70
+ frozen = rep.get("frozen_per_seed") or {}
71
+ stages = {}
72
+ for st_name in rep.get("stage_removal", {}) | {s: None for f in frozen.values() for s in f}:
73
+ on = sum(1 for f in frozen.values() if st_name in f and not f[st_name].get("off", True))
74
+ stages[st_name] = {
75
+ "on": on,
76
+ "units": len(frozen),
77
+ "removal": rep.get("stage_removal", {}).get(st_name),
78
+ }
79
+ sc = rep.get("stage_cost")
80
+ return {
81
+ "path": e["path"],
82
+ "name": rep["config_name"],
83
+ "mode": rep["mode"],
84
+ "objective": goal,
85
+ "idslab_version": rep.get("idslab_version"),
86
+ "units": sorted(e["units"]),
87
+ "cascade": st["cascade"].get(goal),
88
+ "first_stage": st["B1_first_stage"].get(goal),
89
+ "minus_first": (rep.get("cascade_minus_control", {}).get("B1_first_stage") or {}).get("diff"),
90
+ "frontier_gap": (rep.get("frontier_gap") or {}).get("interp"),
91
+ "stages": stages,
92
+ "expected_cost": None if not sc else sc.get("cascade_cost"),
93
+ "first_stage_cost": None if not sc else sc.get("first_stage_cost"),
94
+ }
95
+
96
+
97
+ def _pair(base: dict, other: dict, goal: str, *, bootstrap: int) -> dict:
98
+ """Per unit, other's cascade minus base's cascade on the same TEST flows."""
99
+ common = sorted(set(base["units"]) & set(other["units"]))
100
+ rows, skipped = [], []
101
+ for label in common:
102
+ fa, fb = base["units"][label]["fingerprints"], other["units"][label]["fingerprints"]
103
+ if fa.get("split") != fb.get("split") or fa.get("data") != fb.get("data"):
104
+ skipped.append(f"{label}: different data or splits")
105
+ continue
106
+ pa = pd.read_parquet(os.path.join(base["units"][label]["dir"], "per_flow.parquet"))
107
+ pb = pd.read_parquet(os.path.join(other["units"][label]["dir"], "per_flow.parquet"))
108
+ merged = pa[["row_id", "y", "pred_cascade"]].merge(
109
+ pb[["row_id", "pred_cascade"]], on="row_id", suffixes=("_base", "_other")
110
+ )
111
+ if len(merged) != len(pa) or len(merged) != len(pb):
112
+ skipped.append(f"{label}: the TEST flows differ")
113
+ continue
114
+ seed = int(label.split("/")[0])
115
+ res = paired_bootstrap(
116
+ merged["y"].to_numpy(),
117
+ merged["pred_cascade_other"].to_numpy(),
118
+ merged["pred_cascade_base"].to_numpy(),
119
+ n=bootstrap,
120
+ seed=seed,
121
+ names=(goal,),
122
+ )[goal]
123
+ rows.append({"unit": label, "diff": res["diff"], "ci95": res["ci95"]})
124
+ diffs = [r["diff"] for r in rows]
125
+ return {
126
+ "base": base["report"]["config_name"],
127
+ "other": other["report"]["config_name"],
128
+ "objective": goal,
129
+ "per_unit": rows,
130
+ "mean_diff": float(np.mean(diffs)) if diffs else None,
131
+ "units_ci_above_zero": sum(1 for r in rows if r["ci95"] and r["ci95"][0] > 0),
132
+ "units_ci_below_zero": sum(1 for r in rows if r["ci95"] and r["ci95"][1] < 0),
133
+ "skipped": skipped
134
+ + [f"{u}: only in one experiment" for u in sorted(set(base["units"]) ^ set(other["units"]))],
135
+ }
136
+
137
+
138
+ def compare(paths: list[str], *, bootstrap: int = 1000, names: dict[str, str] | None = None) -> dict:
139
+ """Compare experiments; the first one is the baseline of the paired comparisons."""
140
+ if len(paths) < 2:
141
+ raise CompareError("give at least two experiments to compare")
142
+ exps = [load_experiment(p) for p in paths]
143
+ heads = [_headline(e) for e in exps]
144
+ if names:
145
+ for h in heads:
146
+ h["label"] = names.get(h["path"], h["name"])
147
+ warnings = []
148
+ goals = {h["objective"] for h in heads}
149
+ if len(goals) > 1:
150
+ warnings.append(
151
+ f"the experiments use different objectives ({', '.join(sorted(goals))}); "
152
+ "paired differences are shown only for experiments with the baseline's objective"
153
+ )
154
+ modes = {h["mode"] for h in heads}
155
+ if len(modes) > 1:
156
+ warnings.append(f"the experiments use different modes ({', '.join(sorted(modes))})")
157
+ datas = {
158
+ json.dumps(next(iter(e["units"].values()))["fingerprints"].get("data"), sort_keys=True) for e in exps
159
+ }
160
+ if len(datas) > 1:
161
+ warnings.append("the experiments ran on different data files; their numbers are not comparable")
162
+ base = exps[0]
163
+ goal = heads[0]["objective"]
164
+ pairs = [
165
+ _pair(base, e, goal, bootstrap=bootstrap)
166
+ for e, h in zip(exps[1:], heads[1:], strict=True)
167
+ if h["objective"] == goal
168
+ ]
169
+ return {
170
+ "baseline": heads[0]["name"],
171
+ "objective": goal,
172
+ "experiments": heads,
173
+ "paired": pairs,
174
+ "warnings": warnings,
175
+ "bootstrap": bootstrap,
176
+ }
177
+
178
+
179
+ # ---------- output ----------
180
+
181
+
182
+ def _f(d: Any, digits: int = 4, signed: bool = False) -> str:
183
+ v = d.get("mean") if isinstance(d, dict) else d
184
+ if v is None:
185
+ return "n/a"
186
+ s = f"{v:+.{digits}f}" if signed else f"{v:.{digits}f}"
187
+ sd = d.get("sd") if isinstance(d, dict) else None
188
+ return s + (f" ± {sd:.{digits}f}" if sd is not None else "")
189
+
190
+
191
+ def _label(h: dict) -> str:
192
+ return h.get("label") or h["name"]
193
+
194
+
195
+ def to_markdown(c: dict) -> str:
196
+ goal = c["objective"]
197
+ lines = [
198
+ "# IDSLab comparison",
199
+ "",
200
+ f"Baseline: `{c['baseline']}`. TEST {goal}, mean ± sd over units.",
201
+ "",
202
+ ]
203
+ lines += [f"- warning: {w}" for w in c["warnings"]]
204
+ if c["warnings"]:
205
+ lines.append("")
206
+ lines += [
207
+ "## Experiments",
208
+ "",
209
+ "| experiment | units | cascade | first stage alone | cascade minus first stage | frontier gap | expected cost per flow |",
210
+ "|---|---|---|---|---|---|---|",
211
+ ]
212
+ for h in c["experiments"]:
213
+ lines.append(
214
+ f"| {_label(h)} | {len(h['units'])} | {_f(h['cascade'])} | {_f(h['first_stage'])} | "
215
+ f"{_f(h['minus_first'], signed=True)} | {_f(h['frontier_gap'], signed=True)} | {_f(h['expected_cost'], 3)} |"
216
+ )
217
+ lines += ["", "## Stages", "", "| experiment | stage | kept on | lost if removed |", "|---|---|---|---|"]
218
+ for h in c["experiments"]:
219
+ for st, r in list(h["stages"].items())[:STAGE_SHOWN]:
220
+ lines.append(
221
+ f"| {_label(h)} | {st} | {r['on']} of {r['units']} | {_f(r['removal'], 3, signed=True)} |"
222
+ )
223
+ if c["paired"]:
224
+ lines += [
225
+ "",
226
+ f"## Paired against {c['baseline']}",
227
+ "",
228
+ (
229
+ "Same data and splits, so each unit compares the two cascades on the same TEST flows: "
230
+ "experiment minus baseline, with a paired bootstrap 95% interval."
231
+ ),
232
+ "",
233
+ "| experiment | mean difference | units with CI above 0 | units with CI below 0 | per unit |",
234
+ "|---|---|---|---|---|",
235
+ ]
236
+ for p in c["paired"]:
237
+ per = "; ".join(
238
+ f"{r['unit']}: {r['diff']:+.4f}"
239
+ + (f" [{r['ci95'][0]:+.4f}, {r['ci95'][1]:+.4f}]" if r["ci95"] else "")
240
+ for r in p["per_unit"]
241
+ )
242
+ n = len(p["per_unit"])
243
+ lines.append(
244
+ f"| {p['other']} | {_f(p['mean_diff'], signed=True)} | {p['units_ci_above_zero']}/{n} | "
245
+ f"{p['units_ci_below_zero']}/{n} | {per or 'none'} |"
246
+ )
247
+ lines += [f"| | skipped: {s} | | | |" for s in p["skipped"]]
248
+ return "\n".join(lines) + "\n"
249
+
250
+
251
+ CSS = """
252
+ :root{--bg:#f6f8fa;--panel:#fff;--ink:#1f2328;--muted:#59636e;--line:#d0d7de;--accent:#0969da;--good:#1a7f37;--bad:#cf222e}
253
+ @media (prefers-color-scheme:dark){:root{--bg:#0d1117;--panel:#161b22;--ink:#e6edf3;--muted:#9198a1;--line:#30363d;--accent:#4493f8;--good:#3fb950;--bad:#f85149}}
254
+ body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 -apple-system,"Segoe UI",Helvetica,Arial,sans-serif}
255
+ main{max-width:1180px;margin:0 auto;padding:32px 20px}
256
+ h1{margin:0 0 6px;font-size:28px}h2{margin:34px 0 10px;font-size:19px}
257
+ .muted{color:var(--muted)}
258
+ .warn{border:1px solid #d4a72c;background:rgba(212,167,44,.1);border-radius:8px;padding:8px 12px;margin:8px 0}
259
+ .scroll{overflow-x:auto}
260
+ table{border-collapse:collapse;width:100%;background:var(--panel);border:1px solid var(--line);border-radius:8px}
261
+ th,td{padding:8px 10px;border-bottom:1px solid var(--line);text-align:left;font-size:14px}
262
+ th{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
263
+ td.n{text-align:right;font-family:ui-monospace,Consolas,monospace;white-space:nowrap}
264
+ .pos{color:var(--good)}.neg{color:var(--bad)}
265
+ .bar{fill:var(--accent)}.bar.first{fill:var(--muted)}.axis{stroke:var(--line)}
266
+ svg text{fill:var(--muted);font-size:12px;font-family:inherit}
267
+ """
268
+
269
+
270
+ def _cls(v: float | None) -> str:
271
+ return "" if v is None else "pos" if v > 0 else "neg" if v < 0 else ""
272
+
273
+
274
+ def _chart(c: dict) -> str:
275
+ """Cascade and first stage alone per experiment, as horizontal bars."""
276
+ heads = c["experiments"]
277
+ vals = [v for h in heads for v in (_mean(h["cascade"]), _mean(h["first_stage"])) if v is not None]
278
+ if not vals:
279
+ return ""
280
+ lo, hi = min(vals), max(vals)
281
+ pad = (hi - lo) * 0.25 or 0.01
282
+ lo, hi = lo - pad, hi + pad
283
+ W, row = 760, 46
284
+ H = 20 + row * len(heads)
285
+ x0, x1 = 230, W - 90
286
+ parts = [
287
+ f'<svg viewBox="0 0 {W} {H}" width="100%" role="img" aria-label="Cascade and first stage alone per experiment">'
288
+ ]
289
+ for i, h in enumerate(heads):
290
+ y = 10 + i * row
291
+ parts.append(f'<text x="0" y="{y + 20}">{escape(_label(h)[:30])}</text>')
292
+ for j, (key, cls) in enumerate((("cascade", "bar"), ("first_stage", "bar first"))):
293
+ v = _mean(h[key])
294
+ if v is None:
295
+ continue
296
+ w = (v - lo) / (hi - lo) * (x1 - x0)
297
+ parts.append(
298
+ f'<rect class="{cls}" x="{x0}" y="{y + j * 16}" width="{max(w, 1):.1f}" height="13" rx="3"/>'
299
+ )
300
+ parts.append(
301
+ f'<text x="{x0 + w + 6:.1f}" y="{y + j * 16 + 11}">{v:.4f}{" (first stage)" if j else ""}</text>'
302
+ )
303
+ parts.append(f'<line class="axis" x1="{x0}" x2="{x0}" y1="4" y2="{H - 4}"/></svg>')
304
+ return "".join(parts)
305
+
306
+
307
+ def render_html(c: dict) -> str:
308
+ goal = escape(c["objective"])
309
+ warn = "".join(f'<div class="warn">{escape(w)}</div>' for w in c["warnings"])
310
+ rows = "".join(
311
+ f"<tr><td>{escape(_label(h))}</td><td class=n>{len(h['units'])}</td><td class=n>{escape(_f(h['cascade']))}</td>"
312
+ f"<td class=n>{escape(_f(h['first_stage']))}</td>"
313
+ f"<td class='n {_cls(_mean(h['minus_first']))}'>{escape(_f(h['minus_first'], signed=True))}</td>"
314
+ f"<td class='n {_cls(_mean(h['frontier_gap']))}'>{escape(_f(h['frontier_gap'], signed=True))}</td>"
315
+ f"<td class=n>{escape(_f(h['expected_cost'], 3))}</td></tr>"
316
+ for h in c["experiments"]
317
+ )
318
+ stage_rows = "".join(
319
+ f"<tr><td>{escape(_label(h))}</td><td>{escape(st)}</td><td class=n>{r['on']} of {r['units']}</td>"
320
+ f"<td class='n {_cls(_mean(r['removal']))}'>{escape(_f(r['removal'], 3, signed=True))}</td></tr>"
321
+ for h in c["experiments"]
322
+ for st, r in list(h["stages"].items())[:STAGE_SHOWN]
323
+ )
324
+ paired = ""
325
+ if c["paired"]:
326
+ prow = []
327
+ for p in c["paired"]:
328
+ n = len(p["per_unit"])
329
+ per = (
330
+ "<br>".join(
331
+ escape(
332
+ f"{r['unit']}: {r['diff']:+.4f}"
333
+ + (f" [{r['ci95'][0]:+.4f}, {r['ci95'][1]:+.4f}]" if r["ci95"] else "")
334
+ )
335
+ for r in p["per_unit"]
336
+ )
337
+ or "none"
338
+ )
339
+ skipped = "".join(f'<div class="muted">skipped {escape(s)}</div>' for s in p["skipped"])
340
+ prow.append(
341
+ f"<tr><td>{escape(p['other'])}</td><td class='n {_cls(p['mean_diff'])}'>{escape(_f(p['mean_diff'], signed=True))}</td>"
342
+ f"<td class=n>{p['units_ci_above_zero']}/{n}</td><td class=n>{p['units_ci_below_zero']}/{n}</td>"
343
+ f"<td class=n>{per}{skipped}</td></tr>"
344
+ )
345
+ paired = (
346
+ f"<h2>Paired against {escape(c['baseline'])}</h2><p class=muted>Same data and splits, so each unit "
347
+ "compares the two cascades on the same TEST flows: experiment minus baseline, with a paired bootstrap "
348
+ "95% interval.</p><div class=scroll><table><tr><th>experiment</th><th>mean difference</th>"
349
+ f"<th>CI above 0</th><th>CI below 0</th><th>per unit</th></tr>{''.join(prow)}</table></div>"
350
+ )
351
+ return f"""<!doctype html>
352
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
353
+ <title>IDSLab comparison</title><style>{CSS}</style></head><body><main>
354
+ <h1>IDSLab comparison</h1><p class=muted>Baseline {escape(c["baseline"])}. TEST {goal}, mean ± sd over units.</p>
355
+ {warn}
356
+ <h2>Cascade and first stage alone</h2>{_chart(c)}
357
+ <div class=scroll><table><tr><th>experiment</th><th>units</th><th>cascade</th><th>first stage alone</th>
358
+ <th>cascade minus first stage</th><th>frontier gap</th><th>expected cost per flow</th></tr>{rows}</table></div>
359
+ {paired}
360
+ <h2>Stages</h2><div class=scroll><table><tr><th>experiment</th><th>stage</th><th>kept on</th><th>lost if removed</th></tr>{stage_rows}</table></div>
361
+ </main></body></html>
362
+ """
363
+
364
+
365
+ def write_comparison(c: dict, out_dir: str) -> tuple[str, str, str]:
366
+ """Write compare.md, compare.json and compare.html into ``out_dir``."""
367
+ os.makedirs(out_dir, exist_ok=True)
368
+ md, js, html = (os.path.join(out_dir, f"compare.{ext}") for ext in ("md", "json", "html"))
369
+ with open(md, "w", encoding="utf-8") as f:
370
+ f.write(to_markdown(c))
371
+ with open(js, "w", encoding="utf-8") as f:
372
+ json.dump(c, f, indent=2, default=str)
373
+ with open(html, "w", encoding="utf-8") as f:
374
+ f.write(render_html(c))
375
+ return md, js, html