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.
- {margin-0.8.1/margin.egg-info → margin-0.9.0}/PKG-INFO +1 -1
- {margin-0.8.1 → margin-0.9.0}/margin/__init__.py +5 -2
- {margin-0.8.1 → margin-0.9.0}/margin/calibrate.py +135 -0
- {margin-0.8.1 → margin-0.9.0}/margin/causal.py +25 -1
- {margin-0.8.1 → margin-0.9.0}/margin/correlate.py +1 -0
- {margin-0.8.1 → margin-0.9.0}/margin/ledger.py +8 -0
- {margin-0.8.1 → margin-0.9.0}/margin/observation.py +15 -2
- {margin-0.8.1 → margin-0.9.0}/margin/persist.py +41 -8
- {margin-0.8.1 → margin-0.9.0}/margin/streaming.py +38 -6
- {margin-0.8.1 → margin-0.9.0/margin.egg-info}/PKG-INFO +1 -1
- {margin-0.8.1 → margin-0.9.0}/pyproject.toml +1 -1
- {margin-0.8.1 → margin-0.9.0}/LICENSE +0 -0
- {margin-0.8.1 → margin-0.9.0}/NOTICE +0 -0
- {margin-0.8.1 → margin-0.9.0}/README.md +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin/__main__.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin/algebra.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin/anomaly.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin/bridge.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin/composite.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin/confidence.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin/config.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin/contract.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin/diff.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin/drift.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin/events.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin/forecast.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin/health.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin/intent.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin/loop.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin/policy/__init__.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin/policy/compose.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin/policy/core.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin/policy/temporal.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin/policy/trace.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin/policy/tuning.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin/policy/validate.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin/predicates.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin/provenance.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin/transitions.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin/uncertain.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin/validity.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin.egg-info/SOURCES.txt +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin.egg-info/dependency_links.txt +0 -0
- {margin-0.8.1 → margin-0.9.0}/margin.egg-info/top_level.txt +0 -0
- {margin-0.8.1 → margin-0.9.0}/setup.cfg +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_algebra.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_anomaly.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_bridge.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_calibrate.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_causal.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_composite.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_confidence.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_config.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_contract.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_correlate.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_diff.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_drift.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_events.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_forecast.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_health.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_healthcare.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_intent.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_ledger.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_loop.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_neuro.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_observation.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_persist.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_policy.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_policy_compose.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_policy_temporal.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_policy_trace.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_policy_tuning.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_policy_validate.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_predicates.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_printer3d.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_provenance.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_reverse_bridge.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_ros2.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_staleness.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_streaming.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_transformer.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_transitions.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_uncertain.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_validity.py +0 -0
- {margin-0.8.1 → margin-0.9.0}/tests/test_windowing.py +0 -0
|
@@ -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
|
|
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
|
-
|
|
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":
|
|
165
|
-
"magnitude":
|
|
165
|
+
"alpha": self.alpha,
|
|
166
|
+
"magnitude": self.magnitude,
|
|
166
167
|
"triggered_by": self.triggered_by,
|
|
167
168
|
"provenance": self.provenance,
|
|
168
169
|
}
|
|
@@ -198,6 +199,18 @@ class Expression:
|
|
|
198
199
|
label: str = ""
|
|
199
200
|
step: Optional[int] = None
|
|
200
201
|
|
|
202
|
+
def __post_init__(self) -> None:
|
|
203
|
+
if self.observations:
|
|
204
|
+
weakest = min(o.confidence for o in self.observations)
|
|
205
|
+
if self.confidence > weakest:
|
|
206
|
+
warnings.warn(
|
|
207
|
+
f"Expression.confidence ({self.confidence.value}) exceeds the weakest "
|
|
208
|
+
f"observation confidence ({weakest.value}). This overclaims certainty "
|
|
209
|
+
"and will cause min_confidence policy gates to fire incorrectly. "
|
|
210
|
+
"Use Parser.parse() to construct Expressions with correct net confidence.",
|
|
211
|
+
stacklevel=2,
|
|
212
|
+
)
|
|
213
|
+
|
|
201
214
|
def health_of(self, name: str) -> Optional[Health]:
|
|
202
215
|
for o in self.observations:
|
|
203
216
|
if o.name == name:
|
|
@@ -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
|
-
|
|
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:
|
|
107
|
-
data:
|
|
108
|
-
timestamps:
|
|
109
|
-
window:
|
|
110
|
-
|
|
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(
|
|
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(
|
|
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
|
|
356
|
-
|
|
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=
|
|
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=
|
|
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
|
-
|
|
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})"
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "margin"
|
|
7
|
-
version = "0.
|
|
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
|
|
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
|