margin 0.9.4__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.4/margin.egg-info → margin-0.9.6}/PKG-INFO +1 -1
- {margin-0.9.4 → margin-0.9.6}/margin/__init__.py +2 -1
- {margin-0.9.4 → margin-0.9.6}/margin/calibrate.py +12 -5
- {margin-0.9.4 → margin-0.9.6}/margin/config.py +34 -7
- {margin-0.9.4 → margin-0.9.6}/margin/contract.py +47 -0
- {margin-0.9.4 → margin-0.9.6}/margin/loop.py +8 -2
- {margin-0.9.4 → margin-0.9.6}/margin/observation.py +7 -0
- {margin-0.9.4 → margin-0.9.6}/margin/policy/core.py +23 -0
- {margin-0.9.4 → margin-0.9.6}/margin/streaming.py +12 -1
- {margin-0.9.4 → margin-0.9.6/margin.egg-info}/PKG-INFO +1 -1
- {margin-0.9.4 → margin-0.9.6}/pyproject.toml +1 -1
- {margin-0.9.4 → margin-0.9.6}/tests/test_calibrate.py +43 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_config.py +63 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_contract.py +50 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_loop.py +37 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_observation.py +38 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_policy.py +33 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_policy_trace.py +40 -17
- {margin-0.9.4 → margin-0.9.6}/LICENSE +0 -0
- {margin-0.9.4 → margin-0.9.6}/NOTICE +0 -0
- {margin-0.9.4 → margin-0.9.6}/README.md +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin/__main__.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin/algebra.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin/anomaly.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin/bridge.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin/causal.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin/composite.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin/confidence.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin/correlate.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin/diff.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin/drift.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin/events.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin/forecast.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin/health.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin/intent.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin/ledger.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin/persist.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin/policy/__init__.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin/policy/compose.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin/policy/temporal.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin/policy/trace.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin/policy/tuning.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin/policy/validate.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin/predicates.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin/provenance.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin/transitions.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin/uncertain.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin/validity.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin.egg-info/SOURCES.txt +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin.egg-info/dependency_links.txt +0 -0
- {margin-0.9.4 → margin-0.9.6}/margin.egg-info/top_level.txt +0 -0
- {margin-0.9.4 → margin-0.9.6}/setup.cfg +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_algebra.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_anomaly.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_bridge.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_causal.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_composite.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_confidence.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_correlate.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_diff.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_drift.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_events.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_forecast.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_health.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_healthcare.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_intent.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_ledger.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_neuro.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_persist.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_policy_compose.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_policy_temporal.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_policy_tuning.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_policy_validate.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_predicates.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_printer3d.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_provenance.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_reverse_bridge.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_ros2.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_staleness.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_streaming.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_transformer.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_transitions.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_uncertain.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/tests/test_validity.py +0 -0
- {margin-0.9.4 → margin-0.9.6}/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",
|
|
@@ -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],
|
|
@@ -312,7 +319,7 @@ def recalibrate_parser(
|
|
|
312
319
|
components: Optional[list[str]] = None,
|
|
313
320
|
intact_fraction: float = 0.70,
|
|
314
321
|
ablated_fraction: float = 0.30,
|
|
315
|
-
active_min: float =
|
|
322
|
+
active_min: Optional[float] = None,
|
|
316
323
|
use_std: bool = False,
|
|
317
324
|
intact_std_multiplier: float = 1.5,
|
|
318
325
|
ablated_std_multiplier: float = 3.0,
|
|
@@ -338,8 +345,8 @@ def recalibrate_parser(
|
|
|
338
345
|
all components in new_healthy_data are recalibrated
|
|
339
346
|
intact_fraction: fraction of baseline for intact threshold (fraction mode)
|
|
340
347
|
ablated_fraction: fraction of baseline for ablated threshold (fraction mode)
|
|
341
|
-
active_min:
|
|
342
|
-
|
|
348
|
+
active_min: None (default) inherits from each component's existing
|
|
349
|
+
threshold; pass an explicit float to override
|
|
343
350
|
use_std: if True, use std-based thresholds (see calibrate())
|
|
344
351
|
intact_std_multiplier: std deviations for intact threshold (std mode)
|
|
345
352
|
ablated_std_multiplier: std deviations for ablated threshold (std mode)
|
|
@@ -373,8 +380,8 @@ def recalibrate_parser(
|
|
|
373
380
|
# Resolve polarity: explicit override → existing parser polarity → True
|
|
374
381
|
existing_t = parser.component_thresholds.get(name) or parser.thresholds
|
|
375
382
|
hib = polarities.get(name, existing_t.higher_is_better)
|
|
376
|
-
# Inherit active_min from existing thresholds unless caller
|
|
377
|
-
component_active_min = existing_t.active_min if active_min
|
|
383
|
+
# Inherit active_min from existing thresholds unless caller provides an explicit override
|
|
384
|
+
component_active_min = existing_t.active_min if active_min is None else active_min
|
|
378
385
|
result = calibrate(
|
|
379
386
|
vals, hib, intact_fraction, ablated_fraction, component_active_min,
|
|
380
387
|
use_std=use_std,
|
|
@@ -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,41 @@ 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
|
+
|
|
249
|
+
|
|
250
|
+
_TERM_TYPES: dict = {
|
|
251
|
+
"health_target": lambda d: HealthTarget(
|
|
252
|
+
name=d["name"], component=d["component"],
|
|
253
|
+
target=Health(d["target"]), or_better=d.get("or_better", True),
|
|
254
|
+
),
|
|
255
|
+
"reach_health": lambda d: ReachHealth(
|
|
256
|
+
name=d["name"], component=d["component"],
|
|
257
|
+
target=Health(d["target"]), within_steps=d["within_steps"],
|
|
258
|
+
),
|
|
259
|
+
"sustain_health": lambda d: SustainHealth(
|
|
260
|
+
name=d["name"], component=d["component"],
|
|
261
|
+
target=Health(d["target"]), for_steps=d["for_steps"],
|
|
262
|
+
or_better=d.get("or_better", True),
|
|
263
|
+
),
|
|
264
|
+
"recovery_threshold": lambda d: RecoveryThreshold(
|
|
265
|
+
name=d["name"], min_recovery=d["min_recovery"], over_steps=d["over_steps"],
|
|
266
|
+
),
|
|
267
|
+
"no_harmful": lambda d: NoHarmful(
|
|
268
|
+
name=d["name"], over_steps=d["over_steps"],
|
|
269
|
+
),
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def contract_term_from_dict(d: dict) -> ContractTerm:
|
|
274
|
+
"""Deserialize a ContractTerm subclass from a dict produced by its to_dict()."""
|
|
275
|
+
term_type = d.get("type")
|
|
276
|
+
if term_type not in _TERM_TYPES:
|
|
277
|
+
raise ValueError(f"Unknown contract term type: {term_type!r}. "
|
|
278
|
+
f"Expected one of: {list(_TERM_TYPES)}")
|
|
279
|
+
return _TERM_TYPES[term_type](d)
|
|
280
|
+
|
|
234
281
|
|
|
235
282
|
# -----------------------------------------------------------------------
|
|
236
283
|
# Contract — a set of terms evaluated together
|
|
@@ -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:
|
|
@@ -279,6 +281,9 @@ def full_step(
|
|
|
279
281
|
contract: Optional[Contract] = None,
|
|
280
282
|
intent: Optional[object] = None,
|
|
281
283
|
now=None,
|
|
284
|
+
confidences=None,
|
|
285
|
+
provenance=None,
|
|
286
|
+
label: str = "",
|
|
282
287
|
) -> FullStepResult:
|
|
283
288
|
"""
|
|
284
289
|
Run ALL layers in one call:
|
|
@@ -303,7 +308,8 @@ def full_step(
|
|
|
303
308
|
now = now or datetime.now()
|
|
304
309
|
|
|
305
310
|
# Layer 1-4: health + drift + anomaly + correlation
|
|
306
|
-
expr = monitor.update(values, now=now
|
|
311
|
+
expr = monitor.update(values, now=now, label=label,
|
|
312
|
+
confidences=confidences, provenance=provenance)
|
|
307
313
|
|
|
308
314
|
# Layer 5-7: explain + decide + contract
|
|
309
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"}
|
|
@@ -208,3 +208,46 @@ class TestCalibrateManyReturnParser:
|
|
|
208
208
|
import pytest
|
|
209
209
|
with pytest.raises(ValueError):
|
|
210
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)
|
|
@@ -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)
|
|
@@ -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"})
|
|
@@ -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
|
|
@@ -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
|
|
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
|