cloudcircuit 0.2.0__tar.gz → 0.3.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.
- {cloudcircuit-0.2.0 → cloudcircuit-0.3.0}/PKG-INFO +20 -1
- {cloudcircuit-0.2.0 → cloudcircuit-0.3.0}/README.md +19 -0
- {cloudcircuit-0.2.0 → cloudcircuit-0.3.0}/pyproject.toml +1 -1
- {cloudcircuit-0.2.0 → cloudcircuit-0.3.0}/src/cloudcircuit/__init__.py +3 -1
- {cloudcircuit-0.2.0 → cloudcircuit-0.3.0}/src/cloudcircuit/safeguards.py +88 -1
- {cloudcircuit-0.2.0 → cloudcircuit-0.3.0}/tests/conftest.py +0 -1
- {cloudcircuit-0.2.0 → cloudcircuit-0.3.0}/tests/test_safeguards.py +76 -0
- {cloudcircuit-0.2.0 → cloudcircuit-0.3.0}/.github/workflows/ci.yml +0 -0
- {cloudcircuit-0.2.0 → cloudcircuit-0.3.0}/.github/workflows/publish.yml +0 -0
- {cloudcircuit-0.2.0 → cloudcircuit-0.3.0}/.gitignore +0 -0
- {cloudcircuit-0.2.0 → cloudcircuit-0.3.0}/src/cloudcircuit/py.typed +0 -0
- {cloudcircuit-0.2.0 → cloudcircuit-0.3.0}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cloudcircuit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: CloudCircuit Python package.
|
|
5
5
|
Author: CloudCircuit Contributors
|
|
6
6
|
License: MIT
|
|
@@ -68,6 +68,7 @@ python -m pip install cloudcircuit
|
|
|
68
68
|
|
|
69
69
|
```python
|
|
70
70
|
from cloudcircuit import (
|
|
71
|
+
check_anomaly_robust,
|
|
71
72
|
check_anomaly_spike,
|
|
72
73
|
check_budget,
|
|
73
74
|
check_burn_rate,
|
|
@@ -79,6 +80,7 @@ from cloudcircuit import (
|
|
|
79
80
|
|
|
80
81
|
budget = check_budget(current_spend=920.0, budget_limit=1000.0, warning_ratio=0.9)
|
|
81
82
|
anomaly = check_anomaly_spike([120.0, 118.0, 121.0, 250.0], spike_multiplier=1.6)
|
|
83
|
+
robust_anomaly = check_anomaly_robust([120.0, 118.0, 121.0, 250.0], method="mad", z_threshold=3.5)
|
|
82
84
|
burn = check_burn_rate([120.0, 118.0, 121.0, 250.0], hot_multiplier=1.4)
|
|
83
85
|
breaker = evaluate_circuit_breaker(consecutive_failures=0, failure_threshold=3)
|
|
84
86
|
forecast = forecast_budget_breach(
|
|
@@ -94,9 +96,25 @@ alert = make_alert_payload(policy, service="billing-worker", environment="prod")
|
|
|
94
96
|
print(policy.action, policy.severity)
|
|
95
97
|
print(forecast.projected_total_spend, forecast.will_breach)
|
|
96
98
|
print(burn.burn_rate_ratio, burn.is_hot)
|
|
99
|
+
print(robust_anomaly.threshold, robust_anomaly.is_spike)
|
|
97
100
|
print(alert)
|
|
98
101
|
```
|
|
99
102
|
|
|
103
|
+
## Robust anomaly detection (MAD / percentile)
|
|
104
|
+
|
|
105
|
+
Mean-based thresholds can be brittle when cloud spend is heavy-tailed or bursty. For more robust
|
|
106
|
+
detection, use `check_anomaly_robust`:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from cloudcircuit import check_anomaly_robust
|
|
110
|
+
|
|
111
|
+
mad = check_anomaly_robust([120.0, 118.0, 121.0, 250.0], method="mad", z_threshold=3.5)
|
|
112
|
+
p95 = check_anomaly_robust([120.0, 118.0, 121.0, 250.0], method="percentile", percentile=0.95)
|
|
113
|
+
|
|
114
|
+
print(mad.threshold, mad.is_spike)
|
|
115
|
+
print(p95.threshold, p95.is_spike)
|
|
116
|
+
```
|
|
117
|
+
|
|
100
118
|
## Common developer problems solved
|
|
101
119
|
|
|
102
120
|
1. **Runaway cron or queue workers**
|
|
@@ -117,6 +135,7 @@ print(alert)
|
|
|
117
135
|
|
|
118
136
|
- `check_budget(...) -> BudgetCheckResult`
|
|
119
137
|
- `check_anomaly_spike(...) -> AnomalyCheckResult`
|
|
138
|
+
- `check_anomaly_robust(...) -> AnomalyCheckResult`
|
|
120
139
|
- `check_burn_rate(...) -> BurnRateResult`
|
|
121
140
|
- `forecast_budget_breach(...) -> ForecastResult`
|
|
122
141
|
- `evaluate_circuit_breaker(...) -> CircuitBreakerDecision`
|
|
@@ -43,6 +43,7 @@ python -m pip install cloudcircuit
|
|
|
43
43
|
|
|
44
44
|
```python
|
|
45
45
|
from cloudcircuit import (
|
|
46
|
+
check_anomaly_robust,
|
|
46
47
|
check_anomaly_spike,
|
|
47
48
|
check_budget,
|
|
48
49
|
check_burn_rate,
|
|
@@ -54,6 +55,7 @@ from cloudcircuit import (
|
|
|
54
55
|
|
|
55
56
|
budget = check_budget(current_spend=920.0, budget_limit=1000.0, warning_ratio=0.9)
|
|
56
57
|
anomaly = check_anomaly_spike([120.0, 118.0, 121.0, 250.0], spike_multiplier=1.6)
|
|
58
|
+
robust_anomaly = check_anomaly_robust([120.0, 118.0, 121.0, 250.0], method="mad", z_threshold=3.5)
|
|
57
59
|
burn = check_burn_rate([120.0, 118.0, 121.0, 250.0], hot_multiplier=1.4)
|
|
58
60
|
breaker = evaluate_circuit_breaker(consecutive_failures=0, failure_threshold=3)
|
|
59
61
|
forecast = forecast_budget_breach(
|
|
@@ -69,9 +71,25 @@ alert = make_alert_payload(policy, service="billing-worker", environment="prod")
|
|
|
69
71
|
print(policy.action, policy.severity)
|
|
70
72
|
print(forecast.projected_total_spend, forecast.will_breach)
|
|
71
73
|
print(burn.burn_rate_ratio, burn.is_hot)
|
|
74
|
+
print(robust_anomaly.threshold, robust_anomaly.is_spike)
|
|
72
75
|
print(alert)
|
|
73
76
|
```
|
|
74
77
|
|
|
78
|
+
## Robust anomaly detection (MAD / percentile)
|
|
79
|
+
|
|
80
|
+
Mean-based thresholds can be brittle when cloud spend is heavy-tailed or bursty. For more robust
|
|
81
|
+
detection, use `check_anomaly_robust`:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from cloudcircuit import check_anomaly_robust
|
|
85
|
+
|
|
86
|
+
mad = check_anomaly_robust([120.0, 118.0, 121.0, 250.0], method="mad", z_threshold=3.5)
|
|
87
|
+
p95 = check_anomaly_robust([120.0, 118.0, 121.0, 250.0], method="percentile", percentile=0.95)
|
|
88
|
+
|
|
89
|
+
print(mad.threshold, mad.is_spike)
|
|
90
|
+
print(p95.threshold, p95.is_spike)
|
|
91
|
+
```
|
|
92
|
+
|
|
75
93
|
## Common developer problems solved
|
|
76
94
|
|
|
77
95
|
1. **Runaway cron or queue workers**
|
|
@@ -92,6 +110,7 @@ print(alert)
|
|
|
92
110
|
|
|
93
111
|
- `check_budget(...) -> BudgetCheckResult`
|
|
94
112
|
- `check_anomaly_spike(...) -> AnomalyCheckResult`
|
|
113
|
+
- `check_anomaly_robust(...) -> AnomalyCheckResult`
|
|
95
114
|
- `check_burn_rate(...) -> BurnRateResult`
|
|
96
115
|
- `forecast_budget_breach(...) -> ForecastResult`
|
|
97
116
|
- `evaluate_circuit_breaker(...) -> CircuitBreakerDecision`
|
|
@@ -7,6 +7,7 @@ from cloudcircuit.safeguards import (
|
|
|
7
7
|
CircuitBreakerDecision,
|
|
8
8
|
ForecastResult,
|
|
9
9
|
PolicyDecision,
|
|
10
|
+
check_anomaly_robust,
|
|
10
11
|
check_anomaly_spike,
|
|
11
12
|
check_budget,
|
|
12
13
|
check_burn_rate,
|
|
@@ -16,7 +17,7 @@ from cloudcircuit.safeguards import (
|
|
|
16
17
|
make_alert_payload,
|
|
17
18
|
)
|
|
18
19
|
|
|
19
|
-
__version__ = "0.
|
|
20
|
+
__version__ = "0.3.0"
|
|
20
21
|
__all__ = [
|
|
21
22
|
"__version__",
|
|
22
23
|
"AnomalyCheckResult",
|
|
@@ -25,6 +26,7 @@ __all__ = [
|
|
|
25
26
|
"CircuitBreakerDecision",
|
|
26
27
|
"ForecastResult",
|
|
27
28
|
"PolicyDecision",
|
|
29
|
+
"check_anomaly_robust",
|
|
28
30
|
"check_anomaly_spike",
|
|
29
31
|
"check_budget",
|
|
30
32
|
"check_burn_rate",
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from collections.abc import Sequence
|
|
5
6
|
from dataclasses import dataclass
|
|
6
|
-
from
|
|
7
|
+
from math import ceil, floor
|
|
8
|
+
from typing import Literal
|
|
7
9
|
|
|
8
10
|
|
|
9
11
|
@dataclass(frozen=True, slots=True)
|
|
@@ -123,6 +125,91 @@ def check_anomaly_spike(
|
|
|
123
125
|
)
|
|
124
126
|
|
|
125
127
|
|
|
128
|
+
def _median(values: Sequence[float]) -> float:
|
|
129
|
+
if not values:
|
|
130
|
+
raise ValueError("values must be non-empty")
|
|
131
|
+
sorted_values = sorted(float(value) for value in values)
|
|
132
|
+
mid = len(sorted_values) // 2
|
|
133
|
+
if len(sorted_values) % 2 == 1:
|
|
134
|
+
return sorted_values[mid]
|
|
135
|
+
return (sorted_values[mid - 1] + sorted_values[mid]) / 2.0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _quantile(values: Sequence[float], p: float) -> float:
|
|
139
|
+
if not values:
|
|
140
|
+
raise ValueError("values must be non-empty")
|
|
141
|
+
if not (0.0 < p < 1.0):
|
|
142
|
+
raise ValueError("p must be in (0, 1)")
|
|
143
|
+
sorted_values = sorted(float(value) for value in values)
|
|
144
|
+
if len(sorted_values) == 1:
|
|
145
|
+
return sorted_values[0]
|
|
146
|
+
rank = p * (len(sorted_values) - 1)
|
|
147
|
+
lo = int(floor(rank))
|
|
148
|
+
hi = int(ceil(rank))
|
|
149
|
+
if lo == hi:
|
|
150
|
+
return sorted_values[lo]
|
|
151
|
+
weight = rank - lo
|
|
152
|
+
return sorted_values[lo] + weight * (sorted_values[hi] - sorted_values[lo])
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def check_anomaly_robust(
|
|
156
|
+
spend_series: Sequence[float],
|
|
157
|
+
*,
|
|
158
|
+
method: Literal["mad", "percentile"] = "mad",
|
|
159
|
+
z_threshold: float = 3.5,
|
|
160
|
+
min_mad: float = 0.0,
|
|
161
|
+
percentile: float = 0.95,
|
|
162
|
+
min_baseline: float = 0.0,
|
|
163
|
+
) -> AnomalyCheckResult:
|
|
164
|
+
"""
|
|
165
|
+
Robust spend spike detection using MAD or percentile thresholding.
|
|
166
|
+
|
|
167
|
+
- MAD method uses median + z * (MAD / 0.6745) as a robust z-score threshold.
|
|
168
|
+
- Percentile method uses a historical percentile as the spike threshold.
|
|
169
|
+
|
|
170
|
+
Baseline is computed from all values except the latest.
|
|
171
|
+
"""
|
|
172
|
+
if len(spend_series) < 2:
|
|
173
|
+
raise ValueError("spend_series must contain at least 2 points")
|
|
174
|
+
if any(value < 0 for value in spend_series):
|
|
175
|
+
raise ValueError("spend_series values must be >= 0")
|
|
176
|
+
if min_baseline < 0:
|
|
177
|
+
raise ValueError("min_baseline must be >= 0")
|
|
178
|
+
|
|
179
|
+
if method == "mad":
|
|
180
|
+
if z_threshold <= 0:
|
|
181
|
+
raise ValueError("z_threshold must be > 0")
|
|
182
|
+
if min_mad < 0:
|
|
183
|
+
raise ValueError("min_mad must be >= 0")
|
|
184
|
+
elif method == "percentile":
|
|
185
|
+
if not (0.0 < percentile < 1.0):
|
|
186
|
+
raise ValueError("percentile must be in (0, 1)")
|
|
187
|
+
else:
|
|
188
|
+
raise ValueError("method must be 'mad' or 'percentile'")
|
|
189
|
+
|
|
190
|
+
latest_spend = float(spend_series[-1])
|
|
191
|
+
history = spend_series[:-1]
|
|
192
|
+
|
|
193
|
+
if method == "mad":
|
|
194
|
+
baseline_location = _median(history)
|
|
195
|
+
abs_deviations = [abs(float(value) - baseline_location) for value in history]
|
|
196
|
+
mad = max(_median(abs_deviations), float(min_mad))
|
|
197
|
+
effective_location = max(baseline_location, float(min_baseline))
|
|
198
|
+
robust_sigma = mad / 0.6745 if mad > 0 else 0.0
|
|
199
|
+
threshold = effective_location + (float(z_threshold) * robust_sigma)
|
|
200
|
+
else:
|
|
201
|
+
baseline_location = _quantile(history, float(percentile))
|
|
202
|
+
threshold = max(baseline_location, float(min_baseline))
|
|
203
|
+
|
|
204
|
+
is_spike = latest_spend > threshold
|
|
205
|
+
return AnomalyCheckResult(
|
|
206
|
+
latest_spend=latest_spend,
|
|
207
|
+
baseline_mean=float(baseline_location),
|
|
208
|
+
threshold=float(threshold),
|
|
209
|
+
is_spike=is_spike,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
126
213
|
def evaluate_circuit_breaker(
|
|
127
214
|
consecutive_failures: int,
|
|
128
215
|
failure_threshold: int = 3,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import pytest
|
|
2
2
|
|
|
3
3
|
from cloudcircuit.safeguards import (
|
|
4
|
+
check_anomaly_robust,
|
|
4
5
|
check_anomaly_spike,
|
|
5
6
|
check_budget,
|
|
6
7
|
check_burn_rate,
|
|
@@ -60,6 +61,81 @@ def test_anomaly_invalid_inputs() -> None:
|
|
|
60
61
|
check_anomaly_spike([1.0, -1.0], spike_multiplier=2.0)
|
|
61
62
|
|
|
62
63
|
|
|
64
|
+
def test_anomaly_robust_mad_detects_spike_with_zero_mad() -> None:
|
|
65
|
+
result = check_anomaly_robust([10.0, 10.0, 10.0, 25.0], method="mad", z_threshold=3.5)
|
|
66
|
+
assert result.baseline_mean == 10.0
|
|
67
|
+
assert result.threshold == 10.0
|
|
68
|
+
assert result.is_spike is True
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_anomaly_robust_mad_not_spike_on_stable_series() -> None:
|
|
72
|
+
result = check_anomaly_robust([10.0, 10.0, 10.0, 10.0], method="mad")
|
|
73
|
+
assert result.baseline_mean == 10.0
|
|
74
|
+
assert result.threshold == 10.0
|
|
75
|
+
assert result.is_spike is False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_anomaly_robust_mad_detects_spike_with_nonzero_mad() -> None:
|
|
79
|
+
result = check_anomaly_robust([10.0, 10.0, 11.0, 10.0, 12.0, 11.0, 50.0], method="mad")
|
|
80
|
+
assert result.baseline_mean == 10.5
|
|
81
|
+
assert 0.0 < result.threshold < 50.0
|
|
82
|
+
assert result.is_spike is True
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_anomaly_robust_percentile_detects_spike() -> None:
|
|
86
|
+
result = check_anomaly_robust(
|
|
87
|
+
[10.0, 10.0, 10.0, 10.0, 50.0],
|
|
88
|
+
method="percentile",
|
|
89
|
+
percentile=0.95,
|
|
90
|
+
)
|
|
91
|
+
assert result.baseline_mean == 10.0
|
|
92
|
+
assert result.threshold == 10.0
|
|
93
|
+
assert result.is_spike is True
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_anomaly_robust_percentile_not_spike() -> None:
|
|
97
|
+
result = check_anomaly_robust(
|
|
98
|
+
[10.0, 12.0, 11.0, 13.0, 12.8],
|
|
99
|
+
method="percentile",
|
|
100
|
+
percentile=0.95,
|
|
101
|
+
)
|
|
102
|
+
assert result.is_spike is False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_anomaly_robust_invalid_inputs() -> None:
|
|
106
|
+
with pytest.raises(ValueError):
|
|
107
|
+
check_anomaly_robust([1.0], method="mad")
|
|
108
|
+
with pytest.raises(ValueError):
|
|
109
|
+
check_anomaly_robust([1.0, -1.0], method="mad")
|
|
110
|
+
with pytest.raises(ValueError):
|
|
111
|
+
check_anomaly_robust([1.0, 2.0], method="unknown") # type: ignore[arg-type]
|
|
112
|
+
|
|
113
|
+
with pytest.raises(ValueError):
|
|
114
|
+
check_anomaly_robust([1.0, 2.0], method="mad", z_threshold=0.0)
|
|
115
|
+
with pytest.raises(ValueError):
|
|
116
|
+
check_anomaly_robust([1.0, 2.0], method="mad", min_mad=-0.1)
|
|
117
|
+
with pytest.raises(ValueError):
|
|
118
|
+
check_anomaly_robust([1.0, 2.0], method="mad", min_baseline=-0.1)
|
|
119
|
+
|
|
120
|
+
with pytest.raises(ValueError):
|
|
121
|
+
check_anomaly_robust([1.0, 2.0], method="percentile", percentile=1.0)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_anomaly_robust_min_baseline_floor() -> None:
|
|
125
|
+
mad = check_anomaly_robust([0.0, 0.0, 0.0, 0.4], method="mad", min_baseline=0.5)
|
|
126
|
+
assert mad.threshold == 0.5
|
|
127
|
+
assert mad.is_spike is False
|
|
128
|
+
|
|
129
|
+
pct = check_anomaly_robust(
|
|
130
|
+
[0.0, 0.0, 0.0, 0.4],
|
|
131
|
+
method="percentile",
|
|
132
|
+
percentile=0.95,
|
|
133
|
+
min_baseline=0.5,
|
|
134
|
+
)
|
|
135
|
+
assert pct.threshold == 0.5
|
|
136
|
+
assert pct.is_spike is False
|
|
137
|
+
|
|
138
|
+
|
|
63
139
|
def test_circuit_breaker_allows_operation_below_threshold() -> None:
|
|
64
140
|
decision = evaluate_circuit_breaker(consecutive_failures=2, failure_threshold=3)
|
|
65
141
|
assert decision.is_open is False
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|