detectkit 0.27.0__tar.gz → 0.28.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 (126) hide show
  1. {detectkit-0.27.0/detectkit.egg-info → detectkit-0.28.0}/PKG-INFO +1 -1
  2. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/__init__.py +1 -1
  3. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/autotune/crossval.py +24 -3
  4. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/autotune/grid_search.py +27 -7
  5. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/autotune/html_labeler.py +13 -0
  6. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/autotune/label_server.py +8 -1
  7. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/autotune/labels.py +38 -2
  8. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/autotune/settings.py +3 -1
  9. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/autotune/window_select.py +30 -11
  10. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/cli/assets/claude/rules/autotune.md +14 -6
  11. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/cli/commands/autotune.py +18 -1
  12. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/config/metric_config.py +15 -0
  13. {detectkit-0.27.0 → detectkit-0.28.0/detectkit.egg-info}/PKG-INFO +1 -1
  14. {detectkit-0.27.0 → detectkit-0.28.0}/LICENSE +0 -0
  15. {detectkit-0.27.0 → detectkit-0.28.0}/MANIFEST.in +0 -0
  16. {detectkit-0.27.0 → detectkit-0.28.0}/README.md +0 -0
  17. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/alerting/__init__.py +0 -0
  18. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/alerting/channels/__init__.py +0 -0
  19. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/alerting/channels/base.py +0 -0
  20. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/alerting/channels/branding.py +0 -0
  21. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/alerting/channels/email.py +0 -0
  22. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/alerting/channels/factory.py +0 -0
  23. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/alerting/channels/mattermost.py +0 -0
  24. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/alerting/channels/slack.py +0 -0
  25. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/alerting/channels/telegram.py +0 -0
  26. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/alerting/channels/webhook.py +0 -0
  27. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  28. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/alerting/orchestrator/_base.py +0 -0
  29. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  30. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
  31. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  32. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
  33. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  34. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  35. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/autotune/__init__.py +0 -0
  36. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/autotune/_base.py +0 -0
  37. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/autotune/_types.py +0 -0
  38. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/autotune/autotuner.py +0 -0
  39. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/autotune/config_emitter.py +0 -0
  40. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/autotune/detector_select.py +0 -0
  41. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/autotune/distribution.py +0 -0
  42. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/autotune/result.py +0 -0
  43. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/autotune/scoring.py +0 -0
  44. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/autotune/seasonality_search.py +0 -0
  45. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/cli/__init__.py +0 -0
  46. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/cli/_output.py +0 -0
  47. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
  48. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
  49. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/cli/assets/claude/rules/cli.md +0 -0
  50. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
  51. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
  52. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  53. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
  54. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
  55. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  56. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
  57. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  58. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/cli/commands/__init__.py +0 -0
  59. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/cli/commands/clean.py +0 -0
  60. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/cli/commands/init.py +0 -0
  61. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/cli/commands/init_claude.py +0 -0
  62. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/cli/commands/run.py +0 -0
  63. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/cli/commands/test_alert.py +0 -0
  64. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/cli/commands/unlock.py +0 -0
  65. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/cli/main.py +0 -0
  66. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/config/__init__.py +0 -0
  67. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/config/profile.py +0 -0
  68. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/config/project_config.py +0 -0
  69. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/config/validator.py +0 -0
  70. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/core/__init__.py +0 -0
  71. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/core/interval.py +0 -0
  72. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/core/models.py +0 -0
  73. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/database/__init__.py +0 -0
  74. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/database/_sql_manager.py +0 -0
  75. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/database/clickhouse_manager.py +0 -0
  76. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/database/internal_tables/__init__.py +0 -0
  77. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  78. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  79. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/database/internal_tables/_base.py +0 -0
  80. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  81. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/database/internal_tables/_detections.py +0 -0
  82. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  83. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  84. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/database/internal_tables/_schema.py +0 -0
  85. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  86. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/database/internal_tables/manager.py +0 -0
  87. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/database/manager.py +0 -0
  88. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/database/mysql_manager.py +0 -0
  89. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/database/postgres_manager.py +0 -0
  90. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/database/tables.py +0 -0
  91. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/detectors/__init__.py +0 -0
  92. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/detectors/base.py +0 -0
  93. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/detectors/factory.py +0 -0
  94. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/detectors/seasonality.py +0 -0
  95. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/detectors/statistical/__init__.py +0 -0
  96. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  97. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/detectors/statistical/iqr.py +0 -0
  98. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/detectors/statistical/mad.py +0 -0
  99. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  100. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/detectors/statistical/zscore.py +0 -0
  101. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/loaders/__init__.py +0 -0
  102. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/loaders/metric_loader.py +0 -0
  103. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/loaders/query_template.py +0 -0
  104. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/orchestration/__init__.py +0 -0
  105. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/orchestration/error_dispatch.py +0 -0
  106. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  107. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
  108. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/orchestration/task_manager/_base.py +0 -0
  109. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  110. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  111. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  112. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  113. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/utils/__init__.py +0 -0
  114. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/utils/datetime_utils.py +0 -0
  115. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/utils/env_interpolation.py +0 -0
  116. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/utils/json_utils.py +0 -0
  117. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit/utils/stats.py +0 -0
  118. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit.egg-info/SOURCES.txt +0 -0
  119. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit.egg-info/dependency_links.txt +0 -0
  120. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit.egg-info/entry_points.txt +0 -0
  121. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit.egg-info/requires.txt +0 -0
  122. {detectkit-0.27.0 → detectkit-0.28.0}/detectkit.egg-info/top_level.txt +0 -0
  123. {detectkit-0.27.0 → detectkit-0.28.0}/pyproject.toml +0 -0
  124. {detectkit-0.27.0 → detectkit-0.28.0}/requirements.txt +0 -0
  125. {detectkit-0.27.0 → detectkit-0.28.0}/setup.cfg +0 -0
  126. {detectkit-0.27.0 → detectkit-0.28.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.27.0
3
+ Version: 0.28.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.27.0"
7
+ __version__ = "0.28.0"
8
8
 
9
9
  from detectkit.core.interval import Interval
10
10
  from detectkit.core.models import ColumnDefinition, TableModel
@@ -61,6 +61,29 @@ def predictions_from_results(
61
61
  return y_pred, y_score, valid
62
62
 
63
63
 
64
+ def _aggregate(per_fold: list[float], stability_lambda: float) -> tuple[float, float]:
65
+ """Mean across folds minus a **downside-only** dispersion penalty.
66
+
67
+ Returns ``(aggregate, penalty)``. The penalty uses the semi-deviation of the
68
+ folds that score *below* the mean, not the full ``std``: a config that simply
69
+ scores *higher* on some folds — e.g. a recency-aware baseline that fits the
70
+ current regime better than stale history — should not be punished for that
71
+ *upside* spread. Penalizing full ``std`` did exactly that, biasing the search
72
+ against regime-adaptive configs; downside-only keeps the guard against
73
+ genuinely unstable candidates while letting an adaptive one win.
74
+ """
75
+ arr = np.asarray(per_fold, dtype=float)
76
+ mean = float(np.mean(arr))
77
+ # Downside deviation: square only the shortfalls below the mean (upside → 0),
78
+ # averaged over ALL folds. This is always <= the full std, and reduces to 0
79
+ # when folds are equal — so a config that's merely *better* on recent folds is
80
+ # not penalized, only one that drops below par on some.
81
+ deficits = np.minimum(arr - mean, 0.0)
82
+ downside = float(np.sqrt(np.mean(deficits**2)))
83
+ penalty = stability_lambda * downside
84
+ return mean - penalty, penalty
85
+
86
+
64
87
  def run_cv(
65
88
  detector: BaseDetector,
66
89
  data: dict[str, np.ndarray],
@@ -95,7 +118,5 @@ def run_cv(
95
118
  if not per_fold:
96
119
  return FoldScores(per_fold=[], aggregate=0.0, stability_penalty=0.0)
97
120
 
98
- arr = np.asarray(per_fold, dtype=float)
99
- penalty = settings.stability_lambda * float(np.std(arr))
100
- aggregate = float(np.mean(arr)) - penalty
121
+ aggregate, penalty = _aggregate(per_fold, settings.stability_lambda)
101
122
  return FoldScores(per_fold=per_fold, aggregate=aggregate, stability_penalty=penalty)
@@ -13,10 +13,13 @@ from __future__ import annotations
13
13
 
14
14
  from typing import Any
15
15
 
16
+ import numpy as np
17
+
16
18
  from detectkit.autotune._base import _AutoTuneBase
17
19
  from detectkit.autotune._types import CandidateEval
18
20
  from detectkit.autotune.window_select import (
19
21
  detect_level_shift,
22
+ half_life_grid,
20
23
  min_samples_for,
21
24
  select_window,
22
25
  trend_present,
@@ -50,18 +53,23 @@ def grid_search(
50
53
  # enough to inflate the global MAD it is measured against. When that
51
54
  # happens the engine treats the series as stationary — prefers the largest
52
55
  # window, skips detrend — and the baseline quietly averages two regimes.
53
- # Surface it so the user can narrow the window and re-tune; advisory only.
54
- found, sigmas, frac = detect_level_shift(tuner)
56
+ # Surface it (with a concrete --from date) so the user can narrow the
57
+ # window and re-tune; advisory only.
58
+ found, sigmas, idx = detect_level_shift(tuner)
55
59
  if found:
60
+ timestamps = tuner.data["timestamp"]
61
+ n = int(len(timestamps))
62
+ from_date = str(np.datetime64(timestamps[idx], "D"))
63
+ pct = round(idx / n * 100) if n else 0
56
64
  tuner.log(
57
65
  "regime",
58
66
  f"series reads stationary, but a large level shift (~{sigmas:.1f}σ "
59
- f"within-regime) sits ~{round(frac * 100)}% into the training window — "
60
- "the midpoint trend test misses an off-center shift, so the baseline "
61
- "may average two regimes. If the earlier regime is stale, re-tune with "
62
- "`--from <date after the shift>` (or set `autotune.max_history`).",
67
+ f"within-regime) sits ~{pct}% in, around {from_date} the midpoint "
68
+ "trend test misses an off-center shift, so the baseline may average "
69
+ f"two regimes. If the earlier regime is stale, re-tune with "
70
+ f"`--from {from_date}` (or set `autotune.max_history`).",
63
71
  shift_sigmas=round(sigmas, 2),
64
- shift_fraction=round(frac, 3),
72
+ shift_at=from_date,
65
73
  )
66
74
  eps = tuner.settings.min_improvement
67
75
  best_overall: CandidateEval | None = None
@@ -104,6 +112,18 @@ def grid_search(
104
112
  if ev is not None and ev.score > best.score + eps:
105
113
  best, accepted["window_weights"] = ev, weights
106
114
 
115
+ # Axis 2b: half-life of the recency weighting — only when exponential
116
+ # weighting was adopted. The detector defaults to a fixed half-life; this
117
+ # lets the search pick a faster-forgetting baseline that tracks the current
118
+ # regime (the term that matters on a metric that shifted level).
119
+ if accepted.get("window_weights") == "exponential":
120
+ for half_life in half_life_grid(accepted["window_size"], accepted["min_samples"]):
121
+ if half_life == accepted.get("half_life"):
122
+ continue
123
+ ev = tuner.safe_evaluate(detector_type, {**accepted, "half_life": half_life})
124
+ if ev is not None and ev.score > best.score + eps:
125
+ best, accepted["half_life"] = ev, half_life
126
+
107
127
  # Axis 3: detrend (gated by the trend pre-test).
108
128
  if has_trend:
109
129
  for detrend in (None, "linear"):
@@ -216,6 +216,8 @@ const INTERVAL_S = __INTERVAL__;
216
216
  // Incidents to seed the editor with (editing an existing labels file). Each is
217
217
  // {start, end, label} in "YYYY-MM-DD HH:MM:SS" UTC; a point is start === end.
218
218
  const PRELOAD = __INCIDENTS__;
219
+ // Threshold-capture window(s) to restore (from a saved file): [{start, end}] UTC.
220
+ const CAPWINS = __CAPTURE_WINDOWS__;
219
221
  const pts = DATA.points.map(p => ({ts: Date.parse(p.t.replace(' ','T')+'Z'), v: p.v}));
220
222
  const N = pts.length;
221
223
  const vraw = pts.filter(p => p.v !== null).map(p => p.v);
@@ -242,6 +244,12 @@ let selObj = null, hoverRow = -1, hoverDel = -1, thMode = false, thHover = null;
242
244
  // Threshold-capture window: thDown tracks a press, thDragWin a live drag, capWin
243
245
  // the committed custom window (null → capture within the current view).
244
246
  let thDown = null, thDragWin = null, capWin = null;
247
+ // Restore a saved capture window so re-opening a labels file keeps the painted
248
+ // regime scope (only shown once threshold capture is toggled on).
249
+ if (CAPWINS && CAPWINS.length) { const w0 = CAPWINS[0];
250
+ const a = Date.parse(String(w0.start).replace(' ','T')+'Z'),
251
+ b = Date.parse(String(w0.end).replace(' ','T')+'Z');
252
+ if (!isNaN(a) && !isNaN(b)) capWin = {a: Math.min(a,b), b: Math.max(a,b)}; }
245
253
 
246
254
  const clamp = (x,a,b) => Math.max(a, Math.min(b, x));
247
255
  const vspan = () => viewMax - viewMin;
@@ -710,6 +718,9 @@ const buildYaml = () => {
710
718
  if (!sorted.length) y+=' []\\n';
711
719
  sorted.forEach(iv => { y+=' - {start: "'+fmtTs(iv.a)+'", end: "'+fmtTs(iv.b)+'"'
712
720
  + (iv.label && iv.label.trim() ? ', label: '+yamlStr(iv.label.trim()) : '') + '}\\n'; });
721
+ // Persist the painted threshold-capture window so the regime scope is auditable
722
+ // in the saved file and restored on reopen. Pure metadata — autotune ignores it.
723
+ if (capWin) y+='capture_windows:\\n - {start: "'+fmtTs(capWin.a)+'", end: "'+fmtTs(capWin.b)+'"}\\n';
713
724
  return y;
714
725
  };
715
726
 
@@ -767,6 +778,7 @@ def render_labeler_html(
767
778
  save_url: str | None = None,
768
779
  interval_seconds: int | None = None,
769
780
  incidents: list[dict[str, str]] | None = None,
781
+ capture_windows: list[dict[str, str]] | None = None,
770
782
  ) -> str:
771
783
  """Return a self-contained HTML labeler page for *metric_name*'s series.
772
784
 
@@ -791,6 +803,7 @@ def render_labeler_html(
791
803
  return (
792
804
  _TEMPLATE.replace("__PAYLOAD__", payload)
793
805
  .replace("__INCIDENTS__", preload)
806
+ .replace("__CAPTURE_WINDOWS__", json_dumps_sorted(capture_windows or []))
794
807
  .replace("__FAVICON__", _favicon_data_uri())
795
808
  .replace("__SAVE_URL__", json.dumps(save_url))
796
809
  .replace("__INTERVAL__", json.dumps(interval_seconds))
@@ -122,11 +122,14 @@ def build_label_server(
122
122
  incidents_dir: Path,
123
123
  interval_seconds: int,
124
124
  preload: list[dict[str, str]] | None = None,
125
+ capture_windows: list[dict[str, str]] | None = None,
125
126
  ) -> tuple[_LabelServer, str]:
126
127
  """Construct (without running) the labeler server; return ``(server, page_url)``.
127
128
 
128
129
  ``preload`` seeds the labeler with already-marked incidents (editing an
129
130
  existing labels file); the caller resolves which file to load.
131
+ ``capture_windows`` restores the painted threshold-capture window from a saved
132
+ file so the regime scope survives a reopen.
130
133
  """
131
134
  server = _LabelServer(("127.0.0.1", 0), _Handler)
132
135
  token = secrets.token_urlsafe(16)
@@ -141,6 +144,7 @@ def build_label_server(
141
144
  save_url=f"http://127.0.0.1:{port}/save?token={token}",
142
145
  interval_seconds=interval_seconds,
143
146
  incidents=preload,
147
+ capture_windows=capture_windows,
144
148
  )
145
149
  return server, f"http://127.0.0.1:{port}/?token={token}"
146
150
 
@@ -155,10 +159,12 @@ def serve_labeler(
155
159
  echo: Callable[[str], None] = print,
156
160
  on_ready: Callable[[str], None] | None = None,
157
161
  preload: list[dict[str, str]] | None = None,
162
+ capture_windows: list[dict[str, str]] | None = None,
158
163
  ) -> Path | None:
159
164
  """Serve the labeler until the user saves (returns the file) or cancels (None).
160
165
 
161
- ``preload`` seeds the page with existing incidents to edit in place.
166
+ ``preload`` seeds the page with existing incidents to edit in place;
167
+ ``capture_windows`` restores the painted threshold-capture scope.
162
168
  """
163
169
  server, url = build_label_server(
164
170
  metric_name=metric_name,
@@ -166,6 +172,7 @@ def serve_labeler(
166
172
  incidents_dir=incidents_dir,
167
173
  interval_seconds=interval_seconds,
168
174
  preload=preload,
175
+ capture_windows=capture_windows,
169
176
  )
170
177
  if on_ready is not None:
171
178
  on_ready(url)
@@ -14,7 +14,7 @@ When no labels are supplied the tuner falls back to unsupervised mode.
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
- from dataclasses import dataclass
17
+ from dataclasses import dataclass, field
18
18
  from datetime import datetime, timezone
19
19
  from pathlib import Path
20
20
  from typing import Any
@@ -62,6 +62,10 @@ class IncidentLabels:
62
62
 
63
63
  intervals: list[IncidentInterval]
64
64
  points: list[IncidentPoint]
65
+ # Optional threshold-capture time window(s) painted in the labeler. Pure
66
+ # metadata: it records the regime scope the user reasoned about (auditable in
67
+ # the saved file, restored on reopen); it does NOT affect ground truth.
68
+ capture_windows: list[tuple[datetime, datetime]] = field(default_factory=list)
65
69
 
66
70
  def is_empty(self) -> bool:
67
71
  return not self.intervals and not self.points
@@ -152,6 +156,7 @@ def parse_incident_labels(
152
156
  if raw is None:
153
157
  return IncidentLabels([], [])
154
158
 
159
+ raw_windows: list = []
155
160
  if isinstance(raw, list):
156
161
  entries = raw
157
162
  tz: ZoneInfo | None = None
@@ -164,6 +169,9 @@ def parse_incident_labels(
164
169
  entries = raw.get("incidents", [])
165
170
  if not isinstance(entries, list):
166
171
  raise ValueError("'incidents' must be a list")
172
+ raw_windows = raw.get("capture_windows") or []
173
+ if not isinstance(raw_windows, list):
174
+ raise ValueError("'capture_windows' must be a list")
167
175
  else:
168
176
  raise ValueError("Labels must be a mapping with 'incidents' or a list of incidents")
169
177
 
@@ -187,7 +195,16 @@ def parse_incident_labels(
187
195
  "Each incident needs either 'at' (a point) or 'start'+'end' (an interval)"
188
196
  )
189
197
 
190
- return IncidentLabels(intervals=intervals, points=points)
198
+ capture_windows: list[tuple[datetime, datetime]] = []
199
+ for win in raw_windows:
200
+ if not isinstance(win, dict) or "start" not in win or "end" not in win:
201
+ raise ValueError("Each capture_windows entry needs 'start' and 'end'")
202
+ ws, we = _parse_dt(win["start"], tz), _parse_dt(win["end"], tz)
203
+ if ws > we:
204
+ raise ValueError(f"Capture window start {ws} is after end {we}")
205
+ capture_windows.append((ws, we))
206
+
207
+ return IncidentLabels(intervals=intervals, points=points, capture_windows=capture_windows)
191
208
 
192
209
 
193
210
  def parse_labels_file(
@@ -243,3 +260,22 @@ def load_incidents_for_display(
243
260
  """Load a canonical labels file and render it as labeler display dicts."""
244
261
  labels = parse_labels_file(path, interval_seconds=interval_seconds, metric_name=metric_name)
245
262
  return incidents_to_display(labels)
263
+
264
+
265
+ def capture_windows_to_display(labels: IncidentLabels) -> list[dict[str, str]]:
266
+ """Render parsed capture windows as labeler display dicts (naive-UTC strings)."""
267
+ return [
268
+ {"start": start.strftime(_DISPLAY_FMT), "end": end.strftime(_DISPLAY_FMT)}
269
+ for start, end in labels.capture_windows
270
+ ]
271
+
272
+
273
+ def load_capture_windows(
274
+ path: str | Path,
275
+ *,
276
+ interval_seconds: int,
277
+ metric_name: str | None = None,
278
+ ) -> list[dict[str, str]]:
279
+ """Load a labels file and render its capture windows as labeler display dicts."""
280
+ labels = parse_labels_file(path, interval_seconds=interval_seconds, metric_name=metric_name)
281
+ return capture_windows_to_display(labels)
@@ -24,7 +24,9 @@ class TuneSettings:
24
24
 
25
25
  # Cross-validation
26
26
  fold_count: int = 5
27
- stability_lambda: float = 0.5 # aggregate = mean - lambda * std(folds)
27
+ # aggregate = mean(folds) - lambda * downside_semideviation(folds); downside-only
28
+ # so a regime-adaptive config isn't penalized for scoring *better* on recent folds.
29
+ stability_lambda: float = 0.5
28
30
 
29
31
  # Detector selection: by default the grid search evaluates ALL windowed
30
32
  # statistical detectors and lets cross-validation pick the winner; the
@@ -64,7 +64,7 @@ _SHIFT_MIN_SIDE_FRAC = 0.1 # each candidate segment must hold ≥10% of the poi
64
64
  _SHIFT_SCAN_SPLITS = 24 # coarse grid of candidate split points to scan
65
65
 
66
66
 
67
- def detect_level_shift(tuner: _AutoTuneBase) -> tuple[bool, float, float]:
67
+ def detect_level_shift(tuner: _AutoTuneBase) -> tuple[bool, float, int]:
68
68
  """Scan for the strongest single level shift anywhere in the series.
69
69
 
70
70
  Complements :func:`trend_present`, which only compares the two *halves'*
@@ -74,29 +74,48 @@ def detect_level_shift(tuner: _AutoTuneBase) -> tuple[bool, float, float]:
74
74
  the series and scores each step against the **within-segment** robust scale,
75
75
  which a true step does not inflate (a smooth ramp, by contrast, keeps a large
76
76
  within-segment spread and so does not register). Returns ``(found,
77
- magnitude_sigmas, location_fraction)``; ``found`` is ``True`` only when the
78
- strongest step clears :data:`_SHIFT_SIGMA_BAR` within-regime sigmas.
77
+ magnitude_sigmas, boundary_index)`` where ``boundary_index`` is the index of
78
+ the **first point of the new regime** in ``tuner.data`` (so the caller can map
79
+ it to a timestamp for a concrete ``--from`` suggestion). The scan runs on the
80
+ raw grid (NaN-aware medians) so the index aligns with ``timestamp``. ``found``
81
+ is ``True`` only when the strongest step clears :data:`_SHIFT_SIGMA_BAR`
82
+ within-regime sigmas.
79
83
  """
80
84
  v = np.asarray(tuner.data["value"], dtype=float)
81
- v = v[~np.isnan(v)]
82
85
  n = int(v.size)
83
86
  min_side = max(4, int(n * _SHIFT_MIN_SIDE_FRAC))
84
87
  if n < _SHIFT_MIN_POINTS or n - 2 * min_side < 1:
85
- return (False, 0.0, 0.0)
88
+ return (False, 0.0, 0)
86
89
  step = max(1, (n - 2 * min_side) // _SHIFT_SCAN_SPLITS)
87
90
  best_sigmas = 0.0
88
- best_frac = 0.0
91
+ best_idx = 0
89
92
  for s in range(min_side, n - min_side + 1, step):
90
- med_l = float(np.median(v[:s]))
91
- med_r = float(np.median(v[s:]))
93
+ left, right = v[:s], v[s:]
94
+ if np.isnan(left).all() or np.isnan(right).all():
95
+ continue
96
+ med_l = float(np.nanmedian(left))
97
+ med_r = float(np.nanmedian(right))
92
98
  delta = abs(med_r - med_l)
93
99
  if delta <= 0:
94
100
  continue
95
- within = float(np.median(np.abs(np.concatenate([v[:s] - med_l, v[s:] - med_r]))))
101
+ within = float(np.nanmedian(np.abs(np.concatenate([left - med_l, right - med_r]))))
96
102
  sigmas = delta / (1.4826 * within) if within > 0 else 99.0
97
103
  if sigmas > best_sigmas:
98
- best_sigmas, best_frac = sigmas, s / n
99
- return (best_sigmas >= _SHIFT_SIGMA_BAR, min(best_sigmas, 99.0), best_frac)
104
+ best_sigmas, best_idx = sigmas, s
105
+ return (best_sigmas >= _SHIFT_SIGMA_BAR, min(best_sigmas, 99.0), best_idx)
106
+
107
+
108
+ def half_life_grid(window_size: int, min_samples: int) -> list[int]:
109
+ """Candidate half-lives (in points) for the recency-weighting sweep.
110
+
111
+ Spaced as fractions of the window so the search can trade a fast-forgetting
112
+ baseline (small half-life → tracks the current regime, good after a shift)
113
+ against a steady one. Floored at ``min_samples / 2`` to keep the weighted
114
+ effective sample size from collapsing into a noisy band.
115
+ """
116
+ floor = max(2, min_samples // 2)
117
+ cands = {round(window_size * f) for f in (0.05, 0.1, 0.25, 0.5)}
118
+ return sorted({max(floor, c) for c in cands if c >= 2})
100
119
 
101
120
 
102
121
  def select_window(
@@ -27,8 +27,12 @@ same windowed detectors and `detector_id` identity). The fastest path is the
27
27
  detectors (`mad`/`zscore`/`iqr`) and cross-validation picks the winner — no
28
28
  type is excluded by heuristic.
29
29
  3. **Hyperparameters** — a bounded coordinate grid search over `threshold`,
30
- recency weighting, detrending and `window_size`, maximizing a
31
- cross-validated score, with a final `threshold` re-sweep at the chosen window.
30
+ recency weighting (and its **half-life** when adopted), detrending and
31
+ `window_size`, maximizing a cross-validated score, with a final `threshold`
32
+ re-sweep at the chosen window. Fold scores aggregate as
33
+ `mean − stability_lambda · downside_deviation` (downside-only, so a config that
34
+ scores *better* on recent folds isn't penalized; lower `stability_lambda` for a
35
+ regime-shift metric).
32
36
  4. **History window** — on near-ties uses a trend-gated tie-break: a stationary
33
37
  series prefers the **larger** `window_size` ("more history is better"), a
34
38
  trending / regime-shifting one the **smaller**; sets `loading_start_time` to
@@ -37,9 +41,10 @@ same windowed detectors and `detector_id` identity). The fastest path is the
37
41
  test, so it can miss a level shift that sits off-center or self-masks by
38
42
  inflating the global MAD; a backstop scan then logs a **`REGIME`** advisory in
39
43
  the decision log (and streams it) when the series reads stationary yet a large
40
- (≥3σ within-regime) level shift is present — surface it to the user and suggest
41
- re-tuning with `--from <date after the shift>` (or `autotune.max_history`) if
42
- the earlier regime is stale. Advisory only; it changes no chosen parameters,
44
+ (≥3σ within-regime) level shift is present — it names a **concrete `--from
45
+ <date>`** (the shift's timestamp); surface it to the user and suggest re-tuning
46
+ with that date (or `autotune.max_history`) if the earlier regime is stale.
47
+ Advisory only; it changes no chosen parameters,
43
48
  and it detects level shifts, not variance/shape changes (label incidents for
44
49
  those).
45
50
  5. **Alert window** (supervised only) — sweeps `consecutive_anomalies` on the
@@ -109,7 +114,9 @@ user to recall timestamps** — it is the easiest, most reliable path:
109
114
  clear outliers, **Threshold capture** grabs every span past a horizontal line
110
115
  at once (above/below, with an optional gap-bridge); it captures within the
111
116
  current view by default, and dragging across the chart limits it to a time
112
- window (different boundary per period). Each band's (or select + Delete)
117
+ window (different boundary per period) the painted window is **saved with the
118
+ set and restored on reopen** (a `capture_windows:` block; metadata only). Each
119
+ band's ✕ (or select + Delete)
113
120
  removes one, and **focus** on a list row jumps the chart to it.
114
121
  3. That writes `incidents/<metric>/<metric>[-<set>]-<UTC>.yml` automatically
115
122
  (named after the metric, optional set name as a suffix; versioned —
@@ -156,6 +163,7 @@ autotune:
156
163
  force_seasonality: [hour] # pin the grouping, skip the search (see below)
157
164
  fixed_params: {window_size: 4320} # pin hyperparameters (excluded from search)
158
165
  folds: 5
166
+ stability_lambda: 0.5 # downside-dispersion penalty weight (0 disables)
159
167
  max_history: 50000 # cap training points
160
168
  ```
161
169
 
@@ -32,6 +32,7 @@ from detectkit.autotune.labels import (
32
32
  GroundTruth,
33
33
  IncidentLabels,
34
34
  incidents_to_display,
35
+ load_capture_windows,
35
36
  load_incidents_for_display,
36
37
  parse_incident_labels,
37
38
  )
@@ -264,6 +265,7 @@ def _build_settings(*, scoring: ScoringMetric, autotune_cfg: AutoTuneConfig) ->
264
265
  metric=scoring,
265
266
  beta=autotune_cfg.beta,
266
267
  fold_count=autotune_cfg.folds,
268
+ stability_lambda=autotune_cfg.stability_lambda,
267
269
  allowed_detector_types=autotune_cfg.detector_types,
268
270
  allowed_seasonality=autotune_cfg.seasonality_candidates,
269
271
  force_seasonality=autotune_cfg.force_seasonality,
@@ -427,9 +429,23 @@ def _tune_one(
427
429
  except ValueError: # an absolute path outside the project tree
428
430
  where = preload_src
429
431
  click.echo(f" Editing {len(preload)} existing incident(s) from {where}")
432
+ # Restore the painted threshold-capture window from the same seed file
433
+ # (best-effort — a missing/old file just yields no window).
434
+ capture_windows: list[dict[str, str]] = []
435
+ if preload_src is not None:
436
+ try:
437
+ capture_windows = load_capture_windows(
438
+ preload_src, interval_seconds=interval_seconds, metric_name=name
439
+ )
440
+ except Exception: # noqa: BLE001 — restoring the scope is a convenience
441
+ capture_windows = []
430
442
  if no_serve:
431
443
  html = render_labeler_html(
432
- name, data, interval_seconds=interval_seconds, incidents=preload
444
+ name,
445
+ data,
446
+ interval_seconds=interval_seconds,
447
+ incidents=preload,
448
+ capture_windows=capture_windows,
433
449
  )
434
450
  out = project_root / "metrics" / f"{metric_path.stem}__labeler.html"
435
451
  out.write_text(html, encoding="utf-8")
@@ -447,6 +463,7 @@ def _tune_one(
447
463
  open_browser=not no_open,
448
464
  echo=click.echo,
449
465
  preload=preload,
466
+ capture_windows=capture_windows,
450
467
  )
451
468
  if saved is None:
452
469
  echo_noop(name, "labeling cancelled — no labels saved")
@@ -371,6 +371,13 @@ class AutoTuneConfig(BaseModel):
371
371
  default_factory=dict, description="Hyperparameters pinned across the whole search"
372
372
  )
373
373
  folds: int = Field(default=5, description="Cross-validation folds")
374
+ stability_lambda: float = Field(
375
+ default=0.5,
376
+ description="Weight on the cross-fold downside-dispersion penalty "
377
+ "(aggregate = mean - lambda * downside_semideviation). Lower it (e.g. 0.0) for a "
378
+ "metric whose behavior differs across a regime shift, so a config that adapts to "
379
+ "the recent regime isn't penalized for fold-to-fold variance.",
380
+ )
374
381
  max_history: int | None = Field(
375
382
  default=None, description="Cap on training points used during the search"
376
383
  )
@@ -463,6 +470,14 @@ class AutoTuneConfig(BaseModel):
463
470
  raise ValueError("max_history must be at least 1")
464
471
  return v
465
472
 
473
+ @field_validator("stability_lambda")
474
+ @classmethod
475
+ def validate_stability_lambda(cls, v: float) -> float:
476
+ """The dispersion-penalty weight must be non-negative."""
477
+ if v < 0:
478
+ raise ValueError("stability_lambda must be >= 0")
479
+ return v
480
+
466
481
  @model_validator(mode="after")
467
482
  def validate_inline_incidents(self) -> "AutoTuneConfig":
468
483
  """Validate inline incidents: not alongside labels_file, and well-formed.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.27.0
3
+ Version: 0.28.0
4
4
  Summary: Metric monitoring with automatic anomaly detection
5
5
  Author: detectkit team
6
6
  License: MIT
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes