margin 0.8.2__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.8.2/margin.egg-info → margin-0.9.1}/PKG-INFO +6 -4
- {margin-0.8.2 → margin-0.9.1}/README.md +5 -3
- {margin-0.8.2 → margin-0.9.1}/margin/__init__.py +8 -3
- margin-0.9.1/margin/calibrate.py +433 -0
- {margin-0.8.2 → margin-0.9.1}/margin/contract.py +16 -0
- {margin-0.8.2 → margin-0.9.1}/margin/loop.py +6 -6
- {margin-0.8.2 → margin-0.9.1}/margin/observation.py +15 -2
- {margin-0.8.2 → margin-0.9.1}/margin/persist.py +48 -8
- {margin-0.8.2 → margin-0.9.1}/margin/policy/validate.py +45 -0
- {margin-0.8.2 → margin-0.9.1}/margin/streaming.py +65 -6
- {margin-0.8.2 → margin-0.9.1/margin.egg-info}/PKG-INFO +6 -4
- {margin-0.8.2 → margin-0.9.1}/pyproject.toml +1 -1
- margin-0.8.2/margin/calibrate.py +0 -182
- {margin-0.8.2 → margin-0.9.1}/LICENSE +0 -0
- {margin-0.8.2 → margin-0.9.1}/NOTICE +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin/__main__.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin/algebra.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin/anomaly.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin/bridge.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin/causal.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin/composite.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin/confidence.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin/config.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin/correlate.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin/diff.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin/drift.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin/events.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin/forecast.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin/health.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin/intent.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin/ledger.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin/policy/__init__.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin/policy/compose.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin/policy/core.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin/policy/temporal.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin/policy/trace.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin/policy/tuning.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin/predicates.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin/provenance.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin/transitions.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin/uncertain.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin/validity.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin.egg-info/SOURCES.txt +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin.egg-info/dependency_links.txt +0 -0
- {margin-0.8.2 → margin-0.9.1}/margin.egg-info/top_level.txt +0 -0
- {margin-0.8.2 → margin-0.9.1}/setup.cfg +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_algebra.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_anomaly.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_bridge.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_calibrate.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_causal.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_composite.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_confidence.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_config.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_contract.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_correlate.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_diff.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_drift.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_events.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_forecast.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_health.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_healthcare.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_intent.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_ledger.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_loop.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_neuro.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_observation.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_persist.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_policy.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_policy_compose.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_policy_temporal.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_policy_trace.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_policy_tuning.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_policy_validate.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_predicates.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_printer3d.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_provenance.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_reverse_bridge.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_ros2.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_staleness.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_streaming.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_transformer.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_transitions.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_uncertain.py +0 -0
- {margin-0.8.2 → margin-0.9.1}/tests/test_validity.py +0 -0
- {margin-0.8.2 → 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.
|
|
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
|
|
|
@@ -29,7 +29,11 @@ from .ledger import Record, Ledger
|
|
|
29
29
|
|
|
30
30
|
# Observability
|
|
31
31
|
from .bridge import observe, observe_many, delta, to_uncertain
|
|
32
|
-
from .calibrate import
|
|
32
|
+
from .calibrate import (
|
|
33
|
+
CalibrationResult, calibrate, calibrate_many,
|
|
34
|
+
parser_from_calibration, recalibrate_parser,
|
|
35
|
+
needs_recalibration, needs_recalibration_many,
|
|
36
|
+
)
|
|
33
37
|
from .composite import CompositeObservation, AggregateStrategy
|
|
34
38
|
from .diff import ComponentChange, Diff, diff
|
|
35
39
|
from .events import EventBus
|
|
@@ -95,7 +99,7 @@ from .causal import (
|
|
|
95
99
|
)
|
|
96
100
|
|
|
97
101
|
# Streaming
|
|
98
|
-
from .streaming import DriftTracker, AnomalyTracker, CorrelationTracker, Monitor
|
|
102
|
+
from .streaming import DriftTracker, AnomalyTracker, CorrelationTracker, Monitor, WindowConfig
|
|
99
103
|
|
|
100
104
|
# Config
|
|
101
105
|
from .config import from_config, load_config
|
|
@@ -125,6 +129,7 @@ __all__ = [
|
|
|
125
129
|
# Observability
|
|
126
130
|
"observe", "observe_many", "delta", "to_uncertain",
|
|
127
131
|
"CalibrationResult", "calibrate", "calibrate_many", "parser_from_calibration",
|
|
132
|
+
"recalibrate_parser", "needs_recalibration", "needs_recalibration_many",
|
|
128
133
|
"CompositeObservation", "AggregateStrategy",
|
|
129
134
|
"ComponentChange", "Diff", "diff",
|
|
130
135
|
"AnomalyState", "ANOMALY_SEVERITY", "AnomalyClassification",
|
|
@@ -171,7 +176,7 @@ __all__ = [
|
|
|
171
176
|
# Intent
|
|
172
177
|
"Feasibility", "Requirement", "RiskFactor", "IntentResult", "Intent",
|
|
173
178
|
# Streaming
|
|
174
|
-
"DriftTracker", "AnomalyTracker", "CorrelationTracker", "Monitor",
|
|
179
|
+
"DriftTracker", "AnomalyTracker", "CorrelationTracker", "Monitor", "WindowConfig",
|
|
175
180
|
# Config
|
|
176
181
|
"from_config", "load_config",
|
|
177
182
|
# Persistence
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Threshold derivation from historical data.
|
|
3
|
+
|
|
4
|
+
Takes a set of "known healthy" measurements and derives baselines and
|
|
5
|
+
thresholds automatically.
|
|
6
|
+
|
|
7
|
+
Also provides runtime recalibration: detect when a baseline has drifted
|
|
8
|
+
and construct a replacement Parser with updated baselines/thresholds.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from .health import Thresholds
|
|
18
|
+
from .observation import Parser
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class CalibrationResult:
|
|
23
|
+
"""
|
|
24
|
+
Output of calibrate(): derived baseline and thresholds for one component.
|
|
25
|
+
|
|
26
|
+
baseline: mean of healthy measurements
|
|
27
|
+
std: standard deviation of healthy measurements
|
|
28
|
+
n_samples: number of measurements used
|
|
29
|
+
thresholds: derived Thresholds object
|
|
30
|
+
"""
|
|
31
|
+
baseline: float
|
|
32
|
+
std: float
|
|
33
|
+
n_samples: int
|
|
34
|
+
thresholds: Thresholds
|
|
35
|
+
|
|
36
|
+
def to_dict(self) -> dict:
|
|
37
|
+
return {
|
|
38
|
+
"baseline": round(self.baseline, 6),
|
|
39
|
+
"std": round(self.std, 6),
|
|
40
|
+
"n_samples": self.n_samples,
|
|
41
|
+
"intact": self.thresholds.intact,
|
|
42
|
+
"ablated": self.thresholds.ablated,
|
|
43
|
+
"higher_is_better": self.thresholds.higher_is_better,
|
|
44
|
+
"active_min": self.thresholds.active_min,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def calibrate(
|
|
49
|
+
values: list[float],
|
|
50
|
+
higher_is_better: bool = True,
|
|
51
|
+
intact_fraction: float = 0.70,
|
|
52
|
+
ablated_fraction: float = 0.30,
|
|
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,
|
|
57
|
+
) -> CalibrationResult:
|
|
58
|
+
"""
|
|
59
|
+
Derive a baseline and thresholds from known-healthy measurements.
|
|
60
|
+
|
|
61
|
+
Takes a list of measurements collected when the component was operating
|
|
62
|
+
normally, and derives thresholds in one of two modes:
|
|
63
|
+
|
|
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.
|
|
68
|
+
|
|
69
|
+
intact = baseline * intact_fraction (e.g. 70% of healthy mean)
|
|
70
|
+
ablated = baseline * ablated_fraction (e.g. 30% of healthy mean)
|
|
71
|
+
|
|
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:
|
|
93
|
+
intact = baseline * (1 + (1 - intact_fraction)) (e.g. 130% of healthy mean)
|
|
94
|
+
ablated = baseline * (1 + (1 - ablated_fraction)) (e.g. 170% of healthy mean)
|
|
95
|
+
This puts the "bad" direction above baseline for lower-is-better metrics.
|
|
96
|
+
"""
|
|
97
|
+
if not values:
|
|
98
|
+
raise ValueError("Cannot calibrate from empty list")
|
|
99
|
+
|
|
100
|
+
n = len(values)
|
|
101
|
+
baseline = sum(values) / n
|
|
102
|
+
|
|
103
|
+
if baseline == 0:
|
|
104
|
+
raise ValueError("Cannot calibrate from zero baseline — thresholds would be degenerate")
|
|
105
|
+
if higher_is_better and baseline < 0 and not use_std:
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"Cannot calibrate higher_is_better=True with negative baseline ({baseline}). "
|
|
108
|
+
"Fraction-based thresholds invert for negative values. "
|
|
109
|
+
"Use higher_is_better=False, shift your measurements to be positive, "
|
|
110
|
+
"or use use_std=True for std-based thresholds.")
|
|
111
|
+
|
|
112
|
+
variance = sum((v - baseline) ** 2 for v in values) / max(n - 1, 1)
|
|
113
|
+
std = math.sqrt(variance)
|
|
114
|
+
|
|
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
|
|
126
|
+
else:
|
|
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)
|
|
135
|
+
|
|
136
|
+
thresholds = Thresholds(
|
|
137
|
+
intact=round(intact, 6),
|
|
138
|
+
ablated=round(ablated, 6),
|
|
139
|
+
higher_is_better=higher_is_better,
|
|
140
|
+
active_min=active_min,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return CalibrationResult(
|
|
144
|
+
baseline=baseline,
|
|
145
|
+
std=std,
|
|
146
|
+
n_samples=n,
|
|
147
|
+
thresholds=thresholds,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def calibrate_many(
|
|
152
|
+
component_values: dict[str, list[float]],
|
|
153
|
+
polarities: Optional[dict[str, bool]] = None,
|
|
154
|
+
intact_fraction: float = 0.70,
|
|
155
|
+
ablated_fraction: float = 0.30,
|
|
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,
|
|
160
|
+
) -> tuple[dict[str, float], dict[str, Thresholds]]:
|
|
161
|
+
"""
|
|
162
|
+
Calibrate multiple components at once. Returns (baselines, thresholds)
|
|
163
|
+
ready to pass directly to Parser().
|
|
164
|
+
|
|
165
|
+
Args:
|
|
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)
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
(baselines_dict, thresholds_dict) suitable for:
|
|
177
|
+
Parser(baselines=baselines_dict,
|
|
178
|
+
thresholds=first_threshold,
|
|
179
|
+
component_thresholds=thresholds_dict)
|
|
180
|
+
"""
|
|
181
|
+
polarities = polarities or {}
|
|
182
|
+
baselines = {}
|
|
183
|
+
thresholds = {}
|
|
184
|
+
|
|
185
|
+
for name, vals in component_values.items():
|
|
186
|
+
hib = polarities.get(name, True)
|
|
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
|
+
)
|
|
193
|
+
baselines[name] = result.baseline
|
|
194
|
+
thresholds[name] = result.thresholds
|
|
195
|
+
|
|
196
|
+
return baselines, thresholds
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def needs_recalibration(
|
|
200
|
+
calibration_samples: list[float],
|
|
201
|
+
recent_samples: list[float],
|
|
202
|
+
mean_shift_threshold: float = 0.2,
|
|
203
|
+
std_ratio_high: float = 2.0,
|
|
204
|
+
std_ratio_low: float = 0.5,
|
|
205
|
+
min_recent: int = 5,
|
|
206
|
+
) -> bool:
|
|
207
|
+
"""
|
|
208
|
+
Return True if recent values suggest the baseline has drifted.
|
|
209
|
+
|
|
210
|
+
Uses distribution comparison between calibration-time healthy data and
|
|
211
|
+
a recent window to detect two signals:
|
|
212
|
+
|
|
213
|
+
Signal A — mean shift: the operating centre has moved by more than
|
|
214
|
+
`mean_shift_threshold` (relative fraction, default 20%).
|
|
215
|
+
|
|
216
|
+
Signal B — spread change: variance has doubled (`std_ratio > 2.0`)
|
|
217
|
+
or halved (`std_ratio < 0.5`) relative to the calibration period.
|
|
218
|
+
|
|
219
|
+
Returns True when either signal fires and there are enough recent samples.
|
|
220
|
+
A third signal (AnomalyState.ANOMALOUS at healthy sigma) must be checked
|
|
221
|
+
externally via AnomalyTracker — see `needs_recalibration` docs.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
calibration_samples: original healthy measurements used to build the Parser
|
|
225
|
+
recent_samples: recent window of observed values
|
|
226
|
+
mean_shift_threshold: relative mean shift that triggers recalibration (default 0.2)
|
|
227
|
+
std_ratio_high: std ratio above which spread is considered changed (default 2.0)
|
|
228
|
+
std_ratio_low: std ratio below which spread is considered changed (default 0.5)
|
|
229
|
+
min_recent: minimum recent samples required to make a judgment (default 5)
|
|
230
|
+
"""
|
|
231
|
+
if len(recent_samples) < min_recent or len(calibration_samples) < 2:
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
from .anomaly import check_distribution
|
|
235
|
+
ds = check_distribution(recent_samples, calibration_samples)
|
|
236
|
+
|
|
237
|
+
mean_shifted = abs(ds.mean_shift) > mean_shift_threshold
|
|
238
|
+
spread_changed = ds.std_ratio > std_ratio_high or ds.std_ratio < std_ratio_low
|
|
239
|
+
|
|
240
|
+
return mean_shifted or spread_changed
|
|
241
|
+
|
|
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
|
+
|
|
294
|
+
def recalibrate_parser(
|
|
295
|
+
parser: Parser,
|
|
296
|
+
new_healthy_data: dict[str, list[float]],
|
|
297
|
+
polarities: Optional[dict[str, bool]] = None,
|
|
298
|
+
components: Optional[list[str]] = None,
|
|
299
|
+
intact_fraction: float = 0.70,
|
|
300
|
+
ablated_fraction: float = 0.30,
|
|
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,
|
|
305
|
+
) -> tuple[Parser, dict[str, CalibrationResult]]:
|
|
306
|
+
"""
|
|
307
|
+
Return a new Parser with updated baselines for components whose healthy
|
|
308
|
+
operating range has drifted.
|
|
309
|
+
|
|
310
|
+
Does NOT mutate the input parser — returns a replacement. Components not
|
|
311
|
+
in `new_healthy_data` (or not in `components`) keep their existing
|
|
312
|
+
baselines and thresholds unchanged.
|
|
313
|
+
|
|
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.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
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)
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
(new_parser, results) where results is {component: CalibrationResult}
|
|
335
|
+
for each recalibrated component.
|
|
336
|
+
|
|
337
|
+
Transition notes:
|
|
338
|
+
- Historical Ledger Observations remain anchored to the baseline that was
|
|
339
|
+
active when they were recorded. Recalibration does not rewrite history.
|
|
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.
|
|
347
|
+
"""
|
|
348
|
+
polarities = polarities or {}
|
|
349
|
+
to_recalibrate = set(components) if components else set(new_healthy_data.keys())
|
|
350
|
+
|
|
351
|
+
results: dict[str, CalibrationResult] = {}
|
|
352
|
+
new_baselines: dict[str, float] = {}
|
|
353
|
+
new_component_thresholds: dict[str, Thresholds] = {}
|
|
354
|
+
|
|
355
|
+
for name in to_recalibrate:
|
|
356
|
+
if name not in new_healthy_data:
|
|
357
|
+
continue
|
|
358
|
+
vals = new_healthy_data[name]
|
|
359
|
+
# Resolve polarity: explicit override → existing parser polarity → True
|
|
360
|
+
existing_t = parser.component_thresholds.get(name) or parser.thresholds
|
|
361
|
+
hib = polarities.get(name, existing_t.higher_is_better)
|
|
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
|
+
)
|
|
370
|
+
results[name] = result
|
|
371
|
+
new_baselines[name] = result.baseline
|
|
372
|
+
new_component_thresholds[name] = result.thresholds
|
|
373
|
+
|
|
374
|
+
# Merge: new overrides old; unrecalibrated components are preserved verbatim
|
|
375
|
+
merged_baselines = {**parser.baselines, **new_baselines}
|
|
376
|
+
merged_component_thresholds = {**parser.component_thresholds, **new_component_thresholds}
|
|
377
|
+
|
|
378
|
+
# Default thresholds: recalibrate if the first component was recalibrated,
|
|
379
|
+
# otherwise keep the existing default.
|
|
380
|
+
first_name = list(parser.baselines.keys())[0] if parser.baselines else None
|
|
381
|
+
if first_name and first_name in new_component_thresholds:
|
|
382
|
+
default_thresholds = new_component_thresholds.pop(first_name)
|
|
383
|
+
merged_component_thresholds.pop(first_name, None)
|
|
384
|
+
else:
|
|
385
|
+
default_thresholds = parser.thresholds
|
|
386
|
+
# Remove first component from component_thresholds if it was there
|
|
387
|
+
if first_name:
|
|
388
|
+
merged_component_thresholds.pop(first_name, None)
|
|
389
|
+
|
|
390
|
+
new_parser = Parser(
|
|
391
|
+
baselines=merged_baselines,
|
|
392
|
+
thresholds=default_thresholds,
|
|
393
|
+
component_thresholds=merged_component_thresholds,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
return new_parser, results
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def parser_from_calibration(
|
|
400
|
+
component_values: dict[str, list[float]],
|
|
401
|
+
polarities: Optional[dict[str, bool]] = None,
|
|
402
|
+
intact_fraction: float = 0.70,
|
|
403
|
+
ablated_fraction: float = 0.30,
|
|
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,
|
|
408
|
+
) -> Parser:
|
|
409
|
+
"""
|
|
410
|
+
One-shot: calibrate from historical data and return a ready-to-use Parser.
|
|
411
|
+
|
|
412
|
+
Uses the first component's thresholds as the default, with all others
|
|
413
|
+
as component_thresholds overrides.
|
|
414
|
+
"""
|
|
415
|
+
baselines, thresh_dict = calibrate_many(
|
|
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,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
names = list(thresh_dict.keys())
|
|
423
|
+
if not names:
|
|
424
|
+
raise ValueError("No components to calibrate")
|
|
425
|
+
|
|
426
|
+
default_thresholds = thresh_dict[names[0]]
|
|
427
|
+
component_thresholds = {n: t for n, t in thresh_dict.items() if n != names[0]}
|
|
428
|
+
|
|
429
|
+
return Parser(
|
|
430
|
+
baselines=baselines,
|
|
431
|
+
thresholds=default_thresholds,
|
|
432
|
+
component_thresholds=component_thresholds,
|
|
433
|
+
)
|
|
@@ -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
|
|
|
@@ -10,6 +10,7 @@ Expression: composed snapshot — all observations + corrections at one moment
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import json
|
|
13
|
+
import warnings
|
|
13
14
|
from dataclasses import dataclass, field
|
|
14
15
|
from datetime import datetime
|
|
15
16
|
from enum import Enum
|
|
@@ -161,8 +162,8 @@ class Correction:
|
|
|
161
162
|
return {
|
|
162
163
|
"target": self.target,
|
|
163
164
|
"op": self.op.value,
|
|
164
|
-
"alpha":
|
|
165
|
-
"magnitude":
|
|
165
|
+
"alpha": self.alpha,
|
|
166
|
+
"magnitude": self.magnitude,
|
|
166
167
|
"triggered_by": self.triggered_by,
|
|
167
168
|
"provenance": self.provenance,
|
|
168
169
|
}
|
|
@@ -198,6 +199,18 @@ class Expression:
|
|
|
198
199
|
label: str = ""
|
|
199
200
|
step: Optional[int] = None
|
|
200
201
|
|
|
202
|
+
def __post_init__(self) -> None:
|
|
203
|
+
if self.observations:
|
|
204
|
+
weakest = min(o.confidence for o in self.observations)
|
|
205
|
+
if self.confidence > weakest:
|
|
206
|
+
warnings.warn(
|
|
207
|
+
f"Expression.confidence ({self.confidence.value}) exceeds the weakest "
|
|
208
|
+
f"observation confidence ({weakest.value}). This overclaims certainty "
|
|
209
|
+
"and will cause min_confidence policy gates to fire incorrectly. "
|
|
210
|
+
"Use Parser.parse() to construct Expressions with correct net confidence.",
|
|
211
|
+
stacklevel=2,
|
|
212
|
+
)
|
|
213
|
+
|
|
201
214
|
def health_of(self, name: str) -> Optional[Health]:
|
|
202
215
|
for o in self.observations:
|
|
203
216
|
if o.name == name:
|