detectkit 0.52.1__tar.gz → 0.53.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 (157) hide show
  1. {detectkit-0.52.1/detectkit.egg-info → detectkit-0.53.0}/PKG-INFO +1 -1
  2. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/__init__.py +1 -1
  3. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/autotuner.py +100 -16
  4. detectkit-0.53.0/detectkit/autotune/axis_spec.py +118 -0
  5. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/config_emitter.py +5 -0
  6. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/detector_select.py +11 -6
  7. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/grid_search.py +52 -29
  8. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/result.py +7 -0
  9. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/settings.py +15 -1
  10. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/window_select.py +15 -6
  11. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/assets/claude/rules/alerting.md +6 -3
  12. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/assets/claude/rules/autotune.md +15 -7
  13. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/assets/claude/rules/cli.md +11 -6
  14. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/assets/claude/rules/detectors.md +10 -4
  15. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +10 -6
  16. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +5 -4
  17. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/assets/claude/skills/dtk-tune/SKILL.md +14 -18
  18. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/detectors/statistical/autoreg.py +36 -11
  19. detectkit-0.53.0/detectkit/tuning/assets/tune.js +187 -0
  20. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/tuning/config_writer.py +46 -9
  21. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/tuning/payload.py +36 -5
  22. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/tuning/server.py +26 -0
  23. {detectkit-0.52.1 → detectkit-0.53.0/detectkit.egg-info}/PKG-INFO +1 -1
  24. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit.egg-info/SOURCES.txt +1 -0
  25. detectkit-0.52.1/detectkit/tuning/assets/tune.js +0 -187
  26. {detectkit-0.52.1 → detectkit-0.53.0}/LICENSE +0 -0
  27. {detectkit-0.52.1 → detectkit-0.53.0}/MANIFEST.in +0 -0
  28. {detectkit-0.52.1 → detectkit-0.53.0}/README.md +0 -0
  29. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/__init__.py +0 -0
  30. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/channels/__init__.py +0 -0
  31. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/channels/base.py +0 -0
  32. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/channels/branding.py +0 -0
  33. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/channels/email.py +0 -0
  34. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/channels/factory.py +0 -0
  35. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/channels/mattermost.py +0 -0
  36. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/channels/slack.py +0 -0
  37. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/channels/telegram.py +0 -0
  38. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/channels/webhook.py +0 -0
  39. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  40. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/orchestrator/_base.py +0 -0
  41. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  42. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
  43. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  44. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
  45. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
  46. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  47. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  48. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/__init__.py +0 -0
  49. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/_base.py +0 -0
  50. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/_types.py +0 -0
  51. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/crossval.py +0 -0
  52. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/distribution.py +0 -0
  53. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/labels.py +0 -0
  54. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/runner.py +0 -0
  55. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/scoring.py +0 -0
  56. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/seasonality_search.py +0 -0
  57. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/__init__.py +0 -0
  58. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/_output.py +0 -0
  59. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
  60. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
  61. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  62. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
  63. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  64. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  65. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/commands/__init__.py +0 -0
  66. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/commands/autotune.py +0 -0
  67. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/commands/clean.py +0 -0
  68. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/commands/init.py +0 -0
  69. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/commands/init_claude.py +0 -0
  70. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/commands/osi.py +0 -0
  71. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/commands/run.py +0 -0
  72. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/commands/test_alert.py +0 -0
  73. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/commands/tune.py +0 -0
  74. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/commands/ui.py +0 -0
  75. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/commands/unlock.py +0 -0
  76. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/main.py +0 -0
  77. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/config/__init__.py +0 -0
  78. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/config/metric_config.py +0 -0
  79. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/config/metric_io.py +0 -0
  80. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/config/profile.py +0 -0
  81. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/config/project_config.py +0 -0
  82. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/config/validator.py +0 -0
  83. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/core/__init__.py +0 -0
  84. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/core/interval.py +0 -0
  85. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/core/models.py +0 -0
  86. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/__init__.py +0 -0
  87. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/_sql_manager.py +0 -0
  88. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/clickhouse_manager.py +0 -0
  89. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/internal_tables/__init__.py +0 -0
  90. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  91. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  92. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/internal_tables/_base.py +0 -0
  93. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  94. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/internal_tables/_detections.py +0 -0
  95. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  96. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  97. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/internal_tables/_schema.py +0 -0
  98. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  99. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/internal_tables/manager.py +0 -0
  100. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/manager.py +0 -0
  101. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/mysql_manager.py +0 -0
  102. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/postgres_manager.py +0 -0
  103. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/tables.py +0 -0
  104. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/detectors/__init__.py +0 -0
  105. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/detectors/base.py +0 -0
  106. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/detectors/factory.py +0 -0
  107. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/detectors/seasonality.py +0 -0
  108. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/detectors/statistical/__init__.py +0 -0
  109. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  110. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/detectors/statistical/iqr.py +0 -0
  111. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/detectors/statistical/mad.py +0 -0
  112. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  113. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/detectors/statistical/zscore.py +0 -0
  114. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/loaders/__init__.py +0 -0
  115. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/loaders/metric_loader.py +0 -0
  116. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/loaders/query_template.py +0 -0
  117. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/orchestration/__init__.py +0 -0
  118. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/orchestration/error_dispatch.py +0 -0
  119. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  120. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
  121. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/orchestration/task_manager/_base.py +0 -0
  122. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  123. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  124. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  125. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  126. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/reporting/__init__.py +0 -0
  127. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/reporting/assets/report.js +0 -0
  128. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/reporting/builder.py +0 -0
  129. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/reporting/html_report.py +0 -0
  130. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/semantic/__init__.py +0 -0
  131. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/semantic/errors.py +0 -0
  132. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/semantic/exporter.py +0 -0
  133. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/semantic/importer.py +0 -0
  134. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/semantic/osi_model.py +0 -0
  135. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/semantic/query_gen.py +0 -0
  136. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/tuning/__init__.py +0 -0
  137. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/tuning/html.py +0 -0
  138. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/ui/__init__.py +0 -0
  139. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/ui/assets/ui.js +0 -0
  140. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/ui/html.py +0 -0
  141. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/ui/jobs.py +0 -0
  142. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/ui/metric_files.py +0 -0
  143. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/ui/overview.py +0 -0
  144. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/ui/server.py +0 -0
  145. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/utils/__init__.py +0 -0
  146. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/utils/datetime_utils.py +0 -0
  147. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/utils/env_interpolation.py +0 -0
  148. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/utils/json_utils.py +0 -0
  149. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/utils/stats.py +0 -0
  150. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit.egg-info/dependency_links.txt +0 -0
  151. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit.egg-info/entry_points.txt +0 -0
  152. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit.egg-info/requires.txt +0 -0
  153. {detectkit-0.52.1 → detectkit-0.53.0}/detectkit.egg-info/top_level.txt +0 -0
  154. {detectkit-0.52.1 → detectkit-0.53.0}/pyproject.toml +0 -0
  155. {detectkit-0.52.1 → detectkit-0.53.0}/requirements.txt +0 -0
  156. {detectkit-0.52.1 → detectkit-0.53.0}/setup.cfg +0 -0
  157. {detectkit-0.52.1 → detectkit-0.53.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.52.1
3
+ Version: 0.53.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.52.1"
7
+ __version__ = "0.53.0"
8
8
 
9
9
  from detectkit.core.interval import Interval
10
10
  from detectkit.core.models import ColumnDefinition, TableModel
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
  import contextlib
10
10
  import logging
11
11
  from collections.abc import Callable, Iterator
12
+ from dataclasses import dataclass
12
13
  from datetime import datetime, timedelta
13
14
  from typing import Any
14
15
 
@@ -16,6 +17,7 @@ import numpy as np
16
17
 
17
18
  from detectkit.autotune._base import AutoTuneError, _AutoTuneBase
18
19
  from detectkit.autotune._types import CandidateEval, TuneMode
20
+ from detectkit.autotune.axis_spec import max_context_size
19
21
  from detectkit.autotune.crossval import build_cv_plan, predictions_from_results
20
22
  from detectkit.autotune.detector_select import select_detector_types
21
23
  from detectkit.autotune.grid_search import grid_search
@@ -71,6 +73,33 @@ def _consecutive(flags: np.ndarray, k: int) -> np.ndarray:
71
73
  return out
72
74
 
73
75
 
76
+ def _fraction_fire(flags: np.ndarray, window_points: int, share: float) -> np.ndarray:
77
+ """Mark index i where the fraction alert rule fires — the pure-numpy sibling
78
+ of ``_decision._share_fire``: the latest point itself is anomalous (a stale
79
+ window never fires) AND the anomalous share over the trailing
80
+ ``window_points`` slots (current point inclusive) reaches *share*. Slots
81
+ before the series start, gaps and invalid points all count in the
82
+ denominator only, exactly like the pipeline's missing-slot handling."""
83
+ n = len(flags)
84
+ if n == 0 or window_points <= 0:
85
+ return flags.copy()
86
+ counts = np.convolve(flags.astype(np.int64), np.ones(window_points, dtype=np.int64))[:n]
87
+ return flags & (counts >= share * window_points)
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class _AlertRuleChoice:
92
+ """Outcome of the supervised alert-rule sweep.
93
+
94
+ ``window_points``/``min_anomaly_share`` are set only when the OR-ed
95
+ fraction rule strictly beat the consecutive-only optimum; the deployed
96
+ behavior (consecutive OR fraction) is exactly what was scored."""
97
+
98
+ consecutive_anomalies: int | None
99
+ window_points: int | None = None
100
+ min_anomaly_share: float | None = None
101
+
102
+
74
103
  class AutoTuner(_AutoTuneBase):
75
104
  """Runs the load-free tuning pipeline and returns an :class:`AutoTuneResult`."""
76
105
 
@@ -83,12 +112,17 @@ class AutoTuner(_AutoTuneBase):
83
112
  )
84
113
 
85
114
  grid = window_grid(self)
86
- max_window = max([*grid, 100])
87
- self.cv_plan = build_cv_plan(n, max_window, self.settings.fold_count)
115
+ # Reserve the largest context any candidate the search can build might
116
+ # need (window + stabilization warm-up + AR lags), not just the raw
117
+ # window size — otherwise folds silently score points where detect()
118
+ # returns insufficient_data / missing_lags (valid=False), degrading
119
+ # the CV signal without erroring.
120
+ max_context = max_context_size(self, grid)
121
+ self.cv_plan = build_cv_plan(n, max_context, self.settings.fold_count)
88
122
  if not self.cv_plan.fold_bounds:
89
123
  raise AutoTuneError(
90
124
  f"not enough datapoints ({n}) for {self.settings.fold_count}-fold "
91
- f"cross-validation with a {max_window}-point context window"
125
+ f"cross-validation with a {max_context}-point context window"
92
126
  )
93
127
 
94
128
  gt = self.ground_truth
@@ -111,36 +145,80 @@ class AutoTuner(_AutoTuneBase):
111
145
  if best is None:
112
146
  raise AutoTuneError("no viable detector candidate found for this data")
113
147
 
114
- consecutive = self._select_alert_window(best.detector_type, best.params)
148
+ alert_rule = self._select_alert_window(best.detector_type, best.params)
115
149
 
116
- return self._build_result(seasonality, best, consecutive)
150
+ return self._build_result(seasonality, best, alert_rule)
117
151
 
118
152
  # ------------------------------------------------------------------
119
153
 
120
- def _select_alert_window(self, detector_type: str, params: dict[str, Any]) -> int | None:
121
- """Sweep consecutive_anomalies on labeled incidents (supervised only)."""
154
+ def _select_alert_window(
155
+ self, detector_type: str, params: dict[str, Any]
156
+ ) -> _AlertRuleChoice | None:
157
+ """Sweep the alert rule on labeled incidents (supervised only).
158
+
159
+ Two passes over one ``detect()`` run: the legacy 1-D
160
+ ``consecutive_anomalies`` sweep first, then a 2-D (window × share)
161
+ sweep of the fraction rule **OR-ed with the chosen consecutive rule**
162
+ — scoring exactly the composite the pipeline would deploy. The pair is
163
+ adopted only on a strictly greater score, so the legacy rule wins ties
164
+ and existing behavior is byte-stable when the fraction rule doesn't
165
+ help.
166
+ """
122
167
  if self.ground_truth.mode != TuneMode.SUPERVISED:
123
168
  return None
124
169
  detector = DetectorFactory.create(detector_type, params)
125
170
  y_pred, y_score, valid = predictions_from_results(detector.detect(self.data))
126
171
  y_true = self.ground_truth.y_true
172
+ metric, beta = self.settings.metric, self.settings.beta
173
+
174
+ def _score(alert: np.ndarray) -> float:
175
+ # Same invalid-point handling seam as the CV folds (pointwise
176
+ # metrics mask; the segment-aware one keeps unmasked arrays).
177
+ yt, yp, ys = arrays_for_metric(y_true, alert, y_score, valid, metric)
178
+ return score_predictions(yt, yp, ys, metric, beta)
179
+
127
180
  best_k = 1
128
181
  best_score = float("-inf")
129
182
  for k in _ALERT_WINDOW_GRID:
130
- alert = _consecutive(y_pred, k)
131
- # Same invalid-point handling seam as the CV folds (pointwise
132
- # metrics mask; the segment-aware one keeps unmasked arrays).
133
- yt, yp, ys = arrays_for_metric(y_true, alert, y_score, valid, self.settings.metric)
134
- score = score_predictions(yt, yp, ys, self.settings.metric, self.settings.beta)
183
+ score = _score(_consecutive(y_pred, k))
135
184
  if score > best_score:
136
185
  best_score, best_k = score, k
186
+
187
+ consecutive_fire = _consecutive(y_pred, best_k)
188
+ n = len(y_pred)
189
+ best_pair: tuple[int, float] | None = None
190
+ for window_points in self.settings.alert_window_points_grid:
191
+ # < 2 would degenerate to consecutive_anomalies=1 (MetricConfig
192
+ # rejects it); a window longer than the series can never be
193
+ # observed at its own width.
194
+ if window_points < 2 or window_points > n:
195
+ continue
196
+ for share in self.settings.alert_share_grid:
197
+ alert = consecutive_fire | _fraction_fire(y_pred, window_points, share)
198
+ score = _score(alert)
199
+ if score > best_score:
200
+ best_score, best_pair = score, (window_points, share)
201
+
202
+ if best_pair is not None:
203
+ window_points, share = best_pair
204
+ self.log(
205
+ "window",
206
+ f"consecutive_anomalies={best_k} OR anomaly_window={window_points}p × "
207
+ f"min_anomaly_share={share} "
208
+ f"(max {metric.value}={best_score:.3f} on labeled incidents)",
209
+ consecutive_anomalies=best_k,
210
+ anomaly_window_points=window_points,
211
+ min_anomaly_share=share,
212
+ )
213
+ return _AlertRuleChoice(best_k, window_points, share)
214
+
137
215
  self.log(
138
216
  "window",
139
217
  f"consecutive_anomalies={best_k} "
140
- f"(max {self.settings.metric.value}={best_score:.3f} on labeled incidents)",
218
+ f"(max {metric.value}={best_score:.3f} on labeled incidents)",
141
219
  consecutive_anomalies=best_k,
142
220
  )
143
- return best_k
221
+ return _AlertRuleChoice(best_k)
144
222
 
145
223
  def _clean_params(self, params: dict[str, Any]) -> dict[str, Any]:
146
224
  """Drop None/empty values so the emitted config is tidy."""
@@ -154,7 +232,7 @@ class AutoTuner(_AutoTuneBase):
154
232
  return out
155
233
 
156
234
  def _build_result(
157
- self, seasonality: list | None, best: CandidateEval, consecutive: int | None
235
+ self, seasonality: list | None, best: CandidateEval, alert_rule: _AlertRuleChoice | None
158
236
  ) -> AutoTuneResult:
159
237
  timestamps = self.data["timestamp"]
160
238
  training_start = _ts_to_dt(timestamps[0]) if len(timestamps) else None
@@ -194,7 +272,13 @@ class AutoTuner(_AutoTuneBase):
194
272
  score=best.score,
195
273
  cv_per_fold=best.fold_scores.per_fold,
196
274
  cv_stability_penalty=best.fold_scores.stability_penalty,
197
- consecutive_anomalies=consecutive,
275
+ consecutive_anomalies=alert_rule.consecutive_anomalies if alert_rule else None,
276
+ anomaly_window=(
277
+ f"{alert_rule.window_points * self.interval_seconds}s"
278
+ if alert_rule and alert_rule.window_points is not None
279
+ else None
280
+ ),
281
+ min_anomaly_share=alert_rule.min_anomaly_share if alert_rule else None,
198
282
  candidate_detector_ids=self.evaluated_ids(),
199
283
  candidates=candidates,
200
284
  group_votes=group_votes,
@@ -0,0 +1,118 @@
1
+ """Per-detector-type axis specification for the grid search (issue #97 Phase 2).
2
+
3
+ ``grid_search`` used to be one hardcoded coordinate sweep whose every axis name
4
+ (``window_weights``, ``half_life``, ``detrend``, ``seasonality_components``) is
5
+ a ``WindowedStatDetector`` constructor kwarg. The prediction-based ``autoreg``
6
+ detector accepts none of those (and *raises* on truthy
7
+ ``seasonality_components``), so the sweep dispatches on a small spec keyed by
8
+ detector type — kept here, NOT on the detector classes, the same
9
+ easy-to-remove externalized-knowledge choice as ``detector_select``'s
10
+ suitability spec. Unlisted (future) types get the windowed default, which is
11
+ behavior-identical to the pre-seam sweep.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ from detectkit.detectors.factory import DetectorFactory
20
+
21
+ if TYPE_CHECKING:
22
+ from detectkit.autotune._base import _AutoTuneBase
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class AxisSpec:
27
+ """Which grid-search axes apply to a detector type, plus per-type floors.
28
+
29
+ ``min_samples_floor`` / ``threshold_default`` override the class-attribute
30
+ lookup (``MIN_SAMPLES_FLOOR`` / ``THRESHOLD_DEFAULT``) for types that don't
31
+ carry the windowed template's attributes; ``None`` keeps the lookup.
32
+ ``initial`` seeds the accepted-params dict so type-specific defaults (e.g.
33
+ autoreg's default-on stabilization) are explicit from the first candidate.
34
+ """
35
+
36
+ seasonality: bool = True # inject the chosen seasonality_components
37
+ weighting: bool = True # window_weights + half_life axes
38
+ detrend: bool = True # detrend axis (still trend-gated)
39
+ stabilization: bool = True # stabilization axis
40
+ lags: bool = False # AR-order axis (settings.lags_grid)
41
+ min_samples_floor: int | None = None
42
+ threshold_default: float | None = None
43
+ initial: dict[str, Any] = field(default_factory=dict)
44
+
45
+
46
+ _WINDOWED = AxisSpec()
47
+
48
+ _AXIS_SPECS: dict[str, AxisSpec] = {
49
+ # v1 autoreg has no seasonality/smoothing/weighting/detrend by design (the
50
+ # AR residual model adapts to level and short-range dynamics on its own);
51
+ # it *rejects* seasonality_components, so the seam must never inject them.
52
+ # min_samples additionally floors at lags + 2 — see ``min_samples_for``.
53
+ "autoreg": AxisSpec(
54
+ seasonality=False,
55
+ weighting=False,
56
+ detrend=False,
57
+ lags=True,
58
+ min_samples_floor=10,
59
+ threshold_default=3.0,
60
+ initial={"lags": 5, "stabilization": "clamp"},
61
+ ),
62
+ }
63
+
64
+
65
+ def axis_spec_for(detector_type: str) -> AxisSpec:
66
+ """The axis spec for *detector_type* (windowed default when unlisted)."""
67
+ return _AXIS_SPECS.get(detector_type, _WINDOWED)
68
+
69
+
70
+ def resolve_floor(detector_type: str) -> int:
71
+ """Min-samples floor: spec override first, then the class attribute."""
72
+ spec = axis_spec_for(detector_type)
73
+ if spec.min_samples_floor is not None:
74
+ return spec.min_samples_floor
75
+ detector_cls = DetectorFactory.DETECTOR_TYPES[detector_type]
76
+ return int(getattr(detector_cls, "MIN_SAMPLES_FLOOR", 1))
77
+
78
+
79
+ def resolve_threshold_default(detector_type: str) -> float:
80
+ """Default threshold: spec override first, then the class attribute."""
81
+ spec = axis_spec_for(detector_type)
82
+ if spec.threshold_default is not None:
83
+ return spec.threshold_default
84
+ detector_cls = DetectorFactory.DETECTOR_TYPES[detector_type]
85
+ return float(getattr(detector_cls, "THRESHOLD_DEFAULT", 3.0))
86
+
87
+
88
+ def max_context_size(tuner: _AutoTuneBase, grid: list[int]) -> int:
89
+ """Largest ``get_context_size()`` any candidate the search can build needs.
90
+
91
+ The CV plan must reserve this much lead-in — deriving it from the raw
92
+ window grid alone (the pre-fix behavior) under-reserves for stabilization
93
+ (an extra window of warm-up) and for autoreg (``+ lags``), silently
94
+ scoring points where ``detect()`` returns ``insufficient_data`` /
95
+ ``missing_lags`` (they become ``valid=False``, degrading the CV signal
96
+ without erroring).
97
+ """
98
+ from detectkit.autotune.detector_select import _candidate_types
99
+
100
+ max_window = max([*grid, 100])
101
+ context = max_window
102
+ for detector_type in _candidate_types(tuner):
103
+ spec = axis_spec_for(detector_type)
104
+ params: dict[str, Any] = {**tuner.settings.fixed_params}
105
+ params["window_size"] = max_window
106
+ # Worst-case axes the sweep can adopt: stabilization adds a window of
107
+ # warm-up; the largest lags in the grid adds `lags` context points.
108
+ params["stabilization"] = "clamp"
109
+ if spec.lags and tuner.settings.lags_grid:
110
+ max_lags = max(tuner.settings.lags_grid)
111
+ params["lags"] = max_lags
112
+ params["min_samples"] = max(max_lags + 2, min(30, max_window))
113
+ try:
114
+ detector = DetectorFactory.create(detector_type, params)
115
+ except ValueError:
116
+ continue
117
+ context = max(context, detector.get_context_size())
118
+ return context
@@ -61,6 +61,11 @@ def _build_alerting(original: MetricConfig, result: AutoTuneResult) -> list[dict
61
61
  first = original.alerting[0].model_dump(exclude_none=True, exclude_defaults=True)
62
62
  if result.consecutive_anomalies is not None:
63
63
  first["consecutive_anomalies"] = result.consecutive_anomalies
64
+ if result.anomaly_window is not None and result.min_anomaly_share is not None:
65
+ # Exact-seconds duration: round-trips losslessly through
66
+ # `AlertConditions.from_alert_config` back to the swept point count.
67
+ first["anomaly_window"] = result.anomaly_window
68
+ first["min_anomaly_share"] = result.min_anomaly_share
64
69
  first["min_detectors"] = 1 # single tuned detector now
65
70
  return [first]
66
71
 
@@ -20,12 +20,10 @@ from detectkit.autotune.distribution import compute_distribution_features
20
20
  from detectkit.detectors.factory import DetectorFactory
21
21
  from detectkit.detectors.seasonality import parse_seasonality_data
22
22
 
23
- # Detector types the engine can auto-tune (windowed statistical detectors).
24
- # Derived from the factory minus the manual/stateless ones.
25
- # "autoreg" is prediction-based (fits AR coefficients, not the windowed
26
- # center/spread stats this module's grid axes assume) — Phase 2 will add a
27
- # per-type grid seam before it can be tuned here.
28
- _EXCLUDED_TYPES = {"manual", "manual_bounds", "autoreg"}
23
+ # Detector types the engine can auto-tune. Derived from the factory minus the
24
+ # manual/stateless ones; the prediction-based "autoreg" participates via its
25
+ # AxisSpec (see autotune/axis_spec.py), which gates the windowed-only axes.
26
+ _EXCLUDED_TYPES = {"manual", "manual_bounds"}
29
27
  # A seasonal sub-group needs at least this many points to vote.
30
28
  _MIN_GROUP_FOR_VOTE = 10
31
29
  # Cap on voting sub-groups so a high-cardinality component can't explode cost.
@@ -56,6 +54,13 @@ def detector_suitability(detector_type: str, features: dict[str, float]) -> floa
56
54
  if detector_type == "iqr":
57
55
  # Best on skewed / asymmetric distributions.
58
56
  return _clip01(0.3 + 0.5 * min(1.0, skewness / 3.0) + 0.4 * outlier_fraction)
57
+ if detector_type == "autoreg":
58
+ # Prediction-based: models short-range dynamics, not level, and the
59
+ # distribution shape says little about dynamics — so the vote is
60
+ # mildly conservative (clean data, where a per-window AR fit is
61
+ # stable, nudges it up; it never outranks a clearly-matched level
62
+ # detector). Ordering only; the grid search still evaluates it.
63
+ return _clip01(0.3 + 0.3 * normality * (1.0 - outlier_fraction))
59
64
  return 0.5
60
65
 
61
66
 
@@ -18,6 +18,7 @@ import numpy as np
18
18
 
19
19
  from detectkit.autotune._base import _AutoTuneBase
20
20
  from detectkit.autotune._types import CandidateEval
21
+ from detectkit.autotune.axis_spec import axis_spec_for, resolve_floor, resolve_threshold_default
21
22
  from detectkit.autotune.window_select import (
22
23
  detect_level_shift,
23
24
  half_life_grid,
@@ -26,7 +27,6 @@ from detectkit.autotune.window_select import (
26
27
  select_window,
27
28
  trend_present,
28
29
  )
29
- from detectkit.detectors.factory import DetectorFactory
30
30
 
31
31
 
32
32
  def _initial_window(grid: list[int], floor: int) -> int | None:
@@ -92,9 +92,9 @@ def grid_search(
92
92
  best_overall: CandidateEval | None = None
93
93
 
94
94
  for detector_type in detector_types:
95
- detector_cls = DetectorFactory.DETECTOR_TYPES[detector_type]
96
- floor = int(getattr(detector_cls, "MIN_SAMPLES_FLOOR", 1))
97
- threshold_default = float(getattr(detector_cls, "THRESHOLD_DEFAULT", 3.0))
95
+ spec = axis_spec_for(detector_type)
96
+ floor = resolve_floor(detector_type)
97
+ threshold_default = resolve_threshold_default(detector_type)
98
98
  window = _initial_window(grid, floor)
99
99
  if window is None:
100
100
  tuner.log(
@@ -104,11 +104,12 @@ def grid_search(
104
104
  continue
105
105
 
106
106
  accepted: dict[str, Any] = {
107
- **base,
107
+ **(base if spec.seasonality else {}),
108
+ **spec.initial,
108
109
  "threshold": threshold_default,
109
110
  "window_size": window,
110
- "min_samples": min_samples_for(window, floor),
111
111
  }
112
+ accepted["min_samples"] = min_samples_for(window, floor, accepted.get("lags"))
112
113
  best = tuner.safe_evaluate(detector_type, accepted)
113
114
  if best is None:
114
115
  continue
@@ -121,28 +122,47 @@ def grid_search(
121
122
  if ev is not None and ev.score > best.score:
122
123
  best, accepted["threshold"] = ev, threshold
123
124
 
125
+ # Axis 1b: AR order (autoreg only) — swept like threshold, with the
126
+ # min-samples floor tracking lags + 2 (the detector validates that).
127
+ if spec.lags:
128
+ for lags in tuner.settings.lags_grid:
129
+ if lags == accepted.get("lags"):
130
+ continue
131
+ candidate = {
132
+ **accepted,
133
+ "lags": lags,
134
+ "min_samples": min_samples_for(accepted["window_size"], floor, lags),
135
+ }
136
+ ev = tuner.safe_evaluate(detector_type, candidate)
137
+ if ev is not None and ev.score > best.score:
138
+ best = ev
139
+ accepted["lags"] = lags
140
+ accepted["min_samples"] = candidate["min_samples"]
141
+
124
142
  # Axis 2: recency weighting (only adopt if it clears the margin).
125
- for weights in (None, "exponential"):
126
- if weights == accepted.get("window_weights"):
127
- continue
128
- ev = tuner.safe_evaluate(detector_type, {**accepted, "window_weights": weights})
129
- if ev is not None and ev.score > best.score + eps:
130
- best, accepted["window_weights"] = ev, weights
131
-
132
- # Axis 2b: half-life of the recency weighting — only when exponential
133
- # weighting was adopted. The detector defaults to a fixed half-life; this
134
- # lets the search pick a faster-forgetting baseline that tracks the current
135
- # regime (the term that matters on a metric that shifted level).
136
- if accepted.get("window_weights") == "exponential":
137
- for half_life in half_life_grid(accepted["window_size"], accepted["min_samples"]):
138
- if half_life == accepted.get("half_life"):
143
+ if spec.weighting:
144
+ for weights in (None, "exponential"):
145
+ if weights == accepted.get("window_weights"):
139
146
  continue
140
- ev = tuner.safe_evaluate(detector_type, {**accepted, "half_life": half_life})
147
+ ev = tuner.safe_evaluate(detector_type, {**accepted, "window_weights": weights})
141
148
  if ev is not None and ev.score > best.score + eps:
142
- best, accepted["half_life"] = ev, half_life
149
+ best, accepted["window_weights"] = ev, weights
150
+
151
+ # Axis 2b: half-life of the recency weighting — only when exponential
152
+ # weighting was adopted. The detector defaults to a fixed half-life;
153
+ # this lets the search pick a faster-forgetting baseline that tracks
154
+ # the current regime (the term that matters on a metric that shifted
155
+ # level).
156
+ if accepted.get("window_weights") == "exponential":
157
+ for half_life in half_life_grid(accepted["window_size"], accepted["min_samples"]):
158
+ if half_life == accepted.get("half_life"):
159
+ continue
160
+ ev = tuner.safe_evaluate(detector_type, {**accepted, "half_life": half_life})
161
+ if ev is not None and ev.score > best.score + eps:
162
+ best, accepted["half_life"] = ev, half_life
143
163
 
144
164
  # Axis 3: detrend (gated by the trend pre-test).
145
- if has_trend:
165
+ if spec.detrend and has_trend:
146
166
  for detrend in (None, "linear"):
147
167
  if detrend == accepted.get("detrend"):
148
168
  continue
@@ -156,12 +176,15 @@ def grid_search(
156
176
  # the band and mask its own tail — usually decisive on labeled data
157
177
  # with long incidents, near-neutral on clean series. Swept before the
158
178
  # 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
179
+ if spec.stabilization:
180
+ for stabilization in (None, "clamp"):
181
+ if stabilization == accepted.get("stabilization"):
182
+ continue
183
+ ev = tuner.safe_evaluate(
184
+ detector_type, {**accepted, "stabilization": stabilization}
185
+ )
186
+ if ev is not None and ev.score > best.score + eps:
187
+ best, accepted["stabilization"] = ev, stabilization
165
188
 
166
189
  # Axis 4: window size (large-window tie-bias, trend-gated in select_window).
167
190
  window_best = select_window(tuner, detector_type, accepted, best, grid)
@@ -35,6 +35,13 @@ class AutoTuneResult:
35
35
 
36
36
  consecutive_anomalies: int | None
37
37
  candidate_detector_ids: list[str]
38
+ # Fraction alert rule (issue #101): an exact-seconds duration string (e.g.
39
+ # "7200s", lossless through `Interval(...).seconds // interval_seconds`) +
40
+ # share, set only when the supervised sweep adopts the OR-ed fraction rule.
41
+ # Like consecutive_anomalies they are non-identity metadata — deliberately
42
+ # excluded from `compute_run_id`.
43
+ anomaly_window: str | None = None
44
+ min_anomaly_share: float | None = None
38
45
  candidates: list[dict[str, Any]] = field(default_factory=list)
39
46
  group_votes: list[dict[str, Any]] = field(default_factory=list)
40
47
  decision_log: list[dict[str, Any]] = field(default_factory=list)
@@ -43,10 +43,20 @@ class TuneSettings:
43
43
  # being trapped flagging its legitimate tail.
44
44
  threshold_grid_sigma: tuple[float, ...] = (2.5, 3.0, 3.5, 4.0, 5.0, 6.0) # mad / zscore
45
45
  threshold_grid_iqr: tuple[float, ...] = (1.5, 2.0, 3.0, 4.0, 6.0) # iqr (Tukey)
46
+ # AR-order sweep for the prediction-based autoreg detector (axis gated by
47
+ # its AxisSpec; the residual z is in σ-units so it reuses the sigma grid).
48
+ lags_grid: tuple[int, ...] = (2, 3, 5, 8)
46
49
 
47
50
  # History / window selection
48
51
  window_tie_margin: float = 0.01 # prefer a larger window within this score gap
49
52
 
53
+ # Fraction alert window (supervised 2-D sweep, OR-ed with the consecutive
54
+ # rule exactly as the pipeline deploys it). Window grid is in grid points
55
+ # of the metric interval; entries < 2 are dropped (MetricConfig rejects an
56
+ # anomaly_window spanning < 2 intervals). Shares are fractions in (0, 1].
57
+ alert_window_points_grid: tuple[int, ...] = (4, 6, 12, 24)
58
+ alert_share_grid: tuple[float, ...] = (0.2, 0.3, 0.5)
59
+
50
60
  # Stage-1 seasonality search probe
51
61
  probe_detector_type: str = "mad"
52
62
 
@@ -58,5 +68,9 @@ class TuneSettings:
58
68
  max_history: int | None = None
59
69
 
60
70
  def threshold_grid(self, detector_type: str) -> tuple[float, ...]:
61
- """Threshold sweep for a detector type (Tukey multipliers for IQR)."""
71
+ """Threshold sweep for a detector type (Tukey multipliers for IQR).
72
+
73
+ ``autoreg`` deliberately falls through to the sigma grid: its residual
74
+ z-score is in σ-units, directly comparable with mad/zscore thresholds.
75
+ """
62
76
  return self.threshold_grid_iqr if detector_type == "iqr" else self.threshold_grid_sigma
@@ -15,7 +15,7 @@ import numpy as np
15
15
 
16
16
  from detectkit.autotune._base import _AutoTuneBase
17
17
  from detectkit.autotune._types import CandidateEval
18
- from detectkit.detectors.factory import DetectorFactory
18
+ from detectkit.autotune.axis_spec import resolve_floor
19
19
  from detectkit.detectors.seasonality import parse_seasonality_data
20
20
  from detectkit.detectors.statistical._windowed import WindowedStatDetector
21
21
 
@@ -27,9 +27,17 @@ from detectkit.detectors.statistical._windowed import WindowedStatDetector
27
27
  _MSPG_REF = int(WindowedStatDetector.MIN_SAMPLES_PER_GROUP_DEFAULT)
28
28
 
29
29
 
30
- def min_samples_for(window_size: int, floor: int) -> int:
31
- """Derived min_samples: a quarter of the window, clamped to ``[floor, window]``."""
32
- return min(window_size, max(floor, round(window_size / 4)))
30
+ def min_samples_for(window_size: int, floor: int, lags: int | None = None) -> int:
31
+ """Derived min_samples: a quarter of the window, clamped to ``[floor, window]``.
32
+
33
+ For the autoreg detector the floor additionally tracks the AR order: the
34
+ AR(p) + intercept model has ``lags + 1`` unknowns, so at least ``lags + 2``
35
+ fit rows are required (the detector validates exactly that).
36
+ """
37
+ ms = min(window_size, max(floor, round(window_size / 4)))
38
+ if lags is not None:
39
+ ms = min(window_size, max(ms, lags + 2))
40
+ return ms
33
41
 
34
42
 
35
43
  def max_seasonal_cardinality(tuner: _AutoTuneBase) -> int:
@@ -184,12 +192,13 @@ def select_window(
184
192
  grid: list[int],
185
193
  ) -> CandidateEval:
186
194
  """Sweep the window grid (final axis); return the tie-biased-largest winner."""
187
- floor = int(getattr(DetectorFactory.DETECTOR_TYPES[detector_type], "MIN_SAMPLES_FLOOR", 1))
195
+ floor = resolve_floor(detector_type)
196
+ lags = accepted.get("lags")
188
197
  evals: list[tuple[int, CandidateEval]] = []
189
198
  for w in grid:
190
199
  if w < floor:
191
200
  continue
192
- candidate = {**accepted, "window_size": w, "min_samples": min_samples_for(w, floor)}
201
+ candidate = {**accepted, "window_size": w, "min_samples": min_samples_for(w, floor, lags)}
193
202
  ev = tuner.safe_evaluate(detector_type, candidate)
194
203
  if ev is not None:
195
204
  evals.append((w, ev))
@@ -118,9 +118,12 @@ fire and recovery always name one rule; a scattered share-fired incident's
118
118
  Reports and `dtk ui`'s overview pick up share-fired
119
119
  alerts automatically via the shared replay seam.
120
120
 
121
- Not yet tunable: `dtk tune`'s cockpit and `dtk autotune`'s alert-window sweep
122
- still only search `consecutive_anomalies` (fraction-rule support is a
123
- follow-up) set `anomaly_window` / `min_anomaly_share` by hand in the YAML.
121
+ Both paths tune this pair now: supervised `dtk autotune` runs a 2-D sweep of
122
+ `anomaly_window` × `min_anomaly_share` OR-ed with the chosen
123
+ `consecutive_anomalies` rule, adopted only on a strictly greater score (a tie
124
+ keeps the consecutive-only rule); `dtk tune`'s cockpit has always-visible
125
+ anomaly-window / min-share rail controls with live replay of the same OR-ed
126
+ rule, and Apply writes the pair back (or removes both when off).
124
127
 
125
128
  ## Cooldown (spam control) — **set it in production**
126
129