margin 0.9.1__tar.gz → 0.9.3__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.1/margin.egg-info → margin-0.9.3}/PKG-INFO +1 -1
- {margin-0.9.1 → margin-0.9.3}/margin/health.py +31 -0
- {margin-0.9.1 → margin-0.9.3}/margin/loop.py +9 -0
- {margin-0.9.1 → margin-0.9.3}/margin/observation.py +12 -2
- {margin-0.9.1 → margin-0.9.3}/margin/persist.py +12 -7
- {margin-0.9.1 → margin-0.9.3}/margin/policy/core.py +3 -1
- {margin-0.9.1 → margin-0.9.3}/margin/policy/trace.py +15 -11
- {margin-0.9.1 → margin-0.9.3}/margin/streaming.py +46 -25
- {margin-0.9.1 → margin-0.9.3/margin.egg-info}/PKG-INFO +1 -1
- {margin-0.9.1 → margin-0.9.3}/pyproject.toml +1 -1
- {margin-0.9.1 → margin-0.9.3}/tests/test_calibrate.py +79 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_health.py +45 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_observation.py +58 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_persist.py +50 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_policy_trace.py +93 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_streaming.py +121 -0
- {margin-0.9.1 → margin-0.9.3}/LICENSE +0 -0
- {margin-0.9.1 → margin-0.9.3}/NOTICE +0 -0
- {margin-0.9.1 → margin-0.9.3}/README.md +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin/__init__.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin/__main__.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin/algebra.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin/anomaly.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin/bridge.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin/calibrate.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin/causal.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin/composite.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin/confidence.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin/config.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin/contract.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin/correlate.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin/diff.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin/drift.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin/events.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin/forecast.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin/intent.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin/ledger.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin/policy/__init__.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin/policy/compose.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin/policy/temporal.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin/policy/tuning.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin/policy/validate.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin/predicates.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin/provenance.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin/transitions.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin/uncertain.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin/validity.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin.egg-info/SOURCES.txt +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin.egg-info/dependency_links.txt +0 -0
- {margin-0.9.1 → margin-0.9.3}/margin.egg-info/top_level.txt +0 -0
- {margin-0.9.1 → margin-0.9.3}/setup.cfg +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_algebra.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_anomaly.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_bridge.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_causal.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_composite.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_confidence.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_config.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_contract.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_correlate.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_diff.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_drift.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_events.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_forecast.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_healthcare.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_intent.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_ledger.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_loop.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_neuro.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_policy.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_policy_compose.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_policy_temporal.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_policy_tuning.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_policy_validate.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_predicates.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_printer3d.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_provenance.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_reverse_bridge.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_ros2.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_staleness.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_transformer.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_transitions.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_uncertain.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_validity.py +0 -0
- {margin-0.9.1 → margin-0.9.3}/tests/test_windowing.py +0 -0
|
@@ -46,6 +46,7 @@ class Thresholds:
|
|
|
46
46
|
(throughput, signal), False means lower values are
|
|
47
47
|
healthier (error rate, latency)
|
|
48
48
|
active_min: minimum correction magnitude to count as "active"
|
|
49
|
+
labels: optional display labels keyed by Health.value strings
|
|
49
50
|
|
|
50
51
|
For higher_is_better=True: intact >= ablated, value >= intact → INTACT
|
|
51
52
|
For higher_is_better=False: intact <= ablated, value <= intact → INTACT
|
|
@@ -54,6 +55,36 @@ class Thresholds:
|
|
|
54
55
|
ablated: float
|
|
55
56
|
higher_is_better: bool = True
|
|
56
57
|
active_min: float = 0.05
|
|
58
|
+
labels: Optional[dict[str, str]] = None
|
|
59
|
+
|
|
60
|
+
def label_for(self, health: 'Health') -> str:
|
|
61
|
+
"""Return the display label for a health state, or the state name if no label set."""
|
|
62
|
+
if self.labels:
|
|
63
|
+
return self.labels.get(health.value, health.value)
|
|
64
|
+
return health.value
|
|
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
|
+
)
|
|
57
88
|
|
|
58
89
|
def __post_init__(self):
|
|
59
90
|
if self.higher_is_better and self.ablated > self.intact:
|
|
@@ -45,6 +45,7 @@ class StepResult:
|
|
|
45
45
|
correction: Optional[Correction] = None
|
|
46
46
|
escalation: Optional[Escalation] = None
|
|
47
47
|
contract: Optional[ContractResult] = None
|
|
48
|
+
corrections: list = field(default_factory=list) # all fired corrections (multi_rule)
|
|
48
49
|
|
|
49
50
|
@property
|
|
50
51
|
def acted(self) -> bool:
|
|
@@ -74,6 +75,8 @@ class StepResult:
|
|
|
74
75
|
d["decision"] = self.decision.to_dict()
|
|
75
76
|
if self.correction:
|
|
76
77
|
d["correction"] = self.correction.to_dict()
|
|
78
|
+
if self.corrections:
|
|
79
|
+
d["corrections"] = [c.to_dict() for c in self.corrections]
|
|
77
80
|
if self.escalation:
|
|
78
81
|
d["escalation"] = self.escalation.to_dict()
|
|
79
82
|
if self.contract:
|
|
@@ -140,6 +143,11 @@ def step(
|
|
|
140
143
|
elif isinstance(decision.result, Escalation):
|
|
141
144
|
escalation = decision.result
|
|
142
145
|
|
|
146
|
+
# Collect all corrections for multi_rule mode
|
|
147
|
+
all_corrections = []
|
|
148
|
+
if policy.multi_rule:
|
|
149
|
+
all_corrections = [r for r in decision.results if isinstance(r, Correction)]
|
|
150
|
+
|
|
143
151
|
# 3. Evaluate contract
|
|
144
152
|
contract_result = None
|
|
145
153
|
if contract:
|
|
@@ -152,6 +160,7 @@ def step(
|
|
|
152
160
|
correction=correction,
|
|
153
161
|
escalation=escalation,
|
|
154
162
|
contract=contract_result,
|
|
163
|
+
corrections=all_corrections,
|
|
155
164
|
)
|
|
156
165
|
|
|
157
166
|
|
|
@@ -60,6 +60,7 @@ class Observation:
|
|
|
60
60
|
higher_is_better: bool = True
|
|
61
61
|
provenance: list[str] = field(default_factory=list)
|
|
62
62
|
measured_at: Optional[datetime] = None
|
|
63
|
+
health_label: Optional[str] = None
|
|
63
64
|
|
|
64
65
|
@property
|
|
65
66
|
def sigma(self) -> float:
|
|
@@ -91,10 +92,11 @@ class Observation:
|
|
|
91
92
|
|
|
92
93
|
def to_atom(self) -> str:
|
|
93
94
|
"""Compact string: NAME:HEALTH(±σ)"""
|
|
95
|
+
display = self.health_label or self.health.value
|
|
94
96
|
if self.health == Health.OOD:
|
|
95
|
-
return f"{self.name}:{
|
|
97
|
+
return f"{self.name}:{display}"
|
|
96
98
|
sign = "+" if self.sigma >= 0 else ""
|
|
97
|
-
return f"{self.name}:{
|
|
99
|
+
return f"{self.name}:{display}({sign}{self.sigma:.2f}σ)"
|
|
98
100
|
|
|
99
101
|
def to_dict(self) -> dict:
|
|
100
102
|
d = {
|
|
@@ -107,6 +109,8 @@ class Observation:
|
|
|
107
109
|
"higher_is_better": self.higher_is_better,
|
|
108
110
|
"provenance": self.provenance,
|
|
109
111
|
}
|
|
112
|
+
if self.health_label is not None:
|
|
113
|
+
d["health_label"] = self.health_label
|
|
110
114
|
if self.measured_at is not None:
|
|
111
115
|
d["measured_at"] = self.measured_at.isoformat()
|
|
112
116
|
return d
|
|
@@ -125,6 +129,7 @@ class Observation:
|
|
|
125
129
|
higher_is_better=d.get("higher_is_better", True),
|
|
126
130
|
provenance=d.get("provenance", []),
|
|
127
131
|
measured_at=measured_at,
|
|
132
|
+
health_label=d.get("health_label"),
|
|
128
133
|
)
|
|
129
134
|
|
|
130
135
|
|
|
@@ -311,6 +316,10 @@ class Parser:
|
|
|
311
316
|
def _thresholds_for(self, name: str) -> Thresholds:
|
|
312
317
|
return self.component_thresholds.get(name, self.thresholds)
|
|
313
318
|
|
|
319
|
+
def label_for(self, component: str, health: Health) -> str:
|
|
320
|
+
"""Return the display label for a component's health state."""
|
|
321
|
+
return self._thresholds_for(component).label_for(health)
|
|
322
|
+
|
|
314
323
|
def parse(
|
|
315
324
|
self,
|
|
316
325
|
values: dict[str, float],
|
|
@@ -343,6 +352,7 @@ class Parser:
|
|
|
343
352
|
name=name, health=h, value=val, baseline=baseline,
|
|
344
353
|
confidence=conf, higher_is_better=ct.higher_is_better,
|
|
345
354
|
provenance=list(provenance),
|
|
355
|
+
health_label=ct.label_for(h) if ct.labels else None,
|
|
346
356
|
))
|
|
347
357
|
|
|
348
358
|
corrections = []
|
|
@@ -33,13 +33,14 @@ def save_monitor(monitor: Monitor, path: str) -> None:
|
|
|
33
33
|
"anomaly_window": monitor.anomaly_window,
|
|
34
34
|
"correlation_window": monitor.correlation_window,
|
|
35
35
|
"anomaly_min_reference": _anomaly_min_ref,
|
|
36
|
+
"features": sorted(monitor.features),
|
|
36
37
|
"drift": {},
|
|
37
38
|
"anomaly": {},
|
|
38
39
|
"correlation": {
|
|
39
40
|
"components": monitor._correlation_tracker.components,
|
|
40
41
|
"series": {c: list(d) for c, d in monitor._correlation_tracker._series.items()},
|
|
41
42
|
"n_updates": monitor._correlation_tracker.n_updates,
|
|
42
|
-
},
|
|
43
|
+
} if monitor._correlation_tracker is not None else None,
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
for name, dt in monitor._drift_trackers.items():
|
|
@@ -71,12 +72,15 @@ def load_monitor(path: str, parser: Parser, **kwargs) -> Monitor:
|
|
|
71
72
|
anomaly_window = data.get("anomaly_window", kwargs.pop("anomaly_window", None))
|
|
72
73
|
correlation_window = data.get("correlation_window", kwargs.pop("correlation_window", None))
|
|
73
74
|
anomaly_min_reference = data.get("anomaly_min_reference", kwargs.pop("anomaly_min_reference", 10))
|
|
75
|
+
features_data = data.get("features", None)
|
|
76
|
+
features = set(features_data) if features_data is not None else None
|
|
74
77
|
monitor = Monitor(
|
|
75
78
|
parser, window=window,
|
|
76
79
|
drift_window=drift_window,
|
|
77
80
|
anomaly_window=anomaly_window,
|
|
78
81
|
correlation_window=correlation_window,
|
|
79
82
|
anomaly_min_reference=anomaly_min_reference,
|
|
83
|
+
features=features,
|
|
80
84
|
**kwargs,
|
|
81
85
|
)
|
|
82
86
|
monitor._step = data.get("step", 0)
|
|
@@ -101,12 +105,13 @@ def load_monitor(path: str, parser: Parser, **kwargs) -> Monitor:
|
|
|
101
105
|
tracker._values.append(v)
|
|
102
106
|
|
|
103
107
|
# Restore correlation series
|
|
104
|
-
corr_data = data.get("correlation"
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
108
|
+
corr_data = data.get("correlation")
|
|
109
|
+
if corr_data is not None and monitor._correlation_tracker is not None:
|
|
110
|
+
for c in monitor._correlation_tracker.components:
|
|
111
|
+
series = corr_data.get("series", {}).get(c, [])
|
|
112
|
+
for v in series:
|
|
113
|
+
monitor._correlation_tracker._series[c].append(v)
|
|
114
|
+
monitor._correlation_tracker._n_updates = corr_data.get("n_updates", 0)
|
|
110
115
|
|
|
111
116
|
return monitor
|
|
112
117
|
|
|
@@ -258,6 +258,7 @@ class Policy:
|
|
|
258
258
|
rules: list[PolicyRule] = field(default_factory=list)
|
|
259
259
|
default_constraint: Optional[Constraint] = None
|
|
260
260
|
default_escalation: Optional[Escalation] = None
|
|
261
|
+
multi_rule: bool = False
|
|
261
262
|
|
|
262
263
|
def evaluate(self, expr: Expression, ledger: Optional[Ledger] = None) -> list[Union[Correction, Escalation]]:
|
|
263
264
|
matching = sorted([r for r in self.rules if r.matches(expr)],
|
|
@@ -305,7 +306,7 @@ class Policy:
|
|
|
305
306
|
return results
|
|
306
307
|
|
|
307
308
|
def to_dict(self) -> dict:
|
|
308
|
-
d: dict = {"name": self.name, "rules": [r.to_dict() for r in self.rules]}
|
|
309
|
+
d: dict = {"name": self.name, "rules": [r.to_dict() for r in self.rules], "multi_rule": self.multi_rule}
|
|
309
310
|
if self.default_constraint:
|
|
310
311
|
d["default_constraint"] = self.default_constraint.to_dict()
|
|
311
312
|
if self.default_escalation:
|
|
@@ -324,6 +325,7 @@ class Policy:
|
|
|
324
325
|
name=d["name"], rules=rules,
|
|
325
326
|
default_constraint=Constraint.from_dict(d["default_constraint"]) if "default_constraint" in d else None,
|
|
326
327
|
default_escalation=Escalation.from_dict(d["default_escalation"]) if "default_escalation" in d else None,
|
|
328
|
+
multi_rule=d.get("multi_rule", False),
|
|
327
329
|
)
|
|
328
330
|
|
|
329
331
|
def __repr__(self) -> str:
|
|
@@ -59,6 +59,7 @@ class DecisionTrace:
|
|
|
59
59
|
winner: Optional[str] = None
|
|
60
60
|
result: Optional[Union[Correction, Escalation]] = None
|
|
61
61
|
causal_context: Optional[dict] = None # from causal.explain_all() if available
|
|
62
|
+
results: list = field(default_factory=list) # all fired corrections/escalations (multi_rule)
|
|
62
63
|
|
|
63
64
|
@property
|
|
64
65
|
def rules_considered(self) -> int:
|
|
@@ -111,6 +112,11 @@ class DecisionTrace:
|
|
|
111
112
|
d["result_type"] = None
|
|
112
113
|
if self.causal_context:
|
|
113
114
|
d["causal_context"] = self.causal_context
|
|
115
|
+
if self.results:
|
|
116
|
+
d["results"] = [
|
|
117
|
+
r.to_dict() if hasattr(r, 'to_dict') else str(r)
|
|
118
|
+
for r in self.results
|
|
119
|
+
]
|
|
114
120
|
return d
|
|
115
121
|
|
|
116
122
|
def __repr__(self) -> str:
|
|
@@ -132,12 +138,12 @@ def trace_evaluate(
|
|
|
132
138
|
attached to the trace for auditability.
|
|
133
139
|
"""
|
|
134
140
|
dt = DecisionTrace(expression=expr, causal_context=causal_context)
|
|
135
|
-
winner_result = None
|
|
136
|
-
winner_name = None
|
|
137
141
|
|
|
138
142
|
# Evaluate all rules in priority order
|
|
139
143
|
sorted_rules = sorted(policy.rules, key=lambda r: r.priority, reverse=True)
|
|
140
144
|
|
|
145
|
+
all_results = []
|
|
146
|
+
|
|
141
147
|
for rule in sorted_rules:
|
|
142
148
|
matched = rule.matches(expr)
|
|
143
149
|
ev = RuleEvaluation(
|
|
@@ -156,26 +162,24 @@ def trace_evaluate(
|
|
|
156
162
|
ev.outcome = "escalation"
|
|
157
163
|
ev.reason = f"{result.level.value}: {result.reason}"
|
|
158
164
|
ev.result = result
|
|
159
|
-
|
|
160
|
-
if winner_result is None:
|
|
161
|
-
winner_result = result
|
|
162
|
-
winner_name = rule.name
|
|
165
|
+
all_results.append((rule.name, result))
|
|
163
166
|
else:
|
|
164
167
|
ev.outcome = "suppressed"
|
|
165
168
|
ev.reason = "evaluate returned None"
|
|
166
169
|
|
|
167
170
|
dt.evaluations.append(ev)
|
|
168
171
|
|
|
169
|
-
if
|
|
170
|
-
|
|
172
|
+
if not all_results and policy.default_escalation:
|
|
173
|
+
default_esc = Escalation(
|
|
171
174
|
policy.default_escalation.level,
|
|
172
175
|
policy.default_escalation.reason,
|
|
173
176
|
"default",
|
|
174
177
|
)
|
|
175
|
-
|
|
178
|
+
all_results.append(("default", default_esc))
|
|
176
179
|
|
|
177
|
-
dt.
|
|
178
|
-
dt.
|
|
180
|
+
dt.results = [r for _, r in all_results]
|
|
181
|
+
dt.winner = all_results[0][0] if all_results else None
|
|
182
|
+
dt.result = all_results[0][1] if all_results else None
|
|
179
183
|
return dt
|
|
180
184
|
|
|
181
185
|
|
|
@@ -364,6 +364,7 @@ class Monitor:
|
|
|
364
364
|
window_config: Optional[WindowConfig] = None,
|
|
365
365
|
min_correlation: float = 0.7,
|
|
366
366
|
anomaly_min_reference: int = 10,
|
|
367
|
+
features: Optional[set] = None,
|
|
367
368
|
):
|
|
368
369
|
self.parser = parser
|
|
369
370
|
self.window = window
|
|
@@ -374,7 +375,10 @@ class Monitor:
|
|
|
374
375
|
self.anomaly_window = cfg.anomaly or anomaly_window or window
|
|
375
376
|
self.correlation_window = cfg.correlation or correlation_window or window
|
|
376
377
|
|
|
377
|
-
|
|
378
|
+
# Resolve feature flags: None means all enabled (backward compatible)
|
|
379
|
+
self.features = frozenset(features) if features is not None else frozenset({"drift", "anomaly", "correlation"})
|
|
380
|
+
|
|
381
|
+
if "anomaly" in self.features and anomaly_min_reference > self.anomaly_window:
|
|
378
382
|
warnings.warn(
|
|
379
383
|
f"Monitor: anomaly_min_reference={anomaly_min_reference} > "
|
|
380
384
|
f"anomaly_window={self.anomaly_window} — AnomalyTrackers will never "
|
|
@@ -386,22 +390,28 @@ class Monitor:
|
|
|
386
390
|
self._expression: Optional[Expression] = None
|
|
387
391
|
self._step = 0
|
|
388
392
|
|
|
389
|
-
# Create per-component trackers
|
|
393
|
+
# Create per-component trackers for enabled features only
|
|
390
394
|
self._drift_trackers: dict[str, DriftTracker] = {}
|
|
391
395
|
self._anomaly_trackers: dict[str, AnomalyTracker] = {}
|
|
392
396
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
name, window=self.anomaly_window, min_reference=anomaly_min_reference,
|
|
397
|
-
)
|
|
397
|
+
if "drift" in self.features:
|
|
398
|
+
for name in parser.baselines:
|
|
399
|
+
self._drift_trackers[name] = DriftTracker(name, window=self.drift_window)
|
|
398
400
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
401
|
+
if "anomaly" in self.features:
|
|
402
|
+
for name in parser.baselines:
|
|
403
|
+
self._anomaly_trackers[name] = AnomalyTracker(
|
|
404
|
+
name, window=self.anomaly_window, min_reference=anomaly_min_reference,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
if "correlation" in self.features:
|
|
408
|
+
self._correlation_tracker: Optional[CorrelationTracker] = CorrelationTracker(
|
|
409
|
+
list(parser.baselines.keys()),
|
|
410
|
+
window=self.correlation_window,
|
|
411
|
+
min_correlation=min_correlation,
|
|
412
|
+
)
|
|
413
|
+
else:
|
|
414
|
+
self._correlation_tracker = None
|
|
405
415
|
|
|
406
416
|
def update(
|
|
407
417
|
self,
|
|
@@ -440,7 +450,8 @@ class Monitor:
|
|
|
440
450
|
self._anomaly_trackers[name].update(obs.value)
|
|
441
451
|
|
|
442
452
|
# Correlation
|
|
443
|
-
self._correlation_tracker
|
|
453
|
+
if self._correlation_tracker is not None:
|
|
454
|
+
self._correlation_tracker.update(values)
|
|
444
455
|
|
|
445
456
|
return self._expression
|
|
446
457
|
|
|
@@ -458,6 +469,8 @@ class Monitor:
|
|
|
458
469
|
|
|
459
470
|
@property
|
|
460
471
|
def correlations(self) -> Optional[CorrelationMatrix]:
|
|
472
|
+
if self._correlation_tracker is None:
|
|
473
|
+
return None
|
|
461
474
|
return self._correlation_tracker.matrix
|
|
462
475
|
|
|
463
476
|
@property
|
|
@@ -469,16 +482,18 @@ class Monitor:
|
|
|
469
482
|
d: dict = {
|
|
470
483
|
"step": self._step,
|
|
471
484
|
"expression": self._expression.to_dict() if self._expression else None,
|
|
472
|
-
"drift": {},
|
|
473
|
-
"anomaly": {},
|
|
474
485
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
486
|
+
if "drift" in self.features:
|
|
487
|
+
d["drift"] = {}
|
|
488
|
+
for name, tracker in self._drift_trackers.items():
|
|
489
|
+
if tracker.classification:
|
|
490
|
+
d["drift"][name] = tracker.classification.to_dict()
|
|
491
|
+
if "anomaly" in self.features:
|
|
492
|
+
d["anomaly"] = {}
|
|
493
|
+
for name, tracker in self._anomaly_trackers.items():
|
|
494
|
+
if tracker.classification:
|
|
495
|
+
d["anomaly"][name] = tracker.classification.to_dict()
|
|
496
|
+
if self._correlation_tracker is not None and self._correlation_tracker.matrix:
|
|
482
497
|
d["correlations"] = self._correlation_tracker.matrix.to_dict()
|
|
483
498
|
return d
|
|
484
499
|
|
|
@@ -506,7 +521,8 @@ class Monitor:
|
|
|
506
521
|
t.reset()
|
|
507
522
|
for t in self._anomaly_trackers.values():
|
|
508
523
|
t.reset()
|
|
509
|
-
self._correlation_tracker
|
|
524
|
+
if self._correlation_tracker is not None:
|
|
525
|
+
self._correlation_tracker.reset()
|
|
510
526
|
|
|
511
527
|
def __repr__(self) -> str:
|
|
512
528
|
windows_uniform = (self.drift_window == self.anomaly_window == self.correlation_window)
|
|
@@ -514,4 +530,9 @@ class Monitor:
|
|
|
514
530
|
w = f"window={self.drift_window}"
|
|
515
531
|
else:
|
|
516
532
|
w = f"drift={self.drift_window}, anomaly={self.anomaly_window}, corr={self.correlation_window}"
|
|
517
|
-
|
|
533
|
+
all_features = frozenset({"drift", "anomaly", "correlation"})
|
|
534
|
+
if self.features == all_features:
|
|
535
|
+
feat = ""
|
|
536
|
+
else:
|
|
537
|
+
feat = f", features={{{', '.join(sorted(self.features))}}}"
|
|
538
|
+
return f"Monitor(step={self._step}, {len(self._drift_trackers)} components, {w}{feat})"
|
|
@@ -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.3"
|
|
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"}
|
|
@@ -97,3 +97,82 @@ class TestParserFromCalibration:
|
|
|
97
97
|
def test_empty_raises(self):
|
|
98
98
|
with pytest.raises(ValueError):
|
|
99
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
|
|
@@ -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 == []
|
|
@@ -284,3 +284,124 @@ class TestMonitor:
|
|
|
284
284
|
m = Monitor(self._parser())
|
|
285
285
|
assert "Monitor" in repr(m)
|
|
286
286
|
assert "3 components" in repr(m)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# -----------------------------------------------------------------------
|
|
290
|
+
# Monitor — feature flags
|
|
291
|
+
# -----------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
class TestMonitorFeatures:
|
|
294
|
+
def _parser(self):
|
|
295
|
+
return Parser(
|
|
296
|
+
baselines={"cpu": 50.0, "mem": 70.0},
|
|
297
|
+
thresholds=Thresholds(intact=40.0, ablated=10.0),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def test_default_features_all_enabled(self):
|
|
301
|
+
m = Monitor(self._parser())
|
|
302
|
+
assert "drift" in m.features
|
|
303
|
+
assert "anomaly" in m.features
|
|
304
|
+
assert "correlation" in m.features
|
|
305
|
+
|
|
306
|
+
def test_features_health_only_skips_trackers(self):
|
|
307
|
+
m = Monitor(self._parser(), features={"health"})
|
|
308
|
+
assert len(m._drift_trackers) == 0
|
|
309
|
+
assert len(m._anomaly_trackers) == 0
|
|
310
|
+
assert m._correlation_tracker is None
|
|
311
|
+
|
|
312
|
+
def test_features_health_drift_no_anomaly(self):
|
|
313
|
+
m = Monitor(self._parser(), features={"health", "drift"})
|
|
314
|
+
assert len(m._drift_trackers) == 2
|
|
315
|
+
assert len(m._anomaly_trackers) == 0
|
|
316
|
+
assert m._correlation_tracker is None
|
|
317
|
+
|
|
318
|
+
def test_features_health_anomaly_no_drift(self):
|
|
319
|
+
m = Monitor(self._parser(), features={"health", "anomaly"})
|
|
320
|
+
assert len(m._drift_trackers) == 0
|
|
321
|
+
assert len(m._anomaly_trackers) == 2
|
|
322
|
+
|
|
323
|
+
def test_update_works_with_partial_features(self):
|
|
324
|
+
m = Monitor(self._parser(), features={"health", "drift"})
|
|
325
|
+
expr = m.update({"cpu": 48.0, "mem": 65.0})
|
|
326
|
+
assert expr is not None
|
|
327
|
+
assert m.step == 1
|
|
328
|
+
|
|
329
|
+
def test_features_is_frozenset(self):
|
|
330
|
+
m = Monitor(self._parser(), features={"health", "drift"})
|
|
331
|
+
assert isinstance(m.features, frozenset)
|
|
332
|
+
|
|
333
|
+
def test_status_omits_disabled_feature_keys(self):
|
|
334
|
+
m = Monitor(self._parser(), features={"health"})
|
|
335
|
+
for i in range(5):
|
|
336
|
+
m.update({"cpu": 50.0 - i, "mem": 70.0},
|
|
337
|
+
now=t0 + timedelta(seconds=i * 60))
|
|
338
|
+
s = m.status()
|
|
339
|
+
# drift disabled → "drift" key absent
|
|
340
|
+
assert "drift" not in s
|
|
341
|
+
assert "anomaly" not in s
|
|
342
|
+
|
|
343
|
+
def test_repr_shows_features_when_partial(self):
|
|
344
|
+
m = Monitor(self._parser(), features={"health", "drift"})
|
|
345
|
+
r = repr(m)
|
|
346
|
+
assert "features" in r
|
|
347
|
+
|
|
348
|
+
def test_repr_no_features_label_when_all_enabled(self):
|
|
349
|
+
m = Monitor(self._parser())
|
|
350
|
+
r = repr(m)
|
|
351
|
+
assert "features" not in r
|
|
352
|
+
|
|
353
|
+
def test_reset_works_with_partial_features(self):
|
|
354
|
+
m = Monitor(self._parser(), features={"health", "drift"})
|
|
355
|
+
for i in range(5):
|
|
356
|
+
m.update({"cpu": 50.0 - i, "mem": 70.0})
|
|
357
|
+
m.reset()
|
|
358
|
+
assert m.step == 0
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# -----------------------------------------------------------------------
|
|
362
|
+
# Monitor — reset_anomaly_reference
|
|
363
|
+
# -----------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
class TestMonitorResetAnomalyReference:
|
|
366
|
+
def _parser(self):
|
|
367
|
+
return Parser(
|
|
368
|
+
baselines={"cpu": 50.0, "mem": 70.0},
|
|
369
|
+
thresholds=Thresholds(intact=40.0, ablated=10.0),
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
def test_reset_clears_all_reference_values(self):
|
|
373
|
+
m = Monitor(self._parser(), anomaly_min_reference=5)
|
|
374
|
+
for i in range(10):
|
|
375
|
+
m.update({"cpu": 50.0, "mem": 70.0},
|
|
376
|
+
now=t0 + timedelta(seconds=i * 60))
|
|
377
|
+
assert m._anomaly_trackers["cpu"].n_values == 10
|
|
378
|
+
m.reset_anomaly_reference()
|
|
379
|
+
assert m._anomaly_trackers["cpu"].n_values == 0
|
|
380
|
+
assert m._anomaly_trackers["mem"].n_values == 0
|
|
381
|
+
|
|
382
|
+
def test_reset_specific_components(self):
|
|
383
|
+
m = Monitor(self._parser(), anomaly_min_reference=5)
|
|
384
|
+
for i in range(10):
|
|
385
|
+
m.update({"cpu": 50.0, "mem": 70.0},
|
|
386
|
+
now=t0 + timedelta(seconds=i * 60))
|
|
387
|
+
m.reset_anomaly_reference(components=["cpu"])
|
|
388
|
+
assert m._anomaly_trackers["cpu"].n_values == 0
|
|
389
|
+
assert m._anomaly_trackers["mem"].n_values == 10
|
|
390
|
+
|
|
391
|
+
def test_reset_allows_fresh_reference(self):
|
|
392
|
+
m = Monitor(self._parser(), anomaly_min_reference=5)
|
|
393
|
+
# Build reference at baseline=50
|
|
394
|
+
for i in range(10):
|
|
395
|
+
m.update({"cpu": 50.0, "mem": 70.0},
|
|
396
|
+
now=t0 + timedelta(seconds=i * 60))
|
|
397
|
+
m.reset_anomaly_reference()
|
|
398
|
+
# Feed new baseline at 100 — should warm up without contamination
|
|
399
|
+
for i in range(10):
|
|
400
|
+
m.update({"cpu": 100.0, "mem": 70.0},
|
|
401
|
+
now=t0 + timedelta(seconds=(20 + i) * 60))
|
|
402
|
+
assert m._anomaly_trackers["cpu"].n_values == 10
|
|
403
|
+
|
|
404
|
+
def test_reset_noop_when_anomaly_disabled(self):
|
|
405
|
+
m = Monitor(self._parser(), features={"health", "drift"})
|
|
406
|
+
# Should not raise even though anomaly trackers don't exist
|
|
407
|
+
m.reset_anomaly_reference()
|
|
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
|