margin 0.9.9__tar.gz → 0.9.11__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 (86) hide show
  1. {margin-0.9.9/margin.egg-info → margin-0.9.11}/PKG-INFO +1 -1
  2. {margin-0.9.9 → margin-0.9.11}/margin/drift.py +6 -0
  3. {margin-0.9.9 → margin-0.9.11}/margin/events.py +16 -2
  4. {margin-0.9.9 → margin-0.9.11}/margin/observation.py +25 -0
  5. {margin-0.9.9 → margin-0.9.11}/margin/persist.py +34 -20
  6. {margin-0.9.9 → margin-0.9.11}/margin/provenance.py +24 -0
  7. {margin-0.9.9 → margin-0.9.11}/margin/streaming.py +57 -1
  8. {margin-0.9.9 → margin-0.9.11/margin.egg-info}/PKG-INFO +1 -1
  9. {margin-0.9.9 → margin-0.9.11}/pyproject.toml +1 -1
  10. {margin-0.9.9 → margin-0.9.11}/tests/test_drift.py +15 -0
  11. {margin-0.9.9 → margin-0.9.11}/tests/test_persist.py +88 -0
  12. margin-0.9.11/tests/test_provenance.py +84 -0
  13. {margin-0.9.9 → margin-0.9.11}/tests/test_streaming.py +145 -0
  14. margin-0.9.9/tests/test_provenance.py +0 -43
  15. {margin-0.9.9 → margin-0.9.11}/LICENSE +0 -0
  16. {margin-0.9.9 → margin-0.9.11}/NOTICE +0 -0
  17. {margin-0.9.9 → margin-0.9.11}/README.md +0 -0
  18. {margin-0.9.9 → margin-0.9.11}/margin/__init__.py +0 -0
  19. {margin-0.9.9 → margin-0.9.11}/margin/__main__.py +0 -0
  20. {margin-0.9.9 → margin-0.9.11}/margin/algebra.py +0 -0
  21. {margin-0.9.9 → margin-0.9.11}/margin/anomaly.py +0 -0
  22. {margin-0.9.9 → margin-0.9.11}/margin/bridge.py +0 -0
  23. {margin-0.9.9 → margin-0.9.11}/margin/calibrate.py +0 -0
  24. {margin-0.9.9 → margin-0.9.11}/margin/causal.py +0 -0
  25. {margin-0.9.9 → margin-0.9.11}/margin/composite.py +0 -0
  26. {margin-0.9.9 → margin-0.9.11}/margin/confidence.py +0 -0
  27. {margin-0.9.9 → margin-0.9.11}/margin/config.py +0 -0
  28. {margin-0.9.9 → margin-0.9.11}/margin/contract.py +0 -0
  29. {margin-0.9.9 → margin-0.9.11}/margin/correlate.py +0 -0
  30. {margin-0.9.9 → margin-0.9.11}/margin/diff.py +0 -0
  31. {margin-0.9.9 → margin-0.9.11}/margin/forecast.py +0 -0
  32. {margin-0.9.9 → margin-0.9.11}/margin/health.py +0 -0
  33. {margin-0.9.9 → margin-0.9.11}/margin/intent.py +0 -0
  34. {margin-0.9.9 → margin-0.9.11}/margin/ledger.py +0 -0
  35. {margin-0.9.9 → margin-0.9.11}/margin/loop.py +0 -0
  36. {margin-0.9.9 → margin-0.9.11}/margin/policy/__init__.py +0 -0
  37. {margin-0.9.9 → margin-0.9.11}/margin/policy/compose.py +0 -0
  38. {margin-0.9.9 → margin-0.9.11}/margin/policy/core.py +0 -0
  39. {margin-0.9.9 → margin-0.9.11}/margin/policy/temporal.py +0 -0
  40. {margin-0.9.9 → margin-0.9.11}/margin/policy/trace.py +0 -0
  41. {margin-0.9.9 → margin-0.9.11}/margin/policy/tuning.py +0 -0
  42. {margin-0.9.9 → margin-0.9.11}/margin/policy/validate.py +0 -0
  43. {margin-0.9.9 → margin-0.9.11}/margin/predicates.py +0 -0
  44. {margin-0.9.9 → margin-0.9.11}/margin/transitions.py +0 -0
  45. {margin-0.9.9 → margin-0.9.11}/margin/uncertain.py +0 -0
  46. {margin-0.9.9 → margin-0.9.11}/margin/validity.py +0 -0
  47. {margin-0.9.9 → margin-0.9.11}/margin.egg-info/SOURCES.txt +0 -0
  48. {margin-0.9.9 → margin-0.9.11}/margin.egg-info/dependency_links.txt +0 -0
  49. {margin-0.9.9 → margin-0.9.11}/margin.egg-info/top_level.txt +0 -0
  50. {margin-0.9.9 → margin-0.9.11}/setup.cfg +0 -0
  51. {margin-0.9.9 → margin-0.9.11}/tests/test_algebra.py +0 -0
  52. {margin-0.9.9 → margin-0.9.11}/tests/test_anomaly.py +0 -0
  53. {margin-0.9.9 → margin-0.9.11}/tests/test_bridge.py +0 -0
  54. {margin-0.9.9 → margin-0.9.11}/tests/test_calibrate.py +0 -0
  55. {margin-0.9.9 → margin-0.9.11}/tests/test_causal.py +0 -0
  56. {margin-0.9.9 → margin-0.9.11}/tests/test_composite.py +0 -0
  57. {margin-0.9.9 → margin-0.9.11}/tests/test_confidence.py +0 -0
  58. {margin-0.9.9 → margin-0.9.11}/tests/test_config.py +0 -0
  59. {margin-0.9.9 → margin-0.9.11}/tests/test_contract.py +0 -0
  60. {margin-0.9.9 → margin-0.9.11}/tests/test_correlate.py +0 -0
  61. {margin-0.9.9 → margin-0.9.11}/tests/test_diff.py +0 -0
  62. {margin-0.9.9 → margin-0.9.11}/tests/test_events.py +0 -0
  63. {margin-0.9.9 → margin-0.9.11}/tests/test_forecast.py +0 -0
  64. {margin-0.9.9 → margin-0.9.11}/tests/test_health.py +0 -0
  65. {margin-0.9.9 → margin-0.9.11}/tests/test_healthcare.py +0 -0
  66. {margin-0.9.9 → margin-0.9.11}/tests/test_intent.py +0 -0
  67. {margin-0.9.9 → margin-0.9.11}/tests/test_ledger.py +0 -0
  68. {margin-0.9.9 → margin-0.9.11}/tests/test_loop.py +0 -0
  69. {margin-0.9.9 → margin-0.9.11}/tests/test_neuro.py +0 -0
  70. {margin-0.9.9 → margin-0.9.11}/tests/test_observation.py +0 -0
  71. {margin-0.9.9 → margin-0.9.11}/tests/test_policy.py +0 -0
  72. {margin-0.9.9 → margin-0.9.11}/tests/test_policy_compose.py +0 -0
  73. {margin-0.9.9 → margin-0.9.11}/tests/test_policy_temporal.py +0 -0
  74. {margin-0.9.9 → margin-0.9.11}/tests/test_policy_trace.py +0 -0
  75. {margin-0.9.9 → margin-0.9.11}/tests/test_policy_tuning.py +0 -0
  76. {margin-0.9.9 → margin-0.9.11}/tests/test_policy_validate.py +0 -0
  77. {margin-0.9.9 → margin-0.9.11}/tests/test_predicates.py +0 -0
  78. {margin-0.9.9 → margin-0.9.11}/tests/test_printer3d.py +0 -0
  79. {margin-0.9.9 → margin-0.9.11}/tests/test_reverse_bridge.py +0 -0
  80. {margin-0.9.9 → margin-0.9.11}/tests/test_ros2.py +0 -0
  81. {margin-0.9.9 → margin-0.9.11}/tests/test_staleness.py +0 -0
  82. {margin-0.9.9 → margin-0.9.11}/tests/test_transformer.py +0 -0
  83. {margin-0.9.9 → margin-0.9.11}/tests/test_transitions.py +0 -0
  84. {margin-0.9.9 → margin-0.9.11}/tests/test_uncertain.py +0 -0
  85. {margin-0.9.9 → margin-0.9.11}/tests/test_validity.py +0 -0
  86. {margin-0.9.9 → margin-0.9.11}/tests/test_windowing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: margin
3
- Version: 0.9.9
3
+ Version: 0.9.11
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
@@ -71,6 +71,12 @@ class DriftClassification:
71
71
  n_samples: int
72
72
  window_seconds: float
73
73
 
74
+ @property
75
+ def step_count(self) -> int:
76
+ """Number of observations that contributed to this classification.
77
+ A 3-step OSCILLATING is noise; a 20-step OSCILLATING is real."""
78
+ return self.n_samples
79
+
74
80
  @property
75
81
  def stable(self) -> bool:
76
82
  return self.state == DriftState.STABLE
@@ -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
@@ -320,6 +320,31 @@ class Parser:
320
320
  """Return the display label for a component's health state."""
321
321
  return self._thresholds_for(component).label_for(health)
322
322
 
323
+ def with_baselines(self, fingerprint: dict[str, dict]) -> 'Parser':
324
+ """
325
+ Return a new Parser with baselines updated from a fingerprint dict.
326
+
327
+ Components present in ``fingerprint`` get their baseline replaced with
328
+ ``fingerprint[name]["mean"]``. All other components and all thresholds
329
+ are preserved unchanged.
330
+
331
+ Typical use: dispositional calibration from ``Monitor.fingerprint()``
332
+ at a session boundary, without full Parser reconstruction.
333
+
334
+ fp = monitor.fingerprint()
335
+ new_parser = monitor.parser.with_baselines(fp)
336
+ # new_parser has empirical session means as baselines
337
+ """
338
+ new_baselines = dict(self.baselines)
339
+ for name, stats in fingerprint.items():
340
+ if name in new_baselines and "mean" in stats:
341
+ new_baselines[name] = stats["mean"]
342
+ return Parser(
343
+ baselines=new_baselines,
344
+ thresholds=self.thresholds,
345
+ component_thresholds=dict(self.component_thresholds),
346
+ )
347
+
323
348
  def parse(
324
349
  self,
325
350
  values: dict[str, float],
@@ -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():
@@ -56,14 +59,19 @@ def save_monitor(monitor: Monitor, path: str) -> None:
56
59
  Path(path).write_text(json.dumps(state, indent=2))
57
60
 
58
61
 
59
- def load_monitor(path: str, parser: Parser, **kwargs) -> Monitor:
62
+ def load_monitor(path: str, parser: Parser, warm_only: bool = False, **kwargs) -> Monitor:
60
63
  """
61
64
  Restore a Monitor from a saved state file.
62
65
 
63
66
  Args:
64
- path: path to the state JSON
65
- parser: the Parser to use (must match the saved components)
66
- **kwargs: passed to Monitor constructor (window, min_correlation, etc.)
67
+ path: path to the state JSON
68
+ parser: the Parser to use (must match the saved components)
69
+ warm_only: if True, load drift observations only — anomaly and
70
+ correlation trackers start fresh and step count resets
71
+ to 0. Use this when starting a new session that should
72
+ inherit drift trajectory (warm prior) but not anomaly
73
+ reference windows or correlation history.
74
+ **kwargs: passed to Monitor constructor (window, min_correlation, etc.)
67
75
  """
68
76
  data = json.loads(Path(path).read_text())
69
77
 
@@ -83,7 +91,12 @@ def load_monitor(path: str, parser: Parser, **kwargs) -> Monitor:
83
91
  features=features,
84
92
  **kwargs,
85
93
  )
86
- monitor._step = data.get("step", 0)
94
+ monitor._step = 0 if warm_only else data.get("step", 0)
95
+
96
+ # Restore provenance graph if present
97
+ pg_data = data.get("provenance_graph")
98
+ if pg_data is not None:
99
+ monitor.provenance_graph = ProvenanceGraph.from_dict(pg_data)
87
100
 
88
101
  # Restore drift tracker observations
89
102
  for name, dt_data in data.get("drift", {}).items():
@@ -97,21 +110,22 @@ def load_monitor(path: str, parser: Parser, **kwargs) -> Monitor:
97
110
  from .drift import classify_drift
98
111
  tracker._classification = classify_drift(list(tracker._observations))
99
112
 
100
- # Restore anomaly tracker values
101
- for name, at_data in data.get("anomaly", {}).items():
102
- if name in monitor._anomaly_trackers:
103
- tracker = monitor._anomaly_trackers[name]
104
- for v in at_data.get("values", []):
105
- tracker._values.append(v)
106
-
107
- # Restore correlation series
108
- corr_data = data.get("correlation")
109
- if corr_data is not None and monitor._correlation_tracker is not None:
110
- for c in monitor._correlation_tracker.components:
111
- series = corr_data.get("series", {}).get(c, [])
112
- for v in series:
113
- monitor._correlation_tracker._series[c].append(v)
114
- monitor._correlation_tracker._n_updates = corr_data.get("n_updates", 0)
113
+ # Restore anomaly tracker values (skipped in warm_only mode)
114
+ if not warm_only:
115
+ for name, at_data in data.get("anomaly", {}).items():
116
+ if name in monitor._anomaly_trackers:
117
+ tracker = monitor._anomaly_trackers[name]
118
+ for v in at_data.get("values", []):
119
+ tracker._values.append(v)
120
+
121
+ # Restore correlation series (skipped in warm_only mode)
122
+ corr_data = data.get("correlation")
123
+ if corr_data is not None and monitor._correlation_tracker is not None:
124
+ for c in monitor._correlation_tracker.components:
125
+ series = corr_data.get("series", {}).get(c, [])
126
+ for v in series:
127
+ monitor._correlation_tracker._series[c].append(v)
128
+ monitor._correlation_tracker._n_updates = corr_data.get("n_updates", 0)
115
129
 
116
130
  return monitor
117
131
 
@@ -111,6 +111,30 @@ class ProvenanceGraph:
111
111
  self.add_node(ProvenanceNode(id=node_id, operation=operation, source_ids=source_ids))
112
112
  return node_id
113
113
 
114
+ def compress(self, max_nodes: int = 500) -> 'ProvenanceGraph':
115
+ """
116
+ Prune the oldest nodes to stay within max_nodes.
117
+
118
+ Nodes are removed in insertion order (oldest first). Any
119
+ ``source_ids`` in surviving nodes that reference a pruned node
120
+ are removed, so the graph stays self-consistent — survivors
121
+ whose parents were pruned become new roots.
122
+
123
+ Returns self for chaining (e.g. before save_monitor).
124
+
125
+ monitor.provenance_graph.compress(500)
126
+ save_monitor(monitor, "state.json")
127
+ """
128
+ if len(self.nodes) <= max_nodes:
129
+ return self
130
+ all_ids = list(self.nodes.keys())
131
+ pruned = set(all_ids[:-max_nodes])
132
+ for node_id in pruned:
133
+ del self.nodes[node_id]
134
+ for node in self.nodes.values():
135
+ node.source_ids = [sid for sid in node.source_ids if sid not in pruned]
136
+ return self
137
+
114
138
  def to_dict(self) -> dict:
115
139
  return {"nodes": {k: v.to_dict() for k, v in self.nodes.items()}}
116
140
 
@@ -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,8 +561,48 @@ 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
 
568
+ def fingerprint(self) -> dict[str, dict]:
569
+ """
570
+ Session statistics for each component from the current drift window.
571
+
572
+ Returns ``{name: {mean, std, n, trend}}`` where:
573
+
574
+ - ``mean`` — empirical mean of values in the window
575
+ - ``std`` — sample standard deviation (0.0 if n < 2)
576
+ - ``n`` — number of observations currently in the window
577
+ - ``trend`` — DriftState value string, or ``"UNKNOWN"`` if < min_samples
578
+
579
+ Typical use: dispositional calibration before a session boundary.
580
+
581
+ fp = monitor.fingerprint()
582
+ new_parser = monitor.parser.with_baselines(fp)
583
+ """
584
+ result: dict[str, dict] = {}
585
+ for name, tracker in self._drift_trackers.items():
586
+ obs = list(tracker._observations)
587
+ n = len(obs)
588
+ if n == 0:
589
+ result[name] = {
590
+ "mean": self.parser.baselines.get(name, 0.0),
591
+ "std": 0.0, "n": 0, "trend": "UNKNOWN",
592
+ }
593
+ continue
594
+ values = [o.value for o in obs]
595
+ mean = sum(values) / n
596
+ std = (sum((v - mean) ** 2 for v in values) / (n - 1)) ** 0.5 if n >= 2 else 0.0
597
+ dc = tracker.classification
598
+ result[name] = {
599
+ "mean": mean,
600
+ "std": std,
601
+ "n": n,
602
+ "trend": dc.state.value if dc is not None else "UNKNOWN",
603
+ }
604
+ return result
605
+
551
606
  def reset_anomaly_reference(self, components: Optional[list[str]] = None) -> None:
552
607
  """
553
608
  Clear AnomalyTracker reference windows for the specified components.
@@ -586,4 +641,5 @@ class Monitor:
586
641
  feat = ""
587
642
  else:
588
643
  feat = f", features={{{', '.join(sorted(self.features))}}}"
589
- return f"Monitor(step={self._step}, {len(self._drift_trackers)} components, {w}{feat})"
644
+ pg = ", provenance=True" if self.provenance_graph is not None else ""
645
+ 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.9
3
+ Version: 0.9.11
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.9"
7
+ version = "0.9.11"
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"}
@@ -497,3 +497,18 @@ class TestDriftForecast:
497
497
  assert df.drift.state == DriftState.STABLE
498
498
  # No ETA in summary for stable
499
499
  assert "ETA" not in df.summary
500
+
501
+
502
+ class TestDriftClassificationStepCount:
503
+ def test_step_count_equals_n_samples(self):
504
+ from margin.drift import DriftClassification, DriftState, DriftDirection
505
+ from margin.confidence import Confidence
506
+ dc = DriftClassification(
507
+ component="cpu", state=DriftState.STABLE,
508
+ direction=DriftDirection.NEUTRAL, rate=0.0,
509
+ acceleration=0.0, r_squared=0.9,
510
+ confidence=Confidence.HIGH, n_samples=17,
511
+ window_seconds=120.0,
512
+ )
513
+ assert dc.step_count == 17
514
+ assert dc.step_count == dc.n_samples
@@ -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,90 @@ 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
299
+
300
+
301
+ class TestLoadMonitorWarmOnly:
302
+ def test_warm_only_resets_step(self):
303
+ p = _parser()
304
+ m = Monitor(p)
305
+ for i in range(20):
306
+ m.update({"cpu": 50.0, "mem": 70.0}, now=t0 + timedelta(seconds=i * 60))
307
+
308
+ with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
309
+ save_monitor(m, f.name)
310
+ m2 = load_monitor(f.name, p, warm_only=True)
311
+ Path(f.name).unlink()
312
+
313
+ assert m2.step == 0 # fresh session
314
+
315
+ def test_warm_only_keeps_drift_observations(self):
316
+ p = _parser()
317
+ m = Monitor(p)
318
+ for i in range(20):
319
+ m.update({"cpu": 50.0 - i * 0.5, "mem": 70.0},
320
+ now=t0 + timedelta(seconds=i * 60))
321
+
322
+ with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
323
+ save_monitor(m, f.name)
324
+ m2 = load_monitor(f.name, p, warm_only=True)
325
+ Path(f.name).unlink()
326
+
327
+ assert m2._drift_trackers["cpu"].n_observations > 0
328
+
329
+ def test_warm_only_clears_anomaly(self):
330
+ p = _parser()
331
+ m = Monitor(p)
332
+ for i in range(20):
333
+ m.update({"cpu": 50.0, "mem": 70.0}, now=t0 + timedelta(seconds=i * 60))
334
+
335
+ with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
336
+ save_monitor(m, f.name)
337
+ m2 = load_monitor(f.name, p, warm_only=True)
338
+ Path(f.name).unlink()
339
+
340
+ assert all(t._values == [] or len(t._values) == 0
341
+ for t in m2._anomaly_trackers.values())
342
+
343
+ def test_warm_only_false_restores_everything(self):
344
+ p = _parser()
345
+ m = Monitor(p)
346
+ for i in range(20):
347
+ m.update({"cpu": 50.0, "mem": 70.0}, now=t0 + timedelta(seconds=i * 60))
348
+
349
+ with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
350
+ save_monitor(m, f.name)
351
+ m2 = load_monitor(f.name, p, warm_only=False)
352
+ Path(f.name).unlink()
353
+
354
+ assert m2.step == 20
@@ -0,0 +1,84 @@
1
+ from margin.provenance import new_id, are_correlated, merge, ProvenanceGraph
2
+
3
+
4
+ class TestNewId:
5
+ def test_returns_string(self):
6
+ assert isinstance(new_id(), str)
7
+
8
+ def test_unique(self):
9
+ ids = {new_id() for _ in range(100)}
10
+ assert len(ids) == 100
11
+
12
+ def test_length(self):
13
+ assert len(new_id()) == 8
14
+
15
+
16
+ class TestAreCorrelated:
17
+ def test_shared_ancestor(self):
18
+ shared = new_id()
19
+ assert are_correlated([shared, "a"], [shared, "b"]) is True
20
+
21
+ def test_no_shared_ancestor(self):
22
+ assert are_correlated(["a", "b"], ["c", "d"]) is False
23
+
24
+ def test_empty_lists(self):
25
+ assert are_correlated([], []) is False
26
+
27
+ def test_one_empty(self):
28
+ assert are_correlated(["a"], []) is False
29
+
30
+
31
+ class TestMerge:
32
+ def test_contains_both(self):
33
+ m = merge(["a"], ["b"])
34
+ assert "a" in m
35
+ assert "b" in m
36
+
37
+ def test_adds_new_id(self):
38
+ m = merge(["a"], ["b"])
39
+ assert len(m) == 3 # a, b, new
40
+
41
+ def test_deduplicates(self):
42
+ m = merge(["a", "b"], ["b", "c"])
43
+ assert sorted([x for x in m if x in ("a", "b", "c")]) == ["a", "b", "c"]
44
+
45
+
46
+ class TestProvenanceGraphCompress:
47
+ def test_compress_noop_when_under_limit(self):
48
+ pg = ProvenanceGraph()
49
+ for _ in range(10):
50
+ pg.create_root("step")
51
+ pg.compress(max_nodes=20)
52
+ assert len(pg.nodes) == 10
53
+
54
+ def test_compress_prunes_to_max(self):
55
+ pg = ProvenanceGraph()
56
+ for _ in range(100):
57
+ pg.create_root("step")
58
+ pg.compress(max_nodes=50)
59
+ assert len(pg.nodes) == 50
60
+
61
+ def test_compress_keeps_newest(self):
62
+ pg = ProvenanceGraph()
63
+ ids = [pg.create_root(f"step:{i}") for i in range(10)]
64
+ pg.compress(max_nodes=5)
65
+ for node_id in ids[-5:]:
66
+ assert node_id in pg.nodes
67
+ for node_id in ids[:5]:
68
+ assert node_id not in pg.nodes
69
+
70
+ def test_compress_cleans_dangling_source_ids(self):
71
+ pg = ProvenanceGraph()
72
+ root = pg.create_root("root")
73
+ child = pg.derive("child", [root])
74
+ pg.compress(max_nodes=1) # prune root, keep child
75
+ assert root not in pg.nodes
76
+ assert child in pg.nodes
77
+ assert pg.nodes[child].source_ids == [] # dangling ref removed
78
+
79
+ def test_compress_returns_self(self):
80
+ pg = ProvenanceGraph()
81
+ for _ in range(10):
82
+ pg.create_root("step")
83
+ result = pg.compress(max_nodes=5)
84
+ assert result is pg
@@ -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,147 @@ 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
522
+
523
+
524
+ class TestMonitorFingerprint:
525
+ def _parser(self):
526
+ return Parser({"cpu": 100.0, "mem": 80.0}, Thresholds(intact=80.0, ablated=40.0))
527
+
528
+ def test_fingerprint_empty_before_updates(self):
529
+ m = Monitor(self._parser())
530
+ fp = m.fingerprint()
531
+ assert set(fp.keys()) == {"cpu", "mem"}
532
+ assert fp["cpu"]["n"] == 0
533
+ assert fp["cpu"]["trend"] == "UNKNOWN"
534
+ assert fp["cpu"]["mean"] == 100.0 # falls back to parser baseline
535
+
536
+ def test_fingerprint_mean_and_n(self):
537
+ m = Monitor(self._parser())
538
+ for v in [90.0, 95.0, 85.0]:
539
+ m.update({"cpu": v, "mem": 75.0})
540
+ fp = m.fingerprint()
541
+ assert fp["cpu"]["n"] == 3
542
+ assert abs(fp["cpu"]["mean"] - 90.0) < 0.01
543
+
544
+ def test_fingerprint_std(self):
545
+ m = Monitor(self._parser())
546
+ for v in [90.0, 90.0, 90.0]:
547
+ m.update({"cpu": v, "mem": 75.0})
548
+ fp = m.fingerprint()
549
+ assert fp["cpu"]["std"] == 0.0
550
+
551
+ def test_fingerprint_trend_populated(self):
552
+ m = Monitor(self._parser())
553
+ for i in range(10):
554
+ m.update({"cpu": 90.0 - i * 2, "mem": 75.0})
555
+ fp = m.fingerprint()
556
+ assert fp["cpu"]["trend"] in {"STABLE", "DRIFTING", "ACCELERATING",
557
+ "DECELERATING", "REVERTING", "OSCILLATING"}
558
+
559
+
560
+ class TestParserWithBaselines:
561
+ def _parser(self):
562
+ return Parser(
563
+ {"cpu": 100.0, "mem": 80.0},
564
+ Thresholds(intact=80.0, ablated=40.0),
565
+ )
566
+
567
+ def test_updated_baseline(self):
568
+ p = self._parser()
569
+ p2 = p.with_baselines({"cpu": {"mean": 92.0, "std": 3.0, "n": 50, "trend": "STABLE"}})
570
+ assert p2.baselines["cpu"] == 92.0
571
+ assert p2.baselines["mem"] == 80.0 # unchanged
572
+
573
+ def test_unknown_component_ignored(self):
574
+ p = self._parser()
575
+ p2 = p.with_baselines({"gpu": {"mean": 70.0}})
576
+ assert p2.baselines == p.baselines
577
+
578
+ def test_thresholds_preserved(self):
579
+ p = self._parser()
580
+ p2 = p.with_baselines({"cpu": {"mean": 92.0}})
581
+ assert p2.thresholds is p.thresholds
582
+ assert p2.component_thresholds == p.component_thresholds
583
+
584
+ def test_original_parser_unchanged(self):
585
+ p = self._parser()
586
+ p.with_baselines({"cpu": {"mean": 92.0}})
587
+ assert p.baselines["cpu"] == 100.0 # original untouched
588
+
589
+ def test_fingerprint_roundtrip(self):
590
+ m = Monitor(self._parser())
591
+ for v in [88.0, 90.0, 92.0]:
592
+ m.update({"cpu": v, "mem": 75.0})
593
+ fp = m.fingerprint()
594
+ p2 = m.parser.with_baselines(fp)
595
+ assert abs(p2.baselines["cpu"] - fp["cpu"]["mean"]) < 0.001
@@ -1,43 +0,0 @@
1
- from margin.provenance import new_id, are_correlated, merge
2
-
3
-
4
- class TestNewId:
5
- def test_returns_string(self):
6
- assert isinstance(new_id(), str)
7
-
8
- def test_unique(self):
9
- ids = {new_id() for _ in range(100)}
10
- assert len(ids) == 100
11
-
12
- def test_length(self):
13
- assert len(new_id()) == 8
14
-
15
-
16
- class TestAreCorrelated:
17
- def test_shared_ancestor(self):
18
- shared = new_id()
19
- assert are_correlated([shared, "a"], [shared, "b"]) is True
20
-
21
- def test_no_shared_ancestor(self):
22
- assert are_correlated(["a", "b"], ["c", "d"]) is False
23
-
24
- def test_empty_lists(self):
25
- assert are_correlated([], []) is False
26
-
27
- def test_one_empty(self):
28
- assert are_correlated(["a"], []) is False
29
-
30
-
31
- class TestMerge:
32
- def test_contains_both(self):
33
- m = merge(["a"], ["b"])
34
- assert "a" in m
35
- assert "b" in m
36
-
37
- def test_adds_new_id(self):
38
- m = merge(["a"], ["b"])
39
- assert len(m) == 3 # a, b, new
40
-
41
- def test_deduplicates(self):
42
- m = merge(["a", "b"], ["b", "c"])
43
- assert sorted([x for x in m if x in ("a", "b", "c")]) == ["a", "b", "c"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes