idslab 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- idslab/__init__.py +5 -0
- idslab/adapters.py +214 -0
- idslab/cli.py +301 -0
- idslab/compare.py +375 -0
- idslab/config.py +1179 -0
- idslab/controls.py +185 -0
- idslab/dashboard/README.md +113 -0
- idslab/dashboard/__init__.py +3 -0
- idslab/dashboard/catalog.py +122 -0
- idslab/dashboard/jobs.py +333 -0
- idslab/dashboard/server.py +377 -0
- idslab/dashboard/static/app.js +188 -0
- idslab/dashboard/static/builder.js +884 -0
- idslab/dashboard/static/charts.js +151 -0
- idslab/dashboard/static/compare.js +231 -0
- idslab/dashboard/static/core.js +375 -0
- idslab/dashboard/static/draft.js +276 -0
- idslab/dashboard/static/experiments.js +413 -0
- idslab/dashboard/static/index.html +49 -0
- idslab/dashboard/static/jobs.js +122 -0
- idslab/dashboard/static/library.js +215 -0
- idslab/dashboard/static/results.js +313 -0
- idslab/dashboard/static/setup.js +567 -0
- idslab/dashboard/static/styles.css +421 -0
- idslab/dashboard/static/views.js +94 -0
- idslab/dashboard/workspace.py +778 -0
- idslab/data.py +268 -0
- idslab/examples/__init__.py +3 -0
- idslab/examples/r1_four_layer.yaml +66 -0
- idslab/examples/r1_router_gate.yaml +57 -0
- idslab/examples/r2_anomaly_first.yaml +45 -0
- idslab/external.py +196 -0
- idslab/features.py +96 -0
- idslab/fitting.py +429 -0
- idslab/graph.py +174 -0
- idslab/html_report.py +314 -0
- idslab/metrics.py +365 -0
- idslab/plugins.py +64 -0
- idslab/prep.py +74 -0
- idslab/report.py +453 -0
- idslab/runner.py +529 -0
- idslab/selection.py +308 -0
- idslab/sweep.py +261 -0
- idslab/synthetic.py +56 -0
- idslab/torch_models.py +169 -0
- idslab-0.3.0.dist-info/METADATA +254 -0
- idslab-0.3.0.dist-info/RECORD +51 -0
- idslab-0.3.0.dist-info/WHEEL +5 -0
- idslab-0.3.0.dist-info/entry_points.txt +2 -0
- idslab-0.3.0.dist-info/licenses/LICENSE +661 -0
- idslab-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 hashtagRR
|
|
3
|
+
//
|
|
4
|
+
// Visual cascade builder. It edits the shared draft's config data; the server writes the YAML and
|
|
5
|
+
// validates it with the CLI's loader after every change. Routes can be drawn by dragging a port,
|
|
6
|
+
// by clicking a port and then a destination, from the keyboard, or with the route lists in the
|
|
7
|
+
// inspector.
|
|
8
|
+
|
|
9
|
+
import {el, svg, card, cardBody, note, toast, field, checkbox, ask, rerender, KIND, KIND_SHORT, plainError} from "./core.js";
|
|
10
|
+
import {bound, parseList, listText, names} from "./draft.js";
|
|
11
|
+
|
|
12
|
+
const TERMS = ["ATTACK", "BENIGN"];
|
|
13
|
+
const OUTPUTS = {classifier: ["attack", "benign"], detector: ["anomalous", "normal"]};
|
|
14
|
+
const DECISIONS = {
|
|
15
|
+
classifier: ["threshold", "grid"],
|
|
16
|
+
detector: ["threshold", "grid", "budget", "top_fraction"],
|
|
17
|
+
multiclass: ["confidence"],
|
|
18
|
+
};
|
|
19
|
+
const DECISION_HINT = {
|
|
20
|
+
threshold: "one number; a score at or above it takes attack (or anomalous)",
|
|
21
|
+
grid: "a list of cutoffs; the best on VAL is kept, and switching the stage off is one of the options",
|
|
22
|
+
budget: "share of benign VAL flows the detector may flag; a list is chosen on VAL",
|
|
23
|
+
top_fraction: "share of the flows reaching the stage to flag",
|
|
24
|
+
confidence: "below this top probability the type is uncertain; a list is chosen on VAL",
|
|
25
|
+
};
|
|
26
|
+
const NW = 196, PORT = 20, COLW = 252, ROWH = 150;
|
|
27
|
+
|
|
28
|
+
const shortClass = (c) => String(c || "").split(".").pop().replace("Classifier", "");
|
|
29
|
+
|
|
30
|
+
export function outputsOf(st) {
|
|
31
|
+
if (st.kind === "multiclass") {
|
|
32
|
+
if ((st.role || "annotate") === "annotate") return ["next"];
|
|
33
|
+
return Object.keys(st.routes || {}).length ? Object.keys(st.routes) : ["default"];
|
|
34
|
+
}
|
|
35
|
+
return OUTPUTS[st.kind] || [];
|
|
36
|
+
}
|
|
37
|
+
const targetOf = (st, out) => (out === "next" ? st.next : (st.routes || {})[out]);
|
|
38
|
+
function setTarget(st, out, target) {
|
|
39
|
+
if (out === "next") { if (target) st.next = target; else delete st.next; return; }
|
|
40
|
+
st.routes = {...(st.routes || {})};
|
|
41
|
+
if (target) st.routes[out] = target; else delete st.routes[out];
|
|
42
|
+
}
|
|
43
|
+
const outName = (o) => (o === "next" ? "output" : `${o} output`);
|
|
44
|
+
|
|
45
|
+
// Problems visible in the drawing itself: outputs without a destination, loops, stages no route
|
|
46
|
+
// reaches. The server's validation covers everything else; the draft stays editable either way.
|
|
47
|
+
export function graphIssues(raw) {
|
|
48
|
+
const st = raw.stages || {};
|
|
49
|
+
const out = [];
|
|
50
|
+
if (!st[raw.entry]) out.push({stage: null, text: "Choose an entry stage: the stage every flow starts at."});
|
|
51
|
+
for (const [n, s] of Object.entries(st)) {
|
|
52
|
+
for (const o of outputsOf(s)) {
|
|
53
|
+
const t = targetOf(s, o);
|
|
54
|
+
if (!t) out.push({stage: n, out: o, text: `${n}: the ${outName(o)} has no destination. Choose a stage, ATTACK or BENIGN.`});
|
|
55
|
+
else if (!st[t] && !TERMS.includes(t)) out.push({stage: n, out: o, text: `${n}: the ${outName(o)} goes to ${t}, which is not a stage. Choose a stage, ATTACK or BENIGN.`});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const cyc = cycle(st);
|
|
59
|
+
if (cyc) out.push({stage: cyc[0], cycle: cyc, text: `The routes loop: ${cyc.join(" to ")}. Point one of these outputs to a later stage or a terminal.`});
|
|
60
|
+
const reached = new Set();
|
|
61
|
+
const walk = (n) => {
|
|
62
|
+
if (!st[n] || reached.has(n)) return;
|
|
63
|
+
reached.add(n);
|
|
64
|
+
for (const o of outputsOf(st[n])) walk(targetOf(st[n], o));
|
|
65
|
+
};
|
|
66
|
+
walk(raw.entry);
|
|
67
|
+
const sources = new Set(Object.values(st).flatMap((s) => [...(s.extra_features || []), s.hard?.source].filter(Boolean)));
|
|
68
|
+
for (const n of Object.keys(st)) {
|
|
69
|
+
if (!reached.has(n) && !sources.has(n) && st[raw.entry]) {
|
|
70
|
+
out.push({stage: n, text: `${n} is not reached: no route leads to it from the entry stage. Send an output to it, or remove it.`});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
function cycle(st) {
|
|
76
|
+
const color = {}, stack = [];
|
|
77
|
+
let found = null;
|
|
78
|
+
const visit = (n) => {
|
|
79
|
+
color[n] = 1;
|
|
80
|
+
stack.push(n);
|
|
81
|
+
for (const o of outputsOf(st[n])) {
|
|
82
|
+
const t = targetOf(st[n], o);
|
|
83
|
+
if (!st[t] || found) continue;
|
|
84
|
+
if (color[t] === 1) { found = [...stack.slice(stack.indexOf(t)), t]; return; }
|
|
85
|
+
if (!color[t]) visit(t);
|
|
86
|
+
}
|
|
87
|
+
stack.pop();
|
|
88
|
+
color[n] = 2;
|
|
89
|
+
};
|
|
90
|
+
for (const n of Object.keys(st)) if (!color[n] && !found) visit(n);
|
|
91
|
+
return found;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------- edits on the config ----------
|
|
95
|
+
|
|
96
|
+
function encodeStep(draft) {
|
|
97
|
+
const cats = draft.dataset?.suggest?.categorical;
|
|
98
|
+
const existing = Object.values(draft.raw.stages || {}).flatMap((s) => s.pipeline || []).find((p) => p.class === "idslab.prep.Encode");
|
|
99
|
+
const categorical = existing?.params?.categorical || cats;
|
|
100
|
+
return categorical?.length ? [{class: "idslab.prep.Encode", params: {categorical: [...categorical]}}] : [];
|
|
101
|
+
}
|
|
102
|
+
export function stageDefaults(kind, prev = {}) {
|
|
103
|
+
const pipeline = prev.pipeline;
|
|
104
|
+
if (kind === "classifier") {
|
|
105
|
+
return {kind, pipeline, candidates: [{class: "sklearn.ensemble.RandomForestClassifier"}],
|
|
106
|
+
decision: {threshold: 0.5, off: "benign"}, routes: {attack: "ATTACK", benign: "BENIGN"}};
|
|
107
|
+
}
|
|
108
|
+
if (kind === "detector") {
|
|
109
|
+
return {kind, fit_on: "benign", pipeline, candidates: [{class: "sklearn.ensemble.IsolationForest"}],
|
|
110
|
+
decision: {budget: [0.01, 0.05], off: "normal"}, routes: {anomalous: "ATTACK", normal: "BENIGN"}};
|
|
111
|
+
}
|
|
112
|
+
return {kind: "multiclass", role: "annotate", pipeline, candidates: [{class: "sklearn.ensemble.HistGradientBoostingClassifier"}],
|
|
113
|
+
decision: {confidence: [0.0, 0.2, 0.4]}, next: "ATTACK"};
|
|
114
|
+
}
|
|
115
|
+
export function addStage(draft, kind) {
|
|
116
|
+
const raw = draft.raw;
|
|
117
|
+
raw.stages = raw.stages || {};
|
|
118
|
+
const base = {classifier: "classifier", detector: "detector", multiclass: "type"}[kind];
|
|
119
|
+
let n = base, i = 1;
|
|
120
|
+
while (raw.stages[n]) n = `${base}${++i}`;
|
|
121
|
+
const st = stageDefaults(kind, {pipeline: encodeStep(draft)});
|
|
122
|
+
if (!st.pipeline.length) delete st.pipeline;
|
|
123
|
+
raw.stages[n] = st;
|
|
124
|
+
if (!raw.entry || !raw.stages[raw.entry]) raw.entry = n;
|
|
125
|
+
return n;
|
|
126
|
+
}
|
|
127
|
+
function renameStage(draft, from, to) {
|
|
128
|
+
const raw = draft.raw;
|
|
129
|
+
const stages = {};
|
|
130
|
+
for (const [n, s] of Object.entries(raw.stages)) stages[n === from ? to : n] = s;
|
|
131
|
+
raw.stages = stages;
|
|
132
|
+
for (const s of Object.values(stages)) {
|
|
133
|
+
for (const k of Object.keys(s.routes || {})) if (s.routes[k] === from) s.routes[k] = to;
|
|
134
|
+
if (s.next === from) s.next = to;
|
|
135
|
+
if (s.extra_features) s.extra_features = s.extra_features.map((x) => (x === from ? to : x));
|
|
136
|
+
if (s.hard?.source === from) s.hard.source = to;
|
|
137
|
+
}
|
|
138
|
+
if (raw.entry === from) raw.entry = to;
|
|
139
|
+
if (raw.controls?.first_stage === from) raw.controls.first_stage = to;
|
|
140
|
+
if (draft.positions[from]) { draft.positions[to] = draft.positions[from]; delete draft.positions[from]; }
|
|
141
|
+
}
|
|
142
|
+
function removeStage(draft, name) {
|
|
143
|
+
const raw = draft.raw;
|
|
144
|
+
delete raw.stages[name];
|
|
145
|
+
delete draft.positions[name];
|
|
146
|
+
for (const s of Object.values(raw.stages)) {
|
|
147
|
+
for (const k of Object.keys(s.routes || {})) if (s.routes[k] === name) delete s.routes[k];
|
|
148
|
+
if (s.next === name) delete s.next;
|
|
149
|
+
if (s.extra_features) {
|
|
150
|
+
s.extra_features = s.extra_features.filter((x) => x !== name);
|
|
151
|
+
if (!s.extra_features.length) delete s.extra_features;
|
|
152
|
+
}
|
|
153
|
+
if (s.hard?.source === name) delete s.hard.source;
|
|
154
|
+
}
|
|
155
|
+
if (raw.entry === name) raw.entry = Object.keys(raw.stages)[0] || "";
|
|
156
|
+
if (raw.controls?.first_stage === name) delete raw.controls.first_stage;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------- the component ----------
|
|
160
|
+
|
|
161
|
+
// box: the element to fill. ctx: {catalog}. Returns {destroy}.
|
|
162
|
+
export function mountCascade(box, draft, ctx) {
|
|
163
|
+
const UI = {selected: draft.raw?.entry || Object.keys(draft.raw?.stages || {})[0] || null, pending: null,
|
|
164
|
+
open: new Set(["candidates"])};
|
|
165
|
+
const issuesBox = el("div", {class: "stack", "aria-live": "polite"});
|
|
166
|
+
const hint = el("div", {class: "bl-hint muted small", role: "status"});
|
|
167
|
+
const canvasWrap = el("div", {class: "bl-canvas"});
|
|
168
|
+
const inspector = el("div", {class: "stack bl-inspector"});
|
|
169
|
+
|
|
170
|
+
const palette = el("div", {class: "bl-palette"},
|
|
171
|
+
el("div", {class: "section-title", text: "Add a stage"}),
|
|
172
|
+
["classifier", "detector", "multiclass"].map((k) => el("button", {
|
|
173
|
+
class: `bl-chip ${k}`, type: "button", draggable: "true", title: "Click to add, or drag onto the canvas",
|
|
174
|
+
ondragstart: (e) => e.dataTransfer.setData("text/x-idslab-kind", k),
|
|
175
|
+
onclick: () => { const n = addStage(draft, k); UI.selected = n; draft.commit(); },
|
|
176
|
+
}, el("b", {text: KIND_SHORT[k]}), el("small", {text: {
|
|
177
|
+
classifier: "attack or benign", detector: "anomalous or normal", multiclass: "names the attack type",
|
|
178
|
+
}[k]}))),
|
|
179
|
+
el("div", {class: "section-title", text: "How to connect"}),
|
|
180
|
+
el("ul", {class: "bl-help"},
|
|
181
|
+
el("li", {text: "Drag a port (the dots on a stage's right edge) onto a stage or a terminal."}),
|
|
182
|
+
el("li", {text: "Or click a port, then click where that output should go. Escape cancels."}),
|
|
183
|
+
el("li", {text: "From the keyboard: Tab to a port, press Enter, Tab to the destination, press Enter."}),
|
|
184
|
+
el("li", {text: "The orange diamond under a stage passes its score to a later stage as a feature."}),
|
|
185
|
+
el("li", {text: "Drag stages, or use the arrow keys on a selected stage, to arrange them. Delete removes it."})));
|
|
186
|
+
|
|
187
|
+
box.replaceChildren(el("div", {class: "stack"}, issuesBox,
|
|
188
|
+
el("div", {class: "bl-grid"}, palette, el("div", {class: "stack"}, hint, canvasWrap), el("div", {class: "bl-side-panel"}, inspector))));
|
|
189
|
+
|
|
190
|
+
const unsub = draft.on((what) => {
|
|
191
|
+
if (what === "text") return; // the YAML view is typing; redraw once it is parsed
|
|
192
|
+
if (!draft.raw) { renderUnavailable(); return; }
|
|
193
|
+
if (UI.selected && !draft.raw.stages?.[UI.selected]) UI.selected = draft.raw.entry || null;
|
|
194
|
+
renderIssues();
|
|
195
|
+
renderCanvas();
|
|
196
|
+
if (what === "validation" || what === "layout") markInspectorErrors();
|
|
197
|
+
else if (what !== "dataset") rerender(inspector, renderInspector);
|
|
198
|
+
});
|
|
199
|
+
const onKey = (e) => {
|
|
200
|
+
if (e.key === "Escape" && UI.pending) { UI.pending = null; renderCanvas(); setHint(); }
|
|
201
|
+
};
|
|
202
|
+
addEventListener("keydown", onKey);
|
|
203
|
+
|
|
204
|
+
function renderUnavailable() {
|
|
205
|
+
issuesBox.replaceChildren(note("The YAML does not parse, so the cascade cannot be drawn. Fix it in the YAML view; " +
|
|
206
|
+
`the loader says: ${draft.validation?.error || "unknown error"}`, "bad"));
|
|
207
|
+
canvasWrap.replaceChildren();
|
|
208
|
+
inspector.replaceChildren();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function setHint() {
|
|
212
|
+
const p = UI.pending;
|
|
213
|
+
hint.textContent = p
|
|
214
|
+
? (p.feed ? `Choose the stage that should use ${p.from}'s score: click it or press Enter on it. Escape cancels.`
|
|
215
|
+
: `Choose where ${p.from}'s ${outName(p.out)} goes: click a stage or ATTACK or BENIGN, or press Enter on it. Escape cancels.`)
|
|
216
|
+
: "";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function connect(target) {
|
|
220
|
+
const p = UI.pending;
|
|
221
|
+
UI.pending = null;
|
|
222
|
+
setHint();
|
|
223
|
+
const st = draft.raw.stages;
|
|
224
|
+
if (!p || !st[p.from]) return;
|
|
225
|
+
if (p.feed) {
|
|
226
|
+
if (!st[target] || target === p.from) { toast("A score can feed another stage, not a terminal"); renderCanvas(); return; }
|
|
227
|
+
st[target].extra_features = [...new Set([...(st[target].extra_features || []), p.from])];
|
|
228
|
+
} else {
|
|
229
|
+
if (target === p.from) { toast("A stage cannot route to itself"); renderCanvas(); return; }
|
|
230
|
+
setTarget(st[p.from], p.out, target);
|
|
231
|
+
}
|
|
232
|
+
draft.commit();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function select(name) {
|
|
236
|
+
UI.selected = name;
|
|
237
|
+
renderCanvas();
|
|
238
|
+
renderInspector();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------- issues ----------
|
|
242
|
+
|
|
243
|
+
function renderIssues() {
|
|
244
|
+
const list = graphIssues(draft.raw);
|
|
245
|
+
issuesBox.replaceChildren(...list.map((i) => el("div", {class: `note ${i.warn ? "warn" : "bad"} bl-issue`},
|
|
246
|
+
el("span", {text: i.text}),
|
|
247
|
+
i.stage && draft.raw.stages[i.stage] ? el("button", {class: "btn small", type: "button", text: "Show",
|
|
248
|
+
onclick: () => {
|
|
249
|
+
select(i.stage);
|
|
250
|
+
if (i.out) { UI.pending = {from: i.stage, out: i.out}; setHint(); renderCanvas(); }
|
|
251
|
+
}}) : null)));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------- canvas ----------
|
|
255
|
+
|
|
256
|
+
function layout() {
|
|
257
|
+
const st = draft.raw.stages || {}, all = Object.keys(st);
|
|
258
|
+
const col = {};
|
|
259
|
+
if (st[draft.raw.entry]) col[draft.raw.entry] = 0;
|
|
260
|
+
for (let pass = 0; pass < all.length; pass++) {
|
|
261
|
+
for (const n of all) {
|
|
262
|
+
if (col[n] == null) continue;
|
|
263
|
+
for (const o of outputsOf(st[n])) {
|
|
264
|
+
const t = targetOf(st[n], o);
|
|
265
|
+
if (st[t] && (col[t] == null || col[t] < col[n] + 1) && col[n] + 1 < all.length) col[t] = col[n] + 1;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const rows = {}, pos = {};
|
|
270
|
+
for (const n of all) {
|
|
271
|
+
const c = col[n] ?? 0;
|
|
272
|
+
rows[c] = (rows[c] || 0) + 1;
|
|
273
|
+
pos[n] = draft.positions[n] || [40 + c * COLW, 40 + (rows[c] - 1) * ROWH];
|
|
274
|
+
}
|
|
275
|
+
return pos;
|
|
276
|
+
}
|
|
277
|
+
const nodeH = (s) => Math.max(84, 50 + outputsOf(s).length * PORT);
|
|
278
|
+
|
|
279
|
+
function renderCanvas() {
|
|
280
|
+
const focusKey = document.activeElement?.getAttribute?.("data-key");
|
|
281
|
+
const st = draft.raw.stages || {};
|
|
282
|
+
const pos = layout();
|
|
283
|
+
const cyc = graphIssues(draft.raw).find((i) => i.cycle)?.cycle || [];
|
|
284
|
+
const cycEdges = new Set(cyc.slice(0, -1).map((n, i) => `${n}>${cyc[i + 1]}`));
|
|
285
|
+
const errStages = new Set(draft.errors().map((l) => /^stage (\S+?)(?=[: ])/.exec(l)?.[1]).filter(Boolean));
|
|
286
|
+
const xs = Object.values(pos).map((p) => p[0] + NW), ys = Object.values(pos).map((p) => p[1]);
|
|
287
|
+
const termX = Math.max(300, ...xs) + 90;
|
|
288
|
+
const termPos = {ATTACK: [termX, 40], BENIGN: [termX, Math.max(220, ...ys.map((y) => y + 60))]};
|
|
289
|
+
const W = termX + 140, H = Math.max(420, ...ys.map((y) => y + 200), termPos.BENIGN[1] + 120);
|
|
290
|
+
const root = svg("svg", {class: `bl-svg${UI.pending ? " routing" : ""}`, viewBox: `0 0 ${W} ${H}`,
|
|
291
|
+
role: "group", "aria-label": "Cascade canvas",
|
|
292
|
+
style: {width: "100%", minWidth: `${Math.round(W * 0.75)}px`}},
|
|
293
|
+
svg("defs", {}, ...["bl-arr", "bl-arr-feed", "bl-arr-bad"].map((id) => svg("marker", {id, viewBox: "0 0 8 8", refX: 7, refY: 4,
|
|
294
|
+
markerWidth: 7, markerHeight: 7, orient: "auto"}, svg("path", {d: "M0,0 L8,4 L0,8 z",
|
|
295
|
+
class: `bl-arrow${id === "bl-arr-feed" ? " feed" : id === "bl-arr-bad" ? " bad" : ""}`})))));
|
|
296
|
+
const edges = svg("g", {}), nodes = svg("g", {});
|
|
297
|
+
root.append(edges, nodes);
|
|
298
|
+
const inPoint = (t) => (st[t] ? [pos[t][0], pos[t][1] + 24] : termPos[t] ? [termPos[t][0], termPos[t][1] + 20] : null);
|
|
299
|
+
|
|
300
|
+
for (const [name, s] of Object.entries(st)) {
|
|
301
|
+
const [x, y] = pos[name];
|
|
302
|
+
outputsOf(s).forEach((o, i) => {
|
|
303
|
+
const t = targetOf(s, o), b = inPoint(t);
|
|
304
|
+
const a = [x + NW, y + 50 + i * PORT];
|
|
305
|
+
if (!b) { // an output with no destination: a short dashed stub, so the gap is visible
|
|
306
|
+
edges.append(svg("path", {class: "bl-edge missing", d: `M${a[0]},${a[1]} L${a[0] + 34},${a[1]}`}),
|
|
307
|
+
svg("text", {class: "bl-missing-label", x: a[0] + 38, y: a[1] + 4, text: "?"}));
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const mx = Math.max(a[0] + 30, (a[0] + b[0]) / 2);
|
|
311
|
+
const bad = cycEdges.has(`${name}>${t}`);
|
|
312
|
+
edges.append(svg("path", {class: `bl-edge${bad ? " cycle" : ""}`, d: `M${a[0]},${a[1]} C${mx},${a[1]} ${mx},${b[1]} ${b[0] - 3},${b[1]}`,
|
|
313
|
+
"marker-end": bad ? "url(#bl-arr-bad)" : "url(#bl-arr)"}));
|
|
314
|
+
});
|
|
315
|
+
for (const src of s.extra_features || []) {
|
|
316
|
+
if (!pos[src]) continue;
|
|
317
|
+
const a = [pos[src][0] + NW / 2, pos[src][1] + nodeH(st[src])], b = [x + NW / 2, y];
|
|
318
|
+
const dip = Math.max(a[1], b[1]) + 40;
|
|
319
|
+
const g = svg("path", {class: "bl-edge feed", d: `M${a[0]},${a[1]} C${a[0]},${dip} ${b[0]},${b[1] - 60} ${b[0]},${b[1] - 3}`,
|
|
320
|
+
"marker-end": "url(#bl-arr-feed)"});
|
|
321
|
+
g.append(svg("title", {text: `${src} score feeds ${name}. Click to remove.`}));
|
|
322
|
+
g.addEventListener("click", () => {
|
|
323
|
+
s.extra_features = s.extra_features.filter((z) => z !== src);
|
|
324
|
+
if (!s.extra_features.length) delete s.extra_features;
|
|
325
|
+
draft.commit();
|
|
326
|
+
});
|
|
327
|
+
edges.append(g);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
for (const t of TERMS) {
|
|
331
|
+
const [x, y] = termPos[t];
|
|
332
|
+
const g = svg("g", {class: `bl-term ${t.toLowerCase()}`, "data-target": t, "data-key": `t:${t}`,
|
|
333
|
+
transform: `translate(${x},${y})`, tabindex: UI.pending && !UI.pending.feed ? "0" : "-1", role: "button",
|
|
334
|
+
"aria-label": `terminal ${t}`},
|
|
335
|
+
svg("rect", {width: 120, height: 40, rx: 20}),
|
|
336
|
+
svg("text", {x: 60, y: 25, "text-anchor": "middle", text: t}));
|
|
337
|
+
const pick = () => { if (UI.pending) connect(t); };
|
|
338
|
+
g.addEventListener("click", pick);
|
|
339
|
+
g.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); pick(); } });
|
|
340
|
+
nodes.append(g);
|
|
341
|
+
}
|
|
342
|
+
for (const [name, s] of Object.entries(st)) {
|
|
343
|
+
const [x, y] = pos[name];
|
|
344
|
+
const h = nodeH(s);
|
|
345
|
+
const cls = ["bl-node", s.kind, name === UI.selected ? "sel" : "", name === draft.raw.entry ? "entry" : "",
|
|
346
|
+
errStages.has(name) ? "err" : "", UI.pending ? "target" : ""].join(" ");
|
|
347
|
+
const g = svg("g", {class: cls, "data-target": name, "data-key": `n:${name}`, transform: `translate(${x},${y})`,
|
|
348
|
+
tabindex: "0", role: "button", "aria-pressed": String(name === UI.selected),
|
|
349
|
+
"aria-label": `${KIND_SHORT[s.kind] || s.kind} stage ${name}${name === draft.raw.entry ? ", entry" : ""}`});
|
|
350
|
+
g.append(svg("rect", {class: "body", width: NW, height: h, rx: 10}),
|
|
351
|
+
svg("rect", {class: "stripe", x: 0, y: 10, width: 4, height: h - 20, rx: 2}),
|
|
352
|
+
svg("text", {class: "t", x: 14, y: 22, text: name + (name === draft.raw.entry ? " (entry)" : "")}),
|
|
353
|
+
svg("text", {class: "m", x: 14, y: 38, text: `${KIND_SHORT[s.kind] || s.kind}: ${(s.candidates || []).map((c) => c.external ? "score file" : shortClass(c.class)).join(", ").slice(0, 24)}`}));
|
|
354
|
+
outputsOf(s).forEach((o, i) => {
|
|
355
|
+
const py = 50 + i * PORT;
|
|
356
|
+
const t = targetOf(s, o);
|
|
357
|
+
const linked = inPoint(t);
|
|
358
|
+
const active = UI.pending && !UI.pending.feed && UI.pending.from === name && UI.pending.out === o;
|
|
359
|
+
g.append(svg("text", {class: "port-label", x: NW - 14, y: py + 4, "text-anchor": "end", text: o}));
|
|
360
|
+
const port = svg("circle", {class: `port${linked ? "" : " open"}${active ? " active" : ""}`, cx: NW, cy: py, r: 7,
|
|
361
|
+
tabindex: "0", role: "button", "data-key": `p:${name}:${o}`,
|
|
362
|
+
"aria-label": `${name} ${outName(o)}, goes to ${t || "nowhere yet"}. Press Enter to choose a destination.`});
|
|
363
|
+
port.append(svg("title", {text: `${o}: drag to a stage or terminal, or click and then click the destination`}));
|
|
364
|
+
port.addEventListener("pointerdown", (e) => startWire(e, root, [x + NW, y + py], (hit) => {
|
|
365
|
+
UI.pending = {from: name, out: o};
|
|
366
|
+
connect(hit);
|
|
367
|
+
}, () => { UI.pending = {from: name, out: o}; setHint(); renderCanvas(); }));
|
|
368
|
+
port.addEventListener("keydown", (e) => {
|
|
369
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
370
|
+
e.preventDefault();
|
|
371
|
+
e.stopPropagation();
|
|
372
|
+
UI.pending = {from: name, out: o};
|
|
373
|
+
setHint();
|
|
374
|
+
renderCanvas();
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
g.append(port);
|
|
378
|
+
});
|
|
379
|
+
if (s.kind !== "detector" || s.fit_on !== "pool") {
|
|
380
|
+
const active = UI.pending?.feed && UI.pending.from === name;
|
|
381
|
+
const sp = svg("rect", {class: `score-port${active ? " active" : ""}`, x: NW / 2 - 7, y: h - 7, width: 14, height: 14, rx: 2,
|
|
382
|
+
transform: `rotate(45 ${NW / 2} ${h})`, tabindex: "0", role: "button", "data-key": `f:${name}`,
|
|
383
|
+
"aria-label": `${name} score. Press Enter, then choose a later stage to use it as a feature.`});
|
|
384
|
+
sp.append(svg("title", {text: "score: drag (or click, then click) to a later stage to use it as a feature"}));
|
|
385
|
+
const begin = () => { UI.pending = {from: name, feed: true}; setHint(); renderCanvas(); };
|
|
386
|
+
sp.addEventListener("pointerdown", (e) => startWire(e, root, [x + NW / 2, y + h], (hit) => {
|
|
387
|
+
UI.pending = {from: name, feed: true};
|
|
388
|
+
connect(hit);
|
|
389
|
+
}, begin, true));
|
|
390
|
+
sp.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); begin(); } });
|
|
391
|
+
g.append(sp);
|
|
392
|
+
}
|
|
393
|
+
g.addEventListener("pointerdown", (e) => {
|
|
394
|
+
if (e.target.closest(".port, .score-port")) return;
|
|
395
|
+
startMove(e, root, name, pos);
|
|
396
|
+
});
|
|
397
|
+
g.addEventListener("keydown", (e) => {
|
|
398
|
+
if (e.target !== g) return;
|
|
399
|
+
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (UI.pending) connect(name); else select(name); return; }
|
|
400
|
+
if (e.key === "Delete" || e.key === "Backspace") { e.preventDefault(); confirmRemove(name); return; }
|
|
401
|
+
const step = {ArrowLeft: [-10, 0], ArrowRight: [10, 0], ArrowUp: [0, -10], ArrowDown: [0, 10]}[e.key];
|
|
402
|
+
if (step) {
|
|
403
|
+
e.preventDefault();
|
|
404
|
+
const cur = layout();
|
|
405
|
+
draft.positions = {...cur, [name]: [Math.max(0, cur[name][0] + step[0]), Math.max(0, cur[name][1] + step[1])]};
|
|
406
|
+
draft.touched = true;
|
|
407
|
+
draft.emit("layout");
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
nodes.append(g);
|
|
411
|
+
}
|
|
412
|
+
root.addEventListener("dragover", (e) => e.preventDefault());
|
|
413
|
+
root.addEventListener("drop", (e) => {
|
|
414
|
+
const kind = e.dataTransfer.getData("text/x-idslab-kind");
|
|
415
|
+
if (!kind) return;
|
|
416
|
+
e.preventDefault();
|
|
417
|
+
const p = toSvg(root, e);
|
|
418
|
+
const n = addStage(draft, kind);
|
|
419
|
+
draft.positions = {...layout(), [n]: [Math.max(10, p.x - NW / 2), Math.max(10, p.y - 30)]};
|
|
420
|
+
UI.selected = n;
|
|
421
|
+
draft.commit();
|
|
422
|
+
});
|
|
423
|
+
canvasWrap.replaceChildren(root);
|
|
424
|
+
if (focusKey) root.querySelector(`[data-key="${CSS.escape(focusKey)}"]`)?.focus();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function toSvg(root, e) { // client coordinates to drawing coordinates (the drawing is scaled to fit)
|
|
428
|
+
const pt = root.createSVGPoint();
|
|
429
|
+
pt.x = e.clientX;
|
|
430
|
+
pt.y = e.clientY;
|
|
431
|
+
const r = pt.matrixTransform(root.getScreenCTM().inverse());
|
|
432
|
+
return {x: r.x, y: r.y};
|
|
433
|
+
}
|
|
434
|
+
// drag from a port; a press without movement counts as a click, which starts click-to-route
|
|
435
|
+
function startWire(e, root, from, onDrop, onClick, feed = false) {
|
|
436
|
+
e.preventDefault();
|
|
437
|
+
e.stopPropagation();
|
|
438
|
+
const start = [e.clientX, e.clientY];
|
|
439
|
+
let moved = false;
|
|
440
|
+
const line = svg("path", {class: `bl-edge temp${feed ? " feed" : ""}`});
|
|
441
|
+
root.append(line);
|
|
442
|
+
const move = (ev) => {
|
|
443
|
+
if (Math.abs(ev.clientX - start[0]) + Math.abs(ev.clientY - start[1]) > 4) moved = true;
|
|
444
|
+
const p = toSvg(root, ev);
|
|
445
|
+
line.setAttribute("d", `M${from[0]},${from[1]} L${p.x},${p.y}`);
|
|
446
|
+
};
|
|
447
|
+
const up = (ev) => {
|
|
448
|
+
removeEventListener("pointermove", move);
|
|
449
|
+
removeEventListener("pointerup", up);
|
|
450
|
+
line.remove();
|
|
451
|
+
if (!moved) { onClick(); return; }
|
|
452
|
+
const hit = document.elementFromPoint(ev.clientX, ev.clientY)?.closest("[data-target]");
|
|
453
|
+
if (hit) onDrop(hit.getAttribute("data-target"));
|
|
454
|
+
};
|
|
455
|
+
addEventListener("pointermove", move);
|
|
456
|
+
addEventListener("pointerup", up);
|
|
457
|
+
}
|
|
458
|
+
function startMove(e, root, name, pos) {
|
|
459
|
+
const start = toSvg(root, e), origin = [...pos[name]];
|
|
460
|
+
let moved = false;
|
|
461
|
+
const node = e.target.closest(".bl-node");
|
|
462
|
+
const move = (ev) => {
|
|
463
|
+
const p = toSvg(root, ev);
|
|
464
|
+
const dx = p.x - start.x, dy = p.y - start.y;
|
|
465
|
+
if (Math.abs(dx) + Math.abs(dy) > 3) moved = true;
|
|
466
|
+
node.setAttribute("transform", `translate(${Math.max(0, origin[0] + dx)},${Math.max(0, origin[1] + dy)})`);
|
|
467
|
+
};
|
|
468
|
+
const up = (ev) => {
|
|
469
|
+
removeEventListener("pointermove", move);
|
|
470
|
+
removeEventListener("pointerup", up);
|
|
471
|
+
if (moved) {
|
|
472
|
+
const p = toSvg(root, ev);
|
|
473
|
+
draft.positions = {...layout(), [name]: [Math.max(0, origin[0] + p.x - start.x), Math.max(0, origin[1] + p.y - start.y)]};
|
|
474
|
+
draft.touched = true;
|
|
475
|
+
UI.selected = name;
|
|
476
|
+
draft.emit("layout");
|
|
477
|
+
renderInspector();
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (UI.pending) { connect(name); return; }
|
|
481
|
+
select(name);
|
|
482
|
+
};
|
|
483
|
+
addEventListener("pointermove", move);
|
|
484
|
+
addEventListener("pointerup", up);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function confirmRemove(name) {
|
|
488
|
+
if (!(await ask("Remove stage", `Remove ${name}? Outputs that led to it are left without a destination until you choose a new one.`,
|
|
489
|
+
"Remove", "danger"))) return;
|
|
490
|
+
removeStage(draft, name);
|
|
491
|
+
UI.selected = draft.raw.entry || null;
|
|
492
|
+
draft.commit();
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ---------- inspector ----------
|
|
496
|
+
|
|
497
|
+
function section(key, title, body, sub) {
|
|
498
|
+
const d = el("details", {class: "trace bl-section", open: UI.open.has(key)},
|
|
499
|
+
el("summary", {}, title, sub ? el("span", {class: "muted small", text: ` ${sub}`}) : null), el("div", {class: "stack"}, body));
|
|
500
|
+
d.addEventListener("toggle", () => { if (d.open) UI.open.add(key); else UI.open.delete(key); });
|
|
501
|
+
return d;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function markInspectorErrors() {
|
|
505
|
+
const box = inspector.querySelector(".bl-stage-errors");
|
|
506
|
+
if (box && UI.selected) fillStageErrors(box, UI.selected);
|
|
507
|
+
}
|
|
508
|
+
function fillStageErrors(box, name) {
|
|
509
|
+
const mine = draft.errors().filter((l) => l.startsWith(`stage ${name}:`) || l.startsWith(`stage ${name} `));
|
|
510
|
+
box.replaceChildren(...mine.map((l) => {
|
|
511
|
+
const plain = plainError(l);
|
|
512
|
+
return el("div", {class: "note bad"}, plain, plain !== l ? el("div", {class: "mono small muted", text: l}) : null);
|
|
513
|
+
}));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function renderInspector() {
|
|
517
|
+
inspector.replaceChildren();
|
|
518
|
+
const s = draft.raw.stages?.[UI.selected];
|
|
519
|
+
if (!s) {
|
|
520
|
+
inspector.append(note(Object.keys(draft.raw.stages || {}).length
|
|
521
|
+
? "Select a stage on the canvas to edit it." : "Add a stage from the list on the left.", "info"));
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
inspector.append(stagePanel(UI.selected, s));
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function stagePanel(name, s) {
|
|
528
|
+
const nameInput = el("input", {class: "input mono", value: name, "aria-label": "Stage name"});
|
|
529
|
+
nameInput.addEventListener("change", () => {
|
|
530
|
+
const nn = nameInput.value.trim();
|
|
531
|
+
if (!nn || nn === name) return;
|
|
532
|
+
if (!/^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(nn)) { toast("Use letters, digits, '.', '-' and '_'"); nameInput.value = name; return; }
|
|
533
|
+
if (draft.raw.stages[nn] || TERMS.includes(nn)) { toast(`${nn} is taken`); nameInput.value = name; return; }
|
|
534
|
+
renameStage(draft, name, nn);
|
|
535
|
+
UI.selected = nn;
|
|
536
|
+
draft.commit();
|
|
537
|
+
});
|
|
538
|
+
const entry = checkbox("Entry stage", "every flow starts here", draft.raw.entry === name);
|
|
539
|
+
entry.input.addEventListener("change", () => { if (entry.input.checked) { draft.raw.entry = name; draft.commit(); } else entry.input.checked = true; });
|
|
540
|
+
const kind = el("select", {class: "select", "aria-label": "Kind"}, Object.keys(KIND).map((k) => el("option", {value: k, text: KIND[k]})));
|
|
541
|
+
kind.value = s.kind;
|
|
542
|
+
kind.addEventListener("change", () => {
|
|
543
|
+
const d = stageDefaults(kind.value, s);
|
|
544
|
+
for (const k of Object.keys(s)) if (!(k in d)) delete s[k];
|
|
545
|
+
Object.assign(s, d);
|
|
546
|
+
if (!s.pipeline) delete s.pipeline;
|
|
547
|
+
draft.commit();
|
|
548
|
+
});
|
|
549
|
+
const remove = el("button", {class: "btn danger small", type: "button", text: "Remove stage", onclick: () => confirmRemove(name)});
|
|
550
|
+
const errs = el("div", {class: "stack bl-stage-errors"});
|
|
551
|
+
fillStageErrors(errs, name);
|
|
552
|
+
|
|
553
|
+
const isDetector = s.kind === "detector", isType = s.kind === "multiclass";
|
|
554
|
+
return el("div", {class: "stack"},
|
|
555
|
+
card(name, KIND[s.kind] || s.kind, cardBody(el("div", {class: "stack"},
|
|
556
|
+
errs,
|
|
557
|
+
el("div", {class: "form-grid"}, field("Name", nameInput), field("Kind", kind)),
|
|
558
|
+
el("div", {class: "row"}, entry.node, el("span", {class: "spacer"}), remove),
|
|
559
|
+
el("div", {class: "section-title", text: "Where each output goes"}), routesForm(name, s),
|
|
560
|
+
el("div", {class: "section-title", text: "Decision"}), decisionForm(s)))),
|
|
561
|
+
section("candidates", "Models and parameters", candidatesForm(name, s), `${(s.candidates || []).length} candidate${(s.candidates || []).length === 1 ? "" : "s"}`),
|
|
562
|
+
!isDetector ? section("search", "Hyperparameter search", searchForm(s)) : null,
|
|
563
|
+
section("pipeline", "Preprocessing pipeline", pipelineForm(s), s.pipeline?.length ? `${s.pipeline.length} step${s.pipeline.length === 1 ? "" : "s"}` : "none"),
|
|
564
|
+
section("selection", isDetector ? "Selection" : "Calibration and selection", selectionForm(s)),
|
|
565
|
+
isType ? section("type", "Type stage", typeForm(s)) : null,
|
|
566
|
+
isDetector ? section("detector", "Detector training", detectorForm(s)) : null,
|
|
567
|
+
section("features", "Score features and training rows", featuresForm(name, s),
|
|
568
|
+
s.extra_features?.length ? `uses ${s.extra_features.join(", ")}` : null),
|
|
569
|
+
section("cost", "Declared cost", costForm(s), s.cost != null ? String(s.cost) : null));
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function targetsFor(name) {
|
|
573
|
+
return [...TERMS, ...Object.keys(draft.raw.stages).filter((n) => n !== name)];
|
|
574
|
+
}
|
|
575
|
+
function routesForm(name, s) {
|
|
576
|
+
const box = el("div", {class: "stack"});
|
|
577
|
+
if (s.kind === "multiclass") {
|
|
578
|
+
const role = el("select", {class: "select", "aria-label": "Role"}, [["annotate", "annotate: names the type, the verdict does not change"],
|
|
579
|
+
["decide", "decide: sends flows on by type"]].map(([v, t]) => el("option", {value: v, text: t})));
|
|
580
|
+
role.value = s.role || "annotate";
|
|
581
|
+
role.addEventListener("change", () => {
|
|
582
|
+
s.role = role.value;
|
|
583
|
+
if (s.role === "annotate") { delete s.routes; s.next = s.next || "ATTACK"; if (s.decision) delete s.decision.off; }
|
|
584
|
+
else { delete s.next; s.routes = s.routes || {default: "ATTACK"}; s.decision = {...(s.decision || {}), off: "default"}; }
|
|
585
|
+
draft.commit();
|
|
586
|
+
});
|
|
587
|
+
box.append(field("Role", role));
|
|
588
|
+
}
|
|
589
|
+
for (const o of outputsOf(s)) {
|
|
590
|
+
const sel = el("select", {class: "select", "aria-label": `${name} ${outName(o)} destination`},
|
|
591
|
+
el("option", {value: "", text: "(no destination yet)"}),
|
|
592
|
+
targetsFor(name).map((t) => el("option", {value: t, text: t})));
|
|
593
|
+
const cur = targetOf(s, o) || "";
|
|
594
|
+
if (cur && ![...sel.options].some((op) => op.value === cur)) sel.append(el("option", {value: cur, text: `${cur} (not a stage)`}));
|
|
595
|
+
sel.value = cur;
|
|
596
|
+
sel.addEventListener("change", () => { setTarget(s, o, sel.value || undefined); draft.commit(); });
|
|
597
|
+
const row = el("div", {class: `bl-route${cur ? "" : " missing"}`}, el("span", {class: "mono", text: o === "next" ? "every flow" : o}), sel);
|
|
598
|
+
if (s.kind === "multiclass" && s.role === "decide" && o !== "default") {
|
|
599
|
+
row.append(el("button", {class: "btn small", type: "button", text: "Remove", "aria-label": `remove the ${o} route`,
|
|
600
|
+
onclick: () => { delete s.routes[o]; draft.commit(); }}));
|
|
601
|
+
}
|
|
602
|
+
box.append(row);
|
|
603
|
+
}
|
|
604
|
+
if (s.kind === "multiclass" && s.role === "decide") {
|
|
605
|
+
const types = draft.dataset?.typeValues || [];
|
|
606
|
+
const add = el("input", {class: "input mono", placeholder: "type name or uncertain", list: "bl-types", "aria-label": "Type to route"});
|
|
607
|
+
box.append(el("datalist", {id: "bl-types"}, [...types, "uncertain"].map((t) => el("option", {value: t}))),
|
|
608
|
+
el("div", {class: "row"}, add, el("button", {class: "btn small", type: "button", text: "Add route", onclick: () => {
|
|
609
|
+
const t = add.value.trim();
|
|
610
|
+
if (!t) return;
|
|
611
|
+
s.routes = {...s.routes, [t]: "ATTACK"};
|
|
612
|
+
draft.commit();
|
|
613
|
+
}})));
|
|
614
|
+
}
|
|
615
|
+
return box;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function decisionForm(s) {
|
|
619
|
+
s.decision = s.decision || {};
|
|
620
|
+
const d = s.decision;
|
|
621
|
+
const modes = DECISIONS[s.kind] || [];
|
|
622
|
+
const current = modes.find((m) => m in d) || modes[0];
|
|
623
|
+
const mode = el("select", {class: "select", "aria-label": "Decision mode"}, modes.map((m) => el("option", {value: m, text: m.replace("_", " ")})));
|
|
624
|
+
mode.value = current;
|
|
625
|
+
const values = el("input", {class: "input mono", value: listText(d[current]), "aria-label": "Decision values"});
|
|
626
|
+
const hintEl = el("div", {class: "hint", text: DECISION_HINT[current]});
|
|
627
|
+
const apply = () => {
|
|
628
|
+
const m = mode.value, vals = parseList(values.value).filter((x) => typeof x === "number");
|
|
629
|
+
for (const k of modes) delete d[k];
|
|
630
|
+
d[m] = m === "threshold" ? (vals[0] ?? 0.5) : m === "grid" ? vals : vals.length === 1 ? vals[0] : vals;
|
|
631
|
+
hintEl.textContent = DECISION_HINT[m];
|
|
632
|
+
draft.commit();
|
|
633
|
+
};
|
|
634
|
+
mode.addEventListener("change", apply);
|
|
635
|
+
values.addEventListener("change", apply);
|
|
636
|
+
const parts = [el("div", {class: "form-grid"}, field("Mode", mode), field("Values", values)), hintEl];
|
|
637
|
+
if (!(s.kind === "multiclass" && (s.role || "annotate") === "annotate")) {
|
|
638
|
+
parts.push(field("When the stage is switched off, every flow takes", bound(draft, d, "off",
|
|
639
|
+
{options: [["", "(choose an output)"], ...outputsOf(s)]})));
|
|
640
|
+
}
|
|
641
|
+
return el("div", {class: "stack"}, parts);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function catalogSelect(section, value) {
|
|
645
|
+
const entries = ctx.catalog[section] || [];
|
|
646
|
+
const sel = el("select", {class: "select", "aria-label": "Model class"},
|
|
647
|
+
el("optgroup", {label: "Installed"}, entries.filter((e) => e.installed).map((e) => el("option", {value: e.class, text: e.label}))),
|
|
648
|
+
el("optgroup", {label: "Needs an extra"}, entries.filter((e) => !e.installed).map((e) =>
|
|
649
|
+
el("option", {value: e.class, text: `${e.label} (pip install idslab[${e.extra}])`}))),
|
|
650
|
+
el("option", {value: "__custom", text: "Other class path (custom adapter or plugin)..."}));
|
|
651
|
+
sel.value = entries.some((e) => e.class === value) ? value : "__custom";
|
|
652
|
+
return sel;
|
|
653
|
+
}
|
|
654
|
+
function paramsForm(section, obj) {
|
|
655
|
+
const entry = (ctx.catalog[section] || []).find((e) => e.class === obj.class);
|
|
656
|
+
const box = el("div", {class: "form-grid bl-params"});
|
|
657
|
+
const known = new Set((entry?.params || []).map((p) => p.name));
|
|
658
|
+
for (const p of entry?.params || []) {
|
|
659
|
+
let input;
|
|
660
|
+
const cur = obj.params?.[p.name];
|
|
661
|
+
if (p.type === "bool") {
|
|
662
|
+
input = el("select", {class: "select"}, [["", `default (${p.default})`], ["true", "true"], ["false", "false"]].map(([v, t]) => el("option", {value: v, text: t})));
|
|
663
|
+
input.value = cur == null ? "" : String(cur);
|
|
664
|
+
} else {
|
|
665
|
+
input = el("input", {class: "input mono", placeholder: p.default == null ? "default" : listText(p.default),
|
|
666
|
+
value: cur == null ? "" : Array.isArray(cur) ? listText(cur) : typeof cur === "object" ? JSON.stringify(cur) : String(cur)});
|
|
667
|
+
}
|
|
668
|
+
input.addEventListener("change", () => {
|
|
669
|
+
const v = input.value.trim();
|
|
670
|
+
obj.params = obj.params || {};
|
|
671
|
+
if (v === "") delete obj.params[p.name];
|
|
672
|
+
else if (p.type === "bool") obj.params[p.name] = v === "true";
|
|
673
|
+
else if (p.type === "list") obj.params[p.name] = parseList(v);
|
|
674
|
+
else if (p.type === "int" || p.type === "float") obj.params[p.name] = v === "null" ? null : Number(v);
|
|
675
|
+
else obj.params[p.name] = v === "null" ? null : (Number.isFinite(Number(v)) ? Number(v) : v);
|
|
676
|
+
if (!Object.keys(obj.params).length) delete obj.params;
|
|
677
|
+
draft.commit();
|
|
678
|
+
});
|
|
679
|
+
box.append(field(p.name, input));
|
|
680
|
+
}
|
|
681
|
+
const extra = Object.keys(obj.params || {}).filter((k) => !known.has(k));
|
|
682
|
+
const kept = extra.length ? el("div", {class: "hint", text: `Also set, kept as written: ${extra.map((k) => `${k}: ${JSON.stringify(obj.params[k])}`).join("; ")}. ` +
|
|
683
|
+
"Edit these in the YAML view."}) : null;
|
|
684
|
+
if (!entry) box.append(el("div", {class: "hint", text: "This class is not in the catalog; set its parameters in the YAML view."}));
|
|
685
|
+
return el("div", {class: "stack"}, box, kept);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function candidatesForm(name, s) {
|
|
689
|
+
s.candidates = s.candidates || [];
|
|
690
|
+
const sectionName = s.kind === "detector" ? "detector" : "classifier";
|
|
691
|
+
const box = el("div", {class: "stack"});
|
|
692
|
+
if (!s.candidates.length) box.append(note("A stage needs at least one candidate model.", "warn"));
|
|
693
|
+
s.candidates.forEach((c, i) => {
|
|
694
|
+
const row = el("div", {class: "bl-item"});
|
|
695
|
+
if (c.external) {
|
|
696
|
+
const p = el("input", {class: "input mono", value: typeof c.external === "string" ? c.external : c.external.path || "", "aria-label": "Score file"});
|
|
697
|
+
p.addEventListener("change", () => {
|
|
698
|
+
const path = p.value.trim();
|
|
699
|
+
c.external = typeof c.external === "object" ? {...c.external, path} : path;
|
|
700
|
+
draft.commit();
|
|
701
|
+
});
|
|
702
|
+
row.append(field(`Candidate ${i + 1}: score file (may use {seed} and {family})`, p),
|
|
703
|
+
field("Label", bound(draft, c, "label", {placeholder: "external"})));
|
|
704
|
+
} else {
|
|
705
|
+
const sel = catalogSelect(sectionName, c.class);
|
|
706
|
+
const custom = el("input", {class: `input mono${sel.value === "__custom" ? "" : " hidden"}`, value: c.class || "",
|
|
707
|
+
placeholder: "package.module.Class", "aria-label": "Class path"});
|
|
708
|
+
sel.addEventListener("change", () => {
|
|
709
|
+
custom.classList.toggle("hidden", sel.value !== "__custom");
|
|
710
|
+
if (sel.value !== "__custom") { c.class = sel.value; delete c.params; delete c.search; draft.commit(); }
|
|
711
|
+
});
|
|
712
|
+
custom.addEventListener("change", () => { c.class = custom.value.trim(); delete c.params; draft.commit(); });
|
|
713
|
+
row.append(field(`Candidate ${i + 1}`, el("div", {class: "stack"}, sel, custom)), paramsForm(sectionName, c));
|
|
714
|
+
if (c.score != null) row.append(el("div", {class: "hint", text: `Score overrides kept as written: ${JSON.stringify(c.score)}.`}));
|
|
715
|
+
}
|
|
716
|
+
row.append(el("div", {class: "row"}, el("span", {class: "spacer"}),
|
|
717
|
+
el("button", {class: "btn small danger", type: "button", text: "Remove", "aria-label": `remove candidate ${i + 1}`,
|
|
718
|
+
onclick: () => { s.candidates.splice(i, 1); draft.commit(); }})));
|
|
719
|
+
box.append(row);
|
|
720
|
+
});
|
|
721
|
+
box.append(el("div", {class: "row"},
|
|
722
|
+
el("button", {class: "btn small", type: "button", text: "Add model", onclick: () => {
|
|
723
|
+
s.candidates.push({class: (ctx.catalog[sectionName] || []).find((e) => e.installed)?.class}); draft.commit();
|
|
724
|
+
}}),
|
|
725
|
+
el("button", {class: "btn small", type: "button", text: "Add score file made elsewhere", onclick: () => {
|
|
726
|
+
s.candidates.push({external: {path: `scores/${name}_{seed}.parquet`}}); draft.commit();
|
|
727
|
+
}})));
|
|
728
|
+
return box;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function searchForm(s) {
|
|
732
|
+
const box = el("div", {class: "stack"});
|
|
733
|
+
const models = (s.candidates || []).filter((c) => !c.external);
|
|
734
|
+
if (!models.length) return note("No fitted candidates to search over.", "info");
|
|
735
|
+
box.append(el("p", {class: "hint", text: "Cross-validated search on this stage's training rows, per candidate. Leave the space empty to use the parameters as set."}));
|
|
736
|
+
for (const [i, c] of models.entries()) {
|
|
737
|
+
const space = el("textarea", {class: "textarea mono", rows: 2, placeholder: "max_depth: 8, 16, null\nmin_samples_leaf: 1, 5",
|
|
738
|
+
"aria-label": `Search space for candidate ${i + 1}`,
|
|
739
|
+
value: Object.entries(c.search?.space || {}).map(([k, v]) => `${k}: ${listText(v)}`).join("\n")});
|
|
740
|
+
const kind = el("select", {class: "select", "aria-label": "Search kind"}, [["random", "random"], ["grid", "grid (every combination)"]].map(([v, t]) => el("option", {value: v, text: t})));
|
|
741
|
+
kind.value = c.search?.kind || "random";
|
|
742
|
+
const iters = el("input", {class: "input", type: "number", min: "1", value: c.search?.n_iter ?? "", placeholder: "10", "aria-label": "Tries"});
|
|
743
|
+
const cv = el("input", {class: "input", type: "number", min: "2", value: c.search?.cv ?? "", placeholder: "3", "aria-label": "Folds"});
|
|
744
|
+
const apply = () => {
|
|
745
|
+
const sp = {};
|
|
746
|
+
for (const line of space.value.split("\n")) {
|
|
747
|
+
const m = /^\s*([\w.]+)\s*:\s*(.+)$/.exec(line);
|
|
748
|
+
if (m) sp[m[1]] = parseList(m[2]);
|
|
749
|
+
}
|
|
750
|
+
if (Object.keys(sp).length) {
|
|
751
|
+
c.search = {space: sp, ...(kind.value !== "random" ? {kind: kind.value} : {}),
|
|
752
|
+
...(iters.value ? {n_iter: Number(iters.value)} : {}), ...(cv.value ? {cv: Number(cv.value)} : {})};
|
|
753
|
+
} else delete c.search;
|
|
754
|
+
draft.commit();
|
|
755
|
+
};
|
|
756
|
+
for (const n of [space, kind, iters, cv]) n.addEventListener("change", apply);
|
|
757
|
+
box.append(el("div", {class: "bl-item"}, el("b", {class: "small", text: `${shortClass(c.class)} (candidate ${s.candidates.indexOf(c) + 1})`}),
|
|
758
|
+
field("One parameter per line", space),
|
|
759
|
+
el("div", {class: "form-grid"}, field("Kind", kind), field("Tries (n_iter)", iters), field("Folds (cv)", cv))));
|
|
760
|
+
}
|
|
761
|
+
return box;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function pipelineForm(s) {
|
|
765
|
+
const steps = s.pipeline || [];
|
|
766
|
+
const box = el("div", {class: "stack"});
|
|
767
|
+
if (s.candidates?.some((c) => c.class && !c.class.startsWith("sklearn.") && !c.class.startsWith("xgboost.") &&
|
|
768
|
+
!c.class.startsWith("lightgbm.") && !c.class.startsWith("catboost.") && !c.class.startsWith("pyod."))) {
|
|
769
|
+
box.append(el("p", {class: "hint", text: "A custom adapter class does its own preprocessing; it takes no pipeline steps."}));
|
|
770
|
+
}
|
|
771
|
+
let dragFrom = null;
|
|
772
|
+
steps.forEach((step, i) => {
|
|
773
|
+
const sel = catalogSelect("step", step.class);
|
|
774
|
+
const custom = el("input", {class: `input mono${sel.value === "__custom" ? "" : " hidden"}`, value: step.class || "", "aria-label": "Step class path"});
|
|
775
|
+
sel.addEventListener("change", () => {
|
|
776
|
+
custom.classList.toggle("hidden", sel.value !== "__custom");
|
|
777
|
+
if (sel.value !== "__custom") { step.class = sel.value; delete step.params; draft.commit(); }
|
|
778
|
+
});
|
|
779
|
+
custom.addEventListener("change", () => { step.class = custom.value.trim(); draft.commit(); });
|
|
780
|
+
const moveBy = (k) => { const j = i + k; if (j < 0 || j >= steps.length) return; [steps[i], steps[j]] = [steps[j], steps[i]]; draft.commit(); };
|
|
781
|
+
const item = el("div", {class: "bl-item bl-step", draggable: "true"},
|
|
782
|
+
el("div", {class: "row"}, el("span", {class: "bl-grip", text: "::", title: "drag to reorder"}),
|
|
783
|
+
el("b", {text: `${i + 1}`}), el("div", {class: "spacer"}, sel, custom),
|
|
784
|
+
el("button", {class: "btn small", type: "button", text: "Up", disabled: i === 0, "aria-label": `move step ${i + 1} up`, onclick: () => moveBy(-1)}),
|
|
785
|
+
el("button", {class: "btn small", type: "button", text: "Down", disabled: i === steps.length - 1, "aria-label": `move step ${i + 1} down`, onclick: () => moveBy(1)}),
|
|
786
|
+
el("button", {class: "btn small danger", type: "button", text: "Remove", onclick: () => { steps.splice(i, 1); if (!steps.length) delete s.pipeline; draft.commit(); }})));
|
|
787
|
+
if (step.class === "idslab.prep.Encode") {
|
|
788
|
+
const cats = el("input", {class: "input mono", value: listText(step.params?.categorical || []), placeholder: "proto, service", "aria-label": "Categorical columns"});
|
|
789
|
+
cats.addEventListener("change", () => {
|
|
790
|
+
step.params = {...(step.params || {}), categorical: names(cats.value)};
|
|
791
|
+
draft.commit();
|
|
792
|
+
});
|
|
793
|
+
const fill = draft.dataset?.suggest?.categorical;
|
|
794
|
+
item.append(field("Categorical columns", el("div", {class: "row"}, cats,
|
|
795
|
+
fill ? el("button", {class: "btn small", type: "button", text: "Use the text columns found", onclick: () => {
|
|
796
|
+
step.params = {...(step.params || {}), categorical: [...fill]}; draft.commit();
|
|
797
|
+
}}) : null)));
|
|
798
|
+
} else {
|
|
799
|
+
item.append(paramsForm("step", step));
|
|
800
|
+
}
|
|
801
|
+
item.addEventListener("dragstart", () => { dragFrom = i; });
|
|
802
|
+
item.addEventListener("dragover", (e) => e.preventDefault());
|
|
803
|
+
item.addEventListener("drop", (e) => {
|
|
804
|
+
e.preventDefault();
|
|
805
|
+
if (dragFrom == null || dragFrom === i) return;
|
|
806
|
+
const [moved] = steps.splice(dragFrom, 1);
|
|
807
|
+
steps.splice(i, 0, moved);
|
|
808
|
+
draft.commit();
|
|
809
|
+
});
|
|
810
|
+
box.append(item);
|
|
811
|
+
});
|
|
812
|
+
box.append(el("div", {class: "row"}, el("button", {class: "btn small", type: "button", text: "Add step", onclick: () => {
|
|
813
|
+
steps.push({class: "sklearn.preprocessing.StandardScaler"}); s.pipeline = steps; draft.commit();
|
|
814
|
+
}})));
|
|
815
|
+
return box;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function selectionForm(s) {
|
|
819
|
+
return el("div", {class: "form-grid"},
|
|
820
|
+
s.kind !== "detector" ? field("Calibrate scores", bound(draft, s, "calibrate", {options: [["", "none"], ["sigmoid", "sigmoid"], ["isotonic", "isotonic"]]})) : null,
|
|
821
|
+
field("Choose candidates by", bound(draft, s, "select_by", {options: [["", "default for this kind"], ["auc", "AUC, then tune the decision"], ["objective", "the objective, jointly with the decision"]]})),
|
|
822
|
+
field("Fewest VAL rows to tune on", bound(draft, s, "min_val_rows", {type: "number", placeholder: "20"}),
|
|
823
|
+
"below this many VAL rows reaching the stage, the decision is not tuned"));
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function typeForm(s) {
|
|
827
|
+
return el("div", {class: "stack"},
|
|
828
|
+
field("Type objective", bound(draft, s, "type_objective", {options: [["", "macro F1 (default)"], ["accuracy", "accuracy"], ["auc", "AUC"]]}),
|
|
829
|
+
"how candidates of this type stage are compared on VAL"),
|
|
830
|
+
el("p", {class: "hint", text: "The role (annotate or decide) is set with the routes above."}));
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function detectorForm(s) {
|
|
834
|
+
return el("div", {class: "stack"},
|
|
835
|
+
el("div", {class: "form-grid"},
|
|
836
|
+
field("Fit on", bound(draft, s, "fit_on", {options: [["", "every FIT row"], ["benign", "benign FIT rows"], ["pool", "each batch of flows (refit per batch)"]]})),
|
|
837
|
+
s.fit_on === "pool" ? field("Preprocessing fit on", bound(draft, s, "prep_fit_on", {options: [["", "FIT rows"], ["benign", "benign FIT rows"]]})) : null,
|
|
838
|
+
s.fit_on === "pool" ? field("Smallest batch scored", bound(draft, s, "min_pool", {type: "number", placeholder: "automatic"})) : null),
|
|
839
|
+
el("p", {class: "hint", text: "Thresholds (budget, top fraction, grid) are set under Decision above."}));
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function featuresForm(name, s) {
|
|
843
|
+
const box = el("div", {class: "stack"});
|
|
844
|
+
const feats = s.extra_features || [];
|
|
845
|
+
box.append(field("Uses the scores of", el("div", {class: "row"}, feats.length ? feats.map((f) => el("span", {class: "badge amber"}, f,
|
|
846
|
+
el("button", {class: "chip-x", type: "button", text: "x", "aria-label": `stop using ${f}'s score`, onclick: () => {
|
|
847
|
+
s.extra_features = feats.filter((z) => z !== f);
|
|
848
|
+
if (!s.extra_features.length) delete s.extra_features;
|
|
849
|
+
draft.commit();
|
|
850
|
+
}}))) : el("span", {class: "muted small", text: "none"}))));
|
|
851
|
+
const others = Object.keys(draft.raw.stages).filter((n) => n !== name && !feats.includes(n));
|
|
852
|
+
if (others.length) {
|
|
853
|
+
const add = el("select", {class: "select", "aria-label": "Add a score feature"}, el("option", {value: "", text: "Add the score of..."}),
|
|
854
|
+
others.map((n) => el("option", {value: n, text: n})));
|
|
855
|
+
add.addEventListener("change", () => { if (add.value) { s.extra_features = [...feats, add.value]; draft.commit(); } });
|
|
856
|
+
box.append(add);
|
|
857
|
+
}
|
|
858
|
+
if (s.kind !== "detector") {
|
|
859
|
+
const trainOn = Array.isArray(s.train_on)
|
|
860
|
+
? field("Train on these types", bound(draft, s, "train_on", {parse: names}), "a list of type names")
|
|
861
|
+
: field("Train on", bound(draft, s, "train_on", {options: [["", "every row"], ...(s.kind === "multiclass" ? [["attack", "attack rows"]] : []),
|
|
862
|
+
["hard", "hard rows of another stage"]],
|
|
863
|
+
onSet: () => {
|
|
864
|
+
if (s.train_on === "hard") s.hard = s.hard || {source: Object.keys(draft.raw.stages).find((n) => n !== name), band: [0.3, 0.7]};
|
|
865
|
+
else delete s.hard;
|
|
866
|
+
}}));
|
|
867
|
+
box.append(trainOn);
|
|
868
|
+
if (s.train_on === "hard" && s.hard) {
|
|
869
|
+
box.append(el("div", {class: "form-grid"},
|
|
870
|
+
field("Hard rows from", bound(draft, s.hard, "source", {options: Object.keys(draft.raw.stages).filter((n) => n !== name)})),
|
|
871
|
+
field("Score band", bound(draft, s.hard, "band", {parse: (v) => parseList(v).slice(0, 2), placeholder: "0.3, 0.7"}))));
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
return box;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function costForm(s) {
|
|
878
|
+
return field("Cost per flow reaching this stage", bound(draft, s, "cost", {type: "number", placeholder: "1"}),
|
|
879
|
+
"used in the report's stage cost section only; it does not change any choice");
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
if (draft.raw) { renderIssues(); renderCanvas(); renderInspector(); } else renderUnavailable();
|
|
883
|
+
return {destroy() { unsub(); removeEventListener("keydown", onKey); }};
|
|
884
|
+
}
|