margin 0.9.5__tar.gz → 0.9.7__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.7}/PKG-INFO +1 -1
  2. {margin-0.9.5 → margin-0.9.7}/margin/calibrate.py +7 -0
  3. {margin-0.9.5 → margin-0.9.7}/margin/config.py +45 -8
  4. {margin-0.9.5 → margin-0.9.7}/margin/contract.py +22 -0
  5. {margin-0.9.5 → margin-0.9.7}/margin/ledger.py +4 -4
  6. {margin-0.9.5 → margin-0.9.7}/margin/loop.py +18 -9
  7. {margin-0.9.5 → margin-0.9.7}/margin/observation.py +13 -3
  8. {margin-0.9.5 → margin-0.9.7}/margin/policy/core.py +23 -0
  9. {margin-0.9.5 → margin-0.9.7}/margin/streaming.py +12 -1
  10. {margin-0.9.5 → margin-0.9.7/margin.egg-info}/PKG-INFO +1 -1
  11. {margin-0.9.5 → margin-0.9.7}/pyproject.toml +1 -1
  12. {margin-0.9.5 → margin-0.9.7}/tests/test_config.py +119 -0
  13. {margin-0.9.5 → margin-0.9.7}/tests/test_contract.py +21 -0
  14. {margin-0.9.5 → margin-0.9.7}/tests/test_ledger.py +24 -0
  15. {margin-0.9.5 → margin-0.9.7}/tests/test_loop.py +37 -0
  16. {margin-0.9.5 → margin-0.9.7}/tests/test_observation.py +38 -0
  17. {margin-0.9.5 → margin-0.9.7}/tests/test_policy.py +33 -0
  18. {margin-0.9.5 → margin-0.9.7}/LICENSE +0 -0
  19. {margin-0.9.5 → margin-0.9.7}/NOTICE +0 -0
  20. {margin-0.9.5 → margin-0.9.7}/README.md +0 -0
  21. {margin-0.9.5 → margin-0.9.7}/margin/__init__.py +0 -0
  22. {margin-0.9.5 → margin-0.9.7}/margin/__main__.py +0 -0
  23. {margin-0.9.5 → margin-0.9.7}/margin/algebra.py +0 -0
  24. {margin-0.9.5 → margin-0.9.7}/margin/anomaly.py +0 -0
  25. {margin-0.9.5 → margin-0.9.7}/margin/bridge.py +0 -0
  26. {margin-0.9.5 → margin-0.9.7}/margin/causal.py +0 -0
  27. {margin-0.9.5 → margin-0.9.7}/margin/composite.py +0 -0
  28. {margin-0.9.5 → margin-0.9.7}/margin/confidence.py +0 -0
  29. {margin-0.9.5 → margin-0.9.7}/margin/correlate.py +0 -0
  30. {margin-0.9.5 → margin-0.9.7}/margin/diff.py +0 -0
  31. {margin-0.9.5 → margin-0.9.7}/margin/drift.py +0 -0
  32. {margin-0.9.5 → margin-0.9.7}/margin/events.py +0 -0
  33. {margin-0.9.5 → margin-0.9.7}/margin/forecast.py +0 -0
  34. {margin-0.9.5 → margin-0.9.7}/margin/health.py +0 -0
  35. {margin-0.9.5 → margin-0.9.7}/margin/intent.py +0 -0
  36. {margin-0.9.5 → margin-0.9.7}/margin/persist.py +0 -0
  37. {margin-0.9.5 → margin-0.9.7}/margin/policy/__init__.py +0 -0
  38. {margin-0.9.5 → margin-0.9.7}/margin/policy/compose.py +0 -0
  39. {margin-0.9.5 → margin-0.9.7}/margin/policy/temporal.py +0 -0
  40. {margin-0.9.5 → margin-0.9.7}/margin/policy/trace.py +0 -0
  41. {margin-0.9.5 → margin-0.9.7}/margin/policy/tuning.py +0 -0
  42. {margin-0.9.5 → margin-0.9.7}/margin/policy/validate.py +0 -0
  43. {margin-0.9.5 → margin-0.9.7}/margin/predicates.py +0 -0
  44. {margin-0.9.5 → margin-0.9.7}/margin/provenance.py +0 -0
  45. {margin-0.9.5 → margin-0.9.7}/margin/transitions.py +0 -0
  46. {margin-0.9.5 → margin-0.9.7}/margin/uncertain.py +0 -0
  47. {margin-0.9.5 → margin-0.9.7}/margin/validity.py +0 -0
  48. {margin-0.9.5 → margin-0.9.7}/margin.egg-info/SOURCES.txt +0 -0
  49. {margin-0.9.5 → margin-0.9.7}/margin.egg-info/dependency_links.txt +0 -0
  50. {margin-0.9.5 → margin-0.9.7}/margin.egg-info/top_level.txt +0 -0
  51. {margin-0.9.5 → margin-0.9.7}/setup.cfg +0 -0
  52. {margin-0.9.5 → margin-0.9.7}/tests/test_algebra.py +0 -0
  53. {margin-0.9.5 → margin-0.9.7}/tests/test_anomaly.py +0 -0
  54. {margin-0.9.5 → margin-0.9.7}/tests/test_bridge.py +0 -0
  55. {margin-0.9.5 → margin-0.9.7}/tests/test_calibrate.py +0 -0
  56. {margin-0.9.5 → margin-0.9.7}/tests/test_causal.py +0 -0
  57. {margin-0.9.5 → margin-0.9.7}/tests/test_composite.py +0 -0
  58. {margin-0.9.5 → margin-0.9.7}/tests/test_confidence.py +0 -0
  59. {margin-0.9.5 → margin-0.9.7}/tests/test_correlate.py +0 -0
  60. {margin-0.9.5 → margin-0.9.7}/tests/test_diff.py +0 -0
  61. {margin-0.9.5 → margin-0.9.7}/tests/test_drift.py +0 -0
  62. {margin-0.9.5 → margin-0.9.7}/tests/test_events.py +0 -0
  63. {margin-0.9.5 → margin-0.9.7}/tests/test_forecast.py +0 -0
  64. {margin-0.9.5 → margin-0.9.7}/tests/test_health.py +0 -0
  65. {margin-0.9.5 → margin-0.9.7}/tests/test_healthcare.py +0 -0
  66. {margin-0.9.5 → margin-0.9.7}/tests/test_intent.py +0 -0
  67. {margin-0.9.5 → margin-0.9.7}/tests/test_neuro.py +0 -0
  68. {margin-0.9.5 → margin-0.9.7}/tests/test_persist.py +0 -0
  69. {margin-0.9.5 → margin-0.9.7}/tests/test_policy_compose.py +0 -0
  70. {margin-0.9.5 → margin-0.9.7}/tests/test_policy_temporal.py +0 -0
  71. {margin-0.9.5 → margin-0.9.7}/tests/test_policy_trace.py +0 -0
  72. {margin-0.9.5 → margin-0.9.7}/tests/test_policy_tuning.py +0 -0
  73. {margin-0.9.5 → margin-0.9.7}/tests/test_policy_validate.py +0 -0
  74. {margin-0.9.5 → margin-0.9.7}/tests/test_predicates.py +0 -0
  75. {margin-0.9.5 → margin-0.9.7}/tests/test_printer3d.py +0 -0
  76. {margin-0.9.5 → margin-0.9.7}/tests/test_provenance.py +0 -0
  77. {margin-0.9.5 → margin-0.9.7}/tests/test_reverse_bridge.py +0 -0
  78. {margin-0.9.5 → margin-0.9.7}/tests/test_ros2.py +0 -0
  79. {margin-0.9.5 → margin-0.9.7}/tests/test_staleness.py +0 -0
  80. {margin-0.9.5 → margin-0.9.7}/tests/test_streaming.py +0 -0
  81. {margin-0.9.5 → margin-0.9.7}/tests/test_transformer.py +0 -0
  82. {margin-0.9.5 → margin-0.9.7}/tests/test_transitions.py +0 -0
  83. {margin-0.9.5 → margin-0.9.7}/tests/test_uncertain.py +0 -0
  84. {margin-0.9.5 → margin-0.9.7}/tests/test_validity.py +0 -0
  85. {margin-0.9.5 → margin-0.9.7}/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.7
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,
@@ -125,6 +125,7 @@ def from_config(config: dict) -> dict:
125
125
  intact=spec.get("intact", default_intact),
126
126
  ablated=spec.get("ablated", default_ablated),
127
127
  higher_is_better=hib,
128
+ labels=spec.get("labels"),
128
129
  )
129
130
 
130
131
  # Use first component's thresholds as default (or the explicit default)
@@ -153,25 +154,61 @@ def from_config(config: dict) -> dict:
153
154
  op=Op(action_spec.get("op", "RESTORE")),
154
155
  alpha=action_spec.get("alpha", 0.5),
155
156
  magnitude=action_spec.get("magnitude", 1.0),
157
+ alpha_from_sigma=action_spec.get("alpha_from_sigma", False),
158
+ magnitude_from_sigma=action_spec.get("magnitude_from_sigma", False),
156
159
  )
160
+ constraint = None
161
+ if "constraint" in spec:
162
+ cs = spec["constraint"]
163
+ constraint = Constraint(
164
+ max_alpha=cs.get("max_alpha", 1.0),
165
+ min_alpha=cs.get("min_alpha", 0.0),
166
+ cooldown_steps=cs.get("cooldown_steps", 0),
167
+ max_per_window=cs.get("max_per_window", 0),
168
+ window_steps=cs.get("window_steps", 0),
169
+ )
170
+ escalation = None
171
+ if "escalation" in spec:
172
+ es = spec["escalation"]
173
+ escalation = Escalation(
174
+ level=EscalationLevel(es.get("level", "LOG")),
175
+ reason=es.get("reason", ""),
176
+ )
177
+ min_conf_str = spec.get("min_confidence")
178
+ min_confidence = Confidence(min_conf_str) if min_conf_str else Confidence.LOW
157
179
  rules.append(PolicyRule(
158
180
  name=spec["name"],
159
181
  predicate=predicate,
160
182
  action=action,
161
183
  priority=spec.get("priority", 0),
184
+ constraint=constraint,
185
+ escalation=escalation,
186
+ min_confidence=min_confidence,
162
187
  ))
163
- result["policy"] = Policy(name=config.get("name", "config-policy"), rules=rules)
188
+ result["policy"] = Policy(
189
+ name=config.get("name", "config-policy"),
190
+ rules=rules,
191
+ multi_rule=config.get("multi_rule", False),
192
+ )
164
193
 
165
194
  # --- Contract ---
166
195
  contract_specs = config.get("contract", [])
167
196
  if contract_specs:
168
197
  terms = []
169
198
  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
- ))
199
+ term_type = spec.get("type", "health_target")
200
+ if term_type == "health_target" and "type" not in spec:
201
+ # backward-compatible: old configs without explicit type field
202
+ terms.append(HealthTarget(
203
+ name=spec["name"],
204
+ component=spec["component"],
205
+ target=Health(spec.get("health", "INTACT")),
206
+ ))
207
+ else:
208
+ # normalise: config may use "health" instead of "target"
209
+ if "health" in spec and "target" not in spec:
210
+ spec = dict(spec, target=spec["health"])
211
+ terms.append(contract_term_from_dict(spec))
175
212
  result["contract"] = Contract(
176
213
  name=config.get("contract_name", "config-contract"),
177
214
  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(
@@ -337,5 +352,12 @@ class Contract:
337
352
  def to_dict(self) -> dict:
338
353
  return {"name": self.name, "terms": [t.to_dict() for t in self.terms]}
339
354
 
355
+ @classmethod
356
+ def from_dict(cls, d: dict) -> 'Contract':
357
+ return cls(
358
+ name=d.get("name", ""),
359
+ terms=[contract_term_from_dict(t) for t in d.get("terms", [])],
360
+ )
361
+
340
362
  def __repr__(self) -> str:
341
363
  return f"Contract({self.name!r}, {len(self.terms)} terms)"
@@ -211,11 +211,11 @@ class Ledger:
211
211
  "n_harmful": len(self.harmful()),
212
212
  }
213
213
 
214
+ def to_dict(self) -> dict:
215
+ return {"label": self.label, "records": [r.to_dict() for r in self.records]}
216
+
214
217
  def to_json(self, indent: int = 2) -> str:
215
- return json.dumps({
216
- "label": self.label,
217
- "records": [r.to_dict() for r in self.records],
218
- }, indent=indent)
218
+ return json.dumps(self.to_dict(), indent=indent)
219
219
 
220
220
  @classmethod
221
221
  def from_dict(cls, d: dict) -> 'Ledger':
@@ -250,6 +250,8 @@ class FullStepResult:
250
250
  d["drift"] = {k: v.to_dict() for k, v in self.drift.items()}
251
251
  if self.anomaly:
252
252
  d["anomaly"] = {k: v.to_dict() for k, v in self.anomaly.items()}
253
+ if self.correlations is not None and self.correlations.to_dict():
254
+ d["correlations"] = self.correlations.to_dict()
253
255
  if self.intent:
254
256
  d["intent"] = self.intent.to_dict()
255
257
  return d
@@ -281,6 +283,9 @@ def full_step(
281
283
  contract: Optional[Contract] = None,
282
284
  intent: Optional[object] = None,
283
285
  now=None,
286
+ confidences=None,
287
+ provenance=None,
288
+ label: str = "",
284
289
  ) -> FullStepResult:
285
290
  """
286
291
  Run ALL layers in one call:
@@ -289,14 +294,17 @@ def full_step(
289
294
  3. Intent.evaluate() → goal feasibility
290
295
 
291
296
  Args:
292
- monitor: streaming Monitor
293
- values: raw measurements {component: value}
294
- policy: decision policy
295
- ledger: correction ledger (optional)
296
- graph: causal graph (optional)
297
- contract: success contract (optional)
298
- intent: Intent goal (optional)
299
- now: current timestamp (optional)
297
+ monitor: streaming Monitor
298
+ values: raw measurements {component: value}
299
+ policy: decision policy
300
+ ledger: correction ledger (optional)
301
+ graph: causal graph (optional)
302
+ contract: success contract (optional)
303
+ intent: Intent goal (optional)
304
+ now: current timestamp (optional)
305
+ confidences: per-component Confidence overrides (optional)
306
+ provenance: provenance tags for all observations (optional)
307
+ label: expression label for this step (optional)
300
308
 
301
309
  Returns FullStepResult with all stages evaluated.
302
310
  """
@@ -305,7 +313,8 @@ def full_step(
305
313
  now = now or datetime.now()
306
314
 
307
315
  # Layer 1-4: health + drift + anomaly + correlation
308
- expr = monitor.update(values, now=now)
316
+ expr = monitor.update(values, now=now, label=label,
317
+ confidences=confidences, provenance=provenance)
309
318
 
310
319
  # Layer 5-7: explain + decide + contract
311
320
  step_result = step(expr, policy, ledger, graph, contract)
@@ -333,16 +333,26 @@ class Parser:
333
333
  """
334
334
  Parse a set of component measurements into a typed Expression.
335
335
 
336
- values: {component_name: measurement}
336
+ values: {component_name: measurement}
337
337
  correction_magnitude: size of the correction being applied
338
- alpha: mixing coefficient for the correction
339
- confidences: per-component confidence (defaults to MODERATE)
338
+ alpha: mixing coefficient for the correction
339
+ confidences: per-component Confidence overrides (defaults to MODERATE)
340
+ label: tag for this expression (e.g. step identifier)
341
+ step: step index (set automatically by Monitor)
342
+ provenance: provenance tags attached to all observations
340
343
  """
341
344
  confidences = confidences or {}
342
345
  provenance = provenance or []
343
346
 
344
347
  observations = []
345
348
  for name, val in values.items():
349
+ if name not in self.baselines:
350
+ warnings.warn(
351
+ f"Parser.parse: component {name!r} has no baseline — "
352
+ "using the observed value as baseline (sigma=0). "
353
+ "Add it to Parser.baselines or check for a typo.",
354
+ stacklevel=2,
355
+ )
346
356
  baseline = self.baselines.get(name, val)
347
357
  conf = confidences.get(name, Confidence.MODERATE)
348
358
  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.7
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.7"
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,122 @@ 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)
247
+
248
+
249
+ class TestConfigNewFields:
250
+ def test_alpha_from_sigma_in_config(self):
251
+ from margin import from_config
252
+ cfg = {
253
+ "components": {"cpu": {"baseline": 50.0, "intact": 40.0, "ablated": 10.0}},
254
+ "policy": [{
255
+ "name": "dynamic",
256
+ "when": "any_degraded",
257
+ "action": {"op": "RESTORE", "alpha_from_sigma": True},
258
+ }],
259
+ }
260
+ result = from_config(cfg)
261
+ assert result["policy"].rules[0].action.alpha_from_sigma is True
262
+
263
+ def test_min_confidence_in_config(self):
264
+ from margin import from_config
265
+ from margin.confidence import Confidence
266
+ cfg = {
267
+ "components": {"cpu": {"baseline": 50.0, "intact": 40.0, "ablated": 10.0}},
268
+ "policy": [{
269
+ "name": "high-conf-only",
270
+ "when": "any_degraded",
271
+ "action": {"op": "RESTORE", "alpha": 0.5},
272
+ "min_confidence": "high",
273
+ }],
274
+ }
275
+ result = from_config(cfg)
276
+ assert result["policy"].rules[0].min_confidence == Confidence.HIGH
277
+
278
+ def test_multi_rule_in_config(self):
279
+ from margin import from_config
280
+ cfg = {
281
+ "name": "my-policy",
282
+ "multi_rule": True,
283
+ "components": {"cpu": {"baseline": 50.0, "intact": 40.0, "ablated": 10.0}},
284
+ "policy": [{"name": "r", "when": "any_degraded", "action": {"op": "RESTORE"}}],
285
+ }
286
+ result = from_config(cfg)
287
+ assert result["policy"].multi_rule is True
288
+
289
+ def test_labels_in_config(self):
290
+ from margin import from_config
291
+ cfg = {
292
+ "components": {
293
+ "cpu": {
294
+ "baseline": 50.0, "intact": 40.0, "ablated": 10.0,
295
+ "labels": {"ABLATED": "CRITICAL", "INTACT": "OK"},
296
+ }
297
+ },
298
+ }
299
+ result = from_config(cfg)
300
+ from margin.health import Health
301
+ assert result["parser"].label_for("cpu", Health.ABLATED) == "CRITICAL"
302
+ assert result["parser"].label_for("cpu", Health.INTACT) == "OK"
@@ -272,3 +272,24 @@ class TestContractTermFromDict:
272
272
  from margin import contract_term_from_dict
273
273
  with pytest.raises(ValueError, match="Unknown"):
274
274
  contract_term_from_dict({"type": "nonexistent", "name": "x"})
275
+
276
+
277
+ class TestContractFromDict:
278
+ def test_roundtrip(self):
279
+ from margin import Contract, SustainHealth, HealthTarget
280
+ c = Contract(name="my-contract", terms=[
281
+ HealthTarget(name="h", component="cpu", target=Health.INTACT),
282
+ SustainHealth(name="s", component="cpu", target=Health.INTACT, for_steps=5),
283
+ ])
284
+ c2 = Contract.from_dict(c.to_dict())
285
+ assert c2.name == "my-contract"
286
+ assert len(c2.terms) == 2
287
+ assert isinstance(c2.terms[0], HealthTarget)
288
+ assert isinstance(c2.terms[1], SustainHealth)
289
+
290
+ def test_empty_contract_roundtrip(self):
291
+ from margin import Contract
292
+ c = Contract(name="empty")
293
+ c2 = Contract.from_dict(c.to_dict())
294
+ assert c2.name == "empty"
295
+ assert c2.terms == []
@@ -197,3 +197,27 @@ class TestLedgerRoundtrip:
197
197
  assert lr.records[0].op == Op.RESTORE
198
198
  assert lr.records[1].after is None
199
199
  assert lr.render() == ledger.render()
200
+
201
+
202
+ class TestLedgerToDict:
203
+ def test_to_dict_roundtrip(self):
204
+ from margin.ledger import Ledger, Record
205
+ from margin.observation import Observation, Op
206
+ from margin.health import Health
207
+ from margin.confidence import Confidence
208
+
209
+ obs = Observation("cpu", Health.DEGRADED, 30.0, 50.0, Confidence.HIGH)
210
+ rec = Record(step=0, tag="s0", before=obs, fired=False, op=Op.NOOP, alpha=0.0)
211
+ ledger = Ledger(label="test", records=[rec])
212
+ d = ledger.to_dict()
213
+ assert d["label"] == "test"
214
+ assert len(d["records"]) == 1
215
+ ledger2 = Ledger.from_dict(d)
216
+ assert ledger2.label == "test"
217
+ assert len(ledger2.records) == 1
218
+
219
+ def test_to_json_uses_to_dict(self):
220
+ import json
221
+ from margin.ledger import Ledger
222
+ ledger = Ledger(label="x")
223
+ assert json.loads(ledger.to_json())["label"] == "x"
@@ -184,3 +184,40 @@ class TestRun:
184
184
  def test_repr(self):
185
185
  result = step(_expr(_obs("x", Health.DEGRADED)), self._make_policy())
186
186
  assert "CORRECT" in repr(result)
187
+
188
+
189
+ class TestFullStepThreading:
190
+ def _monitor(self):
191
+ from margin import Monitor, Parser, Thresholds
192
+ return Monitor(Parser(
193
+ baselines={"cpu": 50.0},
194
+ thresholds=Thresholds(intact=40.0, ablated=10.0),
195
+ ))
196
+
197
+ def _policy(self):
198
+ from margin.policy.core import Policy, PolicyRule, Action
199
+ from margin.observation import Op
200
+ from margin.predicates import any_degraded
201
+ return Policy("p", [PolicyRule("r", any_degraded(), Action("*", Op.RESTORE, 0.5))])
202
+
203
+ def test_full_step_confidences_threaded(self):
204
+ from margin import full_step
205
+ from margin.confidence import Confidence
206
+ m = self._monitor()
207
+ result = full_step(m, {"cpu": 20.0}, self._policy(),
208
+ confidences={"cpu": Confidence.HIGH})
209
+ obs = result.expression.observations[0]
210
+ assert obs.confidence == Confidence.HIGH
211
+
212
+ def test_full_step_label_threaded(self):
213
+ from margin import full_step
214
+ m = self._monitor()
215
+ result = full_step(m, {"cpu": 20.0}, self._policy(), label="step-A")
216
+ assert result.expression.label == "step-A"
217
+
218
+ def test_full_step_provenance_threaded(self):
219
+ from margin import full_step
220
+ m = self._monitor()
221
+ result = full_step(m, {"cpu": 20.0}, self._policy(), provenance=["source-X"])
222
+ obs = result.expression.observations[0]
223
+ assert "source-X" in obs.provenance
@@ -268,3 +268,41 @@ class TestHealthLabel:
268
268
  )
269
269
  d = obs.to_dict()
270
270
  assert "health_label" not in d
271
+
272
+
273
+ class TestParserUnknownComponent:
274
+ def test_warns_on_unknown_component(self):
275
+ import warnings
276
+ p = Parser(
277
+ baselines={"cpu": 50.0},
278
+ thresholds=Thresholds(intact=40.0, ablated=10.0),
279
+ )
280
+ with warnings.catch_warnings(record=True) as w:
281
+ warnings.simplefilter("always")
282
+ p.parse({"cpuu": 48.0}) # typo
283
+ assert len(w) == 1
284
+ assert "cpuu" in str(w[0].message)
285
+ assert "no baseline" in str(w[0].message)
286
+
287
+ def test_no_warning_for_known_component(self):
288
+ import warnings
289
+ p = Parser(
290
+ baselines={"cpu": 50.0},
291
+ thresholds=Thresholds(intact=40.0, ablated=10.0),
292
+ )
293
+ with warnings.catch_warnings(record=True) as w:
294
+ warnings.simplefilter("always")
295
+ p.parse({"cpu": 48.0})
296
+ assert len(w) == 0
297
+
298
+ def test_unknown_component_gets_sigma_zero(self):
299
+ p = Parser(
300
+ baselines={"cpu": 50.0},
301
+ thresholds=Thresholds(intact=40.0, ablated=10.0),
302
+ )
303
+ import warnings
304
+ with warnings.catch_warnings(record=True):
305
+ warnings.simplefilter("always")
306
+ expr = p.parse({"unknown": 100.0})
307
+ obs = expr.observations[0]
308
+ assert obs.sigma == 0.0
@@ -379,3 +379,36 @@ class TestSerialization:
379
379
  d = {"name": "test", "rules": [{"name": "x", "action": {"op": "RESTORE"}}]}
380
380
  with pytest.raises(ValueError, match="No predicate"):
381
381
  Policy.from_dict(d, {})
382
+
383
+
384
+ class TestActionResolveUnknownTarget:
385
+ def test_warns_when_named_target_not_in_expression(self):
386
+ import warnings
387
+ from margin.policy.core import Policy, PolicyRule, Action
388
+ from margin.observation import Op, Parser, Expression
389
+ from margin.health import Thresholds
390
+ from margin.predicates import any_degraded
391
+
392
+ p = Parser(baselines={"cpu": 50.0}, thresholds=Thresholds(intact=40.0, ablated=10.0))
393
+ expr = p.parse({"cpu": 20.0})
394
+
395
+ action = Action(target="nonexistent", op=Op.RESTORE, alpha=0.5)
396
+ with warnings.catch_warnings(record=True) as w:
397
+ warnings.simplefilter("always")
398
+ action.resolve(expr)
399
+ assert any("nonexistent" in str(x.message) for x in w)
400
+
401
+ def test_no_warning_when_target_exists(self):
402
+ import warnings
403
+ from margin.policy.core import Action
404
+ from margin.observation import Op, Parser
405
+ from margin.health import Thresholds
406
+
407
+ p = Parser(baselines={"cpu": 50.0}, thresholds=Thresholds(intact=40.0, ablated=10.0))
408
+ expr = p.parse({"cpu": 20.0})
409
+
410
+ action = Action(target="cpu", op=Op.RESTORE, alpha=0.5)
411
+ with warnings.catch_warnings(record=True) as w:
412
+ warnings.simplefilter("always")
413
+ action.resolve(expr)
414
+ assert len(w) == 0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes