margin 0.9.1__tar.gz → 0.9.3__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.1/margin.egg-info → margin-0.9.3}/PKG-INFO +1 -1
  2. {margin-0.9.1 → margin-0.9.3}/margin/health.py +31 -0
  3. {margin-0.9.1 → margin-0.9.3}/margin/loop.py +9 -0
  4. {margin-0.9.1 → margin-0.9.3}/margin/observation.py +12 -2
  5. {margin-0.9.1 → margin-0.9.3}/margin/persist.py +12 -7
  6. {margin-0.9.1 → margin-0.9.3}/margin/policy/core.py +3 -1
  7. {margin-0.9.1 → margin-0.9.3}/margin/policy/trace.py +15 -11
  8. {margin-0.9.1 → margin-0.9.3}/margin/streaming.py +46 -25
  9. {margin-0.9.1 → margin-0.9.3/margin.egg-info}/PKG-INFO +1 -1
  10. {margin-0.9.1 → margin-0.9.3}/pyproject.toml +1 -1
  11. {margin-0.9.1 → margin-0.9.3}/tests/test_calibrate.py +79 -0
  12. {margin-0.9.1 → margin-0.9.3}/tests/test_health.py +45 -0
  13. {margin-0.9.1 → margin-0.9.3}/tests/test_observation.py +58 -0
  14. {margin-0.9.1 → margin-0.9.3}/tests/test_persist.py +50 -0
  15. {margin-0.9.1 → margin-0.9.3}/tests/test_policy_trace.py +93 -0
  16. {margin-0.9.1 → margin-0.9.3}/tests/test_streaming.py +121 -0
  17. {margin-0.9.1 → margin-0.9.3}/LICENSE +0 -0
  18. {margin-0.9.1 → margin-0.9.3}/NOTICE +0 -0
  19. {margin-0.9.1 → margin-0.9.3}/README.md +0 -0
  20. {margin-0.9.1 → margin-0.9.3}/margin/__init__.py +0 -0
  21. {margin-0.9.1 → margin-0.9.3}/margin/__main__.py +0 -0
  22. {margin-0.9.1 → margin-0.9.3}/margin/algebra.py +0 -0
  23. {margin-0.9.1 → margin-0.9.3}/margin/anomaly.py +0 -0
  24. {margin-0.9.1 → margin-0.9.3}/margin/bridge.py +0 -0
  25. {margin-0.9.1 → margin-0.9.3}/margin/calibrate.py +0 -0
  26. {margin-0.9.1 → margin-0.9.3}/margin/causal.py +0 -0
  27. {margin-0.9.1 → margin-0.9.3}/margin/composite.py +0 -0
  28. {margin-0.9.1 → margin-0.9.3}/margin/confidence.py +0 -0
  29. {margin-0.9.1 → margin-0.9.3}/margin/config.py +0 -0
  30. {margin-0.9.1 → margin-0.9.3}/margin/contract.py +0 -0
  31. {margin-0.9.1 → margin-0.9.3}/margin/correlate.py +0 -0
  32. {margin-0.9.1 → margin-0.9.3}/margin/diff.py +0 -0
  33. {margin-0.9.1 → margin-0.9.3}/margin/drift.py +0 -0
  34. {margin-0.9.1 → margin-0.9.3}/margin/events.py +0 -0
  35. {margin-0.9.1 → margin-0.9.3}/margin/forecast.py +0 -0
  36. {margin-0.9.1 → margin-0.9.3}/margin/intent.py +0 -0
  37. {margin-0.9.1 → margin-0.9.3}/margin/ledger.py +0 -0
  38. {margin-0.9.1 → margin-0.9.3}/margin/policy/__init__.py +0 -0
  39. {margin-0.9.1 → margin-0.9.3}/margin/policy/compose.py +0 -0
  40. {margin-0.9.1 → margin-0.9.3}/margin/policy/temporal.py +0 -0
  41. {margin-0.9.1 → margin-0.9.3}/margin/policy/tuning.py +0 -0
  42. {margin-0.9.1 → margin-0.9.3}/margin/policy/validate.py +0 -0
  43. {margin-0.9.1 → margin-0.9.3}/margin/predicates.py +0 -0
  44. {margin-0.9.1 → margin-0.9.3}/margin/provenance.py +0 -0
  45. {margin-0.9.1 → margin-0.9.3}/margin/transitions.py +0 -0
  46. {margin-0.9.1 → margin-0.9.3}/margin/uncertain.py +0 -0
  47. {margin-0.9.1 → margin-0.9.3}/margin/validity.py +0 -0
  48. {margin-0.9.1 → margin-0.9.3}/margin.egg-info/SOURCES.txt +0 -0
  49. {margin-0.9.1 → margin-0.9.3}/margin.egg-info/dependency_links.txt +0 -0
  50. {margin-0.9.1 → margin-0.9.3}/margin.egg-info/top_level.txt +0 -0
  51. {margin-0.9.1 → margin-0.9.3}/setup.cfg +0 -0
  52. {margin-0.9.1 → margin-0.9.3}/tests/test_algebra.py +0 -0
  53. {margin-0.9.1 → margin-0.9.3}/tests/test_anomaly.py +0 -0
  54. {margin-0.9.1 → margin-0.9.3}/tests/test_bridge.py +0 -0
  55. {margin-0.9.1 → margin-0.9.3}/tests/test_causal.py +0 -0
  56. {margin-0.9.1 → margin-0.9.3}/tests/test_composite.py +0 -0
  57. {margin-0.9.1 → margin-0.9.3}/tests/test_confidence.py +0 -0
  58. {margin-0.9.1 → margin-0.9.3}/tests/test_config.py +0 -0
  59. {margin-0.9.1 → margin-0.9.3}/tests/test_contract.py +0 -0
  60. {margin-0.9.1 → margin-0.9.3}/tests/test_correlate.py +0 -0
  61. {margin-0.9.1 → margin-0.9.3}/tests/test_diff.py +0 -0
  62. {margin-0.9.1 → margin-0.9.3}/tests/test_drift.py +0 -0
  63. {margin-0.9.1 → margin-0.9.3}/tests/test_events.py +0 -0
  64. {margin-0.9.1 → margin-0.9.3}/tests/test_forecast.py +0 -0
  65. {margin-0.9.1 → margin-0.9.3}/tests/test_healthcare.py +0 -0
  66. {margin-0.9.1 → margin-0.9.3}/tests/test_intent.py +0 -0
  67. {margin-0.9.1 → margin-0.9.3}/tests/test_ledger.py +0 -0
  68. {margin-0.9.1 → margin-0.9.3}/tests/test_loop.py +0 -0
  69. {margin-0.9.1 → margin-0.9.3}/tests/test_neuro.py +0 -0
  70. {margin-0.9.1 → margin-0.9.3}/tests/test_policy.py +0 -0
  71. {margin-0.9.1 → margin-0.9.3}/tests/test_policy_compose.py +0 -0
  72. {margin-0.9.1 → margin-0.9.3}/tests/test_policy_temporal.py +0 -0
  73. {margin-0.9.1 → margin-0.9.3}/tests/test_policy_tuning.py +0 -0
  74. {margin-0.9.1 → margin-0.9.3}/tests/test_policy_validate.py +0 -0
  75. {margin-0.9.1 → margin-0.9.3}/tests/test_predicates.py +0 -0
  76. {margin-0.9.1 → margin-0.9.3}/tests/test_printer3d.py +0 -0
  77. {margin-0.9.1 → margin-0.9.3}/tests/test_provenance.py +0 -0
  78. {margin-0.9.1 → margin-0.9.3}/tests/test_reverse_bridge.py +0 -0
  79. {margin-0.9.1 → margin-0.9.3}/tests/test_ros2.py +0 -0
  80. {margin-0.9.1 → margin-0.9.3}/tests/test_staleness.py +0 -0
  81. {margin-0.9.1 → margin-0.9.3}/tests/test_transformer.py +0 -0
  82. {margin-0.9.1 → margin-0.9.3}/tests/test_transitions.py +0 -0
  83. {margin-0.9.1 → margin-0.9.3}/tests/test_uncertain.py +0 -0
  84. {margin-0.9.1 → margin-0.9.3}/tests/test_validity.py +0 -0
  85. {margin-0.9.1 → margin-0.9.3}/tests/test_windowing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: margin
3
- Version: 0.9.1
3
+ Version: 0.9.3
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
@@ -46,6 +46,7 @@ class Thresholds:
46
46
  (throughput, signal), False means lower values are
47
47
  healthier (error rate, latency)
48
48
  active_min: minimum correction magnitude to count as "active"
49
+ labels: optional display labels keyed by Health.value strings
49
50
 
50
51
  For higher_is_better=True: intact >= ablated, value >= intact → INTACT
51
52
  For higher_is_better=False: intact <= ablated, value <= intact → INTACT
@@ -54,6 +55,36 @@ class Thresholds:
54
55
  ablated: float
55
56
  higher_is_better: bool = True
56
57
  active_min: float = 0.05
58
+ labels: Optional[dict[str, str]] = None
59
+
60
+ def label_for(self, health: 'Health') -> str:
61
+ """Return the display label for a health state, or the state name if no label set."""
62
+ if self.labels:
63
+ return self.labels.get(health.value, health.value)
64
+ return health.value
65
+
66
+ def to_dict(self) -> dict:
67
+ """Serialize to a plain dict (labels included if set)."""
68
+ d: dict = {
69
+ "intact": self.intact,
70
+ "ablated": self.ablated,
71
+ "higher_is_better": self.higher_is_better,
72
+ "active_min": self.active_min,
73
+ }
74
+ if self.labels:
75
+ d["labels"] = dict(self.labels)
76
+ return d
77
+
78
+ @classmethod
79
+ def from_dict(cls, d: dict) -> 'Thresholds':
80
+ """Deserialize from a plain dict produced by to_dict()."""
81
+ return cls(
82
+ intact=d["intact"],
83
+ ablated=d["ablated"],
84
+ higher_is_better=d.get("higher_is_better", True),
85
+ active_min=d.get("active_min", 0.05),
86
+ labels=d.get("labels"),
87
+ )
57
88
 
58
89
  def __post_init__(self):
59
90
  if self.higher_is_better and self.ablated > self.intact:
@@ -45,6 +45,7 @@ class StepResult:
45
45
  correction: Optional[Correction] = None
46
46
  escalation: Optional[Escalation] = None
47
47
  contract: Optional[ContractResult] = None
48
+ corrections: list = field(default_factory=list) # all fired corrections (multi_rule)
48
49
 
49
50
  @property
50
51
  def acted(self) -> bool:
@@ -74,6 +75,8 @@ class StepResult:
74
75
  d["decision"] = self.decision.to_dict()
75
76
  if self.correction:
76
77
  d["correction"] = self.correction.to_dict()
78
+ if self.corrections:
79
+ d["corrections"] = [c.to_dict() for c in self.corrections]
77
80
  if self.escalation:
78
81
  d["escalation"] = self.escalation.to_dict()
79
82
  if self.contract:
@@ -140,6 +143,11 @@ def step(
140
143
  elif isinstance(decision.result, Escalation):
141
144
  escalation = decision.result
142
145
 
146
+ # Collect all corrections for multi_rule mode
147
+ all_corrections = []
148
+ if policy.multi_rule:
149
+ all_corrections = [r for r in decision.results if isinstance(r, Correction)]
150
+
143
151
  # 3. Evaluate contract
144
152
  contract_result = None
145
153
  if contract:
@@ -152,6 +160,7 @@ def step(
152
160
  correction=correction,
153
161
  escalation=escalation,
154
162
  contract=contract_result,
163
+ corrections=all_corrections,
155
164
  )
156
165
 
157
166
 
@@ -60,6 +60,7 @@ class Observation:
60
60
  higher_is_better: bool = True
61
61
  provenance: list[str] = field(default_factory=list)
62
62
  measured_at: Optional[datetime] = None
63
+ health_label: Optional[str] = None
63
64
 
64
65
  @property
65
66
  def sigma(self) -> float:
@@ -91,10 +92,11 @@ class Observation:
91
92
 
92
93
  def to_atom(self) -> str:
93
94
  """Compact string: NAME:HEALTH(±σ)"""
95
+ display = self.health_label or self.health.value
94
96
  if self.health == Health.OOD:
95
- return f"{self.name}:{self.health.value}"
97
+ return f"{self.name}:{display}"
96
98
  sign = "+" if self.sigma >= 0 else ""
97
- return f"{self.name}:{self.health.value}({sign}{self.sigma:.2f}σ)"
99
+ return f"{self.name}:{display}({sign}{self.sigma:.2f}σ)"
98
100
 
99
101
  def to_dict(self) -> dict:
100
102
  d = {
@@ -107,6 +109,8 @@ class Observation:
107
109
  "higher_is_better": self.higher_is_better,
108
110
  "provenance": self.provenance,
109
111
  }
112
+ if self.health_label is not None:
113
+ d["health_label"] = self.health_label
110
114
  if self.measured_at is not None:
111
115
  d["measured_at"] = self.measured_at.isoformat()
112
116
  return d
@@ -125,6 +129,7 @@ class Observation:
125
129
  higher_is_better=d.get("higher_is_better", True),
126
130
  provenance=d.get("provenance", []),
127
131
  measured_at=measured_at,
132
+ health_label=d.get("health_label"),
128
133
  )
129
134
 
130
135
 
@@ -311,6 +316,10 @@ class Parser:
311
316
  def _thresholds_for(self, name: str) -> Thresholds:
312
317
  return self.component_thresholds.get(name, self.thresholds)
313
318
 
319
+ def label_for(self, component: str, health: Health) -> str:
320
+ """Return the display label for a component's health state."""
321
+ return self._thresholds_for(component).label_for(health)
322
+
314
323
  def parse(
315
324
  self,
316
325
  values: dict[str, float],
@@ -343,6 +352,7 @@ class Parser:
343
352
  name=name, health=h, value=val, baseline=baseline,
344
353
  confidence=conf, higher_is_better=ct.higher_is_better,
345
354
  provenance=list(provenance),
355
+ health_label=ct.label_for(h) if ct.labels else None,
346
356
  ))
347
357
 
348
358
  corrections = []
@@ -33,13 +33,14 @@ def save_monitor(monitor: Monitor, path: str) -> None:
33
33
  "anomaly_window": monitor.anomaly_window,
34
34
  "correlation_window": monitor.correlation_window,
35
35
  "anomaly_min_reference": _anomaly_min_ref,
36
+ "features": sorted(monitor.features),
36
37
  "drift": {},
37
38
  "anomaly": {},
38
39
  "correlation": {
39
40
  "components": monitor._correlation_tracker.components,
40
41
  "series": {c: list(d) for c, d in monitor._correlation_tracker._series.items()},
41
42
  "n_updates": monitor._correlation_tracker.n_updates,
42
- },
43
+ } if monitor._correlation_tracker is not None else None,
43
44
  }
44
45
 
45
46
  for name, dt in monitor._drift_trackers.items():
@@ -71,12 +72,15 @@ def load_monitor(path: str, parser: Parser, **kwargs) -> Monitor:
71
72
  anomaly_window = data.get("anomaly_window", kwargs.pop("anomaly_window", None))
72
73
  correlation_window = data.get("correlation_window", kwargs.pop("correlation_window", None))
73
74
  anomaly_min_reference = data.get("anomaly_min_reference", kwargs.pop("anomaly_min_reference", 10))
75
+ features_data = data.get("features", None)
76
+ features = set(features_data) if features_data is not None else None
74
77
  monitor = Monitor(
75
78
  parser, window=window,
76
79
  drift_window=drift_window,
77
80
  anomaly_window=anomaly_window,
78
81
  correlation_window=correlation_window,
79
82
  anomaly_min_reference=anomaly_min_reference,
83
+ features=features,
80
84
  **kwargs,
81
85
  )
82
86
  monitor._step = data.get("step", 0)
@@ -101,12 +105,13 @@ def load_monitor(path: str, parser: Parser, **kwargs) -> Monitor:
101
105
  tracker._values.append(v)
102
106
 
103
107
  # Restore correlation series
104
- corr_data = data.get("correlation", {})
105
- for c in monitor._correlation_tracker.components:
106
- series = corr_data.get("series", {}).get(c, [])
107
- for v in series:
108
- monitor._correlation_tracker._series[c].append(v)
109
- monitor._correlation_tracker._n_updates = corr_data.get("n_updates", 0)
108
+ corr_data = data.get("correlation")
109
+ if corr_data is not None and monitor._correlation_tracker is not None:
110
+ for c in monitor._correlation_tracker.components:
111
+ series = corr_data.get("series", {}).get(c, [])
112
+ for v in series:
113
+ monitor._correlation_tracker._series[c].append(v)
114
+ monitor._correlation_tracker._n_updates = corr_data.get("n_updates", 0)
110
115
 
111
116
  return monitor
112
117
 
@@ -258,6 +258,7 @@ class Policy:
258
258
  rules: list[PolicyRule] = field(default_factory=list)
259
259
  default_constraint: Optional[Constraint] = None
260
260
  default_escalation: Optional[Escalation] = None
261
+ multi_rule: bool = False
261
262
 
262
263
  def evaluate(self, expr: Expression, ledger: Optional[Ledger] = None) -> list[Union[Correction, Escalation]]:
263
264
  matching = sorted([r for r in self.rules if r.matches(expr)],
@@ -305,7 +306,7 @@ class Policy:
305
306
  return results
306
307
 
307
308
  def to_dict(self) -> dict:
308
- d: dict = {"name": self.name, "rules": [r.to_dict() for r in self.rules]}
309
+ d: dict = {"name": self.name, "rules": [r.to_dict() for r in self.rules], "multi_rule": self.multi_rule}
309
310
  if self.default_constraint:
310
311
  d["default_constraint"] = self.default_constraint.to_dict()
311
312
  if self.default_escalation:
@@ -324,6 +325,7 @@ class Policy:
324
325
  name=d["name"], rules=rules,
325
326
  default_constraint=Constraint.from_dict(d["default_constraint"]) if "default_constraint" in d else None,
326
327
  default_escalation=Escalation.from_dict(d["default_escalation"]) if "default_escalation" in d else None,
328
+ multi_rule=d.get("multi_rule", False),
327
329
  )
328
330
 
329
331
  def __repr__(self) -> str:
@@ -59,6 +59,7 @@ class DecisionTrace:
59
59
  winner: Optional[str] = None
60
60
  result: Optional[Union[Correction, Escalation]] = None
61
61
  causal_context: Optional[dict] = None # from causal.explain_all() if available
62
+ results: list = field(default_factory=list) # all fired corrections/escalations (multi_rule)
62
63
 
63
64
  @property
64
65
  def rules_considered(self) -> int:
@@ -111,6 +112,11 @@ class DecisionTrace:
111
112
  d["result_type"] = None
112
113
  if self.causal_context:
113
114
  d["causal_context"] = self.causal_context
115
+ if self.results:
116
+ d["results"] = [
117
+ r.to_dict() if hasattr(r, 'to_dict') else str(r)
118
+ for r in self.results
119
+ ]
114
120
  return d
115
121
 
116
122
  def __repr__(self) -> str:
@@ -132,12 +138,12 @@ def trace_evaluate(
132
138
  attached to the trace for auditability.
133
139
  """
134
140
  dt = DecisionTrace(expression=expr, causal_context=causal_context)
135
- winner_result = None
136
- winner_name = None
137
141
 
138
142
  # Evaluate all rules in priority order
139
143
  sorted_rules = sorted(policy.rules, key=lambda r: r.priority, reverse=True)
140
144
 
145
+ all_results = []
146
+
141
147
  for rule in sorted_rules:
142
148
  matched = rule.matches(expr)
143
149
  ev = RuleEvaluation(
@@ -156,26 +162,24 @@ def trace_evaluate(
156
162
  ev.outcome = "escalation"
157
163
  ev.reason = f"{result.level.value}: {result.reason}"
158
164
  ev.result = result
159
-
160
- if winner_result is None:
161
- winner_result = result
162
- winner_name = rule.name
165
+ all_results.append((rule.name, result))
163
166
  else:
164
167
  ev.outcome = "suppressed"
165
168
  ev.reason = "evaluate returned None"
166
169
 
167
170
  dt.evaluations.append(ev)
168
171
 
169
- if winner_result is None and policy.default_escalation:
170
- winner_result = Escalation(
172
+ if not all_results and policy.default_escalation:
173
+ default_esc = Escalation(
171
174
  policy.default_escalation.level,
172
175
  policy.default_escalation.reason,
173
176
  "default",
174
177
  )
175
- winner_name = "default"
178
+ all_results.append(("default", default_esc))
176
179
 
177
- dt.winner = winner_name
178
- dt.result = winner_result
180
+ dt.results = [r for _, r in all_results]
181
+ dt.winner = all_results[0][0] if all_results else None
182
+ dt.result = all_results[0][1] if all_results else None
179
183
  return dt
180
184
 
181
185
 
@@ -364,6 +364,7 @@ class Monitor:
364
364
  window_config: Optional[WindowConfig] = None,
365
365
  min_correlation: float = 0.7,
366
366
  anomaly_min_reference: int = 10,
367
+ features: Optional[set] = None,
367
368
  ):
368
369
  self.parser = parser
369
370
  self.window = window
@@ -374,7 +375,10 @@ class Monitor:
374
375
  self.anomaly_window = cfg.anomaly or anomaly_window or window
375
376
  self.correlation_window = cfg.correlation or correlation_window or window
376
377
 
377
- if anomaly_min_reference > self.anomaly_window:
378
+ # Resolve feature flags: None means all enabled (backward compatible)
379
+ self.features = frozenset(features) if features is not None else frozenset({"drift", "anomaly", "correlation"})
380
+
381
+ if "anomaly" in self.features and anomaly_min_reference > self.anomaly_window:
378
382
  warnings.warn(
379
383
  f"Monitor: anomaly_min_reference={anomaly_min_reference} > "
380
384
  f"anomaly_window={self.anomaly_window} — AnomalyTrackers will never "
@@ -386,22 +390,28 @@ class Monitor:
386
390
  self._expression: Optional[Expression] = None
387
391
  self._step = 0
388
392
 
389
- # Create per-component trackers
393
+ # Create per-component trackers for enabled features only
390
394
  self._drift_trackers: dict[str, DriftTracker] = {}
391
395
  self._anomaly_trackers: dict[str, AnomalyTracker] = {}
392
396
 
393
- for name in parser.baselines:
394
- self._drift_trackers[name] = DriftTracker(name, window=self.drift_window)
395
- self._anomaly_trackers[name] = AnomalyTracker(
396
- name, window=self.anomaly_window, min_reference=anomaly_min_reference,
397
- )
397
+ if "drift" in self.features:
398
+ for name in parser.baselines:
399
+ self._drift_trackers[name] = DriftTracker(name, window=self.drift_window)
398
400
 
399
- # Correlation tracker across all components
400
- self._correlation_tracker = CorrelationTracker(
401
- list(parser.baselines.keys()),
402
- window=self.correlation_window,
403
- min_correlation=min_correlation,
404
- )
401
+ if "anomaly" in self.features:
402
+ for name in parser.baselines:
403
+ self._anomaly_trackers[name] = AnomalyTracker(
404
+ name, window=self.anomaly_window, min_reference=anomaly_min_reference,
405
+ )
406
+
407
+ if "correlation" in self.features:
408
+ self._correlation_tracker: Optional[CorrelationTracker] = CorrelationTracker(
409
+ list(parser.baselines.keys()),
410
+ window=self.correlation_window,
411
+ min_correlation=min_correlation,
412
+ )
413
+ else:
414
+ self._correlation_tracker = None
405
415
 
406
416
  def update(
407
417
  self,
@@ -440,7 +450,8 @@ class Monitor:
440
450
  self._anomaly_trackers[name].update(obs.value)
441
451
 
442
452
  # Correlation
443
- self._correlation_tracker.update(values)
453
+ if self._correlation_tracker is not None:
454
+ self._correlation_tracker.update(values)
444
455
 
445
456
  return self._expression
446
457
 
@@ -458,6 +469,8 @@ class Monitor:
458
469
 
459
470
  @property
460
471
  def correlations(self) -> Optional[CorrelationMatrix]:
472
+ if self._correlation_tracker is None:
473
+ return None
461
474
  return self._correlation_tracker.matrix
462
475
 
463
476
  @property
@@ -469,16 +482,18 @@ class Monitor:
469
482
  d: dict = {
470
483
  "step": self._step,
471
484
  "expression": self._expression.to_dict() if self._expression else None,
472
- "drift": {},
473
- "anomaly": {},
474
485
  }
475
- for name, tracker in self._drift_trackers.items():
476
- if tracker.classification:
477
- d["drift"][name] = tracker.classification.to_dict()
478
- for name, tracker in self._anomaly_trackers.items():
479
- if tracker.classification:
480
- d["anomaly"][name] = tracker.classification.to_dict()
481
- if self._correlation_tracker.matrix:
486
+ if "drift" in self.features:
487
+ d["drift"] = {}
488
+ for name, tracker in self._drift_trackers.items():
489
+ if tracker.classification:
490
+ d["drift"][name] = tracker.classification.to_dict()
491
+ if "anomaly" in self.features:
492
+ d["anomaly"] = {}
493
+ for name, tracker in self._anomaly_trackers.items():
494
+ if tracker.classification:
495
+ d["anomaly"][name] = tracker.classification.to_dict()
496
+ if self._correlation_tracker is not None and self._correlation_tracker.matrix:
482
497
  d["correlations"] = self._correlation_tracker.matrix.to_dict()
483
498
  return d
484
499
 
@@ -506,7 +521,8 @@ class Monitor:
506
521
  t.reset()
507
522
  for t in self._anomaly_trackers.values():
508
523
  t.reset()
509
- self._correlation_tracker.reset()
524
+ if self._correlation_tracker is not None:
525
+ self._correlation_tracker.reset()
510
526
 
511
527
  def __repr__(self) -> str:
512
528
  windows_uniform = (self.drift_window == self.anomaly_window == self.correlation_window)
@@ -514,4 +530,9 @@ class Monitor:
514
530
  w = f"window={self.drift_window}"
515
531
  else:
516
532
  w = f"drift={self.drift_window}, anomaly={self.anomaly_window}, corr={self.correlation_window}"
517
- return f"Monitor(step={self._step}, {len(self._drift_trackers)} components, {w})"
533
+ all_features = frozenset({"drift", "anomaly", "correlation"})
534
+ if self.features == all_features:
535
+ feat = ""
536
+ else:
537
+ feat = f", features={{{', '.join(sorted(self.features))}}}"
538
+ return f"Monitor(step={self._step}, {len(self._drift_trackers)} components, {w}{feat})"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: margin
3
- Version: 0.9.1
3
+ Version: 0.9.3
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.1"
7
+ version = "0.9.3"
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"}
@@ -97,3 +97,82 @@ class TestParserFromCalibration:
97
97
  def test_empty_raises(self):
98
98
  with pytest.raises(ValueError):
99
99
  parser_from_calibration({})
100
+
101
+
102
+ class TestCalibrateUseStd:
103
+ def test_higher_is_better_thresholds(self):
104
+ # mean=100, std=10 → intact=85 (100-1.5*10), ablated=70 (100-3*10)
105
+ r = calibrate([90.0, 100.0, 110.0], use_std=True)
106
+ assert r.thresholds.intact == pytest.approx(85.0, abs=0.1)
107
+ assert r.thresholds.ablated == pytest.approx(70.0, abs=0.1)
108
+ assert r.thresholds.higher_is_better is True
109
+
110
+ def test_lower_is_better_thresholds(self):
111
+ # mean=0.01, std=0.001 → intact=0.0115 (0.01+1.5*0.001), ablated=0.013 (0.01+3*0.001)
112
+ r = calibrate([0.009, 0.010, 0.011], use_std=True, higher_is_better=False)
113
+ assert r.thresholds.higher_is_better is False
114
+ assert r.thresholds.intact == pytest.approx(0.0115, abs=0.0001)
115
+ assert r.thresholds.ablated == pytest.approx(0.013, abs=0.0001)
116
+
117
+ def test_custom_multipliers(self):
118
+ # mean=100, std=10 → intact=100-2*10=80, ablated=100-4*10=60
119
+ r = calibrate([90.0, 100.0, 110.0], use_std=True,
120
+ intact_std_multiplier=2.0, ablated_std_multiplier=4.0)
121
+ assert r.thresholds.intact == pytest.approx(80.0, abs=0.1)
122
+ assert r.thresholds.ablated == pytest.approx(60.0, abs=0.1)
123
+
124
+ def test_zero_variance_raises(self):
125
+ with pytest.raises(ValueError, match="zero-variance"):
126
+ calibrate([100.0, 100.0, 100.0], use_std=True)
127
+
128
+ def test_threads_through_calibrate_many(self):
129
+ from margin.calibrate import calibrate_many
130
+ _, thresholds = calibrate_many(
131
+ {"rps": [90.0, 100.0, 110.0]},
132
+ use_std=True,
133
+ )
134
+ # Should not raise; thresholds set from std mode
135
+ assert thresholds["rps"].intact < 100.0
136
+
137
+ def test_threads_through_parser_from_calibration(self):
138
+ p = parser_from_calibration(
139
+ {"rps": [90.0, 100.0, 110.0]},
140
+ use_std=True,
141
+ )
142
+ expr = p.parse({"rps": 60.0})
143
+ assert expr.health_of("rps") in (Health.ABLATED, Health.DEGRADED)
144
+
145
+
146
+ class TestNeedsRecalibrationMany:
147
+ from margin.calibrate import needs_recalibration_many as _nrm
148
+
149
+ def test_stable_data_no_recalibration(self):
150
+ from margin.calibrate import needs_recalibration_many
151
+ cal = {"cpu": [50.0] * 20, "mem": [70.0] * 20}
152
+ recent = {"cpu": [50.5] * 10, "mem": [70.2] * 10}
153
+ flags = needs_recalibration_many(cal, recent)
154
+ assert flags["cpu"] is False
155
+ assert flags["mem"] is False
156
+
157
+ def test_mean_shift_triggers_recalibration(self):
158
+ from margin.calibrate import needs_recalibration_many
159
+ cal = {"cpu": [50.0] * 20}
160
+ recent = {"cpu": [100.0] * 10} # >20% shift
161
+ flags = needs_recalibration_many(cal, recent)
162
+ assert flags["cpu"] is True
163
+
164
+ def test_returns_per_component_dict(self):
165
+ from margin.calibrate import needs_recalibration_many
166
+ cal = {"cpu": [50.0] * 20, "mem": [70.0] * 20, "err": [0.01] * 20}
167
+ recent = {"cpu": [50.0] * 10, "mem": [70.0] * 10, "err": [0.01] * 10}
168
+ flags = needs_recalibration_many(cal, recent)
169
+ assert set(flags.keys()) == {"cpu", "mem", "err"}
170
+ assert all(isinstance(v, bool) for v in flags.values())
171
+
172
+ def test_partial_subset_triggers(self):
173
+ from margin.calibrate import needs_recalibration_many
174
+ cal = {"cpu": [50.0] * 20, "mem": [70.0] * 20}
175
+ recent = {"cpu": [100.0] * 10, "mem": [70.1] * 10}
176
+ flags = needs_recalibration_many(cal, recent)
177
+ assert flags["cpu"] is True
178
+ assert flags["mem"] is False
@@ -97,3 +97,48 @@ class TestClassifyEdgeCases:
97
97
  def test_negative_inf_lower_is_better(self):
98
98
  t = Thresholds(intact=0.02, ablated=0.10, higher_is_better=False)
99
99
  assert classify(float('-inf'), Confidence.HIGH, thresholds=t) == Health.INTACT
100
+
101
+
102
+ class TestThresholdsLabels:
103
+ def test_label_for_returns_custom(self):
104
+ t = Thresholds(intact=80.0, ablated=30.0, labels={"ABLATED": "CRITICAL", "INTACT": "OK"})
105
+ assert t.label_for(Health.ABLATED) == "CRITICAL"
106
+ assert t.label_for(Health.INTACT) == "OK"
107
+
108
+ def test_label_for_fallback_to_enum(self):
109
+ t = Thresholds(intact=80.0, ablated=30.0, labels={"ABLATED": "CRITICAL"})
110
+ assert t.label_for(Health.DEGRADED) == "DEGRADED"
111
+
112
+ def test_no_labels_returns_enum_name(self):
113
+ t = Thresholds(intact=80.0, ablated=30.0)
114
+ assert t.label_for(Health.INTACT) == "INTACT"
115
+
116
+ def test_to_dict_includes_labels(self):
117
+ t = Thresholds(intact=80.0, ablated=30.0, labels={"ABLATED": "CRITICAL"})
118
+ d = t.to_dict()
119
+ assert d["labels"] == {"ABLATED": "CRITICAL"}
120
+ assert d["intact"] == 80.0
121
+ assert d["ablated"] == 30.0
122
+ assert d["higher_is_better"] is True
123
+ assert d["active_min"] == pytest.approx(0.05)
124
+
125
+ def test_to_dict_omits_labels_when_none(self):
126
+ t = Thresholds(intact=80.0, ablated=30.0)
127
+ d = t.to_dict()
128
+ assert "labels" not in d
129
+
130
+ def test_from_dict_roundtrip(self):
131
+ t = Thresholds(intact=0.01, ablated=0.10, higher_is_better=False,
132
+ active_min=0.1, labels={"INTACT": "GOOD", "DEGRADED": "WARN"})
133
+ t2 = Thresholds.from_dict(t.to_dict())
134
+ assert t2.intact == t.intact
135
+ assert t2.ablated == t.ablated
136
+ assert t2.higher_is_better == t.higher_is_better
137
+ assert t2.active_min == t.active_min
138
+ assert t2.labels == t.labels
139
+
140
+ def test_from_dict_no_labels(self):
141
+ t = Thresholds.from_dict({"intact": 80.0, "ablated": 30.0})
142
+ assert t.labels is None
143
+ assert t.higher_is_better is True
144
+ assert t.active_min == pytest.approx(0.05)
@@ -210,3 +210,61 @@ class TestParserPolarity:
210
210
  e = p.parse({"throughput": 95.0, "err": 0.08}, correction_magnitude=1.0, alpha=0.5)
211
211
  assert e.corrections[0].target == "err"
212
212
  assert e.corrections[0].op == Op.RESTORE
213
+
214
+
215
+ class TestHealthLabel:
216
+ def test_to_atom_uses_health_label(self):
217
+ obs = Observation(
218
+ name="cpu", health=Health.ABLATED, value=10.0, baseline=100.0,
219
+ confidence=Confidence.HIGH, health_label="CRITICAL",
220
+ )
221
+ atom = obs.to_atom()
222
+ assert "CRITICAL" in atom
223
+ assert "ABLATED" not in atom
224
+
225
+ def test_to_atom_fallback_when_no_label(self):
226
+ obs = Observation(
227
+ name="cpu", health=Health.DEGRADED, value=60.0, baseline=100.0,
228
+ confidence=Confidence.HIGH,
229
+ )
230
+ atom = obs.to_atom()
231
+ assert "DEGRADED" in atom
232
+
233
+ def test_parser_sets_health_label(self):
234
+ p = Parser(
235
+ baselines={"cpu": 100.0},
236
+ thresholds=Thresholds(
237
+ intact=80.0, ablated=30.0,
238
+ labels={"ABLATED": "CRITICAL", "DEGRADED": "WARNING", "INTACT": "OK"},
239
+ ),
240
+ )
241
+ expr = p.parse({"cpu": 10.0})
242
+ obs = expr.observations[0]
243
+ assert obs.health_label == "CRITICAL"
244
+ assert "CRITICAL" in obs.to_atom()
245
+
246
+ def test_parser_label_for(self):
247
+ p = Parser(
248
+ baselines={"cpu": 100.0},
249
+ thresholds=Thresholds(intact=80.0, ablated=30.0, labels={"INTACT": "OK"}),
250
+ )
251
+ assert p.label_for("cpu", Health.INTACT) == "OK"
252
+ assert p.label_for("cpu", Health.DEGRADED) == "DEGRADED"
253
+
254
+ def test_health_label_to_dict_roundtrip(self):
255
+ obs = Observation(
256
+ name="mem", health=Health.DEGRADED, value=60.0, baseline=100.0,
257
+ confidence=Confidence.HIGH, health_label="WARNING",
258
+ )
259
+ d = obs.to_dict()
260
+ assert d["health_label"] == "WARNING"
261
+ obs2 = Observation.from_dict(d)
262
+ assert obs2.health_label == "WARNING"
263
+
264
+ def test_health_label_none_not_in_dict(self):
265
+ obs = Observation(
266
+ name="mem", health=Health.INTACT, value=95.0, baseline=100.0,
267
+ confidence=Confidence.HIGH,
268
+ )
269
+ d = obs.to_dict()
270
+ assert "health_label" not in d
@@ -214,3 +214,53 @@ class TestCLI:
214
214
  assert result.returncode == 0
215
215
  assert "cpu" in result.stdout
216
216
  assert "INTACT" in result.stdout or "DEGRADED" in result.stdout
217
+
218
+
219
+ class TestFeaturesRoundtrip:
220
+ def test_partial_features_saved_and_restored(self):
221
+ p = _parser()
222
+ m = Monitor(p, features={"health", "drift"})
223
+ for i in range(10):
224
+ m.update({"cpu": 50.0 - i, "mem": 70.0},
225
+ now=t0 + timedelta(seconds=i * 60))
226
+
227
+ with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
228
+ save_monitor(m, f.name)
229
+ m2 = load_monitor(f.name, p)
230
+ Path(f.name).unlink()
231
+
232
+ assert m2.features == frozenset({"health", "drift"})
233
+ assert len(m2._drift_trackers) == 2
234
+ assert m2._correlation_tracker is None
235
+
236
+ def test_all_features_roundtrip(self):
237
+ p = _parser()
238
+ m = Monitor(p, features={"health", "drift", "anomaly", "correlation"})
239
+ for i in range(10):
240
+ m.update({"cpu": 50.0, "mem": 70.0},
241
+ now=t0 + timedelta(seconds=i * 60))
242
+
243
+ with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
244
+ save_monitor(m, f.name)
245
+ m2 = load_monitor(f.name, p)
246
+ Path(f.name).unlink()
247
+
248
+ assert "drift" in m2.features
249
+ assert "anomaly" in m2.features
250
+ assert "correlation" in m2.features
251
+
252
+ def test_anomaly_min_reference_roundtrip(self):
253
+ p = _parser()
254
+ m = Monitor(p, anomaly_min_reference=25)
255
+ for i in range(10):
256
+ m.update({"cpu": 50.0, "mem": 70.0},
257
+ now=t0 + timedelta(seconds=i * 60))
258
+
259
+ with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
260
+ save_monitor(m, f.name)
261
+ data = json.loads(Path(f.name).read_text())
262
+ m2 = load_monitor(f.name, p)
263
+ Path(f.name).unlink()
264
+
265
+ assert data["anomaly_min_reference"] == 25
266
+ assert m2._anomaly_trackers["cpu"]._min_reference == 25
@@ -136,3 +136,96 @@ class TestBacktestProposedBy:
136
136
  ledger = Ledger(records=[_rec(0, "x", Health.DEGRADED)])
137
137
  bt = policy.backtest(ledger)
138
138
  assert bt[0]["proposed_by"] == ""
139
+
140
+
141
+ class TestMultiRule:
142
+ 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
+ return Policy(
147
+ name="multi",
148
+ multi_rule=True,
149
+ rules=[
150
+ PolicyRule("log", any_degraded(),
151
+ Action(target="*", op=Op.NOOP, alpha=0.0), priority=5),
152
+ PolicyRule("restore", any_health(Health.DEGRADED),
153
+ Action(target="*", op=Op.RESTORE, alpha=0.5), priority=10),
154
+ ],
155
+ )
156
+
157
+ def test_multi_rule_all_matching_rules_fire(self):
158
+ policy = self._make_multi_policy()
159
+ dt = trace_evaluate(policy, _expr(_obs("x", Health.DEGRADED)))
160
+ # Both rules match DEGRADED
161
+ assert len(dt.results) == 2
162
+
163
+ def test_multi_rule_winner_is_highest_priority(self):
164
+ policy = self._make_multi_policy()
165
+ dt = trace_evaluate(policy, _expr(_obs("x", Health.DEGRADED)))
166
+ assert dt.winner == "restore"
167
+ assert dt.result is not None
168
+
169
+ def test_trace_always_has_all_matched_results(self):
170
+ # trace_evaluate always populates dt.results for all matches;
171
+ # 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
+ policy = Policy(
176
+ name="single",
177
+ multi_rule=False,
178
+ rules=[
179
+ PolicyRule("r1", any_degraded(), Action(target="*", op=Op.RESTORE, alpha=0.5), priority=10),
180
+ PolicyRule("r2", any_degraded(), Action(target="*", op=Op.NOOP), priority=5),
181
+ ],
182
+ )
183
+ dt = trace_evaluate(policy, _expr(_obs("x", Health.DEGRADED)))
184
+ # Both rules match — trace always records all; multi_rule only affects step()
185
+ assert len(dt.results) == 2
186
+ assert dt.winner == "r1" # highest priority still wins
187
+
188
+ def test_multi_rule_to_dict_includes_multi_rule_flag(self):
189
+ policy = self._make_multi_policy()
190
+ d = policy.to_dict()
191
+ assert d["multi_rule"] is True
192
+
193
+ def test_multi_rule_default_is_false(self):
194
+ from margin.policy.core import Policy
195
+ policy = Policy(name="p", rules=[])
196
+ assert policy.multi_rule is False
197
+ assert policy.to_dict()["multi_rule"] is False
198
+
199
+
200
+ class TestStepResultCorrections:
201
+ 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
+ policy = Policy(
208
+ name="multi",
209
+ multi_rule=True,
210
+ rules=[
211
+ PolicyRule("r1", any_degraded(), Action(target="*", op=Op.RESTORE, alpha=0.5), priority=10),
212
+ PolicyRule("r2", any_degraded(), Action(target="*", op=Op.NOOP), priority=5),
213
+ ],
214
+ )
215
+ result = step(_expr(_obs("x", Health.DEGRADED)), policy)
216
+ assert len(result.corrections) >= 1
217
+
218
+ 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
+ policy = Policy(
225
+ name="single",
226
+ multi_rule=False,
227
+ rules=[PolicyRule("r", any_degraded(), Action(target="*", op=Op.RESTORE, alpha=0.5))],
228
+ )
229
+ result = step(_expr(_obs("x", Health.DEGRADED)), policy)
230
+ # multi_rule=False → corrections list is empty (only .correction populated)
231
+ assert result.corrections == []
@@ -284,3 +284,124 @@ class TestMonitor:
284
284
  m = Monitor(self._parser())
285
285
  assert "Monitor" in repr(m)
286
286
  assert "3 components" in repr(m)
287
+
288
+
289
+ # -----------------------------------------------------------------------
290
+ # Monitor — feature flags
291
+ # -----------------------------------------------------------------------
292
+
293
+ class TestMonitorFeatures:
294
+ def _parser(self):
295
+ return Parser(
296
+ baselines={"cpu": 50.0, "mem": 70.0},
297
+ thresholds=Thresholds(intact=40.0, ablated=10.0),
298
+ )
299
+
300
+ def test_default_features_all_enabled(self):
301
+ m = Monitor(self._parser())
302
+ assert "drift" in m.features
303
+ assert "anomaly" in m.features
304
+ assert "correlation" in m.features
305
+
306
+ def test_features_health_only_skips_trackers(self):
307
+ m = Monitor(self._parser(), features={"health"})
308
+ assert len(m._drift_trackers) == 0
309
+ assert len(m._anomaly_trackers) == 0
310
+ assert m._correlation_tracker is None
311
+
312
+ def test_features_health_drift_no_anomaly(self):
313
+ m = Monitor(self._parser(), features={"health", "drift"})
314
+ assert len(m._drift_trackers) == 2
315
+ assert len(m._anomaly_trackers) == 0
316
+ assert m._correlation_tracker is None
317
+
318
+ def test_features_health_anomaly_no_drift(self):
319
+ m = Monitor(self._parser(), features={"health", "anomaly"})
320
+ assert len(m._drift_trackers) == 0
321
+ assert len(m._anomaly_trackers) == 2
322
+
323
+ def test_update_works_with_partial_features(self):
324
+ m = Monitor(self._parser(), features={"health", "drift"})
325
+ expr = m.update({"cpu": 48.0, "mem": 65.0})
326
+ assert expr is not None
327
+ assert m.step == 1
328
+
329
+ def test_features_is_frozenset(self):
330
+ m = Monitor(self._parser(), features={"health", "drift"})
331
+ assert isinstance(m.features, frozenset)
332
+
333
+ def test_status_omits_disabled_feature_keys(self):
334
+ m = Monitor(self._parser(), features={"health"})
335
+ for i in range(5):
336
+ m.update({"cpu": 50.0 - i, "mem": 70.0},
337
+ now=t0 + timedelta(seconds=i * 60))
338
+ s = m.status()
339
+ # drift disabled → "drift" key absent
340
+ assert "drift" not in s
341
+ assert "anomaly" not in s
342
+
343
+ def test_repr_shows_features_when_partial(self):
344
+ m = Monitor(self._parser(), features={"health", "drift"})
345
+ r = repr(m)
346
+ assert "features" in r
347
+
348
+ def test_repr_no_features_label_when_all_enabled(self):
349
+ m = Monitor(self._parser())
350
+ r = repr(m)
351
+ assert "features" not in r
352
+
353
+ def test_reset_works_with_partial_features(self):
354
+ m = Monitor(self._parser(), features={"health", "drift"})
355
+ for i in range(5):
356
+ m.update({"cpu": 50.0 - i, "mem": 70.0})
357
+ m.reset()
358
+ assert m.step == 0
359
+
360
+
361
+ # -----------------------------------------------------------------------
362
+ # Monitor — reset_anomaly_reference
363
+ # -----------------------------------------------------------------------
364
+
365
+ class TestMonitorResetAnomalyReference:
366
+ def _parser(self):
367
+ return Parser(
368
+ baselines={"cpu": 50.0, "mem": 70.0},
369
+ thresholds=Thresholds(intact=40.0, ablated=10.0),
370
+ )
371
+
372
+ def test_reset_clears_all_reference_values(self):
373
+ m = Monitor(self._parser(), anomaly_min_reference=5)
374
+ for i in range(10):
375
+ m.update({"cpu": 50.0, "mem": 70.0},
376
+ now=t0 + timedelta(seconds=i * 60))
377
+ assert m._anomaly_trackers["cpu"].n_values == 10
378
+ m.reset_anomaly_reference()
379
+ assert m._anomaly_trackers["cpu"].n_values == 0
380
+ assert m._anomaly_trackers["mem"].n_values == 0
381
+
382
+ def test_reset_specific_components(self):
383
+ m = Monitor(self._parser(), anomaly_min_reference=5)
384
+ for i in range(10):
385
+ m.update({"cpu": 50.0, "mem": 70.0},
386
+ now=t0 + timedelta(seconds=i * 60))
387
+ m.reset_anomaly_reference(components=["cpu"])
388
+ assert m._anomaly_trackers["cpu"].n_values == 0
389
+ assert m._anomaly_trackers["mem"].n_values == 10
390
+
391
+ def test_reset_allows_fresh_reference(self):
392
+ m = Monitor(self._parser(), anomaly_min_reference=5)
393
+ # Build reference at baseline=50
394
+ for i in range(10):
395
+ m.update({"cpu": 50.0, "mem": 70.0},
396
+ now=t0 + timedelta(seconds=i * 60))
397
+ m.reset_anomaly_reference()
398
+ # Feed new baseline at 100 — should warm up without contamination
399
+ for i in range(10):
400
+ m.update({"cpu": 100.0, "mem": 70.0},
401
+ now=t0 + timedelta(seconds=(20 + i) * 60))
402
+ assert m._anomaly_trackers["cpu"].n_values == 10
403
+
404
+ def test_reset_noop_when_anomaly_disabled(self):
405
+ m = Monitor(self._parser(), features={"health", "drift"})
406
+ # Should not raise even though anomaly trackers don't exist
407
+ m.reset_anomaly_reference()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes