margin 0.9.0__tar.gz → 0.9.1__tar.gz

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

Potentially problematic release.


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

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