margin 0.9.0__tar.gz → 0.9.1__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.1}/PKG-INFO +6 -4
- {margin-0.9.0 → margin-0.9.1}/README.md +5 -3
- {margin-0.9.0 → margin-0.9.1}/margin/__init__.py +4 -2
- {margin-0.9.0 → margin-0.9.1}/margin/calibrate.py +160 -44
- {margin-0.9.0 → margin-0.9.1}/margin/contract.py +16 -0
- {margin-0.9.0 → margin-0.9.1}/margin/loop.py +6 -6
- {margin-0.9.0 → margin-0.9.1}/margin/persist.py +7 -0
- {margin-0.9.0 → margin-0.9.1}/margin/policy/validate.py +45 -0
- {margin-0.9.0 → margin-0.9.1}/margin/streaming.py +27 -0
- {margin-0.9.0 → margin-0.9.1/margin.egg-info}/PKG-INFO +6 -4
- {margin-0.9.0 → margin-0.9.1}/pyproject.toml +1 -1
- {margin-0.9.0 → margin-0.9.1}/LICENSE +0 -0
- {margin-0.9.0 → margin-0.9.1}/NOTICE +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin/__main__.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin/algebra.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin/anomaly.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin/bridge.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin/causal.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin/composite.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin/confidence.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin/config.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin/correlate.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin/diff.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin/drift.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin/events.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin/forecast.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin/health.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin/intent.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin/ledger.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin/observation.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin/policy/__init__.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin/policy/compose.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin/policy/core.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin/policy/temporal.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin/policy/trace.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin/policy/tuning.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin/predicates.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin/provenance.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin/transitions.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin/uncertain.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin/validity.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin.egg-info/SOURCES.txt +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin.egg-info/dependency_links.txt +0 -0
- {margin-0.9.0 → margin-0.9.1}/margin.egg-info/top_level.txt +0 -0
- {margin-0.9.0 → margin-0.9.1}/setup.cfg +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_algebra.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_anomaly.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_bridge.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_calibrate.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_causal.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_composite.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_confidence.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_config.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_contract.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_correlate.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_diff.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_drift.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_events.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_forecast.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_health.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_healthcare.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_intent.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_ledger.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_loop.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_neuro.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_observation.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_persist.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_policy.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_policy_compose.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_policy_temporal.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_policy_trace.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_policy_tuning.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_policy_validate.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_predicates.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_printer3d.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_provenance.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_reverse_bridge.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_ros2.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_staleness.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_streaming.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_transformer.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_transitions.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_uncertain.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/tests/test_validity.py +0 -0
- {margin-0.9.0 → margin-0.9.1}/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.1
|
|
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,
|
|
@@ -205,13 +205,13 @@ def run(
|
|
|
205
205
|
@dataclass
|
|
206
206
|
class FullStepResult:
|
|
207
207
|
"""
|
|
208
|
-
Complete output of one
|
|
208
|
+
Complete output of one full_step() evaluation.
|
|
209
209
|
|
|
210
|
-
step:
|
|
211
|
-
drift:
|
|
212
|
-
anomaly:
|
|
210
|
+
step: policy/contract/causal evaluation
|
|
211
|
+
drift: per-component drift classifications
|
|
212
|
+
anomaly: per-component anomaly classifications
|
|
213
213
|
correlations: discovered component correlations
|
|
214
|
-
intent:
|
|
214
|
+
intent: goal feasibility (if intent provided)
|
|
215
215
|
"""
|
|
216
216
|
step: StepResult
|
|
217
217
|
drift: dict = field(default_factory=dict)
|
|
@@ -287,7 +287,7 @@ def full_step(
|
|
|
287
287
|
intent: Intent goal (optional)
|
|
288
288
|
now: current timestamp (optional)
|
|
289
289
|
|
|
290
|
-
Returns FullStepResult with all
|
|
290
|
+
Returns FullStepResult with all stages evaluated.
|
|
291
291
|
"""
|
|
292
292
|
from datetime import datetime
|
|
293
293
|
|
|
@@ -22,12 +22,17 @@ 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,
|
|
31
36
|
"drift": {},
|
|
32
37
|
"anomaly": {},
|
|
33
38
|
"correlation": {
|
|
@@ -65,11 +70,13 @@ def load_monitor(path: str, parser: Parser, **kwargs) -> Monitor:
|
|
|
65
70
|
drift_window = data.get("drift_window", kwargs.pop("drift_window", None))
|
|
66
71
|
anomaly_window = data.get("anomaly_window", kwargs.pop("anomaly_window", None))
|
|
67
72
|
correlation_window = data.get("correlation_window", kwargs.pop("correlation_window", None))
|
|
73
|
+
anomaly_min_reference = data.get("anomaly_min_reference", kwargs.pop("anomaly_min_reference", 10))
|
|
68
74
|
monitor = Monitor(
|
|
69
75
|
parser, window=window,
|
|
70
76
|
drift_window=drift_window,
|
|
71
77
|
anomaly_window=anomaly_window,
|
|
72
78
|
correlation_window=correlation_window,
|
|
79
|
+
anomaly_min_reference=anomaly_min_reference,
|
|
73
80
|
**kwargs,
|
|
74
81
|
)
|
|
75
82
|
monitor._step = data.get("step", 0)
|
|
@@ -143,6 +143,9 @@ def validate(
|
|
|
143
143
|
if components:
|
|
144
144
|
_check_component_coverage(policy, components, issues)
|
|
145
145
|
|
|
146
|
+
# 8. alpha_from_sigma without a constraint floor
|
|
147
|
+
_check_alpha_from_sigma(policy, issues)
|
|
148
|
+
|
|
146
149
|
return ValidationResult(issues=issues)
|
|
147
150
|
|
|
148
151
|
|
|
@@ -195,6 +198,48 @@ def _check_contract_coverage(
|
|
|
195
198
|
f"contract requires '{comp}' but no policy rule targets it"))
|
|
196
199
|
|
|
197
200
|
|
|
201
|
+
def _check_alpha_from_sigma(policy: Policy, issues: list[ValidationIssue]) -> None:
|
|
202
|
+
"""
|
|
203
|
+
Warn when alpha_from_sigma=True with no constraint floor.
|
|
204
|
+
|
|
205
|
+
When a rule derives alpha from sigma at runtime, the resulting alpha can
|
|
206
|
+
be arbitrarily small (e.g. 0.001 in a stable period) and fire useless
|
|
207
|
+
micro-corrections on every step. A Constraint(min_alpha=...) suppresses
|
|
208
|
+
these; without one there is no floor.
|
|
209
|
+
|
|
210
|
+
Also warns when both alpha_from_sigma and magnitude_from_sigma are set —
|
|
211
|
+
double sigma scaling amplifies corrections non-linearly.
|
|
212
|
+
"""
|
|
213
|
+
for rule in policy.rules:
|
|
214
|
+
if not rule.action.alpha_from_sigma:
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
constraint = rule.constraint or policy.default_constraint
|
|
218
|
+
|
|
219
|
+
if constraint is None:
|
|
220
|
+
issues.append(ValidationIssue(
|
|
221
|
+
"warning", rule.name,
|
|
222
|
+
"alpha_from_sigma=True with no constraint — sigma-derived alpha has no "
|
|
223
|
+
"floor; add Constraint(min_alpha=...) to suppress micro-corrections "
|
|
224
|
+
"during stable periods, or pair with a drift predicate so the rule only "
|
|
225
|
+
"fires when sigma is meaningfully elevated",
|
|
226
|
+
))
|
|
227
|
+
elif constraint.min_alpha == 0.0:
|
|
228
|
+
issues.append(ValidationIssue(
|
|
229
|
+
"info", rule.name,
|
|
230
|
+
"alpha_from_sigma=True and constraint.min_alpha=0.0 — corrections will "
|
|
231
|
+
"fire with alpha≈0 during stable periods; consider raising min_alpha",
|
|
232
|
+
))
|
|
233
|
+
|
|
234
|
+
if rule.action.alpha_from_sigma and rule.action.magnitude_from_sigma:
|
|
235
|
+
issues.append(ValidationIssue(
|
|
236
|
+
"warning", rule.name,
|
|
237
|
+
"both alpha_from_sigma and magnitude_from_sigma are True — correction "
|
|
238
|
+
"strength scales as sigma² (alpha × magnitude); this amplifies strongly "
|
|
239
|
+
"for large deviations and may overcorrect",
|
|
240
|
+
))
|
|
241
|
+
|
|
242
|
+
|
|
198
243
|
def _check_component_coverage(
|
|
199
244
|
policy: Policy,
|
|
200
245
|
components: list[str],
|
|
@@ -11,6 +11,7 @@ maintain a bounded window and update classification on each new value.
|
|
|
11
11
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
|
+
import warnings
|
|
14
15
|
from collections import deque
|
|
15
16
|
from dataclasses import dataclass, field
|
|
16
17
|
from datetime import datetime
|
|
@@ -373,6 +374,15 @@ class Monitor:
|
|
|
373
374
|
self.anomaly_window = cfg.anomaly or anomaly_window or window
|
|
374
375
|
self.correlation_window = cfg.correlation or correlation_window or window
|
|
375
376
|
|
|
377
|
+
if anomaly_min_reference > self.anomaly_window:
|
|
378
|
+
warnings.warn(
|
|
379
|
+
f"Monitor: anomaly_min_reference={anomaly_min_reference} > "
|
|
380
|
+
f"anomaly_window={self.anomaly_window} — AnomalyTrackers will never "
|
|
381
|
+
"classify because the reference window can never be filled; raise "
|
|
382
|
+
"anomaly_window or lower anomaly_min_reference.",
|
|
383
|
+
stacklevel=2,
|
|
384
|
+
)
|
|
385
|
+
|
|
376
386
|
self._expression: Optional[Expression] = None
|
|
377
387
|
self._step = 0
|
|
378
388
|
|
|
@@ -472,6 +482,23 @@ class Monitor:
|
|
|
472
482
|
d["correlations"] = self._correlation_tracker.matrix.to_dict()
|
|
473
483
|
return d
|
|
474
484
|
|
|
485
|
+
def reset_anomaly_reference(self, components: Optional[list[str]] = None) -> None:
|
|
486
|
+
"""
|
|
487
|
+
Clear AnomalyTracker reference windows for the specified components.
|
|
488
|
+
|
|
489
|
+
Call after recalibrate_parser() to prevent old-baseline data from
|
|
490
|
+
contaminating anomaly classification during the warm-up period that
|
|
491
|
+
follows recalibration. The trackers will return None classifications
|
|
492
|
+
until they accumulate min_reference new values under the new baseline.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
components: names to reset; None resets all components.
|
|
496
|
+
"""
|
|
497
|
+
targets = components or list(self._anomaly_trackers.keys())
|
|
498
|
+
for name in targets:
|
|
499
|
+
if name in self._anomaly_trackers:
|
|
500
|
+
self._anomaly_trackers[name].reset()
|
|
501
|
+
|
|
475
502
|
def reset(self) -> None:
|
|
476
503
|
self._expression = None
|
|
477
504
|
self._step = 0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: margin
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.1
|
|
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
|
|
|
@@ -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.1"
|
|
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"}
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|