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,282 @@
1
+ """
2
+ AetherRuntime v2.0.0 — Monitoring: Baseline Computation
3
+
4
+ This module implements Operation 2 (`update_baseline`) from
5
+ docs/specification/02_operations.md.
6
+
7
+ The `update_baseline` operation computes a statistical model of
8
+ expected behaviour for an agent's telemetry category. It is the
9
+ reference point for anomaly detection (Layer 2).
10
+
11
+ Invariants enforced:
12
+ - I7: sample_count >= 10
13
+ - I8: parameters contains required keys for model_type
14
+
15
+ Errors:
16
+ - E5: AgentNotFound
17
+ - E6: InsufficientTelemetry
18
+ - E7: InvalidWindowSize
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import statistics
24
+ from datetime import datetime, timezone
25
+ from typing import Optional
26
+
27
+ from ..models import (
28
+ Agent,
29
+ Baseline,
30
+ BaselineModel,
31
+ TelemetryCategory,
32
+ TelemetryRecord,
33
+ _now_iso,
34
+ _random_hex,
35
+ )
36
+
37
+
38
+ # ═══════════════════════════════════════════════════════════════════════════
39
+ # Errors
40
+ # ═══════════════════════════════════════════════════════════════════════════
41
+
42
+
43
+ class AgentNotFound(Exception):
44
+ """E5: Agent does not exist."""
45
+ pass
46
+
47
+
48
+ class InsufficientTelemetry(Exception):
49
+ """E6: Fewer than window_size telemetry records exist."""
50
+ pass
51
+
52
+
53
+ class InvalidWindowSize(Exception):
54
+ """E7: window_size < 10."""
55
+ pass
56
+
57
+
58
+ # ═══════════════════════════════════════════════════════════════════════════
59
+ # Operation 2 — update_baseline
60
+ # ═══════════════════════════════════════════════════════════════════════════
61
+
62
+
63
+ def update_baseline(
64
+ agent: Agent,
65
+ category: TelemetryCategory,
66
+ model_type: BaselineModel,
67
+ window_size: int,
68
+ telemetry_records: list[TelemetryRecord],
69
+ existing_baseline: Optional[Baseline] = None,
70
+ ) -> Baseline:
71
+ """
72
+ Update the baseline model for an agent's telemetry category (Operation 2).
73
+
74
+ Args:
75
+ agent: The agent to compute a baseline for.
76
+ category: Telemetry category (cpu, memory, network, syscall, action).
77
+ model_type: Baseline model type (mean_std, zscore, moving_average, seasonal).
78
+ window_size: Number of recent telemetry records to use.
79
+ telemetry_records: All telemetry records for the agent.
80
+ existing_baseline: Optional existing baseline (to preserve baseline_id).
81
+
82
+ Returns:
83
+ A new (or updated) `Baseline` with computed parameters.
84
+
85
+ Raises:
86
+ AgentNotFound: If agent is None.
87
+ InvalidWindowSize: If window_size < 10.
88
+ InsufficientTelemetry: If fewer than window_size records exist.
89
+
90
+ Invariants:
91
+ I7: sample_count >= 10.
92
+ I8: parameters contains all required keys for model_type.
93
+
94
+ Example:
95
+ >>> agent = create_agent(public_key=...)
96
+ >>> records = [collect(agent, TelemetryCategory.CPU, 45.0) for _ in range(10)]
97
+ >>> baseline = update_baseline(agent, TelemetryCategory.CPU,
98
+ ... BaselineModel.MEAN_STD, 10, records)
99
+ >>> baseline.sample_count
100
+ 10
101
+ """
102
+ # ─── P1: Agent exists ───
103
+ if agent is None:
104
+ raise AgentNotFound("agent must not be None")
105
+
106
+ # ─── P2: window_size >= 10 (I7) ───
107
+ if window_size < 10:
108
+ raise InvalidWindowSize(
109
+ f"window_size must be >= 10, got {window_size}"
110
+ )
111
+
112
+ # ─── P3: Enough telemetry records ───
113
+ relevant = [r for r in telemetry_records if r.category == category]
114
+ if len(relevant) < window_size:
115
+ raise InsufficientTelemetry(
116
+ f"need >= {window_size} records for category {category.value}, "
117
+ f"got {len(relevant)}"
118
+ )
119
+
120
+ # Use the most recent `window_size` records
121
+ recent = relevant[-window_size:]
122
+ values = [r.value for r in recent]
123
+
124
+ # ─── Compute parameters based on model_type ───
125
+ parameters = _compute_parameters(model_type, values)
126
+
127
+ # ─── Create or update baseline ───
128
+ baseline_id = existing_baseline.baseline_id if existing_baseline else _random_hex()
129
+
130
+ return Baseline(
131
+ baseline_id=baseline_id,
132
+ agent_id=agent.agent_id,
133
+ category=category,
134
+ model_type=model_type,
135
+ parameters=parameters,
136
+ computed_at=_now_iso(),
137
+ sample_count=len(recent),
138
+ )
139
+
140
+
141
+ def _compute_parameters(model_type: BaselineModel, values: list[float]) -> dict:
142
+ """Compute model parameters from a list of values."""
143
+ if model_type == BaselineModel.MEAN_STD:
144
+ return {
145
+ "mean": statistics.mean(values),
146
+ "std": statistics.stdev(values) if len(values) > 1 else 0.0,
147
+ }
148
+
149
+ elif model_type == BaselineModel.ZSCORE:
150
+ mean = statistics.mean(values)
151
+ std = statistics.stdev(values) if len(values) > 1 else 0.0
152
+ return {
153
+ "mean": mean,
154
+ "std": std,
155
+ "z_scores": [(v - mean) / std if std > 0 else 0.0 for v in values],
156
+ }
157
+
158
+ elif model_type == BaselineModel.MOVING_AVERAGE:
159
+ return {
160
+ "window": len(values),
161
+ "values": values,
162
+ "moving_average": statistics.mean(values),
163
+ }
164
+
165
+ elif model_type == BaselineModel.SEASONAL:
166
+ # Simple seasonal decomposition: period = 4 (quarterly)
167
+ period = 4
168
+ return {
169
+ "period": period,
170
+ "coefficients": _seasonal_coefficients(values, period),
171
+ "mean": statistics.mean(values),
172
+ }
173
+
174
+ else:
175
+ raise ValueError(f"Unknown model_type: {model_type}")
176
+
177
+
178
+ def _seasonal_coefficients(values: list[float], period: int) -> list[float]:
179
+ """Compute simple seasonal coefficients."""
180
+ if len(values) < period:
181
+ return [1.0] * period
182
+ overall_mean = statistics.mean(values)
183
+ if overall_mean == 0:
184
+ return [1.0] * period
185
+ coefficients = []
186
+ for i in range(period):
187
+ seasonal_values = values[i::period]
188
+ if seasonal_values:
189
+ coefficients.append(statistics.mean(seasonal_values) / overall_mean)
190
+ else:
191
+ coefficients.append(1.0)
192
+ return coefficients
193
+
194
+
195
+ # ═══════════════════════════════════════════════════════════════════════════
196
+ # Batch baseline helper
197
+ # ═══════════════════════════════════════════════════════════════════════════
198
+
199
+
200
+ def update_all_baselines(
201
+ agent: Agent,
202
+ telemetry_records: list[TelemetryRecord],
203
+ model_type: BaselineModel = BaselineModel.MEAN_STD,
204
+ window_size: int = 10,
205
+ ) -> dict[TelemetryCategory, Baseline]:
206
+ """
207
+ Update baselines for all telemetry categories with sufficient data.
208
+
209
+ Args:
210
+ agent: The agent.
211
+ telemetry_records: All telemetry records.
212
+ model_type: Model type (default: mean_std).
213
+ window_size: Window size (default: 10).
214
+
215
+ Returns:
216
+ Dict mapping category → Baseline (only for categories with enough data).
217
+ """
218
+ baselines: dict[TelemetryCategory, Baseline] = {}
219
+ for category in TelemetryCategory:
220
+ relevant = [r for r in telemetry_records if r.category == category]
221
+ if len(relevant) >= window_size:
222
+ try:
223
+ baseline = update_baseline(
224
+ agent=agent,
225
+ category=category,
226
+ model_type=model_type,
227
+ window_size=window_size,
228
+ telemetry_records=telemetry_records,
229
+ )
230
+ baselines[category] = baseline
231
+ except (InsufficientTelemetry, InvalidWindowSize):
232
+ continue
233
+ return baselines
234
+
235
+
236
+ # ═══════════════════════════════════════════════════════════════════════════
237
+ # Baseline registry
238
+ # ═══════════════════════════════════════════════════════════════════════════
239
+
240
+
241
+ class BaselineRegistry:
242
+ """
243
+ An in-memory registry of baselines.
244
+
245
+ Used by the detection layer (Layer 2) to look up the baseline
246
+ for a given (agent, category) pair.
247
+ """
248
+
249
+ def __init__(self) -> None:
250
+ self._baselines: dict[tuple[str, TelemetryCategory], Baseline] = {}
251
+
252
+ def put(self, baseline: Baseline) -> None:
253
+ """Register a baseline."""
254
+ key = (baseline.agent_id, baseline.category)
255
+ self._baselines[key] = baseline
256
+
257
+ def get(
258
+ self, agent_id: str, category: TelemetryCategory
259
+ ) -> Optional[Baseline]:
260
+ """Get the baseline for an agent and category."""
261
+ return self._baselines.get((agent_id, category))
262
+
263
+ def get_all(self, agent_id: str) -> dict[TelemetryCategory, Baseline]:
264
+ """Get all baselines for an agent."""
265
+ return {
266
+ cat: bl
267
+ for (aid, cat), bl in self._baselines.items()
268
+ if aid == agent_id
269
+ }
270
+
271
+ def has(self, agent_id: str, category: TelemetryCategory) -> bool:
272
+ """Check if a baseline exists."""
273
+ return (agent_id, category) in self._baselines
274
+
275
+
276
+ # ═══════════════════════════════════════════════════════════════════════════
277
+ # Module-level singleton (optional)
278
+ # ═══════════════════════════════════════════════════════════════════════════
279
+
280
+
281
+ default_baseline_registry = BaselineRegistry()
282
+ """Default baseline registry (can be replaced per-test)."""
@@ -0,0 +1,235 @@
1
+ """
2
+ AetherRuntime v2.0.0 — Monitoring: Telemetry Collection
3
+
4
+ This module implements Operation 1 (`collect`) from
5
+ docs/specification/02_operations.md.
6
+
7
+ The `collect` operation gathers a telemetry record from a running agent.
8
+ It is the input to the detection layer (Layer 2).
9
+
10
+ Invariants enforced:
11
+ - I4: telemetry_id is unique (via _random_hex)
12
+ - I5: value is finite
13
+ - I6: timestamp is monotonic per agent
14
+
15
+ Errors:
16
+ - E2: AgentNotRunning
17
+ - E3: InvalidValue
18
+ - E4: NonMonotonicTimestamp
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Optional
24
+
25
+ from ..models import (
26
+ Agent,
27
+ AgentStatus,
28
+ TelemetryCategory,
29
+ TelemetryRecord,
30
+ _now_iso,
31
+ _random_hex,
32
+ )
33
+
34
+
35
+ # ═══════════════════════════════════════════════════════════════════════════
36
+ # Errors
37
+ # ═══════════════════════════════════════════════════════════════════════════
38
+
39
+
40
+ class AgentNotRunning(Exception):
41
+ """E2: Agent is not in `running` state."""
42
+ pass
43
+
44
+
45
+ class InvalidValue(Exception):
46
+ """E3: Value is not finite."""
47
+ pass
48
+
49
+
50
+ class NonMonotonicTimestamp(Exception):
51
+ """E4: Timestamp is not monotonic per agent."""
52
+ pass
53
+
54
+
55
+ # ═══════════════════════════════════════════════════════════════════════════
56
+ # Operation 1 — collect
57
+ # ═══════════════════════════════════════════════════════════════════════════
58
+
59
+
60
+ def collect(
61
+ agent: Agent,
62
+ category: TelemetryCategory,
63
+ value: float,
64
+ timestamp: Optional[str] = None,
65
+ metadata: Optional[dict] = None,
66
+ previous_timestamp: Optional[str] = None,
67
+ ) -> TelemetryRecord:
68
+ """
69
+ Collect a telemetry record from a running agent (Operation 1).
70
+
71
+ Args:
72
+ agent: The agent to collect from. Must be in `running` state.
73
+ category: Telemetry category (cpu, memory, network, syscall, action).
74
+ value: Numeric value (unit depends on category). Must be finite.
75
+ timestamp: Logical time of observation (ISO 8601). Defaults to now.
76
+ metadata: Optional metadata (pid, tid, etc.).
77
+ previous_timestamp: Optional previous timestamp for monotonicity check.
78
+
79
+ Returns:
80
+ A new `TelemetryRecord` with a unique `telemetry_id`.
81
+
82
+ Raises:
83
+ AgentNotRunning: If agent.status != AgentStatus.RUNNING.
84
+ InvalidValue: If value is NaN or Inf.
85
+ NonMonotonicTimestamp: If timestamp < previous_timestamp.
86
+
87
+ Invariants:
88
+ I4: telemetry_id is unique (via _random_hex).
89
+ I5: value is finite.
90
+ I6: timestamp is monotonic per agent (if previous_timestamp provided).
91
+
92
+ Example:
93
+ >>> agent = create_agent(public_key=...)
94
+ >>> agent.status = AgentStatus.RUNNING
95
+ >>> record = collect(agent, TelemetryCategory.CPU, 45.2)
96
+ >>> record.value
97
+ 45.2
98
+ """
99
+ # ─── P1: Agent must be running ───
100
+ if agent.status != AgentStatus.RUNNING:
101
+ raise AgentNotRunning(
102
+ f"Agent {agent.agent_id} is not running (status: {agent.status.value})"
103
+ )
104
+
105
+ # ─── P2: Value must be finite (I5) ───
106
+ if not isinstance(value, (int, float)):
107
+ raise InvalidValue(f"value must be int or float, got {type(value)}")
108
+ if value != value: # NaN check
109
+ raise InvalidValue("value must not be NaN")
110
+ if value == float("inf") or value == float("-inf"):
111
+ raise InvalidValue("value must not be Inf")
112
+
113
+ # ─── P3: Timestamp must be monotonic (I6) ───
114
+ if timestamp is None:
115
+ timestamp = _now_iso()
116
+ if previous_timestamp is not None:
117
+ from datetime import datetime
118
+ try:
119
+ current_dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
120
+ previous_dt = datetime.fromisoformat(previous_timestamp.replace("Z", "+00:00"))
121
+ except (ValueError, AttributeError) as e:
122
+ raise NonMonotonicTimestamp(f"Invalid timestamp format: {e}")
123
+ if current_dt < previous_dt:
124
+ raise NonMonotonicTimestamp(
125
+ f"timestamp {timestamp} is before previous_timestamp {previous_timestamp}"
126
+ )
127
+
128
+ # ─── Q1–Q3: Create the record ───
129
+ return TelemetryRecord(
130
+ telemetry_id=_random_hex(),
131
+ agent_id=agent.agent_id,
132
+ category=category,
133
+ value=value,
134
+ timestamp=timestamp,
135
+ metadata=metadata or {},
136
+ )
137
+
138
+
139
+ # ═══════════════════════════════════════════════════════════════════════════
140
+ # Batch collection helper
141
+ # ═══════════════════════════════════════════════════════════════════════════
142
+
143
+
144
+ def collect_batch(
145
+ agent: Agent,
146
+ records: list[tuple[TelemetryCategory, float]],
147
+ ) -> list[TelemetryRecord]:
148
+ """
149
+ Collect multiple telemetry records in one call.
150
+
151
+ Args:
152
+ agent: The agent to collect from.
153
+ records: List of (category, value) tuples.
154
+
155
+ Returns:
156
+ List of TelemetryRecord, one per input tuple.
157
+
158
+ Raises:
159
+ AgentNotRunning: If agent.status != AgentStatus.RUNNING.
160
+ InvalidValue: If any value is NaN or Inf.
161
+ """
162
+ from datetime import datetime, timezone
163
+
164
+ results: list[TelemetryRecord] = []
165
+ previous: Optional[str] = None
166
+
167
+ for i, (category, value) in enumerate(records):
168
+ # Use microsecond-precision timestamps to ensure monotonicity
169
+ ts = datetime.now(timezone.utc).isoformat()
170
+ # Small artificial delay to ensure monotonicity
171
+ if previous is not None:
172
+ ts = datetime.now(timezone.utc).isoformat()
173
+ record = collect(
174
+ agent=agent,
175
+ category=category,
176
+ value=value,
177
+ timestamp=ts,
178
+ previous_timestamp=previous,
179
+ )
180
+ results.append(record)
181
+ previous = ts
182
+
183
+ return results
184
+
185
+
186
+ # ═══════════════════════════════════════════════════════════════════════════
187
+ # Telemetry log helper
188
+ # ═══════════════════════════════════════════════════════════════════════════
189
+
190
+
191
+ class TelemetryLog:
192
+ """
193
+ An in-memory log of telemetry records for an agent.
194
+
195
+ Used by the detection layer (Layer 2) and baseline computation.
196
+ """
197
+
198
+ def __init__(self) -> None:
199
+ self._records: dict[str, list[TelemetryRecord]] = {}
200
+
201
+ def append(self, record: TelemetryRecord) -> None:
202
+ """Append a record to the log (grouped by agent_id)."""
203
+ if record.agent_id not in self._records:
204
+ self._records[record.agent_id] = []
205
+ self._records[record.agent_id].append(record)
206
+
207
+ def get(self, agent_id: str) -> list[TelemetryRecord]:
208
+ """Get all records for an agent."""
209
+ return list(self._records.get(agent_id, []))
210
+
211
+ def get_by_category(
212
+ self, agent_id: str, category: TelemetryCategory
213
+ ) -> list[TelemetryRecord]:
214
+ """Get all records for an agent and category."""
215
+ return [
216
+ r for r in self._records.get(agent_id, []) if r.category == category
217
+ ]
218
+
219
+ def last_timestamp(self, agent_id: str) -> Optional[str]:
220
+ """Get the last timestamp for an agent (for monotonicity check)."""
221
+ records = self._records.get(agent_id, [])
222
+ return records[-1].timestamp if records else None
223
+
224
+ def count(self, agent_id: str) -> int:
225
+ """Count records for an agent."""
226
+ return len(self._records.get(agent_id, []))
227
+
228
+
229
+ # ═══════════════════════════════════════════════════════════════════════════
230
+ # Module-level singleton (optional)
231
+ # ═══════════════════════════════════════════════════════════════════════════
232
+
233
+
234
+ default_telemetry_log = TelemetryLog()
235
+ """Default telemetry log (can be replaced per-test)."""
aetherruntime/py.typed ADDED
File without changes
@@ -0,0 +1,37 @@
1
+ """AetherRuntime — Recovery layer (Layer 5)."""
2
+
3
+ from .snapshot import (
4
+ create_snapshot,
5
+ verify_snapshot,
6
+ SnapshotRegistry,
7
+ SnapshotNotFound as SnapshotSnapshotNotFound,
8
+ HashMismatch,
9
+ get_snapshot_hash,
10
+ compare_snapshots,
11
+ )
12
+ from .replay import (
13
+ recover,
14
+ RecoveryRegistry,
15
+ RecoveryStats,
16
+ AgentNotHalted,
17
+ SnapshotNotFound as ReplaySnapshotNotFound,
18
+ UnauthorizedReauthorizer,
19
+ VerificationFailed,
20
+ )
21
+
22
+ __all__ = [
23
+ "create_snapshot",
24
+ "verify_snapshot",
25
+ "SnapshotRegistry",
26
+ "SnapshotSnapshotNotFound",
27
+ "HashMismatch",
28
+ "get_snapshot_hash",
29
+ "compare_snapshots",
30
+ "recover",
31
+ "RecoveryRegistry",
32
+ "RecoveryStats",
33
+ "AgentNotHalted",
34
+ "ReplaySnapshotNotFound",
35
+ "UnauthorizedReauthorizer",
36
+ "VerificationFailed",
37
+ ]