Yaswanth-Bolla commited on
Commit
1175c0b
Β·
0 Parent(s):

Initial commit

Browse files
Files changed (41) hide show
  1. .dockerignore +9 -0
  2. Dockerfile +21 -0
  3. README.md +193 -0
  4. __init__.py +32 -0
  5. __pycache__/__init__.cpython-313.pyc +0 -0
  6. __pycache__/models.cpython-313.pyc +0 -0
  7. __pycache__/tasks.cpython-313.pyc +0 -0
  8. client.py +84 -0
  9. inference.py +313 -0
  10. models.py +199 -0
  11. openenv.yaml +6 -0
  12. pyproject.toml +26 -0
  13. scenarios/__init__.py +1 -0
  14. scenarios/__pycache__/__init__.cpython-313.pyc +0 -0
  15. scenarios/__pycache__/base.cpython-313.pyc +0 -0
  16. scenarios/__pycache__/easy_memory_leak.cpython-313.pyc +0 -0
  17. scenarios/__pycache__/hard_distributed_deadlock.cpython-313.pyc +0 -0
  18. scenarios/__pycache__/medium_cascading_failure.cpython-313.pyc +0 -0
  19. scenarios/base.py +205 -0
  20. scenarios/easy_memory_leak.py +126 -0
  21. scenarios/hard_distributed_deadlock.py +192 -0
  22. scenarios/medium_cascading_failure.py +156 -0
  23. server/__init__.py +1 -0
  24. server/__pycache__/__init__.cpython-313.pyc +0 -0
  25. server/__pycache__/app.cpython-313.pyc +0 -0
  26. server/__pycache__/incident_environment.cpython-313.pyc +0 -0
  27. server/app.py +111 -0
  28. server/incident_environment.py +449 -0
  29. simulation/__init__.py +1 -0
  30. simulation/__pycache__/__init__.cpython-313.pyc +0 -0
  31. simulation/__pycache__/alerts.cpython-313.pyc +0 -0
  32. simulation/__pycache__/infrastructure.cpython-313.pyc +0 -0
  33. simulation/__pycache__/logs.cpython-313.pyc +0 -0
  34. simulation/__pycache__/metrics.cpython-313.pyc +0 -0
  35. simulation/__pycache__/service.cpython-313.pyc +0 -0
  36. simulation/alerts.py +145 -0
  37. simulation/infrastructure.py +297 -0
  38. simulation/logs.py +198 -0
  39. simulation/metrics.py +147 -0
  40. simulation/service.py +335 -0
  41. tasks.py +47 -0
.dockerignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__
2
+ *.pyc
3
+ *.pyo
4
+ .git
5
+ .gitignore
6
+ *.md
7
+ outputs/
8
+ .env
9
+ .venv
Dockerfile ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install dependencies
6
+ COPY pyproject.toml /app/
7
+ RUN pip install --no-cache-dir fastapi uvicorn[standard] pydantic websockets openai requests
8
+
9
+ # Copy environment code
10
+ COPY . /app/incident_env/
11
+
12
+ # Set Python path
13
+ ENV PYTHONPATH=/app
14
+
15
+ # Health check
16
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
17
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
18
+
19
+ EXPOSE 8000
20
+
21
+ CMD ["uvicorn", "incident_env.server.app:app", "--host", "0.0.0.0", "--port", "8000"]
README.md ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: SRE Incident Response Simulator
3
+ emoji: 🚨
4
+ colorFrom: red
5
+ colorTo: gray
6
+ sdk: docker
7
+ app_port: 8000
8
+ pinned: false
9
+ ---
10
+
11
+ # 🚨 SRE Incident Response Simulator
12
+
13
+ An OpenEnv environment where AI agents must diagnose and remediate production incidents across a simulated microservices architecture.
14
+
15
+ ## Why This Environment Matters
16
+
17
+ This is a **POMDP** (Partially Observable Markov Decision Process). The agent never sees the root cause β€” it sees _symptoms_: climbing memory metrics, cascading error logs, firing alerts. It must gather evidence, form hypotheses, and act β€” exactly like a real SRE at 3 AM.
18
+
19
+ | Dimension | Detail |
20
+ |-----------|--------|
21
+ | **Observation** | Alerts, metric timeseries, structured logs, dependency graphs, deploy history |
22
+ | **Action space** | 10 hierarchical action types Γ— 7 target services = rich combinatorics |
23
+ | **Difficulty** | Easy (single-service leak) β†’ Medium (cascading failure) β†’ Hard (distributed deadlock) |
24
+ | **Reward** | Oracle-shaped per-step signal for training + oracle-independent grader for evaluation |
25
+ | **Realism** | Reactive simulation β€” memory climbs over time, cascades propagate, restarts don't fix root causes |
26
+
27
+ ---
28
+
29
+ ## Architecture
30
+
31
+ ```
32
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
33
+ β”‚ SIMULATED INFRASTRUCTURE β”‚
34
+ β”‚ β”‚
35
+ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
36
+ β”‚ β”‚ API GW │────►│ Auth │────►│ Orders │────►│ Payment β”‚ β”‚
37
+ β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β”‚
38
+ β”‚ β”‚ β”‚ β”‚ β”‚
39
+ β”‚ β–Ό β–Ό β–Ό β”‚
40
+ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
41
+ β”‚ β”‚ Cache β”‚ β”‚ DB β”‚ β”‚ Queue β”‚ β”‚
42
+ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
43
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
44
+ ```
45
+
46
+ 7 services with reactive metrics, logs, alerts, and dependency-aware cascade propagation.
47
+
48
+ ---
49
+
50
+ ## Action Space (Hierarchical)
51
+
52
+ ### Level 1: Action Type
53
+
54
+ | Action | Category | Description |
55
+ |--------|----------|------------|
56
+ | `view_alerts` | Diagnostic | See all firing alerts |
57
+ | `query_logs` | Diagnostic | Query service logs (with level/keyword filters) |
58
+ | `check_metrics` | Diagnostic | Get 30-minute metric timeseries |
59
+ | `check_dependencies` | Diagnostic | View upstream/downstream dependency map |
60
+ | `check_deploy_history` | Diagnostic | Recent deploys for a service |
61
+ | `run_health_check` | Diagnostic | Ping a service for status |
62
+ | `restart_service` | Remediation | Restart (fixes symptoms temporarily, not root cause) |
63
+ | `rollback_deploy` | Remediation | Rollback to previous deploy version |
64
+ | `scale_service` | Remediation | Scale replicas up/down |
65
+ | `declare_root_cause` | Terminal | Submit diagnosis β€” ends episode |
66
+
67
+ ### Level 2: Target Service + Parameters
68
+ Targeted actions require `target_service` from: `api_gateway`, `auth`, `orders`, `payment`, `cache`, `database`, `queue`.
69
+
70
+ ### Action Masking
71
+ The observation includes `valid_actions[]` β€” illegal actions (e.g., rollback on a service with no deploy history) are rejected with a penalty.
72
+
73
+ ---
74
+
75
+ ## Observation Space (POMDP)
76
+
77
+ The agent **never** sees: `fault_type`, `is_bad` deploy flag, or internal simulation state.
78
+
79
+ It **does** see:
80
+ - **Incident summary** and severity
81
+ - **Service statuses** (healthy/degraded/down)
82
+ - **Active alert count**
83
+ - **Action result** (data from the last action: logs, metrics, alerts, etc.)
84
+ - **Valid actions** (action mask)
85
+ - **Time elapsed / budget** (SLA pressure)
86
+ - **Cumulative reward** and step count
87
+
88
+ ---
89
+
90
+ ## Tasks
91
+
92
+ | Task | Description | Difficulty | Root Cause |
93
+ |------|-------------|-----------|------------|
94
+ | `memory_leak` | Orders service OOM from bad deploy | Easy | Rollback orders deploy v2.3.1 |
95
+ | `cascading_failure` | Auth config change cascading to API GW + orders | Medium | Rollback auth deploy, restart dependents |
96
+ | `distributed_deadlock` | Payment retry change creates circular wait | Hard | Rollback payment, scale queue, restart orders |
97
+
98
+ ---
99
+
100
+ ## Reward Design (Two-Layer)
101
+
102
+ ### Layer 1: Per-Step Training Rewards (Oracle-Shaped)
103
+ These rewards peek at hidden state to guide RL training:
104
+
105
+ | Action Category | Condition | Reward |
106
+ |----------------|-----------|--------|
107
+ | Diagnostic | Investigating involved service | +0.15 |
108
+ | Diagnostic | Investigating uninvolved service | +0.05 |
109
+ | Any | Repeating a previous action | -0.05 |
110
+ | Remediation | Correct target (root cause service) | +0.30 |
111
+ | Remediation | Helpful (affected, not root cause) | +0.10 |
112
+ | Remediation | Harmful (healthy service) | -0.15 |
113
+ | Declaration | Correct root cause | +0.40 |
114
+ | Declaration | Wrong root cause | -0.20 |
115
+ | Any | Per-step efficiency penalty | -0.02 |
116
+ | Completion | All services healthy | +0.20 |
117
+ | Completion | Time budget exceeded | -0.10 |
118
+
119
+ ### Layer 2: Evaluation Grader (Oracle-Independent)
120
+ The grader scores only the trajectory β€” no hidden state access:
121
+
122
+ | Criterion | Weight | What it measures |
123
+ |-----------|--------|-----------------|
124
+ | Root cause accuracy | 40% | Did the agent declare the correct root cause? |
125
+ | Remediation quality | 30% | Did the agent take the right fix actions? |
126
+ | Diagnostic efficiency | 20% | Fewer steps to diagnosis = better |
127
+ | Service restoration | 10% | Are all services healthy at episode end? |
128
+
129
+ ---
130
+
131
+ ## Quick Start
132
+
133
+ ### Local Development
134
+
135
+ ```bash
136
+ # Install dependencies
137
+ cd incident_env
138
+ pip install -e .
139
+
140
+ # Start server
141
+ uvicorn incident_env.server.app:app --host 0.0.0.0 --port 8000
142
+
143
+ # Test endpoints
144
+ curl http://localhost:8000/health
145
+ curl -X POST http://localhost:8000/reset -H "Content-Type: application/json" -d '{"task_name": "memory_leak"}'
146
+ curl -X POST http://localhost:8000/step -H "Content-Type: application/json" -d '{"action_type": "view_alerts"}'
147
+ ```
148
+
149
+ ### Run Inference
150
+
151
+ ```bash
152
+ export OPENAI_API_KEY=sk-...
153
+ export MODEL_NAME=gpt-4o-mini
154
+ export API_BASE_URL=http://localhost:8000
155
+
156
+ python inference.py
157
+ ```
158
+
159
+ ### Docker
160
+
161
+ ```bash
162
+ docker build -t incident-env -f server/Dockerfile .
163
+ docker run -p 8000:8000 incident-env
164
+ ```
165
+
166
+ ---
167
+
168
+ ## Example Agent Interaction
169
+
170
+ ```
171
+ Agent: POST /reset {"task_name": "memory_leak"}
172
+ β†’ Incident triggered: "Orders service experiencing failures..."
173
+ β†’ Services: orders=degraded, rest=healthy
174
+
175
+ Agent: POST /step {"action_type": "view_alerts"}
176
+ β†’ 3 alerts: orders HighMemoryUsage (critical), orders HighErrorRate, orders HighLatencyP99
177
+ β†’ reward = +0.13
178
+
179
+ Agent: POST /step {"action_type": "check_metrics", "target_service": "orders"}
180
+ β†’ 30 data points: memory climbing from 35% β†’ 78% over 20 minutes
181
+ β†’ reward = +0.13
182
+
183
+ Agent: POST /step {"action_type": "check_deploy_history", "target_service": "orders"}
184
+ β†’ 2 deploys: v2.3.1 (20 min ago, "batch order processing") and v1.2.0
185
+ β†’ reward = +0.13
186
+
187
+ Agent: POST /step {"action_type": "rollback_deploy", "target_service": "orders"}
188
+ β†’ "Rolled back orders from v2.3.1 to v1.2.0 β€” service recovering"
189
+ β†’ reward = +0.28
190
+
191
+ Agent: POST /step {"action_type": "declare_root_cause", "parameters": {"root_cause": "memory leak in orders caused by bad deploy v2.3.1"}}
192
+ β†’ Episode done. Final grade: 0.97
193
+ ```
__init__.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SRE Incident Response Simulator β€” OpenEnv Environment.
3
+
4
+ A POMDP environment where an AI agent must diagnose and remediate
5
+ production incidents across a simulated microservices architecture.
6
+ """
7
+
8
+ from .models import (
9
+ ActionType,
10
+ IncidentAction,
11
+ IncidentObservation,
12
+ IncidentState,
13
+ StepRecord,
14
+ AlertInfo,
15
+ MetricSnapshot,
16
+ LogEntry,
17
+ DeployRecord,
18
+ DependencyInfo,
19
+ )
20
+
21
+ __all__ = [
22
+ "ActionType",
23
+ "IncidentAction",
24
+ "IncidentObservation",
25
+ "IncidentState",
26
+ "StepRecord",
27
+ "AlertInfo",
28
+ "MetricSnapshot",
29
+ "LogEntry",
30
+ "DeployRecord",
31
+ "DependencyInfo",
32
+ ]
__pycache__/__init__.cpython-313.pyc ADDED
Binary file (672 Bytes). View file
 
__pycache__/models.cpython-313.pyc ADDED
Binary file (7.58 kB). View file
 
__pycache__/tasks.cpython-313.pyc ADDED
Binary file (1.87 kB). View file
 
client.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ EnvClient subclass for the SRE Incident Response environment.
3
+
4
+ Handles WebSocket/HTTP communication with the environment server.
5
+ Parses responses into typed models.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Dict, Optional
11
+
12
+ from .models import IncidentAction, IncidentObservation, IncidentState
13
+
14
+
15
+ class IncidentEnvClient:
16
+ """
17
+ Client for interacting with the Incident Response environment.
18
+
19
+ Can be used standalone (direct HTTP calls) or subclassed from
20
+ openenv.core.EnvClient for full framework integration.
21
+ """
22
+
23
+ def __init__(self, base_url: str = "http://localhost:8000") -> None:
24
+ self.base_url = base_url.rstrip("/")
25
+ self._session = None
26
+
27
+ def _ensure_session(self):
28
+ if self._session is None:
29
+ import requests
30
+ self._session = requests.Session()
31
+
32
+ def reset(
33
+ self,
34
+ task_name: Optional[str] = None,
35
+ seed: Optional[int] = None,
36
+ ) -> Dict[str, Any]:
37
+ """Reset the environment. Returns initial observation."""
38
+ self._ensure_session()
39
+ payload: Dict[str, Any] = {}
40
+ if task_name:
41
+ payload["task_name"] = task_name
42
+ if seed is not None:
43
+ payload["seed"] = seed
44
+
45
+ resp = self._session.post(f"{self.base_url}/reset", json=payload)
46
+ resp.raise_for_status()
47
+ return resp.json()
48
+
49
+ def step(self, action: IncidentAction) -> Dict[str, Any]:
50
+ """Execute an action. Returns observation, reward, done."""
51
+ self._ensure_session()
52
+ payload = {
53
+ "action_type": action.action_type,
54
+ "target_service": action.target_service,
55
+ "parameters": action.parameters,
56
+ }
57
+ resp = self._session.post(f"{self.base_url}/step", json=payload)
58
+ resp.raise_for_status()
59
+ return resp.json()
60
+
61
+ def state(self) -> Dict[str, Any]:
62
+ """Get current episode state."""
63
+ self._ensure_session()
64
+ resp = self._session.get(f"{self.base_url}/state")
65
+ resp.raise_for_status()
66
+ return resp.json()
67
+
68
+ def health(self) -> Dict[str, str]:
69
+ """Health check."""
70
+ self._ensure_session()
71
+ resp = self._session.get(f"{self.base_url}/health")
72
+ resp.raise_for_status()
73
+ return resp.json()
74
+
75
+ def close(self) -> None:
76
+ if self._session:
77
+ self._session.close()
78
+ self._session = None
79
+
80
+ def __enter__(self):
81
+ return self
82
+
83
+ def __exit__(self, *args):
84
+ self.close()
inference.py ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Baseline inference script.
3
+
4
+ Uses an LLM (via OpenAI-compatible API) to play through all 3 incident
5
+ scenarios. The conversation history acts as a soft belief tracker β€”
6
+ the LLM accumulates evidence across steps.
7
+
8
+ stdout format: [START], [STEP], [END] blocks with exact field names
9
+ as required by the OpenEnv automated evaluator.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import sys
17
+ import time
18
+ import traceback
19
+ from typing import Any, Dict, List, Optional
20
+
21
+ import requests
22
+ from openai import OpenAI
23
+
24
+
25
+ # ------------------------------------------------------------------
26
+ # Config
27
+ # ------------------------------------------------------------------
28
+
29
+ API_BASE_URL = os.environ.get("API_BASE_URL", "http://localhost:8000")
30
+ MODEL_NAME = os.environ.get("MODEL_NAME", "gpt-4o-mini")
31
+ HF_TOKEN = os.environ.get("HF_TOKEN", "")
32
+ OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
33
+ MAX_STEPS = 20
34
+ TEMPERATURE = 0.3
35
+
36
+
37
+ # ------------------------------------------------------------------
38
+ # System prompt β€” Layer 3: the LLM acts as an SRE
39
+ # ------------------------------------------------------------------
40
+
41
+ SYSTEM_PROMPT = """You are an expert Site Reliability Engineer (SRE) responding to a production incident.
42
+
43
+ You are interacting with a simulated microservices infrastructure through an environment API.
44
+ Your goal is to:
45
+ 1. DIAGNOSE the root cause of the incident
46
+ 2. REMEDIATE the issue (fix it)
47
+ 3. DECLARE the root cause when confident
48
+
49
+ ## Available Actions
50
+ You must respond with a single JSON object containing your chosen action:
51
+
52
+ DIAGNOSTIC (information gathering):
53
+ - {"action_type": "view_alerts"} β€” See all firing alerts
54
+ - {"action_type": "query_logs", "target_service": "<name>", "parameters": {"level": "ERROR"}} β€” Query logs
55
+ - {"action_type": "check_metrics", "target_service": "<name>"} β€” Get metric timeseries
56
+ - {"action_type": "check_dependencies", "target_service": "<name>"} β€” View dependency graph
57
+ - {"action_type": "check_deploy_history", "target_service": "<name>"} β€” Recent deploys
58
+ - {"action_type": "run_health_check", "target_service": "<name>"} β€” Ping a service
59
+
60
+ REMEDIATION (fix actions):
61
+ - {"action_type": "restart_service", "target_service": "<name>"} β€” Restart a service
62
+ - {"action_type": "rollback_deploy", "target_service": "<name>"} β€” Rollback to previous deploy
63
+ - {"action_type": "scale_service", "target_service": "<name>", "parameters": {"replicas": 5}} β€” Scale replicas
64
+
65
+ DECLARATION:
66
+ - {"action_type": "declare_root_cause", "parameters": {"root_cause": "<your diagnosis>"}}
67
+
68
+ ## Available services: api_gateway, auth, orders, payment, cache, database, queue
69
+
70
+ ## Strategy
71
+ 1. Start by viewing alerts to understand the scope
72
+ 2. Check metrics and logs for the most affected services
73
+ 3. Check dependency graphs to trace upstream causes
74
+ 4. Check deploy history for recently changed services
75
+ 5. Apply remediation to the root cause service FIRST
76
+ 6. Declare root cause when confident
77
+
78
+ IMPORTANT: Respond with ONLY a valid JSON object. No explanation, no markdown, just the JSON action.
79
+ """
80
+
81
+
82
+ # ------------------------------------------------------------------
83
+ # Environment client (direct HTTP)
84
+ # ------------------------------------------------------------------
85
+
86
+ class EnvClient:
87
+ def __init__(self, base_url: str):
88
+ self.base_url = base_url.rstrip("/")
89
+ self.session = requests.Session()
90
+
91
+ def reset(self, task_name: str, seed: int = 42) -> Dict[str, Any]:
92
+ resp = self.session.post(f"{self.base_url}/reset", json={
93
+ "task_name": task_name, "seed": seed})
94
+ resp.raise_for_status()
95
+ return resp.json()
96
+
97
+ def step(self, action: Dict[str, Any]) -> Dict[str, Any]:
98
+ resp = self.session.post(f"{self.base_url}/step", json=action)
99
+ resp.raise_for_status()
100
+ return resp.json()
101
+
102
+ def state(self) -> Dict[str, Any]:
103
+ resp = self.session.get(f"{self.base_url}/state")
104
+ resp.raise_for_status()
105
+ return resp.json()
106
+
107
+
108
+ # ------------------------------------------------------------------
109
+ # LLM agent
110
+ # ------------------------------------------------------------------
111
+
112
+ def create_openai_client() -> OpenAI:
113
+ """Create OpenAI client with appropriate config."""
114
+ api_key = OPENAI_API_KEY or HF_TOKEN or "no-key"
115
+ base_url = None
116
+
117
+ # If using HF inference endpoint, set base_url
118
+ if HF_TOKEN and not OPENAI_API_KEY:
119
+ base_url = f"https://api-inference.huggingface.co/models/{MODEL_NAME}/v1"
120
+
121
+ return OpenAI(api_key=api_key, base_url=base_url)
122
+
123
+
124
+ def parse_llm_action(response_text: str) -> Dict[str, Any]:
125
+ """Extract JSON action from LLM response. Handles markdown wrapping."""
126
+ text = response_text.strip()
127
+
128
+ # Strip markdown code fences if present
129
+ if text.startswith("```"):
130
+ lines = text.split("\n")
131
+ lines = [l for l in lines if not l.strip().startswith("```")]
132
+ text = "\n".join(lines).strip()
133
+
134
+ # Find JSON object
135
+ start = text.find("{")
136
+ end = text.rfind("}") + 1
137
+ if start >= 0 and end > start:
138
+ return json.loads(text[start:end])
139
+
140
+ raise ValueError(f"Could not parse action from: {response_text[:200]}")
141
+
142
+
143
+ def summarize_observation(obs: Dict[str, Any]) -> str:
144
+ """Convert observation dict to a readable string for the LLM context."""
145
+ parts = []
146
+ parts.append(f"Incident: {obs.get('incident_summary', 'N/A')}")
147
+ parts.append(f"Severity: {obs.get('severity', 'N/A')}")
148
+ parts.append(f"Time: {obs.get('time_elapsed_minutes', 0)}/{obs.get('time_budget_minutes', 30)} min")
149
+ parts.append(f"Steps: {obs.get('steps_taken', 0)}/{obs.get('max_steps', 20)}")
150
+ parts.append(f"Reward: {obs.get('current_reward', 0)} (cumulative: {obs.get('cumulative_reward', 0)})")
151
+
152
+ statuses = obs.get("service_statuses", {})
153
+ if statuses:
154
+ status_str = ", ".join(f"{k}: {v}" for k, v in statuses.items())
155
+ parts.append(f"Services: {status_str}")
156
+
157
+ parts.append(f"Alerts: {obs.get('active_alerts_count', 0)} active")
158
+ parts.append(f"Action result: {obs.get('action_message', 'N/A')}")
159
+
160
+ # Include action_result details (truncated)
161
+ action_result = obs.get("action_result", {})
162
+ if action_result:
163
+ result_str = json.dumps(action_result, indent=2, default=str)
164
+ if len(result_str) > 2000:
165
+ result_str = result_str[:2000] + "\n... (truncated)"
166
+ parts.append(f"Data:\n{result_str}")
167
+
168
+ return "\n".join(parts)
169
+
170
+
171
+ def run_episode(
172
+ env: EnvClient,
173
+ llm: OpenAI,
174
+ task_name: str,
175
+ seed: int = 42,
176
+ ) -> Dict[str, Any]:
177
+ """Run a single episode and return results."""
178
+
179
+ # --- [START] ---
180
+ print(f"[START] task={task_name}")
181
+
182
+ result = env.reset(task_name, seed)
183
+ obs = result["observation"]
184
+
185
+ # Conversation history for belief tracking (Layer 3)
186
+ messages: List[Dict[str, str]] = [
187
+ {"role": "system", "content": SYSTEM_PROMPT},
188
+ {"role": "user", "content": f"INCIDENT TRIGGERED:\n{summarize_observation(obs)}"},
189
+ ]
190
+
191
+ episode_reward = 0.0
192
+ final_info = {}
193
+
194
+ for step_num in range(1, MAX_STEPS + 1):
195
+ try:
196
+ # Get LLM action
197
+ completion = llm.chat.completions.create(
198
+ model=MODEL_NAME,
199
+ messages=messages,
200
+ temperature=TEMPERATURE,
201
+ max_tokens=256,
202
+ )
203
+ llm_response = completion.choices[0].message.content or ""
204
+
205
+ # Parse action
206
+ action = parse_llm_action(llm_response)
207
+
208
+ # --- [STEP] ---
209
+ print(f"[STEP] step={step_num} action={json.dumps(action)}")
210
+
211
+ # Execute in environment
212
+ step_result = env.step(action)
213
+ obs = step_result["observation"]
214
+ reward = step_result.get("reward", 0.0)
215
+ done = step_result.get("done", False)
216
+ info = step_result.get("info", {})
217
+ episode_reward += reward
218
+
219
+ # Update conversation history (belief tracker)
220
+ messages.append({"role": "assistant", "content": llm_response})
221
+ messages.append({
222
+ "role": "user",
223
+ "content": f"Step {step_num} result (reward={reward}):\n{summarize_observation(obs)}"
224
+ })
225
+
226
+ if done:
227
+ final_info = info
228
+ break
229
+
230
+ except Exception as e:
231
+ print(f"[STEP] step={step_num} error={str(e)}", file=sys.stderr)
232
+ # Fallback action: view alerts
233
+ action = {"action_type": "view_alerts"}
234
+ step_result = env.step(action)
235
+ obs = step_result["observation"]
236
+ reward = step_result.get("reward", 0.0)
237
+ done = step_result.get("done", False)
238
+ episode_reward += reward
239
+ if done:
240
+ final_info = step_result.get("info", {})
241
+ break
242
+
243
+ # Get final state
244
+ final_state = env.state()
245
+ final_grade = final_info.get("final_grade", 0.0)
246
+
247
+ # --- [END] ---
248
+ print(f"[END] task={task_name} "
249
+ f"grade={final_grade:.3f} "
250
+ f"reward={episode_reward:.3f} "
251
+ f"steps={final_state.get('step_count', 0)}")
252
+
253
+ return {
254
+ "task_name": task_name,
255
+ "final_grade": final_grade,
256
+ "cumulative_reward": episode_reward,
257
+ "steps": final_state.get("step_count", 0),
258
+ "declared_root_cause": final_state.get("declared_root_cause"),
259
+ }
260
+
261
+
262
+ # ------------------------------------------------------------------
263
+ # Main
264
+ # ------------------------------------------------------------------
265
+
266
+ def main():
267
+ tasks = ["memory_leak", "cascading_failure", "distributed_deadlock"]
268
+
269
+ print("=" * 60)
270
+ print("SRE Incident Response β€” OpenEnv Inference")
271
+ print(f"Model: {MODEL_NAME}")
272
+ print(f"Environment: {API_BASE_URL}")
273
+ print("=" * 60)
274
+
275
+ env = EnvClient(API_BASE_URL)
276
+ llm = create_openai_client()
277
+
278
+ results = []
279
+ for task in tasks:
280
+ print(f"\n{'─' * 40}")
281
+ print(f"Task: {task}")
282
+ print(f"{'─' * 40}")
283
+
284
+ try:
285
+ result = run_episode(env, llm, task)
286
+ results.append(result)
287
+ except Exception as e:
288
+ print(f"[ERROR] Task {task} failed: {e}", file=sys.stderr)
289
+ traceback.print_exc()
290
+ results.append({
291
+ "task_name": task,
292
+ "final_grade": 0.0,
293
+ "cumulative_reward": 0.0,
294
+ "steps": 0,
295
+ "error": str(e),
296
+ })
297
+
298
+ # Summary
299
+ print(f"\n{'=' * 60}")
300
+ print("RESULTS SUMMARY")
301
+ print(f"{'=' * 60}")
302
+ for r in results:
303
+ print(f" {r['task_name']:30s} grade={r.get('final_grade', 0):.3f} "
304
+ f"steps={r.get('steps', 0):2d} "
305
+ f"root_cause={r.get('declared_root_cause', 'N/A')}")
306
+
307
+ avg_grade = sum(r.get("final_grade", 0) for r in results) / len(results)
308
+ print(f"\n {'AVERAGE':30s} grade={avg_grade:.3f}")
309
+ print(f"{'=' * 60}")
310
+
311
+
312
+ if __name__ == "__main__":
313
+ main()
models.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Typed models for the SRE Incident Response environment.
3
+
4
+ Action space: hierarchical β€” select action_type first, then target + params.
5
+ Observation space: POMDP β€” agent never sees fault_type, only symptoms.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from enum import Enum
12
+ from typing import Any, Dict, List, Optional
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Action Space (Layer 4 β€” Hierarchical + Masked)
17
+ # ---------------------------------------------------------------------------
18
+
19
+ class ActionType(str, Enum):
20
+ """Level-1 action categories β€” what kind of operation."""
21
+ VIEW_ALERTS = "view_alerts"
22
+ QUERY_LOGS = "query_logs"
23
+ CHECK_METRICS = "check_metrics"
24
+ CHECK_DEPENDENCIES = "check_dependencies"
25
+ CHECK_DEPLOY_HISTORY = "check_deploy_history"
26
+ RUN_HEALTH_CHECK = "run_health_check"
27
+ RESTART_SERVICE = "restart_service"
28
+ ROLLBACK_DEPLOY = "rollback_deploy"
29
+ SCALE_SERVICE = "scale_service"
30
+ DECLARE_ROOT_CAUSE = "declare_root_cause"
31
+
32
+
33
+ # Actions that require a target_service (Level 2 β€” where to apply)
34
+ TARGETED_ACTIONS = {
35
+ ActionType.QUERY_LOGS,
36
+ ActionType.CHECK_METRICS,
37
+ ActionType.CHECK_DEPENDENCIES,
38
+ ActionType.CHECK_DEPLOY_HISTORY,
39
+ ActionType.RUN_HEALTH_CHECK,
40
+ ActionType.RESTART_SERVICE,
41
+ ActionType.ROLLBACK_DEPLOY,
42
+ ActionType.SCALE_SERVICE,
43
+ }
44
+
45
+ # Actions that are diagnostic (information-gathering, no state mutation)
46
+ DIAGNOSTIC_ACTIONS = {
47
+ ActionType.VIEW_ALERTS,
48
+ ActionType.QUERY_LOGS,
49
+ ActionType.CHECK_METRICS,
50
+ ActionType.CHECK_DEPENDENCIES,
51
+ ActionType.CHECK_DEPLOY_HISTORY,
52
+ ActionType.RUN_HEALTH_CHECK,
53
+ }
54
+
55
+ # Actions that mutate infrastructure state
56
+ REMEDIATION_ACTIONS = {
57
+ ActionType.RESTART_SERVICE,
58
+ ActionType.ROLLBACK_DEPLOY,
59
+ ActionType.SCALE_SERVICE,
60
+ }
61
+
62
+
63
+ @dataclass
64
+ class IncidentAction:
65
+ """
66
+ Agent action β€” hierarchical: action_type β†’ target_service β†’ parameters.
67
+
68
+ The LLM emits JSON with these three fields. The action mask in the
69
+ observation tells it which (action_type, target_service) pairs are legal.
70
+ """
71
+ action_type: str # ActionType value
72
+ target_service: Optional[str] = None # Required for TARGETED_ACTIONS
73
+ parameters: Dict[str, Any] = field(default_factory=dict)
74
+
75
+ def parsed_type(self) -> ActionType:
76
+ return ActionType(self.action_type)
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Observation Space (Layer 2 β€” POMDP, partial views only)
81
+ # ---------------------------------------------------------------------------
82
+
83
+ @dataclass
84
+ class AlertInfo:
85
+ """A single firing alert β€” what fired, not why."""
86
+ alert_id: str
87
+ severity: str # "critical" | "warning" | "info"
88
+ source_service: str
89
+ description: str
90
+ firing_since: str # ISO timestamp
91
+
92
+ @dataclass
93
+ class MetricSnapshot:
94
+ """Time-series metrics for a single service β€” temporal pattern visible."""
95
+ service_name: str
96
+ timestamps: List[str]
97
+ cpu_percent: List[float]
98
+ memory_percent: List[float]
99
+ error_rate_percent: List[float]
100
+ latency_p50_ms: List[float]
101
+ latency_p95_ms: List[float]
102
+ latency_p99_ms: List[float]
103
+ requests_per_sec: List[float]
104
+
105
+ @dataclass
106
+ class LogEntry:
107
+ """A single structured log entry β€” error semantics visible."""
108
+ timestamp: str
109
+ level: str # "DEBUG" | "INFO" | "WARN" | "ERROR" | "FATAL"
110
+ service: str
111
+ message: str
112
+ trace_id: Optional[str] = None
113
+ extra: Dict[str, Any] = field(default_factory=dict)
114
+
115
+ @dataclass
116
+ class DeployRecord:
117
+ """A single deploy β€” evidence trail for rollback decisions."""
118
+ version: str
119
+ timestamp: str
120
+ author: str
121
+ commit_hash: str
122
+ description: str
123
+
124
+ @dataclass
125
+ class DependencyInfo:
126
+ """Upstream/downstream dependency map for a service."""
127
+ service_name: str
128
+ depends_on: List[str] # services this one calls
129
+ depended_by: List[str] # services that call this one
130
+
131
+
132
+ @dataclass
133
+ class IncidentObservation:
134
+ """
135
+ What the agent sees after each step.
136
+
137
+ This is a PARTIAL observation β€” the agent never sees fault_type,
138
+ fault_target, or the internal simulation state. It must infer
139
+ the root cause from the five observation modalities.
140
+ """
141
+ # --- Incident context (always visible) ---
142
+ incident_summary: str
143
+ severity: str # "SEV1" | "SEV2" | "SEV3"
144
+ time_elapsed_minutes: int
145
+ time_budget_minutes: int
146
+
147
+ # --- Result of last action ---
148
+ action_result: Dict[str, Any] = field(default_factory=dict)
149
+ action_success: bool = True
150
+ action_message: str = ""
151
+
152
+ # --- Dashboard (always visible) ---
153
+ service_statuses: Dict[str, str] = field(default_factory=dict) # name β†’ "healthy"|"degraded"|"down"
154
+ active_alerts_count: int = 0
155
+
156
+ # --- Action mask (Layer 4 β€” prevents illegal actions) ---
157
+ valid_actions: List[str] = field(default_factory=list)
158
+ available_services: List[str] = field(default_factory=list)
159
+
160
+ # --- Episode progress ---
161
+ current_reward: float = 0.0
162
+ cumulative_reward: float = 0.0
163
+ steps_taken: int = 0
164
+ max_steps: int = 20
165
+ done: bool = False
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # State (internal tracking β€” exposed via state() for debugging)
170
+ # ---------------------------------------------------------------------------
171
+
172
+ @dataclass
173
+ class IncidentState:
174
+ """Episode metadata β€” returned by state() for monitoring/debugging."""
175
+ episode_id: str = ""
176
+ task_name: str = ""
177
+ step_count: int = 0
178
+ time_elapsed_minutes: int = 0
179
+ done: bool = False
180
+ cumulative_reward: float = 0.0
181
+ declared_root_cause: Optional[str] = None
182
+
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # Step record β€” stored per step for trajectory-based grading
186
+ # ---------------------------------------------------------------------------
187
+
188
+ @dataclass
189
+ class StepRecord:
190
+ """
191
+ Immutable record of a single step β€” used by the grader.
192
+ The grader receives List[StepRecord] and scores WITHOUT hidden state.
193
+ """
194
+ step_number: int
195
+ action: IncidentAction
196
+ reward: float
197
+ observation_summary: Dict[str, Any] # key fields from observation
198
+ service_statuses_after: Dict[str, str] # service health after this step
199
+ timestamp_minutes: int # simulation time
openenv.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: incident_env
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
6
+ port: 8000
pyproject.toml ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.backends._legacy:_Backend"
4
+
5
+ [project]
6
+ name = "incident_env"
7
+ version = "0.1.0"
8
+ description = "SRE Incident Response Simulator β€” an OpenEnv environment for training and evaluating AI agents on production incident diagnosis and remediation."
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "openenv-core>=0.1.0",
12
+ "fastapi>=0.104.0",
13
+ "uvicorn[standard]>=0.24.0",
14
+ "pydantic>=2.0.0",
15
+ "websockets>=12.0",
16
+ "openai>=1.0.0",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = [
21
+ "pytest>=7.0",
22
+ "httpx>=0.25.0",
23
+ ]
24
+
25
+ [tool.setuptools.packages.find]
26
+ include = ["incident_env*"]
scenarios/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Scenarios package
scenarios/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (174 Bytes). View file
 
scenarios/__pycache__/base.cpython-313.pyc ADDED
Binary file (8.66 kB). View file
 
scenarios/__pycache__/easy_memory_leak.cpython-313.pyc ADDED
Binary file (5.69 kB). View file
 
scenarios/__pycache__/hard_distributed_deadlock.cpython-313.pyc ADDED
Binary file (8.89 kB). View file
 
scenarios/__pycache__/medium_cascading_failure.cpython-313.pyc ADDED
Binary file (7.42 kB). View file
 
scenarios/base.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Base scenario class.
3
+
4
+ Each scenario defines:
5
+ - How to inject faults into the infrastructure
6
+ - The correct root cause string
7
+ - Which services are involved (for reward shaping)
8
+ - The oracle grader (trajectory-only, no hidden state)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from abc import ABC, abstractmethod
14
+ from typing import Any, Dict, List, Set
15
+
16
+ from ..simulation.infrastructure import Infrastructure
17
+ from ..models import StepRecord
18
+
19
+
20
+ class BaseScenario(ABC):
21
+ """
22
+ Abstract scenario. Subclasses implement inject() and grade().
23
+
24
+ inject() mutates the infrastructure to set up the incident.
25
+ grade() evaluates a complete trajectory WITHOUT access to hidden state.
26
+ """
27
+
28
+ @property
29
+ @abstractmethod
30
+ def task_name(self) -> str:
31
+ """Machine-readable task identifier."""
32
+ ...
33
+
34
+ @property
35
+ @abstractmethod
36
+ def display_name(self) -> str:
37
+ """Human-readable name for display."""
38
+ ...
39
+
40
+ @property
41
+ @abstractmethod
42
+ def incident_summary(self) -> str:
43
+ """Opening summary shown to the agent at reset."""
44
+ ...
45
+
46
+ @property
47
+ @abstractmethod
48
+ def severity(self) -> str:
49
+ """SEV1/SEV2/SEV3."""
50
+ ...
51
+
52
+ @property
53
+ @abstractmethod
54
+ def correct_root_cause(self) -> str:
55
+ """The canonical root cause string (for grading)."""
56
+ ...
57
+
58
+ @property
59
+ @abstractmethod
60
+ def involved_services(self) -> Set[str]:
61
+ """Services that are actually part of the incident (for reward shaping)."""
62
+ ...
63
+
64
+ @property
65
+ @abstractmethod
66
+ def root_cause_service(self) -> str:
67
+ """The primary service where the fault originates."""
68
+ ...
69
+
70
+ @property
71
+ @abstractmethod
72
+ def correct_remediation_actions(self) -> List[Dict[str, str]]:
73
+ """List of {action_type, target_service} that constitute correct remediation."""
74
+ ...
75
+
76
+ @property
77
+ def root_cause_keywords(self) -> List[str]:
78
+ """Keywords that must appear in a correct root cause declaration."""
79
+ return []
80
+
81
+ @property
82
+ def time_budget_minutes(self) -> int:
83
+ return 30
84
+
85
+ @property
86
+ def max_steps(self) -> int:
87
+ return 20
88
+
89
+ @abstractmethod
90
+ def inject(self, infra: Infrastructure) -> None:
91
+ """
92
+ Inject faults into the infrastructure.
93
+ Called once at reset time.
94
+ """
95
+ ...
96
+
97
+ # ---------------------------------------------------------------
98
+ # Grading β€” oracle-independent, trajectory-only (Layer 6)
99
+ # ---------------------------------------------------------------
100
+
101
+ def grade(self, trajectory: List[StepRecord]) -> float:
102
+ """
103
+ Grade the complete trajectory.
104
+ Returns float in [0.0, 1.0].
105
+
106
+ This function receives ONLY the step records β€” no hidden state,
107
+ no infrastructure reference. This is critical: the evaluation
108
+ harness must be able to call this on a saved trajectory.
109
+ """
110
+ score = 0.0
111
+ score += self._grade_root_cause(trajectory) # 0.00 – 0.40
112
+ score += self._grade_remediation(trajectory) # 0.00 – 0.30
113
+ score += self._grade_efficiency(trajectory) # 0.00 – 0.20
114
+ score += self._grade_restoration(trajectory) # 0.00 – 0.10
115
+ return max(0.0, min(1.0, score))
116
+
117
+ def _grade_root_cause(self, trajectory: List[StepRecord]) -> float:
118
+ """
119
+ Did the agent correctly declare the root cause?
120
+ Full credit (0.40) for correct, partial credit for close.
121
+ """
122
+ declarations = [
123
+ s for s in trajectory
124
+ if s.action.action_type == "declare_root_cause"
125
+ ]
126
+ if not declarations:
127
+ return 0.0 # Never declared β€” 0 points
128
+
129
+ # Use the LAST declaration
130
+ declared = declarations[-1].action.parameters.get("root_cause", "").lower()
131
+
132
+ # Check keyword match
133
+ keywords = self.root_cause_keywords
134
+ if not keywords:
135
+ keywords = self.correct_root_cause.lower().split()
136
+
137
+ matched = sum(1 for kw in keywords if kw in declared)
138
+ match_ratio = matched / len(keywords) if keywords else 0
139
+
140
+ if match_ratio >= 0.6:
141
+ return 0.40 # Close enough β€” full credit
142
+ elif match_ratio >= 0.3:
143
+ return 0.20 # Partial credit
144
+ else:
145
+ return 0.0
146
+
147
+ def _grade_remediation(self, trajectory: List[StepRecord]) -> float:
148
+ """
149
+ Did the agent take the correct fix actions?
150
+ """
151
+ correct_actions = self.correct_remediation_actions
152
+ if not correct_actions:
153
+ return 0.0
154
+
155
+ taken_remediations = [
156
+ (s.action.action_type, s.action.target_service)
157
+ for s in trajectory
158
+ if s.action.action_type in ("restart_service", "rollback_deploy", "scale_service")
159
+ ]
160
+
161
+ matched = 0
162
+ for ca in correct_actions:
163
+ needed = (ca["action_type"], ca["target_service"])
164
+ if needed in taken_remediations:
165
+ matched += 1
166
+
167
+ ratio = matched / len(correct_actions)
168
+ return round(ratio * 0.30, 3)
169
+
170
+ def _grade_efficiency(self, trajectory: List[StepRecord]) -> float:
171
+ """
172
+ Fewer steps to reach correct diagnosis = more points.
173
+ Optimal path (for the scenario) gets full credit.
174
+ """
175
+ total_steps = len(trajectory)
176
+ if total_steps == 0:
177
+ return 0.0
178
+
179
+ # Generous: < 8 steps is excellent, 8-12 is good, 13-16 is okay, 17+ is bad
180
+ if total_steps <= 6:
181
+ return 0.20
182
+ elif total_steps <= 10:
183
+ return 0.15
184
+ elif total_steps <= 14:
185
+ return 0.10
186
+ elif total_steps <= 17:
187
+ return 0.05
188
+ else:
189
+ return 0.02
190
+
191
+ def _grade_restoration(self, trajectory: List[StepRecord]) -> float:
192
+ """
193
+ Are all services healthy at the end of the episode?
194
+ Check the LAST step's service_statuses_after.
195
+ """
196
+ if not trajectory:
197
+ return 0.0
198
+
199
+ final_statuses = trajectory[-1].service_statuses_after
200
+ if all(s == "healthy" for s in final_statuses.values()):
201
+ return 0.10
202
+ # Partial credit: how many are healthy
203
+ healthy_count = sum(1 for s in final_statuses.values() if s == "healthy")
204
+ total = len(final_statuses) if final_statuses else 1
205
+ return round(0.10 * (healthy_count / total), 3)
scenarios/easy_memory_leak.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Task 1 β€” Easy: Memory Leak.
3
+
4
+ Single-service failure with clear metric signal and obvious recent deploy.
5
+ Agent should: view alerts β†’ check metrics(orders) β†’ check deploy history β†’
6
+ rollback deploy β†’ declare root cause.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import random
12
+ from typing import Dict, List, Set
13
+
14
+ from .base import BaseScenario
15
+ from ..simulation.infrastructure import Infrastructure
16
+ from ..simulation.service import Deploy
17
+ from ..simulation.metrics import generate_memory_leak_history
18
+
19
+
20
+ class MemoryLeakScenario(BaseScenario):
21
+
22
+ @property
23
+ def task_name(self) -> str:
24
+ return "memory_leak"
25
+
26
+ @property
27
+ def display_name(self) -> str:
28
+ return "Memory Leak β€” Orders Service"
29
+
30
+ @property
31
+ def incident_summary(self) -> str:
32
+ return (
33
+ "INCIDENT: Orders service is experiencing intermittent failures "
34
+ "and restarts. Customers are reporting failed checkout attempts. "
35
+ "The on-call SRE has been paged."
36
+ )
37
+
38
+ @property
39
+ def severity(self) -> str:
40
+ return "SEV2"
41
+
42
+ @property
43
+ def correct_root_cause(self) -> str:
44
+ return "memory leak in orders service caused by bad deploy v2.3.1"
45
+
46
+ @property
47
+ def root_cause_keywords(self) -> List[str]:
48
+ return ["memory", "leak", "orders", "deploy"]
49
+
50
+ @property
51
+ def involved_services(self) -> Set[str]:
52
+ return {"orders"}
53
+
54
+ @property
55
+ def root_cause_service(self) -> str:
56
+ return "orders"
57
+
58
+ @property
59
+ def correct_remediation_actions(self) -> List[Dict[str, str]]:
60
+ return [
61
+ {"action_type": "rollback_deploy", "target_service": "orders"},
62
+ ]
63
+
64
+ def inject(self, infra: Infrastructure) -> None:
65
+ """
66
+ Set up the memory leak scenario:
67
+ 1. Add a bad deploy to orders service
68
+ 2. Inject memory_leak fault
69
+ 3. Populate metric history showing the leak pattern
70
+ 4. Pre-populate some logs showing OOM symptoms
71
+ """
72
+ orders = infra.get_service("orders")
73
+ if not orders:
74
+ return
75
+
76
+ # --- Bad deploy (the root cause) ---
77
+ bad_deploy = Deploy(
78
+ version="v2.3.1",
79
+ timestamp_minutes=-20, # 20 minutes ago
80
+ author="alice",
81
+ commit_hash="a1b2c3",
82
+ description="Feature: batch order processing with in-memory cache",
83
+ is_bad=True,
84
+ )
85
+ orders.deploy_history.append(bad_deploy)
86
+
87
+ # --- Inject the memory leak fault ---
88
+ orders.inject_fault("memory_leak", rate=1.2)
89
+
90
+ # --- Pre-populate metric history showing the leak ---
91
+ orders.metric_history = generate_memory_leak_history(
92
+ minutes=30,
93
+ start_minute=0,
94
+ leak_start_offset=10, # leak started 20 min into the history
95
+ rate=1.2,
96
+ )
97
+
98
+ # --- Set current metrics to reflect ~20 minutes of leak ---
99
+ orders.memory_percent = 78.0 + random.gauss(0, 2)
100
+ orders.cpu_percent = 35.0 + random.gauss(0, 3)
101
+ orders.error_rate_percent = 12.0 + random.gauss(0, 2)
102
+ orders.latency_p95_ms = 350.0 + random.gauss(0, 30)
103
+ orders.latency_p99_ms = 800.0 + random.gauss(0, 50)
104
+ orders.status = "degraded"
105
+
106
+ # --- Pre-populated logs showing symptoms ---
107
+ base_logs = [
108
+ {"timestamp": "2025-01-15T14:10:00Z", "level": "INFO", "service": "orders",
109
+ "message": "Deploy v2.3.1 started β€” rolling update initiated", "trace_id": None},
110
+ {"timestamp": "2025-01-15T14:11:00Z", "level": "INFO", "service": "orders",
111
+ "message": "Deploy v2.3.1 complete β€” all replicas updated", "trace_id": None},
112
+ {"timestamp": "2025-01-15T14:18:00Z", "level": "WARN", "service": "orders",
113
+ "message": "GC pressure: heap usage at 62%, GC pause 340ms", "trace_id": None},
114
+ {"timestamp": "2025-01-15T14:22:00Z", "level": "WARN", "service": "orders",
115
+ "message": "GC pressure: heap usage at 71%, GC pause 580ms", "trace_id": None},
116
+ {"timestamp": "2025-01-15T14:25:00Z", "level": "ERROR", "service": "orders",
117
+ "message": "Memory allocation failed: unable to allocate 128MB for batch cache",
118
+ "trace_id": "trace-442918"},
119
+ {"timestamp": "2025-01-15T14:27:00Z", "level": "WARN", "service": "orders",
120
+ "message": "GC overhead limit exceeded: spent 87% of time in GC",
121
+ "trace_id": None},
122
+ {"timestamp": "2025-01-15T14:28:00Z", "level": "ERROR", "service": "orders",
123
+ "message": "Request processing failed: OutOfMemoryError in batch order handler",
124
+ "trace_id": "trace-553201"},
125
+ ]
126
+ orders.logs = base_logs
scenarios/hard_distributed_deadlock.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Task 3 β€” Hard: Distributed Deadlock.
3
+
4
+ A deploy to the payment service changed its retry logic to be aggressive.
5
+ This creates a circular wait:
6
+ orders β†’ payment (waiting on ack)
7
+ payment β†’ queue (retrying aggressively, flooding)
8
+ queue β†’ orders (backpressure, orders can't consume)
9
+
10
+ No single service crashes β€” all three show high latency and scattered
11
+ timeout errors. The agent must correlate cross-service logs with trace IDs
12
+ and check deploy history to find that payment's retry change is the root cause.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import random
18
+ from typing import Dict, List, Set
19
+
20
+ from .base import BaseScenario
21
+ from ..simulation.infrastructure import Infrastructure
22
+ from ..simulation.service import Deploy
23
+ from ..simulation.metrics import generate_high_latency_history, generate_healthy_history
24
+
25
+
26
+ class DistributedDeadlockScenario(BaseScenario):
27
+
28
+ @property
29
+ def task_name(self) -> str:
30
+ return "distributed_deadlock"
31
+
32
+ @property
33
+ def display_name(self) -> str:
34
+ return "Distributed Deadlock β€” Payment/Orders/Queue Circular Wait"
35
+
36
+ @property
37
+ def incident_summary(self) -> str:
38
+ return (
39
+ "INCIDENT: Order processing latency has spiked dramatically. "
40
+ "Customers report orders 'stuck in processing' for 5+ minutes. "
41
+ "No single service appears to be down β€” all health checks pass "
42
+ "but with high latency. Payment confirmations are severely delayed."
43
+ )
44
+
45
+ @property
46
+ def severity(self) -> str:
47
+ return "SEV1"
48
+
49
+ @property
50
+ def correct_root_cause(self) -> str:
51
+ return "payment service deploy changed retry logic creating circular deadlock with orders and queue"
52
+
53
+ @property
54
+ def root_cause_keywords(self) -> List[str]:
55
+ return ["payment", "retry", "deadlock", "deploy"]
56
+
57
+ @property
58
+ def involved_services(self) -> Set[str]:
59
+ return {"orders", "payment", "queue"}
60
+
61
+ @property
62
+ def root_cause_service(self) -> str:
63
+ return "payment"
64
+
65
+ @property
66
+ def correct_remediation_actions(self) -> List[Dict[str, str]]:
67
+ return [
68
+ {"action_type": "rollback_deploy", "target_service": "payment"},
69
+ {"action_type": "scale_service", "target_service": "queue"},
70
+ {"action_type": "restart_service", "target_service": "orders"},
71
+ ]
72
+
73
+ def inject(self, infra: Infrastructure) -> None:
74
+ """
75
+ Set up distributed deadlock:
76
+ 1. Payment gets bad deploy (aggressive retry) β†’ circular_wait fault
77
+ 2. Orders and queue also get circular_wait
78
+ 3. All three show high latency but no crashes
79
+ 4. Logs show scattered timeouts with correlated trace IDs
80
+ 5. Red herring: cache shows a brief latency spike too
81
+ """
82
+ payment = infra.get_service("payment")
83
+ orders = infra.get_service("orders")
84
+ queue = infra.get_service("queue")
85
+ cache = infra.get_service("cache")
86
+
87
+ if not all([payment, orders, queue]):
88
+ return
89
+
90
+ # --- Bad deploy on payment (root cause) ---
91
+ bad_deploy = Deploy(
92
+ version="v3.1.0",
93
+ timestamp_minutes=-12,
94
+ author="charlie",
95
+ commit_hash="f7g8h9",
96
+ description="Improve payment reliability: increase retry count and reduce backoff",
97
+ is_bad=True,
98
+ )
99
+ payment.deploy_history.append(bad_deploy)
100
+
101
+ # --- Inject circular_wait on all three services ---
102
+ payment.inject_fault("circular_wait", peers=["queue", "orders"])
103
+ orders.inject_fault("circular_wait", peers=["payment"])
104
+ queue.inject_fault("circular_wait", peers=["orders", "payment"])
105
+
106
+ # --- High latency metric histories ---
107
+ payment.metric_history = generate_high_latency_history(
108
+ minutes=30, start_minute=0, latency_start_offset=18, target_p99=8000)
109
+ orders.metric_history = generate_high_latency_history(
110
+ minutes=30, start_minute=0, latency_start_offset=20, target_p99=6000)
111
+ queue.metric_history = generate_high_latency_history(
112
+ minutes=30, start_minute=0, latency_start_offset=19, target_p99=10000)
113
+
114
+ # --- Current metrics ---
115
+ for svc, p99 in [(payment, 7000), (orders, 5500), (queue, 9000)]:
116
+ svc.latency_p50_ms = 400 + random.gauss(0, 30)
117
+ svc.latency_p95_ms = p99 * 0.6 + random.gauss(0, 100)
118
+ svc.latency_p99_ms = p99 + random.gauss(0, 200)
119
+ svc.error_rate_percent = 12.0 + random.gauss(0, 3)
120
+ svc.requests_per_sec = max(30, 150 + random.gauss(0, 20))
121
+ svc.status = "degraded"
122
+
123
+ # --- Shared trace IDs to enable cross-service correlation ---
124
+ shared_traces = [f"trace-{random.randint(800000, 899999)}" for _ in range(5)]
125
+
126
+ # Payment logs
127
+ payment.logs = [
128
+ {"timestamp": "2025-01-15T14:18:00Z", "level": "INFO", "service": "payment",
129
+ "message": "Deploy v3.1.0 started β€” retry improvements", "trace_id": None},
130
+ {"timestamp": "2025-01-15T14:18:30Z", "level": "INFO", "service": "payment",
131
+ "message": "Deploy v3.1.0 complete β€” retry_count=10, backoff=100ms",
132
+ "trace_id": None},
133
+ {"timestamp": "2025-01-15T14:20:00Z", "level": "WARN", "service": "payment",
134
+ "message": f"Retrying queue publish: attempt 5/10, waiting 100ms β€” {shared_traces[0]}",
135
+ "trace_id": shared_traces[0]},
136
+ {"timestamp": "2025-01-15T14:21:00Z", "level": "ERROR", "service": "payment",
137
+ "message": f"Timeout waiting for queue acknowledgment: blocked 12000ms",
138
+ "trace_id": shared_traces[1]},
139
+ {"timestamp": "2025-01-15T14:23:00Z", "level": "WARN", "service": "payment",
140
+ "message": "Thread pool: 195/200 threads blocked waiting on downstream calls",
141
+ "trace_id": None},
142
+ {"timestamp": "2025-01-15T14:25:00Z", "level": "ERROR", "service": "payment",
143
+ "message": f"Payment processing stuck: round-trip to queue exceeded 25000ms",
144
+ "trace_id": shared_traces[2]},
145
+ ]
146
+
147
+ # Orders logs
148
+ orders.logs = [
149
+ {"timestamp": "2025-01-15T14:20:00Z", "level": "WARN", "service": "orders",
150
+ "message": f"Waiting on payment confirmation: no response after 8000ms",
151
+ "trace_id": shared_traces[0]},
152
+ {"timestamp": "2025-01-15T14:21:30Z", "level": "ERROR", "service": "orders",
153
+ "message": f"Order {random.randint(10000, 99999)} stuck in PROCESSING state: "
154
+ f"payment callback not received after 15000ms",
155
+ "trace_id": shared_traces[1]},
156
+ {"timestamp": "2025-01-15T14:23:00Z", "level": "WARN", "service": "orders",
157
+ "message": "Cannot consume from queue: consumer threads blocked waiting on payment",
158
+ "trace_id": shared_traces[2]},
159
+ {"timestamp": "2025-01-15T14:25:00Z", "level": "ERROR", "service": "orders",
160
+ "message": "Timeout calling payment-service: deadline exceeded after 30000ms",
161
+ "trace_id": shared_traces[3]},
162
+ ]
163
+
164
+ # Queue logs
165
+ queue.logs = [
166
+ {"timestamp": "2025-01-15T14:20:00Z", "level": "WARN", "service": "queue",
167
+ "message": "Queue depth increasing: 5,420 messages pending (threshold: 1,000)",
168
+ "trace_id": None},
169
+ {"timestamp": "2025-01-15T14:21:00Z", "level": "WARN", "service": "queue",
170
+ "message": f"Consumer lag: orders consumer 3,200 messages behind",
171
+ "trace_id": None},
172
+ {"timestamp": "2025-01-15T14:22:00Z", "level": "ERROR", "service": "queue",
173
+ "message": f"Publish flood detected: payment-service publishing at 500 msg/s "
174
+ f"(normal: 50 msg/s)", "trace_id": shared_traces[2]},
175
+ {"timestamp": "2025-01-15T14:24:00Z", "level": "WARN", "service": "queue",
176
+ "message": "Backpressure applied to orders consumer: cannot keep up with "
177
+ "publish rate", "trace_id": shared_traces[3]},
178
+ {"timestamp": "2025-01-15T14:26:00Z", "level": "ERROR", "service": "queue",
179
+ "message": "Queue depth critical: 12,840 messages pending β€” memory pressure",
180
+ "trace_id": None},
181
+ ]
182
+
183
+ # --- Red herring: cache shows a brief latency spike ---
184
+ if cache:
185
+ cache.logs.append({
186
+ "timestamp": "2025-01-15T14:22:00Z", "level": "WARN", "service": "cache",
187
+ "message": "Redis SLOWLOG: KEYS pattern='order:pending:*' took 450ms",
188
+ "trace_id": None})
189
+ cache.logs.append({
190
+ "timestamp": "2025-01-15T14:24:00Z", "level": "WARN", "service": "cache",
191
+ "message": "Redis memory usage: 78% β€” evicting LRU keys",
192
+ "trace_id": None})
scenarios/medium_cascading_failure.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Task 2 β€” Medium: Cascading Failure.
3
+
4
+ A bad configuration change to the auth service causes it to return 500s
5
+ on every token validation request. This cascades to api_gateway and orders
6
+ (both depend on auth). Multiple alerts fire across services β€” the agent
7
+ must trace the dependency graph to find the ROOT cause is auth, not the
8
+ downstream services showing symptoms.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import random
14
+ from typing import Dict, List, Set
15
+
16
+ from .base import BaseScenario
17
+ from ..simulation.infrastructure import Infrastructure
18
+ from ..simulation.service import Deploy
19
+ from ..simulation.metrics import generate_error_spike_history, generate_healthy_history
20
+
21
+
22
+ class CascadingFailureScenario(BaseScenario):
23
+
24
+ @property
25
+ def task_name(self) -> str:
26
+ return "cascading_failure"
27
+
28
+ @property
29
+ def display_name(self) -> str:
30
+ return "Cascading Failure β€” Auth Service Configuration"
31
+
32
+ @property
33
+ def incident_summary(self) -> str:
34
+ return (
35
+ "INCIDENT: Multiple services are experiencing elevated error rates. "
36
+ "API Gateway is returning 5xx errors to external clients. "
37
+ "Orders and Auth services both show failures. "
38
+ "Customer-facing impact confirmed β€” payments not processing."
39
+ )
40
+
41
+ @property
42
+ def severity(self) -> str:
43
+ return "SEV1"
44
+
45
+ @property
46
+ def correct_root_cause(self) -> str:
47
+ return "auth service bad config deploy caused authentication failures cascading to dependents"
48
+
49
+ @property
50
+ def root_cause_keywords(self) -> List[str]:
51
+ return ["auth", "config", "deploy", "cascade"]
52
+
53
+ @property
54
+ def involved_services(self) -> Set[str]:
55
+ return {"auth", "api_gateway", "orders"}
56
+
57
+ @property
58
+ def root_cause_service(self) -> str:
59
+ return "auth"
60
+
61
+ @property
62
+ def correct_remediation_actions(self) -> List[Dict[str, str]]:
63
+ return [
64
+ {"action_type": "rollback_deploy", "target_service": "auth"},
65
+ {"action_type": "restart_service", "target_service": "api_gateway"},
66
+ {"action_type": "restart_service", "target_service": "orders"},
67
+ ]
68
+
69
+ def inject(self, infra: Infrastructure) -> None:
70
+ """
71
+ Set up cascading failure:
72
+ 1. Auth gets a bad config deploy β†’ high_error_rate fault
73
+ 2. Downstream services (api_gateway, orders) get dependency_degraded
74
+ 3. Red herrings: orders also shows some unrelated log noise
75
+ """
76
+ auth = infra.get_service("auth")
77
+ api_gw = infra.get_service("api_gateway")
78
+ orders = infra.get_service("orders")
79
+
80
+ if not all([auth, api_gw, orders]):
81
+ return
82
+
83
+ # --- Bad deploy on auth (the root cause) ---
84
+ bad_deploy = Deploy(
85
+ version="v1.8.0",
86
+ timestamp_minutes=-15,
87
+ author="bob",
88
+ commit_hash="d4e5f6",
89
+ description="Config update: rotate JWT signing secret",
90
+ is_bad=True,
91
+ )
92
+ auth.deploy_history.append(bad_deploy)
93
+
94
+ # --- Inject auth fault ---
95
+ auth.inject_fault("high_error_rate", rate=65.0)
96
+ auth.metric_history = generate_error_spike_history(
97
+ minutes=30, start_minute=0, spike_start_offset=15, error_rate_target=65.0)
98
+ auth.error_rate_percent = 65.0 + random.gauss(0, 3)
99
+ auth.status = "down"
100
+ auth.latency_p95_ms = 500 + random.gauss(0, 50)
101
+ auth.latency_p99_ms = 1500 + random.gauss(0, 100)
102
+
103
+ # Auth-specific logs showing the config error
104
+ auth.logs = [
105
+ {"timestamp": "2025-01-15T14:15:00Z", "level": "INFO", "service": "auth",
106
+ "message": "Deploy v1.8.0 started β€” config update: JWT secret rotation",
107
+ "trace_id": None},
108
+ {"timestamp": "2025-01-15T14:15:30Z", "level": "INFO", "service": "auth",
109
+ "message": "Deploy v1.8.0 complete β€” all replicas updated", "trace_id": None},
110
+ {"timestamp": "2025-01-15T14:16:00Z", "level": "ERROR", "service": "auth",
111
+ "message": "NullPointerException: configuration key 'auth.jwt.secret' is null",
112
+ "trace_id": "trace-110001"},
113
+ {"timestamp": "2025-01-15T14:16:15Z", "level": "ERROR", "service": "auth",
114
+ "message": "Token validation failed: cannot sign with null key β€” returning 500",
115
+ "trace_id": "trace-110002"},
116
+ {"timestamp": "2025-01-15T14:17:00Z", "level": "ERROR", "service": "auth",
117
+ "message": "Health check failed: auth returned HTTP 500", "trace_id": None},
118
+ {"timestamp": "2025-01-15T14:20:00Z", "level": "ERROR", "service": "auth",
119
+ "message": "Authentication failed for 180 requests in last 60s β€” returning HTTP 500",
120
+ "trace_id": None},
121
+ ]
122
+
123
+ # --- Cascaded impact on api_gateway ---
124
+ api_gw.inject_fault("dependency_degraded", upstream="auth")
125
+ api_gw.error_rate_percent = 45.0 + random.gauss(0, 5)
126
+ api_gw.latency_p95_ms = 2000 + random.gauss(0, 200)
127
+ api_gw.status = "degraded"
128
+ api_gw.logs = [
129
+ {"timestamp": "2025-01-15T14:17:00Z", "level": "ERROR", "service": "api_gateway",
130
+ "message": "Call to auth-service failed: HTTP 500 Internal Server Error β€” "
131
+ "retrying (1/3)", "trace_id": "trace-220001"},
132
+ {"timestamp": "2025-01-15T14:17:30Z", "level": "ERROR", "service": "api_gateway",
133
+ "message": "All retry attempts to auth-service exhausted β€” returning 502 to client",
134
+ "trace_id": "trace-220002"},
135
+ {"timestamp": "2025-01-15T14:18:00Z", "level": "WARN", "service": "api_gateway",
136
+ "message": "Circuit breaker for auth-service: state=OPEN, failures=25, threshold=10",
137
+ "trace_id": None},
138
+ ]
139
+
140
+ # --- Cascaded impact on orders ---
141
+ orders.inject_fault("dependency_degraded", upstream="auth")
142
+ orders.error_rate_percent = 30.0 + random.gauss(0, 4)
143
+ orders.latency_p95_ms = 1800 + random.gauss(0, 150)
144
+ orders.status = "degraded"
145
+ orders.logs = [
146
+ {"timestamp": "2025-01-15T14:17:00Z", "level": "ERROR", "service": "orders",
147
+ "message": "Call to auth-service failed: HTTP 500 β€” cannot validate order token",
148
+ "trace_id": "trace-330001"},
149
+ {"timestamp": "2025-01-15T14:18:00Z", "level": "ERROR", "service": "orders",
150
+ "message": "Order creation failed: authentication service unavailable",
151
+ "trace_id": "trace-330002"},
152
+ # Red herring
153
+ {"timestamp": "2025-01-15T14:19:00Z", "level": "WARN", "service": "orders",
154
+ "message": "Database connection pool: 18/20 active connections β€” approaching limit",
155
+ "trace_id": None},
156
+ ]
server/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Server package
server/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (171 Bytes). View file
 
server/__pycache__/app.cpython-313.pyc ADDED
Binary file (3.89 kB). View file
 
server/__pycache__/incident_environment.cpython-313.pyc ADDED
Binary file (18.4 kB). View file
 
server/app.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Thin FastAPI server β€” marshals JSON in/out.
3
+ No simulation logic lives here.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, Dict, Optional
9
+
10
+ from fastapi import FastAPI, HTTPException
11
+ from fastapi.middleware.cors import CORSMiddleware
12
+ from pydantic import BaseModel
13
+
14
+ from .incident_environment import IncidentEnvironment
15
+
16
+ # ------------------------------------------------------------------
17
+ # App
18
+ # ------------------------------------------------------------------
19
+
20
+ app = FastAPI(
21
+ title="SRE Incident Response Environment",
22
+ description="An OpenEnv environment for training AI agents on production incident response.",
23
+ version="0.1.0",
24
+ )
25
+
26
+ app.add_middleware(
27
+ CORSMiddleware,
28
+ allow_origins=["*"],
29
+ allow_methods=["*"],
30
+ allow_headers=["*"],
31
+ )
32
+
33
+ env = IncidentEnvironment()
34
+
35
+
36
+ # ------------------------------------------------------------------
37
+ # Request / Response models (thin wrappers)
38
+ # ------------------------------------------------------------------
39
+
40
+ class ResetRequest(BaseModel):
41
+ task_name: Optional[str] = None
42
+ seed: Optional[int] = None
43
+
44
+
45
+ class StepRequest(BaseModel):
46
+ action_type: str
47
+ target_service: Optional[str] = None
48
+ parameters: Dict[str, Any] = {}
49
+
50
+
51
+ # ------------------------------------------------------------------
52
+ # Endpoints
53
+ # ------------------------------------------------------------------
54
+
55
+ @app.get("/health")
56
+ def health() -> Dict[str, str]:
57
+ """Health check β€” the validator pings this first."""
58
+ return {"status": "healthy"}
59
+
60
+
61
+ @app.post("/reset")
62
+ def reset(request: ResetRequest) -> Dict[str, Any]:
63
+ """
64
+ Initialize a new incident episode.
65
+ POST /reset {"task_name": "memory_leak", "seed": 42}
66
+ """
67
+ result = env.reset(
68
+ task_name=request.task_name,
69
+ seed=request.seed,
70
+ )
71
+ return result
72
+
73
+
74
+ @app.post("/step")
75
+ def step(request: StepRequest) -> Dict[str, Any]:
76
+ """
77
+ Execute one agent action.
78
+ POST /step {"action_type": "view_alerts"}
79
+ """
80
+ action_data = {
81
+ "action_type": request.action_type,
82
+ "target_service": request.target_service,
83
+ "parameters": request.parameters,
84
+ }
85
+ result = env.step(action_data)
86
+ return result
87
+
88
+
89
+ @app.get("/state")
90
+ def state() -> Dict[str, Any]:
91
+ """
92
+ Get current episode metadata.
93
+ GET /state
94
+ """
95
+ return env.get_state()
96
+
97
+
98
+ @app.get("/tasks")
99
+ def list_tasks() -> Dict[str, Any]:
100
+ """List available tasks with descriptions."""
101
+ from ..tasks import TASK_REGISTRY
102
+ tasks = {}
103
+ for name, cls in TASK_REGISTRY.items():
104
+ scenario = cls()
105
+ tasks[name] = {
106
+ "display_name": scenario.display_name,
107
+ "severity": scenario.severity,
108
+ "max_steps": scenario.max_steps,
109
+ "time_budget_minutes": scenario.time_budget_minutes,
110
+ }
111
+ return {"tasks": tasks}
server/incident_environment.py ADDED
@@ -0,0 +1,449 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Core Environment implementation.
3
+
4
+ Execution order per step: validate β†’ mutate β†’ tick β†’ observe β†’ reward.
5
+
6
+ The environment uses oracle-shaped rewards for training (they peek at hidden
7
+ state to compute whether the agent investigated the right service) but the
8
+ grader used for evaluation is oracle-independent (trajectory-only).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import uuid
14
+ import random
15
+ from dataclasses import asdict
16
+ from typing import Any, Dict, List, Optional, Tuple
17
+
18
+ from ..models import (
19
+ ActionType,
20
+ IncidentAction,
21
+ IncidentObservation,
22
+ IncidentState,
23
+ StepRecord,
24
+ DIAGNOSTIC_ACTIONS,
25
+ REMEDIATION_ACTIONS,
26
+ TARGETED_ACTIONS,
27
+ )
28
+ from ..simulation.infrastructure import Infrastructure, SERVICE_NAMES
29
+ from ..tasks import get_scenario, TASK_NAMES
30
+ from ..scenarios.base import BaseScenario
31
+
32
+
33
+ class IncidentEnvironment:
34
+ """
35
+ SRE Incident Response Environment.
36
+
37
+ Implements the three OpenEnv methods:
38
+ - reset(task_name) β†’ IncidentObservation
39
+ - step(action) β†’ dict with observation, reward, done
40
+ - state() β†’ IncidentState
41
+ """
42
+
43
+ def __init__(self) -> None:
44
+ self._infra: Optional[Infrastructure] = None
45
+ self._scenario: Optional[BaseScenario] = None
46
+ self._state = IncidentState()
47
+ self._trajectory: List[StepRecord] = []
48
+ self._cumulative_reward: float = 0.0
49
+ self._done: bool = False
50
+ self._root_cause_declared: bool = False
51
+
52
+ # ------------------------------------------------------------------
53
+ # reset()
54
+ # ------------------------------------------------------------------
55
+
56
+ def reset(
57
+ self,
58
+ task_name: Optional[str] = None,
59
+ seed: Optional[int] = None,
60
+ **kwargs: Any,
61
+ ) -> Dict[str, Any]:
62
+ """
63
+ Initialize a new incident episode.
64
+
65
+ Args:
66
+ task_name: One of "memory_leak", "cascading_failure", "distributed_deadlock".
67
+ If None, picks randomly.
68
+ seed: Optional random seed for reproducibility.
69
+
70
+ Returns:
71
+ Dict with observation, reward=0.0, done=False.
72
+ """
73
+ if seed is not None:
74
+ random.seed(seed)
75
+
76
+ if task_name is None:
77
+ task_name = random.choice(TASK_NAMES)
78
+
79
+ # Create fresh infrastructure
80
+ self._infra = Infrastructure()
81
+ self._scenario = get_scenario(task_name)
82
+ self._infra.time_budget_minutes = self._scenario.time_budget_minutes
83
+
84
+ # Inject scenario faults
85
+ self._scenario.inject(self._infra)
86
+
87
+ # Run a few ticks to let cascades propagate
88
+ for _ in range(3):
89
+ self._infra.tick()
90
+
91
+ # Reset episode state
92
+ self._state = IncidentState(
93
+ episode_id=str(uuid.uuid4()),
94
+ task_name=task_name,
95
+ step_count=0,
96
+ time_elapsed_minutes=self._infra.current_minute,
97
+ done=False,
98
+ cumulative_reward=0.0,
99
+ )
100
+ self._trajectory = []
101
+ self._cumulative_reward = 0.0
102
+ self._done = False
103
+ self._root_cause_declared = False
104
+
105
+ obs = self._build_observation(
106
+ action_result={"message": "Incident triggered. Begin investigation."},
107
+ action_success=True,
108
+ action_message="Episode started",
109
+ reward=0.0,
110
+ )
111
+
112
+ return {
113
+ "observation": obs,
114
+ "reward": 0.0,
115
+ "done": False,
116
+ }
117
+
118
+ # ------------------------------------------------------------------
119
+ # step() β€” validate β†’ mutate β†’ tick β†’ observe β†’ reward
120
+ # ------------------------------------------------------------------
121
+
122
+ def step(self, action_data: Dict[str, Any]) -> Dict[str, Any]:
123
+ """
124
+ Execute one agent action.
125
+
126
+ Args:
127
+ action_data: Dict with action_type, target_service, parameters.
128
+
129
+ Returns:
130
+ Dict with observation, reward, done, info.
131
+ """
132
+ if self._done:
133
+ obs = self._build_observation(
134
+ action_result={"error": "Episode is already done."},
135
+ action_success=False,
136
+ action_message="Episode already finished",
137
+ reward=0.0,
138
+ )
139
+ return {"observation": obs, "reward": 0.0, "done": True, "info": {}}
140
+
141
+ if self._infra is None or self._scenario is None:
142
+ obs = self._build_observation(
143
+ action_result={"error": "Environment not initialized. Call reset() first."},
144
+ action_success=False,
145
+ action_message="Not initialized",
146
+ reward=0.0,
147
+ )
148
+ return {"observation": obs, "reward": 0.0, "done": False, "info": {}}
149
+
150
+ # Parse action
151
+ action = IncidentAction(
152
+ action_type=action_data.get("action_type", ""),
153
+ target_service=action_data.get("target_service"),
154
+ parameters=action_data.get("parameters", {}),
155
+ )
156
+
157
+ # ---- VALIDATE ----
158
+ is_valid, error_msg = self._infra.validate_action(
159
+ action.action_type, action.target_service)
160
+
161
+ if not is_valid:
162
+ reward = -0.05
163
+ self._cumulative_reward += reward
164
+ self._state.step_count += 1
165
+ obs = self._build_observation(
166
+ action_result={"error": error_msg},
167
+ action_success=False,
168
+ action_message=f"Invalid action: {error_msg}",
169
+ reward=reward,
170
+ )
171
+ self._record_step(action, reward, obs)
172
+ return {"observation": obs, "reward": reward, "done": False, "info": {"error": error_msg}}
173
+
174
+ # ---- MUTATE ----
175
+ action_result, action_msg = self._execute_action(action)
176
+
177
+ # ---- TICK ----
178
+ self._infra.tick()
179
+ self._state.step_count += 1
180
+ self._state.time_elapsed_minutes = self._infra.current_minute
181
+
182
+ # ---- REWARD (oracle-shaped for training) ----
183
+ # Must compute BEFORE recording β€” so repeat detection doesn't
184
+ # flag the current action as already taken.
185
+ reward = self._compute_reward(action)
186
+ self._infra.record_action(action.action_type, action.target_service)
187
+ self._cumulative_reward += reward
188
+ self._state.cumulative_reward = self._cumulative_reward
189
+
190
+ # ---- CHECK DONE ----
191
+ done = self._check_done(action)
192
+ self._done = done
193
+ self._state.done = done
194
+
195
+ # ---- OBSERVE ----
196
+ obs = self._build_observation(
197
+ action_result=action_result,
198
+ action_success=True,
199
+ action_message=action_msg,
200
+ reward=reward,
201
+ )
202
+
203
+ self._record_step(action, reward, obs)
204
+
205
+ info: Dict[str, Any] = {}
206
+ if done:
207
+ # Compute final grade (oracle-independent)
208
+ final_grade = self._scenario.grade(self._trajectory)
209
+ info["final_grade"] = final_grade
210
+ info["task_name"] = self._scenario.task_name
211
+ info["steps_taken"] = self._state.step_count
212
+ info["trajectory_length"] = len(self._trajectory)
213
+
214
+ return {"observation": obs, "reward": reward, "done": done, "info": info}
215
+
216
+ # ------------------------------------------------------------------
217
+ # state()
218
+ # ------------------------------------------------------------------
219
+
220
+ @property
221
+ def state(self) -> IncidentState:
222
+ return self._state
223
+
224
+ def get_state(self) -> Dict[str, Any]:
225
+ return {
226
+ "episode_id": self._state.episode_id,
227
+ "task_name": self._state.task_name,
228
+ "step_count": self._state.step_count,
229
+ "time_elapsed_minutes": self._state.time_elapsed_minutes,
230
+ "done": self._state.done,
231
+ "cumulative_reward": round(self._state.cumulative_reward, 3),
232
+ "declared_root_cause": self._state.declared_root_cause,
233
+ }
234
+
235
+ # ------------------------------------------------------------------
236
+ # Action execution handlers
237
+ # ------------------------------------------------------------------
238
+
239
+ def _execute_action(
240
+ self, action: IncidentAction
241
+ ) -> Tuple[Dict[str, Any], str]:
242
+ """Execute a validated action. Returns (result_dict, message)."""
243
+ at = action.parsed_type()
244
+ target = action.target_service
245
+
246
+ if at == ActionType.VIEW_ALERTS:
247
+ alerts = self._infra.get_alerts()
248
+ return {"alerts": alerts, "count": len(alerts)}, f"Viewing {len(alerts)} active alerts"
249
+
250
+ elif at == ActionType.QUERY_LOGS:
251
+ level_filter = action.parameters.get("level")
252
+ keyword = action.parameters.get("keyword")
253
+ limit = action.parameters.get("limit", 15)
254
+ logs = self._infra.get_logs_for_service(target, level_filter, keyword, limit)
255
+ return {"logs": logs, "count": len(logs), "service": target}, \
256
+ f"Queried {len(logs)} logs from {target}"
257
+
258
+ elif at == ActionType.CHECK_METRICS:
259
+ metrics = self._infra.get_metrics_for_service(target)
260
+ return {"metrics": metrics, "service": target, "data_points": len(metrics)}, \
261
+ f"Retrieved {len(metrics)} metric data points for {target}"
262
+
263
+ elif at == ActionType.CHECK_DEPENDENCIES:
264
+ deps = self._infra.get_dependencies_for_service(target)
265
+ return {"dependencies": deps, "service": target}, \
266
+ f"Retrieved dependency map for {target}"
267
+
268
+ elif at == ActionType.CHECK_DEPLOY_HISTORY:
269
+ deploys = self._infra.get_deploy_history_for_service(target)
270
+ return {"deploys": deploys, "service": target, "count": len(deploys)}, \
271
+ f"Retrieved {len(deploys)} deploys for {target}"
272
+
273
+ elif at == ActionType.RUN_HEALTH_CHECK:
274
+ health = self._infra.run_health_check(target)
275
+ return {"health_check": health, "service": target}, \
276
+ f"Health check for {target}: {health['status']}"
277
+
278
+ elif at == ActionType.RESTART_SERVICE:
279
+ svc = self._infra.get_service(target)
280
+ msg = svc.restart(self._infra.current_minute) if svc else "Service not found"
281
+ return {"result": msg, "service": target}, msg
282
+
283
+ elif at == ActionType.ROLLBACK_DEPLOY:
284
+ svc = self._infra.get_service(target)
285
+ msg = svc.rollback_deploy(self._infra.current_minute) if svc else "Service not found"
286
+ return {"result": msg, "service": target}, msg
287
+
288
+ elif at == ActionType.SCALE_SERVICE:
289
+ svc = self._infra.get_service(target)
290
+ new_replicas = action.parameters.get("replicas", 5)
291
+ msg = svc.scale(new_replicas, self._infra.current_minute) if svc else "Service not found"
292
+ return {"result": msg, "service": target}, msg
293
+
294
+ elif at == ActionType.DECLARE_ROOT_CAUSE:
295
+ root_cause = action.parameters.get("root_cause", "")
296
+ self._state.declared_root_cause = root_cause
297
+ self._root_cause_declared = True
298
+ return {
299
+ "declared": root_cause,
300
+ "message": "Root cause declaration registered. Episode will end after this step.",
301
+ }, f"Root cause declared: {root_cause}"
302
+
303
+ else:
304
+ return {"error": f"Unhandled action type: {at}"}, "Unknown action"
305
+
306
+ # ------------------------------------------------------------------
307
+ # Reward computation (oracle-shaped β€” Layer 6)
308
+ # ------------------------------------------------------------------
309
+
310
+ def _compute_reward(self, action: IncidentAction) -> float:
311
+ """
312
+ Compute per-step reward using oracle-shaped signal.
313
+ The training reward has access to hidden state (involved_services,
314
+ root_cause_service) β€” this is necessary for learning.
315
+ The GRADER does NOT use this; it scores trajectory-only.
316
+ """
317
+ at = action.parsed_type()
318
+ target = action.target_service
319
+ scenario = self._scenario
320
+ reward = 0.0
321
+
322
+ # --- Step penalty (efficiency pressure) ---
323
+ reward -= 0.02
324
+
325
+ # --- Repeat detection ---
326
+ if self._infra.was_action_taken(action.action_type, target):
327
+ reward -= 0.05
328
+ return round(reward, 3)
329
+
330
+ # --- Diagnostic actions ---
331
+ if at in DIAGNOSTIC_ACTIONS:
332
+ if target and target in scenario.involved_services:
333
+ reward += 0.15 # Investigating a relevant service
334
+ elif target and target not in scenario.involved_services:
335
+ reward += 0.05 # Exploring β€” not penalized heavily
336
+ elif at == ActionType.VIEW_ALERTS:
337
+ reward += 0.15 # Always good to view alerts
338
+
339
+ # --- Remediation actions ---
340
+ elif at in REMEDIATION_ACTIONS:
341
+ if target == scenario.root_cause_service:
342
+ reward += 0.30 # Correct remediation target
343
+ elif target and target in scenario.involved_services:
344
+ reward += 0.10 # Helpful but not the root cause
345
+ else:
346
+ reward -= 0.15 # Remediating healthy/uninvolved service
347
+
348
+ # --- Root cause declaration ---
349
+ elif at == ActionType.DECLARE_ROOT_CAUSE:
350
+ declared = action.parameters.get("root_cause", "").lower()
351
+ keywords = scenario.root_cause_keywords
352
+ if keywords:
353
+ matched = sum(1 for kw in keywords if kw in declared)
354
+ ratio = matched / len(keywords)
355
+ if ratio >= 0.6:
356
+ reward += 0.40 # Correct
357
+ elif ratio >= 0.3:
358
+ reward += 0.15 # Partial
359
+ else:
360
+ reward -= 0.20 # Wrong
361
+ else:
362
+ reward -= 0.20
363
+
364
+ # --- Episode completion bonus/penalty ---
365
+ if self._root_cause_declared:
366
+ if self._infra.all_services_healthy():
367
+ reward += 0.20 # All services restored
368
+ if self._infra.current_minute > self._infra.time_budget_minutes:
369
+ reward -= 0.10 # Exceeded time budget
370
+
371
+ return round(reward, 3)
372
+
373
+ # ------------------------------------------------------------------
374
+ # Done check
375
+ # ------------------------------------------------------------------
376
+
377
+ def _check_done(self, action: IncidentAction) -> bool:
378
+ """Episode ends when root cause is declared or max steps reached."""
379
+ if self._root_cause_declared:
380
+ return True
381
+ if self._state.step_count >= self._scenario.max_steps:
382
+ return True
383
+ return False
384
+
385
+ # ------------------------------------------------------------------
386
+ # Observation builder
387
+ # ------------------------------------------------------------------
388
+
389
+ def _build_observation(
390
+ self,
391
+ action_result: Dict[str, Any],
392
+ action_success: bool,
393
+ action_message: str,
394
+ reward: float,
395
+ ) -> Dict[str, Any]:
396
+ """Build the POMDP observation dict (no hidden state exposed)."""
397
+ statuses = self._infra.get_all_statuses() if self._infra else {}
398
+ alerts = self._infra.get_alerts() if self._infra else []
399
+ valid_actions = self._infra.get_valid_actions() if self._infra else []
400
+
401
+ return {
402
+ "incident_summary": self._scenario.incident_summary if self._scenario else "",
403
+ "severity": self._scenario.severity if self._scenario else "SEV3",
404
+ "time_elapsed_minutes": self._infra.current_minute if self._infra else 0,
405
+ "time_budget_minutes": self._infra.time_budget_minutes if self._infra else 30,
406
+ "action_result": action_result,
407
+ "action_success": action_success,
408
+ "action_message": action_message,
409
+ "service_statuses": statuses,
410
+ "active_alerts_count": len(alerts),
411
+ "valid_actions": valid_actions,
412
+ "available_services": list(SERVICE_NAMES),
413
+ "current_reward": reward,
414
+ "cumulative_reward": round(self._cumulative_reward, 3),
415
+ "steps_taken": self._state.step_count,
416
+ "max_steps": self._scenario.max_steps if self._scenario else 20,
417
+ "done": self._done,
418
+ }
419
+
420
+ # ------------------------------------------------------------------
421
+ # Trajectory recording
422
+ # ------------------------------------------------------------------
423
+
424
+ def _record_step(
425
+ self,
426
+ action: IncidentAction,
427
+ reward: float,
428
+ observation: Dict[str, Any],
429
+ ) -> None:
430
+ """Record step for trajectory-based grading."""
431
+ record = StepRecord(
432
+ step_number=self._state.step_count,
433
+ action=action,
434
+ reward=reward,
435
+ observation_summary={
436
+ "action_message": observation.get("action_message", ""),
437
+ "active_alerts_count": observation.get("active_alerts_count", 0),
438
+ },
439
+ service_statuses_after=dict(observation.get("service_statuses", {})),
440
+ timestamp_minutes=self._infra.current_minute if self._infra else 0,
441
+ )
442
+ self._trajectory.append(record)
443
+
444
+ # ------------------------------------------------------------------
445
+ # Trajectory access (for external grading)
446
+ # ------------------------------------------------------------------
447
+
448
+ def get_trajectory(self) -> List[StepRecord]:
449
+ return list(self._trajectory)
simulation/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Simulation engine package
simulation/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (175 Bytes). View file
 
simulation/__pycache__/alerts.cpython-313.pyc ADDED
Binary file (4.62 kB). View file
 
simulation/__pycache__/infrastructure.cpython-313.pyc ADDED
Binary file (14.5 kB). View file
 
simulation/__pycache__/logs.cpython-313.pyc ADDED
Binary file (8.77 kB). View file
 
simulation/__pycache__/metrics.cpython-313.pyc ADDED
Binary file (8.92 kB). View file
 
simulation/__pycache__/service.cpython-313.pyc ADDED
Binary file (19.8 kB). View file
 
simulation/alerts.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Alert firing engine.
3
+
4
+ Alerts fire based on metric thresholds β€” the agent sees what fired
5
+ but must investigate to find why. Alert correlation (multiple alerts
6
+ from a cascading failure) is represented by shared source timestamps.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import random
12
+ from typing import Any, Dict, List
13
+
14
+ from .service import ServiceState
15
+
16
+
17
+ # ------------------------------------------------------------------
18
+ # Threshold definitions
19
+ # ------------------------------------------------------------------
20
+
21
+ _ALERT_RULES = [
22
+ {
23
+ "name": "HighErrorRate",
24
+ "field": "error_rate_percent",
25
+ "threshold": 10.0,
26
+ "severity": "critical",
27
+ "description": "{service}: error rate {value:.1f}% exceeds threshold 10%",
28
+ },
29
+ {
30
+ "name": "HighMemoryUsage",
31
+ "field": "memory_percent",
32
+ "threshold": 80.0,
33
+ "severity": "critical",
34
+ "description": "{service}: memory usage {value:.0f}% exceeds threshold 80%",
35
+ },
36
+ {
37
+ "name": "HighLatencyP99",
38
+ "field": "latency_p99_ms",
39
+ "threshold": 1000.0,
40
+ "severity": "warning",
41
+ "description": "{service}: p99 latency {value:.0f}ms exceeds threshold 1000ms",
42
+ },
43
+ {
44
+ "name": "HighLatencyP95",
45
+ "field": "latency_p95_ms",
46
+ "threshold": 500.0,
47
+ "severity": "warning",
48
+ "description": "{service}: p95 latency {value:.0f}ms exceeds threshold 500ms",
49
+ },
50
+ {
51
+ "name": "HighCPU",
52
+ "field": "cpu_percent",
53
+ "threshold": 80.0,
54
+ "severity": "warning",
55
+ "description": "{service}: CPU usage {value:.0f}% exceeds threshold 80%",
56
+ },
57
+ {
58
+ "name": "ServiceDown",
59
+ "field": "status",
60
+ "threshold": "down",
61
+ "severity": "critical",
62
+ "description": "{service}: service is DOWN β€” health check failing",
63
+ },
64
+ {
65
+ "name": "ServiceDegraded",
66
+ "field": "status",
67
+ "threshold": "degraded",
68
+ "severity": "warning",
69
+ "description": "{service}: service is DEGRADED β€” partial failures detected",
70
+ },
71
+ {
72
+ "name": "LowRequestRate",
73
+ "field": "requests_per_sec",
74
+ "threshold": 100.0,
75
+ "severity": "warning",
76
+ "description": "{service}: request rate {value:.0f} rps dropped below threshold 100 rps",
77
+ "below": True,
78
+ },
79
+ ]
80
+
81
+
82
+ def evaluate_alerts(
83
+ services: Dict[str, ServiceState],
84
+ current_minute: int,
85
+ ) -> List[Dict[str, Any]]:
86
+ """
87
+ Evaluate all alert rules against current service states.
88
+ Returns list of firing alert dicts.
89
+ """
90
+ alerts = []
91
+ alert_counter = 0
92
+
93
+ for svc_name, svc in services.items():
94
+ for rule in _ALERT_RULES:
95
+ field = rule["field"]
96
+ threshold = rule["threshold"]
97
+
98
+ # Status-based alerts
99
+ if field == "status":
100
+ if svc.status == threshold:
101
+ alert_counter += 1
102
+ alerts.append({
103
+ "alert_id": f"alert-{alert_counter:03d}",
104
+ "severity": rule["severity"],
105
+ "source_service": svc_name,
106
+ "description": rule["description"].format(
107
+ service=svc_name, value=0),
108
+ "firing_since": f"2025-01-15T14:{max(0, current_minute - svc.ticks_in_down):02d}:00Z"
109
+ if threshold == "down"
110
+ else f"2025-01-15T14:{max(0, current_minute - svc.ticks_in_degraded):02d}:00Z",
111
+ "rule_name": rule["name"],
112
+ })
113
+ continue
114
+
115
+ # Numeric threshold alerts
116
+ value = getattr(svc, field, 0)
117
+ is_below = rule.get("below", False)
118
+ triggered = value < threshold if is_below else value > threshold
119
+
120
+ if triggered:
121
+ alert_counter += 1
122
+ ticks_firing = max(1, len([
123
+ h for h in svc.metric_history[-10:]
124
+ if (h.get(field.replace("_percent", "").replace("_ms", ""),
125
+ h.get(field, 0))
126
+ < threshold if is_below
127
+ else h.get(field.replace("_percent", "").replace("_ms", ""),
128
+ h.get(field, 0))
129
+ > threshold)
130
+ ]))
131
+ alerts.append({
132
+ "alert_id": f"alert-{alert_counter:03d}",
133
+ "severity": rule["severity"],
134
+ "source_service": svc_name,
135
+ "description": rule["description"].format(
136
+ service=svc_name, value=value),
137
+ "firing_since": f"2025-01-15T14:{max(0, current_minute - ticks_firing):02d}:00Z",
138
+ "rule_name": rule["name"],
139
+ })
140
+
141
+ # Sort by severity: critical first
142
+ severity_order = {"critical": 0, "warning": 1, "info": 2}
143
+ alerts.sort(key=lambda a: severity_order.get(a["severity"], 9))
144
+
145
+ return alerts
simulation/infrastructure.py ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Infrastructure state machine.
3
+
4
+ Manages the full service topology, cascade propagation, and the
5
+ validate β†’ mutate β†’ tick execution ordering.
6
+
7
+ This is the central coordinator that scenarios inject faults into
8
+ and the environment executes actions against.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import random
14
+ from typing import Any, Dict, List, Optional, Set, Tuple
15
+
16
+ from .service import Deploy, ServiceState
17
+ from .alerts import evaluate_alerts
18
+ from .logs import (
19
+ generate_noise_logs,
20
+ generate_red_herring_logs,
21
+ )
22
+
23
+
24
+ # ------------------------------------------------------------------
25
+ # Service topology β€” the dependency graph
26
+ # ------------------------------------------------------------------
27
+
28
+ SERVICE_NAMES = [
29
+ "api_gateway", "auth", "orders", "payment",
30
+ "cache", "database", "queue",
31
+ ]
32
+
33
+ # depends_on: service β†’ list of services it calls
34
+ DEPENDENCY_GRAPH: Dict[str, List[str]] = {
35
+ "api_gateway": ["auth", "orders", "cache"],
36
+ "auth": ["database"],
37
+ "orders": ["database", "payment", "auth"],
38
+ "payment": ["queue", "database"],
39
+ "cache": [],
40
+ "database": [],
41
+ "queue": [],
42
+ }
43
+
44
+
45
+ def _depended_by_graph() -> Dict[str, List[str]]:
46
+ """Invert the dependency graph: service β†’ who depends on it."""
47
+ inv: Dict[str, List[str]] = {name: [] for name in SERVICE_NAMES}
48
+ for service, deps in DEPENDENCY_GRAPH.items():
49
+ for dep in deps:
50
+ inv[dep].append(service)
51
+ return inv
52
+
53
+
54
+ DEPENDED_BY = _depended_by_graph()
55
+
56
+
57
+ class Infrastructure:
58
+ """
59
+ Virtual infrastructure state machine.
60
+
61
+ Owns all services, handles cascade propagation, tracks simulation
62
+ time, and enforces action validation.
63
+ """
64
+
65
+ def __init__(self) -> None:
66
+ self.services: Dict[str, ServiceState] = {}
67
+ self.current_minute: int = 0
68
+ self.time_budget_minutes: int = 30
69
+ self._actions_taken: List[Tuple[str, Optional[str]]] = [] # (action_type, target)
70
+ self._all_logs: List[Dict[str, Any]] = []
71
+ self._setup_services()
72
+
73
+ def _setup_services(self) -> None:
74
+ """Create all seven services with their dependency graphs."""
75
+ for name in SERVICE_NAMES:
76
+ svc = ServiceState(
77
+ name=name,
78
+ dependencies=list(DEPENDENCY_GRAPH.get(name, [])),
79
+ )
80
+ # Give each service a "good" deploy history baseline
81
+ svc.deploy_history = [
82
+ Deploy(
83
+ version=f"v1.{random.randint(0, 9)}.{random.randint(0, 9)}",
84
+ timestamp_minutes=-120,
85
+ author=random.choice(["alice", "bob", "charlie", "deploy-bot"]),
86
+ commit_hash=f"{random.randint(0, 0xFFFFFF):06x}",
87
+ description="Routine release β€” bug fixes and performance improvements",
88
+ ),
89
+ ]
90
+ # Populate 30 minutes of healthy metric history
91
+ from .metrics import generate_healthy_history
92
+ svc.metric_history = generate_healthy_history(30, start_minute=0)
93
+ svc._reset_metrics_healthy()
94
+ self.services[name] = svc
95
+
96
+ # ------------------------------------------------------------------
97
+ # Public API
98
+ # ------------------------------------------------------------------
99
+
100
+ def get_service(self, name: str) -> Optional[ServiceState]:
101
+ return self.services.get(name)
102
+
103
+ def get_all_statuses(self) -> Dict[str, str]:
104
+ return {name: svc.status for name, svc in self.services.items()}
105
+
106
+ def get_alerts(self) -> List[Dict[str, Any]]:
107
+ return evaluate_alerts(self.services, self.current_minute)
108
+
109
+ def record_action(self, action_type: str, target: Optional[str]) -> None:
110
+ self._actions_taken.append((action_type, target))
111
+
112
+ def was_action_taken(self, action_type: str, target: Optional[str] = None) -> bool:
113
+ """Check if this exact action was already taken (for repeat detection)."""
114
+ return (action_type, target) in self._actions_taken
115
+
116
+ def action_count(self) -> int:
117
+ return len(self._actions_taken)
118
+
119
+ # ------------------------------------------------------------------
120
+ # Tick β€” advances simulation by one minute (called after every step)
121
+ # ------------------------------------------------------------------
122
+
123
+ def tick(self) -> None:
124
+ """
125
+ Advance the simulation by one minute.
126
+ Order: propagate cascades β†’ tick all services β†’ generate noise logs.
127
+ """
128
+ self.current_minute += 1
129
+ self._propagate_cascades()
130
+
131
+ for name, svc in self.services.items():
132
+ new_logs = svc.tick(self.current_minute)
133
+ self._all_logs.extend(new_logs)
134
+
135
+ # Mix in noise and red herrings
136
+ if random.random() < 0.4:
137
+ noise = generate_noise_logs(name, self.current_minute, count=1)
138
+ svc.logs.extend(noise)
139
+ self._all_logs.extend(noise)
140
+ if random.random() < 0.15:
141
+ herrings = generate_red_herring_logs(name, self.current_minute, count=1)
142
+ svc.logs.extend(herrings)
143
+ self._all_logs.extend(herrings)
144
+
145
+ # ------------------------------------------------------------------
146
+ # Cascade propagation
147
+ # ------------------------------------------------------------------
148
+
149
+ def _propagate_cascades(self) -> None:
150
+ """
151
+ If a service is DOWN or DEGRADED, its downstream dependents
152
+ should accumulate dependency_degraded faults.
153
+ If a service recovers, clear the cascaded faults.
154
+ """
155
+ for name, svc in self.services.items():
156
+ dependents = DEPENDED_BY.get(name, [])
157
+ if svc.status in ("down", "degraded") and svc.active_faults:
158
+ # Cascade to dependents
159
+ for dep_name in dependents:
160
+ dep_svc = self.services[dep_name]
161
+ if not dep_svc.has_fault("dependency_degraded"):
162
+ dep_svc.inject_fault("dependency_degraded", upstream=name)
163
+ elif svc.status == "healthy" and not svc.active_faults:
164
+ # Service recovered β€” clear cascade on dependents
165
+ for dep_name in dependents:
166
+ dep_svc = self.services[dep_name]
167
+ params = dep_svc.fault_params.get("dependency_degraded", {})
168
+ if params.get("upstream") == name:
169
+ dep_svc.recover_from_dependency(self.current_minute)
170
+
171
+ # ------------------------------------------------------------------
172
+ # Validation β€” Layer 5: validate BEFORE mutating
173
+ # ------------------------------------------------------------------
174
+
175
+ def validate_action(
176
+ self,
177
+ action_type: str,
178
+ target_service: Optional[str],
179
+ ) -> Tuple[bool, str]:
180
+ """
181
+ Validate that an action is legal in the current state.
182
+ Returns (is_valid, error_message).
183
+ """
184
+ from ..models import ActionType, TARGETED_ACTIONS
185
+
186
+ try:
187
+ at = ActionType(action_type)
188
+ except ValueError:
189
+ return False, f"Unknown action type: {action_type}"
190
+
191
+ if at in TARGETED_ACTIONS:
192
+ if not target_service:
193
+ return False, f"Action {action_type} requires a target_service"
194
+ if target_service not in self.services:
195
+ return False, f"Unknown service: {target_service}"
196
+
197
+ # Specific validations (action masking)
198
+ if at == ActionType.ROLLBACK_DEPLOY:
199
+ svc = self.services.get(target_service, None)
200
+ if svc and len(svc.deploy_history) < 2:
201
+ return False, f"No previous deploy to rollback to for {target_service}"
202
+
203
+ if at == ActionType.SCALE_SERVICE:
204
+ svc = self.services.get(target_service, None)
205
+ if svc and svc.status == "down":
206
+ return False, f"Cannot scale {target_service} β€” service is DOWN"
207
+
208
+ return True, ""
209
+
210
+ def get_valid_actions(self) -> List[str]:
211
+ """
212
+ Return list of valid (action_type, target) descriptions.
213
+ Used to populate valid_actions[] in the observation.
214
+ """
215
+ from ..models import ActionType, TARGETED_ACTIONS
216
+ valid = []
217
+ for at in ActionType:
218
+ if at in TARGETED_ACTIONS:
219
+ for svc_name in self.services:
220
+ is_valid, _ = self.validate_action(at.value, svc_name)
221
+ if is_valid:
222
+ valid.append(f"{at.value}:{svc_name}")
223
+ else:
224
+ valid.append(at.value)
225
+ return valid
226
+
227
+ # ------------------------------------------------------------------
228
+ # Service queries (observation builders)
229
+ # ------------------------------------------------------------------
230
+
231
+ def get_logs_for_service(
232
+ self,
233
+ service_name: str,
234
+ level_filter: Optional[str] = None,
235
+ keyword: Optional[str] = None,
236
+ limit: int = 20,
237
+ ) -> List[Dict[str, Any]]:
238
+ """Query logs for a service with optional filtering."""
239
+ svc = self.services.get(service_name)
240
+ if not svc:
241
+ return []
242
+
243
+ logs = list(svc.logs)
244
+ if level_filter:
245
+ logs = [l for l in logs if l.get("level", "").upper() == level_filter.upper()]
246
+ if keyword:
247
+ kw = keyword.lower()
248
+ logs = [l for l in logs if kw in l.get("message", "").lower()]
249
+ return logs[-limit:]
250
+
251
+ def get_metrics_for_service(self, service_name: str) -> List[Dict[str, float]]:
252
+ svc = self.services.get(service_name)
253
+ return list(svc.metric_history) if svc else []
254
+
255
+ def get_dependencies_for_service(self, service_name: str) -> Dict[str, List[str]]:
256
+ return {
257
+ "depends_on": list(DEPENDENCY_GRAPH.get(service_name, [])),
258
+ "depended_by": list(DEPENDED_BY.get(service_name, [])),
259
+ }
260
+
261
+ def get_deploy_history_for_service(self, service_name: str) -> List[Dict[str, Any]]:
262
+ svc = self.services.get(service_name)
263
+ if not svc:
264
+ return []
265
+ return [
266
+ {
267
+ "version": d.version,
268
+ "timestamp": f"2025-01-15T{14 + d.timestamp_minutes // 60:02d}:"
269
+ f"{d.timestamp_minutes % 60:02d}:00Z"
270
+ if d.timestamp_minutes >= 0
271
+ else f"2025-01-15T{12 + (d.timestamp_minutes + 120) // 60:02d}:"
272
+ f"{(d.timestamp_minutes + 120) % 60:02d}:00Z",
273
+ "author": d.author,
274
+ "commit_hash": d.commit_hash,
275
+ "description": d.description,
276
+ }
277
+ for d in svc.deploy_history
278
+ ]
279
+
280
+ def run_health_check(self, service_name: str) -> Dict[str, Any]:
281
+ svc = self.services.get(service_name)
282
+ if not svc:
283
+ return {"status": "unknown", "response_time_ms": 0}
284
+ response_time = {
285
+ "healthy": random.randint(5, 50),
286
+ "degraded": random.randint(200, 2000),
287
+ "down": 30000, # timeout
288
+ }.get(svc.status, 0)
289
+ return {
290
+ "status": svc.status,
291
+ "response_time_ms": response_time,
292
+ "replicas": svc.replica_count,
293
+ "active_faults_count": len(svc.active_faults), # agent can see SOMETHING is wrong
294
+ }
295
+
296
+ def all_services_healthy(self) -> bool:
297
+ return all(svc.status == "healthy" for svc in self.services.values())
simulation/logs.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Log stream generator.
3
+
4
+ Produces realistic structured log entries β€” both signal and noise.
5
+ Red herring logs are mixed in so the agent must filter real evidence
6
+ from routine chatter.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import random
12
+ from typing import Any, Dict, List, Optional
13
+
14
+
15
+ # ------------------------------------------------------------------
16
+ # Noise logs β€” routine operational chatter
17
+ # ------------------------------------------------------------------
18
+
19
+ _NOISE_TEMPLATES = [
20
+ ("INFO", "Processed {n} requests in last 60 seconds"),
21
+ ("INFO", "Health check passed β€” all dependencies reachable"),
22
+ ("INFO", "Connection pool stats: active={a}, idle={i}, max={m}"),
23
+ ("DEBUG", "Cache hit ratio: {r:.1%} β€” {h} hits, {m} misses"),
24
+ ("INFO", "Scheduled job 'metrics_export' completed in {d}ms"),
25
+ ("DEBUG", "TLS handshake completed with upstream in {d}ms"),
26
+ ("INFO", "Config reload: no changes detected"),
27
+ ("WARN", "Slow query detected: SELECT * FROM sessions took {d}ms"),
28
+ ("INFO", "Garbage collection: freed {n}MB in {d}ms"),
29
+ ("DEBUG", "Rate limiter: {n} requests allowed, 0 throttled"),
30
+ ]
31
+
32
+
33
+ def generate_noise_logs(
34
+ service_name: str,
35
+ current_minute: int,
36
+ count: int = 3,
37
+ ) -> List[Dict[str, Any]]:
38
+ """Generate routine noise logs for a service."""
39
+ logs = []
40
+ for _ in range(count):
41
+ template_level, template_msg = random.choice(_NOISE_TEMPLATES)
42
+ msg = template_msg.format(
43
+ n=random.randint(100, 5000),
44
+ a=random.randint(5, 20),
45
+ i=random.randint(0, 10),
46
+ m=random.randint(20, 50),
47
+ r=random.uniform(0.85, 0.99),
48
+ h=random.randint(1000, 9000),
49
+ d=random.randint(1, 500),
50
+ )
51
+ logs.append({
52
+ "timestamp": f"2025-01-15T14:{current_minute:02d}:{random.randint(0,59):02d}Z",
53
+ "level": template_level,
54
+ "service": service_name,
55
+ "message": msg,
56
+ "trace_id": None,
57
+ })
58
+ return logs
59
+
60
+
61
+ # ------------------------------------------------------------------
62
+ # Scenario-specific log generators (signal)
63
+ # ------------------------------------------------------------------
64
+
65
+ def generate_memory_leak_logs(
66
+ service_name: str,
67
+ current_minute: int,
68
+ memory_percent: float,
69
+ ) -> List[Dict[str, Any]]:
70
+ """Logs that indicate a memory leak is in progress."""
71
+ logs = []
72
+ trace = f"trace-{random.randint(100000, 999999)}"
73
+
74
+ if memory_percent > 90:
75
+ logs.append(_log(current_minute, "FATAL", service_name,
76
+ f"OutOfMemoryError: Java heap space β€” requested 256MB, "
77
+ f"available 12MB", trace))
78
+ logs.append(_log(current_minute, "ERROR", service_name,
79
+ f"Container {service_name}-{random.randint(0,2)} killed by OOM killer "
80
+ f"(exit code 137)", trace))
81
+ elif memory_percent > 80:
82
+ logs.append(_log(current_minute, "ERROR", service_name,
83
+ f"Memory allocation failed: unable to allocate {random.randint(64, 256)}MB "
84
+ f"for request processing", trace))
85
+ logs.append(_log(current_minute, "WARN", service_name,
86
+ f"GC overhead limit exceeded: spent {random.randint(80, 97)}% of time in GC"))
87
+ elif memory_percent > 70:
88
+ logs.append(_log(current_minute, "WARN", service_name,
89
+ f"Heap usage warning: {memory_percent:.0f}% β€” approaching limit. "
90
+ f"Consider increasing -Xmx or investigating leaks"))
91
+
92
+ return logs
93
+
94
+
95
+ def generate_auth_failure_logs(
96
+ service_name: str,
97
+ current_minute: int,
98
+ is_auth_service: bool = False,
99
+ ) -> List[Dict[str, Any]]:
100
+ """Logs for auth-related failures (used in cascading failure scenario)."""
101
+ logs = []
102
+ trace = f"trace-{random.randint(100000, 999999)}"
103
+
104
+ if is_auth_service:
105
+ logs.append(_log(current_minute, "ERROR", service_name,
106
+ "NullPointerException: configuration key 'auth.jwt.secret' is null "
107
+ "β€” cannot validate tokens", trace))
108
+ logs.append(_log(current_minute, "ERROR", service_name,
109
+ f"Authentication failed for {random.randint(50, 200)} requests in "
110
+ f"last 60s β€” returning HTTP 500"))
111
+ else:
112
+ logs.append(_log(current_minute, "ERROR", service_name,
113
+ f"Call to auth-service failed: HTTP 500 Internal Server Error "
114
+ f"β€” retrying ({random.randint(1,3)}/3)", trace))
115
+ logs.append(_log(current_minute, "WARN", service_name,
116
+ f"Circuit breaker for auth-service: state=HALF_OPEN, "
117
+ f"failures={random.randint(5, 20)}, threshold=10"))
118
+
119
+ return logs
120
+
121
+
122
+ def generate_deadlock_logs(
123
+ service_name: str,
124
+ current_minute: int,
125
+ waiting_on: str,
126
+ ) -> List[Dict[str, Any]]:
127
+ """Logs for distributed deadlock / circular wait."""
128
+ logs = []
129
+ trace = f"trace-{random.randint(100000, 999999)}"
130
+
131
+ logs.append(_log(current_minute, "WARN", service_name,
132
+ f"Request {trace} waiting on {waiting_on}: blocked for "
133
+ f"{random.randint(5000, 25000)}ms β€” no response", trace))
134
+
135
+ if random.random() < 0.4:
136
+ logs.append(_log(current_minute, "ERROR", service_name,
137
+ f"Timeout calling {waiting_on}: deadline exceeded after 30000ms. "
138
+ f"Retry attempt {random.randint(3, 8)} of 10", trace))
139
+
140
+ if random.random() < 0.2:
141
+ logs.append(_log(current_minute, "ERROR", service_name,
142
+ f"Thread pool exhausted: all {random.randint(50, 200)} threads blocked "
143
+ f"waiting on downstream calls"))
144
+
145
+ return logs
146
+
147
+
148
+ # ------------------------------------------------------------------
149
+ # Red herring logs β€” plausible but misleading
150
+ # ------------------------------------------------------------------
151
+
152
+ _RED_HERRING_TEMPLATES = [
153
+ ("WARN", "DNS resolution for {svc}.internal took {d}ms (threshold: 100ms)"),
154
+ ("WARN", "TLS certificate for {svc}.internal expires in {n} days"),
155
+ ("WARN", "Disk usage on /var/log: {n}% β€” consider log rotation"),
156
+ ("ERROR", "Failed to export metrics to Prometheus: connection timeout after {d}ms"),
157
+ ("WARN", "Background job 'cleanup_sessions' took {d}ms (expected <500ms)"),
158
+ ("ERROR", "Redis SLOWLOG: KEYS pattern='session:*' took {d}ms"),
159
+ ]
160
+
161
+
162
+ def generate_red_herring_logs(
163
+ service_name: str,
164
+ current_minute: int,
165
+ count: int = 1,
166
+ ) -> List[Dict[str, Any]]:
167
+ """Generate plausible but misleading log entries."""
168
+ logs = []
169
+ services = ["api_gateway", "auth", "orders", "payment", "cache", "database", "queue"]
170
+ for _ in range(count):
171
+ level, tmpl = random.choice(_RED_HERRING_TEMPLATES)
172
+ msg = tmpl.format(
173
+ svc=random.choice(services),
174
+ d=random.randint(100, 3000),
175
+ n=random.randint(3, 85),
176
+ )
177
+ logs.append(_log(current_minute, level, service_name, msg))
178
+ return logs
179
+
180
+
181
+ # ------------------------------------------------------------------
182
+ # Helper
183
+ # ------------------------------------------------------------------
184
+
185
+ def _log(
186
+ minute: int,
187
+ level: str,
188
+ service: str,
189
+ message: str,
190
+ trace_id: Optional[str] = None,
191
+ ) -> Dict[str, Any]:
192
+ return {
193
+ "timestamp": f"2025-01-15T14:{minute:02d}:{random.randint(0,59):02d}Z",
194
+ "level": level,
195
+ "service": service,
196
+ "message": message,
197
+ "trace_id": trace_id,
198
+ }
simulation/metrics.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Metric time-series generator.
3
+
4
+ Produces plausible metric history for services β€” both the healthy baseline
5
+ and the anomaly window. Used to populate metric_history on reset so the
6
+ agent sees a 30-minute lookback, not just the current point.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import random
12
+ from typing import Dict, List
13
+
14
+
15
+ def generate_healthy_history(
16
+ minutes: int = 30,
17
+ start_minute: int = 0,
18
+ ) -> List[Dict[str, float]]:
19
+ """Generate 'minutes' worth of normal baseline metrics."""
20
+ history = []
21
+ for m in range(start_minute, start_minute + minutes):
22
+ noise = lambda: random.gauss(0, 1)
23
+ history.append({
24
+ "minute": m,
25
+ "cpu": round(max(5, min(40, 15 + noise() * 3)), 1),
26
+ "memory": round(max(20, min(55, 35 + noise() * 3)), 1),
27
+ "error_rate": round(max(0, min(2, 0.1 + abs(noise()) * 0.1)), 2),
28
+ "latency_p50": round(max(5, 12 + noise() * 2), 1),
29
+ "latency_p95": round(max(20, 45 + noise() * 5), 1),
30
+ "latency_p99": round(max(50, 120 + noise() * 10), 1),
31
+ "rps": round(max(200, 500 + noise() * 30), 1),
32
+ })
33
+ return history
34
+
35
+
36
+ def generate_memory_leak_history(
37
+ minutes: int = 30,
38
+ start_minute: int = 0,
39
+ leak_start_offset: int = 10,
40
+ rate: float = 1.5,
41
+ ) -> List[Dict[str, float]]:
42
+ """
43
+ Generate metric history with a memory leak starting partway through.
44
+ First 'leak_start_offset' minutes are normal, then memory climbs.
45
+ """
46
+ history = []
47
+ mem = 35.0
48
+ for m in range(start_minute, start_minute + minutes):
49
+ noise = lambda: random.gauss(0, 1)
50
+ elapsed = m - start_minute
51
+ if elapsed >= leak_start_offset:
52
+ mem = min(99.0, mem + rate + noise() * 0.3)
53
+ cpu = min(95, 15 + (elapsed - leak_start_offset) * 0.3 + noise() * 2)
54
+ error_rate = max(0, min(100, (mem - 75) * 2 + noise() * 2)) if mem > 75 else 0.1
55
+ lat_p95 = max(45, 45 + (mem - 70) * 8 + noise() * 10) if mem > 70 else 45 + noise() * 5
56
+ lat_p99 = max(120, lat_p95 * 2.5 + noise() * 20)
57
+ else:
58
+ cpu = max(5, min(40, 15 + noise() * 3))
59
+ mem = max(20, min(55, 35 + noise() * 3))
60
+ error_rate = max(0, 0.1 + abs(noise()) * 0.1)
61
+ lat_p95 = max(20, 45 + noise() * 5)
62
+ lat_p99 = max(50, 120 + noise() * 10)
63
+
64
+ history.append({
65
+ "minute": m,
66
+ "cpu": round(cpu, 1),
67
+ "memory": round(mem, 1),
68
+ "error_rate": round(error_rate, 2),
69
+ "latency_p50": round(max(5, 12 + noise() * 2), 1),
70
+ "latency_p95": round(lat_p95, 1),
71
+ "latency_p99": round(lat_p99, 1),
72
+ "rps": round(max(100, 500 - max(0, mem - 80) * 15 + noise() * 20), 1),
73
+ })
74
+ return history
75
+
76
+
77
+ def generate_error_spike_history(
78
+ minutes: int = 30,
79
+ start_minute: int = 0,
80
+ spike_start_offset: int = 5,
81
+ error_rate_target: float = 60.0,
82
+ ) -> List[Dict[str, float]]:
83
+ """Metric history where error rate jumps suddenly (e.g. bad config push)."""
84
+ history = []
85
+ for m in range(start_minute, start_minute + minutes):
86
+ noise = lambda: random.gauss(0, 1)
87
+ elapsed = m - start_minute
88
+ if elapsed >= spike_start_offset:
89
+ error_rate = min(100, error_rate_target + noise() * 5)
90
+ lat_p95 = max(100, 500 + noise() * 50)
91
+ lat_p99 = max(200, 1500 + noise() * 100)
92
+ cpu = max(5, min(80, 40 + noise() * 5))
93
+ else:
94
+ error_rate = max(0, 0.1 + abs(noise()) * 0.1)
95
+ lat_p95 = max(20, 45 + noise() * 5)
96
+ lat_p99 = max(50, 120 + noise() * 10)
97
+ cpu = max(5, min(40, 15 + noise() * 3))
98
+
99
+ history.append({
100
+ "minute": m,
101
+ "cpu": round(cpu, 1),
102
+ "memory": round(max(20, min(55, 35 + noise() * 3)), 1),
103
+ "error_rate": round(error_rate, 2),
104
+ "latency_p50": round(max(5, 12 + noise() * 2), 1),
105
+ "latency_p95": round(lat_p95, 1),
106
+ "latency_p99": round(lat_p99, 1),
107
+ "rps": round(max(100, 500 - error_rate * 3 + noise() * 20), 1),
108
+ })
109
+ return history
110
+
111
+
112
+ def generate_high_latency_history(
113
+ minutes: int = 30,
114
+ start_minute: int = 0,
115
+ latency_start_offset: int = 8,
116
+ target_p99: float = 8000,
117
+ ) -> List[Dict[str, float]]:
118
+ """Metric history with gradually increasing latency (deadlock/contention)."""
119
+ history = []
120
+ for m in range(start_minute, start_minute + minutes):
121
+ noise = lambda: random.gauss(0, 1)
122
+ elapsed = m - start_minute
123
+ if elapsed >= latency_start_offset:
124
+ progress = min(1.0, (elapsed - latency_start_offset) / 15)
125
+ lat_p50 = max(12, 12 + progress * 400 + noise() * 20)
126
+ lat_p95 = max(45, 45 + progress * target_p99 * 0.6 + noise() * 80)
127
+ lat_p99 = max(120, 120 + progress * target_p99 + noise() * 200)
128
+ error_rate = max(0, progress * 15 + noise() * 2)
129
+ rps = max(20, 500 * (1 - progress * 0.7) + noise() * 15)
130
+ else:
131
+ lat_p50 = max(5, 12 + noise() * 2)
132
+ lat_p95 = max(20, 45 + noise() * 5)
133
+ lat_p99 = max(50, 120 + noise() * 10)
134
+ error_rate = max(0, 0.1 + abs(noise()) * 0.1)
135
+ rps = max(200, 500 + noise() * 30)
136
+
137
+ history.append({
138
+ "minute": m,
139
+ "cpu": round(max(5, min(60, 15 + noise() * 3)), 1),
140
+ "memory": round(max(20, min(55, 35 + noise() * 3)), 1),
141
+ "error_rate": round(error_rate, 2),
142
+ "latency_p50": round(lat_p50, 1),
143
+ "latency_p95": round(lat_p95, 1),
144
+ "latency_p99": round(lat_p99, 1),
145
+ "rps": round(rps, 1),
146
+ })
147
+ return history
simulation/service.py ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Individual service simulator.
3
+
4
+ Each service is a stateful entity with health, metrics, logs, deploy history,
5
+ and fault injection points. When faults are injected, metrics respond
6
+ reactively β€” memory climbs, error rates spike, latency degrades β€” and the
7
+ service produces appropriate log entries automatically.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import random
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Dict, List, Optional
15
+
16
+
17
+ @dataclass
18
+ class Deploy:
19
+ """A single deploy record."""
20
+ version: str
21
+ timestamp_minutes: int # simulation minutes since epoch
22
+ author: str
23
+ commit_hash: str
24
+ description: str
25
+ is_bad: bool = False # hidden β€” grader never sees this directly
26
+
27
+
28
+ @dataclass
29
+ class ServiceState:
30
+ """
31
+ Full mutable state for one service.
32
+
33
+ The agent NEVER sees this directly. It can only observe symptoms
34
+ through the five observation modalities (alerts, metrics, logs, deps, deploys).
35
+ """
36
+ name: str
37
+ status: str = "healthy" # healthy | degraded | down
38
+ dependencies: List[str] = field(default_factory=list)
39
+
40
+ # --- Metrics (reactive) ---
41
+ cpu_percent: float = 15.0
42
+ memory_percent: float = 35.0
43
+ error_rate_percent: float = 0.1
44
+ latency_p50_ms: float = 12.0
45
+ latency_p95_ms: float = 45.0
46
+ latency_p99_ms: float = 120.0
47
+ requests_per_sec: float = 500.0
48
+
49
+ # --- Metric history (last 30 data points = 30 minutes) ---
50
+ metric_history: List[Dict[str, float]] = field(default_factory=list)
51
+
52
+ # --- Logs (circular buffer, last 50) ---
53
+ logs: List[Dict[str, Any]] = field(default_factory=list)
54
+
55
+ # --- Deploy history ---
56
+ deploy_history: List[Deploy] = field(default_factory=list)
57
+
58
+ # --- Fault state (hidden β€” drives reactive behavior) ---
59
+ active_faults: List[str] = field(default_factory=list)
60
+ fault_params: Dict[str, Any] = field(default_factory=dict)
61
+
62
+ # --- Operational ---
63
+ replica_count: int = 3
64
+ restarts_since_fault: int = 0
65
+ ticks_in_degraded: int = 0
66
+ ticks_in_down: int = 0
67
+ was_rolled_back: bool = False
68
+
69
+ # ---------------------------------------------------------------
70
+ # Fault injection β€” called by scenarios at setup time
71
+ # ---------------------------------------------------------------
72
+
73
+ def inject_fault(self, fault_type: str, **params: Any) -> None:
74
+ """Inject a named fault. Metrics will react on subsequent ticks."""
75
+ self.active_faults.append(fault_type)
76
+ self.fault_params[fault_type] = params
77
+
78
+ def clear_fault(self, fault_type: str) -> None:
79
+ """Remove a fault (e.g. after rollback fixes the root cause)."""
80
+ if fault_type in self.active_faults:
81
+ self.active_faults.remove(fault_type)
82
+ self.fault_params.pop(fault_type, None)
83
+
84
+ def clear_all_faults(self) -> None:
85
+ self.active_faults.clear()
86
+ self.fault_params.clear()
87
+
88
+ def has_fault(self, fault_type: str) -> bool:
89
+ return fault_type in self.active_faults
90
+
91
+ # ---------------------------------------------------------------
92
+ # Tick β€” advance one simulation minute. Metrics react to faults.
93
+ # ---------------------------------------------------------------
94
+
95
+ def tick(self, current_minute: int) -> List[Dict[str, Any]]:
96
+ """
97
+ Advance the service by one simulation minute.
98
+ Returns any new log entries generated this tick.
99
+ """
100
+ new_logs: List[Dict[str, Any]] = []
101
+ noise = lambda: random.gauss(0, 1)
102
+
103
+ # --- Memory leak: memory climbs steadily ---
104
+ if "memory_leak" in self.active_faults:
105
+ rate = self.fault_params.get("memory_leak", {}).get("rate", 1.5)
106
+ self.memory_percent = min(99.0, self.memory_percent + rate + noise() * 0.3)
107
+ self.cpu_percent = min(95.0, self.cpu_percent + 0.3 + noise() * 0.2)
108
+ if self.memory_percent > 90:
109
+ self.status = "down"
110
+ self.error_rate_percent = min(100.0, 85.0 + noise() * 5)
111
+ new_logs.append(self._log(current_minute, "FATAL",
112
+ f"OutOfMemoryError: Java heap space β€” service {self.name} killed by OOM killer"))
113
+ new_logs.append(self._log(current_minute, "ERROR",
114
+ f"Container {self.name}-0 exited with code 137 (OOMKilled)"))
115
+ elif self.memory_percent > 75:
116
+ self.status = "degraded"
117
+ self.error_rate_percent = min(50.0, 15.0 + (self.memory_percent - 75) * 1.5 + noise() * 2)
118
+ self.latency_p95_ms = max(self.latency_p95_ms, 200 + noise() * 20)
119
+ self.latency_p99_ms = max(self.latency_p99_ms, 500 + noise() * 30)
120
+ new_logs.append(self._log(current_minute, "WARN",
121
+ f"GC pressure: heap usage at {self.memory_percent:.0f}%, "
122
+ f"GC pause {random.randint(200, 800)}ms"))
123
+
124
+ # --- High error rate (e.g. bad config) ---
125
+ if "high_error_rate" in self.active_faults:
126
+ target_rate = self.fault_params.get("high_error_rate", {}).get("rate", 60.0)
127
+ self.error_rate_percent = min(100.0, target_rate + noise() * 5)
128
+ if self.error_rate_percent > 50:
129
+ self.status = "down"
130
+ new_logs.append(self._log(current_minute, "ERROR",
131
+ f"Health check failed: {self.name} returned HTTP 500"))
132
+ elif self.error_rate_percent > 20:
133
+ self.status = "degraded"
134
+ new_logs.append(self._log(current_minute, "ERROR",
135
+ f"Internal Server Error: configuration key 'auth.token.secret' is null"))
136
+
137
+ # --- High latency (e.g. deadlock / contention) ---
138
+ if "high_latency" in self.active_faults:
139
+ target_p99 = self.fault_params.get("high_latency", {}).get("p99", 5000)
140
+ self.latency_p50_ms = min(2000, 300 + noise() * 30)
141
+ self.latency_p95_ms = min(8000, target_p99 * 0.7 + noise() * 100)
142
+ self.latency_p99_ms = min(15000, target_p99 + noise() * 200)
143
+ self.error_rate_percent = min(40.0, 10.0 + noise() * 3)
144
+ self.status = "degraded"
145
+ new_logs.append(self._log(current_minute, "WARN",
146
+ f"Request timeout: upstream call to dependency exceeded 5000ms"))
147
+
148
+ # --- Dependency degradation (cascaded from upstream) ---
149
+ if "dependency_degraded" in self.active_faults:
150
+ upstream = self.fault_params.get("dependency_degraded", {}).get("upstream", "unknown")
151
+ self.error_rate_percent = min(80.0, 25.0 + noise() * 8)
152
+ self.latency_p95_ms = max(self.latency_p95_ms, 1500 + noise() * 100)
153
+ self.latency_p99_ms = max(self.latency_p99_ms, 3000 + noise() * 200)
154
+ if self.error_rate_percent > 50:
155
+ self.status = "down"
156
+ else:
157
+ self.status = "degraded"
158
+ new_logs.append(self._log(current_minute, "ERROR",
159
+ f"Connection refused: {upstream}:8080 β€” upstream service unavailable"))
160
+
161
+ # --- Circular wait / deadlock ---
162
+ if "circular_wait" in self.active_faults:
163
+ peers = self.fault_params.get("circular_wait", {}).get("peers", [])
164
+ self.latency_p50_ms = min(3000, 500 + noise() * 50)
165
+ self.latency_p95_ms = min(10000, 4000 + noise() * 200)
166
+ self.latency_p99_ms = min(30000, 8000 + noise() * 500)
167
+ self.error_rate_percent = min(30.0, 12.0 + noise() * 3)
168
+ self.requests_per_sec = max(10, self.requests_per_sec * 0.85)
169
+ self.status = "degraded"
170
+ peer = random.choice(peers) if peers else "unknown"
171
+ new_logs.append(self._log(current_minute, "WARN",
172
+ f"Timeout waiting for response from {peer}: "
173
+ f"request {self._trace_id()} blocked for {random.randint(5000, 15000)}ms"))
174
+ if random.random() < 0.3:
175
+ new_logs.append(self._log(current_minute, "ERROR",
176
+ f"Retry exhausted for {peer}: CircuitBreaker OPEN after 5 consecutive failures"))
177
+
178
+ # --- Healthy service noise ---
179
+ if not self.active_faults:
180
+ self._tick_healthy(current_minute)
181
+ else:
182
+ self.ticks_in_degraded += 1 if self.status == "degraded" else 0
183
+ self.ticks_in_down += 1 if self.status == "down" else 0
184
+
185
+ # Record metric snapshot
186
+ self.metric_history.append({
187
+ "minute": current_minute,
188
+ "cpu": round(self.cpu_percent, 1),
189
+ "memory": round(self.memory_percent, 1),
190
+ "error_rate": round(self.error_rate_percent, 2),
191
+ "latency_p50": round(self.latency_p50_ms, 1),
192
+ "latency_p95": round(self.latency_p95_ms, 1),
193
+ "latency_p99": round(self.latency_p99_ms, 1),
194
+ "rps": round(self.requests_per_sec, 1),
195
+ })
196
+ # Keep last 30 data points
197
+ if len(self.metric_history) > 30:
198
+ self.metric_history = self.metric_history[-30:]
199
+
200
+ # Keep last 50 logs
201
+ self.logs.extend(new_logs)
202
+ if len(self.logs) > 50:
203
+ self.logs = self.logs[-50:]
204
+
205
+ return new_logs
206
+
207
+ # ---------------------------------------------------------------
208
+ # Remediation actions
209
+ # ---------------------------------------------------------------
210
+
211
+ def restart(self, current_minute: int) -> str:
212
+ """
213
+ Restart the service. Temporarily fixes symptoms but NOT root cause
214
+ unless the fault has been cleared first (e.g. via rollback).
215
+ """
216
+ self.restarts_since_fault += 1
217
+
218
+ if not self.active_faults:
219
+ # Service is healthy β€” restart is unnecessary
220
+ self.status = "healthy"
221
+ self.logs.append(self._log(current_minute, "INFO",
222
+ f"Service {self.name} restarted (was already healthy)"))
223
+ return f"{self.name} restarted (was already healthy)"
224
+
225
+ # Reset metrics temporarily β€” faults will re-corrupt on next tick
226
+ self.memory_percent = 35.0 + random.gauss(0, 3)
227
+ self.cpu_percent = 15.0 + random.gauss(0, 2)
228
+ self.error_rate_percent = max(0.1, self.error_rate_percent * 0.3)
229
+ self.latency_p50_ms = 12.0 + random.gauss(0, 2)
230
+ self.latency_p95_ms = 45.0 + random.gauss(0, 5)
231
+ self.latency_p99_ms = 120.0 + random.gauss(0, 10)
232
+ self.status = "healthy"
233
+
234
+ self.logs.append(self._log(current_minute, "INFO",
235
+ f"Service {self.name} restarted β€” metrics reset. "
236
+ f"NOTE: underlying issue may recur."))
237
+ return f"{self.name} restarted β€” metrics temporarily reset"
238
+
239
+ def rollback_deploy(self, current_minute: int) -> str:
240
+ """
241
+ Roll back to the previous deploy.
242
+ If the active fault was caused by a bad deploy, this FIXES IT.
243
+ """
244
+ if len(self.deploy_history) < 2:
245
+ return f"No previous deploy to rollback to for {self.name}"
246
+
247
+ bad_deploy = self.deploy_history[-1]
248
+ prev_deploy = self.deploy_history[-2]
249
+
250
+ self.was_rolled_back = True
251
+
252
+ # If the bad deploy is what caused the fault, clear it
253
+ if bad_deploy.is_bad:
254
+ self.clear_all_faults()
255
+ self.status = "healthy"
256
+ self._reset_metrics_healthy()
257
+ self.logs.append(self._log(current_minute, "INFO",
258
+ f"Rolled back {self.name} from {bad_deploy.version} to "
259
+ f"{prev_deploy.version} β€” fault cleared"))
260
+ return (f"Rolled back {self.name} from {bad_deploy.version} to "
261
+ f"{prev_deploy.version} β€” service recovering")
262
+ else:
263
+ self.logs.append(self._log(current_minute, "INFO",
264
+ f"Rolled back {self.name} from {bad_deploy.version} to "
265
+ f"{prev_deploy.version} β€” no change in symptoms"))
266
+ return (f"Rolled back {self.name} to {prev_deploy.version} "
267
+ f"β€” symptoms unchanged (likely not the cause)")
268
+
269
+ def scale(self, new_replicas: int, current_minute: int) -> str:
270
+ """Scale to new replica count. Helps with load but not root cause."""
271
+ old = self.replica_count
272
+ self.replica_count = max(1, min(10, new_replicas))
273
+ if self.replica_count > old and "circular_wait" not in self.active_faults:
274
+ # Scaling up reduces latency proportionally
275
+ factor = old / self.replica_count
276
+ self.latency_p50_ms *= factor
277
+ self.latency_p95_ms *= factor
278
+ self.latency_p99_ms *= factor
279
+ self.requests_per_sec /= factor
280
+ self.logs.append(self._log(current_minute, "INFO",
281
+ f"Scaled {self.name} from {old} to {self.replica_count} replicas"))
282
+ return f"Scaled {self.name}: {old} -> {self.replica_count} replicas"
283
+
284
+ # ---------------------------------------------------------------
285
+ # Recovery after upstream fix
286
+ # ---------------------------------------------------------------
287
+
288
+ def recover_from_dependency(self, current_minute: int) -> None:
289
+ """Called when an upstream fault clears β€” this service should heal."""
290
+ self.clear_fault("dependency_degraded")
291
+ if not self.active_faults:
292
+ self.status = "healthy"
293
+ self._reset_metrics_healthy()
294
+ self.logs.append(self._log(current_minute, "INFO",
295
+ f"Service {self.name} recovering β€” upstream dependency restored"))
296
+
297
+ # ---------------------------------------------------------------
298
+ # Internals
299
+ # ---------------------------------------------------------------
300
+
301
+ def _tick_healthy(self, current_minute: int) -> None:
302
+ """Normal baseline metric jitter for healthy services."""
303
+ noise = lambda: random.gauss(0, 1)
304
+ self.cpu_percent = max(5, min(40, 15 + noise() * 3))
305
+ self.memory_percent = max(20, min(55, 35 + noise() * 3))
306
+ self.error_rate_percent = max(0, min(2, 0.1 + abs(noise()) * 0.1))
307
+ self.latency_p50_ms = max(5, 12 + noise() * 2)
308
+ self.latency_p95_ms = max(20, 45 + noise() * 5)
309
+ self.latency_p99_ms = max(50, 120 + noise() * 10)
310
+ self.requests_per_sec = max(200, 500 + noise() * 30)
311
+ self.status = "healthy"
312
+
313
+ def _reset_metrics_healthy(self) -> None:
314
+ """Fully reset to healthy baseline."""
315
+ self.cpu_percent = 15.0 + random.gauss(0, 2)
316
+ self.memory_percent = 35.0 + random.gauss(0, 3)
317
+ self.error_rate_percent = 0.1 + abs(random.gauss(0, 0.05))
318
+ self.latency_p50_ms = 12.0 + random.gauss(0, 1)
319
+ self.latency_p95_ms = 45.0 + random.gauss(0, 3)
320
+ self.latency_p99_ms = 120.0 + random.gauss(0, 8)
321
+ self.requests_per_sec = 500.0 + random.gauss(0, 20)
322
+ self.status = "healthy"
323
+
324
+ def _log(self, minute: int, level: str, message: str) -> Dict[str, Any]:
325
+ return {
326
+ "timestamp": f"2025-01-15T14:{minute:02d}:00Z",
327
+ "level": level,
328
+ "service": self.name,
329
+ "message": message,
330
+ "trace_id": self._trace_id() if level in ("ERROR", "FATAL") else None,
331
+ }
332
+
333
+ @staticmethod
334
+ def _trace_id() -> str:
335
+ return f"trace-{random.randint(100000, 999999)}"
tasks.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Task registry and evaluation graders.
3
+
4
+ Each task maps to a scenario. The grader is oracle-independent β€”
5
+ it takes only List[StepRecord] and returns a float in [0, 1].
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Dict, List, Type
11
+
12
+ from .models import StepRecord
13
+ from .scenarios.base import BaseScenario
14
+ from .scenarios.easy_memory_leak import MemoryLeakScenario
15
+ from .scenarios.medium_cascading_failure import CascadingFailureScenario
16
+ from .scenarios.hard_distributed_deadlock import DistributedDeadlockScenario
17
+
18
+
19
+ # ------------------------------------------------------------------
20
+ # Registry
21
+ # ------------------------------------------------------------------
22
+
23
+ TASK_REGISTRY: Dict[str, Type[BaseScenario]] = {
24
+ "memory_leak": MemoryLeakScenario,
25
+ "cascading_failure": CascadingFailureScenario,
26
+ "distributed_deadlock": DistributedDeadlockScenario,
27
+ }
28
+
29
+ TASK_NAMES = list(TASK_REGISTRY.keys())
30
+
31
+
32
+ def get_scenario(task_name: str) -> BaseScenario:
33
+ """Instantiate a scenario by task name."""
34
+ cls = TASK_REGISTRY.get(task_name)
35
+ if cls is None:
36
+ raise ValueError(
37
+ f"Unknown task: {task_name}. Available: {TASK_NAMES}")
38
+ return cls()
39
+
40
+
41
+ def grade_trajectory(task_name: str, trajectory: List[StepRecord]) -> float:
42
+ """
43
+ Grade a trajectory for a given task.
44
+ This is the evaluation entry point β€” standalone, no hidden state.
45
+ """
46
+ scenario = get_scenario(task_name)
47
+ return scenario.grade(trajectory)