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,366 @@
1
+ "use strict";
2
+ // Chart kit for the dashboard, plus the per-check evidence views built on it.
3
+ // Charts are SVG drawn at the container's real pixel width and redrawn on
4
+ // resize, so text stays 12px instead of scaling with a viewBox. Colors come
5
+ // from CSS classes (c-s1 = train / primary series, c-s2 = test, c-ref =
6
+ // reference or control), so dark mode needs nothing here. Loaded before
7
+ // app.js, and uses its el(), withTip() and fmtNum() only at call time.
8
+
9
+ function S(tag, attrs = {}, ...kids) {
10
+ const n = document.createElementNS("http://www.w3.org/2000/svg", tag);
11
+ for (const [k, v] of Object.entries(attrs)) {
12
+ if (v == null || v === false) continue;
13
+ if (k === "text") n.textContent = v; else n.setAttribute(k, v);
14
+ }
15
+ for (const k of kids.flat()) if (k != null && k !== false) n.append(k);
16
+ return n;
17
+ }
18
+
19
+ // Re-run build(width) whenever the host's width changes; stops observing
20
+ // once the host has left the page.
21
+ function responsive(build) {
22
+ const host = el("div", {class: "plot"});
23
+ let last = 0;
24
+ const ro = new ResizeObserver(() => {
25
+ if (!host.isConnected) { ro.disconnect(); return; }
26
+ const w = Math.floor(host.clientWidth);
27
+ if (!w || Math.abs(w - last) < 4) return;
28
+ last = w;
29
+ host.replaceChildren(build(w));
30
+ });
31
+ ro.observe(host);
32
+ return host;
33
+ }
34
+
35
+ // ---------- scales, ticks, formats ----------
36
+
37
+ function scale([d0, d1], [r0, r1], log) {
38
+ if (log) {
39
+ const a = Math.log10(d0), b = Math.log10(d1);
40
+ return v => r0 + ((Math.log10(Math.max(v, d0)) - a) / (b - a || 1)) * (r1 - r0);
41
+ }
42
+ return v => r0 + ((v - d0) / (d1 - d0 || 1)) * (r1 - r0);
43
+ }
44
+ function ticks([lo, hi], log, n = 5) {
45
+ if (log) {
46
+ const out = [];
47
+ for (let e = Math.ceil(Math.log10(lo) - 1e-9); e <= Math.floor(Math.log10(hi) + 1e-9); e++) out.push(10 ** e);
48
+ const step = Math.ceil(out.length / 7);
49
+ return out.filter((_, i) => i % step === 0);
50
+ }
51
+ const raw = (hi - lo) / n || 1, p = 10 ** Math.floor(Math.log10(raw)), m = raw / p;
52
+ const step = (m < 1.5 ? 1 : m < 3 ? 2 : m < 7 ? 5 : 10) * p;
53
+ const out = [];
54
+ for (let v = Math.ceil(lo / step - 1e-9) * step; v <= hi + step * 1e-9; v += step) out.push(+v.toPrecision(10));
55
+ return out;
56
+ }
57
+ function logDomain(vals) {
58
+ const pos = vals.filter(v => v > 0);
59
+ if (!pos.length) return [1, 10];
60
+ const lo = 10 ** Math.floor(Math.log10(Math.min(...pos))), hi = 10 ** Math.ceil(Math.log10(Math.max(...pos)));
61
+ return [lo, hi > lo ? hi : lo * 10];
62
+ }
63
+ // Padded data range for position encodings (dots, scatter) - bars never use this.
64
+ function padDomain(vals, {zero = false, unit = false} = {}) {
65
+ let lo = Math.min(...vals), hi = Math.max(...vals);
66
+ const pad = (hi - lo) * 0.08 || Math.max(Math.abs(hi) * 0.02, 0.005);
67
+ lo -= pad; hi += pad;
68
+ if (unit) { lo = Math.max(0, lo); hi = Math.min(1, hi); }
69
+ if (zero) { lo = Math.min(0, lo); hi = Math.max(0, hi); }
70
+ return [lo, hi];
71
+ }
72
+ function fmtTick(v) {
73
+ const a = Math.abs(v);
74
+ if (a >= 1e9) return +(v / 1e9).toPrecision(3) + "B";
75
+ if (a >= 1e6) return +(v / 1e6).toPrecision(3) + "M";
76
+ if (a >= 1e3) return +(v / 1e3).toPrecision(3) + "k";
77
+ return String(+v.toPrecision(3));
78
+ }
79
+ const pct = (v) => v == null ? "n/a" : (v * 100).toFixed(Math.abs(v) < 0.01 && v !== 0 ? 2 : 1) + "%";
80
+ const trunc = (s, n) => s.length > n ? s.slice(0, Math.max(1, n - 1)) + "…" : s;
81
+
82
+ const TRAIN_TEST = [{key: "train", label: "Train", cls: "s1"}, {key: "test", label: "Test", cls: "s2"}];
83
+ const ONE = (label) => [{key: "v", label, cls: "s1"}];
84
+
85
+ function legend(series, refs = []) {
86
+ return el("div", {class: "legend"},
87
+ ...series.map(s => el("span", {}, el("i", {class: "sw-dot c-" + s.cls}), s.label)),
88
+ ...refs.filter(r => r.label).map(r => el("span", {}, el("i", {class: "sw-line"}), r.label)));
89
+ }
90
+ function stats(items) {
91
+ const shown = items.filter(([, v]) => v != null && v !== "");
92
+ return shown.length ? el("div", {class: "stats"}, ...shown.map(([k, v, status]) =>
93
+ el("div", {class: "stat" + (status ? " s-" + status : "")}, el("span", {text: k}), el("b", {text: String(v)})))) : null;
94
+ }
95
+
96
+ // ---------- dot plot / dumbbell ----------
97
+
98
+ // One row per category, one dot per series, a connector when there are
99
+ // several. Position-only encoding, so a zoomed axis is honest here.
100
+ function dotPlot({rows, series, log = false, domain, xLabel, refs = [], fmt, unit = false}) {
101
+ const f = fmt || (unit ? pct : fmtNum);
102
+ const all = rows.flatMap(r => series.map(s => r.values[s.key])).filter(v => v != null && isFinite(v));
103
+ if (!all.length) return null;
104
+ const dom = domain || (log ? logDomain(all) : padDomain([...all, ...refs.map(r => r.value)], {unit}));
105
+ const rowH = 24, top = 4, axisH = xLabel ? 38 : 22;
106
+ const plot = responsive(W => {
107
+ const labelW = Math.min(230, Math.max(90, W * 0.34)), x0 = labelW + 14, x1 = W - 14;
108
+ const x = scale(dom, [x0, x1], log), bottom = top + rows.length * rowH;
109
+ const svg = S("svg", {class: "chart", width: W, height: bottom + axisH, role: "img", "aria-label": xLabel || "Dot plot"});
110
+ for (const t of ticks(dom, log)) svg.append(
111
+ S("line", {class: "grid-line", x1: x(t), x2: x(t), y1: top, y2: bottom}),
112
+ S("text", {class: "tick", x: x(t), y: bottom + 14, "text-anchor": "middle", text: unit ? pct(t) : fmtTick(t)}));
113
+ if (xLabel) svg.append(S("text", {class: "axis-label", x: (x0 + x1) / 2, y: bottom + 32, "text-anchor": "middle", text: xLabel}));
114
+ rows.forEach((r, i) => {
115
+ const cy = top + i * rowH + rowH / 2;
116
+ const vals = series.map(s => [s, r.values[s.key]]).filter(([, v]) => v != null && isFinite(v));
117
+ const hit = S("rect", {class: "hit", x: 0, y: cy - rowH / 2, width: W, height: rowH, rx: 4});
118
+ const icon = r.mark ? (STATUS[r.mark]?.icon || "") + " " : "";
119
+ const g = S("g", {}, hit, S("text", {class: "row-label" + (r.mark ? " mark-" + r.mark : ""), x: labelW, y: cy + 4,
120
+ "text-anchor": "end", text: icon + trunc(r.label, Math.floor(labelW / 6.6) - icon.length)}));
121
+ if (vals.length > 1) {
122
+ const xs = vals.map(([, v]) => x(v));
123
+ g.append(S("line", {class: "connector", x1: Math.min(...xs), x2: Math.max(...xs), y1: cy, y2: cy}));
124
+ }
125
+ for (const [s, v] of vals) g.append(S("circle", {class: "dot-mark c-" + s.cls, cx: x(v), cy, r: 5}));
126
+ withTip(hit, r.label, [...vals.map(([s, v]) => `${s.label}: ${f(v)}`), ...(r.tip || [])]);
127
+ svg.append(g);
128
+ });
129
+ for (const ref of refs) svg.append(S("line", {class: "ref-line", x1: x(ref.value), x2: x(ref.value), y1: top - 2, y2: bottom}));
130
+ return svg;
131
+ });
132
+ return el("div", {class: "viz"}, series.length > 1 || refs.some(r => r.label) ? legend(series.length > 1 ? series : [], refs) : null, plot);
133
+ }
134
+
135
+ // Many values on one line (per-seed results), with an optional interval band.
136
+ function stripPlot({values, band, refs = [], xLabel, fmt = fmtNum, name = "Value"}) {
137
+ if (!values.length) return null;
138
+ const dom = padDomain([...values, ...refs.map(r => r.value), ...(band || [])], {zero: true});
139
+ const plot = responsive(W => {
140
+ const x0 = 14, x1 = W - 14, x = scale(dom, [x0, x1]), cy = 34, H = 92;
141
+ const svg = S("svg", {class: "chart", width: W, height: H, role: "img", "aria-label": xLabel || "Strip plot"});
142
+ for (const t of ticks(dom, false)) svg.append(
143
+ S("line", {class: "grid-line", x1: x(t), x2: x(t), y1: 8, y2: 60}),
144
+ S("text", {class: "tick", x: x(t), y: 74, "text-anchor": "middle", text: fmtTick(t)}));
145
+ if (band) svg.append(S("rect", {class: "band", x: x(band[0]), y: cy - 14, width: Math.max(2, x(band[1]) - x(band[0])), height: 28, rx: 4}));
146
+ for (const r of refs) svg.append(S("line", {class: "ref-line", x1: x(r.value), x2: x(r.value), y1: 6, y2: 62}),
147
+ r.short ? S("text", {class: "ref-text", x: x(r.value) + 4, y: 14, text: r.short}) : null);
148
+ values.forEach((v, i) => {
149
+ const c = S("circle", {class: "dot-mark c-s1 hit-dot", cx: x(v), cy, r: 5});
150
+ svg.append(c);
151
+ withTip(c, `${name} ${i + 1}`, [fmt(v)]);
152
+ });
153
+ if (xLabel) svg.append(S("text", {class: "axis-label", x: (x0 + x1) / 2, y: 89, "text-anchor": "middle", text: xLabel}));
154
+ return svg;
155
+ });
156
+ const keys = [];
157
+ if (band) keys.push(el("span", {}, el("i", {class: "sw-band"}), "95% interval"));
158
+ for (const r of refs) if (r.label) keys.push(el("span", {}, el("i", {class: "sw-line"}), r.label));
159
+ return el("div", {class: "viz"}, keys.length ? el("div", {class: "legend"}, el("span", {}, el("i", {class: "sw-dot c-s1"}), name), ...keys) : null, plot);
160
+ }
161
+
162
+ // ---------- scatter ----------
163
+
164
+ // One series; points on the upper-left frontier (better score for less x)
165
+ // get direct labels, the rest are readable through the tooltip and table.
166
+ function scatter({points, xLog = false, xLabel, yLabel, xFmt = fmtNum, yFmt = fmtNum, unitY = false}) {
167
+ if (points.length < 2) return null;
168
+ const xs = points.map(p => p.x), ys = points.map(p => p.y);
169
+ const xd = xLog ? logDomain(xs) : padDomain(xs, {zero: true}), yd = padDomain(ys, {unit: unitY});
170
+ const sorted = [...points].sort((a, b) => a.x - b.x);
171
+ const frontier = new Set();
172
+ let best = -Infinity;
173
+ for (const p of sorted) if (p.y > best) { best = p.y; frontier.add(p); }
174
+ const plot = responsive(W => {
175
+ const H = 300, l = 54, r = W - 16, t = 10, b = H - 44;
176
+ const x = scale(xd, [l, r], xLog), y = scale(yd, [b, t]);
177
+ const svg = S("svg", {class: "chart", width: W, height: H, role: "img", "aria-label": `${yLabel} against ${xLabel}`});
178
+ for (const v of ticks(xd, xLog)) svg.append(S("line", {class: "grid-line", x1: x(v), x2: x(v), y1: t, y2: b}),
179
+ S("text", {class: "tick", x: x(v), y: b + 15, "text-anchor": "middle", text: fmtTick(v)}));
180
+ for (const v of ticks(yd, false)) svg.append(S("line", {class: "grid-line", x1: l, x2: r, y1: y(v), y2: y(v)}),
181
+ S("text", {class: "tick", x: l - 6, y: y(v) + 4, "text-anchor": "end", text: unitY ? (+v.toFixed(4)).toString() : fmtTick(v)}));
182
+ svg.append(S("text", {class: "axis-label", x: (l + r) / 2, y: H - 6, "text-anchor": "middle", text: xLabel}),
183
+ S("text", {class: "axis-label", x: 12, y: (t + b) / 2, "text-anchor": "middle", transform: `rotate(-90 12 ${(t + b) / 2})`, text: yLabel}));
184
+ for (const p of points) {
185
+ const cx = x(p.x), cy = y(p.y);
186
+ const hit = S("circle", {class: "hit", cx, cy, r: 13});
187
+ svg.append(hit, S("circle", {class: "dot-mark c-s1", cx, cy, r: 6}));
188
+ if (frontier.has(p)) svg.append(S("text", {class: "point-label", x: cx + (cx > r - 120 ? -10 : 10), y: cy - 9,
189
+ "text-anchor": cx > r - 120 ? "end" : "start", text: p.label}));
190
+ withTip(hit, p.label, [`${yLabel}: ${yFmt(p.y)}`, `${xLabel}: ${xFmt(p.x)}`, ...(p.tip || [])]);
191
+ }
192
+ return svg;
193
+ });
194
+ return el("div", {class: "viz"}, el("div", {class: "legend"}, el("span", {}, el("i", {class: "sw-dot c-s1"}), "Classifier"),
195
+ el("span", {class: "hint", text: "labelled: best score at or below that cost"})), plot);
196
+ }
197
+
198
+ // ---------- heatmap ----------
199
+
200
+ // HTML table so it reflows; one blue ramp, value printed in each cell while
201
+ // the grid is small enough to read them.
202
+ function heatmap({rows, cols, value, text, domain = [0, 1], rowHead = "", tip, caption}) {
203
+ const [lo, hi] = domain;
204
+ const showText = cols.length <= 16;
205
+ const head = el("tr", {}, el("th", {class: "corner", text: rowHead}),
206
+ ...cols.map(c => el("th", {class: "col", scope: "col", title: c}, el("span", {text: trunc(c, 22)}))));
207
+ const body = rows.map(r => el("tr", {}, el("th", {class: "rowh", scope: "row", title: r, text: trunc(r, 32)}), ...cols.map(c => {
208
+ const v = value(r, c);
209
+ if (v == null || !isFinite(v)) return el("td", {class: "hcell empty-cell", text: "–"});
210
+ const p = Math.round(100 * Math.min(1, Math.max(0, (v - lo) / (hi - lo || 1))));
211
+ const td = el("td", {class: "hcell" + (p > 55 ? " dark" : ""), style: {
212
+ background: `color-mix(in oklab, var(--seq-hi) ${p}%, var(--seq-lo))`}, text: showText ? text(v, r, c) : ""});
213
+ withTip(td, `${r} · ${c}`, tip ? tip(v, r, c) : [text(v, r, c)]);
214
+ return td;
215
+ })));
216
+ return el("div", {class: "viz"},
217
+ el("div", {class: "legend"}, el("span", {text: fmtTick(lo)}), el("i", {class: "seq-bar"}), el("span", {text: fmtTick(hi)}),
218
+ caption ? el("span", {class: "hint", text: caption}) : null),
219
+ el("div", {class: "tablewrap heatwrap"}, el("table", {class: "heat"}, el("thead", {}, head), el("tbody", {}, ...body))));
220
+ }
221
+
222
+ // ---------- composition bar ----------
223
+
224
+ // Horizontal 100% bars of named parts (kept vs removed rows).
225
+ function splitBars(rows, parts) {
226
+ return el("div", {class: "viz"}, legend(parts.map(p => ({label: p.label, cls: p.cls}))),
227
+ el("div", {class: "hbars"}, ...rows.map(r => {
228
+ const total = parts.reduce((s, p) => s + (r[p.key] || 0), 0) || 1;
229
+ const removed = total - (r[parts[0].key] || 0);
230
+ return withTip(el("div", {class: "hb-row"},
231
+ el("span", {class: "hb-label", text: r.label}),
232
+ el("div", {class: "hb-track"}, ...parts.filter(p => r[p.key]).map(p =>
233
+ el("span", {class: "hb-seg c-" + p.cls, style: {flexGrow: String(r[p.key] / total), flexBasis: "0"}}))),
234
+ el("span", {class: "hb-val", text: removed ? "−" + pct(removed / total) : "0%"})),
235
+ r.label, parts.filter(p => r[p.key] != null).map(p => `${p.label}: ${fmtInt(r[p.key])} (${pct((r[p.key] || 0) / total)})`));
236
+ })));
237
+ }
238
+
239
+ // ---------- per-check evidence views ----------
240
+
241
+ // Each returns a list of nodes (or []) for a check's recorded details;
242
+ // `before` is the pre-deduplication report, which is where dedup_check's
243
+ // per-class rates live (after dedup they're zero by construction).
244
+ const CHECK_VIZ = {
245
+ class_distribution_report(d) {
246
+ const tr = d.train?.counts || {}, te = d.test?.counts || {};
247
+ const classes = [...new Set([...Object.keys(tr), ...Object.keys(te)])].sort((a, b) => (tr[b] || 0) - (tr[a] || 0));
248
+ if (!classes.length) return [];
249
+ const rare = new Set(d.rare_classes || []);
250
+ return [stats([["Classes", classes.length], ["Imbalance (majority:minority)",
251
+ d.imbalance_ratio != null ? fmtNum(Math.round(d.imbalance_ratio)) + ":1" : null],
252
+ ["Rare classes (<1% of train)", rare.size, rare.size ? "warning" : null]]),
253
+ dotPlot({rows: classes.map(c => ({label: c, values: {train: tr[c], test: te[c]}, mark: rare.has(c) ? "warning" : null,
254
+ tip: [`Train share: ${pct(d.train?.shares?.[c])}`, ...(rare.has(c) ? ["Below 1% of train"] : [])]})),
255
+ series: TRAIN_TEST, log: true, xLabel: "Rows per class (log scale)", fmt: fmtInt})];
256
+ },
257
+ dedup_check(d, before) {
258
+ const src = before && before.by_class ? before : d;
259
+ return [dedupComposition(src), dedupByClass(src)].filter(Boolean);
260
+ },
261
+ label_conflict_check(d) {
262
+ const by = d.by_class || {};
263
+ const rows = Object.entries(by).filter(([, v]) => v.conflicting_rate != null)
264
+ .sort((a, b) => b[1].conflicting_rate - a[1].conflicting_rate)
265
+ .map(([c, v]) => ({label: c, values: {v: v.conflicting_rate}}));
266
+ return [stats([["Conflicting groups", d.conflicting_groups != null ? fmtInt(d.conflicting_groups) : null],
267
+ ["Conflicting rows", d.conflicting_rows != null ? fmtInt(d.conflicting_rows) : null],
268
+ ["Groups spanning train and test", d.cross_split_conflicting_groups != null ? fmtInt(d.cross_split_conflicting_groups) : null]]),
269
+ rows.length ? dotPlot({rows, series: ONE("Rows with a conflicting label"), unit: true,
270
+ domain: [0, Math.max(...rows.map(r => r.values.v), 0.01) * 1.08], xLabel: "Share of the class's rows in a conflicting group"}) : null]
271
+ .filter(Boolean);
272
+ },
273
+ feature_auc_ranking_check(d) {
274
+ const top = Object.entries(d.top_features || {});
275
+ if (!top.length) return [];
276
+ return [stats([["Best feature", d.best_feature], ["Class it separates", d.best_class]]),
277
+ dotPlot({rows: top.map(([f, v]) => ({label: f, values: {v}, mark: f === d.best_feature ? "flag" : null})),
278
+ series: ONE("Standalone AUC"), domain: [0.5, 1], fmt: v => v.toFixed(4), xLabel: "Standalone ROC AUC (0.5 = chance)"})];
279
+ },
280
+ identity_column_flag(d) {
281
+ const auc = Object.entries(d.standalone_auc || {});
282
+ if (!auc.length) return [];
283
+ const drop = new Set(d.suggested_drop || []);
284
+ const out = [dotPlot({rows: auc.sort((a, b) => b[1] - a[1]).map(([c, v]) => ({label: c, values: {v},
285
+ mark: drop.has(c) ? "flag" : null, tip: drop.has(c) ? ["Suggested to drop"] : []})),
286
+ series: ONE("Standalone AUC"), domain: [0.5, 1], fmt: v => v.toFixed(3), xLabel: "Standalone ROC AUC (0.5 = chance)"})];
287
+ const by = d.by_class || {};
288
+ const cols = Object.keys(by), classes = [...new Set(cols.flatMap(c => Object.keys(by[c] || {})))];
289
+ if (cols.length && classes.length) out.push(el("h3", {text: "AUC per class (one class against the rest)"}),
290
+ heatmap({rows: cols, cols: classes, value: (r, c) => by[r]?.[c], text: v => v.toFixed(2), domain: [0.5, 1],
291
+ rowHead: "Column", caption: "values below 0.5 shown at the lightest step"}));
292
+ return out;
293
+ },
294
+ one_rule_check(d) {
295
+ if (d.train_accuracy == null && d.test_accuracy == null) return [];
296
+ return [stats([["Rule", d.rule]]),
297
+ dotPlot({rows: [{label: "Accuracy of the single rule", values: {train: d.train_accuracy, test: d.test_accuracy}}],
298
+ series: TRAIN_TEST, unit: true, domain: [0, 1], xLabel: "Accuracy"})];
299
+ },
300
+ homogeneity_test(d) {
301
+ const per = Object.entries(d.per_class || {});
302
+ if (!per.length) return [];
303
+ const hits = per.filter(([, v]) => v.leakage_signature).length;
304
+ return [stats([["Classes tested", per.length], ["Leakage signature", hits, hits ? "flag" : "ok"]]),
305
+ dotPlot({rows: per.sort((a, b) => b[1].test_near_zero_rate - a[1].test_near_zero_rate).map(([c, v]) => ({label: c,
306
+ values: {test: v.test_near_zero_rate, control: v.control_near_zero_rate}, mark: v.leakage_signature ? "flag" : null,
307
+ tip: [`p = ${v.p_value != null ? v.p_value.toPrecision(3) : "n/a"}`, ...(v.leakage_signature ? ["Leakage signature (p < 0.05)"] : [])]})),
308
+ series: [{key: "test", label: "Test rows", cls: "s2"}, {key: "control", label: "Control", cls: "ref"}],
309
+ unit: true, xLabel: "Share with a near-zero nearest-neighbour distance to train"})];
310
+ },
311
+ row_order_leakage_check(d) {
312
+ const c = d.contiguity_ratio;
313
+ if (!c) return [];
314
+ return [dotPlot({rows: [{label: "Label contiguity", values: {train: c.train, test: c.test}}], series: TRAIN_TEST,
315
+ domain: [0, 1], fmt: v => v.toFixed(4), xLabel: "Contiguity ratio (higher = rows grouped by label)"})];
316
+ },
317
+ repeated_seed_falsification_check(d) {
318
+ const drops = d.drops_by_seed || [];
319
+ if (!drops.length) return [];
320
+ const refs = [{value: 0}];
321
+ if (d.materiality_threshold != null) refs.push({value: d.materiality_threshold, label: "Materiality threshold",
322
+ short: "threshold"});
323
+ return [stats([["Seeds", d.n_seeds ?? drops.length], ["Mean drop", d.mean_drop != null ? d.mean_drop.toPrecision(3) : null],
324
+ ["95% interval", d.ci_95 ? d.ci_95.map(v => v.toPrecision(3)).join(" to ") : null],
325
+ ["Material", d.material == null ? null : d.material ? "yes" : "no", d.material ? "flag" : "ok"]]),
326
+ stripPlot({values: drops, band: d.ci_95, refs, name: "Seed", fmt: v => v.toPrecision(4), xLabel: "Accuracy drop per seed"})];
327
+ },
328
+ resplit_falsification(d) {
329
+ if (d.random_accuracy == null) return [];
330
+ return [stats([["Drop", d.drop != null ? d.drop.toPrecision(3) : null]]),
331
+ dotPlot({rows: [{label: "Random split", values: {v: d.random_accuracy}}, {label: "Grouped split", values: {v: d.grouped_accuracy}}],
332
+ series: ONE("Accuracy"), fmt: v => v.toFixed(5), xLabel: "Accuracy (axis zoomed to the data)"})];
333
+ },
334
+ result_robustness_check(d) {
335
+ const by = Object.entries(d.accuracy_by_condition || {});
336
+ if (!by.length) return [];
337
+ return [stats([["Spread", d.spread != null ? d.spread.toPrecision(3) : null], ["Best", d.best_condition?.replace(/_/g, " ")],
338
+ ["Worst", d.worst_condition?.replace(/_/g, " ")]]),
339
+ dotPlot({rows: by.sort((a, b) => b[1] - a[1]).map(([c, v]) => ({label: c.replace(/_/g, " "), values: {v}})),
340
+ series: ONE("Accuracy"), fmt: v => v.toFixed(4), xLabel: "Accuracy (axis zoomed to the data)"})];
341
+ },
342
+ };
343
+ function checkViz(check, details, before) {
344
+ const f = CHECK_VIZ[check];
345
+ if (!f || !details) return [];
346
+ try { return f(details, before).filter(Boolean); } catch (e) { console.warn("viz failed for", check, e); return []; }
347
+ }
348
+
349
+ function dedupComposition(d) {
350
+ if (d.train_rows_raw == null) return null;
351
+ return splitBars([
352
+ {label: "Train", kept: d.train_rows_deduped, dup: d.train_duplicates_dropped},
353
+ {label: "Test", kept: d.test_rows_deduped, leak: d.test_leakage_dropped, dup: d.test_duplicates_dropped},
354
+ ], [{key: "kept", label: "Kept", cls: "s1"}, {key: "leak", label: "Test row matching a train row", cls: "s2"},
355
+ {key: "dup", label: "Duplicate within split", cls: "ref"}]);
356
+ }
357
+ function dedupByClass(d) {
358
+ const by = Object.entries(d.by_class || {});
359
+ if (!by.length || by.every(([, v]) => !v.train_duplicate_rate && !v.test_leak_rate)) return null;
360
+ return dotPlot({rows: by.sort((a, b) => Math.max(b[1].train_duplicate_rate || 0, b[1].test_leak_rate || 0) -
361
+ Math.max(a[1].train_duplicate_rate || 0, a[1].test_leak_rate || 0))
362
+ .map(([c, v]) => ({label: c, values: {train: v.train_duplicate_rate, test: v.test_leak_rate}})),
363
+ series: [{key: "train", label: "Train duplicate rate", cls: "s1"}, {key: "test", label: "Test rows leaking a train match", cls: "s2"}],
364
+ unit: true, domain: [0, Math.min(1, Math.max(...by.flatMap(([, v]) => [v.train_duplicate_rate || 0, v.test_leak_rate || 0])) * 1.08 || 1)],
365
+ xLabel: "Share of the class's rows (before deduplication)"});
366
+ }
@@ -0,0 +1,33 @@
1
+ <!doctype html>
2
+ <html lang="en"><head><meta charset="utf-8">
3
+ <meta name="viewport" content="width=device-width,initial-scale=1">
4
+ <title>IDS2Eval</title>
5
+ <link rel="stylesheet" href="/static/style.css"></head>
6
+ <body>
7
+ <div class="shell">
8
+ <aside class="sidebar" id="sidebar">
9
+ <a class="brand" href="#/">IDS<sup>2</sup>Eval</a>
10
+ <a class="newrun" href="#/new">+ New run</a>
11
+ <nav class="nav" aria-label="Main">
12
+ <a href="#/" data-nav="dashboard">Dashboard</a>
13
+ <a href="#/datasets" data-nav="datasets">Datasets</a>
14
+ <a href="#/runs" data-nav="runs">Runs</a>
15
+ <a href="#/compare" data-nav="compare">Compare datasets</a>
16
+ </nav>
17
+ <div class="side-foot">Local dashboard · dataset audit &amp; benchmark</div>
18
+ </aside>
19
+ <section class="content">
20
+ <header class="top">
21
+ <button id="menu" class="icon-btn" aria-label="Open navigation" aria-controls="sidebar">☰</button>
22
+ <nav class="crumbs" id="crumbs" aria-label="Breadcrumb"></nav>
23
+ <span class="spacer"></span>
24
+ <button id="refresh" title="Reload runs from disk">Refresh</button>
25
+ <button id="theme" class="icon-btn" title="Theme"></button>
26
+ </header>
27
+ <main class="main" id="main"></main>
28
+ </section>
29
+ </div>
30
+ <div id="tip" class="tip" role="tooltip" hidden></div>
31
+ <script src="/static/charts.js"></script>
32
+ <script src="/static/app.js"></script>
33
+ </body></html>