margin 0.9.4__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.4/margin.egg-info → margin-0.9.5}/PKG-INFO +1 -1
- {margin-0.9.4 → margin-0.9.5}/margin/__init__.py +2 -1
- {margin-0.9.4 → margin-0.9.5}/margin/calibrate.py +5 -5
- {margin-0.9.4 → margin-0.9.5}/margin/contract.py +32 -0
- {margin-0.9.4 → margin-0.9.5}/margin/loop.py +3 -1
- {margin-0.9.4 → margin-0.9.5/margin.egg-info}/PKG-INFO +1 -1
- {margin-0.9.4 → margin-0.9.5}/pyproject.toml +1 -1
- {margin-0.9.4 → margin-0.9.5}/tests/test_calibrate.py +43 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_contract.py +50 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_policy_trace.py +40 -17
- {margin-0.9.4 → margin-0.9.5}/LICENSE +0 -0
- {margin-0.9.4 → margin-0.9.5}/NOTICE +0 -0
- {margin-0.9.4 → margin-0.9.5}/README.md +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/__main__.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/algebra.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/anomaly.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/bridge.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/causal.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/composite.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/confidence.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/config.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/correlate.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/diff.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/drift.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/events.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/forecast.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/health.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/intent.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/ledger.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/observation.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/persist.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/policy/__init__.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/policy/compose.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/policy/core.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/policy/temporal.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/policy/trace.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/policy/tuning.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/policy/validate.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/predicates.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/provenance.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/streaming.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/transitions.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/uncertain.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin/validity.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin.egg-info/SOURCES.txt +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin.egg-info/dependency_links.txt +0 -0
- {margin-0.9.4 → margin-0.9.5}/margin.egg-info/top_level.txt +0 -0
- {margin-0.9.4 → margin-0.9.5}/setup.cfg +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_algebra.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_anomaly.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_bridge.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_causal.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_composite.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_confidence.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_config.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_correlate.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_diff.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_drift.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_events.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_forecast.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_health.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_healthcare.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_intent.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_ledger.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_loop.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_neuro.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_observation.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_persist.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_policy.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_policy_compose.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_policy_temporal.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_policy_tuning.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_policy_validate.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_predicates.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_printer3d.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_provenance.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_reverse_bridge.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_ros2.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_staleness.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_streaming.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_transformer.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_transitions.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_uncertain.py +0 -0
- {margin-0.9.4 → margin-0.9.5}/tests/test_validity.py +0 -0
- {margin-0.9.4 → 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",
|
|
@@ -312,7 +312,7 @@ def recalibrate_parser(
|
|
|
312
312
|
components: Optional[list[str]] = None,
|
|
313
313
|
intact_fraction: float = 0.70,
|
|
314
314
|
ablated_fraction: float = 0.30,
|
|
315
|
-
active_min: float =
|
|
315
|
+
active_min: Optional[float] = None,
|
|
316
316
|
use_std: bool = False,
|
|
317
317
|
intact_std_multiplier: float = 1.5,
|
|
318
318
|
ablated_std_multiplier: float = 3.0,
|
|
@@ -338,8 +338,8 @@ def recalibrate_parser(
|
|
|
338
338
|
all components in new_healthy_data are recalibrated
|
|
339
339
|
intact_fraction: fraction of baseline for intact threshold (fraction mode)
|
|
340
340
|
ablated_fraction: fraction of baseline for ablated threshold (fraction mode)
|
|
341
|
-
active_min:
|
|
342
|
-
|
|
341
|
+
active_min: None (default) inherits from each component's existing
|
|
342
|
+
threshold; pass an explicit float to override
|
|
343
343
|
use_std: if True, use std-based thresholds (see calibrate())
|
|
344
344
|
intact_std_multiplier: std deviations for intact threshold (std mode)
|
|
345
345
|
ablated_std_multiplier: std deviations for ablated threshold (std mode)
|
|
@@ -373,8 +373,8 @@ def recalibrate_parser(
|
|
|
373
373
|
# Resolve polarity: explicit override → existing parser polarity → True
|
|
374
374
|
existing_t = parser.component_thresholds.get(name) or parser.thresholds
|
|
375
375
|
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
|
|
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
|
|
378
378
|
result = calibrate(
|
|
379
379
|
vals, hib, intact_fraction, ablated_fraction, component_active_min,
|
|
380
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:
|
|
@@ -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"}
|
|
@@ -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)
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|