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.
- {margin-0.9.9/margin.egg-info → margin-0.9.11}/PKG-INFO +1 -1
- {margin-0.9.9 → margin-0.9.11}/margin/drift.py +6 -0
- {margin-0.9.9 → margin-0.9.11}/margin/events.py +16 -2
- {margin-0.9.9 → margin-0.9.11}/margin/observation.py +25 -0
- {margin-0.9.9 → margin-0.9.11}/margin/persist.py +34 -20
- {margin-0.9.9 → margin-0.9.11}/margin/provenance.py +24 -0
- {margin-0.9.9 → margin-0.9.11}/margin/streaming.py +57 -1
- {margin-0.9.9 → margin-0.9.11/margin.egg-info}/PKG-INFO +1 -1
- {margin-0.9.9 → margin-0.9.11}/pyproject.toml +1 -1
- {margin-0.9.9 → margin-0.9.11}/tests/test_drift.py +15 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_persist.py +88 -0
- margin-0.9.11/tests/test_provenance.py +84 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_streaming.py +145 -0
- margin-0.9.9/tests/test_provenance.py +0 -43
- {margin-0.9.9 → margin-0.9.11}/LICENSE +0 -0
- {margin-0.9.9 → margin-0.9.11}/NOTICE +0 -0
- {margin-0.9.9 → margin-0.9.11}/README.md +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/__init__.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/__main__.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/algebra.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/anomaly.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/bridge.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/calibrate.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/causal.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/composite.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/confidence.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/config.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/contract.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/correlate.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/diff.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/forecast.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/health.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/intent.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/ledger.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/loop.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/policy/__init__.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/policy/compose.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/policy/core.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/policy/temporal.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/policy/trace.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/policy/tuning.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/policy/validate.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/predicates.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/transitions.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/uncertain.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin/validity.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin.egg-info/SOURCES.txt +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin.egg-info/dependency_links.txt +0 -0
- {margin-0.9.9 → margin-0.9.11}/margin.egg-info/top_level.txt +0 -0
- {margin-0.9.9 → margin-0.9.11}/setup.cfg +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_algebra.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_anomaly.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_bridge.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_calibrate.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_causal.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_composite.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_confidence.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_config.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_contract.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_correlate.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_diff.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_events.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_forecast.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_health.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_healthcare.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_intent.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_ledger.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_loop.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_neuro.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_observation.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_policy.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_policy_compose.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_policy_temporal.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_policy_trace.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_policy_tuning.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_policy_validate.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_predicates.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_printer3d.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_reverse_bridge.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_ros2.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_staleness.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_transformer.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_transitions.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_uncertain.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_validity.py +0 -0
- {margin-0.9.9 → margin-0.9.11}/tests/test_windowing.py +0 -0
|
@@ -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
|
-
|
|
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
|
|
@@ -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:
|
|
65
|
-
parser:
|
|
66
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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})"
|
|
@@ -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.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|