detectkit 0.42.0__tar.gz → 0.44.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 (140) hide show
  1. {detectkit-0.42.0/detectkit.egg-info → detectkit-0.44.0}/PKG-INFO +1 -1
  2. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/__init__.py +1 -1
  3. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/autotune/autotuner.py +30 -2
  4. detectkit-0.44.0/detectkit/autotune/runner.py +102 -0
  5. detectkit-0.44.0/detectkit/cli/_output.py +112 -0
  6. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/cli/assets/claude/CLAUDE.section.md +3 -1
  7. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/cli/assets/claude/rules/autotune.md +10 -0
  8. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/cli/assets/claude/rules/cli.md +15 -3
  9. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/cli/commands/autotune.py +17 -69
  10. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/cli/commands/tune.py +4 -0
  11. detectkit-0.44.0/detectkit/tuning/assets/tune.js +187 -0
  12. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/tuning/payload.py +31 -17
  13. detectkit-0.44.0/detectkit/tuning/server.py +512 -0
  14. {detectkit-0.42.0 → detectkit-0.44.0/detectkit.egg-info}/PKG-INFO +1 -1
  15. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit.egg-info/SOURCES.txt +1 -0
  16. detectkit-0.42.0/detectkit/cli/_output.py +0 -50
  17. detectkit-0.42.0/detectkit/tuning/assets/tune.js +0 -176
  18. detectkit-0.42.0/detectkit/tuning/server.py +0 -233
  19. {detectkit-0.42.0 → detectkit-0.44.0}/LICENSE +0 -0
  20. {detectkit-0.42.0 → detectkit-0.44.0}/MANIFEST.in +0 -0
  21. {detectkit-0.42.0 → detectkit-0.44.0}/README.md +0 -0
  22. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/alerting/__init__.py +0 -0
  23. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/alerting/channels/__init__.py +0 -0
  24. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/alerting/channels/base.py +0 -0
  25. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/alerting/channels/branding.py +0 -0
  26. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/alerting/channels/email.py +0 -0
  27. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/alerting/channels/factory.py +0 -0
  28. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/alerting/channels/mattermost.py +0 -0
  29. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/alerting/channels/slack.py +0 -0
  30. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/alerting/channels/telegram.py +0 -0
  31. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/alerting/channels/webhook.py +0 -0
  32. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  33. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/alerting/orchestrator/_base.py +0 -0
  34. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  35. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
  36. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  37. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
  38. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
  39. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  40. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  41. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/autotune/__init__.py +0 -0
  42. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/autotune/_base.py +0 -0
  43. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/autotune/_types.py +0 -0
  44. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/autotune/config_emitter.py +0 -0
  45. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/autotune/crossval.py +0 -0
  46. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/autotune/detector_select.py +0 -0
  47. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/autotune/distribution.py +0 -0
  48. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/autotune/grid_search.py +0 -0
  49. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/autotune/labels.py +0 -0
  50. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/autotune/result.py +0 -0
  51. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/autotune/scoring.py +0 -0
  52. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/autotune/seasonality_search.py +0 -0
  53. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/autotune/settings.py +0 -0
  54. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/autotune/window_select.py +0 -0
  55. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/cli/__init__.py +0 -0
  56. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
  57. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
  58. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
  59. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  60. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
  61. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
  62. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  63. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
  64. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  65. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/cli/commands/__init__.py +0 -0
  66. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/cli/commands/clean.py +0 -0
  67. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/cli/commands/init.py +0 -0
  68. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/cli/commands/init_claude.py +0 -0
  69. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/cli/commands/run.py +0 -0
  70. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/cli/commands/test_alert.py +0 -0
  71. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/cli/commands/unlock.py +0 -0
  72. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/cli/main.py +0 -0
  73. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/config/__init__.py +0 -0
  74. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/config/metric_config.py +0 -0
  75. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/config/profile.py +0 -0
  76. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/config/project_config.py +0 -0
  77. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/config/validator.py +0 -0
  78. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/core/__init__.py +0 -0
  79. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/core/interval.py +0 -0
  80. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/core/models.py +0 -0
  81. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/database/__init__.py +0 -0
  82. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/database/_sql_manager.py +0 -0
  83. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/database/clickhouse_manager.py +0 -0
  84. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/database/internal_tables/__init__.py +0 -0
  85. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  86. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  87. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/database/internal_tables/_base.py +0 -0
  88. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  89. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/database/internal_tables/_detections.py +0 -0
  90. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  91. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  92. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/database/internal_tables/_schema.py +0 -0
  93. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  94. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/database/internal_tables/manager.py +0 -0
  95. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/database/manager.py +0 -0
  96. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/database/mysql_manager.py +0 -0
  97. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/database/postgres_manager.py +0 -0
  98. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/database/tables.py +0 -0
  99. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/detectors/__init__.py +0 -0
  100. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/detectors/base.py +0 -0
  101. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/detectors/factory.py +0 -0
  102. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/detectors/seasonality.py +0 -0
  103. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/detectors/statistical/__init__.py +0 -0
  104. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  105. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/detectors/statistical/iqr.py +0 -0
  106. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/detectors/statistical/mad.py +0 -0
  107. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  108. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/detectors/statistical/zscore.py +0 -0
  109. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/loaders/__init__.py +0 -0
  110. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/loaders/metric_loader.py +0 -0
  111. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/loaders/query_template.py +0 -0
  112. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/orchestration/__init__.py +0 -0
  113. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/orchestration/error_dispatch.py +0 -0
  114. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  115. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
  116. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/orchestration/task_manager/_base.py +0 -0
  117. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  118. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  119. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  120. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  121. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/reporting/__init__.py +0 -0
  122. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/reporting/assets/report.js +0 -0
  123. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/reporting/builder.py +0 -0
  124. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/reporting/html_report.py +0 -0
  125. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/tuning/__init__.py +0 -0
  126. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/tuning/config_writer.py +0 -0
  127. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/tuning/html.py +0 -0
  128. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/utils/__init__.py +0 -0
  129. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/utils/datetime_utils.py +0 -0
  130. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/utils/env_interpolation.py +0 -0
  131. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/utils/json_utils.py +0 -0
  132. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit/utils/stats.py +0 -0
  133. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit.egg-info/dependency_links.txt +0 -0
  134. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit.egg-info/entry_points.txt +0 -0
  135. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit.egg-info/requires.txt +0 -0
  136. {detectkit-0.42.0 → detectkit-0.44.0}/detectkit.egg-info/top_level.txt +0 -0
  137. {detectkit-0.42.0 → detectkit-0.44.0}/pyproject.toml +0 -0
  138. {detectkit-0.42.0 → detectkit-0.44.0}/requirements.txt +0 -0
  139. {detectkit-0.42.0 → detectkit-0.44.0}/setup.cfg +0 -0
  140. {detectkit-0.42.0 → detectkit-0.44.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.42.0
3
+ Version: 0.44.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.42.0"
7
+ __version__ = "0.44.0"
8
8
 
9
9
  from detectkit.core.interval import Interval
10
10
  from detectkit.core.models import ColumnDefinition, TableModel
@@ -6,7 +6,9 @@ command handles loading, persistence, config emission and candidate cleanup.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from collections.abc import Callable
9
+ import contextlib
10
+ import logging
11
+ from collections.abc import Callable, Iterator
10
12
  from datetime import datetime, timedelta
11
13
  from typing import Any
12
14
 
@@ -27,6 +29,31 @@ from detectkit.detectors.factory import DetectorFactory
27
29
 
28
30
  _ALERT_WINDOW_GRID = (1, 2, 3, 4, 5)
29
31
 
32
+ # The windowed detectors emit a one-time "seasonality group can't fill this
33
+ # window → falls back to global" warning per *instance*. A tune builds dozens of
34
+ # throwaway candidate detectors, so that warning would flood the terminal and
35
+ # bury the structured decision log. The engine already surfaces an under-fill of
36
+ # the *chosen* seasonality as a `window` advisory in its own log, so the raw
37
+ # per-candidate warnings are pure noise here.
38
+ _WINDOWED_LOGGER = "detectkit.detectors.statistical._windowed"
39
+
40
+
41
+ @contextlib.contextmanager
42
+ def _quiet_per_candidate_warnings() -> Iterator[None]:
43
+ """Silence the windowed detectors' per-instance under-fill warning for a tune.
44
+
45
+ Scoped + restored, so a real ``dtk run`` (which builds one detector and *does*
46
+ want the warning) is unaffected — only the candidate sweep inside the engine
47
+ is quieted.
48
+ """
49
+ logger = logging.getLogger(_WINDOWED_LOGGER)
50
+ prev_level = logger.level
51
+ logger.setLevel(logging.ERROR)
52
+ try:
53
+ yield
54
+ finally:
55
+ logger.setLevel(prev_level)
56
+
30
57
 
31
58
  def _ts_to_dt(ts64: np.datetime64) -> datetime:
32
59
  ms = int(ts64.astype("datetime64[ms]").astype(np.int64))
@@ -196,4 +223,5 @@ def run_autotune_engine(
196
223
  settings=settings,
197
224
  on_stage=on_stage,
198
225
  )
199
- return tuner.tune()
226
+ with _quiet_per_candidate_warnings():
227
+ return tuner.tune()
@@ -0,0 +1,102 @@
1
+ """Shared autotune entry point reused by ``dtk autotune`` and ``dtk tune``.
2
+
3
+ The pure ``run_autotune_engine`` operates on an in-memory ``data`` dict, a
4
+ ``GroundTruth`` and a ``TuneSettings``. Both callers need the same plumbing to
5
+ get there from a metric's ``AutoTuneConfig`` + labels: resolve the scoring
6
+ metric, cap the training history, build the settings, project the labels onto
7
+ the grid, and run the engine. That plumbing used to live privately in
8
+ ``cli/commands/autotune.py``; it lives here so the ``dtk tune`` server can reuse
9
+ it verbatim for its server-side **Autotune** mode (no browser port of the
10
+ scoring/CV/seasonality search). Neither this module nor the engine touches the
11
+ database or the filesystem — the caller loads the datapoints and decides what to
12
+ persist.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Callable
18
+
19
+ import numpy as np
20
+
21
+ from detectkit.autotune._types import ScoringMetric
22
+ from detectkit.autotune.autotuner import run_autotune_engine
23
+ from detectkit.autotune.labels import IncidentLabels
24
+ from detectkit.autotune.result import AutoTuneResult
25
+ from detectkit.autotune.settings import TuneSettings
26
+ from detectkit.config.metric_config import AutoTuneConfig
27
+
28
+ # Cap the scored training span when the user doesn't pin max_history, so tuning
29
+ # stays responsive on very long histories (recent data is the most relevant).
30
+ DEFAULT_TRAIN_CAP = 50_000
31
+
32
+
33
+ def resolve_scoring(scoring_override: str | None, autotune_cfg: AutoTuneConfig) -> ScoringMetric:
34
+ """Resolve the scoring metric: CLI override > config > MCC default.
35
+
36
+ Raises ``ValueError`` on an unknown name (callers translate as they see fit).
37
+ """
38
+ value = scoring_override or autotune_cfg.scoring_metric or ScoringMetric.MCC.value
39
+ try:
40
+ return ScoringMetric(value)
41
+ except ValueError as exc:
42
+ allowed = ", ".join(m.value for m in ScoringMetric)
43
+ raise ValueError(f"Invalid scoring metric '{value}'. Allowed: {allowed}") from exc
44
+
45
+
46
+ def build_settings(*, scoring: ScoringMetric, autotune_cfg: AutoTuneConfig) -> TuneSettings:
47
+ """Map an ``AutoTuneConfig`` block (+ resolved scoring) to ``TuneSettings``."""
48
+ return TuneSettings(
49
+ metric=scoring,
50
+ beta=autotune_cfg.beta,
51
+ fold_count=autotune_cfg.folds,
52
+ stability_lambda=autotune_cfg.stability_lambda,
53
+ allowed_detector_types=autotune_cfg.detector_types,
54
+ allowed_seasonality=autotune_cfg.seasonality_candidates,
55
+ force_seasonality=autotune_cfg.force_seasonality,
56
+ fixed_params=dict(autotune_cfg.fixed_params),
57
+ max_history=autotune_cfg.max_history,
58
+ )
59
+
60
+
61
+ def cap_history(data: dict[str, np.ndarray], max_history: int | None) -> dict[str, np.ndarray]:
62
+ """Keep the most recent ``max_history`` (or the default cap) points."""
63
+ n = len(data["timestamp"])
64
+ cap = max_history if max_history is not None else DEFAULT_TRAIN_CAP
65
+ if n <= cap:
66
+ return data
67
+ return {
68
+ "timestamp": data["timestamp"][-cap:],
69
+ "value": data["value"][-cap:],
70
+ "seasonality_data": data["seasonality_data"][-cap:],
71
+ "seasonality_columns": data["seasonality_columns"],
72
+ }
73
+
74
+
75
+ def autotune_from_data(
76
+ *,
77
+ metric_name: str,
78
+ data: dict[str, np.ndarray],
79
+ labels: IncidentLabels,
80
+ interval_seconds: int,
81
+ autotune_cfg: AutoTuneConfig,
82
+ scoring_override: str | None = None,
83
+ on_stage: Callable[[str, str], None] | None = None,
84
+ ) -> AutoTuneResult:
85
+ """Run the full autotune engine on an in-memory series + labels.
86
+
87
+ Caps the history, resolves the scoring metric, projects ``labels`` onto the
88
+ (capped) grid as ground truth, builds the settings and runs the engine.
89
+ Pure: no DB, no filesystem, no lock — the caller decides what to persist.
90
+ """
91
+ data = cap_history(data, autotune_cfg.max_history)
92
+ scoring = resolve_scoring(scoring_override, autotune_cfg)
93
+ ground_truth = labels.to_ground_truth(data["timestamp"], interval_seconds)
94
+ settings = build_settings(scoring=scoring, autotune_cfg=autotune_cfg)
95
+ return run_autotune_engine(
96
+ metric_name=metric_name,
97
+ data=data,
98
+ ground_truth=ground_truth,
99
+ interval_seconds=interval_seconds,
100
+ settings=settings,
101
+ on_stage=on_stage,
102
+ )
@@ -0,0 +1,112 @@
1
+ """Shared CLI output helpers so every command renders in one house style.
2
+
3
+ Mirrors the load → detect → alert pipeline's tree look (``┌─ / │ / └─``) used by
4
+ ``dtk run`` so the maintenance commands (``dtk clean``, ``dtk unlock``) match it
5
+ instead of each inventing its own formatting.
6
+
7
+ House conventions:
8
+ - A metric *with* something to report is a tree: a cyan-bold ``┌─ <name>``
9
+ header followed by one child line per item (``│ `` for all but the last,
10
+ ``└─ `` for the last).
11
+ - A metric with *nothing* to do is a single ``•`` line.
12
+ - A per-metric error is a red ``✗`` line (to stderr).
13
+ - The final summary is a cyan-bold ``Done. …`` line.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Callable
19
+
20
+ import click
21
+
22
+ # Autotune-engine stage name → display title for the streamed run-log tree.
23
+ # Most stages render as ``stage.upper()``; only the two underscored names need a
24
+ # space so the header reads cleanly (``DETECTOR SELECT`` / ``GRID SEARCH``).
25
+ AUTOTUNE_STAGE_TITLES = {
26
+ "labels": "LABELS",
27
+ "seasonality": "SEASONALITY",
28
+ "detector_select": "DETECTOR SELECT",
29
+ "grid_search": "GRID SEARCH",
30
+ "window": "WINDOW",
31
+ }
32
+
33
+
34
+ def echo_block(
35
+ title: str,
36
+ children: list[str],
37
+ *,
38
+ warnings: list[str] | None = None,
39
+ echo: Callable[[str], None] = click.echo,
40
+ ) -> None:
41
+ """Print a cyan-bold ``┌─ title`` header with ``│``/``└─`` child lines.
42
+
43
+ The injectable core of the house tree style (``dtk run``'s load/detect/alert
44
+ blocks): ``warnings`` render as yellow ``│`` continuation lines above the
45
+ children, the last child gets the ``└─`` elbow. ``echo`` defaults to
46
+ ``click.echo`` but can be any line sink, so the tune server can stream the
47
+ same blocks through its own output callback. ``children`` must be non-empty.
48
+ """
49
+ echo(click.style(f" ┌─ {title}", fg="cyan", bold=True))
50
+ for warning in warnings or []:
51
+ echo(click.style(f" │ ⚠ {warning}", fg="yellow", bold=True))
52
+ last = len(children) - 1
53
+ for i, child in enumerate(children):
54
+ prefix = " └─ " if i == last else " │ "
55
+ echo(f"{prefix}{child}")
56
+
57
+
58
+ def echo_tree(name: str, children: list[str], *, warnings: list[str] | None = None) -> None:
59
+ """Print a ``┌─ name`` header with ``│``/``└─`` child lines.
60
+
61
+ ``warnings`` (if any) are rendered as yellow ``│`` continuation lines above
62
+ the children. ``children`` must be non-empty (a metric with no items should
63
+ use :func:`echo_noop` instead). Thin wrapper over :func:`echo_block` (the
64
+ CLI-default ``click.echo`` sink).
65
+ """
66
+ echo_block(name, children, warnings=warnings)
67
+
68
+
69
+ class StageLogRenderer:
70
+ """Stream ``(stage, line)`` engine progress as the run-log tree.
71
+
72
+ Opens a cyan-bold ``┌─ TITLE`` header the first time each stage appears and
73
+ prints every subsequent line for that stage as a ``│ `` child — the same
74
+ look as ``dtk run``'s load/detect/alert blocks. ``titles`` maps an engine
75
+ stage name to its display title (unmapped names fall back to ``upper()``);
76
+ ``echo`` is injectable so one renderer drives both the CLI (``click.echo``)
77
+ and the ``dtk tune`` server (its own output callback). Build a fresh renderer
78
+ per run so the first stage re-opens its header.
79
+ """
80
+
81
+ def __init__(
82
+ self,
83
+ *,
84
+ titles: dict[str, str] | None = None,
85
+ echo: Callable[[str], None] = click.echo,
86
+ ) -> None:
87
+ self._open: str | None = None
88
+ self._titles = titles or {}
89
+ self._echo = echo
90
+
91
+ def __call__(self, stage: str, line: str) -> None:
92
+ if self._open != stage:
93
+ title = self._titles.get(stage, stage.upper())
94
+ self._echo(click.style(f" ┌─ {title}", fg="cyan", bold=True))
95
+ self._open = stage
96
+ self._echo(f" │ {line}")
97
+
98
+
99
+ def echo_noop(name: str, reason: str) -> None:
100
+ """A metric with nothing to do — a single ``•`` line."""
101
+ click.echo(f" • {name}: {reason}")
102
+
103
+
104
+ def echo_error(name: str, message: str) -> None:
105
+ """A per-metric failure — a red ``✗`` line on stderr."""
106
+ click.echo(click.style(f" ✗ {name}: {message}", fg="red"), err=True)
107
+
108
+
109
+ def echo_done(summary: str) -> None:
110
+ """The closing ``Done. …`` summary (cyan, bold), preceded by a blank line."""
111
+ click.echo()
112
+ click.echo(click.style(f"Done. {summary}", fg="cyan", bold=True))
@@ -54,7 +54,9 @@ version — **read the relevant one on demand** instead of guessing:
54
54
  `dtk autotune`, and explains the chosen, annotated config (an automatic,
55
55
  cross-validated search). For **hands-on** tuning instead, `dtk tune` opens a
56
56
  browser view where you drag the detector's knobs against the real series and
57
- write the result back in place (see `cli.md`).
57
+ write the result back in place and its **Autotune** mode can run that same
58
+ search server-side and re-seed the knobs without leaving the cockpit (see
59
+ `cli.md`).
58
60
  - **Hit a detectkit bug, or have feedback** — once you've ruled out a local
59
61
  config fix (see the gotchas below), use the **`dtk-feedback`** skill to file a
60
62
  redacted bug report, feature request, or comment as a GitHub issue on the
@@ -17,6 +17,16 @@ same windowed detectors and `detector_id` identity). The fastest path is the
17
17
  > `metrics/.history/<metric>/` first). Use `autotune` to search automatically and
18
18
  > emit a new file; use `tune` to dial a detector in by eye and commit it. See
19
19
  > `cli.md`.
20
+ >
21
+ > **You can also run this engine *inside* `dtk tune`** — its **Autotune** mode runs
22
+ > the same search **server-side** (using the incidents you've marked as ground
23
+ > truth), re-seeds the knobs with the winner, and lets you **Apply** in place. That's
24
+ > the same computation as this command, with two differences: it tunes on the
25
+ > **window currently shown** in the cockpit (the **Points shown** trim — the series
26
+ > you see and score) rather than the full capped history, and it edits the metric YAML
27
+ > in place (advisory — no run record / `__tuned_<id>.yml` / persisted detections)
28
+ > rather than emitting a new tuned file. Reach for `dtk autotune` when you want the
29
+ > audited run + tuned file + persisted winner detections.
20
30
 
21
31
  ## What it searches
22
32
 
@@ -88,7 +88,8 @@ cockpit**: ONE chart (the windshield) fills the view, the live metrics ride
88
88
  **pinned in a HUD over the chart** (the speedometer), and every control lives in an
89
89
  **always-visible, mode-aware side rail** beside the chart (Tune shows the detector
90
90
  knobs + effective config + Apply, Review the verdict actions, Label the capture
91
- tools + incident list + Save), while the controls that aren't detector-specific
91
+ tools + incident list + Save, Autotune the search button + winning config), while
92
+ the controls that aren't detector-specific —
92
93
  the **Points shown** data window, the alert rule (**direction** + **consecutive
93
94
  anomalies**) and the **y = 0** toggle — stay visible in every mode. The chart is
94
95
  **zoomable** (scroll/drag + navigator strip) with a **"Points shown"** trim slider.
@@ -103,11 +104,22 @@ and which interactions are armed on the one chart: **Tune** (band leads; inciden
103
104
  recede to read-only context; hover a point for its window), **Review** (the fired
104
105
  alerts lead, band ghosts — click an alert marker to cycle its verdict un-reviewed →
105
106
  **valid** (green) → **false alarm** (slate); **Confirm all unreviewed valid** does
106
- the lot), and **Label** (band hides; **mark the real incidents** by drag, **Lasso
107
+ the lot), **Label** (band hides; **mark the real incidents** by drag, **Lasso
107
108
  anomalies** — loop a cloud of anomaly dots, each consecutive run, gaps bridged up to
108
109
  `consecutive_anomalies`, becomes one span — or **Threshold capture** every span past
109
110
  a horizontal line, each widened to a full interval so the alert lands inside; the
110
- painted window saves as `capture_windows`). **Confirming an alert valid IS marking an
111
+ painted window saves as `capture_windows`), and **Autotune** (**Run autotune**
112
+ launches the **real** `dtk autotune` engine **server-side** over the **window
113
+ currently shown** (the **Points shown** trim — the same series you see and score, not
114
+ the full history), using your marked incidents as ground truth, then **re-seeds every
115
+ knob** with the winner and shows the score + decision log; the band leads like Tune;
116
+ each run also streams a structured `LABELS → … → RESULT` log to the terminal, like
117
+ `dtk run`). The Autotune mode is **advisory** — it computes + re-seeds only and persists nothing (no
118
+ run record / `__tuned_<id>.yml` / detections, so `dtk tune` stays lock-free); review
119
+ the band and **Apply** to write it back. It honours the metric's `autotune:` block,
120
+ is supervised when incidents are marked (also picks `consecutive_anomalies`) else
121
+ unsupervised, and needs the live server (unavailable under `--no-serve`).
122
+ **Confirming an alert valid IS marking an
111
123
  incident**: the confirmed streak becomes a first-class **ground-truth incident** that
112
124
  shows in the Marked-incidents list (a read-only "✓ confirmed alert" row; its ✕
113
125
  un-confirms the alert), counts toward recall + correct, and is written on Save — so a
@@ -20,7 +20,6 @@ import numpy as np
20
20
  from detectkit.autotune import (
21
21
  AutoTuneError,
22
22
  ScoringMetric,
23
- TuneSettings,
24
23
  compute_run_id,
25
24
  emit_tuned_config,
26
25
  parse_labels_file,
@@ -31,7 +30,15 @@ from detectkit.autotune.labels import (
31
30
  IncidentLabels,
32
31
  parse_incident_labels,
33
32
  )
34
- from detectkit.cli._output import echo_done, echo_error, echo_noop
33
+ from detectkit.autotune.runner import build_settings, cap_history, resolve_scoring
34
+ from detectkit.cli._output import (
35
+ AUTOTUNE_STAGE_TITLES,
36
+ StageLogRenderer,
37
+ echo_block,
38
+ echo_done,
39
+ echo_error,
40
+ echo_noop,
41
+ )
35
42
  from detectkit.cli.commands.run import find_project_root, parse_date, select_metrics
36
43
  from detectkit.config.metric_config import AutoTuneConfig, MetricConfig
37
44
  from detectkit.config.profile import ProfilesConfig
@@ -41,32 +48,6 @@ from detectkit.detectors.base import DetectionResult
41
48
  from detectkit.detectors.factory import DetectorFactory
42
49
  from detectkit.utils.json_utils import json_dumps_sorted
43
50
 
44
- # Cap the scored training span when the user doesn't pin max_history, so tuning
45
- # stays responsive on very long histories (recent data is the most relevant).
46
- _DEFAULT_TRAIN_CAP = 50_000
47
-
48
- _STAGE_TITLES = {
49
- "labels": "LABELS",
50
- "seasonality": "SEASONALITY",
51
- "detector_select": "DETECTOR SELECT",
52
- "grid_search": "GRID SEARCH",
53
- "window": "WINDOW",
54
- }
55
-
56
-
57
- class _StageRenderer:
58
- """Streams engine progress as the run-log tree (header per stage + │ lines)."""
59
-
60
- def __init__(self) -> None:
61
- self._open: str | None = None
62
-
63
- def __call__(self, stage: str, line: str) -> None:
64
- if self._open != stage:
65
- title = _STAGE_TITLES.get(stage, stage.upper())
66
- click.echo(click.style(f" ┌─ {title}", fg="cyan", bold=True))
67
- self._open = stage
68
- click.echo(f" │ {line}")
69
-
70
51
 
71
52
  def _results_to_batch(results: list[DetectionResult]) -> dict[str, np.ndarray]:
72
53
  """Convert detect() output into the ``save_detections`` batch shape."""
@@ -88,12 +69,11 @@ def _results_to_batch(results: list[DetectionResult]) -> dict[str, np.ndarray]:
88
69
 
89
70
 
90
71
  def _resolve_scoring(scoring_override: str | None, autotune_cfg: AutoTuneConfig) -> ScoringMetric:
91
- value = scoring_override or autotune_cfg.scoring_metric or ScoringMetric.MCC.value
72
+ """CLI wrapper over the shared resolver: bad name → a click usage error."""
92
73
  try:
93
- return ScoringMetric(value)
74
+ return resolve_scoring(scoring_override, autotune_cfg)
94
75
  except ValueError as exc:
95
- allowed = ", ".join(m.value for m in ScoringMetric)
96
- raise click.BadParameter(f"Invalid scoring metric '{value}'. Allowed: {allowed}") from exc
76
+ raise click.BadParameter(str(exc)) from exc
97
77
 
98
78
 
99
79
  _LABELS_GLOBS = ("*.yml", "*.yaml", "*.json")
@@ -216,34 +196,6 @@ def _prompt_labels(*, interval_seconds: int) -> IncidentLabels:
216
196
  return parse_incident_labels({"incidents": incidents}, interval_seconds=interval_seconds)
217
197
 
218
198
 
219
- def _build_settings(*, scoring: ScoringMetric, autotune_cfg: AutoTuneConfig) -> TuneSettings:
220
- return TuneSettings(
221
- metric=scoring,
222
- beta=autotune_cfg.beta,
223
- fold_count=autotune_cfg.folds,
224
- stability_lambda=autotune_cfg.stability_lambda,
225
- allowed_detector_types=autotune_cfg.detector_types,
226
- allowed_seasonality=autotune_cfg.seasonality_candidates,
227
- force_seasonality=autotune_cfg.force_seasonality,
228
- fixed_params=dict(autotune_cfg.fixed_params),
229
- max_history=autotune_cfg.max_history,
230
- )
231
-
232
-
233
- def _cap_history(data: dict[str, np.ndarray], max_history: int | None) -> dict[str, np.ndarray]:
234
- """Keep the most recent ``max_history`` (or default-cap) points."""
235
- n = len(data["timestamp"])
236
- cap = max_history if max_history is not None else _DEFAULT_TRAIN_CAP
237
- if n <= cap:
238
- return data
239
- return {
240
- "timestamp": data["timestamp"][-cap:],
241
- "value": data["value"][-cap:],
242
- "seasonality_data": data["seasonality_data"][-cap:],
243
- "seasonality_columns": data["seasonality_columns"],
244
- }
245
-
246
-
247
199
  def _load_project(
248
200
  profile: str | None,
249
201
  ) -> tuple[Path, ProjectConfig, InternalTablesManager, Any] | None:
@@ -368,7 +320,7 @@ def _tune_one(
368
320
  return False
369
321
 
370
322
  try:
371
- data = _cap_history(data, autotune_cfg.max_history)
323
+ data = cap_history(data, autotune_cfg.max_history)
372
324
  click.echo(
373
325
  f" Training span: {len(data['timestamp']):,} points " f"(interval {interval_seconds}s)"
374
326
  )
@@ -384,14 +336,14 @@ def _tune_one(
384
336
  ground_truth = labels.to_ground_truth(data["timestamp"], interval_seconds)
385
337
  click.echo(f" Labels: {label_source}")
386
338
 
387
- settings = _build_settings(scoring=scoring, autotune_cfg=autotune_cfg)
339
+ settings = build_settings(scoring=scoring, autotune_cfg=autotune_cfg)
388
340
  result = run_autotune_engine(
389
341
  metric_name=name,
390
342
  data=data,
391
343
  ground_truth=ground_truth,
392
344
  interval_seconds=interval_seconds,
393
345
  settings=settings,
394
- on_stage=_StageRenderer(),
346
+ on_stage=StageLogRenderer(titles=AUTOTUNE_STAGE_TITLES),
395
347
  )
396
348
 
397
349
  run_id = compute_run_id(result)
@@ -467,9 +419,7 @@ def _finalize(
467
419
 
468
420
  if dry_run:
469
421
  children.append("dry-run: no config written, no detections persisted")
470
- click.echo(click.style(" ┌─ RESULT", fg="cyan", bold=True))
471
- for i, child in enumerate(children):
472
- click.echo(f" {'└─' if i == len(children) - 1 else '│ '} {child}")
422
+ echo_block("RESULT", children)
473
423
  return
474
424
 
475
425
  # Persist the run record (the audit trail).
@@ -551,9 +501,7 @@ def _finalize(
551
501
  f"persisted winner, pruned {pruned} superseded run(s)"
552
502
  )
553
503
  children.append(f"Re-run with: dtk run --select {name}__tuned_{run_id}")
554
- click.echo(click.style(" ┌─ RESULT", fg="cyan", bold=True))
555
- for i, child in enumerate(children):
556
- click.echo(f" {'└─' if i == len(children) - 1 else '│ '} {child}")
504
+ echo_block("RESULT", children)
557
505
 
558
506
 
559
507
  def _prune_prior_winners(
@@ -143,6 +143,10 @@ def run_tune(
143
143
  metric_name=name,
144
144
  incidents_dir=incidents_dir,
145
145
  interval_seconds=interval_seconds,
146
+ # Enable the server-side Autotune mode: the config (autotune block, interval)
147
+ # + a DB handle to reload the metric's history for the engine.
148
+ metric_config=config,
149
+ internal_manager=internal_manager,
146
150
  )
147
151
  if applied is None:
148
152
  echo_noop(name, "tuning cancelled — metric unchanged")