aetherruntime-arch 2.0.0__py3-none-any.whl
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.
- aetherruntime/__init__.py +314 -0
- aetherruntime/__version__.py +1 -0
- aetherruntime/cli.py +306 -0
- aetherruntime/containment/__init__.py +39 -0
- aetherruntime/containment/isolation.py +253 -0
- aetherruntime/containment/policy.py +273 -0
- aetherruntime/detection/__init__.py +35 -0
- aetherruntime/detection/alert.py +190 -0
- aetherruntime/detection/classifier.py +298 -0
- aetherruntime/halt/__init__.py +37 -0
- aetherruntime/halt/order.py +248 -0
- aetherruntime/halt/quorum.py +280 -0
- aetherruntime/models.py +489 -0
- aetherruntime/monitoring/__init__.py +33 -0
- aetherruntime/monitoring/baseline.py +282 -0
- aetherruntime/monitoring/telemetry.py +235 -0
- aetherruntime/py.typed +0 -0
- aetherruntime/recovery/__init__.py +37 -0
- aetherruntime/recovery/replay.py +271 -0
- aetherruntime/recovery/snapshot.py +199 -0
- aetherruntime/simulation/__init__.py +37 -0
- aetherruntime/simulation/network.py +216 -0
- aetherruntime/simulation/node.py +341 -0
- aetherruntime/simulation/testbed.py +262 -0
- aetherruntime_arch-2.0.0.dist-info/METADATA +748 -0
- aetherruntime_arch-2.0.0.dist-info/RECORD +30 -0
- aetherruntime_arch-2.0.0.dist-info/WHEEL +5 -0
- aetherruntime_arch-2.0.0.dist-info/entry_points.txt +2 -0
- aetherruntime_arch-2.0.0.dist-info/licenses/LICENSE +26 -0
- aetherruntime_arch-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AetherRuntime v2.0.0 — Detection: Alert Management (Layer 2)
|
|
3
|
+
|
|
4
|
+
This module provides an in-memory registry for AnomalyAlerts and
|
|
5
|
+
helpers for querying them.
|
|
6
|
+
|
|
7
|
+
The alert registry is used by the containment layer (Layer 3) to
|
|
8
|
+
look up alerts that trigger containment policies.
|
|
9
|
+
|
|
10
|
+
Invariants enforced:
|
|
11
|
+
- I9: confidence in [0.0, 1.0]
|
|
12
|
+
- I10: telemetry_id refers to existing TelemetryRecord
|
|
13
|
+
- I30: alert references existing Agent and TelemetryRecord
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections import defaultdict
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
from ..models import (
|
|
22
|
+
Agent,
|
|
23
|
+
AnomalyAlert,
|
|
24
|
+
TelemetryRecord,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
29
|
+
# Alert registry
|
|
30
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AlertRegistry:
|
|
34
|
+
"""
|
|
35
|
+
An in-memory registry of AnomalyAlerts.
|
|
36
|
+
|
|
37
|
+
Used by the containment layer to look up alerts that trigger
|
|
38
|
+
containment policies.
|
|
39
|
+
|
|
40
|
+
Indexes:
|
|
41
|
+
- by_agent: dict[agent_id → list[AnomalyAlert]]
|
|
42
|
+
- by_telemetry: dict[telemetry_id → AnomalyAlert]
|
|
43
|
+
- by_id: dict[alert_id → AnomalyAlert]
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
self._by_agent: dict[str, list[AnomalyAlert]] = defaultdict(list)
|
|
48
|
+
self._by_telemetry: dict[str, AnomalyAlert] = {}
|
|
49
|
+
self._by_id: dict[str, AnomalyAlert] = {}
|
|
50
|
+
|
|
51
|
+
def put(self, alert: AnomalyAlert) -> None:
|
|
52
|
+
"""Register an alert."""
|
|
53
|
+
if alert.alert_id in self._by_id:
|
|
54
|
+
raise ValueError(f"alert_id {alert.alert_id} already registered")
|
|
55
|
+
self._by_agent[alert.agent_id].append(alert)
|
|
56
|
+
self._by_telemetry[alert.telemetry_id] = alert
|
|
57
|
+
self._by_id[alert.alert_id] = alert
|
|
58
|
+
|
|
59
|
+
def get(self, alert_id: str) -> Optional[AnomalyAlert]:
|
|
60
|
+
"""Get an alert by ID."""
|
|
61
|
+
return self._by_id.get(alert_id)
|
|
62
|
+
|
|
63
|
+
def get_by_telemetry(self, telemetry_id: str) -> Optional[AnomalyAlert]:
|
|
64
|
+
"""Get the alert for a telemetry record."""
|
|
65
|
+
return self._by_telemetry.get(telemetry_id)
|
|
66
|
+
|
|
67
|
+
def get_by_agent(self, agent_id: str) -> list[AnomalyAlert]:
|
|
68
|
+
"""Get all alerts for an agent."""
|
|
69
|
+
return list(self._by_agent.get(agent_id, []))
|
|
70
|
+
|
|
71
|
+
def get_unresolved(self, agent_id: str) -> list[AnomalyAlert]:
|
|
72
|
+
"""Get all alerts for an agent (alias for get_by_agent)."""
|
|
73
|
+
return self.get_by_agent(agent_id)
|
|
74
|
+
|
|
75
|
+
def count(self) -> int:
|
|
76
|
+
"""Count all alerts."""
|
|
77
|
+
return len(self._by_id)
|
|
78
|
+
|
|
79
|
+
def count_by_agent(self, agent_id: str) -> int:
|
|
80
|
+
"""Count alerts for an agent."""
|
|
81
|
+
return len(self._by_agent.get(agent_id, []))
|
|
82
|
+
|
|
83
|
+
def clear(self) -> None:
|
|
84
|
+
"""Clear all alerts."""
|
|
85
|
+
self._by_agent.clear()
|
|
86
|
+
self._by_telemetry.clear()
|
|
87
|
+
self._by_id.clear()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
91
|
+
# Alert helpers
|
|
92
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def validate_alert_against_telemetry(
|
|
96
|
+
alert: AnomalyAlert,
|
|
97
|
+
telemetry: TelemetryRecord,
|
|
98
|
+
) -> bool:
|
|
99
|
+
"""
|
|
100
|
+
Validate that an alert is consistent with a telemetry record.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
True if valid, False otherwise.
|
|
104
|
+
"""
|
|
105
|
+
if alert.telemetry_id != telemetry.telemetry_id:
|
|
106
|
+
return False
|
|
107
|
+
if alert.agent_id != telemetry.agent_id:
|
|
108
|
+
return False
|
|
109
|
+
if not (0.0 <= alert.confidence <= 1.0):
|
|
110
|
+
return False
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def filter_alerts_by_confidence(
|
|
115
|
+
alerts: list[AnomalyAlert],
|
|
116
|
+
min_confidence: float = 0.5,
|
|
117
|
+
) -> list[AnomalyAlert]:
|
|
118
|
+
"""Filter alerts by minimum confidence."""
|
|
119
|
+
return [a for a in alerts if a.confidence >= min_confidence]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def group_alerts_by_agent(
|
|
123
|
+
alerts: list[AnomalyAlert],
|
|
124
|
+
) -> dict[str, list[AnomalyAlert]]:
|
|
125
|
+
"""Group alerts by agent_id."""
|
|
126
|
+
result: dict[str, list[AnomalyAlert]] = defaultdict(list)
|
|
127
|
+
for alert in alerts:
|
|
128
|
+
result[alert.agent_id].append(alert)
|
|
129
|
+
return dict(result)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def latest_alert_for_agent(
|
|
133
|
+
alerts: list[AnomalyAlert],
|
|
134
|
+
agent_id: str,
|
|
135
|
+
) -> Optional[AnomalyAlert]:
|
|
136
|
+
"""Return the latest alert for an agent (by timestamp)."""
|
|
137
|
+
agent_alerts = [a for a in alerts if a.agent_id == agent_id]
|
|
138
|
+
if not agent_alerts:
|
|
139
|
+
return None
|
|
140
|
+
from datetime import datetime
|
|
141
|
+
agent_alerts.sort(
|
|
142
|
+
key=lambda a: datetime.fromisoformat(a.timestamp.replace("Z", "+00:00")),
|
|
143
|
+
reverse=True,
|
|
144
|
+
)
|
|
145
|
+
return agent_alerts[0]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
149
|
+
# Alert statistics
|
|
150
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class AlertStats:
|
|
154
|
+
"""Aggregated statistics for a list of alerts."""
|
|
155
|
+
|
|
156
|
+
def __init__(self, alerts: list[AnomalyAlert]) -> None:
|
|
157
|
+
self.alerts = alerts
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def total(self) -> int:
|
|
161
|
+
return len(self.alerts)
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def mean_confidence(self) -> float:
|
|
165
|
+
if not self.alerts:
|
|
166
|
+
return 0.0
|
|
167
|
+
return sum(a.confidence for a in self.alerts) / len(self.alerts)
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def max_confidence(self) -> float:
|
|
171
|
+
if not self.alerts:
|
|
172
|
+
return 0.0
|
|
173
|
+
return max(a.confidence for a in self.alerts)
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def min_confidence(self) -> float:
|
|
177
|
+
if not self.alerts:
|
|
178
|
+
return 0.0
|
|
179
|
+
return min(a.confidence for a in self.alerts)
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def unique_agents(self) -> int:
|
|
183
|
+
return len(set(a.agent_id for a in self.alerts))
|
|
184
|
+
|
|
185
|
+
def __repr__(self) -> str:
|
|
186
|
+
return (
|
|
187
|
+
f"AlertStats(total={self.total}, "
|
|
188
|
+
f"mean_confidence={self.mean_confidence:.3f}, "
|
|
189
|
+
f"unique_agents={self.unique_agents})"
|
|
190
|
+
)
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AetherRuntime v2.0.0 — Detection: Anomaly Classifier (Layer 2)
|
|
3
|
+
|
|
4
|
+
This module implements Operation 3 (`detect`) from
|
|
5
|
+
docs/specification/02_operations.md.
|
|
6
|
+
|
|
7
|
+
The `detect` operation compares a telemetry record against the baseline
|
|
8
|
+
and returns an AnomalyAlert if the record deviates beyond the threshold.
|
|
9
|
+
|
|
10
|
+
Key property (Proposition 1): The verdict is a deterministic function of
|
|
11
|
+
the telemetry record and the baseline. No non-local state is consulted.
|
|
12
|
+
|
|
13
|
+
Invariants enforced:
|
|
14
|
+
- I9: confidence in [0.0, 1.0]
|
|
15
|
+
- I10: telemetry_id refers to existing TelemetryRecord
|
|
16
|
+
- I30: alert references existing Agent and TelemetryRecord
|
|
17
|
+
- O3: detect is deterministic (Proposition 1)
|
|
18
|
+
- X3: verdict reproducible from local state
|
|
19
|
+
|
|
20
|
+
Errors:
|
|
21
|
+
- E8: TelemetryNotFound
|
|
22
|
+
- E9: BaselineNotFound
|
|
23
|
+
- E10: NonDeterministic (structural; not raised at runtime)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import math
|
|
29
|
+
from typing import Optional
|
|
30
|
+
|
|
31
|
+
from ..models import (
|
|
32
|
+
AnomalyAlert,
|
|
33
|
+
Baseline,
|
|
34
|
+
BaselineModel,
|
|
35
|
+
TelemetryCategory,
|
|
36
|
+
TelemetryRecord,
|
|
37
|
+
_now_iso,
|
|
38
|
+
_random_hex,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
43
|
+
# Errors
|
|
44
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TelemetryNotFound(Exception):
|
|
48
|
+
"""E8: TelemetryRecord does not exist."""
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class BaselineNotFound(Exception):
|
|
53
|
+
"""E9: No baseline for the (agent, category) pair."""
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class NonDeterministic(Exception):
|
|
58
|
+
"""E10: Anomaly predicate depends on non-local state."""
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
63
|
+
# Anomaly predicates
|
|
64
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _anomaly_predicate(
|
|
68
|
+
telemetry: TelemetryRecord,
|
|
69
|
+
baseline: Baseline,
|
|
70
|
+
threshold: float,
|
|
71
|
+
) -> tuple[bool, float]:
|
|
72
|
+
"""
|
|
73
|
+
Pure function: (telemetry, baseline, threshold) → (is_anomalous, confidence).
|
|
74
|
+
|
|
75
|
+
This is the core of Proposition 1. The predicate reads ONLY from
|
|
76
|
+
the telemetry record and the baseline. No global state.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
telemetry: The telemetry record to evaluate.
|
|
80
|
+
baseline: The baseline model for comparison.
|
|
81
|
+
threshold: Anomaly threshold (Θ_max, typically 2.0–3.0 for z-score).
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
(is_anomalous, confidence):
|
|
85
|
+
- is_anomalous: True if the value is anomalous.
|
|
86
|
+
- confidence: [0.0, 1.0] how confident the classifier is.
|
|
87
|
+
"""
|
|
88
|
+
value = telemetry.value
|
|
89
|
+
params = baseline.parameters
|
|
90
|
+
model = baseline.model_type
|
|
91
|
+
|
|
92
|
+
# ─── Mean/std model ───
|
|
93
|
+
if model in (BaselineModel.MEAN_STD, BaselineModel.ZSCORE):
|
|
94
|
+
mean = params["mean"]
|
|
95
|
+
std = params["std"]
|
|
96
|
+
if std == 0:
|
|
97
|
+
# Degenerate case: no variance. Any deviation is anomalous.
|
|
98
|
+
deviation = abs(value - mean)
|
|
99
|
+
if deviation == 0:
|
|
100
|
+
return (False, 1.0)
|
|
101
|
+
return (True, 1.0)
|
|
102
|
+
z = (value - mean) / std
|
|
103
|
+
is_anomalous = abs(z) > threshold
|
|
104
|
+
# Confidence: sigmoid of |z| - threshold
|
|
105
|
+
confidence = _sigmoid(abs(z) - threshold)
|
|
106
|
+
return (is_anomalous, confidence)
|
|
107
|
+
|
|
108
|
+
# ─── Moving average model ───
|
|
109
|
+
elif model == BaselineModel.MOVING_AVERAGE:
|
|
110
|
+
moving_avg = params["moving_average"]
|
|
111
|
+
window = params["window"]
|
|
112
|
+
if window == 0 or moving_avg == 0:
|
|
113
|
+
return (False, 0.0)
|
|
114
|
+
deviation = abs(value - moving_avg) / abs(moving_avg)
|
|
115
|
+
is_anomalous = deviation > threshold * 0.1 # 10% * threshold
|
|
116
|
+
confidence = _sigmoid(deviation * 10 - threshold)
|
|
117
|
+
return (is_anomalous, confidence)
|
|
118
|
+
|
|
119
|
+
# ─── Seasonal model ───
|
|
120
|
+
elif model == BaselineModel.SEASONAL:
|
|
121
|
+
period = params["period"]
|
|
122
|
+
coeffs = params["coefficients"]
|
|
123
|
+
mean = params["mean"]
|
|
124
|
+
if not coeffs or mean == 0:
|
|
125
|
+
return (False, 0.0)
|
|
126
|
+
# Use the mean coefficient for a simple check
|
|
127
|
+
avg_coeff = sum(coeffs) / len(coeffs)
|
|
128
|
+
expected = mean * avg_coeff
|
|
129
|
+
deviation = abs(value - expected) / abs(expected) if expected != 0 else 0.0
|
|
130
|
+
is_anomalous = deviation > threshold * 0.1
|
|
131
|
+
confidence = _sigmoid(deviation * 10 - threshold)
|
|
132
|
+
return (is_anomalous, confidence)
|
|
133
|
+
|
|
134
|
+
else:
|
|
135
|
+
raise ValueError(f"Unknown baseline model: {model}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _sigmoid(x: float) -> float:
|
|
139
|
+
"""Sigmoid function: 1 / (1 + exp(-x))."""
|
|
140
|
+
if x < -500:
|
|
141
|
+
return 0.0
|
|
142
|
+
if x > 500:
|
|
143
|
+
return 1.0
|
|
144
|
+
return 1.0 / (1.0 + math.exp(-x))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
148
|
+
# Operation 3 — detect
|
|
149
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def detect(
|
|
153
|
+
telemetry: TelemetryRecord,
|
|
154
|
+
baseline: Optional[Baseline] = None,
|
|
155
|
+
threshold: float = 2.0,
|
|
156
|
+
) -> Optional[AnomalyAlert]:
|
|
157
|
+
"""
|
|
158
|
+
Compare a telemetry record against the baseline (Operation 3).
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
telemetry: The telemetry record to evaluate.
|
|
162
|
+
baseline: The baseline to compare against. If None, raises BaselineNotFound.
|
|
163
|
+
threshold: Anomaly threshold (Θ_max). Default 2.0 (2-sigma for z-score).
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
AnomalyAlert if anomalous, None otherwise.
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
TelemetryNotFound: If telemetry is None.
|
|
170
|
+
BaselineNotFound: If baseline is None.
|
|
171
|
+
|
|
172
|
+
Invariants:
|
|
173
|
+
I9: confidence in [0.0, 1.0].
|
|
174
|
+
I10: telemetry_id refers to the input telemetry.
|
|
175
|
+
I30: alert references agent_id from telemetry.
|
|
176
|
+
O3: deterministic (Proposition 1).
|
|
177
|
+
X3: reproducible.
|
|
178
|
+
|
|
179
|
+
Example:
|
|
180
|
+
>>> alert = detect(telemetry, baseline, threshold=2.0)
|
|
181
|
+
>>> if alert:
|
|
182
|
+
... print(f"Anomaly! confidence={alert.confidence:.2f}")
|
|
183
|
+
"""
|
|
184
|
+
# ─── E8: Telemetry must exist ───
|
|
185
|
+
if telemetry is None:
|
|
186
|
+
raise TelemetryNotFound("telemetry must not be None")
|
|
187
|
+
|
|
188
|
+
# ─── E9: Baseline must exist ───
|
|
189
|
+
if baseline is None:
|
|
190
|
+
raise BaselineNotFound(
|
|
191
|
+
f"No baseline for agent {telemetry.agent_id} "
|
|
192
|
+
f"category {telemetry.category.value}"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# ─── Verify categories match ───
|
|
196
|
+
if telemetry.category != baseline.category:
|
|
197
|
+
raise ValueError(
|
|
198
|
+
f"category mismatch: telemetry={telemetry.category.value}, "
|
|
199
|
+
f"baseline={baseline.category.value}"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# ─── O3/X3: Deterministic predicate (Proposition 1) ───
|
|
203
|
+
is_anomalous, confidence = _anomaly_predicate(telemetry, baseline, threshold)
|
|
204
|
+
|
|
205
|
+
if not is_anomalous:
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
# ─── Q1: Create AnomalyAlert ───
|
|
209
|
+
return AnomalyAlert(
|
|
210
|
+
alert_id=_random_hex(),
|
|
211
|
+
agent_id=telemetry.agent_id,
|
|
212
|
+
telemetry_id=telemetry.telemetry_id,
|
|
213
|
+
baseline_id=baseline.baseline_id,
|
|
214
|
+
confidence=confidence,
|
|
215
|
+
reason=f"value {telemetry.value:.2f} deviates from baseline "
|
|
216
|
+
f"({baseline.model_type.value})",
|
|
217
|
+
timestamp=_now_iso(),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
222
|
+
# Batch detection
|
|
223
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def detect_batch(
|
|
227
|
+
telemetry_records: list[TelemetryRecord],
|
|
228
|
+
baselines: dict[TelemetryCategory, Baseline],
|
|
229
|
+
threshold: float = 2.0,
|
|
230
|
+
) -> list[AnomalyAlert]:
|
|
231
|
+
"""
|
|
232
|
+
Detect anomalies in multiple telemetry records.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
telemetry_records: List of telemetry records.
|
|
236
|
+
baselines: Dict mapping category → Baseline.
|
|
237
|
+
threshold: Anomaly threshold.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
List of AnomalyAlert (may be empty).
|
|
241
|
+
"""
|
|
242
|
+
alerts: list[AnomalyAlert] = []
|
|
243
|
+
for record in telemetry_records:
|
|
244
|
+
baseline = baselines.get(record.category)
|
|
245
|
+
if baseline is None:
|
|
246
|
+
continue
|
|
247
|
+
alert = detect(record, baseline, threshold)
|
|
248
|
+
if alert is not None:
|
|
249
|
+
alerts.append(alert)
|
|
250
|
+
return alerts
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
254
|
+
# Detection summary
|
|
255
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class DetectionSummary:
|
|
259
|
+
"""Aggregated detection statistics."""
|
|
260
|
+
|
|
261
|
+
def __init__(
|
|
262
|
+
self,
|
|
263
|
+
total: int,
|
|
264
|
+
alerts: list[AnomalyAlert],
|
|
265
|
+
) -> None:
|
|
266
|
+
self.total = total
|
|
267
|
+
self.alerts = alerts
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def n_anomalies(self) -> int:
|
|
271
|
+
return len(self.alerts)
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def anomaly_rate(self) -> float:
|
|
275
|
+
return self.n_anomalies / self.total if self.total > 0 else 0.0
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def mean_confidence(self) -> float:
|
|
279
|
+
if not self.alerts:
|
|
280
|
+
return 0.0
|
|
281
|
+
return sum(a.confidence for a in self.alerts) / len(self.alerts)
|
|
282
|
+
|
|
283
|
+
def __repr__(self) -> str:
|
|
284
|
+
return (
|
|
285
|
+
f"DetectionSummary(total={self.total}, "
|
|
286
|
+
f"anomalies={self.n_anomalies}, "
|
|
287
|
+
f"rate={self.anomaly_rate:.3f})"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def summarize_detection(
|
|
292
|
+
telemetry_records: list[TelemetryRecord],
|
|
293
|
+
baselines: dict[TelemetryCategory, Baseline],
|
|
294
|
+
threshold: float = 2.0,
|
|
295
|
+
) -> DetectionSummary:
|
|
296
|
+
"""Run detection and return a summary."""
|
|
297
|
+
alerts = detect_batch(telemetry_records, baselines, threshold)
|
|
298
|
+
return DetectionSummary(total=len(telemetry_records), alerts=alerts)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""AetherRuntime — Halt layer (Layer 4)."""
|
|
2
|
+
|
|
3
|
+
from .order import (
|
|
4
|
+
propose_halt,
|
|
5
|
+
HaltOrderRegistry,
|
|
6
|
+
compute_halt_window,
|
|
7
|
+
validate_halt_window,
|
|
8
|
+
AgentNotFound,
|
|
9
|
+
UnauthorizedProposer,
|
|
10
|
+
InvalidSignature as OrderInvalidSignature,
|
|
11
|
+
)
|
|
12
|
+
from .quorum import (
|
|
13
|
+
approve_halt,
|
|
14
|
+
approve_batch,
|
|
15
|
+
QuorumTracker,
|
|
16
|
+
HaltNotFound,
|
|
17
|
+
InvalidSignature as QuorumInvalidSignature,
|
|
18
|
+
AlreadyApproved,
|
|
19
|
+
QuorumAlreadyReached,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"propose_halt",
|
|
24
|
+
"HaltOrderRegistry",
|
|
25
|
+
"compute_halt_window",
|
|
26
|
+
"validate_halt_window",
|
|
27
|
+
"AgentNotFound",
|
|
28
|
+
"UnauthorizedProposer",
|
|
29
|
+
"OrderInvalidSignature",
|
|
30
|
+
"approve_halt",
|
|
31
|
+
"approve_batch",
|
|
32
|
+
"QuorumTracker",
|
|
33
|
+
"HaltNotFound",
|
|
34
|
+
"QuorumInvalidSignature",
|
|
35
|
+
"AlreadyApproved",
|
|
36
|
+
"QuorumAlreadyReached",
|
|
37
|
+
]
|