margin 0.8.1__tar.gz → 0.9.0__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.8.1/margin.egg-info → margin-0.9.0}/PKG-INFO +1 -1
  2. {margin-0.8.1 → margin-0.9.0}/margin/__init__.py +5 -2
  3. {margin-0.8.1 → margin-0.9.0}/margin/calibrate.py +135 -0
  4. {margin-0.8.1 → margin-0.9.0}/margin/causal.py +25 -1
  5. {margin-0.8.1 → margin-0.9.0}/margin/correlate.py +1 -0
  6. {margin-0.8.1 → margin-0.9.0}/margin/ledger.py +8 -0
  7. {margin-0.8.1 → margin-0.9.0}/margin/observation.py +15 -2
  8. {margin-0.8.1 → margin-0.9.0}/margin/persist.py +41 -8
  9. {margin-0.8.1 → margin-0.9.0}/margin/streaming.py +38 -6
  10. {margin-0.8.1 → margin-0.9.0/margin.egg-info}/PKG-INFO +1 -1
  11. {margin-0.8.1 → margin-0.9.0}/pyproject.toml +1 -1
  12. {margin-0.8.1 → margin-0.9.0}/LICENSE +0 -0
  13. {margin-0.8.1 → margin-0.9.0}/NOTICE +0 -0
  14. {margin-0.8.1 → margin-0.9.0}/README.md +0 -0
  15. {margin-0.8.1 → margin-0.9.0}/margin/__main__.py +0 -0
  16. {margin-0.8.1 → margin-0.9.0}/margin/algebra.py +0 -0
  17. {margin-0.8.1 → margin-0.9.0}/margin/anomaly.py +0 -0
  18. {margin-0.8.1 → margin-0.9.0}/margin/bridge.py +0 -0
  19. {margin-0.8.1 → margin-0.9.0}/margin/composite.py +0 -0
  20. {margin-0.8.1 → margin-0.9.0}/margin/confidence.py +0 -0
  21. {margin-0.8.1 → margin-0.9.0}/margin/config.py +0 -0
  22. {margin-0.8.1 → margin-0.9.0}/margin/contract.py +0 -0
  23. {margin-0.8.1 → margin-0.9.0}/margin/diff.py +0 -0
  24. {margin-0.8.1 → margin-0.9.0}/margin/drift.py +0 -0
  25. {margin-0.8.1 → margin-0.9.0}/margin/events.py +0 -0
  26. {margin-0.8.1 → margin-0.9.0}/margin/forecast.py +0 -0
  27. {margin-0.8.1 → margin-0.9.0}/margin/health.py +0 -0
  28. {margin-0.8.1 → margin-0.9.0}/margin/intent.py +0 -0
  29. {margin-0.8.1 → margin-0.9.0}/margin/loop.py +0 -0
  30. {margin-0.8.1 → margin-0.9.0}/margin/policy/__init__.py +0 -0
  31. {margin-0.8.1 → margin-0.9.0}/margin/policy/compose.py +0 -0
  32. {margin-0.8.1 → margin-0.9.0}/margin/policy/core.py +0 -0
  33. {margin-0.8.1 → margin-0.9.0}/margin/policy/temporal.py +0 -0
  34. {margin-0.8.1 → margin-0.9.0}/margin/policy/trace.py +0 -0
  35. {margin-0.8.1 → margin-0.9.0}/margin/policy/tuning.py +0 -0
  36. {margin-0.8.1 → margin-0.9.0}/margin/policy/validate.py +0 -0
  37. {margin-0.8.1 → margin-0.9.0}/margin/predicates.py +0 -0
  38. {margin-0.8.1 → margin-0.9.0}/margin/provenance.py +0 -0
  39. {margin-0.8.1 → margin-0.9.0}/margin/transitions.py +0 -0
  40. {margin-0.8.1 → margin-0.9.0}/margin/uncertain.py +0 -0
  41. {margin-0.8.1 → margin-0.9.0}/margin/validity.py +0 -0
  42. {margin-0.8.1 → margin-0.9.0}/margin.egg-info/SOURCES.txt +0 -0
  43. {margin-0.8.1 → margin-0.9.0}/margin.egg-info/dependency_links.txt +0 -0
  44. {margin-0.8.1 → margin-0.9.0}/margin.egg-info/top_level.txt +0 -0
  45. {margin-0.8.1 → margin-0.9.0}/setup.cfg +0 -0
  46. {margin-0.8.1 → margin-0.9.0}/tests/test_algebra.py +0 -0
  47. {margin-0.8.1 → margin-0.9.0}/tests/test_anomaly.py +0 -0
  48. {margin-0.8.1 → margin-0.9.0}/tests/test_bridge.py +0 -0
  49. {margin-0.8.1 → margin-0.9.0}/tests/test_calibrate.py +0 -0
  50. {margin-0.8.1 → margin-0.9.0}/tests/test_causal.py +0 -0
  51. {margin-0.8.1 → margin-0.9.0}/tests/test_composite.py +0 -0
  52. {margin-0.8.1 → margin-0.9.0}/tests/test_confidence.py +0 -0
  53. {margin-0.8.1 → margin-0.9.0}/tests/test_config.py +0 -0
  54. {margin-0.8.1 → margin-0.9.0}/tests/test_contract.py +0 -0
  55. {margin-0.8.1 → margin-0.9.0}/tests/test_correlate.py +0 -0
  56. {margin-0.8.1 → margin-0.9.0}/tests/test_diff.py +0 -0
  57. {margin-0.8.1 → margin-0.9.0}/tests/test_drift.py +0 -0
  58. {margin-0.8.1 → margin-0.9.0}/tests/test_events.py +0 -0
  59. {margin-0.8.1 → margin-0.9.0}/tests/test_forecast.py +0 -0
  60. {margin-0.8.1 → margin-0.9.0}/tests/test_health.py +0 -0
  61. {margin-0.8.1 → margin-0.9.0}/tests/test_healthcare.py +0 -0
  62. {margin-0.8.1 → margin-0.9.0}/tests/test_intent.py +0 -0
  63. {margin-0.8.1 → margin-0.9.0}/tests/test_ledger.py +0 -0
  64. {margin-0.8.1 → margin-0.9.0}/tests/test_loop.py +0 -0
  65. {margin-0.8.1 → margin-0.9.0}/tests/test_neuro.py +0 -0
  66. {margin-0.8.1 → margin-0.9.0}/tests/test_observation.py +0 -0
  67. {margin-0.8.1 → margin-0.9.0}/tests/test_persist.py +0 -0
  68. {margin-0.8.1 → margin-0.9.0}/tests/test_policy.py +0 -0
  69. {margin-0.8.1 → margin-0.9.0}/tests/test_policy_compose.py +0 -0
  70. {margin-0.8.1 → margin-0.9.0}/tests/test_policy_temporal.py +0 -0
  71. {margin-0.8.1 → margin-0.9.0}/tests/test_policy_trace.py +0 -0
  72. {margin-0.8.1 → margin-0.9.0}/tests/test_policy_tuning.py +0 -0
  73. {margin-0.8.1 → margin-0.9.0}/tests/test_policy_validate.py +0 -0
  74. {margin-0.8.1 → margin-0.9.0}/tests/test_predicates.py +0 -0
  75. {margin-0.8.1 → margin-0.9.0}/tests/test_printer3d.py +0 -0
  76. {margin-0.8.1 → margin-0.9.0}/tests/test_provenance.py +0 -0
  77. {margin-0.8.1 → margin-0.9.0}/tests/test_reverse_bridge.py +0 -0
  78. {margin-0.8.1 → margin-0.9.0}/tests/test_ros2.py +0 -0
  79. {margin-0.8.1 → margin-0.9.0}/tests/test_staleness.py +0 -0
  80. {margin-0.8.1 → margin-0.9.0}/tests/test_streaming.py +0 -0
  81. {margin-0.8.1 → margin-0.9.0}/tests/test_transformer.py +0 -0
  82. {margin-0.8.1 → margin-0.9.0}/tests/test_transitions.py +0 -0
  83. {margin-0.8.1 → margin-0.9.0}/tests/test_uncertain.py +0 -0
  84. {margin-0.8.1 → margin-0.9.0}/tests/test_validity.py +0 -0
  85. {margin-0.8.1 → margin-0.9.0}/tests/test_windowing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: margin
3
- Version: 0.8.1
3
+ Version: 0.9.0
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
@@ -29,7 +29,10 @@ 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, needs_recalibration,
35
+ )
33
36
  from .composite import CompositeObservation, AggregateStrategy
34
37
  from .diff import ComponentChange, Diff, diff
35
38
  from .events import EventBus
@@ -95,7 +98,7 @@ from .causal import (
95
98
  )
96
99
 
97
100
  # Streaming
98
- from .streaming import DriftTracker, AnomalyTracker, CorrelationTracker, Monitor
101
+ from .streaming import DriftTracker, AnomalyTracker, CorrelationTracker, Monitor, WindowConfig
99
102
 
100
103
  # Config
101
104
  from .config import from_config, load_config
@@ -3,6 +3,9 @@ Threshold derivation from historical data.
3
3
 
4
4
  Takes a set of "known healthy" measurements and derives baselines and
5
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.
6
9
  """
7
10
 
8
11
  from __future__ import annotations
@@ -151,6 +154,138 @@ def calibrate_many(
151
154
  return baselines, thresholds
152
155
 
153
156
 
157
+ def needs_recalibration(
158
+ calibration_samples: list[float],
159
+ recent_samples: list[float],
160
+ mean_shift_threshold: float = 0.2,
161
+ std_ratio_high: float = 2.0,
162
+ std_ratio_low: float = 0.5,
163
+ min_recent: int = 5,
164
+ ) -> bool:
165
+ """
166
+ Return True if recent values suggest the baseline has drifted.
167
+
168
+ Uses distribution comparison between calibration-time healthy data and
169
+ a recent window to detect two signals:
170
+
171
+ Signal A — mean shift: the operating centre has moved by more than
172
+ `mean_shift_threshold` (relative fraction, default 20%).
173
+
174
+ Signal B — spread change: variance has doubled (`std_ratio > 2.0`)
175
+ or halved (`std_ratio < 0.5`) relative to the calibration period.
176
+
177
+ Returns True when either signal fires and there are enough recent samples.
178
+ A third signal (AnomalyState.ANOMALOUS at healthy sigma) must be checked
179
+ externally via AnomalyTracker — see `needs_recalibration` docs.
180
+
181
+ Args:
182
+ calibration_samples: original healthy measurements used to build the Parser
183
+ recent_samples: recent window of observed values
184
+ mean_shift_threshold: relative mean shift that triggers recalibration (default 0.2)
185
+ std_ratio_high: std ratio above which spread is considered changed (default 2.0)
186
+ std_ratio_low: std ratio below which spread is considered changed (default 0.5)
187
+ min_recent: minimum recent samples required to make a judgment (default 5)
188
+ """
189
+ if len(recent_samples) < min_recent or len(calibration_samples) < 2:
190
+ return False
191
+
192
+ from .anomaly import check_distribution
193
+ ds = check_distribution(recent_samples, calibration_samples)
194
+
195
+ mean_shifted = abs(ds.mean_shift) > mean_shift_threshold
196
+ spread_changed = ds.std_ratio > std_ratio_high or ds.std_ratio < std_ratio_low
197
+
198
+ return mean_shifted or spread_changed
199
+
200
+
201
+ def recalibrate_parser(
202
+ parser: Parser,
203
+ new_healthy_data: dict[str, list[float]],
204
+ polarities: Optional[dict[str, bool]] = None,
205
+ components: Optional[list[str]] = None,
206
+ intact_fraction: float = 0.70,
207
+ ablated_fraction: float = 0.30,
208
+ active_min: float = 0.05,
209
+ ) -> tuple[Parser, dict[str, CalibrationResult]]:
210
+ """
211
+ Return a new Parser with updated baselines for components whose healthy
212
+ operating range has drifted.
213
+
214
+ Does NOT mutate the input parser — returns a replacement. Components not
215
+ in `new_healthy_data` (or not in `components`) keep their existing
216
+ baselines and thresholds unchanged.
217
+
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.
220
+
221
+ 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
231
+
232
+ Returns:
233
+ (new_parser, results) where results is {component: CalibrationResult}
234
+ for each recalibrated component.
235
+
236
+ Transition notes:
237
+ - Historical Ledger Observations remain anchored to the baseline that was
238
+ 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.
244
+ """
245
+ polarities = polarities or {}
246
+ to_recalibrate = set(components) if components else set(new_healthy_data.keys())
247
+
248
+ results: dict[str, CalibrationResult] = {}
249
+ new_baselines: dict[str, float] = {}
250
+ new_component_thresholds: dict[str, Thresholds] = {}
251
+
252
+ for name in to_recalibrate:
253
+ if name not in new_healthy_data:
254
+ continue
255
+ vals = new_healthy_data[name]
256
+ # Resolve polarity: explicit override → existing parser polarity → True
257
+ existing_t = parser.component_thresholds.get(name) or parser.thresholds
258
+ hib = polarities.get(name, existing_t.higher_is_better)
259
+ result = calibrate(vals, hib, intact_fraction, ablated_fraction, active_min)
260
+ results[name] = result
261
+ new_baselines[name] = result.baseline
262
+ new_component_thresholds[name] = result.thresholds
263
+
264
+ # Merge: new overrides old; unrecalibrated components are preserved verbatim
265
+ merged_baselines = {**parser.baselines, **new_baselines}
266
+ merged_component_thresholds = {**parser.component_thresholds, **new_component_thresholds}
267
+
268
+ # Default thresholds: recalibrate if the first component was recalibrated,
269
+ # otherwise keep the existing default.
270
+ first_name = list(parser.baselines.keys())[0] if parser.baselines else None
271
+ if first_name and first_name in new_component_thresholds:
272
+ default_thresholds = new_component_thresholds.pop(first_name)
273
+ merged_component_thresholds.pop(first_name, None)
274
+ else:
275
+ default_thresholds = parser.thresholds
276
+ # Remove first component from component_thresholds if it was there
277
+ if first_name:
278
+ merged_component_thresholds.pop(first_name, None)
279
+
280
+ new_parser = Parser(
281
+ baselines=merged_baselines,
282
+ thresholds=default_thresholds,
283
+ component_thresholds=merged_component_thresholds,
284
+ )
285
+
286
+ return new_parser, results
287
+
288
+
154
289
  def parser_from_calibration(
155
290
  component_values: dict[str, list[float]],
156
291
  polarities: Optional[dict[str, bool]] = None,
@@ -45,6 +45,10 @@ class CausalLink:
45
45
  strength: 0.0-1.0, how strong the relationship is (1.0 = deterministic)
46
46
  condition: optional — this link only applies when source is in this health state
47
47
  evidence: free text — why we believe this link exists
48
+ origin: "asserted" (manually declared) or "discovered" (auto_causal_graph).
49
+ Asserted links are authoritative. Discovered links are correlational
50
+ heuristics — lag-based causal direction is plausible but not proven.
51
+ Never treat discovered links as ground truth in high-stakes domains.
48
52
  """
49
53
  source: str
50
54
  target: str
@@ -52,6 +56,15 @@ class CausalLink:
52
56
  strength: float = 1.0
53
57
  condition: Optional[Health] = None
54
58
  evidence: str = ""
59
+ origin: str = "asserted" # "asserted" | "discovered"
60
+
61
+ @property
62
+ def is_asserted(self) -> bool:
63
+ return self.origin == "asserted"
64
+
65
+ @property
66
+ def is_discovered(self) -> bool:
67
+ return self.origin == "discovered"
55
68
 
56
69
  def to_dict(self) -> dict:
57
70
  d = {
@@ -59,6 +72,7 @@ class CausalLink:
59
72
  "cause_type": self.cause_type.value,
60
73
  "strength": round(self.strength, 4),
61
74
  "evidence": self.evidence,
75
+ "origin": self.origin,
62
76
  }
63
77
  if self.condition:
64
78
  d["condition"] = self.condition.value
@@ -72,11 +86,13 @@ class CausalLink:
72
86
  strength=d.get("strength", 1.0),
73
87
  condition=Health(d["condition"]) if "condition" in d else None,
74
88
  evidence=d.get("evidence", ""),
89
+ origin=d.get("origin", "asserted"),
75
90
  )
76
91
 
77
92
  def __repr__(self) -> str:
78
93
  cond = f" when {self.condition.value}" if self.condition else ""
79
- return f"{self.source} --{self.cause_type.value}--> {self.target}{cond}"
94
+ tag = "[discovered]" if self.is_discovered else ""
95
+ return f"{self.source} --{self.cause_type.value}--> {self.target}{cond}{tag}"
80
96
 
81
97
 
82
98
  # -----------------------------------------------------------------------
@@ -124,6 +140,14 @@ class CausalGraph:
124
140
  """All links where `component` is the source (what it affects)."""
125
141
  return [l for l in self.links if l.source == component]
126
142
 
143
+ def asserted_links(self) -> list[CausalLink]:
144
+ """Links that were manually declared (authoritative)."""
145
+ return [l for l in self.links if l.is_asserted]
146
+
147
+ def discovered_links(self) -> list[CausalLink]:
148
+ """Links auto-discovered via correlation (heuristic, not proven)."""
149
+ return [l for l in self.links if l.is_discovered]
150
+
127
151
  def upstream(self, component: str, depth: int = 10) -> list[str]:
128
152
  """All components upstream of `component` (transitive causes)."""
129
153
  visited = set()
@@ -85,6 +85,7 @@ class Correlation:
85
85
  cause_type=cause_type,
86
86
  strength=self.strength,
87
87
  evidence=f"auto-correlated: r={self.coefficient:.3f}, lag={self.lag}, n={self.n_samples}",
88
+ origin="discovered", # lag-based causal direction is a heuristic, not proven
88
89
  )
89
90
 
90
91
  def to_dict(self) -> dict:
@@ -109,12 +109,20 @@ class Ledger:
109
109
  """
110
110
  Accumulates Records across a session.
111
111
  Provides aggregate stats and renders the intervention history.
112
+
113
+ max_records: optional bound on records kept. When set, old records
114
+ are dropped (oldest first) on each append. Use this for
115
+ long-running production loops to prevent unbounded growth.
116
+ None (default) = unbounded, suitable for batch/replay use.
112
117
  """
113
118
  label: str = ""
114
119
  records: list[Record] = field(default_factory=list)
120
+ max_records: Optional[int] = None
115
121
 
116
122
  def append(self, record: Record) -> None:
117
123
  self.records.append(record)
124
+ if self.max_records is not None and len(self.records) > self.max_records:
125
+ self.records.pop(0)
118
126
 
119
127
  def __len__(self) -> int:
120
128
  return len(self.records)
@@ -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:
@@ -25,6 +25,9 @@ def save_monitor(monitor: Monitor, path: str) -> None:
25
25
  state = {
26
26
  "step": monitor.step,
27
27
  "window": monitor.window,
28
+ "drift_window": monitor.drift_window,
29
+ "anomaly_window": monitor.anomaly_window,
30
+ "correlation_window": monitor.correlation_window,
28
31
  "drift": {},
29
32
  "anomaly": {},
30
33
  "correlation": {
@@ -59,7 +62,16 @@ def load_monitor(path: str, parser: Parser, **kwargs) -> Monitor:
59
62
  data = json.loads(Path(path).read_text())
60
63
 
61
64
  window = data.get("window", kwargs.pop("window", 100))
62
- monitor = Monitor(parser, window=window, **kwargs)
65
+ drift_window = data.get("drift_window", kwargs.pop("drift_window", None))
66
+ anomaly_window = data.get("anomaly_window", kwargs.pop("anomaly_window", None))
67
+ correlation_window = data.get("correlation_window", kwargs.pop("correlation_window", None))
68
+ monitor = Monitor(
69
+ parser, window=window,
70
+ drift_window=drift_window,
71
+ anomaly_window=anomaly_window,
72
+ correlation_window=correlation_window,
73
+ **kwargs,
74
+ )
63
75
  monitor._step = data.get("step", 0)
64
76
 
65
77
  # Restore drift tracker observations
@@ -97,24 +109,36 @@ def replay(
97
109
  data: list[dict[str, float]],
98
110
  timestamps: Optional[list[datetime]] = None,
99
111
  window: int = 100,
112
+ drift_window: Optional[int] = None,
113
+ anomaly_window: Optional[int] = None,
114
+ correlation_window: Optional[int] = None,
100
115
  **kwargs,
101
116
  ) -> tuple[Monitor, list[dict]]:
102
117
  """
103
118
  Replay historical data through a Monitor.
104
119
 
105
120
  Args:
106
- parser: Parser for health classification
107
- data: list of {component: value} dicts, one per step
108
- timestamps: optional timestamps per step (defaults to 1-second intervals)
109
- window: tracker window size
110
- **kwargs: passed to Monitor
121
+ parser: Parser for health classification
122
+ data: list of {component: value} dicts, one per step
123
+ timestamps: optional timestamps per step (defaults to 1-second intervals)
124
+ window: base tracker window size (all trackers inherit if per-tracker not set)
125
+ drift_window: override window for DriftTracker
126
+ anomaly_window: override window for AnomalyTracker
127
+ correlation_window: override window for CorrelationTracker
128
+ **kwargs: passed to Monitor
111
129
 
112
130
  Returns (monitor, snapshots) where snapshots is a list of status dicts,
113
131
  one per step.
114
132
  """
115
133
  from datetime import timedelta
116
134
 
117
- monitor = Monitor(parser, window=window, **kwargs)
135
+ monitor = Monitor(
136
+ parser, window=window,
137
+ drift_window=drift_window,
138
+ anomaly_window=anomaly_window,
139
+ correlation_window=correlation_window,
140
+ **kwargs,
141
+ )
118
142
  snapshots = []
119
143
 
120
144
  t0 = timestamps[0] if timestamps else datetime(2000, 1, 1)
@@ -132,6 +156,9 @@ def replay_csv(
132
156
  path: str,
133
157
  timestamp_column: Optional[str] = None,
134
158
  window: int = 100,
159
+ drift_window: Optional[int] = None,
160
+ anomaly_window: Optional[int] = None,
161
+ correlation_window: Optional[int] = None,
135
162
  **kwargs,
136
163
  ) -> tuple[Monitor, list[dict]]:
137
164
  """
@@ -166,4 +193,10 @@ def replay_csv(
166
193
  if values:
167
194
  rows.append(values)
168
195
 
169
- return replay(parser, rows, timestamps or None, window, **kwargs)
196
+ return replay(
197
+ parser, rows, timestamps or None, window,
198
+ drift_window=drift_window,
199
+ anomaly_window=anomaly_window,
200
+ correlation_window=correlation_window,
201
+ **kwargs,
202
+ )
@@ -16,6 +16,23 @@ from dataclasses import dataclass, field
16
16
  from datetime import datetime
17
17
  from typing import Optional
18
18
 
19
+
20
+ @dataclass
21
+ class WindowConfig:
22
+ """
23
+ Per-concern window sizes for Monitor.
24
+
25
+ drift: window for DriftTracker (how many steps to classify trajectory)
26
+ anomaly: window for AnomalyTracker (reference window for outlier detection)
27
+ correlation: window for CorrelationTracker (how many steps for pairwise correlation)
28
+
29
+ All default to None, meaning "inherit the Monitor's base `window` parameter."
30
+ Rule of thumb: anomaly ≈ 4× drift, correlation ≈ 10× drift.
31
+ """
32
+ drift: Optional[int] = None
33
+ anomaly: Optional[int] = None
34
+ correlation: Optional[int] = None
35
+
19
36
  from .confidence import Confidence
20
37
  from .health import Health, Thresholds, classify
21
38
  from .observation import Observation, Expression
@@ -340,11 +357,22 @@ class Monitor:
340
357
  self,
341
358
  parser,
342
359
  window: int = 100,
360
+ drift_window: Optional[int] = None,
361
+ anomaly_window: Optional[int] = None,
362
+ correlation_window: Optional[int] = None,
363
+ window_config: Optional[WindowConfig] = None,
343
364
  min_correlation: float = 0.7,
344
365
  anomaly_min_reference: int = 10,
345
366
  ):
346
367
  self.parser = parser
347
368
  self.window = window
369
+
370
+ # Resolve per-tracker windows: window_config > named params > base window
371
+ cfg = window_config or WindowConfig()
372
+ self.drift_window = cfg.drift or drift_window or window
373
+ self.anomaly_window = cfg.anomaly or anomaly_window or window
374
+ self.correlation_window = cfg.correlation or correlation_window or window
375
+
348
376
  self._expression: Optional[Expression] = None
349
377
  self._step = 0
350
378
 
@@ -352,17 +380,16 @@ class Monitor:
352
380
  self._drift_trackers: dict[str, DriftTracker] = {}
353
381
  self._anomaly_trackers: dict[str, AnomalyTracker] = {}
354
382
 
355
- for name, baseline in parser.baselines.items():
356
- thresholds = parser._thresholds_for(name)
357
- self._drift_trackers[name] = DriftTracker(name, window=window)
383
+ for name in parser.baselines:
384
+ self._drift_trackers[name] = DriftTracker(name, window=self.drift_window)
358
385
  self._anomaly_trackers[name] = AnomalyTracker(
359
- name, window=window, min_reference=anomaly_min_reference,
386
+ name, window=self.anomaly_window, min_reference=anomaly_min_reference,
360
387
  )
361
388
 
362
389
  # Correlation tracker across all components
363
390
  self._correlation_tracker = CorrelationTracker(
364
391
  list(parser.baselines.keys()),
365
- window=window,
392
+ window=self.correlation_window,
366
393
  min_correlation=min_correlation,
367
394
  )
368
395
 
@@ -455,4 +482,9 @@ class Monitor:
455
482
  self._correlation_tracker.reset()
456
483
 
457
484
  def __repr__(self) -> str:
458
- return f"Monitor(step={self._step}, {len(self._drift_trackers)} components)"
485
+ windows_uniform = (self.drift_window == self.anomaly_window == self.correlation_window)
486
+ if windows_uniform:
487
+ w = f"window={self.drift_window}"
488
+ else:
489
+ w = f"drift={self.drift_window}, anomaly={self.anomaly_window}, corr={self.correlation_window}"
490
+ return f"Monitor(step={self._step}, {len(self._drift_trackers)} components, {w})"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: margin
3
- Version: 0.8.1
3
+ Version: 0.9.0
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "margin"
7
- version = "0.8.1"
7
+ version = "0.9.0"
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