detectkit 0.49.2__tar.gz → 0.51.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {detectkit-0.49.2/detectkit.egg-info → detectkit-0.51.0}/PKG-INFO +1 -1
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/__init__.py +1 -1
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/autotune/grid_search.py +20 -6
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/cli/assets/claude/CLAUDE.section.md +1 -1
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/cli/assets/claude/rules/autotune.md +4 -3
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/cli/assets/claude/rules/cli.md +28 -2
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/cli/assets/claude/rules/detectors.md +23 -3
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/cli/assets/claude/rules/overview.md +6 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +8 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/cli/assets/claude/skills/dtk-tune/SKILL.md +4 -1
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/config/metric_config.py +4 -3
- detectkit-0.51.0/detectkit/config/metric_io.py +90 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/detectors/statistical/_windowed.py +55 -2
- detectkit-0.51.0/detectkit/tuning/assets/tune.js +187 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/tuning/config_writer.py +9 -15
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/tuning/payload.py +1 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/ui/__init__.py +6 -2
- detectkit-0.51.0/detectkit/ui/assets/ui.js +323 -0
- detectkit-0.51.0/detectkit/ui/metric_files.py +256 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/ui/server.py +214 -8
- {detectkit-0.49.2 → detectkit-0.51.0/detectkit.egg-info}/PKG-INFO +1 -1
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit.egg-info/SOURCES.txt +2 -0
- detectkit-0.49.2/detectkit/tuning/assets/tune.js +0 -187
- detectkit-0.49.2/detectkit/ui/assets/ui.js +0 -255
- {detectkit-0.49.2 → detectkit-0.51.0}/LICENSE +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/MANIFEST.in +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/README.md +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/alerting/__init__.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/alerting/channels/__init__.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/alerting/channels/base.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/alerting/channels/branding.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/alerting/channels/email.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/alerting/channels/factory.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/alerting/channels/mattermost.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/alerting/channels/slack.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/alerting/channels/telegram.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/alerting/channels/webhook.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/alerting/orchestrator/_base.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/alerting/orchestrator/_types.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/autotune/__init__.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/autotune/_base.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/autotune/_types.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/autotune/autotuner.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/autotune/config_emitter.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/autotune/crossval.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/autotune/detector_select.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/autotune/distribution.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/autotune/labels.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/autotune/result.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/autotune/runner.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/autotune/scoring.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/autotune/seasonality_search.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/autotune/settings.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/autotune/window_select.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/cli/__init__.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/cli/_output.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/cli/commands/__init__.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/cli/commands/autotune.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/cli/commands/clean.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/cli/commands/init.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/cli/commands/init_claude.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/cli/commands/osi.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/cli/commands/run.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/cli/commands/test_alert.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/cli/commands/tune.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/cli/commands/ui.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/cli/commands/unlock.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/cli/main.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/config/__init__.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/config/profile.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/config/project_config.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/config/validator.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/core/__init__.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/core/interval.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/core/models.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/database/__init__.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/database/_sql_manager.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/database/clickhouse_manager.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/database/internal_tables/__init__.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/database/internal_tables/_base.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/database/internal_tables/_detections.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/database/internal_tables/_metrics.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/database/internal_tables/_schema.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/database/internal_tables/_tasks.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/database/internal_tables/manager.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/database/manager.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/database/mysql_manager.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/database/postgres_manager.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/database/tables.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/detectors/__init__.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/detectors/base.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/detectors/factory.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/detectors/seasonality.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/detectors/statistical/__init__.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/detectors/statistical/iqr.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/detectors/statistical/mad.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/detectors/statistical/zscore.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/loaders/__init__.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/loaders/metric_loader.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/loaders/query_template.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/orchestration/__init__.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/orchestration/error_dispatch.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/orchestration/task_manager/_base.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/orchestration/task_manager/_types.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/orchestration/task_manager/manager.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/reporting/__init__.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/reporting/assets/report.js +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/reporting/builder.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/reporting/html_report.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/semantic/__init__.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/semantic/errors.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/semantic/exporter.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/semantic/importer.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/semantic/osi_model.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/semantic/query_gen.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/tuning/__init__.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/tuning/html.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/tuning/server.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/ui/html.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/ui/jobs.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/ui/overview.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/utils/__init__.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/utils/datetime_utils.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/utils/env_interpolation.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/utils/json_utils.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit/utils/stats.py +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit.egg-info/dependency_links.txt +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit.egg-info/entry_points.txt +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit.egg-info/requires.txt +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/detectkit.egg-info/top_level.txt +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/pyproject.toml +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/requirements.txt +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/setup.cfg +0 -0
- {detectkit-0.49.2 → detectkit-0.51.0}/setup.py +0 -0
|
@@ -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.
|
|
7
|
+
__version__ = "0.51.0"
|
|
8
8
|
|
|
9
9
|
from detectkit.core.interval import Interval
|
|
10
10
|
from detectkit.core.models import ColumnDefinition, TableModel
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"""Stage 3: bounded coordinate search over detector hyperparameters.
|
|
2
2
|
|
|
3
3
|
Not a Cartesian product — a greedy coordinate sweep per candidate type:
|
|
4
|
-
threshold → recency weighting → detrend (gated by a trend test) →
|
|
5
|
-
(with the trend-gated window tie-bias
|
|
6
|
-
re-sweep at the chosen window (the
|
|
7
|
-
is the cross-validated score
|
|
8
|
-
|
|
9
|
-
capped by
|
|
4
|
+
threshold → recency weighting → detrend (gated by a trend test) → stabilization
|
|
5
|
+
(anomaly-robust baseline) → window size (with the trend-gated window tie-bias
|
|
6
|
+
from window_select) → a final threshold re-sweep at the chosen window (the
|
|
7
|
+
threshold↔window coupling fix). The objective is the cross-validated score
|
|
8
|
+
(settings.metric, or the unsupervised objective when there are no labels).
|
|
9
|
+
Total evaluations stay in the low tens per type and are capped by
|
|
10
|
+
settings.max_candidates.
|
|
10
11
|
"""
|
|
11
12
|
|
|
12
13
|
from __future__ import annotations
|
|
@@ -149,6 +150,19 @@ def grid_search(
|
|
|
149
150
|
if ev is not None and ev.score > best.score + eps:
|
|
150
151
|
best, accepted["detrend"] = ev, detrend
|
|
151
152
|
|
|
153
|
+
# Axis 3b: stabilization (anomaly-robust baseline; adopt only when it
|
|
154
|
+
# clears the margin). Flagged points enter subsequent windows clamped
|
|
155
|
+
# to the bound they violated, so a sustained incident cannot inflate
|
|
156
|
+
# the band and mask its own tail — usually decisive on labeled data
|
|
157
|
+
# with long incidents, near-neutral on clean series. Swept before the
|
|
158
|
+
# window axis so select_window evaluates with the adopted baseline.
|
|
159
|
+
for stabilization in (None, "clamp"):
|
|
160
|
+
if stabilization == accepted.get("stabilization"):
|
|
161
|
+
continue
|
|
162
|
+
ev = tuner.safe_evaluate(detector_type, {**accepted, "stabilization": stabilization})
|
|
163
|
+
if ev is not None and ev.score > best.score + eps:
|
|
164
|
+
best, accepted["stabilization"] = ev, stabilization
|
|
165
|
+
|
|
152
166
|
# Axis 4: window size (large-window tie-bias, trend-gated in select_window).
|
|
153
167
|
window_best = select_window(tuner, detector_type, accepted, best, grid)
|
|
154
168
|
if window_best.score >= best.score - tuner.settings.window_tie_margin:
|
|
@@ -40,7 +40,7 @@ version — **read the relevant one on demand** instead of guessing:
|
|
|
40
40
|
| Alert rules (quorum/direction/consecutive), cooldown, recovery, templates | `.claude/rules/detectkit/alerting.md` |
|
|
41
41
|
| Auto-tuning a detector (`dtk autotune`), labels file, the `autotune:` block, `_dtk_autotune_runs` | `.claude/rules/detectkit/autotune.md` |
|
|
42
42
|
| Manually/interactively tuning a detector in the browser and writing it back in place (`dtk tune`) | `.claude/rules/detectkit/cli.md` |
|
|
43
|
-
| A project-wide live overview
|
|
43
|
+
| A project-wide live overview, driving the pipeline, and creating/editing/deleting metric YAMLs from the browser (`dtk ui`) | `.claude/rules/detectkit/cli.md` |
|
|
44
44
|
| OSI (Open Semantic Interchange) interop: import an OSI metric into a native metric / export back (`dtk osi`) | `.claude/rules/detectkit/cli.md` |
|
|
45
45
|
|
|
46
46
|
### Skills
|
|
@@ -45,9 +45,10 @@ same windowed detectors and `detector_id` identity). The fastest path is the
|
|
|
45
45
|
detectors (`mad`/`zscore`/`iqr`) and cross-validation picks the winner — no
|
|
46
46
|
type is excluded by heuristic.
|
|
47
47
|
3. **Hyperparameters** — a bounded coordinate grid search over `threshold`,
|
|
48
|
-
recency weighting (and its **half-life** when adopted), detrending
|
|
49
|
-
`
|
|
50
|
-
|
|
48
|
+
recency weighting (and its **half-life** when adopted), detrending,
|
|
49
|
+
**stabilization** (`clamp`, adopted only when it clears the score margin)
|
|
50
|
+
and `window_size`, maximizing a cross-validated score, with a final
|
|
51
|
+
`threshold` re-sweep at the chosen window. Fold scores aggregate as
|
|
51
52
|
`mean − stability_lambda · downside_deviation` (downside-only, so a config that
|
|
52
53
|
scores *better* on recent folds isn't penalized; lower `stability_lambda` for a
|
|
53
54
|
regime-shift metric).
|
|
@@ -12,7 +12,7 @@ Run all commands from a project directory (the one containing
|
|
|
12
12
|
| `dtk run --select <sel>` | Run the load → detect → alert pipeline |
|
|
13
13
|
| `dtk autotune --select <sel>` | Auto-configure a metric's detector (see `autotune.md`) |
|
|
14
14
|
| `dtk tune --select <sel>` | Interactively tune a detector on real data, write it back in place |
|
|
15
|
-
| `dtk ui` | Project-wide live overview + browser pipeline control panel |
|
|
15
|
+
| `dtk ui` | Project-wide live overview + browser pipeline control panel + create/edit/delete metric YAMLs |
|
|
16
16
|
| `dtk test-alert <metric>` | Send a mock alert to the metric's channels |
|
|
17
17
|
| `dtk unlock --select <sel>` | Clear a stuck pipeline lock |
|
|
18
18
|
| `dtk clean --select <sel>` | Prune internal data that no longer matches the config |
|
|
@@ -84,7 +84,7 @@ Full reference: `autotune.md`.
|
|
|
84
84
|
The **manual, interactive** sibling of `dtk autotune`. Opens a localhost browser
|
|
85
85
|
view of the metric's **real** persisted series and lets you turn the detector's
|
|
86
86
|
knobs (type — including **Manual bounds** with lower/upper sliders — threshold,
|
|
87
|
-
window, recency weighting + half-life, detrend, smoothing, **seasonality groups**,
|
|
87
|
+
window, recency weighting + half-life, detrend, stabilization, smoothing, **seasonality groups**,
|
|
88
88
|
**direction** (both/up/down), alert `consecutive_anomalies`) while the confidence
|
|
89
89
|
band and flagged anomalies **recompute live**. The whole screen is a **chart-first
|
|
90
90
|
cockpit**: ONE chart (the windshield) fills the view, the live metrics ride
|
|
@@ -210,6 +210,32 @@ its own isolated, lock-free session. The `dtk ui` server itself takes **no
|
|
|
210
210
|
pipeline lock** and never mutates anything — every spawned command behaves
|
|
211
211
|
exactly as if typed into a terminal.
|
|
212
212
|
|
|
213
|
+
### Managing metrics from the UI
|
|
214
|
+
|
|
215
|
+
The cockpit header has a **New metric** button — an editor overlay seeded
|
|
216
|
+
with a starter YAML template, with an optional subfolder under `metrics/`;
|
|
217
|
+
saving writes `metrics/[<folder>/]<name>.yml`. Every metric row also has an
|
|
218
|
+
**Edit** action that opens its raw YAML in the same editor. **Save** validates
|
|
219
|
+
server-side **before any write**: YAML syntax → full `MetricConfig` → a deep
|
|
220
|
+
detector-params check (each factory-known detector is actually constructed) —
|
|
221
|
+
an invalid config lands in the editor's error pane with nothing written. A
|
|
222
|
+
successful save **archives the previous file verbatim** to
|
|
223
|
+
`metrics/.history/<metric>/` (the same archive `dtk tune`'s Apply writes,
|
|
224
|
+
excluded from metric discovery) and only then overwrites in place, so the
|
|
225
|
+
text typed is what lands on disk, comments intact (normalized only to end
|
|
226
|
+
with a newline). A save is refused when the file changed on disk after the
|
|
227
|
+
editor was opened (a `dtk tune` Apply or another session saved first) —
|
|
228
|
+
reopen the metric rather than overwrite it. Renaming via `name:`
|
|
229
|
+
is allowed — uniqueness is enforced project-wide — and rows under the old name
|
|
230
|
+
stay in the `_dtk_*` tables until `dtk clean`. **Delete** lives in the edit
|
|
231
|
+
overlay behind an explicit confirmation step, and the server additionally
|
|
232
|
+
requires the request to echo the metric name back; it archives the file
|
|
233
|
+
(`…-deleted.yml`) before removing it, so a delete is reversible by hand, and
|
|
234
|
+
its `_dtk_*` rows likewise remain until `dtk clean`. While a `dtk tune` session
|
|
235
|
+
for that metric is running, Save and Delete are refused (its Apply would race
|
|
236
|
+
the edit). Like the rest of `dtk ui`, metric management takes no pipeline lock
|
|
237
|
+
and never touches the database — it only manages metric YAML files.
|
|
238
|
+
|
|
213
239
|
## `dtk test-alert <metric>`
|
|
214
240
|
|
|
215
241
|
Sends a mock alert (fake value/CI/severity) through the metric's configured
|
|
@@ -66,6 +66,7 @@ expected interval for the current point.
|
|
|
66
66
|
window_weights: null # null (uniform) | exponential | linear
|
|
67
67
|
half_life: null # exponential half-life: int points or "3d"/"12h"
|
|
68
68
|
detrend: null # null | linear
|
|
69
|
+
stabilization: null # null (off) | clamp
|
|
69
70
|
# --- execution (NOT hashed) ---
|
|
70
71
|
start_time: "2024-01-01 00:00:00" # optional; when detection begins (default: loading_start_time)
|
|
71
72
|
batch_size: 500
|
|
@@ -161,6 +162,24 @@ Trade-off: a shorter `half_life` adapts faster but also "accepts" a real
|
|
|
161
162
|
sustained degradation as the new normal sooner. (`weight_decay` is a deprecated
|
|
162
163
|
alias for `half_life`; prefer `half_life`.)
|
|
163
164
|
|
|
165
|
+
## Stabilization — sustained incidents poisoning their own baseline
|
|
166
|
+
|
|
167
|
+
A sustained incident's anomalous points enter the trailing window and inflate
|
|
168
|
+
the spread / drag the center toward it, so the band widens mid-incident and the
|
|
169
|
+
detector stops flagging the tail ("the incident becomes the new normal") —
|
|
170
|
+
z-score (mean/std) is the most vulnerable; median/quartile-based mad/iqr resist
|
|
171
|
+
short incidents but still bend under a long one. `stabilization: clamp` fixes
|
|
172
|
+
this: once a point is flagged anomalous, subsequent trailing windows see it
|
|
173
|
+
**clamped to the confidence bound it violated** (winsorized) instead of the
|
|
174
|
+
raw value — only the statistics windows read the substituted history; scored
|
|
175
|
+
and persisted values are unchanged, and anomalies still render as anomalies.
|
|
176
|
+
Reach for it when alerting on metrics with occasional sustained incidents; it
|
|
177
|
+
composes with `window_weights`/`half_life` (where it matters most, since
|
|
178
|
+
recency weighting gives an ongoing incident's points more weight), `detrend`,
|
|
179
|
+
`smoothing`, `input_type` and seasonality groups, and is near-neutral on clean
|
|
180
|
+
series. Enabling it is a hashed change — it produces a new `detector_id` and
|
|
181
|
+
recomputes on the next run; existing configs that don't set it keep their ids.
|
|
182
|
+
|
|
164
183
|
## Feature compatibility
|
|
165
184
|
|
|
166
185
|
| Feature | mad | zscore | iqr | manual_bounds |
|
|
@@ -169,6 +188,7 @@ alias for `half_life`; prefer `half_life`.)
|
|
|
169
188
|
| `smoothing` | Yes | Yes | Yes | No |
|
|
170
189
|
| `window_weights` / `half_life` | Yes | Yes | Yes | No |
|
|
171
190
|
| `detrend` | Yes | Yes | Yes | No |
|
|
191
|
+
| `stabilization` | Yes | Yes | Yes | No |
|
|
172
192
|
| `seasonality_components` | Yes | Yes | Yes | No |
|
|
173
193
|
|
|
174
194
|
`manual_bounds` has no window, so window-based features don't apply.
|
|
@@ -177,9 +197,9 @@ alias for `half_life`; prefer `half_life`.)
|
|
|
177
197
|
|
|
178
198
|
Every parameter that affects results (threshold, window_size, min_samples,
|
|
179
199
|
seasonality_components, min_samples_per_group, input_type, smoothing*,
|
|
180
|
-
window_weights, half_life, detrend) is hashed into the
|
|
181
|
-
non-default values participate. Execution params
|
|
182
|
-
are **not** hashed.
|
|
200
|
+
window_weights, half_life, detrend, stabilization) is hashed into the
|
|
201
|
+
`detector_id` — only non-default values participate. Execution params
|
|
202
|
+
(`start_time`, `batch_size`) are **not** hashed.
|
|
183
203
|
|
|
184
204
|
Changing any hashed parameter creates a new `detector_id` and recomputes that
|
|
185
205
|
detector's detections from scratch on the next run; old rows stay under the
|
|
@@ -97,6 +97,12 @@ series, watch the band recompute live, then write the config back into the metri
|
|
|
97
97
|
— use `dtk tune --select <m>`, the interactive sibling of `dtk autotune`
|
|
98
98
|
(`cli.md`).
|
|
99
99
|
|
|
100
|
+
For a **live**, project-wide view instead of a one-off file, run `dtk ui`: the
|
|
101
|
+
same reports in a browser overlay plus an overview table (alert frequency,
|
|
102
|
+
freshness, quality when incidents are labeled), a panel that drives
|
|
103
|
+
`dtk run` / `dtk autotune` / `dtk unlock`, and an in-browser editor to create,
|
|
104
|
+
edit and delete metric YAML files. See `cli.md`.
|
|
105
|
+
|
|
100
106
|
## Glossary
|
|
101
107
|
|
|
102
108
|
- **metric** — a named time series (SQL + interval) you monitor.
|
{detectkit-0.49.2 → detectkit-0.51.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md
RENAMED
|
@@ -15,6 +15,14 @@ need detail on any field, read the matching file under
|
|
|
15
15
|
`.claude/rules/detectkit/` (`metrics.md`, `detectors.md`, `alerting.md`,
|
|
16
16
|
`project.md`); this skill is the procedure, those are the reference.
|
|
17
17
|
|
|
18
|
+
`dtk ui` also has a browser editor for metric YAMLs (a **New metric** button
|
|
19
|
+
and, per row, an **Edit** action), validated the same way before it writes.
|
|
20
|
+
Prefer this skill when scaffolding from scratch — it gathers the query,
|
|
21
|
+
detector and alerting choices and explains each one in context; prefer the UI
|
|
22
|
+
for a quick manual edit while you're already watching the cockpit (nudging a
|
|
23
|
+
threshold, adding a channel) — it's a raw-YAML editor with no procedure behind
|
|
24
|
+
it.
|
|
25
|
+
|
|
18
26
|
## Step 0 — Confirm you're in a detectkit project
|
|
19
27
|
|
|
20
28
|
A project root contains `detectkit_project.yml`. Verify it exists in the target
|
|
@@ -101,7 +101,10 @@ Walk the user to the mode that fits what they want to do:
|
|
|
101
101
|
**window size**, **recency weighting + half-life**, **detrend**, **smoothing**,
|
|
102
102
|
and **seasonality groups**; the band + anomalies + would-fire alerts recompute
|
|
103
103
|
on every change. The window-size / half-life readouts show the equivalent
|
|
104
|
-
wall-clock span.
|
|
104
|
+
wall-clock span. **Stabilization** (none / clamp) clamps a flagged point to
|
|
105
|
+
the bound it violated in later windows' statistics — try it when a sustained
|
|
106
|
+
incident visibly balloons the band mid-incident and the detector stops
|
|
107
|
+
flagging its tail. This is the core "turn the knobs yourself" loop.
|
|
105
108
|
|
|
106
109
|
- **Autotune** — *let the engine search, then refine.* Click **Run autotune** and
|
|
107
110
|
the **same engine `dtk autotune` uses** (seasonality → detector → grid → window,
|
|
@@ -890,8 +890,9 @@ class MetricConfig(BaseModel):
|
|
|
890
890
|
if not data:
|
|
891
891
|
raise ValueError(f"Empty metric config file: {path}")
|
|
892
892
|
|
|
893
|
-
# Support nested structure: metric: { ... }
|
|
894
|
-
|
|
895
|
-
|
|
893
|
+
# Support nested structure: metric: { ... } (shared unwrap seam)
|
|
894
|
+
from detectkit.config.metric_io import unwrap_metric_mapping
|
|
895
|
+
|
|
896
|
+
data = unwrap_metric_mapping(data)
|
|
896
897
|
|
|
897
898
|
return cls.model_validate(data)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Shared low-level seams for metric YAML files.
|
|
2
|
+
|
|
3
|
+
Three tiny helpers that were previously re-implemented per caller and had
|
|
4
|
+
already started to drift (``tuning/config_writer.py``'s archive write had no
|
|
5
|
+
same-second collision handling; the nested-form unwrap lived in four copies):
|
|
6
|
+
|
|
7
|
+
- :func:`unwrap_metric_mapping` — the nested ``metric: {...}`` form accepted
|
|
8
|
+
everywhere a metric YAML is read (``MetricConfig.from_yaml_file``, the tune
|
|
9
|
+
write-back, the ``dtk ui`` editor).
|
|
10
|
+
- :func:`safe_metric_stem` — a metric name reduced to one safe path component
|
|
11
|
+
(filenames, ``.history`` archive keys).
|
|
12
|
+
- :func:`archive_metric_text` — the ``metrics/.history/<metric>/`` verbatim
|
|
13
|
+
archive convention shared by ``dtk tune``'s Apply and ``dtk ui``'s
|
|
14
|
+
update/delete, collision-safe within one UTC second.
|
|
15
|
+
|
|
16
|
+
No imports from the rest of detectkit — safe to use from any layer.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
_SAFE_CHAR_RE = re.compile(r"[A-Za-z0-9_.\-]")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def unwrap_metric_mapping(data: dict[str, Any]) -> dict[str, Any]:
|
|
30
|
+
"""The metric body of a parsed YAML mapping — unwraps the nested ``metric: {...}`` form.
|
|
31
|
+
|
|
32
|
+
Returns the **same object reference** for the flat form and the nested
|
|
33
|
+
body for the wrapped form, so in-place mutations (the tune write-back
|
|
34
|
+
edits the body inside the original document) behave identically.
|
|
35
|
+
"""
|
|
36
|
+
nested = data.get("metric")
|
|
37
|
+
return nested if isinstance(nested, dict) else data
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def safe_metric_stem(name: str) -> str:
|
|
41
|
+
"""A single safe path component for a metric name — sanitized, never refused.
|
|
42
|
+
|
|
43
|
+
``MetricConfig`` accepts names (unicode letters, a leading ``-``) that make
|
|
44
|
+
unsafe or awkward filenames; rather than rejecting a valid metric, every
|
|
45
|
+
character outside the safe set is replaced with ``_`` and leading dots or
|
|
46
|
+
dashes are stripped. Path separators can never survive, so the result is
|
|
47
|
+
always one component; a same-stem collision surfaces as "file already
|
|
48
|
+
exists". Used for created files' stems and ``.history`` archive keys.
|
|
49
|
+
"""
|
|
50
|
+
stem = "".join(c if _SAFE_CHAR_RE.fullmatch(c) else "_" for c in name)
|
|
51
|
+
stem = stem.lstrip(".-")
|
|
52
|
+
return stem or "metric"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def metric_stamp(now: datetime | None = None) -> str:
|
|
56
|
+
"""UTC filesystem-safe timestamp (``20260709T101530Z``) used in archive filenames."""
|
|
57
|
+
now = now or datetime.now(timezone.utc)
|
|
58
|
+
return now.strftime("%Y%m%dT%H%M%SZ")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def archive_metric_text(
|
|
62
|
+
project_root: Path,
|
|
63
|
+
metric_name: str,
|
|
64
|
+
text: str,
|
|
65
|
+
*,
|
|
66
|
+
suffix: str = "",
|
|
67
|
+
stamp: str | None = None,
|
|
68
|
+
now: datetime | None = None,
|
|
69
|
+
) -> Path:
|
|
70
|
+
"""Write *text* verbatim into ``metrics/.history/<key>/`` and return the path.
|
|
71
|
+
|
|
72
|
+
The directory key is the **sanitized** metric name (:func:`safe_metric_stem`)
|
|
73
|
+
— at archive time the name can be attacker/editor-influenced free text
|
|
74
|
+
(an on-disk ``name:`` changed after boot), and joining it raw into a path
|
|
75
|
+
would let ``../`` or an absolute component escape ``metrics/.history/``.
|
|
76
|
+
Two archives within the same UTC second get ``-1``, ``-2``, … suffixes so
|
|
77
|
+
a tune Apply and a UI save landing together never overwrite each other's
|
|
78
|
+
snapshot.
|
|
79
|
+
"""
|
|
80
|
+
key = safe_metric_stem(metric_name)
|
|
81
|
+
archive_dir = project_root / "metrics" / ".history" / key
|
|
82
|
+
archive_dir.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
base = f"{key}-{stamp or metric_stamp(now)}{suffix}"
|
|
84
|
+
archive_path = archive_dir / f"{base}.yml"
|
|
85
|
+
counter = 1
|
|
86
|
+
while archive_path.exists():
|
|
87
|
+
archive_path = archive_dir / f"{base}-{counter}.yml"
|
|
88
|
+
counter += 1
|
|
89
|
+
archive_path.write_text(text, encoding="utf-8")
|
|
90
|
+
return archive_path
|
|
@@ -48,6 +48,7 @@ _CHANGE_INPUT_TYPES = {"changes", "absolute_changes", "log_changes"}
|
|
|
48
48
|
_SMOOTHING_METHODS = {None, "ema", "sma"}
|
|
49
49
|
_WEIGHT_METHODS = {None, "exponential", "linear"}
|
|
50
50
|
_DETREND_METHODS = {None, "linear"}
|
|
51
|
+
_STABILIZATION_METHODS = {None, "clamp"}
|
|
51
52
|
|
|
52
53
|
|
|
53
54
|
class WindowedStatDetector(BaseDetector):
|
|
@@ -88,6 +89,18 @@ class WindowedStatDetector(BaseDetector):
|
|
|
88
89
|
compute statistics on values projected to the current point.
|
|
89
90
|
Recommended for metrics with a gradual trend so the slow
|
|
90
91
|
drift itself is not flagged as anomalous.
|
|
92
|
+
stabilization (str | None): None (default) or "clamp" — when a
|
|
93
|
+
point is flagged anomalous, subsequent trailing windows see
|
|
94
|
+
the confidence bound it violated (winsorized value) instead
|
|
95
|
+
of the observed value. Keeps a long incident from poisoning
|
|
96
|
+
the baseline (inflating spread / dragging the center toward
|
|
97
|
+
the incident), so the detector keeps flagging it instead of
|
|
98
|
+
adapting to it. Clamping to the bound (not the center) is
|
|
99
|
+
deliberate: substituting the band center feeds zero-deviation
|
|
100
|
+
points back into the spread statistics, which collapses the
|
|
101
|
+
band and cascades into false flags after a long incident.
|
|
102
|
+
The scored/persisted values are unchanged — only the
|
|
103
|
+
statistics windows read the substituted history.
|
|
91
104
|
|
|
92
105
|
All parameters that change detection output participate in the
|
|
93
106
|
detector ID hash (only non-default values are hashed).
|
|
@@ -124,6 +137,7 @@ class WindowedStatDetector(BaseDetector):
|
|
|
124
137
|
half_life: int | str | None = None,
|
|
125
138
|
weight_decay: float | None = None,
|
|
126
139
|
detrend: str | None = None,
|
|
140
|
+
stabilization: str | None = None,
|
|
127
141
|
):
|
|
128
142
|
super().__init__(
|
|
129
143
|
threshold=self.THRESHOLD_DEFAULT if threshold is None else threshold,
|
|
@@ -143,6 +157,7 @@ class WindowedStatDetector(BaseDetector):
|
|
|
143
157
|
half_life=half_life,
|
|
144
158
|
weight_decay=weight_decay,
|
|
145
159
|
detrend=detrend,
|
|
160
|
+
stabilization=stabilization,
|
|
146
161
|
)
|
|
147
162
|
# Whether the one-time "seasonality groups can't fill this window" check
|
|
148
163
|
# has run for this instance (so the warning fires at most once per run,
|
|
@@ -235,6 +250,10 @@ class WindowedStatDetector(BaseDetector):
|
|
|
235
250
|
if detrend not in _DETREND_METHODS:
|
|
236
251
|
raise ValueError(f"Unknown detrend method: {detrend}. Supported: linear")
|
|
237
252
|
|
|
253
|
+
stabilization = p.get("stabilization")
|
|
254
|
+
if stabilization not in _STABILIZATION_METHODS:
|
|
255
|
+
raise ValueError(f"Unknown stabilization method: {stabilization}. Supported: clamp")
|
|
256
|
+
|
|
238
257
|
# ------------------------------------------------------------------
|
|
239
258
|
# Identity
|
|
240
259
|
# ------------------------------------------------------------------
|
|
@@ -254,6 +273,7 @@ class WindowedStatDetector(BaseDetector):
|
|
|
254
273
|
"half_life": None,
|
|
255
274
|
"weight_decay": None,
|
|
256
275
|
"detrend": None,
|
|
276
|
+
"stabilization": None,
|
|
257
277
|
}
|
|
258
278
|
|
|
259
279
|
def _get_non_default_params(self) -> dict[str, Any]:
|
|
@@ -291,6 +311,15 @@ class WindowedStatDetector(BaseDetector):
|
|
|
291
311
|
if self.params.get("input_type", "values") in _CHANGE_INPUT_TYPES:
|
|
292
312
|
context += 1
|
|
293
313
|
|
|
314
|
+
if self.params.get("stabilization"):
|
|
315
|
+
# A point's substituted-history state depends on whether its own
|
|
316
|
+
# window points were flagged, which depends on THEIR windows. One
|
|
317
|
+
# extra window of warm-up lets every context point be scored on a
|
|
318
|
+
# full window, so incremental batches reproduce the same
|
|
319
|
+
# substitution history as a continuous run (residual differences
|
|
320
|
+
# decay geometrically past that).
|
|
321
|
+
context += int(self.params.get("window_size", 0) or 0)
|
|
322
|
+
|
|
294
323
|
return context
|
|
295
324
|
|
|
296
325
|
def _resolve_half_life_points(self, timestamps: np.ndarray) -> float:
|
|
@@ -463,12 +492,22 @@ class WindowedStatDetector(BaseDetector):
|
|
|
463
492
|
"min_samples_per_group", self.MIN_SAMPLES_PER_GROUP_DEFAULT
|
|
464
493
|
)
|
|
465
494
|
detrend = self.params.get("detrend")
|
|
495
|
+
stabilization = self.params.get("stabilization")
|
|
466
496
|
weighted = self.params.get("window_weights") is not None
|
|
467
497
|
|
|
468
498
|
# STEP 0: Preprocessing (smoothing first, then input_type)
|
|
469
499
|
smoothed_values = self._apply_smoothing(values)
|
|
470
500
|
processed_values = self._preprocess_input(smoothed_values)
|
|
471
501
|
|
|
502
|
+
# Stabilization (opt-in): statistics windows read from a working copy
|
|
503
|
+
# where every previously-flagged point is clamped to the confidence
|
|
504
|
+
# bound it violated, so an ongoing incident cannot inflate the spread
|
|
505
|
+
# or drag the center toward itself. The scored value and the persisted
|
|
506
|
+
# processed_value stay the raw observations; genuinely missing (NaN)
|
|
507
|
+
# points are never substituted.
|
|
508
|
+
work_values = processed_values.copy() if stabilization else processed_values
|
|
509
|
+
replaced = np.zeros(len(processed_values), dtype=bool) if stabilization else None
|
|
510
|
+
|
|
472
511
|
seasonality_dict = {}
|
|
473
512
|
if seasonality_components and len(seasonality_data) > 0 and seasonality_columns:
|
|
474
513
|
seasonality_dict = parse_seasonality_data(seasonality_data, seasonality_columns)
|
|
@@ -498,9 +537,11 @@ class WindowedStatDetector(BaseDetector):
|
|
|
498
537
|
)
|
|
499
538
|
continue
|
|
500
539
|
|
|
501
|
-
# Trailing window, current point excluded
|
|
540
|
+
# Trailing window, current point excluded. Both the global slice
|
|
541
|
+
# and the seasonality-group slices below read window_processed, so
|
|
542
|
+
# sourcing it from work_values covers every statistics consumer.
|
|
502
543
|
window_start = max(0, i - window_size)
|
|
503
|
-
window_processed =
|
|
544
|
+
window_processed = work_values[window_start:i]
|
|
504
545
|
valid_mask = ~np.isnan(window_processed)
|
|
505
546
|
window_valid = window_processed[valid_mask]
|
|
506
547
|
|
|
@@ -586,6 +627,14 @@ class WindowedStatDetector(BaseDetector):
|
|
|
586
627
|
current_processed > confidence_upper
|
|
587
628
|
)
|
|
588
629
|
|
|
630
|
+
# Stabilization write-back: later windows see this point clamped
|
|
631
|
+
# to the bound it violated, not the anomalous observation.
|
|
632
|
+
if stabilization == "clamp" and is_anomaly:
|
|
633
|
+
work_values[i] = (
|
|
634
|
+
confidence_lower if current_processed < confidence_lower else confidence_upper
|
|
635
|
+
)
|
|
636
|
+
replaced[i] = True # type: ignore[index]
|
|
637
|
+
|
|
589
638
|
# STEP 5: Metadata
|
|
590
639
|
metadata: dict[str, Any] = {}
|
|
591
640
|
for name, _ in self.STATS:
|
|
@@ -598,6 +647,10 @@ class WindowedStatDetector(BaseDetector):
|
|
|
598
647
|
metadata["ess"] = round(effective_sample_size(weights), 1)
|
|
599
648
|
if detrend == "linear":
|
|
600
649
|
metadata["trend_slope_per_point"] = float(slope)
|
|
650
|
+
if replaced is not None:
|
|
651
|
+
n_replaced = int(np.count_nonzero(replaced[window_start:i]))
|
|
652
|
+
if n_replaced:
|
|
653
|
+
metadata["stabilized_in_window"] = n_replaced
|
|
601
654
|
|
|
602
655
|
if self.params.get("smoothing") or self.params.get("input_type") != "values":
|
|
603
656
|
metadata["preprocessing"] = {
|