margin 0.9.5__tar.gz → 0.9.6__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.5/margin.egg-info → margin-0.9.6}/PKG-INFO +1 -1
- {margin-0.9.5 → margin-0.9.6}/margin/calibrate.py +7 -0
- {margin-0.9.5 → margin-0.9.6}/margin/config.py +34 -7
- {margin-0.9.5 → margin-0.9.6}/margin/contract.py +15 -0
- {margin-0.9.5 → margin-0.9.6}/margin/loop.py +5 -1
- {margin-0.9.5 → margin-0.9.6}/margin/observation.py +7 -0
- {margin-0.9.5 → margin-0.9.6}/margin/policy/core.py +23 -0
- {margin-0.9.5 → margin-0.9.6}/margin/streaming.py +12 -1
- {margin-0.9.5 → margin-0.9.6/margin.egg-info}/PKG-INFO +1 -1
- {margin-0.9.5 → margin-0.9.6}/pyproject.toml +1 -1
- {margin-0.9.5 → margin-0.9.6}/tests/test_config.py +63 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_loop.py +37 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_observation.py +38 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_policy.py +33 -0
- {margin-0.9.5 → margin-0.9.6}/LICENSE +0 -0
- {margin-0.9.5 → margin-0.9.6}/NOTICE +0 -0
- {margin-0.9.5 → margin-0.9.6}/README.md +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin/__init__.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin/__main__.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin/algebra.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin/anomaly.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin/bridge.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin/causal.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin/composite.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin/confidence.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin/correlate.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin/diff.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin/drift.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin/events.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin/forecast.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin/health.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin/intent.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin/ledger.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin/persist.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin/policy/__init__.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin/policy/compose.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin/policy/temporal.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin/policy/trace.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin/policy/tuning.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin/policy/validate.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin/predicates.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin/provenance.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin/transitions.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin/uncertain.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin/validity.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin.egg-info/SOURCES.txt +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin.egg-info/dependency_links.txt +0 -0
- {margin-0.9.5 → margin-0.9.6}/margin.egg-info/top_level.txt +0 -0
- {margin-0.9.5 → margin-0.9.6}/setup.cfg +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_algebra.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_anomaly.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_bridge.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_calibrate.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_causal.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_composite.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_confidence.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_contract.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_correlate.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_diff.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_drift.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_events.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_forecast.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_health.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_healthcare.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_intent.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_ledger.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_neuro.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_persist.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_policy_compose.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_policy_temporal.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_policy_trace.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_policy_tuning.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_policy_validate.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_predicates.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_printer3d.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_provenance.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_reverse_bridge.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_ros2.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_staleness.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_streaming.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_transformer.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_transitions.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_uncertain.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_validity.py +0 -0
- {margin-0.9.5 → margin-0.9.6}/tests/test_windowing.py +0 -0
|
@@ -44,6 +44,13 @@ class CalibrationResult:
|
|
|
44
44
|
"active_min": self.thresholds.active_min,
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
def __repr__(self) -> str:
|
|
48
|
+
t = self.thresholds
|
|
49
|
+
polarity = "↑" if t.higher_is_better else "↓"
|
|
50
|
+
return (f"CalibrationResult(baseline={self.baseline:.4g}, "
|
|
51
|
+
f"intact={t.intact:.4g}, ablated={t.ablated:.4g}, "
|
|
52
|
+
f"std={self.std:.4g}, n={self.n_samples}, {polarity})")
|
|
53
|
+
|
|
47
54
|
|
|
48
55
|
def calibrate(
|
|
49
56
|
values: list[float],
|
|
@@ -19,8 +19,8 @@ from typing import Optional
|
|
|
19
19
|
|
|
20
20
|
from .health import Thresholds
|
|
21
21
|
from .observation import Parser, Op
|
|
22
|
-
from .policy.core import Action, PolicyRule, Policy
|
|
23
|
-
from .contract import Contract, HealthTarget, Health
|
|
22
|
+
from .policy.core import Action, PolicyRule, Policy, Constraint, Escalation, EscalationLevel
|
|
23
|
+
from .contract import Contract, HealthTarget, Health, contract_term_from_dict
|
|
24
24
|
from .predicates import (
|
|
25
25
|
any_health, all_health, any_degraded, any_correction,
|
|
26
26
|
confidence_below, sigma_below, component_health,
|
|
@@ -154,11 +154,30 @@ def from_config(config: dict) -> dict:
|
|
|
154
154
|
alpha=action_spec.get("alpha", 0.5),
|
|
155
155
|
magnitude=action_spec.get("magnitude", 1.0),
|
|
156
156
|
)
|
|
157
|
+
constraint = None
|
|
158
|
+
if "constraint" in spec:
|
|
159
|
+
cs = spec["constraint"]
|
|
160
|
+
constraint = Constraint(
|
|
161
|
+
max_alpha=cs.get("max_alpha", 1.0),
|
|
162
|
+
min_alpha=cs.get("min_alpha", 0.0),
|
|
163
|
+
cooldown_steps=cs.get("cooldown_steps", 0),
|
|
164
|
+
max_per_window=cs.get("max_per_window", 0),
|
|
165
|
+
window_steps=cs.get("window_steps", 0),
|
|
166
|
+
)
|
|
167
|
+
escalation = None
|
|
168
|
+
if "escalation" in spec:
|
|
169
|
+
es = spec["escalation"]
|
|
170
|
+
escalation = Escalation(
|
|
171
|
+
level=EscalationLevel(es.get("level", "LOG")),
|
|
172
|
+
reason=es.get("reason", ""),
|
|
173
|
+
)
|
|
157
174
|
rules.append(PolicyRule(
|
|
158
175
|
name=spec["name"],
|
|
159
176
|
predicate=predicate,
|
|
160
177
|
action=action,
|
|
161
178
|
priority=spec.get("priority", 0),
|
|
179
|
+
constraint=constraint,
|
|
180
|
+
escalation=escalation,
|
|
162
181
|
))
|
|
163
182
|
result["policy"] = Policy(name=config.get("name", "config-policy"), rules=rules)
|
|
164
183
|
|
|
@@ -167,11 +186,19 @@ def from_config(config: dict) -> dict:
|
|
|
167
186
|
if contract_specs:
|
|
168
187
|
terms = []
|
|
169
188
|
for spec in contract_specs:
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
189
|
+
term_type = spec.get("type", "health_target")
|
|
190
|
+
if term_type == "health_target" and "type" not in spec:
|
|
191
|
+
# backward-compatible: old configs without explicit type field
|
|
192
|
+
terms.append(HealthTarget(
|
|
193
|
+
name=spec["name"],
|
|
194
|
+
component=spec["component"],
|
|
195
|
+
target=Health(spec.get("health", "INTACT")),
|
|
196
|
+
))
|
|
197
|
+
else:
|
|
198
|
+
# normalise: config may use "health" instead of "target"
|
|
199
|
+
if "health" in spec and "target" not in spec:
|
|
200
|
+
spec = dict(spec, target=spec["health"])
|
|
201
|
+
terms.append(contract_term_from_dict(spec))
|
|
175
202
|
result["contract"] = Contract(
|
|
176
203
|
name=config.get("contract_name", "config-contract"),
|
|
177
204
|
terms=terms,
|
|
@@ -92,6 +92,9 @@ class HealthTarget(ContractTerm):
|
|
|
92
92
|
return {"type": "health_target", "name": self.name, "component": self.component,
|
|
93
93
|
"target": self.target.value, "or_better": self.or_better}
|
|
94
94
|
|
|
95
|
+
def __repr__(self) -> str:
|
|
96
|
+
return f"HealthTarget({self.name!r}, {self.component}={self.target.value})"
|
|
97
|
+
|
|
95
98
|
|
|
96
99
|
@dataclass
|
|
97
100
|
class ReachHealth(ContractTerm):
|
|
@@ -138,6 +141,9 @@ class ReachHealth(ContractTerm):
|
|
|
138
141
|
return {"type": "reach_health", "name": self.name, "component": self.component,
|
|
139
142
|
"target": self.target.value, "within_steps": self.within_steps}
|
|
140
143
|
|
|
144
|
+
def __repr__(self) -> str:
|
|
145
|
+
return f"ReachHealth({self.name!r}, {self.component}={self.target.value} within {self.within_steps})"
|
|
146
|
+
|
|
141
147
|
|
|
142
148
|
@dataclass
|
|
143
149
|
class SustainHealth(ContractTerm):
|
|
@@ -184,6 +190,9 @@ class SustainHealth(ContractTerm):
|
|
|
184
190
|
"target": self.target.value, "for_steps": self.for_steps,
|
|
185
191
|
"or_better": self.or_better}
|
|
186
192
|
|
|
193
|
+
def __repr__(self) -> str:
|
|
194
|
+
return f"SustainHealth({self.name!r}, {self.component}={self.target.value} for {self.for_steps})"
|
|
195
|
+
|
|
187
196
|
|
|
188
197
|
@dataclass
|
|
189
198
|
class RecoveryThreshold(ContractTerm):
|
|
@@ -212,6 +221,9 @@ class RecoveryThreshold(ContractTerm):
|
|
|
212
221
|
return {"type": "recovery_threshold", "name": self.name,
|
|
213
222
|
"min_recovery": self.min_recovery, "over_steps": self.over_steps}
|
|
214
223
|
|
|
224
|
+
def __repr__(self) -> str:
|
|
225
|
+
return f"RecoveryThreshold({self.name!r}, min={self.min_recovery} over {self.over_steps})"
|
|
226
|
+
|
|
215
227
|
|
|
216
228
|
@dataclass
|
|
217
229
|
class NoHarmful(ContractTerm):
|
|
@@ -231,6 +243,9 @@ class NoHarmful(ContractTerm):
|
|
|
231
243
|
def to_dict(self) -> dict:
|
|
232
244
|
return {"type": "no_harmful", "name": self.name, "over_steps": self.over_steps}
|
|
233
245
|
|
|
246
|
+
def __repr__(self) -> str:
|
|
247
|
+
return f"NoHarmful({self.name!r}, over {self.over_steps})"
|
|
248
|
+
|
|
234
249
|
|
|
235
250
|
_TERM_TYPES: dict = {
|
|
236
251
|
"health_target": lambda d: HealthTarget(
|
|
@@ -281,6 +281,9 @@ def full_step(
|
|
|
281
281
|
contract: Optional[Contract] = None,
|
|
282
282
|
intent: Optional[object] = None,
|
|
283
283
|
now=None,
|
|
284
|
+
confidences=None,
|
|
285
|
+
provenance=None,
|
|
286
|
+
label: str = "",
|
|
284
287
|
) -> FullStepResult:
|
|
285
288
|
"""
|
|
286
289
|
Run ALL layers in one call:
|
|
@@ -305,7 +308,8 @@ def full_step(
|
|
|
305
308
|
now = now or datetime.now()
|
|
306
309
|
|
|
307
310
|
# Layer 1-4: health + drift + anomaly + correlation
|
|
308
|
-
expr = monitor.update(values, now=now
|
|
311
|
+
expr = monitor.update(values, now=now, label=label,
|
|
312
|
+
confidences=confidences, provenance=provenance)
|
|
309
313
|
|
|
310
314
|
# Layer 5-7: explain + decide + contract
|
|
311
315
|
step_result = step(expr, policy, ledger, graph, contract)
|
|
@@ -343,6 +343,13 @@ class Parser:
|
|
|
343
343
|
|
|
344
344
|
observations = []
|
|
345
345
|
for name, val in values.items():
|
|
346
|
+
if name not in self.baselines:
|
|
347
|
+
warnings.warn(
|
|
348
|
+
f"Parser.parse: component {name!r} has no baseline — "
|
|
349
|
+
"using the observed value as baseline (sigma=0). "
|
|
350
|
+
"Add it to Parser.baselines or check for a typo.",
|
|
351
|
+
stacklevel=2,
|
|
352
|
+
)
|
|
346
353
|
baseline = self.baselines.get(name, val)
|
|
347
354
|
conf = confidences.get(name, Confidence.MODERATE)
|
|
348
355
|
ct = self._thresholds_for(name)
|
|
@@ -7,6 +7,7 @@ constrained Correction or Escalation.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import warnings
|
|
10
11
|
from dataclasses import dataclass, field
|
|
11
12
|
from enum import Enum
|
|
12
13
|
from typing import Callable, Optional, Union
|
|
@@ -97,6 +98,13 @@ class Action:
|
|
|
97
98
|
if o.name == target_name:
|
|
98
99
|
target_obs = o
|
|
99
100
|
break
|
|
101
|
+
if target_obs is None:
|
|
102
|
+
warnings.warn(
|
|
103
|
+
f"Action.resolve: target {self.target!r} not found in expression "
|
|
104
|
+
f"({[o.name for o in expr.observations]}). "
|
|
105
|
+
"Correction will reference a nonexistent component.",
|
|
106
|
+
stacklevel=3,
|
|
107
|
+
)
|
|
100
108
|
|
|
101
109
|
alpha = self.alpha
|
|
102
110
|
if self.alpha_from_sigma and target_obs:
|
|
@@ -131,6 +139,9 @@ class Action:
|
|
|
131
139
|
magnitude_from_sigma=d.get("magnitude_from_sigma", False),
|
|
132
140
|
)
|
|
133
141
|
|
|
142
|
+
def __repr__(self) -> str:
|
|
143
|
+
return f"Action(target={self.target!r}, op={self.op.value}, alpha={self.alpha})"
|
|
144
|
+
|
|
134
145
|
|
|
135
146
|
# -----------------------------------------------------------------------
|
|
136
147
|
# Constraint
|
|
@@ -180,6 +191,18 @@ class Constraint:
|
|
|
180
191
|
def from_dict(cls, d: dict) -> Constraint:
|
|
181
192
|
return cls(**{k: d[k] for k in ("max_alpha", "min_alpha", "cooldown_steps", "max_per_window", "window_steps") if k in d})
|
|
182
193
|
|
|
194
|
+
def __repr__(self) -> str:
|
|
195
|
+
parts = []
|
|
196
|
+
if self.min_alpha > 0.0:
|
|
197
|
+
parts.append(f"min_alpha={self.min_alpha}")
|
|
198
|
+
if self.max_alpha < 1.0:
|
|
199
|
+
parts.append(f"max_alpha={self.max_alpha}")
|
|
200
|
+
if self.cooldown_steps > 0:
|
|
201
|
+
parts.append(f"cooldown={self.cooldown_steps}")
|
|
202
|
+
if self.max_per_window > 0:
|
|
203
|
+
parts.append(f"max_per_window={self.max_per_window}")
|
|
204
|
+
return f"Constraint({', '.join(parts)})" if parts else "Constraint()"
|
|
205
|
+
|
|
183
206
|
|
|
184
207
|
# -----------------------------------------------------------------------
|
|
185
208
|
# PolicyRule
|
|
@@ -54,6 +54,12 @@ class WindowConfig:
|
|
|
54
54
|
correlation=d.get("correlation"),
|
|
55
55
|
)
|
|
56
56
|
|
|
57
|
+
def __repr__(self) -> str:
|
|
58
|
+
parts = [f"{k}={v}" for k, v in
|
|
59
|
+
[("drift", self.drift), ("anomaly", self.anomaly), ("correlation", self.correlation)]
|
|
60
|
+
if v is not None]
|
|
61
|
+
return f"WindowConfig({', '.join(parts)})" if parts else "WindowConfig()"
|
|
62
|
+
|
|
57
63
|
from .confidence import Confidence
|
|
58
64
|
from .health import Health, Thresholds, classify
|
|
59
65
|
from .observation import Observation, Expression
|
|
@@ -438,6 +444,8 @@ class Monitor:
|
|
|
438
444
|
values: dict[str, float],
|
|
439
445
|
label: str = "",
|
|
440
446
|
now: Optional[datetime] = None,
|
|
447
|
+
confidences=None,
|
|
448
|
+
provenance=None,
|
|
441
449
|
) -> Expression:
|
|
442
450
|
"""
|
|
443
451
|
Process new measurements: classify health, update drift, check anomalies.
|
|
@@ -447,7 +455,10 @@ class Monitor:
|
|
|
447
455
|
now = now or datetime.now()
|
|
448
456
|
|
|
449
457
|
# Health classification via Parser
|
|
450
|
-
self._expression = self.parser.parse(
|
|
458
|
+
self._expression = self.parser.parse(
|
|
459
|
+
values, label=label, step=self._step,
|
|
460
|
+
confidences=confidences, provenance=provenance,
|
|
461
|
+
)
|
|
451
462
|
self._step += 1
|
|
452
463
|
|
|
453
464
|
# Update per-component trackers
|
|
@@ -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.6"
|
|
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"}
|
|
@@ -181,3 +181,66 @@ class TestLoadConfig:
|
|
|
181
181
|
assert "policy" in result
|
|
182
182
|
assert "contract" in result
|
|
183
183
|
Path(f.name).unlink()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class TestConfigConstraintEscalation:
|
|
187
|
+
def test_constraint_from_config(self):
|
|
188
|
+
from margin import from_config
|
|
189
|
+
cfg = {
|
|
190
|
+
"components": {"cpu": {"baseline": 50.0, "intact": 40.0, "ablated": 10.0}},
|
|
191
|
+
"policy": [{
|
|
192
|
+
"name": "throttle",
|
|
193
|
+
"when": "any_degraded",
|
|
194
|
+
"action": {"op": "RESTORE", "alpha": 0.5},
|
|
195
|
+
"constraint": {"min_alpha": 0.1, "cooldown_steps": 3},
|
|
196
|
+
}],
|
|
197
|
+
}
|
|
198
|
+
result = from_config(cfg)
|
|
199
|
+
rule = result["policy"].rules[0]
|
|
200
|
+
assert rule.constraint is not None
|
|
201
|
+
assert rule.constraint.min_alpha == 0.1
|
|
202
|
+
assert rule.constraint.cooldown_steps == 3
|
|
203
|
+
|
|
204
|
+
def test_escalation_from_config(self):
|
|
205
|
+
from margin import from_config
|
|
206
|
+
cfg = {
|
|
207
|
+
"components": {"cpu": {"baseline": 50.0, "intact": 40.0, "ablated": 10.0}},
|
|
208
|
+
"policy": [{
|
|
209
|
+
"name": "alert",
|
|
210
|
+
"when": "any_ablated",
|
|
211
|
+
"action": {"op": "NOOP"},
|
|
212
|
+
"escalation": {"level": "ALERT", "reason": "cpu failed"},
|
|
213
|
+
}],
|
|
214
|
+
}
|
|
215
|
+
result = from_config(cfg)
|
|
216
|
+
rule = result["policy"].rules[0]
|
|
217
|
+
assert rule.escalation is not None
|
|
218
|
+
assert rule.escalation.reason == "cpu failed"
|
|
219
|
+
|
|
220
|
+
def test_contract_reach_health_from_config(self):
|
|
221
|
+
from margin import from_config
|
|
222
|
+
from margin.contract import ReachHealth
|
|
223
|
+
cfg = {
|
|
224
|
+
"components": {"cpu": {"baseline": 50.0, "intact": 40.0, "ablated": 10.0}},
|
|
225
|
+
"contract": [{
|
|
226
|
+
"type": "reach_health",
|
|
227
|
+
"name": "recovery",
|
|
228
|
+
"component": "cpu",
|
|
229
|
+
"target": "INTACT",
|
|
230
|
+
"within_steps": 10,
|
|
231
|
+
}],
|
|
232
|
+
}
|
|
233
|
+
result = from_config(cfg)
|
|
234
|
+
term = result["contract"].terms[0]
|
|
235
|
+
assert isinstance(term, ReachHealth)
|
|
236
|
+
assert term.within_steps == 10
|
|
237
|
+
|
|
238
|
+
def test_contract_backward_compat_no_type(self):
|
|
239
|
+
from margin import from_config
|
|
240
|
+
from margin.contract import HealthTarget
|
|
241
|
+
cfg = {
|
|
242
|
+
"components": {"cpu": {"baseline": 50.0, "intact": 40.0, "ablated": 10.0}},
|
|
243
|
+
"contract": [{"name": "ok", "component": "cpu", "health": "INTACT"}],
|
|
244
|
+
}
|
|
245
|
+
result = from_config(cfg)
|
|
246
|
+
assert isinstance(result["contract"].terms[0], HealthTarget)
|
|
@@ -184,3 +184,40 @@ class TestRun:
|
|
|
184
184
|
def test_repr(self):
|
|
185
185
|
result = step(_expr(_obs("x", Health.DEGRADED)), self._make_policy())
|
|
186
186
|
assert "CORRECT" in repr(result)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class TestFullStepThreading:
|
|
190
|
+
def _monitor(self):
|
|
191
|
+
from margin import Monitor, Parser, Thresholds
|
|
192
|
+
return Monitor(Parser(
|
|
193
|
+
baselines={"cpu": 50.0},
|
|
194
|
+
thresholds=Thresholds(intact=40.0, ablated=10.0),
|
|
195
|
+
))
|
|
196
|
+
|
|
197
|
+
def _policy(self):
|
|
198
|
+
from margin.policy.core import Policy, PolicyRule, Action
|
|
199
|
+
from margin.observation import Op
|
|
200
|
+
from margin.predicates import any_degraded
|
|
201
|
+
return Policy("p", [PolicyRule("r", any_degraded(), Action("*", Op.RESTORE, 0.5))])
|
|
202
|
+
|
|
203
|
+
def test_full_step_confidences_threaded(self):
|
|
204
|
+
from margin import full_step
|
|
205
|
+
from margin.confidence import Confidence
|
|
206
|
+
m = self._monitor()
|
|
207
|
+
result = full_step(m, {"cpu": 20.0}, self._policy(),
|
|
208
|
+
confidences={"cpu": Confidence.HIGH})
|
|
209
|
+
obs = result.expression.observations[0]
|
|
210
|
+
assert obs.confidence == Confidence.HIGH
|
|
211
|
+
|
|
212
|
+
def test_full_step_label_threaded(self):
|
|
213
|
+
from margin import full_step
|
|
214
|
+
m = self._monitor()
|
|
215
|
+
result = full_step(m, {"cpu": 20.0}, self._policy(), label="step-A")
|
|
216
|
+
assert result.expression.label == "step-A"
|
|
217
|
+
|
|
218
|
+
def test_full_step_provenance_threaded(self):
|
|
219
|
+
from margin import full_step
|
|
220
|
+
m = self._monitor()
|
|
221
|
+
result = full_step(m, {"cpu": 20.0}, self._policy(), provenance=["source-X"])
|
|
222
|
+
obs = result.expression.observations[0]
|
|
223
|
+
assert "source-X" in obs.provenance
|
|
@@ -268,3 +268,41 @@ class TestHealthLabel:
|
|
|
268
268
|
)
|
|
269
269
|
d = obs.to_dict()
|
|
270
270
|
assert "health_label" not in d
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class TestParserUnknownComponent:
|
|
274
|
+
def test_warns_on_unknown_component(self):
|
|
275
|
+
import warnings
|
|
276
|
+
p = Parser(
|
|
277
|
+
baselines={"cpu": 50.0},
|
|
278
|
+
thresholds=Thresholds(intact=40.0, ablated=10.0),
|
|
279
|
+
)
|
|
280
|
+
with warnings.catch_warnings(record=True) as w:
|
|
281
|
+
warnings.simplefilter("always")
|
|
282
|
+
p.parse({"cpuu": 48.0}) # typo
|
|
283
|
+
assert len(w) == 1
|
|
284
|
+
assert "cpuu" in str(w[0].message)
|
|
285
|
+
assert "no baseline" in str(w[0].message)
|
|
286
|
+
|
|
287
|
+
def test_no_warning_for_known_component(self):
|
|
288
|
+
import warnings
|
|
289
|
+
p = Parser(
|
|
290
|
+
baselines={"cpu": 50.0},
|
|
291
|
+
thresholds=Thresholds(intact=40.0, ablated=10.0),
|
|
292
|
+
)
|
|
293
|
+
with warnings.catch_warnings(record=True) as w:
|
|
294
|
+
warnings.simplefilter("always")
|
|
295
|
+
p.parse({"cpu": 48.0})
|
|
296
|
+
assert len(w) == 0
|
|
297
|
+
|
|
298
|
+
def test_unknown_component_gets_sigma_zero(self):
|
|
299
|
+
p = Parser(
|
|
300
|
+
baselines={"cpu": 50.0},
|
|
301
|
+
thresholds=Thresholds(intact=40.0, ablated=10.0),
|
|
302
|
+
)
|
|
303
|
+
import warnings
|
|
304
|
+
with warnings.catch_warnings(record=True):
|
|
305
|
+
warnings.simplefilter("always")
|
|
306
|
+
expr = p.parse({"unknown": 100.0})
|
|
307
|
+
obs = expr.observations[0]
|
|
308
|
+
assert obs.sigma == 0.0
|
|
@@ -379,3 +379,36 @@ class TestSerialization:
|
|
|
379
379
|
d = {"name": "test", "rules": [{"name": "x", "action": {"op": "RESTORE"}}]}
|
|
380
380
|
with pytest.raises(ValueError, match="No predicate"):
|
|
381
381
|
Policy.from_dict(d, {})
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class TestActionResolveUnknownTarget:
|
|
385
|
+
def test_warns_when_named_target_not_in_expression(self):
|
|
386
|
+
import warnings
|
|
387
|
+
from margin.policy.core import Policy, PolicyRule, Action
|
|
388
|
+
from margin.observation import Op, Parser, Expression
|
|
389
|
+
from margin.health import Thresholds
|
|
390
|
+
from margin.predicates import any_degraded
|
|
391
|
+
|
|
392
|
+
p = Parser(baselines={"cpu": 50.0}, thresholds=Thresholds(intact=40.0, ablated=10.0))
|
|
393
|
+
expr = p.parse({"cpu": 20.0})
|
|
394
|
+
|
|
395
|
+
action = Action(target="nonexistent", op=Op.RESTORE, alpha=0.5)
|
|
396
|
+
with warnings.catch_warnings(record=True) as w:
|
|
397
|
+
warnings.simplefilter("always")
|
|
398
|
+
action.resolve(expr)
|
|
399
|
+
assert any("nonexistent" in str(x.message) for x in w)
|
|
400
|
+
|
|
401
|
+
def test_no_warning_when_target_exists(self):
|
|
402
|
+
import warnings
|
|
403
|
+
from margin.policy.core import Action
|
|
404
|
+
from margin.observation import Op, Parser
|
|
405
|
+
from margin.health import Thresholds
|
|
406
|
+
|
|
407
|
+
p = Parser(baselines={"cpu": 50.0}, thresholds=Thresholds(intact=40.0, ablated=10.0))
|
|
408
|
+
expr = p.parse({"cpu": 20.0})
|
|
409
|
+
|
|
410
|
+
action = Action(target="cpu", op=Op.RESTORE, alpha=0.5)
|
|
411
|
+
with warnings.catch_warnings(record=True) as w:
|
|
412
|
+
warnings.simplefilter("always")
|
|
413
|
+
action.resolve(expr)
|
|
414
|
+
assert len(w) == 0
|
|
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
|