margin 0.9.0__tar.gz → 0.9.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of margin might be problematic. Click here for more details.

Files changed (85) hide show
  1. {margin-0.9.0/margin.egg-info → margin-0.9.2}/PKG-INFO +6 -4
  2. {margin-0.9.0 → margin-0.9.2}/README.md +5 -3
  3. {margin-0.9.0 → margin-0.9.2}/margin/__init__.py +4 -2
  4. {margin-0.9.0 → margin-0.9.2}/margin/calibrate.py +160 -44
  5. {margin-0.9.0 → margin-0.9.2}/margin/contract.py +16 -0
  6. {margin-0.9.0 → margin-0.9.2}/margin/health.py +8 -0
  7. {margin-0.9.0 → margin-0.9.2}/margin/loop.py +15 -6
  8. {margin-0.9.0 → margin-0.9.2}/margin/observation.py +12 -2
  9. {margin-0.9.0 → margin-0.9.2}/margin/persist.py +19 -7
  10. {margin-0.9.0 → margin-0.9.2}/margin/policy/core.py +3 -1
  11. {margin-0.9.0 → margin-0.9.2}/margin/policy/trace.py +15 -11
  12. {margin-0.9.0 → margin-0.9.2}/margin/policy/validate.py +45 -0
  13. {margin-0.9.0 → margin-0.9.2}/margin/streaming.py +72 -24
  14. {margin-0.9.0 → margin-0.9.2/margin.egg-info}/PKG-INFO +6 -4
  15. {margin-0.9.0 → margin-0.9.2}/pyproject.toml +1 -1
  16. {margin-0.9.0 → margin-0.9.2}/LICENSE +0 -0
  17. {margin-0.9.0 → margin-0.9.2}/NOTICE +0 -0
  18. {margin-0.9.0 → margin-0.9.2}/margin/__main__.py +0 -0
  19. {margin-0.9.0 → margin-0.9.2}/margin/algebra.py +0 -0
  20. {margin-0.9.0 → margin-0.9.2}/margin/anomaly.py +0 -0
  21. {margin-0.9.0 → margin-0.9.2}/margin/bridge.py +0 -0
  22. {margin-0.9.0 → margin-0.9.2}/margin/causal.py +0 -0
  23. {margin-0.9.0 → margin-0.9.2}/margin/composite.py +0 -0
  24. {margin-0.9.0 → margin-0.9.2}/margin/confidence.py +0 -0
  25. {margin-0.9.0 → margin-0.9.2}/margin/config.py +0 -0
  26. {margin-0.9.0 → margin-0.9.2}/margin/correlate.py +0 -0
  27. {margin-0.9.0 → margin-0.9.2}/margin/diff.py +0 -0
  28. {margin-0.9.0 → margin-0.9.2}/margin/drift.py +0 -0
  29. {margin-0.9.0 → margin-0.9.2}/margin/events.py +0 -0
  30. {margin-0.9.0 → margin-0.9.2}/margin/forecast.py +0 -0
  31. {margin-0.9.0 → margin-0.9.2}/margin/intent.py +0 -0
  32. {margin-0.9.0 → margin-0.9.2}/margin/ledger.py +0 -0
  33. {margin-0.9.0 → margin-0.9.2}/margin/policy/__init__.py +0 -0
  34. {margin-0.9.0 → margin-0.9.2}/margin/policy/compose.py +0 -0
  35. {margin-0.9.0 → margin-0.9.2}/margin/policy/temporal.py +0 -0
  36. {margin-0.9.0 → margin-0.9.2}/margin/policy/tuning.py +0 -0
  37. {margin-0.9.0 → margin-0.9.2}/margin/predicates.py +0 -0
  38. {margin-0.9.0 → margin-0.9.2}/margin/provenance.py +0 -0
  39. {margin-0.9.0 → margin-0.9.2}/margin/transitions.py +0 -0
  40. {margin-0.9.0 → margin-0.9.2}/margin/uncertain.py +0 -0
  41. {margin-0.9.0 → margin-0.9.2}/margin/validity.py +0 -0
  42. {margin-0.9.0 → margin-0.9.2}/margin.egg-info/SOURCES.txt +0 -0
  43. {margin-0.9.0 → margin-0.9.2}/margin.egg-info/dependency_links.txt +0 -0
  44. {margin-0.9.0 → margin-0.9.2}/margin.egg-info/top_level.txt +0 -0
  45. {margin-0.9.0 → margin-0.9.2}/setup.cfg +0 -0
  46. {margin-0.9.0 → margin-0.9.2}/tests/test_algebra.py +0 -0
  47. {margin-0.9.0 → margin-0.9.2}/tests/test_anomaly.py +0 -0
  48. {margin-0.9.0 → margin-0.9.2}/tests/test_bridge.py +0 -0
  49. {margin-0.9.0 → margin-0.9.2}/tests/test_calibrate.py +0 -0
  50. {margin-0.9.0 → margin-0.9.2}/tests/test_causal.py +0 -0
  51. {margin-0.9.0 → margin-0.9.2}/tests/test_composite.py +0 -0
  52. {margin-0.9.0 → margin-0.9.2}/tests/test_confidence.py +0 -0
  53. {margin-0.9.0 → margin-0.9.2}/tests/test_config.py +0 -0
  54. {margin-0.9.0 → margin-0.9.2}/tests/test_contract.py +0 -0
  55. {margin-0.9.0 → margin-0.9.2}/tests/test_correlate.py +0 -0
  56. {margin-0.9.0 → margin-0.9.2}/tests/test_diff.py +0 -0
  57. {margin-0.9.0 → margin-0.9.2}/tests/test_drift.py +0 -0
  58. {margin-0.9.0 → margin-0.9.2}/tests/test_events.py +0 -0
  59. {margin-0.9.0 → margin-0.9.2}/tests/test_forecast.py +0 -0
  60. {margin-0.9.0 → margin-0.9.2}/tests/test_health.py +0 -0
  61. {margin-0.9.0 → margin-0.9.2}/tests/test_healthcare.py +0 -0
  62. {margin-0.9.0 → margin-0.9.2}/tests/test_intent.py +0 -0
  63. {margin-0.9.0 → margin-0.9.2}/tests/test_ledger.py +0 -0
  64. {margin-0.9.0 → margin-0.9.2}/tests/test_loop.py +0 -0
  65. {margin-0.9.0 → margin-0.9.2}/tests/test_neuro.py +0 -0
  66. {margin-0.9.0 → margin-0.9.2}/tests/test_observation.py +0 -0
  67. {margin-0.9.0 → margin-0.9.2}/tests/test_persist.py +0 -0
  68. {margin-0.9.0 → margin-0.9.2}/tests/test_policy.py +0 -0
  69. {margin-0.9.0 → margin-0.9.2}/tests/test_policy_compose.py +0 -0
  70. {margin-0.9.0 → margin-0.9.2}/tests/test_policy_temporal.py +0 -0
  71. {margin-0.9.0 → margin-0.9.2}/tests/test_policy_trace.py +0 -0
  72. {margin-0.9.0 → margin-0.9.2}/tests/test_policy_tuning.py +0 -0
  73. {margin-0.9.0 → margin-0.9.2}/tests/test_policy_validate.py +0 -0
  74. {margin-0.9.0 → margin-0.9.2}/tests/test_predicates.py +0 -0
  75. {margin-0.9.0 → margin-0.9.2}/tests/test_printer3d.py +0 -0
  76. {margin-0.9.0 → margin-0.9.2}/tests/test_provenance.py +0 -0
  77. {margin-0.9.0 → margin-0.9.2}/tests/test_reverse_bridge.py +0 -0
  78. {margin-0.9.0 → margin-0.9.2}/tests/test_ros2.py +0 -0
  79. {margin-0.9.0 → margin-0.9.2}/tests/test_staleness.py +0 -0
  80. {margin-0.9.0 → margin-0.9.2}/tests/test_streaming.py +0 -0
  81. {margin-0.9.0 → margin-0.9.2}/tests/test_transformer.py +0 -0
  82. {margin-0.9.0 → margin-0.9.2}/tests/test_transitions.py +0 -0
  83. {margin-0.9.0 → margin-0.9.2}/tests/test_uncertain.py +0 -0
  84. {margin-0.9.0 → margin-0.9.2}/tests/test_validity.py +0 -0
  85. {margin-0.9.0 → margin-0.9.2}/tests/test_windowing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: margin
3
- Version: 0.9.0
3
+ Version: 0.9.2
4
4
  Summary: Typed health classification, uncertainty algebra, and correction auditing for any system that measures things and needs to explain what happened.
5
5
  Author: Cope Labs LLC
6
6
  License: MIT
@@ -85,7 +85,7 @@ Then the correction loop:
85
85
  - **CLI** — `python -m margin status`, `monitor`, `replay` — no Python code required
86
86
  - **Ledger** — full audit trail of every correction, serializable, replayable
87
87
 
88
- All 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
 
@@ -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, needs_recalibration,
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
- Args:
64
- values: list of healthy-state measurements
65
- higher_is_better: polarity
66
- intact_fraction: fraction of baseline for intact threshold (default 0.70)
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
- For higher_is_better=False:
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 or shift your measurements to be positive.")
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 higher_is_better:
97
- intact = baseline * intact_fraction
98
- ablated = baseline * ablated_fraction
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
- # For lower-is-better, thresholds are above baseline (unhealthy direction).
101
- # Uses abs(baseline) so the math works for both positive and negative baselines.
102
- intact = baseline + abs(baseline) * (1 - intact_fraction)
103
- ablated = baseline + abs(baseline) * (1 - ablated_fraction)
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: {name: [healthy_measurements]}
133
- polarities: {name: higher_is_better} (defaults to True)
134
- intact_fraction: fraction of baseline for intact threshold
135
- ablated_fraction: fraction of baseline for ablated threshold
136
- active_min: minimum correction magnitude
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(vals, hib, intact_fraction, ablated_fraction, active_min)
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()` signals that a component's baseline
219
- is stale, or when you have a new labeled window of known-healthy data.
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: the existing Parser to replace
223
- new_healthy_data: {component: [new_healthy_measurements]}
224
- polarities: {component: higher_is_better} overrides (defaults
225
- to existing parser polarity for each component)
226
- components: allowlist of components to recalibrate; if None,
227
- all components in new_healthy_data are recalibrated
228
- intact_fraction: fraction of baseline for intact threshold
229
- ablated_fraction: fraction of baseline for ablated threshold
230
- active_min: minimum correction magnitude
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, rebuild Monitor from the new Parser:
240
- save_monitor(old_monitor, "checkpoint.json") # optional audit trail
241
- new_monitor = Monitor(new_parser, drift_window=..., anomaly_window=...,
242
- correlation_window=...)
243
- - Per-tracker windows should match the old Monitor's windows for continuity.
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
- result = calibrate(vals, hib, intact_fraction, ablated_fraction, active_min)
362
+ # Inherit active_min from existing thresholds unless caller overrides
363
+ component_active_min = existing_t.active_min if active_min == 0.05 else active_min
364
+ result = calibrate(
365
+ vals, hib, intact_fraction, ablated_fraction, component_active_min,
366
+ use_std=use_std,
367
+ intact_std_multiplier=intact_std_multiplier,
368
+ ablated_std_multiplier=ablated_std_multiplier,
369
+ )
260
370
  results[name] = result
261
371
  new_baselines[name] = result.baseline
262
372
  new_component_thresholds[name] = result.thresholds
@@ -292,6 +402,9 @@ def parser_from_calibration(
292
402
  intact_fraction: float = 0.70,
293
403
  ablated_fraction: float = 0.30,
294
404
  active_min: float = 0.05,
405
+ use_std: bool = False,
406
+ intact_std_multiplier: float = 1.5,
407
+ ablated_std_multiplier: float = 3.0,
295
408
  ) -> Parser:
296
409
  """
297
410
  One-shot: calibrate from historical data and return a ready-to-use Parser.
@@ -301,6 +414,9 @@ def parser_from_calibration(
301
414
  """
302
415
  baselines, thresh_dict = calibrate_many(
303
416
  component_values, polarities, intact_fraction, ablated_fraction, active_min,
417
+ use_std=use_std,
418
+ intact_std_multiplier=intact_std_multiplier,
419
+ ablated_std_multiplier=ablated_std_multiplier,
304
420
  )
305
421
 
306
422
  names = list(thresh_dict.keys())
@@ -14,6 +14,7 @@ Contracts express:
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
+ import warnings
17
18
  from dataclasses import dataclass, field
18
19
  from enum import Enum
19
20
  from typing import Optional
@@ -108,6 +109,14 @@ class ReachHealth(ContractTerm):
108
109
  within_steps: int
109
110
 
110
111
  def evaluate(self, ledger: Ledger, expr: Optional[Expression] = None) -> TermResult:
112
+ if ledger.max_records is not None and ledger.max_records < self.within_steps:
113
+ warnings.warn(
114
+ f"ReachHealth '{self.name}': ledger.max_records={ledger.max_records} < "
115
+ f"within_steps={self.within_steps} — records older than max_records are "
116
+ "dropped; if the target was reached before the cap, this term will show "
117
+ "PENDING rather than MET. Increase max_records or reduce within_steps.",
118
+ stacklevel=2,
119
+ )
111
120
  recent = ledger.for_component(self.component).last_n(self.within_steps)
112
121
  if len(recent) < 1:
113
122
  return TermResult(self.name, TermStatus.PENDING, "no records yet")
@@ -142,6 +151,13 @@ class SustainHealth(ContractTerm):
142
151
  or_better: bool = True
143
152
 
144
153
  def evaluate(self, ledger: Ledger, expr: Optional[Expression] = None) -> TermResult:
154
+ if ledger.max_records is not None and ledger.max_records < self.for_steps:
155
+ warnings.warn(
156
+ f"SustainHealth '{self.name}': ledger.max_records={ledger.max_records} < "
157
+ f"for_steps={self.for_steps} — this term can never be MET; "
158
+ "increase Ledger(max_records=...) or reduce SustainHealth(for_steps=...).",
159
+ stacklevel=2,
160
+ )
145
161
  recent = ledger.for_component(self.component).last_n(self.for_steps)
146
162
  if len(recent) < self.for_steps:
147
163
  return TermResult(self.name, TermStatus.PENDING,
@@ -46,6 +46,7 @@ class Thresholds:
46
46
  (throughput, signal), False means lower values are
47
47
  healthier (error rate, latency)
48
48
  active_min: minimum correction magnitude to count as "active"
49
+ labels: optional display labels keyed by Health.value strings
49
50
 
50
51
  For higher_is_better=True: intact >= ablated, value >= intact → INTACT
51
52
  For higher_is_better=False: intact <= ablated, value <= intact → INTACT
@@ -54,6 +55,13 @@ class Thresholds:
54
55
  ablated: float
55
56
  higher_is_better: bool = True
56
57
  active_min: float = 0.05
58
+ labels: Optional[dict[str, str]] = None
59
+
60
+ def label_for(self, health: 'Health') -> str:
61
+ """Return the display label for a health state, or the state name if no label set."""
62
+ if self.labels:
63
+ return self.labels.get(health.value, health.value)
64
+ return health.value
57
65
 
58
66
  def __post_init__(self):
59
67
  if self.higher_is_better and self.ablated > self.intact:
@@ -45,6 +45,7 @@ class StepResult:
45
45
  correction: Optional[Correction] = None
46
46
  escalation: Optional[Escalation] = None
47
47
  contract: Optional[ContractResult] = None
48
+ corrections: list = field(default_factory=list) # all fired corrections (multi_rule)
48
49
 
49
50
  @property
50
51
  def acted(self) -> bool:
@@ -74,6 +75,8 @@ class StepResult:
74
75
  d["decision"] = self.decision.to_dict()
75
76
  if self.correction:
76
77
  d["correction"] = self.correction.to_dict()
78
+ if self.corrections:
79
+ d["corrections"] = [c.to_dict() for c in self.corrections]
77
80
  if self.escalation:
78
81
  d["escalation"] = self.escalation.to_dict()
79
82
  if self.contract:
@@ -140,6 +143,11 @@ def step(
140
143
  elif isinstance(decision.result, Escalation):
141
144
  escalation = decision.result
142
145
 
146
+ # Collect all corrections for multi_rule mode
147
+ all_corrections = []
148
+ if policy.multi_rule:
149
+ all_corrections = [r for r in decision.results if isinstance(r, Correction)]
150
+
143
151
  # 3. Evaluate contract
144
152
  contract_result = None
145
153
  if contract:
@@ -152,6 +160,7 @@ def step(
152
160
  correction=correction,
153
161
  escalation=escalation,
154
162
  contract=contract_result,
163
+ corrections=all_corrections,
155
164
  )
156
165
 
157
166
 
@@ -205,13 +214,13 @@ def run(
205
214
  @dataclass
206
215
  class FullStepResult:
207
216
  """
208
- Complete output of one full evaluation across all 8 layers.
217
+ Complete output of one full_step() evaluation.
209
218
 
210
- step: policy/contract/causal evaluation
211
- drift: per-component drift classifications
212
- anomaly: per-component anomaly classifications
219
+ step: policy/contract/causal evaluation
220
+ drift: per-component drift classifications
221
+ anomaly: per-component anomaly classifications
213
222
  correlations: discovered component correlations
214
- intent: goal feasibility (if intent provided)
223
+ intent: goal feasibility (if intent provided)
215
224
  """
216
225
  step: StepResult
217
226
  drift: dict = field(default_factory=dict)
@@ -287,7 +296,7 @@ def full_step(
287
296
  intent: Intent goal (optional)
288
297
  now: current timestamp (optional)
289
298
 
290
- Returns FullStepResult with all 8 layers evaluated.
299
+ Returns FullStepResult with all stages evaluated.
291
300
  """
292
301
  from datetime import datetime
293
302
 
@@ -60,6 +60,7 @@ class Observation:
60
60
  higher_is_better: bool = True
61
61
  provenance: list[str] = field(default_factory=list)
62
62
  measured_at: Optional[datetime] = None
63
+ health_label: Optional[str] = None
63
64
 
64
65
  @property
65
66
  def sigma(self) -> float:
@@ -91,10 +92,11 @@ class Observation:
91
92
 
92
93
  def to_atom(self) -> str:
93
94
  """Compact string: NAME:HEALTH(±σ)"""
95
+ display = self.health_label or self.health.value
94
96
  if self.health == Health.OOD:
95
- return f"{self.name}:{self.health.value}"
97
+ return f"{self.name}:{display}"
96
98
  sign = "+" if self.sigma >= 0 else ""
97
- return f"{self.name}:{self.health.value}({sign}{self.sigma:.2f}σ)"
99
+ return f"{self.name}:{display}({sign}{self.sigma:.2f}σ)"
98
100
 
99
101
  def to_dict(self) -> dict:
100
102
  d = {
@@ -107,6 +109,8 @@ class Observation:
107
109
  "higher_is_better": self.higher_is_better,
108
110
  "provenance": self.provenance,
109
111
  }
112
+ if self.health_label is not None:
113
+ d["health_label"] = self.health_label
110
114
  if self.measured_at is not None:
111
115
  d["measured_at"] = self.measured_at.isoformat()
112
116
  return d
@@ -125,6 +129,7 @@ class Observation:
125
129
  higher_is_better=d.get("higher_is_better", True),
126
130
  provenance=d.get("provenance", []),
127
131
  measured_at=measured_at,
132
+ health_label=d.get("health_label"),
128
133
  )
129
134
 
130
135
 
@@ -311,6 +316,10 @@ class Parser:
311
316
  def _thresholds_for(self, name: str) -> Thresholds:
312
317
  return self.component_thresholds.get(name, self.thresholds)
313
318
 
319
+ def label_for(self, component: str, health: Health) -> str:
320
+ """Return the display label for a component's health state."""
321
+ return self._thresholds_for(component).label_for(health)
322
+
314
323
  def parse(
315
324
  self,
316
325
  values: dict[str, float],
@@ -343,6 +352,7 @@ class Parser:
343
352
  name=name, health=h, value=val, baseline=baseline,
344
353
  confidence=conf, higher_is_better=ct.higher_is_better,
345
354
  provenance=list(provenance),
355
+ health_label=ct.label_for(h) if ct.labels else None,
346
356
  ))
347
357
 
348
358
  corrections = []
@@ -22,19 +22,25 @@ from .streaming import Monitor, DriftTracker, AnomalyTracker, CorrelationTracker
22
22
 
23
23
  def save_monitor(monitor: Monitor, path: str) -> None:
24
24
  """Save Monitor state to a JSON file."""
25
+ # Read anomaly_min_reference from one of the trackers (all share the same value)
26
+ _any_tracker = next(iter(monitor._anomaly_trackers.values()), None)
27
+ _anomaly_min_ref = _any_tracker._min_reference if _any_tracker else 10
28
+
25
29
  state = {
26
30
  "step": monitor.step,
27
31
  "window": monitor.window,
28
32
  "drift_window": monitor.drift_window,
29
33
  "anomaly_window": monitor.anomaly_window,
30
34
  "correlation_window": monitor.correlation_window,
35
+ "anomaly_min_reference": _anomaly_min_ref,
36
+ "features": sorted(monitor.features),
31
37
  "drift": {},
32
38
  "anomaly": {},
33
39
  "correlation": {
34
40
  "components": monitor._correlation_tracker.components,
35
41
  "series": {c: list(d) for c, d in monitor._correlation_tracker._series.items()},
36
42
  "n_updates": monitor._correlation_tracker.n_updates,
37
- },
43
+ } if monitor._correlation_tracker is not None else None,
38
44
  }
39
45
 
40
46
  for name, dt in monitor._drift_trackers.items():
@@ -65,11 +71,16 @@ def load_monitor(path: str, parser: Parser, **kwargs) -> Monitor:
65
71
  drift_window = data.get("drift_window", kwargs.pop("drift_window", None))
66
72
  anomaly_window = data.get("anomaly_window", kwargs.pop("anomaly_window", None))
67
73
  correlation_window = data.get("correlation_window", kwargs.pop("correlation_window", None))
74
+ anomaly_min_reference = data.get("anomaly_min_reference", kwargs.pop("anomaly_min_reference", 10))
75
+ features_data = data.get("features", None)
76
+ features = set(features_data) if features_data is not None else None
68
77
  monitor = Monitor(
69
78
  parser, window=window,
70
79
  drift_window=drift_window,
71
80
  anomaly_window=anomaly_window,
72
81
  correlation_window=correlation_window,
82
+ anomaly_min_reference=anomaly_min_reference,
83
+ features=features,
73
84
  **kwargs,
74
85
  )
75
86
  monitor._step = data.get("step", 0)
@@ -94,12 +105,13 @@ def load_monitor(path: str, parser: Parser, **kwargs) -> Monitor:
94
105
  tracker._values.append(v)
95
106
 
96
107
  # Restore correlation series
97
- corr_data = data.get("correlation", {})
98
- for c in monitor._correlation_tracker.components:
99
- series = corr_data.get("series", {}).get(c, [])
100
- for v in series:
101
- monitor._correlation_tracker._series[c].append(v)
102
- monitor._correlation_tracker._n_updates = corr_data.get("n_updates", 0)
108
+ corr_data = data.get("correlation")
109
+ if corr_data is not None and monitor._correlation_tracker is not None:
110
+ for c in monitor._correlation_tracker.components:
111
+ series = corr_data.get("series", {}).get(c, [])
112
+ for v in series:
113
+ monitor._correlation_tracker._series[c].append(v)
114
+ monitor._correlation_tracker._n_updates = corr_data.get("n_updates", 0)
103
115
 
104
116
  return monitor
105
117