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.
Files changed (139) hide show
  1. {detectkit-0.36.2/detectkit.egg-info → detectkit-0.38.0}/PKG-INFO +1 -1
  2. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/__init__.py +1 -1
  3. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/html_labeler.py +67 -10
  4. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/labels.py +48 -1
  5. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/assets/claude/rules/autotune.md +7 -4
  6. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/assets/claude/rules/cli.md +29 -16
  7. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/commands/tune.py +6 -0
  8. detectkit-0.38.0/detectkit/tuning/assets/tune.js +147 -0
  9. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/tuning/payload.py +4 -0
  10. {detectkit-0.36.2 → detectkit-0.38.0/detectkit.egg-info}/PKG-INFO +1 -1
  11. detectkit-0.36.2/detectkit/tuning/assets/tune.js +0 -131
  12. {detectkit-0.36.2 → detectkit-0.38.0}/LICENSE +0 -0
  13. {detectkit-0.36.2 → detectkit-0.38.0}/MANIFEST.in +0 -0
  14. {detectkit-0.36.2 → detectkit-0.38.0}/README.md +0 -0
  15. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/__init__.py +0 -0
  16. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/channels/__init__.py +0 -0
  17. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/channels/base.py +0 -0
  18. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/channels/branding.py +0 -0
  19. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/channels/email.py +0 -0
  20. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/channels/factory.py +0 -0
  21. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/channels/mattermost.py +0 -0
  22. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/channels/slack.py +0 -0
  23. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/channels/telegram.py +0 -0
  24. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/channels/webhook.py +0 -0
  25. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  26. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/orchestrator/_base.py +0 -0
  27. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  28. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
  29. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  30. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
  31. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
  32. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  33. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  34. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/__init__.py +0 -0
  35. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/_base.py +0 -0
  36. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/_types.py +0 -0
  37. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/autotuner.py +0 -0
  38. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/config_emitter.py +0 -0
  39. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/crossval.py +0 -0
  40. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/detector_select.py +0 -0
  41. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/distribution.py +0 -0
  42. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/grid_search.py +0 -0
  43. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/label_server.py +0 -0
  44. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/result.py +0 -0
  45. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/scoring.py +0 -0
  46. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/seasonality_search.py +0 -0
  47. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/settings.py +0 -0
  48. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/autotune/window_select.py +0 -0
  49. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/__init__.py +0 -0
  50. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/_output.py +0 -0
  51. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
  52. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
  53. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
  54. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
  55. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  56. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
  57. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
  58. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  59. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
  60. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  61. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/commands/__init__.py +0 -0
  62. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/commands/autotune.py +0 -0
  63. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/commands/clean.py +0 -0
  64. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/commands/init.py +0 -0
  65. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/commands/init_claude.py +0 -0
  66. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/commands/run.py +0 -0
  67. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/commands/test_alert.py +0 -0
  68. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/commands/unlock.py +0 -0
  69. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/cli/main.py +0 -0
  70. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/config/__init__.py +0 -0
  71. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/config/metric_config.py +0 -0
  72. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/config/profile.py +0 -0
  73. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/config/project_config.py +0 -0
  74. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/config/validator.py +0 -0
  75. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/core/__init__.py +0 -0
  76. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/core/interval.py +0 -0
  77. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/core/models.py +0 -0
  78. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/__init__.py +0 -0
  79. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/_sql_manager.py +0 -0
  80. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/clickhouse_manager.py +0 -0
  81. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/internal_tables/__init__.py +0 -0
  82. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  83. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  84. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/internal_tables/_base.py +0 -0
  85. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  86. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/internal_tables/_detections.py +0 -0
  87. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  88. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  89. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/internal_tables/_schema.py +0 -0
  90. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  91. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/internal_tables/manager.py +0 -0
  92. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/manager.py +0 -0
  93. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/mysql_manager.py +0 -0
  94. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/postgres_manager.py +0 -0
  95. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/database/tables.py +0 -0
  96. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/detectors/__init__.py +0 -0
  97. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/detectors/base.py +0 -0
  98. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/detectors/factory.py +0 -0
  99. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/detectors/seasonality.py +0 -0
  100. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/detectors/statistical/__init__.py +0 -0
  101. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  102. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/detectors/statistical/iqr.py +0 -0
  103. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/detectors/statistical/mad.py +0 -0
  104. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  105. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/detectors/statistical/zscore.py +0 -0
  106. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/loaders/__init__.py +0 -0
  107. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/loaders/metric_loader.py +0 -0
  108. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/loaders/query_template.py +0 -0
  109. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/orchestration/__init__.py +0 -0
  110. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/orchestration/error_dispatch.py +0 -0
  111. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  112. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
  113. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/orchestration/task_manager/_base.py +0 -0
  114. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  115. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  116. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  117. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  118. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/reporting/__init__.py +0 -0
  119. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/reporting/assets/report.js +0 -0
  120. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/reporting/builder.py +0 -0
  121. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/reporting/html_report.py +0 -0
  122. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/tuning/__init__.py +0 -0
  123. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/tuning/config_writer.py +0 -0
  124. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/tuning/html.py +0 -0
  125. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/tuning/server.py +0 -0
  126. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/utils/__init__.py +0 -0
  127. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/utils/datetime_utils.py +0 -0
  128. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/utils/env_interpolation.py +0 -0
  129. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/utils/json_utils.py +0 -0
  130. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit/utils/stats.py +0 -0
  131. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit.egg-info/SOURCES.txt +0 -0
  132. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit.egg-info/dependency_links.txt +0 -0
  133. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit.egg-info/entry_points.txt +0 -0
  134. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit.egg-info/requires.txt +0 -0
  135. {detectkit-0.36.2 → detectkit-0.38.0}/detectkit.egg-info/top_level.txt +0 -0
  136. {detectkit-0.36.2 → detectkit-0.38.0}/pyproject.toml +0 -0
  137. {detectkit-0.36.2 → detectkit-0.38.0}/requirements.txt +0 -0
  138. {detectkit-0.36.2 → detectkit-0.38.0}/setup.cfg +0 -0
  139. {detectkit-0.36.2 → detectkit-0.38.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.36.2
3
+ Version: 0.38.0
4
4
  Summary: Metric monitoring with automatic anomaly detection
5
5
  Author: detectkit team
6
6
  License: MIT
@@ -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.36.2"
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), **on-chart deletion** (each band carries an handle; the selected
14
- band also responds to the Delete key), and an in-browser **Import** button that
15
- loads a labels file you pick (YAML/JSON).
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>focus</b> a row to jump the chart to it · scroll to zoom, double-click to reset · drag the navigator
220
- window below to pan.</div>
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; } else { thHover=null; thDown=null; thDragWin=null; capWin=null; updateThWin(); }
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(); const t=tsAt(e.clientX);
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 (thMode) toggleTh(false); else if (selObj) { selObj=null; draw(); } return; }
717
- if ((e.key==='Delete' || e.key==='Backspace') && selObj && !typing) { e.preventDefault(); removeIncident(selObj); }
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
- return IncidentLabels(intervals=intervals, points=points, capture_windows=capture_windows)
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), **on-chart
84
- delete** (each band's ✕, or select + Delete key), and **Import file…** (load a
85
- labels file you pick). `--no-serve` writes a static
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). Each
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 chart is **zoomable** (scroll/drag +
84
- a navigator strip) and a **"Points shown"** slider trims the active sample to speed
85
- up recompute on a long metric. Clicking **Apply** writes the chosen
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
- **Mark incidents + see alert quality live.** A second, **synced** chart beneath
92
- the detector view lets you **mark the real incidents** (drag to create a span,
93
- drag its edges/middle to adjust, or Delete to remove), or **Threshold capture**
94
- every contiguous span past a horizontal line in one shot (set the line by click or
95
- value, above/below, optional gap-bridging, optionally limited to a painted time
96
- window — the same tool as the autotune labeler; the window is saved as
97
- `capture_windows` and restored on reopen). As you tune, a metrics
98
- bar shows two operator numbers: **incident catch rate (recall)** how many marked
99
- incidents your config catches and **false-alert rate** what share of alerts
100
- fall outside any real incident ("≈1 in N false"); only incidents within the loaded
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). Saving incidents does not end
107
- the session; only **Apply** does. A
108
- **y = 0 line** toggle (shared with `dtk run --report`) shows the metric relative to zero.
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: