detectkit 0.18.0__tar.gz → 0.19.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 (125) hide show
  1. {detectkit-0.18.0/detectkit.egg-info → detectkit-0.19.0}/PKG-INFO +1 -1
  2. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/__init__.py +1 -1
  3. detectkit-0.19.0/detectkit/autotune/__init__.py +44 -0
  4. detectkit-0.19.0/detectkit/autotune/_base.py +109 -0
  5. detectkit-0.19.0/detectkit/autotune/_types.py +87 -0
  6. detectkit-0.19.0/detectkit/autotune/autotuner.py +194 -0
  7. detectkit-0.19.0/detectkit/autotune/config_emitter.py +167 -0
  8. detectkit-0.19.0/detectkit/autotune/crossval.py +101 -0
  9. detectkit-0.19.0/detectkit/autotune/detector_select.py +157 -0
  10. detectkit-0.19.0/detectkit/autotune/distribution.py +81 -0
  11. detectkit-0.19.0/detectkit/autotune/grid_search.py +111 -0
  12. detectkit-0.19.0/detectkit/autotune/html_labeler.py +103 -0
  13. detectkit-0.19.0/detectkit/autotune/labels.py +196 -0
  14. detectkit-0.19.0/detectkit/autotune/result.py +40 -0
  15. detectkit-0.19.0/detectkit/autotune/scoring.py +177 -0
  16. detectkit-0.19.0/detectkit/autotune/seasonality_search.py +135 -0
  17. detectkit-0.19.0/detectkit/autotune/settings.py +53 -0
  18. detectkit-0.19.0/detectkit/autotune/window_select.py +80 -0
  19. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/cli/assets/claude/CLAUDE.section.md +4 -0
  20. detectkit-0.19.0/detectkit/cli/assets/claude/rules/autotune.md +128 -0
  21. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/cli/assets/claude/rules/cli.md +10 -0
  22. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/cli/assets/claude/rules/detectors.md +4 -0
  23. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/cli/assets/claude/rules/metrics.md +2 -1
  24. detectkit-0.19.0/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +192 -0
  25. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +7 -1
  26. detectkit-0.19.0/detectkit/cli/commands/autotune.py +505 -0
  27. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/cli/main.py +98 -0
  28. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/config/metric_config.py +145 -0
  29. detectkit-0.19.0/detectkit/database/internal_tables/_autotune_runs.py +113 -0
  30. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/database/internal_tables/manager.py +2 -0
  31. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/database/tables.py +71 -0
  32. {detectkit-0.18.0 → detectkit-0.19.0/detectkit.egg-info}/PKG-INFO +1 -1
  33. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit.egg-info/SOURCES.txt +20 -0
  34. {detectkit-0.18.0 → detectkit-0.19.0}/LICENSE +0 -0
  35. {detectkit-0.18.0 → detectkit-0.19.0}/MANIFEST.in +0 -0
  36. {detectkit-0.18.0 → detectkit-0.19.0}/README.md +0 -0
  37. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/alerting/__init__.py +0 -0
  38. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/alerting/channels/__init__.py +0 -0
  39. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/alerting/channels/base.py +0 -0
  40. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/alerting/channels/branding.py +0 -0
  41. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/alerting/channels/email.py +0 -0
  42. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/alerting/channels/factory.py +0 -0
  43. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/alerting/channels/mattermost.py +0 -0
  44. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/alerting/channels/slack.py +0 -0
  45. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/alerting/channels/telegram.py +0 -0
  46. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/alerting/channels/webhook.py +0 -0
  47. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  48. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/alerting/orchestrator/_base.py +0 -0
  49. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  50. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
  51. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  52. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
  53. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  54. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  55. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/cli/__init__.py +0 -0
  56. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/cli/_output.py +0 -0
  57. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
  58. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  59. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
  60. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  61. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  62. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/cli/commands/__init__.py +0 -0
  63. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/cli/commands/clean.py +0 -0
  64. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/cli/commands/init.py +0 -0
  65. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/cli/commands/init_claude.py +0 -0
  66. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/cli/commands/run.py +0 -0
  67. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/cli/commands/test_alert.py +0 -0
  68. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/cli/commands/unlock.py +0 -0
  69. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/config/__init__.py +0 -0
  70. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/config/profile.py +0 -0
  71. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/config/project_config.py +0 -0
  72. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/config/validator.py +0 -0
  73. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/core/__init__.py +0 -0
  74. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/core/interval.py +0 -0
  75. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/core/models.py +0 -0
  76. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/database/__init__.py +0 -0
  77. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/database/_sql_manager.py +0 -0
  78. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/database/clickhouse_manager.py +0 -0
  79. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/database/internal_tables/__init__.py +0 -0
  80. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  81. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/database/internal_tables/_base.py +0 -0
  82. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  83. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/database/internal_tables/_detections.py +0 -0
  84. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  85. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  86. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/database/internal_tables/_schema.py +0 -0
  87. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  88. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/database/manager.py +0 -0
  89. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/database/mysql_manager.py +0 -0
  90. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/database/postgres_manager.py +0 -0
  91. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/detectors/__init__.py +0 -0
  92. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/detectors/base.py +0 -0
  93. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/detectors/factory.py +0 -0
  94. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/detectors/seasonality.py +0 -0
  95. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/detectors/statistical/__init__.py +0 -0
  96. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  97. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/detectors/statistical/iqr.py +0 -0
  98. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/detectors/statistical/mad.py +0 -0
  99. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  100. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/detectors/statistical/zscore.py +0 -0
  101. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/loaders/__init__.py +0 -0
  102. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/loaders/metric_loader.py +0 -0
  103. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/loaders/query_template.py +0 -0
  104. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/orchestration/__init__.py +0 -0
  105. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/orchestration/error_dispatch.py +0 -0
  106. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  107. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
  108. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/orchestration/task_manager/_base.py +0 -0
  109. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  110. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  111. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  112. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  113. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/utils/__init__.py +0 -0
  114. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/utils/datetime_utils.py +0 -0
  115. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/utils/env_interpolation.py +0 -0
  116. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/utils/json_utils.py +0 -0
  117. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit/utils/stats.py +0 -0
  118. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit.egg-info/dependency_links.txt +0 -0
  119. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit.egg-info/entry_points.txt +0 -0
  120. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit.egg-info/requires.txt +0 -0
  121. {detectkit-0.18.0 → detectkit-0.19.0}/detectkit.egg-info/top_level.txt +0 -0
  122. {detectkit-0.18.0 → detectkit-0.19.0}/pyproject.toml +0 -0
  123. {detectkit-0.18.0 → detectkit-0.19.0}/requirements.txt +0 -0
  124. {detectkit-0.18.0 → detectkit-0.19.0}/setup.cfg +0 -0
  125. {detectkit-0.18.0 → detectkit-0.19.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.18.0
3
+ Version: 0.19.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.18.0"
7
+ __version__ = "0.19.0"
8
8
 
9
9
  from detectkit.core.interval import Interval
10
10
  from detectkit.core.models import ColumnDefinition, TableModel
@@ -0,0 +1,44 @@
1
+ """detectkit autotune engine.
2
+
3
+ Given a metric's loaded series (+ optional labeled incidents), automatically
4
+ chooses the seasonality grouping, detector type, hyperparameters and history
5
+ window, cross-validates the choice, and returns an :class:`AutoTuneResult`.
6
+
7
+ Pure and DB-free: the engine operates on the in-memory ``data`` dict and reuses
8
+ the existing ``WindowedStatDetector`` / ``DetectorFactory``. The CLI command
9
+ (``dtk autotune``) handles loading, persistence, config emission and cleanup.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from detectkit.autotune._base import AutoTuneError, _AutoTuneBase
15
+ from detectkit.autotune._types import ScoringMetric, TuneMode
16
+ from detectkit.autotune.autotuner import AutoTuner, run_autotune_engine
17
+ from detectkit.autotune.config_emitter import compute_run_id, emit_tuned_config
18
+ from detectkit.autotune.html_labeler import render_labeler_html
19
+ from detectkit.autotune.labels import (
20
+ GroundTruth,
21
+ IncidentLabels,
22
+ parse_incident_labels,
23
+ parse_labels_file,
24
+ )
25
+ from detectkit.autotune.result import AutoTuneResult
26
+ from detectkit.autotune.settings import TuneSettings
27
+
28
+ __all__ = [
29
+ "AutoTuner",
30
+ "AutoTuneError",
31
+ "AutoTuneResult",
32
+ "GroundTruth",
33
+ "IncidentLabels",
34
+ "ScoringMetric",
35
+ "TuneMode",
36
+ "TuneSettings",
37
+ "_AutoTuneBase",
38
+ "compute_run_id",
39
+ "emit_tuned_config",
40
+ "parse_incident_labels",
41
+ "parse_labels_file",
42
+ "render_labeler_html",
43
+ "run_autotune_engine",
44
+ ]
@@ -0,0 +1,109 @@
1
+ """Shared engine state + the candidate-evaluation primitive.
2
+
3
+ The stages are plain functions (in their own modules) that take an
4
+ :class:`_AutoTuneBase` as their first argument and call ``evaluate`` /
5
+ ``log`` on it. This keeps cross-stage calls explicit and type-checkable
6
+ (no cross-mixin attribute access) while still splitting the pipeline into
7
+ focused, <250-line files.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Callable
13
+ from typing import Any
14
+
15
+ import numpy as np
16
+
17
+ from detectkit.autotune._types import (
18
+ CandidateEval,
19
+ CVPlan,
20
+ DecisionEntry,
21
+ GroupVote,
22
+ )
23
+ from detectkit.autotune.crossval import run_cv
24
+ from detectkit.autotune.labels import GroundTruth
25
+ from detectkit.autotune.settings import TuneSettings
26
+ from detectkit.detectors.factory import DetectorFactory
27
+
28
+
29
+ class AutoTuneError(RuntimeError):
30
+ """Raised when a metric cannot be tuned (no data, no viable candidate, …)."""
31
+
32
+
33
+ class _AutoTuneBase:
34
+ """Holds the loaded series, labels, settings, decision log + eval cache."""
35
+
36
+ def __init__(
37
+ self,
38
+ *,
39
+ metric_name: str,
40
+ data: dict[str, np.ndarray],
41
+ ground_truth: GroundTruth,
42
+ interval_seconds: int,
43
+ settings: TuneSettings,
44
+ on_stage: Callable[[str, str], None] | None = None,
45
+ ) -> None:
46
+ self.metric_name = metric_name
47
+ self.data = data
48
+ self.ground_truth = ground_truth
49
+ self.interval_seconds = interval_seconds
50
+ self.settings = settings
51
+ self._on_stage = on_stage
52
+ self.decision_log: list[DecisionEntry] = []
53
+ self.group_votes: list[GroupVote] = []
54
+ self.cv_plan: CVPlan | None = None
55
+ # detector_id -> evaluated candidate (doubles as the dedup cache and
56
+ # the ledger of every candidate considered during the run)
57
+ self._evaluated: dict[str, CandidateEval] = {}
58
+
59
+ # ------------------------------------------------------------------
60
+ # Progress + decision log
61
+ # ------------------------------------------------------------------
62
+
63
+ def emit(self, stage: str, line: str) -> None:
64
+ """Stream one progress line to the CLI renderer (if attached)."""
65
+ if self._on_stage is not None:
66
+ self._on_stage(stage, line)
67
+
68
+ def log(self, stage: str, message: str, *, emit: bool = True, **fields: Any) -> None:
69
+ """Record a decision-log entry (and optionally stream it)."""
70
+ self.decision_log.append(DecisionEntry(stage=stage, message=message, fields=fields))
71
+ if emit:
72
+ self.emit(stage, message)
73
+
74
+ # ------------------------------------------------------------------
75
+ # Candidate evaluation
76
+ # ------------------------------------------------------------------
77
+
78
+ def evaluate(self, detector_type: str, params: dict[str, Any]) -> CandidateEval:
79
+ """Build + cross-validate a candidate detector (memoized by detector id)."""
80
+ full_params = {**self.settings.fixed_params, **params}
81
+ detector = DetectorFactory.create(detector_type, full_params)
82
+ detector_id = detector.get_detector_id()
83
+ cached = self._evaluated.get(detector_id)
84
+ if cached is not None:
85
+ return cached
86
+
87
+ if self.cv_plan is None:
88
+ raise AutoTuneError("CV plan not initialized before evaluation")
89
+ fold_scores = run_cv(detector, self.data, self.cv_plan, self.ground_truth, self.settings)
90
+ ev = CandidateEval(
91
+ detector_type=detector_type,
92
+ params=full_params,
93
+ detector_id=detector_id,
94
+ fold_scores=fold_scores,
95
+ score=fold_scores.aggregate,
96
+ )
97
+ self._evaluated[detector_id] = ev
98
+ return ev
99
+
100
+ def safe_evaluate(self, detector_type: str, params: dict[str, Any]) -> CandidateEval | None:
101
+ """Evaluate, returning None on an invalid parameter combination."""
102
+ try:
103
+ return self.evaluate(detector_type, params)
104
+ except ValueError:
105
+ return None
106
+
107
+ def evaluated_ids(self) -> list[str]:
108
+ """Every distinct detector id considered during the run (cleanup ledger)."""
109
+ return list(self._evaluated.keys())
@@ -0,0 +1,87 @@
1
+ """Small shared types for the autotune engine.
2
+
3
+ Kept dependency-free (no imports from other autotune modules) so every stage
4
+ can import these without cycles.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from enum import Enum
11
+ from typing import Any
12
+
13
+
14
+ class ScoringMetric(str, Enum):
15
+ """Optimization target for the grid search.
16
+
17
+ All are computed in pure numpy (see :mod:`detectkit.autotune.scoring`).
18
+ MCC is the default: it uses all four confusion cells and is robust to the
19
+ heavy class imbalance of rare anomalies.
20
+ """
21
+
22
+ MCC = "mcc"
23
+ F1 = "f1"
24
+ F_BETA = "f_beta"
25
+ BALANCED_ACCURACY = "balanced_accuracy"
26
+ ROC_AUC = "roc_auc"
27
+ PR_AUC = "pr_auc"
28
+
29
+
30
+ class TuneMode(str, Enum):
31
+ """Whether the run optimizes against labels or data statistics."""
32
+
33
+ SUPERVISED = "supervised"
34
+ UNSUPERVISED = "unsupervised"
35
+
36
+
37
+ @dataclass
38
+ class CVPlan:
39
+ """Walk-forward fold layout over a single loaded series.
40
+
41
+ ``fold_bounds`` are ``[lo, hi)`` index ranges into the series; the first
42
+ ``context_end`` points are reserved as pure context and never scored.
43
+ """
44
+
45
+ fold_bounds: list[tuple[int, int]]
46
+ context_end: int
47
+
48
+
49
+ @dataclass
50
+ class FoldScores:
51
+ """Per-fold scores plus the stability-penalized aggregate."""
52
+
53
+ per_fold: list[float]
54
+ aggregate: float
55
+ stability_penalty: float
56
+
57
+
58
+ @dataclass
59
+ class CandidateEval:
60
+ """A single evaluated detector candidate."""
61
+
62
+ detector_type: str
63
+ params: dict[str, Any]
64
+ detector_id: str
65
+ fold_scores: FoldScores
66
+ score: float
67
+
68
+
69
+ @dataclass
70
+ class GroupVote:
71
+ """Per-seasonality-group distribution features + ranked detector suitabilities."""
72
+
73
+ group: list[str]
74
+ features: dict[str, float]
75
+ ranked_types: list[tuple[str, float]]
76
+
77
+
78
+ @dataclass
79
+ class DecisionEntry:
80
+ """One ordered, human-readable rationale entry for the decision log."""
81
+
82
+ stage: str
83
+ message: str
84
+ fields: dict[str, Any] = field(default_factory=dict)
85
+
86
+ def to_dict(self) -> dict[str, Any]:
87
+ return {"stage": self.stage, "message": self.message, "fields": self.fields}
@@ -0,0 +1,194 @@
1
+ """The autotune orchestrator: runs the stages and assembles the result.
2
+
3
+ Pure and DB-free — operates entirely on the in-memory ``data`` dict. The CLI
4
+ command handles loading, persistence, config emission and candidate cleanup.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+ from datetime import datetime, timedelta
11
+ from typing import Any
12
+
13
+ import numpy as np
14
+
15
+ from detectkit.autotune._base import AutoTuneError, _AutoTuneBase
16
+ from detectkit.autotune._types import CandidateEval, TuneMode
17
+ from detectkit.autotune.crossval import build_cv_plan, predictions_from_results
18
+ from detectkit.autotune.detector_select import select_detector_types
19
+ from detectkit.autotune.grid_search import grid_search
20
+ from detectkit.autotune.labels import GroundTruth
21
+ from detectkit.autotune.result import AutoTuneResult
22
+ from detectkit.autotune.scoring import score_predictions
23
+ from detectkit.autotune.seasonality_search import search_seasonality
24
+ from detectkit.autotune.settings import TuneSettings
25
+ from detectkit.autotune.window_select import window_grid
26
+ from detectkit.detectors.factory import DetectorFactory
27
+
28
+ _ALERT_WINDOW_GRID = (1, 2, 3, 4, 5)
29
+
30
+
31
+ def _ts_to_dt(ts64: np.datetime64) -> datetime:
32
+ ms = int(ts64.astype("datetime64[ms]").astype(np.int64))
33
+ return datetime(1970, 1, 1) + timedelta(milliseconds=ms)
34
+
35
+
36
+ def _consecutive(flags: np.ndarray, k: int) -> np.ndarray:
37
+ """Mark index i where the last *k* grid points are all anomalous."""
38
+ if k <= 1:
39
+ return flags.copy()
40
+ out = flags.copy()
41
+ for shift in range(1, k):
42
+ shifted = np.concatenate([np.zeros(shift, dtype=bool), flags[:-shift]])
43
+ out &= shifted
44
+ return out
45
+
46
+
47
+ class AutoTuner(_AutoTuneBase):
48
+ """Runs the load-free tuning pipeline and returns an :class:`AutoTuneResult`."""
49
+
50
+ def tune(self) -> AutoTuneResult:
51
+ timestamps = self.data["timestamp"]
52
+ n = int(len(timestamps))
53
+ if n == 0:
54
+ raise AutoTuneError(
55
+ "no datapoints to tune on — run `dtk run --select <metric> --steps load` first"
56
+ )
57
+
58
+ grid = window_grid(self)
59
+ max_window = max([*grid, 100])
60
+ self.cv_plan = build_cv_plan(n, max_window, self.settings.fold_count)
61
+ if not self.cv_plan.fold_bounds:
62
+ raise AutoTuneError(
63
+ f"not enough datapoints ({n}) for {self.settings.fold_count}-fold "
64
+ f"cross-validation with a {max_window}-point context window"
65
+ )
66
+
67
+ gt = self.ground_truth
68
+ self.log(
69
+ "labels",
70
+ f"{gt.n_intervals} interval(s) + {gt.n_points} point(s) → {gt.mode.value} mode "
71
+ f"({gt.n_positive} labeled grid point(s)); scoring={self.settings.metric.value}",
72
+ mode=gt.mode.value,
73
+ n_positive=gt.n_positive,
74
+ )
75
+
76
+ seasonality = search_seasonality(self)
77
+ detector_types = select_detector_types(self, seasonality)
78
+ best = grid_search(self, detector_types, seasonality, grid)
79
+ if best is None:
80
+ raise AutoTuneError("no viable detector candidate found for this data")
81
+
82
+ consecutive = self._select_alert_window(best.detector_type, best.params)
83
+
84
+ return self._build_result(seasonality, best, consecutive)
85
+
86
+ # ------------------------------------------------------------------
87
+
88
+ def _select_alert_window(self, detector_type: str, params: dict[str, Any]) -> int | None:
89
+ """Sweep consecutive_anomalies on labeled incidents (supervised only)."""
90
+ if self.ground_truth.mode != TuneMode.SUPERVISED:
91
+ return None
92
+ detector = DetectorFactory.create(detector_type, params)
93
+ y_pred, y_score, valid = predictions_from_results(detector.detect(self.data))
94
+ y_true = self.ground_truth.y_true
95
+ best_k = 1
96
+ best_score = float("-inf")
97
+ for k in _ALERT_WINDOW_GRID:
98
+ alert = _consecutive(y_pred, k)
99
+ score = score_predictions(
100
+ y_true[valid],
101
+ alert[valid],
102
+ y_score[valid],
103
+ self.settings.metric,
104
+ self.settings.beta,
105
+ )
106
+ if score > best_score:
107
+ best_score, best_k = score, k
108
+ self.log(
109
+ "window",
110
+ f"consecutive_anomalies={best_k} "
111
+ f"(max {self.settings.metric.value}={best_score:.3f} on labeled incidents)",
112
+ consecutive_anomalies=best_k,
113
+ )
114
+ return best_k
115
+
116
+ def _clean_params(self, params: dict[str, Any]) -> dict[str, Any]:
117
+ """Drop None/empty values so the emitted config is tidy."""
118
+ out: dict[str, Any] = {}
119
+ for key, value in params.items():
120
+ if value is None:
121
+ continue
122
+ if key == "seasonality_components" and not value:
123
+ continue
124
+ out[key] = value
125
+ return out
126
+
127
+ def _build_result(
128
+ self, seasonality: list | None, best: CandidateEval, consecutive: int | None
129
+ ) -> AutoTuneResult:
130
+ timestamps = self.data["timestamp"]
131
+ training_start = _ts_to_dt(timestamps[0]) if len(timestamps) else None
132
+ training_end = _ts_to_dt(timestamps[-1]) if len(timestamps) else None
133
+ gt = self.ground_truth
134
+
135
+ candidates = [
136
+ {
137
+ "detector_type": ev.detector_type,
138
+ "params": self._clean_params(ev.params),
139
+ "detector_id": ev.detector_id,
140
+ }
141
+ for ev in self._evaluated.values()
142
+ ]
143
+ group_votes = [
144
+ {"group": gv.group, "features": gv.features, "ranked_types": gv.ranked_types}
145
+ for gv in self.group_votes
146
+ ]
147
+
148
+ return AutoTuneResult(
149
+ metric_name=self.metric_name,
150
+ mode=gt.mode.value,
151
+ scoring_metric=self.settings.metric.value,
152
+ training_start=training_start,
153
+ training_end=training_end,
154
+ interval_seconds=self.interval_seconds,
155
+ n_points=int(len(timestamps)),
156
+ labels_summary={
157
+ "intervals": gt.n_intervals,
158
+ "points": gt.n_points,
159
+ "positive_grid_points": gt.n_positive,
160
+ },
161
+ chosen_seasonality=seasonality,
162
+ chosen_detector_type=best.detector_type,
163
+ chosen_detector_params=self._clean_params(best.params),
164
+ winning_detector_id=best.detector_id,
165
+ score=best.score,
166
+ cv_per_fold=best.fold_scores.per_fold,
167
+ cv_stability_penalty=best.fold_scores.stability_penalty,
168
+ consecutive_anomalies=consecutive,
169
+ candidate_detector_ids=self.evaluated_ids(),
170
+ candidates=candidates,
171
+ group_votes=group_votes,
172
+ decision_log=[entry.to_dict() for entry in self.decision_log],
173
+ )
174
+
175
+
176
+ def run_autotune_engine(
177
+ *,
178
+ metric_name: str,
179
+ data: dict[str, np.ndarray],
180
+ ground_truth: GroundTruth,
181
+ interval_seconds: int,
182
+ settings: TuneSettings,
183
+ on_stage: Callable[[str, str], None] | None = None,
184
+ ) -> AutoTuneResult:
185
+ """Build an :class:`AutoTuner` and run it (the command↔engine entry point)."""
186
+ tuner = AutoTuner(
187
+ metric_name=metric_name,
188
+ data=data,
189
+ ground_truth=ground_truth,
190
+ interval_seconds=interval_seconds,
191
+ settings=settings,
192
+ on_stage=on_stage,
193
+ )
194
+ return tuner.tune()
@@ -0,0 +1,167 @@
1
+ """Emit the annotated, ready-to-run tuned metric config.
2
+
3
+ Builds a new metric YAML named ``<original>__tuned_<run_id>`` led by a
4
+ ``#``-comment block that walks the entire decision log, followed by the real
5
+ config (single chosen detector + chosen seasonality + copied query/alerting).
6
+ The body is validated through ``MetricConfig`` before it is ever written, so a
7
+ broken config is never emitted. PyYAML only — no new dependency.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import yaml
17
+
18
+ from detectkit.autotune.result import AutoTuneResult
19
+ from detectkit.config.metric_config import MetricConfig
20
+ from detectkit.utils.json_utils import json_dumps_sorted
21
+
22
+ _RULE = "# " + "─" * 61
23
+ _STAGE_LABELS = {
24
+ "seasonality": "SEASONALITY",
25
+ "detector_select": "DETECTOR",
26
+ "grid_search": "GRID SEARCH",
27
+ "window": "WINDOW",
28
+ }
29
+
30
+
31
+ def compute_run_id(result: AutoTuneResult) -> str:
32
+ """Deterministic 6-hex id from the run's inputs + outputs (no wall-clock)."""
33
+ payload = {
34
+ "metric": result.metric_name,
35
+ "training_start": result.training_start.isoformat() if result.training_start else None,
36
+ "training_end": result.training_end.isoformat() if result.training_end else None,
37
+ "labels": result.labels_summary,
38
+ "detector_type": result.chosen_detector_type,
39
+ "detector_params": result.chosen_detector_params,
40
+ "seasonality": result.chosen_seasonality,
41
+ "scoring_metric": result.scoring_metric,
42
+ }
43
+ return hashlib.sha256(json_dumps_sorted(payload).encode()).hexdigest()[:6]
44
+
45
+
46
+ def _flatten_scalar_seasonality(seasonality: list | None) -> list[str]:
47
+ if not seasonality:
48
+ return []
49
+ cols: list[str] = []
50
+ for comp in seasonality:
51
+ for c in [comp] if isinstance(comp, str) else comp:
52
+ if c not in cols:
53
+ cols.append(c)
54
+ return cols
55
+
56
+
57
+ def _build_alerting(original: MetricConfig, result: AutoTuneResult) -> list[dict] | None:
58
+ if not original.alerting:
59
+ return None
60
+ first = original.alerting[0].model_dump(exclude_none=True, exclude_defaults=True)
61
+ if result.consecutive_anomalies is not None:
62
+ first["consecutive_anomalies"] = result.consecutive_anomalies
63
+ first["min_detectors"] = 1 # single tuned detector now
64
+ return [first]
65
+
66
+
67
+ def _build_body(original: MetricConfig, result: AutoTuneResult, new_name: str) -> dict[str, Any]:
68
+ body: dict[str, Any] = {"name": new_name}
69
+ if original.description:
70
+ body["description"] = original.description
71
+ if original.tags:
72
+ body["tags"] = original.tags
73
+ if original.profile:
74
+ body["profile"] = original.profile
75
+ if original.query is not None:
76
+ body["query"] = original.query
77
+ elif original.query_file is not None:
78
+ body["query_file"] = str(original.query_file)
79
+ if original.query_columns is not None:
80
+ body["query_columns"] = original.query_columns.model_dump(exclude_none=True)
81
+ body["interval"] = original.interval
82
+ if result.training_start is not None:
83
+ body["loading_start_time"] = result.training_start.strftime("%Y-%m-%d %H:%M:%S")
84
+ elif original.loading_start_time:
85
+ body["loading_start_time"] = original.loading_start_time
86
+ body["loading_batch_size"] = original.loading_batch_size
87
+
88
+ scalar_cols = _flatten_scalar_seasonality(result.chosen_seasonality)
89
+ if scalar_cols:
90
+ body["seasonality_columns"] = scalar_cols
91
+ elif original.seasonality_columns:
92
+ body["seasonality_columns"] = original.seasonality_columns
93
+
94
+ body["detectors"] = [
95
+ {"type": result.chosen_detector_type, "params": result.chosen_detector_params}
96
+ ]
97
+ alerting = _build_alerting(original, result)
98
+ if alerting is not None:
99
+ body["alerting"] = alerting
100
+ body["enabled"] = True
101
+ return body
102
+
103
+
104
+ def _build_comments(result: AutoTuneResult, source_label: str, run_id: str) -> str:
105
+ lines = [
106
+ _RULE,
107
+ f"# Auto-tuned by `dtk autotune` (run_id: {run_id})",
108
+ f"# Generated from: {source_label}",
109
+ "#",
110
+ ]
111
+ if result.training_start and result.training_end:
112
+ lines.append(
113
+ f"# Training period : {result.training_start:%Y-%m-%d %H:%M:%S} → "
114
+ f"{result.training_end:%Y-%m-%d %H:%M:%S} UTC ({result.n_points:,} points)"
115
+ )
116
+ summary = result.labels_summary
117
+ lines.append(
118
+ f"# Labels : {result.mode} — {summary.get('intervals', 0)} interval(s), "
119
+ f"{summary.get('points', 0)} point(s), "
120
+ f"{summary.get('positive_grid_points', 0)} labeled grid point(s)"
121
+ )
122
+ folds = " ".join(f"{f:.2f}" for f in result.cv_per_fold) or "—"
123
+ lines.append(
124
+ f"# Scoring metric : {result.scoring_metric} = {result.score:.3f} (CV folds: {folds})"
125
+ )
126
+ lines.append("#")
127
+ for entry in result.decision_log:
128
+ label = _STAGE_LABELS.get(entry.get("stage", ""))
129
+ if label:
130
+ lines.append(f"# {label:<12}: {entry.get('message', '')}")
131
+ lines.append("#")
132
+ lines.append(f"# Reproduce: dtk autotune --select {result.metric_name}")
133
+ lines.append(_RULE)
134
+ return "\n".join(lines)
135
+
136
+
137
+ def emit_tuned_config(
138
+ *,
139
+ original_config: MetricConfig,
140
+ original_path: Path,
141
+ result: AutoTuneResult,
142
+ project_root: Path,
143
+ run_id: str | None = None,
144
+ ) -> tuple[Path, str, str]:
145
+ """Return ``(out_path, yaml_text, run_id)`` for the tuned config.
146
+
147
+ Validates the body through ``MetricConfig`` before returning so callers
148
+ never write an unparseable file. Does not touch the filesystem.
149
+ """
150
+ run_id = run_id or compute_run_id(result)
151
+ new_name = f"{original_config.name}__tuned_{run_id}"
152
+ body = _build_body(original_config, result, new_name)
153
+
154
+ # Fail fast on an invalid body rather than writing a broken config.
155
+ MetricConfig.model_validate(body)
156
+
157
+ try:
158
+ source_label = str(original_path.relative_to(project_root))
159
+ except ValueError:
160
+ source_label = original_path.name
161
+
162
+ comments = _build_comments(result, source_label, run_id)
163
+ yaml_body = yaml.safe_dump(body, sort_keys=False, default_flow_style=False, allow_unicode=True)
164
+ text = f"{comments}\n{yaml_body}"
165
+
166
+ out_path = project_root / "metrics" / f"{original_path.stem}__tuned_{run_id}.yml"
167
+ return out_path, text, run_id