detectkit 0.36.2__tar.gz → 0.38.0__tar.gz
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.
- {detectkit-0.36.2/detectkit.egg-info → detectkit-0.38.0}/PKG-INFO +1 -1
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/__init__.py +1 -1
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/html_labeler.py +67 -10
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/labels.py +48 -1
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/assets/claude/rules/autotune.md +7 -4
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/assets/claude/rules/cli.md +29 -16
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/commands/tune.py +6 -0
- detectkit-0.38.0/detectkit/tuning/assets/tune.js +147 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/tuning/payload.py +4 -0
- {detectkit-0.36.2 → detectkit-0.38.0/detectkit.egg-info}/PKG-INFO +1 -1
- detectkit-0.36.2/detectkit/tuning/assets/tune.js +0 -131
- {detectkit-0.36.2 → detectkit-0.38.0}/LICENSE +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/MANIFEST.in +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/README.md +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/__init__.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/channels/__init__.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/channels/base.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/channels/branding.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/channels/email.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/channels/factory.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/channels/mattermost.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/channels/slack.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/channels/telegram.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/channels/webhook.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/orchestrator/_base.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/orchestrator/_types.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/__init__.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/_base.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/_types.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/autotuner.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/config_emitter.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/crossval.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/detector_select.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/distribution.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/grid_search.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/label_server.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/result.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/scoring.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/seasonality_search.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/settings.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/window_select.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/__init__.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/_output.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/commands/__init__.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/commands/autotune.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/commands/clean.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/commands/init.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/commands/init_claude.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/commands/run.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/commands/test_alert.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/commands/unlock.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/main.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/config/__init__.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/config/metric_config.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/config/profile.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/config/project_config.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/config/validator.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/core/__init__.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/core/interval.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/core/models.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/__init__.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/_sql_manager.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/clickhouse_manager.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/internal_tables/__init__.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/internal_tables/_base.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/internal_tables/_detections.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/internal_tables/_metrics.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/internal_tables/_schema.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/internal_tables/_tasks.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/internal_tables/manager.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/manager.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/mysql_manager.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/postgres_manager.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/tables.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/detectors/__init__.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/detectors/base.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/detectors/factory.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/detectors/seasonality.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/detectors/statistical/__init__.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/detectors/statistical/_windowed.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/detectors/statistical/iqr.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/detectors/statistical/mad.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/detectors/statistical/zscore.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/loaders/__init__.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/loaders/metric_loader.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/loaders/query_template.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/orchestration/__init__.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/orchestration/error_dispatch.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/orchestration/task_manager/_base.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/orchestration/task_manager/_types.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/orchestration/task_manager/manager.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/reporting/__init__.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/reporting/assets/report.js +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/reporting/builder.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/reporting/html_report.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/tuning/__init__.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/tuning/config_writer.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/tuning/html.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/tuning/server.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/utils/__init__.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/utils/datetime_utils.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/utils/env_interpolation.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/utils/json_utils.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/utils/stats.py +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit.egg-info/SOURCES.txt +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit.egg-info/dependency_links.txt +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit.egg-info/entry_points.txt +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit.egg-info/requires.txt +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/detectkit.egg-info/top_level.txt +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/pyproject.toml +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/requirements.txt +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/setup.cfg +0 -0
- {detectkit-0.36.2 → detectkit-0.38.0}/setup.py +0 -0
|
@@ -4,7 +4,7 @@ detectk - Anomaly Detection for Time-Series Metrics
|
|
|
4
4
|
A Python library for data analysts and engineers to monitor metrics with automatic anomaly detection.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
__version__ = "0.
|
|
7
|
+
__version__ = "0.38.0"
|
|
8
8
|
|
|
9
9
|
from detectkit.core.interval import Interval
|
|
10
10
|
from detectkit.core.models import ColumnDefinition, TableModel
|
|
@@ -10,9 +10,11 @@ It can be **seeded with existing incidents** (the ``incidents=`` argument) so a
|
|
|
10
10
|
labels file can be opened and edited in place — the round-trip flow of filling
|
|
11
11
|
incidents in over time. Beyond click-drag marking it supports **threshold
|
|
12
12
|
capture** (grab every contiguous span above/below a horizontal line in one
|
|
13
|
-
gesture), **
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
gesture), **lasso capture** (draw a freeform loop around a cloud of points and
|
|
14
|
+
turn each grid-adjacent run — small gaps bridged — into one incident span),
|
|
15
|
+
**on-chart deletion** (each band carries an ✕ handle; the selected band also
|
|
16
|
+
responds to the Delete key), and an in-browser **Import** button that loads a
|
|
17
|
+
labels file you pick (YAML/JSON).
|
|
16
18
|
|
|
17
19
|
The page is offline-only — a browser cannot write to the project, so Export
|
|
18
20
|
downloads a **versioned** file named after the metric, with the optional set name
|
|
@@ -186,6 +188,7 @@ _TEMPLATE = """<!doctype html>
|
|
|
186
188
|
<input id="setname" class="setname" type="text" placeholder="name this set (optional)" />
|
|
187
189
|
<button id="export" class="primary">Export labels</button>
|
|
188
190
|
<button id="thbtn" class="ghost" title="capture every span above or below a horizontal line">Threshold capture</button>
|
|
191
|
+
<button id="lassobtn" class="ghost" title="draw a freeform loop around a cloud of points to grab them all at once">Lasso capture</button>
|
|
189
192
|
<button id="clear" class="ghost">Clear all</button>
|
|
190
193
|
<span id="summary" class="summary"></span>
|
|
191
194
|
</div>
|
|
@@ -216,8 +219,9 @@ _TEMPLATE = """<!doctype html>
|
|
|
216
219
|
adjust it, or its middle to move it · click its <b>✕</b> (or select it and press Delete) to remove it ·
|
|
217
220
|
use <b>Threshold capture</b> to grab every span past a horizontal line at once (click sets the line; drag
|
|
218
221
|
across the chart to limit it to a time window — handy when the metric behaves differently across periods) ·
|
|
219
|
-
<b>
|
|
220
|
-
|
|
222
|
+
use <b>Lasso capture</b> to draw a freeform loop around a cloud of points and turn each consecutive run into
|
|
223
|
+
an incident · <b>focus</b> a row to jump the chart to it · scroll to zoom, double-click to reset · drag the
|
|
224
|
+
navigator window below to pan.</div>
|
|
221
225
|
<div id="empty" class="empty">No incidents marked yet — drag across a span on the chart above.</div>
|
|
222
226
|
<ul id="list"></ul>
|
|
223
227
|
<footer>All times UTC · self-contained, nothing leaves your browser · re-label any time —
|
|
@@ -264,6 +268,10 @@ let selObj = null, hoverRow = -1, hoverDel = -1, thMode = false, thHover = null;
|
|
|
264
268
|
// Threshold-capture window: thDown tracks a press, thDragWin a live drag, capWin
|
|
265
269
|
// the committed custom window (null → capture within the current view).
|
|
266
270
|
let thDown = null, thDragWin = null, capWin = null;
|
|
271
|
+
// Lasso capture: draw a freeform loop around a cloud of points and turn each
|
|
272
|
+
// grid-adjacent run (small gaps bridged) into one incident span. lassoDrag holds
|
|
273
|
+
// the in-progress loop as DATA coords ({t, v}); off until the tool is toggled.
|
|
274
|
+
let lassoMode = false, lassoDrag = null; const LASSO_BRIDGE = 2;
|
|
267
275
|
// Restore a saved capture window so re-opening a labels file keeps the painted
|
|
268
276
|
// regime scope (only shown once threshold capture is toggled on).
|
|
269
277
|
if (CAPWINS && CAPWINS.length) { const w0 = CAPWINS[0];
|
|
@@ -426,9 +434,47 @@ function addCaptured(a,b) {
|
|
|
426
434
|
if (host===null) incidents.push({a, b, label:''}); }
|
|
427
435
|
function toggleTh(on) { thMode = (on===undefined) ? !thMode : !!on;
|
|
428
436
|
thbtnEl.classList.toggle('active', thMode); thbarEl.style.display = thMode ? 'flex' : 'none';
|
|
429
|
-
if (thMode) { hover=null;
|
|
437
|
+
if (thMode) { hover=null; if (lassoMode) toggleLasso(false); }
|
|
438
|
+
else { thHover=null; thDown=null; thDragWin=null; capWin=null; updateThWin(); }
|
|
430
439
|
c.style.cursor = thMode ? 'crosshair' : 'crosshair'; if (thMode) { updateThWin(); thCount(); } draw(); }
|
|
431
440
|
|
|
441
|
+
// ---- lasso capture --------------------------------------------------------
|
|
442
|
+
// Ray-casting point-in-polygon in DATA space ({t, v}); the loop and the test
|
|
443
|
+
// points map through the same per-axis affine, so screen-inside == data-inside.
|
|
444
|
+
function pointInPoly(t, v, poly) { let inside=false;
|
|
445
|
+
for (let i=0,j=poly.length-1;i<poly.length;j=i++) {
|
|
446
|
+
const ti=poly[i].t, vi=poly[i].v, tj=poly[j].t, vj=poly[j].v;
|
|
447
|
+
if (((vi>v)!==(vj>v)) && t < (tj-ti)*(v-vi)/((vj-vi)||1e-9)+ti) inside=!inside; }
|
|
448
|
+
return inside; }
|
|
449
|
+
// The raw point indices enclosed by the in-progress loop.
|
|
450
|
+
function lassoCapturedIdx() { const out=[]; if (!lassoDrag || lassoDrag.length<3) return out;
|
|
451
|
+
for (let i=0;i<N;i++) { const p=pts[i]; if (p.v===null) continue;
|
|
452
|
+
if (pointInPoly(p.ts, p.v, lassoDrag)) out.push(i); } return out; }
|
|
453
|
+
// Collapse the captured points into incident spans: a new span starts only when
|
|
454
|
+
// the gap to the previous capture exceeds LASSO_BRIDGE grid steps; each span is
|
|
455
|
+
// padded half a step each side so a lone point becomes one full-interval incident.
|
|
456
|
+
function commitLasso() { const cap=lassoCapturedIdx(); if (!cap.length) return;
|
|
457
|
+
const maxGap=(LASSO_BRIDGE+1)*step, half=step/2; let runStart=cap[0], prev=cap[0], added=0;
|
|
458
|
+
const push=(s,e)=>{ addCaptured(pts[s].ts-half, pts[e].ts+half); added++; };
|
|
459
|
+
for (let k=1;k<cap.length;k++) { const idx=cap[k];
|
|
460
|
+
if (pts[idx].ts-pts[prev].ts > maxGap) { push(runStart, prev); runStart=idx; } prev=idx; }
|
|
461
|
+
push(runStart, prev);
|
|
462
|
+
setMsg('Added '+added+' incident'+(added===1?'':'s')+' from the lasso — review and tidy below.', 'ok');
|
|
463
|
+
render(); }
|
|
464
|
+
// The lasso loop (dashed clay outline + faint fill) + a ring on every captured point.
|
|
465
|
+
function drawLasso() { if (!lassoDrag || lassoDrag.length<2) return;
|
|
466
|
+
ctx.beginPath(); ctx.moveTo(px(lassoDrag[0].t), py(lassoDrag[0].v));
|
|
467
|
+
for (let i=1;i<lassoDrag.length;i++) ctx.lineTo(px(lassoDrag[i].t), py(lassoDrag[i].v));
|
|
468
|
+
ctx.closePath(); ctx.fillStyle='rgba(209,91,54,0.08)'; ctx.fill();
|
|
469
|
+
ctx.strokeStyle='rgba(209,91,54,0.9)'; ctx.lineWidth=1.5*dpr; ctx.setLineDash([5*dpr,4*dpr]);
|
|
470
|
+
ctx.stroke(); ctx.setLineDash([]);
|
|
471
|
+
lassoCapturedIdx().forEach(i => { const X=px(pts[i].ts), Y=py(pts[i].v);
|
|
472
|
+
ctx.strokeStyle='#d63232'; ctx.lineWidth=2*dpr; ctx.beginPath(); ctx.arc(X,Y,7*dpr,0,7); ctx.stroke(); }); }
|
|
473
|
+
function toggleLasso(on) { lassoMode = (on===undefined) ? !lassoMode : !!on;
|
|
474
|
+
lassobtnEl.classList.toggle('active', lassoMode);
|
|
475
|
+
if (lassoMode) { hover=null; selObj=null; if (thMode) toggleTh(false); } else { lassoDrag=null; }
|
|
476
|
+
c.style.cursor='crosshair'; draw(); }
|
|
477
|
+
|
|
432
478
|
function rr(g,x,y,w,h,r) { g.beginPath();
|
|
433
479
|
g.moveTo(x+r,y); g.arcTo(x+w,y,x+w,y+h,r); g.arcTo(x+w,y+h,x,y+h,r);
|
|
434
480
|
g.arcTo(x,y+h,x,y,r); g.arcTo(x,y,x+w,y,r); g.closePath(); }
|
|
@@ -496,6 +542,7 @@ function draw() {
|
|
|
496
542
|
if (val!==null && val>=vmin && val<=vmax) { const yy=py(val);
|
|
497
543
|
ctx.strokeStyle='#f0ad4e'; ctx.lineWidth=1.5*dpr; ctx.setLineDash([6*dpr,4*dpr]);
|
|
498
544
|
ctx.beginPath(); ctx.moveTo(xlo, yy); ctx.lineTo(xhi, yy); ctx.stroke(); ctx.setLineDash([]); } }
|
|
545
|
+
if (lassoMode) drawLasso();
|
|
499
546
|
ctx.restore();
|
|
500
547
|
if (thMode) drawThLabel();
|
|
501
548
|
else if (dragging && !ovAct) drawDragLabel();
|
|
@@ -612,7 +659,9 @@ const ovTsAtCss = clientX => { const r=ov.getBoundingClientRect();
|
|
|
612
659
|
const ovEdgeCss = ts => { const r=ov.getBoundingClientRect();
|
|
613
660
|
return r.left + OM.l + (ts-tmin)/fullSpan*(r.width-(OM.l+OM.r)); };
|
|
614
661
|
|
|
615
|
-
c.addEventListener('wheel', e => { e.preventDefault();
|
|
662
|
+
c.addEventListener('wheel', e => { e.preventDefault();
|
|
663
|
+
if (lassoMode && lassoDrag) return; // don't distort the loop mid-draw
|
|
664
|
+
const t=tsAt(e.clientX);
|
|
616
665
|
let s=clamp(vspan()*Math.pow(1.0015, e.deltaY), minSpan, fullSpan);
|
|
617
666
|
const f=(t-viewMin)/(vspan()||1); setView(t-f*s, t-f*s+s); }, {passive:false});
|
|
618
667
|
// Hit-test an existing incident's ✕ handle / edge / body in CSS px.
|
|
@@ -634,6 +683,8 @@ function hitIncident(clientX, clientY) {
|
|
|
634
683
|
return null;
|
|
635
684
|
}
|
|
636
685
|
c.addEventListener('mousedown', e => {
|
|
686
|
+
// Lasso mode: a press starts a freeform loop accumulated on mousemove.
|
|
687
|
+
if (lassoMode) { lassoDrag = [{t: tsAt(e.clientX), v: vAt(e.clientY)}]; draw(); return; }
|
|
637
688
|
// In threshold mode a press either sets the line (a click) or paints a capture
|
|
638
689
|
// window (a horizontal drag) — resolved on mouseup by how far it moved.
|
|
639
690
|
if (thMode) { thDown = {x:e.clientX, ts:tsAt(e.clientX)}; thHover = vAt(e.clientY); thCount(); draw(); return; }
|
|
@@ -646,6 +697,8 @@ c.addEventListener('mousedown', e => {
|
|
|
646
697
|
else { selObj=null; dragging={mode:'new', a:t, b:t, sx:e.clientX, cx:e.clientX}; draw(); }
|
|
647
698
|
});
|
|
648
699
|
c.addEventListener('mousemove', e => { if (ovAct) return;
|
|
700
|
+
if (lassoMode) { if (lassoDrag) { lassoDrag.push({t: tsAt(e.clientX), v: vAt(e.clientY)}); draw(); }
|
|
701
|
+
else { c.style.cursor='crosshair'; } return; }
|
|
649
702
|
if (thMode && !dragging) {
|
|
650
703
|
if (thDown && Math.abs(e.clientX - thDown.x) > 6) { thDragWin = {a: thDown.ts, b: tsAt(e.clientX)}; }
|
|
651
704
|
else { if (thDown) thDragWin = null; thHover = vAt(e.clientY); }
|
|
@@ -689,6 +742,7 @@ window.addEventListener('mousemove', e => { if (!ovAct) return; const t=ovTsAtCs
|
|
|
689
742
|
else { const d=t-ovAct.grab; setView(ovAct.vMin+d, ovAct.vMax+d); } });
|
|
690
743
|
window.addEventListener('mouseup', e => {
|
|
691
744
|
if (ovAct) { ovAct=null; return; }
|
|
745
|
+
if (lassoMode) { if (lassoDrag) { commitLasso(); lassoDrag=null; } draw(); return; }
|
|
692
746
|
if (thMode && thDown) {
|
|
693
747
|
if (Math.abs(e.clientX - thDown.x) > 6) { // a drag → set the capture window
|
|
694
748
|
const a=thDown.ts, b=tsAt(e.clientX); capWin = {a:Math.min(a,b), b:Math.max(a,b)};
|
|
@@ -713,12 +767,13 @@ function removeIncident(iv) { const k=incidents.indexOf(iv); if (k<0) return;
|
|
|
713
767
|
incidents.splice(k,1); if (selObj===iv) selObj=null; hoverDel=-1; render(); }
|
|
714
768
|
window.addEventListener('keydown', e => {
|
|
715
769
|
const t=e.target, typing = t && (t.tagName==='INPUT' || t.tagName==='TEXTAREA' || t.isContentEditable);
|
|
716
|
-
if (e.key==='Escape') { if (
|
|
717
|
-
|
|
770
|
+
if (e.key==='Escape') { if (lassoMode) toggleLasso(false); else if (thMode) toggleTh(false);
|
|
771
|
+
else if (selObj) { selObj=null; draw(); } return; }
|
|
772
|
+
if ((e.key==='Delete' || e.key==='Backspace') && selObj && !typing && !lassoMode) { e.preventDefault(); removeIncident(selObj); }
|
|
718
773
|
});
|
|
719
774
|
|
|
720
775
|
document.getElementById('zreset').onclick = () => setView(tmin, tmax);
|
|
721
|
-
c.addEventListener('dblclick', () => { if (!thMode) setView(tmin, tmax); });
|
|
776
|
+
c.addEventListener('dblclick', () => { if (!thMode && !lassoMode) setView(tmin, tmax); });
|
|
722
777
|
document.getElementById('clear').onclick = () => { incidents.length=0; selObj=null; render(); };
|
|
723
778
|
window.setLabel = (i, val) => { if (incidents[i]) incidents[i].label = val; };
|
|
724
779
|
window.rm = i => { const iv=incidents[i]; if (iv && selObj===iv) selObj=null; incidents.splice(i,1); render(); };
|
|
@@ -743,6 +798,8 @@ function updateThWin() {
|
|
|
743
798
|
thwinEl.onclick = () => { capWin=null; updateThWin(); thCount(); draw(); };
|
|
744
799
|
thbtnEl.onclick = () => toggleTh();
|
|
745
800
|
thdoneEl.onclick = () => toggleTh(false);
|
|
801
|
+
const lassobtnEl=document.getElementById('lassobtn');
|
|
802
|
+
lassobtnEl.onclick = () => toggleLasso();
|
|
746
803
|
thdirEl.onchange = () => { thCount(); draw(); };
|
|
747
804
|
thgapEl.oninput = () => { thCount(); draw(); };
|
|
748
805
|
thvalEl.oninput = () => { thCount(); draw(); };
|
|
@@ -67,6 +67,11 @@ class IncidentLabels:
|
|
|
67
67
|
# metadata: it records the regime scope the user reasoned about (auditable in
|
|
68
68
|
# the saved file, restored on reopen); it does NOT affect ground truth.
|
|
69
69
|
capture_windows: list[tuple[datetime, datetime]] = field(default_factory=list)
|
|
70
|
+
# Optional per-alert review verdicts from `dtk tune` (streak span + 'valid' /
|
|
71
|
+
# 'false'). Pure metadata: it re-seeds the green/slate markers on reopen (a
|
|
72
|
+
# confirmed alert is also written as a normal incident, which IS ground truth).
|
|
73
|
+
# autotune ignores this block entirely.
|
|
74
|
+
alert_reviews: list[tuple[datetime, datetime, str]] = field(default_factory=list)
|
|
70
75
|
|
|
71
76
|
def is_empty(self) -> bool:
|
|
72
77
|
return not self.intervals and not self.points
|
|
@@ -158,6 +163,7 @@ def parse_incident_labels(
|
|
|
158
163
|
return IncidentLabels([], [])
|
|
159
164
|
|
|
160
165
|
raw_windows: list = []
|
|
166
|
+
raw_reviews: list = []
|
|
161
167
|
if isinstance(raw, list):
|
|
162
168
|
entries = raw
|
|
163
169
|
tz: ZoneInfo | None = None
|
|
@@ -173,6 +179,9 @@ def parse_incident_labels(
|
|
|
173
179
|
raw_windows = raw.get("capture_windows") or []
|
|
174
180
|
if not isinstance(raw_windows, list):
|
|
175
181
|
raise ValueError("'capture_windows' must be a list")
|
|
182
|
+
raw_reviews = raw.get("alert_reviews") or []
|
|
183
|
+
if not isinstance(raw_reviews, list):
|
|
184
|
+
raise ValueError("'alert_reviews' must be a list")
|
|
176
185
|
else:
|
|
177
186
|
raise ValueError("Labels must be a mapping with 'incidents' or a list of incidents")
|
|
178
187
|
|
|
@@ -205,7 +214,22 @@ def parse_incident_labels(
|
|
|
205
214
|
raise ValueError(f"Capture window start {ws} is after end {we}")
|
|
206
215
|
capture_windows.append((ws, we))
|
|
207
216
|
|
|
208
|
-
|
|
217
|
+
alert_reviews: list[tuple[datetime, datetime, str]] = []
|
|
218
|
+
for rv in raw_reviews:
|
|
219
|
+
if not isinstance(rv, dict) or "start" not in rv or "end" not in rv:
|
|
220
|
+
raise ValueError("Each alert_reviews entry needs 'start' and 'end'")
|
|
221
|
+
rs, re_ = _parse_dt(rv["start"], tz), _parse_dt(rv["end"], tz)
|
|
222
|
+
if rs > re_:
|
|
223
|
+
raise ValueError(f"Alert review start {rs} is after end {re_}")
|
|
224
|
+
verdict = "false" if str(rv.get("verdict", "valid")) == "false" else "valid"
|
|
225
|
+
alert_reviews.append((rs, re_, verdict))
|
|
226
|
+
|
|
227
|
+
return IncidentLabels(
|
|
228
|
+
intervals=intervals,
|
|
229
|
+
points=points,
|
|
230
|
+
capture_windows=capture_windows,
|
|
231
|
+
alert_reviews=alert_reviews,
|
|
232
|
+
)
|
|
209
233
|
|
|
210
234
|
|
|
211
235
|
def parse_labels_file(
|
|
@@ -282,6 +306,29 @@ def load_capture_windows(
|
|
|
282
306
|
return capture_windows_to_display(labels)
|
|
283
307
|
|
|
284
308
|
|
|
309
|
+
def alert_reviews_to_display(labels: IncidentLabels) -> list[dict[str, str]]:
|
|
310
|
+
"""Render parsed per-alert review verdicts as display dicts (naive-UTC strings)."""
|
|
311
|
+
return [
|
|
312
|
+
{
|
|
313
|
+
"start": start.strftime(_DISPLAY_FMT),
|
|
314
|
+
"end": end.strftime(_DISPLAY_FMT),
|
|
315
|
+
"verdict": verdict,
|
|
316
|
+
}
|
|
317
|
+
for start, end, verdict in labels.alert_reviews
|
|
318
|
+
]
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def load_alert_reviews(
|
|
322
|
+
path: str | Path,
|
|
323
|
+
*,
|
|
324
|
+
interval_seconds: int,
|
|
325
|
+
metric_name: str | None = None,
|
|
326
|
+
) -> list[dict[str, str]]:
|
|
327
|
+
"""Load a labels file and render its per-alert review verdicts as display dicts."""
|
|
328
|
+
labels = parse_labels_file(path, interval_seconds=interval_seconds, metric_name=metric_name)
|
|
329
|
+
return alert_reviews_to_display(labels)
|
|
330
|
+
|
|
331
|
+
|
|
285
332
|
# Versioned labels store helpers (shared by the autotune labeler server and the
|
|
286
333
|
# `dtk tune` labeler so both name + discover files the same way).
|
|
287
334
|
_LABELS_GLOBS = ("*.yml", "*.yaml", "*.json")
|
|
@@ -80,9 +80,10 @@ dtk autotune --select <sel> [--incidents FILE] [--label] [--scoring METRIC] \
|
|
|
80
80
|
**continues into tuning on it**. It **seeds from the metric's newest saved set**
|
|
81
81
|
(or from `--incidents FILE|DIR` when given), so re-running `--label` continues
|
|
82
82
|
editing in place. Beyond click-drag marking it offers **Threshold capture**
|
|
83
|
-
(grab every span above/below a horizontal line in one gesture), **
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
(grab every span above/below a horizontal line in one gesture), **Lasso capture**
|
|
84
|
+
(loop around a cloud of points; each grid-adjacent run, gaps bridged, becomes one
|
|
85
|
+
incident span), **on-chart delete** (each band's ✕, or select + Delete key), and
|
|
86
|
+
**Import file…** (load a labels file you pick). `--no-serve` writes a static
|
|
86
87
|
`metrics/<name>__labeler.html` (Export downloads the file) and exits; `--no-open`
|
|
87
88
|
prints the URL instead of launching a browser.
|
|
88
89
|
- `--scoring` — `mcc` (default), `f1`, `f_beta`, `balanced_accuracy`, `roc_auc`,
|
|
@@ -129,7 +130,9 @@ user to recall timestamps** — it is the easiest, most reliable path:
|
|
|
129
130
|
at once (above/below, with an optional gap-bridge); it captures within the
|
|
130
131
|
current view by default, and dragging across the chart limits it to a time
|
|
131
132
|
window (different boundary per period) — the painted window is **saved with the
|
|
132
|
-
set and restored on reopen** (a `capture_windows:` block; metadata only).
|
|
133
|
+
set and restored on reopen** (a `capture_windows:` block; metadata only). For an
|
|
134
|
+
irregular cloud, **Lasso capture** loops around the points freehand and turns
|
|
135
|
+
each grid-adjacent run (small gaps bridged) into one incident span. Each
|
|
133
136
|
band's ✕ (or select + Delete)
|
|
134
137
|
removes one, and **focus** on a list row jumps the chart to it.
|
|
135
138
|
3. That writes `incidents/<metric>/<metric>[-<set>]-<UTC>.yml` automatically
|
|
@@ -80,32 +80,45 @@ view of the metric's **real** persisted series and lets you turn the detector's
|
|
|
80
80
|
knobs (type — including **Manual bounds** with lower/upper sliders — threshold,
|
|
81
81
|
window, recency weighting + half-life, detrend, smoothing, **seasonality groups**,
|
|
82
82
|
**direction** (both/up/down), alert `consecutive_anomalies`) while the confidence
|
|
83
|
-
band and flagged anomalies **recompute live**. The
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
band and flagged anomalies **recompute live**. The whole screen is a **chart-first
|
|
84
|
+
cockpit**: ONE chart (the windshield) fills the view, every knob lives in a
|
|
85
|
+
**collapsible dock under it**, the metrics ride beneath the chart, and the chart is
|
|
86
|
+
**zoomable** (scroll/drag + navigator strip) with a **"Points shown"** trim slider.
|
|
87
|
+
Clicking **Apply** writes the chosen
|
|
86
88
|
config back into the metric YAML **in place** (autotune, by contrast, writes a new
|
|
87
89
|
`__tuned_<id>.yml` and never edits the original). Reads the metric's loaded
|
|
88
90
|
datapoints (run `dtk run --steps load` first if empty); the selector must resolve
|
|
89
91
|
to a single metric.
|
|
90
92
|
|
|
91
|
-
**
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
93
|
+
**Modes + alert review + live quality.** A **mode switch** picks which layers lead
|
|
94
|
+
and which interactions are armed on the one chart: **Tune** (band leads; incidents
|
|
95
|
+
recede to read-only context; hover a point for its window), **Review** (the fired
|
|
96
|
+
alerts lead, band ghosts — click an alert marker to cycle its verdict un-reviewed →
|
|
97
|
+
**valid** (green) → **false alarm** (slate); **Confirm all unreviewed valid** does
|
|
98
|
+
the lot), and **Label** (band hides; **mark the real incidents** by drag, **Lasso
|
|
99
|
+
anomalies** — loop a cloud of anomaly dots, each consecutive run, gaps bridged up to
|
|
100
|
+
`consecutive_anomalies`, becomes one span — or **Threshold capture** every span past
|
|
101
|
+
a horizontal line, each widened to a full interval so the alert lands inside; the
|
|
102
|
+
painted window saves as `capture_windows`). A confirmed (valid) alert is the user
|
|
103
|
+
asserting a real incident happened there — it folds in as a virtual incident (counts
|
|
104
|
+
toward recall + correct) so a clean metric whose alerts are all good is validated in
|
|
105
|
+
a few clicks **without hand-drawing spans**, and is written as an incident on Save.
|
|
106
|
+
As you tune, a metrics bar shows **incident catch rate (recall)** — how many
|
|
107
|
+
ground-truth incidents (marked + validated) your config catches (an incident is
|
|
108
|
+
caught when an alert's anomaly **streak overlaps** it, not just the fire instant) —
|
|
109
|
+
**false-alert rate** (what share of alerts fall outside any incident and aren't
|
|
110
|
+
confirmed valid; "≈1 in N false", a decimal so a mostly-false rate doesn't round to
|
|
111
|
+
a misleading "1 in 1") — and **reviewed N/M**; only incidents within the loaded
|
|
101
112
|
window are scored. **Save incidents** writes a
|
|
102
113
|
versioned `incidents/<metric>/*.yml` — the same store `dtk autotune` reads, so the
|
|
103
114
|
same labels feed the next supervised tune (it seeds from the newest file on open,
|
|
104
115
|
**anchoring the budget-sized loaded window on the seeded incidents** — ending just
|
|
105
116
|
past the latest one rather than at the last datapoint — so they render/count
|
|
106
|
-
without an old outlier pulling the whole history in
|
|
107
|
-
|
|
108
|
-
|
|
117
|
+
without an old outlier pulling the whole history in; per-alert verdicts persist as
|
|
118
|
+
an `alert_reviews:` metadata block and re-seed on reopen, re-bound to the moved
|
|
119
|
+
alerts by streak overlap). Saving incidents does not end the session; only **Apply**
|
|
120
|
+
does. A **y = 0 line** toggle (shared with `dtk run --report`) shows the metric
|
|
121
|
+
relative to zero.
|
|
109
122
|
|
|
110
123
|
Safe write-back: the config is validated before anything is written, the previous
|
|
111
124
|
YAML is archived under `metrics/.history/<metric>/`, and only then is the metric
|
|
@@ -16,6 +16,7 @@ from __future__ import annotations
|
|
|
16
16
|
import click
|
|
17
17
|
|
|
18
18
|
from detectkit.autotune.labels import (
|
|
19
|
+
load_alert_reviews,
|
|
19
20
|
load_capture_windows,
|
|
20
21
|
load_incidents_for_display,
|
|
21
22
|
newest_labels_file,
|
|
@@ -70,6 +71,7 @@ def run_tune(
|
|
|
70
71
|
incidents_dir = project_root / "incidents" / name
|
|
71
72
|
preload_incidents: list[dict[str, str]] = []
|
|
72
73
|
preload_capture: list[dict[str, str]] = []
|
|
74
|
+
preload_reviews: list[dict[str, str]] = []
|
|
73
75
|
newest = newest_labels_file(incidents_dir)
|
|
74
76
|
if newest is not None:
|
|
75
77
|
try:
|
|
@@ -79,6 +81,9 @@ def run_tune(
|
|
|
79
81
|
preload_capture = load_capture_windows(
|
|
80
82
|
newest, interval_seconds=interval_seconds, metric_name=name
|
|
81
83
|
)
|
|
84
|
+
preload_reviews = load_alert_reviews(
|
|
85
|
+
newest, interval_seconds=interval_seconds, metric_name=name
|
|
86
|
+
)
|
|
82
87
|
click.echo(
|
|
83
88
|
f" Seeded {len(preload_incidents)} incident(s) from "
|
|
84
89
|
f"{newest.relative_to(project_root)}"
|
|
@@ -99,6 +104,7 @@ def run_tune(
|
|
|
99
104
|
project_name=project_name,
|
|
100
105
|
incidents=preload_incidents,
|
|
101
106
|
capture_windows=preload_capture,
|
|
107
|
+
alert_reviews=preload_reviews,
|
|
102
108
|
)
|
|
103
109
|
n_points = len(payload["points"])
|
|
104
110
|
if n_points == 0:
|