margin 0.9.4__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.4/margin.egg-info → margin-0.9.6}/PKG-INFO +1 -1
  2. {margin-0.9.4 → margin-0.9.6}/margin/__init__.py +2 -1
  3. {margin-0.9.4 → margin-0.9.6}/margin/calibrate.py +12 -5
  4. {margin-0.9.4 → margin-0.9.6}/margin/config.py +34 -7
  5. {margin-0.9.4 → margin-0.9.6}/margin/contract.py +47 -0
  6. {margin-0.9.4 → margin-0.9.6}/margin/loop.py +8 -2
  7. {margin-0.9.4 → margin-0.9.6}/margin/observation.py +7 -0
  8. {margin-0.9.4 → margin-0.9.6}/margin/policy/core.py +23 -0
  9. {margin-0.9.4 → margin-0.9.6}/margin/streaming.py +12 -1
  10. {margin-0.9.4 → margin-0.9.6/margin.egg-info}/PKG-INFO +1 -1
  11. {margin-0.9.4 → margin-0.9.6}/pyproject.toml +1 -1
  12. {margin-0.9.4 → margin-0.9.6}/tests/test_calibrate.py +43 -0
  13. {margin-0.9.4 → margin-0.9.6}/tests/test_config.py +63 -0
  14. {margin-0.9.4 → margin-0.9.6}/tests/test_contract.py +50 -0
  15. {margin-0.9.4 → margin-0.9.6}/tests/test_loop.py +37 -0
  16. {margin-0.9.4 → margin-0.9.6}/tests/test_observation.py +38 -0
  17. {margin-0.9.4 → margin-0.9.6}/tests/test_policy.py +33 -0
  18. {margin-0.9.4 → margin-0.9.6}/tests/test_policy_trace.py +40 -17
  19. {margin-0.9.4 → margin-0.9.6}/LICENSE +0 -0
  20. {margin-0.9.4 → margin-0.9.6}/NOTICE +0 -0
  21. {margin-0.9.4 → margin-0.9.6}/README.md +0 -0
  22. {margin-0.9.4 → margin-0.9.6}/margin/__main__.py +0 -0
  23. {margin-0.9.4 → margin-0.9.6}/margin/algebra.py +0 -0
  24. {margin-0.9.4 → margin-0.9.6}/margin/anomaly.py +0 -0
  25. {margin-0.9.4 → margin-0.9.6}/margin/bridge.py +0 -0
  26. {margin-0.9.4 → margin-0.9.6}/margin/causal.py +0 -0
  27. {margin-0.9.4 → margin-0.9.6}/margin/composite.py +0 -0
  28. {margin-0.9.4 → margin-0.9.6}/margin/confidence.py +0 -0
  29. {margin-0.9.4 → margin-0.9.6}/margin/correlate.py +0 -0
  30. {margin-0.9.4 → margin-0.9.6}/margin/diff.py +0 -0
  31. {margin-0.9.4 → margin-0.9.6}/margin/drift.py +0 -0
  32. {margin-0.9.4 → margin-0.9.6}/margin/events.py +0 -0
  33. {margin-0.9.4 → margin-0.9.6}/margin/forecast.py +0 -0
  34. {margin-0.9.4 → margin-0.9.6}/margin/health.py +0 -0
  35. {margin-0.9.4 → margin-0.9.6}/margin/intent.py +0 -0
  36. {margin-0.9.4 → margin-0.9.6}/margin/ledger.py +0 -0
  37. {margin-0.9.4 → margin-0.9.6}/margin/persist.py +0 -0
  38. {margin-0.9.4 → margin-0.9.6}/margin/policy/__init__.py +0 -0
  39. {margin-0.9.4 → margin-0.9.6}/margin/policy/compose.py +0 -0
  40. {margin-0.9.4 → margin-0.9.6}/margin/policy/temporal.py +0 -0
  41. {margin-0.9.4 → margin-0.9.6}/margin/policy/trace.py +0 -0
  42. {margin-0.9.4 → margin-0.9.6}/margin/policy/tuning.py +0 -0
  43. {margin-0.9.4 → margin-0.9.6}/margin/policy/validate.py +0 -0
  44. {margin-0.9.4 → margin-0.9.6}/margin/predicates.py +0 -0
  45. {margin-0.9.4 → margin-0.9.6}/margin/provenance.py +0 -0
  46. {margin-0.9.4 → margin-0.9.6}/margin/transitions.py +0 -0
  47. {margin-0.9.4 → margin-0.9.6}/margin/uncertain.py +0 -0
  48. {margin-0.9.4 → margin-0.9.6}/margin/validity.py +0 -0
  49. {margin-0.9.4 → margin-0.9.6}/margin.egg-info/SOURCES.txt +0 -0
  50. {margin-0.9.4 → margin-0.9.6}/margin.egg-info/dependency_links.txt +0 -0
  51. {margin-0.9.4 → margin-0.9.6}/margin.egg-info/top_level.txt +0 -0
  52. {margin-0.9.4 → margin-0.9.6}/setup.cfg +0 -0
  53. {margin-0.9.4 → margin-0.9.6}/tests/test_algebra.py +0 -0
  54. {margin-0.9.4 → margin-0.9.6}/tests/test_anomaly.py +0 -0
  55. {margin-0.9.4 → margin-0.9.6}/tests/test_bridge.py +0 -0
  56. {margin-0.9.4 → margin-0.9.6}/tests/test_causal.py +0 -0
  57. {margin-0.9.4 → margin-0.9.6}/tests/test_composite.py +0 -0
  58. {margin-0.9.4 → margin-0.9.6}/tests/test_confidence.py +0 -0
  59. {margin-0.9.4 → margin-0.9.6}/tests/test_correlate.py +0 -0
  60. {margin-0.9.4 → margin-0.9.6}/tests/test_diff.py +0 -0
  61. {margin-0.9.4 → margin-0.9.6}/tests/test_drift.py +0 -0
  62. {margin-0.9.4 → margin-0.9.6}/tests/test_events.py +0 -0
  63. {margin-0.9.4 → margin-0.9.6}/tests/test_forecast.py +0 -0
  64. {margin-0.9.4 → margin-0.9.6}/tests/test_health.py +0 -0
  65. {margin-0.9.4 → margin-0.9.6}/tests/test_healthcare.py +0 -0
  66. {margin-0.9.4 → margin-0.9.6}/tests/test_intent.py +0 -0
  67. {margin-0.9.4 → margin-0.9.6}/tests/test_ledger.py +0 -0
  68. {margin-0.9.4 → margin-0.9.6}/tests/test_neuro.py +0 -0
  69. {margin-0.9.4 → margin-0.9.6}/tests/test_persist.py +0 -0
  70. {margin-0.9.4 → margin-0.9.6}/tests/test_policy_compose.py +0 -0
  71. {margin-0.9.4 → margin-0.9.6}/tests/test_policy_temporal.py +0 -0
  72. {margin-0.9.4 → margin-0.9.6}/tests/test_policy_tuning.py +0 -0
  73. {margin-0.9.4 → margin-0.9.6}/tests/test_policy_validate.py +0 -0
  74. {margin-0.9.4 → margin-0.9.6}/tests/test_predicates.py +0 -0
  75. {margin-0.9.4 → margin-0.9.6}/tests/test_printer3d.py +0 -0
  76. {margin-0.9.4 → margin-0.9.6}/tests/test_provenance.py +0 -0
  77. {margin-0.9.4 → margin-0.9.6}/tests/test_reverse_bridge.py +0 -0
  78. {margin-0.9.4 → margin-0.9.6}/tests/test_ros2.py +0 -0
  79. {margin-0.9.4 → margin-0.9.6}/tests/test_staleness.py +0 -0
  80. {margin-0.9.4 → margin-0.9.6}/tests/test_streaming.py +0 -0
  81. {margin-0.9.4 → margin-0.9.6}/tests/test_transformer.py +0 -0
  82. {margin-0.9.4 → margin-0.9.6}/tests/test_transitions.py +0 -0
  83. {margin-0.9.4 → margin-0.9.6}/tests/test_uncertain.py +0 -0
  84. {margin-0.9.4 → margin-0.9.6}/tests/test_validity.py +0 -0
  85. {margin-0.9.4 → 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.4
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
@@ -82,6 +82,7 @@ from .contract import (
82
82
  HealthTarget, ReachHealth, SustainHealth,
83
83
  RecoveryThreshold, NoHarmful,
84
84
  ContractResult, Contract,
85
+ contract_term_from_dict,
85
86
  )
86
87
 
87
88
  # Correlation
@@ -164,7 +165,7 @@ __all__ = [
164
165
  "TermStatus", "TermResult", "ContractTerm",
165
166
  "HealthTarget", "ReachHealth", "SustainHealth",
166
167
  "RecoveryThreshold", "NoHarmful",
167
- "ContractResult", "Contract",
168
+ "ContractResult", "Contract", "contract_term_from_dict",
168
169
  # Correlation
169
170
  "Correlation", "CorrelationMatrix",
170
171
  "correlate_pair", "correlate", "correlate_from_ledger",
@@ -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],
@@ -312,7 +319,7 @@ def recalibrate_parser(
312
319
  components: Optional[list[str]] = None,
313
320
  intact_fraction: float = 0.70,
314
321
  ablated_fraction: float = 0.30,
315
- active_min: float = 0.05,
322
+ active_min: Optional[float] = None,
316
323
  use_std: bool = False,
317
324
  intact_std_multiplier: float = 1.5,
318
325
  ablated_std_multiplier: float = 3.0,
@@ -338,8 +345,8 @@ def recalibrate_parser(
338
345
  all components in new_healthy_data are recalibrated
339
346
  intact_fraction: fraction of baseline for intact threshold (fraction mode)
340
347
  ablated_fraction: fraction of baseline for ablated threshold (fraction mode)
341
- active_min: inherited from existing parser thresholds; pass only
342
- to override for recalibrated components
348
+ active_min: None (default) inherits from each component's existing
349
+ threshold; pass an explicit float to override
343
350
  use_std: if True, use std-based thresholds (see calibrate())
344
351
  intact_std_multiplier: std deviations for intact threshold (std mode)
345
352
  ablated_std_multiplier: std deviations for ablated threshold (std mode)
@@ -373,8 +380,8 @@ def recalibrate_parser(
373
380
  # Resolve polarity: explicit override → existing parser polarity → True
374
381
  existing_t = parser.component_thresholds.get(name) or parser.thresholds
375
382
  hib = polarities.get(name, existing_t.higher_is_better)
376
- # Inherit active_min from existing thresholds unless caller overrides
377
- component_active_min = existing_t.active_min if active_min == 0.05 else active_min
383
+ # Inherit active_min from existing thresholds unless caller provides an explicit override
384
+ component_active_min = existing_t.active_min if active_min is None else active_min
378
385
  result = calibrate(
379
386
  vals, hib, intact_fraction, ablated_fraction, component_active_min,
380
387
  use_std=use_std,
@@ -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,41 @@ 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
+
249
+
250
+ _TERM_TYPES: dict = {
251
+ "health_target": lambda d: HealthTarget(
252
+ name=d["name"], component=d["component"],
253
+ target=Health(d["target"]), or_better=d.get("or_better", True),
254
+ ),
255
+ "reach_health": lambda d: ReachHealth(
256
+ name=d["name"], component=d["component"],
257
+ target=Health(d["target"]), within_steps=d["within_steps"],
258
+ ),
259
+ "sustain_health": lambda d: SustainHealth(
260
+ name=d["name"], component=d["component"],
261
+ target=Health(d["target"]), for_steps=d["for_steps"],
262
+ or_better=d.get("or_better", True),
263
+ ),
264
+ "recovery_threshold": lambda d: RecoveryThreshold(
265
+ name=d["name"], min_recovery=d["min_recovery"], over_steps=d["over_steps"],
266
+ ),
267
+ "no_harmful": lambda d: NoHarmful(
268
+ name=d["name"], over_steps=d["over_steps"],
269
+ ),
270
+ }
271
+
272
+
273
+ def contract_term_from_dict(d: dict) -> ContractTerm:
274
+ """Deserialize a ContractTerm subclass from a dict produced by its to_dict()."""
275
+ term_type = d.get("type")
276
+ if term_type not in _TERM_TYPES:
277
+ raise ValueError(f"Unknown contract term type: {term_type!r}. "
278
+ f"Expected one of: {list(_TERM_TYPES)}")
279
+ return _TERM_TYPES[term_type](d)
280
+
234
281
 
235
282
  # -----------------------------------------------------------------------
236
283
  # Contract — a set of terms evaluated together
@@ -50,7 +50,9 @@ class StepResult:
50
50
  @property
51
51
  def acted(self) -> bool:
52
52
  """True if a real correction was produced (not NOOP, not escalation)."""
53
- return self.correction is not None and self.correction.is_active()
53
+ if self.correction is not None and self.correction.is_active():
54
+ return True
55
+ return any(c.is_active() for c in self.corrections)
54
56
 
55
57
  @property
56
58
  def escalated(self) -> bool:
@@ -279,6 +281,9 @@ def full_step(
279
281
  contract: Optional[Contract] = None,
280
282
  intent: Optional[object] = None,
281
283
  now=None,
284
+ confidences=None,
285
+ provenance=None,
286
+ label: str = "",
282
287
  ) -> FullStepResult:
283
288
  """
284
289
  Run ALL layers in one call:
@@ -303,7 +308,8 @@ def full_step(
303
308
  now = now or datetime.now()
304
309
 
305
310
  # Layer 1-4: health + drift + anomaly + correlation
306
- expr = monitor.update(values, now=now)
311
+ expr = monitor.update(values, now=now, label=label,
312
+ confidences=confidences, provenance=provenance)
307
313
 
308
314
  # Layer 5-7: explain + decide + contract
309
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.4
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.4"
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"}
@@ -208,3 +208,46 @@ class TestCalibrateManyReturnParser:
208
208
  import pytest
209
209
  with pytest.raises(ValueError):
210
210
  calibrate_many({}, return_parser=True)
211
+
212
+
213
+ class TestRecalibrateParserActiveMin:
214
+ def test_none_inherits_from_existing_parser(self):
215
+ from margin.calibrate import recalibrate_parser
216
+ # Parser with non-default active_min
217
+ parser = parser_from_calibration({"cpu": [100.0] * 5})
218
+ # Manually set a custom active_min on the thresholds
219
+ import dataclasses
220
+ custom_thresh = dataclasses.replace(parser.thresholds, active_min=0.15)
221
+ parser2 = parser.__class__(
222
+ baselines=parser.baselines,
223
+ thresholds=custom_thresh,
224
+ component_thresholds=parser.component_thresholds,
225
+ )
226
+ new_parser, _ = recalibrate_parser(parser2, {"cpu": [110.0] * 5})
227
+ # active_min should be inherited as 0.15, not the old 0.05 sentinel
228
+ result_thresh = new_parser.component_thresholds.get("cpu") or new_parser.thresholds
229
+ assert result_thresh.active_min == pytest.approx(0.15)
230
+
231
+ def test_explicit_active_min_overrides_existing(self):
232
+ from margin.calibrate import recalibrate_parser
233
+ parser = parser_from_calibration({"cpu": [100.0] * 5})
234
+ new_parser, _ = recalibrate_parser(parser, {"cpu": [110.0] * 5}, active_min=0.20)
235
+ result_thresh = new_parser.component_thresholds.get("cpu") or new_parser.thresholds
236
+ assert result_thresh.active_min == pytest.approx(0.20)
237
+
238
+ def test_explicit_005_is_not_swallowed(self):
239
+ # Regression: before fix, active_min=0.05 was sentinel for "inherit"
240
+ # so an explicit 0.05 would be silently replaced by existing parser's value
241
+ from margin.calibrate import recalibrate_parser
242
+ import dataclasses
243
+ parser = parser_from_calibration({"cpu": [100.0] * 5})
244
+ custom_thresh = dataclasses.replace(parser.thresholds, active_min=0.30)
245
+ parser2 = parser.__class__(
246
+ baselines=parser.baselines,
247
+ thresholds=custom_thresh,
248
+ component_thresholds=parser.component_thresholds,
249
+ )
250
+ # Explicitly pass 0.05 — should get 0.05, not 0.30
251
+ new_parser, _ = recalibrate_parser(parser2, {"cpu": [110.0] * 5}, active_min=0.05)
252
+ result_thresh = new_parser.component_thresholds.get("cpu") or new_parser.thresholds
253
+ assert result_thresh.active_min == pytest.approx(0.05)
@@ -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)
@@ -222,3 +222,53 @@ class TestContract:
222
222
  d = r.to_dict()
223
223
  assert d["all_met"] is True
224
224
  assert d["n_met"] == 1
225
+
226
+
227
+ class TestContractTermFromDict:
228
+ def test_health_target_roundtrip(self):
229
+ from margin import contract_term_from_dict
230
+ from margin import HealthTarget
231
+ t = HealthTarget(name="t1", component="cpu", target=Health.INTACT)
232
+ t2 = contract_term_from_dict(t.to_dict())
233
+ assert isinstance(t2, HealthTarget)
234
+ assert t2.name == "t1"
235
+ assert t2.component == "cpu"
236
+ assert t2.target == Health.INTACT
237
+ assert t2.or_better is True
238
+
239
+ def test_reach_health_roundtrip(self):
240
+ from margin import contract_term_from_dict
241
+ from margin import ReachHealth
242
+ t = ReachHealth(name="r1", component="mem", target=Health.DEGRADED, within_steps=10)
243
+ t2 = contract_term_from_dict(t.to_dict())
244
+ assert isinstance(t2, ReachHealth)
245
+ assert t2.within_steps == 10
246
+
247
+ def test_sustain_health_roundtrip(self):
248
+ from margin import contract_term_from_dict
249
+ from margin import SustainHealth
250
+ t = SustainHealth(name="s1", component="cpu", target=Health.INTACT,
251
+ for_steps=5, or_better=False)
252
+ t2 = contract_term_from_dict(t.to_dict())
253
+ assert isinstance(t2, SustainHealth)
254
+ assert t2.for_steps == 5
255
+ assert t2.or_better is False
256
+
257
+ def test_recovery_threshold_roundtrip(self):
258
+ from margin import contract_term_from_dict, RecoveryThreshold
259
+ t = RecoveryThreshold(name="rt", min_recovery=0.8, over_steps=20)
260
+ t2 = contract_term_from_dict(t.to_dict())
261
+ assert isinstance(t2, RecoveryThreshold)
262
+ assert t2.min_recovery == pytest.approx(0.8)
263
+
264
+ def test_no_harmful_roundtrip(self):
265
+ from margin import contract_term_from_dict, NoHarmful
266
+ t = NoHarmful(name="nh", over_steps=15)
267
+ t2 = contract_term_from_dict(t.to_dict())
268
+ assert isinstance(t2, NoHarmful)
269
+ assert t2.over_steps == 15
270
+
271
+ def test_unknown_type_raises(self):
272
+ from margin import contract_term_from_dict
273
+ with pytest.raises(ValueError, match="Unknown"):
274
+ contract_term_from_dict({"type": "nonexistent", "name": "x"})
@@ -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
@@ -1,4 +1,5 @@
1
1
  import pytest
2
+ from margin.loop import step
2
3
  from margin.policy.core import Policy, PolicyRule, Action, Constraint, Escalation, EscalationLevel
3
4
  from margin.policy.trace import trace_evaluate, trace_backtest, DecisionTrace
4
5
  from margin.observation import Observation, Expression, Op
@@ -140,9 +141,6 @@ class TestBacktestProposedBy:
140
141
 
141
142
  class TestMultiRule:
142
143
  def _make_multi_policy(self):
143
- from margin.policy.core import Policy, PolicyRule, Action
144
- from margin.observation import Op
145
- from margin.predicates import any_health, any_degraded
146
144
  return Policy(
147
145
  name="multi",
148
146
  multi_rule=True,
@@ -169,9 +167,6 @@ class TestMultiRule:
169
167
  def test_trace_always_has_all_matched_results(self):
170
168
  # trace_evaluate always populates dt.results for all matches;
171
169
  # multi_rule only gates StepResult.corrections in step()
172
- from margin.policy.core import Policy, PolicyRule, Action
173
- from margin.observation import Op
174
- from margin.predicates import any_degraded
175
170
  policy = Policy(
176
171
  name="single",
177
172
  multi_rule=False,
@@ -191,7 +186,6 @@ class TestMultiRule:
191
186
  assert d["multi_rule"] is True
192
187
 
193
188
  def test_multi_rule_default_is_false(self):
194
- from margin.policy.core import Policy
195
189
  policy = Policy(name="p", rules=[])
196
190
  assert policy.multi_rule is False
197
191
  assert policy.to_dict()["multi_rule"] is False
@@ -199,11 +193,6 @@ class TestMultiRule:
199
193
 
200
194
  class TestStepResultCorrections:
201
195
  def test_step_corrections_populated_multi_rule(self):
202
- from margin.loop import step
203
- from margin.policy.core import Policy, PolicyRule, Action
204
- from margin.observation import Op
205
- from margin.predicates import any_degraded
206
-
207
196
  policy = Policy(
208
197
  name="multi",
209
198
  multi_rule=True,
@@ -216,11 +205,6 @@ class TestStepResultCorrections:
216
205
  assert len(result.corrections) >= 1
217
206
 
218
207
  def test_step_corrections_empty_single_rule(self):
219
- from margin.loop import step
220
- from margin.policy.core import Policy, PolicyRule, Action
221
- from margin.observation import Op
222
- from margin.predicates import any_degraded
223
-
224
208
  policy = Policy(
225
209
  name="single",
226
210
  multi_rule=False,
@@ -229,3 +213,42 @@ class TestStepResultCorrections:
229
213
  result = step(_expr(_obs("x", Health.DEGRADED)), policy)
230
214
  # multi_rule=False → corrections list is empty (only .correction populated)
231
215
  assert result.corrections == []
216
+
217
+
218
+ class TestStepResultActed:
219
+ def test_acted_true_when_correction_active(self):
220
+ policy = Policy(
221
+ name="p", multi_rule=False,
222
+ rules=[PolicyRule("r", any_degraded(), Action(target="*", op=Op.RESTORE, alpha=0.5))],
223
+ )
224
+ result = step(_expr(_obs("x", Health.DEGRADED)), policy)
225
+ assert result.acted is True
226
+
227
+ def test_acted_false_on_noop(self):
228
+ from margin.predicates import any_health
229
+ policy = Policy(
230
+ name="p", multi_rule=False,
231
+ rules=[PolicyRule("r", any_health(Health.DEGRADED), Action(target="*", op=Op.NOOP))],
232
+ )
233
+ result = step(_expr(_obs("x", Health.DEGRADED)), policy)
234
+ assert result.acted is False
235
+
236
+ def test_acted_true_when_correction_in_multi_rule_corrections_list(self):
237
+ # First result is Escalation (no min_alpha satisfied), second is a Correction
238
+ # multi_rule=True → both fire; acted should see the Correction in .corrections
239
+ policy = Policy(
240
+ name="p", multi_rule=True,
241
+ rules=[
242
+ PolicyRule("esc", any_degraded(),
243
+ Action(target="*", op=Op.RESTORE, alpha=0.1),
244
+ constraint=Constraint(min_alpha=0.9), # suppressed → escalation
245
+ priority=10),
246
+ PolicyRule("fix", any_degraded(),
247
+ Action(target="*", op=Op.RESTORE, alpha=0.5),
248
+ priority=5),
249
+ ],
250
+ )
251
+ result = step(_expr(_obs("x", Health.DEGRADED, 10.0)), policy)
252
+ # .corrections contains the Correction from "fix"
253
+ assert any(c.is_active() for c in result.corrections)
254
+ assert result.acted is True
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes