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.
- {margin-0.9.5/margin.egg-info → margin-0.9.7}/PKG-INFO +1 -1
- {margin-0.9.5 → margin-0.9.7}/margin/calibrate.py +7 -0
- {margin-0.9.5 → margin-0.9.7}/margin/config.py +45 -8
- {margin-0.9.5 → margin-0.9.7}/margin/contract.py +22 -0
- {margin-0.9.5 → margin-0.9.7}/margin/ledger.py +4 -4
- {margin-0.9.5 → margin-0.9.7}/margin/loop.py +18 -9
- {margin-0.9.5 → margin-0.9.7}/margin/observation.py +13 -3
- {margin-0.9.5 → margin-0.9.7}/margin/policy/core.py +23 -0
- {margin-0.9.5 → margin-0.9.7}/margin/streaming.py +12 -1
- {margin-0.9.5 → margin-0.9.7/margin.egg-info}/PKG-INFO +1 -1
- {margin-0.9.5 → margin-0.9.7}/pyproject.toml +1 -1
- {margin-0.9.5 → margin-0.9.7}/tests/test_config.py +119 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_contract.py +21 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_ledger.py +24 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_loop.py +37 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_observation.py +38 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_policy.py +33 -0
- {margin-0.9.5 → margin-0.9.7}/LICENSE +0 -0
- {margin-0.9.5 → margin-0.9.7}/NOTICE +0 -0
- {margin-0.9.5 → margin-0.9.7}/README.md +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin/__init__.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin/__main__.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin/algebra.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin/anomaly.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin/bridge.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin/causal.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin/composite.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin/confidence.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin/correlate.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin/diff.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin/drift.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin/events.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin/forecast.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin/health.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin/intent.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin/persist.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin/policy/__init__.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin/policy/compose.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin/policy/temporal.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin/policy/trace.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin/policy/tuning.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin/policy/validate.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin/predicates.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin/provenance.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin/transitions.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin/uncertain.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin/validity.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin.egg-info/SOURCES.txt +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin.egg-info/dependency_links.txt +0 -0
- {margin-0.9.5 → margin-0.9.7}/margin.egg-info/top_level.txt +0 -0
- {margin-0.9.5 → margin-0.9.7}/setup.cfg +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_algebra.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_anomaly.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_bridge.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_calibrate.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_causal.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_composite.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_confidence.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_correlate.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_diff.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_drift.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_events.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_forecast.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_health.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_healthcare.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_intent.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_neuro.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_persist.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_policy_compose.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_policy_temporal.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_policy_trace.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_policy_tuning.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_policy_validate.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_predicates.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_printer3d.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_provenance.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_reverse_bridge.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_ros2.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_staleness.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_streaming.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_transformer.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_transitions.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_uncertain.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_validity.py +0 -0
- {margin-0.9.5 → margin-0.9.7}/tests/test_windowing.py +0 -0
|
@@ -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(
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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:
|
|
293
|
-
values:
|
|
294
|
-
policy:
|
|
295
|
-
ledger:
|
|
296
|
-
graph:
|
|
297
|
-
contract:
|
|
298
|
-
intent:
|
|
299
|
-
now:
|
|
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:
|
|
336
|
+
values: {component_name: measurement}
|
|
337
337
|
correction_magnitude: size of the correction being applied
|
|
338
|
-
alpha:
|
|
339
|
-
confidences:
|
|
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(
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "margin"
|
|
7
|
-
version = "0.9.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|