Files
PrimAITE/src/primaite/game/agent/rewards.py

304 lines
11 KiB
Python
Raw Normal View History

"""
Manages the reward function for the agent.
Each agent is equipped with a RewardFunction, which is made up of a list of reward components. The components are
designed to calculate a reward value based on the current state of the simulation. The overall reward function is a
weighed sum of the components.
The reward function is typically specified using a config yaml file or a config dictionary. The following example shows
the structure:
```yaml
reward_function:
reward_components:
- type: DATABASE_FILE_INTEGRITY
weight: 0.5
options:
node_ref: database_server
folder_name: database
file_name: database.db
- type: WEB_SERVER_404_PENALTY
weight: 0.5
options:
node_ref: web_server
service_ref: web_server_database_client
```
"""
from abc import abstractmethod
from typing import Dict, List, Tuple, Type
2023-10-19 01:56:40 +01:00
2023-10-11 15:49:41 +01:00
from primaite import getLogger
2023-10-23 16:26:34 +01:00
from primaite.game.agent.utils import access_from_nested_dict, NOT_PRESENT_IN_STATE
2023-10-19 01:56:40 +01:00
2023-10-11 15:49:41 +01:00
_LOGGER = getLogger(__name__)
2023-10-09 18:35:30 +01:00
class AbstractReward:
"""Base class for reward function components."""
2023-10-06 20:32:52 +01:00
@abstractmethod
2023-10-09 18:35:30 +01:00
def calculate(self, state: Dict) -> float:
"""Calculate the reward for the current state."""
2023-10-11 15:49:41 +01:00
return 0.0
@classmethod
2023-10-12 09:59:45 +01:00
@abstractmethod
def from_config(cls, config: dict) -> "AbstractReward":
"""Create a reward function component from a config dictionary.
:param config: dict of options for the reward component's constructor
:type config: dict
:return: The reward component.
:rtype: AbstractReward
"""
2023-10-11 15:49:41 +01:00
return cls()
2023-10-06 20:32:52 +01:00
2023-10-09 18:35:30 +01:00
class DummyReward(AbstractReward):
"""Dummy reward function component which always returns 0."""
2023-10-06 20:32:52 +01:00
def calculate(self, state: Dict) -> float:
"""Calculate the reward for the current state."""
2023-10-11 15:49:41 +01:00
return 0.0
@classmethod
def from_config(cls, config: dict) -> "DummyReward":
"""Create a reward function component from a config dictionary.
:param config: dict of options for the reward component's constructor. Should be empty.
:type config: dict
2024-02-08 13:20:32 +00:00
:return: The reward component.
:rtype: DummyReward
"""
2023-10-11 15:49:41 +01:00
return cls()
2023-10-19 01:56:40 +01:00
2023-10-11 15:49:41 +01:00
class DatabaseFileIntegrity(AbstractReward):
"""Reward function component which rewards the agent for maintaining the integrity of a database file."""
def __init__(self, node_hostname: str, folder_name: str, file_name: str) -> None:
"""Initialise the reward component.
:param node_hostname: Hostname of the node which contains the database file.
:type node_hostname: str
:param folder_name: folder which contains the database file.
:type folder_name: str
:param file_name: name of the database file.
:type file_name: str
"""
2023-10-19 01:56:40 +01:00
self.location_in_state = [
"network",
"nodes",
node_hostname,
2023-10-19 01:56:40 +01:00
"file_system",
"folders",
folder_name,
"files",
file_name,
]
2023-10-11 15:49:41 +01:00
def calculate(self, state: Dict) -> float:
"""Calculate the reward for the current state.
:param state: The current state of the simulation.
:type state: Dict
"""
2023-10-11 15:49:41 +01:00
database_file_state = access_from_nested_dict(state, self.location_in_state)
2024-01-15 09:48:14 +00:00
if database_file_state is NOT_PRESENT_IN_STATE:
_LOGGER.info(
f"Could not calculate {self.__class__} reward because "
"simulation state did not contain enough information."
)
return 0.0
2023-10-19 01:56:40 +01:00
health_status = database_file_state["health_status"]
2024-01-11 14:49:36 +00:00
if health_status == 2:
2023-10-11 15:49:41 +01:00
return -1
2024-01-11 11:05:55 +00:00
elif health_status == 1:
2023-10-11 15:49:41 +01:00
return 1
else:
return 0
@classmethod
def from_config(cls, config: Dict) -> "DatabaseFileIntegrity":
"""Create a reward function component from a config dictionary.
:param config: dict of options for the reward component's constructor
:type config: Dict
:return: The reward component.
:rtype: DatabaseFileIntegrity
"""
node_hostname = config.get("node_hostname")
2023-10-11 15:49:41 +01:00
folder_name = config.get("folder_name")
file_name = config.get("file_name")
2023-12-15 13:09:50 +00:00
if not (node_hostname and folder_name and file_name):
msg = f"{cls.__name__} could not be initialised with parameters {config}"
_LOGGER.error(msg)
raise ValueError(msg)
2023-10-19 01:56:40 +01:00
return cls(node_hostname=node_hostname, folder_name=folder_name, file_name=file_name)
2023-10-11 15:49:41 +01:00
class WebServer404Penalty(AbstractReward):
"""Reward function component which penalises the agent when the web server returns a 404 error."""
def __init__(self, node_hostname: str, service_name: str) -> None:
"""Initialise the reward component.
:param node_hostname: Hostname of the node which contains the web server service.
:type node_hostname: str
2023-12-15 13:09:50 +00:00
:param service_name: Name of the web server service.
:type service_name: str
"""
self.location_in_state = ["network", "nodes", node_hostname, "services", service_name]
2023-10-11 15:49:41 +01:00
def calculate(self, state: Dict) -> float:
"""Calculate the reward for the current state.
:param state: The current state of the simulation.
:type state: Dict
"""
2023-10-11 15:49:41 +01:00
web_service_state = access_from_nested_dict(state, self.location_in_state)
2023-10-23 16:26:34 +01:00
if web_service_state is NOT_PRESENT_IN_STATE:
return 0.0
most_recent_return_code = web_service_state["last_response_status_code"]
2023-10-19 01:56:40 +01:00
# TODO: reward needs to use the current web state. Observation should return web state at the time of last scan.
2023-10-11 15:49:41 +01:00
if most_recent_return_code == 200:
2023-10-23 16:26:34 +01:00
return 1.0
2023-10-11 15:49:41 +01:00
elif most_recent_return_code == 404:
2023-10-23 16:26:34 +01:00
return -1.0
2023-10-11 15:49:41 +01:00
else:
2023-10-23 16:26:34 +01:00
return 0.0
2023-10-11 15:49:41 +01:00
@classmethod
def from_config(cls, config: Dict) -> "WebServer404Penalty":
"""Create a reward function component from a config dictionary.
:param config: dict of options for the reward component's constructor
:type config: Dict
:return: The reward component.
:rtype: WebServer404Penalty
"""
node_hostname = config.get("node_hostname")
service_name = config.get("service_name")
if not (node_hostname and service_name):
msg = (
f"{cls.__name__} could not be initialised from config because node_ref and service_ref were not "
"found in reward config."
)
2023-11-30 21:11:35 +00:00
_LOGGER.warning(msg)
raise ValueError(msg)
2023-10-11 15:49:41 +01:00
return cls(node_hostname=node_hostname, service_name=service_name)
2023-10-09 18:35:30 +01:00
class WebpageUnavailablePenalty(AbstractReward):
"""Penalises the agent when the web browser fails to fetch a webpage."""
def __init__(self, node_hostname: str) -> None:
"""
Initialise the reward component.
:param node_hostname: Hostname of the node which has the web browser.
:type node_hostname: str
"""
self._node = node_hostname
self.location_in_state = ["network", "nodes", node_hostname, "applications", "WebBrowser"]
def calculate(self, state: Dict) -> float:
"""
Calculate the reward based on current simulation state.
:param state: The current state of the simulation.
:type state: Dict
"""
web_browser_state = access_from_nested_dict(state, self.location_in_state)
if web_browser_state is NOT_PRESENT_IN_STATE or "history" not in web_browser_state:
_LOGGER.info(
"Web browser reward could not be calculated because the web browser history on node",
f"{self._node} was not reported in the simulation state. Returning 0.0",
)
return 0.0 # 0 if the web browser cannot be found
if not web_browser_state["history"]:
return 0.0 # 0 if no requests have been attempted yet
outcome = web_browser_state["history"][-1]["outcome"]
if outcome == "PENDING":
return 0.0 # 0 if a request was attempted but not yet resolved
elif outcome == 200:
return 1.0 # 1 for successful request
else: # includes failure codes and SERVER_UNREACHABLE
return -1.0 # -1 for failure
@classmethod
def from_config(cls, config: dict) -> AbstractReward:
2024-02-08 13:20:32 +00:00
"""
Build the reward component object from config.
:param config: Configuration dictionary.
:type config: Dict
"""
node_hostname = config.get("node_hostname")
return cls(node_hostname=node_hostname)
2023-10-09 18:35:30 +01:00
class RewardFunction:
"""Manages the reward function for the agent."""
2023-11-16 15:28:38 +00:00
__rew_class_identifiers: Dict[str, Type[AbstractReward]] = {
2023-10-11 15:49:41 +01:00
"DUMMY": DummyReward,
"DATABASE_FILE_INTEGRITY": DatabaseFileIntegrity,
"WEB_SERVER_404_PENALTY": WebServer404Penalty,
"WEBPAGE_UNAVAILABLE_PENALTY": WebpageUnavailablePenalty,
2023-10-19 01:56:40 +01:00
}
2023-10-09 18:35:30 +01:00
2023-10-11 15:49:41 +01:00
def __init__(self):
"""Initialise the reward function object."""
2023-10-11 15:49:41 +01:00
self.reward_components: List[Tuple[AbstractReward, float]] = []
"attribute reward_components keeps track of reward components and the weights assigned to each."
2023-12-04 09:21:47 +00:00
self.current_reward: float = 0.0
self.total_reward: float = 0.0
2023-10-11 15:49:41 +01:00
2024-02-02 14:48:49 +00:00
def register_component(self, component: AbstractReward, weight: float = 1.0) -> None:
"""Add a reward component to the reward function.
:param component: Instance of a reward component.
:type component: AbstractReward
:param weight: Relative weight of the reward component, defaults to 1.0
:type weight: float, optional
"""
2023-10-11 15:49:41 +01:00
self.reward_components.append((component, weight))
def update(self, state: Dict) -> float:
"""Calculate the overall reward for the current state.
:param state: The current state of the simulation.
:type state: Dict
"""
2023-10-11 15:49:41 +01:00
total = 0.0
for comp_and_weight in self.reward_components:
comp = comp_and_weight[0]
weight = comp_and_weight[1]
total += weight * comp.calculate(state=state)
self.current_reward = total
return self.current_reward
2023-10-06 20:32:52 +01:00
@classmethod
def from_config(cls, config: Dict) -> "RewardFunction":
"""Create a reward function from a config dictionary.
:param config: dict of options for the reward manager's constructor
:type config: Dict
:return: The reward manager.
:rtype: RewardFunction
"""
2023-10-11 15:49:41 +01:00
new = cls()
for rew_component_cfg in config["reward_components"]:
2023-10-09 18:35:30 +01:00
rew_type = rew_component_cfg["type"]
2023-10-19 01:56:40 +01:00
weight = rew_component_cfg.get("weight", 1.0)
2023-10-11 15:49:41 +01:00
rew_class = cls.__rew_class_identifiers[rew_type]
rew_instance = rew_class.from_config(config=rew_component_cfg.get("options", {}))
2024-02-02 14:48:49 +00:00
new.register_component(component=rew_instance, weight=weight)
2023-10-06 20:32:52 +01:00
return new