margin 0.9.8__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.

Files changed (85) hide show
  1. {margin-0.9.8/margin.egg-info → margin-0.9.10}/PKG-INFO +1 -1
  2. {margin-0.9.8 → margin-0.9.10}/margin/events.py +16 -2
  3. {margin-0.9.8 → margin-0.9.10}/margin/persist.py +8 -0
  4. {margin-0.9.8 → margin-0.9.10}/margin/streaming.py +19 -1
  5. {margin-0.9.8 → margin-0.9.10/margin.egg-info}/PKG-INFO +1 -1
  6. {margin-0.9.8 → margin-0.9.10}/pyproject.toml +1 -1
  7. {margin-0.9.8 → margin-0.9.10}/tests/test_persist.py +32 -0
  8. {margin-0.9.8 → margin-0.9.10}/tests/test_streaming.py +71 -0
  9. {margin-0.9.8 → margin-0.9.10}/LICENSE +0 -0
  10. {margin-0.9.8 → margin-0.9.10}/NOTICE +0 -0
  11. {margin-0.9.8 → margin-0.9.10}/README.md +0 -0
  12. {margin-0.9.8 → margin-0.9.10}/margin/__init__.py +0 -0
  13. {margin-0.9.8 → margin-0.9.10}/margin/__main__.py +0 -0
  14. {margin-0.9.8 → margin-0.9.10}/margin/algebra.py +0 -0
  15. {margin-0.9.8 → margin-0.9.10}/margin/anomaly.py +0 -0
  16. {margin-0.9.8 → margin-0.9.10}/margin/bridge.py +0 -0
  17. {margin-0.9.8 → margin-0.9.10}/margin/calibrate.py +0 -0
  18. {margin-0.9.8 → margin-0.9.10}/margin/causal.py +0 -0
  19. {margin-0.9.8 → margin-0.9.10}/margin/composite.py +0 -0
  20. {margin-0.9.8 → margin-0.9.10}/margin/confidence.py +0 -0
  21. {margin-0.9.8 → margin-0.9.10}/margin/config.py +0 -0
  22. {margin-0.9.8 → margin-0.9.10}/margin/contract.py +0 -0
  23. {margin-0.9.8 → margin-0.9.10}/margin/correlate.py +0 -0
  24. {margin-0.9.8 → margin-0.9.10}/margin/diff.py +0 -0
  25. {margin-0.9.8 → margin-0.9.10}/margin/drift.py +0 -0
  26. {margin-0.9.8 → margin-0.9.10}/margin/forecast.py +0 -0
  27. {margin-0.9.8 → margin-0.9.10}/margin/health.py +0 -0
  28. {margin-0.9.8 → margin-0.9.10}/margin/intent.py +0 -0
  29. {margin-0.9.8 → margin-0.9.10}/margin/ledger.py +0 -0
  30. {margin-0.9.8 → margin-0.9.10}/margin/loop.py +0 -0
  31. {margin-0.9.8 → margin-0.9.10}/margin/observation.py +0 -0
  32. {margin-0.9.8 → margin-0.9.10}/margin/policy/__init__.py +0 -0
  33. {margin-0.9.8 → margin-0.9.10}/margin/policy/compose.py +0 -0
  34. {margin-0.9.8 → margin-0.9.10}/margin/policy/core.py +0 -0
  35. {margin-0.9.8 → margin-0.9.10}/margin/policy/temporal.py +0 -0
  36. {margin-0.9.8 → margin-0.9.10}/margin/policy/trace.py +0 -0
  37. {margin-0.9.8 → margin-0.9.10}/margin/policy/tuning.py +0 -0
  38. {margin-0.9.8 → margin-0.9.10}/margin/policy/validate.py +0 -0
  39. {margin-0.9.8 → margin-0.9.10}/margin/predicates.py +0 -0
  40. {margin-0.9.8 → margin-0.9.10}/margin/provenance.py +0 -0
  41. {margin-0.9.8 → margin-0.9.10}/margin/transitions.py +0 -0
  42. {margin-0.9.8 → margin-0.9.10}/margin/uncertain.py +0 -0
  43. {margin-0.9.8 → margin-0.9.10}/margin/validity.py +0 -0
  44. {margin-0.9.8 → margin-0.9.10}/margin.egg-info/SOURCES.txt +0 -0
  45. {margin-0.9.8 → margin-0.9.10}/margin.egg-info/dependency_links.txt +0 -0
  46. {margin-0.9.8 → margin-0.9.10}/margin.egg-info/top_level.txt +0 -0
  47. {margin-0.9.8 → margin-0.9.10}/setup.cfg +0 -0
  48. {margin-0.9.8 → margin-0.9.10}/tests/test_algebra.py +0 -0
  49. {margin-0.9.8 → margin-0.9.10}/tests/test_anomaly.py +0 -0
  50. {margin-0.9.8 → margin-0.9.10}/tests/test_bridge.py +0 -0
  51. {margin-0.9.8 → margin-0.9.10}/tests/test_calibrate.py +0 -0
  52. {margin-0.9.8 → margin-0.9.10}/tests/test_causal.py +0 -0
  53. {margin-0.9.8 → margin-0.9.10}/tests/test_composite.py +0 -0
  54. {margin-0.9.8 → margin-0.9.10}/tests/test_confidence.py +0 -0
  55. {margin-0.9.8 → margin-0.9.10}/tests/test_config.py +0 -0
  56. {margin-0.9.8 → margin-0.9.10}/tests/test_contract.py +0 -0
  57. {margin-0.9.8 → margin-0.9.10}/tests/test_correlate.py +0 -0
  58. {margin-0.9.8 → margin-0.9.10}/tests/test_diff.py +0 -0
  59. {margin-0.9.8 → margin-0.9.10}/tests/test_drift.py +0 -0
  60. {margin-0.9.8 → margin-0.9.10}/tests/test_events.py +0 -0
  61. {margin-0.9.8 → margin-0.9.10}/tests/test_forecast.py +0 -0
  62. {margin-0.9.8 → margin-0.9.10}/tests/test_health.py +0 -0
  63. {margin-0.9.8 → margin-0.9.10}/tests/test_healthcare.py +0 -0
  64. {margin-0.9.8 → margin-0.9.10}/tests/test_intent.py +0 -0
  65. {margin-0.9.8 → margin-0.9.10}/tests/test_ledger.py +0 -0
  66. {margin-0.9.8 → margin-0.9.10}/tests/test_loop.py +0 -0
  67. {margin-0.9.8 → margin-0.9.10}/tests/test_neuro.py +0 -0
  68. {margin-0.9.8 → margin-0.9.10}/tests/test_observation.py +0 -0
  69. {margin-0.9.8 → margin-0.9.10}/tests/test_policy.py +0 -0
  70. {margin-0.9.8 → margin-0.9.10}/tests/test_policy_compose.py +0 -0
  71. {margin-0.9.8 → margin-0.9.10}/tests/test_policy_temporal.py +0 -0
  72. {margin-0.9.8 → margin-0.9.10}/tests/test_policy_trace.py +0 -0
  73. {margin-0.9.8 → margin-0.9.10}/tests/test_policy_tuning.py +0 -0
  74. {margin-0.9.8 → margin-0.9.10}/tests/test_policy_validate.py +0 -0
  75. {margin-0.9.8 → margin-0.9.10}/tests/test_predicates.py +0 -0
  76. {margin-0.9.8 → margin-0.9.10}/tests/test_printer3d.py +0 -0
  77. {margin-0.9.8 → margin-0.9.10}/tests/test_provenance.py +0 -0
  78. {margin-0.9.8 → margin-0.9.10}/tests/test_reverse_bridge.py +0 -0
  79. {margin-0.9.8 → margin-0.9.10}/tests/test_ros2.py +0 -0
  80. {margin-0.9.8 → margin-0.9.10}/tests/test_staleness.py +0 -0
  81. {margin-0.9.8 → margin-0.9.10}/tests/test_transformer.py +0 -0
  82. {margin-0.9.8 → margin-0.9.10}/tests/test_transitions.py +0 -0
  83. {margin-0.9.8 → margin-0.9.10}/tests/test_uncertain.py +0 -0
  84. {margin-0.9.8 → margin-0.9.10}/tests/test_validity.py +0 -0
  85. {margin-0.9.8 → margin-0.9.10}/tests/test_windowing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: margin
3
- Version: 0.9.8
3
+ Version: 0.9.10
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
@@ -1,8 +1,22 @@
1
1
  """
2
2
  Event bus for validity invalidation.
3
3
 
4
- Wires Validity.until_event() to actual event dispatch. When an event
5
- fires, all values whose Validity references that event become stale.
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
- return f"Monitor(step={self._step}, {len(self._drift_trackers)} components, {w}{feat})"
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})"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: margin
3
- Version: 0.9.8
3
+ Version: 0.9.10
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.9.8"
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