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.

Files changed (85) hide show
  1. {margin-0.9.3/margin.egg-info → margin-0.9.5}/PKG-INFO +1 -1
  2. {margin-0.9.3 → margin-0.9.5}/margin/__init__.py +2 -1
  3. {margin-0.9.3 → margin-0.9.5}/margin/calibrate.py +23 -9
  4. {margin-0.9.3 → margin-0.9.5}/margin/contract.py +32 -0
  5. {margin-0.9.3 → margin-0.9.5}/margin/loop.py +3 -1
  6. {margin-0.9.3 → margin-0.9.5}/margin/streaming.py +28 -3
  7. {margin-0.9.3 → margin-0.9.5/margin.egg-info}/PKG-INFO +1 -1
  8. {margin-0.9.3 → margin-0.9.5}/pyproject.toml +1 -1
  9. {margin-0.9.3 → margin-0.9.5}/tests/test_calibrate.py +75 -0
  10. {margin-0.9.3 → margin-0.9.5}/tests/test_contract.py +50 -0
  11. {margin-0.9.3 → margin-0.9.5}/tests/test_policy_trace.py +40 -17
  12. {margin-0.9.3 → margin-0.9.5}/tests/test_streaming.py +47 -4
  13. {margin-0.9.3 → margin-0.9.5}/LICENSE +0 -0
  14. {margin-0.9.3 → margin-0.9.5}/NOTICE +0 -0
  15. {margin-0.9.3 → margin-0.9.5}/README.md +0 -0
  16. {margin-0.9.3 → margin-0.9.5}/margin/__main__.py +0 -0
  17. {margin-0.9.3 → margin-0.9.5}/margin/algebra.py +0 -0
  18. {margin-0.9.3 → margin-0.9.5}/margin/anomaly.py +0 -0
  19. {margin-0.9.3 → margin-0.9.5}/margin/bridge.py +0 -0
  20. {margin-0.9.3 → margin-0.9.5}/margin/causal.py +0 -0
  21. {margin-0.9.3 → margin-0.9.5}/margin/composite.py +0 -0
  22. {margin-0.9.3 → margin-0.9.5}/margin/confidence.py +0 -0
  23. {margin-0.9.3 → margin-0.9.5}/margin/config.py +0 -0
  24. {margin-0.9.3 → margin-0.9.5}/margin/correlate.py +0 -0
  25. {margin-0.9.3 → margin-0.9.5}/margin/diff.py +0 -0
  26. {margin-0.9.3 → margin-0.9.5}/margin/drift.py +0 -0
  27. {margin-0.9.3 → margin-0.9.5}/margin/events.py +0 -0
  28. {margin-0.9.3 → margin-0.9.5}/margin/forecast.py +0 -0
  29. {margin-0.9.3 → margin-0.9.5}/margin/health.py +0 -0
  30. {margin-0.9.3 → margin-0.9.5}/margin/intent.py +0 -0
  31. {margin-0.9.3 → margin-0.9.5}/margin/ledger.py +0 -0
  32. {margin-0.9.3 → margin-0.9.5}/margin/observation.py +0 -0
  33. {margin-0.9.3 → margin-0.9.5}/margin/persist.py +0 -0
  34. {margin-0.9.3 → margin-0.9.5}/margin/policy/__init__.py +0 -0
  35. {margin-0.9.3 → margin-0.9.5}/margin/policy/compose.py +0 -0
  36. {margin-0.9.3 → margin-0.9.5}/margin/policy/core.py +0 -0
  37. {margin-0.9.3 → margin-0.9.5}/margin/policy/temporal.py +0 -0
  38. {margin-0.9.3 → margin-0.9.5}/margin/policy/trace.py +0 -0
  39. {margin-0.9.3 → margin-0.9.5}/margin/policy/tuning.py +0 -0
  40. {margin-0.9.3 → margin-0.9.5}/margin/policy/validate.py +0 -0
  41. {margin-0.9.3 → margin-0.9.5}/margin/predicates.py +0 -0
  42. {margin-0.9.3 → margin-0.9.5}/margin/provenance.py +0 -0
  43. {margin-0.9.3 → margin-0.9.5}/margin/transitions.py +0 -0
  44. {margin-0.9.3 → margin-0.9.5}/margin/uncertain.py +0 -0
  45. {margin-0.9.3 → margin-0.9.5}/margin/validity.py +0 -0
  46. {margin-0.9.3 → margin-0.9.5}/margin.egg-info/SOURCES.txt +0 -0
  47. {margin-0.9.3 → margin-0.9.5}/margin.egg-info/dependency_links.txt +0 -0
  48. {margin-0.9.3 → margin-0.9.5}/margin.egg-info/top_level.txt +0 -0
  49. {margin-0.9.3 → margin-0.9.5}/setup.cfg +0 -0
  50. {margin-0.9.3 → margin-0.9.5}/tests/test_algebra.py +0 -0
  51. {margin-0.9.3 → margin-0.9.5}/tests/test_anomaly.py +0 -0
  52. {margin-0.9.3 → margin-0.9.5}/tests/test_bridge.py +0 -0
  53. {margin-0.9.3 → margin-0.9.5}/tests/test_causal.py +0 -0
  54. {margin-0.9.3 → margin-0.9.5}/tests/test_composite.py +0 -0
  55. {margin-0.9.3 → margin-0.9.5}/tests/test_confidence.py +0 -0
  56. {margin-0.9.3 → margin-0.9.5}/tests/test_config.py +0 -0
  57. {margin-0.9.3 → margin-0.9.5}/tests/test_correlate.py +0 -0
  58. {margin-0.9.3 → margin-0.9.5}/tests/test_diff.py +0 -0
  59. {margin-0.9.3 → margin-0.9.5}/tests/test_drift.py +0 -0
  60. {margin-0.9.3 → margin-0.9.5}/tests/test_events.py +0 -0
  61. {margin-0.9.3 → margin-0.9.5}/tests/test_forecast.py +0 -0
  62. {margin-0.9.3 → margin-0.9.5}/tests/test_health.py +0 -0
  63. {margin-0.9.3 → margin-0.9.5}/tests/test_healthcare.py +0 -0
  64. {margin-0.9.3 → margin-0.9.5}/tests/test_intent.py +0 -0
  65. {margin-0.9.3 → margin-0.9.5}/tests/test_ledger.py +0 -0
  66. {margin-0.9.3 → margin-0.9.5}/tests/test_loop.py +0 -0
  67. {margin-0.9.3 → margin-0.9.5}/tests/test_neuro.py +0 -0
  68. {margin-0.9.3 → margin-0.9.5}/tests/test_observation.py +0 -0
  69. {margin-0.9.3 → margin-0.9.5}/tests/test_persist.py +0 -0
  70. {margin-0.9.3 → margin-0.9.5}/tests/test_policy.py +0 -0
  71. {margin-0.9.3 → margin-0.9.5}/tests/test_policy_compose.py +0 -0
  72. {margin-0.9.3 → margin-0.9.5}/tests/test_policy_temporal.py +0 -0
  73. {margin-0.9.3 → margin-0.9.5}/tests/test_policy_tuning.py +0 -0
  74. {margin-0.9.3 → margin-0.9.5}/tests/test_policy_validate.py +0 -0
  75. {margin-0.9.3 → margin-0.9.5}/tests/test_predicates.py +0 -0
  76. {margin-0.9.3 → margin-0.9.5}/tests/test_printer3d.py +0 -0
  77. {margin-0.9.3 → margin-0.9.5}/tests/test_provenance.py +0 -0
  78. {margin-0.9.3 → margin-0.9.5}/tests/test_reverse_bridge.py +0 -0
  79. {margin-0.9.3 → margin-0.9.5}/tests/test_ros2.py +0 -0
  80. {margin-0.9.3 → margin-0.9.5}/tests/test_staleness.py +0 -0
  81. {margin-0.9.3 → margin-0.9.5}/tests/test_transformer.py +0 -0
  82. {margin-0.9.3 → margin-0.9.5}/tests/test_transitions.py +0 -0
  83. {margin-0.9.3 → margin-0.9.5}/tests/test_uncertain.py +0 -0
  84. {margin-0.9.3 → margin-0.9.5}/tests/test_validity.py +0 -0
  85. {margin-0.9.3 → margin-0.9.5}/tests/test_windowing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: margin
3
- Version: 0.9.3
3
+ Version: 0.9.5
4
4
  Summary: Typed health classification, uncertainty algebra, and correction auditing for any system that measures things and needs to explain what happened.
5
5
  Author: Cope Labs LLC
6
6
  License: MIT
@@ -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
- ) -> tuple[dict[str, float], dict[str, Thresholds]]:
160
+ return_parser: bool = False,
161
+ ):
161
162
  """
162
- Calibrate multiple components at once. Returns (baselines, thresholds)
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 = 0.05,
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: inherited from existing parser thresholds; pass only
328
- to override for recalibrated components
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 overrides
363
- component_active_min = existing_t.active_min if active_min == 0.05 else 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
- return self.correction is not None and self.correction.is_active()
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()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: margin
3
- Version: 0.9.3
3
+ Version: 0.9.5
4
4
  Summary: Typed health classification, uncertainty algebra, and correction auditing for any system that measures things and needs to explain what happened.
5
5
  Author: Cope Labs LLC
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "margin"
7
- version = "0.9.3"
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 test_status_omits_disabled_feature_keys(self):
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
- # drift disabled → "drift" key absent
340
- assert "drift" not in s
341
- assert "anomaly" not in s
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