margin 0.9.0__tar.gz → 0.9.2__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.0/margin.egg-info → margin-0.9.2}/PKG-INFO +6 -4
- {margin-0.9.0 → margin-0.9.2}/README.md +5 -3
- {margin-0.9.0 → margin-0.9.2}/margin/__init__.py +4 -2
- {margin-0.9.0 → margin-0.9.2}/margin/calibrate.py +160 -44
- {margin-0.9.0 → margin-0.9.2}/margin/contract.py +16 -0
- {margin-0.9.0 → margin-0.9.2}/margin/health.py +8 -0
- {margin-0.9.0 → margin-0.9.2}/margin/loop.py +15 -6
- {margin-0.9.0 → margin-0.9.2}/margin/observation.py +12 -2
- {margin-0.9.0 → margin-0.9.2}/margin/persist.py +19 -7
- {margin-0.9.0 → margin-0.9.2}/margin/policy/core.py +3 -1
- {margin-0.9.0 → margin-0.9.2}/margin/policy/trace.py +15 -11
- {margin-0.9.0 → margin-0.9.2}/margin/policy/validate.py +45 -0
- {margin-0.9.0 → margin-0.9.2}/margin/streaming.py +72 -24
- {margin-0.9.0 → margin-0.9.2/margin.egg-info}/PKG-INFO +6 -4
- {margin-0.9.0 → margin-0.9.2}/pyproject.toml +1 -1
- {margin-0.9.0 → margin-0.9.2}/LICENSE +0 -0
- {margin-0.9.0 → margin-0.9.2}/NOTICE +0 -0
- {margin-0.9.0 → margin-0.9.2}/margin/__main__.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/margin/algebra.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/margin/anomaly.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/margin/bridge.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/margin/causal.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/margin/composite.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/margin/confidence.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/margin/config.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/margin/correlate.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/margin/diff.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/margin/drift.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/margin/events.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/margin/forecast.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/margin/intent.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/margin/ledger.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/margin/policy/__init__.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/margin/policy/compose.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/margin/policy/temporal.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/margin/policy/tuning.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/margin/predicates.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/margin/provenance.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/margin/transitions.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/margin/uncertain.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/margin/validity.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/margin.egg-info/SOURCES.txt +0 -0
- {margin-0.9.0 → margin-0.9.2}/margin.egg-info/dependency_links.txt +0 -0
- {margin-0.9.0 → margin-0.9.2}/margin.egg-info/top_level.txt +0 -0
- {margin-0.9.0 → margin-0.9.2}/setup.cfg +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_algebra.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_anomaly.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_bridge.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_calibrate.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_causal.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_composite.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_confidence.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_config.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_contract.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_correlate.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_diff.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_drift.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_events.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_forecast.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_health.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_healthcare.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_intent.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_ledger.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_loop.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_neuro.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_observation.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_persist.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_policy.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_policy_compose.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_policy_temporal.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_policy_trace.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_policy_tuning.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_policy_validate.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_predicates.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_printer3d.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_provenance.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_reverse_bridge.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_ros2.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_staleness.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_streaming.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_transformer.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_transitions.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_uncertain.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_validity.py +0 -0
- {margin-0.9.0 → margin-0.9.2}/tests/test_windowing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: margin
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.2
|
|
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
|
|
@@ -85,7 +85,7 @@ Then the correction loop:
|
|
|
85
85
|
- **CLI** — `python -m margin status`, `monitor`, `replay` — no Python code required
|
|
86
86
|
- **Ledger** — full audit trail of every correction, serializable, replayable
|
|
87
87
|
|
|
88
|
-
All
|
|
88
|
+
All stages in one call:
|
|
89
89
|
|
|
90
90
|
```python
|
|
91
91
|
from margin import full_step
|
|
@@ -129,17 +129,19 @@ parser = parser_from_calibration(
|
|
|
129
129
|
)
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
-
##
|
|
132
|
+
## Architecture
|
|
133
133
|
|
|
134
134
|
| Layer | Question | Key types |
|
|
135
135
|
|---|---|---|
|
|
136
136
|
| **Foundation** | What was measured? | `Health`, `Observation`, `Expression`, `UncertainValue` |
|
|
137
137
|
| **Observability** | What changed? When will it cross? | `diff()`, `forecast()`, `track()`, `calibrate()` |
|
|
138
|
+
| **Streaming** | Is it drifting or anomalous? | `Monitor`, `DriftTracker`, `AnomalyTracker`, `CorrelationTracker` |
|
|
138
139
|
| **Policy** | What should we do? | `PolicyRule`, `Action`, `Constraint`, `Escalation` |
|
|
139
140
|
| **Contract** | Are we meeting our goals? | `HealthTarget`, `SustainHealth`, `RecoveryThreshold` |
|
|
140
141
|
| **Causal** | Why did this happen? | `CausalGraph`, `CausalLink`, `Explanation` |
|
|
142
|
+
| **Intent** | Can we still get there? | `Intent`, `Feasibility`, `IntentResult` |
|
|
141
143
|
|
|
142
|
-
|
|
144
|
+
`full_step()` orchestrates all seven in one call.
|
|
143
145
|
|
|
144
146
|
## Domain adapters
|
|
145
147
|
|
|
@@ -58,7 +58,7 @@ Then the correction loop:
|
|
|
58
58
|
- **CLI** — `python -m margin status`, `monitor`, `replay` — no Python code required
|
|
59
59
|
- **Ledger** — full audit trail of every correction, serializable, replayable
|
|
60
60
|
|
|
61
|
-
All
|
|
61
|
+
All stages in one call:
|
|
62
62
|
|
|
63
63
|
```python
|
|
64
64
|
from margin import full_step
|
|
@@ -102,17 +102,19 @@ parser = parser_from_calibration(
|
|
|
102
102
|
)
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
-
##
|
|
105
|
+
## Architecture
|
|
106
106
|
|
|
107
107
|
| Layer | Question | Key types |
|
|
108
108
|
|---|---|---|
|
|
109
109
|
| **Foundation** | What was measured? | `Health`, `Observation`, `Expression`, `UncertainValue` |
|
|
110
110
|
| **Observability** | What changed? When will it cross? | `diff()`, `forecast()`, `track()`, `calibrate()` |
|
|
111
|
+
| **Streaming** | Is it drifting or anomalous? | `Monitor`, `DriftTracker`, `AnomalyTracker`, `CorrelationTracker` |
|
|
111
112
|
| **Policy** | What should we do? | `PolicyRule`, `Action`, `Constraint`, `Escalation` |
|
|
112
113
|
| **Contract** | Are we meeting our goals? | `HealthTarget`, `SustainHealth`, `RecoveryThreshold` |
|
|
113
114
|
| **Causal** | Why did this happen? | `CausalGraph`, `CausalLink`, `Explanation` |
|
|
115
|
+
| **Intent** | Can we still get there? | `Intent`, `Feasibility`, `IntentResult` |
|
|
114
116
|
|
|
115
|
-
|
|
117
|
+
`full_step()` orchestrates all seven in one call.
|
|
116
118
|
|
|
117
119
|
## Domain adapters
|
|
118
120
|
|
|
@@ -31,7 +31,8 @@ from .ledger import Record, Ledger
|
|
|
31
31
|
from .bridge import observe, observe_many, delta, to_uncertain
|
|
32
32
|
from .calibrate import (
|
|
33
33
|
CalibrationResult, calibrate, calibrate_many,
|
|
34
|
-
parser_from_calibration, recalibrate_parser,
|
|
34
|
+
parser_from_calibration, recalibrate_parser,
|
|
35
|
+
needs_recalibration, needs_recalibration_many,
|
|
35
36
|
)
|
|
36
37
|
from .composite import CompositeObservation, AggregateStrategy
|
|
37
38
|
from .diff import ComponentChange, Diff, diff
|
|
@@ -128,6 +129,7 @@ __all__ = [
|
|
|
128
129
|
# Observability
|
|
129
130
|
"observe", "observe_many", "delta", "to_uncertain",
|
|
130
131
|
"CalibrationResult", "calibrate", "calibrate_many", "parser_from_calibration",
|
|
132
|
+
"recalibrate_parser", "needs_recalibration", "needs_recalibration_many",
|
|
131
133
|
"CompositeObservation", "AggregateStrategy",
|
|
132
134
|
"ComponentChange", "Diff", "diff",
|
|
133
135
|
"AnomalyState", "ANOMALY_SEVERITY", "AnomalyClassification",
|
|
@@ -174,7 +176,7 @@ __all__ = [
|
|
|
174
176
|
# Intent
|
|
175
177
|
"Feasibility", "Requirement", "RiskFactor", "IntentResult", "Intent",
|
|
176
178
|
# Streaming
|
|
177
|
-
"DriftTracker", "AnomalyTracker", "CorrelationTracker", "Monitor",
|
|
179
|
+
"DriftTracker", "AnomalyTracker", "CorrelationTracker", "Monitor", "WindowConfig",
|
|
178
180
|
# Config
|
|
179
181
|
"from_config", "load_config",
|
|
180
182
|
# Persistence
|
|
@@ -41,6 +41,7 @@ class CalibrationResult:
|
|
|
41
41
|
"intact": self.thresholds.intact,
|
|
42
42
|
"ablated": self.thresholds.ablated,
|
|
43
43
|
"higher_is_better": self.thresholds.higher_is_better,
|
|
44
|
+
"active_min": self.thresholds.active_min,
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
|
|
@@ -50,28 +51,45 @@ def calibrate(
|
|
|
50
51
|
intact_fraction: float = 0.70,
|
|
51
52
|
ablated_fraction: float = 0.30,
|
|
52
53
|
active_min: float = 0.05,
|
|
54
|
+
use_std: bool = False,
|
|
55
|
+
intact_std_multiplier: float = 1.5,
|
|
56
|
+
ablated_std_multiplier: float = 3.0,
|
|
53
57
|
) -> CalibrationResult:
|
|
54
58
|
"""
|
|
55
59
|
Derive a baseline and thresholds from known-healthy measurements.
|
|
56
60
|
|
|
57
61
|
Takes a list of measurements collected when the component was operating
|
|
58
|
-
normally, and derives:
|
|
59
|
-
- baseline: mean of the measurements
|
|
60
|
-
- intact threshold: intact_fraction * baseline
|
|
61
|
-
- ablated threshold: ablated_fraction * baseline
|
|
62
|
+
normally, and derives thresholds in one of two modes:
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
ablated_fraction: fraction of baseline for ablated threshold (default 0.30)
|
|
68
|
-
active_min: minimum correction magnitude for "active"
|
|
64
|
+
**Fraction mode** (default, ``use_std=False``):
|
|
65
|
+
Thresholds are a fixed fraction of the mean. Simple and predictable,
|
|
66
|
+
but variance-blind — high-noise and low-noise components produce the
|
|
67
|
+
same thresholds if their means are equal.
|
|
69
68
|
|
|
70
|
-
For higher_is_better=True:
|
|
71
69
|
intact = baseline * intact_fraction (e.g. 70% of healthy mean)
|
|
72
70
|
ablated = baseline * ablated_fraction (e.g. 30% of healthy mean)
|
|
73
71
|
|
|
74
|
-
|
|
72
|
+
**Std mode** (``use_std=True``):
|
|
73
|
+
Thresholds are placed N standard deviations from the mean. Adapts to
|
|
74
|
+
the variance of the calibration data, so a noisy component gets wider
|
|
75
|
+
thresholds than a stable one with the same mean.
|
|
76
|
+
|
|
77
|
+
intact = baseline − intact_std_multiplier × std (default 1.5σ)
|
|
78
|
+
ablated = baseline − ablated_std_multiplier × std (default 3.0σ)
|
|
79
|
+
|
|
80
|
+
For ``higher_is_better=False``, thresholds are placed above baseline.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
values: list of healthy-state measurements
|
|
84
|
+
higher_is_better: polarity
|
|
85
|
+
intact_fraction: fraction of baseline for intact threshold (fraction mode)
|
|
86
|
+
ablated_fraction: fraction of baseline for ablated threshold (fraction mode)
|
|
87
|
+
active_min: minimum correction magnitude for "active"
|
|
88
|
+
use_std: if True, derive thresholds from mean ± N×std instead
|
|
89
|
+
intact_std_multiplier: std deviations from mean for intact threshold (std mode)
|
|
90
|
+
ablated_std_multiplier: std deviations from mean for ablated threshold (std mode)
|
|
91
|
+
|
|
92
|
+
For ``higher_is_better=False`` in fraction mode:
|
|
75
93
|
intact = baseline * (1 + (1 - intact_fraction)) (e.g. 130% of healthy mean)
|
|
76
94
|
ablated = baseline * (1 + (1 - ablated_fraction)) (e.g. 170% of healthy mean)
|
|
77
95
|
This puts the "bad" direction above baseline for lower-is-better metrics.
|
|
@@ -84,23 +102,36 @@ def calibrate(
|
|
|
84
102
|
|
|
85
103
|
if baseline == 0:
|
|
86
104
|
raise ValueError("Cannot calibrate from zero baseline — thresholds would be degenerate")
|
|
87
|
-
if higher_is_better and baseline < 0:
|
|
105
|
+
if higher_is_better and baseline < 0 and not use_std:
|
|
88
106
|
raise ValueError(
|
|
89
107
|
f"Cannot calibrate higher_is_better=True with negative baseline ({baseline}). "
|
|
90
108
|
"Fraction-based thresholds invert for negative values. "
|
|
91
|
-
"Use higher_is_better=False
|
|
109
|
+
"Use higher_is_better=False, shift your measurements to be positive, "
|
|
110
|
+
"or use use_std=True for std-based thresholds.")
|
|
92
111
|
|
|
93
112
|
variance = sum((v - baseline) ** 2 for v in values) / max(n - 1, 1)
|
|
94
113
|
std = math.sqrt(variance)
|
|
95
114
|
|
|
96
|
-
if
|
|
97
|
-
|
|
98
|
-
|
|
115
|
+
if use_std:
|
|
116
|
+
if std == 0:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
"Cannot use use_std=True with zero-variance data — all values are identical. "
|
|
119
|
+
"Use fraction mode (use_std=False) instead.")
|
|
120
|
+
if higher_is_better:
|
|
121
|
+
intact = baseline - intact_std_multiplier * std
|
|
122
|
+
ablated = baseline - ablated_std_multiplier * std
|
|
123
|
+
else:
|
|
124
|
+
intact = baseline + intact_std_multiplier * std
|
|
125
|
+
ablated = baseline + ablated_std_multiplier * std
|
|
99
126
|
else:
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
127
|
+
if higher_is_better:
|
|
128
|
+
intact = baseline * intact_fraction
|
|
129
|
+
ablated = baseline * ablated_fraction
|
|
130
|
+
else:
|
|
131
|
+
# For lower-is-better, thresholds are above baseline (unhealthy direction).
|
|
132
|
+
# Uses abs(baseline) so the math works for both positive and negative baselines.
|
|
133
|
+
intact = baseline + abs(baseline) * (1 - intact_fraction)
|
|
134
|
+
ablated = baseline + abs(baseline) * (1 - ablated_fraction)
|
|
104
135
|
|
|
105
136
|
thresholds = Thresholds(
|
|
106
137
|
intact=round(intact, 6),
|
|
@@ -123,17 +154,23 @@ def calibrate_many(
|
|
|
123
154
|
intact_fraction: float = 0.70,
|
|
124
155
|
ablated_fraction: float = 0.30,
|
|
125
156
|
active_min: float = 0.05,
|
|
157
|
+
use_std: bool = False,
|
|
158
|
+
intact_std_multiplier: float = 1.5,
|
|
159
|
+
ablated_std_multiplier: float = 3.0,
|
|
126
160
|
) -> tuple[dict[str, float], dict[str, Thresholds]]:
|
|
127
161
|
"""
|
|
128
162
|
Calibrate multiple components at once. Returns (baselines, thresholds)
|
|
129
163
|
ready to pass directly to Parser().
|
|
130
164
|
|
|
131
165
|
Args:
|
|
132
|
-
component_values:
|
|
133
|
-
polarities:
|
|
134
|
-
intact_fraction:
|
|
135
|
-
ablated_fraction:
|
|
136
|
-
active_min:
|
|
166
|
+
component_values: {name: [healthy_measurements]}
|
|
167
|
+
polarities: {name: higher_is_better} (defaults to True)
|
|
168
|
+
intact_fraction: fraction of baseline for intact threshold (fraction mode)
|
|
169
|
+
ablated_fraction: fraction of baseline for ablated threshold (fraction mode)
|
|
170
|
+
active_min: minimum correction magnitude
|
|
171
|
+
use_std: if True, use std-based thresholds (see calibrate())
|
|
172
|
+
intact_std_multiplier: std deviations for intact threshold (std mode)
|
|
173
|
+
ablated_std_multiplier: std deviations for ablated threshold (std mode)
|
|
137
174
|
|
|
138
175
|
Returns:
|
|
139
176
|
(baselines_dict, thresholds_dict) suitable for:
|
|
@@ -147,7 +184,12 @@ def calibrate_many(
|
|
|
147
184
|
|
|
148
185
|
for name, vals in component_values.items():
|
|
149
186
|
hib = polarities.get(name, True)
|
|
150
|
-
result = calibrate(
|
|
187
|
+
result = calibrate(
|
|
188
|
+
vals, hib, intact_fraction, ablated_fraction, active_min,
|
|
189
|
+
use_std=use_std,
|
|
190
|
+
intact_std_multiplier=intact_std_multiplier,
|
|
191
|
+
ablated_std_multiplier=ablated_std_multiplier,
|
|
192
|
+
)
|
|
151
193
|
baselines[name] = result.baseline
|
|
152
194
|
thresholds[name] = result.thresholds
|
|
153
195
|
|
|
@@ -198,6 +240,57 @@ def needs_recalibration(
|
|
|
198
240
|
return mean_shifted or spread_changed
|
|
199
241
|
|
|
200
242
|
|
|
243
|
+
def needs_recalibration_many(
|
|
244
|
+
calibration_samples: dict[str, list[float]],
|
|
245
|
+
recent_samples: dict[str, list[float]],
|
|
246
|
+
mean_shift_threshold: float = 0.2,
|
|
247
|
+
std_ratio_high: float = 2.0,
|
|
248
|
+
std_ratio_low: float = 0.5,
|
|
249
|
+
min_recent: int = 5,
|
|
250
|
+
) -> dict[str, bool]:
|
|
251
|
+
"""
|
|
252
|
+
Check multiple components for baseline staleness in one call.
|
|
253
|
+
|
|
254
|
+
Runs ``needs_recalibration()`` for each component present in both
|
|
255
|
+
``calibration_samples`` and ``recent_samples``. Components missing from
|
|
256
|
+
either dict are omitted from the result.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
calibration_samples: {component: original healthy measurements}
|
|
260
|
+
recent_samples: {component: recent window of observed values}
|
|
261
|
+
mean_shift_threshold: relative mean shift that triggers recalibration
|
|
262
|
+
std_ratio_high: std ratio above which spread is considered changed
|
|
263
|
+
std_ratio_low: std ratio below which spread is considered changed
|
|
264
|
+
min_recent: minimum recent samples required per component
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
{component: needs_recalibration} for every component present in both dicts.
|
|
268
|
+
|
|
269
|
+
Example::
|
|
270
|
+
|
|
271
|
+
flags = needs_recalibration_many(
|
|
272
|
+
calibration_samples={"cpu": cpu_cal, "mem": mem_cal},
|
|
273
|
+
recent_samples={"cpu": cpu_recent, "mem": mem_recent},
|
|
274
|
+
)
|
|
275
|
+
stale = [c for c, flag in flags.items() if flag]
|
|
276
|
+
if stale:
|
|
277
|
+
new_parser, _ = recalibrate_parser(parser, {c: recent_samples[c] for c in stale})
|
|
278
|
+
"""
|
|
279
|
+
result: dict[str, bool] = {}
|
|
280
|
+
for component in calibration_samples:
|
|
281
|
+
if component not in recent_samples:
|
|
282
|
+
continue
|
|
283
|
+
result[component] = needs_recalibration(
|
|
284
|
+
calibration_samples[component],
|
|
285
|
+
recent_samples[component],
|
|
286
|
+
mean_shift_threshold=mean_shift_threshold,
|
|
287
|
+
std_ratio_high=std_ratio_high,
|
|
288
|
+
std_ratio_low=std_ratio_low,
|
|
289
|
+
min_recent=min_recent,
|
|
290
|
+
)
|
|
291
|
+
return result
|
|
292
|
+
|
|
293
|
+
|
|
201
294
|
def recalibrate_parser(
|
|
202
295
|
parser: Parser,
|
|
203
296
|
new_healthy_data: dict[str, list[float]],
|
|
@@ -206,6 +299,9 @@ def recalibrate_parser(
|
|
|
206
299
|
intact_fraction: float = 0.70,
|
|
207
300
|
ablated_fraction: float = 0.30,
|
|
208
301
|
active_min: float = 0.05,
|
|
302
|
+
use_std: bool = False,
|
|
303
|
+
intact_std_multiplier: float = 1.5,
|
|
304
|
+
ablated_std_multiplier: float = 3.0,
|
|
209
305
|
) -> tuple[Parser, dict[str, CalibrationResult]]:
|
|
210
306
|
"""
|
|
211
307
|
Return a new Parser with updated baselines for components whose healthy
|
|
@@ -215,19 +311,24 @@ def recalibrate_parser(
|
|
|
215
311
|
in `new_healthy_data` (or not in `components`) keep their existing
|
|
216
312
|
baselines and thresholds unchanged.
|
|
217
313
|
|
|
218
|
-
Use after `needs_recalibration()`
|
|
219
|
-
is stale, or when you have a new labeled window
|
|
314
|
+
Use after `needs_recalibration()` or `needs_recalibration_many()` signals
|
|
315
|
+
that a component's baseline is stale, or when you have a new labeled window
|
|
316
|
+
of known-healthy data.
|
|
220
317
|
|
|
221
318
|
Args:
|
|
222
|
-
parser:
|
|
223
|
-
new_healthy_data:
|
|
224
|
-
polarities:
|
|
225
|
-
|
|
226
|
-
components:
|
|
227
|
-
|
|
228
|
-
intact_fraction:
|
|
229
|
-
ablated_fraction:
|
|
230
|
-
active_min:
|
|
319
|
+
parser: the existing Parser to replace
|
|
320
|
+
new_healthy_data: {component: [new_healthy_measurements]}
|
|
321
|
+
polarities: {component: higher_is_better} overrides (defaults
|
|
322
|
+
to existing parser polarity for each component)
|
|
323
|
+
components: allowlist of components to recalibrate; if None,
|
|
324
|
+
all components in new_healthy_data are recalibrated
|
|
325
|
+
intact_fraction: fraction of baseline for intact threshold (fraction mode)
|
|
326
|
+
ablated_fraction: fraction of baseline for ablated threshold (fraction mode)
|
|
327
|
+
active_min: inherited from existing parser thresholds; pass only
|
|
328
|
+
to override for recalibrated components
|
|
329
|
+
use_std: if True, use std-based thresholds (see calibrate())
|
|
330
|
+
intact_std_multiplier: std deviations for intact threshold (std mode)
|
|
331
|
+
ablated_std_multiplier: std deviations for ablated threshold (std mode)
|
|
231
332
|
|
|
232
333
|
Returns:
|
|
233
334
|
(new_parser, results) where results is {component: CalibrationResult}
|
|
@@ -236,11 +337,13 @@ def recalibrate_parser(
|
|
|
236
337
|
Transition notes:
|
|
237
338
|
- Historical Ledger Observations remain anchored to the baseline that was
|
|
238
339
|
active when they were recorded. Recalibration does not rewrite history.
|
|
239
|
-
- After recalibrating,
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
340
|
+
- After recalibrating, call monitor.reset_anomaly_reference() to flush
|
|
341
|
+
old-baseline data from AnomalyTracker windows:
|
|
342
|
+
new_parser, _ = recalibrate_parser(old_parser, new_data)
|
|
343
|
+
monitor.parser = new_parser
|
|
344
|
+
monitor.reset_anomaly_reference() # clears stale reference window
|
|
345
|
+
- Per-tracker drift windows need not be reset — drift history under the
|
|
346
|
+
old baseline is simply outweighed as new observations accumulate.
|
|
244
347
|
"""
|
|
245
348
|
polarities = polarities or {}
|
|
246
349
|
to_recalibrate = set(components) if components else set(new_healthy_data.keys())
|
|
@@ -256,7 +359,14 @@ def recalibrate_parser(
|
|
|
256
359
|
# Resolve polarity: explicit override → existing parser polarity → True
|
|
257
360
|
existing_t = parser.component_thresholds.get(name) or parser.thresholds
|
|
258
361
|
hib = polarities.get(name, existing_t.higher_is_better)
|
|
259
|
-
|
|
362
|
+
# Inherit active_min from existing thresholds unless caller overrides
|
|
363
|
+
component_active_min = existing_t.active_min if active_min == 0.05 else active_min
|
|
364
|
+
result = calibrate(
|
|
365
|
+
vals, hib, intact_fraction, ablated_fraction, component_active_min,
|
|
366
|
+
use_std=use_std,
|
|
367
|
+
intact_std_multiplier=intact_std_multiplier,
|
|
368
|
+
ablated_std_multiplier=ablated_std_multiplier,
|
|
369
|
+
)
|
|
260
370
|
results[name] = result
|
|
261
371
|
new_baselines[name] = result.baseline
|
|
262
372
|
new_component_thresholds[name] = result.thresholds
|
|
@@ -292,6 +402,9 @@ def parser_from_calibration(
|
|
|
292
402
|
intact_fraction: float = 0.70,
|
|
293
403
|
ablated_fraction: float = 0.30,
|
|
294
404
|
active_min: float = 0.05,
|
|
405
|
+
use_std: bool = False,
|
|
406
|
+
intact_std_multiplier: float = 1.5,
|
|
407
|
+
ablated_std_multiplier: float = 3.0,
|
|
295
408
|
) -> Parser:
|
|
296
409
|
"""
|
|
297
410
|
One-shot: calibrate from historical data and return a ready-to-use Parser.
|
|
@@ -301,6 +414,9 @@ def parser_from_calibration(
|
|
|
301
414
|
"""
|
|
302
415
|
baselines, thresh_dict = calibrate_many(
|
|
303
416
|
component_values, polarities, intact_fraction, ablated_fraction, active_min,
|
|
417
|
+
use_std=use_std,
|
|
418
|
+
intact_std_multiplier=intact_std_multiplier,
|
|
419
|
+
ablated_std_multiplier=ablated_std_multiplier,
|
|
304
420
|
)
|
|
305
421
|
|
|
306
422
|
names = list(thresh_dict.keys())
|
|
@@ -14,6 +14,7 @@ Contracts express:
|
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
+
import warnings
|
|
17
18
|
from dataclasses import dataclass, field
|
|
18
19
|
from enum import Enum
|
|
19
20
|
from typing import Optional
|
|
@@ -108,6 +109,14 @@ class ReachHealth(ContractTerm):
|
|
|
108
109
|
within_steps: int
|
|
109
110
|
|
|
110
111
|
def evaluate(self, ledger: Ledger, expr: Optional[Expression] = None) -> TermResult:
|
|
112
|
+
if ledger.max_records is not None and ledger.max_records < self.within_steps:
|
|
113
|
+
warnings.warn(
|
|
114
|
+
f"ReachHealth '{self.name}': ledger.max_records={ledger.max_records} < "
|
|
115
|
+
f"within_steps={self.within_steps} — records older than max_records are "
|
|
116
|
+
"dropped; if the target was reached before the cap, this term will show "
|
|
117
|
+
"PENDING rather than MET. Increase max_records or reduce within_steps.",
|
|
118
|
+
stacklevel=2,
|
|
119
|
+
)
|
|
111
120
|
recent = ledger.for_component(self.component).last_n(self.within_steps)
|
|
112
121
|
if len(recent) < 1:
|
|
113
122
|
return TermResult(self.name, TermStatus.PENDING, "no records yet")
|
|
@@ -142,6 +151,13 @@ class SustainHealth(ContractTerm):
|
|
|
142
151
|
or_better: bool = True
|
|
143
152
|
|
|
144
153
|
def evaluate(self, ledger: Ledger, expr: Optional[Expression] = None) -> TermResult:
|
|
154
|
+
if ledger.max_records is not None and ledger.max_records < self.for_steps:
|
|
155
|
+
warnings.warn(
|
|
156
|
+
f"SustainHealth '{self.name}': ledger.max_records={ledger.max_records} < "
|
|
157
|
+
f"for_steps={self.for_steps} — this term can never be MET; "
|
|
158
|
+
"increase Ledger(max_records=...) or reduce SustainHealth(for_steps=...).",
|
|
159
|
+
stacklevel=2,
|
|
160
|
+
)
|
|
145
161
|
recent = ledger.for_component(self.component).last_n(self.for_steps)
|
|
146
162
|
if len(recent) < self.for_steps:
|
|
147
163
|
return TermResult(self.name, TermStatus.PENDING,
|
|
@@ -46,6 +46,7 @@ class Thresholds:
|
|
|
46
46
|
(throughput, signal), False means lower values are
|
|
47
47
|
healthier (error rate, latency)
|
|
48
48
|
active_min: minimum correction magnitude to count as "active"
|
|
49
|
+
labels: optional display labels keyed by Health.value strings
|
|
49
50
|
|
|
50
51
|
For higher_is_better=True: intact >= ablated, value >= intact → INTACT
|
|
51
52
|
For higher_is_better=False: intact <= ablated, value <= intact → INTACT
|
|
@@ -54,6 +55,13 @@ class Thresholds:
|
|
|
54
55
|
ablated: float
|
|
55
56
|
higher_is_better: bool = True
|
|
56
57
|
active_min: float = 0.05
|
|
58
|
+
labels: Optional[dict[str, str]] = None
|
|
59
|
+
|
|
60
|
+
def label_for(self, health: 'Health') -> str:
|
|
61
|
+
"""Return the display label for a health state, or the state name if no label set."""
|
|
62
|
+
if self.labels:
|
|
63
|
+
return self.labels.get(health.value, health.value)
|
|
64
|
+
return health.value
|
|
57
65
|
|
|
58
66
|
def __post_init__(self):
|
|
59
67
|
if self.higher_is_better and self.ablated > self.intact:
|
|
@@ -45,6 +45,7 @@ class StepResult:
|
|
|
45
45
|
correction: Optional[Correction] = None
|
|
46
46
|
escalation: Optional[Escalation] = None
|
|
47
47
|
contract: Optional[ContractResult] = None
|
|
48
|
+
corrections: list = field(default_factory=list) # all fired corrections (multi_rule)
|
|
48
49
|
|
|
49
50
|
@property
|
|
50
51
|
def acted(self) -> bool:
|
|
@@ -74,6 +75,8 @@ class StepResult:
|
|
|
74
75
|
d["decision"] = self.decision.to_dict()
|
|
75
76
|
if self.correction:
|
|
76
77
|
d["correction"] = self.correction.to_dict()
|
|
78
|
+
if self.corrections:
|
|
79
|
+
d["corrections"] = [c.to_dict() for c in self.corrections]
|
|
77
80
|
if self.escalation:
|
|
78
81
|
d["escalation"] = self.escalation.to_dict()
|
|
79
82
|
if self.contract:
|
|
@@ -140,6 +143,11 @@ def step(
|
|
|
140
143
|
elif isinstance(decision.result, Escalation):
|
|
141
144
|
escalation = decision.result
|
|
142
145
|
|
|
146
|
+
# Collect all corrections for multi_rule mode
|
|
147
|
+
all_corrections = []
|
|
148
|
+
if policy.multi_rule:
|
|
149
|
+
all_corrections = [r for r in decision.results if isinstance(r, Correction)]
|
|
150
|
+
|
|
143
151
|
# 3. Evaluate contract
|
|
144
152
|
contract_result = None
|
|
145
153
|
if contract:
|
|
@@ -152,6 +160,7 @@ def step(
|
|
|
152
160
|
correction=correction,
|
|
153
161
|
escalation=escalation,
|
|
154
162
|
contract=contract_result,
|
|
163
|
+
corrections=all_corrections,
|
|
155
164
|
)
|
|
156
165
|
|
|
157
166
|
|
|
@@ -205,13 +214,13 @@ def run(
|
|
|
205
214
|
@dataclass
|
|
206
215
|
class FullStepResult:
|
|
207
216
|
"""
|
|
208
|
-
Complete output of one
|
|
217
|
+
Complete output of one full_step() evaluation.
|
|
209
218
|
|
|
210
|
-
step:
|
|
211
|
-
drift:
|
|
212
|
-
anomaly:
|
|
219
|
+
step: policy/contract/causal evaluation
|
|
220
|
+
drift: per-component drift classifications
|
|
221
|
+
anomaly: per-component anomaly classifications
|
|
213
222
|
correlations: discovered component correlations
|
|
214
|
-
intent:
|
|
223
|
+
intent: goal feasibility (if intent provided)
|
|
215
224
|
"""
|
|
216
225
|
step: StepResult
|
|
217
226
|
drift: dict = field(default_factory=dict)
|
|
@@ -287,7 +296,7 @@ def full_step(
|
|
|
287
296
|
intent: Intent goal (optional)
|
|
288
297
|
now: current timestamp (optional)
|
|
289
298
|
|
|
290
|
-
Returns FullStepResult with all
|
|
299
|
+
Returns FullStepResult with all stages evaluated.
|
|
291
300
|
"""
|
|
292
301
|
from datetime import datetime
|
|
293
302
|
|
|
@@ -60,6 +60,7 @@ class Observation:
|
|
|
60
60
|
higher_is_better: bool = True
|
|
61
61
|
provenance: list[str] = field(default_factory=list)
|
|
62
62
|
measured_at: Optional[datetime] = None
|
|
63
|
+
health_label: Optional[str] = None
|
|
63
64
|
|
|
64
65
|
@property
|
|
65
66
|
def sigma(self) -> float:
|
|
@@ -91,10 +92,11 @@ class Observation:
|
|
|
91
92
|
|
|
92
93
|
def to_atom(self) -> str:
|
|
93
94
|
"""Compact string: NAME:HEALTH(±σ)"""
|
|
95
|
+
display = self.health_label or self.health.value
|
|
94
96
|
if self.health == Health.OOD:
|
|
95
|
-
return f"{self.name}:{
|
|
97
|
+
return f"{self.name}:{display}"
|
|
96
98
|
sign = "+" if self.sigma >= 0 else ""
|
|
97
|
-
return f"{self.name}:{
|
|
99
|
+
return f"{self.name}:{display}({sign}{self.sigma:.2f}σ)"
|
|
98
100
|
|
|
99
101
|
def to_dict(self) -> dict:
|
|
100
102
|
d = {
|
|
@@ -107,6 +109,8 @@ class Observation:
|
|
|
107
109
|
"higher_is_better": self.higher_is_better,
|
|
108
110
|
"provenance": self.provenance,
|
|
109
111
|
}
|
|
112
|
+
if self.health_label is not None:
|
|
113
|
+
d["health_label"] = self.health_label
|
|
110
114
|
if self.measured_at is not None:
|
|
111
115
|
d["measured_at"] = self.measured_at.isoformat()
|
|
112
116
|
return d
|
|
@@ -125,6 +129,7 @@ class Observation:
|
|
|
125
129
|
higher_is_better=d.get("higher_is_better", True),
|
|
126
130
|
provenance=d.get("provenance", []),
|
|
127
131
|
measured_at=measured_at,
|
|
132
|
+
health_label=d.get("health_label"),
|
|
128
133
|
)
|
|
129
134
|
|
|
130
135
|
|
|
@@ -311,6 +316,10 @@ class Parser:
|
|
|
311
316
|
def _thresholds_for(self, name: str) -> Thresholds:
|
|
312
317
|
return self.component_thresholds.get(name, self.thresholds)
|
|
313
318
|
|
|
319
|
+
def label_for(self, component: str, health: Health) -> str:
|
|
320
|
+
"""Return the display label for a component's health state."""
|
|
321
|
+
return self._thresholds_for(component).label_for(health)
|
|
322
|
+
|
|
314
323
|
def parse(
|
|
315
324
|
self,
|
|
316
325
|
values: dict[str, float],
|
|
@@ -343,6 +352,7 @@ class Parser:
|
|
|
343
352
|
name=name, health=h, value=val, baseline=baseline,
|
|
344
353
|
confidence=conf, higher_is_better=ct.higher_is_better,
|
|
345
354
|
provenance=list(provenance),
|
|
355
|
+
health_label=ct.label_for(h) if ct.labels else None,
|
|
346
356
|
))
|
|
347
357
|
|
|
348
358
|
corrections = []
|
|
@@ -22,19 +22,25 @@ from .streaming import Monitor, DriftTracker, AnomalyTracker, CorrelationTracker
|
|
|
22
22
|
|
|
23
23
|
def save_monitor(monitor: Monitor, path: str) -> None:
|
|
24
24
|
"""Save Monitor state to a JSON file."""
|
|
25
|
+
# Read anomaly_min_reference from one of the trackers (all share the same value)
|
|
26
|
+
_any_tracker = next(iter(monitor._anomaly_trackers.values()), None)
|
|
27
|
+
_anomaly_min_ref = _any_tracker._min_reference if _any_tracker else 10
|
|
28
|
+
|
|
25
29
|
state = {
|
|
26
30
|
"step": monitor.step,
|
|
27
31
|
"window": monitor.window,
|
|
28
32
|
"drift_window": monitor.drift_window,
|
|
29
33
|
"anomaly_window": monitor.anomaly_window,
|
|
30
34
|
"correlation_window": monitor.correlation_window,
|
|
35
|
+
"anomaly_min_reference": _anomaly_min_ref,
|
|
36
|
+
"features": sorted(monitor.features),
|
|
31
37
|
"drift": {},
|
|
32
38
|
"anomaly": {},
|
|
33
39
|
"correlation": {
|
|
34
40
|
"components": monitor._correlation_tracker.components,
|
|
35
41
|
"series": {c: list(d) for c, d in monitor._correlation_tracker._series.items()},
|
|
36
42
|
"n_updates": monitor._correlation_tracker.n_updates,
|
|
37
|
-
},
|
|
43
|
+
} if monitor._correlation_tracker is not None else None,
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
for name, dt in monitor._drift_trackers.items():
|
|
@@ -65,11 +71,16 @@ def load_monitor(path: str, parser: Parser, **kwargs) -> Monitor:
|
|
|
65
71
|
drift_window = data.get("drift_window", kwargs.pop("drift_window", None))
|
|
66
72
|
anomaly_window = data.get("anomaly_window", kwargs.pop("anomaly_window", None))
|
|
67
73
|
correlation_window = data.get("correlation_window", kwargs.pop("correlation_window", None))
|
|
74
|
+
anomaly_min_reference = data.get("anomaly_min_reference", kwargs.pop("anomaly_min_reference", 10))
|
|
75
|
+
features_data = data.get("features", None)
|
|
76
|
+
features = set(features_data) if features_data is not None else None
|
|
68
77
|
monitor = Monitor(
|
|
69
78
|
parser, window=window,
|
|
70
79
|
drift_window=drift_window,
|
|
71
80
|
anomaly_window=anomaly_window,
|
|
72
81
|
correlation_window=correlation_window,
|
|
82
|
+
anomaly_min_reference=anomaly_min_reference,
|
|
83
|
+
features=features,
|
|
73
84
|
**kwargs,
|
|
74
85
|
)
|
|
75
86
|
monitor._step = data.get("step", 0)
|
|
@@ -94,12 +105,13 @@ def load_monitor(path: str, parser: Parser, **kwargs) -> Monitor:
|
|
|
94
105
|
tracker._values.append(v)
|
|
95
106
|
|
|
96
107
|
# Restore correlation series
|
|
97
|
-
corr_data = data.get("correlation"
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
108
|
+
corr_data = data.get("correlation")
|
|
109
|
+
if corr_data is not None and monitor._correlation_tracker is not None:
|
|
110
|
+
for c in monitor._correlation_tracker.components:
|
|
111
|
+
series = corr_data.get("series", {}).get(c, [])
|
|
112
|
+
for v in series:
|
|
113
|
+
monitor._correlation_tracker._series[c].append(v)
|
|
114
|
+
monitor._correlation_tracker._n_updates = corr_data.get("n_updates", 0)
|
|
103
115
|
|
|
104
116
|
return monitor
|
|
105
117
|
|