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.

Files changed (86) hide show
  1. {margin-0.8.2/margin.egg-info → margin-0.9.1}/PKG-INFO +6 -4
  2. {margin-0.8.2 → margin-0.9.1}/README.md +5 -3
  3. {margin-0.8.2 → margin-0.9.1}/margin/__init__.py +8 -3
  4. margin-0.9.1/margin/calibrate.py +433 -0
  5. {margin-0.8.2 → margin-0.9.1}/margin/contract.py +16 -0
  6. {margin-0.8.2 → margin-0.9.1}/margin/loop.py +6 -6
  7. {margin-0.8.2 → margin-0.9.1}/margin/observation.py +15 -2
  8. {margin-0.8.2 → margin-0.9.1}/margin/persist.py +48 -8
  9. {margin-0.8.2 → margin-0.9.1}/margin/policy/validate.py +45 -0
  10. {margin-0.8.2 → margin-0.9.1}/margin/streaming.py +65 -6
  11. {margin-0.8.2 → margin-0.9.1/margin.egg-info}/PKG-INFO +6 -4
  12. {margin-0.8.2 → margin-0.9.1}/pyproject.toml +1 -1
  13. margin-0.8.2/margin/calibrate.py +0 -182
  14. {margin-0.8.2 → margin-0.9.1}/LICENSE +0 -0
  15. {margin-0.8.2 → margin-0.9.1}/NOTICE +0 -0
  16. {margin-0.8.2 → margin-0.9.1}/margin/__main__.py +0 -0
  17. {margin-0.8.2 → margin-0.9.1}/margin/algebra.py +0 -0
  18. {margin-0.8.2 → margin-0.9.1}/margin/anomaly.py +0 -0
  19. {margin-0.8.2 → margin-0.9.1}/margin/bridge.py +0 -0
  20. {margin-0.8.2 → margin-0.9.1}/margin/causal.py +0 -0
  21. {margin-0.8.2 → margin-0.9.1}/margin/composite.py +0 -0
  22. {margin-0.8.2 → margin-0.9.1}/margin/confidence.py +0 -0
  23. {margin-0.8.2 → margin-0.9.1}/margin/config.py +0 -0
  24. {margin-0.8.2 → margin-0.9.1}/margin/correlate.py +0 -0
  25. {margin-0.8.2 → margin-0.9.1}/margin/diff.py +0 -0
  26. {margin-0.8.2 → margin-0.9.1}/margin/drift.py +0 -0
  27. {margin-0.8.2 → margin-0.9.1}/margin/events.py +0 -0
  28. {margin-0.8.2 → margin-0.9.1}/margin/forecast.py +0 -0
  29. {margin-0.8.2 → margin-0.9.1}/margin/health.py +0 -0
  30. {margin-0.8.2 → margin-0.9.1}/margin/intent.py +0 -0
  31. {margin-0.8.2 → margin-0.9.1}/margin/ledger.py +0 -0
  32. {margin-0.8.2 → margin-0.9.1}/margin/policy/__init__.py +0 -0
  33. {margin-0.8.2 → margin-0.9.1}/margin/policy/compose.py +0 -0
  34. {margin-0.8.2 → margin-0.9.1}/margin/policy/core.py +0 -0
  35. {margin-0.8.2 → margin-0.9.1}/margin/policy/temporal.py +0 -0
  36. {margin-0.8.2 → margin-0.9.1}/margin/policy/trace.py +0 -0
  37. {margin-0.8.2 → margin-0.9.1}/margin/policy/tuning.py +0 -0
  38. {margin-0.8.2 → margin-0.9.1}/margin/predicates.py +0 -0
  39. {margin-0.8.2 → margin-0.9.1}/margin/provenance.py +0 -0
  40. {margin-0.8.2 → margin-0.9.1}/margin/transitions.py +0 -0
  41. {margin-0.8.2 → margin-0.9.1}/margin/uncertain.py +0 -0
  42. {margin-0.8.2 → margin-0.9.1}/margin/validity.py +0 -0
  43. {margin-0.8.2 → margin-0.9.1}/margin.egg-info/SOURCES.txt +0 -0
  44. {margin-0.8.2 → margin-0.9.1}/margin.egg-info/dependency_links.txt +0 -0
  45. {margin-0.8.2 → margin-0.9.1}/margin.egg-info/top_level.txt +0 -0
  46. {margin-0.8.2 → margin-0.9.1}/setup.cfg +0 -0
  47. {margin-0.8.2 → margin-0.9.1}/tests/test_algebra.py +0 -0
  48. {margin-0.8.2 → margin-0.9.1}/tests/test_anomaly.py +0 -0
  49. {margin-0.8.2 → margin-0.9.1}/tests/test_bridge.py +0 -0
  50. {margin-0.8.2 → margin-0.9.1}/tests/test_calibrate.py +0 -0
  51. {margin-0.8.2 → margin-0.9.1}/tests/test_causal.py +0 -0
  52. {margin-0.8.2 → margin-0.9.1}/tests/test_composite.py +0 -0
  53. {margin-0.8.2 → margin-0.9.1}/tests/test_confidence.py +0 -0
  54. {margin-0.8.2 → margin-0.9.1}/tests/test_config.py +0 -0
  55. {margin-0.8.2 → margin-0.9.1}/tests/test_contract.py +0 -0
  56. {margin-0.8.2 → margin-0.9.1}/tests/test_correlate.py +0 -0
  57. {margin-0.8.2 → margin-0.9.1}/tests/test_diff.py +0 -0
  58. {margin-0.8.2 → margin-0.9.1}/tests/test_drift.py +0 -0
  59. {margin-0.8.2 → margin-0.9.1}/tests/test_events.py +0 -0
  60. {margin-0.8.2 → margin-0.9.1}/tests/test_forecast.py +0 -0
  61. {margin-0.8.2 → margin-0.9.1}/tests/test_health.py +0 -0
  62. {margin-0.8.2 → margin-0.9.1}/tests/test_healthcare.py +0 -0
  63. {margin-0.8.2 → margin-0.9.1}/tests/test_intent.py +0 -0
  64. {margin-0.8.2 → margin-0.9.1}/tests/test_ledger.py +0 -0
  65. {margin-0.8.2 → margin-0.9.1}/tests/test_loop.py +0 -0
  66. {margin-0.8.2 → margin-0.9.1}/tests/test_neuro.py +0 -0
  67. {margin-0.8.2 → margin-0.9.1}/tests/test_observation.py +0 -0
  68. {margin-0.8.2 → margin-0.9.1}/tests/test_persist.py +0 -0
  69. {margin-0.8.2 → margin-0.9.1}/tests/test_policy.py +0 -0
  70. {margin-0.8.2 → margin-0.9.1}/tests/test_policy_compose.py +0 -0
  71. {margin-0.8.2 → margin-0.9.1}/tests/test_policy_temporal.py +0 -0
  72. {margin-0.8.2 → margin-0.9.1}/tests/test_policy_trace.py +0 -0
  73. {margin-0.8.2 → margin-0.9.1}/tests/test_policy_tuning.py +0 -0
  74. {margin-0.8.2 → margin-0.9.1}/tests/test_policy_validate.py +0 -0
  75. {margin-0.8.2 → margin-0.9.1}/tests/test_predicates.py +0 -0
  76. {margin-0.8.2 → margin-0.9.1}/tests/test_printer3d.py +0 -0
  77. {margin-0.8.2 → margin-0.9.1}/tests/test_provenance.py +0 -0
  78. {margin-0.8.2 → margin-0.9.1}/tests/test_reverse_bridge.py +0 -0
  79. {margin-0.8.2 → margin-0.9.1}/tests/test_ros2.py +0 -0
  80. {margin-0.8.2 → margin-0.9.1}/tests/test_staleness.py +0 -0
  81. {margin-0.8.2 → margin-0.9.1}/tests/test_streaming.py +0 -0
  82. {margin-0.8.2 → margin-0.9.1}/tests/test_transformer.py +0 -0
  83. {margin-0.8.2 → margin-0.9.1}/tests/test_transitions.py +0 -0
  84. {margin-0.8.2 → margin-0.9.1}/tests/test_uncertain.py +0 -0
  85. {margin-0.8.2 → margin-0.9.1}/tests/test_validity.py +0 -0
  86. {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.8.2
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 eight layers in one call:
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
- ## Five layers
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
- Plus `step()` and `run()` to orchestrate all five in one call.
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 eight layers in one call:
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
- ## Five layers
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
- Plus `step()` and `run()` to orchestrate all five in one call.
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 CalibrationResult, calibrate, calibrate_many, parser_from_calibration
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 full evaluation across all 8 layers.
208
+ Complete output of one full_step() evaluation.
209
209
 
210
- step: policy/contract/causal evaluation
211
- drift: per-component drift classifications
212
- anomaly: per-component anomaly classifications
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: goal feasibility (if intent provided)
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 8 layers evaluated.
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": round(self.alpha, 4),
165
- "magnitude": round(self.magnitude, 4),
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: