margin 0.9.3__tar.gz → 0.9.5__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.3/margin.egg-info → margin-0.9.5}/PKG-INFO +1 -1
- {margin-0.9.3 → margin-0.9.5}/margin/__init__.py +2 -1
- {margin-0.9.3 → margin-0.9.5}/margin/calibrate.py +23 -9
- {margin-0.9.3 → margin-0.9.5}/margin/contract.py +32 -0
- {margin-0.9.3 → margin-0.9.5}/margin/loop.py +3 -1
- {margin-0.9.3 → margin-0.9.5}/margin/streaming.py +28 -3
- {margin-0.9.3 → margin-0.9.5/margin.egg-info}/PKG-INFO +1 -1
- {margin-0.9.3 → margin-0.9.5}/pyproject.toml +1 -1
- {margin-0.9.3 → margin-0.9.5}/tests/test_calibrate.py +75 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_contract.py +50 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_policy_trace.py +40 -17
- {margin-0.9.3 → margin-0.9.5}/tests/test_streaming.py +47 -4
- {margin-0.9.3 → margin-0.9.5}/LICENSE +0 -0
- {margin-0.9.3 → margin-0.9.5}/NOTICE +0 -0
- {margin-0.9.3 → margin-0.9.5}/README.md +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/__main__.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/algebra.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/anomaly.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/bridge.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/causal.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/composite.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/confidence.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/config.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/correlate.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/diff.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/drift.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/events.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/forecast.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/health.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/intent.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/ledger.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/observation.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/persist.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/policy/__init__.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/policy/compose.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/policy/core.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/policy/temporal.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/policy/trace.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/policy/tuning.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/policy/validate.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/predicates.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/provenance.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/transitions.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/uncertain.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin/validity.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin.egg-info/SOURCES.txt +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin.egg-info/dependency_links.txt +0 -0
- {margin-0.9.3 → margin-0.9.5}/margin.egg-info/top_level.txt +0 -0
- {margin-0.9.3 → margin-0.9.5}/setup.cfg +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_algebra.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_anomaly.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_bridge.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_causal.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_composite.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_confidence.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_config.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_correlate.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_diff.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_drift.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_events.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_forecast.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_health.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_healthcare.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_intent.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_ledger.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_loop.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_neuro.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_observation.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_persist.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_policy.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_policy_compose.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_policy_temporal.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_policy_tuning.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_policy_validate.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_predicates.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_printer3d.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_provenance.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_reverse_bridge.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_ros2.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_staleness.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_transformer.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_transitions.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_uncertain.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_validity.py +0 -0
- {margin-0.9.3 → margin-0.9.5}/tests/test_windowing.py +0 -0
|
@@ -82,6 +82,7 @@ from .contract import (
|
|
|
82
82
|
HealthTarget, ReachHealth, SustainHealth,
|
|
83
83
|
RecoveryThreshold, NoHarmful,
|
|
84
84
|
ContractResult, Contract,
|
|
85
|
+
contract_term_from_dict,
|
|
85
86
|
)
|
|
86
87
|
|
|
87
88
|
# Correlation
|
|
@@ -164,7 +165,7 @@ __all__ = [
|
|
|
164
165
|
"TermStatus", "TermResult", "ContractTerm",
|
|
165
166
|
"HealthTarget", "ReachHealth", "SustainHealth",
|
|
166
167
|
"RecoveryThreshold", "NoHarmful",
|
|
167
|
-
"ContractResult", "Contract",
|
|
168
|
+
"ContractResult", "Contract", "contract_term_from_dict",
|
|
168
169
|
# Correlation
|
|
169
170
|
"Correlation", "CorrelationMatrix",
|
|
170
171
|
"correlate_pair", "correlate", "correlate_from_ledger",
|
|
@@ -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
|
|
|
@@ -298,7 +312,7 @@ def recalibrate_parser(
|
|
|
298
312
|
components: Optional[list[str]] = None,
|
|
299
313
|
intact_fraction: float = 0.70,
|
|
300
314
|
ablated_fraction: float = 0.30,
|
|
301
|
-
active_min: float =
|
|
315
|
+
active_min: Optional[float] = None,
|
|
302
316
|
use_std: bool = False,
|
|
303
317
|
intact_std_multiplier: float = 1.5,
|
|
304
318
|
ablated_std_multiplier: float = 3.0,
|
|
@@ -324,8 +338,8 @@ def recalibrate_parser(
|
|
|
324
338
|
all components in new_healthy_data are recalibrated
|
|
325
339
|
intact_fraction: fraction of baseline for intact threshold (fraction mode)
|
|
326
340
|
ablated_fraction: fraction of baseline for ablated threshold (fraction mode)
|
|
327
|
-
active_min:
|
|
328
|
-
|
|
341
|
+
active_min: None (default) inherits from each component's existing
|
|
342
|
+
threshold; pass an explicit float to override
|
|
329
343
|
use_std: if True, use std-based thresholds (see calibrate())
|
|
330
344
|
intact_std_multiplier: std deviations for intact threshold (std mode)
|
|
331
345
|
ablated_std_multiplier: std deviations for ablated threshold (std mode)
|
|
@@ -359,8 +373,8 @@ def recalibrate_parser(
|
|
|
359
373
|
# Resolve polarity: explicit override → existing parser polarity → True
|
|
360
374
|
existing_t = parser.component_thresholds.get(name) or parser.thresholds
|
|
361
375
|
hib = polarities.get(name, existing_t.higher_is_better)
|
|
362
|
-
# Inherit active_min from existing thresholds unless caller
|
|
363
|
-
component_active_min = existing_t.active_min if active_min
|
|
376
|
+
# Inherit active_min from existing thresholds unless caller provides an explicit override
|
|
377
|
+
component_active_min = existing_t.active_min if active_min is None else active_min
|
|
364
378
|
result = calibrate(
|
|
365
379
|
vals, hib, intact_fraction, ablated_fraction, component_active_min,
|
|
366
380
|
use_std=use_std,
|
|
@@ -232,6 +232,38 @@ class NoHarmful(ContractTerm):
|
|
|
232
232
|
return {"type": "no_harmful", "name": self.name, "over_steps": self.over_steps}
|
|
233
233
|
|
|
234
234
|
|
|
235
|
+
_TERM_TYPES: dict = {
|
|
236
|
+
"health_target": lambda d: HealthTarget(
|
|
237
|
+
name=d["name"], component=d["component"],
|
|
238
|
+
target=Health(d["target"]), or_better=d.get("or_better", True),
|
|
239
|
+
),
|
|
240
|
+
"reach_health": lambda d: ReachHealth(
|
|
241
|
+
name=d["name"], component=d["component"],
|
|
242
|
+
target=Health(d["target"]), within_steps=d["within_steps"],
|
|
243
|
+
),
|
|
244
|
+
"sustain_health": lambda d: SustainHealth(
|
|
245
|
+
name=d["name"], component=d["component"],
|
|
246
|
+
target=Health(d["target"]), for_steps=d["for_steps"],
|
|
247
|
+
or_better=d.get("or_better", True),
|
|
248
|
+
),
|
|
249
|
+
"recovery_threshold": lambda d: RecoveryThreshold(
|
|
250
|
+
name=d["name"], min_recovery=d["min_recovery"], over_steps=d["over_steps"],
|
|
251
|
+
),
|
|
252
|
+
"no_harmful": lambda d: NoHarmful(
|
|
253
|
+
name=d["name"], over_steps=d["over_steps"],
|
|
254
|
+
),
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def contract_term_from_dict(d: dict) -> ContractTerm:
|
|
259
|
+
"""Deserialize a ContractTerm subclass from a dict produced by its to_dict()."""
|
|
260
|
+
term_type = d.get("type")
|
|
261
|
+
if term_type not in _TERM_TYPES:
|
|
262
|
+
raise ValueError(f"Unknown contract term type: {term_type!r}. "
|
|
263
|
+
f"Expected one of: {list(_TERM_TYPES)}")
|
|
264
|
+
return _TERM_TYPES[term_type](d)
|
|
265
|
+
|
|
266
|
+
|
|
235
267
|
# -----------------------------------------------------------------------
|
|
236
268
|
# Contract — a set of terms evaluated together
|
|
237
269
|
# -----------------------------------------------------------------------
|
|
@@ -50,7 +50,9 @@ class StepResult:
|
|
|
50
50
|
@property
|
|
51
51
|
def acted(self) -> bool:
|
|
52
52
|
"""True if a real correction was produced (not NOOP, not escalation)."""
|
|
53
|
-
|
|
53
|
+
if self.correction is not None and self.correction.is_active():
|
|
54
|
+
return True
|
|
55
|
+
return any(c.is_active() for c in self.corrections)
|
|
54
56
|
|
|
55
57
|
@property
|
|
56
58
|
def escalated(self) -> bool:
|
|
@@ -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.5"
|
|
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"}
|
|
@@ -176,3 +176,78 @@ class TestNeedsRecalibrationMany:
|
|
|
176
176
|
flags = needs_recalibration_many(cal, recent)
|
|
177
177
|
assert flags["cpu"] is True
|
|
178
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)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class TestRecalibrateParserActiveMin:
|
|
214
|
+
def test_none_inherits_from_existing_parser(self):
|
|
215
|
+
from margin.calibrate import recalibrate_parser
|
|
216
|
+
# Parser with non-default active_min
|
|
217
|
+
parser = parser_from_calibration({"cpu": [100.0] * 5})
|
|
218
|
+
# Manually set a custom active_min on the thresholds
|
|
219
|
+
import dataclasses
|
|
220
|
+
custom_thresh = dataclasses.replace(parser.thresholds, active_min=0.15)
|
|
221
|
+
parser2 = parser.__class__(
|
|
222
|
+
baselines=parser.baselines,
|
|
223
|
+
thresholds=custom_thresh,
|
|
224
|
+
component_thresholds=parser.component_thresholds,
|
|
225
|
+
)
|
|
226
|
+
new_parser, _ = recalibrate_parser(parser2, {"cpu": [110.0] * 5})
|
|
227
|
+
# active_min should be inherited as 0.15, not the old 0.05 sentinel
|
|
228
|
+
result_thresh = new_parser.component_thresholds.get("cpu") or new_parser.thresholds
|
|
229
|
+
assert result_thresh.active_min == pytest.approx(0.15)
|
|
230
|
+
|
|
231
|
+
def test_explicit_active_min_overrides_existing(self):
|
|
232
|
+
from margin.calibrate import recalibrate_parser
|
|
233
|
+
parser = parser_from_calibration({"cpu": [100.0] * 5})
|
|
234
|
+
new_parser, _ = recalibrate_parser(parser, {"cpu": [110.0] * 5}, active_min=0.20)
|
|
235
|
+
result_thresh = new_parser.component_thresholds.get("cpu") or new_parser.thresholds
|
|
236
|
+
assert result_thresh.active_min == pytest.approx(0.20)
|
|
237
|
+
|
|
238
|
+
def test_explicit_005_is_not_swallowed(self):
|
|
239
|
+
# Regression: before fix, active_min=0.05 was sentinel for "inherit"
|
|
240
|
+
# so an explicit 0.05 would be silently replaced by existing parser's value
|
|
241
|
+
from margin.calibrate import recalibrate_parser
|
|
242
|
+
import dataclasses
|
|
243
|
+
parser = parser_from_calibration({"cpu": [100.0] * 5})
|
|
244
|
+
custom_thresh = dataclasses.replace(parser.thresholds, active_min=0.30)
|
|
245
|
+
parser2 = parser.__class__(
|
|
246
|
+
baselines=parser.baselines,
|
|
247
|
+
thresholds=custom_thresh,
|
|
248
|
+
component_thresholds=parser.component_thresholds,
|
|
249
|
+
)
|
|
250
|
+
# Explicitly pass 0.05 — should get 0.05, not 0.30
|
|
251
|
+
new_parser, _ = recalibrate_parser(parser2, {"cpu": [110.0] * 5}, active_min=0.05)
|
|
252
|
+
result_thresh = new_parser.component_thresholds.get("cpu") or new_parser.thresholds
|
|
253
|
+
assert result_thresh.active_min == pytest.approx(0.05)
|
|
@@ -222,3 +222,53 @@ class TestContract:
|
|
|
222
222
|
d = r.to_dict()
|
|
223
223
|
assert d["all_met"] is True
|
|
224
224
|
assert d["n_met"] == 1
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class TestContractTermFromDict:
|
|
228
|
+
def test_health_target_roundtrip(self):
|
|
229
|
+
from margin import contract_term_from_dict
|
|
230
|
+
from margin import HealthTarget
|
|
231
|
+
t = HealthTarget(name="t1", component="cpu", target=Health.INTACT)
|
|
232
|
+
t2 = contract_term_from_dict(t.to_dict())
|
|
233
|
+
assert isinstance(t2, HealthTarget)
|
|
234
|
+
assert t2.name == "t1"
|
|
235
|
+
assert t2.component == "cpu"
|
|
236
|
+
assert t2.target == Health.INTACT
|
|
237
|
+
assert t2.or_better is True
|
|
238
|
+
|
|
239
|
+
def test_reach_health_roundtrip(self):
|
|
240
|
+
from margin import contract_term_from_dict
|
|
241
|
+
from margin import ReachHealth
|
|
242
|
+
t = ReachHealth(name="r1", component="mem", target=Health.DEGRADED, within_steps=10)
|
|
243
|
+
t2 = contract_term_from_dict(t.to_dict())
|
|
244
|
+
assert isinstance(t2, ReachHealth)
|
|
245
|
+
assert t2.within_steps == 10
|
|
246
|
+
|
|
247
|
+
def test_sustain_health_roundtrip(self):
|
|
248
|
+
from margin import contract_term_from_dict
|
|
249
|
+
from margin import SustainHealth
|
|
250
|
+
t = SustainHealth(name="s1", component="cpu", target=Health.INTACT,
|
|
251
|
+
for_steps=5, or_better=False)
|
|
252
|
+
t2 = contract_term_from_dict(t.to_dict())
|
|
253
|
+
assert isinstance(t2, SustainHealth)
|
|
254
|
+
assert t2.for_steps == 5
|
|
255
|
+
assert t2.or_better is False
|
|
256
|
+
|
|
257
|
+
def test_recovery_threshold_roundtrip(self):
|
|
258
|
+
from margin import contract_term_from_dict, RecoveryThreshold
|
|
259
|
+
t = RecoveryThreshold(name="rt", min_recovery=0.8, over_steps=20)
|
|
260
|
+
t2 = contract_term_from_dict(t.to_dict())
|
|
261
|
+
assert isinstance(t2, RecoveryThreshold)
|
|
262
|
+
assert t2.min_recovery == pytest.approx(0.8)
|
|
263
|
+
|
|
264
|
+
def test_no_harmful_roundtrip(self):
|
|
265
|
+
from margin import contract_term_from_dict, NoHarmful
|
|
266
|
+
t = NoHarmful(name="nh", over_steps=15)
|
|
267
|
+
t2 = contract_term_from_dict(t.to_dict())
|
|
268
|
+
assert isinstance(t2, NoHarmful)
|
|
269
|
+
assert t2.over_steps == 15
|
|
270
|
+
|
|
271
|
+
def test_unknown_type_raises(self):
|
|
272
|
+
from margin import contract_term_from_dict
|
|
273
|
+
with pytest.raises(ValueError, match="Unknown"):
|
|
274
|
+
contract_term_from_dict({"type": "nonexistent", "name": "x"})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import pytest
|
|
2
|
+
from margin.loop import step
|
|
2
3
|
from margin.policy.core import Policy, PolicyRule, Action, Constraint, Escalation, EscalationLevel
|
|
3
4
|
from margin.policy.trace import trace_evaluate, trace_backtest, DecisionTrace
|
|
4
5
|
from margin.observation import Observation, Expression, Op
|
|
@@ -140,9 +141,6 @@ class TestBacktestProposedBy:
|
|
|
140
141
|
|
|
141
142
|
class TestMultiRule:
|
|
142
143
|
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
144
|
return Policy(
|
|
147
145
|
name="multi",
|
|
148
146
|
multi_rule=True,
|
|
@@ -169,9 +167,6 @@ class TestMultiRule:
|
|
|
169
167
|
def test_trace_always_has_all_matched_results(self):
|
|
170
168
|
# trace_evaluate always populates dt.results for all matches;
|
|
171
169
|
# 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
170
|
policy = Policy(
|
|
176
171
|
name="single",
|
|
177
172
|
multi_rule=False,
|
|
@@ -191,7 +186,6 @@ class TestMultiRule:
|
|
|
191
186
|
assert d["multi_rule"] is True
|
|
192
187
|
|
|
193
188
|
def test_multi_rule_default_is_false(self):
|
|
194
|
-
from margin.policy.core import Policy
|
|
195
189
|
policy = Policy(name="p", rules=[])
|
|
196
190
|
assert policy.multi_rule is False
|
|
197
191
|
assert policy.to_dict()["multi_rule"] is False
|
|
@@ -199,11 +193,6 @@ class TestMultiRule:
|
|
|
199
193
|
|
|
200
194
|
class TestStepResultCorrections:
|
|
201
195
|
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
196
|
policy = Policy(
|
|
208
197
|
name="multi",
|
|
209
198
|
multi_rule=True,
|
|
@@ -216,11 +205,6 @@ class TestStepResultCorrections:
|
|
|
216
205
|
assert len(result.corrections) >= 1
|
|
217
206
|
|
|
218
207
|
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
208
|
policy = Policy(
|
|
225
209
|
name="single",
|
|
226
210
|
multi_rule=False,
|
|
@@ -229,3 +213,42 @@ class TestStepResultCorrections:
|
|
|
229
213
|
result = step(_expr(_obs("x", Health.DEGRADED)), policy)
|
|
230
214
|
# multi_rule=False → corrections list is empty (only .correction populated)
|
|
231
215
|
assert result.corrections == []
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class TestStepResultActed:
|
|
219
|
+
def test_acted_true_when_correction_active(self):
|
|
220
|
+
policy = Policy(
|
|
221
|
+
name="p", multi_rule=False,
|
|
222
|
+
rules=[PolicyRule("r", any_degraded(), Action(target="*", op=Op.RESTORE, alpha=0.5))],
|
|
223
|
+
)
|
|
224
|
+
result = step(_expr(_obs("x", Health.DEGRADED)), policy)
|
|
225
|
+
assert result.acted is True
|
|
226
|
+
|
|
227
|
+
def test_acted_false_on_noop(self):
|
|
228
|
+
from margin.predicates import any_health
|
|
229
|
+
policy = Policy(
|
|
230
|
+
name="p", multi_rule=False,
|
|
231
|
+
rules=[PolicyRule("r", any_health(Health.DEGRADED), Action(target="*", op=Op.NOOP))],
|
|
232
|
+
)
|
|
233
|
+
result = step(_expr(_obs("x", Health.DEGRADED)), policy)
|
|
234
|
+
assert result.acted is False
|
|
235
|
+
|
|
236
|
+
def test_acted_true_when_correction_in_multi_rule_corrections_list(self):
|
|
237
|
+
# First result is Escalation (no min_alpha satisfied), second is a Correction
|
|
238
|
+
# multi_rule=True → both fire; acted should see the Correction in .corrections
|
|
239
|
+
policy = Policy(
|
|
240
|
+
name="p", multi_rule=True,
|
|
241
|
+
rules=[
|
|
242
|
+
PolicyRule("esc", any_degraded(),
|
|
243
|
+
Action(target="*", op=Op.RESTORE, alpha=0.1),
|
|
244
|
+
constraint=Constraint(min_alpha=0.9), # suppressed → escalation
|
|
245
|
+
priority=10),
|
|
246
|
+
PolicyRule("fix", any_degraded(),
|
|
247
|
+
Action(target="*", op=Op.RESTORE, alpha=0.5),
|
|
248
|
+
priority=5),
|
|
249
|
+
],
|
|
250
|
+
)
|
|
251
|
+
result = step(_expr(_obs("x", Health.DEGRADED, 10.0)), policy)
|
|
252
|
+
# .corrections contains the Correction from "fix"
|
|
253
|
+
assert any(c.is_active() for c in result.corrections)
|
|
254
|
+
assert result.acted is True
|
|
@@ -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
|
|
|
@@ -330,15 +331,16 @@ class TestMonitorFeatures:
|
|
|
330
331
|
m = Monitor(self._parser(), features={"health", "drift"})
|
|
331
332
|
assert isinstance(m.features, frozenset)
|
|
332
333
|
|
|
333
|
-
def
|
|
334
|
+
def test_status_always_has_drift_anomaly_correlations_keys(self):
|
|
334
335
|
m = Monitor(self._parser(), features={"health"})
|
|
335
336
|
for i in range(5):
|
|
336
337
|
m.update({"cpu": 50.0 - i, "mem": 70.0},
|
|
337
338
|
now=t0 + timedelta(seconds=i * 60))
|
|
338
339
|
s = m.status()
|
|
339
|
-
#
|
|
340
|
-
assert "drift"
|
|
341
|
-
assert "anomaly"
|
|
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"] == {}
|
|
342
344
|
|
|
343
345
|
def test_repr_shows_features_when_partial(self):
|
|
344
346
|
m = Monitor(self._parser(), features={"health", "drift"})
|
|
@@ -405,3 +407,44 @@ class TestMonitorResetAnomalyReference:
|
|
|
405
407
|
m = Monitor(self._parser(), features={"health", "drift"})
|
|
406
408
|
# Should not raise even though anomaly trackers don't exist
|
|
407
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
|
|
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
|