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.
@@ -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
+ ]