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,1064 @@
1
+ "use strict";
2
+ // IDS2Eval dashboard - a hash-routed single page over the JSON API in server.py.
3
+ // DOM is built with el() and textContent only (never innerHTML), and
4
+ // dynamic sizes go through element.style (CSSOM), which the page's CSP allows.
5
+
6
+ const $ = (s) => document.querySelector(s);
7
+ function el(tag, attrs = {}, ...kids) {
8
+ const n = document.createElement(tag);
9
+ setAttrs(n, attrs);
10
+ for (const k of kids.flat()) if (k != null && k !== false) n.append(k);
11
+ return n;
12
+ }
13
+ function setAttrs(n, attrs) {
14
+ for (const [k, v] of Object.entries(attrs)) {
15
+ if (v == null || v === false) continue;
16
+ if (k === "class") n.setAttribute("class", v);
17
+ else if (k === "text") n.textContent = v;
18
+ else if (k === "style") Object.assign(n.style, v);
19
+ else if (k.startsWith("on")) n.addEventListener(k.slice(2), v);
20
+ else n.setAttribute(k, v === true ? "" : v);
21
+ }
22
+ }
23
+ async function api(path, body) {
24
+ const opts = body === undefined ? {} : {method: "POST", headers: {"Content-Type": "application/json"},
25
+ body: JSON.stringify(body)};
26
+ const r = await fetch(path, opts);
27
+ const data = await r.json().catch(() => ({}));
28
+ if (!r.ok) throw new Error(data.error || r.statusText);
29
+ return data;
30
+ }
31
+ const store = {get(k) { try { return localStorage.getItem(k); } catch { return null; } },
32
+ set(k, v) { try { v == null ? localStorage.removeItem(k) : localStorage.setItem(k, v); } catch {} }};
33
+
34
+ // ---------- vocabulary ----------
35
+
36
+ const STATUS = {ok: {label: "OK", icon: "✓"}, warning: {label: "Warning", icon: "!"}, flag: {label: "Flag", icon: "✕"},
37
+ not_applicable: {label: "Not applicable", icon: "○"}};
38
+ const VERDICT = {passed: {label: "Passed", status: "ok"},
39
+ passed_with_warnings: {label: "Passed with warnings", status: "warning"},
40
+ review_required: {label: "Review Required", status: "flag"},
41
+ not_evaluated: {label: "Not evaluated", status: "not_applicable"}};
42
+ const RANK = {not_applicable: -1, ok: 0, warning: 1, flag: 2};
43
+ // Display grouping only - the audit itself has no families. A check missing
44
+ // here (a new one) lands in "Other" rather than disappearing.
45
+ const FAMILIES = [
46
+ ["Data integrity", ["data_integrity_check", "schema_fingerprint_check", "low_cardinality_warning",
47
+ "class_distribution_report"]],
48
+ ["Duplicates & labels", ["dedup_check", "label_conflict_check", "near_duplicate_class_check"]],
49
+ ["Shortcut features", ["leakage_screen", "one_rule_check", "feature_auc_ranking_check", "identity_column_flag",
50
+ "port_protocol_shortcut_check"]],
51
+ ["Split & ordering leakage", ["temporal_leakage_check", "temporal_realism_check", "flow_group_leakage_check",
52
+ "row_order_leakage_check", "homogeneity_test"]],
53
+ ["Falsification experiments", ["resplit_falsification", "scenario_holdout_falsification",
54
+ "repeated_seed_falsification_check", "result_robustness_check"]],
55
+ ["Known issues", ["known_issue_lookup"]],
56
+ ];
57
+ const FAMILY_OF = Object.fromEntries(FAMILIES.flatMap(([f, cs]) => cs.map(c => [c, f])));
58
+ const familyOf = (check) => FAMILY_OF[check] || "Other";
59
+ const prettyCheck = (c) => c.replace(/_check$/, "").replace(/_/g, " ").replace(/\bauc\b/g, "AUC")
60
+ .replace(/^./, m => m.toUpperCase());
61
+
62
+ const fmtInt = (n) => n == null || n === "" ? "n/a" : Number(n).toLocaleString();
63
+ function fmtNum(v) {
64
+ if (typeof v !== "number") return String(v);
65
+ if (Number.isInteger(v)) return v.toLocaleString();
66
+ return Math.abs(v) < 1 ? v.toFixed(4) : v.toLocaleString(undefined, {maximumFractionDigits: 3});
67
+ }
68
+ function dur(sec) {
69
+ sec = Math.max(0, Math.round(sec));
70
+ const h = Math.floor(sec / 3600), m = Math.floor(sec / 60) % 60, s = sec % 60;
71
+ return h ? `${h}h ${m}m` : m ? `${m}m ${String(s).padStart(2, "0")}s` : `${s}s`;
72
+ }
73
+ function when(name) { // run dirs are named YYYY-MM-DD_HHMMSS_micro (UTC)
74
+ const m = /^(\d{4}-\d{2}-\d{2})_(\d{2})(\d{2})/.exec(name || "");
75
+ return m ? `${m[1]} ${m[2]}:${m[3]} UTC` : name;
76
+ }
77
+ const runHref = (id, tab, extra) => "#/run/" + id.split("/").map(encodeURIComponent).join("/") +
78
+ (tab ? "/" + tab : "") + (extra ? "/" + encodeURIComponent(extra) : "");
79
+
80
+ function statusBadge(status, text) {
81
+ const s = STATUS[status];
82
+ return el("span", {class: "badge s-" + (s ? status : "none")},
83
+ s ? el("span", {class: "ic", "aria-hidden": "true", text: s.icon}) : null, text || (s ? s.label : "n/a"));
84
+ }
85
+ function dot(status) {
86
+ const s = STATUS[status];
87
+ return el("span", {class: "dot s-" + (s ? status : "none"), "aria-hidden": "true", text: s ? s.icon : "–"});
88
+ }
89
+ function runBadge(r) {
90
+ if (r.verdict && VERDICT[r.verdict]) return statusBadge(VERDICT[r.verdict].status, VERDICT[r.verdict].label);
91
+ if (r.run_status === "failed") return statusBadge("flag", "Run crashed");
92
+ if (r.run_status === "running") return el("span", {class: "badge s-running", text: "Running"});
93
+ return el("span", {class: "badge s-none", text: "No audit"});
94
+ }
95
+ function counts(c) {
96
+ if (!c) return el("span", {class: "hint", text: "n/a"});
97
+ return el("span", {class: "counts",
98
+ title: `${c.flag || 0} flagged, ${c.warning || 0} warnings, ${c.ok || 0} ok, ${c.not_applicable || 0} not applicable`},
99
+ el("span", {class: "c-flag", text: `✕ ${c.flag || 0}`}), el("span", {class: "c-warning", text: `! ${c.warning || 0}`}),
100
+ el("span", {class: "c-ok", text: `✓ ${c.ok || 0}`}),
101
+ ...(c.not_applicable ? [el("span", {class: "c-na", text: `○ ${c.not_applicable}`})] : []));
102
+ }
103
+ const kpi = (label, value, status, note) => el("div", {class: "kpi" + (status ? " s-" + status : "")},
104
+ label, el("b", {text: value}), note ? el("div", {class: "kpi-note", text: note}) : null);
105
+ const card = (title, actions, ...kids) => el("section", {class: "card"},
106
+ title || actions ? el("div", {class: "card-head"}, title ? el("h2", {text: title}) : el("span"), actions) : null,
107
+ ...kids);
108
+ const pageHead = (title, sub, ...actions) => el("div", {class: "page-head"},
109
+ el("div", {}, el("div", {class: "title"}, ...(typeof title === "string" ? [el("h1", {text: title})] : title)), sub ? el("div", {class: "sub"}, sub) : null),
110
+ actions.length ? el("div", {class: "row"}, ...actions) : null);
111
+ const errorBox = (msg) => el("div", {class: "error-box", text: msg});
112
+ const empty = (...kids) => el("div", {class: "empty"}, ...kids);
113
+
114
+ // A sortable table: cols = [{key, label, num, render(row), sort(row)}].
115
+ function dataTable(cols, rows, {onRow, initial} = {}) {
116
+ let sortKey = initial ? initial[0] : null, dir = initial ? initial[1] : -1;
117
+ const tbody = el("tbody");
118
+ const ths = cols.map(c => el("th", {class: (c.num ? "num " : "") + (c.nosort ? "" : "sortable"), scope: "col",
119
+ "aria-sort": "none", text: c.label, onclick: c.nosort ? null : () => {
120
+ dir = sortKey === c.key ? -dir : (c.num ? -1 : 1); sortKey = c.key; draw(); }}));
121
+ function draw() {
122
+ const col = cols.find(c => c.key === sortKey);
123
+ const val = col ? (col.sort || (r => r[col.key])) : null;
124
+ const sorted = col ? [...rows].sort((a, b) => {
125
+ const x = val(a), y = val(b);
126
+ if (x == null || x === "") return 1; if (y == null || y === "") return -1;
127
+ return (typeof x === "number" && typeof y === "number" ? x - y : String(x).localeCompare(String(y))) * dir;
128
+ }) : rows;
129
+ ths.forEach((th, i) => {
130
+ const on = cols[i].key === sortKey;
131
+ th.setAttribute("aria-sort", on ? (dir > 0 ? "ascending" : "descending") : "none");
132
+ th.textContent = cols[i].label + (on ? (dir > 0 ? " ▲" : " ▼") : "");
133
+ });
134
+ tbody.replaceChildren(...sorted.map(r => el("tr", {class: onRow ? "click" : null,
135
+ onclick: onRow ? () => onRow(r) : null},
136
+ ...cols.map(c => el("td", {class: c.num ? "num" : null}, c.render ? c.render(r) : fmtCell(r[c.key]))))));
137
+ }
138
+ draw();
139
+ return el("div", {class: "tablewrap"}, el("table", {}, el("thead", {}, el("tr", {}, ...ths)), tbody));
140
+ }
141
+ const fmtCell = (v) => v == null || v === "" ? "n/a" : typeof v === "number" ? fmtNum(v) : String(v);
142
+
143
+ // ---------- tooltip ----------
144
+
145
+ const tipEl = $("#tip");
146
+ function showTip(evt, title, lines) {
147
+ tipEl.replaceChildren(el("b", {text: title}), ...lines.map(l => el("div", {text: l})));
148
+ tipEl.hidden = false;
149
+ const r = evt.clientX != null && evt.type.startsWith("mouse") ? {x: evt.clientX, y: evt.clientY}
150
+ : (() => { const b = evt.target.getBoundingClientRect(); return {x: b.right, y: b.top}; })();
151
+ const w = tipEl.offsetWidth, h = tipEl.offsetHeight;
152
+ tipEl.style.left = Math.min(r.x + 14, innerWidth - w - 8) + "px";
153
+ tipEl.style.top = Math.max(8, Math.min(r.y + 14, innerHeight - h - 8)) + "px";
154
+ }
155
+ const hideTip = () => { tipEl.hidden = true; };
156
+ function withTip(node, title, lines) {
157
+ node.setAttribute("tabindex", "0");
158
+ node.setAttribute("aria-label", title + ": " + lines.join(", "));
159
+ for (const ev of ["mouseenter", "mousemove", "focus"]) node.addEventListener(ev, e => showTip(e, title, lines));
160
+ for (const ev of ["mouseleave", "blur"]) node.addEventListener(ev, hideTip);
161
+ return node;
162
+ }
163
+
164
+ // ---------- data ----------
165
+
166
+ let runs = null, checkInfo = {}, detailCache = new Map(), pollTimer = null;
167
+ const editor = {text: null};
168
+
169
+ async function loadRuns() {
170
+ try { runs = await api("/api/runs"); } catch { runs = []; }
171
+ detailCache.clear();
172
+ return runs;
173
+ }
174
+ async function runDetail(id) {
175
+ if (!detailCache.has(id)) detailCache.set(id, api("/api/run/" + id));
176
+ try { return await detailCache.get(id); } catch (e) { detailCache.delete(id); throw e; }
177
+ }
178
+ // Newest audited run per dataset name (runs arrive newest first).
179
+ function latestByDataset() {
180
+ const seen = new Map();
181
+ for (const r of runs) if (r.verdict && !seen.has(r.dataset)) seen.set(r.dataset, r);
182
+ return [...seen.values()];
183
+ }
184
+
185
+ // ---------- router ----------
186
+
187
+ function parseHash() {
188
+ const parts = location.hash.replace(/^#\/?/, "").split("/").filter(Boolean).map(decodeURIComponent);
189
+ return {page: parts[0] || "dashboard", parts};
190
+ }
191
+ function setCrumbs(...items) {
192
+ $("#crumbs").replaceChildren(...items.flatMap((it, i) => {
193
+ const last = i === items.length - 1;
194
+ const node = last || !it[1] ? el("span", {class: last ? "cur" : null, text: it[0]}) : el("a", {href: it[1], text: it[0]});
195
+ return i ? [el("span", {"aria-hidden": "true", text: "›"}), node] : [node];
196
+ }));
197
+ }
198
+ async function route() {
199
+ clearTimeout(pollTimer); hideTip();
200
+ $("#sidebar").classList.remove("open");
201
+ const {page, parts} = parseHash();
202
+ const nav = {dashboard: "dashboard", datasets: "datasets", runs: "runs", run: "runs", compare: "compare"}[page];
203
+ document.querySelectorAll("[data-nav]").forEach(a => a.classList.toggle("active", a.dataset.nav === nav));
204
+ const main = $("#main");
205
+ main.replaceChildren(el("div", {class: "loading", text: "Loading…"}));
206
+ if (runs === null) await loadRuns();
207
+ try {
208
+ if (page === "dashboard") renderDashboard(main);
209
+ else if (page === "datasets") renderDatasets(main);
210
+ else if (page === "runs") renderRuns(main);
211
+ else if (page === "run" && parts.length >= 3)
212
+ await renderRun(main, parts[1] + "/" + parts[2], parts[3] || "overview", parts[4]);
213
+ else if (page === "new") await renderNewRun(main);
214
+ else if (page === "compare") renderCompare(main);
215
+ else { setCrumbs(["Not found"]); main.replaceChildren(empty("No such page. ", el("a", {href: "#/", text: "Dashboard"}))); }
216
+ } catch (e) {
217
+ main.replaceChildren(errorBox(e.message));
218
+ }
219
+ document.title = "IDS2Eval · " + ($("#crumbs .cur")?.textContent || "Dashboard");
220
+ }
221
+
222
+ // ---------- dashboard ----------
223
+
224
+ function renderDashboard(main) {
225
+ setCrumbs(["Dashboard"]);
226
+ const latest = latestByDataset();
227
+ const needsReview = latest.filter(r => r.verdict === "review_required").length;
228
+ const flags = latest.reduce((s, r) => s + ((r.counts || {}).flag || 0), 0);
229
+ const warns = latest.reduce((s, r) => s + ((r.counts || {}).warning || 0), 0);
230
+ const kids = [
231
+ pageHead("Dashboard", "Audit runs found in the output directories this dashboard was started with.",
232
+ el("a", {class: "btn primary", href: "#/new", text: "+ New run"})),
233
+ el("div", {class: "kpis"},
234
+ kpi("Datasets audited", String(latest.length)),
235
+ kpi("Runs", String(runs.length)),
236
+ kpi("Review required", `${needsReview} / ${latest.length}`, needsReview ? "flag" : "ok", "latest run per dataset"),
237
+ kpi("Flagged checks", String(flags), flags ? "flag" : "ok", "across latest runs"),
238
+ kpi("Warnings", String(warns), warns ? "warning" : "ok", "across latest runs")),
239
+ ];
240
+ if (!runs.length) {
241
+ kids.push(card(null, null, empty("No runs yet. ", el("a", {class: "btn primary", href: "#/new", text: "Start a run"}))));
242
+ main.replaceChildren(...kids); return;
243
+ }
244
+ const recent = runs.slice(0, 8);
245
+ kids.push(el("div", {class: "grid g2-wide"},
246
+ card("Where audits fail", el("a", {class: "btn", href: "#/datasets", text: "Audit matrix →"}),
247
+ el("p", {class: "hint", text: `Status of each check across the latest run of ${latest.length} dataset` +
248
+ (latest.length === 1 ? "" : "s") + ", most-flagged first."}),
249
+ checkStatusChart(latest)),
250
+ card("Recent runs", el("a", {class: "btn", href: "#/runs", text: "All runs →"}),
251
+ dataTable([
252
+ {key: "dataset", label: "Dataset", render: r => el("b", {text: r.dataset || "(unnamed)"})},
253
+ {key: "verdict", label: "Verdict", nosort: true, render: runBadge},
254
+ {key: "counts", label: "Checks", nosort: true, render: r => counts(r.counts)},
255
+ {key: "name", label: "When", render: r => el("span", {class: "hint", text: when(r.name)})},
256
+ ], recent, {onRow: r => { location.hash = runHref(r.id); }}))));
257
+ main.replaceChildren(...kids);
258
+ }
259
+
260
+ // Horizontal 100%-stacked bars: one per check, flag | warning | ok segments.
261
+ // Plain HTML rows rather than SVG, so labels stay at text size at any width.
262
+ function checkStatusChart(latest) {
263
+ const tally = new Map();
264
+ for (const r of latest) for (const [c, s] of Object.entries(r.check_status || {})) {
265
+ if (!tally.has(c)) tally.set(c, {ok: 0, warning: 0, flag: 0, not_applicable: 0});
266
+ if (s in RANK) tally.get(c)[s]++;
267
+ }
268
+ const rows = [...tally].sort((a, b) => b[1].flag - a[1].flag || b[1].warning - a[1].warning || a[0].localeCompare(b[0]));
269
+ if (!rows.length) return empty("No audited runs yet.");
270
+ const chart = el("div", {class: "hbars", role: "list", "aria-label": "Check status across datasets"},
271
+ ...rows.map(([check, t]) => {
272
+ const total = t.ok + t.warning + t.flag + t.not_applicable || 1;
273
+ return withTip(el("div", {class: "hb-row", role: "listitem"},
274
+ el("span", {class: "hb-label", text: prettyCheck(check)}),
275
+ el("div", {class: "hb-track"}, ...["flag", "warning", "ok", "not_applicable"].filter(s => t[s]).map(s =>
276
+ el("span", {class: "hb-seg s-" + s, style: {flexGrow: String(t[s] / total), flexBasis: "0"}}))),
277
+ el("span", {class: "hb-val", text: t.flag ? `${t.flag} ✕` : ""})),
278
+ prettyCheck(check), [`${t.flag} flagged`, `${t.warning} warning`, `${t.ok} ok`, `${t.not_applicable} not applicable`,
279
+ `of ${t.ok + t.warning + t.flag + t.not_applicable} datasets`]);
280
+ }));
281
+ const table = dataTable([
282
+ {key: "check", label: "Check", render: r => prettyCheck(r.check)},
283
+ {key: "flag", label: "Flag", num: true}, {key: "warning", label: "Warning", num: true}, {key: "ok", label: "OK", num: true},
284
+ {key: "not_applicable", label: "N/A", num: true},
285
+ ], rows.map(([check, t]) => ({check, ...t})));
286
+ return toggleView(el("div", {class: "legend"}, ...["flag", "warning", "ok", "not_applicable"].map(s =>
287
+ el("span", {}, el("i", {class: "sw s-" + s}), STATUS[s].label))), chart, table);
288
+ }
289
+
290
+ // Chart with a "Table" toggle, so no reading depends on color alone.
291
+ function toggleView(legend, chart, table) {
292
+ const box = el("div", {}, chart);
293
+ const btn = el("button", {class: "chip", text: "Table view", "aria-pressed": "false"});
294
+ btn.onclick = () => {
295
+ const on = btn.getAttribute("aria-pressed") !== "true";
296
+ btn.setAttribute("aria-pressed", String(on)); btn.classList.toggle("on", on);
297
+ btn.textContent = on ? "Chart view" : "Table view";
298
+ box.replaceChildren(on ? table : chart);
299
+ };
300
+ return el("div", {class: "stack"}, el("div", {class: "row"}, legend, el("span", {class: "spacer"}), btn), box);
301
+ }
302
+
303
+ // ---------- datasets ----------
304
+
305
+ function renderDatasets(main) {
306
+ setCrumbs(["Datasets"]);
307
+ const latest = latestByDataset();
308
+ const runCount = new Map();
309
+ for (const r of runs) runCount.set(r.dataset, (runCount.get(r.dataset) || 0) + 1);
310
+ const kids = [pageHead("Datasets", "One row per dataset, from its most recent audited run.")];
311
+ if (!latest.length) { kids.push(card(null, null, empty("No audited runs yet."))); main.replaceChildren(...kids); return; }
312
+ kids.push(card(null, null, dataTable([
313
+ {key: "dataset", label: "Dataset", render: r => el("b", {text: r.dataset})},
314
+ {key: "train_rows", label: "Train rows", num: true}, {key: "test_rows", label: "Test rows", num: true},
315
+ {key: "feature_count", label: "Features", num: true},
316
+ {key: "verdict", label: "Verdict", render: runBadge},
317
+ {key: "flags", label: "Checks", sort: r => (r.counts || {}).flag || 0, render: r => counts(r.counts)},
318
+ {key: "runs", label: "Runs", num: true, sort: r => runCount.get(r.dataset), render: r => fmtInt(runCount.get(r.dataset))},
319
+ {key: "name", label: "Last run", render: r => el("span", {class: "hint", text: when(r.name)})},
320
+ ], latest, {onRow: r => { location.hash = runHref(r.id); }})));
321
+ kids.push(card("Audit matrix", null,
322
+ el("p", {class: "hint", text: "Final status of every check (after deduplication, where it ran) for each " +
323
+ "dataset's latest run. Select a cell for that check's evidence."}),
324
+ auditMatrix(latest)));
325
+ main.replaceChildren(...kids);
326
+ }
327
+
328
+ function auditMatrix(latest) {
329
+ const checks = [...new Set(latest.flatMap(r => Object.keys(r.check_status || {})))];
330
+ const order = (c) => { const f = FAMILIES.findIndex(([n]) => n === familyOf(c)); return f < 0 ? 99 : f; };
331
+ checks.sort((a, b) => order(a) - order(b) || a.localeCompare(b));
332
+ const rows = [];
333
+ let fam = null;
334
+ for (const c of checks) {
335
+ if (familyOf(c) !== fam) {
336
+ fam = familyOf(c);
337
+ rows.push(el("tr", {}, el("th", {colspan: String(latest.length + 1), text: fam})));
338
+ }
339
+ rows.push(el("tr", {}, el("td", {class: "check", text: prettyCheck(c)}), ...latest.map(r => {
340
+ const s = (r.check_status || {})[c];
341
+ return el("td", {class: "cell"}, s ? el("a", {href: runHref(r.id, "checks", c),
342
+ title: `${r.dataset} · ${prettyCheck(c)}: ${STATUS[s]?.label || s}`,
343
+ "aria-label": `${r.dataset}, ${prettyCheck(c)}: ${STATUS[s]?.label || s}`}, dot(s)) : el("span", {class: "hint", text: "·"}));
344
+ })));
345
+ }
346
+ return el("div", {class: "tablewrap"}, el("table", {class: "matrix"},
347
+ el("thead", {}, el("tr", {}, el("th", {text: "Check"}), ...latest.map(r => el("th", {class: "ds", scope: "col"},
348
+ el("a", {href: runHref(r.id), text: r.dataset}))))),
349
+ el("tbody", {}, ...rows)));
350
+ }
351
+
352
+ // ---------- runs ----------
353
+
354
+ function renderRuns(main) {
355
+ setCrumbs(["Runs"]);
356
+ const filter = {q: store.get("ids2eval.runs.q") || "", v: "all"};
357
+ const host = el("div");
358
+ const search = el("input", {type: "search", placeholder: "Filter by dataset or run…", value: filter.q,
359
+ "aria-label": "Filter runs", oninput: () => { filter.q = search.value; store.set("ids2eval.runs.q", search.value); draw(); }});
360
+ const opts = [["all", "All"], ["review_required", "Review Required"], ["passed_with_warnings", "Warnings"], ["passed", "Passed"],
361
+ ["not_evaluated", "Not evaluated"], ["crashed", "Crashed"]];
362
+ const chipBox = el("div", {class: "chips"});
363
+ function draw() {
364
+ chipBox.replaceChildren(...opts.map(([k, t]) => el("button", {class: "chip" + (filter.v === k ? " on" : ""), text: t,
365
+ "aria-pressed": String(filter.v === k), onclick: () => { filter.v = k; draw(); }})));
366
+ const q = filter.q.toLowerCase();
367
+ const shown = runs.filter(r => (!q || (r.dataset || "").toLowerCase().includes(q) || r.name.includes(q) ||
368
+ r.output_dir.toLowerCase().includes(q)) &&
369
+ (filter.v === "all" || (filter.v === "crashed" ? r.run_status === "failed" : r.verdict === filter.v)));
370
+ host.replaceChildren(shown.length ? dataTable([
371
+ {key: "name", label: "When", render: r => el("span", {class: "mono", text: when(r.name)})},
372
+ {key: "dataset", label: "Dataset", render: r => el("b", {text: r.dataset || "(unnamed)"})},
373
+ {key: "verdict", label: "Result", render: runBadge},
374
+ {key: "flags", label: "Checks", sort: r => (r.counts || {}).flag, render: r => counts(r.counts)},
375
+ {key: "output_dir", label: "Output dir", render: r => el("span", {class: "hint mono", text: r.output_dir})},
376
+ ], shown, {onRow: r => { location.hash = runHref(r.id); }}) : empty("No runs match."));
377
+ }
378
+ draw();
379
+ main.replaceChildren(pageHead("Runs", `${runs.length} run${runs.length === 1 ? "" : "s"}, newest first.`,
380
+ el("a", {class: "btn primary", href: "#/new", text: "+ New run"})),
381
+ card(null, null, el("div", {class: "row"}, search, chipBox), host));
382
+ }
383
+
384
+ // ---------- one run ----------
385
+
386
+ const RUN_TABS = [["overview", "Overview"], ["checks", "Checks"], ["recommendations", "Recommendations"],
387
+ ["benchmark", "Benchmark"], ["provenance", "Provenance"], ["scorecard", "Scorecard"], ["files", "Files"]];
388
+
389
+ async function renderRun(main, id, tab, sel) {
390
+ const d = await runDetail(id);
391
+ const sc = d.scorecard || {};
392
+ setCrumbs(["Runs", "#/runs"], [d.dataset || "(unnamed)", null], [when(d.name)]);
393
+ const base = "/files/" + id + "/";
394
+ const citeHost = el("div");
395
+ const actions = [];
396
+ if (d.files.includes("scorecard.json")) {
397
+ const citeBtn = el("button", {text: "Cite"});
398
+ citeBtn.onclick = async () => {
399
+ citeBtn.disabled = true;
400
+ try {
401
+ const r = await api("/api/run/" + id + "/citation");
402
+ citeHost.replaceChildren(card("BibTeX", el("div", {class: "row"}, copyBtn(r.bibtex),
403
+ el("button", {text: "Close", onclick: () => citeHost.replaceChildren()})), el("pre", {class: "code", text: r.bibtex})));
404
+ } catch (e) { citeHost.replaceChildren(errorBox(e.message)); }
405
+ finally { citeBtn.disabled = false; }
406
+ };
407
+ actions.push(citeBtn);
408
+ }
409
+ if (d.files.includes("SCORECARD.html")) actions.push(el("a", {class: "btn primary", href: base + "SCORECARD.html",
410
+ target: "_blank", rel: "noopener", text: "Open scorecard ↗"}));
411
+
412
+ const c = d.counts || {};
413
+ const total = (c.ok || 0) + (c.warning || 0) + (c.flag || 0) + (c.not_applicable || 0);
414
+ const kids = [
415
+ pageHead([el("h1", {text: d.dataset || "(unnamed)"}), runBadge(d)],
416
+ [el("span", {class: "mono", text: d.name}), " · ", d.output_dir], ...actions),
417
+ citeHost,
418
+ ];
419
+ if (d.run_status === "failed") kids.push(errorBox(`Run crashed at stage "${d.failed_stage}": ${d.error}`));
420
+ if (d.counts) kids.push(el("div", {class: "kpis"},
421
+ kpi("Checks", String(total)), kpi("OK", String(c.ok || 0), "ok"),
422
+ kpi("Warnings", String(c.warning || 0), c.warning ? "warning" : null),
423
+ kpi("Flags", String(c.flag || 0), c.flag ? "flag" : null),
424
+ ...(c.not_applicable ? [kpi("Not applicable", String(c.not_applicable))] : []),
425
+ kpi("Train rows", fmtInt(d.train_rows)), kpi("Test rows", fmtInt(d.test_rows)), kpi("Features", fmtInt(d.feature_count))));
426
+ kids.push(el("nav", {class: "tabs", "aria-label": "Run sections"}, ...RUN_TABS.map(([k, t]) =>
427
+ el("a", {href: runHref(id, k), class: tab === k ? "on" : null, "aria-current": tab === k ? "page" : null,
428
+ text: k === "benchmark" && d.benchmark.length ? `${t} (${d.benchmark.length})` : t}))));
429
+
430
+ const body = el("div", {class: "grid"});
431
+ kids.push(body);
432
+ main.replaceChildren(...kids);
433
+ if (tab === "overview") renderOverview(body, d, id);
434
+ else if (tab === "checks") renderChecks(body, d, id, sel);
435
+ else if (tab === "recommendations") await renderRecommendations(body, id);
436
+ else if (tab === "benchmark") await renderBenchmark(body, d, id);
437
+ else if (tab === "provenance") renderProvenance(body, d, sc);
438
+ else if (tab === "scorecard") renderScorecardTab(body, d, base);
439
+ else renderFiles(body, d, base);
440
+ }
441
+
442
+ function copyBtn(text) {
443
+ const b = el("button", {text: "Copy"});
444
+ b.onclick = async () => {
445
+ try { await navigator.clipboard.writeText(text); b.textContent = "Copied"; }
446
+ catch { b.textContent = "Copy failed"; }
447
+ setTimeout(() => { b.textContent = "Copy"; }, 1500);
448
+ };
449
+ return b;
450
+ }
451
+
452
+ function checksOf(d) {
453
+ return ((d.scorecard || {}).checks || []).map(c => {
454
+ const fin = c.after || c.before || {};
455
+ return {check: c.check, category: c.category, status: fin.status, summary: fin.summary, evidence: fin.evidence,
456
+ before: c.before, after: c.after};
457
+ });
458
+ }
459
+
460
+ function renderOverview(body, d, id) {
461
+ const checks = checksOf(d);
462
+ if (!checks.length) {
463
+ body.append(card(null, null, empty(d.run_status === "running" ? "This run is still in progress."
464
+ : "No audit in this run (skipped, or it didn't get that far). See Benchmark or Files.")));
465
+ return;
466
+ }
467
+ const sc = d.scorecard;
468
+ // Families
469
+ const fams = new Map();
470
+ for (const c of checks) {
471
+ const f = familyOf(c.check);
472
+ if (!fams.has(f)) fams.set(f, []);
473
+ fams.get(f).push(c);
474
+ }
475
+ const famOrder = [...FAMILIES.map(([f]) => f), "Other"].filter(f => fams.has(f));
476
+ const famCard = card("Check families", null, ...famOrder.map(f => {
477
+ const cs = fams.get(f), ok = cs.filter(c => c.status === "ok").length;
478
+ const worst = cs.reduce((w, c) => RANK[c.status] > RANK[w] ? c.status : w, "ok");
479
+ return el("div", {class: "fam"},
480
+ el("div", {class: "lbl"}, el("span", {}, el("b", {text: f}), el("span", {class: "hint", text: ` · ${ok}/${cs.length} ok`})),
481
+ statusBadge(worst)),
482
+ el("div", {class: "progress s-" + worst, role: "img", "aria-label": `${ok} of ${cs.length} ok`},
483
+ el("span", {style: {width: (100 * ok / cs.length) + "%"}})));
484
+ }));
485
+ // Top findings
486
+ const bad = checks.filter(c => RANK[c.status] > 0).sort((a, b) => RANK[b.status] - RANK[a.status]);
487
+ const findCard = card("Findings", el("a", {class: "btn", href: runHref(id, "recommendations"), text: "Recommendations →"}),
488
+ bad.length ? el("div", {}, ...bad.map(c => el("div", {class: "finding"}, dot(c.status),
489
+ el("a", {href: runHref(id, "checks", c.check), text: prettyCheck(c.check)}), el("p", {text: c.summary || ""}))))
490
+ : empty("Every check came back ok."));
491
+ body.append(el("div", {class: "grid g2"}, findCard, famCard));
492
+ const visuals = visualSummary(d, id, checks);
493
+ if (visuals.length) body.append(el("h2", {class: "section-title", text: "Evidence at a glance"}),
494
+ el("div", {class: "grid g2"}, ...visuals));
495
+
496
+ const extra = [];
497
+ const de = sc.dedup_effect;
498
+ if (de) {
499
+ const changed = checks.filter(c => c.before && c.after && c.before.status !== c.after.status);
500
+ extra.push(card("Effect of deduplication", null,
501
+ dedupComposition(de),
502
+ el("dl", {class: "kv"},
503
+ el("dt", {text: "Train duplicates dropped"}), el("dd", {text: fmtInt(de.train_duplicates_dropped)}),
504
+ el("dt", {text: "Test rows leaking a train match"}), el("dd", {text: fmtInt(de.test_leakage_dropped)}),
505
+ el("dt", {text: "Test-internal duplicates"}), el("dd", {text: fmtInt(de.test_duplicates_dropped)})),
506
+ changed.length ? el("div", {}, el("h3", {text: "Checks that changed after deduplication"}),
507
+ ...changed.map(c => el("div", {class: "row"}, el("a", {href: runHref(id, "checks", c.check), text: prettyCheck(c.check)}),
508
+ statusBadge(c.before.status), "→", statusBadge(c.after.status))))
509
+ : el("p", {class: "hint", text: "No check changed status after deduplication."})));
510
+ }
511
+ const prev = sc.previous_run_comparison;
512
+ if (prev) extra.push(prevRunCard(prev, d));
513
+ if (extra.length) body.append(el("div", {class: "grid g2"}, ...extra));
514
+ }
515
+
516
+ // The checks whose evidence charts best, in reading order; each card shows
517
+ // only when that check recorded data in this run.
518
+ const OVERVIEW_VIZ = [
519
+ ["class_distribution_report", "Class distribution"],
520
+ ["dedup_check", "Duplicates and train/test leakage by class", (d, b) => dedupByClass(b && b.by_class ? b : d)],
521
+ ["feature_auc_ranking_check", "Strongest single features"],
522
+ ["identity_column_flag", "Identity-like columns"],
523
+ ["homogeneity_test", "Test-to-train nearest neighbours"],
524
+ ["one_rule_check", "One-rule baseline"],
525
+ ["result_robustness_check", "Accuracy under each condition"],
526
+ ["repeated_seed_falsification_check", "Falsification across seeds"],
527
+ ["label_conflict_check", "Conflicting labels"],
528
+ ];
529
+ function reportsByCheck(d) {
530
+ const by = (rep) => Object.fromEntries((rep || []).map(r => [r.check, r.details || {}]));
531
+ return [by(d.audit_report), by(d.audit_report_before)];
532
+ }
533
+ function visualSummary(d, id, checks) {
534
+ const [after, before] = reportsByCheck(d);
535
+ const status = Object.fromEntries(checks.map(c => [c.check, c.status]));
536
+ const cards = [];
537
+ for (const [check, title, pick] of OVERVIEW_VIZ) {
538
+ const nodes = pick ? [pick(after[check] || {}, before[check])].filter(Boolean) : checkViz(check, after[check], before[check]);
539
+ if (!nodes.length) continue;
540
+ cards.push(card(title, el("div", {class: "row"}, status[check] ? statusBadge(status[check]) : null,
541
+ el("a", {class: "btn", href: runHref(id, "checks", check), text: "Details →"})), ...nodes));
542
+ }
543
+ return cards;
544
+ }
545
+
546
+ function prevRunCard(prev, d) {
547
+ const prevRun = runs.find(r => r.name === prev.previous_run && r.output_dir === d.output_dir);
548
+ return card("Compared with the previous run", prevRun ? el("a", {class: "btn", href: runHref(prevRun.id),
549
+ text: "Open it →"}) : null,
550
+ el("dl", {class: "kv"},
551
+ el("dt", {text: "Previous run"}), el("dd", {class: "mono", text: prev.previous_run}),
552
+ el("dt", {text: "Its verdict"}), el("dd", {}, prev.previous_verdict && VERDICT[prev.previous_verdict]
553
+ ? statusBadge(VERDICT[prev.previous_verdict].status, VERDICT[prev.previous_verdict].label) : "n/a"),
554
+ el("dt", {text: "Data fingerprint"}), el("dd", {}, prev.fingerprint_changed
555
+ ? statusBadge("warning", "Changed") : statusBadge("ok", "Unchanged")),
556
+ el("dt", {text: "Checks that changed"}), el("dd", {}, (prev.changed_checks || []).length
557
+ ? el("div", {}, ...prev.changed_checks.map(cc => el("div", {text: typeof cc === "string" ? cc : JSON.stringify(cc)})))
558
+ : "none")));
559
+ }
560
+
561
+ function renderChecks(body, d, id, sel) {
562
+ const checks = checksOf(d);
563
+ if (!checks.length) { body.append(card(null, null, empty("No audit in this run."))); return; }
564
+ const details = Object.fromEntries((d.audit_report || []).map(r => [r.check, r]));
565
+ const bad = checks.filter(c => RANK[c.status] > 0).sort((a, b) => RANK[b.status] - RANK[a.status]);
566
+ const current = checks.find(c => c.check === sel) || bad[0] || checks[0];
567
+ let filter = store.get("ids2eval.checkFilter") || "all";
568
+
569
+ const list = el("nav", {class: "card checklist", "aria-label": "Checks"});
570
+ function drawList() {
571
+ const chips = el("div", {class: "chips"}, ...[["all", "All"], ["flag", "Flag"], ["warning", "Warning"], ["ok", "OK"],
572
+ ["not_applicable", "N/A"]].map(
573
+ ([k, t]) => el("button", {class: "chip" + (filter === k ? " on" : ""), "aria-pressed": String(filter === k),
574
+ text: `${t} ${k === "all" ? checks.length : checks.filter(c => c.status === k).length}`,
575
+ onclick: () => { filter = k; store.set("ids2eval.checkFilter", k); drawList(); }})));
576
+ const items = [chips];
577
+ for (const f of [...FAMILIES.map(([n]) => n), "Other"]) {
578
+ const cs = checks.filter(c => familyOf(c.check) === f && (filter === "all" || c.status === filter));
579
+ if (!cs.length) continue;
580
+ items.push(el("div", {class: "grp", text: f}));
581
+ for (const c of cs) items.push(el("a", {href: runHref(id, "checks", c.check),
582
+ class: c.check === current.check ? "sel" : null, "aria-current": c.check === current.check ? "true" : null},
583
+ dot(c.status), prettyCheck(c.check)));
584
+ }
585
+ list.replaceChildren(...items);
586
+ }
587
+ drawList();
588
+
589
+ const c = current, info = checkInfo[c.check], rep = details[c.check];
590
+ const viz = checkViz(c.check, rep?.details, reportsByCheck(d)[1][c.check]);
591
+ const generic = rep && rep.details && Object.keys(rep.details).length ? renderValue(rep.details, 0) : null;
592
+ const changed = c.before && c.after && c.before.status !== c.after.status;
593
+ const detail = card(null, null,
594
+ el("div", {class: "card-head"}, el("div", {class: "title row"}, el("h1", {text: prettyCheck(c.check)}), statusBadge(c.status)),
595
+ el("span", {class: "mono hint", text: c.check})),
596
+ el("div", {class: "row"}, el("span", {class: "tag", text: familyOf(c.check)}),
597
+ c.evidence ? el("span", {class: "tag", text: "evidence: " + c.evidence}) : null,
598
+ c.category ? el("span", {class: "tag", text: "category: " + c.category}) : null,
599
+ changed ? el("span", {class: "row"}, "Before dedup:", statusBadge(c.before.status), "→", statusBadge(c.after.status)) : null),
600
+ el("p", {class: "summary", text: c.summary || "No summary recorded."}),
601
+ info ? el("div", {class: "explain"}, el("div", {}, el("b", {text: "What it checks"}), info.what),
602
+ el("div", {}, el("b", {text: "Rule"}), info.rule)) : null,
603
+ changed && c.before.summary ? el("div", {}, el("h3", {text: "Before deduplication"}),
604
+ el("p", {class: "hint", text: c.before.summary})) : null,
605
+ el("h3", {text: "Evidence"}),
606
+ viz.length ? el("div", {class: "stack"}, ...viz) : null,
607
+ viz.length && generic ? el("details", {class: "raw"}, el("summary", {text: "All recorded values"}), generic)
608
+ : generic || el("p", {class: "hint", text: "No structured details recorded for this check."}),
609
+ rep ? el("details", {class: "raw"}, el("summary", {text: "Raw JSON"}),
610
+ el("pre", {class: "code", text: JSON.stringify(rep, null, 2)})) : null);
611
+ body.append(el("div", {class: "split"}, list, detail));
612
+ }
613
+
614
+ // Generic renderer for a check's `details` - shapes vary per check, so this
615
+ // picks a view by shape: number maps become bar lists, maps of flat records
616
+ // become tables, everything else a key/value list.
617
+ function renderValue(v, depth) {
618
+ const isObj = (x) => x && typeof x === "object" && !Array.isArray(x);
619
+ const scalar = (x) => x == null || ["string", "number", "boolean"].includes(typeof x);
620
+ if (scalar(v)) return el("span", {class: typeof v === "number" ? "mono" : null, text: v == null ? "n/a" : fmtNum(v)});
621
+ if (depth > 3) return el("pre", {class: "code", text: JSON.stringify(v, null, 2)});
622
+ if (Array.isArray(v)) {
623
+ if (!v.length) return el("span", {class: "hint", text: "none"});
624
+ if (v.every(scalar)) return el("div", {class: "chips"}, ...v.slice(0, 60).map(x => el("span", {class: "tag", text: fmtNum(x)})),
625
+ v.length > 60 ? el("span", {class: "hint", text: `+${v.length - 60} more`}) : null);
626
+ if (v.every(x => isObj(x) && Object.values(x).every(scalar))) return recordTable(v.map((x, i) => [String(i + 1), x]));
627
+ return el("pre", {class: "code", text: JSON.stringify(v, null, 2)});
628
+ }
629
+ const entries = Object.entries(v);
630
+ if (!entries.length) return el("span", {class: "hint", text: "none"});
631
+ if (entries.length > 1 && entries.every(([, x]) => typeof x === "number")) return barList(entries);
632
+ if (entries.length > 1 && entries.every(([, x]) => isObj(x) && Object.values(x).every(scalar))) return recordTable(entries);
633
+ return el("dl", {class: "kv"}, ...entries.flatMap(([k, x]) => [el("dt", {text: k.replace(/_/g, " ")}),
634
+ el("dd", {}, renderValue(x, depth + 1))]));
635
+ }
636
+ function recordTable(entries) {
637
+ let cols = [...new Set(entries.flatMap(([, x]) => Object.keys(x)))];
638
+ if (cols.length > 2 * entries.length) { // e.g. {counts: {15 classes}, shares: {...}} - one row per class reads better
639
+ const flipped = cols.map(c => [c, Object.fromEntries(entries.map(([k, x]) => [k, x[c]]))]);
640
+ entries = flipped; cols = [...new Set(entries.flatMap(([, x]) => Object.keys(x)))];
641
+ }
642
+ const rows = entries.slice(0, 200).map(([k, x]) => ({_key: k, ...x}));
643
+ return el("div", {}, dataTable([{key: "_key", label: ""}, ...cols.map(c => ({key: c, label: c.replace(/_/g, " "),
644
+ num: rows.some(r => typeof r[c] === "number"), render: r => fmtCell(typeof r[c] === "boolean" ? String(r[c]) : r[c])}))], rows),
645
+ entries.length > 200 ? el("p", {class: "hint", text: `Showing 200 of ${entries.length}.`}) : null);
646
+ }
647
+ // Labelled bars from zero (never a truncated axis) with the exact value printed.
648
+ function barList(entries) {
649
+ const shown = entries.slice(0, 25);
650
+ const max = Math.max(...shown.map(([, x]) => Math.abs(x)), 1e-12);
651
+ return el("div", {}, el("div", {class: "barlist"}, ...shown.flatMap(([k, x]) => [
652
+ el("span", {class: "bl-label", title: k, text: k}),
653
+ el("div", {class: "bl-track"}, el("div", {class: "bl-bar", style: {width: (100 * Math.abs(x) / max) + "%"}})),
654
+ el("span", {class: "bl-val", text: fmtNum(x)})])),
655
+ entries.length > 25 ? el("p", {class: "hint", text: `Showing 25 of ${entries.length}.`}) : null);
656
+ }
657
+
658
+ async function renderRecommendations(body, runId) {
659
+ let recs;
660
+ try { recs = (await api("/api/run/" + runId + "/recommendations")).recommendations; }
661
+ catch (e) { body.append(errorBox(e.message)); return; }
662
+ if (!recs.length) {
663
+ body.append(card(null, null, empty("Nothing flagged or warned in this run has a recommendation.")));
664
+ return;
665
+ }
666
+ const checkboxes = [];
667
+ const cards = recs.map(r => {
668
+ const cb = r.patch ? el("input", {type: "checkbox"}) : null;
669
+ if (cb) { cb.checked = true; checkboxes.push([cb, r.id]); }
670
+ const head = cb ? el("label", {class: "rec-head"}, cb, el("strong", {text: r.title}))
671
+ : el("div", {class: "rec-head"}, el("strong", {text: r.title}), el("span", {class: "tag", text: "no config fix"}));
672
+ const c = el("div", {class: "rec-card"}, head,
673
+ el("div", {class: "row"}, ...r.checks.map(ch => el("a", {class: "tag", href: runHref(runId, "checks", ch),
674
+ text: prettyCheck(ch)}))),
675
+ el("p", {text: r.explanation}));
676
+ if (r.patch) c.append(el("pre", {class: "code", text: JSON.stringify(r.patch, null, 2)}));
677
+ return c;
678
+ });
679
+ const applyBtn = el("button", {class: "primary", text: "Apply selected → New run"});
680
+ applyBtn.disabled = checkboxes.length === 0;
681
+ const msg = el("span", {class: "msg"});
682
+ applyBtn.onclick = async () => {
683
+ applyBtn.disabled = true;
684
+ const ids = checkboxes.filter(([cb]) => cb.checked).map(([, id]) => id);
685
+ try {
686
+ editor.text = (await api("/api/run/" + runId + "/apply-recommendations", {ids})).yaml;
687
+ location.hash = "#/new";
688
+ } catch (e) { msg.textContent = e.message; msg.className = "msg err"; applyBtn.disabled = false; }
689
+ };
690
+ body.append(card("Recommendations", checkboxes.length ? el("div", {class: "row"}, msg, applyBtn) : null,
691
+ el("p", {class: "hint", text: "Only structural fixes get a checkbox - a shortcut-feature flag gets more " +
692
+ "evidence to enable instead of an automatic drop, and a few checks report a property of the data with no " +
693
+ "config fix at all; see each explanation."}), ...cards));
694
+ }
695
+
696
+ const METRICS = [["f1_macro", "Macro F1"], ["f1_weighted", "Weighted F1"], ["accuracy", "Accuracy"], ["auc", "AUC"],
697
+ ["train_time_s", "Train time (s)"], ["infer_time_s", "Inference time (s)"]];
698
+ async function renderBenchmark(body, d, id) {
699
+ const rows = d.benchmark.map(r => {
700
+ const o = {...r};
701
+ for (const [k] of METRICS) o[k] = r[k] === "" || r[k] == null ? null : +r[k];
702
+ return o;
703
+ });
704
+ if (!rows.length) { body.append(card(null, null, empty("No benchmark results in this run."))); return; }
705
+ const metrics = METRICS.filter(([k]) => rows.some(r => r[k] != null));
706
+ const scores = metrics.filter(([k]) => !k.endsWith("_s"));
707
+ const stages = [...new Set(rows.map(r => r.stage))];
708
+ let metric = metrics.some(([k]) => k === store.get("ids2eval.metric")) ? store.get("ids2eval.metric") : metrics[0][0];
709
+ let stage = stages[0], picked = null;
710
+ const label = Object.fromEntries(METRICS);
711
+ // Only name the scaling/sampling variant when the run actually varied it.
712
+ const multiVariant = new Set(rows.map(r => `${r.scaling}|${r.sampling}`)).size > 1;
713
+ const nameOf = (r) => r.classifier + (multiVariant ? ` (${[r.scaling, r.sampling].filter(Boolean).join(", ")})` : "");
714
+ const keyOf = (r) => [r.stage, r.scaling, r.sampling, r.classifier].join("|");
715
+
716
+ const sel = el("select", {"aria-label": "Metric", onchange: () => { metric = sel.value; store.set("ids2eval.metric", metric); draw(); }},
717
+ ...metrics.map(([k, t]) => el("option", {value: k, text: t})));
718
+ sel.value = metric;
719
+ const stageChips = el("div", {class: "chips"});
720
+ const chartBox = el("div"), scatterBox = el("div"), perClassBox = el("div"), cmBox = el("div"), fiBox = el("div");
721
+ const clfSel = el("select", {"aria-label": "Classifier", onchange: () => { picked = clfSel.value; drawDetail(); }});
722
+ let details = null;
723
+
724
+ const stageRows = () => rows.filter(r => r.stage === stage);
725
+ function draw() {
726
+ stageChips.replaceChildren(...(stages.length > 1 ? stages.map(s => el("button", {class: "chip" + (s === stage ? " on" : ""),
727
+ text: s, "aria-pressed": String(s === stage), onclick: () => { stage = s; picked = null; draw(); }})) : []));
728
+ const lower = metric.endsWith("_s");
729
+ const pts = stageRows().filter(r => r[metric] != null).sort((a, b) => lower ? a[metric] - b[metric] : b[metric] - a[metric]);
730
+ chartBox.replaceChildren(pts.length ? classifierChart(pts, metric, nameOf) : empty("No values for this metric."));
731
+ const yKey = lower ? (scores[0] || [])[0] : metric;
732
+ const sc = yKey && rows.some(r => r.train_time_s != null) ? scatter({
733
+ points: stageRows().filter(r => r[yKey] != null && r.train_time_s > 0).map(r => ({x: r.train_time_s, y: r[yKey], label: nameOf(r),
734
+ tip: r.infer_time_s != null ? [`Inference time: ${r.infer_time_s.toFixed(2)} s`] : []})),
735
+ xLog: true, xLabel: "Training time (s, log scale)", yLabel: label[yKey], xFmt: v => v.toFixed(2) + " s",
736
+ yFmt: v => v.toFixed(4), unitY: true}) : null;
737
+ scatterBox.replaceChildren(sc || empty("Not enough timed results to plot."));
738
+ if (details) drawDetail();
739
+ }
740
+
741
+ function drawDetail() {
742
+ const sr = stageRows().filter(r => details[keyOf(r)]);
743
+ if (!sr.length) { perClassBox.replaceChildren(empty("No per-class detail for this stage.")); cmBox.replaceChildren(); fiBox.replaceChildren(); return; }
744
+ const byScore = [...sr].sort((a, b) => (b[scores[0]?.[0]] ?? 0) - (a[scores[0]?.[0]] ?? 0));
745
+ // Per-class F1, classes ordered by test support (largest first)
746
+ const reports = byScore.map(r => [nameOf(r), details[keyOf(r)].per_class_report || {}]);
747
+ const support = {};
748
+ for (const [, rep] of reports) for (const [c, v] of Object.entries(rep))
749
+ if (v && typeof v === "object" && !/avg$/.test(c)) support[c] = Math.max(support[c] || 0, v.support || 0);
750
+ const classes = Object.keys(support).sort((a, b) => support[b] - support[a]);
751
+ const repOf = Object.fromEntries(reports);
752
+ perClassBox.replaceChildren(heatmap({rows: reports.map(([n]) => n), cols: classes, rowHead: "Classifier",
753
+ value: (r, c) => repOf[r][c]?.["f1-score"], text: v => v.toFixed(2), domain: [0, 1],
754
+ caption: "columns ordered by test support, largest first",
755
+ tip: (v, r, c) => { const x = repOf[r][c] || {}; return [`F1: ${v.toFixed(4)}`, `Precision: ${(x.precision ?? NaN).toFixed(4)}`,
756
+ `Recall: ${(x.recall ?? NaN).toFixed(4)}`, `Support: ${fmtInt(x.support)}`]; }}));
757
+ // Classifier picker for the confusion matrix and feature importance
758
+ if (!picked || !sr.some(r => keyOf(r) === picked)) picked = keyOf(byScore[0]);
759
+ clfSel.replaceChildren(...byScore.map(r => el("option", {value: keyOf(r), text: nameOf(r)})));
760
+ clfSel.value = picked;
761
+ const det = details[picked], name = nameOf(sr.find(r => keyOf(r) === picked));
762
+ const cm = det.confusion_matrix, labels = det.confusion_matrix_labels || [];
763
+ if (Array.isArray(cm) && cm.length) {
764
+ const rowSum = cm.map(row => row.reduce((a, b) => a + b, 0) || 1);
765
+ const L = labels.map(String);
766
+ cmBox.replaceChildren(el("p", {class: "hint", text: `${name}. Rows are the true class, columns the prediction; shading is ` +
767
+ "the share of each true class, so small classes read as clearly as large ones."}),
768
+ heatmap({rows: L, cols: L, rowHead: "True ↓ / Predicted →", value: (r, c) => cm[L.indexOf(r)][L.indexOf(c)] / rowSum[L.indexOf(r)],
769
+ text: (v, r, c) => fmtTick(cm[L.indexOf(r)][L.indexOf(c)]), domain: [0, 1], caption: "share of the true class",
770
+ tip: (v, r, c) => [`${fmtInt(cm[L.indexOf(r)][L.indexOf(c)])} rows`, `${pct(v)} of true ${r}`]}));
771
+ } else cmBox.replaceChildren(empty("No confusion matrix recorded."));
772
+ const fi = det.feature_importance;
773
+ if (fi && Object.keys(fi).length) {
774
+ const signed = Object.values(fi).some(v => v < 0);
775
+ const top = Object.entries(fi).map(([k, v]) => [k, Math.abs(v)]).sort((a, b) => b[1] - a[1]).slice(0, 15);
776
+ fiBox.replaceChildren(el("p", {class: "hint", text: signed
777
+ ? `${name} reports signed coefficients; bars show their magnitude. Top 15 of ${Object.keys(fi).length}.`
778
+ : `${name}, top 15 of ${Object.keys(fi).length} features.`}), barList(top));
779
+ } else fiBox.replaceChildren(empty(`${name} doesn't record feature importance.`));
780
+ }
781
+
782
+ draw();
783
+ body.append(card("Classifier comparison", el("div", {class: "row"}, stageChips, sel),
784
+ el("p", {class: "hint", text: "Bars start at zero, so near-ceiling scores look alike on purpose - the exact " +
785
+ "value is printed on each bar and in the table below. Time metrics sort fastest first."}), chartBox));
786
+ body.append(card("Score against training time", null, el("p", {class: "hint", text:
787
+ "Up and to the left is better. The score axis is zoomed to the data, since dots encode position, not length."}), scatterBox));
788
+ if (d.files.includes("benchmark_details.json")) {
789
+ body.append(card("Per-class F1", null, perClassBox),
790
+ el("div", {class: "grid g2"}, card("Confusion matrix", clfSel, cmBox), card("Feature importance", null, fiBox)));
791
+ perClassBox.replaceChildren(el("div", {class: "loading", text: "Loading per-class detail…"}));
792
+ try {
793
+ const r = await fetch("/files/" + id + "/benchmark_details.json");
794
+ if (!r.ok) throw new Error("benchmark_details.json couldn't be read");
795
+ details = await r.json();
796
+ drawDetail();
797
+ } catch (e) { perClassBox.replaceChildren(errorBox(e.message)); }
798
+ }
799
+ body.append(card("All results", null,
800
+ dataTable([{key: "stage", label: "Stage"}, {key: "classifier", label: "Classifier"}, {key: "scaling", label: "Scaling"},
801
+ {key: "sampling", label: "Sampling"},
802
+ ...metrics.map(([k]) => ({key: k, label: label[k], num: true,
803
+ render: r => r[k] == null ? "n/a" : r[k].toFixed(k.endsWith("_s") ? 2 : 4)}))],
804
+ rows, {initial: [metrics[0][0], -1]})));
805
+ }
806
+ function classifierChart(pts, metric, nameOf) {
807
+ const time = metric.endsWith("_s");
808
+ const max = time ? Math.max(...pts.map(p => p[metric])) || 1 : 1;
809
+ const ticks = [0, 0.25, 0.5, 0.75, 1];
810
+ const fmt = (v) => v.toFixed(time ? 2 : 4);
811
+ return el("div", {class: "hbars", role: "list", "aria-label": "Classifier scores"},
812
+ ...pts.map(p => {
813
+ const name = nameOf(p);
814
+ return withTip(el("div", {class: "hb-row", role: "listitem"},
815
+ el("span", {class: "hb-label", title: name, text: name}),
816
+ el("div", {class: "hb-track gridded"}, el("span", {class: "hb-seg acc", style: {width: (100 * p[metric] / max) + "%"}})),
817
+ el("span", {class: "hb-val", text: fmt(p[metric])})),
818
+ name, METRICS.filter(([k]) => p[k] != null).map(([k, t]) => `${t}: ${p[k].toFixed(k.endsWith("_s") ? 2 : 4)}`));
819
+ }),
820
+ el("div", {class: "hb-row axis", "aria-hidden": "true"}, el("span"),
821
+ el("div", {class: "hb-ticks"}, ...ticks.map(t => el("span", {style: {left: (t * 100) + "%"}, text: fmtNum(+(t * max).toPrecision(3))}))),
822
+ el("span")));
823
+ }
824
+
825
+ function renderProvenance(body, d, sc) {
826
+ const fp = sc.dataset_fingerprint || {};
827
+ const env = d.environment || {};
828
+ const hashRow = (label, h) => [el("dt", {text: label}), el("dd", {class: "row"}, el("span", {class: "mono", text: h || "n/a"}),
829
+ h ? copyBtn(h) : null)];
830
+ const cards = [
831
+ card("Dataset fingerprint", null, el("dl", {class: "kv"},
832
+ el("dt", {text: "Train rows"}), el("dd", {text: fmtInt(fp.train_rows)}),
833
+ el("dt", {text: "Test rows"}), el("dd", {text: fmtInt(fp.test_rows)}),
834
+ el("dt", {text: "Features"}), el("dd", {text: fmtInt(fp.feature_count)}),
835
+ ...hashRow("Train content hash", fp.train_content_hash), ...hashRow("Test content hash", fp.test_content_hash))),
836
+ card("Software", null, el("dl", {class: "kv"},
837
+ el("dt", {text: "IDS2Eval"}), el("dd", {text: sc.ids2eval_version || env.ids2eval?.version || "n/a"}),
838
+ ...hashRow("Git commit", sc.ids2eval_git_commit || env.ids2eval?.git_commit),
839
+ el("dt", {text: "Scorecard schema"}), el("dd", {text: sc.scorecard_schema_version || "n/a"}),
840
+ el("dt", {text: "Generated"}), el("dd", {text: sc.generated_at || "n/a"}),
841
+ el("dt", {text: "Audit stage"}), el("dd", {text: sc.audit_stage || "n/a"}),
842
+ el("dt", {text: "Python"}), el("dd", {text: env.python_version || "n/a"}),
843
+ el("dt", {text: "Platform"}), el("dd", {text: env.platform || "n/a"}))),
844
+ ];
845
+ if (env.packages) cards.push(card("Packages", null, dataTable([{key: "name", label: "Package"}, {key: "version", label: "Version"}],
846
+ Object.entries(env.packages).map(([name, version]) => ({name, version})))));
847
+ if (sc.previous_run_comparison) cards.push(prevRunCard(sc.previous_run_comparison, d));
848
+ if (!sc.dataset_fingerprint && !d.environment) {
849
+ body.append(card(null, null, empty("No provenance recorded for this run."))); return;
850
+ }
851
+ body.append(el("div", {class: "grid g2"}, ...cards));
852
+ }
853
+
854
+ const EXPORTS = [["SCORECARD.html", "HTML"], ["SCORECARD.md", "Markdown"], ["scorecard.json", "JSON"],
855
+ ["scorecard.pdf", "PDF"], ["scorecard.png", "PNG"]];
856
+ function renderScorecardTab(body, d, base) {
857
+ const have = EXPORTS.filter(([f]) => d.files.includes(f));
858
+ const actions = el("div", {class: "row"}, ...have.map(([f, t]) => el("a", {class: "btn", href: base + encodeURIComponent(f),
859
+ target: "_blank", rel: "noopener", download: f.endsWith(".html") ? null : f, text: t})));
860
+ if (d.files.includes("SCORECARD.html")) body.append(card("Scorecard", actions,
861
+ el("iframe", {src: base + "SCORECARD.html", title: "Scorecard"})));
862
+ else if (d.scorecard) body.append(card("Scorecard", actions, el("p", {class: "hint",
863
+ text: "This run predates the HTML scorecard - use the exports above, or the Overview and Checks tabs."})));
864
+ else body.append(card(null, null, empty("No scorecard: the audit was skipped or the run didn't get that far.")));
865
+ }
866
+ function renderFiles(body, d, base) {
867
+ body.append(card("Run artifacts", null, el("p", {class: "hint mono", text: d.output_dir + "/runs/" + d.name}),
868
+ el("div", {class: "files"}, ...d.files.map(f => el("a", {href: base + encodeURIComponent(f), target: "_blank",
869
+ rel: "noopener", class: "mono", title: f, text: f})))));
870
+ }
871
+
872
+ // ---------- new run ----------
873
+
874
+ async function configFromRun(id) {
875
+ // resolved_config.json is JSON, and JSON is valid YAML, so the editor and
876
+ // the server's YAML loader both take it as-is.
877
+ const r = await fetch("/files/" + id + "/resolved_config.json");
878
+ if (!r.ok) throw new Error("That run has no resolved_config.json");
879
+ return JSON.stringify(await r.json(), null, 2) + "\n";
880
+ }
881
+ function configPicker(onPick, withStarter) {
882
+ const sources = runs.filter(r => r.files.includes("resolved_config.json"));
883
+ const s = el("select", {"aria-label": "Load a config"},
884
+ el("option", {value: "", text: "Load config from…"}),
885
+ withStarter ? el("option", {value: "@starter", text: "Starter template (every field documented)"}) : null,
886
+ ...sources.slice(0, 40).map(r => el("option", {value: r.id, text: `${r.dataset || "(unnamed)"} · ${when(r.name)}`})));
887
+ s.onchange = async () => {
888
+ const v = s.value; s.value = "";
889
+ if (!v) return;
890
+ try { onPick(v === "@starter" ? (await api("/api/starter-config")).yaml : await configFromRun(v)); }
891
+ catch (e) { onPick(null, e); }
892
+ };
893
+ return s;
894
+ }
895
+
896
+ async function renderNewRun(main) {
897
+ setCrumbs(["New run"]);
898
+ if (editor.text === null) editor.text = (await api("/api/starter-config")).yaml;
899
+ const ta = el("textarea", {spellcheck: "false", "aria-label": "Config YAML"});
900
+ ta.value = editor.text;
901
+ const steps = ["Edit config", "Validate", "Run", "Done"].map(t => el("div", {text: t}));
902
+ let validated = false;
903
+ const setStep = (i, bad) => steps.forEach((s, j) => { s.className = j < i ? "done" : j === i ? (bad ? "bad" : "active") : ""; });
904
+ setStep(0);
905
+ ta.addEventListener("input", () => { editor.text = ta.value; if (validated) { validated = false; setStep(0); } });
906
+ const skipAudit = el("input", {type: "checkbox"}), skipBench = el("input", {type: "checkbox"});
907
+ const msg = el("span", {class: "msg"});
908
+ const setMsg = (t, cls) => { msg.textContent = t; msg.className = "msg " + (cls || ""); };
909
+ const runBtn = el("button", {class: "primary", text: "▶ Run"}), stopBtn = el("button", {text: "■ Stop"});
910
+ const valBtn = el("button", {text: "Validate"});
911
+ const log = el("pre", {class: "log", text: "No run started from this page yet."});
912
+ const jobBadge = el("span");
913
+ const progress = el("div", {class: "runprog", "aria-live": "polite"});
914
+ let lastJob = null, polledAt = 0, tickTimer = null;
915
+ const loader = configPicker((text, err) => {
916
+ if (err) return setMsg(err.message, "err");
917
+ ta.value = editor.text = text; validated = false; setStep(0); setMsg("Loaded.", "ok");
918
+ }, true);
919
+
920
+ valBtn.onclick = async () => {
921
+ try {
922
+ const r = await api("/api/validate", {yaml: ta.value});
923
+ validated = r.ok;
924
+ if (r.ok) { setStep(2); setMsg(`Valid. Runs will be written under ${r.output_dir}/runs/`, "ok"); }
925
+ else { setStep(1, true); setMsg(r.error, "err"); }
926
+ } catch (e) { setStep(1, true); setMsg(e.message, "err"); }
927
+ };
928
+ runBtn.onclick = async () => {
929
+ try {
930
+ await api("/api/run", {yaml: ta.value, skip_audit: skipAudit.checked, skip_benchmark: skipBench.checked});
931
+ setMsg("Running…"); poll();
932
+ } catch (e) { setStep(1, true); setMsg(e.message, "err"); }
933
+ };
934
+ stopBtn.onclick = () => api("/api/job/stop", {}).catch(e => setMsg(e.message, "err"));
935
+
936
+ function show(job) {
937
+ // Follow the tail only when the reader is already at the bottom, so
938
+ // scrolling up to read an earlier line isn't yanked back every second.
939
+ const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 40;
940
+ if (job.log.length) log.textContent = job.log.join("\n");
941
+ if (atBottom) log.scrollTop = log.scrollHeight;
942
+ lastJob = job; polledAt = Date.now();
943
+ drawProgress();
944
+ clearInterval(tickTimer);
945
+ if (job.state === "running") tickTimer = setInterval(() => {
946
+ if (!progress.isConnected) { clearInterval(tickTimer); return; }
947
+ drawProgress();
948
+ }, 1000);
949
+ runBtn.disabled = job.state === "running"; stopBtn.disabled = job.state !== "running";
950
+ jobBadge.replaceChildren(job.state === "running" ? el("span", {class: "badge s-running", text: "Running"})
951
+ : job.state === "completed" ? statusBadge("ok", "Completed") : job.state === "failed"
952
+ ? statusBadge("flag", `Failed (exit ${job.returncode})`) : el("span", {class: "badge s-none", text: "Idle"}));
953
+ if (job.state === "running") setStep(2);
954
+ if (job.state === "completed") {
955
+ setStep(4); setMsg("Run completed. ", "ok");
956
+ msg.append(el("button", {text: "Open the run →", onclick: async () => {
957
+ await loadRuns(); if (runs.length) location.hash = runHref(runs[0].id); }}));
958
+ }
959
+ if (job.state === "failed") { setStep(3, true); setMsg(`Run failed (exit ${job.returncode}) - see the log.`, "err"); }
960
+ }
961
+ // Elapsed time, the current stage and step, and time since the last line of
962
+ // output - so a check that runs for minutes without printing still shows
963
+ // the run is alive. Times come from the server's clock (job.now).
964
+ function drawProgress() {
965
+ const job = lastJob;
966
+ if (!job || !job.started_at || job.state === "idle") { progress.replaceChildren(); return; }
967
+ const now = job.state === "running" ? job.now + (Date.now() - polledAt) / 1000 : (job.finished_at || job.now);
968
+ const elapsed = now - job.started_at, quiet = job.state === "running" ? now - job.last_output_at : 0;
969
+ let stage = null, step = null;
970
+ for (let i = job.log.length - 1; i >= 0 && (!stage || !step); i--) {
971
+ const line = job.log[i];
972
+ if (!stage) { const m = /Stage: (.+)$/.exec(line); if (m) stage = m[1]; }
973
+ if (!step) {
974
+ const m = /(Audit check|Benchmark) (\d+)\/(\d+)( done)?: (\S+)/.exec(line);
975
+ if (m) step = {kind: m[1], i: +m[2], n: +m[3], done: !!m[4], name: m[5]};
976
+ }
977
+ }
978
+ const kids = [el("div", {class: "row"},
979
+ el("span", {class: "tag", text: (job.state === "running" ? "Elapsed " : "Took ") + dur(elapsed)}),
980
+ stage ? el("span", {class: "tag", text: "Stage: " + stage}) : null,
981
+ step && job.state === "running" ? el("span", {class: "tag", text: step.done
982
+ ? `${step.kind} ${step.i}/${step.n} finished: ${step.name}` : `${step.kind} ${step.i}/${step.n}: ${step.name}`}) : null)];
983
+ if (step && job.state === "running") {
984
+ const doneCount = step.done ? step.i : step.i - 1;
985
+ kids.push(el("div", {class: "progress s-ok", role: "progressbar", "aria-valuemin": "0", "aria-valuemax": String(step.n),
986
+ "aria-valuenow": String(doneCount), "aria-label": `${step.kind}: ${doneCount} of ${step.n} done`},
987
+ el("span", {style: {width: (100 * doneCount / step.n) + "%"}})));
988
+ }
989
+ if (job.state === "running" && quiet >= 30) kids.push(el("p", {class: "hint", text:
990
+ `No new output for ${dur(quiet)}. The run is still going: some checks and classifiers work for several ` +
991
+ "minutes on a large dataset before they print anything."}));
992
+ progress.replaceChildren(...kids);
993
+ }
994
+ async function poll() {
995
+ clearTimeout(pollTimer);
996
+ let job; try { job = await api("/api/job"); } catch { return; }
997
+ if (parseHash().page !== "new") return;
998
+ show(job);
999
+ if (job.state === "running") pollTimer = setTimeout(poll, 1000);
1000
+ }
1001
+
1002
+ main.replaceChildren(
1003
+ pageHead("New run", "Edit the YAML config, validate it with the CLI's own checks, then launch. " +
1004
+ "Relative paths resolve against the directory the dashboard was started in."),
1005
+ el("div", {class: "wizard"}, ...steps),
1006
+ el("div", {class: "grid g2"},
1007
+ card("Config", loader, ta,
1008
+ el("div", {class: "row"}, el("label", {}, skipAudit, " skip audit"), el("label", {}, skipBench, " skip benchmark"),
1009
+ el("span", {class: "spacer"}), valBtn, runBtn, stopBtn),
1010
+ msg),
1011
+ card("Run log", jobBadge, progress, log)));
1012
+ poll();
1013
+ }
1014
+
1015
+ // ---------- compare ----------
1016
+
1017
+ function renderCompare(main) {
1018
+ setCrumbs(["Compare datasets"]);
1019
+ const mk = (label) => {
1020
+ const ta = el("textarea", {spellcheck: "false", "aria-label": label + " YAML", placeholder: label + " YAML"});
1021
+ const picker = configPicker((t, err) => { if (err) setMsg(err.message, "err"); else ta.value = t; }, false);
1022
+ return [ta, card(label, picker, ta)];
1023
+ };
1024
+ const [taA, cardA] = mk("Config A"), [taB, cardB] = mk("Config B");
1025
+ const msg = el("span", {class: "msg"});
1026
+ const setMsg = (t, cls) => { msg.textContent = t; msg.className = "msg " + (cls || ""); };
1027
+ const out = el("pre", {class: "log", text: "No comparison run yet."});
1028
+ const verdict = el("span");
1029
+ const cmpBtn = el("button", {class: "primary", text: "Compare"});
1030
+ cmpBtn.onclick = async () => {
1031
+ cmpBtn.disabled = true; setMsg("Loading both datasets - this is as heavy as the CLI command…");
1032
+ try {
1033
+ const r = await api("/api/compare-datasets", {yaml_a: taA.value, yaml_b: taB.value});
1034
+ out.textContent = r.report; setMsg("");
1035
+ verdict.replaceChildren(r.result.combined_content_match ? statusBadge("ok", "Equivalent") : statusBadge("flag", "Not equivalent"));
1036
+ } catch (e) { setMsg(e.message, "err"); }
1037
+ finally { cmpBtn.disabled = false; }
1038
+ };
1039
+ main.replaceChildren(
1040
+ pageHead("Compare datasets", "Check whether two configs load equivalent data - same as `ids2eval compare-datasets`."),
1041
+ el("div", {class: "grid g2"}, cardA, cardB),
1042
+ el("div", {class: "row"}, cmpBtn, verdict, msg),
1043
+ card("Report", null, out));
1044
+ }
1045
+
1046
+ // ---------- chrome ----------
1047
+
1048
+ const THEMES = [[null, "Auto", "Theme: follows the system"], ["light", "Light", "Theme: light"],
1049
+ ["dark", "Dark", "Theme: dark"]];
1050
+ function applyTheme(t) {
1051
+ if (t) document.documentElement.dataset.theme = t; else delete document.documentElement.dataset.theme;
1052
+ const [, icon, label] = THEMES.find(([k]) => k === t) || THEMES[0];
1053
+ $("#theme").textContent = icon; $("#theme").title = label; $("#theme").setAttribute("aria-label", label);
1054
+ }
1055
+ applyTheme(store.get("ids2eval.theme"));
1056
+ $("#theme").onclick = () => {
1057
+ const i = THEMES.findIndex(([k]) => k === (store.get("ids2eval.theme") || null));
1058
+ const next = THEMES[(i + 1) % THEMES.length][0];
1059
+ store.set("ids2eval.theme", next); applyTheme(next);
1060
+ };
1061
+ $("#menu").onclick = () => $("#sidebar").classList.toggle("open");
1062
+ $("#refresh").onclick = async () => { await loadRuns(); route(); };
1063
+ window.addEventListener("hashchange", route);
1064
+ api("/api/check-info").then(ci => { checkInfo = ci; }).catch(() => {}).finally(route);