margin 0.9.2__tar.gz → 0.9.4__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.
Potentially problematic release.
This version of margin might be problematic. Click here for more details.
- {margin-0.9.2/margin.egg-info → margin-0.9.4}/PKG-INFO +1 -1
- {margin-0.9.2 → margin-0.9.4}/margin/calibrate.py +18 -4
- {margin-0.9.2 → margin-0.9.4}/margin/health.py +23 -0
- {margin-0.9.2 → margin-0.9.4}/margin/streaming.py +28 -3
- {margin-0.9.2 → margin-0.9.4/margin.egg-info}/PKG-INFO +1 -1
- {margin-0.9.2 → margin-0.9.4}/pyproject.toml +1 -1
- margin-0.9.4/tests/test_calibrate.py +210 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_health.py +45 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_observation.py +58 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_persist.py +50 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_policy_trace.py +93 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_streaming.py +164 -0
- margin-0.9.2/tests/test_calibrate.py +0 -99
- {margin-0.9.2 → margin-0.9.4}/LICENSE +0 -0
- {margin-0.9.2 → margin-0.9.4}/NOTICE +0 -0
- {margin-0.9.2 → margin-0.9.4}/README.md +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/__init__.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/__main__.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/algebra.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/anomaly.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/bridge.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/causal.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/composite.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/confidence.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/config.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/contract.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/correlate.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/diff.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/drift.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/events.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/forecast.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/intent.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/ledger.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/loop.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/observation.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/persist.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/policy/__init__.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/policy/compose.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/policy/core.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/policy/temporal.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/policy/trace.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/policy/tuning.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/policy/validate.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/predicates.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/provenance.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/transitions.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/uncertain.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin/validity.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin.egg-info/SOURCES.txt +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin.egg-info/dependency_links.txt +0 -0
- {margin-0.9.2 → margin-0.9.4}/margin.egg-info/top_level.txt +0 -0
- {margin-0.9.2 → margin-0.9.4}/setup.cfg +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_algebra.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_anomaly.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_bridge.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_causal.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_composite.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_confidence.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_config.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_contract.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_correlate.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_diff.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_drift.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_events.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_forecast.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_healthcare.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_intent.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_ledger.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_loop.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_neuro.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_policy.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_policy_compose.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_policy_temporal.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_policy_tuning.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_policy_validate.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_predicates.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_printer3d.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_provenance.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_reverse_bridge.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_ros2.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_staleness.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_transformer.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_transitions.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_uncertain.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_validity.py +0 -0
- {margin-0.9.2 → margin-0.9.4}/tests/test_windowing.py +0 -0
|
@@ -157,10 +157,10 @@ def calibrate_many(
|
|
|
157
157
|
use_std: bool = False,
|
|
158
158
|
intact_std_multiplier: float = 1.5,
|
|
159
159
|
ablated_std_multiplier: float = 3.0,
|
|
160
|
-
|
|
160
|
+
return_parser: bool = False,
|
|
161
|
+
):
|
|
161
162
|
"""
|
|
162
|
-
Calibrate multiple components at once.
|
|
163
|
-
ready to pass directly to Parser().
|
|
163
|
+
Calibrate multiple components at once.
|
|
164
164
|
|
|
165
165
|
Args:
|
|
166
166
|
component_values: {name: [healthy_measurements]}
|
|
@@ -171,12 +171,14 @@ def calibrate_many(
|
|
|
171
171
|
use_std: if True, use std-based thresholds (see calibrate())
|
|
172
172
|
intact_std_multiplier: std deviations for intact threshold (std mode)
|
|
173
173
|
ablated_std_multiplier: std deviations for ablated threshold (std mode)
|
|
174
|
+
return_parser: if True, return a ready-to-use Parser instead of the raw tuple
|
|
174
175
|
|
|
175
176
|
Returns:
|
|
176
|
-
(baselines_dict, thresholds_dict) suitable for:
|
|
177
|
+
When return_parser=False (default): (baselines_dict, thresholds_dict) suitable for:
|
|
177
178
|
Parser(baselines=baselines_dict,
|
|
178
179
|
thresholds=first_threshold,
|
|
179
180
|
component_thresholds=thresholds_dict)
|
|
181
|
+
When return_parser=True: a Parser with baselines and per-component thresholds set
|
|
180
182
|
"""
|
|
181
183
|
polarities = polarities or {}
|
|
182
184
|
baselines = {}
|
|
@@ -193,6 +195,18 @@ def calibrate_many(
|
|
|
193
195
|
baselines[name] = result.baseline
|
|
194
196
|
thresholds[name] = result.thresholds
|
|
195
197
|
|
|
198
|
+
if return_parser:
|
|
199
|
+
names = list(thresholds.keys())
|
|
200
|
+
if not names:
|
|
201
|
+
raise ValueError("No components to calibrate")
|
|
202
|
+
default_thresholds = thresholds[names[0]]
|
|
203
|
+
component_thresholds = {n: t for n, t in thresholds.items() if n != names[0]}
|
|
204
|
+
return Parser(
|
|
205
|
+
baselines=baselines,
|
|
206
|
+
thresholds=default_thresholds,
|
|
207
|
+
component_thresholds=component_thresholds,
|
|
208
|
+
)
|
|
209
|
+
|
|
196
210
|
return baselines, thresholds
|
|
197
211
|
|
|
198
212
|
|
|
@@ -63,6 +63,29 @@ class Thresholds:
|
|
|
63
63
|
return self.labels.get(health.value, health.value)
|
|
64
64
|
return health.value
|
|
65
65
|
|
|
66
|
+
def to_dict(self) -> dict:
|
|
67
|
+
"""Serialize to a plain dict (labels included if set)."""
|
|
68
|
+
d: dict = {
|
|
69
|
+
"intact": self.intact,
|
|
70
|
+
"ablated": self.ablated,
|
|
71
|
+
"higher_is_better": self.higher_is_better,
|
|
72
|
+
"active_min": self.active_min,
|
|
73
|
+
}
|
|
74
|
+
if self.labels:
|
|
75
|
+
d["labels"] = dict(self.labels)
|
|
76
|
+
return d
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def from_dict(cls, d: dict) -> 'Thresholds':
|
|
80
|
+
"""Deserialize from a plain dict produced by to_dict()."""
|
|
81
|
+
return cls(
|
|
82
|
+
intact=d["intact"],
|
|
83
|
+
ablated=d["ablated"],
|
|
84
|
+
higher_is_better=d.get("higher_is_better", True),
|
|
85
|
+
active_min=d.get("active_min", 0.05),
|
|
86
|
+
labels=d.get("labels"),
|
|
87
|
+
)
|
|
88
|
+
|
|
66
89
|
def __post_init__(self):
|
|
67
90
|
if self.higher_is_better and self.ablated > self.intact:
|
|
68
91
|
raise ValueError(
|
|
@@ -34,6 +34,26 @@ class WindowConfig:
|
|
|
34
34
|
anomaly: Optional[int] = None
|
|
35
35
|
correlation: Optional[int] = None
|
|
36
36
|
|
|
37
|
+
def to_dict(self) -> dict:
|
|
38
|
+
"""Serialize to plain dict; omits None fields."""
|
|
39
|
+
d: dict = {}
|
|
40
|
+
if self.drift is not None:
|
|
41
|
+
d["drift"] = self.drift
|
|
42
|
+
if self.anomaly is not None:
|
|
43
|
+
d["anomaly"] = self.anomaly
|
|
44
|
+
if self.correlation is not None:
|
|
45
|
+
d["correlation"] = self.correlation
|
|
46
|
+
return d
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_dict(cls, d: dict) -> 'WindowConfig':
|
|
50
|
+
"""Deserialize from a plain dict produced by to_dict()."""
|
|
51
|
+
return cls(
|
|
52
|
+
drift=d.get("drift"),
|
|
53
|
+
anomaly=d.get("anomaly"),
|
|
54
|
+
correlation=d.get("correlation"),
|
|
55
|
+
)
|
|
56
|
+
|
|
37
57
|
from .confidence import Confidence
|
|
38
58
|
from .health import Health, Thresholds, classify
|
|
39
59
|
from .observation import Observation, Expression
|
|
@@ -478,18 +498,23 @@ class Monitor:
|
|
|
478
498
|
return self._step
|
|
479
499
|
|
|
480
500
|
def status(self) -> dict:
|
|
481
|
-
"""Full snapshot: health + drift + anomaly + correlations.
|
|
501
|
+
"""Full snapshot: health + drift + anomaly + correlations.
|
|
502
|
+
|
|
503
|
+
Keys are always present; disabled features or no-data trackers
|
|
504
|
+
return empty dicts so callers can index without key-existence checks.
|
|
505
|
+
"""
|
|
482
506
|
d: dict = {
|
|
483
507
|
"step": self._step,
|
|
484
508
|
"expression": self._expression.to_dict() if self._expression else None,
|
|
509
|
+
"drift": {},
|
|
510
|
+
"anomaly": {},
|
|
511
|
+
"correlations": {},
|
|
485
512
|
}
|
|
486
513
|
if "drift" in self.features:
|
|
487
|
-
d["drift"] = {}
|
|
488
514
|
for name, tracker in self._drift_trackers.items():
|
|
489
515
|
if tracker.classification:
|
|
490
516
|
d["drift"][name] = tracker.classification.to_dict()
|
|
491
517
|
if "anomaly" in self.features:
|
|
492
|
-
d["anomaly"] = {}
|
|
493
518
|
for name, tracker in self._anomaly_trackers.items():
|
|
494
519
|
if tracker.classification:
|
|
495
520
|
d["anomaly"][name] = tracker.classification.to_dict()
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "margin"
|
|
7
|
-
version = "0.9.
|
|
7
|
+
version = "0.9.4"
|
|
8
8
|
description = "Typed health classification, uncertainty algebra, and correction auditing for any system that measures things and needs to explain what happened."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from margin.calibrate import calibrate, calibrate_many, parser_from_calibration, CalibrationResult
|
|
3
|
+
from margin.health import Thresholds, Health
|
|
4
|
+
from margin.confidence import Confidence
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TestCalibrate:
|
|
8
|
+
def test_basic_higher_is_better(self):
|
|
9
|
+
r = calibrate([100.0, 100.0, 100.0])
|
|
10
|
+
assert r.baseline == pytest.approx(100.0)
|
|
11
|
+
assert r.thresholds.higher_is_better is True
|
|
12
|
+
assert r.thresholds.intact == pytest.approx(70.0)
|
|
13
|
+
assert r.thresholds.ablated == pytest.approx(30.0)
|
|
14
|
+
|
|
15
|
+
def test_basic_lower_is_better(self):
|
|
16
|
+
r = calibrate([0.01, 0.01, 0.01], higher_is_better=False)
|
|
17
|
+
assert r.baseline == pytest.approx(0.01)
|
|
18
|
+
assert r.thresholds.higher_is_better is False
|
|
19
|
+
# intact = 0.01 * 1.30 = 0.013
|
|
20
|
+
assert r.thresholds.intact == pytest.approx(0.013, abs=0.001)
|
|
21
|
+
# ablated = 0.01 * 1.70 = 0.017
|
|
22
|
+
assert r.thresholds.ablated == pytest.approx(0.017, abs=0.001)
|
|
23
|
+
|
|
24
|
+
def test_custom_fractions(self):
|
|
25
|
+
r = calibrate([200.0, 200.0], intact_fraction=0.80, ablated_fraction=0.20)
|
|
26
|
+
assert r.thresholds.intact == pytest.approx(160.0)
|
|
27
|
+
assert r.thresholds.ablated == pytest.approx(40.0)
|
|
28
|
+
|
|
29
|
+
def test_std_computed(self):
|
|
30
|
+
r = calibrate([90.0, 100.0, 110.0])
|
|
31
|
+
assert r.std > 0
|
|
32
|
+
assert r.n_samples == 3
|
|
33
|
+
|
|
34
|
+
def test_single_value(self):
|
|
35
|
+
r = calibrate([50.0])
|
|
36
|
+
assert r.baseline == 50.0
|
|
37
|
+
assert r.std == 0.0
|
|
38
|
+
assert r.n_samples == 1
|
|
39
|
+
|
|
40
|
+
def test_empty_raises(self):
|
|
41
|
+
with pytest.raises(ValueError):
|
|
42
|
+
calibrate([])
|
|
43
|
+
|
|
44
|
+
def test_to_dict(self):
|
|
45
|
+
r = calibrate([100.0, 100.0])
|
|
46
|
+
d = r.to_dict()
|
|
47
|
+
assert "baseline" in d
|
|
48
|
+
assert "intact" in d
|
|
49
|
+
assert "ablated" in d
|
|
50
|
+
assert "higher_is_better" in d
|
|
51
|
+
|
|
52
|
+
def test_threshold_validation_holds(self):
|
|
53
|
+
# Higher is better: ablated < intact
|
|
54
|
+
r = calibrate([100.0], higher_is_better=True)
|
|
55
|
+
assert r.thresholds.ablated <= r.thresholds.intact
|
|
56
|
+
|
|
57
|
+
# Lower is better: ablated > intact
|
|
58
|
+
r2 = calibrate([0.01], higher_is_better=False)
|
|
59
|
+
assert r2.thresholds.ablated >= r2.thresholds.intact
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TestCalibrateMany:
|
|
63
|
+
def test_returns_baselines_and_thresholds(self):
|
|
64
|
+
baselines, thresholds = calibrate_many({
|
|
65
|
+
"throughput": [100.0, 105.0, 95.0],
|
|
66
|
+
"error_rate": [0.01, 0.012, 0.008],
|
|
67
|
+
}, polarities={"error_rate": False})
|
|
68
|
+
|
|
69
|
+
assert "throughput" in baselines
|
|
70
|
+
assert "error_rate" in baselines
|
|
71
|
+
assert thresholds["throughput"].higher_is_better is True
|
|
72
|
+
assert thresholds["error_rate"].higher_is_better is False
|
|
73
|
+
|
|
74
|
+
def test_default_polarity_is_higher(self):
|
|
75
|
+
_, thresholds = calibrate_many({"x": [50.0, 50.0]})
|
|
76
|
+
assert thresholds["x"].higher_is_better is True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TestParserFromCalibration:
|
|
80
|
+
def test_produces_working_parser(self):
|
|
81
|
+
parser = parser_from_calibration(
|
|
82
|
+
{"throughput": [100.0] * 5, "latency": [50.0] * 5},
|
|
83
|
+
polarities={"latency": False},
|
|
84
|
+
)
|
|
85
|
+
# Healthy readings
|
|
86
|
+
expr = parser.parse({"throughput": 95.0, "latency": 52.0})
|
|
87
|
+
assert expr.health_of("throughput") == Health.INTACT
|
|
88
|
+
assert expr.health_of("latency") == Health.INTACT
|
|
89
|
+
|
|
90
|
+
def test_detects_degradation(self):
|
|
91
|
+
parser = parser_from_calibration(
|
|
92
|
+
{"throughput": [100.0] * 5},
|
|
93
|
+
)
|
|
94
|
+
expr = parser.parse({"throughput": 40.0})
|
|
95
|
+
assert expr.health_of("throughput") == Health.DEGRADED
|
|
96
|
+
|
|
97
|
+
def test_empty_raises(self):
|
|
98
|
+
with pytest.raises(ValueError):
|
|
99
|
+
parser_from_calibration({})
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TestCalibrateUseStd:
|
|
103
|
+
def test_higher_is_better_thresholds(self):
|
|
104
|
+
# mean=100, std=10 → intact=85 (100-1.5*10), ablated=70 (100-3*10)
|
|
105
|
+
r = calibrate([90.0, 100.0, 110.0], use_std=True)
|
|
106
|
+
assert r.thresholds.intact == pytest.approx(85.0, abs=0.1)
|
|
107
|
+
assert r.thresholds.ablated == pytest.approx(70.0, abs=0.1)
|
|
108
|
+
assert r.thresholds.higher_is_better is True
|
|
109
|
+
|
|
110
|
+
def test_lower_is_better_thresholds(self):
|
|
111
|
+
# mean=0.01, std=0.001 → intact=0.0115 (0.01+1.5*0.001), ablated=0.013 (0.01+3*0.001)
|
|
112
|
+
r = calibrate([0.009, 0.010, 0.011], use_std=True, higher_is_better=False)
|
|
113
|
+
assert r.thresholds.higher_is_better is False
|
|
114
|
+
assert r.thresholds.intact == pytest.approx(0.0115, abs=0.0001)
|
|
115
|
+
assert r.thresholds.ablated == pytest.approx(0.013, abs=0.0001)
|
|
116
|
+
|
|
117
|
+
def test_custom_multipliers(self):
|
|
118
|
+
# mean=100, std=10 → intact=100-2*10=80, ablated=100-4*10=60
|
|
119
|
+
r = calibrate([90.0, 100.0, 110.0], use_std=True,
|
|
120
|
+
intact_std_multiplier=2.0, ablated_std_multiplier=4.0)
|
|
121
|
+
assert r.thresholds.intact == pytest.approx(80.0, abs=0.1)
|
|
122
|
+
assert r.thresholds.ablated == pytest.approx(60.0, abs=0.1)
|
|
123
|
+
|
|
124
|
+
def test_zero_variance_raises(self):
|
|
125
|
+
with pytest.raises(ValueError, match="zero-variance"):
|
|
126
|
+
calibrate([100.0, 100.0, 100.0], use_std=True)
|
|
127
|
+
|
|
128
|
+
def test_threads_through_calibrate_many(self):
|
|
129
|
+
from margin.calibrate import calibrate_many
|
|
130
|
+
_, thresholds = calibrate_many(
|
|
131
|
+
{"rps": [90.0, 100.0, 110.0]},
|
|
132
|
+
use_std=True,
|
|
133
|
+
)
|
|
134
|
+
# Should not raise; thresholds set from std mode
|
|
135
|
+
assert thresholds["rps"].intact < 100.0
|
|
136
|
+
|
|
137
|
+
def test_threads_through_parser_from_calibration(self):
|
|
138
|
+
p = parser_from_calibration(
|
|
139
|
+
{"rps": [90.0, 100.0, 110.0]},
|
|
140
|
+
use_std=True,
|
|
141
|
+
)
|
|
142
|
+
expr = p.parse({"rps": 60.0})
|
|
143
|
+
assert expr.health_of("rps") in (Health.ABLATED, Health.DEGRADED)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class TestNeedsRecalibrationMany:
|
|
147
|
+
from margin.calibrate import needs_recalibration_many as _nrm
|
|
148
|
+
|
|
149
|
+
def test_stable_data_no_recalibration(self):
|
|
150
|
+
from margin.calibrate import needs_recalibration_many
|
|
151
|
+
cal = {"cpu": [50.0] * 20, "mem": [70.0] * 20}
|
|
152
|
+
recent = {"cpu": [50.5] * 10, "mem": [70.2] * 10}
|
|
153
|
+
flags = needs_recalibration_many(cal, recent)
|
|
154
|
+
assert flags["cpu"] is False
|
|
155
|
+
assert flags["mem"] is False
|
|
156
|
+
|
|
157
|
+
def test_mean_shift_triggers_recalibration(self):
|
|
158
|
+
from margin.calibrate import needs_recalibration_many
|
|
159
|
+
cal = {"cpu": [50.0] * 20}
|
|
160
|
+
recent = {"cpu": [100.0] * 10} # >20% shift
|
|
161
|
+
flags = needs_recalibration_many(cal, recent)
|
|
162
|
+
assert flags["cpu"] is True
|
|
163
|
+
|
|
164
|
+
def test_returns_per_component_dict(self):
|
|
165
|
+
from margin.calibrate import needs_recalibration_many
|
|
166
|
+
cal = {"cpu": [50.0] * 20, "mem": [70.0] * 20, "err": [0.01] * 20}
|
|
167
|
+
recent = {"cpu": [50.0] * 10, "mem": [70.0] * 10, "err": [0.01] * 10}
|
|
168
|
+
flags = needs_recalibration_many(cal, recent)
|
|
169
|
+
assert set(flags.keys()) == {"cpu", "mem", "err"}
|
|
170
|
+
assert all(isinstance(v, bool) for v in flags.values())
|
|
171
|
+
|
|
172
|
+
def test_partial_subset_triggers(self):
|
|
173
|
+
from margin.calibrate import needs_recalibration_many
|
|
174
|
+
cal = {"cpu": [50.0] * 20, "mem": [70.0] * 20}
|
|
175
|
+
recent = {"cpu": [100.0] * 10, "mem": [70.1] * 10}
|
|
176
|
+
flags = needs_recalibration_many(cal, recent)
|
|
177
|
+
assert flags["cpu"] is True
|
|
178
|
+
assert flags["mem"] is False
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class TestCalibrateManyReturnParser:
|
|
182
|
+
def test_return_parser_gives_parser(self):
|
|
183
|
+
from margin.observation import Parser
|
|
184
|
+
result = calibrate_many(
|
|
185
|
+
{"rps": [90.0, 100.0, 110.0], "latency": [48.0, 50.0, 52.0]},
|
|
186
|
+
polarities={"latency": False},
|
|
187
|
+
return_parser=True,
|
|
188
|
+
)
|
|
189
|
+
assert isinstance(result, Parser)
|
|
190
|
+
|
|
191
|
+
def test_return_parser_classifies_correctly(self):
|
|
192
|
+
result = calibrate_many(
|
|
193
|
+
{"rps": [100.0] * 10, "latency": [50.0] * 10},
|
|
194
|
+
polarities={"latency": False},
|
|
195
|
+
return_parser=True,
|
|
196
|
+
)
|
|
197
|
+
from margin.health import Health
|
|
198
|
+
expr = result.parse({"rps": 95.0, "latency": 52.0})
|
|
199
|
+
assert expr.health_of("rps") == Health.INTACT
|
|
200
|
+
assert expr.health_of("latency") == Health.INTACT
|
|
201
|
+
|
|
202
|
+
def test_return_parser_false_gives_tuple(self):
|
|
203
|
+
result = calibrate_many({"rps": [100.0] * 5})
|
|
204
|
+
assert isinstance(result, tuple)
|
|
205
|
+
assert len(result) == 2
|
|
206
|
+
|
|
207
|
+
def test_return_parser_empty_raises(self):
|
|
208
|
+
import pytest
|
|
209
|
+
with pytest.raises(ValueError):
|
|
210
|
+
calibrate_many({}, return_parser=True)
|
|
@@ -97,3 +97,48 @@ class TestClassifyEdgeCases:
|
|
|
97
97
|
def test_negative_inf_lower_is_better(self):
|
|
98
98
|
t = Thresholds(intact=0.02, ablated=0.10, higher_is_better=False)
|
|
99
99
|
assert classify(float('-inf'), Confidence.HIGH, thresholds=t) == Health.INTACT
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TestThresholdsLabels:
|
|
103
|
+
def test_label_for_returns_custom(self):
|
|
104
|
+
t = Thresholds(intact=80.0, ablated=30.0, labels={"ABLATED": "CRITICAL", "INTACT": "OK"})
|
|
105
|
+
assert t.label_for(Health.ABLATED) == "CRITICAL"
|
|
106
|
+
assert t.label_for(Health.INTACT) == "OK"
|
|
107
|
+
|
|
108
|
+
def test_label_for_fallback_to_enum(self):
|
|
109
|
+
t = Thresholds(intact=80.0, ablated=30.0, labels={"ABLATED": "CRITICAL"})
|
|
110
|
+
assert t.label_for(Health.DEGRADED) == "DEGRADED"
|
|
111
|
+
|
|
112
|
+
def test_no_labels_returns_enum_name(self):
|
|
113
|
+
t = Thresholds(intact=80.0, ablated=30.0)
|
|
114
|
+
assert t.label_for(Health.INTACT) == "INTACT"
|
|
115
|
+
|
|
116
|
+
def test_to_dict_includes_labels(self):
|
|
117
|
+
t = Thresholds(intact=80.0, ablated=30.0, labels={"ABLATED": "CRITICAL"})
|
|
118
|
+
d = t.to_dict()
|
|
119
|
+
assert d["labels"] == {"ABLATED": "CRITICAL"}
|
|
120
|
+
assert d["intact"] == 80.0
|
|
121
|
+
assert d["ablated"] == 30.0
|
|
122
|
+
assert d["higher_is_better"] is True
|
|
123
|
+
assert d["active_min"] == pytest.approx(0.05)
|
|
124
|
+
|
|
125
|
+
def test_to_dict_omits_labels_when_none(self):
|
|
126
|
+
t = Thresholds(intact=80.0, ablated=30.0)
|
|
127
|
+
d = t.to_dict()
|
|
128
|
+
assert "labels" not in d
|
|
129
|
+
|
|
130
|
+
def test_from_dict_roundtrip(self):
|
|
131
|
+
t = Thresholds(intact=0.01, ablated=0.10, higher_is_better=False,
|
|
132
|
+
active_min=0.1, labels={"INTACT": "GOOD", "DEGRADED": "WARN"})
|
|
133
|
+
t2 = Thresholds.from_dict(t.to_dict())
|
|
134
|
+
assert t2.intact == t.intact
|
|
135
|
+
assert t2.ablated == t.ablated
|
|
136
|
+
assert t2.higher_is_better == t.higher_is_better
|
|
137
|
+
assert t2.active_min == t.active_min
|
|
138
|
+
assert t2.labels == t.labels
|
|
139
|
+
|
|
140
|
+
def test_from_dict_no_labels(self):
|
|
141
|
+
t = Thresholds.from_dict({"intact": 80.0, "ablated": 30.0})
|
|
142
|
+
assert t.labels is None
|
|
143
|
+
assert t.higher_is_better is True
|
|
144
|
+
assert t.active_min == pytest.approx(0.05)
|
|
@@ -210,3 +210,61 @@ class TestParserPolarity:
|
|
|
210
210
|
e = p.parse({"throughput": 95.0, "err": 0.08}, correction_magnitude=1.0, alpha=0.5)
|
|
211
211
|
assert e.corrections[0].target == "err"
|
|
212
212
|
assert e.corrections[0].op == Op.RESTORE
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class TestHealthLabel:
|
|
216
|
+
def test_to_atom_uses_health_label(self):
|
|
217
|
+
obs = Observation(
|
|
218
|
+
name="cpu", health=Health.ABLATED, value=10.0, baseline=100.0,
|
|
219
|
+
confidence=Confidence.HIGH, health_label="CRITICAL",
|
|
220
|
+
)
|
|
221
|
+
atom = obs.to_atom()
|
|
222
|
+
assert "CRITICAL" in atom
|
|
223
|
+
assert "ABLATED" not in atom
|
|
224
|
+
|
|
225
|
+
def test_to_atom_fallback_when_no_label(self):
|
|
226
|
+
obs = Observation(
|
|
227
|
+
name="cpu", health=Health.DEGRADED, value=60.0, baseline=100.0,
|
|
228
|
+
confidence=Confidence.HIGH,
|
|
229
|
+
)
|
|
230
|
+
atom = obs.to_atom()
|
|
231
|
+
assert "DEGRADED" in atom
|
|
232
|
+
|
|
233
|
+
def test_parser_sets_health_label(self):
|
|
234
|
+
p = Parser(
|
|
235
|
+
baselines={"cpu": 100.0},
|
|
236
|
+
thresholds=Thresholds(
|
|
237
|
+
intact=80.0, ablated=30.0,
|
|
238
|
+
labels={"ABLATED": "CRITICAL", "DEGRADED": "WARNING", "INTACT": "OK"},
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
expr = p.parse({"cpu": 10.0})
|
|
242
|
+
obs = expr.observations[0]
|
|
243
|
+
assert obs.health_label == "CRITICAL"
|
|
244
|
+
assert "CRITICAL" in obs.to_atom()
|
|
245
|
+
|
|
246
|
+
def test_parser_label_for(self):
|
|
247
|
+
p = Parser(
|
|
248
|
+
baselines={"cpu": 100.0},
|
|
249
|
+
thresholds=Thresholds(intact=80.0, ablated=30.0, labels={"INTACT": "OK"}),
|
|
250
|
+
)
|
|
251
|
+
assert p.label_for("cpu", Health.INTACT) == "OK"
|
|
252
|
+
assert p.label_for("cpu", Health.DEGRADED) == "DEGRADED"
|
|
253
|
+
|
|
254
|
+
def test_health_label_to_dict_roundtrip(self):
|
|
255
|
+
obs = Observation(
|
|
256
|
+
name="mem", health=Health.DEGRADED, value=60.0, baseline=100.0,
|
|
257
|
+
confidence=Confidence.HIGH, health_label="WARNING",
|
|
258
|
+
)
|
|
259
|
+
d = obs.to_dict()
|
|
260
|
+
assert d["health_label"] == "WARNING"
|
|
261
|
+
obs2 = Observation.from_dict(d)
|
|
262
|
+
assert obs2.health_label == "WARNING"
|
|
263
|
+
|
|
264
|
+
def test_health_label_none_not_in_dict(self):
|
|
265
|
+
obs = Observation(
|
|
266
|
+
name="mem", health=Health.INTACT, value=95.0, baseline=100.0,
|
|
267
|
+
confidence=Confidence.HIGH,
|
|
268
|
+
)
|
|
269
|
+
d = obs.to_dict()
|
|
270
|
+
assert "health_label" not in d
|
|
@@ -214,3 +214,53 @@ class TestCLI:
|
|
|
214
214
|
assert result.returncode == 0
|
|
215
215
|
assert "cpu" in result.stdout
|
|
216
216
|
assert "INTACT" in result.stdout or "DEGRADED" in result.stdout
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class TestFeaturesRoundtrip:
|
|
220
|
+
def test_partial_features_saved_and_restored(self):
|
|
221
|
+
p = _parser()
|
|
222
|
+
m = Monitor(p, features={"health", "drift"})
|
|
223
|
+
for i in range(10):
|
|
224
|
+
m.update({"cpu": 50.0 - i, "mem": 70.0},
|
|
225
|
+
now=t0 + timedelta(seconds=i * 60))
|
|
226
|
+
|
|
227
|
+
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
|
228
|
+
save_monitor(m, f.name)
|
|
229
|
+
m2 = load_monitor(f.name, p)
|
|
230
|
+
Path(f.name).unlink()
|
|
231
|
+
|
|
232
|
+
assert m2.features == frozenset({"health", "drift"})
|
|
233
|
+
assert len(m2._drift_trackers) == 2
|
|
234
|
+
assert m2._correlation_tracker is None
|
|
235
|
+
|
|
236
|
+
def test_all_features_roundtrip(self):
|
|
237
|
+
p = _parser()
|
|
238
|
+
m = Monitor(p, features={"health", "drift", "anomaly", "correlation"})
|
|
239
|
+
for i in range(10):
|
|
240
|
+
m.update({"cpu": 50.0, "mem": 70.0},
|
|
241
|
+
now=t0 + timedelta(seconds=i * 60))
|
|
242
|
+
|
|
243
|
+
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
|
244
|
+
save_monitor(m, f.name)
|
|
245
|
+
m2 = load_monitor(f.name, p)
|
|
246
|
+
Path(f.name).unlink()
|
|
247
|
+
|
|
248
|
+
assert "drift" in m2.features
|
|
249
|
+
assert "anomaly" in m2.features
|
|
250
|
+
assert "correlation" in m2.features
|
|
251
|
+
|
|
252
|
+
def test_anomaly_min_reference_roundtrip(self):
|
|
253
|
+
p = _parser()
|
|
254
|
+
m = Monitor(p, anomaly_min_reference=25)
|
|
255
|
+
for i in range(10):
|
|
256
|
+
m.update({"cpu": 50.0, "mem": 70.0},
|
|
257
|
+
now=t0 + timedelta(seconds=i * 60))
|
|
258
|
+
|
|
259
|
+
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
|
260
|
+
save_monitor(m, f.name)
|
|
261
|
+
data = json.loads(Path(f.name).read_text())
|
|
262
|
+
m2 = load_monitor(f.name, p)
|
|
263
|
+
Path(f.name).unlink()
|
|
264
|
+
|
|
265
|
+
assert data["anomaly_min_reference"] == 25
|
|
266
|
+
assert m2._anomaly_trackers["cpu"]._min_reference == 25
|
|
@@ -136,3 +136,96 @@ class TestBacktestProposedBy:
|
|
|
136
136
|
ledger = Ledger(records=[_rec(0, "x", Health.DEGRADED)])
|
|
137
137
|
bt = policy.backtest(ledger)
|
|
138
138
|
assert bt[0]["proposed_by"] == ""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class TestMultiRule:
|
|
142
|
+
def _make_multi_policy(self):
|
|
143
|
+
from margin.policy.core import Policy, PolicyRule, Action
|
|
144
|
+
from margin.observation import Op
|
|
145
|
+
from margin.predicates import any_health, any_degraded
|
|
146
|
+
return Policy(
|
|
147
|
+
name="multi",
|
|
148
|
+
multi_rule=True,
|
|
149
|
+
rules=[
|
|
150
|
+
PolicyRule("log", any_degraded(),
|
|
151
|
+
Action(target="*", op=Op.NOOP, alpha=0.0), priority=5),
|
|
152
|
+
PolicyRule("restore", any_health(Health.DEGRADED),
|
|
153
|
+
Action(target="*", op=Op.RESTORE, alpha=0.5), priority=10),
|
|
154
|
+
],
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def test_multi_rule_all_matching_rules_fire(self):
|
|
158
|
+
policy = self._make_multi_policy()
|
|
159
|
+
dt = trace_evaluate(policy, _expr(_obs("x", Health.DEGRADED)))
|
|
160
|
+
# Both rules match DEGRADED
|
|
161
|
+
assert len(dt.results) == 2
|
|
162
|
+
|
|
163
|
+
def test_multi_rule_winner_is_highest_priority(self):
|
|
164
|
+
policy = self._make_multi_policy()
|
|
165
|
+
dt = trace_evaluate(policy, _expr(_obs("x", Health.DEGRADED)))
|
|
166
|
+
assert dt.winner == "restore"
|
|
167
|
+
assert dt.result is not None
|
|
168
|
+
|
|
169
|
+
def test_trace_always_has_all_matched_results(self):
|
|
170
|
+
# trace_evaluate always populates dt.results for all matches;
|
|
171
|
+
# multi_rule only gates StepResult.corrections in step()
|
|
172
|
+
from margin.policy.core import Policy, PolicyRule, Action
|
|
173
|
+
from margin.observation import Op
|
|
174
|
+
from margin.predicates import any_degraded
|
|
175
|
+
policy = Policy(
|
|
176
|
+
name="single",
|
|
177
|
+
multi_rule=False,
|
|
178
|
+
rules=[
|
|
179
|
+
PolicyRule("r1", any_degraded(), Action(target="*", op=Op.RESTORE, alpha=0.5), priority=10),
|
|
180
|
+
PolicyRule("r2", any_degraded(), Action(target="*", op=Op.NOOP), priority=5),
|
|
181
|
+
],
|
|
182
|
+
)
|
|
183
|
+
dt = trace_evaluate(policy, _expr(_obs("x", Health.DEGRADED)))
|
|
184
|
+
# Both rules match — trace always records all; multi_rule only affects step()
|
|
185
|
+
assert len(dt.results) == 2
|
|
186
|
+
assert dt.winner == "r1" # highest priority still wins
|
|
187
|
+
|
|
188
|
+
def test_multi_rule_to_dict_includes_multi_rule_flag(self):
|
|
189
|
+
policy = self._make_multi_policy()
|
|
190
|
+
d = policy.to_dict()
|
|
191
|
+
assert d["multi_rule"] is True
|
|
192
|
+
|
|
193
|
+
def test_multi_rule_default_is_false(self):
|
|
194
|
+
from margin.policy.core import Policy
|
|
195
|
+
policy = Policy(name="p", rules=[])
|
|
196
|
+
assert policy.multi_rule is False
|
|
197
|
+
assert policy.to_dict()["multi_rule"] is False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class TestStepResultCorrections:
|
|
201
|
+
def test_step_corrections_populated_multi_rule(self):
|
|
202
|
+
from margin.loop import step
|
|
203
|
+
from margin.policy.core import Policy, PolicyRule, Action
|
|
204
|
+
from margin.observation import Op
|
|
205
|
+
from margin.predicates import any_degraded
|
|
206
|
+
|
|
207
|
+
policy = Policy(
|
|
208
|
+
name="multi",
|
|
209
|
+
multi_rule=True,
|
|
210
|
+
rules=[
|
|
211
|
+
PolicyRule("r1", any_degraded(), Action(target="*", op=Op.RESTORE, alpha=0.5), priority=10),
|
|
212
|
+
PolicyRule("r2", any_degraded(), Action(target="*", op=Op.NOOP), priority=5),
|
|
213
|
+
],
|
|
214
|
+
)
|
|
215
|
+
result = step(_expr(_obs("x", Health.DEGRADED)), policy)
|
|
216
|
+
assert len(result.corrections) >= 1
|
|
217
|
+
|
|
218
|
+
def test_step_corrections_empty_single_rule(self):
|
|
219
|
+
from margin.loop import step
|
|
220
|
+
from margin.policy.core import Policy, PolicyRule, Action
|
|
221
|
+
from margin.observation import Op
|
|
222
|
+
from margin.predicates import any_degraded
|
|
223
|
+
|
|
224
|
+
policy = Policy(
|
|
225
|
+
name="single",
|
|
226
|
+
multi_rule=False,
|
|
227
|
+
rules=[PolicyRule("r", any_degraded(), Action(target="*", op=Op.RESTORE, alpha=0.5))],
|
|
228
|
+
)
|
|
229
|
+
result = step(_expr(_obs("x", Health.DEGRADED)), policy)
|
|
230
|
+
# multi_rule=False → corrections list is empty (only .correction populated)
|
|
231
|
+
assert result.corrections == []
|
|
@@ -6,6 +6,7 @@ from margin import (
|
|
|
6
6
|
Observation, Health, Confidence, Thresholds, Parser,
|
|
7
7
|
DriftState, DriftDirection, AnomalyState,
|
|
8
8
|
DriftTracker, AnomalyTracker, CorrelationTracker, Monitor,
|
|
9
|
+
WindowConfig,
|
|
9
10
|
)
|
|
10
11
|
|
|
11
12
|
|
|
@@ -284,3 +285,166 @@ class TestMonitor:
|
|
|
284
285
|
m = Monitor(self._parser())
|
|
285
286
|
assert "Monitor" in repr(m)
|
|
286
287
|
assert "3 components" in repr(m)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# -----------------------------------------------------------------------
|
|
291
|
+
# Monitor — feature flags
|
|
292
|
+
# -----------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
class TestMonitorFeatures:
|
|
295
|
+
def _parser(self):
|
|
296
|
+
return Parser(
|
|
297
|
+
baselines={"cpu": 50.0, "mem": 70.0},
|
|
298
|
+
thresholds=Thresholds(intact=40.0, ablated=10.0),
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
def test_default_features_all_enabled(self):
|
|
302
|
+
m = Monitor(self._parser())
|
|
303
|
+
assert "drift" in m.features
|
|
304
|
+
assert "anomaly" in m.features
|
|
305
|
+
assert "correlation" in m.features
|
|
306
|
+
|
|
307
|
+
def test_features_health_only_skips_trackers(self):
|
|
308
|
+
m = Monitor(self._parser(), features={"health"})
|
|
309
|
+
assert len(m._drift_trackers) == 0
|
|
310
|
+
assert len(m._anomaly_trackers) == 0
|
|
311
|
+
assert m._correlation_tracker is None
|
|
312
|
+
|
|
313
|
+
def test_features_health_drift_no_anomaly(self):
|
|
314
|
+
m = Monitor(self._parser(), features={"health", "drift"})
|
|
315
|
+
assert len(m._drift_trackers) == 2
|
|
316
|
+
assert len(m._anomaly_trackers) == 0
|
|
317
|
+
assert m._correlation_tracker is None
|
|
318
|
+
|
|
319
|
+
def test_features_health_anomaly_no_drift(self):
|
|
320
|
+
m = Monitor(self._parser(), features={"health", "anomaly"})
|
|
321
|
+
assert len(m._drift_trackers) == 0
|
|
322
|
+
assert len(m._anomaly_trackers) == 2
|
|
323
|
+
|
|
324
|
+
def test_update_works_with_partial_features(self):
|
|
325
|
+
m = Monitor(self._parser(), features={"health", "drift"})
|
|
326
|
+
expr = m.update({"cpu": 48.0, "mem": 65.0})
|
|
327
|
+
assert expr is not None
|
|
328
|
+
assert m.step == 1
|
|
329
|
+
|
|
330
|
+
def test_features_is_frozenset(self):
|
|
331
|
+
m = Monitor(self._parser(), features={"health", "drift"})
|
|
332
|
+
assert isinstance(m.features, frozenset)
|
|
333
|
+
|
|
334
|
+
def test_status_always_has_drift_anomaly_correlations_keys(self):
|
|
335
|
+
m = Monitor(self._parser(), features={"health"})
|
|
336
|
+
for i in range(5):
|
|
337
|
+
m.update({"cpu": 50.0 - i, "mem": 70.0},
|
|
338
|
+
now=t0 + timedelta(seconds=i * 60))
|
|
339
|
+
s = m.status()
|
|
340
|
+
# Keys always present — empty dict when feature disabled or no data yet
|
|
341
|
+
assert "drift" in s and s["drift"] == {}
|
|
342
|
+
assert "anomaly" in s and s["anomaly"] == {}
|
|
343
|
+
assert "correlations" in s and s["correlations"] == {}
|
|
344
|
+
|
|
345
|
+
def test_repr_shows_features_when_partial(self):
|
|
346
|
+
m = Monitor(self._parser(), features={"health", "drift"})
|
|
347
|
+
r = repr(m)
|
|
348
|
+
assert "features" in r
|
|
349
|
+
|
|
350
|
+
def test_repr_no_features_label_when_all_enabled(self):
|
|
351
|
+
m = Monitor(self._parser())
|
|
352
|
+
r = repr(m)
|
|
353
|
+
assert "features" not in r
|
|
354
|
+
|
|
355
|
+
def test_reset_works_with_partial_features(self):
|
|
356
|
+
m = Monitor(self._parser(), features={"health", "drift"})
|
|
357
|
+
for i in range(5):
|
|
358
|
+
m.update({"cpu": 50.0 - i, "mem": 70.0})
|
|
359
|
+
m.reset()
|
|
360
|
+
assert m.step == 0
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# -----------------------------------------------------------------------
|
|
364
|
+
# Monitor — reset_anomaly_reference
|
|
365
|
+
# -----------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
class TestMonitorResetAnomalyReference:
|
|
368
|
+
def _parser(self):
|
|
369
|
+
return Parser(
|
|
370
|
+
baselines={"cpu": 50.0, "mem": 70.0},
|
|
371
|
+
thresholds=Thresholds(intact=40.0, ablated=10.0),
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
def test_reset_clears_all_reference_values(self):
|
|
375
|
+
m = Monitor(self._parser(), anomaly_min_reference=5)
|
|
376
|
+
for i in range(10):
|
|
377
|
+
m.update({"cpu": 50.0, "mem": 70.0},
|
|
378
|
+
now=t0 + timedelta(seconds=i * 60))
|
|
379
|
+
assert m._anomaly_trackers["cpu"].n_values == 10
|
|
380
|
+
m.reset_anomaly_reference()
|
|
381
|
+
assert m._anomaly_trackers["cpu"].n_values == 0
|
|
382
|
+
assert m._anomaly_trackers["mem"].n_values == 0
|
|
383
|
+
|
|
384
|
+
def test_reset_specific_components(self):
|
|
385
|
+
m = Monitor(self._parser(), anomaly_min_reference=5)
|
|
386
|
+
for i in range(10):
|
|
387
|
+
m.update({"cpu": 50.0, "mem": 70.0},
|
|
388
|
+
now=t0 + timedelta(seconds=i * 60))
|
|
389
|
+
m.reset_anomaly_reference(components=["cpu"])
|
|
390
|
+
assert m._anomaly_trackers["cpu"].n_values == 0
|
|
391
|
+
assert m._anomaly_trackers["mem"].n_values == 10
|
|
392
|
+
|
|
393
|
+
def test_reset_allows_fresh_reference(self):
|
|
394
|
+
m = Monitor(self._parser(), anomaly_min_reference=5)
|
|
395
|
+
# Build reference at baseline=50
|
|
396
|
+
for i in range(10):
|
|
397
|
+
m.update({"cpu": 50.0, "mem": 70.0},
|
|
398
|
+
now=t0 + timedelta(seconds=i * 60))
|
|
399
|
+
m.reset_anomaly_reference()
|
|
400
|
+
# Feed new baseline at 100 — should warm up without contamination
|
|
401
|
+
for i in range(10):
|
|
402
|
+
m.update({"cpu": 100.0, "mem": 70.0},
|
|
403
|
+
now=t0 + timedelta(seconds=(20 + i) * 60))
|
|
404
|
+
assert m._anomaly_trackers["cpu"].n_values == 10
|
|
405
|
+
|
|
406
|
+
def test_reset_noop_when_anomaly_disabled(self):
|
|
407
|
+
m = Monitor(self._parser(), features={"health", "drift"})
|
|
408
|
+
# Should not raise even though anomaly trackers don't exist
|
|
409
|
+
m.reset_anomaly_reference()
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# -----------------------------------------------------------------------
|
|
413
|
+
# WindowConfig serialization
|
|
414
|
+
# -----------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
class TestWindowConfigSerialization:
|
|
417
|
+
def test_to_dict_full(self):
|
|
418
|
+
wc = WindowConfig(drift=10, anomaly=40, correlation=100)
|
|
419
|
+
d = wc.to_dict()
|
|
420
|
+
assert d == {"drift": 10, "anomaly": 40, "correlation": 100}
|
|
421
|
+
|
|
422
|
+
def test_to_dict_partial(self):
|
|
423
|
+
wc = WindowConfig(drift=10)
|
|
424
|
+
d = wc.to_dict()
|
|
425
|
+
assert d == {"drift": 10}
|
|
426
|
+
assert "anomaly" not in d
|
|
427
|
+
assert "correlation" not in d
|
|
428
|
+
|
|
429
|
+
def test_to_dict_empty(self):
|
|
430
|
+
wc = WindowConfig()
|
|
431
|
+
assert wc.to_dict() == {}
|
|
432
|
+
|
|
433
|
+
def test_from_dict_roundtrip(self):
|
|
434
|
+
wc = WindowConfig(drift=10, anomaly=40, correlation=100)
|
|
435
|
+
wc2 = WindowConfig.from_dict(wc.to_dict())
|
|
436
|
+
assert wc2.drift == 10
|
|
437
|
+
assert wc2.anomaly == 40
|
|
438
|
+
assert wc2.correlation == 100
|
|
439
|
+
|
|
440
|
+
def test_from_dict_partial(self):
|
|
441
|
+
wc = WindowConfig.from_dict({"drift": 20})
|
|
442
|
+
assert wc.drift == 20
|
|
443
|
+
assert wc.anomaly is None
|
|
444
|
+
assert wc.correlation is None
|
|
445
|
+
|
|
446
|
+
def test_from_dict_empty(self):
|
|
447
|
+
wc = WindowConfig.from_dict({})
|
|
448
|
+
assert wc.drift is None
|
|
449
|
+
assert wc.anomaly is None
|
|
450
|
+
assert wc.correlation is None
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
from margin.calibrate import calibrate, calibrate_many, parser_from_calibration, CalibrationResult
|
|
3
|
-
from margin.health import Thresholds, Health
|
|
4
|
-
from margin.confidence import Confidence
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class TestCalibrate:
|
|
8
|
-
def test_basic_higher_is_better(self):
|
|
9
|
-
r = calibrate([100.0, 100.0, 100.0])
|
|
10
|
-
assert r.baseline == pytest.approx(100.0)
|
|
11
|
-
assert r.thresholds.higher_is_better is True
|
|
12
|
-
assert r.thresholds.intact == pytest.approx(70.0)
|
|
13
|
-
assert r.thresholds.ablated == pytest.approx(30.0)
|
|
14
|
-
|
|
15
|
-
def test_basic_lower_is_better(self):
|
|
16
|
-
r = calibrate([0.01, 0.01, 0.01], higher_is_better=False)
|
|
17
|
-
assert r.baseline == pytest.approx(0.01)
|
|
18
|
-
assert r.thresholds.higher_is_better is False
|
|
19
|
-
# intact = 0.01 * 1.30 = 0.013
|
|
20
|
-
assert r.thresholds.intact == pytest.approx(0.013, abs=0.001)
|
|
21
|
-
# ablated = 0.01 * 1.70 = 0.017
|
|
22
|
-
assert r.thresholds.ablated == pytest.approx(0.017, abs=0.001)
|
|
23
|
-
|
|
24
|
-
def test_custom_fractions(self):
|
|
25
|
-
r = calibrate([200.0, 200.0], intact_fraction=0.80, ablated_fraction=0.20)
|
|
26
|
-
assert r.thresholds.intact == pytest.approx(160.0)
|
|
27
|
-
assert r.thresholds.ablated == pytest.approx(40.0)
|
|
28
|
-
|
|
29
|
-
def test_std_computed(self):
|
|
30
|
-
r = calibrate([90.0, 100.0, 110.0])
|
|
31
|
-
assert r.std > 0
|
|
32
|
-
assert r.n_samples == 3
|
|
33
|
-
|
|
34
|
-
def test_single_value(self):
|
|
35
|
-
r = calibrate([50.0])
|
|
36
|
-
assert r.baseline == 50.0
|
|
37
|
-
assert r.std == 0.0
|
|
38
|
-
assert r.n_samples == 1
|
|
39
|
-
|
|
40
|
-
def test_empty_raises(self):
|
|
41
|
-
with pytest.raises(ValueError):
|
|
42
|
-
calibrate([])
|
|
43
|
-
|
|
44
|
-
def test_to_dict(self):
|
|
45
|
-
r = calibrate([100.0, 100.0])
|
|
46
|
-
d = r.to_dict()
|
|
47
|
-
assert "baseline" in d
|
|
48
|
-
assert "intact" in d
|
|
49
|
-
assert "ablated" in d
|
|
50
|
-
assert "higher_is_better" in d
|
|
51
|
-
|
|
52
|
-
def test_threshold_validation_holds(self):
|
|
53
|
-
# Higher is better: ablated < intact
|
|
54
|
-
r = calibrate([100.0], higher_is_better=True)
|
|
55
|
-
assert r.thresholds.ablated <= r.thresholds.intact
|
|
56
|
-
|
|
57
|
-
# Lower is better: ablated > intact
|
|
58
|
-
r2 = calibrate([0.01], higher_is_better=False)
|
|
59
|
-
assert r2.thresholds.ablated >= r2.thresholds.intact
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
class TestCalibrateMany:
|
|
63
|
-
def test_returns_baselines_and_thresholds(self):
|
|
64
|
-
baselines, thresholds = calibrate_many({
|
|
65
|
-
"throughput": [100.0, 105.0, 95.0],
|
|
66
|
-
"error_rate": [0.01, 0.012, 0.008],
|
|
67
|
-
}, polarities={"error_rate": False})
|
|
68
|
-
|
|
69
|
-
assert "throughput" in baselines
|
|
70
|
-
assert "error_rate" in baselines
|
|
71
|
-
assert thresholds["throughput"].higher_is_better is True
|
|
72
|
-
assert thresholds["error_rate"].higher_is_better is False
|
|
73
|
-
|
|
74
|
-
def test_default_polarity_is_higher(self):
|
|
75
|
-
_, thresholds = calibrate_many({"x": [50.0, 50.0]})
|
|
76
|
-
assert thresholds["x"].higher_is_better is True
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
class TestParserFromCalibration:
|
|
80
|
-
def test_produces_working_parser(self):
|
|
81
|
-
parser = parser_from_calibration(
|
|
82
|
-
{"throughput": [100.0] * 5, "latency": [50.0] * 5},
|
|
83
|
-
polarities={"latency": False},
|
|
84
|
-
)
|
|
85
|
-
# Healthy readings
|
|
86
|
-
expr = parser.parse({"throughput": 95.0, "latency": 52.0})
|
|
87
|
-
assert expr.health_of("throughput") == Health.INTACT
|
|
88
|
-
assert expr.health_of("latency") == Health.INTACT
|
|
89
|
-
|
|
90
|
-
def test_detects_degradation(self):
|
|
91
|
-
parser = parser_from_calibration(
|
|
92
|
-
{"throughput": [100.0] * 5},
|
|
93
|
-
)
|
|
94
|
-
expr = parser.parse({"throughput": 40.0})
|
|
95
|
-
assert expr.health_of("throughput") == Health.DEGRADED
|
|
96
|
-
|
|
97
|
-
def test_empty_raises(self):
|
|
98
|
-
with pytest.raises(ValueError):
|
|
99
|
-
parser_from_calibration({})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|