margin 0.9.9__tar.gz → 0.9.10__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.9.9/margin.egg-info → margin-0.9.10}/PKG-INFO +1 -1
- {margin-0.9.9 → margin-0.9.10}/margin/events.py +16 -2
- {margin-0.9.9 → margin-0.9.10}/margin/persist.py +8 -0
- {margin-0.9.9 → margin-0.9.10}/margin/streaming.py +19 -1
- {margin-0.9.9 → margin-0.9.10/margin.egg-info}/PKG-INFO +1 -1
- {margin-0.9.9 → margin-0.9.10}/pyproject.toml +1 -1
- {margin-0.9.9 → margin-0.9.10}/tests/test_persist.py +32 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_streaming.py +71 -0
- {margin-0.9.9 → margin-0.9.10}/LICENSE +0 -0
- {margin-0.9.9 → margin-0.9.10}/NOTICE +0 -0
- {margin-0.9.9 → margin-0.9.10}/README.md +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/__init__.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/__main__.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/algebra.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/anomaly.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/bridge.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/calibrate.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/causal.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/composite.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/confidence.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/config.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/contract.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/correlate.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/diff.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/drift.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/forecast.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/health.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/intent.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/ledger.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/loop.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/observation.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/policy/__init__.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/policy/compose.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/policy/core.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/policy/temporal.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/policy/trace.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/policy/tuning.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/policy/validate.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/predicates.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/provenance.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/transitions.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/uncertain.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin/validity.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin.egg-info/SOURCES.txt +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin.egg-info/dependency_links.txt +0 -0
- {margin-0.9.9 → margin-0.9.10}/margin.egg-info/top_level.txt +0 -0
- {margin-0.9.9 → margin-0.9.10}/setup.cfg +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_algebra.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_anomaly.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_bridge.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_calibrate.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_causal.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_composite.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_confidence.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_config.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_contract.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_correlate.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_diff.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_drift.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_events.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_forecast.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_health.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_healthcare.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_intent.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_ledger.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_loop.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_neuro.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_observation.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_policy.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_policy_compose.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_policy_temporal.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_policy_trace.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_policy_tuning.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_policy_validate.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_predicates.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_printer3d.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_provenance.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_reverse_bridge.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_ros2.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_staleness.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_transformer.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_transitions.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_uncertain.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_validity.py +0 -0
- {margin-0.9.9 → margin-0.9.10}/tests/test_windowing.py +0 -0
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Event bus for validity invalidation.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
Standalone utility — not wired into Monitor or the evaluation loop.
|
|
5
|
+
Use directly in event-driven systems where values should become stale
|
|
6
|
+
when something changes (config reload, model update, deployment).
|
|
7
|
+
|
|
8
|
+
bus = EventBus()
|
|
9
|
+
bus.fire("config_reload")
|
|
10
|
+
|
|
11
|
+
v = Validity.until_event("config_reload")
|
|
12
|
+
bus.is_valid(v) # False — invalidated
|
|
13
|
+
|
|
14
|
+
bus.on("deploy", lambda evt, ts: print(f"deployed at {ts}"))
|
|
15
|
+
|
|
16
|
+
When an event fires, all Validity descriptors that reference it via
|
|
17
|
+
Validity.until_event() become stale. bus.is_valid() / bus.is_value_valid()
|
|
18
|
+
check this. EventBus does NOT integrate with Monitor health transitions;
|
|
19
|
+
for that use case, attach a listener to Monitor.update() manually.
|
|
6
20
|
"""
|
|
7
21
|
|
|
8
22
|
from __future__ import annotations
|
|
@@ -17,6 +17,7 @@ from pathlib import Path
|
|
|
17
17
|
from typing import Optional
|
|
18
18
|
|
|
19
19
|
from .observation import Observation, Expression, Parser
|
|
20
|
+
from .provenance import ProvenanceGraph
|
|
20
21
|
from .streaming import Monitor, DriftTracker, AnomalyTracker, CorrelationTracker
|
|
21
22
|
|
|
22
23
|
|
|
@@ -41,6 +42,8 @@ def save_monitor(monitor: Monitor, path: str) -> None:
|
|
|
41
42
|
"series": {c: list(d) for c, d in monitor._correlation_tracker._series.items()},
|
|
42
43
|
"n_updates": monitor._correlation_tracker.n_updates,
|
|
43
44
|
} if monitor._correlation_tracker is not None else None,
|
|
45
|
+
"provenance_graph": monitor.provenance_graph.to_dict()
|
|
46
|
+
if monitor.provenance_graph is not None else None,
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
for name, dt in monitor._drift_trackers.items():
|
|
@@ -85,6 +88,11 @@ def load_monitor(path: str, parser: Parser, **kwargs) -> Monitor:
|
|
|
85
88
|
)
|
|
86
89
|
monitor._step = data.get("step", 0)
|
|
87
90
|
|
|
91
|
+
# Restore provenance graph if present
|
|
92
|
+
pg_data = data.get("provenance_graph")
|
|
93
|
+
if pg_data is not None:
|
|
94
|
+
monitor.provenance_graph = ProvenanceGraph.from_dict(pg_data)
|
|
95
|
+
|
|
88
96
|
# Restore drift tracker observations
|
|
89
97
|
for name, dt_data in data.get("drift", {}).items():
|
|
90
98
|
if name in monitor._drift_trackers:
|
|
@@ -63,6 +63,7 @@ class WindowConfig:
|
|
|
63
63
|
from .confidence import Confidence
|
|
64
64
|
from .health import Health, Thresholds, classify
|
|
65
65
|
from .observation import Observation, Expression
|
|
66
|
+
from .provenance import ProvenanceGraph
|
|
66
67
|
from .drift import (
|
|
67
68
|
DriftState, DriftDirection, DriftClassification,
|
|
68
69
|
classify_drift,
|
|
@@ -391,6 +392,7 @@ class Monitor:
|
|
|
391
392
|
min_correlation: float = 0.7,
|
|
392
393
|
anomaly_min_reference: int = 10,
|
|
393
394
|
features: Optional[set] = None,
|
|
395
|
+
provenance_graph: Optional[ProvenanceGraph] = None,
|
|
394
396
|
):
|
|
395
397
|
"""
|
|
396
398
|
Args:
|
|
@@ -406,6 +408,11 @@ class Monitor:
|
|
|
406
408
|
``"anomaly"``, ``"correlation"}``; ``None`` (default)
|
|
407
409
|
enables all three; ``set()`` disables all.
|
|
408
410
|
``"health"`` is always implied and need not be listed.
|
|
411
|
+
provenance_graph: optional ProvenanceGraph; when provided, each
|
|
412
|
+
``update()`` call records a root node
|
|
413
|
+
(``"monitor:step:N"``) and threads its ID into
|
|
414
|
+
every observation's ``provenance`` list, enabling
|
|
415
|
+
full lineage queries via ``monitor.provenance_graph``
|
|
409
416
|
"""
|
|
410
417
|
self.parser = parser
|
|
411
418
|
self.window = window
|
|
@@ -428,6 +435,7 @@ class Monitor:
|
|
|
428
435
|
stacklevel=2,
|
|
429
436
|
)
|
|
430
437
|
|
|
438
|
+
self.provenance_graph: Optional[ProvenanceGraph] = provenance_graph
|
|
431
439
|
self._expression: Optional[Expression] = None
|
|
432
440
|
self._step = 0
|
|
433
441
|
|
|
@@ -469,6 +477,13 @@ class Monitor:
|
|
|
469
477
|
"""
|
|
470
478
|
now = now or datetime.now()
|
|
471
479
|
|
|
480
|
+
# Record a step root in the provenance graph if one is attached.
|
|
481
|
+
# The step ID is prepended to any caller-supplied provenance tags so
|
|
482
|
+
# every observation carries both the step origin and the user context.
|
|
483
|
+
if self.provenance_graph is not None:
|
|
484
|
+
step_id = self.provenance_graph.create_root(f"monitor:step:{self._step}")
|
|
485
|
+
provenance = [step_id] + list(provenance or [])
|
|
486
|
+
|
|
472
487
|
# Health classification via Parser
|
|
473
488
|
self._expression = self.parser.parse(
|
|
474
489
|
values, label=label, step=self._step,
|
|
@@ -546,6 +561,8 @@ class Monitor:
|
|
|
546
561
|
d["anomaly"][name] = tracker.classification.to_dict()
|
|
547
562
|
if self._correlation_tracker is not None and self._correlation_tracker.matrix:
|
|
548
563
|
d["correlations"] = self._correlation_tracker.matrix.to_dict()
|
|
564
|
+
if self.provenance_graph is not None:
|
|
565
|
+
d["provenance_nodes"] = len(self.provenance_graph.nodes)
|
|
549
566
|
return d
|
|
550
567
|
|
|
551
568
|
def reset_anomaly_reference(self, components: Optional[list[str]] = None) -> None:
|
|
@@ -586,4 +603,5 @@ class Monitor:
|
|
|
586
603
|
feat = ""
|
|
587
604
|
else:
|
|
588
605
|
feat = f", features={{{', '.join(sorted(self.features))}}}"
|
|
589
|
-
|
|
606
|
+
pg = ", provenance=True" if self.provenance_graph is not None else ""
|
|
607
|
+
return f"Monitor(step={self._step}, {len(self._drift_trackers)} components, {w}{feat}{pg})"
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "margin"
|
|
7
|
-
version = "0.9.
|
|
7
|
+
version = "0.9.10"
|
|
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"}
|
|
@@ -10,6 +10,7 @@ from margin import (
|
|
|
10
10
|
save_monitor, load_monitor, replay, replay_csv,
|
|
11
11
|
DriftState, AnomalyState,
|
|
12
12
|
)
|
|
13
|
+
from margin.provenance import ProvenanceGraph
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
def _parser():
|
|
@@ -264,3 +265,34 @@ class TestFeaturesRoundtrip:
|
|
|
264
265
|
|
|
265
266
|
assert data["anomaly_min_reference"] == 25
|
|
266
267
|
assert m2._anomaly_trackers["cpu"]._min_reference == 25
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class TestProvenanceGraphRoundtrip:
|
|
271
|
+
def test_provenance_graph_saved_and_restored(self):
|
|
272
|
+
p = _parser()
|
|
273
|
+
pg = ProvenanceGraph()
|
|
274
|
+
m = Monitor(p, provenance_graph=pg)
|
|
275
|
+
for i in range(3):
|
|
276
|
+
m.update({"cpu": 50.0, "mem": 70.0}, now=t0 + timedelta(seconds=i * 60))
|
|
277
|
+
|
|
278
|
+
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
|
279
|
+
save_monitor(m, f.name)
|
|
280
|
+
m2 = load_monitor(f.name, p)
|
|
281
|
+
Path(f.name).unlink()
|
|
282
|
+
|
|
283
|
+
assert m2.provenance_graph is not None
|
|
284
|
+
assert len(m2.provenance_graph.nodes) == 3
|
|
285
|
+
|
|
286
|
+
def test_no_provenance_graph_not_saved(self):
|
|
287
|
+
p = _parser()
|
|
288
|
+
m = Monitor(p)
|
|
289
|
+
m.update({"cpu": 50.0, "mem": 70.0}, now=t0)
|
|
290
|
+
|
|
291
|
+
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
|
292
|
+
save_monitor(m, f.name)
|
|
293
|
+
data = json.loads(Path(f.name).read_text())
|
|
294
|
+
m2 = load_monitor(f.name, p)
|
|
295
|
+
Path(f.name).unlink()
|
|
296
|
+
|
|
297
|
+
assert data.get("provenance_graph") is None
|
|
298
|
+
assert m2.provenance_graph is None
|
|
@@ -8,6 +8,7 @@ from margin import (
|
|
|
8
8
|
DriftTracker, AnomalyTracker, CorrelationTracker, Monitor,
|
|
9
9
|
WindowConfig,
|
|
10
10
|
)
|
|
11
|
+
from margin.provenance import ProvenanceGraph
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
t0 = datetime(2026, 1, 1)
|
|
@@ -448,3 +449,73 @@ class TestWindowConfigSerialization:
|
|
|
448
449
|
assert wc.drift is None
|
|
449
450
|
assert wc.anomaly is None
|
|
450
451
|
assert wc.correlation is None
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
class TestMonitorProvenanceGraph:
|
|
455
|
+
def _parser(self):
|
|
456
|
+
return Parser({"cpu": 100.0}, Thresholds(intact=80.0, ablated=40.0))
|
|
457
|
+
|
|
458
|
+
def test_provenance_graph_off_by_default(self):
|
|
459
|
+
m = Monitor(self._parser())
|
|
460
|
+
assert m.provenance_graph is None
|
|
461
|
+
|
|
462
|
+
def test_provenance_graph_records_step_nodes(self):
|
|
463
|
+
pg = ProvenanceGraph()
|
|
464
|
+
m = Monitor(self._parser(), provenance_graph=pg)
|
|
465
|
+
m.update({"cpu": 90.0})
|
|
466
|
+
m.update({"cpu": 85.0})
|
|
467
|
+
assert len(pg.nodes) == 2
|
|
468
|
+
|
|
469
|
+
def test_observations_carry_step_provenance_id(self):
|
|
470
|
+
pg = ProvenanceGraph()
|
|
471
|
+
m = Monitor(self._parser(), provenance_graph=pg)
|
|
472
|
+
expr = m.update({"cpu": 90.0})
|
|
473
|
+
obs = expr.observations[0]
|
|
474
|
+
assert len(obs.provenance) == 1
|
|
475
|
+
assert obs.provenance[0] in pg.nodes
|
|
476
|
+
|
|
477
|
+
def test_user_provenance_tags_also_included(self):
|
|
478
|
+
pg = ProvenanceGraph()
|
|
479
|
+
m = Monitor(self._parser(), provenance_graph=pg)
|
|
480
|
+
expr = m.update({"cpu": 90.0}, provenance=["sensor-42"])
|
|
481
|
+
obs = expr.observations[0]
|
|
482
|
+
assert "sensor-42" in obs.provenance
|
|
483
|
+
# also has the step root ID
|
|
484
|
+
assert any(p in pg.nodes for p in obs.provenance)
|
|
485
|
+
|
|
486
|
+
def test_step_node_operation_name(self):
|
|
487
|
+
pg = ProvenanceGraph()
|
|
488
|
+
m = Monitor(self._parser(), provenance_graph=pg)
|
|
489
|
+
m.update({"cpu": 90.0})
|
|
490
|
+
node = next(iter(pg.nodes.values()))
|
|
491
|
+
assert node.operation == "monitor:step:0"
|
|
492
|
+
|
|
493
|
+
def test_status_includes_provenance_node_count(self):
|
|
494
|
+
pg = ProvenanceGraph()
|
|
495
|
+
m = Monitor(self._parser(), provenance_graph=pg)
|
|
496
|
+
m.update({"cpu": 90.0})
|
|
497
|
+
s = m.status()
|
|
498
|
+
assert s["provenance_nodes"] == 1
|
|
499
|
+
|
|
500
|
+
def test_status_no_provenance_key_when_none(self):
|
|
501
|
+
m = Monitor(self._parser())
|
|
502
|
+
m.update({"cpu": 90.0})
|
|
503
|
+
assert "provenance_nodes" not in m.status()
|
|
504
|
+
|
|
505
|
+
def test_repr_shows_provenance(self):
|
|
506
|
+
pg = ProvenanceGraph()
|
|
507
|
+
m = Monitor(self._parser(), provenance_graph=pg)
|
|
508
|
+
assert "provenance=True" in repr(m)
|
|
509
|
+
|
|
510
|
+
def test_repr_no_provenance_marker_when_none(self):
|
|
511
|
+
m = Monitor(self._parser())
|
|
512
|
+
assert "provenance" not in repr(m)
|
|
513
|
+
|
|
514
|
+
def test_trace_lineage_via_graph(self):
|
|
515
|
+
pg = ProvenanceGraph()
|
|
516
|
+
m = Monitor(self._parser(), provenance_graph=pg)
|
|
517
|
+
expr = m.update({"cpu": 90.0})
|
|
518
|
+
step_id = expr.observations[0].provenance[0]
|
|
519
|
+
lineage = pg.trace_lineage(step_id)
|
|
520
|
+
assert len(lineage) == 1
|
|
521
|
+
assert lineage[0].id == step_id
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|