margin 0.9.5__tar.gz → 0.9.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of margin might be problematic. Click here for more details.

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