detectkit 0.50.0__tar.gz → 0.51.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 (155) hide show
  1. {detectkit-0.50.0/detectkit.egg-info → detectkit-0.51.0}/PKG-INFO +1 -1
  2. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/__init__.py +1 -1
  3. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/autotune/grid_search.py +20 -6
  4. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/cli/assets/claude/rules/autotune.md +4 -3
  5. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/cli/assets/claude/rules/cli.md +1 -1
  6. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/cli/assets/claude/rules/detectors.md +23 -3
  7. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/cli/assets/claude/skills/dtk-tune/SKILL.md +4 -1
  8. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/detectors/statistical/_windowed.py +55 -2
  9. detectkit-0.51.0/detectkit/tuning/assets/tune.js +187 -0
  10. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/tuning/payload.py +1 -0
  11. {detectkit-0.50.0 → detectkit-0.51.0/detectkit.egg-info}/PKG-INFO +1 -1
  12. detectkit-0.50.0/detectkit/tuning/assets/tune.js +0 -187
  13. {detectkit-0.50.0 → detectkit-0.51.0}/LICENSE +0 -0
  14. {detectkit-0.50.0 → detectkit-0.51.0}/MANIFEST.in +0 -0
  15. {detectkit-0.50.0 → detectkit-0.51.0}/README.md +0 -0
  16. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/alerting/__init__.py +0 -0
  17. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/alerting/channels/__init__.py +0 -0
  18. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/alerting/channels/base.py +0 -0
  19. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/alerting/channels/branding.py +0 -0
  20. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/alerting/channels/email.py +0 -0
  21. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/alerting/channels/factory.py +0 -0
  22. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/alerting/channels/mattermost.py +0 -0
  23. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/alerting/channels/slack.py +0 -0
  24. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/alerting/channels/telegram.py +0 -0
  25. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/alerting/channels/webhook.py +0 -0
  26. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  27. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/alerting/orchestrator/_base.py +0 -0
  28. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  29. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
  30. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  31. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
  32. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
  33. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  34. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  35. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/autotune/__init__.py +0 -0
  36. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/autotune/_base.py +0 -0
  37. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/autotune/_types.py +0 -0
  38. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/autotune/autotuner.py +0 -0
  39. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/autotune/config_emitter.py +0 -0
  40. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/autotune/crossval.py +0 -0
  41. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/autotune/detector_select.py +0 -0
  42. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/autotune/distribution.py +0 -0
  43. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/autotune/labels.py +0 -0
  44. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/autotune/result.py +0 -0
  45. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/autotune/runner.py +0 -0
  46. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/autotune/scoring.py +0 -0
  47. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/autotune/seasonality_search.py +0 -0
  48. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/autotune/settings.py +0 -0
  49. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/autotune/window_select.py +0 -0
  50. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/cli/__init__.py +0 -0
  51. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/cli/_output.py +0 -0
  52. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
  53. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
  54. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
  55. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  56. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
  57. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
  58. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  59. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
  60. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  61. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/cli/commands/__init__.py +0 -0
  62. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/cli/commands/autotune.py +0 -0
  63. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/cli/commands/clean.py +0 -0
  64. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/cli/commands/init.py +0 -0
  65. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/cli/commands/init_claude.py +0 -0
  66. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/cli/commands/osi.py +0 -0
  67. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/cli/commands/run.py +0 -0
  68. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/cli/commands/test_alert.py +0 -0
  69. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/cli/commands/tune.py +0 -0
  70. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/cli/commands/ui.py +0 -0
  71. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/cli/commands/unlock.py +0 -0
  72. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/cli/main.py +0 -0
  73. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/config/__init__.py +0 -0
  74. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/config/metric_config.py +0 -0
  75. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/config/metric_io.py +0 -0
  76. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/config/profile.py +0 -0
  77. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/config/project_config.py +0 -0
  78. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/config/validator.py +0 -0
  79. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/core/__init__.py +0 -0
  80. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/core/interval.py +0 -0
  81. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/core/models.py +0 -0
  82. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/database/__init__.py +0 -0
  83. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/database/_sql_manager.py +0 -0
  84. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/database/clickhouse_manager.py +0 -0
  85. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/database/internal_tables/__init__.py +0 -0
  86. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  87. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  88. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/database/internal_tables/_base.py +0 -0
  89. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  90. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/database/internal_tables/_detections.py +0 -0
  91. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  92. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  93. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/database/internal_tables/_schema.py +0 -0
  94. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  95. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/database/internal_tables/manager.py +0 -0
  96. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/database/manager.py +0 -0
  97. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/database/mysql_manager.py +0 -0
  98. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/database/postgres_manager.py +0 -0
  99. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/database/tables.py +0 -0
  100. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/detectors/__init__.py +0 -0
  101. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/detectors/base.py +0 -0
  102. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/detectors/factory.py +0 -0
  103. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/detectors/seasonality.py +0 -0
  104. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/detectors/statistical/__init__.py +0 -0
  105. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/detectors/statistical/iqr.py +0 -0
  106. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/detectors/statistical/mad.py +0 -0
  107. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  108. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/detectors/statistical/zscore.py +0 -0
  109. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/loaders/__init__.py +0 -0
  110. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/loaders/metric_loader.py +0 -0
  111. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/loaders/query_template.py +0 -0
  112. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/orchestration/__init__.py +0 -0
  113. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/orchestration/error_dispatch.py +0 -0
  114. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  115. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
  116. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/orchestration/task_manager/_base.py +0 -0
  117. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  118. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  119. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  120. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  121. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/reporting/__init__.py +0 -0
  122. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/reporting/assets/report.js +0 -0
  123. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/reporting/builder.py +0 -0
  124. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/reporting/html_report.py +0 -0
  125. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/semantic/__init__.py +0 -0
  126. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/semantic/errors.py +0 -0
  127. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/semantic/exporter.py +0 -0
  128. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/semantic/importer.py +0 -0
  129. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/semantic/osi_model.py +0 -0
  130. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/semantic/query_gen.py +0 -0
  131. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/tuning/__init__.py +0 -0
  132. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/tuning/config_writer.py +0 -0
  133. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/tuning/html.py +0 -0
  134. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/tuning/server.py +0 -0
  135. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/ui/__init__.py +0 -0
  136. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/ui/assets/ui.js +0 -0
  137. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/ui/html.py +0 -0
  138. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/ui/jobs.py +0 -0
  139. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/ui/metric_files.py +0 -0
  140. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/ui/overview.py +0 -0
  141. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/ui/server.py +0 -0
  142. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/utils/__init__.py +0 -0
  143. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/utils/datetime_utils.py +0 -0
  144. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/utils/env_interpolation.py +0 -0
  145. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/utils/json_utils.py +0 -0
  146. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit/utils/stats.py +0 -0
  147. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit.egg-info/SOURCES.txt +0 -0
  148. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit.egg-info/dependency_links.txt +0 -0
  149. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit.egg-info/entry_points.txt +0 -0
  150. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit.egg-info/requires.txt +0 -0
  151. {detectkit-0.50.0 → detectkit-0.51.0}/detectkit.egg-info/top_level.txt +0 -0
  152. {detectkit-0.50.0 → detectkit-0.51.0}/pyproject.toml +0 -0
  153. {detectkit-0.50.0 → detectkit-0.51.0}/requirements.txt +0 -0
  154. {detectkit-0.50.0 → detectkit-0.51.0}/setup.cfg +0 -0
  155. {detectkit-0.50.0 → detectkit-0.51.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.50.0
3
+ Version: 0.51.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.50.0"
7
+ __version__ = "0.51.0"
8
8
 
9
9
  from detectkit.core.interval import Interval
10
10
  from detectkit.core.models import ColumnDefinition, TableModel
@@ -1,12 +1,13 @@
1
1
  """Stage 3: bounded coordinate search over detector hyperparameters.
2
2
 
3
3
  Not a Cartesian product — a greedy coordinate sweep per candidate type:
4
- threshold → recency weighting → detrend (gated by a trend test) → window size
5
- (with the trend-gated window tie-bias from window_select) → a final threshold
6
- re-sweep at the chosen window (the threshold↔window coupling fix). The objective
7
- is the cross-validated score (settings.metric, or the unsupervised objective when
8
- there are no labels). Total evaluations stay in the low tens per type and are
9
- capped by settings.max_candidates.
4
+ threshold → recency weighting → detrend (gated by a trend test) → stabilization
5
+ (anomaly-robust baseline) → window size (with the trend-gated window tie-bias
6
+ from window_select) → a final threshold re-sweep at the chosen window (the
7
+ threshold↔window coupling fix). The objective is the cross-validated score
8
+ (settings.metric, or the unsupervised objective when there are no labels).
9
+ Total evaluations stay in the low tens per type and are capped by
10
+ settings.max_candidates.
10
11
  """
11
12
 
12
13
  from __future__ import annotations
@@ -149,6 +150,19 @@ def grid_search(
149
150
  if ev is not None and ev.score > best.score + eps:
150
151
  best, accepted["detrend"] = ev, detrend
151
152
 
153
+ # Axis 3b: stabilization (anomaly-robust baseline; adopt only when it
154
+ # clears the margin). Flagged points enter subsequent windows clamped
155
+ # to the bound they violated, so a sustained incident cannot inflate
156
+ # the band and mask its own tail — usually decisive on labeled data
157
+ # with long incidents, near-neutral on clean series. Swept before the
158
+ # window axis so select_window evaluates with the adopted baseline.
159
+ for stabilization in (None, "clamp"):
160
+ if stabilization == accepted.get("stabilization"):
161
+ continue
162
+ ev = tuner.safe_evaluate(detector_type, {**accepted, "stabilization": stabilization})
163
+ if ev is not None and ev.score > best.score + eps:
164
+ best, accepted["stabilization"] = ev, stabilization
165
+
152
166
  # Axis 4: window size (large-window tie-bias, trend-gated in select_window).
153
167
  window_best = select_window(tuner, detector_type, accepted, best, grid)
154
168
  if window_best.score >= best.score - tuner.settings.window_tie_margin:
@@ -45,9 +45,10 @@ same windowed detectors and `detector_id` identity). The fastest path is the
45
45
  detectors (`mad`/`zscore`/`iqr`) and cross-validation picks the winner — no
46
46
  type is excluded by heuristic.
47
47
  3. **Hyperparameters** — a bounded coordinate grid search over `threshold`,
48
- recency weighting (and its **half-life** when adopted), detrending and
49
- `window_size`, maximizing a cross-validated score, with a final `threshold`
50
- re-sweep at the chosen window. Fold scores aggregate as
48
+ recency weighting (and its **half-life** when adopted), detrending,
49
+ **stabilization** (`clamp`, adopted only when it clears the score margin)
50
+ and `window_size`, maximizing a cross-validated score, with a final
51
+ `threshold` re-sweep at the chosen window. Fold scores aggregate as
51
52
  `mean − stability_lambda · downside_deviation` (downside-only, so a config that
52
53
  scores *better* on recent folds isn't penalized; lower `stability_lambda` for a
53
54
  regime-shift metric).
@@ -84,7 +84,7 @@ Full reference: `autotune.md`.
84
84
  The **manual, interactive** sibling of `dtk autotune`. Opens a localhost browser
85
85
  view of the metric's **real** persisted series and lets you turn the detector's
86
86
  knobs (type — including **Manual bounds** with lower/upper sliders — threshold,
87
- window, recency weighting + half-life, detrend, smoothing, **seasonality groups**,
87
+ window, recency weighting + half-life, detrend, stabilization, smoothing, **seasonality groups**,
88
88
  **direction** (both/up/down), alert `consecutive_anomalies`) while the confidence
89
89
  band and flagged anomalies **recompute live**. The whole screen is a **chart-first
90
90
  cockpit**: ONE chart (the windshield) fills the view, the live metrics ride
@@ -66,6 +66,7 @@ expected interval for the current point.
66
66
  window_weights: null # null (uniform) | exponential | linear
67
67
  half_life: null # exponential half-life: int points or "3d"/"12h"
68
68
  detrend: null # null | linear
69
+ stabilization: null # null (off) | clamp
69
70
  # --- execution (NOT hashed) ---
70
71
  start_time: "2024-01-01 00:00:00" # optional; when detection begins (default: loading_start_time)
71
72
  batch_size: 500
@@ -161,6 +162,24 @@ Trade-off: a shorter `half_life` adapts faster but also "accepts" a real
161
162
  sustained degradation as the new normal sooner. (`weight_decay` is a deprecated
162
163
  alias for `half_life`; prefer `half_life`.)
163
164
 
165
+ ## Stabilization — sustained incidents poisoning their own baseline
166
+
167
+ A sustained incident's anomalous points enter the trailing window and inflate
168
+ the spread / drag the center toward it, so the band widens mid-incident and the
169
+ detector stops flagging the tail ("the incident becomes the new normal") —
170
+ z-score (mean/std) is the most vulnerable; median/quartile-based mad/iqr resist
171
+ short incidents but still bend under a long one. `stabilization: clamp` fixes
172
+ this: once a point is flagged anomalous, subsequent trailing windows see it
173
+ **clamped to the confidence bound it violated** (winsorized) instead of the
174
+ raw value — only the statistics windows read the substituted history; scored
175
+ and persisted values are unchanged, and anomalies still render as anomalies.
176
+ Reach for it when alerting on metrics with occasional sustained incidents; it
177
+ composes with `window_weights`/`half_life` (where it matters most, since
178
+ recency weighting gives an ongoing incident's points more weight), `detrend`,
179
+ `smoothing`, `input_type` and seasonality groups, and is near-neutral on clean
180
+ series. Enabling it is a hashed change — it produces a new `detector_id` and
181
+ recomputes on the next run; existing configs that don't set it keep their ids.
182
+
164
183
  ## Feature compatibility
165
184
 
166
185
  | Feature | mad | zscore | iqr | manual_bounds |
@@ -169,6 +188,7 @@ alias for `half_life`; prefer `half_life`.)
169
188
  | `smoothing` | Yes | Yes | Yes | No |
170
189
  | `window_weights` / `half_life` | Yes | Yes | Yes | No |
171
190
  | `detrend` | Yes | Yes | Yes | No |
191
+ | `stabilization` | Yes | Yes | Yes | No |
172
192
  | `seasonality_components` | Yes | Yes | Yes | No |
173
193
 
174
194
  `manual_bounds` has no window, so window-based features don't apply.
@@ -177,9 +197,9 @@ alias for `half_life`; prefer `half_life`.)
177
197
 
178
198
  Every parameter that affects results (threshold, window_size, min_samples,
179
199
  seasonality_components, min_samples_per_group, input_type, smoothing*,
180
- window_weights, half_life, detrend) is hashed into the `detector_id` — only
181
- non-default values participate. Execution params (`start_time`, `batch_size`)
182
- are **not** hashed.
200
+ window_weights, half_life, detrend, stabilization) is hashed into the
201
+ `detector_id` — only non-default values participate. Execution params
202
+ (`start_time`, `batch_size`) are **not** hashed.
183
203
 
184
204
  Changing any hashed parameter creates a new `detector_id` and recomputes that
185
205
  detector's detections from scratch on the next run; old rows stay under the
@@ -101,7 +101,10 @@ Walk the user to the mode that fits what they want to do:
101
101
  **window size**, **recency weighting + half-life**, **detrend**, **smoothing**,
102
102
  and **seasonality groups**; the band + anomalies + would-fire alerts recompute
103
103
  on every change. The window-size / half-life readouts show the equivalent
104
- wall-clock span. This is the core "turn the knobs yourself" loop.
104
+ wall-clock span. **Stabilization** (none / clamp) clamps a flagged point to
105
+ the bound it violated in later windows' statistics — try it when a sustained
106
+ incident visibly balloons the band mid-incident and the detector stops
107
+ flagging its tail. This is the core "turn the knobs yourself" loop.
105
108
 
106
109
  - **Autotune** — *let the engine search, then refine.* Click **Run autotune** and
107
110
  the **same engine `dtk autotune` uses** (seasonality → detector → grid → window,
@@ -48,6 +48,7 @@ _CHANGE_INPUT_TYPES = {"changes", "absolute_changes", "log_changes"}
48
48
  _SMOOTHING_METHODS = {None, "ema", "sma"}
49
49
  _WEIGHT_METHODS = {None, "exponential", "linear"}
50
50
  _DETREND_METHODS = {None, "linear"}
51
+ _STABILIZATION_METHODS = {None, "clamp"}
51
52
 
52
53
 
53
54
  class WindowedStatDetector(BaseDetector):
@@ -88,6 +89,18 @@ class WindowedStatDetector(BaseDetector):
88
89
  compute statistics on values projected to the current point.
89
90
  Recommended for metrics with a gradual trend so the slow
90
91
  drift itself is not flagged as anomalous.
92
+ stabilization (str | None): None (default) or "clamp" — when a
93
+ point is flagged anomalous, subsequent trailing windows see
94
+ the confidence bound it violated (winsorized value) instead
95
+ of the observed value. Keeps a long incident from poisoning
96
+ the baseline (inflating spread / dragging the center toward
97
+ the incident), so the detector keeps flagging it instead of
98
+ adapting to it. Clamping to the bound (not the center) is
99
+ deliberate: substituting the band center feeds zero-deviation
100
+ points back into the spread statistics, which collapses the
101
+ band and cascades into false flags after a long incident.
102
+ The scored/persisted values are unchanged — only the
103
+ statistics windows read the substituted history.
91
104
 
92
105
  All parameters that change detection output participate in the
93
106
  detector ID hash (only non-default values are hashed).
@@ -124,6 +137,7 @@ class WindowedStatDetector(BaseDetector):
124
137
  half_life: int | str | None = None,
125
138
  weight_decay: float | None = None,
126
139
  detrend: str | None = None,
140
+ stabilization: str | None = None,
127
141
  ):
128
142
  super().__init__(
129
143
  threshold=self.THRESHOLD_DEFAULT if threshold is None else threshold,
@@ -143,6 +157,7 @@ class WindowedStatDetector(BaseDetector):
143
157
  half_life=half_life,
144
158
  weight_decay=weight_decay,
145
159
  detrend=detrend,
160
+ stabilization=stabilization,
146
161
  )
147
162
  # Whether the one-time "seasonality groups can't fill this window" check
148
163
  # has run for this instance (so the warning fires at most once per run,
@@ -235,6 +250,10 @@ class WindowedStatDetector(BaseDetector):
235
250
  if detrend not in _DETREND_METHODS:
236
251
  raise ValueError(f"Unknown detrend method: {detrend}. Supported: linear")
237
252
 
253
+ stabilization = p.get("stabilization")
254
+ if stabilization not in _STABILIZATION_METHODS:
255
+ raise ValueError(f"Unknown stabilization method: {stabilization}. Supported: clamp")
256
+
238
257
  # ------------------------------------------------------------------
239
258
  # Identity
240
259
  # ------------------------------------------------------------------
@@ -254,6 +273,7 @@ class WindowedStatDetector(BaseDetector):
254
273
  "half_life": None,
255
274
  "weight_decay": None,
256
275
  "detrend": None,
276
+ "stabilization": None,
257
277
  }
258
278
 
259
279
  def _get_non_default_params(self) -> dict[str, Any]:
@@ -291,6 +311,15 @@ class WindowedStatDetector(BaseDetector):
291
311
  if self.params.get("input_type", "values") in _CHANGE_INPUT_TYPES:
292
312
  context += 1
293
313
 
314
+ if self.params.get("stabilization"):
315
+ # A point's substituted-history state depends on whether its own
316
+ # window points were flagged, which depends on THEIR windows. One
317
+ # extra window of warm-up lets every context point be scored on a
318
+ # full window, so incremental batches reproduce the same
319
+ # substitution history as a continuous run (residual differences
320
+ # decay geometrically past that).
321
+ context += int(self.params.get("window_size", 0) or 0)
322
+
294
323
  return context
295
324
 
296
325
  def _resolve_half_life_points(self, timestamps: np.ndarray) -> float:
@@ -463,12 +492,22 @@ class WindowedStatDetector(BaseDetector):
463
492
  "min_samples_per_group", self.MIN_SAMPLES_PER_GROUP_DEFAULT
464
493
  )
465
494
  detrend = self.params.get("detrend")
495
+ stabilization = self.params.get("stabilization")
466
496
  weighted = self.params.get("window_weights") is not None
467
497
 
468
498
  # STEP 0: Preprocessing (smoothing first, then input_type)
469
499
  smoothed_values = self._apply_smoothing(values)
470
500
  processed_values = self._preprocess_input(smoothed_values)
471
501
 
502
+ # Stabilization (opt-in): statistics windows read from a working copy
503
+ # where every previously-flagged point is clamped to the confidence
504
+ # bound it violated, so an ongoing incident cannot inflate the spread
505
+ # or drag the center toward itself. The scored value and the persisted
506
+ # processed_value stay the raw observations; genuinely missing (NaN)
507
+ # points are never substituted.
508
+ work_values = processed_values.copy() if stabilization else processed_values
509
+ replaced = np.zeros(len(processed_values), dtype=bool) if stabilization else None
510
+
472
511
  seasonality_dict = {}
473
512
  if seasonality_components and len(seasonality_data) > 0 and seasonality_columns:
474
513
  seasonality_dict = parse_seasonality_data(seasonality_data, seasonality_columns)
@@ -498,9 +537,11 @@ class WindowedStatDetector(BaseDetector):
498
537
  )
499
538
  continue
500
539
 
501
- # Trailing window, current point excluded
540
+ # Trailing window, current point excluded. Both the global slice
541
+ # and the seasonality-group slices below read window_processed, so
542
+ # sourcing it from work_values covers every statistics consumer.
502
543
  window_start = max(0, i - window_size)
503
- window_processed = processed_values[window_start:i]
544
+ window_processed = work_values[window_start:i]
504
545
  valid_mask = ~np.isnan(window_processed)
505
546
  window_valid = window_processed[valid_mask]
506
547
 
@@ -586,6 +627,14 @@ class WindowedStatDetector(BaseDetector):
586
627
  current_processed > confidence_upper
587
628
  )
588
629
 
630
+ # Stabilization write-back: later windows see this point clamped
631
+ # to the bound it violated, not the anomalous observation.
632
+ if stabilization == "clamp" and is_anomaly:
633
+ work_values[i] = (
634
+ confidence_lower if current_processed < confidence_lower else confidence_upper
635
+ )
636
+ replaced[i] = True # type: ignore[index]
637
+
589
638
  # STEP 5: Metadata
590
639
  metadata: dict[str, Any] = {}
591
640
  for name, _ in self.STATS:
@@ -598,6 +647,10 @@ class WindowedStatDetector(BaseDetector):
598
647
  metadata["ess"] = round(effective_sample_size(weights), 1)
599
648
  if detrend == "linear":
600
649
  metadata["trend_slope_per_point"] = float(slope)
650
+ if replaced is not None:
651
+ n_replaced = int(np.count_nonzero(replaced[window_start:i]))
652
+ if n_replaced:
653
+ metadata["stabilized_in_window"] = n_replaced
601
654
 
602
655
  if self.params.get("smoothing") or self.params.get("input_type") != "values":
603
656
  metadata["preprocessing"] = {