cloudcircuit 0.2.0__tar.gz → 0.3.7__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.7}/PKG-INFO +22 -1
- {cloudcircuit-0.2.0 → cloudcircuit-0.3.7}/README.md +144 -123
- cloudcircuit-0.3.7/assets/images/banner.svg +7 -0
- cloudcircuit-0.3.7/assets/images/concept.svg +13 -0
- {cloudcircuit-0.2.0 → cloudcircuit-0.3.7}/pyproject.toml +2 -1
- {cloudcircuit-0.2.0 → cloudcircuit-0.3.7}/src/cloudcircuit/__init__.py +3 -1
- {cloudcircuit-0.2.0 → cloudcircuit-0.3.7}/src/cloudcircuit/safeguards.py +88 -1
- {cloudcircuit-0.2.0 → cloudcircuit-0.3.7}/tests/conftest.py +0 -1
- {cloudcircuit-0.2.0 → cloudcircuit-0.3.7}/tests/test_safeguards.py +76 -0
- {cloudcircuit-0.2.0 → cloudcircuit-0.3.7}/.github/workflows/ci.yml +0 -0
- {cloudcircuit-0.2.0 → cloudcircuit-0.3.7}/.github/workflows/publish.yml +0 -0
- {cloudcircuit-0.2.0 → cloudcircuit-0.3.7}/.gitignore +0 -0
- {cloudcircuit-0.2.0 → cloudcircuit-0.3.7}/src/cloudcircuit/py.typed +0 -0
- {cloudcircuit-0.2.0 → cloudcircuit-0.3.7}/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.7
|
|
4
4
|
Summary: CloudCircuit Python package.
|
|
5
5
|
Author: CloudCircuit Contributors
|
|
6
6
|
License: MIT
|
|
@@ -23,6 +23,8 @@ Requires-Dist: ruff>=0.6.9; extra == 'dev'
|
|
|
23
23
|
Requires-Dist: twine>=5.1.1; extra == 'dev'
|
|
24
24
|
Description-Content-Type: text/markdown
|
|
25
25
|
|
|
26
|
+

|
|
27
|
+
|
|
26
28
|
# cloudcircuit
|
|
27
29
|
|
|
28
30
|
CloudCircuit is a Python library for **cloud cost control**, **real-time spend anomaly detection**, **budget overrun prevention**, and **circuit-breaker style safety automation**.
|
|
@@ -68,6 +70,7 @@ python -m pip install cloudcircuit
|
|
|
68
70
|
|
|
69
71
|
```python
|
|
70
72
|
from cloudcircuit import (
|
|
73
|
+
check_anomaly_robust,
|
|
71
74
|
check_anomaly_spike,
|
|
72
75
|
check_budget,
|
|
73
76
|
check_burn_rate,
|
|
@@ -79,6 +82,7 @@ from cloudcircuit import (
|
|
|
79
82
|
|
|
80
83
|
budget = check_budget(current_spend=920.0, budget_limit=1000.0, warning_ratio=0.9)
|
|
81
84
|
anomaly = check_anomaly_spike([120.0, 118.0, 121.0, 250.0], spike_multiplier=1.6)
|
|
85
|
+
robust_anomaly = check_anomaly_robust([120.0, 118.0, 121.0, 250.0], method="mad", z_threshold=3.5)
|
|
82
86
|
burn = check_burn_rate([120.0, 118.0, 121.0, 250.0], hot_multiplier=1.4)
|
|
83
87
|
breaker = evaluate_circuit_breaker(consecutive_failures=0, failure_threshold=3)
|
|
84
88
|
forecast = forecast_budget_breach(
|
|
@@ -94,9 +98,25 @@ alert = make_alert_payload(policy, service="billing-worker", environment="prod")
|
|
|
94
98
|
print(policy.action, policy.severity)
|
|
95
99
|
print(forecast.projected_total_spend, forecast.will_breach)
|
|
96
100
|
print(burn.burn_rate_ratio, burn.is_hot)
|
|
101
|
+
print(robust_anomaly.threshold, robust_anomaly.is_spike)
|
|
97
102
|
print(alert)
|
|
98
103
|
```
|
|
99
104
|
|
|
105
|
+
## Robust anomaly detection (MAD / percentile)
|
|
106
|
+
|
|
107
|
+
Mean-based thresholds can be brittle when cloud spend is heavy-tailed or bursty. For more robust
|
|
108
|
+
detection, use `check_anomaly_robust`:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from cloudcircuit import check_anomaly_robust
|
|
112
|
+
|
|
113
|
+
mad = check_anomaly_robust([120.0, 118.0, 121.0, 250.0], method="mad", z_threshold=3.5)
|
|
114
|
+
p95 = check_anomaly_robust([120.0, 118.0, 121.0, 250.0], method="percentile", percentile=0.95)
|
|
115
|
+
|
|
116
|
+
print(mad.threshold, mad.is_spike)
|
|
117
|
+
print(p95.threshold, p95.is_spike)
|
|
118
|
+
```
|
|
119
|
+
|
|
100
120
|
## Common developer problems solved
|
|
101
121
|
|
|
102
122
|
1. **Runaway cron or queue workers**
|
|
@@ -117,6 +137,7 @@ print(alert)
|
|
|
117
137
|
|
|
118
138
|
- `check_budget(...) -> BudgetCheckResult`
|
|
119
139
|
- `check_anomaly_spike(...) -> AnomalyCheckResult`
|
|
140
|
+
- `check_anomaly_robust(...) -> AnomalyCheckResult`
|
|
120
141
|
- `check_burn_rate(...) -> BurnRateResult`
|
|
121
142
|
- `forecast_budget_breach(...) -> ForecastResult`
|
|
122
143
|
- `evaluate_circuit_breaker(...) -> CircuitBreakerDecision`
|
|
@@ -1,124 +1,145 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
- **
|
|
30
|
-
- **
|
|
31
|
-
- **
|
|
32
|
-
- **
|
|
33
|
-
- **
|
|
34
|
-
- **
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
-
|
|
99
|
-
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
-
|
|
117
|
-
-
|
|
118
|
-
-
|
|
119
|
-
-
|
|
120
|
-
-
|
|
121
|
-
|
|
122
|
-
##
|
|
123
|
-
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
# cloudcircuit
|
|
4
|
+
|
|
5
|
+
CloudCircuit is a Python library for **cloud cost control**, **real-time spend anomaly detection**, **budget overrun prevention**, and **circuit-breaker style safety automation**.
|
|
6
|
+
|
|
7
|
+
If you are searching for solutions like:
|
|
8
|
+
- prevent unexpected cloud bills
|
|
9
|
+
- detect cloud spend spikes
|
|
10
|
+
- add budget guardrails in Python
|
|
11
|
+
- implement FinOps automation for developers
|
|
12
|
+
- stop runaway infrastructure loops
|
|
13
|
+
|
|
14
|
+
this package is built for that exact problem set.
|
|
15
|
+
|
|
16
|
+
## Why this library exists
|
|
17
|
+
|
|
18
|
+
Many teams discover cloud overspend only after invoices land. A single misconfigured loop, unbounded worker fan-out, or retry storm can create massive costs in hours.
|
|
19
|
+
|
|
20
|
+
CloudCircuit provides deterministic primitives so you can add spend safety in:
|
|
21
|
+
- background jobs
|
|
22
|
+
- CI/CD workflows
|
|
23
|
+
- internal developer platforms
|
|
24
|
+
- API middleware
|
|
25
|
+
- scheduled FinOps monitors
|
|
26
|
+
|
|
27
|
+
## Features
|
|
28
|
+
|
|
29
|
+
- **Budget guardrails**: warning and hard-breach checks with explicit thresholds
|
|
30
|
+
- **Spend anomaly detection**: latest-point spike detection against baseline
|
|
31
|
+
- **Burn-rate monitoring**: detect hot spend acceleration before full breach
|
|
32
|
+
- **Forecasting**: project period-end spend and overrun amount
|
|
33
|
+
- **Circuit breaker decisions**: open/close logic for high-risk operations
|
|
34
|
+
- **Policy engine**: combine budget + anomaly + breaker into `allow/throttle/block`
|
|
35
|
+
- **Alert payload builder**: provider-agnostic payloads for Slack/webhooks/ops pipelines
|
|
36
|
+
- **Typed API**: dataclass results with clear fields for logs and audits
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
python -m pip install cloudcircuit
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quickstart
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from cloudcircuit import (
|
|
48
|
+
check_anomaly_robust,
|
|
49
|
+
check_anomaly_spike,
|
|
50
|
+
check_budget,
|
|
51
|
+
check_burn_rate,
|
|
52
|
+
evaluate_circuit_breaker,
|
|
53
|
+
evaluate_spend_policy,
|
|
54
|
+
forecast_budget_breach,
|
|
55
|
+
make_alert_payload,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
budget = check_budget(current_spend=920.0, budget_limit=1000.0, warning_ratio=0.9)
|
|
59
|
+
anomaly = check_anomaly_spike([120.0, 118.0, 121.0, 250.0], spike_multiplier=1.6)
|
|
60
|
+
robust_anomaly = check_anomaly_robust([120.0, 118.0, 121.0, 250.0], method="mad", z_threshold=3.5)
|
|
61
|
+
burn = check_burn_rate([120.0, 118.0, 121.0, 250.0], hot_multiplier=1.4)
|
|
62
|
+
breaker = evaluate_circuit_breaker(consecutive_failures=0, failure_threshold=3)
|
|
63
|
+
forecast = forecast_budget_breach(
|
|
64
|
+
current_spend=920.0,
|
|
65
|
+
budget_limit=1000.0,
|
|
66
|
+
periods_elapsed=22,
|
|
67
|
+
total_periods=30,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
policy = evaluate_spend_policy(budget=budget, anomaly=anomaly, breaker=breaker)
|
|
71
|
+
alert = make_alert_payload(policy, service="billing-worker", environment="prod")
|
|
72
|
+
|
|
73
|
+
print(policy.action, policy.severity)
|
|
74
|
+
print(forecast.projected_total_spend, forecast.will_breach)
|
|
75
|
+
print(burn.burn_rate_ratio, burn.is_hot)
|
|
76
|
+
print(robust_anomaly.threshold, robust_anomaly.is_spike)
|
|
77
|
+
print(alert)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Robust anomaly detection (MAD / percentile)
|
|
81
|
+
|
|
82
|
+
Mean-based thresholds can be brittle when cloud spend is heavy-tailed or bursty. For more robust
|
|
83
|
+
detection, use `check_anomaly_robust`:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from cloudcircuit import check_anomaly_robust
|
|
87
|
+
|
|
88
|
+
mad = check_anomaly_robust([120.0, 118.0, 121.0, 250.0], method="mad", z_threshold=3.5)
|
|
89
|
+
p95 = check_anomaly_robust([120.0, 118.0, 121.0, 250.0], method="percentile", percentile=0.95)
|
|
90
|
+
|
|
91
|
+
print(mad.threshold, mad.is_spike)
|
|
92
|
+
print(p95.threshold, p95.is_spike)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Common developer problems solved
|
|
96
|
+
|
|
97
|
+
1. **Runaway cron or queue workers**
|
|
98
|
+
- Use anomaly + burn-rate checks every interval.
|
|
99
|
+
- Auto-throttle workers when policy returns `throttle`.
|
|
100
|
+
|
|
101
|
+
2. **Unexpected month-end budget breach**
|
|
102
|
+
- Use forecast checks daily or hourly.
|
|
103
|
+
- Trigger remediation before hard limit is hit.
|
|
104
|
+
|
|
105
|
+
3. **No standardized cost incident signal**
|
|
106
|
+
- Use `make_alert_payload` for consistent, structured incident events.
|
|
107
|
+
|
|
108
|
+
4. **Need a safe default during telemetry failure**
|
|
109
|
+
- Combine breaker logic with fail-closed policy in critical paths.
|
|
110
|
+
|
|
111
|
+
## API overview
|
|
112
|
+
|
|
113
|
+
- `check_budget(...) -> BudgetCheckResult`
|
|
114
|
+
- `check_anomaly_spike(...) -> AnomalyCheckResult`
|
|
115
|
+
- `check_anomaly_robust(...) -> AnomalyCheckResult`
|
|
116
|
+
- `check_burn_rate(...) -> BurnRateResult`
|
|
117
|
+
- `forecast_budget_breach(...) -> ForecastResult`
|
|
118
|
+
- `evaluate_circuit_breaker(...) -> CircuitBreakerDecision`
|
|
119
|
+
- `evaluate_spend_policy(...) -> PolicyDecision`
|
|
120
|
+
- `make_alert_payload(...) -> dict[str, object]`
|
|
121
|
+
|
|
122
|
+
## SEO keywords (for discoverability)
|
|
123
|
+
|
|
124
|
+
cloud cost control python, finops python library, cloud budget guardrails, spend anomaly detection, prevent cloud bill shock, cost circuit breaker, cloud billing safety, cloud overspend monitoring, infrastructure cost alerts, developer finops toolkit
|
|
125
|
+
|
|
126
|
+
## Development
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
python -m pip install -e ".[dev]"
|
|
130
|
+
python -m pytest
|
|
131
|
+
python -m ruff check .
|
|
132
|
+
python -m mypy src
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Roadmap ideas
|
|
136
|
+
|
|
137
|
+
- rolling window and percentile-based anomaly models
|
|
138
|
+
- provider adapters for AWS/GCP/Azure billing streams
|
|
139
|
+
- async event pipeline helpers
|
|
140
|
+
- OpenTelemetry metric exporters
|
|
141
|
+
- policy DSL for org-wide spend controls
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
124
145
|
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg width="800" height="200" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<rect width="800" height="200" fill="#06b6d4" rx="10"/>
|
|
3
|
+
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle"
|
|
4
|
+
font-family="Arial, sans-serif" font-size="24" fill="white" font-weight="bold">
|
|
5
|
+
Cloud Infrastructure
|
|
6
|
+
</text>
|
|
7
|
+
</svg>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<rect width="200" height="200" fill="#f8f9fa" stroke="#dee2e6" stroke-width="2"/>
|
|
3
|
+
<text x="50%" y="30%" dominant-baseline="middle" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" fill="#495057">
|
|
4
|
+
Cloud Circuit
|
|
5
|
+
</text>
|
|
6
|
+
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" fill="#6c757d">
|
|
7
|
+
Cloud Infrastructure
|
|
8
|
+
</text>
|
|
9
|
+
<circle cx="100" cy="130" r="25" fill="#007bff" opacity="0.2"/>
|
|
10
|
+
<text x="50%" y="135%" dominant-baseline="middle" text-anchor="middle" font-family="Arial, sans-serif" font-size="10" fill="#007bff">
|
|
11
|
+
Deployment
|
|
12
|
+
</text>
|
|
13
|
+
</svg>
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cloudcircuit"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.7"
|
|
8
8
|
description = "CloudCircuit Python package."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -74,3 +74,4 @@ pretty = true
|
|
|
74
74
|
|
|
75
75
|
[tool.twine]
|
|
76
76
|
repository = "pypi"
|
|
77
|
+
|
|
@@ -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
|